Files
rpi-ubahn/ubahn_anzeige.js
2025-11-10 21:37:00 +01:00

325 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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();
}