Files
signage/AGENTS.md
Erik Thiele 64db742d31 feat: Logo-Upload mit automatischem Freistellen
Findet die automatische Suche kein Logo, kann pro Kunde eine Bilddatei
hochgeladen werden. Ein Upload hat Vorrang vor der Suche.

Freistellen:
- remove_logo_background() entfernt den Hintergrund per Flood Fill von
  den Bildraendern her. Bewusst kein globaler Farbvergleich - sonst
  verschwindet auch Weiss innerhalb des Logos (etwa Text in einer
  Flaeche). Weiche Kante gegen Treppeneffekte.
- Umgesetzt mit Pillow, das ueber qrcode[pil] ohnehin vorhanden ist.
  Bewusst kein ML-Modell wie rembg: der Container laeuft mit read-only
  Root-FS, ein Modell-Cache zur Laufzeit waere dort nicht moeglich.
- Bereits transparente Bilder bleiben unangetastet. Ist der Bildrand
  nicht einfarbig, wird nicht freigestellt und die Oberflaeche weist
  darauf hin, statt ein beschaedigtes Logo zu liefern.
- Checkbox "Hintergrund behalten" schaltet das Freistellen pro Kunde ab.
- Gespeichert wird immer als PNG (JPEG kann keine Transparenz), grosse
  Bilder werden auf 800px verkleinert.

Ablage unter media/<site>/logos/logo_<slot>.png, neue Route
/media/<site>/logos/<datei>. Die URL traegt einen Zeitstempel, damit
Player kein altes Logo aus dem Cache zeigen.

Nebenbei behoben: welcome_data speicherte nur die erfolgreichen Kunden
statt der Felder 1-3. Fiel Kunde 1 aus, rutschte Kunde 2 beim naechsten
Aufruf in Feld 1 und Logo wie Ansprechpartner verschoben sich mit. Jetzt
feldweise - dadurch bleibt auch ein hochgeladenes Logo beim erneuten
Generieren erhalten, solange der Kundenname im selben Feld steht.

Die drei Kundenbloecke im Formular sind zu einer Schleife
zusammengefasst, damit die Upload-Felder nicht dreifach gepflegt werden
muessen.

README, AGENTS.md und Hilfeseite nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 13:55:40 +02:00

21 KiB
Raw Blame History

CANCOM Simple Signage — Agent Guide

Start

pip install -r requirements.txt
python app.py
docker compose up -d

python app.py (via .venv/bin/python app.py) serves on http://localhost:5005. app.py runs Flask with debug=True, host="0.0.0.0", and port=5005.

Source Of Truth

  • Single Flask app in app.py; there is no database.
  • Persistent state is config.json plus files under media/<site>/<screen>/.
  • User data in users.json (hashed passwords via werkzeug.security scrypt).
  • Port 5005; README.md hat den korrekten Port.
  • Existing repo instructions in this file are the main local guidance; there is no opencode.json or workflow config in this repo.

Multi-Standort-URL-Struktur

Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.

Route Beschreibung
GET /player/<site>/<screen> Player für Screen an einem Standort
GET /admin Redirect zum ersten zugänglichen Standort
GET /admin/<site> Admin-Dashboard für einen Standort (@site_access_required)
GET /admin/<site>/priority Priority-Playlist als separate Seite
GET /media/<site>/<screen>/<file> Medien-Datei ausliefern
GET /media/priority/<file> Priority-Medien (global)
GET /media/<site>/background/<filename> Hintergrundbild der Willkommensseite
GET /playlist/<site>/<screen>/hash Playlist-Checksumme für Auto-Reload + zeichnet Heartbeat auf (Online-Status)
GET /willkommen?site=<site> Willkommensseite für Standort generieren
POST /admin/<site>/upload-background Hintergrundbild für Willkommensseite hochladen
POST /admin/<site>/delete-background Hintergrundbild zurücksetzen auf Standard
POST /api/customer API-Endpunkt (JSON mit "site"-Feld)
GET /admin/<site>/add-screen?name=<name> Neuen Screen anlegen
POST /admin/<site>/delete-screen/<screen> Screen + Medien löschen
GET /add-site?name=<name> Neuen Standort anlegen (nur Admin)
POST /admin/<site>/update-actions/<screen> Aktionen-Einstellungen speichern (Custom-URL + Position)
POST /admin/<site>/update-voice/<screen> Voice-Agent-Einstellungen speichern (Enabled, Label, Target, Position)
POST /admin/<site>/delete-site Standort + alle Screens/Medien löschen (nur Admin)
GET /change-password Passwort ändern-Seite
GET /admin/users User-Liste (nur Admin)
GET /admin/users/create User anlegen (nur Admin)
GET /admin/users/edit/<email> User bearbeiten (nur Admin)

