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__":