diff --git a/CLAUDE.md b/CLAUDE.md
index ba5745a1d..63e52ad7b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -546,6 +546,7 @@ preferences. OpenHPSDR radios don't store per-slice state.
| [CONTRIBUTING.md](CONTRIBUTING.md) | Contributor guidelines, coding conventions, PR process |
| [docs/development/fast-test-loop.md](docs/development/fast-test-loop.md) | **Read before running tests.** Per-test builds, `ctest -L` subsystem labels, macOS first-run-scan exemption, ccache setup, and how to write tests that stay fast. Building the whole suite costs ~32 min; almost nothing needs it. |
| [docs/development/sunsdr-mitschnitt-anleitung.md](docs/development/sunsdr-mitschnitt-anleitung.md) | **Zwei Minuten Mitschnitt an der QRP**, handgriffgenau: drei tcpdump-Durchgaenge und je Frage ein Auswertebefehl. Beantwortet die Fragen, an denen die QRP-Arbeit sonst steht (RX2-Einschalter, Abtastrate, fehlende Rahmen) -- ohne Antenne, ohne Senden, ohne einen Opcode zu raten. Dazu `tests/tst_sunsdr_messlauf.cpp` fuer Messlaeufe am echten Geraet |
+| [docs/development/sunsdr-abschluss-pruefplan.md](docs/development/sunsdr-abschluss-pruefplan.md) | **Der Durchgang mit dem 50-Ohm-Abschluss.** Alles, was an der QRP noch offen ist, haengt an einem Abschluss am Ausgang; dieses Blatt macht daraus einen Durchgang statt sieben Entdeckungen. Mikrofon-PTT bestaetigen, dann die vermuteten Sende-Opcodes EINZELN gegen die Quittung des Geraets pruefen (die QRP benutzt nachweislich andere Nummern als die DX/PRO, aus der sie stammen), erst dann tasten. Keine Antenne |
| [STYLEGUIDE.md](STYLEGUIDE.md) | Applet color palette, button states, gauge zones, slider/combo styling |
| [CHANGELOG.md](CHANGELOG.md) | Version history and per-phase feature additions |
diff --git a/docs/architecture/2026-10-02-sunsdr-paritaet.md b/docs/architecture/2026-10-02-sunsdr-paritaet.md
index 59f3d5e70..498ce2d34 100644
--- a/docs/architecture/2026-10-02-sunsdr-paritaet.md
+++ b/docs/architecture/2026-10-02-sunsdr-paritaet.md
@@ -16,6 +16,25 @@ zugehen. Es ist ein Fahrplan über mehrere Durchgänge, kein Durchgang.
| Signale nach oben | 13 | 13 | **4** |
| Leere Pflichtmethoden | 0 | 0 | **16** |
+**Diese Tabelle ist vom 2026-10-02 und damit überholt.** Am 2026-10-04
+nachgezählt, nicht geschätzt:
+
+| | ANAN 10E (P1) | Anvelina (P2) | SunSDR2 QRP |
+| --- | --- | --- | --- |
+| Zeilen | 4385 | 3774 | **2692** |
+| Signale nach oben | 10 | 12 | **8** |
+| Leere Rümpfe | 0 | 0 | **14** |
+
+Und die „leeren Rümpfe" sind nicht alle Arbeit: vier davon gibt es an
+diesem Gerät gar nicht (`setTrxRelay`, `setUserDigOut`,
+`setPuresignalRun`, `setWatchdogEnabled`), zwei gehören zum Senden und
+sieben zum Mikrofonweg — beide Gruppen warten nicht auf Arbeit, sondern
+auf **Bestätigung der Opcode-Nummern**. Die Einteilung mit Begründung
+steht seit `6f15428a` im Quelltext selbst.
+
+Die Zahl, die zählt, ist eine andere: **im Empfang ist die QRP
+gleichwertig.** Was noch fehlt, ist Senden.
+
Die Zeilenzahl ist kein Maß für Güte, die anderen beiden Zeilen sind es:
jedes Signal ist eine Meldung, die der Betreiber am Bildschirm sieht, und
jede leere Methode ein Knopf in Longpath, der beim QRP ins Leere greift.
@@ -230,3 +249,253 @@ Zustand nirgends von sich aus meldet.
ein echter Mangel gefunden wurde (eine verlorene Frequenz bleibt
unbemerkt). Braucht eine Entscheidung, weil der Treiber dann von
selbst Rahmen wiederholt.
+
+---
+
+# Neuer Mangel, am 2026-10-03 abends gemessen: Longpath sagt beim Trennen nichts
+
+`SunSdrRadioConnection::disconnect()` schickt dem Gerät **keinen einzigen
+Rahmen** — es schließt Sockets, hält Timer an, räumt auf. Dem Funkgerät
+wird nie gesagt, dass der Strom aufhören soll.
+
+**Was das in Zahlen heißt**, mit `tcpdump` nach einem Longpath-Ende
+gemessen (46 Sekunden Mitschnitt, niemand hörte zu):
+
+| | Pakete/s | Kopien je Folgenummer |
+| --- | --- | --- |
+| Reststrom, niemand quittiert | **1940** | **8,1** |
+| Longpath im Betrieb, mit Blockantwort | 240 | 1,00 |
+
+Zwei Dinge auf einmal:
+
+1. **Die Achtfachung ist abschließend erklärt.** Sie ist keine Eigenart
+ des Geräts, sondern genau die Folge fehlender Quittierung — 8,1 Kopien
+ ohne, 1,00 mit Blockantwort. Damit ist die Frage vom 2026-09-23 zu.
+2. **Die QRP streamt nach dem Trennen unbegrenzt weiter**, mit 2,3 MB/s
+ ins Leere, und der Mac antwortet auf jedes Paket mit ICMP „port
+ unreachable". Bekannt war das als Kuriosum („85 leftover I/Q packets",
+ `setFixedPortBindingEnabledForTest`) — es sind aber nicht 85 Pakete,
+ sondern ein Dauerzustand bis zum Ausschalten des Geräts.
+
+Und sehr wahrscheinlich ist das der Grund, warum ExpertSDR2 am selben
+Abend nicht verbinden konnte: das Gerät stand noch im Streaming-Zustand
+der vorigen Sitzung.
+
+**Was fehlt, ist der Stopp-Befehl.** ArtemisSDR führt für die DX
+`SUNSDR_OP_POWER_OFF 0x02`, und die Boot-Folge dort ruft ihn beim Umbau
+der Empfangswege auf. Welche Nummer das bei der QRP ist, wissen wir
+nicht, und geraten wird sie nicht (Abschnitt 3a).
+
+**Der Mitschnitt dafür ist der leichteste von allen:** ExpertSDR2
+verbinden lassen und dann **beenden**. Der letzte Rahmen, der hinausgeht,
+bevor der Strom verstummt, ist der Stopp-Befehl. Damit wäre `disconnect()`
+vollständig — und das Gerät nach jedem Longpath-Ende still.
+
+---
+
+# Stand am 2026-10-04 — nach dem ersten Tag in der laufenden App
+
+Bis gestern wurde alles am Prüfstand und am Messlauf gemessen. Heute lief
+Longpath selbst gegen die QRP, über die Automationsbrücke ferngesteuert.
+Das hat drei Fehler aufgedeckt, die kein Prüfstand gezeigt hat — und
+einer davon stand genau an der Stelle, die gestern als „erledigt" galt.
+
+## Was sich an der Liste oben ändert
+
+| Posten | Neuer Stand |
+| --- | --- |
+| Veralteter Kommentar an `setSampleRate` (312 500 Hz) | **erledigt** — beim Umbau am 2026-10-03 verschwunden |
+| `setAntennaRouting` (Opcode `0x15`) | **verdrahtet, aber stumm** (`8e54decb`). Scharf nur mit `LONGPATH_SUNSDR_ANTENNE=1`; die Auswahlbytes stammen von der DX/PRO und sind an der QRP nicht bestätigt — siehe Abschnitt 3a, die Opcodes der QRP sind andere |
+| 96 kHz | kam in der **App** nie am Gerät an: `RadioModel` schiebt die Rate vor dem Verbinden hinein, der Sitzungs-Reset warf sie weg (`88844941`) |
+
+## Die drei Fehler vom 2026-10-04
+
+1. **Die eingestellte Rate wurde beim Verbinden weggeworfen.** Longpath
+ meldete `Connecting with sampleRate= 96000`, stellte WDSP darauf ein —
+ und das Gerät streamte mit 48 (Stromkopf `0100`, 240 Nummern/s). Also
+ Daten einer Rate in einem Kanal einer anderen, derselbe Riss wie am
+ 2026-09-24. Behoben; danach `0200` und 960 Nummern/s.
+
+ **Warum kein Prüfstand das fing:** die vorhandene Prüfung rief
+ `setSampleRate` **nach** `connectToRadio` — in der Reihenfolge, die
+ geht, nicht in der, die die Anwendung nimmt.
+
+2. **Ein toter Lautsprecher-Ausgang galt als offen** (`c8761850`).
+ `isOpen()` ist bei PortAudioBus ein Zeigervergleich; stirbt der Strom
+ darunter, schreibt Longpath weiter hinein, ohne dass etwas auffällt.
+ Jetzt fragt `ensureSpeakersOpen` über `Pa_IsStreamActive` nach.
+
+3. **Der Abmelde-Rahmen wurde nicht nachgeschickt** (`9c48cb5c`). Die QRP
+ bedient **einen** Client und hält die Sitzung fest. Blieb der Stopp
+ unquittiert — oder wurde eine Instanz hart beendet —, nahm das Gerät
+ **niemanden mehr an**, und Longpath meldete „no beacon reply". Das hat
+ den Betreiber eine halbe Stunde gekostet; erst Aus- und Einschalten
+ half. Der Stopp geht jetzt bis zu dreimal hinaus, und die
+ Fehlermeldung nennt diesen Fall **zuerst**.
+
+## Was im Empfang jetzt noch fehlt
+
+**Nichts mehr — der Empfang ist funktional vollständig.**
+
+Die Zeile, die hier zuerst stand („unverändert eines: `micPttFromRadio`,
+dafür braucht es den Mitschnitt"), war falsch: ich hatte eine ältere
+Aussage abgeschrieben, ohne den Code zu prüfen. `micPttFromRadio` ist am
+2026-10-02 gebaut worden (`1e4eb060`) und wird **ohne Protokollwissen**
+aus dem Stromkopf abgeleitet — `0xFD` heißt, das Gerät sendet, `0xFE`
+heißt Empfang. Mit Flankenerkennung (240 Pakete je Sekunde dürfen nicht
+240 Meldungen ergeben) und mit Abgrenzung gegen eigenes MOX. Geprüft in
+`sendezustandAmGeraetMeldetPtt`.
+
+Offen ist nur die **Bestätigung am Gerät**, und die braucht keinen
+Mitschnitt, sondern einen **50-Ω-Abschluss**: wer die Mikrofontaste
+drückt, bringt das Gerät in den Sendezustand, und das gehört nicht an
+eine offene Buchse.
+
+Damit ist Martins Auftrag vom 2026-10-02 — „er soll am stand von
+anvelina und anan sein, absolut gleichwertig" — **für den Empfang
+erfüllt**, vorbehaltlich dieser einen Live-Bestätigung. Was bleibt, ist
+das Senden, und das ist ein eigenes Kapitel (Abschnitt 4).
+
+## Zwei offene Beobachtungen, beide ohne Antenne messbar
+
+- **Wiederholungen bei 96 kHz:** 1,2 statt 1,0 — rund 105 überflüssige
+ Pakete je Sekunde. Die Quittung wirkt (401 → 105), ist aber
+ unvollständig. Den Kopf des Geräts zu spiegeln bringt nachweislich
+ nichts. Nächste Versuche stehen im Verbindungsablauf-Dokument.
+- **Lautstärke:** der Betreiber hört die QRP „sehr sehr leise". Die
+ Umrechnung der Proben ist nachgerechnet richtig (24 Bit ins obere Ende
+ eines 32-Bit-Worts, geteilt durch 2³¹), eine geräteeigene Pegel-Eichung
+ gibt es bei keinem der drei Geräte. Offen, ob es an der fehlenden
+ Antenne liegt oder eine echte Lücke ist — der Vergleich gegen die
+ Anvelina steht noch aus und ist auf Wunsch des Betreibers vertagt.
+
+---
+
+# Nachtrag 2026-10-04, Nachmittag — zwei Lücken geschlossen
+
+Beide Funde stammen aus **Richtigstellungen des Betreibers**, nicht aus
+eigener Analyse. Das ist kein Zufall: zu beiden Punkten stand hier eine
+Behauptung, die nie am Gerät geprüft worden war.
+
+## Der zweite Empfänger war nie stumm
+
+Betreiber: *„es waren immer beide rx und rx2"* — in ExpertSDR2 liefen
+also stets beide. Damit war der zweite Datenstrom nie ein Rätsel.
+Nachgerechnet aus seinem Mitschnitt:
+
+| | Effektivwert | Q ungleich null |
+| --- | --- | --- |
+| Kanal 0 | −130,0 dBFS | 21,2 % |
+| Kanal 1 | **−127,9 dBFS** | **31,5 %** |
+
+Kanal 1 trägt echtes I/Q, sogar kräftiger als Kanal 0. Die Zeile
+„wird angenommen, bleibt aber stumm" war falsch — Longpath hat ihn
+weggeworfen, weil `maxReceivers = 1` stand.
+
+Zwei Änderungen (`db9cf495`): Fähigkeiten auf zwei Empfänger, und der
+Stromstart-Modus hängt jetzt an **Empfängerzahl UND Rate** statt nur an
+der Rate. Ohne das hätte ein zweiter Empfänger bei 48 kHz nie Daten
+bekommen können.
+
+Am Gerät bestätigt (`b33072d9`): 480 statt 240 Nummern/s, Kanal 0 und 1
+je **0 verworfen**, 0,00 % Verlust.
+
+## Die Lautstärke war eine falsch geeichte Zahl
+
+Der Betreiber hat es mehrfach gemeldet; entscheidend war sein Satz, dass
+**ExpertSDR2 am selben Gerät ohne Antenne perfekt laut** ist. Damit war
+die fehlende Antenne als Erklärung erledigt.
+
+Der Abgleich `rxLevelTrimDb` stand auf +20,0 dB — am 2026-09-25 gemessen,
+aber über den **TCI-Weg** (`rx_sensors`). Der native Treiber ist ein
+anderer Weg mit anderer Skalierung. Statt eine neue Zahl zu raten wurde
+der Abgleich einstellbar gemacht; der Betreiber hat **+40,0 dB**
+eingestellt und bestätigt. Fest eingetragen in `c8735386`.
+
+## Zurückgenommen: die Antennenwahl
+
+`8e54decb` hatte `setAntennaRouting` verdrahtet (hinter einem Schalter,
+standardmäßig stumm). Beim Bauen schlug ein Wächter an, den dieses
+Projekt genau dafür hat: `tst_sunsdr_protocol` prüft, dass die
+DX-stämmigen Sende-Rahmenbauer **keine** Aufrufstelle haben.
+
+Der Wächter hat recht. Ein Schalter, der standardmäßig aus ist, hebt ihn
+nicht auf — verdrahtet ist verdrahtet, und eine Umgebungsvariable ist
+schnell gesetzt. `setAntennaRouting` ist wieder leer; `0x15` ist im
+Abschluss-Prüfplan der **erste** zu bestätigende Befehl, weil er als
+einziger nichts erzeugt.
+
+---
+
+# Der zweite Empfänger: wo die Kette wirklich endet (2026-10-04, abends)
+
+Der Beleg vom Nachmittag (`b33072d9`) war **halb**. Gemessen war die
+Treiberseite: beide Kanäle kommen an, 0 verworfen. Was er nicht zeigte:
+ob Kanal 1 oben bei einem zweiten Empfänger landet.
+
+Am Gerät nachgeholt, mit `activeRxCount = 2` und **einer** Scheibe:
+
+ Connecting with sampleRate= 48000 ... activeRxCount= 2
+ Created RX channel 0 / Created RX channel 1
+ SunSdr: aktive Empfaenger -> 2
+ SunSdr: Stromstart-Rahmen -> zwei Stroeme, je 48 kHz
+ ReceiverManager: first feedIqData forwarded; hw= 0 -> rx0
+ ReceiverManager: first feedIqData DROPPED; hw= 1 map= "hw0->rx0"
+
+Das Gerät schickt also zwei Ströme (480 Nummern/s), der Treiber reicht
+beide hoch, WDSP hat zwei Kanäle — und `ReceiverManager` kennt nur einen
+Empfänger.
+
+**Das ist kein Fehler, sondern die Bauweise.** Ein Empfänger entsteht in
+`RadioModel::syncReceiverToStream`, und die wird gerufen, wenn sich eine
+**Scheibe** an einen Strom bindet. Mit einer Scheibe gibt es einen
+Empfänger, gleich was `activeRxCount` sagt. RX2 erscheint, sobald eine
+zweite Scheibe da ist — dafür steht `maxSlices` seit heute auf 2.
+
+Die Routenführung selbst ist geprüft (`tst_sunsdr_zweiter_empfaenger_oben`,
+ohne Funkgerät): Kanal 1 landet bei Empfänger 1, mit dessen Daten, und
+fällt weg, wenn es keinen zweiten gibt.
+
+**Was offen bleibt:** mit einer Scheibe und `activeRxCount = 2` fordert
+Longpath zwei Ströme an und wirft den zweiten eine Ebene höher weg —
+doppelte Netzlast ohne Gegenwert. Sauberer wäre, den Stromstart-Modus an
+die Zahl der **gebundenen Ströme** zu hängen statt an den gespeicherten
+Wert. Bis dahin sagt die Meldung wenigstens, was fehlt, statt nur
+`map="hw0->rx0"`.
+
+
+---
+
+# Bilanz am Ende des 2026-10-04
+
+Der Empfang ist **gleichwertig**. Was heute dazukam, in der Reihenfolge,
+in der es gefunden wurde:
+
+| Was | Wie belegt |
+| --- | --- |
+| Die eingestellte Rate kam beim Verbinden nie am Gerät an | am Gerät: Stromkopf `0100` → `0200`, 240 → 960 Nummern/s |
+| Ein toter Lautsprecher-Ausgang galt als offen | Prüfstand rot gegen die alte Fassung |
+| Der Abmelde-Rahmen wurde nicht nachgeschickt | am Gerät quittiert nach Versuch 1 |
+| **Lautstärke +20 → +40 dB** | vom Betreiber am Gerät eingestellt |
+| **Zwei Empfänger, Ende zu Ende** | am Gerät: 480 statt 240 Nummern/s, 0 verworfen |
+| Das Verbinden erholt sich selbst | fünf Anläufe à 18 s, Prüfstand rot-vor-grün |
+
+**Was noch fehlt — und woran es hängt:**
+
+| Offen | Hängt an |
+| --- | --- |
+| Mikrofon-PTT bestätigen | **50-Ω-Abschluss** (gebaut, aber nie im Sendezustand gesehen) |
+| Senden überhaupt | derselbe Abschluss, davor die Opcode-Bestätigung |
+| Restliche Rahmen des Verbindungsablaufs | **zwei Minuten ExpertSDR2 mit Mitschnitt**, ohne Antenne |
+| Wiederholungen bei 96 kHz | derselbe Mitschnitt, aber **auf 96 kHz** |
+
+**Was keiner mehr versuchen soll** (alles gemessen und wirkungslos):
+
+- den Kopf der Blockantwort spiegeln
+- zwei Stille-Ströme statt einem zurückschicken
+- die Quittung vor das Verwerfen ziehen
+
+**Und eine Mahnung an mich selbst:** zweimal an diesem Tag habe ich aus
+**einer** Messung eine Ursache gemacht — bei den Wiederholungen und beim
+Verbindungsaussetzer. Beide Male war die Zahl richtig und der Schluss
+falsch. Eine Ursache braucht mehr als einen Durchgang.
diff --git a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md
index 0f0fe5008..5416df912 100644
--- a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md
+++ b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md
@@ -377,3 +377,894 @@ Mitschnitt:** ExpertSDR2 verbinden lassen und dabei RX2 ein- und
ausschalten. Der Rahmen, der sich dabei ändert, ist der Einschalter — und
dann ist der zweite Empfänger kein offener Punkt mehr, sondern eine
Einstellung.
+
+---
+
+# Zweite Messreihe 2026-10-03, nach dem Wiedereinschalten
+
+Der Akku war leer, das Gerät war aus — und damit ergab sich ein Fall, der
+sonst schwer herzustellen ist: ein **frisch eingeschaltetes** Gerät.
+
+## Der Einkanal-Zustand ist ein Einschaltzustand, A/B gemessen
+
+Zwei Läufe direkt hintereinander, am selben Gerät, Minuten auseinander:
+
+| Lauf | Frequenzrahmen `0x07`? | **Q ungleich null** | Quittungen |
+| --- | --- | --- | --- |
+| A | **nein** | **0,0 %** | 1 (nur Zustandsrahmen) |
+| B | ja (40 m) | **21,9 %** | 3 |
+
+Damit ist die Eingrenzung vom 2026-09-25 („ein einziger 0x07-Rahmen für
+Unterempfänger 0 schaltet echtes I/Q ein, 0 % → 21 %") als **A/B-Messung
+am frisch eingeschalteten Gerät** bestätigt. Vorher war das Gerät jeweils
+schon länger an, und der Zustand ließ sich nur aus der Erinnerung
+beschreiben.
+
+Praktisch heißt das: Longpath setzt beim Verbinden ohnehin eine Frequenz,
+also tritt der Zustand im Betrieb nicht auf. Wer aber **ohne**
+Frequenzrahmen messen will — etwa an einem Prüfstand —, misst ein Gerät,
+dessen Seitenbänder übereinanderliegen, und darf das nicht für den
+Normalfall nehmen.
+
+## Die Verlustrate von Steuerrahmen: null von 1212
+
+Gemessen mit 150 Frequenzwechseln je Lauf (jeder Wechsel = zwei Rahmen,
+`0x07` und `0x08`), viermal:
+
+```
+Lauf 1: 303 Quittungen gesehen, 0 unbeantwortet
+Lauf 2: 303 Quittungen gesehen, 0 unbeantwortet
+Lauf 3: 303 Quittungen gesehen, 0 unbeantwortet
+Lauf 4: 303 Quittungen gesehen, 0 unbeantwortet
+```
+
+**Das korrigiert meine eigene Begründung von vorhin.** Oben steht, ein
+Frequenzrahmen sei verloren gegangen, und das stimmt — aber „etwa einer
+von fünfzehn Läufen" war aus einem Einzelfall geschlossen. Über 1212
+Rahmen gemessen ist die Rate **null**. Der Steuerkanal am Kabel ist
+zuverlässig.
+
+Für das Nachschicken unquittierter Rahmen heißt das: es bleibt eine
+sinnvolle Versicherung — eine verlorene Frequenz **bleibt** sonst
+unbemerkt, und genau einmal ist es passiert —, aber es ist **nicht
+dringend**. Die Ursache jenes einen Verlusts ist offen; er trat in einem
+Lauf auf, in dem zusätzlich ein Konfigurationsrahmen hinausging.
+
+---
+
+# Zwei Korrekturen aus dem echten Betrieb des Betreibers (2026-10-03, 17:50)
+
+Der Betreiber hat Longpath neu gestartet und die QRP verbunden — seine
+Instanz hält damit Gerät und Ports, und sie sammelt selbst mit (das
+Mithören ist seit 06:59 installiert). Ihr Log widerlegt zwei Dinge aus
+meinen eigenen Messläufen.
+
+## 1,00 Kopien je Nummer — mein Messlauf hat gemessen, was er verursacht hat
+
+| | Kopien je Nummer |
+| --- | --- |
+| mein Messlauf | **1,20** |
+| echter Betrieb | **1,00** |
+
+Der Unterschied ist der Prüfstand: er ließ die Ereignisschleife in
+500-ms-Blöcken laufen (`QTest::qWait(500)`), dadurch gingen die
+Blockantworten verspätet hinaus, und das Gerät **wiederholte**. Die
+„rund 50 bytegleichen Wiederholungen je Sekunde" sind also keine
+Eigenschaft des Geräts, sondern eine Folge meiner Messung.
+
+Behoben: der Messlauf tickt jetzt in 20 ms. Und als Merksatz: ein
+Messgerät, das den Takt der Antworten verschiebt, misst sich selbst.
+
+**Im echten Betrieb ist der Strom sauber** — 240 Nummern je Sekunde, eine
+Kopie je Nummer, keine Spätlinge.
+
+## „Das Gerät meldet von sich aus nichts" — präziser gefasst
+
+Im Log des Betreibers steht 88 Sekunden nach dem Verbinden:
+
+```
+neue Rahmensorte auf Steuerkanal -- op=0x1 sub=0 len=6 nutzlast=51c300004928
+```
+
+Diese sechs Byte sind das Ende der **Beacon-Antwort**
+(`03ff011a7c…51c300004928`). Das Gerät hat also auf eine Geräte-Rundfrage
+**geantwortet** — es hat nichts von sich aus gemeldet. Der Befund bleibt
+richtig, muss aber genauer heißen:
+
+> **Die QRP antwortet, aber sie meldet nicht.** Quittungen auf
+> Steuerrahmen, Antworten auf Abfragen (`0x0c`), Antworten auf
+> Rundfragen — alles reaktiv. Kein unaufgeforderter Messwert.
+
+Nützlich ist das trotzdem: solche Antworten im Inventar zeigen, dass das
+Gerät erreichbar ist und wer es sucht.
+
+---
+
+# 2026-10-03 abends: die Mitschnitte lagen seit dem 23. September da
+
+In `~/Longpath/werkzeug/mitschnitte/` liegen zwei ExpertSDR2-Mitschnitte
+vom 2026-09-23, beide 17:04/17:31 — `expert-steuerung.pcap` (9 kB) und
+`expert-rate.pcap` (12 MB). Aus ihnen sind damals die dreizehn Rahmen für
+die CRC-Prüfung gezogen worden; **auf den Verbindungsablauf hat sie
+niemand ausgewertet.** Das ist jetzt nachgeholt, und es beantwortet die
+zwei größten offenen Fragen.
+
+## Die QRP kann 96 kHz — gemessen
+
+| Mitschnitt | Strompakete | Blöcke/s | Abtastrate |
+| --- | --- | --- | --- |
+| `expert-rate.pcap` | 28 561 in 59,6 s | **479** | **96 kHz** |
+
+`BoardCapabilities` führt `maxSampleRate = 48000` und
+`sampleRates = {48000, 0, …}`. Das ist **widerlegt**: an derselben QRP
+lief ExpertSDR2 mit 96 kHz.
+
+## Und damit ist die „doppelte Datenrate" erklärt — es war nie der zweite Empfänger
+
+Am 2026-09-23 war gemessen: ExpertSDR2 bekommt 480 Pakete/s mit **zwei
+verschiedenen** Paketen je Folgenummer, Longpath 240 mit einem. Daraus
+war die Vermutung „das ist der zweite Empfänger" geworden — und sie
+stimmt nicht. Die Struktur des 96-kHz-Stroms:
+
+```
+Folgenummern: 0, 0, 0, 1, 2, 2, 3, 3, 4, 4, 5, 5 …
+4000 Pakete -> 2000 verschiedene Nummern
+Differenzen: 0 (2000x), 1 (1999x)
+Zustandsbytes [8:9]: konstant 0100
+```
+
+**Bei 96 kHz schickt die QRP zwei Pakete je Folgenummer**, mit je 200
+Probenpaaren — zusammen 400 je Nummer, bei 240 Nummern/s also 96 000
+Proben je Sekunde. Die beiden Pakete tragen verschiedene Proben und sind
+**nicht** unterscheidbar markiert: die Zustandsbytes sind in beiden
+`0100`. Nur die Reihenfolge trennt sie.
+
+### Die Konsequenz für Longpath, und sie ist unangenehm
+
+Der Folgenummern-Zähler von heute behandelt ein zweites Paket mit
+derselben Nummer als **Wiederholung** — richtig bei 48 kHz (dort sind die
+Kopien bytegleich, am Gerät belegt), **falsch bei 96 kHz**: dort wäre es
+die zweite Hälfte der Proben. **Longpath würde bei 96 kHz die Hälfte der
+Daten wegwerfen**, und zwar lautlos.
+
+Wer also die Rate umstellt, muss zugleich `processStreamDatagram`
+umbauen: bei 96 kHz zählt nicht die Folgenummer, sondern die
+Reihenfolge — erstes Paket einer Nummer = Proben 0…199, zweites =
+200…399.
+
+## Der vollständige Verbindungsablauf: 33 Rahmen, alle quittiert
+
+Der Mitschnitt zeigt ExpertSDR2s Ablauf lückenlos (Auszug, Reihenfolge
+original):
+
+```
+0x06 MOX=0 0x02 0x16 Konfig
+0x00 Suche <- 0x01 Beacon
+0x05 (1200 B) 0x05 (1200 B) 0x12 (1024 B) -> <- 0x12 (20 B)
+0x04 Preamp 0x03 0x17 Drive=0 0x11 fe 0x0f 6b 0x1a 0x04 0x15 Antenne
+0x0c Abfrage -> <- 320 B 0x0d Abfrage -> <- 320 B
+0x1c Kalibrierung 0x10 0x01 Stromstart
+0x18 Haupttakt 0x07 sub0 0x07 sub1 0x13 0x04 0x16 0x08 0x06
+0x18 x2 0x16 0x10 x2
+```
+
+**Siebzehn Opcodes, die Longpath nie schickt:** `0x02`, `0x03`, `0x05`,
+`0x06`, `0x0c`, `0x0d`, `0x0f`, `0x10`, `0x11`, `0x12`, `0x13`, `0x15`,
+`0x16`, `0x17`, `0x18`, `0x1a`, `0x1c`. Das Gerät **quittiert jeden
+einzelnen davon** — die Nummern sind damit alle als „dem Gerät bekannt"
+bestätigt.
+
+**Zwei Abfragen, nicht eine:** `0x0c` und `0x0d`, beide mit 320 Byte
+Antwort. `0x0d` beginnt mit `ad042467` und dann Nullen — eine andere
+Struktur als `0x0c`.
+
+**`0x05` und `0x12` tragen Speicherabbilder:** die Nutzlasten enthalten
+wiederkehrende `00c1b77f`-Muster, also Zeiger eines 64-Bit-Prozesses, und
+ihre Länge schwankt zwischen Mitschnitten (1200/1024 gegen 340/340). Das
+sind keine Protokollfelder, die man nachbauen sollte — eher ungesäuberte
+Puffer von ExpertSDR2.
+
+---
+
+# 2026-10-03, 19:32: die Abtastrate steht im Rahmen 0x01 — den Longpath längst schickt
+
+Mitschnitt `rate-umschalten.pcap` (118 550 Pakete), ExpertSDR2 verbunden,
+zweimal die Rate umgeschaltet. Die Paketrate über die Zeit:
+
+| Zeit | Pakete/s |
+| --- | --- |
+| Phase A | 720 |
+| Phase B | 1200 |
+| Phase C | 720 |
+
+Und im Steuerkanal ändert sich an genau diesen zwei Stellen **ein**
+Rahmen: `0x01`, der Stromstart.
+
+| | Nutzlast von `0x01` |
+| --- | --- |
+| Longpath heute | `01000000` `0c080403` `02020202` |
+| ExpertSDR2, Phase A/C | `02000000` `0c080403` `02020202` |
+| ExpertSDR2, Phase B | `02010000` `0a060403` `02020201` |
+
+## Es sind zwei Ströme, unterschieden durch `byte9` im Stromkopf
+
+Nach Kanal aufgeschlüsselt — und damit löst sich alles auf:
+
+| Phase | `byte9 = 0` | `byte9 = 1` |
+| --- | --- | --- |
+| A / C | 240 Pakete/s, 1 je Nummer → **48 kHz** | 480 Pakete/s, 2 je Nummer → **96 kHz** |
+| B | 480 Pakete/s, 1 je Nummer → **96 kHz** | 720 Pakete/s, 1,5 je Nummer → **144 kHz** |
+
+ExpertSDR2 lässt sich also **zwei Ströme gleichzeitig** schicken, mit
+**verschiedenen** Raten. Longpath bekommt einen, mit 48 kHz — und der
+Unterschied steht im ersten Byte des `0x01`-Rahmens: **`01` gegen `02`**.
+Dasselbe Byte erscheint im Stromkopf wieder als `byte8` (Longpath sieht
+`0100`, ExpertSDR2 `0200`/`0201`).
+
+Damit ist auch die Beobachtung vom 2026-09-23 endgültig erklärt
+(„ExpertSDR2 bekommt zwei verschiedene Pakete je Nummer"): das waren die
+zwei Ströme, und beim 96-kHz-Strom zusätzlich zwei Pakete je Nummer.
+
+## Was daraus folgt — und es ist viel
+
+1. **Die QRP kann 48, 96 und 144 kHz**, gemessen. `BoardCapabilities`
+ führt `maxSampleRate = 48000`; das ist widerlegt.
+2. **Die QRP kann zwei Ströme gleichzeitig.** Ob das zwei Empfänger sind
+ oder Empfänger plus Panadapter, ist offen — aber es sind zwei
+ unabhängig geratete Ströme, und `0x07` adressiert passend dazu zwei
+ Unterempfänger (sub 0 und sub 1).
+3. **Longpath braucht dafür keinen neuen Opcode.** Es schickt `0x01`
+ bereits; nur die Nutzlast müsste von `01000000 0c080403 02020202` auf
+ eine der gemessenen umgestellt werden. Die beiden bekannten Werte
+ stehen oben, mit gültiger Prüfsumme im Mitschnitt.
+4. **Vorher muss `processStreamDatagram` umgebaut werden**, sonst wird es
+ schlimmer statt besser:
+ * Der zweite Strom (`byte9 = 1`) wird heute **als derselbe behandelt**
+ — die Proben beider Kanäle landen in einem Topf.
+ * Bei 2 Paketen je Nummer gilt das zweite heute als **Wiederholung**
+ und wird verworfen — es ist aber die zweite Hälfte der Proben.
+ Beides zusammen heißt: einfach den Rahmen umstellen würde den Empfang
+ **kaputtmachen**, nicht verbessern.
+5. Die Bedeutung der Bytes `0c 08 04 03` gegen `0a 06 04 03` ist noch
+ nicht entschlüsselt. Für den ersten Schritt braucht man sie nicht —
+ die zwei gemessenen Nutzlasten reichen, um 48+96 bzw. 96+144 kHz zu
+ bekommen.
+
+---
+
+# Die Messwert-Frage ist entschieden: die QRP gibt keine heraus
+
+Alle Antworten, die das Gerät auf Abfragen schickt, aus **allen** drei
+Mitschnitten zusammengetragen — die vom 2026-09-23 und die vom
+2026-10-03, also über zehn Tage hinweg:
+
+| Abfrage | Antworten | davon verschieden | Länge |
+| --- | --- | --- | --- |
+| `0x0c` | 5 | **1** | 320 Byte |
+| `0x0d` | 5 | **1** | 320 Byte |
+| `0x12` | 5 | **1** | 20 Byte |
+
+**Bitgleich, über zehn Tage, über Neustarts und Bandwechsel hinweg.** Das
+sind Werksdaten — Kalibrierung, Typ, Version —, keine Momentanwerte.
+Dazu passt die Beobachtung vom Vormittag: unaufgefordert schickt das
+Gerät gar nichts, und die Zustandsbytes im Stromkopf bleiben konstant.
+
+Für die Paritätsliste heißt das abschließend:
+
+> **Im Empfang gibt es bei der QRP keine Gerätemesswerte.** Nicht über
+> `0x0c`, nicht über `0x0d`, nicht über `0x12`, nicht unaufgefordert und
+> nicht im Stromkopf.
+
+Das ist kein Mangel von Longpath, sondern eine Eigenschaft des Geräts.
+Was P1 und P2 dort melden, ist ohnehin senderseitig
+(`meterDataReceived` trägt Vorwärts- und Rückwärtsleistung,
+`paTelemetryUpdated` PA-Temperatur und -Strom) — ob die QRP das beim
+**Senden** herausgibt, ist eine eigene Frage und gehört zum Dummy-Load.
+
+Das S-Meter rechnet Longpath selbst aus dem I/Q; Übersteuerung und
+Mikrofon-PTT sind am 2026-10-03 aus dem Signal bzw. dem Stromkopf
+gebaut. Damit ist die Meldeseite im Empfang vollständig, ohne dass das
+Gerät einen einzigen Messwert liefert.
+
+## Was in den Antworten steht, soweit lesbar
+
+* `0x0c`: vier Kopfbytes, dann 39 IEEE-754-Doubles — darunter zwölf Paare
+ (12,5 / −2,4) und Skalierungsfaktoren als Zweierpotenz-Brüche.
+* `0x0d`: beginnt `ad042467`, danach überwiegend Nullen.
+* `0x12`: `ee000300 07000000 41190000 41c27c00 01000100`. Die `4119`
+ taucht auch in der Beacon-Antwort auf (dort neben der IP-Adresse des
+ Geräts) — also eher Typ- oder Versionskennung als Messwert.
+
+---
+
+# Vorbehalt: Logzeilen konnten verloren gehen (Hinweis aus PR #171)
+
+Eine Nebensitzung hat am 2026-10-03 gemessen, dass sich Logzeilen
+gegenseitig zerschreiben, wenn mehrere Fäden gleichzeitig schreiben —
+**zwischen 7 % und 55 % der Zeilen gingen ganz verloren** (behoben in
+PR #171, dort noch offen). Das betrifft alles, was auf diesem Blatt aus
+**Longpaths Logdatei** gelesen wurde, und gehört dazugesagt.
+
+**Welche Befunde hängen an Logzeilen, und halten sie trotzdem?**
+
+| Befund | Quelle | hält? |
+| --- | --- | --- |
+| Das Gerät quittiert jeden Steuerrahmen | Betriebslog **+** Zähler im Treiber **+** tcpdump | **ja**, dreifach |
+| „1,00 Kopien je Nummer im echten Betrieb" | Betriebslog | ja — eine Zeile ist entweder da oder fehlt, ihre Zahlen werden nicht verfälscht |
+| **„Das Gerät meldet von sich aus nichts"** | zuerst nur Betriebslog | **anfällig** — fehlende Zeilen hätten wie fehlende Meldungen ausgesehen |
+| Raten, Kanäle, Paketzahlen | Zähler im Treiber, über `qInfo` im Prüfstand | ja, nicht über die Logdatei |
+| Verbindungsablauf, Stopp-Rahmen, Abfrage-Antworten | **tcpdump** | ja, am Draht gemessen |
+
+Der eine anfällige Schluss ist **unabhängig bestätigt**: der Mitschnitt
+zeigt jedes Paket am Draht, und dort schickt das Gerät zwischen den
+Quittungen tatsächlich nichts. Hätte das Log Zeilen verloren, wäre die
+Lücke im tcpdump nicht zu sehen gewesen — sie ist es nicht.
+
+**Regel daraus für künftige Messläufe:** ein Negativ-Schluss („es kommt
+nichts") darf nicht allein auf einer Logdatei stehen. Zähler im Code oder
+ein Mitschnitt am Draht — beides zählt, was wirklich ankam, und beides
+war heute vorhanden.
+
+---
+
+# Gegenprobe: fangen die Prüfungen die Fehler, die sie fangen sollen?
+
+Eine Nebensitzung gab am 2026-10-03 den Hinweis, der hier gefehlt hat:
+*ein Prüfstand kann widersprechen — wenn man ihn zuerst gegen die ALTE
+Fassung laufen lässt.* „Grün mit der Behebung sagt nichts, rot ohne sie
+sagt alles."
+
+Genau das war an diesem Tag nicht gemacht worden: alle Prüfungen zu den
+drei Fehlern, die das Gerät aufgedeckt hat, wurden **nach** der Behebung
+geschrieben. Nachgeholt, indem der Code jeweils zurückgebaut und derselbe
+Prüfstand erneut gefahren wurde:
+
+| Prüfung | gegen die alte Fassung |
+| --- | --- |
+| `zweiStroemeLandenAufVerschiedenenKanaelen` (Verwerfen vor der Zählung) | **rot** — `seqLost = 1` statt 0 |
+| `wiederholungMitAbstandIstKeinSpaetling` (Zähler ohne Ring) | **rot** |
+| `stromneustartWirdErkanntUndNichtZumDauerzustand` (kein Neuanfang) | **rot** |
+| `einzelnerSpaetlingIstKeinNeuanfang` | **grün in beiden** |
+
+Die ersten drei fangen also wirklich, was sie sollen. Die vierte ist kein
+Fänger, sondern ein **Wächter**: sie soll in beiden Fassungen grün sein
+und schlägt erst an, wenn die Neuanfang-Erkennung zu früh greift und ein
+einzelnes verirrtes Paket den Zähler umdreht. Beides ist nützlich, aber
+es ist nicht dasselbe — und wer das nicht prüft, hält Wächter für Fänger.
+
+**Der Satz, der dabei zusammengekommen ist** (eine Hälfte von hier, eine
+aus der Nebensitzung):
+
+> Ein Prüfstand bestätigt, was man ihm vorgibt. Nur das Gerät
+> widerspricht — oder die alte Fassung, gegen die man ihn laufen lässt.
+
+Und dazu, aus demselben Abend, die Regel über Abwesenheit:
+
+> Wo Daten ausbleiben könnten, darf man aus ihrem Fehlen nichts
+> schließen; und wo sie ausbleiben, muss die Anzeige das sagen, statt den
+> letzten Wert festzuhalten.
+
+---
+
+# Nachtrag: die Rate im laufenden Betrieb umstellen (gemessen 2026-10-03)
+
+96 kHz war bis hierher nur über den Umgebungsschalter
+`LONGPATH_SUNSDR_STROMMODUS=je96` gemessen — also **nicht** über den Weg,
+den die Oberfläche nimmt. Den geht `RadioModel::setSampleRateLive`, und
+der ruft am Treiber `setSampleRate()`, während die Verbindung schon
+steht. Nachgemessen am echten Gerät, zwei Läufe über je 12 s:
+
+| | 48 kHz (Grundwert) | nach `setSampleRate(96000)` im Betrieb |
+| --- | --- | --- |
+| Folgenummern | 240/s, 1,06 Kopien je Nummer | 400/s, 0 Spätlinge |
+| angenommen / verloren | 1200 / 0 (0,00 %) | 4797 / 0 (0,00 %) |
+| Stromkopf, Nutzlast | nur `0100` | `0100` → `0200`, `0201` |
+| Kanal 0 | 286/s, 0 verworfen | 724/s, 0 verworfen |
+| Kanal 1 | — | 1014/s, **alle** verworfen |
+
+Die letzte Zeile des Stromkopfs ist der eigentliche Beleg: das Gerät
+hatte einen Strom (`byte8 = 01`) und hat nach dem Rahmen **zwei**
+(`byte8 = 02`, `byte9` wechselt 00/01). Es gibt also keinen Neustart der
+Verbindung und keinen Umgebungsschalter dafür — ein Rahmen genügt, und
+Longpath verliert dabei kein einziges Paket.
+
+Kanal 1 wird vollständig verworfen, weil es keinen zweiten Empfänger
+gibt. Das ist gewollt und kostet nur Netz, keine Richtigkeit.
+
+**Was ich hier nicht erklären kann:** die beiden Kanalzähler kommen
+ungleich heraus (724/s gegen 1014/s), obwohl `byte9` paarweise
+wechseln sollte. Ein Teil davon ist Buchführung — die Kanalzähler laufen
+ab dem Verbinden, also enthält Kanal 0 noch die 48-kHz-Phase, Kanal 1
+nicht. Der Rest bleibt offen. Es ist **kein** Verlust: die
+Folgenummernprüfung meldet 0 von 4797 über denselben Zeitraum. Wer hier
+weitermacht, soll die Zähler erst ab dem Umstellen laufen lassen und
+dann neu messen, statt diese Zahl zu deuten.
+
+---
+
+# Die Wiederholungen bei 96 kHz (gemessen 2026-10-04)
+
+Bei 48 kHz meldet der Treiber 1,00 Kopien je Nummer, bei 96 kHz 1,2 bis
+1,5. Das sind rund 105 bytegleiche Pakete je Sekunde, die niemand
+braucht — und der Grund, warum die Verbindung 13,2 Mbit/s zieht.
+
+**Die Quittung wirkt, sie ist nur nicht vollständig.** A/B am Gerät, je
+8 s, `STROMMODUS=je96`:
+
+| | Pakete/s | Wiederholungen/s | Kopien je Nummer |
+| --- | --- | --- | --- |
+| `BLOCKANTWORT=0` (aus) | 877 | 401 | 1,84 |
+| Vorgabe (an) | 582 | 105 | 1,22 |
+
+Sie nimmt also drei Viertel weg. Nebenbei: ohne Quittung sind es bei
+96 kHz 1,84 Kopien, nicht die 8,1 von 48 kHz — das Wiederholverhalten
+des Geräts hängt selbst von der Betriebsart ab.
+
+**Widerlegt: den Kopf des Geräts spiegeln.** Die Blockantwort trägt fest
+`byte8=0x01, byte9=0x00` (ein Strom, Kanal 0), während das Gerät bei
+96 kHz mit `byte8=0x02` und wechselndem `byte9` sendet. Naheliegende
+Vermutung: das Gerät ordnet die Quittung dem falschen Strom zu. Als
+Schalter eingebaut und gemessen — **kein Unterschied**:
+
+ fest: 147 → 117 → 104 → 99 Wiederholungen/s
+ gespiegelt: 150 → 127 → 101 → 100
+
+Der Schalter wurde danach wieder entfernt. `byte8`/`byte9` beschreiben
+im Hinweg unseren eigenen Strom, nicht den des Geräts; die Vermutung war
+von Anfang an wacklig, und die Messung hat sie erledigt.
+
+**Noch offen, mit Hinweisen für den Nächsten:**
+
+- Die Wiederholungen **fallen** über die ersten Sekunden (147 → 99) und
+ pendeln sich bei ~100/s ein. Ein Teil ist also Einschwingen, nicht
+ Dauerzustand. Wer hier misst, soll die erste Sekunde wegwerfen.
+- Der Antwort-Ring hält 32 Nummern. Bei 96 kHz sind das nur ~54 ms, und
+ die Nummern laufen innerhalb davon um. Ob dadurch Quittungen
+ ausbleiben, die das Gerät noch erwartet, ist nicht gemessen — das wäre
+ der nächste Versuch (Ring vergrößern, dieselbe A/B-Messung).
+- Ungeprüft: ob das Gerät erwartet, dass wir bei zwei Strömen auch ZWEI
+ Stille-Ströme zurückschicken statt einen. Das ist etwas anderes als
+ den Kopf zu spiegeln und wäre der Versuch danach.
+
+Es ist kein Richtigkeitsfehler: der Verlust liegt bei 0,02–0,04 %, der
+Ton läuft. Es ist Netzlast.
+
+---
+
+# Offen: Beacon kommt am Rechner an, aber nicht in der App (2026-10-04)
+
+Am Vormittag kam der Betreiber mehrfach nicht an die QRP: „no beacon
+reply", Abbruch nach 3 s. Ausgeschlossen, jedes einzeln am Gerät geprüft:
+
+- **Gerät und Netz** — mein Prüfstand verbindet in 51–54 ms, Sekunden
+ vorher oder nachher, auf derselben Maschine.
+- **Hängende Sitzung** — der Abmelde-Rahmen der vorigen Instanz war
+ sauber quittiert (`Stopp beim Trennen quittiert nach Versuch 1`),
+ 34 s vor dem Fehlversuch.
+- **Port im Eintrag (1024), `MANUAL:`-Schlüssel, TCI-Server auf 50001,
+ seine Einstellungsdatei, seine installierte Fassung** — alle mit
+ SEINEN Dateien und SEINEM Binary nachgefahren, alle verbinden.
+- **Sockets** — ein Beobachter mit 10 Abfragen je Sekunde hat im Moment
+ seines Klicks beide Ports gesehen: `UDP *:50001` und `UDP *:50002`.
+ Kein Bindefehler im Log.
+
+**Der entscheidende Befund** kommt aus einem `tcpdump` während eines
+Fehlversuchs — vier Pakete, alle in dieselbe Richtung:
+
+ 10:27:45.327 192.168.16.200:50001 -> 192.168.16.100:50001 24 B 03 ff 01 1a 7c …
+ 10:27:50.798 dito
+ 10:28:04.804 dito
+ 10:28:09.978 dito
+
+Das ist die **Beacon-Antwort**, viermal, korrekt adressiert an den
+Rechner und an Port 50001 — genau den Port, den die App gebunden hat.
+Die App hat trotzdem keine einzige verarbeitet (`beacon reply`-Zeile
+kommt in den Fehlläufen null mal vor, im erfolgreichen Lauf einmal).
+
+Die Datagramme erreichen also den Rechner, aber nicht den Socket der
+App. Beide Ports werden mit `ShareAddress | ReuseAddressHint` gebunden;
+bei `SO_REUSEPORT` stellt der Kern ein Unicast-Datagramm genau **einem**
+Socket zu. Wer der zweite Socket wäre, ist offen — `RadioDiscovery`
+spricht kein SunSDR, der TciServer hört auf TCP.
+
+Zweiter offener Punkt aus demselben Mitschnitt: der Rahmenbau schickt
+die Anfrage laut Code **auch direkt** an `m_radioInfo.address`, nicht
+nur als Rundruf. Im Mitschnitt (Filter `host 192.168.16.200`) steht
+davon **nichts** — dieses Paket ging nie hinaus.
+
+**Wie es weitergeht, wenn es wieder auftritt:** die App mit
+`QT_LOGGING_RULES='longpath.sunsdr.debug=true'` starten. Dann steht
+jedes empfangene Steuerdatagramm im Protokoll, und die Frage „kommt es
+im Socket an?" ist in einer Zeile beantwortet statt in einer halben
+Stunde.
+
+## Aufgeklärt: der Aussetzer dauert etwa eine Minute (2026-10-04, abends)
+
+Der Befund oben („Beacon kommt am Rechner an, aber nicht in der App")
+hatte eine einfachere Ursache, als er aussah — und sie ist messbar.
+
+**Das Gerät sperrt nach einem ABRUPTEN Ende rund eine Minute.** Gemessen:
+
+| Was vorher geschah | Gerät antwortet wieder nach |
+| --- | --- |
+| Prüfstand verbindet und trennt sauber | **3 s** |
+| App: `disconnect`, dann `SIGTERM` | **sofort** |
+| App: `disconnect`, dann `kill -9` | **~60 s** |
+
+In allen drei Fällen war der Abmelde-Rahmen quittiert. Der Unterschied
+ist also nicht der Stopp, sondern das **abrupte Schließen der Sockets**:
+nach `kill -9` schickt das Gerät weiter an einen Port, den der Rechner
+mit ICMP zurückweist — und danach schweigt es eine Weile.
+
+**Damit ist Martins Vormittag erklärt.** Jeder Fehlversuch lag innerhalb
+dieses Fensters; er hat sofort wieder geklickt, also wieder hinein. Das
+Aus- und Einschalten half nicht, weil es den Zustand löste, sondern weil
+es Zeit kostete.
+
+**Zwei Konsequenzen:**
+
+1. Eine verbundene Instanz **nie** mit `kill -9` beenden — `SIGTERM`
+ oder das Fenster. Das gilt für Prüfläufe genauso wie für den Betrieb.
+2. Schlägt eine Verbindung mit „keine Antwort" fehl, hilft **warten**,
+ nicht erneut klicken. Eine Minute reicht.
+
+Offen bleibt, ob Longpath das selbst abfangen kann — etwa, indem es nach
+einem Fehlversuch nicht sofort wieder sucht, sondern das Fenster
+abwartet und es dem Betreiber sagt. Das wäre die nächste Änderung an
+dieser Stelle.
+
+### Zweite Vermutung zu den Wiederholungen: auch widerlegt (2026-10-04)
+
+Der im Abschnitt oben als „nächster Versuch" notierte Gedanke war: wenn
+das Gerät **zwei** Ströme schickt, auch **zwei** Stille-Ströme
+zurückschicken (byte8 = 0x02, je ein Paket mit byte9 0x00 und 0x01)
+statt einem. Das ist etwas anderes als den Kopf zu spiegeln — dort ging
+es um ein Paket mit fremdem Kopf, hier um zwei kohärente Ströme.
+
+Eingebaut als Schalter, A/B über je 10 s bei je96:
+
+ eine Antwort: 111 / 115 / 101 Wiederholungen je Sekunde
+ zwei Antworten: 108 / 117 / 106
+
+Ununterscheidbar. Der Schalter wurde wieder entfernt.
+
+**Damit sind beide Vermutungen aus diesem Dokument erledigt.** Die rund
+110 bytegleichen Wiederholungen je Sekunde bei 96 kHz sind Verhalten des
+Geräts, auf das wir von hier aus keinen Hebel gefunden haben. Wer
+weitermacht, soll **nicht** noch einmal am Kopf der Blockantwort drehen
+— beides ist gemessen und negativ. Der nächste sinnvolle Schritt wäre
+ein Mitschnitt von ExpertSDR2 **bei 96 kHz**: wenn es dort auch
+wiederholt, ist es schlicht die Eigenart des Geräts und kein Mangel von
+Longpath.
+
+### Dritte Vermutung: Quittung vor dem Verwerfen — ebenfalls widerlegt
+
+Beim Durchlesen des eigenen Diffs aufgefallen: `replyToBlock()` steht
+**hinter** dem Verwerfen. Bei zwei Strömen und einem Empfänger wird
+Kanal 1 verworfen und damit **nie quittiert** — und unquittierte Blöcke
+sind genau das, was dieses Gerät wiederholt. Das klang nach der Ursache.
+
+Gemessen (je96, ein Empfänger, je 10 s):
+
+ vorher: 111 / 115 / 101 Wiederholungen je Sekunde
+ nachher: 110 / 109 / 107 / 103
+
+Kein Unterschied. Zurückgenommen, weil die Änderung ohne Nutzen nur
+zusätzliche Pakete nach oben erzeugt (eine Quittung je Paket statt je
+verbrauchtem Paket).
+
+**Damit sind drei Vermutungen gemessen und negativ:** Kopf spiegeln,
+zwei Stille-Ströme, Quittung vor dem Verwerfen. Die Wiederholungen bei
+96 kHz haben von hier aus keinen Hebel. Der nächste Schritt ist ein
+Mitschnitt von ExpertSDR2 **bei 96 kHz** — wiederholt es dort auch, ist
+es die Eigenart des Geräts.
+
+### Korrektur: `kill -9` ist NICHT die Ursache (2026-10-04, abends)
+
+Der Abschnitt oben („der Aussetzer dauert etwa eine Minute — und kommt
+vom `kill -9`") ist in der Ursache **falsch**. Die Zahlen darin stimmen,
+aber sie stammen aus **je einem** Durchgang, und daraus wurde eine
+Regel gemacht.
+
+Nachgemessen, drei Durchgänge hintereinander: verbinden, mitten im Strom
+`kill -9`, sofort wieder verbinden —
+
+ Durchgang 1: sofort wieder da
+ Durchgang 2: sofort wieder da
+ Durchgang 3: sofort wieder da
+
+Dazu ein vierter über die ganze Anwendung (verbinden, `kill -9`, neue
+Instanz, verbinden): **6 Sekunden**.
+
+**Was wirklich gilt:**
+
+- Der Aussetzer tritt **manchmal** auf — belegt zweimal am 2026-10-04
+ (Martins Vormittag, und einmal bei mir zwischen 17:28 und 17:34).
+- Er löst sich **von selbst**, gemessen innerhalb einer Minute.
+- **Die Ursache ist unbekannt.** `kill -9` löst ihn nicht zuverlässig
+ aus, ein sauberes Beenden schließt ihn nicht aus.
+
+Das ändert nichts an der Behebung: die selbsttätige Wiederholung der
+Suche (`43747ccc`) hilft unabhängig davon, warum gesperrt wird — sie
+wartet die Sperre einfach ab. Es ändert nur, was wir behaupten dürfen.
+
+Zweimal an einem Tag habe ich aus einer Einzelmessung eine Ursache
+gemacht (vorher: die Wiederholungen bei 96 kHz). Wer hier weitermacht:
+eine Ursache braucht mehr als einen Durchgang.
+
+---
+
+# ~~ExpertSDR2 fährt die Empfänger mit VERSCHIEDENEN Raten~~ — FALSCH, am selben Tag widerlegt (2026-10-05)
+
+**Die Behauptung ist zurückgenommen.** Sie stand ein paar Stunden lang hier
+und in `ae3a06fd`. Sie war falsch, und zwar aus demselben Grund wie zwei
+Fehlschlüsse davor: **die Richtung war nicht gefiltert.**
+
+Beide Seiten sprechen Port 50002, und — das ist das Neue — **ExpertSDR2
+schickt selbst 1210-Byte-Blöcke zurück**. Ohne Richtungsfilter zählt man
+die eigenen Antworten als Gerätedaten mit. Genau das ergab die
+„verschiedenen Raten": Kanal 1 schien mit 480/s zu laufen, es waren
+240/s vom Gerät plus 240/s eigene Antworten, die zufällig `byte9 = 1`
+tragen.
+
+Mit Richtungsfilter, dieselben Dateien:
+
+| Mitschnitt | Gerät → PC | zurück an das Gerät |
+| --- | --- | --- |
+| `expert-A.pcap` (75,2 s) | Kanal 0: 240/s → 48 kHz
Kanal 1: 240/s → 48 kHz | 240/s volle Blöcke + 240/s bloße Köpfe |
+| `expert-B.pcap` (23,5 s) | Kanal 0: 240/s → 48 kHz
Kanal 1: 240/s → 48 kHz | 240/s volle Blöcke + 240/s bloße Köpfe |
+| `expert-96k.pcap` (192,1 s) | Kanal 0: 480/s → **96 kHz** | 240/s volle Blöcke + 240/s bloße Köpfe |
+
+Beide Kanäle laufen also **gleich schnell**. Es gibt keine gemischten
+Raten; es gab nur eine ungefilterte Zählung.
+
+Die Nebenwirkung desselben Fehlers steckte auch in `findeRechner`: der
+Rückfall lautete „die 1210-Byte-Pakete kommen aus dem Gerät". Das ist
+widerlegt. Und die Suchanfrage-Regel (`0x00` geht vom Rechner aus) griff
+auf dem **Strom**port, wo das Gerät 77-Byte-Rahmen schickt, die ebenfalls
+mit `0x00` beginnen — damit lieferte die Erkennung genau verkehrt herum.
+Beides korrigiert; geprüft wird jetzt „berührt den Steuerport und nicht
+den Stromport", nicht `sport == 50001` (der Rechner darf einen beliebigen
+Quellport nehmen, der Prüfstand nimmt 54000).
+
+---
+
+# Was die Mitschnitte WIRKLICH zeigen (2026-10-05)
+
+## 1. ExpertSDR2 antwortet nur auf jeden zweiten Block — und halb so groß
+
+Das ist der Fund, der die Netzlast erklärt. Je Block des Geräts schickt
+ExpertSDR2 abwechselnd:
+
+- einen **vollen Stilleblock** (1210 Byte), und
+- einen **bloßen Kopf** (10 Byte, Längenfeld 0) — `03 ff fe ff 00 00 00 00 01 00`
+
+In allen drei Mitschnitten dasselbe Verhältnis: 240/s voll + 240/s Kopf
+gegen 480/s vom Gerät. **Longpath schickt auf jeden Block einen vollen
+1210-Byte-Stilleblock.** Das ist rund das Doppelte an Rückweg-Bytes.
+
+Der bloße Kopf ist ein Rahmen, den Longpath **nie** schickt und den
+niemand bisher gesehen hat.
+
+## 2. Bei 96 kHz wiederholt das Gerät gegenüber ExpertSDR2 nicht
+
+`expert-96k.pcap`: 92197 Blöcke über 192 s, **0 bytegleiche
+Wiederholungen**. Longpath bekommt in seiner 96-kHz-Betriebsart rund 110
+je Sekunde.
+
+**Vorbehalt, und er ist wichtig:** hier läuft **ein** Strom, Longpath
+fährt bei 96 kHz **zwei**. Das ist noch nicht dieselbe Betriebsart, also
+noch keine Antwort — aber ein starker Hinweis, und zusammen mit Fund 1
+zeigt er zum ersten Mal in eine konkrete Richtung: **nicht mehr
+zurückschicken, sondern weniger und anders.**
+
+## 3. Woran der nächste ansetzen sollte
+
+In dieser Reihenfolge, weil die erste Messung die billigste ist:
+
+1. **Den bloßen Kopf nachbauen.** Jeden zweiten Block mit 10 Byte statt
+ 1210 beantworten, A/B bei 96 kHz messen. Kostet nichts, ändert nur
+ `replyToBlock`.
+2. Bleibt es dabei: ein Mitschnitt mit **zwei** Strömen auf 96 kHz, damit
+ der Vorbehalt oben fällt.
+
+## 4. Für das Werkzeug
+
+`--wiederholungen` filtert jetzt die Richtung, meldet die Abtastrate je
+Kanal, zählt beide Sorten Rückweg-Pakete und **nennt die Stromzahl beim
+Urteil**. Die Gegenprobe gegen die alte Fassung steht in `bb400f1b`; die
+Richtungskorrektur fängt einen Fehler, den die alte Fassung nachweislich
+gemacht hat — sie steht als Behauptung und als Rücknahme in diesem
+Dokument.
+
+**Die Lehre, dreimal in zwei Tagen dieselbe:** eine Zahl aus einem
+Mitschnitt ist erst eine Aussage, wenn feststeht, **wer** gesendet hat
+und in **welcher** Betriebsart.
+
+---
+
+# A/B am Gerät, 2026-10-05: die vierte widerlegte Vermutung
+
+Drei Durchgänge à 60 s an der echten QRP, 96 kHz, Messgerät an. Der
+Betreiber hatte das Gerät freigegeben; ExpertSDR2 war sauber beendet.
+
+| Durchgang | Rückweg | Wiederholungen/s |
+| --- | --- | --- |
+| A — ein Empfänger (Kanal 1 wird verworfen) | 492/s, alle 1210 B | 103–113 (Mittel **110**) |
+| B — zwei Empfänger | 996/s, alle 1210 B | 193–219 (Mittel **207**) |
+| C — zwei Empfänger **+ Kopfantwort** | 996/s, 504/s davon 10 B | 200–222 (Mittel **214**) |
+
+## Was A gegen B sagt — die Quittung VOR dem Verwerfen ist nicht die Ursache
+
+In A stand `m_aktiveEmpfaenger` auf 1, also wurde **Kanal 1 vollständig
+verworfen — und zwar vor `replyToBlock`**, 53522 Pakete ohne eine
+einzige Antwort. Das sah nach der Ursache aus: unbeantwortete Blöcke,
+also wiederholt das Gerät.
+
+Falsch. Mit zwei Empfängern wird jeder Block beantwortet, und die
+Wiederholungen **verdoppeln sich mit der Blockzahl** (110 → 207) statt zu
+verschwinden. Der Anteil bleibt gleich: rund **1,2 Pakete je Nummer** in
+beiden Fällen, bei ~960 Nummern/s. Es ist eine Quote, keine Folge
+fehlender Quittungen.
+
+## Was C sagt — der bloße Kopf ändert an den Wiederholungen nichts
+
+Die Kopfantwort hat nachweislich gegriffen (30265 bloße Köpfe in 60 s),
+und die Wiederholungen bleiben, wo sie waren: 207 gegen 214/s, das ist
+Rauschen. **Vierte Vermutung, vierte Widerlegung.**
+
+Sie bleibt trotzdem eingeschaltet, aus einem anderen Grund: 30265 × 1200
+Byte sind rund **36 MB in 60 s**, also knapp **5 Mbit/s** weniger auf dem
+Rückweg — und ExpertSDR2 macht es genauso. Vorgabe seit diesem Tag AN,
+`LONGPATH_SUNSDR_KOPFANTWORT=0` schaltet aus.
+
+## Was damit über die Wiederholungen feststeht
+
+Widerlegt sind jetzt: den Kopf des Geräts spiegeln · zwei Stille-Ströme ·
+vor dem Verwerfen quittieren · den bloßen Kopf schicken. Und aus A/B
+zusätzlich: es hängt **nicht** an unbeantworteten Kanälen.
+
+Was übrig bleibt, ist eine Quote von ~1,2 Paketen je Nummer, die **mit
+der Blockzahl skaliert** und bei 48 kHz nicht auftritt. Das sieht nach
+einer Eigenschaft des Geräts bei hoher Blockrate aus, nicht nach einem
+fehlenden Rahmen von uns. Belegen ließe sich das nur mit einem
+Mitschnitt, in dem ExpertSDR2 **zwei** Ströme auf 96 kHz fährt —
+`expert-96k.pcap` hatte einen, und dort gab es über 192 s keine einzige
+Wiederholung.
+
+**Kein Richtigkeitsfehler:** Verlust 0,00–0,04 %, Folgenummern sauber,
+der Ton läuft. Es ist Netzlast, und sie ist mit der Kopfantwort um ein
+Viertel kleiner geworden.
+
+---
+
+# Der Verbindungsablauf aus `expert-96k.pcap` (2026-10-05)
+
+57 Steuerrahmen, 29 hinaus und 28 herein, alles vor dem ersten
+Strompaket bei 0,290 s. Der vollständigste Mitschnitt, den wir haben —
+und er beantwortet die Frage, die seit dem 2026-10-03 offen war.
+
+## Der Befund, der die Sendevorbereitung ändert
+
+Bis heute galt: die TX-Opcodes stammen aus der DX/PRO, die QRP benutzt
+nachweislich andere Nummern, also sind sie **Verdacht, nicht Fakt**.
+Dieser Mitschnitt zeigt sie **an der QRP selbst**, von ExpertSDR2
+geschickt und vom Gerät quittiert:
+
+| Opcode | Nutzlast | Quittiert | bisher |
+| --- | --- | --- | --- |
+| `0x06` MOX/PTT | `00000000` | ja | „Kandidat" |
+| `0x15` Antennenwahl | `00000000` | ja | „Kandidat" |
+| `0x17` Ansteuerung/Drive | `00000000` | ja | „Kandidat" |
+
+Alle drei gehen bei **jedem** Verbinden hinaus, mit Wert 0, und das
+Gerät antwortet auf jeden mit dem leeren Echo. Damit steht fest:
+
+- **Die Opcodes existieren auf der QRP** und werden angenommen. Die
+ Sorge „zwei Nummern neben der PA-Freigabe" ist für diese drei
+ ausgeräumt.
+- **Der Wert 0 ist nachweislich harmlos** — ExpertSDR2 schickt ihn
+ jedes Mal, ohne Antenne und ohne Sendezustand.
+
+Was damit **nicht** feststeht: was die Opcodes bedeuten. Dass `0x06`
+angenommen wird, macht es noch nicht zu MOX, und was ein Wert ≠ 0
+bewirkt, steht in keinem Mitschnitt, in dem niemand gesendet hat. Dafür
+bleibt es beim Abschluss am Ausgang.
+
+**`0x24` (PA freigeben) kommt im ganzen Verbindungsablauf NICHT vor.**
+Diese Nummer bleibt also reiner Verdacht aus der DX-Quelle.
+
+## Die sechzehn Rahmen, die Longpath nie schickt
+
+Mit Nutzlast, wie sie auf dem Draht stehen:
+
+ 0x03 4 B 01000000
+ 0x05 1200 B c80600600000... (siehe unten)
+ 0x06 4 B 00000000 MOX/PTT, Wert 0
+ 0x0c 0 B ABFRAGE, Antwort 320 B
+ 0x0d 0 B ABFRAGE, Antwort 320 B
+ 0x0f 4 B 0a000000
+ 0x10 4 B 00000000
+ 0x11 4 B fe000000
+ 0x12 1024 B 64000000...
+ 0x13 4 B 00000000
+ 0x15 4 B 00000000 Antennenwahl, Wert 0
+ 0x16 36 B 0100000001000000... Konfigurationsblock, zweimal
+ 0x17 4 B 00000000 Drive, Wert 0
+ 0x18 12 B 0000000000804f12... Haupttakt 307,2 MHz
+ 0x1a 4 B 00000000
+ 0x1c 16 B 13370c0414490401... Kalibrierwerte
+
+Das Gerät quittiert **jeden** davon. Keiner ist also für den Empfang
+nötig — Longpath hört ohne sie —, aber die Reihenfolge ist jetzt
+vollständig bekannt.
+
+## Eine Beobachtung zu `0x05`, die nicht zu uns gehört
+
+Die 1200 Byte, die ExpertSDR2 in `0x05` schickt, sind erkennbar
+**nicht initialisierter Speicher**: darin stehen Zeigerwerte der Form
+`…ef7f0000` und Textreste wie `de_AT`, `POSIX`, `en_GB`, `es_US`,
+`pt_BR`. ExpertSDR2 schickt also Teile seines eigenen Stapelspeichers
+ans Funkgerät.
+
+~~Für uns ist das nur insofern wichtig, als der Inhalt offensichtlich
+egal ist — wenn `0x05` je gebraucht wird, tut es jede Füllung.~~
+
+**Das war ein Schluss, keine Messung, und am selben Tag am Gerät
+widerlegt.** Mit Nutzlast aus lauter Nullen (richtige Prüfsumme, selbe
+Stelle im Ablauf) **quittiert das Gerät `0x05` und `0x12` nicht** — es
+verwirft sie. ExpertSDR2s Fassungen werden quittiert. Der Inhalt ist
+also **nicht** beliebig, oder es hängt an etwas anderem, das die
+Nullfassung mitverändert (Längenfeld 0 bei 1200 Byte Nutzlast ist bei
+beiden gleich, scheidet also aus).
+
+Wer diese beiden Rahmen braucht, braucht einen Mitschnitt mit ihren
+echten Bytes. Nachbauen aus dem Kopf geht nicht.
+
+## Was das für den Abschluss-Durchgang ändert
+
+Der Prüfplan sah vor, mit der Antennenwahl `0x15` anzufangen, um
+überhaupt erst zu klären, ob die DX-Nummern auf der QRP gelten. **Dieser
+Schritt ist erledigt** — sie gelten für `0x06`, `0x15` und `0x17`.
+Der Durchgang mit dem Abschluss beginnt damit direkt bei der Frage, die
+nur dort zu beantworten ist: was ein Wert ≠ 0 bewirkt.
+
+
+---
+
+# Versuch: die sechzehn Rahmen nachschicken (2026-10-06)
+
+Anlass ist ein Widerspruch, der mir an Martins Schirm aufgefallen ist.
+ExpertSDR2 zeigt, solange es verbunden ist:
+
+ U: 13,1 V I: 0,3 A 35,5 °C
+
+und nach dem Verbindungsverlust `0,0 V / 0,0 A / 0,0 °C`. Das sind
+**Messwerte aus dem Gerät**. Dieses Dokument behauptet ein paar
+Abschnitte weiter oben das Gegenteil: „Im Empfang gibt es bei der QRP
+keine Gerätemesswerte." Beides kann nicht stimmen.
+
+Die Vermutung: die Werte stecken in den **77-Byte-Rahmen**, die das
+Gerät im Mitschnitt während des Stroms rund dreimal je Sekunde schickt
+(612 Stück über 192 s) und die Longpath wegwirft — und man muss sie
+vielleicht mit einem der sechzehn Rahmen anfordern, die ExpertSDR2
+schickt und Longpath nie.
+
+**Gefahren, 60 s und 45 s, ohne Antenne, nichts getastet:** die dreizehn
+kleinen Rahmen über `LONGPATH_SUNSDR_PRE`, alle mit ihren echten
+Nutzlasten aus dem Mitschnitt. `0x24` ist nicht darunter, und `0x15`
+wie `0x17` tragen den Wert 0 — es kann nichts senden.
+
+**Ergebnis: negativ.** Alle dreizehn werden quittiert, `0x0c` antwortet
+mit denselben 320 Byte wie vor zehn Tagen (`10748be4…294033333333`), und
+auf dem Stromweg erscheint **kein einziger 77-Byte-Rahmen**. Die
+Telemetrie lässt sich damit nicht einschalten.
+
+Der zweite Versuch mit den beiden großen Rahmen (`0x05`, `0x12`) scheitert
+daran, dass das Gerät sie mit Nullnutzlast verwirft (siehe oben).
+
+**Damit bleibt offen**, und zwar als echter Lückenposten in der
+Gleichwertigkeit: ExpertSDR2 zeigt Spannung, Strom und Temperatur,
+Longpath zeigt dort nichts. Was es braucht, ist ein Mitschnitt, in dem
+sichtbar wird, **wann** die 77-Byte-Rahmen einsetzen — und die echten
+Bytes von `0x05` und `0x12` gleich mit.
diff --git a/docs/development/sunsdr-abschluss-pruefplan.md b/docs/development/sunsdr-abschluss-pruefplan.md
new file mode 100644
index 000000000..d2cafdd5a
--- /dev/null
+++ b/docs/development/sunsdr-abschluss-pruefplan.md
@@ -0,0 +1,145 @@
+# Der Durchgang mit dem 50-Ohm-Abschluss
+
+Alles, was an der SunSDR2 QRP noch offen ist, hängt an **einer** Sache:
+einem Abschluss am Ausgang. Dieses Blatt macht daraus einen Durchgang
+statt sieben einzelner Entdeckungen.
+
+**Keine Antenne.** Ein Abschluss, keine Antenne — es wird mit Absicht
+Leistung erzeugt, und die soll in einen Widerstand gehen, nicht in die
+Luft und nicht in eine offene Buchse.
+
+Stand beim Schreiben (2026-10-04): der **Empfang ist fertig**. Was hier
+steht, ist das Senden plus die eine Empfangs-Bestätigung, die ohne
+Sendezustand nicht geht.
+
+---
+
+## Vorher, ohne zu senden
+
+| Schritt | Was prüfen | Abbruchgrund |
+| --- | --- | --- |
+| 1 | Abschluss **fest** angeschraubt, nicht nur aufgesteckt | sitzt er nicht, nicht weiter |
+| 2 | Longpath verbunden, Empfang läuft, Wasserfall lebt | kein Strom → erst das klären |
+| 3 | Im Log: `Folgenummern sauber`, Verlust unter 0,1 % | hoher Verlust → Netz klären, nicht senden |
+| 4 | **Leistung ganz herunter**, bevor irgendetwas getastet wird | — |
+
+Schritt 4 ist nicht formal: `setTxDrive` ist bei diesem Treiber ein
+**leerer Rumpf**. Longpath kann die Leistung derzeit **nicht stellen**.
+Was das Gerät beim Tasten abgibt, ist das, was zuletzt in ExpertSDR2
+eingestellt war. Also dort vorher kleinstellen.
+
+---
+
+## Schritt A — Mikrofon-PTT bestätigen (Empfang, braucht aber Sendezustand)
+
+Der einzige Empfangspunkt, der noch offen ist. Gebaut ist er
+(`1e4eb060`): Longpath liest den Zustand aus dem **Stromkopf** —
+Opcode `0xFD` heißt „Gerät sendet", `0xFE` heißt Empfang. Kein
+Protokollwissen nötig, deshalb konnte er ohne Mitschnitt entstehen.
+
+1. Mikrofontaste am Gerät **kurz** drücken und loslassen.
+2. Im Log erwarten:
+
+ SunSdr: PTT vom Geraet: gedrueckt (aus dem Stromkopf, Opcode 0xfd)
+ SunSdr: PTT vom Geraet: losgelassen (aus dem Stromkopf, Opcode 0xfe)
+
+3. **Genau zwei** Zeilen je Tastendruck, nicht zweihundert — die
+ Flankenerkennung muss greifen (240 Pakete je Sekunde dürfen nicht 240
+ Meldungen ergeben).
+
+Kommt nichts: der Zustand steckt dann doch nicht im Stromkopf, und es
+braucht den Mitschnitt. Kommt eine Flut: die Flankenerkennung greift
+nicht, das ist ein Fehler in `pruefeMikrofonPtt`.
+
+---
+
+## Schritt B — die Opcode-Nummern bestätigen, **einzeln**
+
+Hier liegt die eigentliche Arbeit, und hier ist auch die Falle. Am
+2026-10-03 hat sich am Gerät gezeigt, dass die QRP **andere** Opcodes
+benutzt als die DX/PRO, aus der alle unbestätigten Zahlen stammen:
+
+| Befehl | QRP (gemessen) | DX (ArtemisSDR) |
+| --- | --- | --- |
+| Vorverstärker | `0x04` | `0x05` |
+| DDC-Frequenz | `0x07` | `0x08` |
+| VFO-Frequenz | `0x08` | `0x09` |
+| erstes Byte des Rahmens | `0x03` | `0x32` |
+
+**Nachtrag 2026-10-05 — drei davon sind keine Vermutung mehr.** Im
+Mitschnitt `expert-96k.pcap` schickt ExpertSDR2 bei **jedem** Verbinden
+`0x06` (MOX/PTT), `0x15` (Antennenwahl) und `0x17` (Drive) an die QRP,
+jeweils mit Wert `00000000`, und das Gerät **quittiert jeden davon**.
+Damit steht fest: die Opcodes existieren auf der QRP, und der Wert 0 ist
+nachweislich harmlos. Offen bleibt, was ein Wert ≠ 0 bewirkt — und genau
+dafür ist dieser Durchgang da.
+
+`0x24` (PA freigeben) kommt im ganzen Verbindungsablauf **nicht** vor;
+diese Nummer bleibt reiner Verdacht aus der DX-Quelle.
+
+Für die übrigen gilt weiter: jede Zahl, die wir für das Senden benutzen,
+stammt aus derselben Quelle wie die rechte Spalte, ist also **Verdacht,
+nicht Fakt**:
+
+| Zweck | Opcode | Stand 2026-10-05 | Gebaut? |
+| --- | --- | --- | --- |
+| MOX / PTT | `0x06` | **an der QRP quittiert** (Wert 0) | Rahmenbauer fertig, nicht verdrahtet |
+| Leistung | `0x17` | **an der QRP quittiert** (Wert 0) | Rahmenbauer fertig, `setTxDrive` leer |
+| PA freigeben | `0x24` | **nie gesehen** — reiner Verdacht | Rahmenbauer fertig, nicht verdrahtet |
+| Antennenwahl | `0x15` | **an der QRP quittiert** (Wert 0) | verdrahtet, **stumm** (`LONGPATH_SUNSDR_ANTENNE=1`) |
+
+**Vorgehen: einer nach dem anderen, und nach jedem nachsehen.** Das
+Gerät quittiert jeden angenommenen Steuerrahmen binnen 15–50 ms
+(2026-10-03 gemessen). Diese Quittung ist der Prüfstein:
+
+- **quittiert** → der Opcode existiert und wurde angenommen
+- **nicht quittiert** → die Zahl ist falsch; nicht nachlegen, sondern
+ aufhören und den Mitschnitt machen
+
+Im Log steht beides von selbst (`nach N ms quittiert` bzw. beim Trennen
+`noch unquittiert: 0x..`).
+
+**Die Antennenwahl zuerst**, weil sie als einzige nichts erzeugt: sie
+schaltet nur ein Relais.
+
+~~Erst wenn `0x15` quittiert wird, hat die Vermutung „die QRP teilt die
+Opcodes der DX" überhaupt Grundlage.~~ — **erledigt am 2026-10-05**, ohne
+Abschluss und ohne Senden: `0x15` wird quittiert, `0x06` und `0x17` auch.
+Dieser Durchgang fängt damit nicht mehr bei der Frage an, ob die Nummern
+stimmen, sondern bei der, was ein Wert ≠ 0 bewirkt.
+
+---
+
+## Schritt C — erstmals tasten
+
+Erst wenn A und B durch sind.
+
+1. Leistung in ExpertSDR2 auf den kleinsten Wert, dann ExpertSDR2
+ schließen.
+2. Kurz tasten — **eine Sekunde**, nicht mehr.
+3. Danach sofort: Gerät handwarm? Abschluss handwarm? Dann ist Leistung
+ geflossen, wo sie soll.
+4. Im Log: Stromkopf auf `0xFD`, und nach dem Loslassen zurück auf
+ `0xFE`.
+
+**Es gibt keine Rückmeldung über Leistung oder Stehwelle.** Am
+2026-10-03 wurde gemessen, dass die QRP im Empfang **keine Messwerte
+herausgibt** — alle Abfrageantworten waren über zehn Tage bitgleich. Ob
+sie im Sendezustand welche liefert, ist unbekannt. Bis das geklärt ist,
+ersetzt der Handrücken das Instrument, und deshalb bleibt es bei einer
+Sekunde.
+
+---
+
+## Was danach feststeht
+
+- Mikrofon-PTT bestätigt → der Empfang ist **abgeschlossen**, ohne
+ Vorbehalt.
+- Opcodes bestätigt oder widerlegt → entweder kann das Senden gebaut
+ werden, oder es braucht zuerst einen Mitschnitt mit ExpertSDR2 beim
+ Senden. Beides ist ein klares Ergebnis; nur das Raten dazwischen muss
+ aufhören.
+
+Siehe auch `docs/architecture/2026-10-02-sunsdr-paritaet.md` (Lückenliste,
+Abschnitte 3a und 4) und `docs/development/sunsdr-mitschnitt-anleitung.md`
+(wenn der Mitschnitt doch gebraucht wird).
diff --git a/docs/development/sunsdr-mitschnitt-anleitung.md b/docs/development/sunsdr-mitschnitt-anleitung.md
index db9b868b1..4498e3f62 100644
--- a/docs/development/sunsdr-mitschnitt-anleitung.md
+++ b/docs/development/sunsdr-mitschnitt-anleitung.md
@@ -1,15 +1,41 @@
# Zwei Minuten Mitschnitt — die Anleitung
-**Wofür:** drei Fragen, an denen die QRP-Arbeit sonst stehen bleibt
-(Stand 2026-10-03, siehe `docs/architecture/2026-10-02-sunsdr-paritaet.md`):
-
-1. Welcher Rahmen schaltet den **zweiten Empfänger** ein? Der Platz wird
- akzeptiert und quittiert, bleibt aber stumm.
-2. Welcher Rahmen stellt die **Abtastrate**? `0x18` tut es nicht,
- obwohl er den Haupttakt trägt.
-3. Welche Rahmen gehören überhaupt noch zum Verbindungsablauf? Die
- dreizehn bekannten sind unvollständig; gemessen wurden „rund zwei
- Dutzend".
+> **Stand 2026-10-04 — zwei der drei ursprünglichen Fragen sind
+> beantwortet, ohne Mitschnitt.** Die Liste unten ist nachgezogen; wer
+> den Durchgang macht, soll nicht mehr nach Beantwortetem suchen.
+>
+> - ~~Welcher Rahmen schaltet den zweiten Empfänger ein?~~ **Erledigt.**
+> Es ist derselbe Rahmen `0x01`: sein erstes Byte trägt die Zahl der
+> Ströme. Zwei Empfänger laufen seit `38988a85` von Ende zu Ende,
+> am Gerät belegt (480 statt 240 Nummern/s, 0 verworfen).
+> - ~~Welcher Rahmen stellt die Abtastrate?~~ **Erledigt.** Ebenfalls
+> `0x01`, zweites Byte. Drei Nutzlasten sind durchgemessen.
+
+**Wofür noch:** zwei Fragen, an denen die QRP-Arbeit wirklich noch steht:
+
+1. **Welche Rahmen gehören zum Verbindungsablauf?** Die dreizehn
+ bekannten sind unvollständig; gemessen wurden „rund zwei Dutzend".
+ Das ist die Grundlage für alles Sendeseitige — die Opcode-Nummern der
+ QRP sind nachweislich **andere** als die der DX/PRO, aus der alle
+ unbestätigten Zahlen stammen.
+2. **Wiederholt ExpertSDR2 bei 96 kHz auch?** Longpath bekommt dort
+ rund 110 bytegleiche Wiederholungen je Sekunde (1,2 Kopien je
+ Nummer statt 1,0 bei 48 kHz). Drei Gegenmaßnahmen sind gemessen und
+ **wirkungslos** (Kopf der Blockantwort spiegeln, zwei Stille-Ströme,
+ Quittung vor dem Verwerfen). Zeigt der Mitschnitt dieselben
+ Wiederholungen bei ExpertSDR2, ist es die Eigenart des Geräts und
+ kein Mangel von Longpath — und die Frage ist erledigt statt offen.
+
+ **Dafür müssen BEIDE Empfänger auf 96 kHz stehen**, nicht nur einer
+ und nicht die Vorgabe. Der Mitschnitt vom 2026-10-05 (`expert-96k`)
+ hatte **einen** Strom auf 96 kHz und zeigt dort **0 bytegleiche
+ Wiederholungen** über 192 s — ein starker Hinweis, aber noch nicht
+ dieselbe Betriebsart. Das Werkzeug prüft die Rate je Kanal selbst und
+ nennt die Stromzahl beim Urteil.
+
+ (Die hier zwischenzeitlich behaupteten „gemischten Raten" waren ein
+ Zählfehler ohne Richtungsfilter und sind zurückgenommen — siehe
+ `docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md`.)
**Was dabei nicht gebraucht wird:** keine Antenne, kein Senden, keine
Freigabe für HF. Es wird nur zugehört.
@@ -47,6 +73,21 @@ sudo tcpdump -i en9 -s 0 -w ~/Desktop/expert-C.pcap host 192.168.16.200
ExpertSDR2 starten, verbinden, dann **die Abtastrate umstellen** (eine
andere Bandbreite wählen), zehn Sekunden warten, beenden.
+## Durchgang D: beide Empfänger auf 96 kHz — der für die Wiederholungsfrage
+
+```bash
+sudo tcpdump -i en9 -s 0 -w ~/Desktop/expert-96k.pcap host 192.168.16.200
+```
+
+ExpertSDR2 starten, verbinden, **RX2 einschalten und beide Empfänger auf
+96 kHz stellen**, zwanzig Sekunden zuhören, beenden. `tcpdump` mit
+Strg-C beenden.
+
+Zur Kontrolle, dass es wirklich die richtige Betriebsart war: die
+Auswertung muss für **jeden** Kanal 96 kHz melden, also je 480 einzelne
+Blöcke je Sekunde. Steht bei einem Kanal 48 kHz, war es wieder die
+gemischte Betriebsart.
+
## Auswerten — ein Befehl je Frage
Der Ablauf im Überblick, und welche Rahmen Longpath nie schickt:
@@ -71,6 +112,24 @@ python3 ~/Longpath/NereusSDR/tools/sunsdr_handshake_diff.py ~/Desktop/expert-A.p
Beide Befehle geben am Ende fertige `LONGPATH_SUNSDR_PRE`/`_EXTRA`-Zeilen
aus, mit denen sich derselbe Ablauf **ohne Neubau** ausprobieren lässt.
+**Die Wiederholungen** — Frage 2, mit Durchgang D:
+
+```bash
+python3 ~/Longpath/NereusSDR/tools/sunsdr_handshake_diff.py --wiederholungen ~/Desktop/expert-96k.pcap
+```
+
+Dieser Befehl nennt zuerst die Abtastrate **je Kanal** und sagt dann
+selbst, ob der Mitschnitt die Frage beantworten kann. Steht nicht bei
+jedem Kanal 96 kHz, verweigert er die Aussage — absichtlich: am
+2026-10-04 habe ich aus einem Mitschnitt in der falschen Betriebsart den
+falschen Schluss gezogen, und am 2026-10-05 wäre es mit den gemischten
+Raten fast wieder passiert.
+
+Er nennt außerdem, **was zurückgeschickt wird**. Darin steckt der Fund
+vom 2026-10-05: ExpertSDR2 antwortet je Block abwechselnd mit einem
+vollen Stilleblock (1210 Byte) und einem **bloßen Kopf** (10 Byte) —
+Longpath schickt immer den vollen Block.
+
## Warum der Weg über den Vergleich geht und nicht über Probieren
Die Opcode-Nummern der QRP sind nicht die der DX: bei drei am Gerät
diff --git a/src/core/AudioEngine.cpp b/src/core/AudioEngine.cpp
index 9cf20b4ad..c2c36cc7a 100644
--- a/src/core/AudioEngine.cpp
+++ b/src/core/AudioEngine.cpp
@@ -944,9 +944,21 @@ std::unique_ptr AudioEngine::makeMonitorOut(const QString& targetNode
void AudioEngine::ensureSpeakersOpen()
{
- if (m_speakersBus && m_speakersBus->isOpen()) {
+ if (m_speakersBus && m_speakersBus->isAlive()) {
return;
}
+ if (m_speakersBus) {
+ // Geoeffnet, aber tot: das ist der Fall vom 2026-10-04 -- Ton im
+ // Programm, nichts aus den Lautsprechern. Vorher hat isOpen() hier
+ // "ja" gesagt und der tote Strom blieb stehen. Jetzt wird er
+ // weggeraeumt und darunter neu geoeffnet.
+ qCWarning(lcAudio)
+ << "Lautsprecher: der Ausgang war geoeffnet, lebt aber nicht "
+ "mehr (Geraet gewechselt, Rate umgestellt oder Ruhezustand) "
+ "-- er wird neu geoeffnet.";
+ m_speakersBus->close();
+ m_speakersBus.reset();
+ }
if (!m_paInitialized) {
return;
}
diff --git a/src/core/AudioEngine.h b/src/core/AudioEngine.h
index 668160bba..4232c5811 100644
--- a/src/core/AudioEngine.h
+++ b/src/core/AudioEngine.h
@@ -352,6 +352,13 @@ class AudioEngine : public QObject {
/// nach setSpeakersConfig() steht hier nichts mehr.
bool hasSpeakersBusForTest() const { return m_speakersBus != nullptr; }
+ /// Pruef-Naht (2026-10-04) — welcher Bus liegt gerade an, und einmal
+ /// die Pruefung "lebt er noch?" anstossen. Fuer den Fall vom Mac des
+ /// Betreibers: Ton im Programm, nichts aus den Lautsprechern, weil ein
+ /// toter Strom als "geoeffnet" durchging.
+ const IAudioBus* speakersBusForTest() const { return m_speakersBus.get(); }
+ void ensureSpeakersOpenForTest() { ensureSpeakersOpen(); }
+
// Test seam — inject a fake IAudioBus into the TX-input slot so unit
// tests can exercise pullTxMic without standing up a real PortAudio
// capture device. Takes ownership of `bus`.
diff --git a/src/core/BoardCapabilities.cpp b/src/core/BoardCapabilities.cpp
index c0428c43b..f1a13ebcb 100644
--- a/src/core/BoardCapabilities.cpp
+++ b/src/core/BoardCapabilities.cpp
@@ -1254,9 +1254,22 @@ const BoardCapabilities kSunSdr2Qrp = {
// Revisit if/when a second-receiver capability is confirmed on the
// bench.
.adcCount = 1,
- .maxReceivers = 1,
- .maxSlices = 1,
- .userDdcCount = 1,
+ // Zwei Empfaenger -- am 2026-10-04 aus einem Mitschnitt des Betreibers
+ // belegt, in dem ExpertSDR2 mit RX UND RX2 lief ("es waren immer beide
+ // rx und rx2"). Der zweite Strom traegt echtes I/Q: -127,9 dBFS,
+ // 31,5 % Q ungleich null, also etwas KRAEFTIGER als der erste
+ // (-130,0 dBFS / 21,2 %). Bis dahin stand hier 1, und der Treiber hat
+ // den zweiten Kanal weggeworfen -- die Lueckenliste nannte ihn
+ // faelschlich "stumm".
+ .maxReceivers = 2,
+ .maxSlices = 2,
+ // Zwei Stroeme, nicht einer -- sonst hat der Strompool genau einen
+ // Platz, und eine zweite Scheibe bekommt nie einen eigenen Strom.
+ // Am 2026-10-04 am Geraet gesehen: eine zweite Scheibe auf 20 m
+ // wurde abgelehnt ("Placement: slice 1 freq=14.1 MHz -> Rejected
+ // stream=-1"), obwohl das Geraet zwei Stroeme liefert und der
+ // Treiber beide hochreicht. Das war das letzte Glied der Kette.
+ .userDdcCount = 2,
.widebandAdcs = 0, // no wideband/panadapter-bypass stream documented
// 48 000 Hz — am Geraet gemessen (2026-09-23/24), nicht uebernommen.
//
@@ -1276,8 +1289,21 @@ const BoardCapabilities kSunSdr2Qrp = {
// Atlas und mit ihr auf Atlas' 192 kHz. Siehe die Stelle in
// RadioModel::connectToRadio, die das jetzt richtigstellt -- ohne sie
// ist die Zahl hier wirkungslos.
- .sampleRates = {48000, 0, 0, 0, 0, 0},
- .maxSampleRate = 48000,
+ // 96 000 Hz ist am 2026-10-03 am Geraet gemessen und laeuft durch die
+ // ganze Kette: der Stromstart-Rahmen 0x01 stellt sie (SunSdrProtocol.h,
+ // StromModus), setSampleRate waehlt ihn, und Kanal 0 kommt mit
+ // 480 Folgenummern je Sekunde = 96 kHz beim Empfaenger an. Gemessen
+ // ohne Verlust, mit erkanntem Stromneustart.
+ //
+ // 144 000 waere ebenfalls belegt (zwei Stroeme je 96 kHz ergeben
+ // zusammen 192 kHz), steht hier aber NICHT: ein Eintrag in dieser
+ // Liste heisst, dass die Oberflaeche die Rate anbietet, und was die
+ // Oberflaeche anbietet, muss durch den ganzen Weg stimmen -- einmal
+ // Daten einer Rate in einem Kanal einer anderen, und der Betreiber
+ // hoert "schlechtes Rauschen" (2026-09-24). Erst wenn der zweite Kanal
+ // oben einen Empfaenger hat, kommt mehr dazu.
+ .sampleRates = {48000, 96000, 0, 0, 0, 0},
+ .maxSampleRate = 96000,
// No OpenHPSDR-style wire-encoded step attenuator exists on this
// protocol. SunSDR has its own, structurally different mechanism: a
// single opcode (0x05) selecting one of 4 discrete preamp/atten
diff --git a/src/core/DevAutomationServer.cpp b/src/core/DevAutomationServer.cpp
index 7aad51c13..5798245c6 100644
--- a/src/core/DevAutomationServer.cpp
+++ b/src/core/DevAutomationServer.cpp
@@ -353,6 +353,17 @@ QJsonObject DevAutomationServer::handleLine(const QByteArray& line)
if (verb == QStringLiteral("connect")) {
return doConnect(parts.size() >= 2 ? parts.at(1) : QString());
}
+ if (verb == QStringLiteral("addSlice")) {
+ return doAddSlice();
+ }
+ if (verb == QStringLiteral("setFreq")) {
+ if (parts.size() < 3) {
+ return QJsonObject{{QStringLiteral("ok"), false},
+ {QStringLiteral("error"),
+ QStringLiteral("setFreq ")}};
+ }
+ return doSetFrequency(parts.at(1).toInt(), parts.at(2).toDouble());
+ }
if (verb == QStringLiteral("disconnect")) {
return doDisconnect();
}
@@ -367,7 +378,7 @@ QJsonObject DevAutomationServer::handleLine(const QByteArray& line)
return QJsonObject{{QStringLiteral("ok"), false},
{QStringLiteral("error"),
QStringLiteral("unknown command: ") + verb +
- QStringLiteral(" (known: ping, dumpTree, grab, get, connect, disconnect)")}};
+ QStringLiteral(" (known: ping, dumpTree, grab, get, connect, disconnect, addSlice, setFreq)")}};
}
// ── doConnect / doDisconnect (2026-09-30) ────────────────────────────────────
@@ -380,6 +391,46 @@ QJsonObject DevAutomationServer::handleLine(const QByteArray& line)
// das Panel: das gespeicherte Radio aus den Einstellungen holen und übergeben.
// Bewusst NICHT der Discovery-Weg — ein Verb, das sich sein Ziel selbst sucht,
// könnte am falschen Gerät landen, und in einem Shack steht selten nur eines.
+QJsonObject DevAutomationServer::doAddSlice()
+{
+ if (m_radioModel.isNull()) {
+ return QJsonObject{{QStringLiteral("ok"), false},
+ {QStringLiteral("error"), QStringLiteral("no radio model")}};
+ }
+ // Direkt rufen: dieser Handler laeuft bereits im Hauptfaden (siehe
+ // die Begruendung an doConnect). Ein BlockingQueuedConnection in
+ // denselben Faden blockiert sich selbst -- am 2026-10-04 beim ersten
+ // Versuch genau so passiert, die Antwort kam nie.
+ const int id = m_radioModel->addSlice();
+
+ if (id < 0) {
+ return QJsonObject{{QStringLiteral("ok"), false},
+ {QStringLiteral("error"),
+ QStringLiteral("addSlice refused (pool full?)")}};
+ }
+ return QJsonObject{{QStringLiteral("ok"), true},
+ {QStringLiteral("slice"), id}};
+}
+
+QJsonObject DevAutomationServer::doSetFrequency(int sliceId, double hz)
+{
+ if (m_radioModel.isNull()) {
+ return QJsonObject{{QStringLiteral("ok"), false},
+ {QStringLiteral("error"), QStringLiteral("no radio model")}};
+ }
+ // Hauptfaden, direkt -- gleiche Begruendung wie bei doAddSlice.
+ SliceModel* slice = m_radioModel->sliceById(sliceId);
+ if (slice == nullptr) {
+ return QJsonObject{{QStringLiteral("ok"), false},
+ {QStringLiteral("error"),
+ QStringLiteral("keine Scheibe %1").arg(sliceId)}};
+ }
+ slice->setFrequency(hz);
+ return QJsonObject{{QStringLiteral("ok"), true},
+ {QStringLiteral("slice"), sliceId},
+ {QStringLiteral("hz"), slice->frequency()}};
+}
+
QJsonObject DevAutomationServer::doConnect(const QString& macKeyOrEmpty)
{
if (m_radioModel.isNull()) {
diff --git a/src/core/DevAutomationServer.h b/src/core/DevAutomationServer.h
index 86c313419..f71d9bfdd 100644
--- a/src/core/DevAutomationServer.h
+++ b/src/core/DevAutomationServer.h
@@ -137,6 +137,18 @@ private slots:
QJsonObject doConnect(const QString& macKeyOrEmpty);
QJsonObject doDisconnect();
+ /// Eine Scheibe anlegen (2026-10-04). Ohne das haengt jede
+ /// Live-Pruefung mit zwei Empfaengern an einem Mausklick auf das
+ /// Plus -- und genau die stand heute an: der zweite Strom der QRP
+ /// kommt oben erst an, wenn sich eine zweite Scheibe an ihn bindet.
+ QJsonObject doAddSlice();
+
+ /// Die Frequenz einer Scheibe stellen (2026-10-04). Zusammen mit
+ /// doAddSlice() macht das den Live-Beleg fuer zwei Empfaenger ohne
+ /// Mausklick moeglich: eine zweite Scheibe weit genug weg zwingt
+ /// einen zweiten Strom, statt sich den ersten zu teilen.
+ QJsonObject doSetFrequency(int sliceId, double hz);
+
QLocalServer* m_server{nullptr};
QPointer m_radioModel;
};
diff --git a/src/core/IAudioBus.h b/src/core/IAudioBus.h
index 184f793ec..7a5e9d4eb 100644
--- a/src/core/IAudioBus.h
+++ b/src/core/IAudioBus.h
@@ -35,6 +35,22 @@ class IAudioBus {
virtual void close() = 0;
virtual bool isOpen() const = 0;
+ // Lebt der Strom noch -- nicht nur: wurde er einmal geoeffnet?
+ //
+ // Am 2026-10-04 am Mac des Betreibers: Longpath hatte Ton (die
+ // Handy-App bekam ihn ueber TCI), aus den Lautsprechern kam nichts.
+ // Der Ausgang war 33 Minuten vor dem Verbinden geoeffnet worden, und
+ // AudioEngine::ensureSpeakersOpen() prueft mit isOpen() -- das ist bei
+ // PortAudioBus ein ZEIGERVERGLEICH (m_stream != nullptr). Stirbt der
+ // Strom darunter (Geraetewechsel, Ratenwechsel durch einen virtuellen
+ // Treiber wie BoomAudio/DeskFx, Ruhezustand), bleibt der Zeiger
+ // stehen, ensureSpeakersOpen kehrt sofort zurueck, und Longpath
+ // schreibt in einen toten Strom, ohne dass irgendwo etwas auffaellt.
+ //
+ // Vorgabe ist isOpen(), damit jeder Bus, der keine eigene Auskunft
+ // geben kann, sich verhaelt wie bisher.
+ virtual bool isAlive() const { return isOpen(); }
+
// Producer side (RX taps). Interleaved PCM bytes. Returns bytes actually
// written, or -1 on error. Must be callable from the audio thread.
virtual qint64 push(const char* data, qint64 bytes) = 0;
diff --git a/src/core/ReceiverManager.cpp b/src/core/ReceiverManager.cpp
index cd8d23340..72fe61ffa 100644
--- a/src/core/ReceiverManager.cpp
+++ b/src/core/ReceiverManager.cpp
@@ -375,9 +375,17 @@ void ReceiverManager::feedIqData(int hwReceiverIndex, const QVector& samp
for (auto mi = m_hwToLogical.constBegin(); mi != m_hwToLogical.constEnd(); ++mi) {
mapped << QString("hw%1->rx%2").arg(mi.key()).arg(mi.value());
}
- qCWarning(lcReceiver) << "ReceiverManager: first feedIqData dropped;"
- << "hwReceiverIndex=" << hwReceiverIndex
- << "map=" << (mapped.isEmpty() ? QStringLiteral("(empty)") : mapped.join(','));
+ qCWarning(lcReceiver).noquote()
+ << QStringLiteral(
+ "ReceiverManager: Daten von Hardware-Empfaenger %1 "
+ "fallen weg -- dafuer gibt es oben keinen "
+ "Empfaenger. Abbildung: %2. Ein Empfaenger entsteht "
+ "erst, wenn sich eine Scheibe an diesen Strom "
+ "bindet; bis dahin fordert das Geraet den Strom "
+ "zwar, aber niemand hoert ihn.")
+ .arg(hwReceiverIndex)
+ .arg(mapped.isEmpty() ? QStringLiteral("(leer)")
+ : mapped.join(QLatin1Char(',')));
}
return;
}
diff --git a/src/core/SunSdrRadioConnection.cpp b/src/core/SunSdrRadioConnection.cpp
index 4776c2dd6..3344759b2 100644
--- a/src/core/SunSdrRadioConnection.cpp
+++ b/src/core/SunSdrRadioConnection.cpp
@@ -14,6 +14,8 @@
#include "SunSdrRadioConnection.h"
+#include "AppSettings.h"
+
#include
#include
#include
@@ -24,6 +26,7 @@
#include
#include
#include
+#include
namespace Longpath {
@@ -192,6 +195,17 @@ void SunSdrRadioConnection::init()
m_connectWatchdog = new QTimer(this);
m_connectWatchdog->setSingleShot(true);
+
+ // Der Zeitgeber fuer den naechsten Anlauf -- siehe onConnectTimeout().
+ m_erneutTimer = new QTimer(this);
+ m_erneutTimer->setSingleShot(true);
+ connect(m_erneutTimer, &QTimer::timeout, this, [this]() {
+ if (state() != ConnectionState::Connecting) { return; }
+ qCInfo(lcSunSdr) << "SunSdr: neuer Suchversuch";
+ m_awaitingBeacon = true;
+ sendDiscoveryBroadcast();
+ if (m_connectWatchdog) { m_connectWatchdog->start(kConnectTimeoutMs); }
+ });
connect(m_connectWatchdog, &QTimer::timeout,
this, &SunSdrRadioConnection::onConnectTimeout);
@@ -230,7 +244,46 @@ void SunSdrRadioConnection::connectToRadio(const RadioInfo& info)
m_radioInfo = info;
m_profile = &resolveProfile(info.boardType);
- m_rxLevelGain = static_cast(std::pow(10.0, m_profile->rxLevelTrimDb / 20.0));
+ // Pegelabgleich: Vorgabe aus dem Profil, aber ueberschreibbar.
+ //
+ // Warum ueberschreibbar (2026-10-04): die +20,0 dB im QRP-Profil
+ // wurden am 2026-09-25 ueber den TCI-Weg gemessen (rx_sensors), nicht
+ // ueber den nativen Treiber -- ein anderer Weg mit anderer
+ // Skalierung. Der Betreiber meldet den nativen Empfang mehrfach als
+ // "sehr sehr leise", waehrend ExpertSDR2 am SELBEN Geraet ohne
+ // Antenne "perfekt" laut ist. Damit ist belegt, dass es keine Frage
+ // der fehlenden Antenne ist, sondern eine Luecke hier.
+ //
+ // Seine Anforderung, woertlich: "rauschen muss immer zu hoeren sein".
+ //
+ // Statt eine neue Zahl zu RATEN wird sie messbar gemacht: der
+ // Betreiber dreht sie, bis es gegen ExpertSDR2 stimmt, und der
+ // gefundene Wert kommt danach fest ins Profil. Eine geratene Zahl
+ // waere genau der Fehler, der am 2026-09-24 schon einmal "schlechtes
+ // Rauschen" erzeugt hat.
+ double trimDb = m_profile->rxLevelTrimDb;
+ const QString ausEinstellung =
+ AppSettings::instance()
+ .value(QStringLiteral("SunSdrRxLevelTrimDb"), QString())
+ .toString()
+ .trimmed();
+ bool gelesen = false;
+ if (!ausEinstellung.isEmpty()) {
+ const double wert = ausEinstellung.toDouble(&gelesen);
+ if (gelesen) { trimDb = wert; }
+ }
+ if (qEnvironmentVariableIsSet("LONGPATH_SUNSDR_PEGEL")) {
+ bool ok = false;
+ const double wert =
+ qEnvironmentVariable("LONGPATH_SUNSDR_PEGEL").toDouble(&ok);
+ if (ok) { trimDb = wert; gelesen = true; }
+ }
+ m_rxLevelGain = static_cast(std::pow(10.0, trimDb / 20.0));
+ qCInfo(lcSunSdr).noquote()
+ << QStringLiteral("SunSdr: Pegelabgleich %1 dB (%2)")
+ .arg(trimDb, 0, 'f', 1)
+ .arg(gelesen ? QStringLiteral("eingestellt")
+ : QStringLiteral("Vorgabe aus dem Profil"));
m_singleChannelWarned = false;
m_singleChannelSeen = false;
m_iqConfirmed = false;
@@ -253,20 +306,60 @@ void SunSdrRadioConnection::connectToRadio(const RadioInfo& info)
m_letzteAnschlagMeldungMs = -1;
m_anschlagProben = 0;
m_anschlagMeldungen = 0;
+ m_geraetSendet = false;
+ m_mikrofonPttFlanken = 0;
m_rahmenWiederholt = 0;
m_inventoryClock.invalidate();
m_lastStreamStateValid = false;
// Die Folgenummern-Zaehlung gehoert zur Sitzung: die erste Nummer der
// neuen Verbindung darf nicht gegen die letzte der alten gerechnet
// werden, sonst steht eine Luecke im Bericht, die es nie gab.
+ m_seqDeltas.clear();
+ // Auch der Zustand JE KANAL -- ohne das rechnet das erste Paket der
+ // neuen Verbindung gegen die letzte Nummer der alten, und es gilt als
+ // Spaetling statt als Anfang. Vom eigenen Pruefstand gefunden.
+ for (KanalZustand& kz : m_kanal) { kz = KanalZustand{}; }
m_seqSeen = false;
m_lastSeq = 0;
- m_seqDeltas.clear();
+ m_seqRing.clear();
+ m_seqOutOfPlace = 0;
+ // Rate und Empfaengerzahl werden hier ABSICHTLICH NICHT
+ // zurueckgesetzt. RadioModel::connectToRadio schiebt beide
+ // ausdruecklich VOR dem Verbindungsaufruf in die Warteschlange des
+ // Verbindungsfadens (eigene Begruendung dort: sonst liest der
+ // Rahmenbau die Vorgaben statt der Wahl des Betreibers) -- ein Reset
+ // an dieser Stelle wirft also genau das weg, was gerade gesetzt
+ // wurde.
+ //
+ // Am 2026-10-04 an der echten QRP so gesehen: die App meldete
+ // "Connecting with sampleRate= 96000", und das Geraet streamte
+ // weiter mit 48 (Stromkopf 0100, 240 Nummern/s). WDSP lief auf
+ // 96 kHz, die Daten kamen mit 48 -- genau der Fall, vor dem die
+ // Warnung in setSampleRate selbst steht.
+ //
+ // Die Umgebung bleibt eine Vorbelegung, aber nur wenn sie
+ // tatsaechlich gesetzt ist; sonst gilt, was der Aufrufer wollte.
+ if (qEnvironmentVariableIsSet("LONGPATH_SUNSDR_STROMMODUS")) {
+ m_stromModus = stromModusAusUmgebung();
+ // Rate und Empfaengerzahl mitziehen, sonst widersprechen sie dem
+ // Modus: setSampleRate(48000) kehrte bei unveraenderter Rate frueh
+ // zurueck und liess den Modus auf je96 stehen -- das Geraet
+ // streamt dann 96 kHz, waehrend WDSP auf 48 steht. Nur im
+ // Messbetrieb erreichbar, aber genau dort wird gemessen.
+ m_rateHz = (m_stromModus == SunSdr::StromModus::ZweiStroemeJe96)
+ ? 96000 : 48000;
+ if (m_stromModus == SunSdr::StromModus::ZweiStroemeJe48) {
+ m_aktiveEmpfaenger = qMax(2, m_aktiveEmpfaenger);
+ }
+ }
+ m_sucheVersuch = 0;
+ m_stoppGeschickt = 0;
m_iqSeqWndFrames = 0;
m_iqSeqWndRepeats = 0;
m_iqSeqWndLost = 0;
m_iqSeqWndEvents = 0;
m_iqSeqWndBackwards = 0;
+ m_iqSeqWndRestarts = 0;
m_iqSeqWndClock.invalidate();
m_iqSeqCleanClock.invalidate();
m_lastGapSignalMs = -1;
@@ -358,19 +451,56 @@ void SunSdrRadioConnection::sendDiscoveryBroadcast()
// Rueckschleife gibt es keine Rundsendeadresse, also erreichte die
// Anfrage ein Messgeraet auf 127.0.0.1 nie.
if (!m_radioInfo.address.isNull()) {
- m_controlSocket->writeDatagram(query, m_radioInfo.address, ctrlPort);
- recordBytesSent(static_cast(query.size()));
+ // Rueckgabewert pruefen: am 2026-10-04 stand in einem Mitschnitt
+ // waehrend eines Fehlversuchs KEIN einziges Paket an die Adresse
+ // des Geraets -- dieses hier ging nie hinaus, und nichts hat es
+ // gesagt. Eine halbe Stunde Suche spaeter war das der einzige
+ // Hinweis, der uebrig blieb.
+ const qint64 n = m_controlSocket->writeDatagram(
+ query, m_radioInfo.address, ctrlPort);
+ if (n < 0) {
+ qCWarning(lcSunSdr)
+ << "SunSdr: die Anfrage an" << m_radioInfo.address
+ << "ging NICHT hinaus:" << m_controlSocket->errorString();
+ } else {
+ recordBytesSent(n);
+ }
+ } else {
+ qCWarning(lcSunSdr)
+ << "SunSdr: keine Adresse im Geraeteeintrag -- es geht nur "
+ "der Rundruf hinaus. Antwortet das Geraet nicht, fehlt "
+ "hier der direkte Weg.";
}
+ int rundrufe = 0;
for (const QNetworkInterface& iface : QNetworkInterface::allInterfaces()) {
if (!(iface.flags() & QNetworkInterface::IsUp)) { continue; }
for (const QNetworkAddressEntry& entry : iface.addressEntries()) {
const QHostAddress bcast = entry.broadcast();
if (bcast.isNull()) { continue; }
- m_controlSocket->writeDatagram(query, bcast, ctrlPort);
- recordBytesSent(static_cast(query.size()));
+ const qint64 nb =
+ m_controlSocket->writeDatagram(query, bcast, ctrlPort);
+ if (nb < 0) {
+ qCWarning(lcSunSdr)
+ << "SunSdr: Rundruf an" << bcast << "ging NICHT "
+ "hinaus:" << m_controlSocket->errorString();
+ } else {
+ recordBytesSent(nb);
+ ++rundrufe;
+ }
}
}
+
+ // Ohne eine einzige Rundsendeadresse kommt die Anfrage nur an das
+ // Geraet, das im Eintrag steht -- und wenn dort nichts steht, nirgends.
+ if (rundrufe == 0) {
+ qCWarning(lcSunSdr)
+ << "SunSdr: keine Schnittstelle hatte eine Rundsendeadresse -- "
+ "die Suche ging nur direkt hinaus.";
+ } else {
+ qCDebug(lcSunSdr) << "SunSdr: Suche hinaus ueber" << rundrufe
+ << "Rundsendeadresse(n)";
+ }
}
void SunSdrRadioConnection::onConnectTimeout()
@@ -388,6 +518,37 @@ void SunSdrRadioConnection::onConnectTimeout()
// here only when no beacon was ever seen at all.
const bool gotBeacon = !m_awaitingBeacon;
+ // ── Kein Beacon? Dann warten und von selbst noch einmal suchen ──────
+ //
+ // Am 2026-10-04 gemessen: das Geraet sperrt nach einem ABRUPTEN
+ // Programmende rund eine Minute und kommt dann von selbst zurueck
+ // (60 s nach kill -9, sofort nach einem sauberen Beenden). Der
+ // Betreiber hat an dem Vormittag eine halbe Stunde verloren, weil
+ // jeder neue Klick wieder in dasselbe Fenster fiel.
+ //
+ // Darum versucht es die Verbindung jetzt selbst weiter, statt nach
+ // drei Sekunden aufzugeben: kSucheVersuche Anlaeufe im Abstand von
+ // kSuchePauseMs decken die gemessene Minute ab. Der Zustand bleibt
+ // dabei Connecting -- die Oberflaeche zeigt also weiter "verbinde",
+ // und wer nicht warten will, drueckt Trennen.
+ //
+ // Nur wenn ueberhaupt KEIN Beacon kam. Kam einer und nur der Strom
+ // fehlt, ist das ein anderer Fehler und gehoert nicht in diese
+ // Schleife.
+ if (m_sucheWiederholung && !gotBeacon && m_sucheVersuch < kSucheVersuche) {
+ ++m_sucheVersuch;
+ qCInfo(lcSunSdr).noquote()
+ << QStringLiteral(
+ "SunSdr: keine Antwort -- Versuch %1 von %2. Das Geraet "
+ "sperrt nach einem abrupten Programmende rund eine "
+ "Minute; es wird %3 s gewartet und dann von selbst "
+ "erneut gesucht.")
+ .arg(m_sucheVersuch).arg(kSucheVersuche)
+ .arg(kSuchePauseMs / 1000);
+ if (m_erneutTimer) { m_erneutTimer->start(kSuchePauseMs); }
+ return; // Zustand bleibt Connecting, Sockets bleiben offen
+ }
+
// Full teardown here, not just a state flip — mirrors
// P1RadioConnection::onConnectTimeout()'s own "Issue #239" precedent
// (P1RadioConnection.cpp, tear down to Disconnected so the UI does
@@ -449,8 +610,25 @@ void SunSdrRadioConnection::onConnectTimeout()
gotBeacon
? QStringLiteral("SunSDR: beacon replied but no "
"I/Q stream followed")
- : QStringLiteral("SunSDR: no beacon reply — radio "
- "unreachable or discovery blocked"));
+ // Am 2026-10-04 hat diese Meldung eine halbe
+ // Stunde gekostet: Geraet, Netz, Einstellungen
+ // und Programmfassung waren einzeln geprueft in
+ // Ordnung, die QRP hing an einer toten Sitzung
+ // und antwortete deshalb nicht mehr. Sie bedient
+ // EINEN Client. Der dritte Fall gehoert also in
+ // den Text, sonst sucht man an den ersten beiden.
+ : QStringLiteral(
+ "SunSDR: keine Antwort des Geraets. "
+ "HAEUFIGSTE URSACHE: das Geraet sperrt "
+ "nach einem abrupten Programmende rund "
+ "EINE MINUTE (am 2026-10-04 gemessen: 60 s "
+ "nach kill -9, sofort nach einem sauberen "
+ "Beenden). Also kurz warten statt gleich "
+ "wieder zu verbinden — erneutes Klicken "
+ "faellt wieder in dasselbe Fenster. "
+ "Sonst: ein anderes Programm ist mit ihm "
+ "verbunden (es bedient nur einen Client), "
+ "oder es ist nicht erreichbar."));
}
void SunSdrRadioConnection::disconnect()
@@ -460,6 +638,76 @@ void SunSdrRadioConnection::disconnect()
// soll das Ergebnis im Log finden, ohne es waehrenddessen abfragen zu
// muessen. Nur wenn ueberhaupt etwas angekommen ist -- eine Zeile
// "nichts aufgenommen" bei jedem Programmende waere Laerm.
+ // Ein haengendes PTT darf eine Sitzung nicht ueberleben: bricht die
+ // Verbindung ab, waehrend das Geraet sendet, bliebe Longpath sonst im
+ // Zustand "Taste gedrueckt" -- und das ist der falsche Zustand, in dem
+ // man einen Sender in Erinnerung behaelt.
+ if (m_geraetSendet) {
+ m_geraetSendet = false;
+ qCInfo(lcSunSdr) << "SunSdr: Verbindung endet, waehrend das Geraet "
+ "sendete -- PTT wird zurueckgenommen";
+ emit micPttFromRadio(false);
+ }
+
+ // Dem Geraet sagen, dass der Strom aufhoeren soll -- bis zum
+ // 2026-10-03 hat dieser Treiber beim Trennen GAR NICHTS geschickt, und
+ // die QRP streamte danach unbegrenzt weiter (gemessen: 1940 Pakete/s,
+ // 2,3 MB/s ins Leere, bis zum Ausschalten). Der Rahmen steht im
+ // Mitschnitt vom selben Tag: 0x02 mit vier Nullbytes, und das letzte
+ // Strompaket liegt in derselben Millisekunde.
+ //
+ // Nur wenn die Verbindung wirklich stand: vor dem Handschlag gibt es
+ // keine Gegenstelle, und ein Stopp an eine Adresse, die wir nicht
+ // kennen, waere ein Paket ins Nichts.
+ if (m_running && !m_awaitingBeacon && m_profile && m_controlSocket
+ && !m_radioAddr.isNull()) {
+ // Nachschicken, solange er unquittiert bleibt -- siehe
+ // kStoppVersuche im Kopf. Ohne das bleibt die QRP an einer toten
+ // Sitzung haengen und nimmt niemanden mehr an.
+ for (int versuch = 1; versuch <= kStoppVersuche; ++versuch) {
+ sendeSteuerrahmen(SunSdr::buildStopFrame(*m_profile),
+ "Stopp 0x02 beim Trennen");
+ ++m_stoppGeschickt;
+ // Dem Paket einen Augenblick geben, bevor die Sockets zugehen
+ // -- sonst raeumt der Socket es mit ab.
+ if (m_controlSocket->waitForBytesWritten(200)) { /* hinaus */ }
+
+ // Auf die Quittung warten und dabei wirklich lesen: der
+ // Ereignisschleife laeuft hier nichts mehr zu.
+ QElapsedTimer warte;
+ warte.start();
+ while (warte.elapsed() < kStoppQuittungFristMs
+ && rahmenNochOffen(0x02)) {
+ const int rest =
+ int(kStoppQuittungFristMs - warte.elapsed());
+ if (rest > 0 && m_controlSocket->waitForReadyRead(rest)) {
+ while (m_controlSocket->hasPendingDatagrams()) {
+ const QNetworkDatagram dg =
+ m_controlSocket->receiveDatagram();
+ recordBytesReceived(
+ static_cast(dg.data().size()));
+ processControlDatagram(dg.data(),
+ dg.senderAddress());
+ }
+ }
+ }
+ if (!rahmenNochOffen(0x02)) {
+ qCInfo(lcSunSdr)
+ << "SunSdr: Stopp beim Trennen quittiert nach Versuch"
+ << versuch;
+ break;
+ }
+ if (versuch == kStoppVersuche) {
+ qCWarning(lcSunSdr)
+ << "SunSdr: der Stopp blieb nach" << kStoppVersuche
+ << "Versuchen unquittiert. Das Geraet haelt die "
+ "Sitzung moeglicherweise fest und nimmt den "
+ "naechsten Verbindungsversuch nicht an -- dann "
+ "hilft nur Aus- und Einschalten.";
+ }
+ }
+ }
+
berichteMithoeren();
m_running = false;
@@ -903,35 +1151,87 @@ void SunSdrRadioConnection::setMox(bool enabled)
void SunSdrRadioConnection::setActiveReceiverCount(int count)
{
- Q_UNUSED(count);
+ // Bis zum 2026-10-03 ein No-op. Jetzt merkt sich die Verbindung die
+ // Zahl -- nicht um sie dem Geraet zu sagen (welcher Rahmen das tut,
+ // ist offen), sondern um zu wissen, fuer welche Kanaele es oben
+ // ueberhaupt einen Empfaenger gibt. Ein zweiter Strom, den niemand
+ // hoert, wird verworfen statt eingespeist.
+ const int neu = qBound(1, count, kMaxKanaele);
+ if (neu == m_aktiveEmpfaenger) { return; }
+ m_aktiveEmpfaenger = neu;
+ qCInfo(lcSunSdr) << "SunSdr: aktive Empfaenger ->" << m_aktiveEmpfaenger;
+
+ // Und jetzt der Teil, der bis zum 2026-10-04 fehlte: ein zweiter
+ // Empfaenger braucht den zweiten STROM. Der Stromstart-Rahmen traegt
+ // beides -- erstes Byte die Zahl der Stroeme, zweites die Ratenstufe
+ // (SunSdrProtocol.h, StromModus). Bisher waehlte nur die Rate den
+ // Modus, also konnte RX2 gar nie Daten bekommen.
+ stromModusNachziehen();
+}
+
+// Der Modus ergibt sich aus BEIDEM: Zahl der Empfaenger und Rate.
+//
+// 1 Empfaenger, 48 kHz -> ein Strom
+// 2 Empfaenger, 48 kHz -> zwei Stroeme, je 48
+// 1 oder 2, 96 kHz -> zwei Stroeme, je 96 (einen Strom mit 96 kHz
+// gibt es auf dem Draht nicht)
+//
+// Am 2026-10-04 aus einem Mitschnitt des Betreibers belegt, in dem
+// ExpertSDR2 mit RX UND RX2 lief: der zweite Kanal traegt echtes I/Q
+// (-127,9 dBFS, 31,5 % Q ungleich null) -- er ist nicht stumm, Longpath
+// hat ihn nur weggeworfen.
+void SunSdrRadioConnection::stromModusNachziehen()
+{
+ const SunSdr::StromModus gewuenscht =
+ (m_rateHz >= 96000)
+ ? SunSdr::StromModus::ZweiStroemeJe96
+ : (m_aktiveEmpfaenger >= 2 ? SunSdr::StromModus::ZweiStroemeJe48
+ : SunSdr::StromModus::EinStrom48);
+ if (gewuenscht == m_stromModus) { return; }
+ m_stromModus = gewuenscht;
+ qCInfo(lcSunSdr) << "SunSdr: Stromstart-Rahmen ->"
+ << (gewuenscht == SunSdr::StromModus::EinStrom48
+ ? "ein Strom, 48 kHz"
+ : gewuenscht == SunSdr::StromModus::ZweiStroemeJe48
+ ? "zwei Stroeme, je 48 kHz"
+ : "zwei Stroeme, je 96 kHz");
+ if (m_running && !m_awaitingBeacon && m_profile) {
+ sendeSteuerrahmen(SunSdr::buildStromStartFrame(*m_profile, m_stromModus),
+ "Stromstart 0x01 (Modus umgestellt)");
+ }
}
void SunSdrRadioConnection::setSampleRate(int sampleRate)
{
- // 48 000 Hz, am Geraet gemessen (2026-09-23/24): 240 Bloecke je
- // Sekunde zu 200 Probenpaaren. Hier stand bis zum 2026-10-02 noch
- // "fixed at 312,500 Hz" -- diese Zahl war von der SunSDR2 DX
- // abgeschrieben, fuer die QRP nie geprueft und ist widerlegt. Sie
- // stehenzulassen heisst, dass der naechste Leser sie glaubt; die
- // richtige Zahl steht in BoardCapabilities (kSunSdr2Qrp.sampleRates)
- // und in longpath-qrp-48khz.
+ // Seit dem 2026-10-03 ist das kein No-op mehr: die Rate steht im
+ // Stromstart-Rahmen 0x01, und der ist am Geraet durchgemessen
+ // (SunSdrProtocol.h, StromModus). 48 000 und 96 000 sind belegt.
//
- // Umstellen kann diese Verbindung die Rate nicht: welcher Opcode das
- // tut, ist offen -- es braucht einen Mitschnitt, bei dem ExpertSDR2
- // die Rate umstellt. Eine Anfrage auf etwas anderes wird darum
- // protokolliert statt still verschluckt: sie ist nicht falsch
- // gestellt, sie ist hier nur nicht ausfuehrbar, und wer im Log nach
- // der Ursache einer unerwarteten Rate sucht, soll diese Zeile finden.
- const double native = m_profile ? m_profile->rxNativeRateHz
- : SunSdr::kProfileQrp.rxNativeRateHz;
- if (double(sampleRate) != native) {
- qCDebug(lcSunSdr) << "SunSdr: setSampleRate(" << sampleRate
- << ") nicht ausfuehrbar -- das Geraet laeuft auf"
- << native
- << "Hz, der Opcode zum Umstellen ist unbekannt";
+ // Warum nur diese zwei: eine Rate, die nicht aus einem Mitschnitt
+ // kommt, waere geraten -- und ein falsch gesetzter Rahmen bedeutet
+ // nicht "geht nicht", sondern Daten einer Rate in einem Kanal einer
+ // anderen. Genau das ist am 2026-09-24 passiert (48k-Daten in einem
+ // 192k-Kanal) und wurde am Geraet als "schlechtes Rauschen" gehoert.
+ if (sampleRate != 48000 && sampleRate != 96000) {
+ qCWarning(lcSunSdr)
+ << "SunSdr: setSampleRate(" << sampleRate
+ << ") -- fuer diese Rate ist kein Stromstart-Rahmen belegt. Es "
+ "bleibt bei" << m_rateHz
+ << "Hz. Belegt sind 48000 und 96000 (am Geraet gemessen "
+ "2026-10-03).";
+ return;
}
+ if (sampleRate == m_rateHz) { return; }
+ m_rateHz = sampleRate;
+
+ // Den Modus leitet stromModusNachziehen() aus Rate UND Empfaengerzahl
+ // ab und schickt den Stromstart-Rahmen, wenn die Verbindung schon
+ // steht. Das Geraet faengt die Folgenummern dann bei null an (am
+ // 2026-10-03 gemessen; auditStreamSeq erkennt das als Neuanfang).
+ stromModusNachziehen();
}
+
void SunSdrRadioConnection::onControlReadyRead()
{
if (!m_controlSocket) { return; }
@@ -986,6 +1286,19 @@ void SunSdrRadioConnection::processControlDatagram(const QByteArray& data,
|| quint8(data[0]) != m_profile->magic0
|| quint8(data[1]) != SunSdr::kMagic1
|| quint8(data[2]) != 0x01) {
+ // Nicht still verwerfen. Am 2026-10-04 stand die Frage eine halbe
+ // Stunde im Raum, ob ueberhaupt etwas im Socket ankommt -- ein
+ // tcpdump hat am Ende vier Beacon-Antworten gezeigt, die hier nie
+ // ankamen. Wer das Protokoll einschaltet
+ // (QT_LOGGING_RULES='longpath.sunsdr.debug=true'), soll den
+ // Unterschied zwischen "nichts empfangen" und "etwas empfangen,
+ // aber verworfen" in EINER Zeile sehen.
+ qCDebug(lcSunSdr).noquote()
+ << QStringLiteral("SunSdr: waehrend der Suche verworfen, von "
+ "%1, %2 Byte: %3")
+ .arg(sender.toString())
+ .arg(data.size())
+ .arg(QString::fromLatin1(data.left(8).toHex(' ')));
return;
}
@@ -1002,8 +1315,33 @@ void SunSdrRadioConnection::processControlDatagram(const QByteArray& data,
// beide Seiten: PRE davor, EXTRA danach.
sendBenchFrames(QStringLiteral("LONGPATH_SUNSDR_PRE"));
- const QByteArray stateSync = stateSyncFrameForTest();
- sendeSteuerrahmen(stateSync, "Zustandsrahmen beim Verbinden");
+ const SunSdr::StromModus modus = m_stromModus;
+ const QByteArray stateSync =
+ SunSdr::buildStromStartFrame(*m_profile, modus);
+ if (modus != SunSdr::StromModus::EinStrom48) {
+ // Woher der Modus stammt, gehoert in die Zeile: seit dem
+ // 2026-10-04 kommt er im Normalfall aus Rate und
+ // Empfaengerzahl der Oberflaeche, nicht mehr nur aus der
+ // Umgebung. Stand hier pauschal "aus der Umgebung", suchte
+ // der Leser an der falschen Stelle.
+ const bool ausUmgebung =
+ qEnvironmentVariableIsSet("LONGPATH_SUNSDR_STROMMODUS");
+ qCWarning(lcSunSdr).noquote()
+ << QStringLiteral(
+ "SunSdr: Strommodus %2 -- %1 (Rate %3 Hz, %4 "
+ "Empfaenger). Der zweite Kanal hat oben nur dann "
+ "einen Empfaenger, wenn sich eine zweite Scheibe "
+ "an ihn bindet.")
+ .arg(QString(),
+ ausUmgebung ? QStringLiteral("aus der Umgebung")
+ : QStringLiteral("aus der Bedienung"))
+ .arg(m_rateHz)
+ .arg(m_aktiveEmpfaenger)
+ .arg(modus == SunSdr::StromModus::ZweiStroemeJe48
+ ? QStringLiteral("zwei Stroeme, je 48 kHz")
+ : QStringLiteral("zwei Stroeme, je 96 kHz"));
+ }
+ sendeSteuerrahmen(stateSync, "Stromstart 0x01");
sendBenchFrames(QStringLiteral("LONGPATH_SUNSDR_EXTRA"));
}
@@ -1108,12 +1446,62 @@ void SunSdrRadioConnection::processStreamDatagram(const QByteArray& data,
// sagt. Sie hier wegzuwerfen, war der zweite Grund dafuer, dass
// dieser Treiber keine Messwerte kennt.
noteStreamState(hdr);
+ pruefeMikrofonPtt(hdr.opcode);
if (hdr.opcode != SunSdr::kOpIqRxIdle) {
return; // TX-active frames don't apply to a receive-only connection
}
- auditStreamSeq(hdr.seq);
+ const int kanal = kanalVon(hdr);
+
+ KanalZustand& kz = m_kanal[kanal];
+ ++kz.pakete;
+
+ // Wiederkehrende Nummer: Wiederholung oder Fortsetzung? Das entscheidet
+ // der Inhalt (Begruendung am KanalZustand im Kopf). Verglichen wird nur
+ // hier, also nur bei wiederkehrender Nummer.
+ bool fortsetzung = false;
+ const char* nutz = data.constData() + SunSdr::kIqHeaderSize;
+ const int nutzLen = int(data.size()) - SunSdr::kIqHeaderSize;
+ if (kz.gesehen && hdr.seq == kz.letzteNummer) {
+ const bool gleich =
+ kz.letzteNutzlast.size() == nutzLen
+ && std::memcmp(kz.letzteNutzlast.constData(), nutz, size_t(nutzLen)) == 0;
+ if (!gleich) {
+ fortsetzung = true;
+ ++kz.fortsetzungen;
+ }
+ } else {
+ kz.letzteNutzlast = QByteArray(nutz, nutzLen);
+ }
+ kz.gesehen = true;
+ kz.letzteNummer = hdr.seq;
+
+ // Eine Fortsetzung ist KEIN neues Paket im Sinne der Folgenummern --
+ // sie traegt die naechsten Proben derselben Nummer. Sie darf also
+ // weder als Wiederholung noch als Luecke gezaehlt werden.
+ if (!fortsetzung) {
+ // Ein billiges Merkmal des Inhalts mitgeben: ohne das galt jede
+ // wiederkehrende Nummer als bytegleiche Kopie, und die Meldung
+ // "Kopien je Nummer" -- an der die Achtfachung haengt -- hatte
+ // eine Grundlast von 1,4, wo auf dem Draht 0 stand
+ // (Mitschnitt vom 2026-10-03, ausgewertet am 2026-10-04).
+ const quint64 inhalt =
+ quint64(qHashBits(nutz, size_t(qMax(0, nutzLen)), 0));
+ auditStreamSeq(kanal, hdr.seq, inhalt);
+ }
+
+ // Jetzt erst verwerfen, wenn es fuer diesen Kanal oben keinen
+ // Empfaenger gibt (BoardCapabilities: maxReceivers = 1). NACH der
+ // Folgenummern-Zaehlung, nicht davor: die Nummern laufen GLOBAL ueber
+ // alle Stroeme, also fehlt jede uebersprungene Nummer im Nummernraum
+ // und erscheint als Luecke. Davor gestellt meldete der Zaehler 50 %
+ // VERLUST bei einem vollkommen gesunden Strom -- am 2026-10-03 im
+ // Messlauf am Geraet gesehen, zum zweiten Mal an derselben Stelle.
+ if (kanal >= m_aktiveEmpfaenger) {
+ ++kz.verworfen;
+ return;
+ }
if (blockReplyEnabled()) {
replyToBlock(hdr.seq);
@@ -1322,7 +1710,9 @@ void SunSdrRadioConnection::processStreamDatagram(const QByteArray& data,
}
}
- emit iqDataReceived(/*hwReceiverIndex=*/0, samples);
+ // Der Kanal aus dem Stromkopf, nicht mehr fest 0: bei zwei Stroemen
+ // wuerden sonst die Proben beider in einem Topf landen.
+ emit iqDataReceived(kanal, samples);
emit frameReceived();
}
@@ -1419,21 +1809,114 @@ void SunSdrRadioConnection::onDataWatchdogTick()
// setAttenuator() out of this block.
void SunSdrRadioConnection::setTxFrequency(quint64) {}
-void SunSdrRadioConnection::setPreamp(bool) {}
-void SunSdrRadioConnection::setTxDrive(int) {}
+// Der Vorverstaerker dieses Geraets ist KEIN Schalter, sondern vier feste
+// Stufen (-20 / -10 / 0 / +10 dB, Opcode 0x04, am 2026-09-25 in
+// ExpertSDR2 durchgeschaltet und bestaetigt). Gebaut ist er als
+// setPreampModeIndex(); der An/Aus-Weg der OpenHPSDR-Geraete passt hier
+// nicht.
+//
+// Ein leerer Rumpf blieb bis zum 2026-10-04 still -- derselbe Fehler wie
+// bei setTxDrive: ein Bedienelement, das nichts tut und nichts sagt.
+// Einmal je Sitzung melden, nicht bei jedem Klick.
+void SunSdrRadioConnection::setPreamp(bool an)
+{
+ if (m_preampSchalterGemeldet) { return; }
+ m_preampSchalterGemeldet = true;
+ qCInfo(lcSunSdr).noquote()
+ << QStringLiteral("SunSdr: dieses Geraet kennt keinen "
+ "Vorverstaerker-Schalter -- \"%1\" ist nicht "
+ "hinausgegangen. Es hat vier feste Stufen "
+ "(-20/-10/0/+10 dB), zu stellen ueber die "
+ "Stufenauswahl.")
+ .arg(an ? QStringLiteral("ein") : QStringLiteral("aus"));
+}
+
+// Ein leerer Rumpf, der ein BEDIENELEMENT bedient, ist eine Falle: der
+// Betreiber dreht die Leistung herunter, sieht die Zahl sinken, und am
+// Geraet aendert sich nichts. Beim Senden ist das kein Schoenheitsfehler.
+//
+// Der Rahmenbauer fuer 0x17 liegt fertig, aber die Nummer stammt von der
+// DX/PRO, und die QRP benutzt nachweislich andere (0x04 statt 0x05,
+// 0x07 statt 0x08 -- siehe docs/architecture/2026-10-02-sunsdr-paritaet.md
+// Abschnitt 3a). Verdrahtet wird sie erst, wenn sie am Geraet quittiert
+// wurde; bis dahin sagt Longpath wenigstens, dass es nichts tut --
+// einmal je Sitzung, nicht bei jedem Reglerschritt.
+void SunSdrRadioConnection::setTxDrive(int prozent)
+{
+ if (!m_txDriveGemeldet) {
+ m_txDriveGemeldet = true;
+ qCWarning(lcSunSdr).noquote()
+ << QStringLiteral(
+ "SunSdr: die Leistung laesst sich an diesem Geraet "
+ "(noch) nicht aus Longpath stellen -- %1 %% ist NICHT "
+ "hinausgegangen. Es gilt, was zuletzt in ExpertSDR2 "
+ "eingestellt war. Der Rahmen 0x17 ist gebaut, aber "
+ "seine Opcode-Nummer stammt von der DX/PRO und ist an "
+ "der QRP nicht bestaetigt; siehe "
+ "docs/development/sunsdr-abschluss-pruefplan.md.")
+ .arg(prozent);
+ }
+}
+// ── Antennenwahl: bewusst NICHT verdrahtet ─────────────────────────────
+//
+// Am 2026-10-04 habe ich sie verdrahtet (gated hinter
+// LONGPATH_SUNSDR_ANTENNE) und bin dabei in einen Waechter gelaufen, den
+// dieses Projekt genau dafuer hat: tst_sunsdr_protocol prueft, dass die
+// DX-staemmigen Sende-Rahmenbauer KEINE Aufrufstelle haben, und meldet
+//
+// buildAntennaSelectFrame() traegt eine DX-Opcode-Nummer, die fuer die
+// QRP nicht bestaetigt ist (bei drei gemessenen Befehlen liegt die QRP
+// um eins darunter). Erst bestaetigen, dann verdrahten.
+//
+// Der Waechter hat recht, und ein Schalter, der standardmaessig aus ist,
+// hebt ihn nicht auf: verdrahtet ist verdrahtet, und eine Umgebungs-
+// variable ist schnell gesetzt. Die Auswahlbytes stammen aus ArtemisSDR,
+// also von der DX/PRO, und die QRP benutzt nachweislich andere Nummern
+// (Vorverstaerker 0x04 statt 0x05, DDC 0x07 statt 0x08, erstes Byte 0x03
+// statt 0x32).
+//
+// Der Weg dahin steht in docs/development/sunsdr-abschluss-pruefplan.md,
+// Schritt B: die Antennenwahl ist dort der ERSTE Befehl, weil sie als
+// einzige nichts erzeugt -- sie schaltet nur ein Relais. Quittiert das
+// Geraet 0x15, darf diese Methode einen Rumpf bekommen.
+// ── Was dieses Geraet NICHT hat (Stand 2026-10-04) ─────────────────────
+//
+// Leer, weil es die Sache an der QRP nicht gibt -- nicht, weil sie fehlt.
+// Die Begruendung je Zeile steht in
+// docs/architecture/2026-10-02-sunsdr-paritaet.md, Abschnitt 1.
+void SunSdrRadioConnection::setTrxRelay(bool) {} // ocOutputCount = 0, kein OC-Board
+void SunSdrRadioConnection::setUserDigOut(quint8) {} // dito
+void SunSdrRadioConnection::setPuresignalRun(bool) {} // hasPureSignal = false
+void SunSdrRadioConnection::setWatchdogEnabled(bool) {} // HPSDR-FPGA-Wachhund; hier Blockantwort
+
+// ── Was zum Senden gehoert und noch nicht gebaut ist ───────────────────
+//
+// Nicht vergessen, sondern bewusst offen: die Opcode-Nummern dafuer
+// stammen von der DX/PRO, und die QRP benutzt nachweislich andere
+// (0x04 statt 0x05, 0x07 statt 0x08, erstes Byte 0x03 statt 0x32).
+// Erst bestaetigen, dann bauen -- der Weg dahin steht in
+// docs/development/sunsdr-abschluss-pruefplan.md und braucht einen
+// 50-Ohm-Abschluss, keine Antenne.
+//
+// tst_sunsdr_protocol haelt fest, dass die DX-staemmigen Rahmenbauer
+// KEINE Aufrufstelle haben. Am 2026-10-04 habe ich das mit
+// setAntennaRouting verletzt (gated hinter einer Umgebungsvariable) und
+// bin zu Recht aufgelaufen: verdrahtet ist verdrahtet.
void SunSdrRadioConnection::setAntennaRouting(AntennaRouting) {}
void SunSdrRadioConnection::sendTxIq(const float*, int) {}
-void SunSdrRadioConnection::setTrxRelay(bool) {}
+
+// ── Mikrofonweg: Opcode bestaetigt, Werte nicht ────────────────────────
+//
+// MIC_SOURCE (0x21) ist bestaetigt, was hineingehoert nicht. Diese
+// Schalter bleiben still, bis ein Mitschnitt zeigt, welche Werte
+// ExpertSDR2 dafuer schickt.
void SunSdrRadioConnection::setMicBoost(bool) {}
void SunSdrRadioConnection::setLineIn(bool) {}
void SunSdrRadioConnection::setMicTipRing(bool) {}
void SunSdrRadioConnection::setMicBias(bool) {}
void SunSdrRadioConnection::setLineInGain(int) {}
-void SunSdrRadioConnection::setUserDigOut(quint8) {}
-void SunSdrRadioConnection::setPuresignalRun(bool) {}
void SunSdrRadioConnection::setMicPTTDisabled(bool) {}
void SunSdrRadioConnection::setMicXlr(bool) {}
-void SunSdrRadioConnection::setWatchdogEnabled(bool) {}
// ---------------------------------------------------------------------------
@@ -1573,15 +2056,42 @@ void SunSdrRadioConnection::replyToBlock(quint16 seq)
// Kopf wie ExpertSDR2 im Leerlauf und wie ArtemisSDRs
// sunsdr_build_tx_silence(), sunsdr.c:4105-4115 [@f8b01d25c5]:
// op=0xFE, byte8=0x01, byte9=0x00, Nutzlast Null (Stille).
- QByteArray pkt = SunSdr::buildIqHeader(*m_profile, SunSdr::kOpIqRxIdle,
- seq, /*byte8=*/0x01, /*byte9=*/0x00);
- pkt.append(SunSdr::kIqPayloadSize, char(0));
+ //
+ // Mit Kopfantwort: GERADE Nummern bekommen nur den Kopf, Laengenfeld 0.
+ // Die Parität ist nicht ausgedacht, sie steht so im Mitschnitt --
+ // ExpertSDR2 antwortet auf 3, 5, 7, 9 ... voll und auf 0, 2, 4, 6 ...
+ // mit dem blossen Kopf (expert-96k.pcap, 2026-10-05).
+ const bool nurKopf = kopfAntwortEnabled() && (seq % 2 == 0);
+ QByteArray pkt = SunSdr::buildIqHeader(
+ *m_profile, SunSdr::kOpIqRxIdle, seq, /*byte8=*/0x01, /*byte9=*/0x00,
+ nurKopf ? 0 : SunSdr::kIqPayloadSize);
+ if (!nurKopf) {
+ pkt.append(SunSdr::kIqPayloadSize, char(0));
+ }
m_streamSocket->writeDatagram(pkt, m_radioAddr, m_profile->defaultStreamPort);
recordBytesSent(static_cast(pkt.size()));
++m_blockRepliesSent;
+ if (nurKopf) { ++m_bareBlockRepliesSent; }
+ m_lastBlockReplyBytes = int(pkt.size());
m_lastBlockReplySeq = seq;
}
+bool SunSdrRadioConnection::kopfAntwortEnabled()
+{
+ if (!m_kopfAntwortChecked) {
+ m_kopfAntwortChecked = true;
+ // Vorgabe AN -- am 2026-10-05 am Geraet gemessen (Begruendung im
+ // Kopf): die Wiederholungen bleiben unveraendert, der Rueckweg
+ // halbiert sich. LONGPATH_SUNSDR_KOPFANTWORT=0 schaltet aus.
+ const QByteArray env = qgetenv("LONGPATH_SUNSDR_KOPFANTWORT").trimmed();
+ m_kopfAntwortOn = env.isEmpty() ? true : (env != "0");
+ qCInfo(lcSunSdr) << "SunSdr: Kopfantwort"
+ << (m_kopfAntwortOn ? "an" : "aus")
+ << (env.isEmpty() ? "(Vorgabe)" : "(LONGPATH_SUNSDR_KOPFANTWORT)");
+ }
+ return m_kopfAntwortOn;
+}
+
void SunSdrRadioConnection::probeFeed(quint16 seq, const QByteArray& payload)
{
++m_probePackets;
@@ -1893,7 +2403,8 @@ QString SunSdrRadioConnection::frameInventoryReport() const
// (65535 -> 0 ergibt 1, nicht -65535).
// ---------------------------------------------------------------------------
-void SunSdrRadioConnection::auditStreamSeq(quint16 seq)
+void SunSdrRadioConnection::auditStreamSeq(int kanal, quint16 seq,
+ quint64 inhalt)
{
if (!m_iqSeqWndClock.isValid()) {
m_iqSeqWndClock.start();
@@ -1910,15 +2421,26 @@ void SunSdrRadioConnection::auditStreamSeq(quint16 seq)
berichteFolgenummern();
}
- const auto merken = [this](quint16 n) {
- m_seqRing.append(n);
+ // Ein GLOBALER Nummernraum fuer alle Stroeme -- am 2026-10-03 am Geraet
+ // gemessen (siehe KanalZustand im Kopf). Der Kanal steht nur in der
+ // Meldung, damit eine Luecke zuzuordnen ist.
+ Q_UNUSED(kanal);
+
+ const auto merken = [this](quint16 n, quint64 h) {
+ m_seqRing.append(qMakePair(n, h));
while (m_seqRing.size() > kSeqRingSize) { m_seqRing.removeFirst(); }
};
+ const auto suchen = [this](quint16 n) -> QPair* {
+ for (int i = m_seqRing.size() - 1; i >= 0; --i) {
+ if (m_seqRing[i].first == n) { return &m_seqRing[i]; }
+ }
+ return nullptr;
+ };
if (!m_seqSeen) {
m_seqSeen = true;
m_lastSeq = seq;
- merken(seq);
+ merken(seq, inhalt);
++m_iqSeqWndFrames;
return;
}
@@ -1926,8 +2448,19 @@ void SunSdrRadioConnection::auditStreamSeq(quint16 seq)
// 1. Schon gesehen? Dann ist es eine Wiederholung -- die QRP schickt
// bytegleiche Kopien, und zwar mit Abstand, nicht direkt
// hintereinander (am 2026-10-03 gemessen, siehe Kopf).
- if (m_seqRing.contains(seq)) {
- ++m_iqSeqWndRepeats;
+ if (QPair* eintrag = suchen(seq)) {
+ if (eintrag->second == inhalt) {
+ // Wirklich bytegleich -- das ist die Wiederholung, an der die
+ // Achtfachung haengt.
+ ++m_iqSeqWndRepeats;
+ m_seqOutOfPlace = 0;
+ return;
+ }
+ // Gleiche Nummer, anderer Inhalt: der Zaehler ist umgelaufen. Das
+ // ist ein neuer Block, keine Kopie -- und auch kein Verlust.
+ eintrag->second = inhalt;
+ m_lastSeq = seq;
+ ++m_iqSeqWndFrames;
m_seqOutOfPlace = 0;
return;
}
@@ -1937,7 +2470,7 @@ void SunSdrRadioConnection::auditStreamSeq(quint16 seq)
// 2. Der Normalfall: die naechste Nummer.
if (delta == 1) {
m_lastSeq = seq;
- merken(seq);
+ merken(seq, inhalt);
++m_iqSeqWndFrames;
m_seqOutOfPlace = 0;
return;
@@ -1948,12 +2481,11 @@ void SunSdrRadioConnection::auditStreamSeq(quint16 seq)
m_iqSeqWndLost += quint64(delta) - 1;
++m_iqSeqWndEvents;
m_lastSeq = seq;
- merken(seq);
+ merken(seq, inhalt);
++m_iqSeqWndFrames;
m_seqOutOfPlace = 0;
- // Gedrosselt wie bei P1/P2 (20 ms): ein schlechter Netzweg darf die
- // Ereignisschlange nicht fluten. Minus eins heisst "noch nie
+ // Gedrosselt wie bei P1/P2 (20 ms). Minus eins heisst "noch nie
// gemeldet" -- mit 0 als Startwert verschwand die ERSTE Luecke
// einer Verbindung still, weil die Uhr am Anfang selbst 0 ist.
const qint64 now = m_iqSeqWndClock.elapsed();
@@ -1966,26 +2498,22 @@ void SunSdrRadioConnection::auditStreamSeq(quint16 seq)
// 4. Passt zu nichts: ein Spaetling, oder der Strom hat neu angefangen.
// Unterschieden wird nicht an der Groesse der Differenz -- die ist
- // bei einem Neuanfang beliebig --, sondern daran, ob es BEI DIESEM
- // EINEN Paket bleibt. Ein Spaetling ist ein Einzelfall zwischen
- // passenden Paketen; ein Neuanfang betrifft alles, was danach kommt.
+ // bei einem Neuanfang beliebig --, sondern daran, ob es bei DIESEM
+ // EINEN Paket bleibt.
++m_seqOutOfPlace;
if (m_seqOutOfPlace < kSeqRestartAfter) {
++m_iqSeqWndBackwards;
- return; // m_lastSeq NICHT zurueckdrehen
+ return; // lastSeq NICHT zurueckdrehen
}
- // Neuanfang: ab hier gilt die neue Folge. Ohne diesen Zweig hing der
- // Zaehler nach einem Stromneustart dauerhaft fest (gemessen: 0 neue
- // Nummern, 1301 rueckwaerts in 5 s).
++m_iqSeqWndRestarts;
qCInfo(lcSunSdr).nospace()
- << "SunSdr: Folgenummern fangen neu an (" << m_lastSeq << " -> "
- << seq << ") -- der Strom wurde neu gestartet";
+ << "SunSdr: Folgenummern fangen neu an ("
+ << m_lastSeq << " -> " << seq << ") -- der Strom wurde neu gestartet";
m_seqRing.clear();
m_seqOutOfPlace = 0;
m_lastSeq = seq;
- merken(seq);
+ merken(seq, inhalt);
++m_iqSeqWndFrames;
}
@@ -2128,6 +2656,14 @@ void SunSdrRadioConnection::sendeSteuerrahmen(const QByteArray& frame,
m_offeneRahmen.append(offen);
}
+bool SunSdrRadioConnection::rahmenNochOffen(quint8 opcode) const
+{
+ for (const OffenerRahmen& r : m_offeneRahmen) {
+ if (r.opcode == opcode) { return true; }
+ }
+ return false;
+}
+
void SunSdrRadioConnection::pruefeOffeneRahmen()
{
if (m_offeneRahmen.isEmpty() || !m_inventoryClock.isValid()) { return; }
@@ -2203,4 +2739,46 @@ void SunSdrRadioConnection::pruefeAnschlag(const QVector& samples)
"zurueckdrehen oder Daempfung zuschalten.";
}
+void SunSdrRadioConnection::pruefeMikrofonPtt(quint8 streamOpcode)
+{
+ const bool txAktiv = (streamOpcode == SunSdr::kOpIqTxActive);
+ if (txAktiv == m_geraetSendet) {
+ return; // keine Flanke
+ }
+ m_geraetSendet = txAktiv;
+
+ if (m_mox.load(std::memory_order_acquire)) {
+ qCDebug(lcSunSdr) << "SunSdr: Sendezustand gewechselt, aber MOX steht "
+ "auf uns -- kein PTT vom Geraet";
+ return;
+ }
+
+ ++m_mikrofonPttFlanken;
+ qCInfo(lcSunSdr) << "SunSdr: PTT vom Geraet:"
+ << (txAktiv ? "gedrueckt" : "losgelassen")
+ << "(aus dem Stromkopf, Opcode 0x"
+ << QString::number(streamOpcode, 16) << ")";
+ emit micPttFromRadio(txAktiv);
+}
+
+SunSdr::StromModus SunSdrRadioConnection::stromModusAusUmgebung() const
+{
+ const QString wahl =
+ qEnvironmentVariable("LONGPATH_SUNSDR_STROMMODUS").trimmed();
+ if (wahl == QStringLiteral("je48")) {
+ return SunSdr::StromModus::ZweiStroemeJe48;
+ }
+ if (wahl == QStringLiteral("je96")) {
+ return SunSdr::StromModus::ZweiStroemeJe96;
+ }
+ if (!wahl.isEmpty() && wahl != QStringLiteral("48")) {
+ qCWarning(lcSunSdr).noquote()
+ << QStringLiteral("SunSdr: LONGPATH_SUNSDR_STROMMODUS=\"%1\" "
+ "kenne ich nicht -- es bleibt bei einem Strom "
+ "mit 48 kHz. Erlaubt: 48, 48_96, 96_144.")
+ .arg(wahl);
+ }
+ return SunSdr::StromModus::EinStrom48;
+}
+
} // namespace Longpath
diff --git a/src/core/SunSdrRadioConnection.h b/src/core/SunSdrRadioConnection.h
index 4b8ff83db..3210aa699 100644
--- a/src/core/SunSdrRadioConnection.h
+++ b/src/core/SunSdrRadioConnection.h
@@ -205,6 +205,11 @@ class SunSdrRadioConnection : public RadioConnection {
// oder 1 (RX2) -- der Rahmen, der die QRP auf echtes I/Q schaltet.
static QByteArray ddcFrequencyFrame(int subReceiver, quint64 frequencyHz);
quint16 lastBlockReplySeqForTest() const { return m_lastBlockReplySeq; }
+ // Wie viele der Antworten blosse Koepfe waren (10 Byte statt 1210) und
+ // wie gross die letzte Antwort war -- die Kopfantwort laesst sich nur
+ // an der GROESSE pruefen, nicht an der Zahl.
+ quint64 bareBlockRepliesSentForTest() const { return m_bareBlockRepliesSent; }
+ int lastBlockReplyBytesForTest() const { return m_lastBlockReplyBytes; }
// Exposes the private data-watchdog silence threshold, same
// rationale as connectTimeoutMsForTest() above.
@@ -590,6 +595,9 @@ private slots:
QUdpSocket* m_controlSocket{nullptr};
QUdpSocket* m_streamSocket{nullptr};
QTimer* m_connectWatchdog{nullptr};
+ QTimer* m_erneutTimer{nullptr}; // naechster Suchversuch
+ int m_sucheVersuch{0};
+ bool m_sucheWiederholung{true};
static constexpr int kConnectTimeoutMs = 3000;
@@ -702,7 +710,45 @@ private slots:
QElapsedTimer m_streamStartTimer;
int m_singleChannelHoldMs{2000};
quint16 m_lastBlockReplySeq{0};
+ quint64 m_bareBlockRepliesSent{0};
+ int m_lastBlockReplyBytes{0};
bool blockReplyEnabled();
+
+ // ── Kopfantwort: jeden ZWEITEN Block nur mit dem Kopf beantworten ──
+ //
+ // Am 2026-10-05 aus Martins Mitschnitten herausgelesen (expert-A/B und
+ // expert-96k, alle drei gleich): ExpertSDR2 beantwortet jeden Block des
+ // Geraets, aber abwechselnd
+ //
+ // ungerade Nummer -> voller Stilleblock, Laengenfeld 1200 (1210 Byte)
+ // gerade Nummer -> BLOSSER KOPF, Laengenfeld 0 (10 Byte)
+ //
+ // gemessen als 240/s + 240/s gegen 480/s vom Geraet. Longpath schickt
+ // bisher auf JEDEN Block den vollen Block, also rund das Doppelte an
+ // Rueckweg-Bytes.
+ //
+ // Warum das hier steht und nicht gleich die Vorgabe ist: es ist die
+ // erste konkrete Spur zu den ~110 Wiederholungen je Sekunde bei 96 kHz
+ // (drei andere Vermutungen sind gemessen und widerlegt, siehe
+ // docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md). Gemessen
+ // ist aber nur, was ExpertSDR2 TUT -- nicht, dass es hilft.
+ //
+ // Am 2026-10-05 am Geraet A/B gemessen, je 60 s bei 96 kHz mit zwei
+ // Empfaengern:
+ //
+ // ohne Kopfantwort: 996 Antworten/s, alle 1210 Byte
+ // Wiederholungen 193..219/s (Mittel 207)
+ // mit Kopfantwort: 996 Antworten/s, 504/s davon 10 Byte
+ // Wiederholungen 200..222/s (Mittel 214)
+ //
+ // Ergebnis in einem Satz: an den Wiederholungen aendert es NICHTS --
+ // das ist die vierte widerlegte Vermutung. Es spart aber den halben
+ // Rueckweg: 30265 blosse Koepfe in 60 s sind rund 36 MB, also knapp
+ // 5 Mbit/s weniger. Deshalb ist die Vorgabe seitdem AN;
+ // LONGPATH_SUNSDR_KOPFANTWORT=0 schaltet aus.
+ bool kopfAntwortEnabled();
+ bool m_kopfAntwortChecked{false};
+ bool m_kopfAntwortOn{false};
void replyToBlock(quint16 seq);
// ── Mithoeren: was das Geraet von sich aus meldet ────────────────────
@@ -806,7 +852,93 @@ private slots:
// es bisher nur mit LONGPATH_SUNSDR_PROBE gab: im Log steht, ob
// gerade eine oder acht Kopien je Nummer ankommen, also ob die
// Blockantwort wirkt.
- void auditStreamSeq(quint16 seq);
+ // ── Zwei Stroeme, und mehrere Pakete je Folgenummer ─────────────────
+ //
+ // Am 2026-10-03 aus einem ExpertSDR2-Mitschnitt gemessen (Blatt
+ // 2026-10-02-sunsdr-verbindungsablauf.md): die QRP kann ZWEI Stroeme
+ // gleichzeitig schicken, mit verschiedenen Raten, und sie
+ // unterscheidet sie im Stromkopf an byte9:
+ //
+ // 0x01-Nutzlast 02000000 ...: byte9=0 -> 48 kHz, byte9=1 -> 96 kHz
+ // 0x01-Nutzlast 02010000 ...: byte9=0 -> 96 kHz, byte9=1 -> 144 kHz
+ //
+ // Dazu traegt ein Strom ueber 48 kHz MEHRERE Pakete je Folgenummer
+ // (bei 96 kHz zwei, bei 144 kHz im Mittel 1,5) -- und die tragen
+ // VERSCHIEDENE Proben. Longpath bekommt heute einen Strom mit 48 kHz
+ // (byte8=1, byte9=0), weil es im 0x01-Rahmen 01000000 schickt.
+ //
+ // Dieser Umbau macht den Weg bereit, OHNE am heutigen Betrieb etwas
+ // zu aendern: bei byte8 = 1 ist der Kanal immer 0 und jede
+ // Wiederholung bleibt eine Wiederholung. Erst wenn der 0x01-Rahmen
+ // umgestellt wird, treten die neuen Faelle auf.
+ //
+ // Wiederholung oder Fortsetzung? Das entscheidet der INHALT, nicht die
+ // Nummer -- und zwar belegt: bei 48 kHz sind die Kopien einer Nummer
+ // GANZ bytegleich (2026-09-23: 1683 von 1683), bei 96 kHz tragen die
+ // zwei Pakete einer Nummer verschiedene Proben. Verglichen wird nur,
+ // wenn die Nummer wiederkehrt, also selten.
+ static constexpr int kMaxKanaele = 4;
+ struct KanalZustand {
+ bool gesehen{false};
+ quint16 letzteNummer{0};
+ QByteArray letzteNutzlast; // nur fuer den Vergleich bei gleicher Nummer
+ quint64 pakete{0};
+ quint64 fortsetzungen{0};
+ quint64 verworfen{0}; // kein Empfaenger fuer diesen Kanal
+ // KEINE eigenen Folgenummern je Kanal -- am 2026-10-03 am Geraet
+ // widerlegt. Der Pruefstand hatte angenommen, zwei Stroeme haetten
+ // eigene Nummernraeume; der Versuch mit zwei Stroemen zeigt das
+ // Gegenteil:
+ //
+ // 0(1) 1(2) 2(1) 3(2) 4(1) 5(2) 6(1) 7(2) ...
+ //
+ // Die Nummern laufen GLOBAL fortlaufend, und byte9 sagt nur, zu
+ // welchem Strom ein Paket gehoert. Je Kanal gezaehlt sah jeder
+ // Kanal nur jede zweite Nummer -- und der Zaehler meldete 50 %
+ // Verlust bei einem vollkommen gesunden Strom.
+ };
+ KanalZustand m_kanal[kMaxKanaele];
+
+ // Welcher Kanal gehoert zu diesem Stromkopf? byte8 ist die Zahl der
+ // Stroeme (1 bei Longpath heute, 2 bei ExpertSDR2), byte9 der Index.
+ static int kanalVon(const SunSdr::IqHeader& hdr)
+ {
+ if (hdr.byte8 < 2) { return 0; }
+ return (hdr.byte9 < kMaxKanaele) ? int(hdr.byte9) : 0;
+ }
+
+ // ── Welcher Strommodus beim Verbinden gesetzt wird ──────────────────
+ //
+ // Vorgabe ist EinStrom48, also genau das, was dieser Treiber seit dem
+ // 2026-08-26 schickt. Umgestellt wird NUR ueber die Umgebung:
+ //
+ // LONGPATH_SUNSDR_STROMMODUS=48 (Vorgabe)
+ // LONGPATH_SUNSDR_STROMMODUS=je48 zwei Stroeme, je 48 kHz (2x Daten)
+ // LONGPATH_SUNSDR_STROMMODUS=je96 zwei Stroeme, je 96 kHz (4x Daten)
+ //
+ // Warum nicht als Einstellung in der Oberflaeche: die Rate ist am
+ // 2026-10-03 aus einem Mitschnitt gewonnen und am Geraet noch NICHT
+ // gegengeprueft. Was oben mit dem zweiten Kanal passiert, ist auch
+ // noch offen -- BoardCapabilities fuehrt weiter maxReceivers = 1.
+ // Erst wenn beides steht, gehoert das in die Oberflaeche; bis dahin
+ // ist es ein Versuch, und ein Versuch wird ausdruecklich gewaehlt.
+ SunSdr::StromModus stromModusAusUmgebung() const;
+ // Der Modus dieser Sitzung. Vorbelegt aus der Umgebung, umgestellt von
+ // setSampleRate.
+ SunSdr::StromModus m_stromModus{SunSdr::StromModus::EinStrom48};
+ // Wie viele Kanaele oben ueberhaupt einen Empfaenger haben.
+ int m_aktiveEmpfaenger{1};
+
+ // Die zuletzt gewuenschte Rate; zusammen mit der Empfaengerzahl
+ // ergibt sie den Stromstart-Modus (stromModusNachziehen).
+ int m_rateHz{48000};
+
+ // Einmal je Sitzung melden, dass die Leistung nicht gestellt wird.
+ bool m_txDriveGemeldet{false};
+ bool m_preampSchalterGemeldet{false};
+ quint64 m_stoppGeschickt{0};
+
+ void auditStreamSeq(int kanal, quint16 seq, quint64 inhalt);
// Schliesst das 5-s-Fenster: meldet nach oben und schreibt ins Log.
void berichteFolgenummern();
@@ -865,9 +997,14 @@ private slots:
static constexpr int kSeqRestartAfter = 3;
bool m_seqSeen{false};
- quint16 m_lastSeq{0}; // hoechste gesehene Nummer
- QList m_seqRing; // die letzten Nummern, fuer Wiederholungen
- int m_seqOutOfPlace{0}; // wie viele Pakete in Folge zu nichts passen
+ quint16 m_lastSeq{0}; // hoechste gesehene Nummer, ueber alle Stroeme
+ // Die letzten Nummern MIT einem Merkmal ihres Inhalts. Ohne das
+ // Merkmal galt jede wiederkehrende Nummer als bytegleiche Kopie --
+ // am 2026-10-04 aus einem Mitschnitt widerlegt (0 % Kopien auf dem
+ // Draht, 1,41 gemeldet).
+ QList> m_seqRing;
+ int m_seqOutOfPlace{0}; // Pakete in Folge, die zu nichts passen
+
quint64 m_iqSeqWndRestarts{0};
quint64 m_iqSeqWndFrames{0};
quint64 m_iqSeqWndRepeats{0};
@@ -947,6 +1084,34 @@ private slots:
// Deckel gegen Anwachsen, falls ein Geraet gar nicht quittiert.
static constexpr int kMaxOffeneRahmen = 32;
+ // Der Stopp beim Trennen wird nachgeschickt, solange er unquittiert
+ // bleibt. Hintergrund (2026-10-04): die QRP bedient EINEN Client und
+ // haelt die Sitzung fest. Geht der Stopp verloren -- oder wird die
+ // Instanz hart beendet --, bleibt sie an den Toten gebunden und
+ // antwortet auf neue Suchmeldungen nicht mehr ("no beacon reply").
+ // Der Betreiber kam eine halbe Stunde nicht mehr herein; erst Aus-
+ // und Einschalten half.
+ //
+ // Drei Versuche a 150 ms kosten im schlimmsten Fall 450 ms beim
+ // Trennen und sind unschaedlich: der Stopp verstellt nichts, er
+ // meldet nur ab.
+ // Obergrenze beim Trennen: kStoppVersuche * (kStoppQuittungFristMs +
+ // 200 ms waitForBytesWritten) = rund EINE SEKUNDE, wenn das Geraet
+ // gar nicht mehr antwortet (abgezogen, abgestuerzt). Das blockiert
+ // den Verbindungsfaden und damit auch das Programmende. Bewusst in
+ // Kauf genommen: ein unquittierter Stopp kostet den Betreiber eine
+ // Minute Sperre (siehe die Messung im Verbindungsablauf-Dokument),
+ // eine Sekunde beim Beenden kostet ihn nichts.
+ // Selbsttaetige Wiederholung der Suche. Das Geraet sperrt nach einem
+ // abrupten Programmende rund eine Minute (am 2026-10-04 gemessen);
+ // fuenf Anlaeufe im Abstand von 18 s decken sie ab, ohne dass der
+ // Betreiber klicken muss.
+ static constexpr int kSucheVersuche = 5;
+ static constexpr int kSuchePauseMs = 18000;
+
+ static constexpr int kStoppVersuche = 3;
+ static constexpr qint64 kStoppQuittungFristMs = 150;
+
QList m_offeneRahmen;
quint64 m_quittungenGesehen{0};
quint64 m_rahmenOhneQuittung{0};
@@ -958,6 +1123,8 @@ private slots:
void sendeSteuerrahmen(const QByteArray& frame, const char* grund,
bool nachschickbar = true);
void pruefeOffeneRahmen();
+ bool rahmenNochOffen(quint8 opcode) const;
+ void stromModusNachziehen();
// ── Uebersteuerung aus dem I/Q erkennen ─────────────────────────────
//
@@ -993,6 +1160,33 @@ private slots:
void pruefeAnschlag(const QVector& samples);
+ // ── Mikrofon-PTT am Geraet erkennen ─────────────────────────────────
+ //
+ // Die zweite der zwei Empfangsluecken, und sie geht genauso ohne
+ // Protokollwissen wie die Uebersteuerung: P1 und P2 lesen das PTT aus
+ // einem Statusbit, die QRP schickt keine Statusrahmen -- aber ihr
+ // Stromkopf traegt den Betriebszustand. 0xFE heisst Empfang, 0xFD
+ // heisst SENDEN (SunSdrProtocol.h: "0xFE = RX-state/idle-TX,
+ // 0xFD = TX-active"). Drueckt jemand am Geraet die Mikrofontaste,
+ // wechselt der Opcode -- und dieser Treiber hat ihn bisher nur dazu
+ // benutzt, solche Pakete wegzuwerfen.
+ //
+ // Gemeldet wird die FLANKE, nicht jedes Paket. P1/P2 melden den
+ // Zustand mit jedem Statusrahmen, und MoxController::onMicPttFromRadio
+ // ist gegen Wiederholungen gleichgueltig -- bei 240 Strompaketen je
+ // Sekunde waeren 240 Signale ueber eine QueuedConnection aber nichts
+ // als Last.
+ //
+ // Nicht gemeldet wird, was Longpath selbst ausgeloest hat: steht MOX
+ // auf uns, ist der Sendezustand unser eigener und kein PTT vom Geraet.
+ // Heute kann der Fall nicht eintreten (kein Byte des Sendepfads
+ // erreicht den Draht), aber die Unterscheidung gehoert an die Stelle,
+ // die sie trifft -- nicht in den Empfaenger.
+ bool m_geraetSendet{false};
+ quint64 m_mikrofonPttFlanken{0};
+
+ void pruefeMikrofonPtt(quint8 streamOpcode);
+
void berichteMithoeren();
void noteControlFrame(const QByteArray& data);
void noteStreamState(const SunSdr::IqHeader& hdr);
@@ -1054,8 +1248,24 @@ private slots:
quint64 rahmenOhneQuittungForTest() const { return m_rahmenOhneQuittung; }
quint64 anschlagProbenForTest() const { return m_anschlagProben; }
quint64 anschlagMeldungenForTest() const { return m_anschlagMeldungen; }
+ bool geraetSendetForTest() const { return m_geraetSendet; }
+ quint64 mikrofonPttFlankenForTest() const { return m_mikrofonPttFlanken; }
+ quint64 kanalPaketeForTest(int k) const
+ { return (k >= 0 && k < kMaxKanaele) ? m_kanal[k].pakete : 0; }
+ int stromModusForTest() const { return int(m_stromModus); }
+ int aktiveEmpfaengerForTest() const { return m_aktiveEmpfaenger; }
+ quint64 stoppGeschicktForTest() const { return m_stoppGeschickt; }
+ quint64 kanalVerworfenForTest(int k) const
+ { return (k >= 0 && k < kMaxKanaele) ? m_kanal[k].verworfen : 0; }
+ quint64 kanalFortsetzungenForTest(int k) const
+ { return (k >= 0 && k < kMaxKanaele) ? m_kanal[k].fortsetzungen : 0; }
quint64 rahmenWiederholtForTest() const { return m_rahmenWiederholt; }
int offeneRahmenForTest() const { return int(m_offeneRahmen.size()); }
+
+ /// Pruef-Naht (2026-10-04): die selbsttaetige Wiederholung der Suche
+ /// abschalten. Prueflinien, die den FEHLSCHLAG pruefen, wollen ihn
+ /// sofort sehen und nicht neunzig Sekunden darauf warten.
+ void setSucheWiederholungEnabledForTest(bool an) { m_sucheWiederholung = an; }
};
} // namespace Longpath
diff --git a/src/core/WdspEngine.h b/src/core/WdspEngine.h
index 9828a1393..7539fc554 100644
--- a/src/core/WdspEngine.h
+++ b/src/core/WdspEngine.h
@@ -123,6 +123,10 @@ class TstNrBackendsProcessAudio;
// 2026-09-27: same pattern for the PB-SNR noise probe, which pushes
// Gaussian noise through a real channel and reads the S meter.
class TstPbsnrNoiseProbe;
+// 2026-10-04: same pattern for the SunSDR rate pruefstand, which drives
+// RadioModel::setStreamSampleRate through setSampleRateLive and therefore
+// has to get past its !isInitialized() guard without a wisdom load.
+class TstSunSdrBoardCaps;
// Phase 3F Sub-Epic I closeout, defect H1: the per-stream drain-geometry
// test primes the engine so createRxChannel can seed real RX channels.
class TestStreamPoolBinding;
@@ -796,6 +800,8 @@ public slots:
// Phase 3F: same friendship for the channel-id map test, which drives
// RadioModel::openRxChannelPool and asserts on the ids it opened.
friend class ::TestWdspChannelIdMap;
+ // 2026-10-04: same friendship for the SunSDR rate pruefstand.
+ friend class ::TstSunSdrBoardCaps;
// Authoritative-TX regression: seed nonzero RX channels without the
// asynchronous wisdom lifecycle so MOX can prove which exact ID moves.
friend class ::TestRadioModelMoxHardwareFlip;
diff --git a/src/core/audio/PortAudioBus.cpp b/src/core/audio/PortAudioBus.cpp
index a27943408..88a678377 100644
--- a/src/core/audio/PortAudioBus.cpp
+++ b/src/core/audio/PortAudioBus.cpp
@@ -297,6 +297,16 @@ void PortAudioBus::setConfig(const PortAudioConfig& cfg) {
m_cfg = cfg;
}
+// Lebt der Strom noch? Pa_IsStreamActive gibt 1 (laeuft), 0 (steht) oder
+// einen negativen Fehlercode -- zum Beispiel paDeviceUnavailable, wenn das
+// Geraet unter dem Strom weggezogen wurde. Nur 1 heisst "es kommt wirklich
+// etwas heraus"; alles andere behandeln wir als tot, damit der Aufrufer neu
+// oeffnet. Siehe die Begruendung an IAudioBus::isAlive().
+bool PortAudioBus::isAlive() const {
+ if (m_stream == nullptr) { return false; }
+ return Pa_IsStreamActive(m_stream) == 1;
+}
+
bool PortAudioBus::open(const AudioFormat& format) {
if (m_stream) {
close();
diff --git a/src/core/audio/PortAudioBus.h b/src/core/audio/PortAudioBus.h
index b1378c171..7f09b6c33 100644
--- a/src/core/audio/PortAudioBus.h
+++ b/src/core/audio/PortAudioBus.h
@@ -91,6 +91,7 @@ class PortAudioBus : public IAudioBus {
bool open(const AudioFormat& format) override;
void close() override;
bool isOpen() const override { return m_stream != nullptr; }
+ bool isAlive() const override;
qint64 push(const char* data, qint64 bytes) override;
qint64 pull(char* data, qint64 maxBytes) override;
diff --git a/src/core/sunsdr/SunSdrProtocol.cpp b/src/core/sunsdr/SunSdrProtocol.cpp
index a79cfbbf5..7db2a872a 100644
--- a/src/core/sunsdr/SunSdrProtocol.cpp
+++ b/src/core/sunsdr/SunSdrProtocol.cpp
@@ -134,7 +134,7 @@ bool parseControlHeader(const quint8* data, int len, const Profile& profile,
}
QByteArray buildIqHeader(const Profile& profile, quint8 opcode, quint16 seq,
- quint8 byte8, quint8 byte9)
+ quint8 byte8, quint8 byte9, int payloadLen)
{
// Mirrors sunsdr_build_iq_header, sunsdr.c:1678-1690 [@f8b01d25c5].
QByteArray out(kIqHeaderSize, char(0));
@@ -144,8 +144,8 @@ QByteArray buildIqHeader(const Profile& profile, quint8 opcode, quint16 seq,
buf[1] = kMagic1;
buf[2] = opcode;
buf[3] = 0xFF;
- buf[4] = static_cast(kIqPayloadSize & 0xFF);
- buf[5] = static_cast((kIqPayloadSize >> 8) & 0xFF);
+ buf[4] = static_cast(payloadLen & 0xFF);
+ buf[5] = static_cast((payloadLen >> 8) & 0xFF);
buf[6] = static_cast(seq & 0xFF);
buf[7] = static_cast((seq >> 8) & 0xFF);
buf[8] = byte8;
@@ -223,6 +223,36 @@ namespace {
constexpr quint64 kFreqScaleCandidate = 10;
}
+QByteArray stromModusPayload(StromModus modus)
+{
+ switch (modus) {
+ case StromModus::EinStrom48:
+ return QByteArray::fromHex("010000000c08040302020202");
+ case StromModus::ZweiStroemeJe48:
+ return QByteArray::fromHex("020000000c08040302020202");
+ case StromModus::ZweiStroemeJe96:
+ return QByteArray::fromHex("020100000a06040302020201");
+ }
+ return QByteArray::fromHex("010000000c08040302020202");
+}
+
+QByteArray buildStromStartFrame(const Profile& profile, StromModus modus)
+{
+ const QByteArray nutz = stromModusPayload(modus);
+ QByteArray frame = buildControlHeader(profile, 0x01, 0,
+ quint16(nutz.size()));
+ frame += nutz;
+ return withControlFrameCrc(frame);
+}
+
+QByteArray buildStopFrame(const Profile& profile)
+{
+ // Vier Byte Nutzlast, alle null -- byte-fuer-byte wie im Mitschnitt.
+ QByteArray frame = buildControlHeader(profile, 0x02, 0, 4);
+ frame += QByteArray(4, char(0));
+ return withControlFrameCrc(frame);
+}
+
quint32 controlFrameCrc(const QByteArray& frame)
{
// CRC-32/IEEE, bitweise (die Rahmen sind hoechstens ein paar hundert
diff --git a/src/core/sunsdr/SunSdrProtocol.h b/src/core/sunsdr/SunSdrProtocol.h
index e532fb440..93eb13e21 100644
--- a/src/core/sunsdr/SunSdrProtocol.h
+++ b/src/core/sunsdr/SunSdrProtocol.h
@@ -171,13 +171,32 @@ struct Profile {
// Pegelabgleich des Empfangswegs in dB, auf die normierten Proben
// (1/2^23) aufgeschlagen, bevor sie Longpath verlassen.
//
- // QRP: +20,0 dB, GEMESSEN am 2026-09-25 gegen ExpertSDR2 am selben
- // Geraet im selben Zustand (echtes I/Q, Preamp 0 dB, 20 m, 3 kHz,
- // ohne Antenne): ExpertSDR2 zeigt im Mittel -127,9 dBm Rauschen
- // (-127,5 / -127,8 / -127,9 / -128,3), Longpath ohne Abgleich
- // -147,9 dBm (TCI rx_sensors). Ohne diesen Abgleich klingt Longpath
- // im richtigen I/Q-Betrieb "ganz leise" bis "kein Ton" -- die AGC
- // hebt Rauschen bei -148 dBm nicht hoerbar an.
+ // QRP: +40,0 dB.
+ //
+ // Die Zahl stand bis zum 2026-10-04 auf +20,0 -- gemessen am
+ // 2026-09-25 gegen ExpertSDR2, aber ueber den TCI-Weg (rx_sensors).
+ // Der NATIVE Treiber ist ein anderer Weg mit anderer Skalierung, und
+ // dort reichten die 20 dB bei Weitem nicht: der Betreiber meldete
+ // den Empfang mehrfach als "sehr sehr leise" ("ich muss voll
+ // aufdrehen, dass ich etwas hoere"; "bei der haelfte, sprich 50 %
+ // faengt man an, etwas zu hoeren"), waehrend ExpertSDR2 am SELBEN
+ // Geraet ohne Antenne "perfekt" laut war -- es lag also nicht an der
+ // fehlenden Antenne.
+ //
+ // +40,0 dB ist nicht geraten, sondern vom Betreiber am 2026-10-04 am
+ // echten Geraet eingestellt worden: der Abgleich wurde ueber
+ // LONGPATH_SUNSDR_PEGEL einstellbar gemacht, er hat 40 gefahren und
+ // bestaetigt "die lautstaerke passt". Im Log seines Laufs steht
+ // "SunSdr: Pegelabgleich 40.0 dB (eingestellt)".
+ //
+ // Die alte Begruendung bleibt gueltig, nur die Groesse war zu klein:
+ // ExpertSDR2 zeigt im Mittel -127,9 dBm Rauschen (-127,5 / -127,8 /
+ // -127,9 / -128,3), Longpath ohne Abgleich -147,9 dBm. Ohne Abgleich
+ // klingt Longpath "ganz leise" bis "kein Ton" -- die AGC hebt
+ // Rauschen bei -148 dBm nicht hoerbar an.
+ //
+ // Seine Anforderung dazu, woertlich: "rauschen muss immer zu hoeren
+ // sein".
//
// Vorbild fuer die Groessenordnung: ArtemisSDR gleicht das S-Meter
// der SunSDR2 DX um +18,98 dB an, ebenfalls gegen die Hersteller-
@@ -205,7 +224,7 @@ inline constexpr Profile kProfilePro{
// BoardCapabilities' QRP-Zeile.
inline constexpr Profile kProfileQrp{
Variant::Qrp, "SunSDR2 QRP", 50001, 50002, 0x03, 48000.0,
- /*rxLevelTrimDb=*/20.0};
+ /*rxLevelTrimDb=*/40.0};
// From ArtemisSDR sunsdr.h:28 [@f8b01d25c5]. Second magic byte, fixed
// across every model/profile — only byte[0] varies.
@@ -292,8 +311,13 @@ struct IqHeader {
// themselves; this function only builds the header ArtemisSDR itself
// builds separately from the payload (sunsdr_build_iq_header takes no
// payload pointer at all).
+// payloadLen ist das LAENGENFELD des Kopfes, nicht die Groesse des
+// Rueckgabewerts -- der ist immer die 10 Byte des Kopfes. Standard ist die
+// volle Nutzlast; 0 baut den BLOSSEN KOPF, den ExpertSDR2 am 2026-10-05 im
+// Mitschnitt auf jeden zweiten Block schickt (03 ff fe ff 00 00 .. .. 01 00).
QByteArray buildIqHeader(const Profile& profile, quint8 opcode, quint16 seq,
- quint8 byte8, quint8 byte9);
+ quint8 byte8, quint8 byte9,
+ int payloadLen = kIqPayloadSize);
// Parses a 10-byte IQ-stream header. Same magic-byte validation
// discipline as parseControlHeader.
@@ -478,6 +502,69 @@ enum class AntennaPort { A1, A2, A3 };
bool buildAntennaSelectFrame(const Profile& profile, AntennaPort port,
bool forTx, QByteArray* out);
+// ── Der Stromstart 0x01: hier steht die Abtastrate ──────────────────
+//
+// Am 2026-10-03 aus einem ExpertSDR2-Mitschnitt gemessen, in dem die Rate
+// zweimal umgeschaltet wurde (docs/architecture/
+// 2026-10-02-sunsdr-verbindungsablauf.md): der EINZIGE Rahmen, der sich
+// dabei aendert, ist 0x01 -- derselbe, den Longpath beim Verbinden schon
+// schickt. Drei Nutzlasten sind belegt:
+//
+// 01000000 0c080403 02020202 ein Strom, 48 kHz (Longpath heute)
+// 02000000 0c080403 02020202 zwei Stroeme, je 48 kHz
+// 02010000 0a060403 02020201 zwei Stroeme, je 96 kHz
+//
+// Am 2026-10-03 am echten Geraet durchgemessen, alle drei, Verlust null:
+//
+// 01... 239 Folgenummern/s -> ein Strom, 48 kHz (1x Daten)
+// 0200.. 480 Folgenummern/s -> zwei Stroeme, je 48 kHz (2x)
+// 0201.. 958 Folgenummern/s -> zwei Stroeme, je 96 kHz (4x)
+//
+// Erstes Byte = Zahl der Stroeme, zweites = Ratenstufe. Im Mitschnitt von
+// ExpertSDR2 standen die Kanaele auf verschiedenen Raten (48+96 bzw.
+// 96+144); mit NUR diesem Rahmen kommen beide gleich schnell. Was den
+// Unterschied macht, steht in einem der anderen sechzehn Rahmen, die
+// ExpertSDR2 schickt -- fuer die vierfache Datenmenge braucht man es
+// nicht.
+//
+// Das erste Byte ist die Zahl der Stroeme; es erscheint im Stromkopf als
+// byte8 wieder, und byte9 traegt dort den Index (gemessen: Longpath sieht
+// 0100, ExpertSDR2 0200/0201).
+//
+// Was die Bytes 0c 08 04 03 gegen 0a 06 04 03 codieren, ist NICHT
+// entschluesselt. Darum gibt es hier keine Funktion, die aus einer
+// gewuenschten Rate einen Rahmen RECHNET -- nur die drei gemessenen
+// Zustaende, byte-fuer-byte wie aufgezeichnet. Eine gerechnete Rate waere
+// geraten, und das geht an ein Funkgeraet nicht hinaus.
+enum class StromModus {
+ EinStrom48, // Longpath heute
+ ZweiStroemeJe48,
+ ZweiStroemeJe96,
+};
+
+// Die Nutzlast (12 Byte) zum Modus. Der vollstaendige Rahmen entsteht mit
+// buildControlHeader(profile, 0x01, 0, 12) + Nutzlast + withControlFrameCrc;
+// gegengeprueft, dass das byte-fuer-byte den aufgezeichneten Rahmen ergibt
+// (tst_sunsdr_protocol).
+QByteArray stromModusPayload(StromModus modus);
+
+// Der fertige Rahmen zum Modus.
+QByteArray buildStromStartFrame(const Profile& profile, StromModus modus);
+
+// ── Der Stopp-Befehl 0x02 ───────────────────────────────────────────
+//
+// Am 2026-10-03 aus dem Mitschnitt rate-umschalten.pcap gelesen: beim
+// Beenden schickt ExpertSDR2 zwei Rahmen, 0x06 mit 0 (MOX aus) und 0x02
+// mit 0 -- und das LETZTE Strompaket liegt in derselben Millisekunde wie
+// 0x02. Danach ist die QRP still. ArtemisSDR fuehrt 0x02 als
+// SUNSDR_OP_POWER_OFF (sunsdr.h:32 [@f8b01d25c5]), was dazu passt.
+//
+// Warum das zaehlt: Longpaths disconnect() sagt dem Geraet bis heute
+// NICHTS. Gemessen am 2026-10-03: die QRP streamt danach unbegrenzt
+// weiter, 1940 Pakete/s und 2,3 MB/s ins Leere, bis sie ausgeschaltet
+// wird -- und in diesem Zustand laesst sie sich schlecht neu verbinden.
+QByteArray buildStopFrame(const Profile& profile);
+
// Builds the 0x17 drive-byte control frame: a bare 0-255 passthrough,
// u32 payload = raw0to255 (design doc line 984: "u32, low byte =
// pre-calibrated 0-255 passthrough"; ArtemisSDR
diff --git a/src/gui/ConnectionPanel.cpp b/src/gui/ConnectionPanel.cpp
index e45c2da65..3e14c8cf2 100644
--- a/src/gui/ConnectionPanel.cpp
+++ b/src/gui/ConnectionPanel.cpp
@@ -444,7 +444,7 @@ void ConnectionPanel::buildUI()
" background: %1;"
" color: %2;"
" border: 1px solid %3;"
- " font-family: Consolas, 'Courier New', monospace;"
+ " font-family: " LP_MONO_QSS ";"
" font-size: 13px;"
" gridline-color: %4;"
"}"
diff --git a/src/gui/KiwiWaterfallStripWidget.cpp b/src/gui/KiwiWaterfallStripWidget.cpp
index 5a19ffdef..cc6207e4b 100644
--- a/src/gui/KiwiWaterfallStripWidget.cpp
+++ b/src/gui/KiwiWaterfallStripWidget.cpp
@@ -60,7 +60,7 @@ KiwiWaterfallStripWidget::KiwiWaterfallStripWidget(const QString& profileId,
m_peakLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
m_peakLabel->setStyleSheet(
QStringLiteral("QLabel { color: %1; font-size: 10.5px; "
- "font-family: monospace; }").arg(trace()));
+ "font-family: " LP_MONO_QSS "; }").arg(trace()));
header->addWidget(m_peakLabel, 0);
layout->addLayout(header);
diff --git a/src/gui/NetworkDiagnosticsDialog.cpp b/src/gui/NetworkDiagnosticsDialog.cpp
index f25a31345..8860e04cf 100644
--- a/src/gui/NetworkDiagnosticsDialog.cpp
+++ b/src/gui/NetworkDiagnosticsDialog.cpp
@@ -64,17 +64,17 @@ namespace Longpath {
// Monospace font stack is diagnostics-specific (all three constants).
static constexpr const char* kValueStyle =
"color: #c8d8e8;" // Style::kTextPrimary
- "font-family: 'SF Mono', Menlo, monospace;"
+ "font-family: " LP_MONO_QSS ";"
"font-size: 11px;";
static constexpr const char* kFieldStyle =
"color: #8aa8c0;" // Style::kTitleText
- "font-family: 'SF Mono', Menlo, monospace;"
+ "font-family: " LP_MONO_QSS ";"
"font-size: 11px;";
static constexpr const char* kSectionHeaderStyle =
"color: #4a7ba8;" // §D exception: diagnostics section header blue
- "font-family: 'SF Mono', Menlo, monospace;"
+ "font-family: " LP_MONO_QSS ";"
"font-size: 11px;"
"font-weight: bold;"
"padding: 6px 0;"
diff --git a/src/gui/SpotHubDialog.cpp b/src/gui/SpotHubDialog.cpp
index 55b50cfbe..2314b839a 100644
--- a/src/gui/SpotHubDialog.cpp
+++ b/src/gui/SpotHubDialog.cpp
@@ -208,7 +208,7 @@ constexpr const char* kConsoleStyle =
"QPlainTextEdit {"
" background: #0a0a14;"
" color: #8aa8c0;"
- " font-family: monospace;"
+ " font-family: " LP_MONO_QSS ";"
" font-size: 11px;"
" border: 1px solid #203040;"
" padding: 4px;"
diff --git a/src/gui/StyleConstants.h b/src/gui/StyleConstants.h
index 2aee4fea0..c250862ae 100644
--- a/src/gui/StyleConstants.h
+++ b/src/gui/StyleConstants.h
@@ -38,6 +38,38 @@
namespace Longpath::Style {
+/// Dieselbe Schriftwahl wie monoFont(), aber fuer Stilvorlagen (QSS).
+///
+/// Warum es das geben muss: am 2026-10-06 stand im Protokoll
+///
+/// Populating font family aliases took N ms. Replace uses of missing
+/// font family "SF Mono" with one that exists.
+///
+/// Nachgezaehlt gab es dann ACHT verschiedene Schreibweisen fuer
+/// dieselbe Absicht, und auf dem Mac des Betreibers kamen dabei VIER
+/// verschiedene Schriften heraus:
+///
+/// 'SF Mono', Menlo, monospace 12x -> Menlo
+/// monospace 12x -> Qt-Vorgabe
+/// 'ui-monospace','Menlo','Consolas' 3x -> Menlo
+/// Menlo 2x -> Menlo
+/// Menlo, monospace 1x -> Menlo
+/// Menlo, Consolas, monospace 1x -> Menlo
+/// Consolas, 'Courier New', monospace 1x -> COURIER NEW
+/// 'Monaco','Menlo',monospace 1x -> MONACO
+/// QFont("SF Mono") ohne Rueckfall 1x -> gar nicht Monospace
+/// QFont("Consolas") ohne Rueckfall 1x -> gar nicht Monospace
+///
+/// SF Mono ist auf dem Mac NICHT als Familie ansprechbar (geprueft:
+/// 0 Treffer in system_profiler, Menlo 26). Consolas ist Windows. Wer
+/// sie ohne Rueckfall nennt, bekommt die proportionale Vorgabeschrift --
+/// und genau das sah der Betreiber.
+///
+/// Als Makro, damit es sich an Zeichenkettenliterale anfuegen laesst:
+/// "QLabel { font-family: " LP_MONO_QSS "; }"
+#define LP_MONO_QSS "Menlo, 'DejaVu Sans Mono', monospace"
+
+
// ── Entblaut, 2026-08-15 ──────────────────────────────────────────────
//
// „nach [der Vorlage] sieht das aber nicht aus" — OE5SOS, mit einem Screenshot,
@@ -1005,7 +1037,7 @@ inline QString insetValueStyle()
// die dunkle Kante, unten Licht, Monospace fuer die Zahl (Regel 4).
return QStringLiteral(
"QLabel {"
- " font-size: 11px; font-family: Menlo; background: %1; border: 1px solid %2;"
+ " font-size: 11px; font-family: " LP_MONO_QSS "; background: %1; border: 1px solid %2;"
" border-top-color: %4; border-bottom-color: %5;"
" border-radius: %6px; padding: 1px 4px; color: %3;"
"}"
@@ -1325,14 +1357,24 @@ inline QFont capsFont(const QFont& base, int px = kFontCaption)
return f;
}
+
/// Eine Zahl, die sich aendert: Monospace, damit Stellen untereinander
/// stehen. HAUSSTIL.md §Die acht Regeln, Regel 2.
+///
+/// Haelt dieselbe Familie wie LP_MONO_QSS. Wer eine aendert, aendert
+/// beide -- tst_schriftfamilien faengt das Auseinanderlaufen.
inline QFont monoFont(const QFont& base, int px, QFont::Weight w = QFont::Normal)
{
QFont f = base;
f.setPixelSize(px);
f.setWeight(w);
- f.setFamily(QStringLiteral("Menlo"));
+ // setFamilies statt setFamily: die ganze Rueckfallkette, dieselbe wie
+ // LP_MONO_QSS. Mit setFamily() stand hier nur "Menlo" -- auf einem
+ // Rechner ohne Menlo nahm Qt die naechstbeste Schrift, und das war
+ // nicht zwingend eine dicktengleiche.
+ f.setFamilies({QStringLiteral("Menlo"),
+ QStringLiteral("DejaVu Sans Mono"),
+ QStringLiteral("monospace")});
// Menlo gibt es nur auf dem Mac. Ohne diesen Hinweis nahm Qt unter
// Windows/Linux eine proportionale Schrift, und die Stellen tanzten
// doch (2026-09-27).
@@ -1367,7 +1409,15 @@ inline QFont monoFontPt(int pt, QFont::Weight w = QFont::Normal)
QFont f;
f.setPointSize(pt);
f.setWeight(w);
- f.setFamily(QStringLiteral("Menlo"));
+ // setFamilies statt setFamily, dieselbe Kette wie monoFont() und
+ // LP_MONO_QSS. Mit setFamily stand hier nur "Menlo" -- auf einem
+ // Rechner ohne Menlo nahm Qt die naechstbeste Schrift, und die muss
+ // keine dicktengleiche sein. Der Pruefstand
+ // tst_schrift_dicktengleich misst auf DIESEM Mac, auf dem es Menlo
+ // gibt; die Kette ist fuer die anderen.
+ f.setFamilies({QStringLiteral("Menlo"),
+ QStringLiteral("DejaVu Sans Mono"),
+ QStringLiteral("monospace")});
f.setStyleHint(QFont::TypeWriter);
f.setFixedPitch(true);
return f;
diff --git a/src/gui/SupportDialog.cpp b/src/gui/SupportDialog.cpp
index 9d0efae5c..b2d12b451 100644
--- a/src/gui/SupportDialog.cpp
+++ b/src/gui/SupportDialog.cpp
@@ -119,7 +119,7 @@ void SupportDialog::buildUI()
auto* logInfoLayout = new QHBoxLayout();
m_logPathLabel = new QLabel(this);
m_logPathLabel->setStyleSheet(
- QStringLiteral("QLabel { color: %1; font-family: monospace; font-size: 11px; }")
+ QStringLiteral("QLabel { color: %1; font-family: " LP_MONO_QSS "; font-size: 11px; }")
.arg(Style::kTextScale));
logInfoLayout->addWidget(m_logPathLabel, 1);
diff --git a/src/gui/TitleBar.cpp b/src/gui/TitleBar.cpp
index ee00046f9..8d708a32c 100644
--- a/src/gui/TitleBar.cpp
+++ b/src/gui/TitleBar.cpp
@@ -586,7 +586,7 @@ TitleBar::TitleBar(AudioEngine* audio, QWidget* parent)
m_utcLabel->setToolTip(tr("UTC time"));
m_utcLabel->setStyleSheet(Style::themed(QStringLiteral(
"QLabel { color: %1; font-size: 11px;"
- " font-family: 'SF Mono', Menlo, monospace; }")
+ " font-family: " LP_MONO_QSS "; }")
.arg(QString::fromLatin1(Style::kTextSecondary))));
m_hbox->addWidget(m_utcLabel);
m_hbox->addSpacing(24);
diff --git a/src/gui/VaxFirstRunDialog.cpp b/src/gui/VaxFirstRunDialog.cpp
index 8bd4d4ad4..d674e7769 100644
--- a/src/gui/VaxFirstRunDialog.cpp
+++ b/src/gui/VaxFirstRunDialog.cpp
@@ -150,7 +150,7 @@ QWidget* makeDetRow(int vaxSlot,
vaxLabel->setFixedWidth(70);
vaxLabel->setStyleSheet(QStringLiteral(
"QLabel { color: %1; font-weight: bold;"
- " font-family: 'ui-monospace','Menlo','Consolas',monospace;"
+ " font-family: " LP_MONO_QSS ";"
" font-size: 11px; }")
.arg(Style::kTextPrimary));
layout->addWidget(vaxLabel, 0, Qt::AlignTop);
@@ -166,7 +166,7 @@ QWidget* makeDetRow(int vaxSlot,
devLabel->setWordWrap(true);
devLabel->setStyleSheet(QStringLiteral(
"QLabel { color: %1;"
- " font-family: 'ui-monospace','Menlo','Consolas',monospace;"
+ " font-family: " LP_MONO_QSS ";"
" font-size: 11px; }")
.arg(Style::kTextSecondary));
devLayout->addWidget(devLabel);
diff --git a/src/gui/VaxLinuxFirstRunDialog.cpp b/src/gui/VaxLinuxFirstRunDialog.cpp
index e10437848..ce42df732 100644
--- a/src/gui/VaxLinuxFirstRunDialog.cpp
+++ b/src/gui/VaxLinuxFirstRunDialog.cpp
@@ -75,7 +75,7 @@ QString monoLabelStyle()
{
return QStringLiteral(
"QLabel { color: %1;"
- " font-family: 'ui-monospace','Menlo','Consolas',monospace;"
+ " font-family: " LP_MONO_QSS ";"
" font-size: 11px; }")
.arg(Style::kTextSecondary);
}
diff --git a/src/gui/diagnostics/DiagnosticsPhaseHPages.cpp b/src/gui/diagnostics/DiagnosticsPhaseHPages.cpp
index cb9bb25bd..a5c28b367 100644
--- a/src/gui/diagnostics/DiagnosticsPhaseHPages.cpp
+++ b/src/gui/diagnostics/DiagnosticsPhaseHPages.cpp
@@ -13,6 +13,7 @@
// =================================================================
#include "DiagnosticsPhaseHPages.h"
+#include "gui/StyleConstants.h" // LP_MONO_QSS
#include "gui/styles/ThemeQss.h"
#include "core/AppSettings.h"
@@ -308,7 +309,7 @@ void LogsPage::buildUI()
m_logView->setReadOnly(true);
m_logView->setStyleSheet(Style::themed(QStringLiteral(
"QPlainTextEdit { background: #0a0a18; color: #c8d8e8; "
- "border: 1px solid #304050; font-family: 'Monaco','Menlo',monospace; }")));
+ "border: 1px solid #304050; font-family: " LP_MONO_QSS "; }")));
m_logView->setPlaceholderText(QStringLiteral(
"qCWarning / qCDebug capture is wired in a follow-up phase. "
"For now, run with QT_LOGGING_TO_CONSOLE=1 and read stderr."));
diff --git a/src/gui/diagnostics/RadioStatusPage.cpp b/src/gui/diagnostics/RadioStatusPage.cpp
index 597a61785..72ed56c4a 100644
--- a/src/gui/diagnostics/RadioStatusPage.cpp
+++ b/src/gui/diagnostics/RadioStatusPage.cpp
@@ -527,7 +527,7 @@ void RadioStatusPage::buildPttCard(QFrame* card)
m_pttHistoryList->setStyleSheet(QStringLiteral(
"QListWidget {"
" background: %1; border: 1px solid %2;"
- " font-family: monospace; font-size: 9px; color: %3;"
+ " font-family: " LP_MONO_QSS "; font-size: 9px; color: %3;"
"}"
"QListWidget::item { padding: 1px 2px; }"
).arg(QLatin1String(Style::kInsetBg),
diff --git a/src/gui/instruments/FrequencyInstrument.cpp b/src/gui/instruments/FrequencyInstrument.cpp
index 4983e7416..ffa16bedd 100644
--- a/src/gui/instruments/FrequencyInstrument.cpp
+++ b/src/gui/instruments/FrequencyInstrument.cpp
@@ -148,7 +148,7 @@ FrequencyInstrument::FrequencyInstrument(QWidget* parent)
m_edit->setPlaceholderText(QStringLiteral("MHz"));
m_edit->setStyleSheet(Style::themed(QStringLiteral(
"QLineEdit { background: %1; color: %2; border: 1px solid %3;"
- " border-radius: 6px; font-family: Menlo; font-size: %4px; }")
+ " border-radius: 6px; font-family: " LP_MONO_QSS "; font-size: %4px; }")
.arg(Style::kInsetBg, Style::kAmberText, Style::kBorder)
.arg(Style::kFontReading)));
connect(m_edit, &QLineEdit::editingFinished,
diff --git a/src/gui/setup/DisplaySetupPages.cpp b/src/gui/setup/DisplaySetupPages.cpp
index abced84de..2e0be2ac5 100644
--- a/src/gui/setup/DisplaySetupPages.cpp
+++ b/src/gui/setup/DisplaySetupPages.cpp
@@ -467,7 +467,7 @@ void SpectrumDefaultsPage::buildUI()
const QString readoutStyle = QStringLiteral(
"QLabel { background-color: #0a0a18; color: #4a7ba8; "
"border: 1px solid #1e2e3e; padding: 1px 6px; "
- "font-family: Menlo, Consolas, monospace; }");
+ "font-family: " LP_MONO_QSS "; }");
// Row 0: centered "Size" header label. Mirrors Thetis labelTS139
// ("Size") at (118, 13) [v2.10.3.13] -- centered horizontally over
diff --git a/src/gui/setup/hardware/Hl2IoBoardTab.cpp b/src/gui/setup/hardware/Hl2IoBoardTab.cpp
index 65f22f773..63f29da1b 100644
--- a/src/gui/setup/hardware/Hl2IoBoardTab.cpp
+++ b/src/gui/setup/hardware/Hl2IoBoardTab.cpp
@@ -317,7 +317,7 @@ void Hl2IoBoardTab::buildStatusBar(QVBoxLayout* outer)
row->addWidget(m_ocBandLabel);
m_ocByteLabel = new QLabel(QStringLiteral("0x00"), m_statusFrame);
m_ocByteLabel->setStyleSheet(QStringLiteral(
- "color: #ddd; font-family: monospace; font-weight: bold;"));
+ "color: #ddd; font-family: " LP_MONO_QSS "; font-weight: bold;"));
row->addWidget(m_ocByteLabel);
m_ocMoxLabel = new QLabel(QStringLiteral("RX"), m_statusFrame);
m_ocMoxLabel->setStyleSheet(QStringLiteral(
@@ -391,7 +391,7 @@ void Hl2IoBoardTab::buildConfigAndRegisterRow(QVBoxLayout* outer)
lbl->setStyleSheet(QStringLiteral("color: #aaa; font-size: 11px;"));
lbl->setFixedWidth(160);
valueLabel = new QLabel(QStringLiteral("—"), rowW);
- valueLabel->setStyleSheet(QStringLiteral("font-size: 11px; font-family: monospace;"));
+ valueLabel->setStyleSheet(QStringLiteral("font-size: 11px; font-family: " LP_MONO_QSS ";"));
rowL->addWidget(lbl);
rowL->addWidget(valueLabel);
rowL->addStretch();
@@ -558,7 +558,7 @@ void Hl2IoBoardTab::buildI2cAndBandwidthRow(QVBoxLayout* outer)
m_ep6Bar->setTextVisible(false);
m_ep6Bar->setFixedHeight(14);
m_ep6RateLabel = new QLabel(QStringLiteral("0.0 Mbps"), bwGroup);
- m_ep6RateLabel->setStyleSheet(QStringLiteral("font-size: 11px; font-family: monospace;"));
+ m_ep6RateLabel->setStyleSheet(QStringLiteral("font-size: 11px; font-family: " LP_MONO_QSS ";"));
m_ep6RateLabel->setFixedWidth(70);
ep6Row->addWidget(ep6Lbl);
ep6Row->addWidget(m_ep6Bar, 1);
@@ -576,7 +576,7 @@ void Hl2IoBoardTab::buildI2cAndBandwidthRow(QVBoxLayout* outer)
m_ep2Bar->setTextVisible(false);
m_ep2Bar->setFixedHeight(14);
m_ep2RateLabel = new QLabel(QStringLiteral("0.0 Mbps"), bwGroup);
- m_ep2RateLabel->setStyleSheet(QStringLiteral("font-size: 11px; font-family: monospace;"));
+ m_ep2RateLabel->setStyleSheet(QStringLiteral("font-size: 11px; font-family: " LP_MONO_QSS ";"));
m_ep2RateLabel->setFixedWidth(70);
ep2Row->addWidget(ep2Lbl);
ep2Row->addWidget(m_ep2Bar, 1);
@@ -603,7 +603,7 @@ void Hl2IoBoardTab::buildI2cAndBandwidthRow(QVBoxLayout* outer)
droppedLbl->setStyleSheet(QStringLiteral("font-size: 11px;"));
m_throttleEventLabel = new QLabel(QStringLiteral("0"), bwGroup);
m_throttleEventLabel->setStyleSheet(
- QStringLiteral("font-size: 11px; font-family: monospace;"));
+ QStringLiteral("font-size: 11px; font-family: " LP_MONO_QSS ";"));
droppedRow->addWidget(droppedLbl);
droppedRow->addWidget(m_throttleEventLabel);
droppedRow->addStretch();
diff --git a/src/gui/setup/hardware/Hl2OptionsTab.cpp b/src/gui/setup/hardware/Hl2OptionsTab.cpp
index 485616cd4..fcea0e350 100644
--- a/src/gui/setup/hardware/Hl2OptionsTab.cpp
+++ b/src/gui/setup/hardware/Hl2OptionsTab.cpp
@@ -57,6 +57,7 @@
//============================================================================================//
#include "Hl2OptionsTab.h"
+#include "gui/StyleConstants.h" // LP_MONO_QSS
#include "core/BoardCapabilities.h"
#include "core/Hl2OptionsModel.h"
@@ -346,7 +347,7 @@ void Hl2OptionsTab::buildI2cControl(QWidget* parent)
lbl->setAlignment(Qt::AlignCenter);
lbl->setStyleSheet(QStringLiteral(
"QLabel { background: white; color: black; "
- "font-family: monospace; border: 1px solid #555; padding: 2px; }"));
+ "font-family: " LP_MONO_QSS "; border: 1px solid #555; padding: 2px; }"));
return lbl;
};
m_byte0Label = makeByteLbl();
diff --git a/src/gui/widgets/AdcOverloadBadge.cpp b/src/gui/widgets/AdcOverloadBadge.cpp
index daa4a5adf..9b8239087 100644
--- a/src/gui/widgets/AdcOverloadBadge.cpp
+++ b/src/gui/widgets/AdcOverloadBadge.cpp
@@ -1,6 +1,7 @@
// src/gui/widgets/AdcOverloadBadge.cpp
// no-port-check: Longpath-original Qt widget — see header for rationale.
#include "AdcOverloadBadge.h"
+#include "gui/StyleConstants.h" // LP_MONO_QSS
#include
#include
@@ -103,7 +104,7 @@ void AdcOverloadBadge::applyStyle()
// a label rather than a value.
"QLabel#AdcOverloadBadge_Top {"
" color: %2;"
- " font-family: 'SF Mono', Menlo, monospace;"
+ " font-family: " LP_MONO_QSS ";"
" font-size: 9px; font-weight: 600;"
" letter-spacing: 1px;"
" background: transparent; border: none;"
@@ -112,7 +113,7 @@ void AdcOverloadBadge::applyStyle()
// alarm word that the user reads first.
"QLabel#AdcOverloadBadge_Bottom {"
" color: %2;"
- " font-family: 'SF Mono', Menlo, monospace;"
+ " font-family: " LP_MONO_QSS ";"
" font-size: 11px; font-weight: 800;"
" letter-spacing: 0.5px;"
" background: transparent; border: none;"
diff --git a/src/gui/widgets/FilterPolicyDialog.cpp b/src/gui/widgets/FilterPolicyDialog.cpp
index 909b3e164..c998e7fe7 100644
--- a/src/gui/widgets/FilterPolicyDialog.cpp
+++ b/src/gui/widgets/FilterPolicyDialog.cpp
@@ -58,7 +58,7 @@ FilterPolicyDialog::FilterPolicyDialog(int chainIndex, AlexController* alex, QWi
auto* stateLbl = new QLabel(
QStringLiteral("Effective: %1\nReason: %2").arg(effectiveText, state.reasonText),
stateGroup);
- stateLbl->setStyleSheet(QStringLiteral("font-family: monospace; font-size: 11px;"));
+ stateLbl->setStyleSheet(QStringLiteral("font-family: " LP_MONO_QSS "; font-size: 11px;"));
stateLbl->setWordWrap(true);
stateLayout->addWidget(stateLbl);
main->addWidget(stateGroup);
diff --git a/src/gui/widgets/MetricLabel.cpp b/src/gui/widgets/MetricLabel.cpp
index 2ccb8f8d0..c77f35a75 100644
--- a/src/gui/widgets/MetricLabel.cpp
+++ b/src/gui/widgets/MetricLabel.cpp
@@ -1,4 +1,5 @@
#include "MetricLabel.h"
+#include "gui/StyleConstants.h" // LP_MONO_QSS
#include "gui/styles/ThemeQss.h"
#include
@@ -43,10 +44,10 @@ void MetricLabel::applyStyle()
{
setStyleSheet(Style::themed(QStringLiteral(
"QLabel#MetricLabel_Label { color: #607080;"
- " font-family: 'SF Mono', Menlo, monospace;"
+ " font-family: " LP_MONO_QSS ";"
" font-size: 9px; font-weight: 500; letter-spacing: 0.5px; }"
"QLabel#MetricLabel_Value { color: #8aa8c0;"
- " font-family: 'SF Mono', Menlo, monospace;"
+ " font-family: " LP_MONO_QSS ";"
" font-size: 11px; font-weight: 600; }"
)));
}
diff --git a/src/gui/widgets/OverflowChip.cpp b/src/gui/widgets/OverflowChip.cpp
index 3e2f1e042..1ce71f08c 100644
--- a/src/gui/widgets/OverflowChip.cpp
+++ b/src/gui/widgets/OverflowChip.cpp
@@ -1,5 +1,6 @@
// src/gui/widgets/OverflowChip.cpp
#include "OverflowChip.h"
+#include "gui/StyleConstants.h" // LP_MONO_QSS
#include "gui/styles/ThemeQss.h"
#include
@@ -29,7 +30,7 @@ OverflowChip::OverflowChip(QWidget* parent) : QWidget(parent)
"}"
"QLabel#OverflowChip_Glyph {"
" color: #8aa8c0;"
- " font-family: 'SF Mono', Menlo, monospace;"
+ " font-family: " LP_MONO_QSS ";"
" font-size: 16px; font-weight: 700;"
" background: transparent; border: none;"
" padding: 0 2px;"
diff --git a/src/gui/widgets/RotorLogbookPanel.cpp b/src/gui/widgets/RotorLogbookPanel.cpp
index b65e7ae90..4d5408cb0 100644
--- a/src/gui/widgets/RotorLogbookPanel.cpp
+++ b/src/gui/widgets/RotorLogbookPanel.cpp
@@ -1416,7 +1416,7 @@ void RotorLogbookPanel::openRotorSetupDialog()
installLog->setVisible(false);
installLog->setStyleSheet(
QStringLiteral("QPlainTextEdit { background: %1; color: %2; "
- "font-family: Menlo, monospace; font-size: 11px; "
+ "font-family: " LP_MONO_QSS "; font-size: 11px; "
"border: 1px solid %3; }")
.arg(QString::fromLatin1(Style::kInsetBg),
QString::fromLatin1(Style::kTextSecondary),
diff --git a/src/gui/widgets/StationBlock.cpp b/src/gui/widgets/StationBlock.cpp
index 7d088bf78..3cd02223a 100644
--- a/src/gui/widgets/StationBlock.cpp
+++ b/src/gui/widgets/StationBlock.cpp
@@ -1,5 +1,6 @@
// src/gui/widgets/StationBlock.cpp
#include "StationBlock.h"
+#include "gui/StyleConstants.h" // LP_MONO_QSS
#include "gui/styles/ThemeQss.h"
#include
@@ -104,14 +105,14 @@ void StationBlock::applyStyle()
setStyleSheet(Style::themed(QStringLiteral(
"Longpath--StationBlock { border: 1px solid rgba(0,180,216,80);"
" background: #0a0a14; border-radius: 6px; }"
- "QLabel { color: #c8d8e8; font-family: 'SF Mono', Menlo, monospace;"
+ "QLabel { color: #c8d8e8; font-family: " LP_MONO_QSS ";"
" font-size: 13px; font-weight: bold; background: transparent; border: none; }"
)));
} else {
setStyleSheet(Style::themed(QStringLiteral(
"Longpath--StationBlock { border: 1px dashed rgba(255,96,96,102);"
" background: #0a0a14; border-radius: 6px; }"
- "QLabel { color: #607080; font-family: 'SF Mono', Menlo, monospace;"
+ "QLabel { color: #607080; font-family: " LP_MONO_QSS ";"
" font-size: 13px; font-style: italic; background: transparent; border: none; }"
)));
}
diff --git a/src/gui/widgets/StatusBadge.cpp b/src/gui/widgets/StatusBadge.cpp
index 9ddddcf4d..ea9c1a9e4 100644
--- a/src/gui/widgets/StatusBadge.cpp
+++ b/src/gui/widgets/StatusBadge.cpp
@@ -250,7 +250,7 @@ void StatusBadge::applyStyle()
// width layout. The vertical padding bump lives in the constructor.
setStyleSheet(QStringLiteral(
"Longpath--StatusBadge { background: %1; border-radius: 6px; }"
- "QLabel { color: %2; font-family: 'SF Mono', Menlo, monospace;"
+ "QLabel { color: %2; font-family: " LP_MONO_QSS ";"
" font-size: 13px; font-weight: 600; line-height: 1.4; }"
).arg(bg, fg));
}
diff --git a/src/gui/widgets/SwrSweepPanel.cpp b/src/gui/widgets/SwrSweepPanel.cpp
index 1f11c3a17..cf58432a1 100644
--- a/src/gui/widgets/SwrSweepPanel.cpp
+++ b/src/gui/widgets/SwrSweepPanel.cpp
@@ -577,7 +577,7 @@ void SwrSweepPanel::refreshTunePowerLabel()
// should be visible before a sweep rather than after.
const bool quiet = (raw.first < 100 || raw.second < 20);
m_couplerLabel->setStyleSheet(
- QStringLiteral("color:%1; font-family: monospace;")
+ QStringLiteral("color:%1; font-family: " LP_MONO_QSS ";")
.arg(QString::fromLatin1(
quiet ? Style::kTextSecondary : Style::kGreenText)));
} else {
diff --git a/src/models/RadioModel.cpp b/src/models/RadioModel.cpp
index a4e6e3db8..770f08d60 100644
--- a/src/models/RadioModel.cpp
+++ b/src/models/RadioModel.cpp
@@ -292,6 +292,7 @@ warren@wpratt.com
#include "core/RadioConnection.h"
#include "core/RadioConnectionTeardown.h"
#include "core/P1RadioConnection.h"
+#include "core/SunSdrRadioConnection.h"
#include "core/P2RadioConnection.h"
#include "core/PsccPump.h" // Phase 3M-4 Task 17 chunk C — pscc() driver
#include "core/WidebandFftEngine.h" // Phase 3F Sub-Epic F Task 5 — per-ADC wb FFT
@@ -4245,7 +4246,38 @@ bool RadioModel::sampleRateIsRadioWide() const
// takes a single sampleRate and encodes it as srBits, so there is no
// per-receiver rate to set. Protocol 2 carries a per-DDC rate through
// DdcAssignment::rate[], which the codecs populate per stream.
- return qobject_cast(m_connection) != nullptr;
+ if (qobject_cast(m_connection) != nullptr) {
+ return true;
+ }
+
+ // Die SunSDR gehoert auf dieselbe Seite, und das ist seit dem
+ // 2026-10-04 nicht mehr nur eine Einordnung: der Stromstart-Rahmen 0x01
+ // traegt EINEN Modus fuer das ganze Geraet (SunSdrProtocol.h,
+ // StromModus), den SunSdrRadioConnection::setSampleRate waehlt. Eine
+ // Rate je DDC, die man einzeln stellen koennte, gibt es hier nicht.
+ //
+ // Stand hier nur P1, galt die SunSDR als "Rate je Strom" -- und der Weg
+ // auf den Draht fuer so eine Rate ist der DdcAssignment-Push in
+ // invokeCodecDdcAssignment, der ausdruecklich nur P2 bedient. Also ging
+ // eine Ratenaenderung nach dem Verbinden gar nicht hinaus. An der echten
+ // QRP sah das am 2026-10-04 so aus:
+ //
+ // INF: Connecting with sampleRate= 96000 inSize= 128
+ // INF: SunSdr: Abtastrate -> 96000 Hz (Stromstart-Rahmen ...)
+ // DBG: Connected to "SunSDR2 QRP"
+ // INF: setRxChannelRate: channel 0 -> 48000 Hz, in_size= 64
+ //
+ // 63 ms nach dem Verbinden zog die Wiederanwendung der je Band
+ // gespeicherten Rate den Empfangskanal auf 48 kHz, waehrend das Geraet
+ // weiter mit 96 streamte (Stromkopf 0200, 960 Folgenummern/s) -- wieder
+ // Daten einer Rate in einem Kanal einer anderen, derselbe Riss wie am
+ // 2026-09-24. Die RATE-Anzeige der Kopfleiste stand danach bernsteinfarben
+ // auf "48 kHz"; sie hat nicht geirrt, sie hat genau das gemeldet.
+ //
+ // "Radio-wide" schickt die Aenderung stattdessen durch setSampleRateLive,
+ // und dessen Schritt 4 ruft conn->setSampleRate() fuer alles, was nicht P1
+ // ist -- bei der SunSDR also den Stromstart-Rahmen.
+ return qobject_cast(m_connection) != nullptr;
}
int RadioModel::rx0ChannelRateHz() const
@@ -8790,10 +8822,47 @@ void RadioModel::connectToRadio(const RadioInfo& info)
// setActiveReceiverCount on P2 here would enable DDC0..N-1 on top of
// the DDC2 enable that connectToRadio sets, leaving extra DDCs active.
// Deferred to Phase 3F (multi-panadapter) which ports UpdateDDCs().
+ //
+ // Die SunSDR gehoert auf dieselbe Seite wie P1 und zwar seit dem
+ // 2026-10-04 zwingend: ihr setActiveReceiverCount() setzt keine DDCs
+ // frei, sondern waehlt den Stromstart-Modus (ein Strom oder zwei,
+ // SunSdrProtocol.h). Ohne diesen Push bleibt im Treiber stehen, was
+ // die VORIGE Sitzung gesetzt hat -- eine Sitzung mit zwei Empfaengern
+ // vererbt den zweiten Strom an die naechste, und ueber die Oberflaeche
+ // laesst sich der zweite Empfaenger beim Verbinden gar nicht
+ // einschalten, nur nachtraeglich.
+ //
+ // Davor hat der Sitzungs-Reset im Treiber das verdeckt (er setzte auf
+ // 1 zurueck); der musste weg, weil er auch die Rate wegwarf, die
+ // RadioModel kurz vorher gesetzt hatte -- siehe 88844941.
if (info.protocol == ProtocolVersion::Protocol1) {
QMetaObject::invokeMethod(m_connection, [conn = m_connection, activeRxCount]() {
conn->setActiveReceiverCount(activeRxCount);
});
+ } else if (info.protocol == ProtocolVersion::SunSdr) {
+ // Fuer die SunSDR NICHT der gespeicherte Wert, sondern die Zahl
+ // der Empfaenger, die es oben WIRKLICH gibt.
+ //
+ // Hintergrund (2026-10-04, am Geraet gesehen): mit
+ // activeRxCount = 2 und EINER Scheibe forderte Longpath zwei
+ // Stroeme an, und der zweite fiel eine Ebene hoeher weg --
+ //
+ // SunSdr: Stromstart-Rahmen -> zwei Stroeme, je 48 kHz
+ // ReceiverManager: first feedIqData DROPPED; hw= 1 map="hw0->rx0"
+ //
+ // Doppelte Netzlast ohne Gegenwert. Ein Empfaenger entsteht erst,
+ // wenn sich eine Scheibe an den Strom bindet
+ // (syncReceiverToStream), und genau diese Zahl wird hier
+ // geschickt. Waechst sie spaeter, zieht die Verdrahtung ueber
+ // ReceiverManager::hardwareReceiverCountChanged nach -- die gibt
+ // es laengst, mein frueherer Push mit dem gespeicherten Wert hat
+ // sie nur ueberstimmt.
+ const int echte = qMax(1, m_receiverManager
+ ? m_receiverManager->activeReceiverCount()
+ : 1);
+ QMetaObject::invokeMethod(m_connection, [conn = m_connection, echte]() {
+ conn->setActiveReceiverCount(echte);
+ });
}
if (m_activeSlice) {
int hwRx = m_receiverManager->receiverConfig(0).hardwareRx;
diff --git a/src/models/RadioModel.h b/src/models/RadioModel.h
index 936165cd0..06e1b3369 100644
--- a/src/models/RadioModel.h
+++ b/src/models/RadioModel.h
@@ -706,9 +706,19 @@ class RadioModel : public QObject {
/// Protocol 1 encodes the rate as srBits in C&C bank 0
/// (P1RadioConnection::composeCcBank0 takes a single sampleRate), so every
/// stream shares it and setStreamSampleRate fans a change across all of
- /// them. Protocol 2 carries a per-DDC rate in DdcAssignment::rate[]. UI
- /// that offers the rate from a per-slice surface has to disclose the P1
- /// scope rather than imply a private rate. False when disconnected.
+ /// them. Die SunSDR ebenso: ihr Stromstart-Rahmen 0x01 traegt einen Modus
+ /// fuer das ganze Geraet (SunSdrProtocol.h, StromModus). Protocol 2
+ /// carries a per-DDC rate in DdcAssignment::rate[]. UI that offers the
+ /// rate from a per-slice surface has to disclose the radio-wide scope
+ /// rather than imply a private rate. False when disconnected.
+ ///
+ /// Das ist nicht nur eine Beschriftung: nur fuer "radio-wide" schickt
+ /// setStreamSampleRate die Aenderung ueber setSampleRateLive auf den
+ /// Draht. Der Weg fuer eine Rate je DDC ist der DdcAssignment-Push, und
+ /// der bedient ausschliesslich P2 (invokeCodecDdcAssignment). Eine
+ /// Verbindung, die hier faelschlich false meldet, stellt ihren
+ /// Empfangskanal um und laesst das Geraet auf der alten Rate stehen
+ /// (2026-10-04 an der SunSDR2 QRP gemessen).
bool sampleRateIsRadioWide() const;
/// Sample rates the connected radio accepts, ascending; empty when
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 57b8d8a32..22842c16c 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -233,6 +233,7 @@ longpath_add_test(tst_setup_helpers)
longpath_add_test(tst_smoke)
# ── Issue #75: receiver leak across disconnect/reconnect ──────────────────
+longpath_add_test(tst_sunsdr_zweiter_empfaenger_oben)
longpath_add_test(tst_receiver_manager_reconnect)
# ── HL2 connect-init parity: spectrum DDC center push (Bug 1) ─────────────
@@ -345,6 +346,17 @@ if(TEST tst_nnr_without_model)
ENVIRONMENT "LONGPATH_SOURCE_DIR=${CMAKE_SOURCE_DIR}")
endif()
+# ── Waechter gegen das Auseinanderlaufen der Schriftwahl (2026-10-06) ──
+# Liest den Quelltext: jede Stilvorlage muss LP_MONO_QSS benutzen, und
+# keine QFont darf eine Familie ohne Rueckfallkette nennen. Anlass war
+# die Protokollzeile "missing font family SF Mono" -- dahinter steckten
+# acht Schreibweisen und vier verschiedene Schriften auf dem Schirm.
+longpath_add_test(tst_schriftfamilien)
+if(TEST tst_schriftfamilien)
+ set_tests_properties(tst_schriftfamilien PROPERTIES
+ ENVIRONMENT "LONGPATH_SOURCE_DIR=${CMAKE_SOURCE_DIR}")
+endif()
+
# ── Phase 3M-4 Task 3: TxChannel PureSignal API wrappers (22 functions) ──
# Smoke tests for the 22 PS API wrappers added in Task 3:
# 19 calcc setters/readers (setPSRunCal, setPSMox, setPSReset, setPSMancal,
@@ -1369,6 +1381,7 @@ longpath_add_test(tst_audio_engine_vax_tee)
# Covers setMasterMuted / masterMuted / masterMutedChanged. Verifies the
# rxBlockReady mute check gates ONLY the speakers push (VAX taps must keep
# running so 3rd-party consumer apps don't hear the local monitor mute).
+longpath_add_test(tst_audio_engine_dead_speakers)
longpath_add_test(tst_audio_engine_master_mute)
# ── Phase 3O Sub-Phase 10 Task 10b: MasterOutputWidget ──────────────────────
diff --git a/tests/fakes/FakeAudioBus.h b/tests/fakes/FakeAudioBus.h
index 19e89abc3..2625b9256 100644
--- a/tests/fakes/FakeAudioBus.h
+++ b/tests/fakes/FakeAudioBus.h
@@ -41,6 +41,9 @@ class FakeAudioBus : public IAudioBus {
void close() override { m_open = false; }
bool isOpen() const override { return m_open; }
+ // Getrennt von isOpen(): ein Strom kann geoeffnet SEIN und trotzdem
+ // tot. Genau das ist am 2026-10-04 am Mac passiert.
+ bool isAlive() const override { return m_open && m_lebt; }
qint64 push(const char* data, qint64 bytes) override {
if (!m_open || data == nullptr || bytes <= 0) {
@@ -97,6 +100,10 @@ class FakeAudioBus : public IAudioBus {
// next open() call return false (and leave the bus closed).
void setOpenResult(bool ok) { m_openResult = ok; }
+ // Der Strom gilt weiter als geoeffnet, lebt aber nicht mehr --
+ // Geraet weggezogen, Rate umgestellt, Ruhezustand.
+ void setLebendig(bool lebt) { m_lebt = lebt; }
+
// Inject data for pull() to return. Resets the read cursor.
// Caller specifies the negotiated format via setNegotiatedFormat() or
// open(fmt) before calling setPullData(). The byte layout must match
@@ -118,6 +125,7 @@ class FakeAudioBus : public IAudioBus {
AudioFormat m_negotiatedFormat;
bool m_open{false};
bool m_openResult{true};
+ bool m_lebt{true};
QByteArray m_buffer;
int m_pushes{0};
qint64 m_lastPushBytes{0};
diff --git a/tests/tst_audio_engine_dead_speakers.cpp b/tests/tst_audio_engine_dead_speakers.cpp
new file mode 100644
index 000000000..0eaf9b251
--- /dev/null
+++ b/tests/tst_audio_engine_dead_speakers.cpp
@@ -0,0 +1,77 @@
+// Ton im Programm, nichts aus den Lautsprechern (2026-10-04)
+//
+// Am Mac des Betreibers hatte Longpath Ton -- die Handy-App bekam ihn ueber
+// TCI --, aus den Lautsprechern kam nichts. Im Log:
+//
+// 07:24:21 PortAudioBus: output via [Core Audio] on "MacBook Air-..."
+// 07:57:45 AudioEngine started ( speakers bus open )
+//
+// Der Ausgang war 33 Minuten VOR dem Verbinden geoeffnet worden.
+// AudioEngine::ensureSpeakersOpen() prueft mit isOpen(), und das ist bei
+// PortAudioBus ein Zeigervergleich (m_stream != nullptr). Stirbt der Strom
+// darunter -- Geraet gewechselt, Rate umgestellt (auf dem Rechner laufen
+// BoomAudio und DeskFx als virtuelle Treiber), Ruhezustand --, bleibt der
+// Zeiger stehen. ensureSpeakersOpen kehrt sofort zurueck, und Longpath
+// schreibt in einen toten Strom, ohne dass irgendwo etwas auffaellt.
+
+#include
+
+#include "core/AudioEngine.h"
+#include "core/IAudioBus.h"
+#include "models/RadioModel.h"
+
+#include "fakes/FakeAudioBus.h"
+
+#include
+
+using namespace Longpath;
+
+class TstAudioEngineDeadSpeakers : public QObject {
+ Q_OBJECT
+
+private slots:
+ // Ein lebender Bus bleibt in Ruhe -- sonst wuerde die Behebung bei
+ // jedem Start unnoetig neu oeffnen und es klickte.
+ void lebenderAusgangBleibtStehen()
+ {
+ RadioModel radio;
+ AudioEngine* engine = radio.audioEngine();
+ QVERIFY(engine != nullptr);
+
+ auto bus = std::make_unique();
+ FakeAudioBus* roh = bus.get();
+ roh->open(AudioFormat{48000, 2, AudioFormat::Sample::Float32});
+ engine->setSpeakersBusForTest(std::move(bus));
+ QCOMPARE(engine->speakersBusForTest(), static_cast(roh));
+
+ engine->ensureSpeakersOpenForTest();
+ QCOMPARE(engine->speakersBusForTest(), static_cast(roh));
+ }
+
+ // Geoeffnet, aber tot: der Bus muss weg. Vor der Behebung blieb er
+ // stehen, weil isOpen() weiter "ja" sagte.
+ void toterAusgangWirdWeggeraeumt()
+ {
+ RadioModel radio;
+ AudioEngine* engine = radio.audioEngine();
+ QVERIFY(engine != nullptr);
+
+ auto bus = std::make_unique();
+ FakeAudioBus* roh = bus.get();
+ roh->open(AudioFormat{48000, 2, AudioFormat::Sample::Float32});
+ roh->setLebendig(false); // offen, aber tot
+ QVERIFY(roh->isOpen());
+ QVERIFY(!roh->isAlive());
+ engine->setSpeakersBusForTest(std::move(bus));
+
+ engine->ensureSpeakersOpenForTest();
+
+ // Entweder steht dort jetzt ein neuer Bus oder gar keiner (wenn das
+ // Geraet im Pruefstand nicht aufgeht). Beides ist richtig -- nur der
+ // tote darf nicht stehenbleiben.
+ QVERIFY(engine->speakersBusForTest() != static_cast(roh));
+ }
+};
+
+QTEST_MAIN(TstAudioEngineDeadSpeakers)
+#include "tst_audio_engine_dead_speakers.moc"
diff --git a/tests/tst_schriftfamilien.cpp b/tests/tst_schriftfamilien.cpp
new file mode 100644
index 000000000..ef12cc1ad
--- /dev/null
+++ b/tests/tst_schriftfamilien.cpp
@@ -0,0 +1,168 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// tests/tst_schriftfamilien.cpp (Longpath)
+//
+// Ein WAECHTER gegen das Auseinanderlaufen der Schriftwahl.
+//
+// Anlass, 2026-10-06: der Betreiber sagte „das Rendering ist bei Longpath
+// nicht ueberall gleich" und verwies aufs Protokoll. Dort stand:
+//
+// Populating font family aliases took N ms. Replace uses of missing
+// font family "SF Mono" with one that exists to avoid this cost.
+//
+// Nachgezaehlt gab es ACHT Schreibweisen fuer dieselbe Absicht, und auf
+// einem Mac kamen dabei VIER verschiedene Schriften heraus: Menlo,
+// Monaco, Courier New und -- an zwei Stellen ohne Rueckfallkette --
+// die proportionale Vorgabeschrift. Letztere war ausgerechnet die
+// Verbindungsanzeige in der Titelleiste und der Protokollbetrachter.
+//
+// Dieser Test liest den QUELLTEXT. Das ist ungewoehnlich, aber es ist
+// die einzige Stelle, an der sich das pruefen laesst: zur Laufzeit
+// sieht man nur, was Qt daraus gemacht hat, und Qt macht aus einer
+// fehlenden Familie klaglos irgendeine andere.
+//
+// =================================================================
+// Modification history (Longpath):
+// 2026-10-06 — Original fuer Longpath, KI-gestuetzt (Anthropic
+// Claude), Betreiber Martin Fischer.
+// =================================================================
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+
+QString quellbaum()
+{
+ const QByteArray env = qgetenv("LONGPATH_SOURCE_DIR");
+ if (!env.isEmpty()) {
+ return QString::fromLocal8Bit(env) + QStringLiteral("/src");
+ }
+ return QString();
+}
+
+} // namespace
+
+class TestSchriftfamilien : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ // Jede Stilvorlage, die eine Schriftfamilie nennt, muss die zentrale
+ // Konstante benutzen. Eine Familie direkt hinzuschreiben ist der Weg,
+ // auf dem die acht Schreibweisen entstanden sind.
+ void keineSchriftfamilieVonHandInStilvorlagen()
+ {
+ const QString wurzel = quellbaum();
+ if (wurzel.isEmpty()) {
+ QSKIP("LONGPATH_SOURCE_DIR nicht gesetzt");
+ }
+ // "font-family:" gefolgt von irgendetwas, das NICHT sofort das
+ // schliessende Anfuehrungszeichen der Konstante ist.
+ // Leerzeichen AUSDRUECKLICH, nicht \\s*: mit \\s* darf die Suche
+ // null Leerzeichen nehmen und das Leerzeichen selbst als "kein
+ // Anfuehrungszeichen" lesen -- dann meldet sie jede richtige
+ // Zeile als Fund (beim ersten Lauf genau so passiert).
+ static const QRegularExpression vonHand(
+ QStringLiteral("font-family: *[^ \"]"));
+
+ QStringList funde;
+ QDirIterator it(wurzel, QStringList{QStringLiteral("*.cpp"),
+ QStringLiteral("*.h")},
+ QDir::Files, QDirIterator::Subdirectories);
+ while (it.hasNext()) {
+ const QString pfad = it.next();
+ QFile f(pfad);
+ if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { continue; }
+ QTextStream in(&f);
+ int zeile = 0;
+ while (!in.atEnd()) {
+ ++zeile;
+ const QString z = in.readLine();
+ // Die Erklaerung der Konstante selbst zaehlt nicht mit:
+ // dort stehen die alten Schreibweisen mit Absicht, als
+ // Beleg. Kommentarzeilen also auslassen.
+ const QString beschnitten = z.trimmed();
+ if (beschnitten.startsWith(QStringLiteral("//"))
+ || beschnitten.startsWith(QStringLiteral("///"))
+ || beschnitten.startsWith(QStringLiteral("*"))) {
+ continue;
+ }
+ if (vonHand.match(z).hasMatch()) {
+ funde << QStringLiteral("%1:%2 %3")
+ .arg(QDir(wurzel).relativeFilePath(pfad))
+ .arg(zeile)
+ .arg(beschnitten);
+ }
+ }
+ }
+ if (!funde.isEmpty()) {
+ QFAIL(qPrintable(
+ QStringLiteral(
+ "%1 Stilvorlage(n) nennen eine Schriftfamilie von Hand "
+ "statt LP_MONO_QSS:\n %2")
+ .arg(funde.size())
+ .arg(funde.join(QStringLiteral("\n ")))));
+ }
+ }
+
+ // Eine QFont mit EINER Familie und ohne Rueckfallkette ist die zweite
+ // Art, wie es schieflaeuft: fehlt die Familie, nimmt Qt die
+ // proportionale Vorgabe, und niemand merkt es.
+ void keineQFontOhneRueckfallkette()
+ {
+ const QString wurzel = quellbaum();
+ if (wurzel.isEmpty()) {
+ QSKIP("LONGPATH_SOURCE_DIR nicht gesetzt");
+ }
+ // QFont("Irgendeine Schrift" ...) -- nur benannte Familien, nicht
+ // QFont(), QFont(base), QFont(f) usw.
+ static const QRegularExpression ohneKette(
+ QStringLiteral("QFont\\s*\\(\\s*(QStringLiteral\\s*\\(\\s*)?\""));
+
+ QStringList funde;
+ QDirIterator it(wurzel, QStringList{QStringLiteral("*.cpp"),
+ QStringLiteral("*.h")},
+ QDir::Files, QDirIterator::Subdirectories);
+ while (it.hasNext()) {
+ const QString pfad = it.next();
+ QFile f(pfad);
+ if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { continue; }
+ QTextStream in(&f);
+ int zeile = 0;
+ while (!in.atEnd()) {
+ ++zeile;
+ const QString z = in.readLine();
+ const QString beschnitten = z.trimmed();
+ if (beschnitten.startsWith(QStringLiteral("//"))
+ || beschnitten.startsWith(QStringLiteral("*"))) {
+ continue;
+ }
+ if (ohneKette.match(z).hasMatch()) {
+ funde << QStringLiteral("%1:%2 %3")
+ .arg(QDir(wurzel).relativeFilePath(pfad))
+ .arg(zeile)
+ .arg(beschnitten);
+ }
+ }
+ }
+ if (!funde.isEmpty()) {
+ QFAIL(qPrintable(
+ QStringLiteral(
+ "%1 QFont(...) nennt eine Familie ohne Rueckfallkette. "
+ "Style::monoFont() benutzen (setFamilies), sonst nimmt "
+ "Qt bei fehlender Schrift klaglos die proportionale "
+ "Vorgabe:\n %2")
+ .arg(funde.size())
+ .arg(funde.join(QStringLiteral("\n ")))));
+ }
+ }
+};
+
+QTEST_MAIN(TestSchriftfamilien)
+#include "tst_schriftfamilien.moc"
diff --git a/tests/tst_sunsdr_board_caps.cpp b/tests/tst_sunsdr_board_caps.cpp
index 2046da275..373d2e877 100644
--- a/tests/tst_sunsdr_board_caps.cpp
+++ b/tests/tst_sunsdr_board_caps.cpp
@@ -35,7 +35,10 @@
#include "core/HpsdrModel.h"
#include "core/RadioDiscovery.h"
#include "core/AppSettings.h"
+#include "core/SampleRateCatalog.h"
#include "core/SunSdrRadioConnection.h"
+#include "core/RxChannel.h"
+#include "core/WdspEngine.h"
#include "models/RadioModel.h"
#include "models/SliceModel.h"
@@ -79,7 +82,12 @@ private slots:
infoFor(HPSDRHW::SunSdr2Qrp, ProtocolVersion::SunSdr));
QCOMPARE(model.boardCapabilities().board, HPSDRHW::SunSdr2Qrp);
- QCOMPARE(model.boardCapabilities().maxSampleRate, 48000);
+ // 96 000 seit dem 2026-10-03: am Geraet gemessen, dass der
+ // Stromstart-Rahmen 0x01 die Rate stellt und Kanal 0 dann mit 480
+ // Folgenummern je Sekunde ankommt. Bis dahin stand hier 48 000 --
+ // mit dem Vermerk "nicht verhandelt", und das war schlicht die
+ // Grenze unseres Wissens, nicht die des Geraets.
+ QCOMPARE(model.boardCapabilities().maxSampleRate, 96000);
}
void withoutTheExceptionItIsAtlasAgain()
@@ -121,9 +129,15 @@ private slots:
model.injectConnectionForTest(&conn);
auto detach = qScopeGuard([&] { model.injectConnectionForTest(nullptr); });
- QCOMPARE(model.allowedStreamSampleRates(), QVector{48000});
+ QCOMPARE(model.allowedStreamSampleRates(), (QVector{48000, 96000}));
QVERIFY(model.restoredRateAllowed(48000));
+ QVERIFY(model.restoredRateAllowed(96000));
+ // Und was die QRP NICHT kann, bleibt gesperrt -- darum geht es in
+ // diesem Pruefpunkt. Am 2026-09-24 hat eine wiederhergestellte
+ // 192-kHz-Rate 48k-Daten in einen 192k-Kanal gelegt, und der
+ // Betreiber hat es als "schlechtes Rauschen" gehoert.
QVERIFY(!model.restoredRateAllowed(192000));
+ QVERIFY(!model.restoredRateAllowed(144000));
}
void theConnectedRestoreKeepsTheQrpAt48k()
@@ -154,6 +168,118 @@ private slots:
QCOMPARE(model.streamSampleRateHzForTest(stream), 48000);
}
+
+ // ── Die Rate muss auch das GERAET erreichen ──────────────────────────
+ //
+ // Gefunden am 2026-10-04 an der echten QRP, im Protokoll der Sitzung
+ // von 06:59:53:
+ //
+ // INF: Connecting with sampleRate= 96000 inSize= 128
+ // INF: SunSdr: Abtastrate -> 96000 Hz (Stromstart-Rahmen ...)
+ // DBG: Connected to "SunSDR2 QRP"
+ // INF: setRxChannelRate: channel 0 -> 48000 Hz, in_size= 64
+ //
+ // 63 ms nach dem Verbinden zog die Wiederanwendung der je Band
+ // gespeicherten Rate den WDSP-Kanal auf 48 kHz -- und NICHTS schickte
+ // das an das Geraet. Das streamte weiter mit 96 (Stromkopf 0200, 960
+ // Folgenummern/s). Die RATE-Anzeige der Kopfleiste stand danach auf
+ // "48 kHz" und bernsteinfarben; sie hat nicht gelogen, sie hat genau
+ // diesen Riss gemeldet.
+ //
+ // Warum er entstand: setStreamSampleRate schickt die Rate nur dann auf
+ // den Draht, wenn sampleRateIsRadioWide() gilt -- und das war bis
+ // heute ausschliesslich Protokoll 1. Alles andere galt als "Rate je
+ // DDC", und DEREN Weg auf den Draht ist der DdcAssignment-Push in
+ // invokeCodecDdcAssignment, der ausdruecklich nur P2 bedient. Die
+ // SunSDR hat aber gar keine Rate je DDC: der Stromstart-Rahmen 0x01
+ // traegt EINEN Modus fuer das ganze Geraet (SunSdrProtocol.h,
+ // StromModus).
+ //
+ // Das Gegenstueck fuer P1 steht in tst_stream_pool_binding
+ // (on_protocol1_the_rate_change_reaches_the_wire); dieser Pruefpunkt
+ // ist dieselbe Frage fuer die SunSDR.
+ void theRestoredRateAlsoReachesTheRadio()
+ {
+ RadioModel model;
+ model.applyHardwareProfileForTest(
+ infoFor(HPSDRHW::SunSdr2Qrp, ProtocolVersion::SunSdr));
+
+ SunSdrRadioConnection conn;
+ model.injectConnectionForTest(&conn);
+ auto detach = qScopeGuard([&] { model.injectConnectionForTest(nullptr); });
+
+ // Genau der Stand nach connectToRadio: Geraet und Kanal auf 96 kHz.
+ conn.setSampleRate(96000);
+ QCOMPARE(conn.stromModusForTest(), 2); // ZweiStroemeJe96
+ model.setConnectionRateForTest(96000, 1);
+
+ WdspEngine* engine = model.wdspEngine();
+ engine->m_initialized = true; // friend access (LONGPATH_BUILD_TESTS)
+
+ model.configureStreamPool(/*userDdcCount*/ 1, /*maxSlices*/ 1, 96000);
+ const int id = model.addSlice();
+ SliceModel* slice = model.sliceById(id);
+ QVERIFY(slice);
+ const int stream = slice->streamIndex();
+ QVERIFY2(stream >= 0, "precondition: the slice is bound to a stream");
+ engine->createRxChannel(id, bufferSizeForRate(96000), 4096,
+ 96000, 48000, 48000);
+
+ // Der Stand aus einer frueheren Sitzung: dieses Band lief auf 48 kHz.
+ // Die QRP kann die Rate, also gilt sie (restoredRateAllowed).
+ slice->setSampleRateHz(48000);
+ model.applyRestoredSampleRate(slice);
+
+ // setSampleRateLive schiebt den Draht-Schreibvorgang als Ereignis an
+ // die Verbindung; hier liegt sie auf demselben Faden, also von Hand
+ // abarbeiten (wie im P1-Gegenstueck).
+ QCoreApplication::processEvents();
+
+ // Die Client-Seite ist mitgegangen -- das tat sie vorher auch.
+ QCOMPARE(model.streamSampleRateHzForTest(stream), 48000);
+ QCOMPARE(engine->rxChannel(id)->sampleRate(), 48000);
+
+ // Und das hier ist der Fund: vor der Behebung stand hier weiter 2,
+ // das Geraet also auf 96 kHz, waehrend der Kanal auf 48 lief.
+ QCOMPARE(conn.stromModusForTest(), 0); // EinStrom48
+ }
+
+ // Gegenprobe: eine Rate, die das Geraet schon faehrt, darf keinen
+ // Stromstart-Rahmen ausloesen. Sonst wuerde jeder Bandwechsel den Strom
+ // neu starten -- die Folgenummern fangen dabei bei null an, und der
+ // Betreiber hoert eine Luecke, wo nichts zu tun war.
+ void aRestoreToTheRateAlreadyRunningLeavesTheRadioAlone()
+ {
+ RadioModel model;
+ model.applyHardwareProfileForTest(
+ infoFor(HPSDRHW::SunSdr2Qrp, ProtocolVersion::SunSdr));
+
+ SunSdrRadioConnection conn;
+ model.injectConnectionForTest(&conn);
+ auto detach = qScopeGuard([&] { model.injectConnectionForTest(nullptr); });
+
+ conn.setSampleRate(96000);
+ model.setConnectionRateForTest(96000, 1);
+
+ WdspEngine* engine = model.wdspEngine();
+ engine->m_initialized = true; // friend access (LONGPATH_BUILD_TESTS)
+
+ model.configureStreamPool(/*userDdcCount*/ 1, /*maxSlices*/ 1, 96000);
+ const int id = model.addSlice();
+ SliceModel* slice = model.sliceById(id);
+ QVERIFY(slice);
+ engine->createRxChannel(id, bufferSizeForRate(96000), 4096,
+ 96000, 48000, 48000);
+
+ QSignalSpy rejected(&model, &RadioModel::sliceRetuneRejected);
+
+ slice->setSampleRateHz(96000);
+ model.applyRestoredSampleRate(slice);
+ QCoreApplication::processEvents();
+
+ QCOMPARE(conn.stromModusForTest(), 2); // unveraendert
+ QCOMPARE(rejected.count(), 0);
+ }
};
QTEST_MAIN(TstSunSdrBoardCaps)
diff --git a/tests/tst_sunsdr_messlauf.cpp b/tests/tst_sunsdr_messlauf.cpp
index ac8cdb54a..06b76e5bb 100644
--- a/tests/tst_sunsdr_messlauf.cpp
+++ b/tests/tst_sunsdr_messlauf.cpp
@@ -112,13 +112,23 @@ private slots:
// 0x07-Rahmen nie hinaus, und die QRP bleibt im Einkanal-Zustand
// (Q = 0, Seitenbaender uebereinander) -- gemessen am 2026-09-25.
// Ein Messlauf in diesem Zustand misst nicht den Betrieb.
- const quint64 freqHz =
- qEnvironmentVariableIsSet("LONGPATH_SUNSDR_FREQ")
- ? qEnvironmentVariable("LONGPATH_SUNSDR_FREQ").toULongLong()
- : 7100000ULL;
- conn.setReceiverFrequency(0, freqHz);
- qInfo().noquote() << QStringLiteral("Frequenz gesetzt: %1 Hz").arg(freqHz);
- QTest::qWait(1500);
+ // Mit LONGPATH_SUNSDR_KEINE_FREQ bleibt der Frequenzrahmen aus --
+ // damit laesst sich der EINSCHALTZUSTAND messen (am 2026-09-25
+ // eingegrenzt: nach dem Einschalten liefert die QRP nur einen
+ // reellen Kanal, Q = 0, die Seitenbaender liegen uebereinander).
+ if (!qEnvironmentVariableIsSet("LONGPATH_SUNSDR_KEINE_FREQ")) {
+ const quint64 freqHz =
+ qEnvironmentVariableIsSet("LONGPATH_SUNSDR_FREQ")
+ ? qEnvironmentVariable("LONGPATH_SUNSDR_FREQ").toULongLong()
+ : 7100000ULL;
+ conn.setReceiverFrequency(0, freqHz);
+ qInfo().noquote() << QStringLiteral("Frequenz gesetzt: %1 Hz").arg(freqHz);
+ QTest::qWait(1500);
+ } else {
+ qInfo().noquote() << QStringLiteral(
+ "KEIN Frequenzrahmen -- Einschaltzustand wird gemessen");
+ QTest::qWait(1500);
+ }
qInfo().noquote() << QStringLiteral(
"Q ungleich null: %1 % (0 % = nur ein reeller Kanal, "
@@ -146,11 +156,102 @@ private slots:
.arg(conn.qNonZeroPercentForTest(), 0, 'f', 1);
}
+ // Viele Frequenzwechsel, um die Verlustrate von STEUERRAHMEN zu
+ // messen: jeder Wechsel schickt zwei Rahmen (DDC 0x07 und VFO 0x08)
+ // und muss zwei Quittungen bekommen. Am 2026-10-03 war EINER von
+ // etwa fuenfzehn Laeufen unquittiert -- diese Messung sagt, wie oft
+ // das wirklich vorkommt, und das ist die Zahl, an der die
+ // Entscheidung ueber das Nachschicken haengt.
+ const int wechsel = qEnvironmentVariableIntValue("LONGPATH_SUNSDR_WECHSEL");
+ if (wechsel > 0) {
+ quint64 f = 7000000;
+ for (int i = 0; i < wechsel; ++i) {
+ f += 1000; // 1 kHz weiter, im Band bleiben
+ if (f > 7200000) { f = 7000000; }
+ conn.setReceiverFrequency(0, f);
+ QTest::qWait(60); // Quittung kommt in 15-50 ms
+ }
+ qInfo().noquote() << QStringLiteral(
+ "%1 Frequenzwechsel geschickt (= %2 Steuerrahmen)")
+ .arg(wechsel).arg(wechsel * 2);
+ QTest::qWait(1500);
+ }
+
+ // Alle Bedienelemente durchschalten, die beim QRP ueberhaupt etwas
+ // schicken, und mitschreiben, was zurueckkommt. Das ist die Frage,
+ // fuer die das Mithoeren gebaut wurde: aendert sich im Betrieb eine
+ // Nutzlast, ist es ein Messwert; bleibt alles still, meldet das
+ // Geraet nichts. Dazwischen jeweils die Abfrage 0x0c -- wenn ihre
+ // 320 Byte den Geraetezustand tragen, muessen sie sich hier
+ // bewegen.
+ if (qEnvironmentVariableIsSet("LONGPATH_SUNSDR_BEDIENEN")) {
+ const QByteArray abfrage =
+ QByteArray::fromHex("03ff0c000000000000000100000037f7affe");
+ const auto abfragen = [&]() {
+ qputenv("LONGPATH_SUNSDR_ABFRAGE", abfrage.toHex());
+ conn.sendBenchFramesForTest(QStringLiteral("LONGPATH_SUNSDR_ABFRAGE"));
+ qunsetenv("LONGPATH_SUNSDR_ABFRAGE");
+ QTest::qWait(400);
+ };
+
+ abfragen();
+ for (const int stufe : {0, 2, 1, 7}) { // -20, -10, 0, +10 dB
+ conn.setPreampModeIndex(stufe);
+ QTest::qWait(300);
+ abfragen();
+ }
+ for (const int dB : {0, -20}) {
+ conn.setAttenuator(dB);
+ QTest::qWait(300);
+ abfragen();
+ }
+ conn.setActiveReceiverCount(2);
+ conn.setSampleRate(96000);
+ QTest::qWait(500);
+ abfragen();
+ qInfo().noquote() << QStringLiteral(
+ "Bedienung durchgeschaltet: 4 Vorverstaerkerstufen, "
+ "2 Daempfungswerte, Empfaengerzahl, Abtastrate -- je mit "
+ "Abfrage 0x0c dazwischen");
+ }
+
+ // Rate zur Laufzeit umstellen -- der Weg, den spaeter die
+ // Oberflaeche nimmt. LONGPATH_SUNSDR_RATE=96000 schaltet nach dem
+ // Verbinden um.
+ // Zwei Empfaenger: der Weg, den die Oberflaeche nimmt. Am
+ // 2026-10-04 belegt, dass der zweite Strom echtes I/Q traegt --
+ // hier wird gemessen, ob er oben auch ANKOMMT statt verworfen zu
+ // werden.
+ if (qEnvironmentVariableIsSet("LONGPATH_SUNSDR_EMPFAENGER")) {
+ const int n =
+ qEnvironmentVariableIntValue("LONGPATH_SUNSDR_EMPFAENGER");
+ conn.setActiveReceiverCount(n);
+ qInfo().noquote()
+ << QStringLiteral("setActiveReceiverCount(%1) gerufen").arg(n);
+ QTest::qWait(1500);
+ }
+
+ if (qEnvironmentVariableIsSet("LONGPATH_SUNSDR_RATE")) {
+ const int r = qEnvironmentVariableIntValue("LONGPATH_SUNSDR_RATE");
+ conn.setSampleRate(r);
+ qInfo().noquote() << QStringLiteral("setSampleRate(%1) gerufen").arg(r);
+ QTest::qWait(2000);
+ }
+
const int bloeckeVorher = iq.count();
QElapsedTimer fenster;
fenster.start();
while (fenster.elapsed() < sekunden * 1000) {
- QTest::qWait(500);
+ // 20 ms, nicht 500: mit groben Bloecken laeuft die
+ // Ereignisschleife zu selten, die Blockantworten gehen
+ // verspaetet hinaus, und das Geraet WIEDERHOLT -- am
+ // 2026-10-03 gemessen 1,20 Kopien je Nummer im Messlauf gegen
+ // 1,00 im echten Betrieb des Betreibers. Der Pruefstand hat
+ // also gemessen, was er selbst verursacht hat. Genau der
+ // Fehler, vor dem feedback-messung-schlaegt-nicht-das-geraet
+ // warnt, nur umgekehrt: hier war nicht das Geraet schuld,
+ // sondern das Messgeraet.
+ QTest::qWait(20);
}
const double secs = double(fenster.elapsed()) / 1000.0;
const int bloecke = iq.count() - bloeckeVorher;
@@ -176,10 +277,34 @@ private slots:
.arg(conn.rahmenOhneQuittungForTest())
.arg(conn.rahmenWiederholtForTest())
.arg(conn.offeneRahmenForTest());
+ // Was WIR zurueckschicken -- der Gegenstand der A/B-Messung vom
+ // 2026-10-05. Ohne diese Zeile sieht man am Ergebnis nicht, ob der
+ // Schalter ueberhaupt gegriffen hat.
+ qInfo().noquote() << QStringLiteral(
+ "Rueckweg: %1 Antworten (%2/s), davon %3 blosse Koepfe, "
+ "letzte %4 Byte")
+ .arg(conn.blockRepliesSentForTest())
+ .arg(double(conn.blockRepliesSentForTest()) / secs, 0, 'f', 0)
+ .arg(conn.bareBlockRepliesSentForTest())
+ .arg(conn.lastBlockReplyBytesForTest());
qInfo().noquote() << QStringLiteral(
"Uebersteuerung: %1 Proben am Anschlag, %2 Meldungen")
.arg(conn.anschlagProbenForTest())
.arg(conn.anschlagMeldungenForTest());
+ qInfo().noquote() << QStringLiteral(
+ "PTT vom Geraet: %1 Flanken, Geraet sendet jetzt: %2")
+ .arg(conn.mikrofonPttFlankenForTest())
+ .arg(conn.geraetSendetForTest() ? QStringLiteral("ja")
+ : QStringLiteral("nein"));
+ for (int k = 0; k < 4; ++k) {
+ const quint64 n = conn.kanalPaketeForTest(k);
+ if (n == 0) { continue; }
+ qInfo().noquote() << QStringLiteral(
+ "Kanal %1: %2 Pakete (%3/s), %4 Fortsetzungen, %5 verworfen")
+ .arg(k).arg(n).arg(double(n)/secs, 0, 'f', 0)
+ .arg(conn.kanalFortsetzungenForTest(k))
+ .arg(conn.kanalVerworfenForTest(k));
+ }
qInfo().noquote() << conn.frameInventoryReport();
qInfo().noquote() << conn.seqDeltaReport();
diff --git a/tests/tst_sunsdr_protocol.cpp b/tests/tst_sunsdr_protocol.cpp
index ec9914113..3e312dd8a 100644
--- a/tests/tst_sunsdr_protocol.cpp
+++ b/tests/tst_sunsdr_protocol.cpp
@@ -93,6 +93,20 @@ class TestSunSdrProtocol : public QObject
Q_OBJECT
private slots:
+ // Der Pegelabgleich ist keine Geschmacksfrage, sondern eine Messung:
+ // am 2026-10-04 hat der Betreiber am echten Geraet +40,0 dB
+ // eingestellt und bestaetigt ("die lautstaerke passt"), nachdem die
+ // alten +20,0 -- ueber den TCI-Weg geeicht -- am nativen Treiber bei
+ // Weitem nicht reichten ("ich muss voll aufdrehen, dass ich etwas
+ // hoere"). Diese Pruefung haelt die Zahl fest, damit sie nicht
+ // unbemerkt zurueckwandert.
+ void derPegelabgleichDerQrpIstEineMessung()
+ {
+ QCOMPARE(Longpath::SunSdr::kProfileQrp.rxLevelTrimDb, 40.0);
+ // DX und PRO bleiben bei 0 -- nie an dieser Hardware gemessen.
+ QCOMPARE(Longpath::SunSdr::kProfileDx.rxLevelTrimDb, 0.0);
+ QCOMPARE(Longpath::SunSdr::kProfilePro.rxLevelTrimDb, 0.0);
+ }
// CRC-32 ueber den Rahmen mit genulltem Feld 14..17 -- an 13 echten
// Rahmen von 10 Befehlen aus ExpertSDR2s Start (2026-09-25) byte-genau.
void controlFrameCrcReproducesThirteenCapturedFrames()
@@ -574,6 +588,53 @@ private slots:
// vom 2026-09-23, als unzugeordnete Opcodes an die QRP geschickt
// wurden. Bestaetigt wird darum nicht durch Probieren am Geraet,
// sondern durch einen Mitschnitt, in dem ExpertSDR2 sendet.
+ // Der Stopp-Rahmen, byte-fuer-byte aus dem Mitschnitt vom 2026-10-03
+ // (rate-umschalten.pcap, dreimal enthalten, immer gleich). ExpertSDR2
+ // schickt ihn beim Beenden, und das letzte Strompaket liegt in
+ // derselben Millisekunde.
+ void stopRahmenStimmtMitDemAufgezeichnetenUeberein()
+ {
+ using namespace Longpath::SunSdr;
+ QCOMPARE(buildStopFrame(kProfileQrp).toHex(),
+ QByteArray("03ff0200040000000000010000000d99f99d00000000"));
+ }
+
+ // ── Die drei gemessenen Stromstart-Rahmen ──────────────────────────
+ //
+ // Der aus EinStrom48 gebaute Rahmen MUSS byte-fuer-byte der sein, den
+ // Longpath heute schickt (stateSyncFrameForTest) und der im
+ // ExpertSDR2-Mitschnitt steht. Stimmt das, sind auch die beiden
+ // anderen Rahmen richtig gebaut -- gleicher Kopf, gleiche
+ // Pruefsummenrechnung, nur andere Nutzlast.
+ void stromStartRahmenStimmtMitDemAufgezeichnetenUeberein()
+ {
+ using namespace Longpath::SunSdr;
+ QCOMPARE(buildStromStartFrame(kProfileQrp, StromModus::EinStrom48).toHex(),
+ QByteArray("03ff01000c0000000000010000007648ea9e"
+ "010000000c08040302020202"));
+ // Die zwei gemessenen Zustaende mit zwei Stroemen. Nutzlast
+ // byte-fuer-byte aus dem Mitschnitt vom 2026-10-03; die Pruefsumme
+ // rechnet dieselbe Funktion, die oben an dreizehn echten Rahmen
+ // bestaetigt ist.
+ const QByteArray a =
+ buildStromStartFrame(kProfileQrp, StromModus::ZweiStroemeJe48);
+ const QByteArray b =
+ buildStromStartFrame(kProfileQrp, StromModus::ZweiStroemeJe96);
+ QCOMPARE(a.mid(18).toHex(), QByteArray("020000000c08040302020202"));
+ QCOMPARE(b.mid(18).toHex(), QByteArray("020100000a06040302020201"));
+ // Erste zwei Bytes der Nutzlast: Zahl der Stroeme, und sie steigt.
+ QCOMPARE(quint8(a[18]), quint8(2));
+ QCOMPARE(quint8(b[18]), quint8(2));
+ QCOMPARE(quint8(b[19]), quint8(1));
+ // Und die Pruefsummen sind gueltig -- nachgerechnet wie bei den
+ // dreizehn aufgezeichneten Rahmen.
+ for (const QByteArray& f : {a, b}) {
+ QByteArray genullt = f;
+ genullt[14] = genullt[15] = genullt[16] = genullt[17] = 0;
+ QCOMPARE(withControlFrameCrc(genullt).toHex(), f.toHex());
+ }
+ }
+
// Gegenprobe zu den zwei Sperren darueber und darunter: eine Suche, die
// nie etwas findet, beweist nichts. withControlFrameCrc() WIRD benutzt
// (SunSdrRadioConnection.cpp, Frequenzrahmen) -- findet die Hilfe sie
diff --git a/tests/tst_sunsdr_radio_connection.cpp b/tests/tst_sunsdr_radio_connection.cpp
index c885892d7..2aee0f2a1 100644
--- a/tests/tst_sunsdr_radio_connection.cpp
+++ b/tests/tst_sunsdr_radio_connection.cpp
@@ -156,7 +156,12 @@ private slots:
// a real QRP that never replies would produce.
void everyConnectAttemptTimesOutForNow()
{
+ // Diese Linie prueft den FEHLSCHLAG. Seit dem 2026-10-04
+ // wiederholt die Verbindung die Suche von selbst (das Geraet
+ // sperrt nach einem abrupten Ende rund eine Minute) -- hier
+ // soll sie das nicht, sonst wartet der Pruefstand 90 s.
SunSdrRadioConnection conn;
+ conn.setSucheWiederholungEnabledForTest(false);
conn.setFixedPortBindingEnabledForTest(false);
conn.init();
conn.setDiscoveryBroadcastEnabledForTest(false);
@@ -195,7 +200,12 @@ private slots:
void stateBecomesDisconnectedOnConnectTimeout()
{
+ // Diese Linie prueft den FEHLSCHLAG. Seit dem 2026-10-04
+ // wiederholt die Verbindung die Suche von selbst (das Geraet
+ // sperrt nach einem abrupten Ende rund eine Minute) -- hier
+ // soll sie das nicht, sonst wartet der Pruefstand 90 s.
SunSdrRadioConnection conn;
+ conn.setSucheWiederholungEnabledForTest(false);
conn.setFixedPortBindingEnabledForTest(false);
conn.init();
conn.setDiscoveryBroadcastEnabledForTest(false);
@@ -733,17 +743,143 @@ private slots:
QCOMPARE(conn.blockRepliesSentForTest(), quint64(0));
}
- // ── Pegelabgleich QRP, 2026-09-25 ─────────────────────────────
- // Gemessen gegen ExpertSDR2 am selben Geraet: -127,9 dBm dort,
- // -147,9 dBm in Longpath ohne Abgleich -> +20,0 dB auf die Proben.
- void qrpSamplesAreRaisedByTheMeasuredTwentyDb()
+ // 2026-10-05, aus Martins Mitschnitten: ExpertSDR2 beantwortet JEDEN
+ // Block, aber abwechselnd -- ungerade Nummer voll (1210 Byte, Laengenfeld
+ // 1200), gerade Nummer nur der KOPF (10 Byte, Laengenfeld 0). Gemessen
+ // als 240/s + 240/s gegen 480/s vom Geraet, in allen drei Mitschnitten
+ // gleich. Longpath schickt bisher immer den vollen Block.
+ //
+ // Geprueft wird die GROESSE, nicht die Zahl: die Zahl der Antworten
+ // aendert sich nicht, nur ihr Gewicht. Eine Pruefung auf
+ // blockRepliesSentForTest() allein waere in beiden Fassungen gruen.
+ void jedeZweiteAntwortIstNurDerKopf()
{
+ qunsetenv("LONGPATH_SUNSDR_BLOCKANTWORT");
+ qputenv("LONGPATH_SUNSDR_KOPFANTWORT", "1");
+ auto restore = qScopeGuard([] { qunsetenv("LONGPATH_SUNSDR_KOPFANTWORT"); });
SunSdrRadioConnection conn;
conn.setFixedPortBindingEnabledForTest(false);
conn.init();
conn.setDiscoveryBroadcastEnabledForTest(false);
conn.connectToRadio(someQrpInfo());
- QCOMPARE(conn.rxLevelGainForTest(), 10.0f);
+
+ const QHostAddress radio(QStringLiteral("192.0.2.200"));
+ conn.feedControlDatagramForTest(
+ QByteArray::fromHex("03ff011a7c0000004119c0a810c8c0a810c851c300004928"),
+ radio);
+ QVERIFY(conn.isRxReadyForTest());
+
+ auto block = [](quint16 seq) {
+ QByteArray pkt = SunSdr::buildIqHeader(
+ SunSdr::kProfileQrp, SunSdr::kOpIqRxIdle, seq, 0x01, 0x00);
+ pkt.append(SunSdr::kIqPayloadSize, char(0));
+ return pkt;
+ };
+ for (quint16 seq = 0; seq < 4; ++seq) {
+ conn.feedStreamDatagramFromSenderForTest(block(seq), radio);
+ }
+
+ QTRY_COMPARE_WITH_TIMEOUT(conn.blockRepliesSentForTest(), quint64(4), 500);
+ // 0 und 2 sind gerade -> blosser Kopf; 1 und 3 ungerade -> voll.
+ QCOMPARE(conn.bareBlockRepliesSentForTest(), quint64(2));
+ // Die letzte Nummer war 3, also ungerade, also die volle Antwort.
+ QCOMPARE(conn.lastBlockReplySeqForTest(), quint16(3));
+ QCOMPARE(conn.lastBlockReplyBytesForTest(),
+ SunSdr::kIqHeaderSize + SunSdr::kIqPayloadSize);
+ }
+
+ // Seit der Messung am 2026-10-05 ist die Kopfantwort die VORGABE: an
+ // den Wiederholungen aendert sie nichts (207/s gegen 214/s, also
+ // nichts), sie halbiert aber den Rueckweg. Diese Pruefung haelt die
+ // Vorgabe fest -- ohne sie koennte sie jemand unbemerkt zurueckdrehen.
+ void ohneSchalterIstDieKopfantwortAn()
+ {
+ qunsetenv("LONGPATH_SUNSDR_BLOCKANTWORT");
+ qunsetenv("LONGPATH_SUNSDR_KOPFANTWORT");
+ SunSdrRadioConnection conn;
+ conn.setFixedPortBindingEnabledForTest(false);
+ conn.init();
+ conn.setDiscoveryBroadcastEnabledForTest(false);
+ conn.connectToRadio(someQrpInfo());
+
+ const QHostAddress radio(QStringLiteral("192.0.2.200"));
+ conn.feedControlDatagramForTest(
+ QByteArray::fromHex("03ff011a7c0000004119c0a810c8c0a810c851c300004928"),
+ radio);
+ QVERIFY(conn.isRxReadyForTest());
+
+ auto block = [](quint16 seq) {
+ QByteArray pkt = SunSdr::buildIqHeader(
+ SunSdr::kProfileQrp, SunSdr::kOpIqRxIdle, seq, 0x01, 0x00);
+ pkt.append(SunSdr::kIqPayloadSize, char(0));
+ return pkt;
+ };
+ for (quint16 seq = 0; seq < 4; ++seq) {
+ conn.feedStreamDatagramFromSenderForTest(block(seq), radio);
+ }
+
+ QTRY_COMPARE_WITH_TIMEOUT(conn.blockRepliesSentForTest(), quint64(4), 500);
+ QCOMPARE(conn.bareBlockRepliesSentForTest(), quint64(2));
+ }
+
+ // Und sie laesst sich abschalten -- der Rueckweg ist dann wieder
+ // durchgehend der volle Block.
+ void mitNullBleibtJedeAntwortDerVolleBlock()
+ {
+ qunsetenv("LONGPATH_SUNSDR_BLOCKANTWORT");
+ qputenv("LONGPATH_SUNSDR_KOPFANTWORT", "0");
+ auto restore = qScopeGuard([] { qunsetenv("LONGPATH_SUNSDR_KOPFANTWORT"); });
+ SunSdrRadioConnection conn;
+ conn.setFixedPortBindingEnabledForTest(false);
+ conn.init();
+ conn.setDiscoveryBroadcastEnabledForTest(false);
+ conn.connectToRadio(someQrpInfo());
+
+ const QHostAddress radio(QStringLiteral("192.0.2.200"));
+ conn.feedControlDatagramForTest(
+ QByteArray::fromHex("03ff011a7c0000004119c0a810c8c0a810c851c300004928"),
+ radio);
+ QVERIFY(conn.isRxReadyForTest());
+
+ auto block = [](quint16 seq) {
+ QByteArray pkt = SunSdr::buildIqHeader(
+ SunSdr::kProfileQrp, SunSdr::kOpIqRxIdle, seq, 0x01, 0x00);
+ pkt.append(SunSdr::kIqPayloadSize, char(0));
+ return pkt;
+ };
+ for (quint16 seq = 0; seq < 4; ++seq) {
+ conn.feedStreamDatagramFromSenderForTest(block(seq), radio);
+ }
+
+ QTRY_COMPARE_WITH_TIMEOUT(conn.blockRepliesSentForTest(), quint64(4), 500);
+ QCOMPARE(conn.bareBlockRepliesSentForTest(), quint64(0));
+ QCOMPARE(conn.lastBlockReplyBytesForTest(),
+ SunSdr::kIqHeaderSize + SunSdr::kIqPayloadSize);
+ }
+
+ // ── Pegelabgleich QRP, neu gemessen am 2026-10-04 ──────────────
+ //
+ // Bis dahin standen hier +20,0 dB -- am 2026-09-25 gegen ExpertSDR2
+ // gemessen, aber ueber den TCI-Weg (rx_sensors). Der NATIVE Treiber
+ // ist ein anderer Weg mit anderer Skalierung, und dort reichten die
+ // 20 dB bei Weitem nicht: "ich muss voll aufdrehen, dass ich etwas
+ // hoere" / "bei der haelfte, sprich 50 % faengt man an, etwas zu
+ // hoeren", waehrend ExpertSDR2 am SELBEN Geraet ohne Antenne
+ // "perfekt" laut war -- es lag also nicht an der fehlenden Antenne.
+ //
+ // +40,0 dB (Faktor 100) hat der Betreiber am 2026-10-04 am echten
+ // Geraet selbst eingestellt und bestaetigt: "die lautstaerke passt".
+ // Im Log seines Laufs steht "SunSdr: Pegelabgleich 40.0 dB
+ // (eingestellt)". Seine Anforderung dazu: "rauschen muss immer zu
+ // hoeren sein".
+ void qrpSamplesAreRaisedByTheMeasuredFortyDb()
+ {
+ SunSdrRadioConnection conn;
+ conn.setFixedPortBindingEnabledForTest(false);
+ conn.init();
+ conn.setDiscoveryBroadcastEnabledForTest(false);
+ conn.connectToRadio(someQrpInfo());
+ QCOMPARE(conn.rxLevelGainForTest(), 100.0f); // 10^(40/20)
const QHostAddress radio(QStringLiteral("192.0.2.200"));
conn.feedControlDatagramForTest(
@@ -764,8 +900,8 @@ private slots:
const auto samples = iqSpy.first().at(1).value>();
QVERIFY(samples.size() >= 2);
const float raw = 1.0f / 8388608.0f; // 1 / 2^23
- QVERIFY(qFuzzyCompare(samples[0], 1000.0f * raw * 10.0f)); // I
- QVERIFY(qFuzzyCompare(samples[1], 500.0f * raw * 10.0f)); // Q
+ QVERIFY(qFuzzyCompare(samples[0], 1000.0f * raw * 100.0f)); // I
+ QVERIFY(qFuzzyCompare(samples[1], 500.0f * raw * 100.0f)); // Q
}
// Preamp/Abschwaecher 0x04, am 2026-09-25 in ExpertSDR2 der Reihe
@@ -1411,7 +1547,12 @@ private slots:
// reaches without ever needing a beacon.
void pacerStopsAfterConnectTimeoutMidTransmission()
{
+ // Diese Linie prueft den FEHLSCHLAG. Seit dem 2026-10-04
+ // wiederholt die Verbindung die Suche von selbst (das Geraet
+ // sperrt nach einem abrupten Ende rund eine Minute) -- hier
+ // soll sie das nicht, sonst wartet der Pruefstand 90 s.
SunSdrRadioConnection conn;
+ conn.setSucheWiederholungEnabledForTest(false);
conn.setFixedPortBindingEnabledForTest(false);
conn.init();
conn.setDiscoveryBroadcastEnabledForTest(false);
@@ -1825,6 +1966,72 @@ private slots:
QCOMPARE(conn.offeneRahmenForTest(), offenVorher);
}
+ // Beim Trennen geht ein Stopp hinaus -- bis zum 2026-10-03 schickte
+ // dieser Treiber GAR NICHTS, und die QRP streamte danach unbegrenzt
+ // weiter (1940 Pakete/s ins Leere, bis zum Ausschalten). Mit dem Stopp
+ // am Geraet gemessen: 0 Pakete/s.
+ void trennenSchicktDenStopp()
+ {
+ SunSdrRadioConnection conn;
+ conn.setFixedPortBindingEnabledForTest(false);
+ conn.init();
+ conn.setDiscoveryBroadcastEnabledForTest(false);
+ conn.connectToRadio(someQrpInfo());
+ handshake(conn);
+ QCOMPARE(conn.stoppGeschicktForTest(), quint64(0));
+
+ conn.disconnect();
+ // Mindestens einer. Wie viele es werden, wenn niemand quittiert,
+ // sagt bleibtDerStoppUnquittiertWirdErNachgeschickt().
+ QVERIFY(conn.stoppGeschicktForTest() >= quint64(1));
+ }
+
+ // Der Vorfall vom 2026-10-04: der Betreiber konnte eine halbe Stunde
+ // lang nicht mehr verbinden ("no beacon reply"), obwohl Geraet, Netz,
+ // Einstellungen und Programmfassung einzeln geprueft in Ordnung waren.
+ // Ursache: Pruefinstanzen waren hart beendet worden, und in einem Lauf
+ // stand im Log
+ //
+ // WRN: SunSdr: beim Verbindungsende noch unquittiert: 0x02
+ //
+ // Die QRP bedient EINEN Client und haelt die Sitzung fest. Kommt der
+ // Stopp nicht an, bleibt sie an den Toten gebunden und antwortet auf
+ // neue Suchmeldungen nicht mehr. Erst Aus- und Einschalten half.
+ //
+ // Der Stopp wird deshalb nachgeschickt, solange er unquittiert bleibt.
+ // Er ist eine reine Abmeldung und mehrfach unschaedlich -- anders als
+ // ein Rahmen, der etwas verstellt.
+ void bleibtDerStoppUnquittiertWirdErNachgeschickt()
+ {
+ SunSdrRadioConnection conn;
+ conn.setFixedPortBindingEnabledForTest(false);
+ conn.init();
+ conn.setDiscoveryBroadcastEnabledForTest(false);
+ conn.connectToRadio(someQrpInfo());
+ handshake(conn);
+
+ // Im Pruefstand antwortet niemand -- genau der Fall, der das
+ // Geraet haengen laesst.
+ conn.disconnect();
+ QVERIFY2(conn.stoppGeschicktForTest() >= quint64(2),
+ qPrintable(QStringLiteral("nur %1 Stopp-Rahmen geschickt")
+ .arg(conn.stoppGeschicktForTest())));
+ }
+
+ // Ohne stehende Verbindung gibt es keine Gegenstelle -- ein Stopp an
+ // eine Adresse, die wir nicht kennen, waere ein Paket ins Nichts.
+ void trennenOhneVerbindungSchicktKeinenStopp()
+ {
+ SunSdrRadioConnection conn;
+ conn.setFixedPortBindingEnabledForTest(false);
+ conn.init();
+ conn.setDiscoveryBroadcastEnabledForTest(false);
+ conn.connectToRadio(someQrpInfo());
+ // KEIN Handschlag -- m_awaitingBeacon bleibt true.
+ conn.disconnect();
+ QCOMPARE(conn.stoppGeschicktForTest(), quint64(0));
+ }
+
// Ein unquittierter Frequenzrahmen wird EINMAL nachgeschickt -- am
// 2026-10-03 am Geraet beobachtet, dass einer verloren ging, und die
// Folge ist nicht harmlos: das Geraet steht dann auf einer anderen
@@ -1882,6 +2089,398 @@ private slots:
}
+ // ── Zwei Stroeme und mehrere Pakete je Folgenummer ─────────────────
+ //
+ // Am 2026-10-03 aus einem ExpertSDR2-Mitschnitt gemessen: die QRP
+ // schickt bei umgestelltem 0x01-Rahmen ZWEI Stroeme (byte8=2, byte9
+ // als Index) mit verschiedenen Raten, und ein Strom ueber 48 kHz
+ // traegt mehrere Pakete je Nummer -- mit VERSCHIEDENEN Proben.
+
+ static QByteArray qrpBlockKanal(quint16 seq, int stroeme, int kanal,
+ char fuellwert)
+ {
+ QByteArray pkt = SunSdr::buildIqHeader(
+ SunSdr::kProfileQrp, SunSdr::kOpIqRxIdle, seq,
+ quint8(stroeme), quint8(kanal));
+ QByteArray payload(SunSdr::kIqPayloadSize, char(0));
+ for (int k = 0; k < SunSdr::kIqPayloadSize; k += 6) {
+ payload[k + 3] = fuellwert; // I
+ payload[k + 0] = char(5); // Q, damit echtes I/Q gilt
+ }
+ pkt.append(payload);
+ return pkt;
+ }
+
+ void zweiStroemeLandenAufVerschiedenenKanaelen()
+ {
+ SunSdrRadioConnection conn;
+ conn.setFixedPortBindingEnabledForTest(false);
+ conn.init();
+ conn.setDiscoveryBroadcastEnabledForTest(false);
+ conn.connectToRadio(someQrpInfo());
+ conn.setSingleChannelHoldMsForTest(0);
+ handshake(conn);
+
+ QSignalSpy iq(&conn, &RadioConnection::iqDataReceived);
+ // So kommt es am Geraet wirklich (2026-10-03 gemessen): die Nummern
+ // laufen GLOBAL fortlaufend, byte9 wechselt dabei den Strom.
+ // Die erste Fassung dieses Tests nahm an, beide Kanaele traegen
+ // dieselbe Nummer -- der Versuch am Geraet hat das widerlegt, und
+ // der Zaehler meldete daraufhin 50 % Verlust bei gesundem Strom.
+ // Erst mit EINEM Empfaenger: der zweite Strom wird verworfen, statt
+ // nach oben zu gehen, wo er bestenfalls ignoriert und
+ // schlimmstenfalls mit Kanal 0 vermischt wuerde.
+ conn.feedStreamDatagramForTest(qrpBlockKanal(1, 2, 0, char(7)));
+ conn.feedStreamDatagramForTest(qrpBlockKanal(2, 2, 1, char(9)));
+ QCOMPARE(iq.count(), 1);
+ QCOMPARE(iq.at(0).at(0).toInt(), 0);
+ QCOMPARE(conn.kanalVerworfenForTest(1), quint64(1));
+
+ // Und jetzt mit zwei: beide gehen durch, jeder auf seinen Kanal.
+ conn.setActiveReceiverCount(2);
+ conn.feedStreamDatagramForTest(qrpBlockKanal(3, 2, 0, char(7)));
+ conn.feedStreamDatagramForTest(qrpBlockKanal(4, 2, 1, char(9)));
+
+ QCOMPARE(iq.count(), 3);
+ QCOMPARE(iq.at(1).at(0).toInt(), 0);
+ QCOMPARE(iq.at(2).at(0).toInt(), 1);
+ QCOMPARE(conn.kanalPaketeForTest(0), quint64(2));
+ QCOMPARE(conn.kanalPaketeForTest(1), quint64(2));
+ // Lueckenlos im globalen Nummernraum, und zwar EINSCHLIESSLICH der
+ // Nummer des verworfenen Pakets: die Nummern laufen global, also
+ // muss jede gezaehlt werden, auch wenn ihre Proben niemand braucht.
+ // Andernfalls meldet der Zaehler Verlust, wo keiner ist -- am
+ // 2026-10-03 im Messlauf zweimal passiert.
+ QCOMPARE(conn.seqRepeatsForTest(), quint64(0));
+ QCOMPARE(conn.seqLostForTest(), quint64(0));
+ QCOMPARE(conn.seqFramesForTest(), quint64(4));
+ }
+
+ // Zwei Pakete mit derselben Nummer, aber VERSCHIEDENEM Inhalt: das ist
+ // die zweite Haelfte der Proben (96 kHz), keine Wiederholung. Beide
+ // muessen durchgehen.
+ void zweitesPaketMitAnderemInhaltIstFortsetzung()
+ {
+ SunSdrRadioConnection conn;
+ conn.setFixedPortBindingEnabledForTest(false);
+ conn.init();
+ conn.setDiscoveryBroadcastEnabledForTest(false);
+ conn.connectToRadio(someQrpInfo());
+ conn.setSingleChannelHoldMsForTest(0);
+ handshake(conn);
+
+ QSignalSpy iq(&conn, &RadioConnection::iqDataReceived);
+ conn.feedStreamDatagramForTest(qrpBlockKanal(5, 2, 0, char(7)));
+ conn.feedStreamDatagramForTest(qrpBlockKanal(5, 2, 0, char(11)));
+
+ QCOMPARE(iq.count(), 2);
+ QCOMPARE(conn.kanalFortsetzungenForTest(0), quint64(1));
+ QCOMPARE(conn.seqRepeatsForTest(), quint64(0));
+ QCOMPARE(conn.seqLostForTest(), quint64(0));
+ }
+
+ // Gegenprobe, und der heutige Normalfall: zwei Pakete mit derselben
+ // Nummer und BYTEGLEICHEM Inhalt sind eine Wiederholung (am
+ // 2026-09-23 am Geraet belegt: 1683 von 1683 ganz bytegleich).
+ void zweitesPaketMitGleichemInhaltBleibtWiederholung()
+ {
+ SunSdrRadioConnection conn;
+ conn.setFixedPortBindingEnabledForTest(false);
+ conn.init();
+ conn.setDiscoveryBroadcastEnabledForTest(false);
+ conn.connectToRadio(someQrpInfo());
+ conn.setSingleChannelHoldMsForTest(0);
+ handshake(conn);
+
+ conn.feedStreamDatagramForTest(qrpBlockKanal(5, 1, 0, char(7)));
+ conn.feedStreamDatagramForTest(qrpBlockKanal(5, 1, 0, char(7)));
+
+ QCOMPARE(conn.kanalFortsetzungenForTest(0), quint64(0));
+ QCOMPARE(conn.seqRepeatsForTest(), quint64(1));
+ }
+
+ // Und das Wichtigste: am heutigen Betrieb aendert sich nichts. Mit
+ // byte8 = 1 ist der Kanal immer 0, auch wenn byte9 etwas anderes sagt.
+ void einStromBleibtImmerKanalNull()
+ {
+ SunSdrRadioConnection conn;
+ conn.setFixedPortBindingEnabledForTest(false);
+ conn.init();
+ conn.setDiscoveryBroadcastEnabledForTest(false);
+ conn.connectToRadio(someQrpInfo());
+ conn.setSingleChannelHoldMsForTest(0);
+ handshake(conn);
+
+ QSignalSpy iq(&conn, &RadioConnection::iqDataReceived);
+ // byte8 = 1 (ein Strom), byte9 = 1 -- muss trotzdem Kanal 0 sein.
+ conn.feedStreamDatagramForTest(qrpBlockKanal(1, 1, 1, char(7)));
+
+ QCOMPARE(iq.count(), 1);
+ QCOMPARE(iq.first().at(0).toInt(), 0);
+ QCOMPARE(conn.kanalPaketeForTest(0), quint64(1));
+ QCOMPARE(conn.kanalPaketeForTest(1), quint64(0));
+ }
+
+ // Die Rate geht jetzt ueber setSampleRate, nicht nur ueber die
+ // Umgebung -- und nur fuer die zwei Raten, die am Geraet gemessen sind.
+ void setSampleRateStelltDenStromstartRahmenUm()
+ {
+ SunSdrRadioConnection conn;
+ conn.setFixedPortBindingEnabledForTest(false);
+ conn.init();
+ conn.setDiscoveryBroadcastEnabledForTest(false);
+ conn.connectToRadio(someQrpInfo());
+ // Vorgabe: ein Strom, 48 kHz (= EinStrom48).
+ QCOMPARE(conn.stromModusForTest(), 0);
+
+ conn.setSampleRate(96000);
+ QCOMPARE(conn.stromModusForTest(), 2); // ZweiStroemeJe96
+
+ conn.setSampleRate(48000);
+ QCOMPARE(conn.stromModusForTest(), 0);
+
+ // Eine Rate ohne gemessenen Rahmen aendert NICHTS -- raten geht
+ // hier nicht, ein falscher Rahmen bedeutet Daten einer Rate in
+ // einem Kanal einer anderen (am 2026-09-24 als "schlechtes
+ // Rauschen" gehoert).
+ conn.setSampleRate(192000);
+ QCOMPARE(conn.stromModusForTest(), 0);
+ }
+
+ // Am 2026-10-04 an der echten QRP gefunden: die Oberflaeche stellt
+ // 96 kHz ein, Longpath meldet "Connecting with sampleRate= 96000" --
+ // und das Geraet streamt weiter mit 48 (Stromkopf 0100, 240
+ // Nummern/s). WDSP lief also auf 96 kHz, die Daten kamen mit 48.
+ //
+ // Ursache: RadioModel schiebt setSampleRate AUSDRUECKLICH VOR
+ // connectToRadio (eigener Kommentar dort: sonst liest composeEp2Frame
+ // die Vorgaben) -- und der Sitzungs-Reset in connectToRadio hat die
+ // Rate danach wieder auf die Umgebungsvorgabe zurueckgesetzt.
+ //
+ // Die Prueflinie oben (setSampleRateStelltDenStromstartRahmenUm) hat
+ // das nicht gefangen, weil sie in der Reihenfolge prueft, die GEHT,
+ // nicht in der, die die Anwendung nimmt.
+ void rateVorDemVerbindenUeberlebtDenSitzungsReset()
+ {
+ SunSdrRadioConnection conn;
+ conn.setFixedPortBindingEnabledForTest(false);
+ conn.init();
+ conn.setDiscoveryBroadcastEnabledForTest(false);
+
+ // Genau die Reihenfolge aus RadioModel::connectToRadio.
+ conn.setSampleRate(96000);
+ QCOMPARE(conn.stromModusForTest(), 2); // ZweiStroemeJe96
+ conn.connectToRadio(someQrpInfo());
+
+ // Vor der Behebung stand hier wieder 0 -- und der Stromstart-Rahmen
+ // ging mit 48 kHz hinaus, obwohl die App 96 angesagt hatte.
+ QCOMPARE(conn.stromModusForTest(), 2);
+ }
+
+ // Dasselbe fuer die Zahl der Empfaenger: derselbe Reset setzt sie auf 1.
+ void empfaengerzahlVorDemVerbindenUeberlebtDenSitzungsReset()
+ {
+ SunSdrRadioConnection conn;
+ conn.setFixedPortBindingEnabledForTest(false);
+ conn.init();
+ conn.setDiscoveryBroadcastEnabledForTest(false);
+
+ conn.setActiveReceiverCount(2);
+ QCOMPARE(conn.aktiveEmpfaengerForTest(), 2);
+ conn.connectToRadio(someQrpInfo());
+ QCOMPARE(conn.aktiveEmpfaengerForTest(), 2);
+ }
+
+ // ── Zwei Empfaenger brauchen zwei Stroeme ──────────────────────────
+ //
+ // Am 2026-10-04 belegt: der zweite Strom ist NICHT stumm. Im
+ // Mitschnitt des Betreibers (ExpertSDR2 mit RX und RX2, seine eigene
+ // Richtigstellung "es waren immer beide rx und rx2") traegt Kanal 1
+ // echtes I/Q -- -127,9 dBFS bei 31,5 % Q ungleich null, also etwas
+ // kraeftiger als Kanal 0. Longpath hat ihn weggeworfen, weil in den
+ // Geraetefaehigkeiten EIN Empfaenger stand.
+ //
+ // Der Stromstart-Rahmen traegt beides: erstes Byte die Zahl der
+ // Stroeme, zweites die Ratenstufe. Bis hierher waehlte nur die RATE
+ // den Modus -- ein zweiter Empfaenger bei 48 kHz konnte also gar nie
+ // Daten bekommen.
+ void zweiterEmpfaengerStelltAufZweiStroemeUm()
+ {
+ SunSdrRadioConnection conn;
+ conn.setFixedPortBindingEnabledForTest(false);
+ conn.init();
+ conn.setDiscoveryBroadcastEnabledForTest(false);
+ conn.connectToRadio(someQrpInfo());
+ handshake(conn);
+ QCOMPARE(conn.stromModusForTest(), 0); // ein Strom, 48 kHz
+
+ conn.setActiveReceiverCount(2);
+ QCOMPARE(conn.stromModusForTest(), 1); // zwei Stroeme, je 48 kHz
+ QCOMPARE(conn.aktiveEmpfaengerForTest(), 2);
+
+ // Zurueck auf einen: wieder ein Strom, sonst laeuft die halbe
+ // Datenmenge umsonst durchs Netz.
+ conn.setActiveReceiverCount(1);
+ QCOMPARE(conn.stromModusForTest(), 0);
+ }
+
+ // Bei 96 kHz gibt es auf dem Draht keinen Ein-Strom-Modus -- dort
+ // sind es immer zwei, gleich wie viele Empfaenger oben hoeren.
+ void beiSechsundneunzigSindEsImmerZweiStroeme()
+ {
+ SunSdrRadioConnection conn;
+ conn.setFixedPortBindingEnabledForTest(false);
+ conn.init();
+ conn.setDiscoveryBroadcastEnabledForTest(false);
+ conn.connectToRadio(someQrpInfo());
+ handshake(conn);
+
+ conn.setSampleRate(96000);
+ QCOMPARE(conn.stromModusForTest(), 2); // zwei Stroeme, je 96 kHz
+
+ conn.setActiveReceiverCount(2);
+ QCOMPARE(conn.stromModusForTest(), 2); // bleibt
+
+ conn.setActiveReceiverCount(1);
+ QCOMPARE(conn.stromModusForTest(), 2); // bleibt ebenfalls
+ }
+
+ // Die selbsttaetige Wiederholung (2026-10-04): bleibt KEIN Beacon
+ // aus, gibt die Verbindung nicht mehr nach drei Sekunden auf, sondern
+ // wartet und sucht von selbst erneut. Grund ist eine Messung am
+ // Geraet: nach einem abrupten Programmende sperrt es rund eine
+ // Minute und kommt dann von selbst zurueck -- der Betreiber hat an
+ // einem Vormittag eine halbe Stunde verloren, weil jeder neue Klick
+ // wieder in dasselbe Fenster fiel.
+ void ohneBeaconWirdDieSucheWiederholtStattAufzugeben()
+ {
+ SunSdrRadioConnection conn;
+ conn.setFixedPortBindingEnabledForTest(false);
+ conn.init();
+ conn.setDiscoveryBroadcastEnabledForTest(false);
+
+ QSignalSpy fail(&conn, &RadioConnection::connectFailed);
+ conn.connectToRadio(someQrpInfo());
+ // Kein Beacon einspeisen -- der Waechter laeuft ab.
+ QTest::qWait(kWaitMs);
+
+ // Frueher stand hier ein Fehlschlag. Jetzt laeuft es weiter.
+ QCOMPARE(fail.count(), 0);
+ QCOMPARE(conn.state(), ConnectionState::Connecting);
+ }
+
+ // Und mit abgeschalteter Wiederholung gibt es ihn sofort -- sonst
+ // koennte die Prueflinie oben auch dann gruen sein, wenn gar kein
+ // Fehlschlag mehr moeglich waere.
+ void ohneWiederholungGibtEsDenFehlschlagSofort()
+ {
+ SunSdrRadioConnection conn;
+ conn.setSucheWiederholungEnabledForTest(false);
+ conn.setFixedPortBindingEnabledForTest(false);
+ conn.init();
+ conn.setDiscoveryBroadcastEnabledForTest(false);
+
+ QSignalSpy fail(&conn, &RadioConnection::connectFailed);
+ conn.connectToRadio(someQrpInfo());
+ QVERIFY(fail.wait(kWaitMs));
+ QCOMPARE(conn.state(), ConnectionState::Disconnected);
+ }
+
+ // ── Mikrofon-PTT am Geraet ─────────────────────────────────────────
+ //
+ // Die zweite Empfangsluecke, geschlossen ohne Protokollwissen: der
+ // Stromkopf traegt den Betriebszustand (0xFE Empfang, 0xFD Senden).
+ // Drueckt jemand am Geraet die Mikrofontaste, wechselt der Opcode.
+
+ static QByteArray qrpBlockTx(quint16 seq)
+ {
+ QByteArray pkt = SunSdr::buildIqHeader(
+ SunSdr::kProfileQrp, SunSdr::kOpIqTxActive, seq, 0x02, 0x01);
+ pkt.append(QByteArray(SunSdr::kIqPayloadSize, char(0)));
+ return pkt;
+ }
+
+ void sendezustandAmGeraetMeldetPtt()
+ {
+ SunSdrRadioConnection conn;
+ conn.setFixedPortBindingEnabledForTest(false);
+ conn.init();
+ conn.setDiscoveryBroadcastEnabledForTest(false);
+ conn.connectToRadio(someQrpInfo());
+ handshake(conn);
+
+ QSignalSpy ptt(&conn, &RadioConnection::micPttFromRadio);
+ conn.feedStreamDatagramForTest(qrpBlockSeq(1));
+ QCOMPARE(ptt.count(), 0);
+
+ conn.feedStreamDatagramForTest(qrpBlockTx(2));
+ QCOMPARE(ptt.count(), 1);
+ QCOMPARE(ptt.first().at(0).toBool(), true);
+ QVERIFY(conn.geraetSendetForTest());
+
+ // Nur die FLANKE: 240 Pakete je Sekunde duerfen nicht 240 Signale
+ // ergeben.
+ for (quint16 n = 3; n <= 30; ++n) {
+ conn.feedStreamDatagramForTest(qrpBlockTx(n));
+ }
+ QCOMPARE(ptt.count(), 1);
+
+ // Und zurueck.
+ conn.feedStreamDatagramForTest(qrpBlockSeq(31));
+ QCOMPARE(ptt.count(), 2);
+ QCOMPARE(ptt.last().at(0).toBool(), false);
+ QVERIFY(!conn.geraetSendetForTest());
+ QCOMPARE(conn.mikrofonPttFlankenForTest(), quint64(2));
+ }
+
+ // Was Longpath selbst ausgeloest hat, ist kein PTT vom Geraet.
+ void eigenesMoxGiltNichtAlsPttVomGeraet()
+ {
+ SunSdrRadioConnection conn;
+ conn.setFixedPortBindingEnabledForTest(false);
+ conn.init();
+ conn.setDiscoveryBroadcastEnabledForTest(false);
+ conn.connectToRadio(someQrpInfo());
+ handshake(conn);
+ conn.feedStreamDatagramForTest(qrpBlockSeq(1));
+
+ conn.setTxArmedForTest(true);
+ conn.setTxCheckContextForTest(armedInBandCtx());
+ conn.setMox(true);
+ QVERIFY(conn.isMoxForTest());
+
+ QSignalSpy ptt(&conn, &RadioConnection::micPttFromRadio);
+ conn.feedStreamDatagramForTest(qrpBlockTx(2));
+
+ QCOMPARE(ptt.count(), 0);
+ QCOMPARE(conn.mikrofonPttFlankenForTest(), quint64(0));
+ // Der Zustand wird trotzdem mitgefuehrt -- nur nicht als PTT
+ // gemeldet.
+ QVERIFY(conn.geraetSendetForTest());
+ }
+
+ // Ein haengendes PTT darf eine Sitzung nicht ueberleben: das ist der
+ // falsche Zustand, in dem man einen Sender in Erinnerung behaelt.
+ void haengendesPttWirdBeimTrennenZurueckgenommen()
+ {
+ SunSdrRadioConnection conn;
+ conn.setFixedPortBindingEnabledForTest(false);
+ conn.init();
+ conn.setDiscoveryBroadcastEnabledForTest(false);
+ conn.connectToRadio(someQrpInfo());
+ handshake(conn);
+ conn.feedStreamDatagramForTest(qrpBlockSeq(1));
+ conn.feedStreamDatagramForTest(qrpBlockTx(2));
+
+ QSignalSpy ptt(&conn, &RadioConnection::micPttFromRadio);
+ QVERIFY(conn.geraetSendetForTest());
+ conn.disconnect();
+
+ QCOMPARE(ptt.count(), 1);
+ QCOMPARE(ptt.first().at(0).toBool(), false);
+ QVERIFY(!conn.geraetSendetForTest());
+ }
+
// ── Uebersteuerung ─────────────────────────────────────────────────
//
// Eine der zwei echten Luecken im Empfang: P1/P2 melden adcOverflow aus
@@ -1999,6 +2598,55 @@ private slots:
return pkt;
}
+ // Wie oben, aber mit waehlbarem Inhalt -- fuer die Frage, ob eine
+ // wiederkehrende Nummer wirklich eine bytegleiche Kopie ist.
+ static QByteArray qrpBlockSeqInhalt(quint16 seq, char fuellung)
+ {
+ QByteArray pkt = SunSdr::buildIqHeader(
+ SunSdr::kProfileQrp, SunSdr::kOpIqRxIdle, seq, 0x01, 0x00);
+ pkt.append(QByteArray(SunSdr::kIqPayloadSize, fuellung));
+ return pkt;
+ }
+
+ // Am 2026-10-04 aus Martins Mitschnitt (118 550 Pakete, 0 vom Kern
+ // verworfen) belegt: auf dem Draht sind NULL bytegleiche
+ // Wiederholungen -- und Longpath meldete im selben Betrieb "1,41
+ // Kopien je Nummer". Die Zahl kam aus der eigenen Buchfuehrung.
+ //
+ // Grund: der Ring haelt 128 Nummern, aber verglichen wurde nur die
+ // Nummer, nicht der Inhalt. Die Fortsetzungs-Erkennung in
+ // processStreamDatagram prueft den Inhalt zwar, aber nur gegen die
+ // UNMITTELBAR vorige Nummer desselben Kanals. Kehrt eine Nummer mit
+ // Abstand wieder (der Zaehler laeuft um), galt sie ungeprueft als
+ // Kopie.
+ //
+ // Das ist nicht nur eine schiefe Zahl: an genau dieser Groesse
+ // erkennen wir die Achtfachung (1,0 heisst, die Blockantwort wirkt;
+ // 8,0 heisst, sie wirkt nicht). Eine Grundlast von 1,4 verdeckt eine
+ // echte Verschlechterung.
+ void gleicheNummerMitAnderemInhaltIstKeineKopie()
+ {
+ SunSdrRadioConnection conn;
+ conn.setFixedPortBindingEnabledForTest(false);
+ conn.init();
+ conn.setDiscoveryBroadcastEnabledForTest(false);
+ conn.connectToRadio(someQrpInfo());
+ handshake(conn);
+
+ for (quint16 n = 1; n <= 5; ++n) {
+ conn.feedStreamDatagramForTest(qrpBlockSeqInhalt(n, char(0)));
+ }
+ // Dieselbe Nummer, ANDERER Inhalt: der Zaehler ist umgelaufen,
+ // das ist ein neuer Block und keine Kopie.
+ conn.feedStreamDatagramForTest(qrpBlockSeqInhalt(3, char(0x5A)));
+ QCOMPARE(conn.seqRepeatsForTest(), quint64(0));
+
+ // Dieselbe Nummer mit GLEICHEM Inhalt bleibt eine Kopie -- sonst
+ // wuerde die Behebung die Achtfachungs-Erkennung abschalten.
+ conn.feedStreamDatagramForTest(qrpBlockSeqInhalt(4, char(0)));
+ QCOMPARE(conn.seqRepeatsForTest(), quint64(1));
+ }
+
void luekenloseFolgeMeldetKeinenVerlust()
{
SunSdrRadioConnection conn;
diff --git a/tests/tst_sunsdr_zweiter_empfaenger_oben.cpp b/tests/tst_sunsdr_zweiter_empfaenger_oben.cpp
new file mode 100644
index 000000000..4ff89578c
--- /dev/null
+++ b/tests/tst_sunsdr_zweiter_empfaenger_oben.cpp
@@ -0,0 +1,71 @@
+// Kommt der zweite Strom der QRP oben als zweiter Empfaenger an?
+//
+// Am 2026-10-04 wurde am Geraet belegt, dass der Treiber Kanal 1 nicht
+// mehr verwirft (b33072d9: beide Kanaele, 0 verworfen). Das ist aber nur
+// die halbe Strecke -- gemessen wurde die TREIBERSEITE. Ob der Kanal
+// oben auch bei einem zweiten Empfaenger landet, haengt an
+// ReceiverManager::rebuildHardwareMapping, und das war nicht geprueft.
+//
+// Diese Pruefung schliesst die Luecke ohne Funkgeraet: zwei Empfaenger
+// anlegen, Kanal 1 einspeisen, und nachsehen, bei wem er herauskommt.
+
+#include
+#include
+
+#include "core/ReceiverManager.h"
+
+using Longpath::ReceiverManager;
+
+class TstSunSdrZweiterEmpfaengerOben : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ // Mit nur einem Empfaenger gibt es fuer Kanal 1 niemanden -- das Paket
+ // muss fallen, nicht beim ersten landen. Waechter: ein zweiter Strom,
+ // den niemand hoert, darf nicht in den ersten Empfaenger laufen.
+ void kanalEinsOhneZweitenEmpfaengerLandetNirgends()
+ {
+ ReceiverManager rm;
+ rm.setMaxReceivers(2);
+ QCOMPARE(rm.createReceiver(), 0);
+ rm.activateReceiver(0);
+
+ QSignalSpy spy(&rm, &ReceiverManager::iqDataForReceiver);
+ rm.feedIqData(1, QVector{0.1f, 0.2f});
+ QCOMPARE(spy.count(), 0);
+
+ rm.feedIqData(0, QVector{0.3f, 0.4f});
+ QCOMPARE(spy.count(), 1);
+ QCOMPARE(spy.first().at(0).toInt(), 0);
+ }
+
+ // Mit zwei Empfaengern muss Kanal 1 beim ZWEITEN ankommen.
+ void kanalEinsLandetBeimZweitenEmpfaenger()
+ {
+ ReceiverManager rm;
+ rm.setMaxReceivers(2);
+ QCOMPARE(rm.createReceiver(), 0);
+ QCOMPARE(rm.createReceiver(), 1);
+ rm.activateReceiver(0);
+ rm.activateReceiver(1);
+
+ QSignalSpy spy(&rm, &ReceiverManager::iqDataForReceiver);
+
+ rm.feedIqData(0, QVector{0.1f, 0.2f});
+ rm.feedIqData(1, QVector{0.3f, 0.4f});
+
+ QCOMPARE(spy.count(), 2);
+ QCOMPARE(spy.at(0).at(0).toInt(), 0);
+ QCOMPARE(spy.at(1).at(0).toInt(), 1);
+
+ // Und wirklich die Daten des jeweiligen Kanals, nicht zweimal
+ // dieselben -- sonst waere die Abbildung zwar da, aber falsch.
+ const auto zweite = spy.at(1).at(1).value>();
+ QVERIFY(zweite.size() >= 2);
+ QVERIFY(qFuzzyCompare(zweite[0], 0.3f));
+ }
+};
+
+QTEST_MAIN(TstSunSdrZweiterEmpfaengerOben)
+#include "tst_sunsdr_zweiter_empfaenger_oben.moc"
diff --git a/tools/sunsdr_handshake_diff.py b/tools/sunsdr_handshake_diff.py
index f1eaeefdd..2e0872d9f 100644
--- a/tools/sunsdr_handshake_diff.py
+++ b/tools/sunsdr_handshake_diff.py
@@ -98,10 +98,66 @@ def deuten(op):
return KNOWN.get(op, "unbekannt")
-def auswerten(path, alle):
+def findeRechner(path):
+ """Welche Adresse ist der RECHNER (nicht das Geraet)?
+
+ Die Richtung laesst sich NICHT am Zielport ablesen: beide Seiten
+ sprechen Port 50001, also ist dport immer 50001. Die erste Fassung
+ dieses Werkzeugs tat genau das und hielt deshalb am 2026-10-03 im
+ ersten echten Mitschnitt alle zehn Rahmen fuer ausgehend -- auch die
+ fuenf Quittungen des Geraets.
+
+ Belastbar ist die Suchanfrage: Opcode 0x00 geht immer VOM Rechner aus.
+ Fehlt sie im Mitschnitt, bleibt nur --rechner.
+
+ Der frueher hier stehende Rueckfall "die 1210-Byte-Pakete kommen aus
+ dem Geraet" ist am 2026-10-05 widerlegt: ExpertSDR2 schickt SELBST
+ 1210-Byte-Bloecke zurueck (Stille), und zwar fast so viele wie es
+ empfaengt. Wer danach geht, haelt die eigenen Antworten fuer
+ Geraetedaten -- genau der Fehler, der mich an dem Tag eine falsche
+ Behauptung gekostet hat.
+ """
+ bloecke = {}
+ for ts, src, sport, dst, dport, pl in udpMitRichtung(path):
+ # Nur auf dem STEUERweg. Auf dem Stromport schickt das GERAET
+ # 77-Byte-Rahmen, die ebenfalls mit Opcode 0x00 beginnen -- ohne
+ # diese Einschraenkung liefert die Suche genau verkehrt herum
+ # (2026-10-05 an expert-96k.pcap gesehen). Geprueft wird auf
+ # "beruehrt den Steuerport und nicht den Stromport", nicht auf
+ # sport == CTRL_PORT: der Rechner darf einen beliebigen Quellport
+ # benutzen (ExpertSDR2 nimmt 50001, der Pruefstand 54000).
+ amSteuerweg = (sport == CTRL_PORT or dport == CTRL_PORT) \
+ and sport != STREAM_PORT and dport != STREAM_PORT
+ k = kopf(pl) if amSteuerweg else None
+ if k is not None and k[0] == 0x00:
+ return src
+ if sport == STREAM_PORT and len(pl) == 1210:
+ bloecke[src] = bloecke.get(src, 0) + 1
+ # Rueckfall: BEIDE Seiten schicken 1210-Byte-Bloecke, aber nicht gleich
+ # viele -- das Geraet sendet je Block, ExpertSDR2 antwortet nur auf
+ # jeden zweiten (2026-10-05 gemessen: 480/s gegen 240/s, und in den
+ # 48-kHz-Mitschnitten 2 x 240/s gegen 240/s). Die SCHWAECHERE Quelle
+ # ist also der Rechner. Eine Heuristik, kein Beweis; bei Gleichstand
+ # gibt es keine Antwort.
+ if len(bloecke) == 2:
+ a, b = sorted(bloecke.items(), key=lambda kv: kv[1])
+ if a[1] * 4 < b[1] * 3:
+ return a[0]
+ return None
+
+
+def auswerten(path, alle, rechner=None):
rahmen = []
ersterStrom = None
t0 = None
+ if rechner is None:
+ rechner = findeRechner(path)
+ if rechner is None:
+ raise SystemExit(
+ "Die Richtung laesst sich nicht bestimmen: im Mitschnitt fehlen "
+ "sowohl die Suchanfrage (0x00) als auch der Datenstrom. Mit "
+ "--rechner angeben, welche Adresse der Rechner ist.")
+ print("Rechner: %s (alles andere ist das Geraet)" % rechner)
for ts, src, sport, dst, dport, pl in udpMitRichtung(path):
if dport == STREAM_PORT or sport == STREAM_PORT:
if ersterStrom is None:
@@ -111,7 +167,7 @@ def auswerten(path, alle):
continue
if t0 is None:
t0 = ts
- raus = (dport == CTRL_PORT)
+ raus = (src == rechner)
rahmen.append((ts - t0, raus, pl))
if not rahmen:
@@ -238,6 +294,74 @@ def selftest():
print()
auswerten(pfad, alle=True)
os.unlink(pfad)
+ print()
+ print("Selbsttest -- Wiederholungen und Abtastrate")
+ print()
+ selftestWiederholungen()
+
+
+def _iqBlock(kanal, seq, fuellung):
+ kopf = bytearray(10)
+ kopf[0] = 0x03
+ kopf[1] = 0xFF
+ kopf[2] = 0xFE
+ kopf[4] = 1200 & 0xFF
+ kopf[5] = (1200 >> 8) & 0xFF
+ kopf[6] = seq & 0xFF
+ kopf[7] = (seq >> 8) & 0xFF
+ kopf[8] = 2
+ kopf[9] = kanal
+ return bytes(kopf) + bytes([fuellung]) * 1200
+
+
+def _schreibeStrom(rateHz, kopienJeNteBlock):
+ """Baut einen Mitschnitt mit GEBAUTER Abtastrate und gebauten Kopien.
+
+ Damit ist beides bekannt, was das Werkzeug herausrechnen soll: die
+ Rate (ueber die Blockrate) und die Zahl der bytegleichen
+ Wiederholungen.
+ """
+ host, radio = "192.0.2.1", "192.0.2.200"
+ jeKanalJeSek = rateHz / PROBEN
+ dauer = 2.0
+ anzahl = int(jeKanalJeSek * dauer)
+ pakete = []
+ kopien = 0
+ for i in range(anzahl):
+ t = i / jeKanalJeSek
+ for kanal in (0, 1):
+ pakete.append((t, _udpPaket(radio, host, STREAM_PORT, 54001,
+ _iqBlock(kanal, i, i & 0xFF))))
+ if kopienJeNteBlock and i % kopienJeNteBlock == 0:
+ # Dasselbe noch einmal: gleiche Nummer, gleicher Inhalt.
+ pakete.append((t + 0.0001,
+ _udpPaket(radio, host, STREAM_PORT, 54001,
+ _iqBlock(kanal, i, i & 0xFF))))
+ kopien += 1
+ fd, pfad = tempfile.mkstemp(suffix=".pcap")
+ with os.fdopen(fd, "wb") as fh:
+ fh.write(struct.pack(" 48 kHz, 480/s -> 96 kHz. Gezaehlt werden
+ nur die NICHT wiederholten Bloecke, sonst wuerde genau die
+ Wiederholung, um die es hier geht, die Rate hochrechnen -- in
+ Martins 96-kHz-Mitschnitt sind es rohe 577/s, einzeln 481/s.
+
+ Je Kanal, nicht im Mittel, und das ist keine Feinheit: in Martins
+ Mitschnitten vom 2026-10-04 laeuft Kanal 0 mit 48 kHz und Kanal 1
+ mit 96 kHz. ExpertSDR2 fahrt die beiden Empfaenger also mit
+ VERSCHIEDENEN Raten -- eine Betriebsart, die Longpath gar nicht
+ kennt. Wer nur das Maximum ansieht, haelt so einen Mitschnitt fuer
+ "96 kHz" und zieht denselben falschen Schluss wie ich am
+ 2026-10-04.
+
+ Rueckgabe: {Kanal: (Rate in Hz oder 0, Bloecke/s)}.
+ """
+ ergebnis = {}
+ for kanal, n in jeKanalEinzig.items():
+ jeSek = n / max(dauer, 1e-9)
+ gemessen = jeSek * PROBEN
+ rate = 0
+ for kandidat in (48000, 96000, 192000, 384000):
+ if abs(gemessen - kandidat) <= 0.15 * kandidat:
+ rate = kandidat
+ break
+ ergebnis[kanal] = (rate, jeSek)
+ return ergebnis
+
+
+def wiederholungen(pfad, geraet=None):
+ """Zaehlt bytegleiche Wiederholungen im I/Q-Strom eines Mitschnitts.
+
+ Die Frage dahinter (2026-10-04): Longpath bekommt bei 96 kHz rund
+ 110 bytegleiche Wiederholungen je Sekunde, bei 48 kHz keine. Drei
+ Gegenmassnahmen sind gemessen und wirkungslos. Wiederholt ExpertSDR2
+ bei 96 kHz AUCH, ist es die Eigenart des Geraets und kein Mangel von
+ Longpath -- und die Frage ist erledigt statt offen.
+
+ Dafuer muss der Mitschnitt von ExpertSDR2 bei 96 kHz stammen.
+ """
+ import hashlib
+ daten = open(pfad, "rb")
+ kopf = daten.read(24)
+ if len(kopf) < 24:
+ print("Datei zu kurz."); return
+ magic = struct.unpack("H", d[uo:uo + 2])[0]
+ src = ".".join(str(b) for b in d[26:30])
+ if sp != 50002:
+ continue
+ nutz = d[uo + 8:]
+ vomRechner = (geraet is not None and src != geraet) \
+ or (rechner is not None and src == rechner)
+ if vomRechner:
+ # Was die Gegenstelle zurueckschickt, interessiert auch -- aber
+ # getrennt. Zwei Sorten: voller Stilleblock und blosser Kopf.
+ if len(nutz) == 10:
+ pcLeer += 1
+ elif len(nutz) == 1210 and nutz[2] in (0xFE, 0xFD):
+ pcAntworten[nutz[9]] = pcAntworten.get(nutz[9], 0) + 1
+ continue
+ # Nur echte IQ-Bloecke: 10 Byte Kopf + 1200 Byte Nutzlast.
+ if len(nutz) != 1210 or nutz[2] not in (0xFE, 0xFD):
+ continue
+ t = ts + tus / 1e6
+ if t0 is None:
+ t0 = t
+ t1 = t
+ seq = struct.unpack(" 400000:
+ inhalt.clear()
+ if gesamt == 0:
+ print("Keine I/Q-Bloecke gefunden. Stammt der Mitschnitt vom "
+ "Stromport 50002?")
+ if rechner is None and geraet is None:
+ print("Moeglich auch: die Richtung liess sich nicht bestimmen "
+ "(keine Suchanfrage im Mitschnitt). Dann --rechner "
+ "angeben.")
+ return
+ if rechner is None and geraet is None:
+ print("ACHTUNG: die Richtung liess sich NICHT bestimmen -- die "
+ "Zahlen unten enthalten")
+ print(" vermutlich auch die eigenen Antworten. "
+ "--rechner angeben.")
+ print()
+ dauer = max((t1 or 0) - (t0 or 0), 1e-9)
+ print("I/Q-Bloecke: %d ueber %.1f s (%.0f/s)"
+ % (gesamt, dauer, gesamt / dauer))
+ for k in sorted(jeKanal):
+ print(" Kanal %d: %d (%.0f/s)" % (k, jeKanal[k], jeKanal[k] / dauer))
+ print("bytegleiche Wiederholungen: %d (%.1f/s, %.1f %% der Bloecke)"
+ % (dubletten, dubletten / dauer, 100.0 * dubletten / gesamt))
+
+ if pcAntworten or pcLeer:
+ gesamtAntw = sum(pcAntworten.values())
+ print("zurueck an das Geraet: %d volle Bloecke (%.0f/s) + %d blosse "
+ "Koepfe (%.0f/s)"
+ % (gesamtAntw, gesamtAntw / dauer, pcLeer, pcLeer / dauer))
+
+ raten = abtastratenJeKanal(jeKanalEinzig, dauer)
+ print()
+ print("Abtastrate je Kanal (aus %d einzelnen Proben je Block):" % PROBEN)
+ for kanal in sorted(raten):
+ rate, jeSek = raten[kanal]
+ print(" Kanal %d: %s (%.0f einzelne Bloecke/s)"
+ % (kanal, ("%d kHz" % (rate // 1000)) if rate
+ else "nicht eindeutig", jeSek))
+
+ gefunden = sorted({r for r, _ in raten.values()})
+ if gefunden != [96000]:
+ print()
+ print("-> Diese Betriebsart ist NICHT Longpaths 96 kHz, und damit "
+ "beantwortet")
+ print(" dieser Mitschnitt die offene Frage NICHT -- egal wie die "
+ "Zahl oben")
+ print(" aussieht. Longpath fahrt bei 96 kHz BEIDE Stroeme mit "
+ "96 kHz.")
+ if len(gefunden) > 1:
+ print()
+ print(" Hier laufen die Kanaele mit VERSCHIEDENEN Raten. Das "
+ "ist selbst ein")
+ print(" Befund: das Geraet kann gemischt, Longpath kann es "
+ "nicht. Fuer die")
+ print(" Wiederholungsfrage braucht es aber einen Mitschnitt "
+ "mit beiden")
+ print(" Stroemen auf 96 kHz.")
+ return
+
+ print()
+ if dubletten / dauer > 20:
+ print("-> Das Geraet wiederholt auch hier. Dann ist es seine "
+ "Eigenart und kein Mangel von Longpath.")
+ else:
+ print("-> Praktisch keine Wiederholungen. Dann liegt es NICHT am "
+ "Geraet, und Longpath macht etwas anders als dieses "
+ "Programm -- der Unterschied steckt im Verbindungsablauf.")
+ if len(raten) != 2:
+ print()
+ print(" Mit Vorbehalt: hier laeuft %d Strom, Longpath faehrt bei "
+ "96 kHz ZWEI." % len(raten))
+ print(" Das ist ein starker Hinweis, aber noch nicht dieselbe "
+ "Betriebsart.")
+
+
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("pcap", nargs="?", default="",
help="Mitschnitt (pcap oder pcapng)")
+ ap.add_argument("--rechner", default="",
+ help="IP des Rechners, falls sie sich nicht aus dem "
+ "Mitschnitt ergibt (siehe findeRechner)")
ap.add_argument("--alle", action="store_true",
help="auch die Rahmen nach dem Verbindungsablauf zeigen")
ap.add_argument("--vergleich", default="",
help="zweiter Mitschnitt: zeigt, welche Rahmen sich "
"zwischen beiden unterscheiden (z. B. RX2 ein/aus)")
+ ap.add_argument("--wiederholungen", action="store_true",
+ help="zaehlt bytegleiche Wiederholungen im I/Q-Strom -- "
+ "fuer die Frage, ob ExpertSDR2 bei 96 kHz auch "
+ "wiederholt")
ap.add_argument("--selftest", action="store_true",
help="mit einem selbst gebauten Mitschnitt pruefen, "
"dass das Werkzeug tut, was es soll")
@@ -308,10 +639,13 @@ def main():
return
if not args.pcap:
ap.error("Entweder eine pcap-Datei oder --selftest.")
+ if args.wiederholungen:
+ wiederholungen(args.pcap, args.rechner or None)
+ return
if args.vergleich:
vergleiche(args.pcap, args.vergleich)
return
- auswerten(args.pcap, args.alle)
+ auswerten(args.pcap, args.alle, args.rechner or None)
if __name__ == "__main__":