NEWS
Google Material 3 / Material You - Vis2
-
Weil die Wechsel der Themes und die sonstigen Animationen hier etwas schwer zu erkennen sind, habe ich ein Video gemacht. https://youtu.be/cMpPOGAlOhc
-
Ich werde hier mal einige meiner Skripte posten:
Das Theme-Engine
Was macht das Skript?
Dieses Skript arbeitet als Theme-Engine für ioBroker vis (und vis-2), basierend auf dem Material 3 (Material You) Design von Google. Es nimmt ein von euch festgelegtes Hintergrundbild, analysiert dessen Farbspektrum und generiert daraus automatisch eine perfekt abgestimmte CSS-Farbpalette. Zusätzlich enthält das Skript eine automatische Astro-Steuerung: Sobald die Sonne in der Dämmerung einen bestimmten Winkel (standardmäßig -3 Grad) unterschreitet, wechselt das gesamte Dashboard nahtlos und weich animiert in den Dark-Mode. Alternativ können auch eigene JSON-Farbpaletten importiert werden. Diese JSON könnt ihr am einfachsten mit dem Material Theme Builder erstellen. Die Automatikfunktion ist hier technisch identisch zu der in meinem Script.
Was muss vorbereitet werden?
Damit das Skript Bilder analysieren kann, müssen zwingend zwei zusätzliche NPM-Module installiert werden. Geht dazu im ioBroker auf die Instanz-Einstellungen eures JavaScript-Adapters und tragt im Reiter "Zusätzliche NPM-Module" folgendes ein:
- @material/material-color-utilities
- jimp
Bitte speichert die Instanz ab und wartet einen Moment, bis der Adapter die Module heruntergeladen hat. Startet ihr das Skript ohne diese Einträge, hagelt es sofort Fehlermeldungen im Log, da die Werkzeuge für die komplexe Farbberechnung fehlen.
Wie wendet man das CSS in der vis an?
Wenn das Skript läuft, legt es unter anderem den Datenpunkt "themeCSS" an (standardmäßig unter 0_userdata.0.dashboard.themeCSS). Um das dynamische Theme nun in eurer Visualisierung zu aktivieren, geht ihr so vor:
- Öffnet eure vis im Editor.
- Zieht ein einfaches "Basic - String" (oder "Basic - HTML") Widget auf eure Ansicht.
- Tragt in den Widget-Einstellungen unter HTML / Inhalt einfach den Pfad zum Datenpunkt in geschweiften Klammern ein:
{0_userdata.0.dashboard.themeCSS}(Falls ihr den Pfad im Skript ganz oben geändert habt, müsst ihr hier natürlich euren eigenen Pfad eintragen).
Das war es schon. Das Widget injiziert nun das gesamte generierte CSS in eure Ansicht. Sobald das Skript ab jetzt die Farben ändert oder in den Dark-Mode schaltet, reagiert eure komplette vis automatisch darauf.
// ============================================================ // M3 Theme Master V7 — Astro, Monet, JSON & Always-On Wallpaper // ============================================================ // WICHTIG - VOR DEM START: // Damit das Skript Bilder analysieren und Farben berechnen kann, // müssen im JavaScript-Adapter unter "Zusätzliche NPM-Module" // folgende zwei Module eingetragen werden: // @material/material-color-utilities, jimp // ============================================================ // === 1. KONFIGURATION === // Wo sollen die Datenpunkte für das Dashboard angelegt werden? const BASE_PATH = '0_userdata.0.dashboard'; // Datenpunkt für den Sonnenstand (Elevation / Sonnenhöhe in Grad). // Wird für die automatische Umschaltung in den Darkmode benötigt (-3 Grad Regel). // Falls du keinen hast, leer lassen ('') – das Skript nutzt dann die ungenaueren ioBroker-Bordmittel. const DP_SUN_ELEVATION = ''; // z.B. 'javascript.0.Astro.Elevation' oder 'suncalc.0.elevation' // ============================================================ // === AB HIER NICHTS MEHR ÄNDERN === // ============================================================ const { themeFromSourceColor, QuantizerCelebi, Score } = require('@material/material-color-utilities'); const Jimp = require('jimp'); // --- DATENPUNKTE --- const DP_THEME_CSS = `${BASE_PATH}.themeCSS`; const DP_DARK_MODE = `${BASE_PATH}.darkMode`; const DP_AUTO_MODE = `${BASE_PATH}.autoTheme`; const DP_THEME_JSON = `${BASE_PATH}.themeJSON`; const DP_IMAGE_PATH = `${BASE_PATH}.themeImagePath`; const DP_USE_JSON = `${BASE_PATH}.themeUseJson`; const DEFAULT_THEME = { schemes: { light: { primary: '#3d6838', primaryContainer: '#bef0b2', onPrimaryContainer: '#002202', surfaceContainerLow: '#f3f4ed', surfaceContainerHighest: '#e1e3dc', surfaceVariant: '#dde5d8', onSurface: '#191c18', onSurfaceVariant: '#42493f', outline: '#72796f', outlineVariant: '#c1c9bc', }, dark: { primary: '#a3d398', primaryContainer: '#265022', onPrimaryContainer: '#bef0b2', surfaceContainerLow: '#191d17', surfaceContainerHighest: '#323630', surfaceVariant: '#42493f', onSurface: '#e0e4da', onSurfaceVariant: '#c2c8bc', outline: '#8c9388', outlineVariant: '#42493f', }, }, }; function camelToKebab(str) { return str.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2').toLowerCase(); } function toHex(val) { if (typeof val === 'string') return val; return '#' + (val & 0x00ffffff).toString(16).padStart(6, '0'); } // === BILD-ANALYSE (Monet) === async function extractColorFromImage(path) { try { let realPath = path.trim(); if (realPath.startsWith('vis-') || realPath.startsWith('vis.')) { realPath = '/' + realPath; } // Standard ioBroker Pfad für Web-Dateien (Linux/Raspberry) let serverPath = realPath; if (serverPath.startsWith('/vis')) { serverPath = '/opt/iobroker/iobroker-data/files' + serverPath; } const image = await Jimp.read(serverPath); image.resize(128, Jimp.AUTO); // Extrem verkleinern für Speed const pixels = []; image.scan(0, 0, image.bitmap.width, image.bitmap.height, function (x, y, idx) { const r = this.bitmap.data[idx + 0]; const g = this.bitmap.data[idx + 1]; const b = this.bitmap.data[idx + 2]; const a = this.bitmap.data[idx + 3]; const argb = (a << 24) | (r << 16) | (g << 8) | b; pixels.push(argb); }); const quantizedColors = QuantizerCelebi.quantize(pixels, 128); const bestColors = Score.score(quantizedColors); return { color: bestColors[0], webPath: realPath }; } catch (error) { log(`[M3 Theme] Bild konnte nicht analysiert werden (${path}): ${error.message}`, 'warn'); return null; } } // === HAUPT-LOGIK === async function updateTheme() { const isDark = getState(DP_DARK_MODE).val; const useJson = getState(DP_USE_JSON).val; const imgPath = getState(DP_IMAGE_PATH).val; let scheme = null; let bgImageUrl = ''; try { // 1. HINTERGRUNDBILD VORBEREITEN if (imgPath && imgPath.trim() !== '') { let webPath = imgPath.trim(); if (webPath.startsWith('vis-') || webPath.startsWith('vis.')) webPath = '/' + webPath; bgImageUrl = `url('${webPath}')`; } // 2. FARBEN: JSON Priorität if (useJson) { const rawJson = getState(DP_THEME_JSON).val; if (rawJson && rawJson.trim().startsWith('{')) { const parsed = JSON.parse(rawJson); if (parsed.schemes) { scheme = isDark ? parsed.schemes.dark : parsed.schemes.light; log(`[M3 Theme] Theme erfolgreich aus JSON geladen.`); } } else { log(`[M3 Theme] JSON-Schalter aktiv, aber JSON fehlerhaft oder leer.`, 'warn'); } } // 3. FARBEN: Bildanalyse (Monet) if (!scheme && imgPath && imgPath.trim() !== '') { const result = await extractColorFromImage(imgPath); if (result) { const theme = themeFromSourceColor(result.color); scheme = isDark ? theme.schemes.dark.toJSON() : theme.schemes.light.toJSON(); const nPalette = theme.palettes.neutral; if (isDark) { scheme.surfaceContainerLowest = nPalette.tone(4); scheme.surfaceContainerLow = nPalette.tone(10); scheme.surfaceContainer = nPalette.tone(12); scheme.surfaceContainerHigh = nPalette.tone(17); scheme.surfaceContainerHighest = nPalette.tone(22); scheme.surfaceDim = nPalette.tone(6); scheme.surfaceBright = nPalette.tone(24); } else { scheme.surfaceContainerLowest = nPalette.tone(100); scheme.surfaceContainerLow = nPalette.tone(96); scheme.surfaceContainer = nPalette.tone(94); scheme.surfaceContainerHigh = nPalette.tone(92); scheme.surfaceContainerHighest = nPalette.tone(90); scheme.surfaceDim = nPalette.tone(87); scheme.surfaceBright = nPalette.tone(98); } log(`[M3 Theme] Farben perfekt aus Hintergrundbild berechnet.`); } } } catch (e) { log(`[M3 Theme] Fehler bei der Berechnung: ${e.message}`, 'error'); } // 4. FALLBACK if (!scheme) scheme = isDark ? DEFAULT_THEME.schemes.dark : DEFAULT_THEME.schemes.light; // --- CSS ZUSAMMENBAUEN --- let cssVars = ''; for (const [key, value] of Object.entries(scheme)) { cssVars += ` --m3-${camelToKebab(key)}: ${toHex(value)};\n`; } cssVars += ` --m3-bg-image: ${bgImageUrl !== '' ? bgImageUrl : 'none'};\n`; const css = `<style> :root { ${cssVars} } /* 1. Weicher Übergang für den Haupt-Hintergrund von vis-2 */ body, html, #root, #vis_container, .vis-view, .app-container { background-image: var(--m3-bg-image) !important; background-size: cover !important; background-position: center center !important; background-attachment: fixed !important; background-repeat: no-repeat !important; background-color: var(--m3-surface-container-lowest) !important; transition: background-color 1.5s ease-in-out, color 1.5s ease-in-out !important; } /* 2. Sanfter Fade für Dashboard-Elemente */ .vis-view *, .app-container * { transition-property: background-color, background, color, border-color, fill; transition-duration: 1.0s; transition-timing-function: ease-in-out; } </style>`; setState(DP_THEME_CSS, css, true); } // === ASTRO-LOGIK === function checkAstro() { if (getState(DP_AUTO_MODE).val === true) { let elevation = 0; if (DP_SUN_ELEVATION && existsState(DP_SUN_ELEVATION)) { let rawVal = getState(DP_SUN_ELEVATION).val; if (typeof rawVal === 'string') rawVal = rawVal.replace(',', '.'); elevation = parseFloat(rawVal); } else { // Fallback auf ungenauere ioBroker Standard-Funktion elevation = isAstroDay() ? 10 : -10; } if (isNaN(elevation)) { log('[M3 Theme] Fehler: Sonnenstand konnte nicht berechnet werden.', 'warn'); elevation = isAstroDay() ? 10 : -10; } // Dunkel ab -3 Grad const shouldBeDark = elevation < -3; if (getState(DP_DARK_MODE).val !== shouldBeDark) { setState(DP_DARK_MODE, shouldBeDark); log(`[M3 Theme] Auto-Astro Umschaltung: Dark Mode ist jetzt ${shouldBeDark}`); } } } // === INIT & SUBSCRIPTIONS === createState(DP_IMAGE_PATH, '', { type: 'string', name: 'M3 Wallpaper Pfad', role: 'text' }, () => { createState(DP_USE_JSON, false, { type: 'boolean', name: 'M3 Use JSON Theme', role: 'switch' }, () => { createState(DP_DARK_MODE, false, { type: 'boolean', name: 'M3 Dark Mode Toggle', role: 'switch' }, () => { createState(DP_AUTO_MODE, false, { type: 'boolean', name: 'M3 Auto Theme (Astro)', role: 'switch' }, () => { createState(DP_THEME_CSS, '', { type: 'string', name: 'M3 Theme CSS', role: 'html' }, () => { createState(DP_THEME_JSON, '', { type: 'string', name: 'JSON Import', role: 'text' }, () => { on({ id: [DP_DARK_MODE, DP_THEME_JSON, DP_IMAGE_PATH, DP_USE_JSON], change: 'ne' }, () => updateTheme()); on({ id: DP_AUTO_MODE, change: 'any' }, () => checkAstro()); if (DP_SUN_ELEVATION && existsState(DP_SUN_ELEVATION)) { on({ id: DP_SUN_ELEVATION, change: 'ne' }, () => checkAstro()); } checkAstro(); updateTheme(); }); }); }); }); }); }); -
Hier mein "Favoriten-Skript" mit den bei mir am häufigsten genutzten Funktionen.
Was macht das Skript?
Dieses Skript generiert ein interaktives Widget für ioBroker vis (oder vis-2), das im aktuellen Material 3 (Material You) Design gehalten ist. Es erzeugt eine Kachel-Ansicht (Grid), in der ihr eure wichtigsten Geräte ("Favoriten") als Schalter (Toggles) oder als Schieberegler (Slider) hinterlegen könnt.
Die Besonderheiten:
- Theme-Engine Ready: Das Skript nutzt CSS-Variablen. Wenn ihr meinen "M3 Theme Master" (siehe anderen Thread) nutzt, passen sich die Farben dieser Kacheln live und vollautomatisch an euer Hintergrundbild oder den Dark-Mode an!
- Touch-Optimiert: Eingebaute Wisch-Gesten für Slider und der typische "Ripple-Effekt" (Wasser-Welle) beim Antippen.
- Keine Widgets nötig: Es wird reiner HTML-Code generiert, der extrem performant ist.
- Momentary-Funktion: Unterstützt Taster (die nach dem Drücken wieder auf "aus" springen, z.B. für Staubsauger-Routinen).
Was muss vorbereitet werden?
Eigentlich nichts Großes. Ihr müsst nur das Array
devicesim Skript mit euren eigenen ioBroker-Datenpunkten füllen. Es gibt zwei Typen:toggle(für normales An/Aus) undslider(für Dimmer oder Markisen). Im Skript sind ein paar Beispiele hinterlegt, die ihr anpassen könnt.Wie wendet man das Skript an?
Das Skript legt den Datenpunkt
0_userdata.0.dashboard.favoritenHTMLan.- Zieht einfach ein "Basic - String" (oder "Basic - HTML") Widget in eure vis.
- Tragt unter HTML / Inhalt den Datenpunkt-Namen in geschweiften Klammern ein:
{0_userdata.0.dashboard.favoritenHTML} - Zieht das Widget breit genug, damit die Kacheln Platz haben (das Grid bricht automatisch um).
Das Skript (TypeScript)
// ============================================================ // renderFavoriten — M3 Material You Styling (THEME ENGINE READY) // ioBroker TypeScript — rendert HTML in Datenpunkt für vis2 // ============================================================ // === KONFIGURATION: WO SOLL DAS HTML GESPEICHERT WERDEN? === const DP_FAVORITEN = '0_userdata.0.dashboard.favoritenHTML'; // === GRID-KONFIGURATION === const COL_WIDTH = 202; // Breite einer 1-Spalten-Kachel (in px) const GUTTER = 15; // Abstand zwischen den Kacheln (in px) // ============================================================ // === AB HIER NUR NOCH ANPASSEN, WENN IHR WISST WAS IHR TUT === // ============================================================ // === M3 FARBEN (Dynamisch gekoppelt an den Theme-Master!) === const M3 = { primary: 'var(--m3-primary)', onPrimary: 'var(--m3-on-primary)', primaryContainer: 'var(--m3-primary-container)', onPrimaryContainer: 'var(--m3-on-primary-container)', surfaceContainerLow: 'var(--m3-surface-container-low)', surfaceContainerHighest:'var(--m3-surface-container-highest)', surfaceVariant: 'var(--m3-surface-variant)', onSurface: 'var(--m3-on-surface)', onSurfaceVariant: 'var(--m3-on-surface-variant)', outlineVariant: 'var(--m3-outline-variant)' }; // === SVG ICONS (Material Design Icons / MDI) === // Sucht euch neue Icons auf materialdesignicons.com, kopiert den SVG-Pfad (d) und fügt ihn hier ein. const ICONS: Record<string, string> = { 'hygge': 'M15.5,9.63C15.31,6.84 14.18,4.12 12.06,2C9.92,4.14 8.74,6.86 8.5,9.63C9.79,10.31 10.97,11.19 12,12.26C13.03,11.2 14.21,10.32 15.5,9.63M12,15.45C9.85,12.17 6.18,10 2,10C2,20 11.32,21.89 12,22C12.68,21.88 22,20 22,10C17.82,10 14.15,12.17 12,15.45Z', 'garten': 'M12,2L6.36,10.61C6.07,11.06 6.4,11.66 6.94,11.66H9L6.11,16.5A0.75,0.75 0 0,0 6.75,17.66H11V21H13V17.66H17.25A0.75,0.75 0 0,0 17.89,16.5L15,11.66H17.06C17.6,11.66 17.93,11.06 17.64,10.61L12,2Z', 'vacuum': 'M12,2C14.65,2 17.19,3.06 19.07,4.93L17.65,6.35C16.15,4.85 14.12,4 12,4C9.88,4 7.84,4.84 6.35,6.35L4.93,4.93C6.81,3.06 9.35,2 12,2M3.66,6.5L5.11,7.94C4.39,9.17 4,10.57 4,12A8,8 0 0,0 12,20A8,8 0 0,0 20,12C20,10.57 19.61,9.17 18.88,7.94L20.34,6.5C21.42,8.12 22,10.04 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12C2,10.04 2.58,8.12 3.66,6.5M12,6A6,6 0 0,1 18,12C18,13.59 17.37,15.12 16.24,16.24L14.83,14.83C14.08,15.58 13.06,16 12,16C10.94,16 9.92,15.58 9.17,14.83L7.76,16.24C6.63,15.12 6,13.59 6,12A6,6 0 0,1 12,6M12,8A1,1 0 0,0 11,9A1,1 0 0,0 12,10A1,1 0 0,0 13,9A1,1 0 0,0 12,8Z', 'dining': 'M8.1,13.34L3.91,9.16C2.35,7.59 2.35,5.06 3.91,3.5L10.93,10.5L8.1,13.34M14.88,11.53L13.41,13L20.29,19.88L18.88,21.29L12,14.41L5.12,21.29L3.71,19.88L13.47,10.12C12.76,8.59 13.26,6.44 14.85,4.85C16.76,2.93 19.5,2.57 20.96,4.03C22.43,5.5 22.07,8.24 20.15,10.15C18.56,11.74 16.41,12.24 14.88,11.53Z', 'awning': 'M2 4V6H4V4H2M6 4V6H8V4H6M10 4V6H12V4H10M14 4V6H16V4H14M18 4V6H20V4H18M2 8V10H22V8H2M4 12V20H6V12H4M18 12V20H20V12H18M8 14V20H16V14H8' }; // === GERÄTE-DEFINITIONEN === // Hier tragt ihr eure eigenen Geräte ein. // "cols: 1" macht die Kachel quadratisch. "cols: 2" macht sie doppelt so breit (ideal für Slider). // "isMomentary" simuliert einen echten Tasterdruck (springt sofort auf aus zurück). interface ToggleDevice { type: 'toggle'; label: string; icon: string; oid: string; cols: number; isMomentary?: boolean; } interface SliderDevice { type: 'slider'; label: string; icon: string; oid: string; stateOid?: string; min: number; max: number; unit: string; step: number; cols: number; invert?: boolean; } type Device = ToggleDevice | SliderDevice; const devices: Device[] = [ { type: 'toggle', label: 'Hygge', icon: 'hygge', oid: 'scene.0.abendliches_Licht', cols: 1, isMomentary: true }, { type: 'toggle', label: 'Garten', icon: 'garten', oid: 'alias.0.Licht.Terrasse.STATE', cols: 1 }, { type: 'toggle', label: 'Staubsauger', icon: 'vacuum', oid: 'alias.0.Hausgeraete.Staubsauger.start',cols: 1, isMomentary: true }, { type: 'slider', label: 'Esstisch', icon: 'dining', oid: 'alias.0.Licht.Essbereich.LEVEL', stateOid: 'alias.0.Licht.Essbereich.STATE', min: 0, max: 100, unit: '%', step: 1, cols: 2 }, { type: 'slider', label: 'Markise', icon: 'awning', oid: 'alias.0.Beschattung.Markise.LEVEL', min: 0, max: 100, unit: '%', step: 10, cols: 2, invert:true }, ]; function colsToWidth(cols: number) { return cols * COL_WIDTH + (cols - 1) * GUTTER; } function svgIcon(name: string, color: string, size: number = 24): string { const path = ICONS[name] || ICONS['hygge']; return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" style="flex-shrink:0;"><path fill="${color}" d="${path}"/></svg>`; } // === TOGGLE-CARD RENDERN === function renderToggleCard(dev: ToggleDevice) { const val = existsState(dev.oid) ? getState(dev.oid).val : false; const active = dev.isMomentary ? false : (val === true || val === 1 || val === 'true'); const w = colsToWidth(dev.cols); const bg = active ? M3.primaryContainer : M3.surfaceContainerLow; const fg = active ? M3.onPrimaryContainer : M3.onSurfaceVariant; const iconBg = active ? M3.primary : M3.surfaceContainerHighest; const iconFg = active ? M3.onPrimary : M3.onSurfaceVariant; const shadow = '0px 1px 2px 0px rgba(0,0,0,0.3), 0px 1px 3px 1px rgba(0,0,0,0.15)'; const clickAction = dev.isMomentary ? `vis.setValue('${dev.oid}', true)` : `vis.setValue('${dev.oid}', ${active ? 'false' : 'true'})`; const rippleJS = `(function(e,el){var evt=e.touches?e.touches[0]:e;if(!evt.clientX)return;var d=Math.max(el.clientWidth,el.clientHeight);var r=d/2;var rect=el.getBoundingClientRect();var x=evt.clientX-rect.left;var y=evt.clientY-rect.top;var c=document.createElement('span');c.style.width=c.style.height=d+'px';c.style.left=(x-r)+'px';c.style.top=(y-r)+'px';c.style.position='absolute';c.style.borderRadius='50%';c.style.backgroundColor='${active ? M3.onPrimaryContainer : M3.onSurfaceVariant}';c.style.opacity='0.15';c.style.transform='scale(0)';c.style.animation='m3-ripple-anim 0.5s linear';c.style.pointerEvents='none';el.appendChild(c);setTimeout(function(){c.remove();},500);})(event,this)`; return ` <div onmousedown="${rippleJS}" ontouchstart="${rippleJS}" onclick="${clickAction}" style="position:relative; overflow:hidden; width:${w}px; height:${COL_WIDTH}px; border-radius:12px; padding:16px; box-sizing:border-box; background:${bg}; box-shadow: ${shadow}; border: none; cursor:pointer; user-select:none; display:flex; flex-direction:column; justify-content:space-between; font-family:Roboto,'Segoe UI',system-ui,sans-serif; -webkit-tap-highlight-color:transparent; transition: all 0.3s ease;"> <div style="width:40px; height:40px; border-radius:20px; background:${iconBg}; display:flex; align-items:center; justify-content:center; transition: all 0.3s ease;"> ${svgIcon(dev.icon, iconFg, 24)} </div> <div> <div style="font-size:20px; font-weight:500; color:${fg}; line-height:1.3; transition: color 0.3s ease;">${dev.label}</div> <div style="font-size:14px; font-weight:500; color:${fg}; opacity:0.7; margin-top:2px; transition: color 0.3s ease;"> ${dev.isMomentary ? 'Aktivieren' : (active ? 'An' : 'Aus')} </div> </div> </div>`; } // === SLIDER-CARD RENDERN === function renderSliderCard(dev: SliderDevice) { let rawVal = existsState(dev.oid) ? Number(getState(dev.oid).val) : 0; // Optional: Falls eine Lampe ausgeschaltet wurde (über den stateOid), zwinge den Slider optisch auf 0 if (dev.stateOid && existsState(dev.stateOid) && getState(dev.stateOid).val === false) { rawVal = 0; } const isInv = dev.invert ? true : false; const val = isInv ? (dev.max - rawVal + dev.min) : rawVal; const pct = ((val - dev.min) / (dev.max - dev.min)) * 100; const active = pct > 0; const w = colsToWidth(dev.cols); const mixPct = Math.round(20 + (pct * 0.8)); const bg = active ? `color-mix(in srgb, ${M3.primaryContainer} ${mixPct}%, ${M3.surfaceContainerLow})` : M3.surfaceContainerLow; const fg = active ? M3.onPrimaryContainer : M3.onSurfaceVariant; const iconBg = active ? M3.primary : M3.surfaceContainerHighest; const iconFg = active ? M3.onPrimary : M3.onSurfaceVariant; const shadow = '0px 1px 2px 0px rgba(0,0,0,0.3), 0px 1px 3px 1px rgba(0,0,0,0.15)'; const cardToggleJS = `(function(e){ var isOn = ${active ? 'true' : 'false'}; var oid = '${dev.oid}'; var stateOid = '${dev.stateOid || ''}'; if (typeof vis !== 'undefined' && vis.conn && vis.conn.setState) { if (stateOid) vis.conn.setState(stateOid, !isOn, false); else vis.conn.setState(oid, isOn ? 0 : 100, false); } else if (typeof vis !== 'undefined' && vis.setValue) { if (stateOid) vis.setValue(stateOid, !isOn); else vis.setValue(oid, isOn ? 0 : 100); } })(event);`.replace(/\n/g, ' '); const rippleJS = `(function(e,el){var evt=e.touches?e.touches[0]:e;if(!evt.clientX)return;var d=Math.max(el.clientWidth,el.clientHeight);var r=d/2;var rect=el.getBoundingClientRect();var x=evt.clientX-rect.left;var y=evt.clientY-rect.top;var c=document.createElement('span');c.style.width=c.style.height=d+'px';c.style.left=(x-r)+'px';c.style.top=(y-r)+'px';c.style.position='absolute';c.style.borderRadius='50%';c.style.backgroundColor='${active ? M3.onPrimaryContainer : M3.onSurfaceVariant}';c.style.opacity='0.15';c.style.transform='scale(0)';c.style.animation='m3-ripple-anim 0.5s linear';c.style.pointerEvents='none';el.appendChild(c);setTimeout(function(){c.remove();},500);})(event,this)`; const sliderJS = `(function(e, track, oid, min, max, step, unit, invert, stateOid) { var currentVal = 0; var headerValObj = track.parentElement.querySelector('.val-text'); var cardObj = track.parentElement; function move(ev) { var evt = ev.touches ? ev.touches[0] : ev; var rect = track.getBoundingClientRect(); var x = evt.clientX - rect.left; var pctRaw = Math.max(0, Math.min(1, x / rect.width)); currentVal = min + pctRaw * (max - min); if (step) currentVal = Math.round(currentVal / step) * step; currentVal = Math.max(min, Math.min(max, currentVal)); var p = ((currentVal - min) / (max - min) * 100).toFixed(1); track.children[0].style.width = 'calc(' + p + '% - 4px)'; track.children[1].style.left = 'calc(' + p + '% - 2px)'; track.children[2].style.left = 'calc(' + p + '% - 6px)'; track.children[3].style.left = 'calc(' + p + '% + 2px)'; track.children[4].style.left = 'calc(' + p + '% + 6px)'; if (headerValObj) headerValObj.innerText = currentVal + unit; var mixP = Math.round(20 + (p * 0.8)); var newBg = currentVal > 0 ? 'color-mix(in srgb, var(--m3-primary-container) ' + mixP + '%, var(--m3-surface-container-low))' : 'var(--m3-surface-container-low)'; if (cardObj) cardObj.style.background = newBg; track.children[2].style.background = newBg; track.children[3].style.background = newBg; } function up(ev) { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); document.removeEventListener('touchmove', move); document.removeEventListener('touchend', up); var sendVal = invert ? (max - currentVal + min) : currentVal; var isOn = sendVal > 0 ? true : false; if (typeof vis !== 'undefined' && vis.conn && vis.conn.setState) { vis.conn.setState(oid, sendVal, false); if (stateOid && stateOid !== 'undefined') vis.conn.setState(stateOid, isOn, false); } else if (typeof vis !== 'undefined' && vis.setValue) { vis.setValue(oid, sendVal); if (stateOid && stateOid !== 'undefined') vis.setValue(stateOid, isOn); } } document.addEventListener('mousemove', move); document.addEventListener('mouseup', up); document.addEventListener('touchmove', move, {passive: false}); document.addEventListener('touchend', up); move(e); })(event, this, '${dev.oid}', ${dev.min}, ${dev.max}, ${dev.step || 1}, '${dev.unit}', ${isInv}, '${dev.stateOid}')`; return ` <div onclick="${cardToggleJS}" onmousedown="${rippleJS}" ontouchstart="${rippleJS}" style="position:relative; overflow:hidden; cursor:pointer; width:${w}px; height:${COL_WIDTH}px; border-radius:12px; padding:16px; box-sizing:border-box; background:${bg}; box-shadow: ${shadow}; border: none; font-family:Roboto,'Segoe UI',system-ui,sans-serif; display:flex; flex-direction:column; justify-content:space-between; user-select:none; transition: background-color 0.3s ease;"> <div style="display:flex; align-items:center; gap:12px;"> <div style="width:40px; height:40px; border-radius:20px; background:${iconBg}; display:flex; align-items:center; justify-content:center; flex-shrink:0; transition: all 0.3s ease;"> ${svgIcon(dev.icon, iconFg, 24)} </div> <div style="flex:1;"> <div style="font-size:20px; font-weight:500; color:${fg}; line-height:1.2; transition: color 0.3s ease;">${dev.label}</div> </div> <div class="val-text" style="font-size:16px; font-weight:600; color:${fg}; transition: color 0.3s ease;">${val}${dev.unit}</div> </div> <div style="position:relative; height:44px; display:flex; align-items:center; touch-action:none; cursor:grab;" onclick="event.stopPropagation();" onmousedown="event.stopPropagation(); ${sliderJS.replace(/\n/g, ' ')}" ontouchstart="event.stopPropagation(); ${sliderJS.replace(/\n/g, ' ')}"> <div style="position:absolute; left:0; width:calc(${pct}% - 4px); height:16px; border-radius:8px; background:${M3.primary}; top:50%; transform:translateY(-50%); pointer-events:none; transition: background-color 0.3s ease;"></div> <div style="position:absolute; left:calc(${pct}% - 2px); width:4px; height:44px; border-radius:2px; background:${M3.primary}; top:50%; margin-top:-22px; z-index:10; pointer-events:none; transition: background-color 0.3s ease;"></div> <div style="position:absolute; left:calc(${pct}% - 6px); width:4px; height:16px; background:${bg}; top:50%; transform:translateY(-50%); pointer-events:none; transition: background-color 0.3s ease;"></div> <div style="position:absolute; left:calc(${pct}% + 2px); width:4px; height:16px; background:${bg}; top:50%; transform:translateY(-50%); pointer-events:none; transition: background-color 0.3s ease;"></div> <div style="position:absolute; left:calc(${pct}% + 6px); right:0; height:16px; border-radius:8px; background:${M3.surfaceVariant}; top:50%; transform:translateY(-50%); pointer-events:none; transition: background-color 0.3s ease;"></div> </div> </div>`; } // === HAUPT-RENDER === function renderFavoriten() { const cards = devices.map(dev => { if (dev.type === 'toggle') return renderToggleCard(dev); if (dev.type === 'slider') return renderSliderCard(dev); return ''; }).join(''); const html = ` <style> @keyframes m3-ripple-anim { to { transform: scale(4); opacity: 0; } } </style> <div style="background: var(--m3-surface-container-low, rgb(237, 239, 232)); border-radius: 0 0 28px 28px; padding: 24px; display: flex; flex-wrap: wrap; gap: ${GUTTER}px; width: 100%; max-width: 901px; box-sizing: border-box; font-family: Roboto, 'Segoe UI', system-ui, sans-serif; transition: background-color 0.3s ease;"> ${cards} </div>`; setState(DP_FAVORITEN, html, true); } // === SUBSCRIPTIONS & INIT === createState(DP_FAVORITEN, '', { type: 'string', name: 'Favoriten HTML (M3)', role: 'html' }, () => { // Abos einrichten devices.forEach(dev => { if (existsState(dev.oid)) on({ id: dev.oid, change: 'any' }, () => renderFavoriten()); if (dev.type === 'slider' && dev.stateOid && existsState(dev.stateOid)) { on({ id: dev.stateOid, change: 'any' }, () => renderFavoriten()); } }); renderFavoriten(); log('[Favoriten] M3 Renderer gestartet.'); });
-
Hier mein Navigations-Widget
Was macht dieses Widget?
Dieser HTML-Code erzeugt eine vollwertige, interaktive Navigationsleiste im Material 3 Design. Sie besteht aus 5 Tabs (Favoriten, Licht, Heizung, Beschattung und Setup).
Das Besondere: Die Leiste nutzt ioBroker-Bindings. Klickt man auf einen Tab, schreibt das Widget den entsprechenden Wert (0 bis 4) in einen Datenpunkt. Gleichzeitig reagiert die Leiste sofort visuell darauf: Der aktive Tab erhält einen pillenförmigen Hintergrund (M3-typisch in dertertiary-Farbe), der Text wird fett gedruckt und das Icon wechselt weich animiert von der "Outlined" (Umriss) zur "Filled" (Ausgefüllt) Variante.Was muss vorbereitet werden?
- Der Datenpunkt: Ihr benötigt einen Datenpunkt vom Typ Zahl (Number), in dem der aktuelle Tab gespeichert wird. Im Code wird standardmäßig
0_userdata.0.dashboard.active_tabverwendet. Legt diesen Datenpunkt also einfach im Objektbaum an. - Die Farben: Das Widget greift auf die CSS-Variablen aus meinem M3 Theme Master Skript zu (z.B.
var(--m3-surface-container-low)odervar(--m3-tertiary-container)). Wenn ihr das Theme-Skript am Laufen habt, passt sich die Leiste vollautomatisch an Tag/Nacht oder euer Wallpaper an.
Wie wendet man das an?
- Zieht euch ein einfaches "Basic - HTML" Widget in eure vis.
- Kopiert den unten stehenden Code komplett in das HTML-Inhaltsfeld des Widgets.
- Zieht das Widget am besten an den unteren oder oberen Rand eures Dashboards und zieht es über die volle Breite.
- Optional: Wenn ihr euren Datenpunkt anders nennen wollt als
0_userdata.0.dashboard.active_tab, kopiert den Code vorher kurz in einen Texteditor (wie Notepad, Word oder VS Code) und nutzt die Funktion "Suchen und Ersetzen", um den Pfad überall in einem Rutsch auszutauschen.
Tipp für eure Views: Auf euren eigentlichen Kacheln, Graphen oder anderen Widgets nutzt ihr nun einfach die Sichtbarkeits-Bedingung in der vis (Sichtbarkeit -> Objekt-ID:
0_userdata.0.dashboard.active_tab-> Bedingung:== 1), damit z.B. eure Lampen nur dann auftauchen, wenn in der Navigation der "Licht"-Tab aktiv ist.
Der HTML-Code
<div class="m3-widget" style="width:100%; height:100%; display:flex; align-items:stretch; background:var(--m3-surface-container-low); border-radius:28px 28px 0 0; font-family:Roboto,'Segoe UI',system-ui,sans-serif; transition: background-color 0.3s ease;"> <div onclick="vis.setValue('0_userdata.0.dashboard.active_tab', 0)" style="flex:1; display:flex; align-items:center; justify-content:center; cursor:pointer; user-select:none; -webkit-tap-highlight-color:transparent;"> <div style="position:relative; display:flex; align-items:center; justify-content:center; gap:10px; height:48px; padding:0 24px; min-width:80px;"> <div style="position:absolute; inset:0; border-radius:24px; transition: background-color 0.3s ease; background:{a:0_userdata.0.dashboard.active_tab; a === 0 ? 'var(--m3-tertiary-container)' : 'transparent'};"></div> <svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" style="position:relative; z-index:1; flex-shrink:0;"> <path style="transition: fill 0.3s ease;" fill="{a:0_userdata.0.dashboard.active_tab; a === 0 ? 'var(--m3-on-tertiary-container)' : 'transparent'}" d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2L9.19 8.63L2 9.24l5.46 4.73L5.82 21z"/> <path style="transition: fill 0.3s ease;" fill="{a:0_userdata.0.dashboard.active_tab; a === 0 ? 'transparent' : 'var(--m3-on-surface-variant)'}" d="M22 9.24l-7.19-.62L12 2L9.19 8.63L2 9.24l5.46 4.73L5.82 21L12 17.27L18.18 21l-1.64-7.03L22 9.24zM12 15.4l-3.76 2.27l1-4.28l-3.32-2.88l4.38-.38L12 6.1l1.71 4.04l4.38.38l-3.32 2.88l1 4.28L12 15.4z"/> </svg> <span style="position:relative; z-index:1; font-size:18px; letter-spacing:0.1px; white-space:nowrap; transition: color 0.3s ease; font-weight:{a:0_userdata.0.dashboard.active_tab; a === 0 ? '600' : '500'}; color:{a:0_userdata.0.dashboard.active_tab; a === 0 ? 'var(--m3-on-tertiary-container)' : 'var(--m3-on-surface-variant)'};">Favoriten</span> </div> </div> <div onclick="vis.setValue('0_userdata.0.dashboard.active_tab', 1)" style="flex:1; display:flex; align-items:center; justify-content:center; cursor:pointer; user-select:none; -webkit-tap-highlight-color:transparent;"> <div style="position:relative; display:flex; align-items:center; justify-content:center; gap:10px; height:48px; padding:0 24px; min-width:80px;"> <div style="position:absolute; inset:0; border-radius:24px; transition: background-color 0.3s ease; background:{a:0_userdata.0.dashboard.active_tab; a === 1 ? 'var(--m3-tertiary-container)' : 'transparent'};"></div> <svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" style="position:relative; z-index:1; flex-shrink:0;"> <path style="transition: fill 0.3s ease;" fill="{a:0_userdata.0.dashboard.active_tab; a === 1 ? 'var(--m3-on-tertiary-container)' : 'transparent'}" d="M12 2C8.13 2 5 5.13 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74c0-3.87-3.13-7-7-7zM9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1z"/> <path style="transition: fill 0.3s ease;" fill="{a:0_userdata.0.dashboard.active_tab; a === 1 ? 'transparent' : 'var(--m3-on-surface-variant)'}" d="M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74c0-3.86-3.14-7-7-7zm2.85 11.1l-.85.6V16h-4v-2.3l-.85-.6A4.997 4.997 0 0 1 7 9c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.63-.8 3.16-2.15 4.1z"/> </svg> <span style="position:relative; z-index:1; font-size:18px; letter-spacing:0.1px; white-space:nowrap; transition: color 0.3s ease; font-weight:{a:0_userdata.0.dashboard.active_tab; a === 1 ? '600' : '500'}; color:{a:0_userdata.0.dashboard.active_tab; a === 1 ? 'var(--m3-on-tertiary-container)' : 'var(--m3-on-surface-variant)'};">Licht</span> </div> </div> <div onclick="vis.setValue('0_userdata.0.dashboard.active_tab', 2)" style="flex:1; display:flex; align-items:center; justify-content:center; cursor:pointer; user-select:none; -webkit-tap-highlight-color:transparent;"> <div style="position:relative; display:flex; align-items:center; justify-content:center; gap:10px; height:48px; padding:0 24px; min-width:80px;"> <div style="position:absolute; inset:0; border-radius:24px; transition: background-color 0.3s ease; background:{a:0_userdata.0.dashboard.active_tab; a === 2 ? 'var(--m3-tertiary-container)' : 'transparent'};"></div> <svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" style="position:relative; z-index:1; flex-shrink:0;"> <path style="transition: fill 0.3s ease;" fill="{a:0_userdata.0.dashboard.active_tab; a === 2 ? 'var(--m3-on-tertiary-container)' : 'transparent'}" d="M15 13V5a3 3 0 0 0-6 0v8a5 5 0 1 0 6 0m-3-9a1 1 0 0 1 1 1v3h-2V5a1 1 0 0 1 1-1"/> <path style="transition: fill 0.3s ease;" fill="{a:0_userdata.0.dashboard.active_tab; a === 2 ? 'transparent' : 'var(--m3-on-surface-variant)'}" d="M15 13V5a3 3 0 0 0-6 0v8a5 5 0 1 0 6 0m-3 7a3 3 0 0 1-1.79-5.4l.79-.58V5a1 1 0 0 1 2 0v9.02l.79.58A3 3 0 0 1 12 20m1-15a1 1 0 0 0-2 0v3h2V5"/> </svg> <span style="position:relative; z-index:1; font-size:18px; letter-spacing:0.1px; white-space:nowrap; transition: color 0.3s ease; font-weight:{a:0_userdata.0.dashboard.active_tab; a === 2 ? '600' : '500'}; color:{a:0_userdata.0.dashboard.active_tab; a === 2 ? 'var(--m3-on-tertiary-container)' : 'var(--m3-on-surface-variant)'};">Heizung</span> </div> </div> <div onclick="vis.setValue('0_userdata.0.dashboard.active_tab', 3)" style="flex:1; display:flex; align-items:center; justify-content:center; cursor:pointer; user-select:none; -webkit-tap-highlight-color:transparent;"> <div style="position:relative; display:flex; align-items:center; justify-content:center; gap:10px; height:48px; padding:0 24px; min-width:80px;"> <div style="position:absolute; inset:0; border-radius:24px; transition: background-color 0.3s ease; background:{a:0_userdata.0.dashboard.active_tab; a === 3 ? 'var(--m3-tertiary-container)' : 'transparent'};"></div> <svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" style="position:relative; z-index:1; flex-shrink:0;"> <path style="transition: fill 0.3s ease;" fill="{a:0_userdata.0.dashboard.active_tab; a === 3 ? 'var(--m3-on-tertiary-container)' : 'transparent'}" d="M20 19V3H4v16H2v2h20v-2h-2zM6 5h12v2H6V5zm0 4h12v2H6V9zm0 4h12v2H6v-2zm0 4h5v2H6v-2z"/> <path style="transition: fill 0.3s ease;" fill="{a:0_userdata.0.dashboard.active_tab; a === 3 ? 'transparent' : 'var(--m3-on-surface-variant)'}" d="M20 19V3H4v16H2v2h20v-2h-2zM18 5v2H6V5h12zM6 9h12v2H6V9zm0 4h12v2H6v-2zm0 6v-2h5v2H6z"/> </svg> <span style="position:relative; z-index:1; font-size:18px; letter-spacing:0.1px; white-space:nowrap; transition: color 0.3s ease; font-weight:{a:0_userdata.0.dashboard.active_tab; a === 3 ? '600' : '500'}; color:{a:0_userdata.0.dashboard.active_tab; a === 3 ? 'var(--m3-on-tertiary-container)' : 'var(--m3-on-surface-variant)'};">Beschattung</span> </div> </div> <div style="flex:0 0 1px; height:32px; align-self:center; background:var(--m3-outline-variant); opacity:0.5; transition: background-color 0.3s ease;"></div> <div onclick="vis.setValue('0_userdata.0.dashboard.active_tab', 4)" style="flex:0 0 auto; display:flex; align-items:center; justify-content:center; padding:0 12px; cursor:pointer; user-select:none; -webkit-tap-highlight-color:transparent;"> <div style="position:relative; display:flex; align-items:center; justify-content:center; gap:10px; height:48px; padding:0 24px;"> <div style="position:absolute; inset:0; border-radius:24px; transition: background-color 0.3s ease; background:{a:0_userdata.0.dashboard.active_tab; a === 4 ? 'var(--m3-tertiary-container)' : 'transparent'};"></div> <svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" style="position:relative; z-index:1; flex-shrink:0;"> <path style="transition: fill 0.3s ease;" fill="{a:0_userdata.0.dashboard.active_tab; a === 4 ? 'var(--m3-on-tertiary-container)' : 'transparent'}" d="M19.14 12.94c.04-.3.06-.61.06-.94c0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 0 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1 1 12 8.4a3.6 3.6 0 0 1 0 7.2z"/> <path style="transition: fill 0.3s ease;" fill="{a:0_userdata.0.dashboard.active_tab; a === 4 ? 'transparent' : 'var(--m3-on-surface-variant)'}" d="M19.14 12.94c.04-.3.06-.61.06-.94c0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 0 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6a3.6 3.6 0 1 1 0-7.2a3.6 3.6 0 0 1 0 7.2zm0-5.76a2.16 2.16 0 1 0 0 4.32a2.16 2.16 0 0 0 0-4.32z"/> </svg> <span style="position:relative; z-index:1; font-size:18px; letter-spacing:0.1px; white-space:nowrap; transition: color 0.3s ease; font-weight:{a:0_userdata.0.dashboard.active_tab; a === 4 ? '600' : '500'}; color:{a:0_userdata.0.dashboard.active_tab; a === 4 ? 'var(--m3-on-tertiary-container)' : 'var(--m3-on-surface-variant)'};">Setup</span> </div> </div> </div>
- Der Datenpunkt: Ihr benötigt einen Datenpunkt vom Typ Zahl (Number), in dem der aktuelle Tab gespeichert wird. Im Code wird standardmäßig
-
Das Alert-Script:
Was macht dieses Skript?
Dieses Skript ist eine vollautomatische "Benachrichtigungszentrale" für euer ioBroker-Dashboard. Es sammelt Warnungen, Infos oder Statusmeldungen (die ihr aus anderen Skripten schickt) und stellt sie als wunderschöne, gestapelte Kacheln im Material 3 Design dar.
Die Features:
- Theme-Engine Ready: Nutzt nahtlos die Farben eures Themes (Hell/Dunkel).
- Farb-Codes: Fehlermeldungen sind rot, Komfort-Infos blaugrün, reine Infos grün.
- Auto-Icons: Das Skript liest den Text der Warnung mit (z.B. "Garage steht offen") und weist automatisch das passende Icon zu!
- Drei Verhaltensweisen: Ihr könnt steuern, ob der User die Meldung am Tablet manuell wegklicken muss (
M), ob sie nach X Minuten von selbst verschwindet (T), oder ob sie so lange bleibt, bis euer anderes Skript "Entwarnung" gibt (A).
Wie nutze ich das Skript?
- Startet das Skript. Es legt automatisch die nötigen Datenpunkte (u.a.
0_userdata.0.Warnungen.warnung_new) an. - Zieht ein "Basic - HTML" Widget in eure vis und tragt dort
{0_userdata.0.alertMessages2_html}ein. - Meldungen erzeugen: Wenn ihr aus euren Blockly- oder JavaScripts eine Warnung auf dem Tablet anzeigen wollt, schreibt ihr einfach einen Text in den Datenpunkt
0_userdata.0.Warnungen.warnung_new.
Der Trick (Das Prefix-System):
Ihr schreibt vor euren Text immer ein kurzes Präfix, das Farbe und Verhalten steuert:TYP + VERHALTEN : Text- Typen:
E(Error/Rot),C(Comfort/Blaugrün),I(Info/Grün) - Verhalten:
M(Manuell schließen),T(Timeout/Verschwindet von allein),A(Auto/Bleibt bis zur Entwarnung)
Beispiele:
- Ihr schreibt:
EM:Haustürschloss klemmt!(Roter Fehler, User muss wegklicken). - Ihr schreibt:
IT:Waschmaschine ist fertig.(Grüne Info, verschwindet nach 30 Minuten von selbst). - Ihr schreibt:
CA:Temperatur im Wohnzimmer zu hoch.(Bleibt stehen). Um diese "Auto"-Meldung wieder verschwinden zu lassen, schickt ihr exakt denselben Text mit einem Minus-Zeichen davor:-CA:Temperatur im Wohnzimmer zu hoch.
Das Skript (TypeScript)
// ============================================================================= // M3 ALERT RENDERER für vis2 Dashboard (THEME ENGINE READY) // ============================================================================= // === KONFIGURATION ============================================================ // Wo sollen die Datenpunkte angelegt werden? const DP_WARNUNG_NEW = '0_userdata.0.Warnungen.warnung_new'; const DP_ALERT_HTML = '0_userdata.0.alertMessages2_html'; const DP_ALERT_DISMISS = '0_userdata.0.alerts_dismiss'; // Einstellungen const TIMEOUT_MS = 30 * 60 * 1000; // Wie lange bleiben 'T' (Timeout) Meldungen? (Hier: 30 Min) const MAX_ALERTS = 5; // Wie viele Meldungen sollen maximal gestapelt werden? // ============================================================================= // === AB HIER NUR ANPASSEN, WENN IHR NEUE ICONS ODER FARBEN WOLLT === // ============================================================================= // === M3 FARBEN (Dynamisch gekoppelt an Theme-Master inkl. Fallbacks) ========== const COLORS = { E: { // Error — Sicherheit (Rot) bg: 'var(--m3-error-container, rgb(255,218,214))', text: 'var(--m3-on-error-container, rgb(147,0,10))', icon: 'var(--m3-error, rgb(186,26,26))', }, C: { // Comfort — Komfort (Blaugrün) bg: 'var(--m3-tertiary-container, rgb(188,235,239))', text: 'var(--m3-on-tertiary-container, rgb(30,77,81))', icon: 'var(--m3-tertiary, rgb(56,101,105))', }, I: { // Info (Grün) bg: 'var(--m3-secondary-container, rgb(214,232,206))', text: 'var(--m3-on-secondary-container, rgb(60,75,56))', icon: 'var(--m3-secondary, rgb(83,99,78))', }, }; // === M3 SVG ICONS === const ICONS: Record<string, string> = { 'door': 'M12 3H2v18h10v-2H4V5h8V3m7 9l-4-4v3H8v2h7v3l4-4', 'garage': 'M20 9h-2V4H6v5H4L2 20h20L20 9M7 19v-6h2v6H7m4 0v-6h2v6h-2m4 0v-6h2v6h-2', 'lock': 'M12 17a2 2 0 0 0 2-2a2 2 0 0 0-2-2a2 2 0 0 0-2 2a2 2 0 0 0 2 2m6-9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2h1V6a5 5 0 0 1 5-5a5 5 0 0 1 5 5v2h1m-6-5a3 3 0 0 0-3 3v2h6V6a3 3 0 0 0-3-3', 'alert': 'M13 14h-2V9h2m0 9h-2v-2h2M1 21h22L12 2L1 21', 'humidity': 'M12 3.25C12 3.25 6 10 6 14a6 6 0 0 0 6 6a6 6 0 0 0 6-6c0-4-6-10.75-6-10.75', 'temp': 'M15 13V5a3 3 0 0 0-6 0v8a5 5 0 1 0 6 0m-3-9a1 1 0 0 1 1 1v3h-2V5a1 1 0 0 1 1-1', 'washer': 'M6 2h12a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2m1 2v2h6V4H7m5 10a4 4 0 0 1-4-4a4 4 0 0 1 4 4m0 0a4 4 0 0 0 4-4a4 4 0 0 0-4 4m0-6a6 6 0 0 0-6 6a6 6 0 0 0 6 6a6 6 0 0 0 6-6a6 6 0 0 0-6-6', 'info': 'M13 9h-2V7h2m0 10h-2v-6h2m-1-9A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10A10 10 0 0 0 12 2', 'close': 'M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12L19 6.41', }; // === ICON-ZUORDNUNG nach Keywords im Text === const ICON_KEYWORDS: Array<{ keyword: string; icon: string }> = [ { keyword: 'Haustür', icon: 'door' }, { keyword: 'Tür', icon: 'door' }, { keyword: 'Garage', icon: 'garage' }, { keyword: 'Schloss', icon: 'lock' }, { keyword: 'verriegelt', icon: 'lock' }, { keyword: 'Feucht', icon: 'humidity' }, { keyword: 'Temperatur', icon: 'temp' }, { keyword: 'Waschmaschine', icon: 'washer' }, { keyword: 'Trockner', icon: 'washer' }, ]; const DEFAULT_ICONS: Record<string, string> = { E: 'alert', C: 'humidity', I: 'info' }; // === DATENSTRUKTUR === interface Alert { id: string; type: 'E' | 'C' | 'I'; dismiss: 'M' | 'T' | 'A'; text: string; time: string; timestamp: number; icon: string; } let alerts: Alert[] = []; let timeoutTimers: Record<string, any> = {}; // === HILFSFUNKTIONEN === function parsePrefix(raw: string): { type: string; dismiss: string; text: string; remove: boolean } | null { const trimmed = raw.trim(); const remove = trimmed.startsWith('-'); const cleaned = remove ? trimmed.substring(1) : trimmed; const match = cleaned.match(/^([ECI])([MTA]):(.+)$/); if (!match) { log(`[AlertRenderer] Ungültiges Format: "${raw}" — erwartet z.B. "EM:Text"`, 'warn'); return null; } return { type: match[1], dismiss: match[2], text: match[3].trim(), remove }; } function findIcon(text: string, type: string): string { for (const entry of ICON_KEYWORDS) { if (text.toLowerCase().includes(entry.keyword.toLowerCase())) return entry.icon; } return DEFAULT_ICONS[type] || 'alert'; } function makeId(type: string, dismiss: string, text: string): string { return `${type}${dismiss}:${text}`; } function timeNow(): string { const d = new Date(); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; } function svgIcon(name: string, color: string, size: number = 20): string { const path = ICONS[name] || ICONS['alert']; 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>`; } function removeAlertAndTimer(id: string) { const idx = alerts.findIndex(a => a.id === id); if (idx >= 0) { if (timeoutTimers[id]) { clearTimeout(timeoutTimers[id]); delete timeoutTimers[id]; } alerts.splice(idx, 1); } } // === ALERT HINZUFÜGEN ========================================================= function addAlert(raw: string): void { const parsed = parsePrefix(raw); if (!parsed) return; const { type, dismiss, text, remove } = parsed; const id = makeId(type, dismiss, text); // Entwarnung (-) empfangen if (remove) { const exists = alerts.some(a => a.id === id); if (exists) { removeAlertAndTimer(id); log(`[AlertRenderer] Alert entfernt (Auto-Entwarnung): "${text}"`); renderAlerts(); } else { log(`[AlertRenderer] Ignoriert: Entwarnung für "${text}" (war ohnehin nicht aktiv).`, 'debug'); } return; } // Vorhandenen Alert nur aktualisieren (Uhrzeit pushen) const existing = alerts.find(a => a.id === id); if (existing) { existing.time = timeNow(); existing.timestamp = Date.now(); // Timer bei T-Meldungen zurücksetzen, damit sie nicht sofort verschwinden if (dismiss === 'T') { if (timeoutTimers[id]) clearTimeout(timeoutTimers[id]); timeoutTimers[id] = setTimeout(() => { removeAlertAndTimer(id); log(`[AlertRenderer] Timeout abgelaufen (nach Update): "${text}"`); renderAlerts(); }, TIMEOUT_MS); } log(`[AlertRenderer] Alert aktualisiert: "${text}"`); renderAlerts(); return; } // Neuen Alert anlegen const alert: Alert = { id, type: type as Alert['type'], dismiss: dismiss as Alert['dismiss'], text, time: timeNow(), timestamp: Date.now(), icon: findIcon(text, type), }; alerts.push(alert); // Limit überwachen und älteste löschen while (alerts.length > MAX_ALERTS) { const removed = alerts[0]; removeAlertAndTimer(removed.id); } // Timeout-Timer setzen, falls 'T' if (dismiss === 'T') { timeoutTimers[id] = setTimeout(() => { removeAlertAndTimer(id); log(`[AlertRenderer] Timeout abgelaufen: "${text}"`); renderAlerts(); }, TIMEOUT_MS); } log(`[AlertRenderer] Neuer Alert: [${type}${dismiss}] "${text}"`); renderAlerts(); } // === HTML RENDERN ============================================================= function renderAlerts(): void { if (alerts.length === 0) { setState(DP_ALERT_HTML, '', true); return; } const sorted = [...alerts].reverse(); const cardsHTML = sorted.map(a => { const c = COLORS[a.type]; const showClose = a.dismiss === 'M' || a.dismiss === 'T'; const clickAction = `(function(e){ e.stopPropagation(); if (window.vis && window.vis.conn && window.vis.conn.setState) { window.vis.conn.setState('${DP_ALERT_DISMISS}', '${a.id}', false); } else if (window.vis && window.vis.setValue) { window.vis.setValue('${DP_ALERT_DISMISS}', '${a.id}'); } })(event);`.replace(/\n/g, ' '); return ` <div style=" display:flex; align-items:center; gap:12px; background:${c.bg}; color:${c.text}; border-radius:12px; padding:12px 16px; font-family:'Roboto','Segoe UI',system-ui,sans-serif; margin-bottom:8px; transition: all 0.3s ease; "> ${svgIcon(a.icon, c.icon, 22)} <div style="flex:1; min-width:0;"> <div style="font-size:14px; font-weight:500; line-height:1.4; word-wrap:break-word; transition: color 0.3s ease;">${a.text}</div> <div style="font-size:11px; font-weight:500; opacity:0.7; margin-top:2px; transition: color 0.3s ease;">${a.time}</div> </div> ${showClose ? ` <div onclick="${clickAction}" style="cursor:pointer; padding:4px; margin:-4px -4px -4px 0; flex-shrink:0; opacity:0.7; -webkit-tap-highlight-color:transparent;"> ${svgIcon('close', c.text, 18)} </div>` : ''} </div>`; }).join(''); const html = `<div style="display:flex; flex-direction:column; width:100%; height:100%; overflow-y:auto; padding:0;">${cardsHTML}</div>`; setState(DP_ALERT_HTML, html, true); } // === DATENPUNKTE ERSTELLEN ===================================================== function ensureDatapoints(callback: () => void): void { let pending = 3; function done() { pending--; if (pending === 0) callback(); } createState(DP_WARNUNG_NEW, '', { type: 'string', name: 'Alert Input', role: 'state' }, done); createState(DP_ALERT_HTML, '', { type: 'string', name: 'Alert HTML Output (vis2)', role: 'html' }, done); createState(DP_ALERT_DISMISS, '', { type: 'string', name: 'Alert Dismiss Trigger', role: 'state' }, done); } // === INITIALISIERUNG =========================================================== ensureDatapoints(() => { // Lauscht auf neue Text-Eingaben on({ id: DP_WARNUNG_NEW, change: 'any' }, (obj) => { if (obj.state.val && String(obj.state.val).trim() !== '') { addAlert(String(obj.state.val)); } }); // Lauscht auf Klicks im vis-Widget (X-Button) on({ id: DP_ALERT_DISMISS, change: 'any' }, (obj) => { if (obj.state.val && String(obj.state.val).trim() !== '') { const idToDismiss = String(obj.state.val); removeAlertAndTimer(idToDismiss); log(`[AlertRenderer] Manuell geschlossen: "${idToDismiss}"`); renderAlerts(); setState(DP_ALERT_DISMISS, '', true); } }); renderAlerts(); log('[AlertRenderer] M3 Alert Renderer gestartet.'); });
-
Hier die Einstellungen:
Was macht dieses Skript?
Dieses Skript generiert ein komplettes Einstellungs-Menü (Setup-Tab) im Material 3 Design für euer vis-Dashboard. Es rendert sauberes HTML und bringt drei Hauptfunktionen mit:
- System & Automatiken: Eine elegante Listenansicht mit Schaltern, um z.B. Skripte (Rollladenautomatik, Urlaubsmodus) ein- und auszuschalten.
- Graphen-Filter (Chips): Interaktive Buttons (Chips), mit denen man Datenpunkte für ein Diagramm (z.B. Heizung) ein- und ausblenden kann.
- Dynamische Wallpaper-Auswahl: Das absolute Highlight! Das Skript liest einen festgelegten Ordner eures ioBrokers aus und generiert automatisch ein Popup-Menü mit allen darin enthaltenen Bildern (.jpg, .png, .webp). Wählt ihr ein Bild aus, wird der Pfad in einen Datenpunkt geschrieben (perfekt für die Kombination mit meinem "M3 Theme Master" Skript).
Was muss vorbereitet werden?
Damit das Skript auf euren ioBroker-Dateispeicher zugreifen darf, müsst ihr einen wichtigen Haken setzen:
- Geht in die Instanz-Einstellungen eures JavaScript-Adapters.
- Setzt den Haken bei "Erlaube das Kommando exec" (falls noch nicht geschehen) und tragt unter Zusätzliche NPM-Module das Modul
fsein, falls es dort nicht standardmäßig aktiv ist. Alternativ reicht es oft schon, in den JS-Adapter-Einstellungen den Haken bei "Erlaube Zugriff auf Dateisystem" zu setzen. - Wallpaper-Ordner: Legt im ioBroker Dateimanager (unter vis.0 oder vis-2.0) einen Ordner an und ladet dort ein paar Bilder hoch. Passt den Pfad im Skript (
WALLPAPER_WEB_DIR) entsprechend an. - Eure Geräte eintragen: Schaut im Skript in die Funktion
getListItems(). Dort tragt ihr einfach die Datenpunkte eurer eigenen ioBroker-Skripte oder Schalter ein.
Wie wendet man das an?
Das Skript erstellt alle nötigen Datenpunkte automatisch (standardmäßig unter
0_userdata.0.dashboard.).- Startet das Skript.
- Zieht ein "Basic - String" (oder "Basic - HTML") Widget in eure vis.
- Tragt unter HTML / Inhalt den Datenpunkt-Namen in geschweiften Klammern ein:
{0_userdata.0.dashboard.setupHTML} - Zieht das Widget so groß wie nötig, das Skript bringt eine eigene, stylische M3-Scrollbar mit, falls der Platz nicht reicht.
Das Skript (TypeScript)
// ============================================================ // renderSetup — M3 Material You Styling (Kompakt & Robust-Scroll) // ============================================================ const fs = require('fs'); // === KONFIGURATION ========================================================== const BASE_PATH = '0_userdata.0.dashboard'; const DP_SETUP_HTML = `${BASE_PATH}.setupHTML`; const DP_IMAGE_PATH = `${BASE_PATH}.themeImagePath`; const DP_PRIVACY = `${BASE_PATH}.privacyMode`; // GRAPHEN-FILTER (Beispiele für eine Heizungs-Ansicht) const DP_CHART_BRENNER = `${BASE_PATH}.chartShowBrenner`; const DP_CHART_VALVE = `${BASE_PATH}.chartShowValve`; const DP_CHART_ACT = `${BASE_PATH}.chartShowAct`; const DP_CHART_SET = `${BASE_PATH}.chartShowSet`; const DP_CHART_WINDOW = `${BASE_PATH}.chartShowWindow`; const DP_CHART_HUMID = `${BASE_PATH}.chartShowHumid`; // ORDNER FÜR DIE HINTERGRUNDBILDER // Wichtig: Der Ordner muss im ioBroker Dateimanager existieren! const WALLPAPER_WEB_DIR = '/vis-2.0/Material_You/img/wallpaper/'; const WALLPAPER_SYS_DIR = '/opt/iobroker/iobroker-data/files' + WALLPAPER_WEB_DIR; // ============================================================================ // === AB HIER NUR ANPASSEN, WENN EIGENE LISTEN-EINTRÄGE GEWÜNSCHT SIND === // ============================================================================ const M3 = { primary: 'var(--m3-primary)', onPrimary: 'var(--m3-on-primary)', primaryContainer: 'var(--m3-primary-container)', onPrimaryContainer: 'var(--m3-on-primary-container)', surfaceContainerLow: 'var(--m3-surface-container-low)', surfaceContainerHighest:'var(--m3-surface-container-highest)', surfaceVariant: 'var(--m3-surface-variant)', onSurface: 'var(--m3-on-surface)', onSurfaceVariant: 'var(--m3-on-surface-variant)', outline: 'var(--m3-outline)', outlineVariant: 'var(--m3-outline-variant)' }; const ICONS: Record<string, string> = { 'party': 'M2 22l5-14 9 9-14 5zm3.3-3.3l7.05-2.5-4.55-4.55-2.5 7.05zM14.55 12.55l-1.05-1.05 5.6-5.6q.8-.8 1.925-.8t1.925.8l.6.6-1.05 1.05-.6-.6q-.35-.35-.875-.35t-.875.35l-5.6 5.6zM10.55 8.55l-1.05-1.05.6-.6q.35-.35.35-.85t-.35-.85l-.65-.65 1.05-1.05.65.65q.8.8.8 1.9t-.8 1.9l-.6.6zm2 2l-1.05-1.05 3.6-3.6q.35-.35.35-.875t-.35-.875l-1.6-1.6 1.05-1.05 1.6 1.6q.8.8.8 1.925t-.8 1.925l-3.6 3.6zm4 4l-1.05-1.05 1.6-1.6q.8-.8 1.925-.8t1.925.8l1.6 1.6-1.05 1.05-1.6-1.6q-.35-.35-.875-.35t-.875.35l-1.6 1.6z', 'guests': 'M16 11C17.66 11 18.99 9.66 18.99 8C18.99 6.34 17.66 5 16 5C14.34 5 13 6.34 13 8C13 9.66 14.34 11 16 11M8 11C9.66 11 10.99 9.66 10.99 8C10.99 6.34 9.66 5 8 5C6.34 5 5 6.34 5 8C5 9.66 6.34 11 8 11M8 13C5.67 13 1 14.17 1 16.5V19H15V16.5C15 14.17 10.33 13 8 13M16 13C15.71 13 15.38 13.02 15.03 13.05C16.19 13.89 17 15.02 17 16.5V19H23V16.5C23 14.17 18.33 13 16 13Z', 'sun': 'M20 14H18L14.8 23H16.7L17.4 21H20.6L21.3 23H23.2L20 14M17.8 19.7L19 16L20.2 19.7H17.8M7 9H15V11H7V9M7 12H15V14H7V12M7 15H15V16.5L14.8 17H7V15M13.7 20H7V18H14.5L13.7 20M16 8H6V20H4V8H2V4H20V8H18V12H16.6L16.1 13.3L16 13.7V8Z', 'alarm': 'M12 20A7 7 0 0 1 5 13A7 7 0 0 1 12 6A7 7 0 0 1 19 13A7 7 0 0 1 12 20M12 4A9 9 0 0 0 3 13A9 9 0 0 0 12 22A9 9 0 0 0 21 13A9 9 0 0 0 12 4M7.88 3.39L6.6 1.86L2 5.71L3.29 7.24L7.88 3.39M22 5.72L17.4 1.86L16.11 3.39L20.71 7.25L22 5.72M12.5 8H11V14L15.75 16.85L16.5 15.62L12.5 13.25V8Z', 'person': 'M12 4A4 4 0 0 1 16 8A4 4 0 0 1 12 12A4 4 0 0 1 8 8A4 4 0 0 1 12 4M12 14C16.42 14 20 15.79 20 18V20H4V18C4 15.79 7.58 14 12 14Z', 'moon': 'M12 21q-3.75 0-6.375-2.625T3 12q0-3.75 2.625-6.375T12 3q.35 0 .688.025t.662.075q-1.025.725-1.637 1.888T11.1 7.5q0 2.25 1.575 3.825T16.5 12.9q1.375 0 2.525-.612t1.875-1.638q.05.325.075.663T21 12q0 3.75-2.625 6.375T12 21Zm0-2q2.2 0 3.95-1.212T18.5 14.625q-.5.125-1 .2t-1 .075q-3.075 0-5.238-2.162T9.1 7.5q0-.5.075-1t.2-1q-1.95.8-3.162 2.55T5 12q0 2.9 2.05 4.95T12 19Zm-.25-6.75Z', 'auto': 'M11 19q1.3 0 2.47-.52t2.03-1.5q-3.2-.2-5.35-2.49T8 9q0-.32.02-.64t.08-.61q-1.42.8-2.26 2.2T5 13q0 2.5 1.75 4.25T11 19Zm0 2q-3.35 0-5.67-2.32t-2.33-5.68q0-3.35 2.32-5.67t5.68-2.33q.13 0 .25.01t.25.01q-.72.8-1.11 1.83T10 9q0 2.5 1.75 4.25T16 15q.78 0 1.51-.19t1.4-.56q-.45 2.95-2.7 4.85T11 21Zm2.8-10 3.2-9h2l3.2 9h-1.9l-.7-2H17.4l-.7 2h-1.9Zm3.05-3.35h2.3l-1.15-3.65-1.15 3.65ZM10.18-9.53Z', 'palette': 'M12 22q-2.05 0-3.875-.788t-3.187-2.15Q3.575 17.7 2.788 15.875T2 12q0-2.075.812-3.9t2.2-3.175Q6.4 3.575 8.25 2.788T12.2 2q2 0 3.775.688t3.112 1.9q1.338 1.212 2.125 2.875T22 10.95q0 2.875-1.75 4.413T16 17h-1.85q-.225 0-.313.125t-.087.275q0 .3 0.375.863T14.5 20.3q0 1.25-.688 1.85T12 22Zm0-10Zm-4.425.575q.425-.425.425-1.075t-.425-1.075q-.425-.425-1.075-.425t-1.075.425q-.425.425-.425 1.075t.425 1.075q.425.425 1.075.425t1.075-.425Zm3-4q.425-.425.425-1.075t-.425-1.075q-.425-.425-1.075-.425t-1.075.425q-.425.425-.425 1.075t.425 1.075q.425.425 1.075.425t1.075-.425Zm5 0q.425-.425.425-1.075t-.425-1.075q-.425-.425-1.075-.425t-1.075.425q-.425.425-.425 1.075t.425 1.075q.425.425 1.075.425t1.075-.425Zm3 4q.425-.425.425-1.075t-.425-1.075q-.425-.425-1.075-.425t-1.075.425q-.425.425-.425 1.075t.425 1.075q.425.425 1.075.425t1.075-.425ZM12 20q.225 0 .363-.125t.137-.325q0-.35-.375-.825T11.75 17.3q0-1.05.725-1.675T14.25 15h1.75q1.65 0 2.825-.963T20 10.95q0-3.025-2.312-5.038T12.2 3.9q-3.4 0-5.8 2.325T4 11.95q0 3.325 2.338 5.663T12 20Z', 'dropdown': 'M7 10l5 5 5-5H7z', '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' }; interface ListItem { label: string; description: string; icon: string; oid: string; disableIf?: string; category: 'system' | 'appearance'; } // === EURE LISTEN-EINTRÄGE === function getListItems(): ListItem[] { const isPrivacy = getSafeVal(DP_PRIVACY, false) === true; const nameUser1 = isPrivacy ? 'Bewohner 1' : 'Eigener Name'; return [ { category: 'system', label: 'Rollladen Automatik', description: 'Die Rollladen werden nach Sonnenuntergang automatisch geschlossen.', icon: 'sun', oid: 'javascript.0.routines.auto_blinds' }, { category: 'system', label: 'WakeUp-Automatik', description: 'Bei Bewegung nach 6 Uhr am Morgen werden die Rolladen geöffnet.', icon: 'alarm', oid: 'javascript.0.routines.wakeup' }, { category: 'system', label: 'Partymodus', description: 'Schlaf-Taste bleibt verborgen. Automatiken werden pausiert.', icon: 'party', oid: '0_userdata.0.System.Partymodus' }, { category: 'system', label: 'Gäste im Haus', description: 'Verhindert das automatische Ausschalten in bestimmten Bereichen.', icon: 'guests', oid: '0_userdata.0.System.Gaeste' }, { category: 'system', label: nameUser1, description: `Für ${nameUser1} wird eine individuelle Routine aktiviert.`, icon: 'person', oid: 'javascript.0.routines.user1_routine' }, { category: 'appearance', label: 'Privacy Mode (Demo)', description: 'Anonymisiert Namen und Bilder für Screenshots.', icon: 'person', oid: DP_PRIVACY }, { category: 'appearance', label: 'Google JSON Theme', description: 'Aktiv: Nutzt Custom-JSON. Inaktiv: Farben aus dem Wallpaper.', icon: 'palette', oid: `${BASE_PATH}.themeUseJson` }, { category: 'appearance', label: 'Auto-Theme (Astro)', description: 'Wechselt bei Sonnenuntergang in den Dark-Mode.', icon: 'auto', oid: `${BASE_PATH}.autoTheme` }, { category: 'appearance', label: 'Manueller Dark Mode',description: 'Überschreibt das Farbschema (Wenn Auto-Theme aus ist).', icon: 'moon', oid: `${BASE_PATH}.darkMode`, disableIf: `${BASE_PATH}.autoTheme` } ]; } interface WallpaperItem { label: string; path: string; } function getDynamicWallpapers(): WallpaperItem[] { let wallpapers: WallpaperItem[] = []; try { if (fs.existsSync(WALLPAPER_SYS_DIR)) { const files = fs.readdirSync(WALLPAPER_SYS_DIR); files.forEach((file: string) => { const ext = file.toLowerCase(); if (ext.endsWith('.jpg') || ext.endsWith('.jpeg') || ext.endsWith('.png') || ext.endsWith('.webp')) { let cleanName = file.replace(/\.[^/.]+$/, "").replace(/[_-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); wallpapers.push({ label: cleanName, path: WALLPAPER_WEB_DIR + file }); } }); } } catch (e: any) { // Ordner existiert nicht oder fs Modul fehlt } wallpapers.sort((a, b) => a.label.localeCompare(b.label)); return wallpapers; } let DYNAMIC_WALLPAPERS: WallpaperItem[] = getDynamicWallpapers(); function svgIcon(name: string, color: string, size: number = 24): string { const path = ICONS[name] || ICONS['person']; 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>`; } function getSafeVal(oid: string, fallback: any = false): any { if (existsState(oid)) return getState(oid).val; return fallback; } function getWallpaperNameByPath(path: string): string { const found = DYNAMIC_WALLPAPERS.find(wp => wp.path === path); return found ? found.label : 'Auswählen...'; } function renderListItem(item: ListItem): string { const rawVal = getSafeVal(item.oid, false); const active = (rawVal === true || rawVal === 1 || rawVal === 'true'); let disabled = false; if (item.disableIf) disabled = (getSafeVal(item.disableIf, false) === true); const trackBg = active ? M3.primary : M3.surfaceContainerHighest; const trackBorder = active ? M3.primary : M3.outline; const thumbSize = active ? 24 : 16; const thumbColor = active ? M3.onPrimary : M3.outline; const thumbLeft = active ? 'calc(100% - 26px)' : '6px'; const opacity = disabled ? '0.4' : '1'; const pointerEvents = disabled ? 'none' : 'auto'; const clickAction = disabled ? "" : `(function(e){ e.stopPropagation(); if(typeof vis!=='undefined'&&vis.conn&&vis.conn.setState)vis.conn.setState('${item.oid}', ${!active}); else if(typeof vis!=='undefined'&&vis.setValue)vis.setValue('${item.oid}', ${!active}); })(event);`.replace(/\n/g, ' '); const rippleJS = disabled ? "" : `(function(e,el){var evt=e.touches?e.touches[0]:e;if(!evt.clientX)return;var d=Math.max(el.clientWidth,el.clientHeight);var r=d/2;var rect=el.getBoundingClientRect();var x=evt.clientX-rect.left;var y=evt.clientY-rect.top;var c=document.createElement('span');c.style.width=c.style.height=d+'px';c.style.left=(x-r)+'px';c.style.top=(y-r)+'px';c.style.position='absolute';c.style.borderRadius='50%';c.style.backgroundColor='var(--m3-on-surface-variant)';c.style.opacity='0.1';c.style.transform='scale(0)';c.style.animation='m3-ripple-anim 0.5s linear';c.style.pointerEvents='none';el.appendChild(c);setTimeout(function(){c.remove();},500);})(event,this)`; return ` <div onclick="${clickAction}" onmousedown="${rippleJS}" ontouchstart="${rippleJS}" style="position:relative; overflow:hidden; width:100%; border-radius:16px; padding:12px 16px; box-sizing:border-box; background:${M3.surfaceContainerLow}; cursor:${disabled ? 'default' : 'pointer'}; user-select:none; display:flex; align-items:center; opacity:${opacity}; pointer-events:${pointerEvents}; font-family:Roboto,'Segoe UI',system-ui,sans-serif; margin-bottom: 8px; -webkit-tap-highlight-color:transparent; transition: background-color 0.3s ease;"> <div style="flex: 0 0 40px; width:40px; height:40px; border-radius:50%; background:${M3.surfaceVariant}; display:flex; align-items:center; justify-content:center; margin-right:14px;"> ${svgIcon(item.icon, M3.onSurfaceVariant, 20)} </div> <div style="flex: 1 1 0%; min-width: 0; display: block; padding: 2px 0;"> <div style="font-size:16px; font-weight:500; color:${M3.onSurface}; line-height:1.3; margin-bottom:2px; white-space:normal; overflow-wrap:break-word;">${item.label}</div> <div style="font-size:13px; font-weight:400; color:${M3.onSurfaceVariant}; opacity:0.85; line-height:1.4; white-space:normal; overflow-wrap:break-word;">${item.description}</div> </div> <div style="flex: 0 0 52px; width:52px; height:32px; margin-left:12px; border-radius:16px; background:${trackBg}; border:2px solid ${trackBorder}; box-sizing:border-box; position:relative; transition:all 0.25s cubic-bezier(0.2, 0, 0, 1);"> <div style="position:absolute; left:${thumbLeft}; top:50%; transform:translateY(-50%); width:${thumbSize}px; height:${thumbSize}px; border-radius:50%; background:${thumbColor}; transition:all 0.25s cubic-bezier(0.2, 0, 0, 1);"></div> </div> </div>`; } // === RECHTSBÜNDIGE FILTER CHIPS === function renderChartChips(): string { const actVal = getSafeVal(DP_CHART_ACT, true); const setVal = getSafeVal(DP_CHART_SET, true); const winVal = getSafeVal(DP_CHART_WINDOW, true); const brennerVal = getSafeVal(DP_CHART_BRENNER, true); const valveVal = getSafeVal(DP_CHART_VALVE, false); const humidVal = getSafeVal(DP_CHART_HUMID, true); const makeChip = (label: string, active: boolean, dp: string) => { const bg = active ? M3.primaryContainer : 'transparent'; const fg = active ? M3.onPrimaryContainer : M3.onSurfaceVariant; const border = active ? `1px solid ${M3.primaryContainer}` : `1px solid ${M3.outline}`; const iconHtml = active ? `<svg width="18" height="18" viewBox="0 0 24 24" style="margin-right:4px; margin-left:-4px;"><path fill="currentColor" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>` : ''; const js = `(function(e){ e.stopPropagation(); if(typeof vis!=='undefined'&&vis.conn&&vis.conn.setState)vis.conn.setState('${dp}', ${!active}); else if(typeof vis!=='undefined'&&vis.setValue)vis.setValue('${dp}', ${!active}); })(event);`.replace(/\n/g, ' '); return `<div onclick="${js}" style="display:inline-flex; align-items:center; height:32px; padding:0 12px; border-radius:8px; background:${bg}; border:${border}; color:${fg}; font-size:14px; font-weight:500; cursor:pointer; transition:all 0.2s ease;">${iconHtml}${label}</div>`; }; return ` <div style="width:100%; border-radius:16px; padding:12px 16px; box-sizing:border-box; background:${M3.surfaceContainerLow}; margin-bottom:8px; font-family:Roboto,'Segoe UI',system-ui,sans-serif; display:flex; align-items:flex-start;"> <div style="flex:0 0 40px; width:40px; height:40px; border-radius:50%; background:${M3.surfaceVariant}; display:flex; align-items:center; justify-content:center; margin-right:14px;"> ${svgIcon('thermostat', M3.onSurfaceVariant, 20)} </div> <div style="flex: 1 1 0%; min-width: 0;"> <div style="font-size:16px; font-weight:500; color:${M3.onSurface}; margin-bottom:2px;">Graphen-Anzeige</div> <div style="font-size:13px; color:${M3.onSurfaceVariant}; opacity:0.85; margin-bottom:12px; line-height:1.4;">Wähle aus, welche Linien in den Diagrammen gezeichnet werden.</div> <div style="display:flex; flex-wrap:wrap; gap:8px; justify-content:flex-end;"> ${makeChip('Ist-Temp', actVal, DP_CHART_ACT)} ${makeChip('Soll-Temp', setVal, DP_CHART_SET)} ${makeChip('Luftfeuchte', humidVal, DP_CHART_HUMID)} ${makeChip('Fenster', winVal, DP_CHART_WINDOW)} ${makeChip('Brenner', brennerVal, DP_CHART_BRENNER)} ${makeChip('Ventil', valveVal, DP_CHART_VALVE)} </div> </div> </div>`; } function renderMenuWallpaperItem(wp: WallpaperItem, currentPath: string): string { const active = (wp.path === currentPath); const bg = active ? M3.surfaceVariant : 'transparent'; const textColor = active ? M3.primary : M3.onSurface; const clickAction = `(function(e){ e.stopPropagation(); if(typeof vis!=='undefined'&&vis.conn&&vis.conn.setState)vis.conn.setState('${DP_IMAGE_PATH}', '${wp.path}'); else if(typeof vis!=='undefined'&&vis.setValue)vis.setValue('${DP_IMAGE_PATH}', '${wp.path}'); document.getElementById('m3-wallpaper-menu').style.opacity='0'; document.getElementById('m3-wallpaper-menu').style.pointerEvents='none'; })(event);`.replace(/\n/g, ' '); return `<div onclick="${clickAction}" style="position:relative; width:100%; padding:10px 16px; box-sizing:border-box; border-radius:12px; background:${bg}; cursor:pointer; user-select:none; margin-bottom:2px; display:flex; align-items:center; font-family:Roboto,'Segoe UI',system-ui,sans-serif; -webkit-tap-highlight-color:transparent; transition: background-color 0.2s ease;"> <img src="${wp.path}" style="flex:0 0 44px; width:44px; height:32px; border-radius:6px; object-fit:cover; background:${M3.surfaceVariant}; margin-right:14px;"> <div style="flex:1 1 0%; min-width:0; display:block; font-size:15px; font-weight:500; color:${textColor}; line-height:1.4; white-space:normal; overflow-wrap:break-word;">${wp.label}</div> </div>`; } function renderSetup(): void { const allItems = getListItems(); const systemHtml = allItems.filter(item => item.category === 'system').map(item => renderListItem(item)).join(''); const appearanceHtml = allItems.filter(item => item.category === 'appearance').map(item => renderListItem(item)).join(''); const chartChipsHtml = renderChartChips(); const currentWpPath = getSafeVal(DP_IMAGE_PATH, ''); const currentWpName = getWallpaperNameByPath(currentWpPath); const menuItemsHtml = DYNAMIC_WALLPAPERS.map(wp => renderMenuWallpaperItem(wp, currentWpPath)).join(''); const openMenuJS = `document.getElementById('m3-wallpaper-menu').style.opacity='1'; document.getElementById('m3-wallpaper-menu').style.pointerEvents='auto';`; const closeMenuJS = `document.getElementById('m3-wallpaper-menu').style.opacity='0'; document.getElementById('m3-wallpaper-menu').style.pointerEvents='none';`; const rippleJS = `(function(e,el){var evt=e.touches?e.touches[0]:e;if(!evt.clientX)return;var d=Math.max(el.clientWidth,el.clientHeight);var r=d/2;var rect=el.getBoundingClientRect();var x=evt.clientX-rect.left;var y=evt.clientY-rect.top;var c=document.createElement('span');c.style.width=c.style.height=d+'px';c.style.left=(x-r)+'px';c.style.top=(y-r)+'px';c.style.position='absolute';c.style.borderRadius='50%';c.style.backgroundColor='var(--m3-on-surface-variant)';c.style.opacity='0.1';c.style.transform='scale(0)';c.style.animation='m3-ripple-anim 0.5s linear';c.style.pointerEvents='none';el.appendChild(c);setTimeout(function(){c.remove();},500);})(event,this)`; const html = ` <style> @keyframes m3-ripple-anim { to { transform: scale(4); opacity: 0; } } [id^="vis_widget_"] .vis-view-inner-html-html, [id^="vis_widget_"] .vis-view-inner-html-html > div { height: 100% !important; } .m3-scroll-container::-webkit-scrollbar, .m3-menu-scroll::-webkit-scrollbar { width: 6px; } .m3-scroll-container::-webkit-scrollbar-track, .m3-menu-scroll::-webkit-scrollbar-track { background: transparent; } .m3-scroll-container::-webkit-scrollbar-thumb, .m3-menu-scroll::-webkit-scrollbar-thumb { background: var(--m3-outline-variant); border-radius: 3px; } .m3-scroll-container::-webkit-scrollbar-thumb:hover, .m3-menu-scroll::-webkit-scrollbar-thumb:hover { background: var(--m3-outline); } </style> <div class="m3-scroll-container" style="position: absolute; inset: 0; background: var(--m3-surface-container-low, rgb(237, 239, 232)); border-radius: 0 0 28px 28px; padding: 24px; display: block; width: 100%; box-sizing: border-box; overflow-y: auto; transition: background-color 0.3s ease;"> <div style="font-size:12px; font-weight:700; color:${M3.primary}; text-transform:uppercase; letter-spacing:1.2px; margin: 4px 0 8px 4px;">Erscheinungsbild & Anzeige</div> ${appearanceHtml} ${chartChipsHtml} <div style="position:relative; width:100%; border-radius:16px; padding:12px 16px; box-sizing:border-box; background:${M3.surfaceContainerLow}; user-select:none; margin-bottom:24px; display:flex; align-items:center; font-family:Roboto,'Segoe UI',system-ui,sans-serif; -webkit-tap-highlight-color:transparent; transition: all 0.3s ease;"> <div style="flex:0 0 40px; width:40px; height:40px; border-radius:50%; background:${M3.surfaceVariant}; display:flex; align-items:center; justify-content:center; margin-right:14px;">${svgIcon('palette', M3.onSurfaceVariant, 20)}</div> <div style="flex:1 1 0%; min-width:0; display:block;"><div style="font-size:16px; font-weight:500; color:${M3.onSurface}; line-height:normal;">Hintergrundbild</div></div> <div onclick="${openMenuJS}" onmousedown="${rippleJS}" ontouchstart="${rippleJS}" style="flex:0 0 auto; margin-left:12px; position:relative; overflow:hidden; display:flex; align-items:center; gap:8px; background:${M3.surfaceVariant}; padding:8px 16px; border-radius:20px; cursor:pointer; color:${M3.onSurfaceVariant}; transition: background-color 0.2s ease;"> <span style="font-size:14px; font-weight:500; line-height:normal;">${currentWpName}</span>${svgIcon('dropdown', M3.onSurfaceVariant, 20)} </div> </div> <div style="font-size:12px; font-weight:700; color:${M3.primary}; text-transform:uppercase; letter-spacing:1.2px; margin: 0 0 8px 4px;">System & Automatiken</div> ${systemHtml} <div style="height: 24px; flex-shrink: 0;"></div> </div> <div id="m3-wallpaper-menu" style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 999999; display: flex; align-items: center; justify-content: center; opacity: 0; pointer-events: none; transition: opacity 0.2s;"> <div onclick="${closeMenuJS}" style="position: absolute; inset: 0; background: rgba(0,0,0,0.4);"></div> <div style="position: relative; width: 380px; max-height: 80vh; background: var(--m3-surface-container-highest, #e1e3dc); border-radius: 20px; box-shadow: 0 12px 32px rgba(0,0,0,0.3); display: flex; flex-direction: column; overflow: hidden; font-family:Roboto,'Segoe UI',system-ui,sans-serif;"> <div style="padding: 20px 24px 12px 24px; font-size: 15px; font-weight: 600; color:${M3.onSurfaceVariant}; border-bottom: 1px solid ${M3.outlineVariant};">Hintergrundbild auswählen</div> <div class="m3-menu-scroll" style="overflow-y: auto; padding: 12px; display:flex; flex-direction:column;"> ${DYNAMIC_WALLPAPERS.length > 0 ? menuItemsHtml : `<div style="padding:20px; text-align:center; color:${M3.onSurfaceVariant}; font-size:14px;">Keine Bilder gefunden. Ordner prüfen!</div>`} </div> </div> </div>`; setState(DP_SETUP_HTML, html, true); } // === INIT & SUBSCRIPTIONS ====================================================== // Verschachtelte createState Aufrufe stellen sicher, dass alle Datenpunkte existieren, bevor abonniert wird. createState(DP_SETUP_HTML, '', { type: 'string', name: 'Setup HTML (M3)', role: 'html' }, () => { createState(DP_CHART_BRENNER, true, { type: 'boolean', name: 'Show Brenner Chart', role: 'switch' }, () => { createState(DP_CHART_VALVE, false, { type: 'boolean', name: 'Show Valve Chart', role: 'switch' }, () => { createState(DP_CHART_ACT, true, { type: 'boolean', name: 'Show Actual Temp Chart', role: 'switch' }, () => { createState(DP_CHART_SET, true, { type: 'boolean', name: 'Show Set Temp Chart', role: 'switch' }, () => { createState(DP_CHART_WINDOW, true, { type: 'boolean', name: 'Show Window Chart', role: 'switch' }, () => { createState(DP_CHART_HUMID, true, { type: 'boolean', name: 'Show Humidity Chart', role: 'switch' }, () => { createState(DP_PRIVACY, false, { type: 'boolean', name: 'Privacy Mode Toggle', role: 'switch' }, () => { createState(DP_IMAGE_PATH, '', { type: 'string', name: 'Theme Image Path', role: 'text' }, () => { getListItems().forEach(item => { if (existsState(item.oid)) on({ id: item.oid, change: 'any' }, () => renderSetup()); if (item.disableIf && existsState(item.disableIf)) on({ id: item.disableIf, change: 'any' }, () => renderSetup()); }); on({ id: [DP_CHART_BRENNER, DP_CHART_VALVE, DP_CHART_ACT, DP_CHART_SET, DP_CHART_WINDOW, DP_CHART_HUMID], change: 'any' }, () => renderSetup()); on({ id: DP_IMAGE_PATH, change: 'any' }, () => renderSetup()); renderSetup(); }); }); });});});});});}); });

