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/README.md +15 -5
- package/io-package.json +14 -1
- package/main.js +331 -239
- package/package.json +2 -2
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
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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 (
|
|
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 || '
|
|
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
|
|
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
|
|
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
|
|
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
|
|
206
|
-
else log(LVL.DEBUG, CAT.NODE, '
|
|
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
|
|
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
|
|
218
|
-
await ensureState(`${base}.name`, { name: '
|
|
219
|
-
await ensureState(`${base}.version`, { name: 'Firmware
|
|
220
|
-
await ensureState(`${base}.lastSeen`, { name: '
|
|
221
|
-
await ensureState(`${base}.config`, { name: '
|
|
222
|
-
await ensureState(`${base}.configAck`, { name: 'Config
|
|
223
|
-
await ensureState(`${base}.cmd`, { name: '
|
|
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
|
|
229
|
-
const
|
|
230
|
-
const
|
|
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
|
|
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, `
|
|
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: '
|
|
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, `
|
|
295
|
-
res.writeHead(403); res.end(JSON.stringify({ error: '
|
|
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, `
|
|
316
|
-
res.writeHead(404); res.end(JSON.stringify({ error: '
|
|
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
|
|
324
|
-
err.code === 'EADDRINUSE' ? `Port ${port}
|
|
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, `
|
|
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 '
|
|
341
|
-
if (!data.house || typeof data.house !== 'string') return '
|
|
342
|
-
if (!data.apartment || typeof data.apartment !== 'string') return '
|
|
343
|
-
if (!data.meter || typeof data.meter !== 'string') return '
|
|
344
|
-
if (data.value === undefined || data.value === null) return '
|
|
345
|
-
if (isNaN(parseFloat(data.value))) return 'value
|
|
346
|
-
if (!data.readingDate) return '
|
|
347
|
-
if (isNaN(new Date(data.readingDate).getTime())) return 'readingDate:
|
|
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, `
|
|
356
|
-
sendJson(res, 400, { error: '
|
|
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, `
|
|
360
|
-
log(LVL.INFO, CAT.SYNC, `
|
|
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, `
|
|
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, `
|
|
376
|
-
sendJson(res, 400, { error: 'Array
|
|
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
|
|
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
|
|
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}
|
|
388
|
-
if (failed === 0) log(LVL.INFO, CAT.SYNC, `Batch
|
|
389
|
-
else log(LVL.WARN, CAT.SYNC, `Batch
|
|
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, `
|
|
398
|
-
sendJson(res, 400, { error: '
|
|
414
|
+
log(LVL.WARN, CAT.IMPORT, `Invalid JSON`, `IP: ${clientIp}`);
|
|
415
|
+
sendJson(res, 400, { error: 'Invalid JSON' }); return;
|
|
399
416
|
}
|
|
400
417
|
|
|
401
|
-
// Schema
|
|
418
|
+
// Schema validation
|
|
402
419
|
if (!data.Apartments || !data.Meters || !data.Readings) {
|
|
403
|
-
sendJson(res, 422, { error: '
|
|
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 ||
|
|
408
|
-
log(LVL.INFO, CAT.IMPORT, `Import
|
|
409
|
-
`Schema: ${schema} | ${data.Apartments.length}
|
|
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, `
|
|
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, `
|
|
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, `
|
|
471
|
+
log(LVL.ERROR, CAT.IMPORT, `Storage error`, `${meter.Name}: ${e.message}`);
|
|
455
472
|
}
|
|
456
473
|
}
|
|
457
474
|
|
|
458
|
-
const summary = `${stored}
|
|
459
|
-
if (failed === 0) log(LVL.INFO, CAT.IMPORT, `Import
|
|
460
|
-
else log(LVL.WARN, CAT.IMPORT, `Import
|
|
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`, '
|
|
494
|
+
await ensureChannel(`${base}.readings`, 'Readings');
|
|
478
495
|
|
|
479
|
-
if (isNew) log(LVL.INFO, CAT.DATAPOINT, `
|
|
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} –
|
|
482
|
-
await ensureState(`${base}.readings.latestDate`, { name: '
|
|
483
|
-
await ensureState(`${base}.name`, { name: '
|
|
484
|
-
await ensureState(`${base}.unit`, { name: '
|
|
485
|
-
await ensureState(`${base}.typeName`, { name: '
|
|
486
|
-
await ensureState(`${base}.readings.history`, { name: '
|
|
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, `
|
|
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
|
|
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, `
|
|
500
|
-
if (histResult === 'duplicate') log(LVL.DEBUG, CAT.HISTORY, `
|
|
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 =
|
|
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
|
|
587
|
-
if (!
|
|
603
|
+
const allObjects = await adapter.getAdapterObjectsAsync();
|
|
604
|
+
if (!allObjects) return;
|
|
588
605
|
let fixed = 0;
|
|
589
|
-
for (const [id, obj] of Object.entries(
|
|
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
|
|
614
|
+
if (fixed) log(LVL.INFO, CAT.SYSTEM, 'State roles migrated', `${fixed} objects`);
|
|
597
615
|
} catch (e) {
|
|
598
|
-
log(LVL.WARN, CAT.SYSTEM, 'State
|
|
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: '
|
|
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
|
|
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
|
|
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: '
|
|
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, `
|
|
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
|
|
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: '
|
|
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: '
|
|
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
|
|
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
|
|
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="
|
|
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 <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">
|
|
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')" >📊
|
|
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>
|
|
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;">📡
|
|
1371
|
-
<div style="font-size:.82em;color:var(--text-dim);">
|
|
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> | <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()">↻
|
|
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>
|
|
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
|
|
1387
|
-
<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>
|
|
1397
|
-
<input type="text" id="imp-house" value="
|
|
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
|
|
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>📋
|
|
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()">⬆
|
|
1413
|
-
<button class="ghost" onclick="clearImport()">✕
|
|
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="">
|
|
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="">
|
|
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="
|
|
1441
|
-
<button class="ghost" onclick="clearLogs()">🗑
|
|
1442
|
-
<button class="ghost" onclick="exportLogs()">⬇ Export</button>
|
|
1443
|
-
<label class="lbl"><input type="checkbox" id="as" checked> Auto-
|
|
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">
|
|
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>📊
|
|
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;">
|
|
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;">
|
|
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
|
|
1477
|
-
<div class="ver-row"><span class="ver-label">
|
|
1478
|
-
<div class="ver-row"><span class="ver-label">
|
|
1479
|
-
<div class="ver-row"><span class="ver-label">Status</span> <span id="sv-status"><span class="badge-warn">
|
|
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">🔍
|
|
1482
|
-
<
|
|
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
|
|
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
|
|
1500
|
-
<p style="font-size:.82em;color:var(--text-dim);margin:0 0 12px">
|
|
1501
|
-
|
|
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="
|
|
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="
|
|
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 && iobroker status metermaster.0</code>
|
|
1513
|
-
<button class="cmd-copy" onclick="copyCmd(this)" data-cmd="sleep 5 && iobroker status metermaster.0" title="
|
|
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
|
-
💡
|
|
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()">↓
|
|
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;">🔑
|
|
1554
|
-
<div style="font-size:.82em;color:var(--text-dim);margin-bottom:20px;">
|
|
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;">
|
|
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;">
|
|
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;">✓
|
|
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 = '
|
|
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 = '
|
|
1602
|
+
document.getElementById('login-msg').textContent = '\u274C ' + t('login_wrong');
|
|
1602
1603
|
}
|
|
1603
1604
|
} catch(e) {
|
|
1604
|
-
document.getElementById('login-msg').textContent = '
|
|
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('
|
|
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 = '
|
|
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 = '
|
|
1723
|
+
} catch { currentLang = 'en'; }
|
|
1653
1724
|
|
|
1654
1725
|
function t(key, vars) {
|
|
1655
|
-
let s = (I18N[currentLang] && I18N[currentLang][key]) || (I18N.
|
|
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>
|
|
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=""
|
|
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('
|
|
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>
|
|
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>
|
|
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
|
|
2121
|
-
// LED
|
|
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="
|
|
2124
|
-
+'<button class="ledBtn" data-mac="'+esc(n.mac)+'" data-led="0" title="
|
|
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>
|
|
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
|
|
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||'
|
|
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
|
|
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('
|
|
2203
|
-
prow('
|
|
2204
|
-
prow('
|
|
2205
|
-
prow('
|
|
2206
|
-
prow('
|
|
2207
|
-
prow('
|
|
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() || '
|
|
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
|
|
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 = '
|
|
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
|
|
2228
|
-
finally { btn.disabled = false; btn.textContent = '\u2B06
|
|
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 ? '(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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);
|