Für alle, die selbst etwas bauen
Baue deine eigene Anzeige.
Worum es geht
Alles, was der Live-Stand anzeigt, ist über offene HTTP-Routen abrufbar – ohne API-Key, ohne Anmeldung, ohne Registrierung. Die Bühnenseite läuft im Browser: Was sie lesen kann, kann jede lesen. Deshalb steht hier, worauf du dich verlassen darfst.
Gedacht ist das für gebaute Anzeigen: ein ESP32 mit LED-Matrix im Repair Café, ein Raspberry Pi am Fernseher im Foyer, ein Zähler auf einer eigenen Website. Wenn du etwas damit gebaut hast, freuen wir uns über eine Nachricht an mail@gut-einern.org.
Die vollständigen Feldtabellen stehen im Repository: Übersicht, /api/stats für Displays und /api/dashboard für eigene Visualisierungen.
Welche Route wofür
| Route | Inhalt | Takt | Grenze je IP |
|---|---|---|---|
GET /api/stats | Alle Zahlen der Aktion: Stand, Ziel, Tageswerte, Kategorien, Kreise, Zeitachse des ganzen Zeitraums. | 5 Minuten | 120/min |
GET /api/dashboard | Zahlen und die jüngsten 24 Einzeleinträge samt anonymisierter Herkunft für eine Karte. | 20 Sekunden | 240/min |
GET /api/dashboard?since=… | Nur die seit dem genannten Zeitstempel freigegebenen Einträge. | 5 Sekunden | 240/min |
GET /api/campaign | Zeitraum und Zielzahl – die einzige Route, die auch vor dem Start antwortet. | ohne Cache | 240/min |
GET /api/partners | Logos und Links der unterstützenden Organisationen. | 5 Minuten | 120/min |
GET /api/gallery | Die sechs jüngsten freigegebenen Reparaturen mit Bild-URL. | 1 Minute | 120/min |
GET /api/mosaic | Die Bilderwand der Startseite: die 40 jüngsten freigegebenen Fotos samt Gesamtzahl. | 10 Minuten | 120/min |
Faustregel: Soll eine Zahl auf ein Display, nimm /api/stats. Brauchst du wirklich die einzelnen Einträge – für ein Laufband, eine Karte, eine eigene Visualisierung –, nimm /api/dashboard. Die Antwort ist dort ein Vielfaches größer.
Alle Routen antworten mit application/json und ausschließlich auf GET. Neue Felder können jederzeit dazukommen: unbekannte Felder ignorieren, nicht als Fehler behandeln.
Was zählt, und was nicht
Jede Reparatur durchläuft eine Moderation. Nur freigegebene Einreichungen erscheinen überhaupt in den Antworten – eine gerade eingereichte Reparatur taucht also nicht sofort auf. Wie viele gerade warten, sagt pending in /api/stats.
Freigegeben heißt aber nicht automatisch „zählt für den Rekord“. Eine zweite Angabe entscheidet darüber, ob die Reparatur gelungen ist:
total– der Rekordstand: freigegeben und gelungen. Ein Versuch, der nicht geklappt hat, hat keinen Gegenstand im Alltag gehalten und zählt nicht mit.attempted– alle freigegebenen Einreichungen, gescheiterte Versuche eingeschlossen.succeeded– die gelungenen, also gleichtotal.
Die Erfolgsquote ist deshalb succeeded / attempted und nie succeeded / total – das wäre immer 100 Prozent. Alle abgeleiteten Größen (today, bestDay, timeline, categories, kreise, minutesSaved, valueSavedEuros) folgen der Auswahl von total.
Das aktuelle Ziel liegt bei 4.000 Reparaturen. Es ist im Backend änderbar – lies es aus goal, statt es in dein Gerät zu schreiben.
Phase der Aktion
/api/campaign liefert sie als status. Sie ergibt sich aus startAt und endAt; ein Gerät, das lange läuft, sollte sie selbst aus den beiden Zeitpunkten ausrechnen – sonst behauptet es nach dem Ende weiter, die Aktion sei offen.
| status | Bedeutung | Was die anderen Routen tun |
|---|---|---|
before | Der Zeitraum hat noch nicht begonnen. | /api/stats und /api/dashboard antworten 403. |
open | Einreichungen sind offen, es wird gezählt. | Alle Routen antworten. |
after | Der Zeitraum ist beendet. | /api/stats antwortet weiter – der Endstand bleibt stehen. /api/dashboard antwortet 403. |
invalid | Es ist kein gültiger Zeitraum hinterlegt. | Wie before. |
Tagesrekord: immer je Ort
Die Marke, an der sich die Aktion misst, lautet „268 Reparaturen an einem Tag und Ort“ (Exeter 2019). Landesweit gezählt fällt sie an jedem gut besuchten Samstag, ohne dass irgendwo etwas Vergleichbares passiert wäre. Verglichen wird deshalb der Kreis bzw. die kreisfreie Stadt mit dem höchsten Tagesstand:
todayKreise– heutiger Stand je Ort. Der größte Wert darin ist der Ort, der heute vorn liegt.bestKreisDay– bester Tag eines einzelnen Ortes vor heute, mitdate,kreisundtotal.dayRecord– die hinterlegte Marke aus früheren Aktionen, ebenfalls „an einem Tag und Ort“.null, wenn keine hinterlegt ist.
Daneben stehen weiter today und bestDay – dieselben Größen landesweit. Sie sind eine eigene, richtige Aussage („wie viel kam heute in NRW zusammen“) und die Grundlage der Zeitachse, aber nicht der Vergleich mit der Marke.
Gezählt wird immer der Einreichungstag, nicht der Tag der Freigabe: Ein Tag ist der Tag, an dem geschraubt wurde, sonst hinge der Rekord daran, wann die Moderation Zeit hatte. Alle Tagesgrenzen liegen in der Zeitzone Europa/Berlin.
Fehler und Zustände
| Code | Bedeutung | Was ein Gerät tun sollte |
|---|---|---|
200 | Antwort wie dokumentiert. | Anzeigen. |
403 | Außerhalb des Zeitraums, mit code: "outside-campaign-window". | „Zählung startet bald“ anzeigen – nicht eine Null. |
429 | Grenze je IP-Adresse erreicht. Der Header Retry-After nennt die Wartezeit in Sekunden. | So lange warten, dann erneut. Letzten Stand stehen lassen. |
502 | Die Datenbank antwortet nicht. | Letzten Stand stehen lassen, später erneut versuchen. |
503 | Der Dienst ist nicht konfiguriert. | Wie 502. |
Behandle jeden dieser Fälle und lass bei allem außer 200 den zuletzt bekannten Stand stehen. Eine Anzeige, die bei einer Störung auf 0 fällt, sieht auf einer Bühne schlimmer aus als eine, die eine Minute alt ist.
Grenzen je IP-Adresse
Zwei Dinge begrenzen die Abfragen, unabhängig voneinander. Der Cache: Jede Route trägt einen Cache-Control-Header (Spalte „Takt“ oben). Häufiger abzufragen liefert dieselbe Antwort – es kostet nur Strom. Die Grenze je IP-Adresse: Sie zählt Anfragen pro Minute und Absender und ist absichtlich großzügig, weil bei einer Veranstaltung alle Geräte hinter derselben Adresse stecken.
Vercel und Supabase rechnen im kostenlosen Tarif nach Aufrufen, Rechenzeit und ausgeliefertem Datenvolumen. Wird ein Kontingent knapp, lässt sich im Backend ein Schonmoduseinschalten: Dann gilt für alle oben genannten Routen dieselbe, engere Grenze – sofort und ohne Deployment. Aktuell ist er ausgeschaltet, es gelten die Werte in der Tabelle oben.
Für dein Gerät heißt das: auf 429 vorbereitet sein, auch wenn es monatelang nicht vorkommt. Wer im Fünf-Minuten-Takt fragt, merkt vom Schonmodus ohnehin nichts.
Feste Anzeigen können freigegeben werden. Ein Rechner am Beamer oder ein Display im Foyer, das dauerhaft läuft, soll nie anschlagen – solche Adressen lassen sich im Backend von jeder Grenze ausnehmen, einzeln (203.0.113.4) oder als Präfix des Anschlusses (203.0.113.0/24, 2001:db8::/32). Wenn du so etwas aufbaust, schreib uns die Adresse an mail@gut-einern.org. Die Freigabe gilt nur fürs Lesen; die Einreichung bleibt für alle gleich gedrosselt.
| Anzeige | Empfohlener Takt |
|---|---|
| Zahl auf einem Display | /api/stats alle 5 Minuten – der Cache erneuert sich nicht schneller. |
| Laufband oder Karte | einmal /api/dashboard, dann ?since=<cursor> alle 15 Sekunden, und alle 5 Minuten wieder einen vollen Snapshot. |
| Countdown, Zielzahl | /api/campaign einmal beim Start; Restzeit und Phase rechnet das Gerät selbst aus. |
Bilder nur mit images=1 anfordern und nur, wenn sie auch gezeigt werden: Die signierten URLs machen den größten Teil der Antwort aus und sind 15 Minuten gültig.
ESP32 oder Arduino mit WLAN
Benötigt werden WiFi.h, HTTPClient.h und ArduinoJson – letzteres über den Library Manager. Nach der WLAN-Verbindung refreshRepairCount() im setup() aufrufen und über millis() nur im Fünf-Minuten-Takt wiederholen.
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
const char* wifiSsid = "WLAN-NAME";
const char* wifiPassword = "WLAN-PASSWORT";
const char* statsUrl = "http://localhost:3000/api/stats";
void refreshRepairCount() {
HTTPClient http;
http.begin(statsUrl);
const int status = http.GET();
if (status == 200) {
// Nur die Felder lesen, die das Display braucht: Kreisliste und Zeitachse
// zusammen sind fuer den Arbeitsspeicher eines ESP32 viel.
JsonDocument filter;
filter["total"] = true;
filter["goal"] = true;
filter["today"] = true;
filter["dayRecord"] = true;
filter["todayKreise"] = true;
filter["bestKreisDay"]["total"] = true;
JsonDocument document;
deserializeJson(document, http.getStream(), DeserializationOption::Filter(filter));
const long total = document["total"] | 0;
const long goal = document["goal"] | 0;
// Tagesrekord je Ort: der stärkste Kreis von heute gegen die Marke
// "an einem Tag und Ort" (Issue #75).
long bestToday = 0;
const char* bestKreis = "-";
for (JsonPair entry : document["todayKreise"].as<JsonObject>()) {
const long amount = entry.value().as<long>();
if (amount > bestToday) { bestToday = amount; bestKreis = entry.key().c_str(); }
}
const long mark = max(document["dayRecord"] | 0L, document["bestKreisDay"]["total"] | 0L);
// Hier die Werte auf OLED, LCD oder LED-Matrix ausgeben.
Serial.printf("Reparaturen: %ld von %ld\n", total, goal);
Serial.printf("Heute vorn: %s mit %ld (Marke %ld)%s\n",
bestKreis, bestToday, mark,
(mark > 0 && bestToday > mark) ? " - REKORD!" : "");
} else if (status == 403) {
Serial.println("Kampagne ist gerade nicht aktiv.");
} else if (status == 429) {
// Grenze erreicht. Retry-After nennt die Wartezeit in Sekunden.
Serial.printf("Gedrosselt, warte %s s\n", http.header("Retry-After").c_str());
} else {
Serial.printf("Statistik nicht verfuegbar: HTTP %d\n", status);
}
http.end();
}Raspberry Pi mit Python
Installieren mit python -m pip install requests. Für ein dauerhaftes Infodisplay empfiehlt sich ein systemd-Service mit einem eigenen, eingeschränkten Benutzer. Das Gerät darf ausschließlich die öffentlichen Routen lesen.
import time
import requests
STATS_URL = "http://localhost:3000/api/stats"
INTERVAL = 300 # Fuenf Minuten - schneller erneuert sich der Cache nicht.
while True:
try:
response = requests.get(STATS_URL, timeout=10)
except requests.RequestException:
# Netz weg: letzten Stand stehen lassen und spaeter erneut versuchen.
time.sleep(INTERVAL)
continue
if response.status_code == 200:
stats = response.json()
# Tagesrekord je Ort: staerkster Kreis von heute gegen die Marke.
today_kreise = stats.get("todayKreise") or {}
best_kreis, best_today = max(today_kreise.items(), key=lambda item: item[1], default=("-", 0))
best_own = (stats.get("bestKreisDay") or {}).get("total", 0)
mark = max(stats.get("dayRecord") or 0, best_own)
print(f"{stats['total']} von {stats['goal']} Reparaturen")
print(f"Heute vorn: {best_kreis} mit {best_today} (Marke {mark})")
if mark and best_today > mark:
print("Neuer Tagesrekord an einem Ort!")
# display.show(stats["total"]) # Hier die Display-Bibliothek anbinden.
elif response.status_code == 403:
print("Kampagne ist gerade nicht aktiv.")
elif response.status_code == 429:
# Gedrosselt: genau so lange warten, wie der Header sagt.
time.sleep(int(response.headers.get("Retry-After", str(INTERVAL))))
continue
else:
print(f"Statistik nicht verfuegbar: HTTP {response.status_code}")
time.sleep(INTERVAL)Was in den Daten steckt und was nicht
Keine Namen, keine E-Mail-Adressen, keine IP-Adressen, keine genauen Standorte. Was zu einem Ort gehört, ist bereits vor dem Speichern vergröbert: Die Koordinate wird im Browser um eine zufällige Strecke von bis zu 1 km verschoben und auf rund 110 m gerundet, bevor sie gesendet wird. kreis ist die gröbste sinnvolle Ortsangabe und aus derselben Zelle abgeleitet. Fotos werden vor dem Upload im Browser neu gerendert; EXIF- und GPS-Metadaten fallen dabei weg. Details in der Datenschutzerklärung.
Alles, was hier ausgeliefert wird, ist zur Veröffentlichung freigegeben und steht so auch auf der Bühne unter /stats. Wer es weiterverwendet, sollte dieselbe Zurückhaltung walten lassen: Es sind Beiträge von Menschen, die eine Reparatur gezeigt haben, keine Datenbank zum Weiterverkaufen.
Und was nicht öffentlich ist
Die Routen unter /api/admin/, /api/moderation/, die Einreichung selbst (/api/repairs) und die Benachrichtigungen verlangen eine Anmeldung mit einer Team-Rolle oder nehmen nur POST an. Sie sind nicht Teil dieser Zusage und können sich jederzeit ändern.
Kopiere keine Zugangsdaten dieser Website und keinen Supabase-Schlüssel auf ein Gerät. Die öffentlichen Routen brauchen keine – und ein Schlüssel auf einem Mikrocontroller im Foyer ist ein Schlüssel für alle.
Die hier beschriebenen Felder bleiben erhalten. Neue Felder können jederzeit dazukommen, und die Reihenfolge von Listen und Objekten ist nicht garantiert. Lies die Felder, die du brauchst, und ignoriere den Rest.