-
@mrmuppet sehr beeindruckend! Kannst du auch mal zeigen, wie die Scripte für deine Thermostate, Lichter und Beschattungen aussehen?
-
Hier ist auch mein aktuelles Heizungs-Dashboard.
Es generiert ein eigenständiges, responsives HTML-Widget im modernen Material Design 3 (M3) Look, das sich direkt in Vis, Vis-2 oder anderen Visualisierungen einbinden lässt.Das Skript kombiniert die Steuerung der Thermostate mit dynamisch generierten ApexCharts für historische Daten – alles verpackt in sanft aufklappenden Kacheln.
Die Funktionen im Überblick
-
Zentrale Übersicht: Eine große Hauptkachel zeigt den Status der Zentralheizung (Brenner-Modulation) und die Anzahl der aktuell heizenden Räume an.
-
M3 Room-Cards: Jeder Raum hat eine eigene, dynamisch eingefärbte Kachel.
-
Rot = Raum heizt aktuell.
-
Orange = Raum fordert Wärme, aber Zentralheizung ist aus.
-
Grau/Grün = Standby / Zieltemperatur erreicht.
-
Direkte Steuerung: Über die integrierten Plus- und Minus-Buttons lässt sich die Zieltemperatur (
SET) direkt in der Kachel in 0,5er-Schritten anpassen. -
Smarte Sensorik: Die Kacheln zeigen neben der Temperatur auch die aktuelle Luftfeuchtigkeit an. Ist ein Fenster geöffnet, warnt ein rot blinkendes Fenster-Icon.
-
Animierte ApexCharts: Klickt man auf eine Raumkachel, klappt sie weich auf und rendert sofort einen interaktiven Graphen der letzten 24 Stunden.
-
Graphen-Inhalt: Ist-Temperatur, Soll-Temperatur, Ventilöffnung, Brenner-Modulation, Luftfeuchtigkeit und farblich hinterlegte Zonen für Zeiträume mit geöffnetem Fenster.
Voraussetzungen & Einrichtung
Um das Skript nutzen zu können, wird Folgendes benötigt:
- InfluxDB: Eine laufende Instanz (im Skript definiert als
influxdb.0), die die Verlaufsdaten loggt. - Aliase: Die Thermostate und Sensoren sollten als Aliase angelegt sein. Das Skript erwartet pro Raum Datenpunkte für
ACTUAL(Ist-Temperatur),SET(Soll-Temperatur) und idealerweiseVALVE(Ventilöffnung in %). Optional: Fenster-Kontakte (STATE) und Luftfeuchtigkeit (HUMIDITY).
So wird es eingerichtet:
- Ein neues TypeScript im Javascript-Adapter anlegen und den Code einfügen.
- Oben im Skript den
INFLUX_INSTANCENamen anpassen, falls eine andere Instanz genutzt wird. - Das
rooms-Array an die eigenen Alias-Pfade und Raumnamen anpassen. Das Grid skaliert danach automatisch. - Das Skript starten. Es generiert automatisch alle nötigen Konfigurations-Datenpunkte sowie den finalen HTML-Datenpunkt:
0_userdata.0.dashboard.heizungHTML. - Diesen HTML-Datenpunkt in der Vis einfach als "Basis - String (unescaped)" oder HTML-Widget einbinden.
Anpassung der Graphen
Das Skript legt unter
0_userdata.0.dashboard.automatisch mehrere Switch-Datenpunkte an (z. B.chartShowBrenner,chartShowWindow). Hierüber lässt sich jederzeit global und live umschalten, welche Linien in den Charts der Räume gezeichnet werden sollen.Viel Spaß beim Ausprobieren und Anpassen! Wenn es Fragen gibt, gerne melden.