User-Verwaltung (seit v5.6.0)

  • Login via E-Mail + Passwort (scrypt-gehasht in users.json)
  • Drei Rollen:
    • Admin: Zugriff auf alle Standorte + Userverwaltung (anlegen/bearbeiten/löschen/Reset)
    • Superuser: Zugriff auf alle Standorte, keine Userverwaltung
    • User: Zugriff nur auf zugewiesene Standorte
  • Erster Login erfordert Passwort-Änderung (must_change_password=True)
  • Admin-Reset generiert temporäres Passwort (wird im UI angezeigt)
  • Fehlender/leerer password_hash → Login ohne Prüfung, direkt zu /change-password
  • Letzter Admin kann nicht gelöscht werden
  • users.json wird beim ersten Start aus config.json.admin befüllt
  • Notfall: users.json löschen → Server-Neustart erzeugt neuen Admin

User-Routen

Route Beschreibung
GET /admin/users User-Liste (Admin, mit MFA-Spalte)
GET /admin/users/create User anlegen (Formular)
POST /admin/users/create User anlegen
GET /admin/users/edit/<email> User bearbeiten (Formular)
POST /admin/users/edit/<email> User bearbeiten speichern
POST /admin/users/delete/<email> User löschen (letzter Admin geschützt)
POST /admin/users/reset-password/<email> Passwort-Reset (temporäres Passwort)
GET /mfa/setup MFA einrichten (QR-Code + Verifikation)
POST /mfa/setup TOTP-Code verifizieren → MFA aktivieren
GET /mfa/verify TOTP-Code nach Login eingeben
POST /mfa/verify Code prüfen oder Recovery-Code verwenden
POST /mfa/disable MFA deaktivieren (mit Passwort)

MFA (Multi-Faktor-Authentifizierung)

  • TOTP via pyotp, QR-Code via qrcode[pil] (nur in .venv installiert)
  • Der TOTP-Secret wird sofort beim ersten Aufruf von /mfa/setup (GET) in users.json persistiert, sodass die nachfolgende POST-Verifikation denselben Secret verwendet
  • Nach erfolgreicher Aktivierung: 8 Recovery-Codes (hex, 7 Stellen), einmalig im UI angezeigt, jeder nur einmal verwendbar
  • Recovery-Code-Einsatz erzwingt erneute MFA-Einrichtung
  • Deaktivierung via /mfa/disable mit Passwort-Bestätigung
  • MFA-Status in der Userliste (/admin/users) als grünes "Aktiv"-Badge oder "–"
  • Trusted Device: Nach erfolgreichem TOTP-Code kann das Gerät für 1–30 Tage gespeichert werden. Bei erneuter Anmeldung wird der MFA-Code dann übersprungen (Cookie mfa_trust mit Token, gespeichert in users.json[email].trusted_devices). Beim Logout oder Deaktivieren von MFA wird der Cookie gelöscht.

MFA-Login-Flow

  1. POST /login mit Passwort → bei aktivem MFA: Session mfa_pending setzen → redirect /mfa/verify
  2. GET/POST /mfa/verify → TOTP-Code prüfen → login_user() aufrufen → redirect /admin/<site>
  3. Recovery-Code: POST /mfa/verify mit recovery=true → Code prüfen → mfa_enabled=False → Login erlauben, aber User muss MFA neu einrichten
  4. Trusted Device: POST /mfa/verify mit remember (Tage) → Token generieren → in users.json[email].trusted_devices speichern → Cookie mfa_trust setzen. Nächstes POST /login: Cookie erkannt → MFA überspringen → direkt einloggen

