Files
appRobotDriver/doc/ToDo_9_HardwareFeedback.md
2026-06-14 10:32:31 +02:00

22 KiB
Raw Permalink Blame History

ToDo 9 — Hardware-Feedback-Loop

Ziel

Der Roboter-Treiber soll nicht nur Befehle senden, sondern auch Antworten der Hardware lesen. Nur so können Fehler erkannt, Positionen verifiziert und Befehlsfolgen zuverlässig synchronisiert werden.

Aktuell ist der Datenfluss vollständig blind:

WebSocket → GCode → calculateAngles3D → sendCommand → tSocket.write()
                                                          ↓
                                               GRBL antwortet mit "ok" / "error"
                                               → wird nie gelesen (data => {})

Der blinde Kanal ist genau eine Stelle: socket.on('data', () => {}) in robot/TelnetSenderGRBL.js (Z. ~123). Alles in diesem ToDo hängt daran.


FluidNC-Protokoll: gesicherte Fakten (recherchiert)

Quellen: Serial Protocol, Automatic Reporting, Cross-Channel #750.

  1. ? funktioniert über Telnet. FluidNC verarbeitet Realtime-Kommandos (?, Feed-Hold, Cycle-Start, Reset) über eine Channel-Abstraktion auf allen Kanälen (USB, WiFi, Telnet, WebUI). Der bestehende telnet-stream-Socket kann ? senden und den <…>-Report zurücklesen.

  2. Statuswahl über $10 (Bitmaske). Bit0 = MPos statt WPos, Bit1 = Bf (Puffer). → $10=3 liefert MPos + Bf (auf der eingesetzten FluidNC-Version verifizieren).

    • MPos ist die richtige Quelle für die Rückrechnung: der Treiber sendet die portValue()-Werte als absolute G-Code-Koordinaten, MPos ist offset-fest.
    • WCO (Work Coordinate Offset) erscheint periodisch; WPos = MPos WCO. Für uns irrelevant, solange wir MPos lesen.
    • Beispiel: <Idle|MPos:151.000,149.000,-1.000|Bf:15,128|FS:0,0|WCO:12,28,78>
  3. Bf: = das kanonische Flow-Control-Signal. Erste Zahl = freie Planner-Blöcke, zweite = freie RX-Bytes. Damit liest man die echte Pufferfüllung, statt sie über moveTime zu schätzen (das ignoriert Beschleunigung).

  4. Auto-Reporting statt Polling. $Report/Interval=N (ms) lässt FluidNC den Status während der Bewegung selbst pushen — pro Kanal einstellbar, im Stillstand nur bei Änderung. Ersetzt das ?-Polling und hält die Netzlast vorhersehbar gedeckelt.

  5. Cross-Channel-Bleed-Through (Caveat). ok/Reports können auf anderen Kanälen auftauchen als dem auslösenden (Issue #750). Da pro FluidNC mehrere Kanäle aktiv sind (Treiber-Telnet und die appRobot_Access*-WebUI-Container), muss der Parser nach Nachrichtentyp demultiplexen (ok / error: / <…>) und fremde Zeilen tolerieren — kein striktes 1:1 Request→Response annehmen.


Designentscheidungen (festgeschrieben)

B3 — Umkehr-Kinematik. Aktualisiert nach der Analyse (ToDo_9a): Die Port→Motor-Rückrechnung, die der Sync braucht, ist linear und eindeutig — keine Zweig-Wahl nötig. Die Ellbogen-oben/unten- Mehrdeutigkeit betrifft nur die kartesische Inverskinematik calculateAngles3D() (Pose → Gelenkwinkel), die der Sync nicht verwendet. Falls dort je eine Disambiguierung gebraucht wird, gilt die physikalische Zusatzbedingung („Ellbogen höher als Hand" bzw. „hinter der x-Achse").

B5 — Lockstep als abschaltbare Absicherung. Durch die koordinierte Feedrate (ToDo_6a correct) treffen ohnehin alle Achsen gleichzeitig am nächsten Ziel ein — Lockstep ist die Absicherung, nicht der Hauptmechanismus. Umsetzung als Env-Schalter; Freerun zuerst (reines Zeit-/Bf-Pacing), echtes Lockstep erst nach Paket 3/5 (braucht den Feedback-Kanal).

B6 — Sync ist ein G-Code-Befehl. Läuft durch GCodeParser + RobotController (nicht als Sonderfall in InputWS wie heute M114). Folge: Sync ist der erste asynchrone Befehl — er muss auf die ?-Antworten aller drei Controller warten, bevor er den Roboterzustand setzt. Die Dispatch-Kette (RobotController.applyCommand) muss dafür erstmals einen Promise zurückgeben/awaiten können.

Schalter-Übersicht (Namen sind Vorschläge)

Env Werte Default Wirkung
ROBOT_SPEED_MODE legacy / correct legacy koordinierte Feedrate (ToDo_6a)
ROBOT_USE_QUEUE false / true false zeitgesteuerte Sende-Queue (Paket 6)
ROBOT_MOTION_SYNC freerun / lockstep freerun Schritt-für-Schritt-Synchronisation der 3 Controller
ROBOT_GRBL_AUTOREPORT false / true false umgesetzt (Paket 3). Schreibt beim Connect $10=3 + $Report/Interval in die persistenten FluidNC-Settings. Default AUS → ohne Flag wird NICHTS an die Hardware geschrieben; geparst werden nur die Antworten des ohnehin laufenden ?-Heartbeats.
ROBOT_GRBL_REPORT_INTERVAL ms (Zahl) 200 umgesetzt (Paket 3). Intervall für $Report/Interval, nur wirksam bei ROBOT_GRBL_AUTOREPORT=true.

Konsistenz-Regel: ROBOT_MOTION_SYNC=lockstep ergibt nur mit ROBOT_SPEED_MODE=correct Sinn (sonst kommen die Controller zu unterschiedlichen Zeiten an und Lockstep müsste hart warten). Beim Start einmal prüfen und ggf. warnen.


Paket 1: GRBL-Antworten lesen — ERLEDIGT

Umgesetzt in robot/TelnetSenderGRBL.js. Der vorher blinde Kanal wird gelesen: tSocket.on('data', …)_handleIncomingData() (zeilen-gepuffert, fragmentierungs- sicher) → _handleResponseLine() (demultiplext nach Typ). Tests: test/Sender.Telnet.responseParsing.test.js.

  • connection.on('data', …) durch echtes Lesen ersetzt
    • oklastOk-Zeitstempel; error:/ALARM:lastError + Log
    • <…>-Reports → _parseStatusReport() (siehe Paket 3)
    • wirft nie (try/catch um den data-Handler) — ein Parsefehler darf den Prozess nicht abreißen
  • Fehlerantworten nach außen meldbar gemacht
    • über getStatus() (lastError) → InfoServer /api/status; kein EventEmitter-Umbau nötig
    • der raw-socket.on('data')-Handler für _lastDataAt (Heartbeat-Liveness) bleibt unverändert

Bewusst nicht hier: ein ok-Handshake / Sende-Queue (das ist Paket 2 / Paket 6). Paket 1 liest und meldet nur — der Sende-Pfad bleibt unangetastet (fire-and-forget).

Paket 2: Command-Queue mit ok-Handshake

Verhältnis zu Paket 6: Dies ist die einfache, synchrone Variante (ein Befehl pro ok). Die ausgebaute, Bf-basierte und abschaltbare Queue steht in Paket 6 und löst dasselbe Problem flüssiger. Paket 2 kann als Zwischenschritt dienen oder direkt in Paket 6 aufgehen.

  • Sendepuffer einführen: Befehle erst abschicken, wenn das vorherige ok eingegangen ist
    • GRBL hat intern ~128 Byte Puffer — bei schnellen Befehlsfolgen (Datei abspielen) droht sonst Puffer-Überlauf und stille Befehlsverwerfung
    • Alternative: GRBL Line-Counting-Protokoll (sendet mehrere Befehle, zählt Zeichen im Puffer) → in Paket 6 über die zweite Bf-Zahl (freie RX-Bytes) abgedeckt
  • Timeout für ausbleibende ok-Antworten definieren
    • nach X ms ohne Antwort: Fehler loggen, ggf. Verbindung zurücksetzen

Paket 3: Hardwareposition lesen (Auto-Report statt Polling) — weitgehend erledigt

Report-Parsing + InfoServer-Anbindung umgesetzt. Die Settings-Writes ($10, $Report/Interval) sind opt-in (Env ROBOT_GRBL_AUTOREPORT, Default AUS), weil sie persistente FluidNC-NVS-Settings schreiben und auf der eingesetzten FluidNC-Version verifiziert werden müssen. Tests: test/Sender.Telnet.responseParsing.test.js, test/InfoServer.test.js.

  • Beim Verbindungsaufbau je Controller konfigurieren — opt-in (_configureAutoReport()):
    • $10=3 → Report enthält MPos und Bf (Protokoll-Fakt 2)
    • $Report/Interval=N (Default N=200) → FluidNC pusht den Status selbst (Protokoll-Fakt 4)
    • nur wenn ROBOT_GRBL_AUTOREPORT=true; ohne Flag bleibt der ?-Heartbeat (alle 10 s) die einzige Statusquelle — ausreichend für Anzeige, kein Schreibzugriff auf die Hardware
  • data-Handler (Paket 1) parst die <…>-Reports: state, MPos/WPos, Bf
    • robust gegen Cross-Channel-Fremdzeilen (Protokoll-Fakt 5) — nach Typ demultiplext, fremde Zeilen werden ignoriert; zerstörte Felder lassen den alten Wert stehen
  • Offen (→ Paket 4): Gemeldete MPos mit Softwareposition vergleichen / bei Abweichung warnen. Bewusst aufgeschoben: der Vergleich braucht die motorStateFromPorts()-Rückrechnung (ToDo_9a) und eine Roboter-Referenz im Sender — beides gehört zum Sync-Command (Paket 4). Der Sender liest und meldet die Hardwareposition bereits; der Abgleich fehlt noch.
  • Status (Idle/Run/Alarm/Hold) + MPos + Bf für den InfoServer bereitgestellt
    • getStatus() um grblState, machinePosition, plannerBlocksFree, rxBytesFree, lastError, lastReportAt erweitert; /api/status reicht sie durch

Baustein für Paket 4 + 5: Rückabbildung Port → Motorwerte — DURCHGERECHNET

Erledigt als Analyse. Vollständige Herleitung: doc/ToDo_9a_PortRueckrechnung.md. Verifikation: test/Robot.PortInverse.test.js (15 Tests grün).

Beide folgenden Pakete brauchen denselben Baustein: aus den von GRBL gemeldeten MPos-Werten der drei Controller die sieben Motorwerte des Roboters rekonstruieren (xMotor, alpha, beta, a, b, c, eMotor).

Ergebnis: Für die produktive Verkabelung (startRobot.js) ist die Abbildung Motorwerte → gesendete GRBL-Achswerte linear und eindeutig umkehrbar — auf Port-Ebene gibt es keine Mehrdeutigkeit. factorTurnLift/handOpenInMM kommen in der produktiven Verkabelung gar nicht vor (nur in nicht-genutzten portValue-Zweigen).

// D = 180/π ; r = { base:{x,y,z}, elbow:{x}, hand:{x,y,z} }
xMotor = r.base.x
alpha  = r.base.y / D
beta   = (r.base.z + r.base.y) / D
a      = r.elbow.x / D
b      = r.hand.z / D
c      = (r.hand.x + r.hand.z) / D
eMotor = r.hand.y / D

B3 ist hier kein Thema. Die Ellbogen-oben/unten-Mehrdeutigkeit steckt allein in der kartesischen Inverskinematik calculateAngles3D() (Pose → Gelenkwinkel). Der Sync nutzt diese Richtung nicht — er geht MPos → Motorwerte → Vorwärtskinematik → Pose, beide Schritte eindeutig. Der gesamte Sync-Pfad ist damit eindeutig.

Umsetzung (Paket 4) — ERLEDIGT:

  • motorStateFromPorts() in den Produktiv-Code gehoben: robot/portInverse.js. test/Robot.PortInverse.test.js importiert jetzt diese Funktion (statt inline) — die 15 Verifikations-Tests sind damit der Dauer-Guard für den Produktivcode.
  • Im Sync verdrahtet: RobotController.syncFromHardware() ruft motorStateFromPorts(). Die Round-Trip-Invariante ist über test/Robot.PortInverse.test.js (A/B/C) abgedeckt.

Hinweis: Gelesen wird auf dem aktiven Sender TelnetSenderGRBL (im data-Handler, siehe Paket 1) — nicht auf FluidNCClient.js.


Paket 4: Hardware-Position auslesen und übernehmen (Sync-Command) — ERLEDIGT

Ziel: Ein Befehl liest die echten Motor-Koordinaten aller drei Controller aus, übernimmt sie als neuen Soll-Zustand und passt die berechnete Roboter-Pose entsprechend an. Nötig nach Homing, manuellem Jog, Endschalter-Auslösung oder Reconnect — die Software weiß sonst nicht, wo der Roboter physisch wirklich steht.

Umgesetzt. Befehl: M114 R. Pfad: GCodeParserRobotController.applyCommand (M114-R-Branch) → RobotController.syncFromHardware() (async). Tests: test/RobotController.sync.test.js, test/Sender.Telnet.responseParsing.test.js (requestStatusReport). Datenquelle: aktiv ? + await (gewählt; funktioniert auch ohne Auto-Report).

  • G-Code-Befehl M114 R — durch GCodeParser + RobotController geroutet
    • GCodeParser erhält jetzt Flag-Token ohne Wert (Rparams.R = true)
    • GCode.containsCommand('M114 …') erkennt es; das bestehende exakte M114 (Software- Position) in InputWS bleibt unberührt (wird vorher per === "M114" abgefangen)
  • Async-Dispatch: applyCommand gibt für M114 R ein Promise zurück; receive reicht es nur dann hoch (sonst synchron wie bisher). InputWS wartet auf das Promise und broadcastet erst danach die neue Pose; Fehler gehen als Fehler-Envelope an den Anfrager. Alle anderen Befehle bleiben byte-identisch synchron.
  • Ablauf des Sync (syncFromHardware):
    1. an base/elbow/hand je requestStatusReport() → sendet ?, wartet auf <…> (Timeout 1 s)
    2. motorStateFromPorts(...) → sieben Motorwerte (linear/eindeutig, ToDo_9a)
    3. auf den Roboter schreiben (robot.xMotor/alpha/beta/a/b/c/eMotor)
    4. robot.calculatePositionFromMotorAngles() → Pose x/y/z + phi/theta/psi
    5. motorPosition/motorPositionOld auf null → nächster Move rechnet von der echten Position
  • dem Client die übernommene Pose zurückmelden — als Broadcast von getM114 (konsistent mit Bewegungen; erreicht auch die Simulation, nicht nur den Anfrager)
  • kein automatisches Nachfahren — Sync ruft kein sendCommand(); es geht garantiert nichts an die Controller (per Test abgesichert: execCommand wird nie aufgerufen)
  • Guards: fehlende Controller-Rolle, ungültige/zu kurze MPos und ?-Timeout führen zu einem sauberen Fehler an den Client — nie zu Müll im Roboterzustand

Sender→Rolle-Zuordnung: startRobot.js setzt instance.controllerRole = key (base/elbow/hand); syncFromHardware mappt darüber. Ändert sich die Verkabelung, muss portInverse.js mitgezogen werden — der Round-Trip-Test schützt davor.

Warum nicht einfach „letzten gesendeten Wert" merken? Weil die Hardware nach Homing/Jog/ Stall von dem abweicht, was zuletzt gesendet wurde — genau diese Differenz soll Sync auflösen.


Paket 5: Bewegungs-Fortschritt ermitteln (Move-Progress)

Ziel: Herausfinden, wie weit FluidNC einen laufenden Move bereits abgearbeitet hat — also wie weit sich jeder Controller schon zur Ziel-Position bewegt hat (0…100 %).

  • Beim Absenden eines Moves Start und Ziel je Controller festhalten
    • mStart = Port-Werte vor dem Move, mTarget = gesendete Port-Werte
    • liegt bereits vor: robot.motorPositionOld (Start) und robot.motorPosition (Ziel), über portValue() in Port-Werte umgerechnet
  • Aus den auto-gepushten Reports (Paket 3, $Report/Interval) je Controller berechnen — kein eigenes Polling:
    fortschritt_i = |MPos_jetzt  Start_i| / |Ziel_i  Start_i|     (auf 0…1 geklemmt)
    
  • Aggregat-Fortschritt + Fertig-Erkennung:
    • Fertig, wenn alle drei Controller state == Idle UND MPos ≈ Ziel
    • der Gesamt-Fortschritt eines Schritts = Minimum über die Controller (der langsamste bestimmt, wann der Schritt fertig ist)
    • im Korrekt-Modus (ToDo_6a) sollten alle Controller etwa gleich schnell fertig sein — eine große Spreizung der Einzel-Fortschritte ist dort ein Warnsignal (Feedrate-Fehler)
  • Lockstep-Gate (B5, nur bei ROBOT_MOTION_SYNC=lockstep): den nächsten Schritt erst freigeben, wenn der langsamste Controller Idle/Ziel erreicht hat. In freerun entfällt das Gate — die Koordination kommt allein aus der Feedrate (ToDo_6a correct).
  • Fortschritt + Status nach außen geben
    • über InfoServer (/api/status) und/oder als WS-Push an die Clients
    • ermöglicht eine Fortschrittsanzeige beim Abspielen von Dateien (ToDo_6b)

Offener Kernpunkt: Was, wenn währenddessen der nächste Befehl kommt?

Heute ist der Treiber fire-and-forget: ein neuer Befehl wird sofort an alle drei GRBLs geschickt und landet in deren Planner-Puffer. Das hat zwei Folgen, die Paket 5 für sich genommen nicht löst:

  1. Fortschritt wird mehrdeutig. motorPositionOld/motorPosition halten nur die letzten zwei Stellungen. Sind mehrere Moves gleichzeitig im GRBL-Puffer, weiß der Treiber nicht mehr, welcher gerade fährt — der ?-Fortschritt bezieht sich dann auf das falsche Start/Ziel-Paar.
  2. Stille Befehlsverwerfung. Kommen Befehle dauerhaft schneller als sie ausgeführt werden, läuft GRBLs 128-Byte-RX-Puffer über → Befehle gehen verloren (vgl. Paket 2).

Die saubere Lösung ist eine zeitgesteuerte Sende-Queue — bewusst als größerer, abschaltbarer Umbau in Paket 6.


Paket 6: Zeitgesteuerte Sende-Queue (ROBOT_USE_QUEUE)

Ziel: Befehle nicht mehr blind sofort raushauen, sondern in einer Queue puffern und zeitlich getaktet absenden — jeden Befehl gerade rechtzeitig, bevor der vorige fertig ist. So bleibt die Bewegung flüssig (GRBL-Planner läuft nie leer), nichts geht verloren (kein Puffer-Überlauf), und das Netz wird nicht mit Dauer-Polling oder Befehls-Salven belastet.

Schalter

ROBOT_USE_QUEUE (siehe zentrale Schalter-Übersicht oben). false = exakt heutiges Fire-and-forget, byte-identisch zu vorher; true = Queue-Logik aktiv. Wie ROBOT_SPEED_MODE greift der Umbau nur bei true — das bestehende Sender-Test-Sicherheitsnetz bleibt gültig.

# docker-compose.yaml → appRobotDriver
environment:
  - ROBOT_USE_QUEUE=false   # oder: true

Takt: Bf ist die Wahrheit, moveTime nur der Vorhersage-Hint

Die Recherche ändert den ursprünglichen „reine Zeitschätzung"-Entwurf: FluidNC liefert die echte Pufferfüllung (Bf) ohnehin frei Haus über Auto-Reporting (Protokoll-Fakt 3+4). Damit muss nichts mehr aufwändig geschätzt werden.

  • Bf-basierter Haupttakt (echte Wahrheit, kein Extra-Traffic): Aus den auto-gepushten Reports (Paket 3) kennt der Treiber je Controller die freien Planner-Blöcke. Regel:
    solange (freie Planner-Blöcke > schwelle)  →  nächsten Queue-Eintrag senden
    
    Hält den Planner gefüllt (flüssige Bewegung) und kann nie überlaufen — das ist das Standard-GRBL-Streaming, nur mit gepushtem statt gepolltem Status.
  • moveTime als prädiktiver Zusatz (aus ToDo_6a): zwischen zwei Reports überbrückt die geschätzte Ausführzeit die Lücke (z. B. um zu entscheiden, ob jetzt schon der nächste Eintrag sinnvoll ist). Korrigiert wird die Schätzung laufend durch den nächsten Bf-Push; sie ist nie die alleinige Quelle.

Eintrag in der Queue

  • Queue-Eintrag hält: geparster Befehl / Motorziel, moveTime (Hint), die je Sender resultierenden G-Code-Strings, Status (pending → sent → done)
  • done wird aus dem Bf/state-Report abgeleitet (Idle am Ziel), nicht geraten

Pacing-Schleife

  • je Controller die freien Planner-Blöcke aus dem letzten Report führen; senden, solange über Schwelle — In-Flight-Tiefe damit implizit begrenzt (flüssig, aber steuerbar)
  • moveTime nur zur Überbrückung zwischen Reports nutzen
  • Sicherheitsboden: zusätzlich freie RX-Bytes aus Bf beachten (zweite Zahl) — nie mehr senden, als in den RX-Puffer passt (Character-Counting fällt damit faktisch ab)

Verhalten bei „neuer Befehl kommt mitten in der Bewegung"

Zwei Betriebsarten — je nach Quelle der Befehle:

  • Anhängen (Datei abspielen / ToDo_6b): Reihenfolge erhalten, nach Schätzung takten, In-Flight-Tiefe begrenzen. Kein Befehl wird verworfen.
  • Neuester gewinnt (interaktives Streaming, z. B. 3D-Input / Bodytracker): Trifft ein neues Ziel ein, während noch ungesendete (pending) Einträge in der Queue liegen, den veralteten Schwanz ersetzen statt anzuhängen (Coalescing) → niedrige Latenz, kein Auflaufen veralteter Zwischenziele. (Kann als Unter-Schalter / spätere Ausbaustufe kommen.)
  • Backpressure: maximale Queue-Länge festlegen. Bei Dauer-Überlauf entweder Ältestes-verwerfen (Streaming) oder Druck an den WS-Client zurückgeben (Datei) — nie unbegrenzt wachsen lassen.

Fortschritt mit Pipelining (behebt die Mehrdeutigkeit aus Paket 5)

  • Den „aktuell fahrenden" Eintrag in der Queue markieren (Kopf vorrücken, wenn geschätzte Uhr abgelaufen oder ?=Idle). Erst dieser Kopf liefert das richtige Start/Ziel-Paar für die ?-Fortschrittsrechnung aus Paket 5.

Kanten / Sonderfälle

  • Alarm/Error mitten in der Queue (Paket 1) → Queue leeren, Senden stoppen, Fehler melden
  • Sync-Command (Paket 4) bei nicht-leerer Queue → erst Queue leeren; die gepufferten Ziele sind nach einem Re-Homing veraltet
  • Lockstep vs. Freerun (B5): In freerun taktet jeder Controller eigenständig nach seinem Bf. In lockstep kommt zusätzlich das Gate aus Paket 5 dazu — der nächste Schritt wird erst gesendet, wenn der langsamste Controller Idle/Ziel meldet.
  • Drei Controller driften auseinander: Im Korrekt-Modus (ToDo_6a) sollten die Bf-Stände zusammenbleiben; große Spreizung ist ein Warnsignal (Feedrate-Fehler) — in lockstep zudem ein Auslöser, härter zu warten.
  • moveTime nur Hint: Da Bf die echte Wahrheit liefert, ist die Schätzungenauigkeit (dist/feedrate ignoriert Beschleunigung) unkritisch — sie überbrückt nur die Lücke zwischen zwei Reports. Keine Trapez-Profil-Verfeinerung nötig.

Hinweis zur Implementierung

  • Aktiver Pfad: Gelesen und konfiguriert ($10, $Report/Interval) wird auf dem produktiv genutzten robot/TelnetSenderGRBL.js (Telnet-Kanal) — der data-Handler dort ist die zentrale Stelle (Paket 1).
  • robot/fluidnc/FluidNCClient.js (bidirektionale WebSocket-Anbindung, Port 81, Reconnect + EventEmitter) ist eine alternative Anbindung. Nicht der aktive Pfad, kann aber bei ToDo_2 (Sender-Interface) als Referenz für das Event-Modell mit evaluiert werden.

Reihenfolge / Abhängigkeiten

  1. Paket 1 (lesen) ist die Basis für alles Weitere.
  2. Paket 3 (Auto-Report $10=3 + $Report/Interval, MPos/Bf parsen) liefert die Daten für Paket 4, 5, 6.
  3. Baustein (Umkehr-Kinematik inkl. B3-Zweigwahl) → dann Paket 4 (Sync, async).
  4. Paket 5 (Fortschritt) und Paket 6 (Queue) bauen auf Paket 3 auf; Freerun zuerst, Lockstep (B5) erst danach.