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