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();