iobroker.metermaster 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/main.js 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.2';
7
+ const CURRENT_VERSION = '0.9.4';
10
8
  const GITHUB_REPO = 'MPunktBPunkt/ioBroker.metermaster';
11
9
  const GITHUB_URL = 'https://github.com/MPunktBPunkt/ioBroker.metermaster';
12
10
 
@@ -64,11 +62,30 @@ function log(level, category, message, detail) {
64
62
  while (logBuffer.length > logBufferMaxSize) logBuffer.shift();
65
63
  }
66
64
 
65
+ // ─── Config validation ─────────────────────────────────────────────────────────
66
+ function clampInt(val, min, max, fallback) {
67
+ const n = parseInt(val, 10);
68
+ if (isNaN(n)) return fallback;
69
+ return Math.min(max, Math.max(min, n));
70
+ }
71
+
72
+ function getValidatedConfig() {
73
+ return {
74
+ port: clampInt(adapter.config?.port, 1024, 65535, 8089),
75
+ logBufferSize: clampInt(adapter.config?.logBufferSize, 50, 5000, 500),
76
+ keepHistory: clampInt(adapter.config?.keepHistory, 0, 100000, 0),
77
+ verboseLogging: !!adapter.config?.verboseLogging,
78
+ user: (adapter.config?.user || '').trim(),
79
+ password: (adapter.config?.password || '').trim(),
80
+ };
81
+ }
82
+
67
83
  // ─── ready ────────────────────────────────────────────────────────────────────
68
84
  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}`);
85
+ const cfg = getValidatedConfig();
86
+ logBufferMaxSize = cfg.logBufferSize;
87
+ log(LVL.INFO, CAT.SYSTEM, `MeterMaster Adapter v${CURRENT_VERSION} started`,
88
+ `Port: ${cfg.port} | Logging: ${cfg.verboseLogging ? 'verbose' : 'standard'} | Buffer: ${logBufferMaxSize}`);
72
89
 
73
90
  await adapter.setStateAsync('info.connection', { val: false, ack: true });
74
91
 
@@ -81,6 +98,7 @@ adapter.on('ready', async () => {
81
98
  // In-Memory-Cache aus gespeicherten ioBroker-States wiederherstellen
82
99
  await restoreCacheFromStates();
83
100
  await restoreNodesFromStates();
101
+ await migrateStateRoles();
84
102
 
85
103
  // ESP32-Node-States beobachten (Heartbeat-Erkennung via simple-api)
86
104
  adapter.subscribeStates('nodes.*');
@@ -89,7 +107,7 @@ adapter.on('ready', async () => {
89
107
  });
90
108
 
91
109
  adapter.on('unload', (callback) => {
92
- log(LVL.INFO, CAT.SYSTEM, 'Adapter wird gestoppt');
110
+ log(LVL.INFO, CAT.SYSTEM, 'Adapter stopping');
93
111
  try { if (server) { server.close(() => callback()); } else { callback(); } }
94
112
  catch (e) { callback(); }
95
113
  });
@@ -98,7 +116,7 @@ adapter.on('unload', (callback) => {
98
116
  adapter.on('stateChange', async (id, state) => {
99
117
  // Eigene Writes (ack: true) ignorieren – nur externe Änderungen verarbeiten
100
118
  if (!state || state.ack) return;
101
- if (!state || state.val === null) return;
119
+ if (state.val === null) return;
102
120
  const ns = `${adapter.namespace}.`;
103
121
  const relative = id.startsWith(ns) ? id.slice(ns.length) : id;
104
122
  const parts = relative.split('.');
@@ -119,7 +137,7 @@ adapter.on('stateChange', async (id, state) => {
119
137
 
120
138
  if (field === 'lastSeen') {
121
139
  const n = nodesCache[mac];
122
- log(LVL.INFO, CAT.NODE, `Heartbeat`, `${mac} | IP: ${n.ip || '?'} | v${n.version || '?'} | ${n.name || 'unbenannt'}`);
140
+ log(LVL.INFO, CAT.NODE, `Heartbeat`, `${mac} | IP: ${n.ip || '?'} | v${n.version || '?'} | ${n.name || 'unnamed'}`);
123
141
  await ensureNodeStates(mac);
124
142
  }
125
143
  });
@@ -139,7 +157,7 @@ async function restoreCacheFromStates() {
139
157
  );
140
158
 
141
159
  if (latestKeys.length === 0) {
142
- log(LVL.DEBUG, CAT.SYSTEM, 'Cache-Wiederherstellung', 'Keine gespeicherten Ablesungen gefunden');
160
+ log(LVL.DEBUG, CAT.SYSTEM, 'Cache restore', 'No stored readings found');
143
161
  return;
144
162
  }
145
163
 
@@ -171,9 +189,9 @@ async function restoreCacheFromStates() {
171
189
  restored++;
172
190
  }
173
191
 
174
- log(LVL.INFO, CAT.SYSTEM, `Cache wiederhergestellt`, `${restored} Zähler aus ioBroker-States geladen`);
192
+ log(LVL.INFO, CAT.SYSTEM, `Cache restored`, `${restored} meters loaded from ioBroker states`);
175
193
  } catch (e) {
176
- log(LVL.WARN, CAT.SYSTEM, 'Cache-Wiederherstellung fehlgeschlagen', e.message);
194
+ log(LVL.WARN, CAT.SYSTEM, 'Cache restore failed', e.message);
177
195
  }
178
196
  }
179
197
 
@@ -201,10 +219,10 @@ async function restoreNodesFromStates() {
201
219
  if (field === 'config') nodesCache[mac].config = String(state.val);
202
220
  if (field === 'cmd' && state.val) nodesCache[mac].cmd = String(state.val);
203
221
  }
204
- if (count > 0) log(LVL.INFO, CAT.NODE, `Nodes wiederhergestellt`, `${count} ESP32-Node(s) aus States geladen`);
205
- else log(LVL.DEBUG, CAT.NODE, 'Keine registrierten Nodes gefunden');
222
+ if (count > 0) log(LVL.INFO, CAT.NODE, `Nodes restored`, `${count} ESP32 node(s) loaded from states`);
223
+ else log(LVL.DEBUG, CAT.NODE, 'No registered nodes found');
206
224
  } catch (e) {
207
- log(LVL.WARN, CAT.NODE, 'Node-Wiederherstellung fehlgeschlagen', e.message);
225
+ log(LVL.WARN, CAT.NODE, 'Node restore failed', e.message);
208
226
  }
209
227
  }
210
228
 
@@ -213,20 +231,21 @@ async function ensureNodeStates(mac) {
213
231
  const base = `nodes.${mac}`;
214
232
  await ensureChannel('nodes', 'ESP32 Nodes');
215
233
  await ensureChannel(base, `ESP32 Node ${mac}`);
216
- await ensureState(`${base}.ip`, { name: 'IP-Adresse', type: 'string', role: 'info.ip', read: true, write: false });
217
- await ensureState(`${base}.name`, { name: 'Gerätename', type: 'string', role: 'info.name', read: true, write: false });
218
- await ensureState(`${base}.version`, { name: 'Firmware-Version', type: 'string', role: 'info.version', read: true, write: false });
219
- await ensureState(`${base}.lastSeen`, { name: 'Zuletzt gesehen (ms)', type: 'number', role: 'value.time', read: true, write: false });
220
- await ensureState(`${base}.config`, { name: 'Konfiguration (JSON)', type: 'string', role: 'value', read: true, write: true });
221
- await ensureState(`${base}.configAck`, { name: 'Config-Quittierung', type: 'string', role: 'value', read: true, write: false });
222
- await ensureState(`${base}.cmd`, { name: 'Befehl (JSON)', type: 'string', role: 'value', read: true, write: true });
234
+ await ensureState(`${base}.ip`, { name: 'IP address', type: 'string', role: 'info.ip', read: true, write: false });
235
+ await ensureState(`${base}.name`, { name: 'Device name', type: 'string', role: 'info.name', read: true, write: false });
236
+ await ensureState(`${base}.version`, { name: 'Firmware version', type: 'string', role: 'info.firmware', read: true, write: false });
237
+ await ensureState(`${base}.lastSeen`, { name: 'Last seen (ms)', type: 'number', role: 'value.time', read: true, write: false });
238
+ await ensureState(`${base}.config`, { name: 'Configuration (JSON)', type: 'string', role: 'json', read: true, write: true });
239
+ await ensureState(`${base}.configAck`, { name: 'Config acknowledgement', type: 'string', role: 'json', read: true, write: false });
240
+ await ensureState(`${base}.cmd`, { name: 'Command (JSON)', type: 'string', role: 'json', read: true, write: true });
223
241
  }
224
242
 
225
243
  // ─── HTTP-Server ──────────────────────────────────────────────────────────────
226
244
  function startHttpServer() {
227
- const port = parseInt(adapter.config.port) || 8089;
228
- const user = (adapter.config.user || '').trim();
229
- const password = (adapter.config.password || '').trim();
245
+ const cfg = getValidatedConfig();
246
+ const port = cfg.port;
247
+ const user = cfg.user;
248
+ const password = cfg.password;
230
249
 
231
250
  server = http.createServer((req, res) => {
232
251
  res.setHeader('Access-Control-Allow-Origin', '*');
@@ -247,9 +266,8 @@ function startHttpServer() {
247
266
  if (req.method === 'GET' && url === '/api/version') { serveVersion(res); return; }
248
267
  if (req.method === 'GET' && url === '/api/nodes') { serveNodesJson(res); return; }
249
268
  if (req.method === 'GET' && url === '/api/discover'){ serveDiscoverJson(res); return; }
250
- if (req.method === 'POST' && url === '/api/update') { handleUpdate(req, res); return; }
251
269
 
252
- // ESP32 Node-Registrierung (kein Auth – ESP32 kennt keine Zugangsdaten)
270
+ // ESP32 node registration (no auth – ESP32 has no credentials)
253
271
  if (req.method === 'POST' && url === '/api/register') {
254
272
  readBody(req, b => handleNodeRegister(b, res, clientIp)); return;
255
273
  }
@@ -271,9 +289,9 @@ function startHttpServer() {
271
289
  if (user && password) {
272
290
  const authHeader = req.headers['authorization'] || '';
273
291
  if (!authHeader.startsWith('Basic ')) {
274
- log(LVL.WARN, CAT.AUTH, `Kein Auth-Header`, `IP: ${clientIp} | ${url}`);
292
+ log(LVL.WARN, CAT.AUTH, `No auth header`, `IP: ${clientIp} | ${url}`);
275
293
  res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="MeterMaster"' });
276
- res.end(JSON.stringify({ error: 'Authentifizierung erforderlich' })); return;
294
+ res.end(JSON.stringify({ error: 'Authentication required' })); return;
277
295
  }
278
296
  const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf-8');
279
297
  const colonIdx = decoded.indexOf(':');
@@ -290,8 +308,8 @@ function startHttpServer() {
290
308
  authOk = crypto.timingSafeEqual(uBuf, uRef) && crypto.timingSafeEqual(pBuf, pRef);
291
309
  } catch { authOk = false; }
292
310
  if (!authOk) {
293
- log(LVL.WARN, CAT.AUTH, `Ungültige Zugangsdaten`, `IP: ${clientIp} | User: "${reqUser}"`);
294
- res.writeHead(403); res.end(JSON.stringify({ error: 'Ungültige Zugangsdaten' })); return;
311
+ log(LVL.WARN, CAT.AUTH, `Invalid credentials`, `IP: ${clientIp} | User: "${reqUser}"`);
312
+ res.writeHead(403); res.end(JSON.stringify({ error: 'Invalid credentials' })); return;
295
313
  }
296
314
  log(LVL.DEBUG, CAT.AUTH, `Auth OK`, `IP: ${clientIp} | User: "${reqUser}"`);
297
315
  }
@@ -311,19 +329,19 @@ function startHttpServer() {
311
329
  if (req.method === 'POST' && cmdMatch) {
312
330
  readBody(req, b => handleNodeCmd(cmdMatch[1].toUpperCase(), b, res));
313
331
  } else {
314
- log(LVL.WARN, CAT.CONNECT, `Unbekannte URL`, `${req.method} ${url} von ${clientIp}`);
315
- res.writeHead(404); res.end(JSON.stringify({ error: 'Nicht gefunden' }));
332
+ log(LVL.WARN, CAT.CONNECT, `Unknown URL`, `${req.method} ${url} from ${clientIp}`);
333
+ res.writeHead(404); res.end(JSON.stringify({ error: 'Not found' }));
316
334
  }
317
335
  }
318
336
  }
319
337
  });
320
338
 
321
339
  server.on('error', err => {
322
- log(LVL.ERROR, CAT.SYSTEM, `HTTP-Fehler: ${err.message}`,
323
- err.code === 'EADDRINUSE' ? `Port ${port} belegt!` : undefined);
340
+ log(LVL.ERROR, CAT.SYSTEM, `HTTP error: ${err.message}`,
341
+ err.code === 'EADDRINUSE' ? `Port ${port} in use!` : undefined);
324
342
  });
325
343
  server.listen(port, '0.0.0.0', () => {
326
- log(LVL.INFO, CAT.SYSTEM, `Lauscht auf Port ${port}`, `Web-UI: http://IP:${port}/`);
344
+ log(LVL.INFO, CAT.SYSTEM, `Listening on port ${port}`, `Web UI: http://IP:${port}/`);
327
345
  adapter.setState('info.connection', { val: true, ack: true });
