iobroker.metermaster 0.9.3 → 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.3';
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
 
@@ -90,7 +107,7 @@ adapter.on('ready', async () => {
90
107
  });
91
108
 
92
109
  adapter.on('unload', (callback) => {
93
- log(LVL.INFO, CAT.SYSTEM, 'Adapter wird gestoppt');
110
+ log(LVL.INFO, CAT.SYSTEM, 'Adapter stopping');
94
111
  try { if (server) { server.close(() => callback()); } else { callback(); } }
95
112
  catch (e) { callback(); }
96
113
  });
@@ -99,7 +116,7 @@ adapter.on('unload', (callback) => {
99
116
  adapter.on('stateChange', async (id, state) => {
100
117
  // Eigene Writes (ack: true) ignorieren – nur externe Änderungen verarbeiten
101
118
  if (!state || state.ack) return;
102
- if (!state || state.val === null) return;
119
+ if (state.val === null) return;
103
120
  const ns = `${adapter.namespace}.`;
104
121
  const relative = id.startsWith(ns) ? id.slice(ns.length) : id;
105
122
  const parts = relative.split('.');
@@ -120,7 +137,7 @@ adapter.on('stateChange', async (id, state) => {
120
137
 
121
138
  if (field === 'lastSeen') {
122
139
  const n = nodesCache[mac];
123
- 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'}`);
124
141
  await ensureNodeStates(mac);
125
142
  }
126
143
  });
@@ -140,7 +157,7 @@ async function restoreCacheFromStates() {
140
157
  );
141
158
 
142
159
  if (latestKeys.length === 0) {
143
- log(LVL.DEBUG, CAT.SYSTEM, 'Cache-Wiederherstellung', 'Keine gespeicherten Ablesungen gefunden');
160
+ log(LVL.DEBUG, CAT.SYSTEM, 'Cache restore', 'No stored readings found');
144
161
  return;
145
162
  }
146
163
 
@@ -172,9 +189,9 @@ async function restoreCacheFromStates() {
172
189
  restored++;
173
190
  }
174
191
 
175
- 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`);
176
193
  } catch (e) {
177
- log(LVL.WARN, CAT.SYSTEM, 'Cache-Wiederherstellung fehlgeschlagen', e.message);
194
+ log(LVL.WARN, CAT.SYSTEM, 'Cache restore failed', e.message);
178
195
  }
179
196
  }
180
197
 
@@ -202,10 +219,10 @@ async function restoreNodesFromStates() {
202
219
  if (field === 'config') nodesCache[mac].config = String(state.val);
203
220
  if (field === 'cmd' && state.val) nodesCache[mac].cmd = String(state.val);
204
221
  }
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');
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');
207
224
  } catch (e) {
208
- log(LVL.WARN, CAT.NODE, 'Node-Wiederherstellung fehlgeschlagen', e.message);
225
+ log(LVL.WARN, CAT.NODE, 'Node restore failed', e.message);
209
226
  }
210
227
  }
211
228
 
@@ -214,20 +231,21 @@ async function ensureNodeStates(mac) {
214
231
  const base = `nodes.${mac}`;
215
232
  await ensureChannel('nodes', 'ESP32 Nodes');
216
233
  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 });
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 });
224
241
  }
225
242
 
226
243
  // ─── HTTP-Server ──────────────────────────────────────────────────────────────
227
244
  function startHttpServer() {
228
- const port = parseInt(adapter.config.port) || 8089;
229
- const user = (adapter.config.user || '').trim();
230
- 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;
231
249
 
232
250
  server = http.createServer((req, res) => {
233
251
  res.setHeader('Access-Control-Allow-Origin', '*');
@@ -248,9 +266,8 @@ function startHttpServer() {
248
266
  if (req.method === 'GET' && url === '/api/version') { serveVersion(res); return; }
249
267
  if (req.method === 'GET' && url === '/api/nodes') { serveNodesJson(res); return; }
250
268
  if (req.method === 'GET' && url === '/api/discover'){ serveDiscoverJson(res); return; }
251
- if (req.method === 'POST' && url === '/api/update') { handleUpdate(req, res); return; }
252
269
 
253
- // ESP32 Node-Registrierung (kein Auth – ESP32 kennt keine Zugangsdaten)
270
+ // ESP32 node registration (no auth – ESP32 has no credentials)
254
271
  if (req.method === 'POST' && url === '/api/register') {
255
272
  readBody(req, b => handleNodeRegister(b, res, clientIp)); return;
256
273
  }
@@ -272,9 +289,9 @@ function startHttpServer() {
272
289
  if (user && password) {
273
290
  const authHeader = req.headers['authorization'] || '';
274
291
  if (!authHeader.startsWith('Basic ')) {
275
- log(LVL.WARN, CAT.AUTH, `Kein Auth-Header`, `IP: ${clientIp} | ${url}`);
292
+ log(LVL.WARN, CAT.AUTH, `No auth header`, `IP: ${clientIp} | ${url}`);
276
293
  res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="MeterMaster"' });
277
- res.end(JSON.stringify({ error: 'Authentifizierung erforderlich' })); return;
294
+ res.end(JSON.stringify({ error: 'Authentication required' })); return;
278
295
  }
279
296
  const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf-8');
280
297
  const colonIdx = decoded.indexOf(':');
@@ -291,8 +308,8 @@ function startHttpServer() {
291
308
  authOk = crypto.timingSafeEqual(uBuf, uRef) && crypto.timingSafeEqual(pBuf, pRef);
292
309
  } catch { authOk = false; }
293
310
  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;
311
+ log(LVL.WARN, CAT.AUTH, `Invalid credentials`, `IP: ${clientIp} | User: "${reqUser}"`);
312
+ res.writeHead(403); res.end(JSON.stringify({ error: 'Invalid credentials' })); return;
296
313
  }
297
314
  log(LVL.DEBUG, CAT.AUTH, `Auth OK`, `IP: ${clientIp} | User: "${reqUser}"`);
298
315
  }
@@ -312,19 +329,19 @@ function startHttpServer() {
312
329
  if (req.method === 'POST' && cmdMatch) {
313
330
  readBody(req, b => handleNodeCmd(cmdMatch[1].toUpperCase(), b, res));
314
331
  } 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' }));
332
+ log(LVL.WARN, CAT.CONNECT, `Unknown URL`, `${req.method} ${url} from ${clientIp}`);
333
+ res.writeHead(404); res.end(JSON.stringify({ error: 'Not found' }));
317
334
  }
318
335
  }
319
336
  }
320
337
  });
321
338
 
322
339
  server.on('error', err => {
323
- log(LVL.ERROR, CAT.SYSTEM, `HTTP-Fehler: ${err.message}`,
324
- 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);
325
342
  });
326
343
  server.listen(port, '0.0.0.0', () => {
327
- 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}/`);
328
345
  adapter.setState('info.connection', { val: true, ack: true });
329
346
  });
330
347
  }
