NEWS
E-Ink OpenEpaperLink - JSON-Steuerung per Javascript
-
Nachdem die meisten OEPL Steuerungen anscheinend über den Weg der Screenshots mit Puppeteer gehen, hier mal mein Ansatz über JSONs.
Mein Puppeteer Skript habe ich hierdurch ersetzt und bin sehr zufrieden damit.
Mittels der Konfigurationsblöcke kann ich sehr simpel umsetzen, was ich mag und jeder Tag hat seine eigene Zeitsteuerung. Ich habe mal ein paar Varianten reinkopiert, die ich durchgespielt habe.
Vielleicht hilfts ja jemandem.
/** * ############################################################################ * OpenEPaperLink – vollautomatische ePaper-Steuerung mit Zeitschaltung * und Signalbildern für Fenster- / Tür-Status, realisiert über JSON-Templates * ############################################################################ * * Im August 2025 von BertDerKleine * * Zweck * ===== * Dieses Skript versorgt alle registrierten OpenEPaperLink-Tags (WLAN-ePaper- * Displays) zyklisch mit aktuellen Daten aus ioBroker. Durch konfigurierbare * Zeitfenster und individuelle Update-Intervalle wird die Batterielaufzeit * maximal geschont. Neu hinzugekommen ist die Möglichkeit, einfache * „Signalbilder“ einzublenden: anhand eines Boolean-Datenpunktes wird * automatisch zwischen zwei Icon-Dateien (z. B. offen / geschlossen) * gewechselt. * Ziel war es, eine Lösung via JSON-Templates hinzubekommen, ohne den Umweg * über Screenshots mit Puppeteer. U.a. umgeht dies das Problem, das Puppeteer * immer nur eine Sache gleichzeitig tun kann, was zu Konflikten führen kann, * wenn man viele Tags aktualisieren muss. * * Ablauf im Überblick * ------------------- * 1. Konstanten am Kopf definieren Display-Größe und Access-Point. * 2. In `tagConfig` pro Tag eintragen: * – MAC-Adresse * – Anzeige-Titel * – Update-Intervall in Minuten * – ZeitFenster (z. B. „07:00-23:00“) * – body: Array aller anzuzeigenden Elemente * 3. `starteTagUpdates()` startet für jeden Tag ein eigenes Intervall * und führt sofort ein erstes Update aus. * 4. `aktualisiereTag()` prüft das ZeitFenster, baut das JSON zusammen * und sendet es per HTTP-POST an den Access-Point. * * Element-Typen * ------------- * • Text: { dp: 'pfad/zum/Datenpunkt', font: 'Nolo90', … } * • Fortschrittsbalken: { type: 'progressBar', dp: '…', fillColor: 2, … } * • Bild: { image: { filename: '/Bild.jpg', x: 10, y: 20 } } * • Signalbild: { type: 'signalImage', * dp: 'alias.0.Fenster_Kueche', * images: { true: '/offen.jpg', false: '/zu.jpg' }, * x, y, imageWidth, imageHeight, label, … } * * Voraussetzungen * --------------- * • ioBroker mit JavaScript-Adapter * • Node-Modul „luxon“ (nur für zuverlässige Sommer-/Winterzeit) * npm install luxon * • Bild- und Icon-Dateien liegen im Root des Access-Points als 8bit sRGB JPGs * rein schwarz, weiss und rot. * ############################################################################ */ const http = require('http'); const querystring = require('querystring'); const { DateTime } = require('luxon'); /* -------------------------------------------------------------------------- * Globale Konstanten * -------------------------------------------------------------------------- */ const AP_IP = '192.168.178.233'; // IP des OpenEPaperLink-AP const BILDSCHIRM_BREITE = 296; const BILDSCHIRM_HÖHE = 152; const TITEL_BALKEN_HÖHE = 20; const TITEL_SCHRIFT = 'calibrib16'; /* -------------------------------------------------------------------------- * Tag-Konfiguration * Jeder Eintrag definiert exakt ein ePaper-Display. * -------------------------------------------------------------------------- */ // Konfiguration der Tags mit MAC-Adresse, Titel, Update-Intervall, Zeitfenster und Inhalt const tagConfig = [ { mac: '0000AABBCCDDEEFF', // MAC-Adresse des Tags für DG Außentemperatur title: 'Aussentemperatur DG', // Titel, der auf dem Tag angezeigt wird updateIntervalMinutes: 30, // Update alle 30 Minuten ZeitFenster: '07:00-23:00', // Updates zwischen 07:00 und 23:00 (Europe/Berlin) body: [ // Temperatur mit großer Schrift, linksbündig { dp: 'alias.0.Aussentemperatur_kombiniert', font: 'Nolo90', color: 1, suffix: '°C', x: 10, y: 40, decimalPlaces: 1 }, // Regenmenge pro Tag, unten links { dp: 'alias.0.Regen_pro_Tag', font: 'bahnschrift30', color: 2, suffix: 'mm', x: 5, y: 127, decimalPlaces: 1 }, // Luftfeuchtigkeit, rechtsbündig unten { dp: 'alias.0.Luftfeuchtigkeit_Gartenhaus', font: 'bahnschrift30', color: 1, prefix: 'LF ', suffix: '%', x: 296, y: 115, align: 2, decimalPlaces: 0 } ] }, { mac: '0000AABBCCDDEEFF', // MAC-Adresse des Tags für Waage title: 'Waage', updateIntervalMinutes: 10, ZeitFenster: '06:30-12:00', body: [ // Körpergewicht mit großer Schrift, linksbündig { dp: 'alias.0.Körpergewicht', font: 'Nolo70', color: 1, suffix: ' kg', x: 10, y: 40, decimalPlaces: 2 }, // Widerstand, unten links { dp: 'alias.0.Körperwiderstand', font: 'bahnschrift30', color: 2, suffix: 'Ohm', x: 5, y: 127, decimalPlaces: 0 }, // BMI, rechtsbündig unten { dp: 'alias.0.BMI', font: 'bahnschrift30', color: 1, prefix: 'BMI: ', suffix: '', x: 296, y: 115, align: 2, decimalPlaces: 1 } ] }, { mac: '0000AABBCCDDEEFF', // MAC-Adresse des Tags für die Kalendervorschau title: 'Kalender', updateIntervalMinutes: 120, ZeitFenster: '07:00-23:00', body: [ // Kalendertext, oben links { dp: 'alias.0.Kalendervorschau', font: 'bahnschrift20', color: 1, x: 0, y: 25 } ] }, { mac: '0000AABBCCDDEEFF', // MAC-Adresse des Tags für Akku-Status title: 'Akku-Status', updateIntervalMinutes: 30, ZeitFenster: '07:00-23:00', body: [ // Akku-Prozentsatz, rechtsbündig { dp: 'alias.0.Akku_Ladezustand', font: 'Nolo90', color: 1, suffix: '%', x: BILDSCHIRM_BREITE, y: 40, align: 2, decimalPlaces: 0 }, // Fortschrittsbalken für Akku, unten { dp: 'alias.0.Akku_Ladezustand', type: 'progressBar', fillColor: 2, height: 32, y: BILDSCHIRM_HÖHE - 32 } ] }, { mac: '0000AABBCCDDEEFF', // MAC-Adresse des Tags für Stromwerte title: 'Stromwerte', updateIntervalMinutes: 60, ZeitFenster: '07:00-23:00', body: [ { image: { filename: '/Sonne.jpg', x: 10, y: 25 } }, // PV-Erzeugung, rechtsbündig { dp: 'sourceanalytix.0.alias__0__PV_Erzeugungszählerstand.currentYear.consumed.01_currentDay', font: 'bahnschrift30', color: 1, prefix: 'PV: ', suffix: ' kWh', x: BILDSCHIRM_BREITE, y: 28, align: 2, decimalPlaces: 1 }, // Netzbezug, rechtsbündig { dp: 'sourceanalytix.0.alias__0__Netzbezugszählerstand.currentYear.consumed.01_currentDay', font: 'bahnschrift30', color: 2, prefix: 'Bezug: ', suffix: ' kWh', x: BILDSCHIRM_BREITE, y: 58, align: 2, decimalPlaces: 1 }, // Netzeinspeisung, rechtsbündig { dp: 'sourceanalytix.0.alias__0__Netzeinspeisezählerstand.currentYear.delivered.01_currentDay', font: 'bahnschrift30', color: 1, prefix: 'Einsp.: ', suffix: ' kWh', x: BILDSCHIRM_BREITE, y: 88, align: 2, decimalPlaces: 1 }, // Verbrauch, rechtsbündig { dp: 'sourceanalytix.0.alias__0__Verbrauchszaehlerstand.currentYear.consumed.01_currentDay', font: 'bahnschrift30', color: 1, prefix: 'Verbr.: ', suffix: ' kWh', x: BILDSCHIRM_BREITE, y: 118, align: 2, decimalPlaces: 1 } ] }, { mac: '0000AABBCCDDEEFF', // MAC-Adresse des Tags für Wasserverbrauch title: 'Wasserverbrauch', updateIntervalMinutes: 45, ZeitFenster: '07:00-23:00', body: [ { image: { filename: '/Wasser.jpg', x: 10, y: 25, } }, { dp: 'alias.0.Tages-Wasserverbrauch', font: 'Nolo90', color: 1, prefix: '', suffix: ' l', x: BILDSCHIRM_BREITE, y: 50, align: 2, decimalPlaces: 0 }, ] }, { mac: '0000AABBCCDDEEFF', // MAC-Adresse des Tags für offene Fenster/Türen title: 'Fensterstatus', updateIntervalMinutes: 15, ZeitFenster: '07:00-23:00', body: [ // Reihe 1 von links nach rechts; die verwendeten Signalbilder sind 48x48 Pixel gross { type: 'signalImage', dp: 'alias.0.Fenster_Küche', images: { true: '/window_open48.jpg', false: '/window_closed48.jpg' }, x: 38, y: 22, imageWidth: 48, imageHeight: 48, label: 'Küche', labelColor: 1, labelSpacing: 2 }, { type: 'signalImage', dp: 'alias.0.Fenster_Wohnzimmer', images: { true: '/window_open48.jpg', false: '/window_closed48.jpg' }, x: 124, y: 22, imageWidth: 48, imageHeight: 48, label: 'Wohnzimmer', labelColor: 1, labelSpacing: 2 }, { type: 'signalImage', dp: 'alias.0.Fenster_Keller', images: { true: '/window_open48.jpg', false: '/window_closed48.jpg' }, x: 210, y: 22, imageWidth: 48, imageHeight: 48, label: 'Keller', labelColor: 1, labelSpacing: 2 }, // Reihe 2 von links nach rechts { type: 'signalImage', dp: 'alias.0.Fenster_Gäste-WC', images: { true: '/window_open48.jpg', false: '/window_closed48.jpg' }, x: 38, y: 82, imageWidth: 48, imageHeight: 48, label: 'Gäste-WC', labelColor: 1, labelSpacing: 2 }, { type: 'signalImage', dp: 'alias.0.Tür_Terrasse', images: { true: '/door-open48.jpg', false: '/door-closed48.jpg' }, x: 124, y: 82, imageWidth: 48, imageHeight: 48, label: 'Terrassentür', labelColor: 1, labelSpacing: 2 }, { type: 'signalImage', dp: 'alias.0.Fenster_Garage', images: { true: '/window_open48.jpg', false: '/window_closed48.jpg' }, x: 210, y: 82, imageWidth: 48, imageHeight: 48, label: 'Garage', labelColor: 1, labelSpacing: 2 } ] } ]; /* ############################################################################ * Hilfsfunktionen * ############################################################################ */ /** * Prüft, ob die aktuelle Zeit im konfigurierten Zeitfenster liegt. * @param {string} ZeitFenster - Zeitfenster im Format "HH:mm-HH:mm" (z. B. "07:00-23:00"). * @returns {boolean} - True, wenn die aktuelle Zeit im Zeitfenster liegt, sonst false. */ function istImZeitFenster(ZeitFenster) { const jetzt = DateTime.now().setZone('Europe/Berlin'); const [von, bis] = ZeitFenster.split('-').map(t => { const [h, m] = t.split(':').map(Number); return jetzt.set({ hour: h, minute: m, second: 0, millisecond: 0 }); }); return bis < von ? (jetzt >= von || jetzt <= bis) : (jetzt >= von && jetzt <= bis); } /** * Konvertiert eine Zeitangabe (HH:mm) in Minuten seit Mitternacht für Vergleiche. * @param {string} ZeitStr - Zeit im Format "HH:mm". * @returns {number} - Minuten seit Mitternacht. */ function zeitZuMinuten(ZeitStr) { const [h, m] = ZeitStr.split(':').map(Number); return h * 60 + m; } /** * Erstellt die JSON-Elemente für die Titelleiste (Hintergrund + Text). * @param {string} titelText - Text, der in der Titelleiste erscheinen soll. * @returns {Array} - Array mit JSON-Elementen (box + text). */ function titelElementeErzeugen(titelText) { const mitteX = Math.floor(BILDSCHIRM_BREITE / 2); return [ { box: [0, 0, BILDSCHIRM_BREITE, TITEL_BALKEN_HÖHE, 1, 1, 1] }, { text: [mitteX, 3, titelText, `fonts/${TITEL_SCHRIFT}`, 0, 1] } ]; } /** * Verarbeitet ein einzelnes Anzeige-Element und liefert das dafür nötige JSON. * Erkannt werden: * – Signalbilder (signalImage) * – normale Bilder (image) * – Texte (dp oder fester text) * – Fortschrittsbalken (progressBar) * * @param {Object} item - Konfiguration des Elements * @returns {Object|Array} - JSON-Objekt oder Array von Objekten für die Darstellung */ async function elementAuflösen(item) { /* ---------- Signalbild mit optionalem Label ---------- */ if (item.type === 'signalImage') { const status = await getStateAsync(item.dp); const bild = (status && status.val) ? item.images.true : item.images.false; const result = [{ image: [bild, item.x || 0, item.y || 0] }]; if (item.label) { const labelX = (item.x || 0) + Math.floor((item.imageWidth || 48) / 2); const labelY = (item.y || 0) + (item.imageHeight || 48) + (item.labelSpacing || 2); result.push({ text: [labelX, labelY, item.label, 'tahoma11', item.labelColor || 1, 1] }); } return result; } /* ---------- Normales Bild ---------- */ if (item.image) { const { filename, x = 0, y = 0 } = item.image; return { image: [filename, x, y] }; } /* ---------- Text & Balken ---------- */ let wert = ''; if (item.dp) { const state = await getStateAsync(item.dp); wert = (state && state.val != null) ? state.val : ''; if (typeof wert === 'number' || !isNaN(parseFloat(wert))) { const stellen = typeof item.decimalPlaces === 'number' ? item.decimalPlaces : 2; wert = parseFloat(wert).toLocaleString('de-DE', { minimumFractionDigits: stellen, maximumFractionDigits: stellen }); } else { wert = String(wert); } } else if (typeof item.text === 'string') { wert = item.text; } if (item.type === 'progressBar') { const prozent = parseFloat(wert) || 0; const breite = Math.round(Math.max(0, Math.min(100, prozent)) / 100 * BILDSCHIRM_BREITE); const yPos = typeof item.y === 'number' ? item.y : BILDSCHIRM_HÖHE - (item.height || 32); return { box: [0, yPos, breite, item.height || 32, item.fillColor || 2, item.fillColor || 2, item.fillColor || 2] }; } if (item.prefix) wert = item.prefix + wert; if (item.suffix) wert = wert + item.suffix; const x = typeof item.x === 'number' ? item.x : 0; const y = typeof item.y === 'number' ? item.y : 0; const schrift = item.font || 'bahnschrift20'; const farbe = typeof item.color === 'number' ? item.color : 1; const textArr = [x, y, wert, schrift, farbe]; if (typeof item.align === 'number') textArr.push(item.align); return { text: textArr }; } /* ############################################################################ * HTTP-Sender * ############################################################################ */ /** * Sendet das fertige JSON-Array an den OpenEPaperLink-Access-Point. * @param {string} mac - MAC-Adresse des Tags * @param {Array} jsonArray - Vollständiges JSON-Array für das Display * @param {string} titel - Nur für Logging/Console-Ausgabe * @returns {Promise} - Resolves bei Erfolg, rejects bei Netzwerkfehler */ function sendeAnTag(mac, jsonArray, titel) { const postData = querystring.stringify({ mac, json: JSON.stringify(jsonArray) }); const options = { hostname: AP_IP, port: 80, path: '/jsonupload', method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(postData) } }; return new Promise((resolve, reject) => { const req = http.request(options, res => { let data = ''; res.on('data', c => data += c); res.on('end', () => { console.log(`✅ "${titel}" (${mac}) → AP-Antwort: "${data.trim() || 'leer'}"`); resolve(); }); }); req.on('error', e => { console.error(`❌ "${titel}" (${mac}) → HTTP-Fehler: ${e.message}`); reject(e); }); req.write(postData); req.end(); }); } /* ############################################################################ * Update-Logik * ############################################################################ */ /** * Aktualisiert ein einzelnes Tag, sofern die aktuelle Zeit im konfigurierten * Zeitfenster liegt. Bei Erfolg wird das fertige JSON an den AP gesendet. * @param {Object} tag - Ein Eintrag aus tagConfig * @returns {Promise} - Resolves bei Erfolg oder wenn außerhalb des Zeitfensters */ async function aktualisiereTag(tag) { if (!istImZeitFenster(tag.ZeitFenster)) { console.log(`⏸️ "${tag.title}" (${tag.mac}) außerhalb des Zeitfensters ${tag.ZeitFenster}`); return; } const json = [{ clear: [1] }, ...titelElementeErzeugen(tag.title)]; for (const item of tag.body) { const ergebnis = await elementAuflösen(item); Array.isArray(ergebnis) ? json.push(...ergebnis) : json.push(ergebnis); } await sendeAnTag(tag.mac, json, tag.title); } /** * Startet für jeden Tag ein eigenes setInterval und führt sofort ein erstes * Update aus. So kann sich jeder Tag unabhängig aktualisieren. */ function starteTagUpdates() { for (const tag of tagConfig) { const intervallMs = tag.updateIntervalMinutes * 60 * 1000; setInterval(() => aktualisiereTag(tag).catch(e => console.error(`Fehler bei ${tag.title}: ${e.message}`)), intervallMs); aktualisiereTag(tag).catch(e => console.error(`Erstes Update fehlgeschlagen bei ${tag.title}: ${e.message}`)); } } /* ############################################################################ * Start * ############################################################################ */ starteTagUpdates();
-
P.S.: Ich habe öfter eine Schriftart "Nolo", wie "Nolo90" verwendet in dem Beispielskript. Das ist einfach eine gut lesbare Schriftart, die ich selbst reduziert auf die nötigen Zeichen und dann in das VLW Format konvertiert habe; und anschliessend natürlich auf den AP ins "fonts" Verzeichnis hochgeladen.
Bei sehr großen Schriften wie "90" ist es wichtig, nicht zu viele Glyphen zu haben, da sonst im Pixelformat die VLW-Datei riesig wird.
Geht aber alles in 5 min umzusetzen, da die Software dafür ja simpel zu bedienen ist.
Ich habe mir einfach Nolo20, ... , Nolo80, Nolo90 gebaut und hochgeladen und gut ist. Zusammen knapp 1.050 kB. Im Filesystem sind dann immer noch 4MB frei. -
@bertderkleine Danke für den guten Ansatz mit dem Script. Beschreibst du auch noch die Vorgehensweise mit den Schriften?
-
@rene55 sagte in E-Ink OpenEpaperLink - JSON-Steuerung per Javascript:
@bertderkleine Danke für den guten Ansatz mit dem Script. Beschreibst du auch noch die Vorgehensweise mit den Schriften?
Gerne.
Zuerst lade und installiere zwei kostenlose Programme:
FontForge: https://fontforge.org/en-US/downloads/
und
Processing: https://processing.org/downloadSchritt 1: Entscheide Dich für irgendeine .ttf Schriftartendatei auf Deinem Rechner, die Dir gefällt für die Ausgabe auf dem Display.
Schritt 2: Lösche die überflüssigen Glyphen mit Fontforge.
- Starte FontForge und öffne die TTF-Datei.
- Wähle im Menü "Codierung / Compact", um leere Plätze auszublenden.
- Markiere mit Maus Anklicken oder Maus drüberziehen überflüssige Glyphen. Mit "Shift" kann man getrennte Mehrfachauswahlen machen, was sonst unter Windows eher bei "Strg" geht.
- Wähle Rechtsklick / "Löschen".
- Wähle im Menü "Element / Schrift - Informationen", um die Schrift mit einem neuen eindeutigen Namen zu versehen, der sie von der Quelle unterscheidet.
- Wähle im Menü "Datei / Schriften erstellen", um die reduzierte Datei als TrueType zu speichern.
Schritt 3: Installiere die neue Schriftart zumindest temporär auf Deinem System für den Schritt 4. Danach kannst Du sie wieder löschen. Unter Windows geht das mit Rechtsklick / Installieren über der TTF-Datei.
Schritt 4: Wandle die TTF in VLW um mit Processing
- Starte Processing.
- Wähle im Menü "Tools / Schrift erstellen"
- Wähle die eben erstellte und installierte Schriftart, die Zielgröße und den Zieldateinamen.
Drücke "OK" - Optional: Diesen Export mit verschiedenen Zielgrößen wiederholen.
Fertig!
Nun nur noch ins "fonts" Verzeichnis des AP mit dem Filebrowser des WebUI des AP hochladen.
-
Ich teile hier noch mal eine mögliche Ergänzung, die ich bei mir selbst verwende.
Meist gibt man ja eher Zahlen aus, das ist fein soweit. Wenn man aber längere Textfelder ausgeben will, gibt es das Problem, das der Text zu lang ist und eigentlich umgebrochen werden muss.
Beispiel: Texte aus dem Kalender.Mein erster Lösungsansatz sieht im Code so aus:
Neu hinzugekommen sind für Textfelder drei Parameter:
- maxZeilenbreite: Zeichen, die maximal pro Zeile hinpassen auf das Display
- maxZeilen: maximal auszugebende Zeilen auf dem Display für dieses Textfeld
- zeilenAbstand: Pixelhöhe pro Zeile inkl. optischem Abstand
Ersetze im Text-Block (ganz unten in "elementAuflösen" ) die Zeilen
const textArr = [x, y, wert, schrift, farbe]; if (typeof item.align === 'number') textArr.push(item.align); return { text: textArr };
durch die gepimpte Version:
// --- automatischer Zeilenumbruch --- if (item.maxZeilenbreite && item.maxZeilen) { const breite = item.maxZeilenbreite; const zeilen = item.maxZeilen; const abstand = item.zeilenAbstand || 20; const zeilenArr = wert.split('\n'); // erlaubt manuelle \n const result = []; let posY = y; let zähler = 0; for (const rawLine of zeilenArr) { const words = rawLine.split(' '); let line = ''; for (const w of words) { const probe = line + (line ? ' ' : '') + w; if (probe.length > breite && line.length) { // neue Zeile result.push({ text: [x, posY, line.trim(), schrift, farbe] }); posY += abstand; zähler++; if (zähler >= zeilen) return result; line = w; } else { line = probe; } } if (line) { result.push({ text: [x, posY, line.trim(), schrift, farbe] }); posY += abstand; zähler++; if (zähler >= zeilen) return result; } } return result; } // --- klassischer Einzeiler (wie bisher) --- const textArr = [x, y, wert, schrift, farbe]; if (typeof item.align === 'number') textArr.push(item.align); return { text: textArr };
Die relevante Konfigurationszeile in der Konfig sieht dann z.B. so aus:
{ dp: 'alias.0.Kalendervorschau', font: 'calibrib16', color: 1, x: 0, y: 25, maxZeilenbreite: 38, maxZeilen: 7, zeilenAbstand: 17 }
Das setzt zwar etwas Fingerspitzengefühl beim Benutzer voraus, aber gegen eine Vollautomatisierung spricht die Herausforderung, dass jede Schriftart in Pixeln unterschiedlich breit und hoch ist. Dazu mache ich lieber mit drei Parametern per Hand Anpassungen, als zu versuchen, das mit Formlen in den Griff zu bekommen.
-
Könntest du die kleinen Grafiken hier anzeigen?
Also window_open48.jpg usw.
Grüße