@OliverIO Stimmt!
// ============================================================
// renderHeizung — M3 Material You Styling (9 Räume Grid) - v1.4 (Binding Fix)
// ioBroker TypeScript — Inkl. Luftfeuchtigkeit, SYNC-FIX & MULTI-FENSTER
// CHANGELOG v1.4:
// - CRITICAL FIX: "Cannot subscribe..." / "undefined" Fehler in vis/vis-2 behoben!
// Das ApexCharts-Konfigurationsobjekt wird nun als umgewandelter JSON-String
// zusammengebaut und erst zur Laufzeit im Browser per JSON.parse() in ein
// Objekt verwandelt. Dadurch findet der ioBroker-Parser keine { ... }
// Muster mehr im HTML-Code, die er fälschlicherweise als Bindings interpretieren könnte.
// ============================================================
const DP_HEIZUNG_HTML: string = '0_userdata.0.dashboard.heizungHTML';
const DP_PRIVACY: string = '0_userdata.0.dashboard.privacyMode';
const DP_CHART_BRENNER: string = '0_userdata.0.dashboard.chartShowBrenner';
const DP_CHART_VALVE: string = '0_userdata.0.dashboard.chartShowValve';
const DP_CHART_ACT: string = '0_userdata.0.dashboard.chartShowAct';
const DP_CHART_SET: string = '0_userdata.0.dashboard.chartShowSet';
const DP_CHART_WINDOW: string = '0_userdata.0.dashboard.chartShowWindow';
const DP_CHART_HUMID: string = '0_userdata.0.dashboard.chartShowHumid';
const DP_HEIZUNG_AN: string = '0_userdata.0.Heizung.Heizung_an';
const DP_MODULATION: string = 'ems-esp.0.heatSources.actualModulation';
// INSTANZ DES HISTORY-ADAPTERS
const HISTORY_INSTANCE: string = 'history.0';
let globalHeatHistory: any[] = [];
let roomHistories: Record<string, { act: any[]; set: any[]; valve: any[]; heat: any[]; window: any[]; humid: any[] }> = {};
const M3: Record<string, string> = {
primary: 'var(--m3-primary, rgb(18, 66, 24))',
onPrimary: 'var(--m3-on-primary, rgb(255, 255, 255))',
primaryContainer: 'var(--m3-primary-container, rgb(43, 90, 45))',
onPrimaryContainer: 'var(--m3-on-primary-container, rgb(155, 208, 151))',
surfaceContainerLow: 'var(--m3-surface-container-low, rgb(243, 244, 237))',
surfaceContainerHighest: 'var(--m3-surface-container-highest, rgb(225, 227, 220))',
surfaceVariant: 'var(--m3-surface-variant, rgb(221, 229, 216))',
onSurface: 'var(--m3-on-surface, rgb(25, 28, 24))',
onSurfaceVariant: 'var(--m3-on-surface-variant, rgb(66, 73, 63))',
heatPrimary: 'var(--m3-error, rgb(186, 26, 26))',
heatContainer: 'var(--m3-error-container, rgb(255, 218, 214))',
onHeatContainer: 'var(--m3-on-error-container, rgb(65, 0, 2))',
pendingPrimary: 'rgb(176, 96, 0)',
pendingContainer: 'rgb(255, 220, 193)',
onPendingContainer: 'rgb(43, 20, 0)',
};
const ICONS: Record<string, string> = {
thermostat: 'M15 13V5A3 3 0 0 0 9 5V13A5 5 0 1 0 15 13M12 4A1 1 0 0 1 13 5V8H11V5A1 1 0 0 1 12 4Z',
fire: 'M17.5 11C15 7.5 13.5 8.5 12 6C10.5 8.5 9 7.5 6.5 11C4 14.5 5.5 19 12 19C18.5 19 20 14.5 17.5 11M12 17C9 17 8.5 14 10 12.5C11.5 11 12 12.5 12 12.5C12 12.5 12.5 11 14 12.5C15.5 14 15 17 12 17Z',
minus: 'M19 13H5V11H19V13Z',
plus: 'M19 13H13V19H11V13H5V11H11V5H13V11H19V13Z',
power: 'M16.56 5.44L15.11 6.89C16.84 7.94 18 9.83 18 12A6 6 0 0 1 12 18A6 6 0 0 1 6 12C6 9.83 7.16 7.94 8.88 6.88L7.44 5.44C5.36 6.88 4 9.28 4 12A8 8 0 0 0 12 20A8 8 0 0 0 20 12C20 9.28 18.64 6.88 16.56 5.44M11 3H13V13H11V3Z',
window: 'M3 3H21V21H3V3M5 5V11H11V5H5M13 5V11H19V5H13M5 13V19H11V13H5M13 13V19H19V13H13Z',
water: 'M12 20a6 6 0 0 1-6-6c0-4 6-10.75 6-10.75S18 10 18 14a6 6 0 0 1-6 6z'
};
interface RoomHeater {
id: string;
label: string;
anonLabel?: string;
actOid: string;
setOid: string;
valveOid: string;
windowOids?: string[];
humidOid?: string;
}
const rooms: RoomHeater[] = [
{ id: 'wohnzimmer', label: 'Wohnzimmer', actOid: 'alias.0.Heizung.Wohnzimmer.ACTUAL', setOid: 'alias.0.Heizung.Wohnzimmer.SET', valveOid: 'alias.0.Heizung.Wohnzimmer.VALVE', windowOids: ['alias.0.Sicherheit.Wohnzimmer.Fenster.STATE', 'alias.0.Sicherheit.Wohnzimmer.Tuer.STATE'], humidOid: 'alias.0.Klima.Wohnzimmer.HUMIDITY' },
{ id: 'schlafzimmer',label: 'Zimmer Mina', anonLabel: 'Schlafzimmer', actOid: 'alias.0.Heizung.Schlafzimmer.ACTUAL', setOid: 'alias.0.Heizung.Schlafzimmer.SET', valveOid: 'alias.0.Heizung.Schlafzimmer.VALVE', windowOids: ['alias.0.Sicherheit.Schlafzimmer.Fenster.STATE'], humidOid: 'alias.0.Klima.Schlafzimmer.HUMIDITY' },
{ id: 'kind1', label: 'Zimmer Nova', anonLabel: 'Kinderzimmer 1', actOid: 'alias.0.Heizung.Kind1.ACTUAL', setOid: 'alias.0.Heizung.Kind1.SET', valveOid: 'alias.0.Heizung.Kind1.VALVE', windowOids: ['alias.0.Sicherheit.Kinderzimmer1.Fenster.STATE'], humidOid: 'alias.0.Klima.Kind1.HUMIDITY' },
{ id: 'kind2', label: 'Zimmer Vasco', anonLabel: 'Kinderzimmer 2', actOid: 'alias.0.Heizung.Kind2.ACTUAL', setOid: 'alias.0.Heizung.Kind2.SET', valveOid: 'alias.0.Heizung.Kind2.VALVE', windowOids: ['alias.0.Sicherheit.Kinderzimmer2.Fenster.STATE'], humidOid: 'alias.0.Klima.Kind2.HUMIDITY' },
{ id: 'bad', label: 'Bad', actOid: 'alias.0.Heizung.Bad.ACTUAL', setOid: 'alias.0.Heizung.Bad.SET', valveOid: 'alias.0.Heizung.Bad.VALVE', windowOids: ['alias.0.Sicherheit.Bad.Fenster.STATE'], humidOid: 'alias.0.Klima.Bad.HUMIDITY' },
{ id: 'dachzimmer', label: 'Dachzimmer', actOid: 'alias.0.Heizung.Dachzimmer.ACTUAL', setOid: 'alias.0.Heizung.Dachzimmer.SET', valveOid: 'alias.0.Heizung.Dachzimmer.VALVE', windowOids: ['alias.0.Sicherheit.Dachzimmer.Fenster_Garten.STATE', 'alias.0.Sicherheit.Dachzimmer.Fenster_Strasse.STATE'], humidOid: 'alias.0.Klima.Dachzimmer.HUMIDITY' },
{ id: 'flur', label: 'Flur', actOid: 'alias.0.Heizung.Flur.ACTUAL', setOid: 'alias.0.Heizung.Flur.SET', valveOid: 'alias.0.Heizung.Flur.VALVE', windowOids: ['alias.0.Sicherheit.Haustuer.STATE'], humidOid: 'alias.0.Klima.Flur.HUMIDITY' },
{ id: 'gaesteklo', label: 'Gäste WC', actOid: 'alias.0.Heizung.GaesteKlo.ACTUAL', setOid: 'alias.0.Heizung.GaesteKlo.SET', valveOid: 'alias.0.Heizung.GaesteKlo.VALVE' },
{ id: 'hwr', label: 'HWR', actOid: 'alias.0.Heizung.HWR.ACTUAL', setOid: 'alias.0.Heizung.HWR.SET', valveOid: 'alias.0.Heizung.HWR.VALVE', windowOids: ['alias.0.Sicherheit.HWR.Fenster.STATE'] },
];
const COL_WIDTH: number = 202;
const HERO_WIDTH: number = 636;
const CARD_HEIGHT:number = 144;
const GUTTER: number = 15;
const SHADOW: string = '0px 1px 2px 0px rgba(0,0,0,0.3), 0px 1px 3px 1px rgba(0,0,0,0.15)';
function getSafeVal(oid: string | undefined, fallback: any = 0): any {
if (oid && existsState(oid)) {
const s = getState(oid);
return s ? s.val : fallback;
}
return fallback;
}
function svgIcon(name: string, color: string, size: number = 24): string {
const path = ICONS[name] || ICONS['thermostat'];
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" style="flex-shrink:0; transition: fill 0.3s ease;"><path fill="${color}" d="${path}"/></svg>`;
}
async function fetchHistoryData(oid: string | undefined, start: number, end: number, isRaw: boolean = false): Promise<any[]> {
if (!oid || !existsState(oid)) return [];
try {
const queryOptions = isRaw
? { start, end, aggregate: 'none', limit: 400, ignoreNull: true }
: { start, end, step: 1800000, aggregate: 'average', ignoreNull: true };
const result = await new Promise<any>(resolve => {
sendTo(HISTORY_INSTANCE, 'getHistory', { id: oid, options: queryOptions }, res => resolve(res));
});
if (result && result.result && result.result.length > 0) {
let cleanData = result.result
.filter((r: any) => r.val !== null)
.map((r: any) => {
let v = r.val;
if (typeof v === 'boolean') v = v ? 100 : 0;
else if (typeof v === 'string') v = Number(v) || 0;
return [r.ts, Math.round(v * 10) / 10];
})
.filter((r: any) => !isNaN(r[1]));
if (isRaw && cleanData.length > 0) {
const maxVal = Math.max(...cleanData.map((d: any) => d[1]));
if (maxVal <= 1.0 && maxVal > 0) cleanData.forEach((d: any) => (d[1] = d[1] * 100));
}
return cleanData;
}
} catch (e) {}
return [];
}
async function updateHistories(): Promise<void> {
const end = new Date().getTime();
const start = end - 24 * 60 * 60 * 1000;
globalHeatHistory = await fetchHistoryData(DP_MODULATION, start, end, true);
for (const room of rooms) {
const [actData, setData, valveData, humidData] = await Promise.all([
fetchHistoryData(room.actOid, start, end, false),
fetchHistoryData(room.setOid, start, end, false),
fetchHistoryData(room.valveOid, start, end, true),
fetchHistoryData(room.humidOid, start, end, false),
]);
let allWindowData: any[] = [];
if (room.windowOids && room.windowOids.length > 0) {
const wResults = await Promise.all(room.windowOids.map(wOid => fetchHistoryData(wOid, start, end, true)));
allWindowData = wResults.filter(wData => wData.length > 0);
}
roomHistories[room.id] = { act: actData, set: setData, valve: valveData, heat: globalHeatHistory, window: allWindowData, humid: humidData };
}
renderHeizung();
}
function renderHeroCard(activeRoomsCount: number, isZentralAn: boolean): string {
const mod = Number(getSafeVal(DP_MODULATION, 0)) || 0;
const bg = isZentralAn ? M3.primaryContainer : M3.surfaceContainerLow;
const fg = isZentralAn ? M3.onPrimaryContainer : M3.onSurfaceVariant;
const iconBg = isZentralAn ? M3.primary : M3.surfaceContainerHighest;
const iconFg = isZentralAn ? M3.onPrimary : M3.onSurfaceVariant;
return `<div style="width:${HERO_WIDTH}px; height:${CARD_HEIGHT}px; border-radius:12px; padding:20px 24px; box-sizing:border-box; background:${bg}; box-shadow:${SHADOW}; display:flex; align-items:center; justify-content:space-between; font-family:Roboto,sans-serif; transition:all 0.3s ease;"> <div style="display:flex; align-items:center; gap:20px;"> <div style="width:56px; height:56px; border-radius:28px; background:${iconBg}; display:flex; align-items:center; justify-content:center; transition:all 0.3s ease;"> ${svgIcon(isZentralAn ? 'fire' : 'power', iconFg, 32)} </div> <div> <div style="font-size:24px; font-weight:500; color:${fg}; margin-bottom:4px;">Zentralheizung</div> <div style="font-size:15px; font-weight:500; color:${fg}; opacity:0.8;">${isZentralAn ? `Heizbetrieb aktiv • ${mod}% Leistung` : 'Standby / Aus'}</div> </div> </div> <div style="text-align:right;"> <div style="font-size:36px; font-weight:600; color:${isZentralAn ? M3.heatPrimary : fg}; line-height:1;">${activeRoomsCount}</div> <div style="font-size:13px; font-weight:500; color:${fg}; text-transform:uppercase; letter-spacing:0.5px; margin-top:4px;">Räume fordern Wärme</div> </div> </div>`;
}
function renderRoomCard(room: RoomHeater, isZentralAn: boolean, isPrivacyMode: boolean): string {
const actVal = Number(getSafeVal(room.actOid, 0)) || 0;
const setVal = Number(getSafeVal(room.setOid, 0)) || 0;
const valveRaw = getSafeVal(room.valveOid, 0);
const humidVal = room.humidOid ? Number(getSafeVal(room.humidOid, 0)) || 0 : null;
const displayName = isPrivacyMode && room.anonLabel ? room.anonLabel : room.label;
let windowOpen = false;
if (room.windowOids && room.windowOids.length > 0) {
windowOpen = room.windowOids.some(oid => {
const raw = getSafeVal(oid, false);
return raw === true || raw === 1 || raw === '1' || raw === 'true';
});
}
const wantsHeat = (typeof valveRaw === 'number' && valveRaw > 0)
|| (typeof valveRaw === 'string' && Number(valveRaw) > 0)
|| valveRaw === true || valveRaw === 'true';
let bg: string, fg: string, fgSub: string, iconBg: string, iconFg: string;
if (wantsHeat && isZentralAn) {
bg = M3.heatContainer; fg = M3.onHeatContainer; fgSub = M3.onHeatContainer; iconBg = M3.heatPrimary; iconFg = '#ffffff';
} else if (wantsHeat && !isZentralAn) {
bg = M3.pendingContainer; fg = M3.onPendingContainer; fgSub = M3.onPendingContainer; iconBg = M3.pendingPrimary; iconFg = '#ffffff';
} else {
bg = M3.surfaceContainerLow; fg = M3.onSurface; fgSub = M3.onSurfaceVariant; iconBg = M3.surfaceContainerHighest; iconFg = M3.onSurfaceVariant;
}
const jsMinus = `(function(e, btn){ e.stopPropagation(); var card = btn.closest('.m3-room-card'); var span = card.querySelector('.target-temp'); var current = parseFloat(span.innerText); var newVal = current - 0.5; span.innerText = newVal.toFixed(1) + '°'; if(typeof vis!=='undefined'&&vis.conn&&vis.conn.setState) vis.conn.setState('${room.setOid}', newVal); else if(typeof vis!=='undefined'&&vis.setValue) vis.setValue('${room.setOid}', newVal); })(event, this);`;
const jsPlus = `(function(e, btn){ e.stopPropagation(); var card = btn.closest('.m3-room-card'); var span = card.querySelector('.target-temp'); var current = parseFloat(span.innerText); var newVal = current + 0.5; span.innerText = newVal.toFixed(1) + '°'; if(typeof vis!=='undefined'&&vis.conn&&vis.conn.setState) vis.conn.setState('${room.setOid}', newVal); else if(typeof vis!=='undefined'&&vis.setValue) vis.setValue('${room.setOid}', newVal); })(event, this);`;
const windowHtml = windowOpen
? `<div style="width:24px; height:24px; display:flex; align-items:center; justify-content:center; animation: blink 2s infinite;">${svgIcon('window', M3.heatPrimary, 20)}</div>`
: '';
const humidHtml = (humidVal !== null && humidVal > 0)
? `<div style="position:absolute; top:36px; right:-4px; width:40px; display:flex; justify-content:center; align-items:center; font-size:11px; font-weight:600; color:${fgSub}; opacity:0.85; letter-spacing:-0.5px;">${Math.round(humidVal)}%<svg width="10" height="10" viewBox="0 0 24 24" style="margin-left:1px"><path fill="currentColor" d="M12 20a6 6 0 0 1-6-6c0-4 6-10.75 6-10.75S18 10 18 14a6 6 0 0 1-6 6z"/></svg></div>`
: '';
const historyData = roomHistories[room.id] ?? { act: [], set: [], valve: [], heat: [], window: [], humid: [] };
return `<div id="wrapper_${room.id}" style="width:${COL_WIDTH}px; height:${CARD_HEIGHT}px; position:relative; z-index:1;">
<div id="card_${room.id}" class="m3-room-card" data-history='${JSON.stringify(historyData)}' onclick="if(typeof window.toggleCard === 'function') window.toggleCard(this, '${room.id}');" style="position:absolute; top:0; left:0; width:100%; height:100%; border-radius:12px; padding:14px 16px; box-sizing:border-box; background:${bg}; box-shadow:${SHADOW}; display:flex; flex-direction:column; font-family:Roboto,sans-serif; transition:all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); cursor:pointer; overflow:hidden;">
<div style="display:flex; justify-content:space-between; align-items:flex-start; width:100%;">
<div style="font-size:16px; font-weight:500; color:${fg}; max-width:145px; line-height:1.2; overflow:hidden; text-overflow:ellipsis; transition:color 0.3s ease;">${displayName}</div>
<div style="display:flex; gap:6px; align-items:flex-start; position:relative;">
${windowHtml}
<div style="width:32px; height:32px; border-radius:16px; background:${iconBg}; display:flex; align-items:center; justify-content:center; flex-shrink:0;">${svgIcon('thermostat', iconFg, 18)}</div>
${humidHtml}
</div>
</div>
<div style="display:flex; align-items:baseline; margin-top:-6px; width:100%;">
<div style="font-size:36px; font-weight:400; color:${fg}; letter-spacing:-1px;">${actVal.toFixed(1)}</div>
<div style="font-size:18px; font-weight:500; color:${fgSub}; margin-left:2px; opacity:0.7;">°C</div>
</div>
<div id="controls_${room.id}" style="display:flex; justify-content:space-between; align-items:center; width:100%; margin-top:auto;">
<div style="display:flex; flex-direction:column;">
<span style="font-size:11px; font-weight:500; text-transform:uppercase; letter-spacing:0.5px; color:${fgSub}; opacity:0.7;">Ziel</span>
<span class="target-temp" style="font-size:15px; font-weight:600; color:${fg};">${setVal.toFixed(1)}°</span>
</div>
<div style="display:flex; gap:6px;">
<div onclick="${jsMinus}" style="width:32px; height:32px; border-radius:16px; background:rgba(0,0,0,0.06); display:flex; align-items:center; justify-content:center; cursor:pointer; user-select:none; transition:opacity 0.1s; -webkit-tap-highlight-color:transparent;">${svgIcon('minus', fg, 20)}</div>
<div onclick="${jsPlus}" style="width:32px; height:32px; border-radius:16px; background:rgba(0,0,0,0.06); display:flex; align-items:center; justify-content:center; cursor:pointer; user-select:none; transition:opacity 0.1s; -webkit-tap-highlight-color:transparent;">${svgIcon('plus', fg, 20)}</div>
</div>
</div>
<div id="chart_wrapper_${room.id}" style="opacity:0; height:0px; width:100%; transition:opacity 0.4s ease; margin-top:0px; pointer-events:none;">
<div id="chart_${room.id}" style="width:100%; height:100%;"></div>
</div>
</div>
</div>`;
}
function renderHeizung(): void {
let activeRooms = 0;
const isZentralAn = getSafeVal(DP_HEIZUNG_AN, false) === true;
const isPrivacyMode = getSafeVal(DP_PRIVACY, false) === true;
const showBrenner = getSafeVal(DP_CHART_BRENNER, true) === true;
const showValve = getSafeVal(DP_CHART_VALVE, false) === true;
const showAct = getSafeVal(DP_CHART_ACT, true) === true;
const showSet = getSafeVal(DP_CHART_SET, true) === true;
const showWindow = getSafeVal(DP_CHART_WINDOW, true) === true;
const showHumid = getSafeVal(DP_CHART_HUMID, true) === true;
for (const room of rooms) {
const valveRaw = getSafeVal(room.valveOid, 0);
if ((typeof valveRaw === 'number' && valveRaw > 0)
|| (typeof valveRaw === 'string' && Number(valveRaw) > 0)
|| valveRaw === true || valveRaw === 'true') activeRooms++;
}
let cardsHtml = renderHeroCard(activeRooms, isZentralAn);
for (const room of rooms) cardsHtml += renderRoomCard(room, isZentralAn, isPrivacyMode);
const html = `
<style>
@keyframes blink { 0% { opacity: 1; } 50% { opacity: 0.3; } 100% { opacity: 1; } }
.m3-room-card * { white-space: nowrap; }
.apexcharts-legend { padding: 0 !important; margin-top: -10px !important; }
.apexcharts-legend-text { color: var(--m3-on-surface-variant) !important; font-family: Roboto, sans-serif !important; font-size: 11px !important; }
</style>
<div id="m3-dashboard-container"
data-cfg-brenner="${showBrenner}"
data-cfg-valve="${showValve}"
data-cfg-act="${showAct}"
data-cfg-set="${showSet}"
data-cfg-window="${showWindow}"
data-cfg-humid="${showHumid}"
style="position: relative; background: var(--m3-surface-container-low, rgb(237, 239, 232)); border-radius: 0 0 28px 28px; padding: 24px; display: flex; flex-wrap: wrap; align-content: flex-start; gap: ${GUTTER}px; width: 901px; box-sizing: border-box; transition: background-color 0.3s ease;">
${cardsHtml}
</div>
<script>
window.activeM3CardId = window.activeM3CardId || null;
window.toggleCard = function(el, roomId) {
if (window.activeM3CardId === roomId) { window.closeCard(el, roomId); return; }
if (window.activeM3CardId) { const oldEl = document.getElementById('card_' + window.activeM3CardId); if (oldEl) window.closeCard(oldEl, window.activeM3CardId); }
window.openCard(el, roomId);
};
window.closeCard = function(el, roomId) {
el.style.width = '100%'; el.style.height = '100%'; el.style.boxShadow = '${SHADOW}';
const chartWrapper = document.getElementById('chart_wrapper_' + roomId);
if (chartWrapper) {
chartWrapper.style.opacity = '0';
setTimeout(() => {
chartWrapper.style.height = '0px';
const chartDiv = document.getElementById('chart_' + roomId);
if (chartDiv) {
if (chartDiv._apexChart) { chartDiv._apexChart.destroy(); delete chartDiv._apexChart; }
chartDiv.innerHTML = '';
}
}, 400);
}
setTimeout(() => { el.parentElement.style.zIndex = '1'; }, 400); window.activeM3CardId = null;
};
window.openCard = function(el, roomId) {
window.activeM3CardId = roomId; const wrapper = el.parentElement; const container = document.getElementById('m3-dashboard-container');
wrapper.style.zIndex = '999'; const wrapperRect = wrapper.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
const expandedWidth = ${COL_WIDTH * 2 + GUTTER}; const expandedHeight = 310;
el.style.top = 'auto'; el.style.bottom = 'auto'; el.style.left = 'auto'; el.style.right = 'auto';
if (wrapperRect.left + expandedWidth > containerRect.right - 10) { el.style.right = '0'; } else { el.style.left = '0'; }
if (wrapperRect.top + expandedHeight > containerRect.bottom - 10) { el.style.bottom = '0'; } else { el.style.top = '0'; }
el.style.width = expandedWidth + 'px'; el.style.height = expandedHeight + 'px'; el.style.boxShadow = '0px 14px 28px rgba(0,0,0,0.35), 0px 10px 10px rgba(0,0,0,0.22)';
const chartWrapper = document.getElementById('chart_wrapper_' + roomId);
if (chartWrapper) { chartWrapper.style.height = '140px'; chartWrapper.style.marginTop = '16px'; setTimeout(() => { chartWrapper.style.opacity = '1'; window.renderChart(el, roomId); }, 450); }
};
window.renderChart = function(el, roomId) {
const chartDiv = el.querySelector('#chart_' + roomId);
if (!chartDiv || chartDiv.innerHTML.trim() !== '') return;
const rawData = el.getAttribute('data-history'); let chartData = { act: [], set: [], valve: [], heat: [], window: [], humid: [] };
try { chartData = JSON.parse(rawData); } catch(e) {}
if (chartData.act.length === 0 && chartData.set.length === 0) { chartDiv.innerHTML = '<div style="color:var(--m3-on-surface-variant); font-size:13px; text-align:center; padding-top:50px; font-weight:500;">Keine Historie</div>'; return; }
const rootStyles = getComputedStyle(document.documentElement);
const colorIst = rootStyles.getPropertyValue('--m3-primary').trim() || '#4caf50';
const colorSoll = rootStyles.getPropertyValue('--m3-outline').trim() || '#9e9e9e';
const colorVentil = rootStyles.getPropertyValue('--m3-error').trim() || '#ffb4ab';
const colorBrenner = '#ff9800';
const colorFenster = '#00bfff';
const colorHumid = '#2196f3';
const container = el.closest('#m3-dashboard-container');
const cfgShowBrenner = container.getAttribute('data-cfg-brenner') === 'true';
const cfgShowValve = container.getAttribute('data-cfg-valve') === 'true';
const cfgShowAct = container.getAttribute('data-cfg-act') === 'true';
const cfgShowSet = container.getAttribute('data-cfg-set') === 'true';
const cfgShowWindow = container.getAttribute('data-cfg-window') === 'true';
const cfgShowHumid = container.getAttribute('data-cfg-humid') === 'true';
function drawGraph() {
chartDiv.innerHTML = '';
var s_series = []; var s_colors = [];
var actHasData = chartData.act && chartData.act.length > 0;
var setHasData = chartData.set && chartData.set.length > 0;
var minTemp = 999; var maxTemp = -999;
if (cfgShowAct && actHasData) { for (var i = 0; i < chartData.act.length; i++) { if (chartData.act[i][1] < minTemp) minTemp = chartData.act[i][1]; if (chartData.act[i][1] > maxTemp) maxTemp = chartData.act[i][1]; } }
if (cfgShowSet && setHasData) { for (var j = 0; j < chartData.set.length; j++) { if (chartData.set[j][1] < minTemp) minTemp = chartData.set[j][1]; if (chartData.set[j][1] > maxTemp) maxTemp = chartData.set[j][1]; } }
if (minTemp === 999) { minTemp = 15; maxTemp = 25; } else { minTemp = Math.floor(minTemp) - 1; maxTemp = Math.ceil(maxTemp) + 1; }
if (cfgShowHumid && chartData.humid && chartData.humid.length > 0) { s_series.push({ name: 'Luftfeuchte', type: 'area', data: chartData.humid }); s_colors.push(colorHumid); }
if (cfgShowBrenner) { s_series.push({ name: 'Brenner', type: 'area', data: chartData.heat || [] }); s_colors.push(colorBrenner); }
if (cfgShowValve) { s_series.push({ name: 'Ventil', type: 'line', data: chartData.valve || [] }); s_colors.push(colorVentil); }
if (cfgShowAct) { s_series.push({ name: 'Ist-Temp', type: 'area', data: chartData.act || [] }); s_colors.push(colorIst); }
if (cfgShowSet) { s_series.push({ name: 'Ziel-Temp', type: 'line', data: chartData.set || [] }); s_colors.push(colorSoll); }
var windowAnnotations = [];
if (cfgShowWindow && chartData.window && chartData.window.length > 0) {
for (var w = 0; w < chartData.window.length; w++) {
var wData = chartData.window[w]; var inWindowOpen = false; var winStart = null;
for (var k = 0; k < wData.length; k++) { var ts = wData[k][0]; var val = wData[k][1]; if (val > 0 && !inWindowOpen) { inWindowOpen = true; winStart = ts; } else if (val === 0 && inWindowOpen) { inWindowOpen = false; windowAnnotations.push({ x: winStart, x2: ts, fillColor: colorFenster, opacity: 0.15 }); } }
if (inWindowOpen && winStart) windowAnnotations.push({ x: winStart, x2: new Date().getTime(), fillColor: colorFenster, opacity: 0.15 });
}
}
if (cfgShowWindow) { let safeTime = (actHasData) ? chartData.act[0][0] : new Date().getTime() - 3600000; s_series.push({ name: 'Fenster offen', type: 'area', data: [[safeTime, 0]] }); s_colors.push(colorFenster); }
if (s_series.length === 0) { chartDiv.innerHTML = '<div style="color:var(--m3-on-surface-variant); font-size:13px; text-align:center; padding-top:50px; font-weight:500;">Alle Graphen ausgeblendet</div>'; return; }
// CRITICAL FIX v1.4: Komplettes Apex-Config-Objekt als JSON-String generieren und parsen
var configString = '{"chart":{"type":"line","height":140,"width":"100%","parentHeightOffset":0,"toolbar":{"show":false},"animations":{"enabled":true,"easing":"easeinout","speed":800,"dynamicAnimation":{"enabled":false}}},"stroke":{"curve":["smooth","smooth","stepline","smooth","stepline","stepline"],"width":[0,2,2,3,2,0],"dashArray":[0,0,0,0,4,0]},"fill":{"type":["solid","gradient","solid","gradient","solid","solid"],"opacity":[0.1,1,1,1,1,0],"gradient":{"shadeIntensity":1,"opacityFrom":0.45,"opacityTo":0.0,"stops":[0,100]}},"dataLabels":{"enabled":false},"legend":{"show":true,"position":"top","horizontalAlign":"right"},"tooltip":{"theme":"dark","x":{"format":"HH:mm"}},"xaxis":{"type":"datetime","labels":{"show":false},"axisBorder":{"show":false},"axisTicks":{"show":false},"tooltip":{"enabled":false}},"grid":{"show":false}}';
var options = JSON.parse(configString);
options.series = s_series;
options.colors = s_colors;
options.annotations = { xaxis: windowAnnotations };
options.yaxis = [];
if (cfgShowHumid) options.yaxis.push({ min: 0, max: 100, show: false });
if (cfgShowBrenner) options.yaxis.push({ min: 0, max: 100, show: false });
if (cfgShowValve) options.yaxis.push({ min: 0, max: 100, show: false });
if (cfgShowAct) options.yaxis.push({ min: minTemp, max: maxTemp, show: false, tickAmount: 5 });
if (cfgShowSet) options.yaxis.push({ min: minTemp, max: maxTemp, show: false, tickAmount: 5 });
if (cfgShowWindow) options.yaxis.push({ min: minTemp, max: maxTemp, show: false, tickAmount: 5 });
if (chartDiv._apexChart) { chartDiv._apexChart.destroy(); delete chartDiv._apexChart; }
try { var chart = new ApexCharts(chartDiv, options); chart.render(); chartDiv._apexChart = chart; }
catch(err) { console.error(err); chartDiv.innerHTML = '<div style="color:var(--m3-error, red); font-size:13px; text-align:center; padding-top:50px;">Graphen-Fehler</div>'; }
}
if (typeof ApexCharts === 'undefined') {
chartDiv.innerHTML = '<div style="color:var(--m3-on-surface-variant); font-size:13px; text-align:center; padding-top:50px; font-weight:500;">Lade Chart-Engine...</div>';
if (!window.apexLoadingPromise) { window.apexLoadingPromise = new Promise((resolve) => { const tempDefine = window.define; window.define = undefined; let script = document.createElement('script'); script.src = 'https://cdn.jsdelivr.net/npm/apexcharts@5.6.0/dist/apexcharts.min.js'; script.onload = () => { window.define = tempDefine; resolve(); }; document.head.appendChild(script); }); }
window.apexLoadingPromise.then(drawGraph);
} else { drawGraph(); }
};
</script>`;
setState(DP_HEIZUNG_HTML, html, true);
}
// === INIT ===
function startHeizungRenderer(): void {
on({ id: [DP_CHART_BRENNER, DP_CHART_VALVE, DP_CHART_ACT, DP_CHART_SET, DP_CHART_WINDOW, DP_CHART_HUMID], change: 'any' }, () => renderHeizung());
on({ id: DP_HEIZUNG_AN, change: 'any' }, () => renderHeizung());
on({ id: DP_MODULATION, change: 'any' }, () => renderHeizung());
on({ id: DP_PRIVACY, change: 'any' }, () => renderHeizung());
rooms.forEach(room => {
if (room.actOid) on({ id: room.actOid, change: 'any' }, () => renderHeizung());
if (room.setOid) on({ id: room.setOid, change: 'any' }, () => renderHeizung());
if (room.valveOid) on({ id: room.valveOid, change: 'any' }, () => renderHeizung());
if (room.humidOid) on({ id: room.humidOid, change: 'any' }, () => renderHeizung());
if (room.windowOids) {
room.windowOids.forEach(oid => on({ id: oid, change: 'any' }, () => renderHeizung()));
}
});
updateHistories();
schedule('*/15 * * * *', updateHistories);
log('[Heizung] Renderer v1.4 (History Edition & Binding Fix) gestartet.');
}
const chartDPs: Array<{ id: string; val: boolean; name: string }> = [
{ id: DP_CHART_BRENNER, val: true, name: 'Chart: Brenner anzeigen' },
{ id: DP_CHART_VALVE, val: false, name: 'Chart: Ventil anzeigen' },
{ id: DP_CHART_ACT, val: true, name: 'Chart: Ist-Temp anzeigen' },
{ id: DP_CHART_SET, val: true, name: 'Chart: Ziel-Temp anzeigen' },
{ id: DP_CHART_WINDOW, val: true, name: 'Chart: Fenster anzeigen' },
{ id: DP_CHART_HUMID, val: true, name: 'Chart: Luftfeuchte anzeigen' },
];
function ensureChartDPs(index: number, done: () => void): void {
if (index >= chartDPs.length) { done(); return; }
const dp = chartDPs[index];
if (!existsState(dp.id)) {
createState(dp.id, dp.val, { type: 'boolean', name: dp.name, role: 'switch', read: true, write: true }, () => ensureChartDPs(index + 1, done));
} else {
ensureChartDPs(index + 1, done);
}
}
ensureChartDPs(0, () => {
if (!existsState(DP_HEIZUNG_HTML)) {
createState(DP_HEIZUNG_HTML, '', { type: 'string', name: 'Heizung HTML', role: 'html' }, startHeizungRenderer);
} else {
startHeizungRenderer();
}
});