// ==================================================================== // 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] = ''; } else if (t < p - 0.1) { arrowState[h.i] = ''; } } 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(); } })();