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