14 KiB
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.jsonplus files undermedia/<site>/<screen>/. - User data in
users.json(hashed passwords via werkzeug.security scrypt). - Port 5005;
README.mdhat den korrekten Port. - Existing repo instructions in this file are the main local guidance; there is no
opencode.jsonor 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.jsonwird beim ersten Start ausconfig.json.adminbefüllt- Notfall:
users.jsonlö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 viaqrcode[pil](nur in.venvinstalliert) - Der TOTP-Secret wird sofort beim ersten Aufruf von
/mfa/setup(GET) inusers.jsonpersistiert, 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/disablemit Passwort-Bestätigung - MFA-Status in der Userliste (
/admin/users) als grünes "Aktiv"-Badge oder "–"
MFA-Login-Flow
- POST
/loginmit Passwort → bei aktivem MFA: Sessionmfa_pendingsetzen → redirect/mfa/verify - GET/POST
/mfa/verify→ TOTP-Code prüfen →login_user()aufrufen → redirect/admin/<site> - Recovery-Code: POST
/mfa/verifymitrecovery=true→ Code prüfen →mfa_enabled=False→ Login erlauben, aber User muss MFA neu einrichten
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 /adminrequires 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. -
.htmlitems inmedia/are rendered inline as content, not in an iframe. -
config.priority.enabledmakes the priority playlist show on every screen. -
POST /api/customergenerateswelcome.htmland inserts it at the front of the lobby playlist for the specified site. -
New standorte can be added by creating
media/<neuer-standort>/<screen>/directories and optionally adding config toconfig.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)takes lists for up to 3 customers; logos have equal width (280px) withmax-height: 180px. -
customer_namesare preserved in form fields after POST (viavalue-Attribute). -
Admin-UI nutzt keyadmin-Design:
brand-surface(#2b2f36),nav-surface(rot #DA002D), Dark Mode perlocalStorage("signage-theme"). -
Gemeinsame HTML-Bausteine:
_header.html,_footer.html,_styles.html(CSS-Variablen--ccm-*, Dark Mode, Card-Border-Radius 1rem). -
add_customer_to_lobby_playlistentferntwelcome.htmlsowohl als String als auch als Dict aus der Playlist vor dem Einfügen. -
add_screenlegt ein Verzeichnis untermedia/<site>/<screen>/an und einen Config-Eintrag. -
delete_screenentfernt 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_urlwird 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 inconfig.jsongesetzt. -
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 überGET /add-site?name=<name>angelegt werden (nur Admins). -
delete_siteentfernt 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. -
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_customerund/customererfordern jetzt Login (@login_required) mit Site-Zugriffsprüfung. -
/adminredirectet 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.jsonis 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(),playlist_item_enabled(),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 aufstatic/wallpaper.pngwenn keine Datei existiert. get_background_url(site)prüft auf benutzerdefiniertes Hintergrundbild für einen Standort.generate_welcome_html(customer_names, logo_urls, background_url=None)akzeptiert optionalenbackground_url-Parameter.welcome.htmlwird inmedia/<site>/lobby/gespeichert.search_customer_logoingenerate_welcome_page.pynutzt OpenAI GPT-4 + Brandfetch CDN.admin_priorityrendertpriority.htmlmitsite_list,current_site,priority_files,server_url.static/dva.png: Bild für den Voice-Agent-Button (optional, per Admin-Toggle ein-/ausblendbar).
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-Parameterw(Breite) undh(Höhe) +remote_addr+User-Agentund übergibt sie anrecord_heartbeat()admin()-Route sammeltclient_info[screen] = get_client_info(...)für jeden Screen- Admin-Template (
admin.html) zeigt den Online-Button mittitle-Tooltip (Browser, Auflösung, IP, letztes Update) und den Info-Tab mit Client-Info-Tabelle - Player (
player.html) sendetscreen.widthundscreen.heightals?w=...&h=...imcheckForUpdates-Fetch - Die Lösch-Buttons (
btn-outline-danger btn-sm) haben gleiche Größe durch gleiche btn-sm-Klasse, Form-Wrapper hatm-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 {} \;