#!/usr/bin/env node // -*- coding: utf-8 -*- // U-Bahn Anzeige – angepasst für 480x360 (BGRA8888) const axios = require("axios"); const fs = require("fs"); const { createCanvas, registerFont } = require("canvas"); // ---- Konfiguration ---- let config = { device: "/dev/fb0", format: "BGRA8888", width: 480, height: 360 }; try { if (fs.existsSync("display_config.json")) { const savedConfig = JSON.parse(fs.readFileSync("display_config.json", "utf8")); config = { ...config, ...savedConfig }; console.log("✓ Display-Konfiguration geladen"); } else { console.log("! Keine display_config.json gefunden, nutze Defaults"); } } catch (e) { console.log("! Fehler beim Laden der Config:", e.message); } // Die Stations ID findest du unter // "https://v6.vbb.transport.rest/locations?query=U_amrummer_stra%C3%9Fe&results=1" // Das ist für die Amrumer Straße. Siehe hier: ^^^^^^^^^^^^^ const STATION_ID = "900009101"; const API_URL = `https://v6.vbb.transport.rest/stops/${STATION_ID}/departures`; const FB_DEVICE = config.device; const WIDTH = config.width; const HEIGHT = config.height; const FORMAT = config.format; // Farben const BG = { r: 45, g: 55, b: 70 }; const YELLOW = { r: 255, g: 220, b: 0 }; const U9_ORANGE = { r: 255, g: 110, b: 0 }; const WHITE = { r: 255, g: 255, b: 255 }; const GRAY = { r: 130, g: 130, b: 130 }; console.log("\n╔═══════════════════════════════════════════╗"); console.log("║ U-BAHN ANZEIGE AMRUMER STR. ║"); console.log("╚═══════════════════════════════════════════╝\n"); console.log(`Device: ${FB_DEVICE}`); console.log(`Format: ${FORMAT}`); console.log(`Auflösung: ${WIDTH}x${HEIGHT}\n`); function log(level, message) { const timestamp = new Date().toISOString(); console.log(`${timestamp} ${level}: ${message}`); } class Display { constructor() { log("INFO", "Display init gestartet"); this.departures_cache = []; this.running = true; this.fb = null; this.draw_count = 0; process.stdout.write("\x1B[?25l"); // Cursor ausblenden try { registerFont("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", { family: "DejaVuSans" }); log("INFO", "Font geladen"); } catch (e) { log("WARNING", `Font-Fehler: ${e.message}`); } this.openFramebuffer(); process.on("SIGTERM", () => this.onSignal("SIGTERM")); process.on("SIGINT", () => this.onSignal("SIGINT")); this.startApiLoop(); } openFramebuffer() { try { if (!fs.existsSync(FB_DEVICE)) { log("ERROR", `${FB_DEVICE} existiert nicht!`); return; } this.fb = fs.openSync(FB_DEVICE, "r+"); log("INFO", `Framebuffer geöffnet: ${FB_DEVICE}`); const clearBuf = Buffer.alloc(WIDTH * HEIGHT * 4); for (let i = 0; i < WIDTH * HEIGHT; i++) { const offset = i * 4; clearBuf[offset] = BG.b; clearBuf[offset + 1] = BG.g; clearBuf[offset + 2] = BG.r; clearBuf[offset + 3] = 255; } fs.writeSync(this.fb, clearBuf, 0, clearBuf.length, 0); log("INFO", "Display bereit"); } catch (e) { log("ERROR", `Framebuffer-Fehler: ${e.message}`); if (e.code === "EACCES") log("ERROR", `Lösung: sudo chmod 666 ${FB_DEVICE}`); } } onSignal(signum) { log("INFO", `Signal ${signum} empfangen, beende...`); this.running = false; process.stdout.write("\x1B[?25h"); if (this.fb !== null) fs.closeSync(this.fb); process.exit(0); } startApiLoop() { const fetchData = async () => { if (!this.running) return; try { const response = await axios.get(`${API_URL}?duration=60&results=50`, { timeout: 10000 }); const data = response.data; let u9 = []; if (data?.departures) u9 = data.departures.filter(d => d.line?.name === "U9"); else if (Array.isArray(data)) u9 = data.filter(d => d.line?.name === "U9"); this.departures_cache = u9; log("INFO", `API-Update: ${u9.length} U9-Bahnen`); } catch (e) { log("WARNING", `API-Fehler: ${e.message}`); } }; fetchData(); setInterval(fetchData, 20000); } mins(when) { try { let whenStr = when; if (whenStr.endsWith("Z")) whenStr = whenStr.slice(0, -1) + "+00:00"; const dep = new Date(whenStr); const diff = (dep - new Date()) / 60000; return diff >= 0.5 ? String(Math.floor(diff)) : "0"; } catch { return "--"; } } roundRect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r); ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r); ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y); ctx.closePath(); ctx.fill(); } draw() { if (!this.fb) return; const canvas = createCanvas(WIDTH, HEIGHT); const ctx = canvas.getContext("2d"); // Hintergrund ctx.fillStyle = `rgb(${BG.r},${BG.g},${BG.b})`; ctx.fillRect(0, 0, WIDTH, HEIGHT); // Header ctx.fillStyle = `rgb(${YELLOW.r},${YELLOW.g},${YELLOW.b})`; ctx.fillRect(0, 0, WIDTH, 70); // --- U-Bahn Symbol (blau mit weißem U) --- const uBoxX = 15; const uBoxY = 10; const uBoxSize = 50; // Hintergrund des Symbols (blau) ctx.fillStyle = "rgb(0, 80, 160)"; this.roundRect(ctx, uBoxX, uBoxY, uBoxSize, uBoxSize, 6); // Buchstabe „U“ mittig & weiß ctx.fillStyle = "white"; ctx.font = "bold 38px DejaVuSans"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("U", uBoxX + uBoxSize / 2, uBoxY + uBoxSize / 2 + 2); // Stationsname rechts neben dem Symbol ctx.textAlign = "left"; ctx.textBaseline = "alphabetic"; ctx.fillStyle = `rgb(${BG.r},${BG.g},${BG.b})`; ctx.font = "bold 28px DejaVuSans"; ctx.fillText("Amrumer Straße", uBoxX + uBoxSize + 25, 50); // Uhrzeit const now = new Date().toTimeString().slice(0, 5); ctx.font = "bold 26px DejaVuSans"; ctx.fillText(now, WIDTH - 90, 50); // --- Abfahrten --- const deps = [...this.departures_cache]; const osloer = deps.filter(x => x.direction?.includes("Osloer")).slice(0, 2); const steglitz = deps.filter(x => x.direction?.includes("Steglitz") || x.direction?.includes("Rathaus")).slice(0, 2); // Verschiebung nach unten const offsetY = 120; // Abschnitt Osloer ctx.fillStyle = `rgb(${YELLOW.r},${YELLOW.g},${YELLOW.b})`; ctx.font = "bold 26px DejaVuSans"; ctx.fillText("→ Osloer Straße", 15, offsetY); if (osloer.length) { const mins = this.mins(osloer[0].when); ctx.fillStyle = `rgb(${WHITE.r},${WHITE.g},${WHITE.b})`; this.roundRect(ctx, 15, offsetY + 10, 75, 45, 6); ctx.fillStyle = `rgb(${U9_ORANGE.r},${U9_ORANGE.g},${U9_ORANGE.b})`; this.roundRect(ctx, 18, offsetY + 13, 69, 39, 5); ctx.fillStyle = `rgb(${WHITE.r},${WHITE.g},${WHITE.b})`; ctx.font = "bold 32px DejaVuSans"; ctx.fillText("U9", 25, offsetY + 45); ctx.fillStyle = `rgb(${YELLOW.r},${YELLOW.g},${YELLOW.b})`; ctx.font = "bold 52px DejaVuSans"; ctx.fillText(mins, WIDTH - 145, offsetY + 45); ctx.fillStyle = `rgb(${WHITE.r},${WHITE.g},${WHITE.b})`; ctx.font = "bold 36px DejaVuSans"; ctx.fillText("min", WIDTH - 85, offsetY + 45); } if (osloer.length >= 2) { const mins2 = this.mins(osloer[1].when); ctx.fillStyle = `rgb(${YELLOW.r},${YELLOW.g},${YELLOW.b})`; ctx.font = "bold 36px DejaVuSans"; ctx.fillText(mins2, WIDTH - 125, offsetY + 80); ctx.fillStyle = `rgb(${GRAY.r},${GRAY.g},${GRAY.b})`; ctx.font = "bold 24px DejaVuSans"; ctx.fillText("min", WIDTH - 85, offsetY + 80); } // Trennlinie ctx.strokeStyle = `rgb(${YELLOW.r},${YELLOW.g},${YELLOW.b})`; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(10, offsetY + 100); ctx.lineTo(WIDTH - 10, offsetY + 100); ctx.stroke(); // Abschnitt Steglitz ctx.fillStyle = `rgb(${YELLOW.r},${YELLOW.g},${YELLOW.b})`; ctx.font = "bold 26px DejaVuSans"; ctx.fillText("→ Rathaus Steglitz", 15, offsetY + 130); if (steglitz.length) { const mins = this.mins(steglitz[0].when); ctx.fillStyle = `rgb(${WHITE.r},${WHITE.g},${WHITE.b})`; this.roundRect(ctx, 15, offsetY + 140, 75, 45, 6); ctx.fillStyle = `rgb(${U9_ORANGE.r},${U9_ORANGE.g},${U9_ORANGE.b})`; this.roundRect(ctx, 18, offsetY + 143, 69, 39, 5); ctx.fillStyle = `rgb(${WHITE.r},${WHITE.g},${WHITE.b})`; ctx.font = "bold 32px DejaVuSans"; ctx.fillText("U9", 25, offsetY + 175); ctx.fillStyle = `rgb(${YELLOW.r},${YELLOW.g},${YELLOW.b})`; ctx.font = "bold 52px DejaVuSans"; ctx.fillText(mins, WIDTH - 145, offsetY + 175); ctx.fillStyle = `rgb(${WHITE.r},${WHITE.g},${WHITE.b})`; ctx.font = "bold 36px DejaVuSans"; ctx.fillText("min", WIDTH - 85, offsetY + 175); } if (steglitz.length >= 2) { const mins2 = this.mins(steglitz[1].when); ctx.fillStyle = `rgb(${YELLOW.r},${YELLOW.g},${YELLOW.b})`; ctx.font = "bold 36px DejaVuSans"; ctx.fillText(mins2, WIDTH - 125, offsetY + 210); ctx.fillStyle = `rgb(${GRAY.r},${GRAY.g},${GRAY.b})`; ctx.font = "bold 24px DejaVuSans"; ctx.fillText("min", WIDTH - 85, offsetY + 210); } this.write(canvas); } write(canvas) { if (!this.fb) return; const ctx = canvas.getContext("2d"); const img = ctx.getImageData(0, 0, WIDTH, HEIGHT); const buffer = Buffer.alloc(WIDTH * HEIGHT * 4); for (let i = 0; i < WIDTH * HEIGHT; i++) { const s = i * 4; buffer[s] = img.data[s + 2]; // BGRA buffer[s + 1] = img.data[s + 1]; buffer[s + 2] = img.data[s]; buffer[s + 3] = img.data[s + 3]; } fs.writeSync(this.fb, buffer, 0, buffer.length, 0); } run() { const loop = () => { if (!this.running) return; this.draw(); setTimeout(loop, 1000); }; loop(); } } // Main if (require.main === module) { const disp = new Display(); disp.run(); }