@@ -337,14 +354,14 @@ function handlePing(res, clientIp) {
337
354
 
338
355
  // ─── Validierung ─────────────────────────────────────────────────────────────
339
356
  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';
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';
348
365
  return null;
349
366
  }
350
367
 
@@ -352,18 +369,18 @@ function validateReading(data) {
352
369
  async function handleReading(body, res, clientIp) {
353
370
  let data;
354
371
  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;
372
+ log(LVL.WARN, CAT.SYNC, `Invalid JSON`, `IP: ${clientIp}`);
373
+ sendJson(res, 400, { error: 'Invalid JSON' }); return;
357
374
  }
358
375
  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`,
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`,
361
378
  `${data.house}/${data.apartment}/${data.meter} = ${data.value} ${data.unit||''} (${data.readingDate})`);
362
379
  try {
363
380
  const path = await storeReading(data);
364
381
  sendJson(res, 200, { ok: true, path });
365
382
  } catch (e) {
366
- log(LVL.ERROR, CAT.SYNC, `Speicherfehler`, e.message);
383
+ log(LVL.ERROR, CAT.SYNC, `Storage error`, e.message);
367
384
  sendJson(res, 500, { error: e.message });
368
385
  }
369
386
  }
@@ -372,21 +389,21 @@ async function handleReading(body, res, clientIp) {
372
389
  async function handleReadings(body, res, clientIp) {
373
390
  let items;
374
391
  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;
392
+ log(LVL.WARN, CAT.SYNC, `Invalid JSON in batch`, `IP: ${clientIp}`);
393
+ sendJson(res, 400, { error: 'Array expected' }); return;
377
394
  }
378
- 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`);
379
396
  let stored = 0, failed = 0;
380
397
  const errors = [];
381
398
  for (const data of items) {
382
399
  const err = validateReading(data);
383
400
  if (err) { failed++; errors.push(`${data.meter||'?'}: ${err}`); continue; }
384
401
  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}`); }
402
+ catch (e) { failed++; errors.push(`${data.meter||'?'}: ${e.message}`); log(LVL.ERROR, CAT.SYNC, `Batch error`, `${data.meter}: ${e.message}`); }
386
403
  }
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);
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);
390
407
  sendJson(res, 200, { ok: failed === 0, stored, failed, errors });
391
408
  }
392
409
 
@@ -394,19 +411,19 @@ async function handleReadings(body, res, clientIp) {
394
411
  async function handleImport(body, res, clientIp) {
395
412
  let data;
396
413
  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;
414
+ log(LVL.WARN, CAT.IMPORT, `Invalid JSON`, `IP: ${clientIp}`);
415
+ sendJson(res, 400, { error: 'Invalid JSON' }); return;
399
416
  }
400
417
 
401
- // Schema-Prüfung
418
+ // Schema validation
402
419
  if (!data.Apartments || !data.Meters || !data.Readings) {
403
- 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;
404
421
  }
405
422
 
406
423
  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}`);
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}`);
410
427
 
411
428
  // ID-Maps aufbauen: JSON-Id → Objekt
412
429
  const aptMap = {}; // aptId → Apartment
@@ -422,7 +439,7 @@ async function handleImport(body, res, clientIp) {
422
439
  const meter = meterMap[reading.MeterId];
423
440
  if (!meter) {
424
441
  skipped++;
425
- log(LVL.WARN, CAT.IMPORT, `Zähler nicht gefunden`, `MeterId: ${reading.MeterId}`);
442
+ log(LVL.WARN, CAT.IMPORT, `Meter not found`, `MeterId: ${reading.MeterId}`);
426
443
  continue;
427
444
  }
428
445
 
@@ -441,7 +458,7 @@ async function handleImport(body, res, clientIp) {
441
458
  const err = validateReading(payload);
442
459
  if (err) {
443
460
  skipped++;
444
- log(LVL.WARN, CAT.IMPORT, `Ungültige Ablesung übersprungen`, `${meter.Name}: ${err}`);
461
+ log(LVL.WARN, CAT.IMPORT, `Invalid reading skipped`, `${meter.Name}: ${err}`);
445
462
  continue;
446
463
  }
447
464
 
@@ -451,13 +468,13 @@ async function handleImport(body, res, clientIp) {
451
468
  } catch (e) {
452
469
  failed++;
453
470
  errors.push(`${meter.Name}: ${e.message}`);
454
- log(LVL.ERROR, CAT.IMPORT, `Speicherfehler`, `${meter.Name}: ${e.message}`);
471
+ log(LVL.ERROR, CAT.IMPORT, `Storage error`, `${meter.Name}: ${e.message}`);
455
472
  }
456
473
  }
457
474
 
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);
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);
461
478
 