Zugriffs-Dekoratoren

Dekorator Wirkung
@login_required User muss eingeloggt sein
@admin_required Nur Admins
@site_access_required Admin/Superuser → alle Sites, User → nur freigegebene

Behavior To Preserve

  • GET /player/<site>/<screen> renders the playlist and auto-reloads from /playlist/<site>/<screen>/hash.

  • GET /admin requires login; redirects to first accessible site for the user.

  • URL playlist items are stored as dicts like {"url": "https://...", "zoom": 0.8} and the zoom value must survive save/reorder flows. File items may also be dicts ({"name": "bild.jpg", ...}); save_playlist copies existing dicts, so extra keys like schedule survive reordering.

  • .html items in media/ are rendered inline as content, not in an iframe.

  • config.priority.enabled makes the priority playlist show on every screen.

  • POST /api/customer generates welcome.html and inserts it at the front of the playlist of every target screen for the specified site. Target screens are those with welcome_enabled; if no screen of the site has the key at all, the old behaviour applies (lobby only). Optional valid_until ("YYYY-MM-DD") attaches a schedule so the page disappears on its own.

  • New standorte can be added by creating media/<neuer-standort>/<screen>/ directories and optionally adding config to config.json["sites"][<neuer-standort>].

  • Priority playlist (config.priority) is global and affects all sites/screens.

  • Willkommensseite (customer.html) accepts up to 3 customer names; logos are fetched via OpenAI→Brandfetch and displayed in a flex row (portrait: column).

  • generate_welcome_html(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None) takes lists for up to 3 customers; logos have equal width (280px) with fixed height 120px, text left-aligned below each logo.

  • Contact info supports Markdown: **bold**, *italic*, `code`, - list items, and line breaks (preserved as <br>).

  • customer_names are preserved in form fields after POST (via value-Attribute).

  • Admin-UI nutzt keyadmin-Design: brand-surface (#2b2f36), nav-surface (rot #DA002D), Dark Mode per localStorage("signage-theme").

  • Gemeinsame HTML-Bausteine: _header.html, _footer.html, _styles.html (CSS-Variablen --ccm-*, Dark Mode, Card-Border-Radius 1rem).

  • add_customer_to_playlists (früher add_customer_to_lobby_playlist) entfernt welcome.html als String und als Dict aus allen Screens des Standorts und fügt sie danach nur in den Zielscreens an Position 0 wieder ein – ein abgewählter Screen bleibt so nicht mit einer alten Begrüßung stehen. Eingefügt wird ein Dict, kein String, damit enabled und schedule mitgeführt werden können.

  • welcome_target_screens(config, site) fällt nur dann auf lobby zurück, wenn kein Screen des Standorts den Schlüssel welcome_enabled besitzt. Wurde er überall bewusst auf False gesetzt, ist die leere Liste das gewollte Ergebnis.

  • Logo-Upload: Pro Kunde kann im Willkommensseiten-Formular eine Bilddatei hochgeladen werden (logo_file_1..3); ein Upload hat Vorrang vor search_customer_logo. Ablage als media/<site>/logos/logo_<slot>.png, Auslieferung über /media/<site>/logos/<datei>. Die URL traegt einen ?v=<timestamp>, damit Player kein altes Logo aus dem Cache zeigen.

  • remove_logo_background() arbeitet mit Pillow (Flood Fill von den Raendern, weiche Kante). Bewusst kein ML-Modell wie rembg: der Container laeuft mit read-only Root-FS ohne Modell-Cache. Flood Fill statt globalem Farbvergleich ist wesentlich - sonst verschwindet auch Weiss innerhalb des Logos. Bereits transparente Bilder werden unveraendert durchgereicht; bei unruhigem Bildrand bleibt das Bild unangetastet und die Funktion gibt einen Hinweis zurueck.

  • welcome_data in der Site-Config wird feldweise gespeichert (Slot 1-3, auch leere), nicht nur die erfolgreichen Kunden - sonst verschiebt sich die Zuordnung von Name, Logo und Ansprechpartner beim naechsten Aufruf. Ein zuvor hochgeladenes Logo wird beim erneuten Generieren wiederverwendet, solange der Kundenname im selben Feld unveraendert ist.

  • Weder save_welcome_page noch add_customer_to_playlists legen Screens an. Das frühere screens.setdefault("lobby", {}) erzeugte an Standorten ohne Lobby stillschweigend einen Screen.

  • add_screen legt ein Verzeichnis unter media/<site>/<screen>/ an und einen Config-Eintrag.

  • delete_screen entfernt den Screen aus der Config und löscht das Verzeichnis rekursiv.

  • Custom-URL-Button: pro Screen konfigurierbar (custom_url + custom_url_label + custom_url_enabled + custom_url_target + custom_url_position) im Admin-Formular (Aktionen-Tab); wird im Player als Button an wählbarer Position (9 Positionen: oben/mitte/unten × links/mitte/rechts) angezeigt und öffnet die URL wahlweise in einem iframe-Overlay mit Zurück-Button (overlay) oder per Direkt-Weiterleitung (redirect); Player pausiert während das Overlay geöffnet ist. Die unteren Positionen weichen automatisch 16px über dem Newsticker-Balken aus.

  • Voice-Agent-Button: globale config.voice_agent_url wird pro Screen im Tab "Digital Voice Agent" konfiguriert; Einstellungen (voice_agent_enabled, voice_agent_label, voice_agent_target, voice_agent_position) funktionieren identisch zum Aktionen-Tab. Die URL ist read-only im Admin sichtbar und wird global in config.json gesetzt.

  • Voice-Agent-Typewriter: Tagline im Button zeigt wechselnde mehrsprachige Texte mit Buchstaben-für-Buchstaben-Effekt; nach 2,5s Pause fade-out über 0,8s, dann nächster Text.

  • Voice-Agent-Overlay: positioniert den Zurück-Button exakt an der Position des geklickten Buttons (identisches Positionssystem).

  • voice_agent_show_image (per Screen): optionales Bild (static/dva.png) über dem Button, per Admin-Toggle schaltbar (Beta).

  • Action-Button (custom_url) und Voice-Agent-Button haben einheitliches Styling: weißer Hintergrund, schwarzer Text, 20px Border-Radius, fette Schrift.

  • Neue Standorte können über den +-Button im Header oder über GET /add-site?name=<name> angelegt werden (nur Admins).

  • delete_site entfernt den Standort aus Config und löscht das Medienverzeichnis rekursiv (nur Admins).

  • config.json["server_url"] (z. B. http://signage.ccmake.de) wird in der Admin-Ansicht für die Player-URLs verwendet.

  • Query-Parameter-Passthrough: Query-Parameter an der Player-URL (/player/<site>/<screen>) können pro Playlist-URL im Edit-Modal (Checkbox) gesteuert werden. Standardmäßig aus – nur URLs mit aktivierter Checkbox erhalten die Parameter. Beispiel: /player/stuttgart/lobby?customer=acme hängt ?customer=acme nur an URLs mit Passthrough=an. In der Playlist-Zeile zeigt ein Link-Icon (ti-link, grau) den Status an: sichtbar = an, durchgestrichen (ti-link-off, hellgrau) = aus. Funktioniert über appendParams() in player.html + player_params im Flask-Route.

  • Zeitsteuerung: Optionales schedule-Objekt pro Playlist-Eintrag (URL und Datei, Screen-Playlist und Priority). Ausgewertet ausschliesslich serverseitig in is_item_active(); siehe Abschnitt "Zeitsteuerung" unten.

  • Screen-Card-Body hat Tabler-Tabs: Playlist (1, aktiv), Einstellungen (2), Aktionen (3), Digital Voice Agent (4), Medien (5), Info (6); Priority-Seite ebenfalls Tabs Playlist und Medien.

  • stay_on_first: Wenn aktiviert bleibt der Player auf dem ersten Playlist-Element stehen (kein Durchlauf).

  • Tab-Reihenfolge in Screen-Cards: Playlist → Einstellungen → Aktionen → Digital Voice Agent → Medien → Info.

  • Player-URL im Screen-Header ist ein klickbarer Link in grauer Farbe.

  • add_customer und /customer erfordern jetzt Login (@login_required) mit Site-Zugriffsprüfung.

  • /admin redirectet zum ersten für den User zugänglichen Standort (nicht mehr global ersten).

  • User-Dropdown im Header: zeigt E-Mail + Role-Badge, Menü mit Passwort ändern, MFA einrichten (mit "Aktiv"-Badge wenn MFA eingeschaltet), Userverwaltung (Admin), Abmelden.

  • Info-Tab in Screen-Cards: zeigt Client-Info-Tabelle (IP, Browser, Auflösung, zuletzt gesehen) aus dem Heartbeat-JSON für den jeweiligen Screen

Repo Quirks

  • media/ and media file extensions are gitignored.
  • users.json is NOT gitignored (tracked for initial admin setup, contains no secrets by default).
  • The app has no configured tests, lint, typecheck, formatter, or CI.
  • Hardcoded secrets exist in tracked files; do not commit new secrets or reshuffle them casually.
  • Wichtige Helper-Funktionen in app.py: load_config(), save_config(), get_site_list(), get_screen_config(), is_url(), normalize_url(), playlist_item_name(), is_item_active(), _schedule_time(), _window_start_date(), _in_validity_range(), load_priority_files(), prio_redirect(), get_background_url(), load_users(), save_users(), get_user(), init_user_db(), get_accessible_sites(), record_heartbeat(), screen_is_active(), get_client_info(), add_history_entry().
  • Zugriffs-Dekoratoren in app.py: admin_required, site_access_required.
  • Hintergrundbild der Willkommensseite wird pro Standort unter media/<site>/background.* gespeichert; Fallback auf static/wallpaper.png wenn keine Datei existiert.
  • get_background_url(site) prüft auf benutzerdefiniertes Hintergrundbild für einen Standort.
  • generate_welcome_html(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None) akzeptiert optionale contacts-, site- und background_url-Parameter.
  • welcome.html wird in media/<site>/lobby/ gespeichert.
  • search_customer_logo in generate_welcome_page.py nutzt OpenAI GPT-4 + Brandfetch CDN.
  • admin_priority rendert priority.html mit site_list, current_site, priority_files, server_url.
  • static/dva.png: Bild für den Voice-Agent-Button (optional, per Admin-Toggle ein-/ausblendbar).

Zeitsteuerung (seit v6.2.0)

Jeder Playlist-Eintrag kann ein optionales schedule-Objekt tragen — URL wie Datei, in Screen-Playlists wie in der Priority-Playlist.

{ "mode": "daily",  "start": "08:00", "end": "12:00",
  "valid_from": "2026-09-10", "valid_until": "2026-09-20" }   // valid_* optional
{ "mode": "weekly", "start": "09:00", "end": "17:00", "days": ["0", "2", "4"] }
{ "mode": "once",   "start": "2026-09-10T08:00", "end": "2026-09-20T12:00" }

Regeln

  • once vergleicht vollständige Zeitstempel. daily/weekly werten nur die Uhrzeit aus; ein Datum wird dort ausschliesslich über valid_from/valid_until gesetzt (beide optional, beide inklusiv).
  • Bei daily gilt jeder Wochentag; ein vorhandenes days wird in diesem Modus nicht ausgewertet (bleibt aber erhalten, damit ein Moduswechsel die Auswahl nicht vernichtet).
  • start > end bedeutet ein Fenster über Mitternacht. Ein solches Fenster gehört zum Wochentag und Gültigkeitstag seines Starts — deshalb liefert _window_start_date() das Datum des Fensterbeginns und nicht einfach now.date().
  • Unlesbare oder unvollständige Zeitangaben geben True zurück. Ein Konfigurationsfehler soll den Screen nicht stillschweigend leeren — beim Ändern dieser Logik unbedingt beibehalten.
  • _schedule_time() liest "HH:MM", "HH:MM:SS" und volle ISO-Zeitstempel. Letzteres ist nötig, weil ältere Versionen bei daily/weekly einen kompletten Zeitstempel gespeichert haben, dessen Datumsanteil ignoriert wurde.
  • Auswertung erfolgt nur serverseitig (datetime.now() des Servers, nicht des Players).

Reload-Kopplung

playlist_hash() hasht neben der Konfiguration auch [is_item_active(item) for item in ...] für Screen- und Priority-Playlist. Ohne das würde der Player ein ablaufendes Zeitfenster erst beim nächsten Reload aus anderem Grund bemerken, da sich die Konfiguration dabei nicht ändert.

Beteiligte Stellen

  • is_item_active(), _window_start_date(), _in_validity_range(), _schedule_time() in app.py
  • Jinja-Filter schedule_text() für den Tooltip am "Zeit"-Badge
  • add_url() liest bei daily/weekly die Felder schedule_start_time/schedule_end_time, bei once schedule_start/schedule_end
  • update_playlist_item() behandelt new_url: null als Datei-Eintrag und schreibt {"name": ...}; schedule: {} entfernt die Zeitsteuerung, ein fehlender Key behält sie
  • UI in admin.html und priority.html: zwei Feldpaare (datetime-local für once, time für daily/weekly), per Modus umgeschaltet

Heartbeat / Online-Status (seit v6.1.0)

Der Player-Status (Online/Offline) wird mittels dateibasiertem Heartbeat ermittelt.

Funktionsweise

Schritt Beschreibung
Player-pollt alle 5s /playlist/<site>/<screen>/hash (checkForUpdates) Bestehender Mechanismus, kein Extra-Request
Server zeichnet Heartbeat auf record_heartbeat(site, screen) erstellt/updated /tmp/signage-heartbeat/<site>/<screen> via path.touch()
Admin-UI fragt Status ab screen_is_active(site, screen) prüft ob mtime < 60s alt
Anzeige Grüner "Online"-Button (filled success) oder roter "Offline"-Button (filled danger) im Card-Header neben "Löschen"
Client-Info Beim Hovern über den Online-Button: Tooltip mit Browser, Auflösung, IP, letztem Seen-Zeitpunkt

Helper-Funktionen in app.py

HEARTBEAT_DIR = Path("/tmp/signage-heartbeat")
HEARTBEAT_TIMEOUT = 60  # Sekunden

def record_heartbeat(site, screen, ip="", ua="", resolution=""):
    """Schreibt JSON mit Client-Info (ip, ua, resolution, last_seen)."""
    path = HEARTBEAT_DIR / site / f"{screen}.json"
    path.parent.mkdir(parents=True, exist_ok=True)
    data = {"ip": ip, "ua": ua[:200], "resolution": resolution,
            "last_seen": datetime.now().strftime("%d.%m.%Y %H:%M:%S")}
    path.write_text(json.dumps(data))

def screen_is_active(site, screen):
    """Prüft, ob Heartbeat < 60s alt ist (anhand .json-mtime)."""
    path = HEARTBEAT_DIR / site / f"{screen}.json"
    if not path.exists():
        return False
    return (time.time() - path.stat().st_mtime) < 60

def get_client_info(site, screen):
    """Gibt das gespeicherte Client-Info-JSON zurück."""
    ...

Integration

  • playlist_hash()-Route liest Query-Parameter w (Breite) und h (Höhe) + remote_addr + User-Agent und übergibt sie an record_heartbeat()
  • admin()-Route sammelt client_info[screen] = get_client_info(...) für jeden Screen
  • Admin-Template (admin.html) zeigt den Online-Button mit title-Tooltip (Browser, Auflösung, IP, letztes Update) und den Info-Tab mit Client-Info-Tabelle
  • Player (player.html) sendet screen.width und screen.height als ?w=...&h=... im checkForUpdates-Fetch
  • Die Lösch-Buttons (btn-outline-danger btn-sm) haben gleiche Größe durch gleiche btn-sm-Klasse, Form-Wrapper hat m-0 p-0

Docker-Hinweis

Im Docker-Container liegt der Heartbeat-Pfad unter /tmp/signage-heartbeat/ im Container. Da die Docker-Umgebung keine Volumes mounted, sind Heartbeats container-lokal. Nach docker compose restart werden alle Screens kurz rot, bis die Player innerhalb von max. 60s wieder pollen.

Debugging

# Alle Heartbeat-Dateien anzeigen
ls -la /tmp/signage-heartbeat/*/
find /tmp/signage-heartbeat -type f -exec ls -la {} \;