// ============================================================ // renderHeizung — M3 Material You Styling (9 Räume Grid) - v1.2 // ioBroker TypeScript — Inkl. Luftfeuchtigkeit, SYNC-FIX & MULTI-FENSTER // CHANGELOG v1.2: // - FIX: ApexCharts Memory-Leak — chart.destroy() vor jedem Re-Render und beim Schließen. // - FIX: updateHistories() nutzt Promise.all — parallele InfluxDB-Abfragen pro Raum // (45 sequentielle → ~5 parallele Batches, deutlich schneller). // - FIX: createState ohne existsState-Guard überschreibt bei Neustart. // - FIX: getSafeVal() null-Guard für getState() ergänzt. // - FIX: Alle getState()-Aufrufe in renderHeizung() mit null-Guard. // - FIX: createState Chart-Konfig-Datenpunkte fehlen — werden nun ebenfalls angelegt. // ============================================================ 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'; const INFLUX_INSTANCE: string = 'influxdb.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)'; // FIX v1.1: null-Guard für getState() 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 fetchInfluxData(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(INFLUX_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 fetchInfluxData(DP_MODULATION, start, end, true); for (const room of rooms) { // FIX v1.2: Promise.all — alle Sensoren eines Raums parallel abfragen const [actData, setData, valveData, humidData] = await Promise.all([ fetchInfluxData(room.actOid, start, end, false), fetchInfluxData(room.setOid, start, end, false), fetchInfluxData(room.valveOid, start, end, true), fetchInfluxData(room.humidOid, start, end, false), ]); // FIX v1.2: Fenster eines Raums ebenfalls parallel abfragen let allWindowData: any[] = []; if (room.windowOids && room.windowOids.length > 0) { const wResults = await Promise.all(room.windowOids.map(wOid => fetchInfluxData(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 s_curve = []; var s_width = []; var s_dash = []; var s_fillType = []; var s_opacity = []; var s_yaxis = []; 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); s_curve.push('smooth'); s_width.push(0); s_dash.push(0); s_fillType.push('solid'); s_opacity.push(0.1); s_yaxis.push({ min: 0, max: 100, show: false }); } if (cfgShowBrenner) { s_series.push({ name: 'Brenner', type: 'area', data: chartData.heat || [] }); s_colors.push(colorBrenner); s_curve.push('smooth'); s_width.push(2); s_dash.push(0); s_fillType.push('gradient'); s_opacity.push(1); s_yaxis.push({ min: 0, max: 100, show: false }); } if (cfgShowValve) { s_series.push({ name: 'Ventil', type: 'line', data: chartData.valve || [] }); s_colors.push(colorVentil); s_curve.push('stepline'); s_width.push(2); s_dash.push(0); s_fillType.push('solid'); s_opacity.push(1); s_yaxis.push({ min: 0, max: 100, show: false }); } if (cfgShowAct) { s_series.push({ name: 'Ist-Temp', type: 'area', data: chartData.act || [] }); s_colors.push(colorIst); s_curve.push('smooth'); s_width.push(3); s_dash.push(0); s_fillType.push('gradient'); s_opacity.push(1); s_yaxis.push({ min: minTemp, max: maxTemp, show: false, tickAmount: 5 }); } if (cfgShowSet) { s_series.push({ name: 'Ziel-Temp', type: 'line', data: chartData.set || [] }); s_colors.push(colorSoll); s_curve.push('stepline'); s_width.push(2); s_dash.push(4); s_fillType.push('solid'); s_opacity.push(1); s_yaxis.push({ min: minTemp, max: maxTemp, show: false, tickAmount: 5 }); } 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); s_curve.push('stepline'); s_width.push(0); s_dash.push(0); s_fillType.push('solid'); s_opacity.push(0); s_yaxis.push({ min: minTemp, max: maxTemp, show: false, tickAmount: 5 }); } 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; } var options = { chart: { type: 'line', height: 140, width: '100%', parentHeightOffset: 0, toolbar: { show: false }, animations: { enabled: true, easing: 'easeinout', speed: 800, dynamicAnimation: { enabled: false } } }, series: s_series, colors: s_colors, stroke: { curve: s_curve, width: s_width, dashArray: s_dash }, fill: { type: s_fillType, opacity: s_opacity, 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 } }, yaxis: s_yaxis, grid: { show: false }, annotations: { xaxis: windowAnnotations } }; 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 === // FIX v1.1: existsState-Guard + Chart-Konfig-Datenpunkte werden ebenfalls angelegt 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.1 gestartet.'); } // Chart-Konfig-Datenpunkte anlegen falls nicht vorhanden, dann HTML-DP, dann starten 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(); } }); -
-
Hier ist auch mein aktuelles Heizungs-Dashboard.
Es generiert ein eigenständiges, responsives HTML-Widget im modernen Material Design 3 (M3) Look, das sich direkt in Vis, Vis-2 oder anderen Visualisierungen einbinden lässt.Das Skript kombiniert die Steuerung der Thermostate mit dynamisch generierten ApexCharts für historische Daten – alles verpackt in sanft aufklappenden Kacheln.
Die Funktionen im Überblick
-
Zentrale Übersicht: Eine große Hauptkachel zeigt den Status der Zentralheizung (Brenner-Modulation) und die Anzahl der aktuell heizenden Räume an.
-
M3 Room-Cards: Jeder Raum hat eine eigene, dynamisch eingefärbte Kachel.
-
Rot = Raum heizt aktuell.
-
Orange = Raum fordert Wärme, aber Zentralheizung ist aus.
-
Grau/Grün = Standby / Zieltemperatur erreicht.
-
Direkte Steuerung: Über die integrierten Plus- und Minus-Buttons lässt sich die Zieltemperatur (
SET) direkt in der Kachel in 0,5er-Schritten anpassen. -
Smarte Sensorik: Die Kacheln zeigen neben der Temperatur auch die aktuelle Luftfeuchtigkeit an. Ist ein Fenster geöffnet, warnt ein rot blinkendes Fenster-Icon.
-
Animierte ApexCharts: Klickt man auf eine Raumkachel, klappt sie weich auf und rendert sofort einen interaktiven Graphen der letzten 24 Stunden.
-
Graphen-Inhalt: Ist-Temperatur, Soll-Temperatur, Ventilöffnung, Brenner-Modulation, Luftfeuchtigkeit und farblich hinterlegte Zonen für Zeiträume mit geöffnetem Fenster.
Voraussetzungen & Einrichtung
Um das Skript nutzen zu können, wird Folgendes benötigt:
- InfluxDB: Eine laufende Instanz (im Skript definiert als
influxdb.0), die die Verlaufsdaten loggt. - Aliase: Die Thermostate und Sensoren sollten als Aliase angelegt sein. Das Skript erwartet pro Raum Datenpunkte für
ACTUAL(Ist-Temperatur),SET(Soll-Temperatur) und idealerweiseVALVE(Ventilöffnung in %). Optional: Fenster-Kontakte (STATE) und Luftfeuchtigkeit (HUMIDITY).
So wird es eingerichtet:
- Ein neues TypeScript im Javascript-Adapter anlegen und den Code einfügen.
- Oben im Skript den
INFLUX_INSTANCENamen anpassen, falls eine andere Instanz genutzt wird. - Das
rooms-Array an die eigenen Alias-Pfade und Raumnamen anpassen. Das Grid skaliert danach automatisch. - Das Skript starten. Es generiert automatisch alle nötigen Konfigurations-Datenpunkte sowie den finalen HTML-Datenpunkt:
0_userdata.0.dashboard.heizungHTML. - Diesen HTML-Datenpunkt in der Vis einfach als "Basis - String (unescaped)" oder HTML-Widget einbinden.
Anpassung der Graphen
Das Skript legt unter
0_userdata.0.dashboard.automatisch mehrere Switch-Datenpunkte an (z. B.chartShowBrenner,chartShowWindow). Hierüber lässt sich jederzeit global und live umschalten, welche Linien in den Charts der Räume gezeichnet werden sollen.Viel Spaß beim Ausprobieren und Anpassen! Wenn es Fragen gibt, gerne melden.