462
479
  sendJson(res, 200, { ok: failed === 0, stored, skipped, failed, errors, summary });
463
480
  }
@@ -474,18 +491,18 @@ async function storeReading(data) {
474
491
  const isNew = await ensureChannel(`${house}`, data.house);
475
492
  await ensureChannel(`${house}.${apt}`, data.apartment);
476
493
  await ensureChannel(`${base}`, data.meter);
477
- await ensureChannel(`${base}.readings`, 'Ablesungen');
494
+ await ensureChannel(`${base}.readings`, 'Readings');
478
495
 
479
- 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}`);
480
497
 
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 });
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 });
487
504
 
488
- 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`);
489
506
 
490
507
  await adapter.setStateAsync(`${base}.readings.latest`, { val: value, ts, ack: true });
491
508
  await adapter.setStateAsync(`${base}.readings.latestDate`, { val: data.readingDate, ts, ack: true });
@@ -493,11 +510,11 @@ async function storeReading(data) {
493
510
  await adapter.setStateAsync(`${base}.unit`, { val: data.unit||'', ts, ack: true });
494
511
  await adapter.setStateAsync(`${base}.typeName`, { val: data.typeName||'', ts, ack: true });
495
512
 
496
- 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}`);
497
514
 
498
515
  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}`);
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}`);
501
518
 
502
519
  // In-Memory-Cache aktualisieren
503
520
  cacheReading(house, apt, meter, value, data.unit||'', data.typeName||'', data.readingDate, ts);
@@ -511,7 +528,7 @@ async function storeReading(data) {
511
528
  // ─── Historie ─────────────────────────────────────────────────────────────────
512
529
  async function updateHistory(base, entry) {
513
530
  const stateId = `${base}.readings.history`;
514
- const keep = parseInt(adapter.config.keepHistory) || 0;
531
+ const keep = getValidatedConfig().keepHistory;
515
532
  let history = [];
516
533
  try {
517
534
  const ex = await adapter.getStateAsync(stateId);
@@ -583,19 +600,20 @@ function resolveMigratedRole(id, common) {
583
600
 
584
601
  async function migrateStateRoles() {
585
602
  try {
586
- const objects = await adapter.getForeignObjectsAsync('*', { type: 'state' });
587
- if (!objects) return;
603
+ const allObjects = await adapter.getAdapterObjectsAsync();
604
+ if (!allObjects) return;
588
605
  let fixed = 0;
589
- for (const [id, obj] of Object.entries(objects)) {
606
+ for (const [id, obj] of Object.entries(allObjects)) {
607
+ if (obj.type !== 'state') continue;
590
608
  const newRole = resolveMigratedRole(id, obj.common || {});
591
609
  if (newRole && obj.common.role !== newRole) {
592
610
  await adapter.extendObjectAsync(id, { common: { role: newRole } });
593
611
  fixed++;
594
612
  }
595
613
  }
596
- if (fixed) log(LVL.INFO, CAT.SYSTEM, 'State-Rollen migriert', `${fixed} Objekte`);
614
+ if (fixed) log(LVL.INFO, CAT.SYSTEM, 'State roles migrated', `${fixed} objects`);
597
615
  } catch (e) {
598
- log(LVL.WARN, CAT.SYSTEM, 'State-Rollen-Migration fehlgeschlagen', e.message);
616
+ log(LVL.WARN, CAT.SYSTEM, 'State role migration failed', e.message);
599
617
  }
600
618
  }
601
619
 
@@ -676,7 +694,7 @@ function serveDiscoverJson(res) {
676
694
  async function handleNodeConfig(mac, body, res, clientIp) {
677
695
  let data;
678
696
  try { data = JSON.parse(body); } catch {
679
- sendJson(res, 400, { error: 'Ungültiges JSON' }); return;
697
+ sendJson(res, 400, { error: 'Invalid JSON' }); return;
680
698
  }
681
699
  const sid = (data.sid || '').trim();
682
700
  const label = (data.label || '').trim();
@@ -697,10 +715,10 @@ async function handleNodeConfig(mac, body, res, clientIp) {
697
715
  await adapter.setStateAsync(`nodes.${mac}.config`, { val: configStr, ack: true });
698
716
  if (!nodesCache[mac]) nodesCache[mac] = { mac };
699
717
  nodesCache[mac].config = configStr;
700
- 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 || '?'}`);
701
719
  sendJson(res, 200, { ok: true, mac, config });
702
720
  } catch (e) {
703
- log(LVL.ERROR, CAT.NODE, `Config-Fehler`, `${mac}: ${e.message}`);
721
+ log(LVL.ERROR, CAT.NODE, `Config error`, `${mac}: ${e.message}`);
704
722
  sendJson(res, 500, { error: e.message });
705
723
  }
706
724
  }
