Zum Inhalt

Architektur

Überblick

flowchart LR
    subgraph Boards["ESP32-C5 (USB)"]
        B0["Board 0<br/>Ziel"]
        B1["Board 1<br/>Ziel"]
        B2["Board 2<br/>Freigabe"]
    end
    subgraph Server["laptimer serve (Rust)"]
        R["Board-Threads<br/>Frames lesen, Uhrabgleich"]
        C["Core-Thread<br/>Erkennung, Sessions, Ansagen"]
        DB[("SQLite<br/>+ Pegel-Dateien")]
        W["axum<br/>HTTP + WebSocket"]
        H["Hook-Thread"]
    end
    B0 & B1 & B2 --> R
    R -- "std::mpsc" --> C
    C <--> DB
    C -- "state / live" --> W
    C -- Events --> H
    W <--> UI["Browser<br/>React"]
    W <--> API["Eigene Programme<br/>/api/v1"]

Der Server ist ein einzelnes Programm in Rust, ohne Laufzeit-Abhängigkeiten außer mpg123 und einer Sprachausgabe für die Ansagen. SQLite ist einkompiliert, die Weboberfläche liegt als fertige Dateien daneben.

Threads

Der Kern läuft bewusst mit normalen Threads und std::sync::mpsc statt async, weil das einfacher zu lesen ist. Nur der Webserver (axum) läuft auf tokio.

Thread Aufgabe
ein Thread pro Board liest den USB-Strom, zerlegt ihn in Frames, rechnet Board-Zeit in Host-Zeit um, schickt die Pegel per Kanal an den Core
Core nimmt Pegel entgegen (on_board_event), füttert die Detektoren, und alle 10 ms tick: Countdown, Startsignal, neue Durchflüge in die Datenbank, Ergebnisse, Live-Nachricht (10 Hz)
tokio (axum) Seite, REST, WebSockets; Anfragen, die die Datenbank oder Dateien brauchen, gehen in spawn_blocking
Ansagen spielt eine Liste von Clips und Sprachausgaben nacheinander ab
Hooks startet die Scripts nacheinander, mit 10 s Zeitlimit

Der Core liegt in einem std::sync::Mutex. Die Regel: diese Sperre nie über ein .await halten, sonst blockiert sie einen tokio-Thread.

Zeit

Alle Zeiten in der Datenbank sind Wanduhr-Mikrosekunden: origin_unix_us + host_us, also der Zeitpunkt des Serverstarts plus die monotone Uhr seither. Start, Ende, Durchflüge und tick nehmen alle diese eine Uhr, nicht die Systemuhr.

Der Grund: Ein Raspberry Pi hat keine Akku-Uhr. Er bootet mit einer alten Zeit, und erst NTP stellt sie später, wenn überhaupt. In einer echten Session sprang die Uhr so um 27 Stunden, und alle Durchflüge lagen plötzlich vor dem Start. Jetzt folgt der Server einem Sprung der Systemuhr nur beim Start einer Session, nie während einer.

Die Boards haben jeweils ihre eigene Uhr. Der Server gleicht sie über TIME? ab, siehe Firmware und Protokoll.

Speicher

~/.laptimer/data/
  laptimer.db                 SQLite
  levels/session_<id>.csv     Rohpegel jeder Session
  recordings/                 Aufnahmen aus der Debug-Ansicht
  backups/                    gepackte Kopien der Datenbank
  hooks/                      Scripts für Hooks
  tts/                        Sprachausgabe-Cache
Tabelle Inhalt
sessions Training oder Rennen, mit der Konfiguration als JSON (Start, Ende, Erkennung), damit alte Ergebnisse gleich bleiben, wenn sich Einstellungen ändern; confirmed_us
seats wer in der Session auf welchem Kanal flog
passes Durchflüge: Zeit, Kanal, gate (0 Ziel, 1 Freigabe), manual, deleted
pilots, presets Piloten und ihre Schwellen-Presets
settings alle Einstellungen der Seite als JSON
api_keys, logins, hooks Zugänge (nur sha256 gespeichert) und Hooks

Runden werden nie gespeichert. laps::compute rechnet sie bei jedem Abruf aus den Durchflügen: Startregel (Holeshot, ab Start, fliegend), Ende (Runden, Zeit, offen), Mindestrunde, ignorierte Durchflüge. Diese Funktion ist rein und getestet; eine JavaScript-Kopie im Frontend rechnet die Vorschau im Editor.

Session-IDs werden nie wiederverwendet (SQLite würde nach dem Löschen die höchste wieder vergeben). Entwürfe im Browser und exportierte Dateien hängen an der ID.

Vom Durchflug zur Anzeige

sequenceDiagram
    participant B as Board-Thread
    participant C as Core
    participant D as SQLite
    participant W as WebSocket
    B->>C: Pegel (10-ms-Batch)
    C->>C: Detektor: Hüllkurve, Schwellen
    Note over C: 1 s nach dem Austritt: Durchflug
    C->>C: Freigabe-Regel prüfen
    C->>D: INSERT passes
    C->>C: laps::compute → Ergebnis
    C->>W: state (Session, Ergebnis)
    C-->>W: Event pilot_pass (API, Hooks)
    C-->>C: Ansage (Runde, Bestzeit)

Die Seite bekommt über /ws zuerst den ganzen Zustand (state), danach state bei Änderungen und live zehnmal pro Sekunde (Pegel, Freigabe-Status, Boards). Die öffentliche API hat ihren eigenen WebSocket mit Events, siehe API und Events.

Installation und Updates

~/.laptimer/
  config.toml           Host, Port, Passwort, Update-Quelle
  current  -> releases/0.2.0
  previous -> releases/0.1.3
  releases/<ver>/       bin/laptimer, web/, sounds/, …

install.sh lädt das Paket für System und Prozessor, prüft die sha256 und richtet unter Linux per sudo ein, was fehlt. Den Rest macht laptimer install aus dem entpackten Paket: kopieren, current/previous umsetzen (neuer Symlink, dann rename, also atomar), Config beim ersten Mal, optional der Dienst (systemd-User-Unit oder launchd-Agent).

Ein Update über die Seite hat zwei Schritte: Bereitstellen (Upload oder Download von GitHub, Signatur mit minisign gegen den eingebauten öffentlichen Schlüssel prüfen, entpacken, Prozessor des Programms prüfen) und Installieren (Backup, current umsetzen, beenden). Mit Dienst startet systemd bzw. launchd die neue Version; ohne Dienst ersetzt sich der Server per exec selbst.

Kommt die neue Version nicht hoch, greift ein kleines Shell-Script vor jedem Start des Dienstes (update-check.sh): Der Server schreibt vor dem Umschalten update-pending, die neue Version löscht es nach 15 s Laufzeit. Zählt das Script vier Starts ohne Bestätigung, schaltet es zurück und hinterlässt update-failed, das die Seite anzeigt.