1035 lines
40 KiB
JavaScript
1035 lines
40 KiB
JavaScript
// ====================================================================
|
||
// tth_v3_api.js – indexV3.htm OHNE AWP, ueber die S7-1500 Web-API.
|
||
//
|
||
// - Automatische Anmeldung (Api.Login) mit hinterlegtem Benutzer -> Token.
|
||
// - Status zyklisch per Batch "PlcProgram.Read" (ein POST je Zyklus).
|
||
// - Schreiben per "PlcProgram.Write" direkt beim Klick (keine Forms/AWP).
|
||
// - Tacho + Leistungschart wie gehabt selbst auf Canvas (kein Internet).
|
||
//
|
||
// ANMELDUNG: Hat der Benutzer "Jeder" (anonym) auf der CPU das Recht
|
||
// "Variablen lesen/schreiben", ist KEIN Login noetig -> user leer lassen.
|
||
// Fuer einen benannten Benutzer stattdessen user/password setzen.
|
||
// ====================================================================
|
||
|
||
(function () {
|
||
'use strict';
|
||
|
||
// ====== KONFIG ======
|
||
var CFG = {
|
||
url: '/api/jsonrpc', // relativer Pfad -> selbe CPU/Origin
|
||
user: '111', // '' = anonym ueber "Jeder" (kein Login)
|
||
password: '1q2w3e$R',
|
||
readInterval: 500 // ms zwischen Status-Abfragen
|
||
};
|
||
|
||
var ANON = !CFG.user; // true => keine Anmeldung, kein Token
|
||
var token = null;
|
||
var rid = 0;
|
||
var statusEl = null;
|
||
|
||
// ---- Anzeige Verbindungsstatus ------------------------------------
|
||
function setStatus(connected, text) {
|
||
if (!statusEl) { statusEl = document.getElementById('api_status'); }
|
||
if (!statusEl) { return; }
|
||
statusEl.textContent = text;
|
||
statusEl.style.color = connected ? 'var(--v3-accent-2)' : 'var(--v3-danger)';
|
||
}
|
||
|
||
// ---- Low-Level: ein POST (Einzel- oder Batch-Request) -------------
|
||
function doCall(batch) {
|
||
var headers = { 'Content-Type': 'application/json' };
|
||
if (token) { headers['X-Auth-Token'] = token; }
|
||
return fetch(CFG.url, { method: 'POST', headers: headers, body: JSON.stringify(batch) })
|
||
.then(function (r) {
|
||
if (r.status === 401 || r.status === 403) { token = null; throw new Error('unauthorized'); }
|
||
return r.json();
|
||
})
|
||
.then(function (j) { return Array.isArray(j) ? j : [j]; });
|
||
}
|
||
|
||
function login() {
|
||
token = null;
|
||
return doCall([{ jsonrpc: '2.0', id: ++rid, method: 'Api.Login',
|
||
params: { user: CFG.user, password: CFG.password } }])
|
||
.then(function (arr) {
|
||
var res = arr[0];
|
||
if (res && res.result && res.result.token) {
|
||
token = res.result.token;
|
||
return token;
|
||
}
|
||
throw new Error('Login fehlgeschlagen');
|
||
});
|
||
}
|
||
|
||
// Anonym: direkt aufrufen. Sonst: Token sicherstellen, bei Auth-Fehler 1x neu anmelden.
|
||
function withAuth(fn) {
|
||
if (ANON) { return fn(); }
|
||
return (token ? Promise.resolve() : login())
|
||
.then(fn)
|
||
.catch(function (e) {
|
||
// einmaliger Wiederanmelde-Versuch
|
||
return login().then(fn);
|
||
});
|
||
}
|
||
|
||
// ---- Mehrere Variablen lesen (chunked Batch) ----------------------
|
||
function readMany(vars) {
|
||
return withAuth(function () {
|
||
var idMap = {};
|
||
var reqs = vars.map(function (v) {
|
||
var id = ++rid; idMap[id] = v;
|
||
return { jsonrpc: '2.0', id: id, method: 'PlcProgram.Read', params: { var: v, mode: 'simple' } };
|
||
});
|
||
var chunks = [];
|
||
for (var i = 0; i < reqs.length; i += 24) { chunks.push(reqs.slice(i, i + 24)); }
|
||
return Promise.all(chunks.map(doCall)).then(function (res) {
|
||
var flat = [].concat.apply([], res);
|
||
var out = {};
|
||
flat.forEach(function (r) {
|
||
if (r && r.id != null) { out[idMap[r.id]] = (r.error ? null : r.result); }
|
||
});
|
||
return out;
|
||
});
|
||
});
|
||
}
|
||
|
||
// ---- Eine Variable schreiben --------------------------------------
|
||
function write(varName, value) {
|
||
varName = String(varName).trim();
|
||
return withAuth(function () {
|
||
return doCall([{ jsonrpc: '2.0', id: ++rid, method: 'PlcProgram.Write',
|
||
params: { var: varName, value: value, mode: 'simple' } }]);
|
||
}).then(function (arr) {
|
||
var r = arr[0];
|
||
if (r && r.error) {
|
||
console.warn('Write-Fehler', varName,
|
||
'code=' + r.error.code, 'message=' + r.error.message,
|
||
JSON.stringify(r.error));
|
||
}
|
||
return r;
|
||
}).catch(function (e) { console.warn('Write-Ausnahme', varName, e); });
|
||
}
|
||
|
||
// S7-String aus raw-Bytes dekodieren: [maxLen, curLen, char0, char1, ...]
|
||
function decodeS7String(raw) {
|
||
if (!Array.isArray(raw) || raw.length < 2) { return ''; }
|
||
var len = raw[1];
|
||
var s = '';
|
||
for (var i = 0; i < len && (2 + i) < raw.length; i++) { s += String.fromCharCode(raw[2 + i]); }
|
||
return s;
|
||
}
|
||
|
||
// Wemos-String separat im raw-Modus lesen (simple liefert null wegen Steuerzeichen)
|
||
function readWemos() {
|
||
return withAuth(function () {
|
||
return doCall([{ jsonrpc: '2.0', id: ++rid, method: 'PlcProgram.Read',
|
||
params: { var: METER.wemos, mode: 'raw' } }]);
|
||
}).then(function (arr) {
|
||
var r = arr[0];
|
||
var txt = (r && r.result) ? decodeS7String(r.result) : '';
|
||
txt = txt.replace(/[\r\n\t]+/g, ' ').trim(); // Steuerzeichen fuer die Anzeige bereinigen
|
||
setText('sz_wemos', txt === '' ? '–' : txt);
|
||
}).catch(function () {});
|
||
}
|
||
|
||
// ====== Variablen-Zuordnung (Symbolik wie in den AWP-State-Dateien) =
|
||
|
||
// Licht: [Status-Punkt-ID, Lese-Variable, Schreib-Variable]
|
||
var LIGHTS = [
|
||
['li_dot_1', '"Licht Küche"', '"Lichtmerker Küche"'],
|
||
['li_dot_2', '"Licht Essen"', '"Lichtmerker Essen"'],
|
||
['li_dot_3', '"Licht Wohnen 1"', '"Lichtmerker Wohnen 1"'],
|
||
['li_dot_4', '"Licht Wohnen 2"', '"Lichtmerker Wohnen 2"'],
|
||
['li_dot_5', '"Licht Arbeiten"', '"Lichtmerker Arbeiten"'],
|
||
['li_dot_6', '"Licht HWR"', '"Lichtmerker HWR"'],
|
||
['li_dot_7', '"Licht Niko"', '"Lichtmerker Niko"'],
|
||
['li_dot_8', '"Licht Dusche"', '"Lichtmerker Dusche"'],
|
||
['li_dot_9', '"Licht Abstellraum"', '"Lichtmerker Abstellraum"'],
|
||
['li_dot_10', '"Licht Eltern"', '"Lichtmerker Eltern"'],
|
||
['li_dot_11', '"Licht Noah"', '"Lichtmerker Noah"'],
|
||
['li_dot_12', '"Licht Bad 2"', '"Lichtmerker Bad"'],
|
||
['li_dot_13', '"Licht Kind 3"', '"Lichtmerker Kind 3"'],
|
||
['li_dot_14', '"Licht Deele"', '"Kickertaste"']
|
||
];
|
||
|
||
// Heizung: {i, temp, active(heizt), min, max}
|
||
var HEAT = [
|
||
{ i: 1, temp: '"Temp Wohnen"', act: '"Heizung Essen Küche Wohn"', min: '"Einstellungen".Heizung."Heizung Wohnen min"', max: '"Einstellungen".Heizung."Heizung Wohnen max"' },
|
||
{ i: 2, temp: '"Temp Arbeiten"', act: '"Heizung Arbeiten"', min: '"Einstellungen".Heizung."Heizung Arbeiten min"', max: '"Einstellungen".Heizung."Heizung Arbeiten max"' },
|
||
{ i: 3, temp: '"Temp HWR"', act: '"Heizung HWR"', min: '"Einstellungen".Heizung."Heizung HWR min"', max: '"Einstellungen".Heizung."Heizung HWR max"' },
|
||
{ i: 4, temp: '"Temp Bad"', act: '"Heizung Bad"', min: '"Einstellungen".Heizung."Heizung Bad min"', max: '"Einstellungen".Heizung."Heizung Bad max"' },
|
||
{ i: 5, temp: '"Temp Eltern"', act: '"Heizung Eltern"', min: '"Einstellungen".Heizung."Heizung Eltern min"', max: '"Einstellungen".Heizung."Heizung Eltern max"' },
|
||
{ i: 6, temp: '"Temp Noah"', act: '"Heizung Noah"', min: '"Einstellungen".Heizung."Heizung Kind1 min"', max: '"Einstellungen".Heizung."Heizung Kind1 max"' },
|
||
{ i: 7, temp: '"Temp Mika"', act: '"Heizung Mika"', min: '"Einstellungen".Heizung."Heizung Kind2 min"', max: '"Einstellungen".Heizung."Heizung Kind2 max"' },
|
||
{ i: 8, temp: '"Temp Niko"', act: '"Heizung Niko"', min: '"Einstellungen".Heizung."Heizung Gäste min"', max: '"Einstellungen".Heizung."Heizung Gäste max"' },
|
||
{ i: 9, temp: '"Temp Dusche"', act: '"Heizung Gäste Dusche"', min: '"Einstellungen".Heizung."Heizung Dusche min"', max: '"Einstellungen".Heizung."Heizung Dusche max"' }
|
||
];
|
||
|
||
// Steckdosen: [Punkt-ID, Status-Variable] (Status 0 = AN)
|
||
var SOCKETS = [
|
||
['sd_dot_1', '"Steckdose Deele Garder"'],
|
||
['sd_dot_2', '"Steckdose Wohnen"'],
|
||
['sd_dot_3', '"Steckdose Essen"'],
|
||
['sd_dot_4', '"Steckdose Arbeiten"'],
|
||
['sd_dot_5', '"Steckdose Kind 1"'],
|
||
['sd_dot_6', '"Steckdose Kind 2"'],
|
||
['sd_dot_7', '"Steckdose Gäste"'],
|
||
['sd_dot_8', '"Steckdose Eltern"']
|
||
];
|
||
|
||
// Poolpumpe: Laufzeit-Zähler (Sekunden pro Tag) + SOLL-Laufzeit (Sek/Tag)
|
||
var POOL = {
|
||
sek: '"Funksteckdosen_DB"."Poolpumpenzähler Sek/Tag"',
|
||
max: '"Funksteckdosen_DB"."Poolpumpe min Laufzeit/Tag"'
|
||
};
|
||
|
||
var METER = {
|
||
bezug: '"Stromzähler".Stromzähler."Zählerstand Bezug"',
|
||
einsp: '"Stromzähler".Stromzähler."Zählerstand Einspeisung"',
|
||
leist: '"Stromzähler".Stromzähler."aktuelle Leistung"',
|
||
tag: '"Stromzähler".Stromzähler.Aufzeichnung."täglicher Bezug"',
|
||
wemos: '"Buffer"."Wemos RC".Senden',
|
||
hist: [
|
||
'"Stromzähler".Stromzähler.Aufzeichnung.Zählerstand[0]."Zählerstand Bezug"',
|
||
'"Stromzähler".Stromzähler.Aufzeichnung.Zählerstand[1]."Zählerstand Bezug"',
|
||
'"Stromzähler".Stromzähler.Aufzeichnung.Zählerstand[2]."Zählerstand Bezug"',
|
||
'"Stromzähler".Stromzähler.Aufzeichnung.Zählerstand[3]."Zählerstand Bezug"',
|
||
'"Stromzähler".Stromzähler.Aufzeichnung.Zählerstand[4]."Zählerstand Bezug"',
|
||
'"Stromzähler".Stromzähler.Aufzeichnung.Zählerstand[5]."Zählerstand Bezug"',
|
||
'"Stromzähler".Stromzähler.Aufzeichnung.Zählerstand[6]."Zählerstand Bezug"',
|
||
'"Stromzähler".Stromzähler.Aufzeichnung.Zählerstand[7]."Zählerstand Bezug"'
|
||
]
|
||
};
|
||
|
||
var WATER = {
|
||
stand: '"Wasserzähler".Zählerstand[0].aktueller_Stand',
|
||
seit: '"Wasserzähler".Zählerstand[0].seit_letzter_Ablesung',
|
||
wtemp: '"Wasserzähler".Zählerstand[0].Wassertemperatur',
|
||
tag: '"Wasserzähler"."täglicher Bezug"',
|
||
hist: [
|
||
'"Wasserzähler".Zählerstand[0].aktueller_Stand',
|
||
'"Wasserzähler".Zählerstand[1].aktueller_Stand',
|
||
'"Wasserzähler".Zählerstand[2].aktueller_Stand',
|
||
'"Wasserzähler".Zählerstand[3].aktueller_Stand',
|
||
'"Wasserzähler".Zählerstand[4].aktueller_Stand',
|
||
'"Wasserzähler".Zählerstand[5].aktueller_Stand',
|
||
'"Wasserzähler".Zählerstand[6].aktueller_Stand',
|
||
'"Wasserzähler".Zählerstand[7].aktueller_Stand'
|
||
]
|
||
};
|
||
|
||
var ZEIT = {
|
||
y: '"Aufgang_Untergang".aktuelle_CPU_Zeit.YEAR',
|
||
mo: '"Aufgang_Untergang".aktuelle_CPU_Zeit.MONTH',
|
||
d: '"Aufgang_Untergang".aktuelle_CPU_Zeit.DAY',
|
||
h: '"Aufgang_Untergang".aktuelle_CPU_Zeit.HOUR',
|
||
mi: '"Aufgang_Untergang".aktuelle_CPU_Zeit.MINUTE',
|
||
s: '"Aufgang_Untergang".aktuelle_CPU_Zeit.SECOND',
|
||
aufH: '"Aufgang_Untergang".Aufgang.HOUR',
|
||
aufMi: '"Aufgang_Untergang".Aufgang.MINUTE',
|
||
untH: '"Aufgang_Untergang".Untergang.HOUR',
|
||
untMi: '"Aufgang_Untergang".Untergang.MINUTE',
|
||
urlaub: '"Jalousie_DB".Urlaub'
|
||
};
|
||
|
||
// Liste aller zu lesenden Variablen zusammenbauen
|
||
function allReadVars() {
|
||
var v = [];
|
||
LIGHTS.forEach(function (l) { v.push(l[1]); });
|
||
HEAT.forEach(function (h) { v.push(h.temp, h.act); }); // min/max separat (selten)
|
||
SOCKETS.forEach(function (s) { v.push(s[1]); });
|
||
v.push(POOL.sek, POOL.max);
|
||
v.push(METER.bezug, METER.einsp, METER.leist, METER.tag);
|
||
METER.hist.forEach(function (x) { v.push(x); });
|
||
v.push(WATER.stand, WATER.seit, WATER.wtemp, WATER.tag);
|
||
WATER.hist.forEach(function (x) { v.push(x); });
|
||
Object.keys(ZEIT).forEach(function (k) { v.push(ZEIT[k]); });
|
||
return v;
|
||
}
|
||
|
||
// Heizungs-Grenzen (aendern sich fast nie) – separat, nur selten gelesen.
|
||
function settingsVars() {
|
||
var v = [];
|
||
HEAT.forEach(function (h) { v.push(h.min, h.max); });
|
||
return v;
|
||
}
|
||
|
||
// ====== Hilfen ======================================================
|
||
function on(v) { return v === true || v === 1 || v === '1'; }
|
||
function num(v) { var n = parseFloat(v); return isNaN(n) ? null : n; }
|
||
function pad(n) { n = parseInt(n, 10); if (isNaN(n)) { return '--'; } return n < 10 ? '0' + n : '' + n; }
|
||
|
||
function setText(id, value) { var el = document.getElementById(id); if (el) { el.textContent = value; } }
|
||
function setDot(id, isOn, kind) {
|
||
var el = document.getElementById(id);
|
||
if (!el) { return; }
|
||
el.classList.remove('v3-status-dot--on', 'v3-status-dot--off', 'v3-status-dot--active');
|
||
el.classList.add(isOn ? (kind === 'active' ? 'v3-status-dot--active' : 'v3-status-dot--on') : 'v3-status-dot--off');
|
||
}
|
||
function tempClass(t) {
|
||
if (t === null) { return 'v3-temp--normal'; }
|
||
if (t >= 24) { return 'v3-temp--hot'; }
|
||
if (t >= 22) { return 'v3-temp--warm'; }
|
||
return 'v3-temp--normal';
|
||
}
|
||
|
||
// Sekunden -> "X h Y min" (unter 1 Stunde nur "Y min")
|
||
function formatStdMin(sek) {
|
||
if (sek === null || !isFinite(sek)) { return '–'; }
|
||
var min = Math.round(sek / 60);
|
||
var h = Math.floor(min / 60);
|
||
var restMin = min % 60;
|
||
return (h > 0) ? (h + ' h ' + restMin + ' min') : (restMin + ' min');
|
||
}
|
||
|
||
// ====== Rendern =====================================================
|
||
var powerHistory = [];
|
||
var gaugeTarget = 0, gaugeShown = 0;
|
||
var prevTemps = {}, arrowState = {};
|
||
function addPowerSample(v) {
|
||
if (v === null || !isFinite(v)) { return; }
|
||
var now = Date.now();
|
||
powerHistory.push({ t: now, v: v });
|
||
var cutoff = now - 5 * 60 * 1000;
|
||
while (powerHistory.length && powerHistory[0].t < cutoff) { powerHistory.shift(); }
|
||
}
|
||
|
||
function render(m) {
|
||
// Licht
|
||
LIGHTS.forEach(function (l) { setDot(l[0], on(m[l[1]]), 'on'); });
|
||
|
||
// Heizung
|
||
HEAT.forEach(function (h) {
|
||
var t = num(m[h.temp]);
|
||
var tEl = document.getElementById('he_temp_' + h.i);
|
||
if (tEl) {
|
||
if (t === null) {
|
||
tEl.textContent = '–';
|
||
} else {
|
||
var p = prevTemps[h.i];
|
||
if (p != null) {
|
||
if (t > p + 0.1) { arrowState[h.i] = '<span style="color:var(--v3-danger)"> ▲</span>'; }
|
||
else if (t < p - 0.1) { arrowState[h.i] = '<span style="color:var(--v3-accent)"> ▼</span>'; }
|
||
}
|
||
prevTemps[h.i] = t;
|
||
tEl.innerHTML = t.toFixed(1) + ' °C' + (arrowState[h.i] || '');
|
||
}
|
||
tEl.className = 'v3-temp ' + tempClass(t);
|
||
}
|
||
setDot('he_dot_' + h.i, on(m[h.act]), 'active');
|
||
var mn = num(m[h.min]), mx = num(m[h.max]);
|
||
setText('he_ist_' + h.i, (mn === null ? '–' : mn) + ' / ' + (mx === null ? '–' : mx));
|
||
});
|
||
|
||
// Steckdosen (Status false/0 = AN, true/1 = AUS). Unbekannt/Lesefehler -> Punkt AUS.
|
||
SOCKETS.forEach(function (s) {
|
||
var val = m[s[1]];
|
||
var known = (val !== null && val !== undefined);
|
||
setDot(s[0], known && (val === false || val === 0), 'on');
|
||
});
|
||
|
||
// Poolpumpe: Laufzeit heute (Sekunden -> Stunden + Minuten)
|
||
var poolSek = num(m[POOL.sek]);
|
||
|
||
// Fortschrittsbalken: IST vs. SOLL (beide in Sekunden)
|
||
var poolMax = num(m[POOL.max]);
|
||
var bar = document.getElementById('sd_pool_bar');
|
||
var barLabel = document.getElementById('sd_pool_bar_label');
|
||
if (bar && barLabel) {
|
||
if (poolSek === null || poolMax === null || poolMax <= 0) {
|
||
bar.style.width = '0%';
|
||
barLabel.textContent = '– / –';
|
||
bar.classList.remove('v3-pool-bar-fill--done');
|
||
} else {
|
||
var pct = Math.min(100, poolSek / poolMax * 100);
|
||
bar.style.width = pct.toFixed(1) + '%';
|
||
barLabel.textContent = formatStdMin(poolSek) + ' / ' + formatStdMin(poolMax);
|
||
bar.classList.toggle('v3-pool-bar-fill--done', pct >= 100);
|
||
}
|
||
}
|
||
|
||
// Stromzähler
|
||
var bez = num(m[METER.bezug]), ein = num(m[METER.einsp]), lei = num(m[METER.leist]), tag = num(m[METER.tag]);
|
||
setText('sz_bezug', (bez === null ? '–' : bez.toFixed(1)) + ' kWh');
|
||
setText('sz_einsp', (ein === null ? '–' : ein.toFixed(1)) + ' kWh');
|
||
setText('sz_leist', (lei === null ? '–' : Math.round(lei)) + ' W');
|
||
setText('sz_tag', (tag === null ? '–' : tag.toFixed(1)) + ' kWh');
|
||
// Wemos-String wird separat im raw-Modus gelesen (siehe readWemos)
|
||
var hist = METER.hist.map(function (x) { return num(m[x]); });
|
||
var seq = [bez].concat(hist); // [Bezug, h0..h7]
|
||
for (var d = 1; d <= 8; d++) {
|
||
var a = seq[d - 1], b = seq[d];
|
||
setText('sz_d' + d, (a === null || b === null) ? '–' : (a - b).toFixed(2) + ' kWh');
|
||
}
|
||
addPowerSample(lei);
|
||
gaugeTarget = (lei === null ? 0 : lei); // Balken laeuft in der Animation weich nach
|
||
|
||
// Wasserzähler (nur aktuelle Werte aus [0])
|
||
var wStand = num(m[WATER.stand]), wSeit = num(m[WATER.seit]);
|
||
var wTemp = num(m[WATER.wtemp]);
|
||
setText('hdr_wtemp', (wTemp === null ? '–' : 'Wasser: ' + wTemp.toFixed(0) + ' °C'));
|
||
setText('hdr_wstand', (wStand === null ? '–' : 'Stand: ' + wStand.toFixed(0) + ' L'));
|
||
setText('hdr_wseit', (wSeit === null ? '–' : 'seit Ablesung: ' + wSeit.toFixed(0) + ' L'));
|
||
|
||
// Strom-Header-Werte
|
||
setText('hdr_sbezug', (bez === null ? '–' : 'Bezug: ' + bez.toFixed(1) + ' kWh'));
|
||
setText('hdr_seinsp', (ein === null ? '–' : 'Einsp.: ' + ein.toFixed(1) + ' kWh'));
|
||
|
||
// Uhr / Sonne / Urlaub
|
||
setText('clock_time', pad(m[ZEIT.h]) + ':' + pad(m[ZEIT.mi]) + ':' + pad(m[ZEIT.s]));
|
||
setText('clock_date', pad(m[ZEIT.d]) + '.' + pad(m[ZEIT.mo]) + '.' + (m[ZEIT.y] != null ? m[ZEIT.y] : '----'));
|
||
setText('sun_up', pad(m[ZEIT.aufH]) + ':' + pad(m[ZEIT.aufMi]) + ' Uhr');
|
||
setText('sun_down', pad(m[ZEIT.untH]) + ':' + pad(m[ZEIT.untMi]) + ' Uhr');
|
||
setDot('roll_urlaub_dot', on(m[ZEIT.urlaub]), 'active');
|
||
}
|
||
|
||
// Canvas an CSS-Größe anpassen (scharf auf allen Displays)
|
||
function fitCanvas(canvas) {
|
||
var rect = canvas.getBoundingClientRect();
|
||
var dpr = window.devicePixelRatio || 1;
|
||
var targetW = Math.round(rect.width * dpr);
|
||
var targetH = Math.round(rect.height * dpr);
|
||
if (canvas.width !== targetW || canvas.height !== targetH) {
|
||
canvas.width = targetW;
|
||
canvas.height = targetH;
|
||
}
|
||
var ctx = canvas.getContext('2d');
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
return { w: rect.width, h: rect.height, ctx: ctx };
|
||
}
|
||
|
||
// ====== Strom-Tacho: horizontaler Balken von Mitte nach links/rechts ====
|
||
function drawGauge(value) {
|
||
var canvas = document.getElementById('v3_gauge');
|
||
if (!canvas) { return; }
|
||
var fit = fitCanvas(canvas);
|
||
var ctx = fit.ctx, w = fit.w, h = fit.h;
|
||
ctx.clearRect(0, 0, w, h);
|
||
|
||
var MIN = -8000, MAX = 8000;
|
||
var v = Math.max(MIN, Math.min(MAX, isFinite(value) ? value : 0));
|
||
var padT = 18, padB = 22, padL = 10, padR = 10;
|
||
var barY = h - padB - 10;
|
||
var barH = 14;
|
||
var cx = w / 2;
|
||
var halfW = (w - padL - padR) / 2;
|
||
|
||
function xFor(vv) {
|
||
vv = Math.max(MIN, Math.min(MAX, vv));
|
||
return cx + (vv / MAX) * halfW;
|
||
}
|
||
|
||
// Hintergrundbalken (neutral)
|
||
ctx.fillStyle = 'rgba(255,255,255,0.08)';
|
||
roundRect(ctx, padL, barY, w - padL - padR, barH, barH / 2);
|
||
ctx.fill();
|
||
|
||
// Aktiver Balken: von Mitte nach links (grün) oder rechts (rot)
|
||
var col = v < 0 ? '124,255,178' : '255,92,122';
|
||
var xv = xFor(v);
|
||
ctx.save();
|
||
ctx.fillStyle = 'rgba(' + col + ',0.95)';
|
||
ctx.shadowColor = 'rgba(' + col + ',0.6)'; ctx.shadowBlur = 10;
|
||
if (v >= 0) {
|
||
roundRect(ctx, cx, barY, xv - cx, barH, barH / 2);
|
||
} else {
|
||
roundRect(ctx, xv, barY, cx - xv, barH, barH / 2);
|
||
}
|
||
ctx.fill();
|
||
ctx.restore();
|
||
|
||
// Mittelstrich
|
||
ctx.strokeStyle = 'rgba(234,240,255,0.5)'; ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.moveTo(cx, barY - 4); ctx.lineTo(cx, barY + barH + 4); ctx.stroke();
|
||
|
||
// Skalenstriche
|
||
ctx.fillStyle = 'rgba(234,240,255,0.55)'; ctx.font = '10px Segoe UI, Arial, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
for (var t = -8000; t <= 8000; t += 2000) {
|
||
if (t === 0) { continue; }
|
||
var x = xFor(t);
|
||
ctx.beginPath(); ctx.moveTo(x, barY + barH + 3); ctx.lineTo(x, barY + barH + 7); ctx.stroke();
|
||
ctx.fillText((t / 1000) + 'k', x, barY + barH + 18);
|
||
}
|
||
|
||
// Digitalwert + Beschriftung
|
||
ctx.textAlign = 'center';
|
||
ctx.fillStyle = 'rgba(' + col + ',1)'; ctx.font = 'bold 22px Segoe UI, Arial, sans-serif';
|
||
ctx.fillText(Math.round(v) + ' W', cx, padT);
|
||
ctx.fillStyle = 'rgba(234,240,255,0.85)'; ctx.font = '12px Segoe UI, Arial, sans-serif';
|
||
ctx.fillText('Leistung', cx, padT + 16);
|
||
}
|
||
|
||
// Weiche Kurve (Catmull-Rom -> Bezier)
|
||
function smoothCurve(ctx, pts) {
|
||
if (pts.length < 2) { return; }
|
||
ctx.moveTo(pts[0].x, pts[0].y);
|
||
for (var i = 0; i < pts.length - 1; i++) {
|
||
var p0 = pts[i > 0 ? i - 1 : 0];
|
||
var p1 = pts[i];
|
||
var p2 = pts[i + 1];
|
||
var p3 = pts[i + 2 < pts.length ? i + 2 : i + 1];
|
||
var c1x = p1.x + (p2.x - p0.x) / 6, c1y = p1.y + (p2.y - p0.y) / 6;
|
||
var c2x = p2.x - (p3.x - p1.x) / 6, c2y = p2.y - (p3.y - p1.y) / 6;
|
||
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, p2.x, p2.y);
|
||
}
|
||
}
|
||
|
||
function roundRect(ctx, x, y, w, h, r) {
|
||
ctx.beginPath();
|
||
ctx.moveTo(x + r, y);
|
||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||
ctx.arcTo(x, y + h, x, y, r);
|
||
ctx.arcTo(x, y, x + w, y, r);
|
||
ctx.closePath();
|
||
}
|
||
|
||
function drawPowerChart() {
|
||
var canvas = document.getElementById('v3_power_chart');
|
||
if (!canvas) { return; }
|
||
var fit = fitCanvas(canvas);
|
||
var ctx = fit.ctx, w = fit.w, h = fit.h;
|
||
var now = Date.now();
|
||
ctx.clearRect(0, 0, w, h);
|
||
ctx.fillStyle = 'rgba(8,12,20,0.55)';
|
||
ctx.fillRect(0, 0, w, h);
|
||
|
||
var padL = 44, padR = 12, padT = 22, padB = 18;
|
||
var plotW = w - padL - padR, plotH = h - padT - padB;
|
||
|
||
ctx.textAlign = 'left';
|
||
ctx.fillStyle = 'rgba(234,240,255,0.85)';
|
||
ctx.font = '12px Segoe UI, Arial, sans-serif';
|
||
ctx.fillText('Leistung – letzte 5 min', 12, 14);
|
||
|
||
if (!powerHistory.length) {
|
||
ctx.fillStyle = 'rgba(234,240,255,0.5)';
|
||
ctx.fillText('warte auf Daten…', 12, padT + plotH / 2);
|
||
return;
|
||
}
|
||
|
||
var WINDOW = 5 * 60 * 1000;
|
||
var t1 = now, t0 = now - WINDOW;
|
||
|
||
// Y-Bereich inklusive 0
|
||
var minV = 0, maxV = 0;
|
||
for (var i = 0; i < powerHistory.length; i++) {
|
||
var v = powerHistory[i].v;
|
||
if (v < minV) { minV = v; }
|
||
if (v > maxV) { maxV = v; }
|
||
}
|
||
if (minV === maxV) { minV -= 100; maxV += 100; }
|
||
var pad = (maxV - minV) * 0.12;
|
||
minV -= pad; maxV += pad;
|
||
|
||
function X(t) { return padL + ((t - t0) / (t1 - t0)) * plotW; }
|
||
function Y(v) { return padT + (1 - (v - minV) / (maxV - minV)) * plotH; }
|
||
|
||
// Gitter
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.07)';
|
||
ctx.lineWidth = 1;
|
||
for (var gy = 0; gy <= 4; gy++) {
|
||
var yy = padT + plotH * gy / 4;
|
||
ctx.beginPath(); ctx.moveTo(padL, yy); ctx.lineTo(padL + plotW, yy); ctx.stroke();
|
||
}
|
||
for (var mm = 1; mm <= 5; mm++) {
|
||
var xx = X(now - mm * 60000);
|
||
ctx.beginPath(); ctx.moveTo(xx, padT); ctx.lineTo(xx, padT + plotH); ctx.stroke();
|
||
}
|
||
|
||
// Punkte im Fenster + bis "jetzt" verlaengern
|
||
var pts = [];
|
||
for (var k = 0; k < powerHistory.length; k++) {
|
||
var p = powerHistory[k];
|
||
if (p.t < t0) { continue; }
|
||
pts.push({ x: X(p.t), y: Y(p.v), v: p.v });
|
||
}
|
||
if (!pts.length) { return; }
|
||
var lastReal = pts[pts.length - 1];
|
||
pts.push({ x: X(now), y: lastReal.y, v: lastReal.v });
|
||
|
||
var zeroY = Y(0);
|
||
var zeroFrac = Math.max(0, Math.min(1, (zeroY - padT) / plotH));
|
||
|
||
// Flaeche bis Nulllinie mit Verlauf (oben rot=Bezug, unten gruen=Einspeisung)
|
||
ctx.beginPath();
|
||
smoothCurve(ctx, pts);
|
||
ctx.lineTo(pts[pts.length - 1].x, zeroY);
|
||
ctx.lineTo(pts[0].x, zeroY);
|
||
ctx.closePath();
|
||
var grad = ctx.createLinearGradient(0, padT, 0, padT + plotH);
|
||
grad.addColorStop(0, 'rgba(255,92,122,0.38)');
|
||
grad.addColorStop(Math.max(0, zeroFrac - 0.001), 'rgba(255,92,122,0.08)');
|
||
grad.addColorStop(Math.min(1, zeroFrac + 0.001), 'rgba(124,255,178,0.08)');
|
||
grad.addColorStop(1, 'rgba(124,255,178,0.38)');
|
||
ctx.fillStyle = grad;
|
||
ctx.fill();
|
||
|
||
// Nulllinie
|
||
if (minV < 0 && maxV > 0) {
|
||
ctx.save();
|
||
ctx.setLineDash([4, 4]);
|
||
ctx.strokeStyle = 'rgba(234,240,255,0.30)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.moveTo(padL, zeroY); ctx.lineTo(padL + plotW, zeroY); ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
// Linie mit Glow
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
smoothCurve(ctx, pts);
|
||
ctx.strokeStyle = 'rgba(99,212,255,0.95)';
|
||
ctx.lineWidth = 2.4;
|
||
ctx.lineJoin = 'round';
|
||
ctx.shadowColor = 'rgba(99,212,255,0.55)';
|
||
ctx.shadowBlur = 8;
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
|
||
// Y-Beschriftung
|
||
ctx.fillStyle = 'rgba(234,240,255,0.55)';
|
||
ctx.font = '10px Segoe UI, Arial, sans-serif';
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText(Math.round(maxV) + ' W', padL - 6, padT + 8);
|
||
ctx.fillText(Math.round(minV) + ' W', padL - 6, padT + plotH);
|
||
|
||
// Live-Punkt (pulsierend), Farbe nach Vorzeichen
|
||
var lp = pts[pts.length - 1];
|
||
var col = lp.v < 0 ? '124,255,178' : '255,92,122';
|
||
var pulse = 0.5 + 0.5 * Math.sin(now / 320);
|
||
ctx.fillStyle = 'rgba(' + col + ',' + (0.16 + 0.14 * pulse) + ')';
|
||
ctx.beginPath(); ctx.arc(lp.x, lp.y, 6 + 4 * pulse, 0, Math.PI * 2); ctx.fill();
|
||
ctx.fillStyle = 'rgba(' + col + ',1)';
|
||
ctx.beginPath(); ctx.arc(lp.x, lp.y, 3.2, 0, Math.PI * 2); ctx.fill();
|
||
|
||
// aktueller Wert als Pille oben rechts
|
||
var label = 'akt. Leistung: ' + Math.round(lp.v) + ' W';
|
||
ctx.font = 'bold 11px Segoe UI, Arial, sans-serif';
|
||
var tw = ctx.measureText(label).width;
|
||
var bw = tw + 14, bx = w - padR - bw, by = 4, bh = 17;
|
||
ctx.fillStyle = 'rgba(' + col + ',0.16)';
|
||
roundRect(ctx, bx, by, bw, bh, 9); ctx.fill();
|
||
ctx.fillStyle = 'rgba(' + col + ',1)';
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText(label, bx + 7, by + 12);
|
||
}
|
||
|
||
// ---- Tages-Chart: -1 .. -8 als Balken + täglicher Bezug als Linie ----
|
||
function drawDailyChart() {
|
||
var canvas = document.getElementById('v3_daily_chart');
|
||
if (!canvas) { return; }
|
||
var fit = fitCanvas(canvas);
|
||
var ctx = fit.ctx, w = fit.w, h = fit.h;
|
||
ctx.clearRect(0, 0, w, h);
|
||
ctx.fillStyle = 'rgba(8,12,20,0.55)'; ctx.fillRect(0, 0, w, h);
|
||
|
||
var padL = 44, padR = 12, padT = 22, padB = 28;
|
||
var plotW = w - padL - padR, plotH = h - padT - padB;
|
||
|
||
// Werte -1 .. -8 direkt aus den gelesenen Zählerständen berechnen
|
||
var bez = num(lastValues[METER.bezug]);
|
||
var hist = METER.hist.map(function (x) { return num(lastValues[x]); });
|
||
var seq = [bez].concat(hist); // [Bezug, h0..h7]
|
||
var dailyValues = [];
|
||
for (var d = 1; d <= 8; d++) {
|
||
var a = seq[d - 1], b = seq[d];
|
||
dailyValues.push((a === null || b === null) ? 0 : (a - b));
|
||
}
|
||
// Zeitachse von links (alt) nach rechts (heute) – passt zu dayLabels
|
||
dailyValues.reverse();
|
||
// täglicher Bezug
|
||
var tagVal = num(lastValues[METER.tag]) || 0;
|
||
|
||
// Y-Bereich
|
||
var maxV = Math.max(tagVal, Math.max.apply(null, dailyValues));
|
||
var minV = 0;
|
||
if (maxV < 1) { maxV = 10; }
|
||
var pd = (maxV - minV) * 0.12;
|
||
maxV += pd;
|
||
|
||
function Y(v) { return padT + (1 - (v - minV) / (maxV - minV)) * plotH; }
|
||
function X(i) { return padL + (i + 0.5) * (plotW / 8); }
|
||
|
||
// Wochentage für die letzten 8 Tage (rechts = heute) basierend auf CPU-Uhrzeit
|
||
var wochentage = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||
var dayLabels = [];
|
||
var cpuY = lastValues[ZEIT.y], cpuM = lastValues[ZEIT.mo], cpuD = lastValues[ZEIT.d];
|
||
var heute = (cpuY != null && cpuM != null && cpuD != null)
|
||
? new Date(cpuY, cpuM - 1, cpuD)
|
||
: new Date();
|
||
for (var i = 0; i < 8; i++) {
|
||
var d = new Date(heute.getFullYear(), heute.getMonth(), heute.getDate() - (7 - i));
|
||
dayLabels.push(wochentage[d.getDay()]);
|
||
}
|
||
|
||
// Titel
|
||
ctx.textAlign = 'left'; ctx.fillStyle = 'rgba(234,240,255,0.85)';
|
||
ctx.font = '12px Segoe UI, Arial, sans-serif';
|
||
ctx.fillText('Tagesbezug – letzte 8 Tage', 12, 14);
|
||
|
||
// Gitter horizontal
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1;
|
||
for (var gy = 0; gy <= 4; gy++) {
|
||
var yy = padT + plotH * gy / 4;
|
||
ctx.beginPath(); ctx.moveTo(padL, yy); ctx.lineTo(padL + plotW, yy); ctx.stroke();
|
||
}
|
||
|
||
// Balken für -8 .. -1
|
||
var barW = plotW / 8 * 0.55;
|
||
for (var i = 0; i < 8; i++) {
|
||
var x = X(i);
|
||
var y0 = Y(0);
|
||
var y1 = Y(dailyValues[i]);
|
||
var bh = y0 - y1;
|
||
var bx = x - barW / 2;
|
||
var isToday = (i === 7); // -1 = heute
|
||
var grad = ctx.createLinearGradient(0, y1, 0, y0);
|
||
if (isToday) {
|
||
grad.addColorStop(0, 'rgba(255,217,99,0.95)');
|
||
grad.addColorStop(1, 'rgba(255,217,99,0.25)');
|
||
} else {
|
||
grad.addColorStop(0, 'rgba(124,255,178,0.95)');
|
||
grad.addColorStop(1, 'rgba(124,255,178,0.25)');
|
||
}
|
||
ctx.fillStyle = grad;
|
||
roundRect(ctx, bx, y1, barW, bh, 4);
|
||
ctx.fill();
|
||
// Wert über dem Balken
|
||
ctx.fillStyle = isToday ? 'rgba(255,217,99,0.95)' : 'rgba(234,240,255,0.85)';
|
||
ctx.font = 'bold 10px Segoe UI, Arial, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(dailyValues[i].toFixed(2), x, y1 - 5);
|
||
// X-Beschriftung: Wochentag
|
||
ctx.fillStyle = isToday ? 'rgba(255,217,99,0.95)' : 'rgba(234,240,255,0.65)';
|
||
ctx.font = 'bold 10px Segoe UI, Arial, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(dayLabels[i], x, h - 8);
|
||
}
|
||
|
||
// Linie für täglichen Bezug
|
||
var tagY = Y(tagVal);
|
||
ctx.save();
|
||
ctx.strokeStyle = 'rgba(99,212,255,0.95)'; ctx.lineWidth = 2.5;
|
||
ctx.shadowColor = 'rgba(99,212,255,0.55)'; ctx.shadowBlur = 8;
|
||
ctx.beginPath(); ctx.moveTo(padL, tagY); ctx.lineTo(padL + plotW, tagY); ctx.stroke();
|
||
ctx.restore();
|
||
|
||
// Y-Beschriftung
|
||
ctx.fillStyle = 'rgba(234,240,255,0.55)'; ctx.font = '10px Segoe UI, Arial, sans-serif';
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText(maxV.toFixed(1) + ' kWh', padL - 6, padT + 8);
|
||
ctx.fillText(minV.toFixed(1) + ' kWh', padL - 6, padT + plotH);
|
||
|
||
// Legende / täglicher Bezug als Pille
|
||
var label = 'täglicher Bezug: ' + tagVal.toFixed(2) + ' kWh';
|
||
ctx.font = 'bold 11px Segoe UI, Arial, sans-serif';
|
||
var tw = ctx.measureText(label).width, bw = tw + 14, bx = w - padR - bw, by = 4, bh = 17;
|
||
ctx.fillStyle = 'rgba(99,212,255,0.16)'; roundRect(ctx, bx, by, bw, bh, 9); ctx.fill();
|
||
ctx.fillStyle = 'rgba(99,212,255,1)'; ctx.textAlign = 'left';
|
||
ctx.fillText(label, bx + 7, by + 12);
|
||
}
|
||
|
||
// ---- Wasser-Tageschart: -1 .. -8 als Balken + Durchschnittslinie ----
|
||
function drawWaterChart() {
|
||
var canvas = document.getElementById('v3_water_chart');
|
||
if (!canvas) { return; }
|
||
var fit = fitCanvas(canvas);
|
||
var ctx = fit.ctx, w = fit.w, h = fit.h;
|
||
ctx.clearRect(0, 0, w, h);
|
||
ctx.fillStyle = 'rgba(8,12,20,0.55)'; ctx.fillRect(0, 0, w, h);
|
||
|
||
var padL = 50, padR = 16, padT = 22, padB = 28;
|
||
var plotW = w - padL - padR, plotH = h - padT - padB;
|
||
|
||
// Werte -1 .. -8 aus den historischen Zählerständen berechnen
|
||
var hist = WATER.hist.map(function (x) { return num(lastValues[x]); });
|
||
var dailyValues = [];
|
||
for (var d = 1; d < hist.length; d++) {
|
||
var a = hist[d - 1], b = hist[d];
|
||
dailyValues.push((a === null || b === null) ? 0 : (a - b));
|
||
}
|
||
// Zeitachse von links (alt) nach rechts (heute)
|
||
dailyValues.reverse();
|
||
|
||
// Y-Bereich
|
||
var maxV = Math.max.apply(null, dailyValues);
|
||
var minV = 0;
|
||
// täglicher Bezug aus DB
|
||
var tagVal = num(lastValues[WATER.tag]) || 0;
|
||
if (tagVal > maxV) { maxV = tagVal; }
|
||
if (maxV < 1) { maxV = 100; }
|
||
var pd = (maxV - minV) * 0.12;
|
||
maxV += pd;
|
||
|
||
function Y(v) { return padT + (1 - (v - minV) / (maxV - minV)) * plotH; }
|
||
function X(i) { return padL + (i + 0.5) * (plotW / dailyValues.length); }
|
||
|
||
// Wochentage für die letzten Tage (rechts = heute)
|
||
var wochentage = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||
var dayLabels = [];
|
||
var cpuY = lastValues[ZEIT.y], cpuM = lastValues[ZEIT.mo], cpuD = lastValues[ZEIT.d];
|
||
var heute = (cpuY != null && cpuM != null && cpuD != null)
|
||
? new Date(cpuY, cpuM - 1, cpuD)
|
||
: new Date();
|
||
for (var i = 0; i < dailyValues.length; i++) {
|
||
var d = new Date(heute.getFullYear(), heute.getMonth(), heute.getDate() - (dailyValues.length - 1 - i));
|
||
dayLabels.push(wochentage[d.getDay()]);
|
||
}
|
||
|
||
// Titel
|
||
ctx.textAlign = 'left'; ctx.fillStyle = 'rgba(234,240,255,0.85)';
|
||
ctx.font = '12px Segoe UI, Arial, sans-serif';
|
||
ctx.fillText('Wasserverbrauch – letzte 7 Tage', 12, 14);
|
||
|
||
// Gitter horizontal
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1;
|
||
for (var gy = 0; gy <= 4; gy++) {
|
||
var yy = padT + plotH * gy / 4;
|
||
ctx.beginPath(); ctx.moveTo(padL, yy); ctx.lineTo(padL + plotW, yy); ctx.stroke();
|
||
}
|
||
|
||
// Balken
|
||
var barW = plotW / dailyValues.length * 0.55;
|
||
for (var i = 0; i < dailyValues.length; i++) {
|
||
var x = X(i);
|
||
var y0 = Y(0);
|
||
var y1 = Y(dailyValues[i]);
|
||
var bh = y0 - y1;
|
||
var bx = x - barW / 2;
|
||
var isToday = (i === dailyValues.length - 1);
|
||
var grad = ctx.createLinearGradient(0, y1, 0, y0);
|
||
if (isToday) {
|
||
grad.addColorStop(0, 'rgba(99,212,255,0.95)');
|
||
grad.addColorStop(1, 'rgba(99,212,255,0.25)');
|
||
} else {
|
||
grad.addColorStop(0, 'rgba(99,212,255,0.75)');
|
||
grad.addColorStop(1, 'rgba(99,212,255,0.15)');
|
||
}
|
||
ctx.fillStyle = grad;
|
||
roundRect(ctx, bx, y1, barW, bh, 4);
|
||
ctx.fill();
|
||
// Wert über dem Balken
|
||
ctx.fillStyle = isToday ? 'rgba(99,212,255,0.95)' : 'rgba(234,240,255,0.85)';
|
||
ctx.font = 'bold 10px Segoe UI, Arial, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(Math.round(dailyValues[i]), x, y1 - 5);
|
||
// X-Beschriftung: Wochentag
|
||
ctx.fillStyle = isToday ? 'rgba(99,212,255,0.95)' : 'rgba(234,240,255,0.65)';
|
||
ctx.font = 'bold 10px Segoe UI, Arial, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(dayLabels[i], x, h - 8);
|
||
}
|
||
|
||
// Linie für täglichen Bezug
|
||
var tagY = Y(tagVal);
|
||
ctx.save();
|
||
ctx.strokeStyle = 'rgba(255,217,99,0.95)'; ctx.lineWidth = 2.5;
|
||
ctx.setLineDash([6, 4]);
|
||
ctx.shadowColor = 'rgba(255,217,99,0.55)'; ctx.shadowBlur = 8;
|
||
ctx.beginPath(); ctx.moveTo(padL, tagY); ctx.lineTo(padL + plotW, tagY); ctx.stroke();
|
||
ctx.restore();
|
||
|
||
// Y-Beschriftung
|
||
ctx.fillStyle = 'rgba(234,240,255,0.55)'; ctx.font = '10px Segoe UI, Arial, sans-serif';
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText(Math.round(maxV) + ' L', padL - 6, padT + 8);
|
||
ctx.fillText(Math.round(minV) + ' L', padL - 6, padT + plotH);
|
||
|
||
// Legende / täglicher Bezug als Pille
|
||
var label = 'täglicher Bezug: ' + Math.round(tagVal) + ' L';
|
||
ctx.font = 'bold 11px Segoe UI, Arial, sans-serif';
|
||
var tw = ctx.measureText(label).width, bw = tw + 14, bx = w - padR - bw, by = 4, bh = 17;
|
||
ctx.fillStyle = 'rgba(255,217,99,0.16)'; roundRect(ctx, bx, by, bw, bh, 9); ctx.fill();
|
||
ctx.fillStyle = 'rgba(255,217,99,1)'; ctx.textAlign = 'left';
|
||
ctx.fillText(label, bx + 7, by + 12);
|
||
}
|
||
|
||
// Durchgehende Animation (pulsierender Punkt, weiches Scrollen der Zeitachse)
|
||
function startChartAnimation() {
|
||
var last = 0;
|
||
function frame(ts) {
|
||
requestAnimationFrame(frame);
|
||
if (ts - last < 33) { return; } // ~30 fps (schont CPU)
|
||
last = ts;
|
||
gaugeShown += (gaugeTarget - gaugeShown) * 0.18; // weiches Nachlaufen
|
||
if (Math.abs(gaugeTarget - gaugeShown) < 0.5) { gaugeShown = gaugeTarget; }
|
||
drawGauge(gaugeShown);
|
||
drawPowerChart();
|
||
drawDailyChart();
|
||
drawWaterChart();
|
||
}
|
||
requestAnimationFrame(frame);
|
||
}
|
||
|
||
// ====== Schreiben per Klick (Event-Delegation) =====================
|
||
function parseVal(s) {
|
||
if (s === 'true') { return true; }
|
||
if (s === 'false') { return false; }
|
||
if (/^-?\d+(\.\d+)?$/.test(s)) { return Number(s); }
|
||
return s;
|
||
}
|
||
|
||
// kurzes visuelles Feedback am Button (gruen=ok, rot=Fehler)
|
||
function flash(btn, r) {
|
||
if (!btn) { return; }
|
||
var cls = (r && !r.error) ? 'v3-flash-ok' : 'v3-flash-err';
|
||
btn.classList.remove('v3-flash-ok', 'v3-flash-err');
|
||
void btn.offsetWidth; // Reflow -> Animation neu starten
|
||
btn.classList.add(cls);
|
||
setTimeout(function () { btn.classList.remove(cls); }, 600);
|
||
}
|
||
|
||
function onClick(ev) {
|
||
var btn = ev.target.closest ? ev.target.closest('button') : null;
|
||
// Mehrfach-Schreibvorgang (kommagetrennte Variablen in data-vars)
|
||
var ml = ev.target.closest ? ev.target.closest('[data-vars]') : null;
|
||
if (ml && ml.hasAttribute('data-value')) {
|
||
var vars = ml.getAttribute('data-vars').split(',').map(function (s) { return s.trim(); });
|
||
var val = parseVal(ml.getAttribute('data-value'));
|
||
var results = [];
|
||
Promise.all(vars.map(function (v) {
|
||
return write(v, val).then(function (r) { results.push(r); return r; });
|
||
})).then(function () {
|
||
var allOk = results.every(function (r) { return r && !r.error; });
|
||
flash(btn, allOk ? { error: false } : { error: true });
|
||
});
|
||
return;
|
||
}
|
||
// einfacher Einzel-Schreibvorgang
|
||
var el = ev.target.closest ? ev.target.closest('[data-var]') : null;
|
||
if (el && el.hasAttribute('data-value')) {
|
||
write(el.getAttribute('data-var'), parseVal(el.getAttribute('data-value'))).then(function (r) { flash(btn, r); });
|
||
return;
|
||
}
|
||
// Poolpumpe: SOLL-Laufzeit (Eingabe in Minuten -> Sekunden schreiben)
|
||
// WICHTIG: vor [data-write-input] pruefen, da der Pool-Button auch
|
||
// data-write-input traegt und sonst vom Heizungs-Handler abgefangen wird.
|
||
var pm = ev.target.closest ? ev.target.closest('[data-pool-max]') : null;
|
||
if (pm) {
|
||
var pInput = document.getElementById(pm.getAttribute('data-write-input'));
|
||
if (!pInput || pInput.value === '') { return; }
|
||
var pMin = parseFloat(pInput.value);
|
||
if (isNaN(pMin) || pMin < 0) { alert('Bitte eine gültige Zahl eingeben.'); return; }
|
||
write(pm.getAttribute('data-var'), Math.round(pMin * 60)).then(function (r) {
|
||
flash(btn, r);
|
||
// SOLL-Wert neu lesen, damit der Balken sofort stimmt
|
||
readMany([POOL.max]).then(function (m) { merge(m); render(lastValues); }).catch(function () {});
|
||
});
|
||
return;
|
||
}
|
||
// Heizung: Zahl aus zugehoerigem Eingabefeld schreiben
|
||
var hn = ev.target.closest ? ev.target.closest('[data-write-input]') : null;
|
||
if (hn) {
|
||
var input = document.getElementById(hn.getAttribute('data-write-input'));
|
||
if (!input || input.value === '') { return; }
|
||
var n = parseFloat(input.value);
|
||
if (isNaN(n)) { alert('Bitte eine Zahl eingeben.'); return; }
|
||
if (!heatPlausible(hn, n)) { return; }
|
||
write(hn.getAttribute('data-var'), n).then(function (r) { flash(btn, r); setTimeout(refreshSettings, 300); });
|
||
return;
|
||
}
|
||
// WLED: Helligkeit bzw. Farbe + Befehl
|
||
var wl = ev.target.closest ? ev.target.closest('[data-wled]') : null;
|
||
if (wl) {
|
||
var kind = wl.getAttribute('data-wled');
|
||
if (kind === 'bright') {
|
||
var b = document.getElementById('wled_bright');
|
||
write('"wled_DB".Helligkeit', parseInt(b.value, 10)).then(function () { return write('"wled_DB".Befehl', 4); }).then(function (r) { flash(btn, r); });
|
||
} else if (kind === 'color') {
|
||
var c = document.getElementById('wled_color');
|
||
write('"wled_DB".RGB_hex', c.value).then(function () { return write('"wled_DB".Befehl', 5); }).then(function (r) { flash(btn, r); });
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Plausibilitaet min < max ueber die ganze Heizungszeile
|
||
function heatPlausible(btn, newVal) {
|
||
var row = btn.closest('.v3-heat-row');
|
||
if (!row) { return true; }
|
||
var kind = btn.getAttribute('data-kind');
|
||
var minEl = row.querySelector('input[data-kind="min"]');
|
||
var maxEl = row.querySelector('input[data-kind="max"]');
|
||
var ist = row.querySelector('.v3-muted');
|
||
var cur = ist ? ist.textContent.split('/') : [];
|
||
var sMin = (kind === 'min') ? newVal : (minEl && minEl.value !== '' ? parseFloat(minEl.value) : parseFloat(cur[0]));
|
||
var sMax = (kind === 'max') ? newVal : (maxEl && maxEl.value !== '' ? parseFloat(maxEl.value) : parseFloat(cur[1]));
|
||
if (!isNaN(sMin) && !isNaN(sMax) && sMin >= sMax) {
|
||
alert('Min-Temperatur muss kleiner sein als Max.');
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ====== Start =======================================================
|
||
// Last reduzieren: jeden Zyklus nur die schnelle Variable (Leistung) lesen,
|
||
// alles Uebrige nur jeden SLOW_EVERY-ten Zyklus. Heizungs-Grenzen nur beim
|
||
// Start bzw. nach einem Heizungs-Schreibvorgang. Werte werden gepuffert.
|
||
var FAST_VARS = [METER.leist];
|
||
var ALL_VARS = null;
|
||
var SET_VARS = null;
|
||
var SLOW_EVERY = 4;
|
||
var lastValues = {};
|
||
var tickCount = 0;
|
||
var diagnosed = false;
|
||
|
||
function merge(m) { Object.keys(m).forEach(function (k) { lastValues[k] = m[k]; }); }
|
||
|
||
// Einmalige Diagnose: welche Variablen liefern keinen Wert (Tippfehler/Rechte)?
|
||
function diagnose(m, liste) {
|
||
var bad = liste.filter(function (v) { return m[v] === null || m[v] === undefined; });
|
||
if (bad.length === liste.length) {
|
||
console.warn('Web-API: KEINE der ' + liste.length + ' Variablen gelesen – Rechte/Web-API pruefen.');
|
||
} else if (bad.length) {
|
||
console.warn('Web-API: ' + bad.length + ' Variable(n) ohne Wert (Tippfehler im Namen?):', bad);
|
||
} else {
|
||
console.info('Web-API: alle ' + liste.length + ' Variablen ok.');
|
||
}
|
||
}
|
||
|
||
// Heizungs-Grenzen separat lesen (selten / nach Schreibvorgang)
|
||
function refreshSettings() {
|
||
readMany(SET_VARS).then(function (m) { merge(m); render(lastValues); }).catch(function () {});
|
||
}
|
||
|
||
function tick() {
|
||
tickCount++;
|
||
var isFull = (tickCount % SLOW_EVERY === 1);
|
||
var vars = isFull ? ALL_VARS : FAST_VARS;
|
||
readMany(vars)
|
||
.then(function (m) {
|
||
merge(m);
|
||
render(lastValues);
|
||
setStatus(true, 'verbunden');
|
||
if (isFull) { readWemos(); }
|
||
if (isFull && !diagnosed) { diagnosed = true; diagnose(m, ALL_VARS); }
|
||
})
|
||
.catch(function (e) { setStatus(false, 'keine Verbindung'); })
|
||
.then(function () { setTimeout(tick, CFG.readInterval); });
|
||
}
|
||
|
||
function init() {
|
||
ALL_VARS = allReadVars();
|
||
SET_VARS = settingsVars();
|
||
document.addEventListener('click', onClick);
|
||
drawGauge(0);
|
||
startChartAnimation();
|
||
setStatus(false, 'verbinde…');
|
||
if (ANON) {
|
||
refreshSettings();
|
||
} else {
|
||
login().then(function () { setStatus(true, 'verbunden'); refreshSettings(); })
|
||
.catch(function () { setStatus(false, 'Login fehlgeschlagen'); });
|
||
}
|
||
tick();
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
})();
|