@@ -711,7 +729,7 @@ async function handleNodeConfig(mac, body, res, clientIp) {
711
729
  async function handleNodeCmd(mac, body, res) {
712
730
  let data;
713
731
  try { data = JSON.parse(body); } catch {
714
- sendJson(res, 400, { error: 'Ungültiges JSON' }); return;
732
+ sendJson(res, 400, { error: 'Invalid JSON' }); return;
715
733
  }
716
734
  const cmdStr = JSON.stringify(data);
717
735
  try {
@@ -720,10 +738,10 @@ async function handleNodeCmd(mac, body, res) {
720
738
  if (!nodesCache[mac]) nodesCache[mac] = { mac };
721
739
  nodesCache[mac].cmd = cmdStr;
722
740
  const keys = Object.keys(data).join(', ');
723
- log(LVL.INFO, CAT.NODE, `Befehl gesetzt`, `${mac} → ${keys}`);
741
+ log(LVL.INFO, CAT.NODE, `Command set`, `${mac} → ${keys}`);
724
742
  sendJson(res, 200, { ok: true, mac, cmd: data });
725
743
  } catch (e) {
726
- log(LVL.ERROR, CAT.NODE, `Cmd-Fehler`, `${mac}: ${e.message}`);
744
+ log(LVL.ERROR, CAT.NODE, `Cmd error`, `${mac}: ${e.message}`);
727
745
  sendJson(res, 500, { error: e.message });
728
746
  }
729
747
  }
@@ -734,7 +752,7 @@ async function handleNodeCmd(mac, body, res) {
734
752
  async function handleNodeRegister(body, res, clientIp) {
735
753
  let data;
736
754
  try { data = JSON.parse(body); } catch {
737
- sendJson(res, 400, { error: 'Ungültiges JSON' }); return;
755
+ sendJson(res, 400, { error: 'Invalid JSON' }); return;
738
756
  }
739
757
  const mac = (data.mac || '').replace(/[^A-Fa-f0-9]/g, '').toUpperCase();
740
758
  const ip = (data.ip || clientIp || '').trim();
@@ -742,7 +760,7 @@ async function handleNodeRegister(body, res, clientIp) {
742
760
  const version = (data.version || '').trim();
743
761
  const ack = data.configAck ? String(data.configAck) : null;
744
762
 
745
- if (!mac) { sendJson(res, 400, { error: 'Feld mac fehlt' }); return; }
763
+ if (!mac) { sendJson(res, 400, { error: 'Field mac missing' }); return; }
746
764
 
747
765
  const ts = Date.now();
748
766
  if (!nodesCache[mac]) nodesCache[mac] = { mac };
@@ -766,7 +784,7 @@ async function handleNodeRegister(body, res, clientIp) {
766
784
  const config = nodesCache[mac].config || null;
767
785
  sendJson(res, 200, { ok: true, mac, config });
768
786
  } catch (e) {
769
- log(LVL.ERROR, CAT.NODE, `Register-Fehler`, `${mac}: ${e.message}`);
787
+ log(LVL.ERROR, CAT.NODE, `Register error`, `${mac}: ${e.message}`);
770
788
  sendJson(res, 500, { error: e.message });
771
789
  }
772
790
  }
@@ -784,7 +802,7 @@ async function handleNodeAck(mac, body, res) {
784
802
  try {
785
803
  await ensureNodeStates(mac);
786
804
  await adapter.setStateAsync(`nodes.${mac}.configAck`, { val: ack, ack: true });
787
- log(LVL.INFO, CAT.NODE, `Config quittiert`, `${mac} | ack: ${ack}`);
805
+ log(LVL.INFO, CAT.NODE, `Config acknowledged`, `${mac} | ack: ${ack}`);
788
806
  sendJson(res, 200, { ok: true });
789
807
  } catch (e) {
790
808
  sendJson(res, 500, { error: e.message });
@@ -867,22 +885,6 @@ async function serveVersion(res) {
867
885
  }
868
886
  }
869
887
 
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
888
 
887
889
  function serveWebApp(res, port) {
888
890
 
@@ -937,7 +939,7 @@ const TYPE_ICONS = {
937
939
  };
938
940
 
939
941
  const html = `<!DOCTYPE html>
940
- <html lang="de">
942
+ <html lang="en">
941
943
  <head>
942
944
  <meta charset="UTF-8">
943
945
  <meta name="viewport" content="width=device-width,initial-scale=1">
@@ -1333,7 +1335,7 @@ input.search {
1333
1335
  <span class="logo-sub">ioBroker Adapter &nbsp;<span style="color:var(--primary);font-size:.95em;letter-spacing:.5px">v${CURRENT_VERSION}</span></span>
1334
1336
  </div>
1335
1337
  <div class="hstats">
1336
- <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>
1337
1339
  <div class="hstat"><span id="lbl-nodes">Nodes</span>: <b id="st-nodes">–</b></div>
1338
1340
  <div class="hstat"><span id="lbl-up">Uptime</span>: <b id="st-up">–</b></div>
1339
1341
  <div class="live-dot" id="st-live">● Live</div>
@@ -1346,7 +1348,7 @@ input.search {
1346
1348
 
1347
1349
  <!-- ══ NAV ═══════════════════════════════════════════════════════════════════ -->
1348
1350
  <nav>
1349
- <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>
1350
1352
  <button class="tab" id="tab-nodes" data-tab="nodes" onclick="showTab('nodes')" >📡 Nodes</button>
1351
1353
  <button class="tab" id="tab-import" data-tab="import" onclick="showTab('import')">📥 Import</button>
1352
1354
  <button class="tab" id="tab-logs" data-tab="logs" onclick="showTab('logs')" >📋 Logs</button>
@@ -1358,7 +1360,7 @@ input.search {
1358
1360
  <div id="data-container">
1359
1361
  <div class="empty-state">
1360
1362
  <div class="ico">📡</div>
1361
- <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>
1362
1364
  </div>
1363
1365
  </div>
1364
1366
  </div>
@@ -1367,15 +1369,15 @@ input.search {
1367
1369
  <div class="page" id="page-nodes">
1368
1370
  <div id="nodes-page-header" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:10px;">
1369
1371
  <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>
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>
1372
1374
  </div>
1373
- <button class="ghost" onclick="fetchNodes()">↻ Aktualisieren</button>
1375
+ <button class="ghost" id="nodes-refresh-btn" onclick="fetchNodes()">↻ Refresh</button>
1374
1376
  </div>
1375
1377
  <div id="nodes-container">
1376
1378
  <div class="empty-state">
1377
1379
  <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>
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>
1379
1381
  </div>
1380
1382
  </div>
1381
1383
  </div>
@@ -1383,8 +1385,8 @@ input.search {
1383
1385
  <!-- ══ IMPORT ════════════════════════════════════════════════════════════════ -->
1384
1386
  <div class="page" id="page-import">
1385
1387
  <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>
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>
1388
1390
 
1389
1391
  <div class="schema-box">{ "SchemaVersion": "2.0", "Source": "MeterMaster",
1390
1392
  "Apartments": [ { "Id": 1, "Name": "Westerheim" } ],
@@ -1393,24 +1395,24 @@ input.search {
1393
1395
  }</div>
1394
1396
 
1395
1397
  <div class="house-row">
1396
- <label>Hausname (ioBroker-Pfad):</label>
1397
- <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">
1398
1400
  </div>
1399
1401
 
1400
1402
  <div class="drop-zone" id="drop-zone" onclick="document.getElementById('file-in').click()">
1401
1403
  <div class="dz-ico">📂</div>
1402
- <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>
1403
1405
  </div>
1404
1406
  <input type="file" id="file-in" accept=".json">
1405
1407
 
1406
1408
  <div class="preview-box" id="preview-box">
1407
- <h4>📋 Vorschau</h4>
1409
+ <h4 id="import-preview-title">📋 Preview</h4>
1408
1410
  <div id="preview-content"></div>
1409
1411
  </div>
1410
1412
 
1411
1413
  <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>
1414
+ <button class="primary" id="imp-btn" disabled onclick="doImport()">⬆ Import</button>
1415
+ <button class="ghost" id="import-reset-btn" onclick="clearImport()">✕ Reset</button>
1414
1416
  </div>
1415
1417
  <div class="result-box" id="imp-result"></div>
1416
1418
  </div>
@@ -1420,14 +1422,14 @@ input.search {
1420
1422
  <div class="page" id="page-logs">
1421
1423
  <div class="log-toolbar">
1422
1424
  <select id="fl">
1423
- <option value="">Alle Level</option>
1425
+ <option value="" id="fl-all">All levels</option>
1424
1426
  <option value="debug">DEBUG</option>
1425
1427
  <option value="info">INFO</option>
1426
1428
  <option value="warn">WARN</option>
1427
1429
  <option value="error">ERROR</option>
1428
1430
  </select>
1429
1431
  <select id="fc">
1430
- <option value="">Alle Kategorien</option>
1432
+ <option value="" id="fc-all">All categories</option>
1431
1433
  <option value="SYSTEM">SYSTEM</option>
1432
1434
  <option value="AUTH">AUTH</option>
1433
1435
  <option value="CONNECT">CONNECT</option>
@@ -1437,13 +1439,13 @@ input.search {
1437
1439
  <option value="IMPORT">IMPORT</option>
1438
1440
  <option value="NODE">NODE</option>
1439
1441
  </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>
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>
1445
1447
  </div>
1446
- <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>
1447
1449
  </div>
1448
1450
 
1449
1451
 
@@ -1451,75 +1453,74 @@ input.search {
1451
1453
  <div class="page" id="page-system">
1452
1454
 
1453
1455
  <div class="sys-card">
1454
- <h3>📊 Statistiken</h3>
1456
+ <h3 id="sys-stats-title">📊 Statistics</h3>
1455
1457
  <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
1456
1458
  <div style="background:var(--bg-deep);border:1px solid var(--border);border-radius:9px;padding:12px 14px;">
1457
1459
  <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>
1460
+ <div id="sys-rx-label" style="font-size:.78em;color:var(--text-dim);margin-top:3px;">Total readings</div>
1459
1461
  </div>
1460
1462
  <div style="background:var(--bg-deep);border:1px solid var(--border);border-radius:9px;padding:12px 14px;">
1461
1463
  <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>
1464
+ <div id="sys-up-label" style="font-size:.78em;color:var(--text-dim);margin-top:3px;">Uptime</div>
1463
1465
  </div>
1464
1466
  <div style="background:var(--bg-deep);border:1px solid var(--border);border-radius:9px;padding:12px 14px;">
1465
1467
  <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>
1468
+ <div id="sys-online-label" style="font-size:.78em;color:var(--text-dim);margin-top:3px;">Nodes online</div>
1467
1469
  </div>
1468
1470
  <div style="background:var(--bg-deep);border:1px solid var(--border);border-radius:9px;padding:12px 14px;">
1469
1471
  <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>
1472
+ <div id="sys-total-label" style="font-size:.78em;color:var(--text-dim);margin-top:3px;">Total nodes</div>
1471
1473
  </div>
1472
1474
  </div>
1473
1475
  </div>
1474
1476
 
1475
1477
  <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>
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>
1480
1482
  <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>
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>
1484
1485
  </div>
1485
1486
  <div class="sys-out" id="sv-out"></div>
1486
1487
  </div>
1487
1488
 
1488
1489
  <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>
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>
1493
1494
  <a href="https://github.com/MPunktBPunkt/ioBroker.metermaster" target="_blank"
1494
1495
  style="color:var(--primary);font-size:.84em">GitHub ↗</a>
1495
1496
  </div>
1496
1497
  </div>
1497
1498
 
1498
1499
  <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:
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:
1502
1503
  </p>
1503
1504
  <div class="cmd-row">
1504
1505
  <code class="cmd-code">iobroker upgrade metermaster</code>
1505
- <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>
1506
1507
  </div>
1507
1508
  <div class="cmd-row" style="margin-top:8px">
1508
1509
  <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>
1510
+ <button class="cmd-copy" onclick="copyCmd(this)" data-cmd="iobroker restart metermaster.0" title="Copy">📋</button>
1510
1511
  </div>
1511
1512
  <div class="cmd-row" style="margin-top:8px">
1512
1513
  <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>
1514
+ <button class="cmd-copy" onclick="copyCmd(this)" data-cmd="sleep 5 && iobroker status metermaster.0" title="Copy">📋</button>
1514
1515
  </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.
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.
1517
1518
  </p>
1518
1519
  </div>
1519
1520
 
1520
1521
  </div>
1521
1522
 
1522
- <div id="ni" onclick="scrollLogBottom()">↓ Neue Einträge</div>
1523
+ <div id="ni" onclick="scrollLogBottom()">↓ New entries</div>
1523
1524
 
1524
1525
  <!-- ══ CHART-MODAL (vor Script — init() bindet Listener) ═════════════════════ -->
1525
1526
  <div id="chart-overlay" class="modal-overlay">
@@ -1550,14 +1551,14 @@ input.search {
1550
1551
  <!-- ══ LOGIN-MODAL ═══════════════════════════════════════════════════════════ -->
1551
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;">
1552
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%;">
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>
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>
1555
1556
  <div style="margin-bottom:12px;">
1556
- <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>
1557
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()">
1558
1559
  </div>
1559
1560
  <div style="margin-bottom:18px;">
1560
- <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>
1561
1562
  <div style="position:relative;">
1562
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()">
1563
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>
@@ -1565,8 +1566,8 @@ input.search {
1565
1566
  </div>
1566
1567
  <div id="login-msg" style="font-size:.82em;color:var(--danger);min-height:18px;margin-bottom:12px;"></div>
1567
1568
  <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>
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>
1570
1571
  </div>
1571
1572
  </div>
1572
1573
  </div>
@@ -1590,7 +1591,7 @@ function hideLoginModal() {
1590
1591
  async function doLogin() {
1591
1592
  const user = document.getElementById('login-user').value.trim();
1592
1593
  const pass = document.getElementById('login-pass').value;
1593
- if (!user) { document.getElementById('login-msg').textContent = 'Benutzername fehlt'; return; }
1594
+ if (!user) { document.getElementById('login-msg').textContent = t('login_missing_user'); return; }
1594
1595
  setAuth(user, pass);
1595
1596
  try {
1596
1597
  const r = await fetch('/api/ping', { headers: { 'Authorization': _authHeader } });
@@ -1598,17 +1599,17 @@ async function doLogin() {
1598
1599
  hideLoginModal();
1599
1600
  } else {
1600
1601
  _authHeader = null;
1601
- document.getElementById('login-msg').textContent = '❌ Falsche Zugangsdaten';
1602
+ document.getElementById('login-msg').textContent = '\u274C ' + t('login_wrong');
1602
1603
  }
1603
1604
  } catch(e) {
1604
- document.getElementById('login-msg').textContent = '❌ Verbindungsfehler';
1605
+ document.getElementById('login-msg').textContent = '\u274C ' + t('login_conn_err');
1605
1606
  }
1606
1607
  }
1607
1608
  async function authFetch(url, opts) {
1608
1609
  if (!_authHeader) { showLoginModal(); return null; }
1609
1610
  const headers = Object.assign({ 'Content-Type': 'application/json', 'Authorization': _authHeader }, opts.headers || {});
1610
1611
  const r = await fetch(url, Object.assign({}, opts, { headers }));
1611
- 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; }
1612
1613
  return r;
1613
1614
  }
1614
1615
 
@@ -1623,36 +1624,106 @@ const I18N = {
1623
1624
  readings:'Ablesungen', nodes:'Nodes', uptime:'Uptime', live:'Live', disconnected:'Getrennt',
1624
1625
  tab_data:'Daten', tab_nodes:'Nodes', tab_import:'Import', tab_logs:'Logs', tab_system:'System',
1625
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.',
1626
1628
  history:'Verlauf', since_last:'seit letzter Ablesung', days:'Tage', chart_btn:'Chart', csv_btn:'CSV',
1627
1629
  chart_readings:'Z\u00E4hlerstand', chart_monthly:'Monatsverbrauch', chart_period_all:'Alles',
1628
1630
  chart_yearly:'Pro Jahr', chart_yearly_proj:'Hochrechnung', chart_per_year:'/Jahr',
1629
1631
  chart_yearly_hint:'basierend auf {days} {daysLabel} ({delta} {unit})',
1630
1632
  chart_no_data:'Zu wenig Daten f\u00FCr eine Grafik (mind. 2 Ablesungen n\u00F6tig).',
1631
1633
  chart_close:'Schlie\u00DFen', error:'Fehler', consume:'Verbrauch',
1632
- 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'
1633
1669
  },
1634
1670
  en: {
1635
1671
  readings:'Readings', nodes:'Nodes', uptime:'Uptime', live:'Live', disconnected:'Disconnected',
1636
1672
  tab_data:'Data', tab_nodes:'Nodes', tab_import:'Import', tab_logs:'Logs', tab_system:'System',
1637
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.',
1638
1675
  history:'History', since_last:'since last reading', days:'days', chart_btn:'Chart', csv_btn:'CSV',
1639
1676
  chart_readings:'Meter reading', chart_monthly:'Monthly consumption', chart_period_all:'All',
1640
1677
  chart_yearly:'Per year', chart_yearly_proj:'Projected', chart_per_year:'/yr',
1641
1678
  chart_yearly_hint:'based on {days} {daysLabel} ({delta} {unit})',
1642
1679
  chart_no_data:'Not enough data for a chart (at least 2 readings required).',
1643
1680
  chart_close:'Close', error:'Error', consume:'Consumption',
1644
- 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'
1645
1716
  }
1646
1717
  };
1647
1718
 
1648
- let currentLang = 'de';
1719
+ let currentLang = 'en';
1649
1720
  try {
1650
1721
  const saved = localStorage.getItem('mm-lang');
1651
1722
  currentLang = saved || (navigator.language && navigator.language.startsWith('de') ? 'de' : 'en');
1652
- } catch { currentLang = 'de'; }
1723
+ } catch { currentLang = 'en'; }
1653
1724
 
1654
1725
  function t(key, vars) {
1655
- let s = (I18N[currentLang] && I18N[currentLang][key]) || (I18N.de[key]) || key;
1726
+ let s = (I18N[currentLang] && I18N[currentLang][key]) || (I18N.en[key]) || key;
1656
1727
  if (vars) Object.keys(vars).forEach(k => { s = s.replace('{'+k+'}', vars[k]); });
1657
1728
  return s;
1658
1729
  }
@@ -1663,9 +1734,59 @@ function applyI18n() {
1663
1734
  const ls = document.getElementById('lang-select');
1664
1735
  if (ls) ls.value = currentLang;
1665
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); };
1666
1739
  set('lbl-rx', 'readings');
1667
1740
  set('lbl-nodes', 'nodes');
1668
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');
1669
1790
  const tabs = { 'tab-data':'tab_data', 'tab-nodes':'tab_nodes', 'tab-import':'tab_import', 'tab-logs':'tab_logs', 'tab-system':'tab_system' };
1670
1791
  Object.keys(tabs).forEach(id => {
1671
1792
  const el = document.getElementById(id);
@@ -1690,6 +1811,8 @@ function setLang(lang) {
1690
1811
  applyI18n();
1691
1812
  fetchData();
1692
1813
  fetchStats();
1814
+ const nodesPage = document.getElementById('page-nodes');
1815
+ if (nodesPage && nodesPage.classList.contains('active')) fetchNodes();
1693
1816
  }
1694
1817
 
1695
1818
  let dataCacheList = [];
@@ -2085,11 +2208,11 @@ window.fetchNodes = async function fetchNodes() {
2085
2208
  const elTot = document.getElementById('nd-total'); if (elTot) elTot.textContent = total;
2086
2209
  const elOn = document.getElementById('nd-online'); if (elOn) elOn.textContent = online;
2087
2210
  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>';
2211
+ con.innerHTML = '<div class="empty-state"><div class="ico">\uD83D\uDCE1</div><p>' + t('no_nodes') + '</p></div>';
2089
2212
  return;
2090
2213
  }
2091
2214
  const buildOptions = (currentSid) => {
2092
- let opts = '<option value="">\u2014 Kein Z\u00E4hler zugewiesen \u2014</option>';
2215
+ let opts = '<option value="">' + esc(t('no_meter')) + '</option>';
2093
2216
  for (const m of discover) {
2094
2217
  const lbl = m.house + ' \u203A ' + m.apartment + ' \u203A ' + m.meter + (m.latest !== undefined ? ' (' + m.latest + ' ' + esc(m.unit) + ')' : '');
2095
2218
  opts += '<option value="'+esc(m.stateId)+'"'+(m.stateId===currentSid?' selected':'')+'>'+esc(lbl)+'</option>';
@@ -2100,13 +2223,13 @@ window.fetchNodes = async function fetchNodes() {
2100
2223
  html += '<table style="width:100%;border-collapse:collapse;font-size:.85em;">';
2101
2224
  html += '<thead><tr style="text-align:left;">';
2102
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>';
2103
- 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'));
2104
2227
  html += '</tr></thead><tbody>';
2105
2228
  for (const n of nodes) {
2106
2229
  let currentSid = '';
2107
2230
  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>';
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>';
2110
2233
  const badge = n.online ? onBadge : offBadge;
2111
2234
  const ackHint = n.configAck ? '<div style="font-size:.75em;color:var(--text-muted);margin-top:3px;">\u2713 Ack</div>' : '';
2112
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';
@@ -2117,11 +2240,11 @@ window.fetchNodes = async function fetchNodes() {
2117
2240
  html += td(ipCell);
2118
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>');
2119
2242
  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
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
2122
2245
  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>'
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>'
2125
2248
  +'<span id="ledmsg-'+esc(n.mac)+'" style="font-size:.75em;min-height:14px;"></span>'
2126
2249
  +'</div>');
2127
2250
  html += '</tr>';
@@ -2129,7 +2252,7 @@ window.fetchNodes = async function fetchNodes() {
2129
2252
  html += '</tbody></table></div>';
2130
2253
  con.innerHTML = html;
2131
2254
  } catch(e) {
2132
- 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>';
2133
2256
  }
2134
2257
  }
2135
2258
 
@@ -2149,10 +2272,10 @@ window.saveNodeConfig = async function saveNodeConfig(mac) {
2149
2272
  if (!r) { btn.disabled = false; return; }
2150
2273
  const d = await r.json();
2151
2274
  if (d.ok) {
2152
- 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>';
2153
2276
  window.setTimeout(() => { msg.textContent = ''; }, 3000);
2154
2277
  } else {
2155
- 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>';
2156
2279
  }
2157
2280
  } catch(e) {
2158
2281
  msg.innerHTML = '<span style="color:var(--danger);font-size:.82em;">\u2717 '+esc(e.message)+'</span>';
@@ -2191,7 +2314,7 @@ function loadFile(file) {
2191
2314
  const r = new FileReader();
2192
2315
  r.onload = e => {
2193
2316
  try { importData = JSON.parse(e.target.result); showPreview(importData, file.name); }
2194
- catch { showResult('err', '\u274C Ung\u00FCltige JSON-Datei'); }
2317
+ catch { showResult('err', '\u274C ' + t('invalid_json')); }
2195
2318
  };
2196
2319
  r.readAsText(file);
2197
2320
  }
@@ -2199,12 +2322,12 @@ function loadFile(file) {
2199
2322
  function showPreview(d, fname) {
2200
2323
  const valid = !!(d.Apartments && d.Meters && d.Readings);
2201
2324
  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');
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'));
2208
2331
  document.getElementById('preview-box').style.display = 'block';
2209
2332
  document.getElementById('imp-btn').disabled = !valid;
2210
2333
  }
@@ -2212,20 +2335,20 @@ const prow = (l,v) => '<div class="preview-row"><span>'+l+'</span><b>'+v+'</b></
2212
2335
 
2213
2336
  window.doImport = async function doImport() {
2214
2337
  if (!importData) return;
2215
- const house = document.getElementById('imp-house').value.trim() || 'MeinHaus';
2338
+ const house = document.getElementById('imp-house').value.trim() || 'MyHouse';
2216
2339
  const btn = document.getElementById('imp-btn');
2217
- btn.disabled = true; btn.textContent = '\u23F3 Importiere\u2026';
2340
+ btn.disabled = true; btn.textContent = '\u23F3 ' + t('import_importing');
2218
2341
  try {
2219
2342
  const r = await authFetch('/api/import', {
2220
2343
  method: 'POST',
2221
2344
  body: JSON.stringify({...importData, HouseName: house})
2222
2345
  });
2223
- if (!r) { btn.disabled = false; btn.textContent = '⬆ Importieren'; return; }
2346
+ if (!r) { btn.disabled = false; btn.textContent = '\u2B06 ' + t('import_btn'); return; }
2224
2347
  const d = await r.json();
2225
2348
  if (d.ok) { showResult('ok', '\u2705 '+d.summary); fetchData(); fetchStats(); }
2226
2349
  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'; }
2350
+ } catch(e) { showResult('err', '\u274C ' + t('network_err') + ': '+esc(e.message)); }
2351
+ finally { btn.disabled = false; btn.textContent = '\u2B06 ' + t('import_btn'); }
2229
2352
  }
2230
2353
  function showResult(type, msg) {
2231
2354
  const rb = document.getElementById('imp-result');
@@ -2324,56 +2447,27 @@ function startLive() {
2324
2447
  async function checkVersion() {
2325
2448
  const btn = document.getElementById('sv-check-btn');
2326
2449
  const spin = document.getElementById('sv-spin');
2327
- const updBtn = document.getElementById('sv-upd-btn');
2328
2450
  btn.disabled = true; spin.style.display = 'inline';
2329
2451
  try {
2330
2452
  const d = await fetch('/api/version').then(r => r.json());
2331
2453
  document.getElementById('sv-cur').textContent = d.current || '\u2013';
2332
- 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'));
2333
2455
  const st = document.getElementById('sv-status');
2334
2456
  if (d.error) {
2335
- st.innerHTML = '<span class="badge-err">\u26A0 GitHub nicht erreichbar</span>';
2336
- updBtn.style.display = 'none';
2457
+ st.innerHTML = '<span class="badge-err">\u26A0 ' + t('sys_github_err') + '</span>';
2337
2458
  } else if (!d.latest) {
2338
- st.innerHTML = '<span class="badge-warn">\u2139 Kein GitHub-Release vorhanden</span>';
2339
- updBtn.style.display = 'none';
2459
+ st.innerHTML = '<span class="badge-warn">\u2139 ' + t('sys_no_github_release') + '</span>';
2340
2460
  } else if (d.updateAvailable) {
2341
- st.innerHTML = '<span class="badge-new">\uD83C\uDD95 Update verf\u00FCgbar</span>';
2342
- updBtn.style.display = '';
2461
+ st.innerHTML = '<span class="badge-new">\uD83C\uDD95 ' + t('sys_update_avail') + '</span>';
2343
2462
  } else {
2344
- st.innerHTML = '<span class="badge-ok">\u2713 Aktuell</span>';
2345
- updBtn.style.display = 'none';
2463
+ st.innerHTML = '<span class="badge-ok">\u2713 ' + t('sys_up_to_date') + '</span>';
2346
2464
  }
2347
2465
  } catch(e) {
2348
- 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>';
2349
2467
  }
2350
2468
  btn.disabled = false; spin.style.display = 'none';
2351
2469
  }
2352
2470
 
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
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
2378
2472
  // ── Copy-to-Clipboard ─────────────────────────────────────────────────────────
2379
2473
  window.copyCmd = function copyCmd(btn) {
@@ -2416,10 +2510,8 @@ function initTabs() {
2416
2510
  // System-Tab Buttons
2417
2511
  const chkBtn = document.getElementById('sv-check-btn');
2418
2512
  if (chkBtn) chkBtn.addEventListener('click', checkVersion);
2419
- const updBtn = document.getElementById('sv-upd-btn');
2420
- if (updBtn) updBtn.addEventListener('click', doUpdate);
2421
2513
 
2422
- // Verlauf-Toggle via Event Delegation (kein onclick-Attribut nötig)
2514
+ // History toggle via event delegation
2423
2515
  document.addEventListener('click', e => {
2424
2516
  const btn = e.target.closest('.mc-hist-toggle');
2425
2517
  if (btn && btn.dataset.hist) toggleHist(btn.dataset.hist);