// ============================================================ // renderHeizung — M3 Material You Styling (9 Räume Grid) - v1.2 // ioBroker TypeScript — Inkl. Luftfeuchtigkeit, SYNC-FIX & MULTI-FENSTER // CHANGELOG v1.2: // - FIX: ApexCharts Memory-Leak — chart.destroy() vor jedem Re-Render und beim Schließen. // - FIX: updateHistories() nutzt Promise.all — parallele InfluxDB-Abfragen pro Raum // (45 sequentielle → ~5 parallele Batches, deutlich schneller). // - FIX: createState ohne existsState-Guard überschreibt bei Neustart. // - FIX: getSafeVal() null-Guard für getState() ergänzt. // - FIX: Alle getState()-Aufrufe in renderHeizung() mit null-Guard. // - FIX: createState Chart-Konfig-Datenpunkte fehlen — werden nun ebenfalls angelegt. // ============================================================ 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'; const INFLUX_INSTANCE: string = 'influxdb.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)'; // FIX v1.1: null-Guard für getState() 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 fetchInfluxData(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(INFLUX_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 fetchInfluxData(DP_MODULATION, start, end, true); for (const room of rooms) { // FIX v1.2: Promise.all — alle Sensoren eines Raums parallel abfragen const [actData, setData, valveData, humidData] = await Promise.all([ fetchInfluxData(room.actOid, start, end, false), fetchInfluxData(room.setOid, start, end, false), fetchInfluxData(room.valveOid, start, end, true), fetchInfluxData(room.humidOid, start, end, false), ]); // FIX v1.2: Fenster eines Raums ebenfalls parallel abfragen let allWindowData: any[] = []; if (room.windowOids && room.windowOids.length > 0) { const wResults = await Promise.all(room.windowOids.map(wOid => fetchInfluxData(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 s_curve = []; var s_width = []; var s_dash = []; var s_fillType = []; var s_opacity = []; var s_yaxis = []; 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); s_curve.push('smooth'); s_width.push(0); s_dash.push(0); s_fillType.push('solid'); s_opacity.push(0.1); s_yaxis.push({ min: 0, max: 100, show: false }); } if (cfgShowBrenner) { s_series.push({ name: 'Brenner', type: 'area', data: chartData.heat || [] }); s_colors.push(colorBrenner); s_curve.push('smooth'); s_width.push(2); s_dash.push(0); s_fillType.push('gradient'); s_opacity.push(1); s_yaxis.push({ min: 0, max: 100, show: false }); } if (cfgShowValve) { s_series.push({ name: 'Ventil', type: 'line', data: chartData.valve || [] }); s_colors.push(colorVentil); s_curve.push('stepline'); s_width.push(2); s_dash.push(0); s_fillType.push('solid'); s_opacity.push(1); s_yaxis.push({ min: 0, max: 100, show: false }); } if (cfgShowAct) { s_series.push({ name: 'Ist-Temp', type: 'area', data: chartData.act || [] }); s_colors.push(colorIst); s_curve.push('smooth'); s_width.push(3); s_dash.push(0); s_fillType.push('gradient'); s_opacity.push(1); s_yaxis.push({ min: minTemp, max: maxTemp, show: false, tickAmount: 5 }); } if (cfgShowSet) { s_series.push({ name: 'Ziel-Temp', type: 'line', data: chartData.set || [] }); s_colors.push(colorSoll); s_curve.push('stepline'); s_width.push(2); s_dash.push(4); s_fillType.push('solid'); s_opacity.push(1); s_yaxis.push({ min: minTemp, max: maxTemp, show: false, tickAmount: 5 }); } 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); s_curve.push('stepline'); s_width.push(0); s_dash.push(0); s_fillType.push('solid'); s_opacity.push(0); s_yaxis.push({ min: minTemp, max: maxTemp, show: false, tickAmount: 5 }); } 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; } var options = { chart: { type: 'line', height: 140, width: '100%', parentHeightOffset: 0, toolbar: { show: false }, animations: { enabled: true, easing: 'easeinout', speed: 800, dynamicAnimation: { enabled: false } } }, series: s_series, colors: s_colors, stroke: { curve: s_curve, width: s_width, dashArray: s_dash }, fill: { type: s_fillType, opacity: s_opacity, 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 } }, yaxis: s_yaxis, grid: { show: false }, annotations: { xaxis: windowAnnotations } }; 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 === // FIX v1.1: existsState-Guard + Chart-Konfig-Datenpunkte werden ebenfalls angelegt 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.1 gestartet.'); } // Chart-Konfig-Datenpunkte anlegen falls nicht vorhanden, dann HTML-DP, dann starten 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(); } });@mrMuppet
Hallo, kannst du das Skript auch für Historie Adapter machen? -
-
So sollte es gehen:
Das Schöne an ioBroker ist: Der
history.0, dersql.0und derinfluxdb.0Adapter teilen sich exakt denselben Befehl (getHistory), um Daten abzufragen. Wir müssen also nur die Ziel-Instanz ändern und die Variablen-Namen der Übersicht halber anpassen.Hier ist das vollständig auf den history-Adapter (
history.0) umgeschriebene Heizungs-Skript:// ============================================================ // renderHeizung — M3 Material You Styling (9 Räume Grid) - v1.2 (History Edition) // ioBroker TypeScript — Inkl. Luftfeuchtigkeit, SYNC-FIX & MULTI-FENSTER // CHANGELOG v1.2: // - ANPASSUNG: Abfrage der Graphen-Daten von 'influxdb.0' auf den // Standard 'history.0' Adapter umgestellt. // - FIX: ApexCharts Memory-Leak — chart.destroy() vor jedem Re-Render und beim Schließen. // - FIX: updateHistories() nutzt Promise.all — parallele Abfragen pro Raum // (deutlich schneller). // - FIX: createState ohne existsState-Guard überschreibt bei Neustart. // ============================================================ 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 s_curve = []; var s_width = []; var s_dash = []; var s_fillType = []; var s_opacity = []; var s_yaxis = []; 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); s_curve.push('smooth'); s_width.push(0); s_dash.push(0); s_fillType.push('solid'); s_opacity.push(0.1); s_yaxis.push({ min: 0, max: 100, show: false }); } if (cfgShowBrenner) { s_series.push({ name: 'Brenner', type: 'area', data: chartData.heat || [] }); s_colors.push(colorBrenner); s_curve.push('smooth'); s_width.push(2); s_dash.push(0); s_fillType.push('gradient'); s_opacity.push(1); s_yaxis.push({ min: 0, max: 100, show: false }); } if (cfgShowValve) { s_series.push({ name: 'Ventil', type: 'line', data: chartData.valve || [] }); s_colors.push(colorVentil); s_curve.push('stepline'); s_width.push(2); s_dash.push(0); s_fillType.push('solid'); s_opacity.push(1); s_yaxis.push({ min: 0, max: 100, show: false }); } if (cfgShowAct) { s_series.push({ name: 'Ist-Temp', type: 'area', data: chartData.act || [] }); s_colors.push(colorIst); s_curve.push('smooth'); s_width.push(3); s_dash.push(0); s_fillType.push('gradient'); s_opacity.push(1); s_yaxis.push({ min: minTemp, max: maxTemp, show: false, tickAmount: 5 }); } if (cfgShowSet) { s_series.push({ name: 'Ziel-Temp', type: 'line', data: chartData.set || [] }); s_colors.push(colorSoll); s_curve.push('stepline'); s_width.push(2); s_dash.push(4); s_fillType.push('solid'); s_opacity.push(1); s_yaxis.push({ min: minTemp, max: maxTemp, show: false, tickAmount: 5 }); } 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); s_curve.push('stepline'); s_width.push(0); s_dash.push(0); s_fillType.push('solid'); s_opacity.push(0); s_yaxis.push({ min: minTemp, max: maxTemp, show: false, tickAmount: 5 }); } 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; } var options = { chart: { type: 'line', height: 140, width: '100%', parentHeightOffset: 0, toolbar: { show: false }, animations: { enabled: true, easing: 'easeinout', speed: 800, dynamicAnimation: { enabled: false } } }, series: s_series, colors: s_colors, stroke: { curve: s_curve, width: s_width, dashArray: s_dash }, fill: { type: s_fillType, opacity: s_opacity, 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 } }, yaxis: s_yaxis, grid: { show: false }, annotations: { xaxis: windowAnnotations } }; 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.2 (History Edition) 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(); } });Teste gerne mal und berichte!
-
So sollte es gehen:
Das Schöne an ioBroker ist: Der
history.0, dersql.0und derinfluxdb.0Adapter teilen sich exakt denselben Befehl (getHistory), um Daten abzufragen. Wir müssen also nur die Ziel-Instanz ändern und die Variablen-Namen der Übersicht halber anpassen.Hier ist das vollständig auf den history-Adapter (
history.0) umgeschriebene Heizungs-Skript:// ============================================================ // renderHeizung — M3 Material You Styling (9 Räume Grid) - v1.2 (History Edition) // ioBroker TypeScript — Inkl. Luftfeuchtigkeit, SYNC-FIX & MULTI-FENSTER // CHANGELOG v1.2: // - ANPASSUNG: Abfrage der Graphen-Daten von 'influxdb.0' auf den // Standard 'history.0' Adapter umgestellt. // - FIX: ApexCharts Memory-Leak — chart.destroy() vor jedem Re-Render und beim Schließen. // - FIX: updateHistories() nutzt Promise.all — parallele Abfragen pro Raum // (deutlich schneller). // - FIX: createState ohne existsState-Guard überschreibt bei Neustart. // ============================================================ 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 s_curve = []; var s_width = []; var s_dash = []; var s_fillType = []; var s_opacity = []; var s_yaxis = []; 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); s_curve.push('smooth'); s_width.push(0); s_dash.push(0); s_fillType.push('solid'); s_opacity.push(0.1); s_yaxis.push({ min: 0, max: 100, show: false }); } if (cfgShowBrenner) { s_series.push({ name: 'Brenner', type: 'area', data: chartData.heat || [] }); s_colors.push(colorBrenner); s_curve.push('smooth'); s_width.push(2); s_dash.push(0); s_fillType.push('gradient'); s_opacity.push(1); s_yaxis.push({ min: 0, max: 100, show: false }); } if (cfgShowValve) { s_series.push({ name: 'Ventil', type: 'line', data: chartData.valve || [] }); s_colors.push(colorVentil); s_curve.push('stepline'); s_width.push(2); s_dash.push(0); s_fillType.push('solid'); s_opacity.push(1); s_yaxis.push({ min: 0, max: 100, show: false }); } if (cfgShowAct) { s_series.push({ name: 'Ist-Temp', type: 'area', data: chartData.act || [] }); s_colors.push(colorIst); s_curve.push('smooth'); s_width.push(3); s_dash.push(0); s_fillType.push('gradient'); s_opacity.push(1); s_yaxis.push({ min: minTemp, max: maxTemp, show: false, tickAmount: 5 }); } if (cfgShowSet) { s_series.push({ name: 'Ziel-Temp', type: 'line', data: chartData.set || [] }); s_colors.push(colorSoll); s_curve.push('stepline'); s_width.push(2); s_dash.push(4); s_fillType.push('solid'); s_opacity.push(1); s_yaxis.push({ min: minTemp, max: maxTemp, show: false, tickAmount: 5 }); } 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); s_curve.push('stepline'); s_width.push(0); s_dash.push(0); s_fillType.push('solid'); s_opacity.push(0); s_yaxis.push({ min: minTemp, max: maxTemp, show: false, tickAmount: 5 }); } 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; } var options = { chart: { type: 'line', height: 140, width: '100%', parentHeightOffset: 0, toolbar: { show: false }, animations: { enabled: true, easing: 'easeinout', speed: 800, dynamicAnimation: { enabled: false } } }, series: s_series, colors: s_colors, stroke: { curve: s_curve, width: s_width, dashArray: s_dash }, fill: { type: s_fillType, opacity: s_opacity, 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 } }, yaxis: s_yaxis, grid: { show: false }, annotations: { xaxis: windowAnnotations } }; 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.2 (History Edition) 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(); } });Teste gerne mal und berichte!
Ok, stürzt ab:

web.0 2026-06-08 17:03:57.041 error Cannot subscribe "curve: s_curve, width: s_width, dashArray: s_dash": Error The pattern "curve: s_curve, width: s_width, dashArray: s_dash" is not a valid ID pattern web.0 2026-06-08 17:03:57.041 error Cannot subscribe "min: 0, max: 100, show: false": Error The pattern "min: 0, max: 100, show: false" is not a valid ID pattern host.SmartHome-Server 2026-06-08 17:03:57.034 warn States 127.0.0.1:65371 Error from InMemDB: Error: The pattern "curve: s_curve, width: s_width, dashArray: s_dash" is not a valid ID pattern at Module.pattern2RegEx (file:///C:/SmartHome/node_modules/@iobroker/js-controller-common-db/src/lib/common/tools.ts:2515:15) at StatesInMemoryServer.handleSubscribe (file:///C:/SmartHome/node_modules/@iobroker/db-base/src/lib/inMemFileDB.ts:323:59) at StatesInMemoryServer._subscribeForClient (file:///C:/SmartHome/node_modules/@iobroker/db-states-file/src/lib/states/statesInMemFileDB.js:240:14) at RedisHandler.<anonymous> (file:///C:/SmartHome/node_modules/@iobroker/db-states-jsonl/src/lib/states/statesInMemServerRedis.js:380:26) at RedisHandler.emit (node:events:519:28) at Immediate._onImmediate (file:///C:/SmartHome/node_modules/@iobroker/db-base/src/lib/redisHandler.ts:210:37) at processImmediate (node:internal/timers:484:21) host.SmartHome-Server 2026-06-08 17:03:57.034 warn States 127.0.0.1:65371 Error from InMemDB: Error: The pattern "min: 0, max: 100, show: false" is not a valid ID pattern at Module.pattern2RegEx (file:///C:/SmartHome/node_modules/@iobroker/js-controller-common-db/src/lib/common/tools.ts:2515:15) at StatesInMemoryServer.handleSubscribe (file:///C:/SmartHome/node_modules/@iobroker/db-base/src/lib/inMemFileDB.ts:323:59) at StatesInMemoryServer._subscribeForClient (file:///C:/SmartHome/node_modules/@iobroker/db-states-file/src/lib/states/statesInMemFileDB.js:240:14) at RedisHandler.<anonymous> (file:///C:/SmartHome/node_modules/@iobroker/db-states-jsonl/src/lib/states/statesInMemServerRedis.js:380:26) at RedisHandler.emit (node:events:519:28) at Immediate._onImmediate (file:///C:/SmartHome/node_modules/@iobroker/db-base/src/lib/redisHandler.ts:210:37) at processImmediate (node:internal/timers:484:21) web.0 2026-06-08 17:03:57.034 error Cannot subscribe "chartDiv.innerHTML = ''": Error The pattern "chartDiv.innerHTML = ''" is not a valid ID pattern web.0 2026-06-08 17:03:57.034 error Cannot subscribe "el.style.top = '0'": Error The pattern "el.style.top = '0'" is not a valid ID pattern web.0 2026-06-08 17:03:57.034 error Cannot subscribe "const oldEl = document.getElementById('card_' + window.activeM3CardId)": Error The pattern "const oldEl = document.getElementById('card_' + window.activeM3CardId)" is not a valid ID patternconst rooms: RoomHeater[] = [ { id: 'bad', label: 'Bad', actOid: 'hm-rpc.1.000A1D8997CC3E.1.ACTUAL_TEMPERATURE', setOid: 'hm-rpc.1.000A1D8997CC3E.1.SET_POINT_TEMPERATURE', valveOid: 'hm-rpc.1.000A1D8997CC3E.1.LEVEL', windowOids: ['hm-rpc.0.OEQ0926852.1.STATE'], humidOid: 'hm-rpc.0.OEQ0670648.1.HUMIDITY' }, { id: 'schlafzimmer',label: 'Zimmer 1', anonLabel: 'Schlafzimmer', actOid: 'hm-rpc.1.000A1D8997CC3E.1.ACTUAL_TEMPERATURE', setOid: 'hm-rpc.1.000A1D8997CC3E.1.SET_POINT_TEMPERATURE', valveOid: 'hm-rpc.1.000A1D8997CC3E.1.LEVEL', windowOids: ['hm-rpc.0.OEQ0926852.1.STATE'], humidOid: 'hm-rpc.0.OEQ0670648.1.HUMIDITY' }, { id: 'kind1', label: 'Zimmer 2', anonLabel: 'Kinderzimmer 1', actOid: 'hm-rpc.1.000A1D8997CC3E.1.ACTUAL_TEMPERATURE', setOid: 'hm-rpc.1.000A1D8997CC3E.1.SET_POINT_TEMPERATURE', valveOid: 'hm-rpc.1.000A1D8997CC3E.1.LEVEL', windowOids: ['hm-rpc.0.OEQ0926852.1.STATE'], humidOid: 'hm-rpc.0.OEQ0670648.1.HUMIDITY' }, ]; -
Ok, stürzt ab:

web.0 2026-06-08 17:03:57.041 error Cannot subscribe "curve: s_curve, width: s_width, dashArray: s_dash": Error The pattern "curve: s_curve, width: s_width, dashArray: s_dash" is not a valid ID pattern web.0 2026-06-08 17:03:57.041 error Cannot subscribe "min: 0, max: 100, show: false": Error The pattern "min: 0, max: 100, show: false" is not a valid ID pattern host.SmartHome-Server 2026-06-08 17:03:57.034 warn States 127.0.0.1:65371 Error from InMemDB: Error: The pattern "curve: s_curve, width: s_width, dashArray: s_dash" is not a valid ID pattern at Module.pattern2RegEx (file:///C:/SmartHome/node_modules/@iobroker/js-controller-common-db/src/lib/common/tools.ts:2515:15) at StatesInMemoryServer.handleSubscribe (file:///C:/SmartHome/node_modules/@iobroker/db-base/src/lib/inMemFileDB.ts:323:59) at StatesInMemoryServer._subscribeForClient (file:///C:/SmartHome/node_modules/@iobroker/db-states-file/src/lib/states/statesInMemFileDB.js:240:14) at RedisHandler.<anonymous> (file:///C:/SmartHome/node_modules/@iobroker/db-states-jsonl/src/lib/states/statesInMemServerRedis.js:380:26) at RedisHandler.emit (node:events:519:28) at Immediate._onImmediate (file:///C:/SmartHome/node_modules/@iobroker/db-base/src/lib/redisHandler.ts:210:37) at processImmediate (node:internal/timers:484:21) host.SmartHome-Server 2026-06-08 17:03:57.034 warn States 127.0.0.1:65371 Error from InMemDB: Error: The pattern "min: 0, max: 100, show: false" is not a valid ID pattern at Module.pattern2RegEx (file:///C:/SmartHome/node_modules/@iobroker/js-controller-common-db/src/lib/common/tools.ts:2515:15) at StatesInMemoryServer.handleSubscribe (file:///C:/SmartHome/node_modules/@iobroker/db-base/src/lib/inMemFileDB.ts:323:59) at StatesInMemoryServer._subscribeForClient (file:///C:/SmartHome/node_modules/@iobroker/db-states-file/src/lib/states/statesInMemFileDB.js:240:14) at RedisHandler.<anonymous> (file:///C:/SmartHome/node_modules/@iobroker/db-states-jsonl/src/lib/states/statesInMemServerRedis.js:380:26) at RedisHandler.emit (node:events:519:28) at Immediate._onImmediate (file:///C:/SmartHome/node_modules/@iobroker/db-base/src/lib/redisHandler.ts:210:37) at processImmediate (node:internal/timers:484:21) web.0 2026-06-08 17:03:57.034 error Cannot subscribe "chartDiv.innerHTML = ''": Error The pattern "chartDiv.innerHTML = ''" is not a valid ID pattern web.0 2026-06-08 17:03:57.034 error Cannot subscribe "el.style.top = '0'": Error The pattern "el.style.top = '0'" is not a valid ID pattern web.0 2026-06-08 17:03:57.034 error Cannot subscribe "const oldEl = document.getElementById('card_' + window.activeM3CardId)": Error The pattern "const oldEl = document.getElementById('card_' + window.activeM3CardId)" is not a valid ID patternconst rooms: RoomHeater[] = [ { id: 'bad', label: 'Bad', actOid: 'hm-rpc.1.000A1D8997CC3E.1.ACTUAL_TEMPERATURE', setOid: 'hm-rpc.1.000A1D8997CC3E.1.SET_POINT_TEMPERATURE', valveOid: 'hm-rpc.1.000A1D8997CC3E.1.LEVEL', windowOids: ['hm-rpc.0.OEQ0926852.1.STATE'], humidOid: 'hm-rpc.0.OEQ0670648.1.HUMIDITY' }, { id: 'schlafzimmer',label: 'Zimmer 1', anonLabel: 'Schlafzimmer', actOid: 'hm-rpc.1.000A1D8997CC3E.1.ACTUAL_TEMPERATURE', setOid: 'hm-rpc.1.000A1D8997CC3E.1.SET_POINT_TEMPERATURE', valveOid: 'hm-rpc.1.000A1D8997CC3E.1.LEVEL', windowOids: ['hm-rpc.0.OEQ0926852.1.STATE'], humidOid: 'hm-rpc.0.OEQ0670648.1.HUMIDITY' }, { id: 'kind1', label: 'Zimmer 2', anonLabel: 'Kinderzimmer 1', actOid: 'hm-rpc.1.000A1D8997CC3E.1.ACTUAL_TEMPERATURE', setOid: 'hm-rpc.1.000A1D8997CC3E.1.SET_POINT_TEMPERATURE', valveOid: 'hm-rpc.1.000A1D8997CC3E.1.LEVEL', windowOids: ['hm-rpc.0.OEQ0926852.1.STATE'], humidOid: 'hm-rpc.0.OEQ0670648.1.HUMIDITY' }, ];als mit dem adapter unbeteiligter entdecke ich einen effekt, den ich auch bei jsontemplate habe wenn man nichts davon weiß

alles html was durch ein widget in vis2 (auch vis1) eingebracht wird, wird durch vis nochmal auf binding notationen untersucht.
ich vermute mal, das an den stellen an denen ich undefined markiert habe klammerkonstrukte stehen, also
{ .... }
das wird als binding erkannt und interpretiert. da da ungültiges drin steht ist das ergebnis undefined.verhindern kann man das aktuell nur, das man die klammern {} jeweils in separate zeilen schreibt.
also aus{ ... }wird
{ ... }
Hey! Du scheinst an dieser Unterhaltung interessiert zu sein, hast aber noch kein Konto.
Hast du es satt, bei jedem Besuch durch die gleichen Beiträge zu scrollen? Wenn du dich für ein Konto anmeldest, kommst du immer genau dorthin zurück, wo du zuvor warst, und kannst dich über neue Antworten benachrichtigen lassen (entweder per E-Mail oder Push-Benachrichtigung). Du kannst auch Lesezeichen speichern und Beiträge positiv bewerten, um anderen Community-Mitgliedern deine Wertschätzung zu zeigen.
Mit deinem Input könnte dieser Beitrag noch besser werden 💗
Registrieren Anmelden