1321 lines
47 KiB
JavaScript
1321 lines
47 KiB
JavaScript
// ====================================================================
|
||
// tth_v4.js – indexV4.htm "HAUS-BRÜCKE" Kommandozentrale
|
||
//
|
||
// - Basiert auf der V3-Web-API-Logik (tth_v3_api.js)
|
||
// - Futuristisches HUD-Design, Boot-Sequence, Toasts, Touch-Tiles
|
||
// - Kommunikation mit der S7-1500 ueber /api/jsonrpc
|
||
// ====================================================================
|
||
|
||
(function () {
|
||
'use strict';
|
||
|
||
// Preview-Modus: window.V4_PREVIEW = true in indexV4_preview.htm gesetzt
|
||
var PREVIEW = !!(window.V4_PREVIEW);
|
||
|
||
// ====== KONFIG ======
|
||
var CFG = {
|
||
url: '/api/jsonrpc',
|
||
user: '111',
|
||
password: '1q2w3e$R',
|
||
readInterval: 500
|
||
};
|
||
|
||
var ANON = !CFG.user;
|
||
var token = null;
|
||
var rid = 0;
|
||
|
||
// ====== Boot-Sequence ======
|
||
var bootEls = {
|
||
sps: document.getElementById('boot_sps'),
|
||
energie: document.getElementById('boot_energie'),
|
||
kommando: document.getElementById('boot_kommando'),
|
||
status: document.getElementById('boot_status'),
|
||
bar: document.getElementById('boot_bar'),
|
||
done: document.getElementById('boot_done'),
|
||
wrap: document.getElementById('v4_boot')
|
||
};
|
||
|
||
function bootSet(el, text, ok) {
|
||
if (!el) { return; }
|
||
el.textContent = text;
|
||
el.classList.toggle('v4-boot-ok', !!ok);
|
||
}
|
||
|
||
function runBoot() {
|
||
var steps = [
|
||
{ el: bootEls.sps, label: 'OK' },
|
||
{ el: bootEls.energie, label: 'OK' },
|
||
{ el: bootEls.kommando, label: 'OK' },
|
||
{ el: bootEls.status, label: 'OK' }
|
||
];
|
||
var i = 0;
|
||
function next() {
|
||
if (i < steps.length) {
|
||
var s = steps[i];
|
||
bootSet(s.el, s.label, true);
|
||
bootEls.bar.style.width = ((i + 1) / steps.length * 100) + '%';
|
||
i++;
|
||
setTimeout(next, 350);
|
||
} else {
|
||
bootEls.bar.style.width = '100%';
|
||
bootEls.done.classList.add('v4-boot-done--show');
|
||
setTimeout(function () {
|
||
bootEls.wrap.classList.add('v4-boot--hidden');
|
||
setTimeout(function () { bootEls.wrap.style.display = 'none'; }, 600);
|
||
}, 600);
|
||
}
|
||
}
|
||
next();
|
||
}
|
||
|
||
// ====== Status ======
|
||
var statusOrb = document.getElementById('api_status_orb');
|
||
var statusText = document.getElementById('api_status');
|
||
function setStatus(connected, text) {
|
||
if (statusText) { statusText.textContent = text; }
|
||
if (statusOrb) { statusOrb.classList.toggle('v4-status-orb--on', connected); }
|
||
}
|
||
|
||
// ====== Toasts ======
|
||
var toastWrap = document.getElementById('v4_toasts');
|
||
function toast(msg, kind) {
|
||
if (!toastWrap) { return; }
|
||
var el = document.createElement('div');
|
||
el.className = 'v4-toast' + (kind === 'ok' ? ' v4-toast--ok' : kind === 'err' ? ' v4-toast--err' : '');
|
||
el.textContent = msg;
|
||
toastWrap.appendChild(el);
|
||
setTimeout(function () {
|
||
el.classList.add('v4-toast--out');
|
||
setTimeout(function () { el.remove(); }, 300);
|
||
}, 2500);
|
||
}
|
||
|
||
// ====== Preview: Dummy-Daten ======
|
||
function makePreviewValues() {
|
||
var m = {};
|
||
var now = new Date();
|
||
// Uhr
|
||
m[ZEIT.y] = now.getFullYear();
|
||
m[ZEIT.mo] = now.getMonth() + 1;
|
||
m[ZEIT.d] = now.getDate();
|
||
m[ZEIT.h] = now.getHours();
|
||
m[ZEIT.mi] = now.getMinutes();
|
||
m[ZEIT.s] = now.getSeconds();
|
||
m[ZEIT.aufH] = 6; m[ZEIT.aufMi] = 12;
|
||
m[ZEIT.untH] = 20; m[ZEIT.untMi] = 48;
|
||
m[ZEIT.urlaub] = false;
|
||
// Strom
|
||
m[METER.bezug] = 12543.2;
|
||
m[METER.einsp] = 8421.7;
|
||
m[METER.leist] = 300 + Math.sin(Date.now() / 2000) * 1500;
|
||
m[METER.tag] = 18.4;
|
||
var base = 12543.2;
|
||
for (var i = 0; i < 8; i++) {
|
||
base -= 15 + i * 2.3;
|
||
m[METER.hist[i]] = base;
|
||
}
|
||
// Wasser
|
||
m[WATER.stand] = 247300;
|
||
m[WATER.seit] = 120;
|
||
m[WATER.wtemp] = 18;
|
||
m[WATER.tag] = 160;
|
||
base = 247300;
|
||
for (var j = 0; j < 8; j++) {
|
||
base -= 130 + j * 20;
|
||
m[WATER.hist[j]] = base;
|
||
}
|
||
// Licht: jeder 2. an
|
||
LIGHTS.forEach(function (l, idx) { m[l[1]] = (idx % 3 === 0); });
|
||
// Heizung
|
||
HEAT.forEach(function (h) {
|
||
m[h.temp] = 19 + (h.i % 4) * 0.8;
|
||
m[h.act] = (h.i % 3 === 0);
|
||
m[h.min] = 19;
|
||
m[h.max] = 23;
|
||
});
|
||
// Steckdosen: false = AN
|
||
SOCKETS.forEach(function (s, idx) { m[s[1]] = (idx % 2 === 0) ? false : true; });
|
||
// Pool
|
||
m[POOL.sek] = 4 * 3600 + 12 * 60;
|
||
m[POOL.max] = 5 * 3600;
|
||
m[METER.wemos] = '';
|
||
return m;
|
||
}
|
||
|
||
// ====== Robustheit: Backoff, Mutex, Token-Reset ======
|
||
var pollBusy = false;
|
||
var backoffMs = CFG.readInterval;
|
||
var MAX_BACKOFF = 10000;
|
||
var wemosCounter = 0;
|
||
|
||
function resetBackoff() { backoffMs = CFG.readInterval; }
|
||
function increaseBackoff() { backoffMs = Math.min(MAX_BACKOFF, backoffMs * 2); }
|
||
|
||
// ====== Low-Level API ======
|
||
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) })
|
||
.catch(function (e) { token = null; throw e; }) // Netzwerkfehler -> Token verwerfen
|
||
.then(function (r) {
|
||
if (r.status === 401 || r.status === 403) { token = null; throw new Error('unauthorized'); }
|
||
return r.json().catch(function () { throw new Error('leere Antwort'); });
|
||
})
|
||
.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');
|
||
});
|
||
}
|
||
|
||
function withAuth(fn) {
|
||
if (ANON) { return fn(); }
|
||
return (token ? Promise.resolve() : login())
|
||
.then(fn)
|
||
.catch(function () { return login().then(fn); });
|
||
}
|
||
|
||
function readMany(vars) {
|
||
if (PREVIEW) {
|
||
var pv = makePreviewValues();
|
||
var out = {};
|
||
vars.forEach(function (v) { out[v] = pv[v] != null ? pv[v] : null; });
|
||
return Promise.resolve(out);
|
||
}
|
||
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;
|
||
});
|
||
});
|
||
}
|
||
|
||
function write(varName, value) {
|
||
varName = String(varName).trim();
|
||
if (PREVIEW) {
|
||
// Schreibvorgang simulieren: Zielvariable im Dummy-Speicher togglen
|
||
var key = varName;
|
||
var cur = lastValues[key];
|
||
if (typeof value === 'boolean' && typeof cur === 'boolean') {
|
||
lastValues[key] = value;
|
||
}
|
||
render(lastValues);
|
||
return Promise.resolve({ jsonrpc: '2.0', id: 0, result: true });
|
||
}
|
||
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, r.error);
|
||
}
|
||
return r;
|
||
}).catch(function (e) { console.warn('Write-Ausnahme', varName, e); });
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
function readWemos() {
|
||
if (PREVIEW) {
|
||
setText('sz_wemos', 'PREVIEW MODUS – keine SPS');
|
||
return Promise.resolve();
|
||
}
|
||
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();
|
||
setText('sz_wemos', txt === '' ? '–' : txt);
|
||
}).catch(function () {});
|
||
}
|
||
|
||
// ====== Variablen-Zuordnung ======
|
||
var LIGHTS = [
|
||
['Küche', '"Licht Küche"', '"Lichtmerker Küche"'],
|
||
['Esszimmer', '"Licht Essen"', '"Lichtmerker Essen"'],
|
||
['Wohnen 1', '"Licht Wohnen 1"', '"Lichtmerker Wohnen 1"'],
|
||
['Wohnen 2', '"Licht Wohnen 2"', '"Lichtmerker Wohnen 2"'],
|
||
['Arbeiten', '"Licht Arbeiten"', '"Lichtmerker Arbeiten"'],
|
||
['HWR', '"Licht HWR"', '"Lichtmerker HWR"'],
|
||
['Niko', '"Licht Niko"', '"Lichtmerker Niko"'],
|
||
['Dusche', '"Licht Dusche"', '"Lichtmerker Dusche"'],
|
||
['Abstellraum', '"Licht Abstellraum"', '"Lichtmerker Abstellraum"'],
|
||
['Eltern', '"Licht Eltern"', '"Lichtmerker Eltern"'],
|
||
['Noah', '"Licht Noah"', '"Lichtmerker Noah"'],
|
||
['Bad', '"Licht Bad 2"', '"Lichtmerker Bad"'],
|
||
['Mika', '"Licht Kind 3"', '"Lichtmerker Kind 3"'],
|
||
['Kicker', '"Licht Deele"', '"Kickertaste"']
|
||
];
|
||
|
||
var HEAT = [
|
||
{ i: 1, name: 'Wohnzimmer', temp: '"Temp Wohnen"', act: '"Heizung Essen Küche Wohn"', min: '"Einstellungen".Heizung."Heizung Wohnen min"', max: '"Einstellungen".Heizung."Heizung Wohnen max"' },
|
||
{ i: 2, name: 'Arbeiten', temp: '"Temp Arbeiten"', act: '"Heizung Arbeiten"', min: '"Einstellungen".Heizung."Heizung Arbeiten min"', max: '"Einstellungen".Heizung."Heizung Arbeiten max"' },
|
||
{ i: 3, name: 'HWR', temp: '"Temp HWR"', act: '"Heizung HWR"', min: '"Einstellungen".Heizung."Heizung HWR min"', max: '"Einstellungen".Heizung."Heizung HWR max"' },
|
||
{ i: 4, name: 'Bad oben', temp: '"Temp Bad"', act: '"Heizung Bad"', min: '"Einstellungen".Heizung."Heizung Bad min"', max: '"Einstellungen".Heizung."Heizung Bad max"' },
|
||
{ i: 5, name: 'Eltern', temp: '"Temp Eltern"', act: '"Heizung Eltern"', min: '"Einstellungen".Heizung."Heizung Eltern min"', max: '"Einstellungen".Heizung."Heizung Eltern max"' },
|
||
{ i: 6, name: 'Noah', temp: '"Temp Noah"', act: '"Heizung Noah"', min: '"Einstellungen".Heizung."Heizung Kind1 min"', max: '"Einstellungen".Heizung."Heizung Kind1 max"' },
|
||
{ i: 7, name: 'Mika', temp: '"Temp Mika"', act: '"Heizung Mika"', min: '"Einstellungen".Heizung."Heizung Kind2 min"', max: '"Einstellungen".Heizung."Heizung Kind2 max"' },
|
||
{ i: 8, name: 'Niko', temp: '"Temp Niko"', act: '"Heizung Niko"', min: '"Einstellungen".Heizung."Heizung Gäste min"', max: '"Einstellungen".Heizung."Heizung Gäste max"' },
|
||
{ i: 9, name: 'Dusche', temp: '"Temp Dusche"', act: '"Heizung Gäste Dusche"', min: '"Einstellungen".Heizung."Heizung Dusche min"', max: '"Einstellungen".Heizung."Heizung Dusche max"' }
|
||
];
|
||
|
||
var SOCKETS = [
|
||
['Deele', '"Steckdose Deele Garder"'],
|
||
['Wohnzimmer', '"Steckdose Wohnen"'],
|
||
['Esszimmer', '"Steckdose Essen"'],
|
||
['Arbeiten', '"Steckdose Arbeiten"'],
|
||
['Noah', '"Steckdose Kind 1"'],
|
||
['Mika', '"Steckdose Kind 2"'],
|
||
['Niko', '"Steckdose Gäste"'],
|
||
['Eltern', '"Steckdose Eltern"']
|
||
];
|
||
|
||
var ROLLADEN = [
|
||
['Küche', '"Rolladenmerker Küc l auf"', '"Rolladenmerker Küc l ab"', '"Rolladenmerker Küc r auf"', '"Rolladenmerker Küc r ab"'],
|
||
['Esszimmer', '"Rolladenmerker Ess l auf"', '"Rolladenmerker Ess l ab"', '"Rolladenmerker Ess m auf"', '"Rolladenmerker Ess m ab"', '"Rolladenmerker Ess r auf"', '"Rolladenmerker Ess r ab"'],
|
||
['Wohnzimmer', '"Rolladenmerker Woh l auf"', '"Rolladenmerker Woh l ab"', '"Rolladenmerker Woh r auf"', '"Rolladenmerker Woh r ab"', '"Rolladenmerker Wohtü auf"', '"Rolladenmerker Wohtü ab"'],
|
||
['Arbeiten', '"Rolladenmerker Arbei auf"', '"Rolladenmerker Arbei ab"'],
|
||
['HWR', '"Rolladenmerker HWR auf"', '"Rolladenmerker HWR ab"'],
|
||
['Niko', '"Rolladenmerker Niko auf"', '"Rolladenmerker Niko ab"'],
|
||
['Dusche', '"Rolladenmerker Dusche au"', '"Rolladenmerker Dusche ab"'],
|
||
['Eltern', '"Rolladenmerker Elter auf"', '"Rolladenmerker Elter ab"'],
|
||
['Noah', '"Rolladenmerker Noah auf"', '"Rolladenmerker Noah ab"'],
|
||
['Bad', '"Rolladenmerker Bad auf"', '"Rolladenmerker Bad ab"'],
|
||
['Mika', '"Rolladenmerker Mika auf"', '"Rolladenmerker Mika ab"']
|
||
];
|
||
|
||
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'
|
||
};
|
||
|
||
function allReadVars() {
|
||
var v = [];
|
||
LIGHTS.forEach(function (l) { v.push(l[1]); });
|
||
HEAT.forEach(function (h) { v.push(h.temp, h.act); });
|
||
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;
|
||
}
|
||
|
||
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 tempClass(t) {
|
||
if (t === null) { return 'v4-heat-temp--normal'; }
|
||
if (t >= 24) { return 'v4-heat-temp--hot'; }
|
||
if (t >= 22) { return 'v4-heat-temp--warm'; }
|
||
return 'v4-heat-temp--normal';
|
||
}
|
||
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');
|
||
}
|
||
|
||
// ====== UI aufbauen ======
|
||
function buildLightTiles() {
|
||
var wrap = document.getElementById('v4_light_tiles');
|
||
if (!wrap) { return; }
|
||
LIGHTS.forEach(function (l, idx) {
|
||
var tile = document.createElement('div');
|
||
tile.className = 'v4-tile';
|
||
tile.id = 'li_tile_' + idx;
|
||
tile.innerHTML = '<span class="v4-tile-name">' + l[0] + '</span><span class="v4-tile-orb"></span>';
|
||
tile.addEventListener('click', function (ev) {
|
||
// Ripple
|
||
var rect = tile.getBoundingClientRect();
|
||
var r = document.createElement('span');
|
||
r.className = 'v4-tile-ripple';
|
||
var size = Math.max(rect.width, rect.height);
|
||
r.style.width = r.style.height = size + 'px';
|
||
r.style.left = (ev.clientX - rect.left - size / 2) + 'px';
|
||
r.style.top = (ev.clientY - rect.top - size / 2) + 'px';
|
||
tile.appendChild(r);
|
||
setTimeout(function () { r.remove(); }, 500);
|
||
write(l[2], true).then(function (res) {
|
||
flash(tile, res);
|
||
toast(l[0] + ': geschaltet', res && !res.error ? 'ok' : 'err');
|
||
});
|
||
});
|
||
wrap.appendChild(tile);
|
||
});
|
||
}
|
||
|
||
function buildRolladen() {
|
||
var wrap = document.getElementById('v4_rolladen');
|
||
if (!wrap) { return; }
|
||
ROLLADEN.forEach(function (r) {
|
||
var row = document.createElement('div');
|
||
row.className = 'v4-roll-row';
|
||
var name = document.createElement('span');
|
||
name.className = 'v4-roll-name';
|
||
name.textContent = r[0];
|
||
var btns = document.createElement('span');
|
||
btns.className = 'v4-roll-btns';
|
||
// auf/ab Paare
|
||
for (var i = 1; i < r.length; i += 2) {
|
||
var up = document.createElement('button');
|
||
up.className = 'v4-btn v4-btn--on';
|
||
up.type = 'button';
|
||
up.textContent = '↑';
|
||
up.title = 'hoch';
|
||
up.setAttribute('data-var', r[i]);
|
||
up.setAttribute('data-value', 'true');
|
||
var down = document.createElement('button');
|
||
down.className = 'v4-btn v4-btn--off';
|
||
down.type = 'button';
|
||
down.textContent = '↓';
|
||
down.title = 'runter';
|
||
down.setAttribute('data-var', r[i + 1]);
|
||
down.setAttribute('data-value', 'true');
|
||
btns.appendChild(up);
|
||
btns.appendChild(down);
|
||
}
|
||
row.appendChild(name);
|
||
row.appendChild(btns);
|
||
wrap.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function buildSockets() {
|
||
var wrap = document.getElementById('v4_sockets');
|
||
if (!wrap) { return; }
|
||
SOCKETS.forEach(function (s, idx) {
|
||
var row = document.createElement('div');
|
||
row.className = 'v4-socket-row';
|
||
var name = document.createElement('span');
|
||
name.className = 'v4-socket-name';
|
||
name.textContent = s[0];
|
||
var orb = document.createElement('span');
|
||
orb.className = 'v4-socket-orb';
|
||
orb.id = 'sd_orb_' + idx;
|
||
orb.title = 'an/aus';
|
||
orb.addEventListener('click', function () {
|
||
// Status false/0 = AN, true/1 = AUS -> umschalten
|
||
var cur = lastValues[s[1]];
|
||
var isOn = (cur === false || cur === 0);
|
||
write(s[1], isOn ? true : false).then(function (res) {
|
||
flash(orb, res);
|
||
toast(s[0] + ': ' + (isOn ? 'AUS' : 'AN'), res && !res.error ? 'ok' : 'err');
|
||
});
|
||
});
|
||
row.appendChild(name);
|
||
row.appendChild(orb);
|
||
wrap.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function buildHeat() {
|
||
var wrap = document.getElementById('v4_heat');
|
||
if (!wrap) { return; }
|
||
HEAT.forEach(function (h) {
|
||
var row = document.createElement('div');
|
||
row.className = 'v4-heat-row';
|
||
row.id = 'he_row_' + h.i;
|
||
row.innerHTML =
|
||
'<span class="v4-heat-name">' + h.name + '</span>' +
|
||
'<span class="v4-heat-tempwrap"><span class="v4-heat-temp v4-heat-temp--normal" id="he_temp_' + h.i + '">–</span>' +
|
||
'<span class="v4-heat-dot" id="he_dot_' + h.i + '"></span></span>' +
|
||
'<span class="v4-heat-bar-wrap" id="he_bar_' + h.i + '"></span>' +
|
||
'<span class="v4-heat-set v4-heat-set--min"><span class="v4-stepper"><button class="v4-stepper-btn" type="button" data-heat-step="min" data-heat-dir="-1" data-heat-i="' + h.i + '">−</button><span class="v4-stepper-val" id="he_min_' + h.i + '">–</span><button class="v4-stepper-btn" type="button" data-heat-step="min" data-heat-dir="1" data-heat-i="' + h.i + '">+</button></span></span>' +
|
||
'<span class="v4-heat-set v4-heat-set--max"><span class="v4-stepper"><button class="v4-stepper-btn" type="button" data-heat-step="max" data-heat-dir="-1" data-heat-i="' + h.i + '">−</button><span class="v4-stepper-val" id="he_max_' + h.i + '">–</span><button class="v4-stepper-btn" type="button" data-heat-step="max" data-heat-dir="1" data-heat-i="' + h.i + '">+</button></span></span>';
|
||
wrap.appendChild(row);
|
||
});
|
||
}
|
||
|
||
// Plausibilitaet min < max ueber die ganze Heizungszeile
|
||
function heatPlausible(i, kind, newVal) {
|
||
var mn = num(lastValues[HEAT[i - 1].min]);
|
||
var mx = num(lastValues[HEAT[i - 1].max]);
|
||
if (kind === 'min') { mn = newVal; } else { mx = newVal; }
|
||
if (mn !== null && mx !== null && mn >= mx) {
|
||
toast('Min-Temperatur muss kleiner sein als Max.', 'err');
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ====== Rendern ======
|
||
var powerHistory = [];
|
||
var gaugeTarget = 0, gaugeShown = 0;
|
||
var prevTemps = {}, arrowState = {};
|
||
var lastValues = {};
|
||
|
||
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-Tiles
|
||
LIGHTS.forEach(function (l, idx) {
|
||
var tile = document.getElementById('li_tile_' + idx);
|
||
if (tile) { tile.classList.toggle('v4-tile--on', on(m[l[1]])); }
|
||
});
|
||
|
||
// 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.textContent = t.toFixed(1) + '°' + (arrowState[h.i] || '');
|
||
}
|
||
tEl.className = 'v4-heat-temp ' + tempClass(t);
|
||
}
|
||
var dot = document.getElementById('he_dot_' + h.i);
|
||
if (dot) { dot.classList.toggle('v4-heat-dot--on', on(m[h.act])); }
|
||
// SOLL-Stepper-Werte anzeigen
|
||
var minEl = document.getElementById('he_min_' + h.i);
|
||
var maxEl = document.getElementById('he_max_' + h.i);
|
||
var mnVal = num(m[h.min]), mxVal = num(m[h.max]);
|
||
if (minEl) { minEl.textContent = (mnVal === null ? '–' : mnVal.toFixed(1) + '°'); }
|
||
if (maxEl) { maxEl.textContent = (mxVal === null ? '–' : mxVal.toFixed(1) + '°'); }
|
||
// Temp-Bar: IST vs. SOLL-Band
|
||
var barWrap = document.getElementById('he_bar_' + h.i);
|
||
if (barWrap) {
|
||
barWrap.innerHTML = '';
|
||
var mn = num(m[h.min]), mx = num(m[h.max]);
|
||
if (t !== null && mn !== null && mx !== null && mx > mn) {
|
||
var lo = 10, hi = 30; // Skala 10..30°C
|
||
var pct = Math.max(0, Math.min(100, (t - lo) / (hi - lo) * 100));
|
||
var bar = document.createElement('div');
|
||
bar.className = 'v4-heat-bar';
|
||
bar.style.width = pct + '%';
|
||
barWrap.appendChild(bar);
|
||
var band = document.createElement('div');
|
||
band.className = 'v4-heat-band';
|
||
var bLo = Math.max(0, Math.min(100, (mn - lo) / (hi - lo) * 100));
|
||
var bHi = Math.max(0, Math.min(100, (mx - lo) / (hi - lo) * 100));
|
||
band.style.left = bLo + '%';
|
||
band.style.width = Math.max(2, bHi - bLo) + '%';
|
||
barWrap.appendChild(band);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Steckdosen (Status false/0 = AN)
|
||
SOCKETS.forEach(function (s, idx) {
|
||
var orb = document.getElementById('sd_orb_' + idx);
|
||
if (orb) {
|
||
var val = m[s[1]];
|
||
var known = (val !== null && val !== undefined);
|
||
orb.classList.toggle('v4-socket-orb--on', known && (val === false || val === 0));
|
||
}
|
||
});
|
||
|
||
// Poolpumpe
|
||
var poolSek = num(m[POOL.sek]);
|
||
var poolMax = num(m[POOL.max]);
|
||
// SOLL min im Eingabefeld anzeigen (Sekunden -> Minuten)
|
||
var poolInput = document.getElementById('sd_pool_max_input');
|
||
if (poolInput && document.activeElement !== poolInput && poolMax !== null) {
|
||
poolInput.value = Math.round(poolMax / 60);
|
||
}
|
||
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('v4-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('v4-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');
|
||
addPowerSample(lei);
|
||
gaugeTarget = (lei === null ? 0 : lei);
|
||
|
||
// Wasser
|
||
var wStand = num(m[WATER.stand]), wSeit = num(m[WATER.seit]);
|
||
var wTemp = num(m[WATER.wtemp]);
|
||
setText('hdr_wtemp', (wTemp === null ? '–' : wTemp.toFixed(0) + ' °C'));
|
||
setText('hdr_wstand', (wStand === null ? '–' : wStand.toFixed(0) + ' L'));
|
||
setText('hdr_wseit', (wSeit === null ? '–' : wSeit.toFixed(0) + ' L'));
|
||
|
||
// 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]));
|
||
setText('sun_down', pad(m[ZEIT.untH]) + ':' + pad(m[ZEIT.untMi]));
|
||
var urlaub = on(m[ZEIT.urlaub]);
|
||
var udot = document.getElementById('roll_urlaub_dot');
|
||
if (udot) { udot.classList.toggle('v4-urlaub-indicator--on', urlaub); }
|
||
setText('roll_urlaub_text', urlaub ? 'AN' : 'AUS');
|
||
}
|
||
|
||
// ====== Canvas ======
|
||
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 };
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
// HUD-Dial (Halbkreis-Tacho)
|
||
function drawGauge(value) {
|
||
var canvas = document.getElementById('v4_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 cx = w / 2;
|
||
var cy = h - 20;
|
||
var R = Math.min(w / 2 - 20, h - 30);
|
||
|
||
// Bogen-Hintergrund
|
||
ctx.strokeStyle = 'rgba(0, 229, 255, 0.15)';
|
||
ctx.lineWidth = 14;
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, R, Math.PI, 0);
|
||
ctx.stroke();
|
||
|
||
// Skala
|
||
ctx.strokeStyle = 'rgba(0, 229, 255, 0.4)';
|
||
ctx.lineWidth = 1;
|
||
for (var t = -8000; t <= 8000; t += 1000) {
|
||
var ang = Math.PI + (t - MIN) / (MAX - MIN) * Math.PI;
|
||
var x1 = cx + Math.cos(ang) * (R - 8);
|
||
var y1 = cy + Math.sin(ang) * (R - 8);
|
||
var x2 = cx + Math.cos(ang) * (R + 8);
|
||
var y2 = cy + Math.sin(ang) * (R + 8);
|
||
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
|
||
}
|
||
|
||
// Aktiver Bogen (grün links = Einspeisung, rot rechts = Bezug)
|
||
var frac = (v - MIN) / (MAX - MIN);
|
||
var col = v < 0 ? '0,255,156' : '255,59,92';
|
||
ctx.save();
|
||
ctx.strokeStyle = 'rgba(' + col + ',0.9)';
|
||
ctx.lineWidth = 14;
|
||
ctx.shadowColor = 'rgba(' + col + ',0.6)';
|
||
ctx.shadowBlur = 12;
|
||
ctx.beginPath();
|
||
if (v >= 0) {
|
||
ctx.arc(cx, cy, R, Math.PI, Math.PI + frac * Math.PI);
|
||
} else {
|
||
ctx.arc(cx, cy, R, Math.PI + frac * Math.PI, 0);
|
||
}
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
|
||
// Nadel
|
||
var angN = Math.PI + frac * Math.PI;
|
||
var nx = cx + Math.cos(angN) * (R - 20);
|
||
var ny = cy + Math.sin(angN) * (R - 20);
|
||
ctx.save();
|
||
ctx.strokeStyle = 'rgba(' + col + ',1)';
|
||
ctx.lineWidth = 2;
|
||
ctx.shadowColor = 'rgba(' + col + ',0.8)';
|
||
ctx.shadowBlur = 8;
|
||
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(nx, ny); ctx.stroke();
|
||
ctx.restore();
|
||
|
||
// Digitalwert
|
||
ctx.textAlign = 'center';
|
||
ctx.fillStyle = 'rgba(' + col + ',1)';
|
||
ctx.font = 'bold 26px Consolas, monospace';
|
||
ctx.shadowColor = 'rgba(' + col + ',0.6)';
|
||
ctx.shadowBlur = 10;
|
||
ctx.fillText(Math.round(v) + ' W', cx, cy - R + 30);
|
||
ctx.shadowBlur = 0;
|
||
ctx.fillStyle = 'rgba(230,247,255,0.6)';
|
||
ctx.font = '12px Consolas, monospace';
|
||
ctx.fillText('LEISTUNG', cx, cy - R + 48);
|
||
}
|
||
|
||
// Weiche Kurve
|
||
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 drawPowerChart() {
|
||
var canvas = document.getElementById('v4_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(0, 10, 20, 0.6)';
|
||
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(0, 229, 255, 0.9)';
|
||
ctx.font = 'bold 12px Consolas, monospace';
|
||
ctx.fillText('LEISTUNG – 5 MIN', 12, 14);
|
||
|
||
if (!powerHistory.length) {
|
||
ctx.fillStyle = 'rgba(230,247,255,0.5)';
|
||
ctx.font = '12px Consolas, monospace';
|
||
ctx.fillText('warte auf Daten…', 12, padT + plotH / 2);
|
||
return;
|
||
}
|
||
|
||
var WINDOW = 5 * 60 * 1000;
|
||
var t1 = now, t0 = now - WINDOW;
|
||
|
||
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(0, 229, 255, 0.08)';
|
||
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();
|
||
}
|
||
|
||
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));
|
||
|
||
// Fläche
|
||
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,59,92,0.35)');
|
||
grad.addColorStop(Math.max(0, zeroFrac - 0.001), 'rgba(255,59,92,0.05)');
|
||
grad.addColorStop(Math.min(1, zeroFrac + 0.001), 'rgba(0,255,156,0.05)');
|
||
grad.addColorStop(1, 'rgba(0,255,156,0.35)');
|
||
ctx.fillStyle = grad;
|
||
ctx.fill();
|
||
|
||
// Nulllinie
|
||
if (minV < 0 && maxV > 0) {
|
||
ctx.save();
|
||
ctx.setLineDash([4, 4]);
|
||
ctx.strokeStyle = 'rgba(230,247,255,0.3)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.moveTo(padL, zeroY); ctx.lineTo(padL + plotW, zeroY); ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
// Linie
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
smoothCurve(ctx, pts);
|
||
ctx.strokeStyle = 'rgba(0, 229, 255, 0.95)';
|
||
ctx.lineWidth = 2.4;
|
||
ctx.lineJoin = 'round';
|
||
ctx.shadowColor = 'rgba(0, 229, 255, 0.55)';
|
||
ctx.shadowBlur = 8;
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
|
||
// Y-Beschriftung
|
||
ctx.fillStyle = 'rgba(230,247,255,0.55)';
|
||
ctx.font = '10px Consolas, monospace';
|
||
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
|
||
var lp = pts[pts.length - 1];
|
||
var col = lp.v < 0 ? '0,255,156' : '255,59,92';
|
||
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();
|
||
|
||
// Wert-Pille
|
||
var label = 'akt. Leistung: ' + Math.round(lp.v) + ' W';
|
||
ctx.font = 'bold 11px Consolas, monospace';
|
||
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);
|
||
}
|
||
|
||
function drawDailyChart() {
|
||
var canvas = document.getElementById('v4_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(0, 10, 20, 0.6)'; ctx.fillRect(0, 0, w, h);
|
||
|
||
var padL = 44, padR = 12, padT = 22, padB = 28;
|
||
var plotW = w - padL - padR, plotH = h - padT - padB;
|
||
|
||
var bez = num(lastValues[METER.bezug]);
|
||
var hist = METER.hist.map(function (x) { return num(lastValues[x]); });
|
||
var seq = [bez].concat(hist);
|
||
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));
|
||
}
|
||
dailyValues.reverse();
|
||
var tagVal = num(lastValues[METER.tag]) || 0;
|
||
|
||
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); }
|
||
|
||
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 dd = new Date(heute.getFullYear(), heute.getMonth(), heute.getDate() - (7 - i));
|
||
dayLabels.push(wochentage[dd.getDay()]);
|
||
}
|
||
|
||
ctx.textAlign = 'left'; ctx.fillStyle = 'rgba(0, 229, 255, 0.9)';
|
||
ctx.font = 'bold 12px Consolas, monospace';
|
||
ctx.fillText('TAGESBEZUG – 8 TAGE', 12, 14);
|
||
|
||
ctx.strokeStyle = 'rgba(0, 229, 255, 0.08)'; 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();
|
||
}
|
||
|
||
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);
|
||
var grad = ctx.createLinearGradient(0, y1, 0, y0);
|
||
if (isToday) {
|
||
grad.addColorStop(0, 'rgba(255,179,0,0.95)');
|
||
grad.addColorStop(1, 'rgba(255,179,0,0.25)');
|
||
} else {
|
||
grad.addColorStop(0, 'rgba(0,255,156,0.95)');
|
||
grad.addColorStop(1, 'rgba(0,255,156,0.25)');
|
||
}
|
||
ctx.fillStyle = grad;
|
||
roundRect(ctx, bx, y1, barW, bh, 4);
|
||
ctx.fill();
|
||
ctx.fillStyle = isToday ? 'rgba(255,179,0,0.95)' : 'rgba(230,247,255,0.85)';
|
||
ctx.font = 'bold 10px Consolas, monospace';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(dailyValues[i].toFixed(2), x, y1 - 5);
|
||
ctx.fillStyle = isToday ? 'rgba(255,179,0,0.95)' : 'rgba(230,247,255,0.65)';
|
||
ctx.font = 'bold 10px Consolas, monospace';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(dayLabels[i], x, h - 8);
|
||
}
|
||
|
||
var tagY = Y(tagVal);
|
||
ctx.save();
|
||
ctx.strokeStyle = 'rgba(0, 229, 255, 0.95)'; ctx.lineWidth = 2.5;
|
||
ctx.shadowColor = 'rgba(0, 229, 255, 0.55)'; ctx.shadowBlur = 8;
|
||
ctx.beginPath(); ctx.moveTo(padL, tagY); ctx.lineTo(padL + plotW, tagY); ctx.stroke();
|
||
ctx.restore();
|
||
|
||
ctx.fillStyle = 'rgba(230,247,255,0.55)'; ctx.font = '10px Consolas, monospace';
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText(maxV.toFixed(1) + ' kWh', padL - 6, padT + 8);
|
||
ctx.fillText(minV.toFixed(1) + ' kWh', padL - 6, padT + plotH);
|
||
|
||
var label = 'täglicher Bezug: ' + tagVal.toFixed(2) + ' kWh';
|
||
ctx.font = 'bold 11px Consolas, monospace';
|
||
var tw = ctx.measureText(label).width, bw = tw + 14, bx = w - padR - bw, by = 4, bh = 17;
|
||
ctx.fillStyle = 'rgba(0,229,255,0.16)'; roundRect(ctx, bx, by, bw, bh, 9); ctx.fill();
|
||
ctx.fillStyle = 'rgba(0,229,255,1)'; ctx.textAlign = 'left';
|
||
ctx.fillText(label, bx + 7, by + 12);
|
||
}
|
||
|
||
function drawWaterChart() {
|
||
var canvas = document.getElementById('v4_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(0, 10, 20, 0.6)'; ctx.fillRect(0, 0, w, h);
|
||
|
||
var padL = 50, padR = 16, padT = 22, padB = 28;
|
||
var plotW = w - padL - padR, plotH = h - padT - padB;
|
||
|
||
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));
|
||
}
|
||
dailyValues.reverse();
|
||
|
||
var maxV = Math.max.apply(null, dailyValues);
|
||
var minV = 0;
|
||
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); }
|
||
|
||
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 dd = new Date(heute.getFullYear(), heute.getMonth(), heute.getDate() - (dailyValues.length - 1 - i));
|
||
dayLabels.push(wochentage[dd.getDay()]);
|
||
}
|
||
|
||
ctx.textAlign = 'left'; ctx.fillStyle = 'rgba(0, 229, 255, 0.9)';
|
||
ctx.font = 'bold 12px Consolas, monospace';
|
||
ctx.fillText('WASSER – 7 TAGE', 12, 14);
|
||
|
||
ctx.strokeStyle = 'rgba(0, 229, 255, 0.08)'; 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();
|
||
}
|
||
|
||
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(0,229,255,0.95)');
|
||
grad.addColorStop(1, 'rgba(0,229,255,0.25)');
|
||
} else {
|
||
grad.addColorStop(0, 'rgba(0,229,255,0.75)');
|
||
grad.addColorStop(1, 'rgba(0,229,255,0.15)');
|
||
}
|
||
ctx.fillStyle = grad;
|
||
roundRect(ctx, bx, y1, barW, bh, 4);
|
||
ctx.fill();
|
||
ctx.fillStyle = isToday ? 'rgba(0,229,255,0.95)' : 'rgba(230,247,255,0.85)';
|
||
ctx.font = 'bold 10px Consolas, monospace';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(Math.round(dailyValues[i]), x, y1 - 5);
|
||
ctx.fillStyle = isToday ? 'rgba(0,229,255,0.95)' : 'rgba(230,247,255,0.65)';
|
||
ctx.font = 'bold 10px Consolas, monospace';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(dayLabels[i], x, h - 8);
|
||
}
|
||
|
||
var tagY = Y(tagVal);
|
||
ctx.save();
|
||
ctx.strokeStyle = 'rgba(255,179,0,0.95)'; ctx.lineWidth = 2.5;
|
||
ctx.setLineDash([6, 4]);
|
||
ctx.shadowColor = 'rgba(255,179,0,0.55)'; ctx.shadowBlur = 8;
|
||
ctx.beginPath(); ctx.moveTo(padL, tagY); ctx.lineTo(padL + plotW, tagY); ctx.stroke();
|
||
ctx.restore();
|
||
|
||
ctx.fillStyle = 'rgba(230,247,255,0.55)'; ctx.font = '10px Consolas, monospace';
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText(Math.round(maxV) + ' L', padL - 6, padT + 8);
|
||
ctx.fillText(Math.round(minV) + ' L', padL - 6, padT + plotH);
|
||
|
||
var label = 'täglicher Bezug: ' + Math.round(tagVal) + ' L';
|
||
ctx.font = 'bold 11px Consolas, monospace';
|
||
var tw = ctx.measureText(label).width, bw = tw + 14, bx = w - padR - bw, by = 4, bh = 17;
|
||
ctx.fillStyle = 'rgba(255,179,0,0.16)'; roundRect(ctx, bx, by, bw, bh, 9); ctx.fill();
|
||
ctx.fillStyle = 'rgba(255,179,0,1)'; ctx.textAlign = 'left';
|
||
ctx.fillText(label, bx + 7, by + 12);
|
||
}
|
||
|
||
// ====== Animation ======
|
||
function startChartAnimation() {
|
||
var last = 0;
|
||
function frame(ts) {
|
||
requestAnimationFrame(frame);
|
||
if (ts - last < 33) { return; }
|
||
last = ts;
|
||
gaugeShown += (gaugeTarget - gaugeShown) * 0.18;
|
||
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;
|
||
}
|
||
|
||
function flash(el, r) {
|
||
if (!el) { return; }
|
||
var cls = (r && !r.error) ? 'v4-flash-ok' : 'v4-flash-err';
|
||
el.classList.remove('v4-flash-ok', 'v4-flash-err');
|
||
void el.offsetWidth;
|
||
el.classList.add(cls);
|
||
setTimeout(function () { el.classList.remove(cls); }, 600);
|
||
}
|
||
|
||
function onClick(ev) {
|
||
var btn = ev.target.closest ? ev.target.closest('button') : null;
|
||
// Mehrfach-Schreibvorgang
|
||
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 });
|
||
toast('Schnellbefehl ausgeführt', allOk ? 'ok' : 'err');
|
||
});
|
||
return;
|
||
}
|
||
// 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;
|
||
}
|
||
// Heizung: Stepper (+/-) – Wert um 0.5°C aendern und schreiben
|
||
var hs = ev.target.closest ? ev.target.closest('[data-heat-step]') : null;
|
||
if (hs) {
|
||
var hI = parseInt(hs.getAttribute('data-heat-i'), 10);
|
||
var hStep = hs.getAttribute('data-heat-step');
|
||
var hDir = parseInt(hs.getAttribute('data-heat-dir'), 10);
|
||
var hObj = HEAT[hI - 1];
|
||
if (!hObj) { return; }
|
||
var cur = num(lastValues[hObj[hStep]]);
|
||
if (cur === null) { cur = 20; }
|
||
var newVal = Math.round((cur + hDir * 0.5) * 10) / 10;
|
||
if (!heatPlausible(hI, hStep, newVal)) { return; }
|
||
write(hObj[hStep], newVal).then(function (r) {
|
||
flash(btn, r);
|
||
toast(hObj.name + ' ' + hStep + ': ' + newVal.toFixed(1) + '°C', r && !r.error ? 'ok' : 'err');
|
||
setTimeout(refreshSettings, 300);
|
||
});
|
||
return;
|
||
}
|
||
// Poolpumpe
|
||
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) { toast('Bitte eine gültige Zahl eingeben.', 'err'); return; }
|
||
write(pm.getAttribute('data-var'), Math.round(pMin * 60)).then(function (r) {
|
||
flash(btn, r);
|
||
toast('Poolpumpe SOLL gesetzt', r && !r.error ? 'ok' : 'err');
|
||
readMany([POOL.max]).then(function (m) { merge(m); render(lastValues); }).catch(function () {});
|
||
});
|
||
return;
|
||
}
|
||
// WLED
|
||
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); toast('WLED Helligkeit', r && !r.error ? 'ok' : 'err'); });
|
||
} 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); toast('WLED Farbe', r && !r.error ? 'ok' : 'err'); });
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
// ====== Polling ======
|
||
var FAST_VARS = [METER.leist];
|
||
var ALL_VARS = null;
|
||
var SET_VARS = null;
|
||
var SLOW_EVERY = 4;
|
||
var tickCount = 0;
|
||
|
||
function merge(m) { Object.keys(m).forEach(function (k) { lastValues[k] = m[k]; }); }
|
||
|
||
function refreshSettings() {
|
||
readMany(SET_VARS).then(function (m) { merge(m); render(lastValues); }).catch(function () {});
|
||
}
|
||
|
||
function tick() {
|
||
if (pollBusy) { return; } // Mutex: kein überlappender Request
|
||
pollBusy = true;
|
||
tickCount++;
|
||
var isFull = (tickCount % SLOW_EVERY === 1);
|
||
var vars = isFull ? ALL_VARS : FAST_VARS;
|
||
readMany(vars).then(function (m) {
|
||
resetBackoff();
|
||
merge(m);
|
||
render(lastValues);
|
||
setStatus(true, 'ONLINE');
|
||
// readWemos nur jeden 10. vollen Zyklus, um Request-Last zu reduzieren
|
||
if (isFull) {
|
||
wemosCounter++;
|
||
if (wemosCounter >= 10) {
|
||
wemosCounter = 0;
|
||
readWemos();
|
||
}
|
||
}
|
||
pollBusy = false;
|
||
}).catch(function () {
|
||
increaseBackoff(); // exponentieller Backoff bei Fehler
|
||
setStatus(false, 'RECONNECT IN ' + Math.round(backoffMs / 1000) + 's');
|
||
pollBusy = false;
|
||
});
|
||
}
|
||
|
||
// Polling mit dynamischem Intervall (Backoff)
|
||
function scheduleNext() {
|
||
setTimeout(function () {
|
||
tick();
|
||
scheduleNext();
|
||
}, backoffMs);
|
||
}
|
||
|
||
// ====== Scanline-Toggle ======
|
||
var scanEl = document.getElementById('v4_scanlines');
|
||
var scanBtn = document.getElementById('scan_toggle');
|
||
if (scanBtn && scanEl) {
|
||
scanBtn.addEventListener('click', function () {
|
||
var on = scanEl.classList.toggle('v4-scanlines--on');
|
||
try { localStorage.setItem('v4_scanlines', on ? '1' : '0'); } catch (e) {}
|
||
});
|
||
try {
|
||
if (localStorage.getItem('v4_scanlines') === '1') {
|
||
scanEl.classList.add('v4-scanlines--on');
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
// ====== Start ======
|
||
document.addEventListener('click', onClick);
|
||
buildLightTiles();
|
||
buildRolladen();
|
||
buildSockets();
|
||
buildHeat();
|
||
|
||
ALL_VARS = allReadVars();
|
||
SET_VARS = settingsVars();
|
||
|
||
if (PREVIEW) {
|
||
// Sofort Dummy-Daten laden, kein Login/Polling
|
||
var pv = makePreviewValues();
|
||
Object.keys(pv).forEach(function (k) { lastValues[k] = pv[k]; });
|
||
render(lastValues);
|
||
setStatus(true, 'PREVIEW');
|
||
setText('sz_wemos', 'PREVIEW MODUS – keine SPS');
|
||
// Dummy-Leistung zyklisch variieren
|
||
setInterval(function () {
|
||
lastValues[METER.leist] = 300 + Math.sin(Date.now() / 2000) * 1500;
|
||
render(lastValues);
|
||
}, 500);
|
||
runBoot();
|
||
startChartAnimation();
|
||
return;
|
||
}
|
||
|
||
runBoot();
|
||
startChartAnimation();
|
||
|
||
// Erstes volles Lesen, dann zyklisch
|
||
readMany(ALL_VARS).then(function (m) {
|
||
merge(m);
|
||
render(lastValues);
|
||
setStatus(true, 'ONLINE');
|
||
readWemos();
|
||
// Heizungs-SOLL min/max einmalig beim Start lesen (werden sonst nie angezeigt)
|
||
refreshSettings();
|
||
}).catch(function () {
|
||
setStatus(false, 'OFFLINE');
|
||
});
|
||
scheduleNext(); // dynamisches Polling mit Backoff statt setInterval
|
||
})();
|