328
346
  });
329
347
  }
@@ -336,14 +354,14 @@ function handlePing(res, clientIp) {
336
354
 
337
355
  // ─── Validierung ─────────────────────────────────────────────────────────────
338
356
  function validateReading(data) {
339
- if (!data || typeof data !== 'object') return 'Kein Objekt';
340
- if (!data.house || typeof data.house !== 'string') return 'Pflichtfeld fehlt: house';
341
- if (!data.apartment || typeof data.apartment !== 'string') return 'Pflichtfeld fehlt: apartment';
342
- if (!data.meter || typeof data.meter !== 'string') return 'Pflichtfeld fehlt: meter';
343
- if (data.value === undefined || data.value === null) return 'Pflichtfeld fehlt: value';
344
- if (isNaN(parseFloat(data.value))) return 'value muss eine Zahl sein';
345
- if (!data.readingDate) return 'Pflichtfeld fehlt: readingDate';
346
- if (isNaN(new Date(data.readingDate).getTime())) return 'readingDate: kein gültiges Datum';
357
+ if (!data || typeof data !== 'object') return 'Not an object';
358
+ if (!data.house || typeof data.house !== 'string') return 'Required field missing: house';
359
+ if (!data.apartment || typeof data.apartment !== 'string') return 'Required field missing: apartment';
360
+ if (!data.meter || typeof data.meter !== 'string') return 'Required field missing: meter';
361
+ if (data.value === undefined || data.value === null) return 'Required field missing: value';
362
+ if (isNaN(parseFloat(data.value))) return 'value must be a number';
363
+ if (!data.readingDate) return 'Required field missing: readingDate';
364
+ if (isNaN(new Date(data.readingDate).getTime())) return 'readingDate: invalid date';
347
365
  return null;
348
366
  }
349
367
 
@@ -351,18 +369,18 @@ function validateReading(data) {
351
369
  async function handleReading(body, res, clientIp) {
352
370
  let data;
353
371
  try { data = JSON.parse(body); } catch {
354
- log(LVL.WARN, CAT.SYNC, `Ungültiges JSON`, `IP: ${clientIp}`);
355
- sendJson(res, 400, { error: 'Ungültiges JSON' }); return;
372
+ log(LVL.WARN, CAT.SYNC, `Invalid JSON`, `IP: ${clientIp}`);
373
+ sendJson(res, 400, { error: 'Invalid JSON' }); return;
356
374
  }
357
375
  const err = validateReading(data);
358
- if (err) { log(LVL.WARN, CAT.SYNC, `Validierungsfehler`, err); sendJson(res, 422, { error: err }); return; }
359
- log(LVL.INFO, CAT.SYNC, `Ablesung empfangen`,
376
+ if (err) { log(LVL.WARN, CAT.SYNC, `Validation error`, err); sendJson(res, 422, { error: err }); return; }
377
+ log(LVL.INFO, CAT.SYNC, `Reading received`,
360
378
  `${data.house}/${data.apartment}/${data.meter} = ${data.value} ${data.unit||''} (${data.readingDate})`);
361
379
  try {
362
380
  const path = await storeReading(data);
363
381
  sendJson(res, 200, { ok: true, path });
364
382
  } catch (e) {
365
- log(LVL.ERROR, CAT.SYNC, `Speicherfehler`, e.message);
383
+ log(LVL.ERROR, CAT.SYNC, `Storage error`, e.message);
366
384
  sendJson(res, 500, { error: e.message });
367
385
  }
368
386
  }
@@ -371,21 +389,21 @@ async function handleReading(body, res, clientIp) {
371
389
  async function handleReadings(body, res, clientIp) {
372
390
  let items;
373
391
  try { items = JSON.parse(body); if (!Array.isArray(items)) items = [items]; } catch {
374
- log(LVL.WARN, CAT.SYNC, `Ungültiges JSON im Batch`, `IP: ${clientIp}`);
375
- sendJson(res, 400, { error: 'Array erwartet' }); return;
392
+ log(LVL.WARN, CAT.SYNC, `Invalid JSON in batch`, `IP: ${clientIp}`);
393
+ sendJson(res, 400, { error: 'Array expected' }); return;
376
394
  }
377
- log(LVL.INFO, CAT.SYNC, `Batch empfangen`, `IP: ${clientIp} | ${items.length} Ablesungen`);
395
+ log(LVL.INFO, CAT.SYNC, `Batch received`, `IP: ${clientIp} | ${items.length} readings`);
378
396
  let stored = 0, failed = 0;
379
397
  const errors = [];
380
398
  for (const data of items) {
381
399
  const err = validateReading(data);
382
400
  if (err) { failed++; errors.push(`${data.meter||'?'}: ${err}`); continue; }
383
401
  try { await storeReading(data); stored++; }
384
- catch (e) { failed++; errors.push(`${data.meter||'?'}: ${e.message}`); log(LVL.ERROR, CAT.SYNC, `Batch-Fehler`, `${data.meter}: ${e.message}`); }
402
+ catch (e) { failed++; errors.push(`${data.meter||'?'}: ${e.message}`); log(LVL.ERROR, CAT.SYNC, `Batch error`, `${data.meter}: ${e.message}`); }
385
403
  }
386
- const summary = `${stored} gespeichert, ${failed} fehlgeschlagen`;
387
- if (failed === 0) log(LVL.INFO, CAT.SYNC, `Batch ✓`, summary);
388
- else log(LVL.WARN, CAT.SYNC, `Batch mit Fehlern`, summary);
404
+ const summary = `${stored} stored, ${failed} failed`;
405
+ if (failed === 0) log(LVL.INFO, CAT.SYNC, `Batch OK`, summary);
406
+ else log(LVL.WARN, CAT.SYNC, `Batch with errors`, summary);
389
407
  sendJson(res, 200, { ok: failed === 0, stored, failed, errors });
390
408
  }
391
409
 
@@ -393,19 +411,19 @@ async function handleReadings(body, res, clientIp) {
393
411
  async function handleImport(body, res, clientIp) {
394
412
  let data;
395
413
  try { data = JSON.parse(body); } catch {
396
- log(LVL.WARN, CAT.IMPORT, `Ungültiges JSON`, `IP: ${clientIp}`);
397
- sendJson(res, 400, { error: 'Ungültiges JSON' }); return;
414
+ log(LVL.WARN, CAT.IMPORT, `Invalid JSON`, `IP: ${clientIp}`);
415
+ sendJson(res, 400, { error: 'Invalid JSON' }); return;
398
416
  }
399
417
 
400
- // Schema-Prüfung
418
+ // Schema validation
401
419
  if (!data.Apartments || !data.Meters || !data.Readings) {
402
- sendJson(res, 422, { error: 'Ungültiges Format: Apartments, Meters und Readings erforderlich' }); return;
420
+ sendJson(res, 422, { error: 'Invalid format: Apartments, Meters and Readings required' }); return;
403
421
  }
404
422
 
405
423
  const schema = data.SchemaVersion || '1.0';
406
- const house = data.HouseName || adapter.config.houseName || 'MeinHaus';
407
- log(LVL.INFO, CAT.IMPORT, `Import gestartet`,
408
- `Schema: ${schema} | ${data.Apartments.length} Wohnungen | ${data.Meters.length} Zähler | ${data.Readings.length} Ablesungen | IP: ${clientIp}`);
424
+ const house = data.HouseName || 'MyHouse';
425
+ log(LVL.INFO, CAT.IMPORT, `Import started`,
426
+ `Schema: ${schema} | ${data.Apartments.length} apartments | ${data.Meters.length} meters | ${data.Readings.length} readings | IP: ${clientIp}`);
409
427
 
410
428
  // ID-Maps aufbauen: JSON-Id → Objekt
411
429
  const aptMap = {}; // aptId → Apartment
@@ -421,7 +439,7 @@ async function handleImport(body, res, clientIp) {
421
439
  const meter = meterMap[reading.MeterId];
422
440
  if (!meter) {
423
441
  skipped++;
424
- log(LVL.WARN, CAT.IMPORT, `Zähler nicht gefunden`, `MeterId: ${reading.MeterId}`);
442
+ log(LVL.WARN, CAT.IMPORT, `Meter not found`, `MeterId: ${reading.MeterId}`);
425
443
  continue;
426
444
  }
427
445
 
@@ -440,7 +458,7 @@ async function handleImport(body, res, clientIp) {
440
458
  const err = validateReading(payload);
441
459
  if (err) {
442
460
  skipped++;
443
- log(LVL.WARN, CAT.IMPORT, `Ungültige Ablesung übersprungen`, `${meter.Name}: ${err}`);
461
+ log(LVL.WARN, CAT.IMPORT, `Invalid reading skipped`, `${meter.Name}: ${err}`);
444
462
  continue;
445
463
  }
446
464
 
@@ -450,13 +468,13 @@ async function handleImport(body, res, clientIp) {
450
468
  } catch (e) {
451
469
  failed++;
452
470
  errors.push(`${meter.Name}: ${e.message}`);
453
- log(LVL.ERROR, CAT.IMPORT, `Speicherfehler`, `${meter.Name}: ${e.message}`);
471
+ log(LVL.ERROR, CAT.IMPORT, `Storage error`, `${meter.Name}: ${e.message}`);
454
472
  }
455
473
  }
456
474
 
457
- const summary = `${stored} importiert, ${skipped} übersprungen, ${failed} fehlgeschlagen`;
458
- if (failed === 0) log(LVL.INFO, CAT.IMPORT, `Import abgeschlossen ✓`, summary);
459
- else log(LVL.WARN, CAT.IMPORT, `Import mit Fehlern`, summary);
475
+ const summary = `${stored} imported, ${skipped} skipped, ${failed} failed`;
476
+ if (failed === 0) log(LVL.INFO, CAT.IMPORT, `Import completed`, summary);
477
+ else log(LVL.WARN, CAT.IMPORT, `Import with errors`, summary);
460
478
 
461
479
  sendJson(res, 200, { ok: failed === 0, stored, skipped, failed, errors, summary });
462
480
  }
@@ -473,18 +491,18 @@ async function storeReading(data) {
473
491
  const isNew = await ensureChannel(`${house}`, data.house);
474
492
  await ensureChannel(`${house}.${apt}`, data.apartment);
475
493
  await ensureChannel(`${base}`, data.meter);
476
- await ensureChannel(`${base}.readings`, 'Ablesungen');
494
+ await ensureChannel(`${base}.readings`, 'Readings');
477
495
 
478
- if (isNew) log(LVL.INFO, CAT.DATAPOINT, `Neuer Zähler`, `metermaster.0.${base}`);
496
+ if (isNew) log(LVL.INFO, CAT.DATAPOINT, `New meter`, `metermaster.0.${base}`);
479
497
 
480
- const dpNew = await ensureState(`${base}.readings.latest`, { name: `${data.meter} – Letzter Wert`, type: 'number', role: 'value', unit: data.unit||'', read: true, write: false });
481
- await ensureState(`${base}.readings.latestDate`, { name: 'Ablesedatum', type: 'string', role: 'value.datetime', read: true, write: false });
482
- await ensureState(`${base}.name`, { name: 'Zählername', type: 'string', role: 'info.name', read: true, write: false });
483
- await ensureState(`${base}.unit`, { name: 'Einheit', type: 'string', role: 'value.unit', read: true, write: false });
484
- await ensureState(`${base}.typeName`, { name: 'Zählertyp', type: 'string', role: 'info.type', read: true, write: false });
485
- await ensureState(`${base}.readings.history`, { name: 'Historische Ablesungen', type: 'array', role: 'list', read: true, write: false });
498
+ const dpNew = await ensureState(`${base}.readings.latest`, { name: `${data.meter} – latest value`, type: 'number', role: 'value', unit: data.unit||'', read: true, write: false });
499
+ await ensureState(`${base}.readings.latestDate`, { name: 'Reading date', type: 'string', role: 'date', read: true, write: false });
500
+ await ensureState(`${base}.name`, { name: 'Meter name', type: 'string', role: 'text', read: true, write: false });
501
+ await ensureState(`${base}.unit`, { name: 'Unit', type: 'string', role: 'text', read: true, write: false });
502
+ await ensureState(`${base}.typeName`, { name: 'Meter type', type: 'string', role: 'text', read: true, write: false });
503
+ await ensureState(`${base}.readings.history`, { name: 'Reading history', type: 'string', role: 'json', read: true, write: false });
486
504
 
487
- if (dpNew) log(LVL.INFO, CAT.DATAPOINT, `Datenpunkte angelegt`, `${base}.readings.{latest,latestDate,history} + name/unit/typeName`);
505
+ if (dpNew) log(LVL.INFO, CAT.DATAPOINT, `Data points created`, `${base}.readings.{latest,latestDate,history} + name/unit/typeName`);
488
506
 
489
507
  await adapter.setStateAsync(`${base}.readings.latest`, { val: value, ts, ack: true });
490
508
  await adapter.setStateAsync(`${base}.readings.latestDate`, { val: data.readingDate, ts, ack: true });
@@ -492,11 +510,11 @@ async function storeReading(data) {
492
510
  await adapter.setStateAsync(`${base}.unit`, { val: data.unit||'', ts, ack: true });
493
511
  await adapter.setStateAsync(`${base}.typeName`, { val: data.typeName||'', ts, ack: true });
494
512
 
495
- log(LVL.DEBUG, CAT.SYNC, `State geschrieben`, `metermaster.0.${base} = ${value} ${data.unit||''} | ${data.readingDate}`);
513
+ log(LVL.DEBUG, CAT.SYNC, `State written`, `metermaster.0.${base} = ${value} ${data.unit||''} | ${data.readingDate}`);
496
514
 
497
515
  const histResult = await updateHistory(base, { value, unit: data.unit||'', readingDate: data.readingDate, ts });
498
- if (histResult === 'added') log(LVL.DEBUG, CAT.HISTORY, `Historie +1`, `${base} @ ${data.readingDate}`);
499
- if (histResult === 'duplicate') log(LVL.DEBUG, CAT.HISTORY, `Duplikat`, `${base} @ ${data.readingDate}`);
516
+ if (histResult === 'added') log(LVL.DEBUG, CAT.HISTORY, `History +1`, `${base} @ ${data.readingDate}`);
517
+ if (histResult === 'duplicate') log(LVL.DEBUG, CAT.HISTORY, `Duplicate`, `${base} @ ${data.readingDate}`);
500
518
 
501
519
  // In-Memory-Cache aktualisieren
502
520
  cacheReading(house, apt, meter, value, data.unit||'', data.typeName||'', data.readingDate, ts);
@@ -510,7 +528,7 @@ async function storeReading(data) {
510
528
  // ─── Historie ─────────────────────────────────────────────────────────────────
511
529
  async function updateHistory(base, entry) {
512
530
  const stateId = `${base}.readings.history`;
513
- const keep = parseInt(adapter.config.keepHistory) || 0;
531
+ const keep = getValidatedConfig().keepHistory;
514
532
  let history = [];
515
533
  try {
516
534
  const ex = await adapter.getStateAsync(stateId);
@@ -532,20 +550,73 @@ async function ensureChannel(id, name) {
532
550
  return true;
533
551
  }
534
552
  async function ensureState(id, common) {
553
+ const fullCommon = {
554
+ ...common,
555
+ read: common.read !== undefined ? common.read : true,
556
+ write: common.write !== undefined ? common.write : false,
557
+ def: common.type === 'number' ? 0 : (common.type === 'array' ? '[]' : '')
558
+ };
535
559
  const ex = await adapter.getObjectAsync(id).catch(() => null);
536
- if (ex) return false;
560
+ if (ex) {
561
+ const patch = {};
562
+ for (const key of ['role', 'type', 'read', 'write', 'unit']) {
563
+ if (fullCommon[key] !== undefined && ex.common[key] !== fullCommon[key]) {
564
+ patch[key] = fullCommon[key];
565
+ }
566
+ }
567
+ if (Object.keys(patch).length) {
568
+ await adapter.extendObjectAsync(id, { common: patch });
569
+ }
570
+ return false;
571
+ }
537
572
  await adapter.setObjectNotExistsAsync(id, {
538
573
  type: 'state',
539
- common: { ...common,
540
- read: common.read !== undefined ? common.read : true,
541
- write: common.write !== undefined ? common.write : false,
542
- def: common.type === 'number' ? 0 : (common.type === 'array' ? '[]' : '')
543
- },
574
+ common: fullCommon,
544
575
  native: {}
545
576
  });
546
577
  return true;
547
578
  }
548
579
 
580
+ // ─── State-Rollen-Migration (deprecated → ioBroker-Rollenkatalog) ───────────────
581
+ const DEPRECATED_ROLE_MAP = {
582
+ 'value.datetime': 'date',
583
+ 'value.unit': 'text',
584
+ 'info.type': 'text',
585
+ 'info.version': 'info.firmware',
586
+ };
587
+
588
+ function resolveMigratedRole(id, common) {
589
+ if (DEPRECATED_ROLE_MAP[common.role]) {
590
+ return DEPRECATED_ROLE_MAP[common.role];
591
+ }
592
+ if (common.role === 'value' && common.type === 'string') {
593
+ const field = id.split('.').pop();
594
+ if (field === 'config' || field === 'configAck' || field === 'cmd') {
595
+ return 'json';
596
+ }
597
+ }
598
+ return null;
599
+ }
600
+
601
+ async function migrateStateRoles() {
602
+ try {
603
+ const allObjects = await adapter.getAdapterObjectsAsync();
604
+ if (!allObjects) return;
605
+ let fixed = 0;
606
+ for (const [id, obj] of Object.entries(allObjects)) {
607
+ if (obj.type !== 'state') continue;
608
+ const newRole = resolveMigratedRole(id, obj.common || {});
609
+ if (newRole && obj.common.role !== newRole) {
610
+ await adapter.extendObjectAsync(id, { common: { role: newRole } });
611
+ fixed++;
612
+ }
613
+ }
614
+ if (fixed) log(LVL.INFO, CAT.SYSTEM, 'State roles migrated', `${fixed} objects`);
615
+ } catch (e) {
616
+ log(LVL.WARN, CAT.SYSTEM, 'State role migration failed', e.message);
617
+ }
618
+ }
619
+
549
620
  // ─── API Endpunkte ────────────────────────────────────────────────────────────
550
621
  function serveDataJson(res) {
551
622
  sendJson(res, 200, { data: receivedData, receivedTotal: readingsReceived });
@@ -623,7 +694,7 @@ function serveDiscoverJson(res) {
623
694
  async function handleNodeConfig(mac, body, res, clientIp) {
624
695
  let data;
625
696
  try { data = JSON.parse(body); } catch {
626
- sendJson(res, 400, { error: 'Ungültiges JSON' }); return;
697
+ sendJson(res, 400, { error: 'Invalid JSON' }); return;
627
698
  }
628
699
  const sid = (data.sid || '').trim();
629
700
  const label = (data.label || '').trim();
@@ -644,10 +715,10 @@ async function handleNodeConfig(mac, body, res, clientIp) {
644
715
  await adapter.setStateAsync(`nodes.${mac}.config`, { val: configStr, ack: true });
645
716
  if (!nodesCache[mac]) nodesCache[mac] = { mac };
646
717
  nodesCache[mac].config = configStr;
647
- log(LVL.INFO, CAT.NODE, `Config gesetzt`, `${mac} \u2192 ${sid || '(leer)'} | IP: ${nodesCache[mac]?.ip || '?'}`);
718
+ log(LVL.INFO, CAT.NODE, `Config set`, `${mac} \u2192 ${sid || '(empty)'} | IP: ${nodesCache[mac]?.ip || '?'}`);
648
719
  sendJson(res, 200, { ok: true, mac, config });
649
720
  } catch (e) {
650
- log(LVL.ERROR, CAT.NODE, `Config-Fehler`, `${mac}: ${e.message}`);
721
+ log(LVL.ERROR, CAT.NODE, `Config error`, `${mac}: ${e.message}`);
651
722
  sendJson(res, 500, { error: e.message });
652
723
  }
653
724
  }
@@ -658,7 +729,7 @@ async function handleNodeConfig(mac, body, res, clientIp) {
658
729
  async function handleNodeCmd(mac, body, res) {
659
730
  let data;
660
731
  try { data = JSON.parse(body); } catch {
661
- sendJson(res, 400, { error: 'Ungültiges JSON' }); return;
732
+ sendJson(res, 400, { error: 'Invalid JSON' }); return;
662
733
  }
663
734
  const cmdStr = JSON.stringify(data);
664
735
  try {
@@ -667,10 +738,10 @@ async function handleNodeCmd(mac, body, res) {
667
738
  if (!nodesCache[mac]) nodesCache[mac] = { mac };
668
739
  nodesCache[mac].cmd = cmdStr;
669
740
  const keys = Object.keys(data).join(', ');
670
- log(LVL.INFO, CAT.NODE, `Befehl gesetzt`, `${mac} → ${keys}`);
741
+ log(LVL.INFO, CAT.NODE, `Command set`, `${mac} → ${keys}`);
671
742
  sendJson(res, 200, { ok: true, mac, cmd: data });
672
743
  } catch (e) {
673
- log(LVL.ERROR, CAT.NODE, `Cmd-Fehler`, `${mac}: ${e.message}`);
744
+ log(LVL.ERROR, CAT.NODE, `Cmd error`, `${mac}: ${e.message}`);
674
745
  sendJson(res, 500, { error: e.message });
675
746
  }
676
747
  }
@@ -681,7 +752,7 @@ async function handleNodeCmd(mac, body, res) {
681
752
  async function handleNodeRegister(body, res, clientIp) {
682
753
  let data;
683
754
  try { data = JSON.parse(body); } catch {
684
- sendJson(res, 400, { error: 'Ungültiges JSON' }); return;
755
+ sendJson(res, 400, { error: 'Invalid JSON' }); return;
685
756
  }
686
757
  const mac = (data.mac || '').replace(/[^A-Fa-f0-9]/g, '').toUpperCase();
687
758
  const ip = (data.ip || clientIp || '').trim();
@@ -689,7 +760,7 @@ async function handleNodeRegister(body, res, clientIp) {
689
760
  const version = (data.version || '').trim();
690
761
  const ack = data.configAck ? String(data.configAck) : null;
691
762
 
692
- if (!mac) { sendJson(res, 400, { error: 'Feld mac fehlt' }); return; }
763
+ if (!mac) { sendJson(res, 400, { error: 'Field mac missing' }); return; }
693
764
 
694
765
  const ts = Date.now();
695
766
  if (!nodesCache[mac]) nodesCache[mac] = { mac };
@@ -713,7 +784,7 @@ async function handleNodeRegister(body, res, clientIp) {
713
784
  const config = nodesCache[mac].config || null;
714
785
  sendJson(res, 200, { ok: true, mac, config });
715
786
  } catch (e) {
716
- log(LVL.ERROR, CAT.NODE, `Register-Fehler`, `${mac}: ${e.message}`);
787
+ log(LVL.ERROR, CAT.NODE, `Register error`, `${mac}: ${e.message}`);
717
788
  sendJson(res, 500, { error: e.message });
718
789
  }
719
790
  }
@@ -731,7 +802,7 @@ async function handleNodeAck(mac, body, res) {
731
802
  try {
732
803
  await ensureNodeStates(mac);
733
804
  await adapter.setStateAsync(`nodes.${mac}.configAck`, { val: ack, ack: true });
734
- log(LVL.INFO, CAT.NODE, `Config quittiert`, `${mac} | ack: ${ack}`);
805
+ log(LVL.INFO, CAT.NODE, `Config acknowledged`, `${mac} | ack: ${ack}`);
735
806
  sendJson(res, 200, { ok: true });
736
807
  } catch (e) {
737
808
  sendJson(res, 500, { error: e.message });
@@ -814,22 +885,6 @@ async function serveVersion(res) {
814
885
  }
815
886
  }
816
887
 
817
- function handleUpdate(req, res) {
818
- log(LVL.INFO, CAT.SYSTEM, 'Update gestartet', `von ${CURRENT_VERSION} → GitHub`);
819
- const cmd = `iobroker upgrade metermaster && iobroker restart metermaster.0`;
820
- exec(cmd, { timeout: 180000 }, (err, stdout, stderr) => {
821
- const out = (stdout + '\n' + stderr).trim();
822
- if (err) {
823
- log(LVL.ERROR, CAT.SYSTEM, 'Update fehlgeschlagen', err.message);
824
- sendJson(res, 500, { ok: false, error: err.message, output: out });
825
- } else {
826
- log(LVL.INFO, CAT.SYSTEM, 'Update erfolgreich', out.slice(0, 200));
827
- sendJson(res, 200, { ok: true, output: out });
828
- }
829
- });
830
- }
831
-
832
-
833
888
 
834
889
  function serveWebApp(res, port) {
835
890
 
@@ -884,7 +939,7 @@ const TYPE_ICONS = {
884
939
  };
885
940
 
886
941
  const html = `<!DOCTYPE html>
887
- <html lang="de">
942
+ <html lang="en">
888
943
  <head>
889
944
  <meta charset="UTF-8">
890
945
  <meta name="viewport" content="width=device-width,initial-scale=1">
@@ -1280,7 +1335,7 @@ input.search {
1280
1335
  <span class="logo-sub">ioBroker Adapter &nbsp;<span style="color:var(--primary);font-size:.95em;letter-spacing:.5px">v${CURRENT_VERSION}</span></span>
1281
1336
  </div>
1282
1337
  <div class="hstats">
1283
- <div class="hstat"><span id="lbl-rx">Ablesungen</span>: <b id="st-rx">–</b></div>
1338
+ <div class="hstat"><span id="lbl-rx">Readings</span>: <b id="st-rx">–</b></div>
1284
1339
  <div class="hstat"><span id="lbl-nodes">Nodes</span>: <b id="st-nodes">–</b></div>
1285
1340
  <div class="hstat"><span id="lbl-up">Uptime</span>: <b id="st-up">–</b></div>
1286
1341
  <div class="live-dot" id="st-live">● Live</div>
@@ -1293,7 +1348,7 @@ input.search {
1293
1348
 
1294
1349
  <!-- ══ NAV ═══════════════════════════════════════════════════════════════════ -->
1295
1350
  <nav>
1296
- <button class="tab active" id="tab-data" data-tab="data" onclick="showTab('data')" >📊 Daten</button>
1351
+ <button class="tab active" id="tab-data" data-tab="data" onclick="showTab('data')" >📊 Data</button>
1297
1352
  <button class="tab" id="tab-nodes" data-tab="nodes" onclick="showTab('nodes')" >📡 Nodes</button>
1298
1353
  <button class="tab" id="tab-import" data-tab="import" onclick="showTab('import')">📥 Import</button>
1299
1354
  <button class="tab" id="tab-logs" data-tab="logs" onclick="showTab('logs')" >📋 Logs</button>
@@ -1305,7 +1360,7 @@ input.search {
1305
1360
  <div id="data-container">
1306
1361
  <div class="empty-state">
1307
1362
  <div class="ico">📡</div>
1308
- <p>Noch keine Ablesungen empfangen.<br>Starte einen Sync in der MeterMaster App oder lade ein Backup hoch.</p>
1363
+ <p id="no-data-msg">No readings received yet.<br>Start a sync in the MeterMaster app or upload a backup.</p>
1309
1364
  </div>
1310
1365
  </div>
1311
1366
  </div>
@@ -1314,15 +1369,15 @@ input.search {
1314
1369
  <div class="page" id="page-nodes">
1315
1370
  <div id="nodes-page-header" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:10px;">
1316
1371
  <div>
1317
- <h2 style="font-size:1em;color:var(--secondary);margin-bottom:2px;">📡 Registrierte ESP32 Nodes</h2>
1318
- <div style="font-size:.82em;color:var(--text-dim);">Gesamt: <b id="nd-total" style="color:var(--text);">0</b> &nbsp;|&nbsp; Online: <b id="nd-online" style="color:var(--accent);">0</b></div>
1372
+ <h2 id="nodes-title" style="font-size:1em;color:var(--secondary);margin-bottom:2px;">📡 Registered ESP32 Nodes</h2>
1373
+ <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>
1319
1374
  </div>
1320
- <button class="ghost" onclick="fetchNodes()">↻ Aktualisieren</button>
1375
+ <button class="ghost" id="nodes-refresh-btn" onclick="fetchNodes()">↻ Refresh</button>
1321
1376
  </div>
1322
1377
  <div id="nodes-container">
1323
1378
  <div class="empty-state">
1324
1379
  <div class="ico">📡</div>
1325
- <p>Noch keine ESP32 Nodes registriert.<br>Wenn ein MeterMaster Node startet und seinen Heartbeat sendet, erscheint er hier automatisch.</p>
1380
+ <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>
1326
1381
  </div>
1327
1382
  </div>
1328
1383
  </div>
@@ -1330,8 +1385,8 @@ input.search {
1330
1385
  <!-- ══ IMPORT ════════════════════════════════════════════════════════════════ -->
1331
1386
  <div class="page" id="page-import">
1332
1387
  <div class="import-card">
1333
- <h3>📥 App-Backup importieren</h3>
1334
- <p>Importiere einen Backup-Export aus der MeterMaster App direkt in den Adapter. Alle Ablesungen werden mit ihren originalen Zeitstempeln gespeichert — ideal für die erstmalige Befüllung oder das Nachführen historischer Daten.</p>
1388
+ <h3 id="import-title">📥 Import app backup</h3>
1389
+ <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>
1335
1390
 
1336
1391
  <div class="schema-box">{ "SchemaVersion": "2.0", "Source": "MeterMaster",
1337
1392
  "Apartments": [ { "Id": 1, "Name": "Westerheim" } ],
@@ -1340,24 +1395,24 @@ input.search {
1340
1395
  }</div>
1341
1396
 
1342
1397
  <div class="house-row">
1343
- <label>Hausname (ioBroker-Pfad):</label>
1344
- <input type="text" id="imp-house" value="MeinHaus" placeholder="z.B. MeinHaus">
1398
+ <label id="import-house-label">House name (ioBroker path):</label>
1399
+ <input type="text" id="imp-house" value="MyHouse" placeholder="e.g. MyHouse">
1345
1400
  </div>
1346
1401
 
1347
1402
  <div class="drop-zone" id="drop-zone" onclick="document.getElementById('file-in').click()">
1348
1403
  <div class="dz-ico">📂</div>
1349
- <p>JSON-Datei hier ablegen oder klicken zum Auswählen</p>
1404
+ <p id="import-drop-text">Drop JSON file here or click to select</p>
1350
1405
  </div>
1351
1406
  <input type="file" id="file-in" accept=".json">
1352
1407
 
1353
1408
  <div class="preview-box" id="preview-box">
1354
- <h4>📋 Vorschau</h4>
1409
+ <h4 id="import-preview-title">📋 Preview</h4>
1355
1410
  <div id="preview-content"></div>
1356
1411
  </div>
1357
1412
 
1358
1413
  <div class="btn-row">
1359
- <button class="primary" id="imp-btn" disabled onclick="doImport()">⬆ Importieren</button>
1360
- <button class="ghost" onclick="clearImport()">✕ Zurücksetzen</button>
1414
+ <button class="primary" id="imp-btn" disabled onclick="doImport()">⬆ Import</button>
1415
+ <button class="ghost" id="import-reset-btn" onclick="clearImport()">✕ Reset</button>
1361
1416
  </div>
1362
1417
  <div class="result-box" id="imp-result"></div>
1363
1418
  </div>
@@ -1367,14 +1422,14 @@ input.search {
1367
1422
  <div class="page" id="page-logs">
1368
1423
  <div class="log-toolbar">
1369
1424
  <select id="fl">
1370
- <option value="">Alle Level</option>
1425
+ <option value="" id="fl-all">All levels</option>
1371
1426
  <option value="debug">DEBUG</option>
1372
1427
  <option value="info">INFO</option>
1373
1428
  <option value="warn">WARN</option>
1374
1429
  <option value="error">ERROR</option>
1375
1430
  </select>
1376
1431
  <select id="fc">
1377
- <option value="">Alle Kategorien</option>
1432
+ <option value="" id="fc-all">All categories</option>
1378
1433
  <option value="SYSTEM">SYSTEM</option>
1379
1434
  <option value="AUTH">AUTH</option>
1380
1435
  <option value="CONNECT">CONNECT</option>
@@ -1384,13 +1439,13 @@ input.search {
1384
1439
  <option value="IMPORT">IMPORT</option>
1385
1440
  <option value="NODE">NODE</option>
1386
1441
  </select>
1387
- <input class="search" type="text" id="ft" placeholder="Suche…">
1388
- <button class="ghost" onclick="clearLogs()">🗑 Leeren</button>
1389
- <button class="ghost" onclick="exportLogs()">⬇ Export</button>
1390
- <label class="lbl"><input type="checkbox" id="as" checked> Auto-Scroll</label>
1391
- <label class="lbl"><input type="checkbox" id="ar" checked> Live</label>
1442
+ <input class="search" type="text" id="ft" placeholder="Search…">
1443
+ <button class="ghost" id="log-clear-btn" onclick="clearLogs()">🗑 Clear</button>
1444
+ <button class="ghost" id="log-export-btn" onclick="exportLogs()">⬇ Export</button>
1445
+ <label class="lbl"><input type="checkbox" id="as" checked> <span id="lbl-auto-scroll">Auto-scroll</span></label>
1446
+ <label class="lbl"><input type="checkbox" id="ar" checked> <span id="lbl-live">Live</span></label>
1392
1447
  </div>
1393
- <div id="lc"><div class="log-empty" id="log-empty">Keine Log-Einträge vorhanden.</div></div>
1448
+ <div id="lc"><div class="log-empty" id="log-empty">No log entries yet.</div></div>
1394
1449
  </div>
1395
1450
 
1396
1451
 
@@ -1398,75 +1453,74 @@ input.search {
1398
1453
  <div class="page" id="page-system">
1399
1454
 
1400
1455
  <div class="sys-card">
1401
- <h3>📊 Statistiken</h3>
1456
+ <h3 id="sys-stats-title">📊 Statistics</h3>
1402
1457
  <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
1403
1458
  <div style="background:var(--bg-deep);border:1px solid var(--border);border-radius:9px;padding:12px 14px;">
1404
1459
  <div style="font-size:1.6em;font-weight:700;color:var(--secondary);" id="sys-rx">–</div>
1405
- <div style="font-size:.78em;color:var(--text-dim);margin-top:3px;">Ablesungen gesamt</div>
1460
+ <div id="sys-rx-label" style="font-size:.78em;color:var(--text-dim);margin-top:3px;">Total readings</div>
1406
1461
  </div>
1407
1462
  <div style="background:var(--bg-deep);border:1px solid var(--border);border-radius:9px;padding:12px 14px;">
1408
1463
  <div style="font-size:1.6em;font-weight:700;color:var(--secondary);" id="sys-up">–</div>
1409
- <div style="font-size:.78em;color:var(--text-dim);margin-top:3px;">Uptime</div>
1464
+ <div id="sys-up-label" style="font-size:.78em;color:var(--text-dim);margin-top:3px;">Uptime</div>
1410
1465
  </div>
1411
1466
  <div style="background:var(--bg-deep);border:1px solid var(--border);border-radius:9px;padding:12px 14px;">
1412
1467
  <div style="font-size:1.6em;font-weight:700;color:var(--accent);" id="sys-online">–</div>
1413
- <div style="font-size:.78em;color:var(--text-dim);margin-top:3px;">Nodes online</div>
1468
+ <div id="sys-online-label" style="font-size:.78em;color:var(--text-dim);margin-top:3px;">Nodes online</div>
1414
1469
  </div>
1415
1470
  <div style="background:var(--bg-deep);border:1px solid var(--border);border-radius:9px;padding:12px 14px;">
1416
1471
  <div style="font-size:1.6em;font-weight:700;color:var(--secondary);" id="sys-total">–</div>
1417
- <div style="font-size:.78em;color:var(--text-dim);margin-top:3px;">Nodes gesamt</div>
1472
+ <div id="sys-total-label" style="font-size:.78em;color:var(--text-dim);margin-top:3px;">Total nodes</div>
1418
1473
  </div>
1419
1474
  </div>
1420
1475
  </div>
1421
1476
 
1422
1477
  <div class="sys-card">
1423
- <h3>🔄 Adapter-Version</h3>
1424
- <div class="ver-row"><span class="ver-label">Installiert</span> <span class="ver-val" id="sv-cur">–</span></div>
1425
- <div class="ver-row"><span class="ver-label">Aktuell (GitHub)</span><span class="ver-val" id="sv-lat">–</span></div>
1426
- <div class="ver-row"><span class="ver-label">Status</span> <span id="sv-status"><span class="badge-warn">Noch nicht geprüft</span></span></div>
1478
+ <h3 id="sys-version-title">🔄 Adapter version</h3>
1479
+ <div class="ver-row"><span class="ver-label" id="sys-lbl-installed">Installed</span> <span class="ver-val" id="sv-cur">–</span></div>
1480
+ <div class="ver-row"><span class="ver-label" id="sys-lbl-latest">Latest (GitHub)</span><span class="ver-val" id="sv-lat">–</span></div>
1481
+ <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>
1427
1482
  <div class="sys-btn-row">
1428
- <button class="ghost" id="sv-check-btn">🔍 Auf Updates prüfen</button>
1429
- <button class="primary" id="sv-upd-btn" style="display:none">⬆ Update installieren</button>
1430
- <span id="sv-spin" style="display:none;font-size:.84em;color:var(--text-dim)">⏳ Bitte warten…</span>
1483
+ <button class="ghost" id="sv-check-btn">🔍 Check for updates</button>
1484
+ <span id="sv-spin" style="display:none;font-size:.84em;color:var(--text-dim)">⏳ Please wait…</span>
1431
1485
  </div>
1432
1486
  <div class="sys-out" id="sv-out"></div>
1433
1487
  </div>
1434
1488
 
1435
1489
  <div class="sys-card">
1436
- <h3>ℹ️ Adapter-Info</h3>
1437
- <div class="ver-row"><span class="ver-label">Adapter</span> <span class="ver-val">iobroker.metermaster</span></div>
1438
- <div class="ver-row"><span class="ver-label">Port</span> <span class="ver-val">${port}</span></div>
1439
- <div class="ver-row"><span class="ver-label">Repository</span>
1490
+ <h3 id="sys-info-title">ℹ️ Adapter info</h3>
1491
+ <div class="ver-row"><span class="ver-label" id="sys-lbl-adapter">Adapter</span> <span class="ver-val">iobroker.metermaster</span></div>
1492
+ <div class="ver-row"><span class="ver-label" id="sys-lbl-port">Port</span> <span class="ver-val">${port}</span></div>
1493
+ <div class="ver-row"><span class="ver-label" id="sys-lbl-repo">Repository</span>
1440
1494
  <a href="https://github.com/MPunktBPunkt/ioBroker.metermaster" target="_blank"
1441
1495
  style="color:var(--primary);font-size:.84em">GitHub ↗</a>
1442
1496
  </div>
1443
1497
  </div>
1444
1498
 
1445
1499
  <div class="sys-card">
1446
- <h3>🔄 Update-Befehle</h3>
1447
- <p style="font-size:.82em;color:var(--text-dim);margin:0 0 12px">
1448
- Adapter aktualisieren — Befehle in der ioBroker-Konsole ausführen:
1500
+ <h3 id="sys-cmds-title">🔄 Update commands</h3>
1501
+ <p id="sys-cmds-desc" style="font-size:.82em;color:var(--text-dim);margin:0 0 12px">
1502
+ Update the adapter — run these commands in the ioBroker console:
1449
1503
  </p>
1450
1504
  <div class="cmd-row">
1451
1505
  <code class="cmd-code">iobroker upgrade metermaster</code>
1452
- <button class="cmd-copy" onclick="copyCmd(this)" data-cmd="iobroker upgrade metermaster" title="Kopieren">📋</button>
1506
+ <button class="cmd-copy" onclick="copyCmd(this)" data-cmd="iobroker upgrade metermaster" title="Copy">📋</button>
1453
1507
  </div>
1454
1508
  <div class="cmd-row" style="margin-top:8px">
1455
1509
  <code class="cmd-code">iobroker restart metermaster.0</code>
1456
- <button class="cmd-copy" onclick="copyCmd(this)" data-cmd="iobroker restart metermaster.0" title="Kopieren">📋</button>
1510
+ <button class="cmd-copy" onclick="copyCmd(this)" data-cmd="iobroker restart metermaster.0" title="Copy">📋</button>
1457
1511
  </div>
1458
1512
  <div class="cmd-row" style="margin-top:8px">
1459
1513
  <code class="cmd-code">sleep 5 &amp;&amp; iobroker status metermaster.0</code>
1460
- <button class="cmd-copy" onclick="copyCmd(this)" data-cmd="sleep 5 && iobroker status metermaster.0" title="Kopieren">📋</button>
1514
+ <button class="cmd-copy" onclick="copyCmd(this)" data-cmd="sleep 5 && iobroker status metermaster.0" title="Copy">📋</button>
1461
1515
  </div>
1462
- <p style="font-size:.78em;color:var(--text-dim);margin:10px 0 0">
1463
- 💡 Tipp: Alle drei Befehle nacheinander ausführen — warten bis jeder abgeschlossen ist.
1516
+ <p id="sys-cmds-tip" style="font-size:.78em;color:var(--text-dim);margin:10px 0 0">
1517
+ 💡 Tip: Run all three commands in sequence — wait for each to finish.
1464
1518
  </p>
1465
1519
  </div>
1466
1520
 
1467
1521
  </div>
1468
1522
 
1469
- <div id="ni" onclick="scrollLogBottom()">↓ Neue Einträge</div>
1523
+ <div id="ni" onclick="scrollLogBottom()">↓ New entries</div>
1470
1524
 
1471
1525
  <!-- ══ CHART-MODAL (vor Script — init() bindet Listener) ═════════════════════ -->
1472
1526
  <div id="chart-overlay" class="modal-overlay">
@@ -1497,14 +1551,14 @@ input.search {
1497
1551
  <!-- ══ LOGIN-MODAL ═══════════════════════════════════════════════════════════ -->
1498
1552
  <div id="login-overlay" style="display:none;position:fixed;inset:0;background:rgba(15,11,26,.85);z-index:9999;align-items:center;justify-content:center;">
1499
1553
  <div style="background:var(--bg-surface);border:1px solid var(--border-light);border-radius:16px;padding:32px 36px;min-width:320px;max-width:420px;width:90%;">
1500
- <div style="font-size:1.1em;font-weight:700;color:var(--secondary);margin-bottom:6px;">🔑 Anmelden</div>
1501
- <div style="font-size:.82em;color:var(--text-dim);margin-bottom:20px;">Zugangsdaten für schreibende Aktionen (Zähler zuweisen, Import).</div>
1554
+ <div id="login-title" style="font-size:1.1em;font-weight:700;color:var(--secondary);margin-bottom:6px;">🔑 Sign in</div>
1555
+ <div id="login-desc" style="font-size:.82em;color:var(--text-dim);margin-bottom:20px;">Credentials for write actions (assign meter, import).</div>
1502
1556
  <div style="margin-bottom:12px;">
1503
- <label style="font-size:.82em;color:var(--text-dim);display:block;margin-bottom:4px;">Benutzername</label>
1557
+ <label id="login-user-label" style="font-size:.82em;color:var(--text-dim);display:block;margin-bottom:4px;">Username</label>
1504
1558
  <input id="login-user" type="text" value="metermaster" style="width:100%;background:var(--bg-surface2);border:1px solid var(--border-light);color:var(--text);padding:8px 12px;border-radius:8px;font-size:.9em;outline:none;" onkeydown="if(event.key===\'Enter\')document.getElementById(\'login-pass\').focus()">
1505
1559
  </div>
1506
1560
  <div style="margin-bottom:18px;">
1507
- <label style="font-size:.82em;color:var(--text-dim);display:block;margin-bottom:4px;">Passwort</label>
1561
+ <label id="login-pass-label" style="font-size:.82em;color:var(--text-dim);display:block;margin-bottom:4px;">Password</label>
1508
1562
  <div style="position:relative;">
1509
1563
  <input id="login-pass" type="password" style="width:100%;background:var(--bg-surface2);border:1px solid var(--border-light);color:var(--text);padding:8px 36px 8px 12px;border-radius:8px;font-size:.9em;outline:none;" onkeydown="if(event.key===\'Enter\')doLogin()">
1510
1564
  <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>
@@ -1512,8 +1566,8 @@ input.search {
1512
1566
  </div>
1513
1567
  <div id="login-msg" style="font-size:.82em;color:var(--danger);min-height:18px;margin-bottom:12px;"></div>
1514
1568
  <div style="display:flex;gap:10px;">
1515
- <button onclick="doLogin()" style="flex:1;background:var(--primary);color:#fff;border:none;padding:9px;border-radius:9px;cursor:pointer;font-size:.9em;font-weight:600;">✓ Anmelden</button>
1516
- <button onclick="hideLoginModal()" style="background:transparent;border:1px solid var(--border-light);color:var(--text-dim);padding:9px 16px;border-radius:9px;cursor:pointer;font-size:.9em;">✕</button>
1569
+ <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>
1570
+ <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>
1517
1571
  </div>
1518
1572
  </div>
1519
1573
  </div>
@@ -1537,7 +1591,7 @@ function hideLoginModal() {
1537
1591
  async function doLogin() {
1538
1592
  const user = document.getElementById('login-user').value.trim();
1539
1593
  const pass = document.getElementById('login-pass').value;
1540
- if (!user) { document.getElementById('login-msg').textContent = 'Benutzername fehlt'; return; }
1594
+ if (!user) { document.getElementById('login-msg').textContent = t('login_missing_user'); return; }
1541
1595
  setAuth(user, pass);
1542
1596
  try {
1543
1597
  const r = await fetch('/api/ping', { headers: { 'Authorization': _authHeader } });
@@ -1545,17 +1599,17 @@ async function doLogin() {
1545
1599
  hideLoginModal();
1546
1600
  } else {
1547
1601
  _authHeader = null;
1548
- document.getElementById('login-msg').textContent = '❌ Falsche Zugangsdaten';
1602
+ document.getElementById('login-msg').textContent = '\u274C ' + t('login_wrong');
1549
1603
  }
1550
1604
  } catch(e) {
1551
- document.getElementById('login-msg').textContent = '❌ Verbindungsfehler';
1605
+ document.getElementById('login-msg').textContent = '\u274C ' + t('login_conn_err');
1552
1606
  }
1553
1607
  }
1554
1608
  async function authFetch(url, opts) {
1555
1609
  if (!_authHeader) { showLoginModal(); return null; }
1556
1610
  const headers = Object.assign({ 'Content-Type': 'application/json', 'Authorization': _authHeader }, opts.headers || {});
1557
1611
  const r = await fetch(url, Object.assign({}, opts, { headers }));
1558
- if (r.status === 401) { _authHeader = null; showLoginModal('❌ Sitzung abgelaufen – bitte neu anmelden'); return null; }
1612
+ if (r.status === 401) { _authHeader = null; showLoginModal('\u274C ' + t('login_expired')); return null; }
1559
1613
  return r;
1560
1614
  }
1561
1615
 
@@ -1570,36 +1624,106 @@ const I18N = {
1570
1624
  readings:'Ablesungen', nodes:'Nodes', uptime:'Uptime', live:'Live', disconnected:'Getrennt',
1571
1625
  tab_data:'Daten', tab_nodes:'Nodes', tab_import:'Import', tab_logs:'Logs', tab_system:'System',
1572
1626
  no_data:'Noch keine Ablesungen empfangen.<br>Starte einen Sync in der MeterMaster App oder lade ein Backup hoch.',
1627
+ no_nodes:'Noch keine ESP32 Nodes registriert.<br>Wenn ein MeterMaster Node startet, erscheint er automatisch hier.',
1573
1628
  history:'Verlauf', since_last:'seit letzter Ablesung', days:'Tage', chart_btn:'Chart', csv_btn:'CSV',
1574
1629
  chart_readings:'Z\u00E4hlerstand', chart_monthly:'Monatsverbrauch', chart_period_all:'Alles',
1575
1630
  chart_yearly:'Pro Jahr', chart_yearly_proj:'Hochrechnung', chart_per_year:'/Jahr',
1576
1631
  chart_yearly_hint:'basierend auf {days} {daysLabel} ({delta} {unit})',
1577
1632
  chart_no_data:'Zu wenig Daten f\u00FCr eine Grafik (mind. 2 Ablesungen n\u00F6tig).',
1578
1633
  chart_close:'Schlie\u00DFen', error:'Fehler', consume:'Verbrauch',
1579
- ago_just:'gerade eben', ago_sec:'vor {s}s', ago_min:'vor {m}min'
1634
+ ago_just:'gerade eben', ago_sec:'vor {s}s', ago_min:'vor {m}min',
1635
+ nodes_title:'Registrierte ESP32 Nodes', nodes_total:'Gesamt:', nodes_online:'Online:', nodes_refresh:'Aktualisieren',
1636
+ th_status:'Status', th_name:'Name', th_ip:'IP-Adresse', th_fw:'FW-Version', th_last_seen:'Zuletzt gesehen',
1637
+ th_assign:'Z\u00E4hler zuweisen', th_led:'LED', node_online:'Online', node_offline:'Offline',
1638
+ no_meter:'\u2014 Kein Z\u00E4hler zugewiesen \u2014', save:'Speichern', saved:'Gespeichert',
1639
+ led_on:'Ein', led_off:'Aus', led_on_title:'LED ein', led_off_title:'LED aus',
1640
+ import_title:'App-Backup importieren',
1641
+ 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.',
1642
+ import_house_label:'Hausname (ioBroker-Pfad):', import_house_ph:'z.B. MeinHaus',
1643
+ import_drop:'JSON-Datei hier ablegen oder klicken zum Ausw\u00E4hlen',
1644
+ import_preview:'Vorschau', import_btn:'Importieren', import_reset:'Zur\u00FCcksetzen', import_importing:'Importiere\u2026',
1645
+ prev_file:'Datei', prev_schema:'Schema', prev_apts:'Wohnungen', prev_meters:'Z\u00E4hler',
1646
+ prev_readings:'Ablesungen', prev_compat:'Kompatibel', prev_yes:'Ja', prev_no:'Nein \u2013 Pflichtfelder fehlen',
1647
+ log_all_levels:'Alle Level', log_all_cats:'Alle Kategorien', log_search_ph:'Suche\u2026',
1648
+ log_clear:'Leeren', log_export:'Export', log_auto_scroll:'Auto-Scroll', log_live:'Live',
1649
+ log_empty:'Keine Log-Eintr\u00E4ge vorhanden.', new_entries:'Neue Eintr\u00E4ge',
1650
+ sys_stats:'Statistiken', sys_readings_total:'Ablesungen gesamt', sys_uptime:'Uptime',
1651
+ sys_nodes_online:'Nodes online', sys_nodes_total:'Nodes gesamt',
1652
+ sys_version:'Adapter-Version', sys_installed:'Installiert', sys_latest:'Aktuell (GitHub)',
1653
+ sys_status:'Status', sys_check:'Auf Updates pr\u00FCfen', sys_waiting:'Bitte warten\u2026',
1654
+ sys_not_checked:'Noch nicht gepr\u00FCft', sys_no_release:'Noch kein Release',
1655
+ sys_update_avail:'Update verf\u00FCgbar', sys_up_to_date:'Aktuell',
1656
+ sys_github_err:'GitHub nicht erreichbar', sys_no_github_release:'Kein GitHub-Release vorhanden',
1657
+ sys_net_err:'Netzwerkfehler', sys_info:'Adapter-Info', sys_adapter:'Adapter',
1658
+ sys_port:'Port', sys_repo:'Repository',
1659
+ sys_cmds:'Update-Befehle',
1660
+ sys_cmds_desc:'Adapter aktualisieren \u2014 Befehle in der ioBroker-Konsole ausf\u00FChren:',
1661
+ sys_tip:'Tipp: Alle drei Befehle nacheinander ausf\u00FChren \u2014 warten bis jeder abgeschlossen ist.',
1662
+ copy:'Kopieren',
1663
+ login_title:'Anmelden',
1664
+ login_desc:'Zugangsdaten f\u00FCr schreibende Aktionen (Z\u00E4hler zuweisen, Import).',
1665
+ login_user:'Benutzername', login_pass:'Passwort', login_submit:'Anmelden',
1666
+ login_missing_user:'Benutzername fehlt', login_wrong:'Falsche Zugangsdaten',
1667
+ login_conn_err:'Verbindungsfehler', login_expired:'Sitzung abgelaufen \u2013 bitte neu anmelden',
1668
+ invalid_json:'Ung\u00FCltige JSON-Datei', network_err:'Netzwerkfehler'
1580
1669
  },
1581
1670
  en: {
1582
1671
  readings:'Readings', nodes:'Nodes', uptime:'Uptime', live:'Live', disconnected:'Disconnected',
1583
1672
  tab_data:'Data', tab_nodes:'Nodes', tab_import:'Import', tab_logs:'Logs', tab_system:'System',
1584
1673
  no_data:'No readings received yet.<br>Start a sync in the MeterMaster app or upload a backup.',
1674
+ no_nodes:'No ESP32 nodes registered yet.<br>When a MeterMaster node starts and sends its heartbeat, it will appear here automatically.',
1585
1675
  history:'History', since_last:'since last reading', days:'days', chart_btn:'Chart', csv_btn:'CSV',
1586
1676
  chart_readings:'Meter reading', chart_monthly:'Monthly consumption', chart_period_all:'All',
1587
1677
  chart_yearly:'Per year', chart_yearly_proj:'Projected', chart_per_year:'/yr',
1588
1678
  chart_yearly_hint:'based on {days} {daysLabel} ({delta} {unit})',
1589
1679
  chart_no_data:'Not enough data for a chart (at least 2 readings required).',
1590
1680
  chart_close:'Close', error:'Error', consume:'Consumption',
1591
- ago_just:'just now', ago_sec:'{s}s ago', ago_min:'{m}min ago'
1681
+ ago_just:'just now', ago_sec:'{s}s ago', ago_min:'{m}min ago',
1682
+ nodes_title:'Registered ESP32 Nodes', nodes_total:'Total:', nodes_online:'Online:', nodes_refresh:'Refresh',
1683
+ th_status:'Status', th_name:'Name', th_ip:'IP address', th_fw:'FW version', th_last_seen:'Last seen',
1684
+ th_assign:'Assign meter', th_led:'LED', node_online:'Online', node_offline:'Offline',
1685
+ no_meter:'\u2014 No meter assigned \u2014', save:'Save', saved:'Saved',
1686
+ led_on:'On', led_off:'Off', led_on_title:'LED on', led_off_title:'LED off',
1687
+ import_title:'Import app backup',
1688
+ 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.',
1689
+ import_house_label:'House name (ioBroker path):', import_house_ph:'e.g. MyHouse',
1690
+ import_drop:'Drop JSON file here or click to select',
1691
+ import_preview:'Preview', import_btn:'Import', import_reset:'Reset', import_importing:'Importing\u2026',
1692
+ prev_file:'File', prev_schema:'Schema', prev_apts:'Apartments', prev_meters:'Meters',
1693
+ prev_readings:'Readings', prev_compat:'Compatible', prev_yes:'Yes', prev_no:'No \u2013 required fields missing',
1694
+ log_all_levels:'All levels', log_all_cats:'All categories', log_search_ph:'Search\u2026',
1695
+ log_clear:'Clear', log_export:'Export', log_auto_scroll:'Auto-scroll', log_live:'Live',
1696
+ log_empty:'No log entries yet.', new_entries:'New entries',
1697
+ sys_stats:'Statistics', sys_readings_total:'Total readings', sys_uptime:'Uptime',
1698
+ sys_nodes_online:'Nodes online', sys_nodes_total:'Total nodes',
1699
+ sys_version:'Adapter version', sys_installed:'Installed', sys_latest:'Latest (GitHub)',
1700
+ sys_status:'Status', sys_check:'Check for updates', sys_waiting:'Please wait\u2026',
1701
+ sys_not_checked:'Not checked yet', sys_no_release:'No release yet',
1702
+ sys_update_avail:'Update available', sys_up_to_date:'Up to date',
1703
+ sys_github_err:'GitHub unreachable', sys_no_github_release:'No GitHub release available',
1704
+ sys_net_err:'Network error', sys_info:'Adapter info', sys_adapter:'Adapter',
1705
+ sys_port:'Port', sys_repo:'Repository',
1706
+ sys_cmds:'Update commands',
1707
+ sys_cmds_desc:'Update the adapter \u2014 run these commands in the ioBroker console:',
1708
+ sys_tip:'Tip: Run all three commands in sequence \u2014 wait for each to finish.',
1709
+ copy:'Copy',
1710
+ login_title:'Sign in',
1711
+ login_desc:'Credentials for write actions (assign meter, import).',
1712
+ login_user:'Username', login_pass:'Password', login_submit:'Sign in',
1713
+ login_missing_user:'Username required', login_wrong:'Invalid credentials',
1714
+ login_conn_err:'Connection error', login_expired:'Session expired \u2013 please sign in again',
1715
+ invalid_json:'Invalid JSON file', network_err:'Network error'
1592
1716
  }
1593
1717
  };
1594
1718
 
1595
- let currentLang = 'de';
1719
+ let currentLang = 'en';
1596
1720
  try {
1597
1721
  const saved = localStorage.getItem('mm-lang');
1598
1722
  currentLang = saved || (navigator.language && navigator.language.startsWith('de') ? 'de' : 'en');
1599
- } catch { currentLang = 'de'; }
1723
+ } catch { currentLang = 'en'; }
1600
1724
 
1601
1725
  function t(key, vars) {
1602
- let s = (I18N[currentLang] && I18N[currentLang][key]) || (I18N.de[key]) || key;
1726
+ let s = (I18N[currentLang] && I18N[currentLang][key]) || (I18N.en[key]) || key;
1603
1727
  if (vars) Object.keys(vars).forEach(k => { s = s.replace('{'+k+'}', vars[k]); });
1604
1728
  return s;
1605
1729
  }
@@ -1610,9 +1734,59 @@ function applyI18n() {
1610
1734
  const ls = document.getElementById('lang-select');
1611
1735
  if (ls) ls.value = currentLang;
1612
1736
  const set = (id, key) => { const el = document.getElementById(id); if (el) el.textContent = t(key); };
1737
+ const setHtml = (id, key) => { const el = document.getElementById(id); if (el) el.innerHTML = t(key); };
1738
+ const setPh = (id, key) => { const el = document.getElementById(id); if (el) el.placeholder = t(key); };
1613
1739
  set('lbl-rx', 'readings');
1614
1740
  set('lbl-nodes', 'nodes');
1615
1741
  set('lbl-up', 'uptime');
1742
+ set('lbl-nd-total', 'nodes_total');
1743
+ set('lbl-nd-online', 'nodes_online');
1744
+ setHtml('no-data-msg', 'no_data');
1745
+ setHtml('no-nodes-msg', 'no_nodes');
1746
+ set('nodes-title', 'nodes_title');
1747
+ set('nodes-refresh-btn', 'nodes_refresh');
1748
+ set('import-title', 'import_title');
1749
+ set('import-desc', 'import_desc');
1750
+ set('import-house-label', 'import_house_label');
1751
+ setPh('imp-house', 'import_house_ph');
1752
+ set('import-drop-text', 'import_drop');
1753
+ set('import-preview-title', 'import_preview');
1754
+ set('imp-btn', 'import_btn');
1755
+ set('import-reset-btn', 'import_reset');
1756
+ set('fl-all', 'log_all_levels');
1757
+ set('fc-all', 'log_all_cats');
1758
+ setPh('ft', 'log_search_ph');
1759
+ set('log-clear-btn', 'log_clear');
1760
+ set('log-export-btn', 'log_export');
1761
+ set('lbl-auto-scroll', 'log_auto_scroll');
1762
+ set('lbl-live', 'log_live');
1763
+ set('log-empty', 'log_empty');
1764
+ set('ni', 'new_entries');
1765
+ set('sys-stats-title', 'sys_stats');
1766
+ set('sys-rx-label', 'sys_readings_total');
1767
+ set('sys-up-label', 'sys_uptime');
1768
+ set('sys-online-label', 'sys_nodes_online');
1769
+ set('sys-total-label', 'sys_nodes_total');
1770
+ set('sys-version-title', 'sys_version');
1771
+ set('sys-lbl-installed', 'sys_installed');
1772
+ set('sys-lbl-latest', 'sys_latest');
1773
+ set('sys-lbl-status', 'sys_status');
1774
+ set('sv-status-text', 'sys_not_checked');
1775
+ set('sv-check-btn', 'sys_check');
1776
+ set('sv-spin', 'sys_waiting');
1777
+ set('sys-info-title', 'sys_info');
1778
+ set('sys-lbl-adapter', 'sys_adapter');
1779
+ set('sys-lbl-port', 'sys_port');
1780
+ set('sys-lbl-repo', 'sys_repo');
1781
+ set('sys-cmds-title', 'sys_cmds');
1782
+ set('sys-cmds-desc', 'sys_cmds_desc');
1783
+ set('sys-cmds-tip', 'sys_tip');
1784
+ document.querySelectorAll('.cmd-copy').forEach(btn => { btn.title = t('copy'); });
1785
+ set('login-title', 'login_title');
1786
+ set('login-desc', 'login_desc');
1787
+ set('login-user-label', 'login_user');
1788
+ set('login-pass-label', 'login_pass');
1789
+ set('login-submit-btn', 'login_submit');
1616
1790
  const tabs = { 'tab-data':'tab_data', 'tab-nodes':'tab_nodes', 'tab-import':'tab_import', 'tab-logs':'tab_logs', 'tab-system':'tab_system' };
1617
1791
  Object.keys(tabs).forEach(id => {
1618
1792
  const el = document.getElementById(id);
@@ -1637,6 +1811,8 @@ function setLang(lang) {
1637
1811
  applyI18n();
1638
1812
  fetchData();
1639
1813
  fetchStats();
1814
+ const nodesPage = document.getElementById('page-nodes');
1815
+ if (nodesPage && nodesPage.classList.contains('active')) fetchNodes();
1640
1816
  }
1641
1817
 
1642
1818
  let dataCacheList = [];
@@ -2032,11 +2208,11 @@ window.fetchNodes = async function fetchNodes() {
2032
2208
  const elTot = document.getElementById('nd-total'); if (elTot) elTot.textContent = total;
2033
2209
  const elOn = document.getElementById('nd-online'); if (elOn) elOn.textContent = online;
2034
2210
  if (!total) {
2035
- con.innerHTML = '<div class="empty-state"><div class="ico">\uD83D\uDCE1</div><p>Noch keine ESP32 Nodes registriert.<br>Wenn ein MeterMaster Node startet, erscheint er automatisch hier.</p></div>';
2211
+ con.innerHTML = '<div class="empty-state"><div class="ico">\uD83D\uDCE1</div><p>' + t('no_nodes') + '</p></div>';
2036
2212
  return;
2037
2213
  }
2038
2214
  const buildOptions = (currentSid) => {
2039
- let opts = '<option value="">\u2014 Kein Z\u00E4hler zugewiesen \u2014</option>';
2215
+ let opts = '<option value="">' + esc(t('no_meter')) + '</option>';
2040
2216
  for (const m of discover) {
2041
2217
  const lbl = m.house + ' \u203A ' + m.apartment + ' \u203A ' + m.meter + (m.latest !== undefined ? ' (' + m.latest + ' ' + esc(m.unit) + ')' : '');
2042
2218
  opts += '<option value="'+esc(m.stateId)+'"'+(m.stateId===currentSid?' selected':'')+'>'+esc(lbl)+'</option>';
@@ -2047,13 +2223,13 @@ window.fetchNodes = async function fetchNodes() {
2047
2223
  html += '<table style="width:100%;border-collapse:collapse;font-size:.85em;">';
2048
2224
  html += '<thead><tr style="text-align:left;">';
2049
2225
  const th = (t) => '<th style="padding:10px 12px;color:var(--text-dim);font-size:.8em;text-transform:uppercase;letter-spacing:.6px;border-bottom:2px solid var(--border);background:var(--bg-surface);">'+t+'</th>';
2050
- html += th('Status')+th('Name')+th('IP-Adresse')+th('FW-Version')+th('Zuletzt gesehen')+th('Z\u00E4hler zuweisen')+th('LED');
2226
+ 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'));
2051
2227
  html += '</tr></thead><tbody>';
2052
2228
  for (const n of nodes) {
2053
2229
  let currentSid = '';
2054
2230
  try { currentSid = JSON.parse(n.config||'{}').sid || ''; } catch {}
2055
- const onBadge = '<span style="display:inline-flex;align-items:center;gap:5px;font-size:.8em;font-weight:700;padding:2px 9px;border-radius:20px;background:rgba(76,175,80,.15);color:#A5D6A7;border:1px solid rgba(76,175,80,.3);"><span style="width:7px;height:7px;border-radius:50%;background:var(--accent);display:inline-block;box-shadow:0 0 4px var(--accent);"></span>Online</span>';
2056
- const offBadge = '<span style="display:inline-flex;align-items:center;gap:5px;font-size:.8em;font-weight:700;padding:2px 9px;border-radius:20px;background:rgba(244,67,54,.12);color:#EF9A9A;border:1px solid rgba(244,67,54,.3);"><span style="width:7px;height:7px;border-radius:50%;background:var(--danger);display:inline-block;"></span>Offline</span>';
2231
+ 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>';
2232
+ 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>';
2057
2233
  const badge = n.online ? onBadge : offBadge;
2058
2234
  const ackHint = n.configAck ? '<div style="font-size:.75em;color:var(--text-muted);margin-top:3px;">\u2713 Ack</div>' : '';
2059
2235
  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';
@@ -2064,11 +2240,11 @@ window.fetchNodes = async function fetchNodes() {
2064
2240
  html += td(ipCell);
2065
2241
  html += td('<span style="background:var(--bg-surface3);color:var(--secondary);font-family:Consolas,monospace;font-size:.82em;padding:2px 8px;border-radius:6px;">'+esc(n.version||'\u2013')+'</span>');
2066
2242
  html += td(esc(fmtAgo(n.lastSeen)), 'color:var(--text-dim);font-size:.82em;');
2067
- html += td('<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;"><select id="sel-'+esc(n.mac)+'" style="background:var(--bg-surface2);border:1px solid var(--border-light);color:var(--text);padding:6px 10px;border-radius:7px;font-size:.82em;max-width:300px;min-width:180px;outline:none;">'+buildOptions(currentSid)+'</select><button id="sbtn-'+esc(n.mac)+'" style="background:var(--primary);color:#fff;border:none;padding:6px 14px;border-radius:7px;cursor:pointer;font-size:.82em;font-weight:600;white-space:nowrap;">\uD83D\uDCBE Speichern</button><span id="smsg-'+esc(n.mac)+'"></span></div>'+ackHint);
2068
- // LED-Spalte
2243
+ 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);
2244
+ // LED column
2069
2245
  html += td('<div style="display:flex;flex-direction:column;gap:5px;align-items:center;">'
2070
- +'<button class="ledBtn" data-mac="'+esc(n.mac)+'" data-led="1" title="LED ein" style="background:rgba(239,68,68,.2);color:#F87171;border:1px solid rgba(239,68,68,.4);padding:4px 10px;border-radius:6px;cursor:pointer;font-size:.8em;white-space:nowrap;">\uD83D\uDD34 Ein</button>'
2071
- +'<button class="ledBtn" data-mac="'+esc(n.mac)+'" data-led="0" title="LED aus" style="background:var(--bg-surface2);color:var(--text-dim);border:1px solid var(--border);padding:4px 10px;border-radius:6px;cursor:pointer;font-size:.8em;white-space:nowrap;">\u26AB Aus</button>'
2246
+ +'<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>'
2247
+ +'<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>'
2072
2248
  +'<span id="ledmsg-'+esc(n.mac)+'" style="font-size:.75em;min-height:14px;"></span>'
2073
2249
  +'</div>');
2074
2250
  html += '</tr>';
@@ -2076,7 +2252,7 @@ window.fetchNodes = async function fetchNodes() {
2076
2252
  html += '</tbody></table></div>';
2077
2253
  con.innerHTML = html;
2078
2254
  } catch(e) {
2079
- con.innerHTML = '<div class="empty-state"><div class="ico">\u26A0</div><p>Fehler: '+esc(e.message)+'</p></div>';
2255
+ con.innerHTML = '<div class="empty-state"><div class="ico">\u26A0</div><p>'+t('error')+': '+esc(e.message)+'</p></div>';
2080
2256
  }
2081
2257
  }
2082
2258
 
@@ -2096,10 +2272,10 @@ window.saveNodeConfig = async function saveNodeConfig(mac) {
2096
2272
  if (!r) { btn.disabled = false; return; }
2097
2273
  const d = await r.json();
2098
2274
  if (d.ok) {
2099
- msg.innerHTML = '<span style="color:var(--accent);font-size:.82em;">\u2713 Gespeichert</span>';
2275
+ msg.innerHTML = '<span style="color:var(--accent);font-size:.82em;">\u2713 '+esc(t('saved'))+'</span>';
2100
2276
  window.setTimeout(() => { msg.textContent = ''; }, 3000);
2101
2277
  } else {
2102
- msg.innerHTML = '<span style="color:var(--danger);font-size:.82em;">\u2717 '+esc(d.error||'Fehler')+'</span>';
2278
+ msg.innerHTML = '<span style="color:var(--danger);font-size:.82em;">\u2717 '+esc(d.error||t('error'))+'</span>';
2103
2279
  }
2104
2280
  } catch(e) {
2105
2281
  msg.innerHTML = '<span style="color:var(--danger);font-size:.82em;">\u2717 '+esc(e.message)+'</span>';
@@ -2138,7 +2314,7 @@ function loadFile(file) {
2138
2314
  const r = new FileReader();
2139
2315
  r.onload = e => {
2140
2316
  try { importData = JSON.parse(e.target.result); showPreview(importData, file.name); }
2141
- catch { showResult('err', '\u274C Ung\u00FCltige JSON-Datei'); }
2317
+ catch { showResult('err', '\u274C ' + t('invalid_json')); }
2142
2318
  };
2143
2319
  r.readAsText(file);
2144
2320
  }
@@ -2146,12 +2322,12 @@ function loadFile(file) {
2146
2322
  function showPreview(d, fname) {
2147
2323
  const valid = !!(d.Apartments && d.Meters && d.Readings);
2148
2324
  document.getElementById('preview-content').innerHTML =
2149
- prow('Datei', esc(fname)) +
2150
- prow('Schema', d.SchemaVersion||'?') +
2151
- prow('Wohnungen', (d.Apartments||[]).length) +
2152
- prow('Z\u00E4hler', (d.Meters||[]).length) +
2153
- prow('Ablesungen', (d.Readings||[]).length) +
2154
- prow('Kompatibel', valid ? '\u2705 Ja' : '\u274C Nein \u2013 Pflichtfelder fehlen');
2325
+ prow(t('prev_file'), esc(fname)) +
2326
+ prow(t('prev_schema'), d.SchemaVersion||'?') +
2327
+ prow(t('prev_apts'), (d.Apartments||[]).length) +
2328
+ prow(t('prev_meters'), (d.Meters||[]).length) +
2329
+ prow(t('prev_readings'), (d.Readings||[]).length) +
2330
+ prow(t('prev_compat'), valid ? '\u2705 ' + t('prev_yes') : '\u274C ' + t('prev_no'));
2155
2331
  document.getElementById('preview-box').style.display = 'block';
2156
2332
  document.getElementById('imp-btn').disabled = !valid;
2157
2333
  }
@@ -2159,20 +2335,20 @@ const prow = (l,v) => '<div class="preview-row"><span>'+l+'</span><b>'+v+'</b></
2159
2335
 
2160
2336
  window.doImport = async function doImport() {
2161
2337
  if (!importData) return;
2162
- const house = document.getElementById('imp-house').value.trim() || 'MeinHaus';
2338
+ const house = document.getElementById('imp-house').value.trim() || 'MyHouse';
2163
2339
  const btn = document.getElementById('imp-btn');
2164
- btn.disabled = true; btn.textContent = '\u23F3 Importiere\u2026';
2340
+ btn.disabled = true; btn.textContent = '\u23F3 ' + t('import_importing');
2165
2341
  try {
2166
2342
  const r = await authFetch('/api/import', {
2167
2343
  method: 'POST',
2168
2344
  body: JSON.stringify({...importData, HouseName: house})
2169
2345
  });
2170
- if (!r) { btn.disabled = false; btn.textContent = '⬆ Importieren'; return; }
2346
+ if (!r) { btn.disabled = false; btn.textContent = '\u2B06 ' + t('import_btn'); return; }
2171
2347
  const d = await r.json();
2172
2348
  if (d.ok) { showResult('ok', '\u2705 '+d.summary); fetchData(); fetchStats(); }
2173
2349
  else { showResult('warn','\u26A0 '+d.summary+(d.errors.length ? '<br>'+d.errors.slice(0,5).map(esc).join('<br>') : '')); }
2174
- } catch(e) { showResult('err', '\u274C Netzwerkfehler: '+esc(e.message)); }
2175
- finally { btn.disabled = false; btn.textContent = '\u2B06 Importieren'; }
2350
+ } catch(e) { showResult('err', '\u274C ' + t('network_err') + ': '+esc(e.message)); }
2351
+ finally { btn.disabled = false; btn.textContent = '\u2B06 ' + t('import_btn'); }
2176
2352
  }
2177
2353
  function showResult(type, msg) {
2178
2354
  const rb = document.getElementById('imp-result');
@@ -2271,56 +2447,27 @@ function startLive() {
2271
2447
  async function checkVersion() {
2272
2448
  const btn = document.getElementById('sv-check-btn');
2273
2449
  const spin = document.getElementById('sv-spin');
2274
- const updBtn = document.getElementById('sv-upd-btn');
2275
2450
  btn.disabled = true; spin.style.display = 'inline';
2276
2451
  try {
2277
2452
  const d = await fetch('/api/version').then(r => r.json());
2278
2453
  document.getElementById('sv-cur').textContent = d.current || '\u2013';
2279
- document.getElementById('sv-lat').textContent = d.latest || (d.error ? '(Fehler)' : 'Noch kein Release');
2454
+ document.getElementById('sv-lat').textContent = d.latest || (d.error ? '(' + t('error') + ')' : t('sys_no_release'));
2280
2455
  const st = document.getElementById('sv-status');
2281
2456
  if (d.error) {
2282
- st.innerHTML = '<span class="badge-err">\u26A0 GitHub nicht erreichbar</span>';
2283
- updBtn.style.display = 'none';
2457
+ st.innerHTML = '<span class="badge-err">\u26A0 ' + t('sys_github_err') + '</span>';
2284
2458
  } else if (!d.latest) {
2285
- st.innerHTML = '<span class="badge-warn">\u2139 Kein GitHub-Release vorhanden</span>';
2286
- updBtn.style.display = 'none';
2459
+ st.innerHTML = '<span class="badge-warn">\u2139 ' + t('sys_no_github_release') + '</span>';
2287
2460
  } else if (d.updateAvailable) {
2288
- st.innerHTML = '<span class="badge-new">\uD83C\uDD95 Update verf\u00FCgbar</span>';
2289
- updBtn.style.display = '';
2461
+ st.innerHTML = '<span class="badge-new">\uD83C\uDD95 ' + t('sys_update_avail') + '</span>';
2290
2462
  } else {
2291
- st.innerHTML = '<span class="badge-ok">\u2713 Aktuell</span>';
2292
- updBtn.style.display = 'none';
2463
+ st.innerHTML = '<span class="badge-ok">\u2713 ' + t('sys_up_to_date') + '</span>';
2293
2464
  }
2294
2465
  } catch(e) {
2295
- document.getElementById('sv-status').innerHTML = '<span class="badge-err">\u26A0 Netzwerkfehler</span>';
2466
+ document.getElementById('sv-status').innerHTML = '<span class="badge-err">\u26A0 ' + t('sys_net_err') + '</span>';
2296
2467
  }
2297
2468
  btn.disabled = false; spin.style.display = 'none';
2298
2469
  }
2299
2470
 
2300
- async function doUpdate() {
2301
- const btn = document.getElementById('sv-upd-btn');
2302
- const spin = document.getElementById('sv-spin');
2303
- const outBox = document.getElementById('sv-out');
2304
- if (!confirm('Update installieren und Adapter neu starten?')) return;
2305
- btn.style.display = 'none'; spin.style.display = 'inline';
2306
- outBox.style.display = 'block'; outBox.textContent = '\u23F3 Update l\u00E4uft\u2026';
2307
- try {
2308
- const r = await fetch('/api/update', { method: 'POST' });
2309
- const d = await r.json();
2310
- if (d.ok) {
2311
- outBox.textContent = '\u2705 Update erfolgreich.' + String.fromCharCode(10) + 'Adapter wird neu gestartet\u2026' + String.fromCharCode(10,10) + (d.output||'');
2312
- document.getElementById('sv-status').innerHTML = '<span class="badge-ok">\u2713 Neu gestartet</span>';
2313
- window.setTimeout(() => { outBox.textContent += String.fromCharCode(10) + '\u27F3 Seite wird neu geladen\u2026'; location.reload(); }, 8000);
2314
- } else {
2315
- outBox.textContent = '\u274C Fehler:' + String.fromCharCode(10) + (d.error||'') + String.fromCharCode(10,10) + (d.output||'');
2316
- btn.style.display = ''; spin.style.display = 'none';
2317
- }
2318
- } catch(e) {
2319
- outBox.textContent = '\u274C Netzwerkfehler: ' + e.message;
2320
- btn.style.display = ''; spin.style.display = 'none';
2321
- }
2322
- }
2323
-
2324
2471
  // \u2500\u2500 Init \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2325
2472
  // ── Copy-to-Clipboard ─────────────────────────────────────────────────────────
2326
2473
  window.copyCmd = function copyCmd(btn) {
@@ -2363,10 +2510,8 @@ function initTabs() {
2363
2510
  // System-Tab Buttons
2364
2511
  const chkBtn = document.getElementById('sv-check-btn');
2365
2512
  if (chkBtn) chkBtn.addEventListener('click', checkVersion);
2366
- const updBtn = document.getElementById('sv-upd-btn');
2367
- if (updBtn) updBtn.addEventListener('click', doUpdate);
2368
2513
 
2369
- // Verlauf-Toggle via Event Delegation (kein onclick-Attribut nötig)
2514
+ // History toggle via event delegation
2370
2515
  document.addEventListener('click', e => {
2371
2516
  const btn = e.target.closest('.mc-hist-toggle');
2372
2517
  if (btn && btn.dataset.hist) toggleHist(btn.dataset.hist);