From 1e4eb060cdb4b20c4f975b0e8d4de937a31650af Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sat, 3 Oct 2026 17:32:58 +0200 Subject: [PATCH 01/58] feat(sunsdr): Mikrofon-PTT am Geraet erkennen -- die letzte Empfangsluecke 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. 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. Heute kann der Fall nicht eintreten (kein Byte des Sendepfads erreicht den Draht), aber die Unterscheidung gehoert an die Stelle, die sie trifft. Dazu: ein haengendes PTT ueberlebt die Sitzung nicht. Bricht die Verbindung ab, waehrend das Geraet sendet, wird das PTT zurueckgenommen -- "Taste gedrueckt" ist der falsche Zustand, in dem man einen Sender in Erinnerung behaelt. NICHT AM GERAET GEGENGEPROBT: dazu muesste am Geraet die Mikrofontaste gedrueckt werden, und das heisst senden -- ohne Antenne verboten. Drei Pruefungen im Pruefstand (Flanke hin und zurueck, eigenes MOX gilt nicht, haengendes PTT wird beim Trennen zurueckgenommen), 84/84 gruen. Die Live-Bestaetigung gehoert zum ersten Sendeversuch mit 50-Ohm-Abschluss. Liegt auf einem eigenen Zweig, damit #154 gruen bleibt und gemergt werden kann. Co-Authored-By: Claude Opus 5 --- src/core/SunSdrRadioConnection.cpp | 36 ++++++++++ src/core/SunSdrRadioConnection.h | 29 ++++++++ tests/tst_sunsdr_messlauf.cpp | 5 ++ tests/tst_sunsdr_radio_connection.cpp | 95 +++++++++++++++++++++++++++ 4 files changed, 165 insertions(+) diff --git a/src/core/SunSdrRadioConnection.cpp b/src/core/SunSdrRadioConnection.cpp index 6f3091193..950f3aec0 100644 --- a/src/core/SunSdrRadioConnection.cpp +++ b/src/core/SunSdrRadioConnection.cpp @@ -253,6 +253,8 @@ void SunSdrRadioConnection::connectToRadio(const RadioInfo& info) m_letzteAnschlagMeldungMs = -1; m_anschlagProben = 0; m_anschlagMeldungen = 0; + m_geraetSendet = false; + m_mikrofonPttFlanken = 0; m_inventoryClock.invalidate(); m_lastStreamStateValid = false; // Die Folgenummern-Zaehlung gehoert zur Sitzung: die erste Nummer der @@ -459,6 +461,17 @@ 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); + } + berichteMithoeren(); m_running = false; @@ -1107,6 +1120,7 @@ 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 @@ -2146,4 +2160,26 @@ 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); +} + } // namespace Longpath diff --git a/src/core/SunSdrRadioConnection.h b/src/core/SunSdrRadioConnection.h index 9153707a5..060cfdb40 100644 --- a/src/core/SunSdrRadioConnection.h +++ b/src/core/SunSdrRadioConnection.h @@ -967,6 +967,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); @@ -1028,6 +1055,8 @@ 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; } int offeneRahmenForTest() const { return int(m_offeneRahmen.size()); } }; diff --git a/tests/tst_sunsdr_messlauf.cpp b/tests/tst_sunsdr_messlauf.cpp index be0ebd976..731b477c9 100644 --- a/tests/tst_sunsdr_messlauf.cpp +++ b/tests/tst_sunsdr_messlauf.cpp @@ -178,6 +178,11 @@ private slots: "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")); qInfo().noquote() << conn.frameInventoryReport(); qInfo().noquote() << conn.seqDeltaReport(); diff --git a/tests/tst_sunsdr_radio_connection.cpp b/tests/tst_sunsdr_radio_connection.cpp index 5c552e58c..37a3d6be5 100644 --- a/tests/tst_sunsdr_radio_connection.cpp +++ b/tests/tst_sunsdr_radio_connection.cpp @@ -1825,6 +1825,101 @@ private slots: QCOMPARE(conn.offeneRahmenForTest(), offenVorher); } + // ── 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 From 3095b42c1642c1bc310e387a270e3c5c401869f2 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sat, 3 Oct 2026 17:39:45 +0200 Subject: [PATCH 02/58] docs+tools(sunsdr): zweite Messreihe -- Einkanal-Zustand A/B belegt, Verlustrate null von 1212 Der leere Akku hat einen Fall geschenkt, der sonst schwer herzustellen ist: ein frisch eingeschaltetes Geraet. EINKANAL-ZUSTAND, A/B am selben Geraet, Minuten auseinander: A ohne Frequenzrahmen: Q ungleich null = 0,0 %, 1 Quittung B mit Frequenzrahmen: Q ungleich null = 21,9 %, 3 Quittungen Damit ist die Eingrenzung vom 2026-09-25 ("ein einziger 0x07-Rahmen schaltet echtes I/Q ein, 0 % -> 21 %") als A/B-Messung am frisch eingeschalteten Geraet bestaetigt -- vorher war das Geraet jeweils schon laenger an, und der Zustand liess sich nur aus der Erinnerung beschreiben. VERLUSTRATE VON STEUERRAHMEN: viermal 150 Frequenzwechsel (je zwei Rahmen), also 1212 Rahmen mit den Verbindungsrahmen -- 0 unbeantwortet. Das korrigiert meine eigene Begruendung von vorhin: "etwa einer von fuenfzehn Laeufen" war aus einem Einzelfall geschlossen. Ueber 1212 Rahmen ist die Rate null. Das Nachschicken bleibt eine sinnvolle Versicherung -- eine verlorene Frequenz bleibt sonst unbemerkt, und einmal ist es passiert --, aber es ist NICHT dringend. Dazu im Messlauf zwei Schalter: LONGPATH_SUNSDR_KEINE_FREQ laesst den Frequenzrahmen aus (Einschaltzustand messen) und LONGPATH_SUNSDR_WECHSEL schickt N Frequenzwechsel (Verlustrate messen). Co-Authored-By: Claude Opus 5 --- .../2026-10-02-sunsdr-verbindungsablauf.md | 52 +++++++++++++++++++ tests/tst_sunsdr_messlauf.cpp | 45 +++++++++++++--- 2 files changed, 90 insertions(+), 7 deletions(-) diff --git a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md index 0f0fe5008..372af98f9 100644 --- a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md +++ b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md @@ -377,3 +377,55 @@ 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. diff --git a/tests/tst_sunsdr_messlauf.cpp b/tests/tst_sunsdr_messlauf.cpp index 731b477c9..867e224a2 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,6 +156,27 @@ 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); + } + const int bloeckeVorher = iq.count(); QElapsedTimer fenster; fenster.start(); From 411a07808a8f9c01fffc4e2ea5bff857085e3f83 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sat, 3 Oct 2026 17:54:10 +0200 Subject: [PATCH 03/58] docs+fix(sunsdr): zwei Korrekturen aus dem echten Betrieb -- mein Messlauf hat sich selbst gemessen Der Betreiber hat Longpath um 17:50 neu gestartet und die QRP verbunden; seine Instanz sammelt selbst mit. Ihr Log widerlegt zwei Dinge aus meinen Messlaeufen. 1. KOPIEN JE NUMMER: mein Messlauf 1,20, echter Betrieb 1,00. Der Unterschied ist der Pruefstand -- er liess die Ereignisschleife in 500-ms-Bloecken laufen, dadurch gingen die Blockantworten verspaetet hinaus, und das Geraet WIEDERHOLTE. Die "rund 50 bytegleichen Wiederholungen je Sekunde" sind keine Eigenschaft des Geraets, sondern eine Folge meiner Messung. Behoben: der Messlauf tickt in 20 ms. Merksatz: ein Messgeraet, das den Takt der Antworten verschiebt, misst sich selbst. Das ist feedback-messung-schlaegt-nicht-das-geraet in der Gegenrichtung -- nicht das Geraet war schuld, sondern das Messgeraet. 2. "MELDET VON SICH AUS NICHTS": im Log des Betreibers steht 88 s nach dem Verbinden ein 0x01-Rahmen mit 51c300004928 -- das ist das Ende der BEACON-ANTWORT. Das Geraet hat auf eine Rundfrage geantwortet, nicht gemeldet. Der Befund bleibt richtig, genauer gefasst: die QRP ANTWORTET (Quittungen, Abfragen, Rundfragen), aber sie MELDET nicht. Co-Authored-By: Claude Opus 5 --- .../2026-10-02-sunsdr-verbindungsablauf.md | 48 ++++++++++++++++++ tests/tst_sunsdr_messlauf.cpp | 49 ++++++++++++++++++- 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md index 372af98f9..d9e407576 100644 --- a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md +++ b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md @@ -429,3 +429,51 @@ 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. diff --git a/tests/tst_sunsdr_messlauf.cpp b/tests/tst_sunsdr_messlauf.cpp index 867e224a2..6b825644c 100644 --- a/tests/tst_sunsdr_messlauf.cpp +++ b/tests/tst_sunsdr_messlauf.cpp @@ -177,11 +177,58 @@ private slots: 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"); + } + 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; From 95e432eb5515bea67a3c55213f1e0176838e5438 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sat, 3 Oct 2026 19:01:11 +0200 Subject: [PATCH 04/58] docs(sunsdr): Longpath sagt beim Trennen nichts -- die QRP streamt unbegrenzt weiter disconnect() schickt dem Geraet keinen einzigen Rahmen: Sockets zu, Timer aus, aufgeraeumt. Dem Funkgeraet wird nie gesagt, dass der Strom aufhoeren soll. Mit tcpdump nach einem Longpath-Ende gemessen (46 s, niemand hoerte zu): 1940 Pakete/s, 8,1 Kopien je Folgenummer, 2,3 MB/s ins Leere -- und der Mac antwortet auf jedes Paket mit ICMP "port unreachable". Daraus zwei Dinge: 1. DIE ACHTFACHUNG IST ABSCHLIESSEND ERKLAERT. Sie ist keine Eigenart des Geraets, sondern die Folge fehlender Quittierung: 8,1 Kopien ohne Blockantwort, 1,00 mit. Die Frage vom 2026-09-23 ist zu. 2. Die QRP streamt nach dem Trennen UNBEGRENZT weiter. Bekannt war das als Kuriosum ("85 leftover I/Q packets") -- es sind aber nicht 85 Pakete, sondern ein Dauerzustand bis zum Ausschalten. Sehr wahrscheinlich ist das auch der Grund, warum ExpertSDR2 am selben Abend nicht verbinden konnte: das Geraet stand noch im Streaming-Zustand der vorigen Sitzung. Was fehlt, ist der Stopp-Befehl. ArtemisSDR fuehrt fuer die DX SUNSDR_OP_POWER_OFF 0x02; die QRP-Nummer ist unbekannt und wird nicht geraten. Der Mitschnitt dafuer ist der leichteste von allen: ExpertSDR2 verbinden lassen und BEENDEN -- der letzte Rahmen vor dem Verstummen ist es. Co-Authored-By: Claude Opus 5 --- .../2026-10-02-sunsdr-paritaet.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/architecture/2026-10-02-sunsdr-paritaet.md b/docs/architecture/2026-10-02-sunsdr-paritaet.md index 59f3d5e70..cabeec6d5 100644 --- a/docs/architecture/2026-10-02-sunsdr-paritaet.md +++ b/docs/architecture/2026-10-02-sunsdr-paritaet.md @@ -230,3 +230,44 @@ 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. From 0d965a92c37fd9f0e1ff728395bb0910d21cf1a4 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sat, 3 Oct 2026 19:04:35 +0200 Subject: [PATCH 05/58] fix(tools): die Richtung im Mitschnitt kam aus dem Zielport -- und der taugt nicht Beim ersten Einsatz an einem ECHTEN Mitschnitt (2026-10-03) hielt sunsdr_handshake_diff.py alle zehn Steuerrahmen fuer ausgehend -- auch die fuenf Quittungen des Geraets. Ursache: die Richtung kam aus "dport == 50001", und beide Seiten sprechen Port 50001. Der Zielport ist hier also kein Merkmal. Jetzt entscheidet die ADRESSE, und welche der Rechner ist, ergibt sich aus dem Mitschnitt selbst: die Suchanfrage (Opcode 0x00) geht immer vom Rechner aus. Fehlt sie, entscheidet der Datenstrom (die 1210-Byte-Pakete kommen aus dem Geraet); bleibt auch das offen, sagt es --rechner. Am selben Mitschnitt gegengeprueft -- und das Ergebnis ist zugleich eine unabhaengige Bestaetigung der Quittungsmessung von heute, diesmal von AUSSEN am Draht statt aus dem Treiber: -> 0x00 Suchanfrage <- 0x01 Beacon-Antwort -> 0x01 Zustandsrahmen <- 0x01 Quittung -> 0x04 Vorverstaerker <- 0x04 Quittung -> 0x07 DDC-Frequenz <- 0x07 Quittung -> 0x08 VFO-Frequenz <- 0x08 Quittung Vier Rahmen, vier Quittungen, in 101 ms. Genau das, was der Treiber intern berichtet. Co-Authored-By: Claude Opus 5 --- tools/sunsdr_handshake_diff.py | 44 ++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/tools/sunsdr_handshake_diff.py b/tools/sunsdr_handshake_diff.py index f1eaeefdd..3d1023596 100644 --- a/tools/sunsdr_handshake_diff.py +++ b/tools/sunsdr_handshake_diff.py @@ -98,10 +98,42 @@ 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, entscheidet der Datenstrom -- die + 1210-Byte-Pakete kommen aus dem Geraet. Bleibt auch das offen, muss es + --rechner sagen. + """ + stromQuelle = None + for ts, src, sport, dst, dport, pl in udpMitRichtung(path): + k = kopf(pl) + if k is not None and k[0] == 0x00: + return src + if (sport == STREAM_PORT or dport == STREAM_PORT) and len(pl) > 1000: + stromQuelle = stromQuelle or dst # Ziel des Stroms = Rechner + return stromQuelle + + +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 +143,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: @@ -249,9 +281,10 @@ def vergleiche(a, b): zuordnen, ohne ihn am Funkgeraet zu erraten. """ def sammle(path): + rechner = findeRechner(path) aus = {} for ts, src, sport, dst, dport, pl in udpMitRichtung(path): - if dport != CTRL_PORT: + if dport != CTRL_PORT or src != rechner: continue # nur, was der Rechner hinausschickt k = kopf(pl) if k is None: @@ -294,6 +327,9 @@ def main(): 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="", @@ -311,7 +347,7 @@ def main(): 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__": From 3a5d7d2f9f307b3f72e9ff80ac379150bb65f6eb Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sat, 3 Oct 2026 19:11:51 +0200 Subject: [PATCH 06/58] docs(sunsdr): die QRP kann 96 kHz -- und die doppelte Datenrate war nie der zweite Empfaenger In ~/Longpath/werkzeug/mitschnitte/ lagen seit dem 2026-09-23 zwei ExpertSDR2-Mitschnitte. Aus ihnen wurden damals die dreizehn Rahmen fuer die CRC-Pruefung gezogen; auf den VERBINDUNGSABLAUF hat sie niemand ausgewertet. Nachgeholt -- und es beantwortet die zwei groessten offenen Fragen: 1. DIE QRP KANN 96 kHz. expert-rate.pcap: 28 561 Strompakete in 59,6 s = 479 Bloecke/s = 96 kHz. BoardCapabilities fuehrt maxSampleRate = 48000; das ist widerlegt. 2. DIE "DOPPELTE DATENRATE" IST ERKLAERT, und es war nie der zweite Empfaenger. Bei 96 kHz schickt die QRP ZWEI Pakete je Folgenummer: Nummern 0,0,0,1,2,2,3,3,4,4 -- 4000 Pakete, 2000 Nummern, Differenzen 0 und 1. Je 200 Probenpaare, zusammen 400 je Nummer, bei 240 Nummern/s also 96 000 Proben/s. Die beiden sind NICHT markiert (beide Zustandsbytes 0100), nur die Reihenfolge trennt sie. KONSEQUENZ, und sie ist unangenehm: der Folgenummern-Zaehler von heute behandelt das zweite Paket als Wiederholung -- richtig bei 48 kHz (dort bytegleich, am Geraet belegt), FALSCH bei 96 kHz. Longpath wuerde dort die Haelfte der Daten lautlos wegwerfen. Wer die Rate umstellt, muss processStreamDatagram mitumbauen: bei 96 kHz zaehlt die Reihenfolge, nicht die Nummer. 3. Der vollstaendige Verbindungsablauf: 33 Rahmen, SIEBZEHN Opcodes, die Longpath nie schickt -- und das Geraet quittiert jeden einzelnen. Damit sind diese Nummern als "dem Geraet bekannt" bestaetigt. Dazu: ZWEI Abfragen (0x0c und 0x0d, je 320 Byte Antwort), und 0x05/0x12 tragen Speicherabbilder von ExpertSDR2 (0x00c1b77f-Zeiger, schwankende Laenge) -- keine Protokollfelder zum Nachbauen. Co-Authored-By: Claude Opus 5 --- .../2026-10-02-sunsdr-verbindungsablauf.md | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md index d9e407576..f082cb471 100644 --- a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md +++ b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md @@ -477,3 +477,89 @@ richtig, muss aber genauer heißen: 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. From 24fbaa563c019dd969e9834e11fe9c12ae68d1cc Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sat, 3 Oct 2026 19:37:28 +0200 Subject: [PATCH 07/58] docs(sunsdr): DIE ABTASTRATE STEHT IM RAHMEN 0x01 -- und Longpath schickt ihn laengst Mitschnitt rate-umschalten.pcap (118 550 Pakete), ExpertSDR2 verbunden, zweimal die Rate umgeschaltet: 720 -> 1200 -> 720 Pakete/s. Im Steuerkanal aendert sich an genau diesen zwei Stellen EIN Rahmen -- 0x01, der Stromstart: Longpath heute: 01000000 0c080403 02020202 ExpertSDR2 Phase A/C: 02000000 0c080403 02020202 ExpertSDR2 Phase B: 02010000 0a060403 02020201 ES SIND ZWEI STROEME, unterschieden durch byte9 im Stromkopf: Phase A/C: byte9=0 -> 240 Pakete/s, 1 je Nummer = 48 kHz byte9=1 -> 480 Pakete/s, 2 je Nummer = 96 kHz Phase B: byte9=0 -> 480 Pakete/s, 1 je Nummer = 96 kHz byte9=1 -> 720 Pakete/s, 1,5 je Nummer = 144 kHz Longpath bekommt EINEN Strom mit 48 kHz, und der Unterschied ist das erste Byte des 0x01-Rahmens: 01 gegen 02. Dasselbe Byte erscheint im Stromkopf als byte8 wieder (Longpath 0100, ExpertSDR2 0200/0201). Damit ist auch die Beobachtung vom 2026-09-23 endgueltig erklaert ("zwei verschiedene Pakete je Nummer"): zwei Stroeme, beim 96-kHz-Strom zusaetzlich zwei Pakete je Nummer. FOLGEN: * Die QRP kann 48, 96 UND 144 kHz (gemessen). maxSampleRate = 48000 ist widerlegt. * Die QRP kann zwei Stroeme gleichzeitig, unabhaengig geratet -- und 0x07 adressiert passend zwei Unterempfaenger. * Longpath braucht KEINEN neuen Opcode dafuer, nur eine andere Nutzlast in einem Rahmen, den es schon schickt. * ABER: vorher muss processStreamDatagram umgebaut werden. Heute landen die Proben beider Kanaele in einem Topf, und das zweite Paket einer Nummer gilt als Wiederholung und wird verworfen -- es ist aber die zweite Haelfte der Proben. Einfach den Rahmen umstellen wuerde den Empfang KAPUTTMACHEN, nicht verbessern. Co-Authored-By: Claude Opus 5 --- .../2026-10-02-sunsdr-verbindungsablauf.md | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md index f082cb471..19f3141d2 100644 --- a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md +++ b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md @@ -563,3 +563,69 @@ 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. From 617c58bb9c59945824d693f0d44ecb24e7cae267 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sat, 3 Oct 2026 19:49:23 +0200 Subject: [PATCH 08/58] feat(sunsdr): der Strompfad versteht zwei Stroeme und Fortsetzungen Vorarbeit fuer 96/144 kHz, und zwar die, ohne die das Umstellen des 0x01-Rahmens den Empfang KAPUTTMACHEN wuerde (siehe docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md): * Der KANAL kommt jetzt aus dem Stromkopf (byte8 = Zahl der Stroeme, byte9 = Index) und geht als hwReceiverIndex nach oben. Vorher stand dort fest 0 -- bei zwei Stroemen waeren die Proben beider in einem Topf gelandet. * Ein zweites Paket mit derselben Folgenummer ist nicht mehr automatisch eine Wiederholung: der INHALT entscheidet. Bytegleich = Wiederholung (bei 48 kHz am Geraet belegt, 1683 von 1683), verschieden = FORTSETZUNG, also die naechsten Proben derselben Nummer (so kommt der 96-kHz-Strom). Verglichen wird nur bei wiederkehrender Nummer, also selten. * Die Folgenummern werden JE KANAL gezaehlt. Zwei Stroeme haben eigene Nummernraeume -- dieselbe Nummer auf beiden ist keine Wiederholung. Das hat der eigene Pruefstand gefunden; die Fensterzahlen (Verlust/Luecken) bleiben gemeinsam, denn sie messen den Netzweg. AM HEUTIGEN BETRIEB AENDERT SICH NICHTS: bei byte8 = 1 ist der Kanal immer 0, auch wenn byte9 etwas anderes sagt, und jede bytegleiche Wiederholung bleibt eine Wiederholung. Dafuer gibt es eine eigene Pruefung. Zwei weitere Fehler im eigenen Umbau gefunden und behoben: der Kanalzustand wurde beim Verbinden nicht zurueckgesetzt (das erste Paket der neuen Verbindung galt dann als Spaetling), und die Folgenummern-Zaehlung lief zunaechst weiter global. Vier neue Pruefungen, 88/88 gruen. Co-Authored-By: Claude Opus 5 --- src/core/SunSdrRadioConnection.cpp | 105 ++++++++++++++++-------- src/core/SunSdrRadioConnection.h | 61 ++++++++++++-- tests/tst_sunsdr_radio_connection.cpp | 111 ++++++++++++++++++++++++++ 3 files changed, 237 insertions(+), 40 deletions(-) diff --git a/src/core/SunSdrRadioConnection.cpp b/src/core/SunSdrRadioConnection.cpp index 950f3aec0..539f1886b 100644 --- a/src/core/SunSdrRadioConnection.cpp +++ b/src/core/SunSdrRadioConnection.cpp @@ -24,6 +24,7 @@ #include #include #include +#include namespace Longpath { @@ -260,14 +261,17 @@ void SunSdrRadioConnection::connectToRadio(const RadioInfo& info) // 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_seqSeen = false; - m_lastSeq = 0; 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_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; @@ -1126,7 +1130,36 @@ void SunSdrRadioConnection::processStreamDatagram(const QByteArray& data, 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) { + auditStreamSeq(kanal, hdr.seq); + } if (blockReplyEnabled()) { replyToBlock(hdr.seq); @@ -1335,7 +1368,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(); } @@ -1906,14 +1941,14 @@ QString SunSdrRadioConnection::frameInventoryReport() const // (65535 -> 0 ergibt 1, nicht -65535). // --------------------------------------------------------------------------- -void SunSdrRadioConnection::auditStreamSeq(quint16 seq) +void SunSdrRadioConnection::auditStreamSeq(int kanal, quint16 seq) { if (!m_iqSeqWndClock.isValid()) { m_iqSeqWndClock.start(); } if (m_probeOn && m_seqDeltas.size() < kMaxSeqDeltas) { - m_seqDeltas.append(qMakePair(seq, quint16(seq - m_lastSeq))); + m_seqDeltas.append(qMakePair(seq, quint16(seq - m_kanal[kanal].lastSeq))); } // Fenster zuerst schliessen, dann das neue Paket einsortieren: so @@ -1923,14 +1958,19 @@ void SunSdrRadioConnection::auditStreamSeq(quint16 seq) berichteFolgenummern(); } - const auto merken = [this](quint16 n) { - m_seqRing.append(n); - while (m_seqRing.size() > kSeqRingSize) { m_seqRing.removeFirst(); } + // Der Zustand gehoert JE KANAL -- zwei Stroeme haben eigene + // Nummernraeume. Die Fensterzahlen bleiben gemeinsam: sie messen den + // Netzweg, nicht den einzelnen Empfaenger. + KanalZustand& kz = m_kanal[kanal]; + + const auto merken = [&kz](quint16 n) { + kz.seqRing.append(n); + while (kz.seqRing.size() > kSeqRingSize) { kz.seqRing.removeFirst(); } }; - if (!m_seqSeen) { - m_seqSeen = true; - m_lastSeq = seq; + if (!kz.seqSeen) { + kz.seqSeen = true; + kz.lastSeq = seq; merken(seq); ++m_iqSeqWndFrames; return; @@ -1939,20 +1979,20 @@ 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)) { + if (kz.seqRing.contains(seq)) { ++m_iqSeqWndRepeats; - m_seqOutOfPlace = 0; + kz.seqOutOfPlace = 0; return; } - const quint16 delta = quint16(seq - m_lastSeq); + const quint16 delta = quint16(seq - kz.lastSeq); // 2. Der Normalfall: die naechste Nummer. if (delta == 1) { - m_lastSeq = seq; + kz.lastSeq = seq; merken(seq); ++m_iqSeqWndFrames; - m_seqOutOfPlace = 0; + kz.seqOutOfPlace = 0; return; } @@ -1960,13 +2000,12 @@ void SunSdrRadioConnection::auditStreamSeq(quint16 seq) if (delta >= 2 && delta <= kMaxPlausibleGap) { m_iqSeqWndLost += quint64(delta) - 1; ++m_iqSeqWndEvents; - m_lastSeq = seq; + kz.lastSeq = seq; merken(seq); ++m_iqSeqWndFrames; - m_seqOutOfPlace = 0; + kz.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(); @@ -1979,25 +2018,21 @@ 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. - ++m_seqOutOfPlace; - if (m_seqOutOfPlace < kSeqRestartAfter) { + // bei einem Neuanfang beliebig --, sondern daran, ob es bei DIESEM + // EINEN Paket bleibt. + ++kz.seqOutOfPlace; + if (kz.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"; - m_seqRing.clear(); - m_seqOutOfPlace = 0; - m_lastSeq = seq; + << "SunSdr: Folgenummern auf Kanal " << kanal << " fangen neu an (" + << kz.lastSeq << " -> " << seq << ") -- der Strom wurde neu gestartet"; + kz.seqRing.clear(); + kz.seqOutOfPlace = 0; + kz.lastSeq = seq; merken(seq); ++m_iqSeqWndFrames; } diff --git a/src/core/SunSdrRadioConnection.h b/src/core/SunSdrRadioConnection.h index 060cfdb40..3e2f3f934 100644 --- a/src/core/SunSdrRadioConnection.h +++ b/src/core/SunSdrRadioConnection.h @@ -806,7 +806,58 @@ 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}; + // Die Folgenummern gehoeren JE KANAL gezaehlt: zwei Stroeme haben + // eigene Nummernraeume, und dieselbe Nummer auf beiden ist keine + // Wiederholung, sondern ein anderer Strom. Vom eigenen Pruefstand + // gefunden, 2026-10-03. + bool seqSeen{false}; + quint16 lastSeq{0}; // hoechste gesehene Nummer + QList seqRing; // die letzten Nummern, fuer Wiederholungen + int seqOutOfPlace{0}; // Pakete in Folge, die zu nichts passen + }; + 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; + } + + void auditStreamSeq(int kanal, quint16 seq); // Schliesst das 5-s-Fenster: meldet nach oben und schreibt ins Log. void berichteFolgenummern(); @@ -864,10 +915,6 @@ private slots: // und eine echte Stoerung dieser Laenge waere ohnehin eine Luecke. 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 quint64 m_iqSeqWndRestarts{0}; quint64 m_iqSeqWndFrames{0}; quint64 m_iqSeqWndRepeats{0}; @@ -1057,6 +1104,10 @@ private slots: 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; } + quint64 kanalFortsetzungenForTest(int k) const + { return (k >= 0 && k < kMaxKanaele) ? m_kanal[k].fortsetzungen : 0; } int offeneRahmenForTest() const { return int(m_offeneRahmen.size()); } }; diff --git a/tests/tst_sunsdr_radio_connection.cpp b/tests/tst_sunsdr_radio_connection.cpp index 37a3d6be5..9f5adbc16 100644 --- a/tests/tst_sunsdr_radio_connection.cpp +++ b/tests/tst_sunsdr_radio_connection.cpp @@ -1825,6 +1825,117 @@ private slots: QCOMPARE(conn.offeneRahmenForTest(), offenVorher); } + // ── 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); + conn.feedStreamDatagramForTest(qrpBlockKanal(1, 2, 0, char(7))); + conn.feedStreamDatagramForTest(qrpBlockKanal(1, 2, 1, char(9))); + + QCOMPARE(iq.count(), 2); + QCOMPARE(iq.at(0).at(0).toInt(), 0); + QCOMPARE(iq.at(1).at(0).toInt(), 1); + QCOMPARE(conn.kanalPaketeForTest(0), quint64(1)); + QCOMPARE(conn.kanalPaketeForTest(1), quint64(1)); + // Jeder Kanal hat seine eigene Nummer 1 gesehen -- das ist KEINE + // Wiederholung, sondern ein anderer Strom. + QCOMPARE(conn.seqRepeatsForTest(), quint64(0)); + } + + // 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)); + } + // ── Mikrofon-PTT am Geraet ───────────────────────────────────────── // // Die zweite Empfangsluecke, geschlossen ohne Protokollwissen: der From 10419adec47ed3be73373733ca410ca4cff2c0c3 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sat, 3 Oct 2026 20:00:36 +0200 Subject: [PATCH 09/58] feat(sunsdr): 96 kHz und zwei Stroeme -- am Geraet gemessen, vierfache Datenmenge Der Stromstart-Rahmen 0x01 setzt die Zahl der Stroeme und die Ratenstufe. Die drei Nutzlasten stammen byte-fuer-byte aus dem ExpertSDR2-Mitschnitt vom 2026-10-03; der gebaute Rahmen ist bitgleich mit dem aufgezeichneten (eigene Pruefung in tst_sunsdr_protocol). AM ECHTEN GERAET DURCHGEMESSEN, alle drei Modi, Verlust null: 01000000 0c080403 02020202 239 Nummern/s ein Strom, 48 kHz (1x) 02000000 0c080403 02020202 480 Nummern/s zwei Stroeme, je 48 kHz (2x) 02010000 0a060403 02020201 958 Nummern/s zwei Stroeme, je 96 kHz (4x) Also: erstes Byte = Zahl der Stroeme, zweites = Ratenstufe. Longpath kann damit die VIERFACHE Datenmenge holen. Im je96-Modus steigt auch der Q-Anteil von 22 auf 90 % -- voller I/Q-Inhalt statt duennem. Gewaehlt wird das NUR ueber die Umgebung (LONGPATH_SUNSDR_STROMMODUS= 48|je48|je96), Vorgabe bleibt das heutige Verhalten, und ein anderer Modus schreibt eine Warnung ins Log: die Oberflaeche bekommt das erst, wenn der zweite Kanal oben einen Empfaenger hat (BoardCapabilities fuehrt weiter maxReceivers = 1). ZWEI EIGENE ANNAHMEN HAT DAS GERAET WIDERLEGT, beide heute: 1. "Zwei Stroeme haben eigene Nummernraeume." Falsch -- sie laufen GLOBAL fortlaufend, byte9 wechselt dabei: 0(Kanal1) 1(Kanal2) 2(Kanal1) 3(Kanal2) ... Je Kanal gezaehlt sah jeder Kanal nur jede zweite Nummer, und der Zaehler meldete 50 % VERLUST bei einem vollkommen gesunden Strom. Der Pruefstand hatte meine Annahme bestaetigt, weil ich sie hineingeschrieben hatte; das Geraet hat sie widerlegt. 2. Die Modusnamen. Aus dem Mitschnitt sah es nach 48+96 bzw. 96+144 kHz aus (verschiedene Raten je Kanal); mit NUR diesem Rahmen kommen beide Kanaele gleich schnell. Die Namen heissen jetzt je48/je96, nach dem Gemessenen. Co-Authored-By: Claude Opus 5 --- src/core/SunSdrRadioConnection.cpp | 92 +++++++++++++++++++-------- src/core/SunSdrRadioConnection.h | 41 +++++++++--- src/core/sunsdr/SunSdrProtocol.cpp | 22 +++++++ src/core/sunsdr/SunSdrProtocol.h | 49 ++++++++++++++ tests/tst_sunsdr_messlauf.cpp | 9 +++ tests/tst_sunsdr_protocol.cpp | 36 +++++++++++ tests/tst_sunsdr_radio_connection.cpp | 21 ++++-- 7 files changed, 229 insertions(+), 41 deletions(-) diff --git a/src/core/SunSdrRadioConnection.cpp b/src/core/SunSdrRadioConnection.cpp index 539f1886b..8376af002 100644 --- a/src/core/SunSdrRadioConnection.cpp +++ b/src/core/SunSdrRadioConnection.cpp @@ -266,6 +266,10 @@ void SunSdrRadioConnection::connectToRadio(const RadioInfo& info) // 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_seqRing.clear(); + m_seqOutOfPlace = 0; m_iqSeqWndFrames = 0; m_iqSeqWndRepeats = 0; m_iqSeqWndLost = 0; @@ -1018,8 +1022,22 @@ 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 = stromModusAusUmgebung(); + const QByteArray stateSync = + SunSdr::buildStromStartFrame(*m_profile, modus); + if (modus != SunSdr::StromModus::EinStrom48) { + qCWarning(lcSunSdr).noquote() + << QStringLiteral( + "SunSdr: Strommodus aus der Umgebung -- %1. Das ist " + "ein VERSUCH: die Rate kommt aus einem Mitschnitt " + "vom 2026-10-03 und ist am Geraet nicht " + "gegengeprueft, und der zweite Kanal hat oben noch " + "keinen Empfaenger.") + .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")); } @@ -1948,7 +1966,7 @@ void SunSdrRadioConnection::auditStreamSeq(int kanal, quint16 seq) } if (m_probeOn && m_seqDeltas.size() < kMaxSeqDeltas) { - m_seqDeltas.append(qMakePair(seq, quint16(seq - m_kanal[kanal].lastSeq))); + m_seqDeltas.append(qMakePair(seq, quint16(seq - m_lastSeq))); } // Fenster zuerst schliessen, dann das neue Paket einsortieren: so @@ -1958,19 +1976,19 @@ void SunSdrRadioConnection::auditStreamSeq(int kanal, quint16 seq) berichteFolgenummern(); } - // Der Zustand gehoert JE KANAL -- zwei Stroeme haben eigene - // Nummernraeume. Die Fensterzahlen bleiben gemeinsam: sie messen den - // Netzweg, nicht den einzelnen Empfaenger. - KanalZustand& kz = m_kanal[kanal]; + // 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 = [&kz](quint16 n) { - kz.seqRing.append(n); - while (kz.seqRing.size() > kSeqRingSize) { kz.seqRing.removeFirst(); } + const auto merken = [this](quint16 n) { + m_seqRing.append(n); + while (m_seqRing.size() > kSeqRingSize) { m_seqRing.removeFirst(); } }; - if (!kz.seqSeen) { - kz.seqSeen = true; - kz.lastSeq = seq; + if (!m_seqSeen) { + m_seqSeen = true; + m_lastSeq = seq; merken(seq); ++m_iqSeqWndFrames; return; @@ -1979,20 +1997,20 @@ void SunSdrRadioConnection::auditStreamSeq(int kanal, 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 (kz.seqRing.contains(seq)) { + if (m_seqRing.contains(seq)) { ++m_iqSeqWndRepeats; - kz.seqOutOfPlace = 0; + m_seqOutOfPlace = 0; return; } - const quint16 delta = quint16(seq - kz.lastSeq); + const quint16 delta = quint16(seq - m_lastSeq); // 2. Der Normalfall: die naechste Nummer. if (delta == 1) { - kz.lastSeq = seq; + m_lastSeq = seq; merken(seq); ++m_iqSeqWndFrames; - kz.seqOutOfPlace = 0; + m_seqOutOfPlace = 0; return; } @@ -2000,10 +2018,10 @@ void SunSdrRadioConnection::auditStreamSeq(int kanal, quint16 seq) if (delta >= 2 && delta <= kMaxPlausibleGap) { m_iqSeqWndLost += quint64(delta) - 1; ++m_iqSeqWndEvents; - kz.lastSeq = seq; + m_lastSeq = seq; merken(seq); ++m_iqSeqWndFrames; - kz.seqOutOfPlace = 0; + m_seqOutOfPlace = 0; // Gedrosselt wie bei P1/P2 (20 ms). Minus eins heisst "noch nie // gemeldet" -- mit 0 als Startwert verschwand die ERSTE Luecke @@ -2020,19 +2038,19 @@ void SunSdrRadioConnection::auditStreamSeq(int kanal, quint16 seq) // Unterschieden wird nicht an der Groesse der Differenz -- die ist // bei einem Neuanfang beliebig --, sondern daran, ob es bei DIESEM // EINEN Paket bleibt. - ++kz.seqOutOfPlace; - if (kz.seqOutOfPlace < kSeqRestartAfter) { + ++m_seqOutOfPlace; + if (m_seqOutOfPlace < kSeqRestartAfter) { ++m_iqSeqWndBackwards; return; // lastSeq NICHT zurueckdrehen } ++m_iqSeqWndRestarts; qCInfo(lcSunSdr).nospace() - << "SunSdr: Folgenummern auf Kanal " << kanal << " fangen neu an (" - << kz.lastSeq << " -> " << seq << ") -- der Strom wurde neu gestartet"; - kz.seqRing.clear(); - kz.seqOutOfPlace = 0; - kz.lastSeq = seq; + << "SunSdr: Folgenummern fangen neu an (" + << m_lastSeq << " -> " << seq << ") -- der Strom wurde neu gestartet"; + m_seqRing.clear(); + m_seqOutOfPlace = 0; + m_lastSeq = seq; merken(seq); ++m_iqSeqWndFrames; } @@ -2217,4 +2235,24 @@ void SunSdrRadioConnection::pruefeMikrofonPtt(quint8 streamOpcode) 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 3e2f3f934..40eeeecb7 100644 --- a/src/core/SunSdrRadioConnection.h +++ b/src/core/SunSdrRadioConnection.h @@ -838,14 +838,17 @@ private slots: QByteArray letzteNutzlast; // nur fuer den Vergleich bei gleicher Nummer quint64 pakete{0}; quint64 fortsetzungen{0}; - // Die Folgenummern gehoeren JE KANAL gezaehlt: zwei Stroeme haben - // eigene Nummernraeume, und dieselbe Nummer auf beiden ist keine - // Wiederholung, sondern ein anderer Strom. Vom eigenen Pruefstand - // gefunden, 2026-10-03. - bool seqSeen{false}; - quint16 lastSeq{0}; // hoechste gesehene Nummer - QList seqRing; // die letzten Nummern, fuer Wiederholungen - int seqOutOfPlace{0}; // Pakete in Folge, die zu nichts passen + // 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]; @@ -857,6 +860,23 @@ private slots: 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; + void auditStreamSeq(int kanal, quint16 seq); // Schliesst das 5-s-Fenster: meldet nach oben und schreibt ins Log. void berichteFolgenummern(); @@ -915,6 +935,11 @@ private slots: // und eine echte Stoerung dieser Laenge waere ohnehin eine Luecke. static constexpr int kSeqRestartAfter = 3; + bool m_seqSeen{false}; + quint16 m_lastSeq{0}; // hoechste gesehene Nummer, ueber alle Stroeme + QList m_seqRing; // die letzten Nummern, fuer Wiederholungen + int m_seqOutOfPlace{0}; // Pakete in Folge, die zu nichts passen + quint64 m_iqSeqWndRestarts{0}; quint64 m_iqSeqWndFrames{0}; quint64 m_iqSeqWndRepeats{0}; diff --git a/src/core/sunsdr/SunSdrProtocol.cpp b/src/core/sunsdr/SunSdrProtocol.cpp index a79cfbbf5..0ec1edf76 100644 --- a/src/core/sunsdr/SunSdrProtocol.cpp +++ b/src/core/sunsdr/SunSdrProtocol.cpp @@ -223,6 +223,28 @@ 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); +} + 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..961d316a6 100644 --- a/src/core/sunsdr/SunSdrProtocol.h +++ b/src/core/sunsdr/SunSdrProtocol.h @@ -478,6 +478,55 @@ 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); + // 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/tests/tst_sunsdr_messlauf.cpp b/tests/tst_sunsdr_messlauf.cpp index 6b825644c..b1442d72a 100644 --- a/tests/tst_sunsdr_messlauf.cpp +++ b/tests/tst_sunsdr_messlauf.cpp @@ -261,6 +261,15 @@ private slots: .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 kHz") + .arg(k).arg(n).arg(double(n)/secs, 0, 'f', 0) + .arg(conn.kanalFortsetzungenForTest(k)) + .arg(double(n)/secs*200/1000, 0, 'f', 0); + } qInfo().noquote() << conn.frameInventoryReport(); qInfo().noquote() << conn.seqDeltaReport(); diff --git a/tests/tst_sunsdr_protocol.cpp b/tests/tst_sunsdr_protocol.cpp index ec9914113..a6238812d 100644 --- a/tests/tst_sunsdr_protocol.cpp +++ b/tests/tst_sunsdr_protocol.cpp @@ -574,6 +574,42 @@ 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. + // ── 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 9f5adbc16..b336788ce 100644 --- a/tests/tst_sunsdr_radio_connection.cpp +++ b/tests/tst_sunsdr_radio_connection.cpp @@ -1858,17 +1858,26 @@ private slots: 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. conn.feedStreamDatagramForTest(qrpBlockKanal(1, 2, 0, char(7))); - conn.feedStreamDatagramForTest(qrpBlockKanal(1, 2, 1, char(9))); + conn.feedStreamDatagramForTest(qrpBlockKanal(2, 2, 1, char(9))); + conn.feedStreamDatagramForTest(qrpBlockKanal(3, 2, 0, char(7))); + conn.feedStreamDatagramForTest(qrpBlockKanal(4, 2, 1, char(9))); - QCOMPARE(iq.count(), 2); + QCOMPARE(iq.count(), 4); QCOMPARE(iq.at(0).at(0).toInt(), 0); QCOMPARE(iq.at(1).at(0).toInt(), 1); - QCOMPARE(conn.kanalPaketeForTest(0), quint64(1)); - QCOMPARE(conn.kanalPaketeForTest(1), quint64(1)); - // Jeder Kanal hat seine eigene Nummer 1 gesehen -- das ist KEINE - // Wiederholung, sondern ein anderer Strom. + QCOMPARE(conn.kanalPaketeForTest(0), quint64(2)); + QCOMPARE(conn.kanalPaketeForTest(1), quint64(2)); + // Lueckenlos im globalen Nummernraum: kein Verlust, keine + // Wiederholung. 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 From c3203f56a5e99213a3ed168ca3d93515cc87fea3 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sat, 3 Oct 2026 20:12:14 +0200 Subject: [PATCH 10/58] feat(sunsdr): setSampleRate stellt die Rate wirklich um -- 96 kHz live belegt setSampleRate war seit dem 2026-08-26 ein No-op. Jetzt waehlt es den Stromstart-Rahmen: 48000 -> ein Strom, 96000 -> zwei Stroeme je 96 kHz, davon geht Kanal 0 an den Empfaenger. Steht die Verbindung schon, geht der Rahmen sofort hinaus; das Geraet startet den Strom neu und faengt die Folgenummern bei null an -- auditStreamSeq erkennt das als Neuanfang (im Lauf zu sehen: "Folgenummern fangen neu an (368 -> 2)"). Nur die zwei gemessenen Raten, jede andere aendert NICHTS und schreibt eine Warnung: ein geratener Rahmen bedeutet nicht "geht nicht", sondern Daten einer Rate in einem Kanal einer anderen -- genau das ist am 2026-09-24 passiert (48k in einem 192k-Kanal) und wurde am Geraet als "schlechtes Rauschen" gehoert. setActiveReceiverCount ist ebenfalls kein No-op mehr: die Verbindung merkt sich, fuer wie viele Kanaele es oben einen Empfaenger gibt, und verwirft die anderen. Nicht um dem Geraet etwas zu sagen -- welcher Rahmen die Empfaengerzahl stellt, ist offen -- sondern damit ein zweiter Strom nicht nach oben geht, wo er bestenfalls ignoriert und schlimmstenfalls mit Kanal 0 vermischt wuerde. EIN EIGENER FEHLER, ZWEIMAL AN DERSELBEN STELLE: das Verwerfen stand zuerst VOR der Folgenummern-Zaehlung. Weil die Nummern global ueber alle Stroeme laufen, fehlte jede uebersprungene Nummer im Nummernraum, und der Zaehler meldete 50 % VERLUST bei einem vollkommen gesunden Strom. Jetzt wird erst gezaehlt, dann verworfen -- und der Messlauf sagt "Folgenummern sauber, 960/s, 0 Spaetlinge". Live am Geraet, ganze Kette: mit 48 kHz verbunden, zur Laufzeit auf 96 kHz umgestellt, Kanal 0 mit 747 Pakete/s an den Empfaenger, Kanal 1 verworfen, kein Verlust. 89/89 gruen. Co-Authored-By: Claude Opus 5 --- src/core/SunSdrRadioConnection.cpp | 92 ++++++++++++++++++++------- src/core/SunSdrRadioConnection.h | 9 +++ tests/tst_sunsdr_messlauf.cpp | 14 +++- tests/tst_sunsdr_radio_connection.cpp | 48 ++++++++++++-- 4 files changed, 134 insertions(+), 29 deletions(-) diff --git a/src/core/SunSdrRadioConnection.cpp b/src/core/SunSdrRadioConnection.cpp index 8376af002..cadeba07e 100644 --- a/src/core/SunSdrRadioConnection.cpp +++ b/src/core/SunSdrRadioConnection.cpp @@ -270,6 +270,10 @@ void SunSdrRadioConnection::connectToRadio(const RadioInfo& info) m_lastSeq = 0; m_seqRing.clear(); m_seqOutOfPlace = 0; + // Die Umgebung ist nur die VORBELEGUNG fuer diese Sitzung; setSampleRate + // stellt danach um. + m_stromModus = stromModusAusUmgebung(); + m_aktiveEmpfaenger = 1; m_iqSeqWndFrames = 0; m_iqSeqWndRepeats = 0; m_iqSeqWndLost = 0; @@ -923,35 +927,66 @@ 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 + << "(Kanaele darueber werden verworfen)"; } 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. + SunSdr::StromModus modus; + if (sampleRate == 48000) { + modus = SunSdr::StromModus::EinStrom48; + } else if (sampleRate == 96000) { + // Zwei Stroeme je 96 kHz; der erste geht an den Empfaenger, der + // zweite wird verworfen, solange es keinen zweiten gibt (siehe + // processStreamDatagram). + modus = SunSdr::StromModus::ZweiStroemeJe96; + } else { + qCWarning(lcSunSdr) + << "SunSdr: setSampleRate(" << sampleRate + << ") -- fuer diese Rate ist kein Stromstart-Rahmen belegt. Es " + "bleibt bei" << (m_stromModus == SunSdr::StromModus::EinStrom48 + ? 48000 : 96000) + << "Hz. Belegt sind 48000 und 96000 (am Geraet gemessen " + "2026-10-03)."; + return; + } + + if (modus == m_stromModus) { + return; + } + m_stromModus = modus; + qCInfo(lcSunSdr) << "SunSdr: Abtastrate ->" << sampleRate + << "Hz (Stromstart-Rahmen wird umgestellt)"; + + // Steht die Verbindung schon, geht der Rahmen jetzt hinaus -- das + // Geraet startet den Strom dann neu und faengt die Folgenummern bei + // null an (am 2026-10-03 gemessen; auditStreamSeq erkennt das als + // Neuanfang). + if (m_running && !m_awaitingBeacon && m_profile) { + sendeSteuerrahmen(SunSdr::buildStromStartFrame(*m_profile, m_stromModus), + "Stromstart 0x01 (Rate umgestellt)"); } } + void SunSdrRadioConnection::onControlReadyRead() { if (!m_controlSocket) { return; } @@ -1022,7 +1057,7 @@ void SunSdrRadioConnection::processControlDatagram(const QByteArray& data, // beide Seiten: PRE davor, EXTRA danach. sendBenchFrames(QStringLiteral("LONGPATH_SUNSDR_PRE")); - const SunSdr::StromModus modus = stromModusAusUmgebung(); + const SunSdr::StromModus modus = m_stromModus; const QByteArray stateSync = SunSdr::buildStromStartFrame(*m_profile, modus); if (modus != SunSdr::StromModus::EinStrom48) { @@ -1149,6 +1184,7 @@ void SunSdrRadioConnection::processStreamDatagram(const QByteArray& data, } const int kanal = kanalVon(hdr); + KanalZustand& kz = m_kanal[kanal]; ++kz.pakete; @@ -1179,6 +1215,18 @@ void SunSdrRadioConnection::processStreamDatagram(const QByteArray& data, auditStreamSeq(kanal, hdr.seq); } + // 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); } diff --git a/src/core/SunSdrRadioConnection.h b/src/core/SunSdrRadioConnection.h index 40eeeecb7..9ac19ac95 100644 --- a/src/core/SunSdrRadioConnection.h +++ b/src/core/SunSdrRadioConnection.h @@ -838,6 +838,7 @@ private slots: 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 @@ -876,6 +877,11 @@ private slots: // 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}; void auditStreamSeq(int kanal, quint16 seq); // Schliesst das 5-s-Fenster: meldet nach oben und schreibt ins Log. @@ -1131,6 +1137,9 @@ private slots: 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); } + 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; } int offeneRahmenForTest() const { return int(m_offeneRahmen.size()); } diff --git a/tests/tst_sunsdr_messlauf.cpp b/tests/tst_sunsdr_messlauf.cpp index b1442d72a..68294381a 100644 --- a/tests/tst_sunsdr_messlauf.cpp +++ b/tests/tst_sunsdr_messlauf.cpp @@ -215,6 +215,16 @@ private slots: "Abfrage 0x0c dazwischen"); } + // Rate zur Laufzeit umstellen -- der Weg, den spaeter die + // Oberflaeche nimmt. LONGPATH_SUNSDR_RATE=96000 schaltet nach dem + // Verbinden um. + 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(); @@ -265,10 +275,10 @@ private slots: const quint64 n = conn.kanalPaketeForTest(k); if (n == 0) { continue; } qInfo().noquote() << QStringLiteral( - "Kanal %1: %2 Pakete (%3/s), %4 Fortsetzungen -> %5 kHz") + "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(double(n)/secs*200/1000, 0, 'f', 0); + .arg(conn.kanalVerworfenForTest(k)); } qInfo().noquote() << conn.frameInventoryReport(); qInfo().noquote() << conn.seqDeltaReport(); diff --git a/tests/tst_sunsdr_radio_connection.cpp b/tests/tst_sunsdr_radio_connection.cpp index b336788ce..446bd77b7 100644 --- a/tests/tst_sunsdr_radio_connection.cpp +++ b/tests/tst_sunsdr_radio_connection.cpp @@ -1863,18 +1863,30 @@ private slots: // 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(), 4); - QCOMPARE(iq.at(0).at(0).toInt(), 0); - QCOMPARE(iq.at(1).at(0).toInt(), 1); + 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: kein Verlust, keine - // Wiederholung. + // 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)); @@ -1945,6 +1957,32 @@ private slots: 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); + } + // ── Mikrofon-PTT am Geraet ───────────────────────────────────────── // // Die zweite Empfangsluecke, geschlossen ohne Protokollwissen: der From d60d29255e601c7222390e2e70b61a231ff1ab2f Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sat, 3 Oct 2026 20:14:53 +0200 Subject: [PATCH 11/58] feat(sunsdr): 96 kHz in den Geraetefaehigkeiten -- die Oberflaeche kann es jetzt anbieten sampleRates = {48000, 96000}, maxSampleRate = 96000. Beides am 2026-10-03 am Geraet gemessen und durch die ganze Kette belegt: der Stromstart-Rahmen 0x01 stellt die Rate, setSampleRate waehlt ihn, und Kanal 0 kommt mit 480 Folgenummern je Sekunde = 96 kHz beim Empfaenger an -- ohne Verlust, mit erkanntem Stromneustart. Bis heute stand dort 48 000 mit dem Vermerk "nicht verhandelt". Das war die Grenze unseres Wissens, nicht die des Geraets. 144 000 waere ebenfalls belegt (zwei Stroeme je 96 kHz = 192 kHz zusammen), steht aber NICHT in der Liste: ein Eintrag dort heisst, dass die Oberflaeche die Rate anbietet, und was angeboten wird, 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. Die zwei Pruefungen, die "die QRP kann nur 48 kHz" festhielten, sind auf das Gemessene umgestellt -- und der Punkt, auf den sie eigentlich zielen, ist geschaerft: was die QRP NICHT kann (192 kHz, 144 kHz), bleibt gesperrt, und genau das ist am 2026-09-24 schiefgegangen. 9/9 SunSDR-Pruefstaende gruen. Co-Authored-By: Claude Opus 5 --- src/core/BoardCapabilities.cpp | 17 +++++++++++++++-- tests/tst_sunsdr_board_caps.cpp | 15 +++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/core/BoardCapabilities.cpp b/src/core/BoardCapabilities.cpp index c0428c43b..1afa55666 100644 --- a/src/core/BoardCapabilities.cpp +++ b/src/core/BoardCapabilities.cpp @@ -1276,8 +1276,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/tests/tst_sunsdr_board_caps.cpp b/tests/tst_sunsdr_board_caps.cpp index 2046da275..e03b81148 100644 --- a/tests/tst_sunsdr_board_caps.cpp +++ b/tests/tst_sunsdr_board_caps.cpp @@ -79,7 +79,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 +126,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() From fa75eec06ae18331fec42a12a297b1c4af93a218 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sat, 3 Oct 2026 20:29:33 +0200 Subject: [PATCH 12/58] feat(sunsdr): beim Trennen sagt Longpath dem Geraet jetzt Stopp Bis heute schickte disconnect() dem Funkgeraet GAR NICHTS -- Sockets zu, Timer aus, fertig. 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 der Mac antwortet auf jedes Paket mit ICMP. In diesem Zustand laesst sie sich auch schlecht neu verbinden -- sehr wahrscheinlich der Grund, warum ExpertSDR2 am selben Abend zweimal nicht durchkam. Der Rahmen stand im Mitschnitt: beim Beenden schickt ExpertSDR2 0x06 mit 0 (MOX aus) und 0x02 mit vier Nullbytes -- und das LETZTE Strompaket liegt in derselben Millisekunde wie 0x02. ArtemisSDR fuehrt 0x02 als SUNSDR_OP_POWER_OFF, was dazu passt. Der gebaute Rahmen ist BITGLEICH mit dem aufgezeichneten (03ff0200040000000000010000000d99f99d00000000, dreimal im Mitschnitt, immer derselbe); eine Pruefung haelt das fest. AM GERAET GEGENGEMESSEN: nach dem Trennen 0 Pakete/s auf der Schnittstelle. Vorher 1940. Geschickt wird 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. Dafuer gibt es eine eigene Pruefung. 0x06 (MOX aus) schickt Longpath NICHT mit: der Strom verstummt schon auf 0x02, und fuer den MOX-Opcode gilt weiter die Sperre, bis seine Bedeutung an der QRP bestaetigt ist. 35/35 und 91/91 gruen. Co-Authored-By: Claude Opus 5 --- src/core/SunSdrRadioConnection.cpp | 21 ++++++++++++++++++ src/core/SunSdrRadioConnection.h | 2 ++ src/core/sunsdr/SunSdrProtocol.cpp | 8 +++++++ src/core/sunsdr/SunSdrProtocol.h | 14 ++++++++++++ tests/tst_sunsdr_protocol.cpp | 11 +++++++++ tests/tst_sunsdr_radio_connection.cpp | 32 +++++++++++++++++++++++++++ 6 files changed, 88 insertions(+) diff --git a/src/core/SunSdrRadioConnection.cpp b/src/core/SunSdrRadioConnection.cpp index cadeba07e..12bede6f7 100644 --- a/src/core/SunSdrRadioConnection.cpp +++ b/src/core/SunSdrRadioConnection.cpp @@ -274,6 +274,7 @@ void SunSdrRadioConnection::connectToRadio(const RadioInfo& info) // stellt danach um. m_stromModus = stromModusAusUmgebung(); m_aktiveEmpfaenger = 1; + m_stoppGeschickt = 0; m_iqSeqWndFrames = 0; m_iqSeqWndRepeats = 0; m_iqSeqWndLost = 0; @@ -484,6 +485,26 @@ void SunSdrRadioConnection::disconnect() 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()) { + 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 */ } + } + berichteMithoeren(); m_running = false; diff --git a/src/core/SunSdrRadioConnection.h b/src/core/SunSdrRadioConnection.h index 9ac19ac95..c17c6d1f8 100644 --- a/src/core/SunSdrRadioConnection.h +++ b/src/core/SunSdrRadioConnection.h @@ -882,6 +882,7 @@ private slots: SunSdr::StromModus m_stromModus{SunSdr::StromModus::EinStrom48}; // Wie viele Kanaele oben ueberhaupt einen Empfaenger haben. int m_aktiveEmpfaenger{1}; + quint64 m_stoppGeschickt{0}; void auditStreamSeq(int kanal, quint16 seq); // Schliesst das 5-s-Fenster: meldet nach oben und schreibt ins Log. @@ -1138,6 +1139,7 @@ private slots: quint64 kanalPaketeForTest(int k) const { return (k >= 0 && k < kMaxKanaele) ? m_kanal[k].pakete : 0; } int stromModusForTest() const { return int(m_stromModus); } + 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 diff --git a/src/core/sunsdr/SunSdrProtocol.cpp b/src/core/sunsdr/SunSdrProtocol.cpp index 0ec1edf76..fb886afdc 100644 --- a/src/core/sunsdr/SunSdrProtocol.cpp +++ b/src/core/sunsdr/SunSdrProtocol.cpp @@ -245,6 +245,14 @@ QByteArray buildStromStartFrame(const Profile& profile, StromModus modus) 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 961d316a6..7fd7ee954 100644 --- a/src/core/sunsdr/SunSdrProtocol.h +++ b/src/core/sunsdr/SunSdrProtocol.h @@ -527,6 +527,20 @@ 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/tests/tst_sunsdr_protocol.cpp b/tests/tst_sunsdr_protocol.cpp index a6238812d..560de581a 100644 --- a/tests/tst_sunsdr_protocol.cpp +++ b/tests/tst_sunsdr_protocol.cpp @@ -574,6 +574,17 @@ 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 diff --git a/tests/tst_sunsdr_radio_connection.cpp b/tests/tst_sunsdr_radio_connection.cpp index 446bd77b7..a57973e51 100644 --- a/tests/tst_sunsdr_radio_connection.cpp +++ b/tests/tst_sunsdr_radio_connection.cpp @@ -1825,6 +1825,38 @@ 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(); + QCOMPARE(conn.stoppGeschicktForTest(), quint64(1)); + } + + // 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)); + } + // ── Zwei Stroeme und mehrere Pakete je Folgenummer ───────────────── // // Am 2026-10-03 aus einem ExpertSDR2-Mitschnitt gemessen: die QRP From e531b6efdfa8c28cd3a22ff6a0e6399e609e78d9 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sat, 3 Oct 2026 20:33:08 +0200 Subject: [PATCH 13/58] docs(sunsdr): die Messwert-Frage ist entschieden -- die QRP gibt keine heraus Alle Antworten des Geraets aus ALLEN drei Mitschnitten zusammengetragen (23.09. und 03.10., also ueber zehn Tage): 0x0c: 5 Antworten, 1 verschieden, 320 Byte 0x0d: 5 Antworten, 1 verschieden, 320 Byte 0x12: 5 Antworten, 1 verschieden, 20 Byte Bitgleich, ueber zehn Tage, ueber Neustarts und Bandwechsel hinweg. Das sind Werksdaten -- Kalibrierung, Typ, Version --, keine Momentanwerte. Dazu passt, dass das Geraet unaufgefordert gar nichts schickt und die Zustandsbytes im Stromkopf konstant bleiben. Abschliessend fuer die Paritaetsliste: im Empfang gibt es bei der QRP KEINE Geraetemesswerte. Nicht ueber 0x0c, nicht ueber 0x0d, nicht ueber 0x12, nicht unaufgefordert, nicht im Stromkopf. Das ist kein Mangel von Longpath, sondern eine Eigenschaft des Geraets -- und was P1/P2 dort melden, ist ohnehin senderseitig (Vorwaerts-/Rueckwaertsleistung, PA-Temperatur). Ob die QRP das beim SENDEN herausgibt, ist eine eigene Frage und gehoert zum Dummy-Load. Damit ist die Meldeseite im Empfang vollstaendig, ohne dass das Geraet einen einzigen Messwert liefert: S-Meter rechnet Longpath aus dem I/Q, Uebersteuerung kommt aus dem Signal, Mikrofon-PTT aus dem Stromkopf. Co-Authored-By: Claude Opus 5 --- .../2026-10-02-sunsdr-verbindungsablauf.md | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md index 19f3141d2..80badd8ef 100644 --- a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md +++ b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md @@ -629,3 +629,48 @@ zwei Ströme, und beim 96-kHz-Strom zusätzlich zwei Pakete je Nummer. 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. From 70796a4cb5822dc65ea77f087151dce2d329993e Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sat, 3 Oct 2026 20:56:01 +0200 Subject: [PATCH 14/58] docs(sunsdr): Vorbehalt eintragen -- Logzeilen konnten verloren gehen (#171) Eine Nebensitzung hat gemessen, dass sich Logzeilen gegenseitig zerschreiben, wenn mehrere Faeden gleichzeitig schreiben: 7 bis 55 % der Zeilen gingen GANZ verloren (behoben in PR #171, dort noch offen). Das betrifft alles, was auf dem Blatt aus Longpaths Logdatei gelesen wurde, und gehoert dazugesagt. Durchgegangen, welcher Befund an Logzeilen haengt: die Zahlen (Raten, Kanaele, Pakete) kommen aus Zaehlern im Treiber, der Verbindungsablauf und der Stopp-Rahmen aus tcpdump -- beides nicht von der Logdatei betroffen. ANFAELLIG war genau ein Schluss: "das Geraet meldet von sich aus nichts", denn fehlende Zeilen haetten wie fehlende Meldungen ausgesehen. Der ist unabhaengig bestaetigt: der Mitschnitt zeigt jedes Paket am Draht, und dort kommt zwischen den Quittungen nichts. Regel daraus: ein Negativ-Schluss ("es kommt nichts") darf nicht allein auf einer Logdatei stehen. Zaehler im Code oder ein Mitschnitt am Draht zaehlen, was wirklich ankam. Co-Authored-By: Claude Opus 5 --- .../2026-10-02-sunsdr-verbindungsablauf.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md index 80badd8ef..a57cb7532 100644 --- a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md +++ b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md @@ -674,3 +674,33 @@ Gerät einen einzigen Messwert liefert. * `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. From 0446ffe4b5e7503368a495e57aedf3c55438bc8d Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sat, 3 Oct 2026 20:59:33 +0200 Subject: [PATCH 15/58] docs(sunsdr): Gegenprobe nachgeholt -- welche Pruefung faengt, welche wacht Hinweis aus einer Nebensitzung: ein Pruefstand KANN widersprechen, wenn man ihn zuerst gegen die alte Fassung laufen laesst. "Gruen mit der Behebung sagt nichts, rot ohne sie sagt alles." Genau das fehlte hier: alle Pruefungen zu den drei Fehlern, die das Geraet heute aufgedeckt hat, wurden NACH der Behebung geschrieben. Nachgeholt, indem der Code jeweils zurueckgebaut und derselbe Pruefstand erneut gefahren wurde: zweiStroemeLandenAufVerschiedenenKanaelen rot (seqLost = 1) wiederholungMitAbstandIstKeinSpaetling rot stromneustartWirdErkanntUndNichtZumDauerzustand rot einzelnerSpaetlingIstKeinNeuanfang gruen in BEIDEN Die ersten drei fangen wirklich. Die vierte ist kein Faenger, sondern ein Waechter -- sie soll in beiden Fassungen gruen sein und schlaegt erst an, wenn die Neuanfang-Erkennung zu frueh greift. Beides ist nuetzlich, aber nicht dasselbe, und wer es nicht prueft, haelt Waechter fuer Faenger. 93/93 nach der Wiederherstellung. Co-Authored-By: Claude Opus 5 --- .../2026-10-02-sunsdr-verbindungsablauf.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md index a57cb7532..54f4ddcd0 100644 --- a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md +++ b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md @@ -704,3 +704,42 @@ Lücke im tcpdump nicht zu sehen gewesen — sie ist es nicht. 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. From f1db105186dd00dfd01a96f8db3bdfcba1527fa0 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sat, 3 Oct 2026 21:07:31 +0200 Subject: [PATCH 16/58] docs(sunsdr): die Rate im BETRIEB umstellen -- am Geraet gemessen 96 kHz war bisher nur ueber LONGPATH_SUNSDR_STROMMODUS gemessen, also nicht ueber den Weg, den die Oberflaeche nimmt (setSampleRateLive -> setSampleRate bei stehender Verbindung). Nachgemessen, zwei Laeufe: 48 kHz 240 Nummern/s, 1200 angenommen, 0 verloren, Kopf nur 0100 96 kHz 400 Nummern/s, 4797 angenommen, 0 verloren, Kopf 0100 -> 0200/0201 Der Stromkopf ist der Beleg: das Geraet hatte einen Strom (byte8=01) und hat nach EINEM Rahmen zwei (byte8=02, byte9 wechselt). Kein Neuverbinden, kein Umgebungsschalter, und kein verlorenes Paket dabei. Offen und als offen notiert: die Kanalzaehler kommen ungleich heraus (724/s gegen 1014/s). Teils Buchfuehrung -- sie laufen ab dem Verbinden, Kanal 0 enthaelt also noch die 48-kHz-Phase. Der Rest ist unerklaert; es ist aber kein Verlust (0 von 4797 im selben Zeitraum). Co-Authored-By: Claude Opus 5 --- .../2026-10-02-sunsdr-verbindungsablauf.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md index 54f4ddcd0..5895ce2c8 100644 --- a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md +++ b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md @@ -743,3 +743,39 @@ 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. From 8884494157e75e76626ff85d6cf6b04bae2d7466 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 07:02:59 +0200 Subject: [PATCH 17/58] fix(sunsdr): der Sitzungs-Reset warf die eingestellte Rate weg An der echten QRP gefunden, nicht am Pruefstand: die Oberflaeche stellt 96 kHz ein, Longpath meldet "Connecting with sampleRate= 96000", inSize= 128 -- und das Geraet streamt weiter mit 48 (Stromkopf 0100, 240 Nummern/s). WDSP lief also auf 96 kHz, waehrend die Daten mit 48 ankamen. Genau der Fall, vor dem die Warnung in setSampleRate selbst steht ("Daten einer Rate in einem Kanal einer anderen", am 2026-09-24 als schlechtes Rauschen gehoert). Ursache: RadioModel::connectToRadio schiebt setSampleRate und setActiveReceiverCount AUSDRUECKLICH VOR dem Verbindungsaufruf in die Warteschlange des Verbindungsfadens -- mit eigener Begruendung an der Stelle. Der Sitzungs-Reset in connectToRadio hat beide danach wieder auf die Vorgabe gesetzt. Mein eigener Kommentar dort behauptete die umgekehrte Reihenfolge ("setSampleRate stellt danach um"); der Code hatte sie nie. Behebung: der Reset nimmt die Umgebungsvorgabe nur noch, wenn LONGPATH_SUNSDR_STROMMODUS tatsaechlich gesetzt ist, und ruehrt die Empfaengerzahl nicht mehr an. Warum es keine Pruefung gefangen hat: die vorhandene setSampleRateStelltDenStromstartRahmenUm ruft setSampleRate NACH connectToRadio -- in der Reihenfolge, die geht, nicht in der, die die Anwendung nimmt. Zwei neue Pruefungen in der echten Reihenfolge, beide zuerst gegen die unbehobene Fassung gefahren und dort ROT: rateVorDemVerbindenUeberlebtDenSitzungsReset rot -> gruen empfaengerzahlVorDemVerbindenUeberlebtDenSitzungsReset rot -> gruen Danach am Geraet nachgemessen: Stromkopf 0200 statt 0100, 960 Nummern/s statt 240, 13,2 Mbit/s herunter. 95/95. Co-Authored-By: Claude Opus 5 --- src/core/SunSdrRadioConnection.cpp | 23 +++++++++++--- src/core/SunSdrRadioConnection.h | 1 + tests/tst_sunsdr_radio_connection.cpp | 44 +++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/core/SunSdrRadioConnection.cpp b/src/core/SunSdrRadioConnection.cpp index 508a5ada8..f5187d1f7 100644 --- a/src/core/SunSdrRadioConnection.cpp +++ b/src/core/SunSdrRadioConnection.cpp @@ -271,10 +271,25 @@ void SunSdrRadioConnection::connectToRadio(const RadioInfo& info) m_lastSeq = 0; m_seqRing.clear(); m_seqOutOfPlace = 0; - // Die Umgebung ist nur die VORBELEGUNG fuer diese Sitzung; setSampleRate - // stellt danach um. - m_stromModus = stromModusAusUmgebung(); - m_aktiveEmpfaenger = 1; + // 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(); + } m_stoppGeschickt = 0; m_iqSeqWndFrames = 0; m_iqSeqWndRepeats = 0; diff --git a/src/core/SunSdrRadioConnection.h b/src/core/SunSdrRadioConnection.h index d2e1484d6..362c01c39 100644 --- a/src/core/SunSdrRadioConnection.h +++ b/src/core/SunSdrRadioConnection.h @@ -1165,6 +1165,7 @@ private slots: 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; } diff --git a/tests/tst_sunsdr_radio_connection.cpp b/tests/tst_sunsdr_radio_connection.cpp index 9041ff170..99b2088a1 100644 --- a/tests/tst_sunsdr_radio_connection.cpp +++ b/tests/tst_sunsdr_radio_connection.cpp @@ -2072,6 +2072,50 @@ private slots: 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); + } + // ── Mikrofon-PTT am Geraet ───────────────────────────────────────── // // Die zweite Empfangsluecke, geschlossen ohne Protokollwissen: der From 46f8f08076af4d53df71851ff721a60fd68e8a67 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 07:13:51 +0200 Subject: [PATCH 18/58] fix(sunsdr): eine Nummer gilt nur als Kopie, wenn sie auch eine ist Der Ring hielt 128 Nummern, verglichen wurde aber nur die NUMMER. Die Inhaltspruefung in processStreamDatagram sah nur die unmittelbar vorige Nummer desselben Kanals an. Kehrte eine Nummer mit Abstand wieder, galt sie ungeprueft als bytegleiche Kopie. Der Ring traegt jetzt ein Merkmal des Inhalts mit. Gleiche Nummer + gleicher Inhalt = Kopie (daran haengt die Achtfachungs-Erkennung). Gleiche Nummer + anderer Inhalt = der Zaehler ist umgelaufen, also ein neuer Block: weder Kopie noch Verlust. EHRLICH DAZU: diese Aenderung bewegt die gemessene Zahl NICHT. Ich hatte sie aus einem Mitschnitt begruendet (0 bytegleiche Kopien auf dem Draht gegen 1,41 gemeldet) -- und der Schluss war falsch. Der Mitschnitt stammt aus einer anderen Betriebsart (609 Pakete/s, streng +1), nicht aus je96. Am Geraet nachgemessen meldet die geraeteeigene Sonde: 480 Folgenummern/s | 1x bei 390, 2x bei 70, 3x bei 20 Nummern Wiederholungen 114, davon GANZ bytegleich 114, verschieden 0 Die Wiederholungen sind also echt. Die Aenderung bleibt trotzdem, weil sie die Groesse belastbar macht: eine wiederkehrende Nummer wird nur noch dann als Kopie gezaehlt, wenn sie eine ist. Ohne das haette ein umlaufender Zaehler dieselbe Zahl erzeugt wie eine echte Verschlechterung, und an dieser Zahl erkennen wir die Achtfachung. Pruefung zuerst gegen die unbehobene Fassung gefahren und dort ROT (gleicheNummerMitAnderemInhaltIstKeineKopie). 96/96. Offen und notiert: bei 96 kHz liegt die Kopienzahl bei 1,5, bei 48 kHz bei 1,0 -- die Blockantwort kommt bei vierfacher Datenmenge nicht mehr ganz nach. Co-Authored-By: Claude Opus 5 --- src/core/SunSdrRadioConnection.cpp | 45 +++++++--- src/core/SunSdrRadioConnection.h | 8 +- src/core/WdspEngine.h | 6 ++ src/models/RadioModel.cpp | 34 +++++++- src/models/RadioModel.h | 16 +++- tests/tst_sunsdr_board_caps.cpp | 115 ++++++++++++++++++++++++++ tests/tst_sunsdr_radio_connection.cpp | 49 +++++++++++ 7 files changed, 257 insertions(+), 16 deletions(-) diff --git a/src/core/SunSdrRadioConnection.cpp b/src/core/SunSdrRadioConnection.cpp index f5187d1f7..6cbde526b 100644 --- a/src/core/SunSdrRadioConnection.cpp +++ b/src/core/SunSdrRadioConnection.cpp @@ -1249,7 +1249,14 @@ void SunSdrRadioConnection::processStreamDatagram(const QByteArray& data, // sie traegt die naechsten Proben derselben Nummer. Sie darf also // weder als Wiederholung noch als Luecke gezaehlt werden. if (!fortsetzung) { - auditStreamSeq(kanal, hdr.seq); + // 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 @@ -2044,7 +2051,8 @@ QString SunSdrRadioConnection::frameInventoryReport() const // (65535 -> 0 ergibt 1, nicht -65535). // --------------------------------------------------------------------------- -void SunSdrRadioConnection::auditStreamSeq(int kanal, quint16 seq) +void SunSdrRadioConnection::auditStreamSeq(int kanal, quint16 seq, + quint64 inhalt) { if (!m_iqSeqWndClock.isValid()) { m_iqSeqWndClock.start(); @@ -2066,15 +2074,21 @@ void SunSdrRadioConnection::auditStreamSeq(int kanal, quint16 seq) // Meldung, damit eine Luecke zuzuordnen ist. Q_UNUSED(kanal); - const auto merken = [this](quint16 n) { - m_seqRing.append(n); + 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; } @@ -2082,8 +2096,19 @@ void SunSdrRadioConnection::auditStreamSeq(int kanal, 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; } @@ -2093,7 +2118,7 @@ void SunSdrRadioConnection::auditStreamSeq(int kanal, 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; @@ -2104,7 +2129,7 @@ void SunSdrRadioConnection::auditStreamSeq(int kanal, quint16 seq) m_iqSeqWndLost += quint64(delta) - 1; ++m_iqSeqWndEvents; m_lastSeq = seq; - merken(seq); + merken(seq, inhalt); ++m_iqSeqWndFrames; m_seqOutOfPlace = 0; @@ -2136,7 +2161,7 @@ void SunSdrRadioConnection::auditStreamSeq(int kanal, quint16 seq) m_seqRing.clear(); m_seqOutOfPlace = 0; m_lastSeq = seq; - merken(seq); + merken(seq, inhalt); ++m_iqSeqWndFrames; } diff --git a/src/core/SunSdrRadioConnection.h b/src/core/SunSdrRadioConnection.h index 362c01c39..7361f1fb3 100644 --- a/src/core/SunSdrRadioConnection.h +++ b/src/core/SunSdrRadioConnection.h @@ -884,7 +884,7 @@ private slots: int m_aktiveEmpfaenger{1}; quint64 m_stoppGeschickt{0}; - void auditStreamSeq(int kanal, quint16 seq); + void auditStreamSeq(int kanal, quint16 seq, quint64 inhalt); // Schliesst das 5-s-Fenster: meldet nach oben und schreibt ins Log. void berichteFolgenummern(); @@ -944,7 +944,11 @@ private slots: bool m_seqSeen{false}; quint16 m_lastSeq{0}; // hoechste gesehene Nummer, ueber alle Stroeme - QList m_seqRing; // die letzten Nummern, fuer Wiederholungen + // 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}; 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/models/RadioModel.cpp b/src/models/RadioModel.cpp index 6f9b08830..fea2d12f1 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 diff --git a/src/models/RadioModel.h b/src/models/RadioModel.h index fbaa5ba99..24ff41373 100644 --- a/src/models/RadioModel.h +++ b/src/models/RadioModel.h @@ -701,9 +701,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/tst_sunsdr_board_caps.cpp b/tests/tst_sunsdr_board_caps.cpp index e03b81148..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" @@ -165,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_radio_connection.cpp b/tests/tst_sunsdr_radio_connection.cpp index 99b2088a1..3a55c2cc5 100644 --- a/tests/tst_sunsdr_radio_connection.cpp +++ b/tests/tst_sunsdr_radio_connection.cpp @@ -2328,6 +2328,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; From ea79f8709b52e26d03b16f73dfb8bc75197b2cf2 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 07:29:10 +0200 Subject: [PATCH 19/58] docs(sunsdr): die Wiederholungen bei 96 kHz -- gemessen, eine Vermutung widerlegt Bei 48 kHz 1,00 Kopien je Nummer, bei 96 kHz 1,2 bis 1,5 -- rund 105 ueberfluessige Pakete je Sekunde. A/B am Geraet (je 8 s, je96): BLOCKANTWORT=0 877 Pakete/s, 401 Wiederholungen/s, 1,84 Kopien Vorgabe (an) 582 Pakete/s, 105 Wiederholungen/s, 1,22 Kopien Die Quittung wirkt also, sie ist nur nicht vollstaendig. Nebenbei: ohne Quittung sind es bei 96 kHz 1,84, nicht die 8,1 von 48 kHz -- das Wiederholverhalten haengt selbst von der Betriebsart ab. WIDERLEGT: den Kopf des Geraets in der Blockantwort spiegeln. Als Schalter eingebaut, gemessen, kein Unterschied (147/117/104/99 gegen 150/127/101/100), Schalter wieder entfernt. byte8/byte9 beschreiben im Hinweg unseren eigenen Strom, nicht den des Geraets. Offen, mit Hinweisen notiert: das Einschwingen (147 -> 99 ueber die ersten Sekunden), der nur 32 Nummern grosse Antwort-Ring (~54 ms bei 96 kHz, Nummern laufen darin um) und die ungeprueften zwei Stille-Stroeme. Kein Richtigkeitsfehler -- 0,02 bis 0,04 % Verlust, der Ton laeuft. Netzlast. Co-Authored-By: Claude Opus 5 --- .../2026-10-02-sunsdr-verbindungsablauf.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md index 5895ce2c8..7e9cd8e2d 100644 --- a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md +++ b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md @@ -779,3 +779,52 @@ 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. From c8761850e5c2da75925f1bbb99f60f155fbeefce Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 08:11:38 +0200 Subject: [PATCH 20/58] fix(audio): ein toter Ausgang galt als offen -- Ton im Programm, nichts am Lautsprecher Vom Betreiber am 2026-10-04 gemeldet: "ton war bei der app da, aber hier beim mac nicht" -- die Handy-App bekam den Ton ueber TCI, aus den Lautsprechern des Macs kam nichts. Im Log steht die Ursache: 07:24:21 PortAudioBus: output via [Core Audio] on "MacBook Air-..." 07:57:45 AudioEngine started ( speakers bus open ) Der Ausgang wurde beim Programmstart geoeffnet und 33 Minuten spaeter beim Verbinden wiederverwendet. AudioEngine::ensureSpeakersOpen() prueft mit isOpen(), und das ist bei PortAudioBus ein ZEIGERVERGLEICH: bool isOpen() const override { return m_stream != nullptr; } Stirbt der Strom darunter -- Geraet gewechselt, Abtastrate umgestellt (auf dem Rechner laufen BoomAudio und DeskFx als virtuelle Treiber; das Standardgeraet stand auf 44 100, der Strom auf 48 000), Ruhezustand --, bleibt der Zeiger stehen. ensureSpeakersOpen kehrt sofort zurueck, Longpath schreibt weiter in einen toten Strom, und nichts faellt auf. IAudioBus bekommt isAlive() mit isOpen() als Vorgabe, damit jeder Bus ohne eigene Auskunft sich verhaelt wie bisher. PortAudioBus beantwortet es mit Pa_IsStreamActive -- nur 1 heisst, es kommt wirklich etwas heraus; 0 und jeder negative Fehlercode (etwa paDeviceUnavailable) gelten als tot. ensureSpeakersOpen raeumt einen toten Bus weg, meldet es und oeffnet darunter neu. Zwei Pruefungen, zuerst gegen die unbehobene Fassung gefahren: lebenderAusgangBleibtStehen gruen in BEIDEN -- Waechter: ein lebender Bus darf nicht bei jedem Start neu geoeffnet werden, sonst klickt es toterAusgangWirdWeggeraeumt ROT ohne die Behebung, gruen mit ihr 4/4. Nicht geprueft und deshalb nicht behauptet: ob das am Geraet des Betreibers wirklich die Ursache war. Das Log zeigt den Aufbau, nicht den Tod des Stroms -- PortAudio meldet ihn nirgends. Genau deshalb gibt es die Meldung jetzt. Co-Authored-By: Claude Opus 5 --- src/core/AudioEngine.cpp | 14 ++++- src/core/AudioEngine.h | 7 +++ src/core/IAudioBus.h | 16 +++++ src/core/audio/PortAudioBus.cpp | 10 +++ src/core/audio/PortAudioBus.h | 1 + tests/CMakeLists.txt | 1 + tests/fakes/FakeAudioBus.h | 8 +++ tests/tst_audio_engine_dead_speakers.cpp | 77 ++++++++++++++++++++++++ 8 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 tests/tst_audio_engine_dead_speakers.cpp diff --git a/src/core/AudioEngine.cpp b/src/core/AudioEngine.cpp index 3fd410c78..000aca71e 100644 --- a/src/core/AudioEngine.cpp +++ b/src/core/AudioEngine.cpp @@ -892,9 +892,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/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/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/tests/CMakeLists.txt b/tests/CMakeLists.txt index aafd6cfee..4d77361d1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1369,6 +1369,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" From 9c48cb5c8f01c6d6316647891785d6df52fcdf0e Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 08:58:04 +0200 Subject: [PATCH 21/58] fix(sunsdr): der Stopp wird nachgeschickt -- sonst haengt das Geraet an einer toten Sitzung Der Vorfall vom 2026-10-04: der Betreiber kam eine halbe Stunde lang nicht mehr an die QRP ("no beacon reply"). Einzeln geprueft und einzeln in Ordnung: Funkgeraet, Netz, Port im Eintrag, TCI-Server, der MANUAL-Schluessel, seine Einstellungsdatei und seine installierte Fassung -- die beiden letzten habe ich mit SEINEN Dateien und SEINEM Binary nachgefahren, beide verbanden. Erst Aus- und Einschalten half. Ursache: die QRP bedient EINEN Client und haelt die Sitzung fest. Ich hatte Pruefinstanzen hart beendet, und in einem Lauf stand im Log WRN: SunSdr: beim Verbindungsende noch unquittiert: 0x02 Kommt der Stopp nicht an, bleibt das Geraet an den Toten gebunden und antwortet auf neue Suchmeldungen nicht mehr. Zwei Aenderungen: 1. disconnect() schickt den Stopp nach, solange er unquittiert bleibt -- drei Versuche a 150 ms, dabei wird wirklich gelesen (der Ereignisschleife laeuft an der Stelle nichts mehr zu). Der Stopp verstellt nichts, er meldet nur ab, also ist mehrfach unschaedlich. Bleibt er nach drei Versuchen offen, sagt das Log, dass nur noch Aus- und Einschalten hilft. 2. Die Fehlermeldung nennt jetzt alle drei Ursachen in der Reihenfolge, in der man sie pruefen soll -- die haengende Sitzung zuerst. Der alte Text ("radio unreachable or discovery blocked") hat heute die halbe Stunde gekostet, weil er genau den Fall nicht nannte, der vorlag. Pruefung zuerst gegen die unbehobene Fassung gefahren und dort ROT (bleibtDerStoppUnquittiertWirdErNachgeschickt: nur 1 Stopp geschickt). trennenSchicktDenStopp erwartet jetzt ">= 1" statt genau 1. 97/97. Co-Authored-By: Claude Opus 5 --- src/core/SunSdrRadioConnection.cpp | 79 ++++++++++++++++++++++++--- src/core/SunSdrRadioConnection.h | 15 +++++ tests/tst_sunsdr_radio_connection.cpp | 36 +++++++++++- 3 files changed, 121 insertions(+), 9 deletions(-) diff --git a/src/core/SunSdrRadioConnection.cpp b/src/core/SunSdrRadioConnection.cpp index 6cbde526b..ed14777f6 100644 --- a/src/core/SunSdrRadioConnection.cpp +++ b/src/core/SunSdrRadioConnection.cpp @@ -479,8 +479,24 @@ 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. Drei " + "Ursachen, in dieser Reihenfolge pruefen: " + "(1) das Geraet haengt noch an einer " + "frueheren Sitzung — es bedient nur einen " + "Client, und nach einem Absturz oder einem " + "harten Beenden hilft nur Aus- und " + "Einschalten; (2) ein anderes Programm ist " + "gerade mit ihm verbunden; (3) es ist " + "nicht erreichbar oder die Suchmeldung " + "wird im Netz geblockt.")); } void SunSdrRadioConnection::disconnect() @@ -513,12 +529,51 @@ void SunSdrRadioConnection::disconnect() // kennen, waere ein Paket ins Nichts. if (m_running && !m_awaitingBeacon && m_profile && m_controlSocket && !m_radioAddr.isNull()) { - 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 */ } + // 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(); @@ -2304,6 +2359,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; } diff --git a/src/core/SunSdrRadioConnection.h b/src/core/SunSdrRadioConnection.h index 7361f1fb3..266db00be 100644 --- a/src/core/SunSdrRadioConnection.h +++ b/src/core/SunSdrRadioConnection.h @@ -1030,6 +1030,20 @@ 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. + static constexpr int kStoppVersuche = 3; + static constexpr qint64 kStoppQuittungFristMs = 150; + QList m_offeneRahmen; quint64 m_quittungenGesehen{0}; quint64 m_rahmenOhneQuittung{0}; @@ -1041,6 +1055,7 @@ private slots: void sendeSteuerrahmen(const QByteArray& frame, const char* grund, bool nachschickbar = true); void pruefeOffeneRahmen(); + bool rahmenNochOffen(quint8 opcode) const; // ── Uebersteuerung aus dem I/Q erkennen ───────────────────────────── // diff --git a/tests/tst_sunsdr_radio_connection.cpp b/tests/tst_sunsdr_radio_connection.cpp index 3a55c2cc5..0fc6da6d9 100644 --- a/tests/tst_sunsdr_radio_connection.cpp +++ b/tests/tst_sunsdr_radio_connection.cpp @@ -1840,7 +1840,41 @@ private slots: QCOMPARE(conn.stoppGeschicktForTest(), quint64(0)); conn.disconnect(); - QCOMPARE(conn.stoppGeschicktForTest(), quint64(1)); + // 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 From 8e54decb521ef38089f89c46bdb440f35ebf11b4 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 09:08:29 +0200 Subject: [PATCH 22/58] feat(sunsdr): Antennenwahl 0x15 verdrahtet -- und standardmaessig stumm setAntennaRouting() war ein leerer Rumpf, obwohl der Rahmenbauer (buildAntennaSelectFrame) seit Langem fertig und belegt ist. Jetzt ist der Weg vollstaendig verdrahtet: trxAnt 1..3 auf A1/A2/A3, Richtung aus routing.tx, einschliesslich der Falle, derentwegen die Byte-Tabelle ueberhaupt existiert -- A3 traegt je Richtung ein ANDERES Byte (RX 0x03, TX 0x02). Es geht aber NICHTS hinaus. Die Auswahlbytes stammen aus ArtemisSDR, also von der DX/PRO, und am 2026-10-03 hat sich am Geraet gezeigt, dass die Opcode-Nummern der QRP andere sind: Vorverstaerker 0x04 statt 0x05, DDC 0x07 statt 0x08, erstes Byte des Rahmens 0x03 statt 0x32. Damit ist 0x15 ein Verdachtsfall, kein Fakt -- und ungepruefte Bytes gehen nicht ungefragt an fremde Hardware. Stattdessen steht im Log, was hinausgegangen WAERE, mit den Bytes: SunSdr: Antennenwahl A2 (Empfang) waere 03 ff 15 00 04 00 ... -- NICHT geschickt. ... scharf mit LONGPATH_SUNSDR_ANTENNE=1 und einem 50-Ohm-Abschluss. Drei Pruefungen: stumm als Vorgabe, scharf schickt, Buchse ausserhalb 1..3 wird nicht geraten. 100/100. Zur Gegenprobe: die scharfe Pruefung habe ich NICHT gegen die alte Fassung gefahren -- der alte Rumpf war buchstaeblich und kann nichts geschickt haben. Das ist am Code ablesbar, nicht erschlossen. Co-Authored-By: Claude Opus 5 --- src/core/SunSdrRadioConnection.cpp | 69 ++++++++++++++++++++++++++- src/core/SunSdrRadioConnection.h | 9 ++++ tests/tst_sunsdr_radio_connection.cpp | 65 +++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 1 deletion(-) diff --git a/src/core/SunSdrRadioConnection.cpp b/src/core/SunSdrRadioConnection.cpp index ed14777f6..de7618cda 100644 --- a/src/core/SunSdrRadioConnection.cpp +++ b/src/core/SunSdrRadioConnection.cpp @@ -1634,7 +1634,74 @@ void SunSdrRadioConnection::onDataWatchdogTick() void SunSdrRadioConnection::setTxFrequency(quint64) {} void SunSdrRadioConnection::setPreamp(bool) {} void SunSdrRadioConnection::setTxDrive(int) {} -void SunSdrRadioConnection::setAntennaRouting(AntennaRouting) {} +// ── Antennenwahl: gebaut, aber standardmaessig STUMM ──────────────────── +// +// Der Rahmenbauer (SunSdrProtocol::buildAntennaSelectFrame) liegt seit +// Langem fertig und ist sauber belegt -- aber seine Auswahlbytes stammen +// aus ArtemisSDR, also von der DX/PRO, und sind an der QRP NIE bestaetigt +// worden. Am 2026-10-03 hat sich gezeigt, dass die Opcode-Nummern der QRP +// nicht die der DX sind (Vorverstaerker 0x04 statt 0x05, DDC 0x07 statt +// 0x08, erstes Byte 0x03 statt 0x32 -- siehe die Tabelle in +// docs/architecture/2026-10-02-sunsdr-paritaet.md, Abschnitt 3a). Damit +// ist auch 0x15 ein Verdachtsfall und kein Fakt. +// +// Deshalb geht hier standardmaessig NICHTS hinaus. Der Weg ist vollstaendig +// verdrahtet und im Log nachvollziehbar; scharf wird er erst mit +// LONGPATH_SUNSDR_ANTENNE=1, und das gehoert an ein Geraet mit +// 50-Ohm-Abschluss, nicht an eine Antenne. +// +// A3 traegt die Falle, derentwegen die Tabelle ueberhaupt existiert: +// dasselbe Buchse, derselbe Opcode, ein ANDERES Byte je Richtung +// (RX 0x03, TX 0x02). +void SunSdrRadioConnection::setAntennaRouting(AntennaRouting routing) +{ + if (!m_profile) { return; } + + // trxAnt ist 1..3 und meint die gemeinsame Buchse. Die QRP hat genau + // drei, also ist die Abbildung eins zu eins -- aber ein Wert + // ausserhalb waere geraten, und geraten wird hier nicht. + SunSdr::AntennaPort buchse; + switch (routing.trxAnt) { + case 1: buchse = SunSdr::AntennaPort::A1; break; + case 2: buchse = SunSdr::AntennaPort::A2; break; + case 3: buchse = SunSdr::AntennaPort::A3; break; + default: + qCWarning(lcSunSdr) << "SunSdr: Antenne" << routing.trxAnt + << "gibt es an diesem Geraet nicht (1..3) --" + " nichts geschickt."; + return; + } + + QByteArray rahmen; + if (!SunSdr::buildAntennaSelectFrame(*m_profile, buchse, routing.tx, + &rahmen)) { + qCWarning(lcSunSdr) + << "SunSdr: fuer diese Buchse/Richtung ist kein Auswahlbyte " + "belegt -- nichts geschickt."; + return; + } + + if (!m_antenneScharf) { + qCInfo(lcSunSdr).noquote() + << QStringLiteral("SunSdr: Antennenwahl A%1 (%2) waere %3 -- " + "NICHT geschickt. Die Auswahlbytes stammen " + "von der DX/PRO und sind an der QRP nicht " + "bestaetigt; scharf mit " + "LONGPATH_SUNSDR_ANTENNE=1 und einem " + "50-Ohm-Abschluss.") + .arg(routing.trxAnt) + .arg(routing.tx ? QStringLiteral("Senden") + : QStringLiteral("Empfang"), + QString::fromLatin1(rahmen.toHex(' '))); + return; + } + + if (!m_running || m_awaitingBeacon || m_radioAddr.isNull()) { return; } + sendeSteuerrahmen(rahmen, "Antennenwahl 0x15"); + qCInfo(lcSunSdr) << "SunSdr: Antennenwahl A" << routing.trxAnt + << (routing.tx ? "(Senden)" : "(Empfang)") + << "geschickt -- VERSUCH, nicht bestaetigt."; +} void SunSdrRadioConnection::sendTxIq(const float*, int) {} void SunSdrRadioConnection::setTrxRelay(bool) {} void SunSdrRadioConnection::setMicBoost(bool) {} diff --git a/src/core/SunSdrRadioConnection.h b/src/core/SunSdrRadioConnection.h index 266db00be..b65b76bdb 100644 --- a/src/core/SunSdrRadioConnection.h +++ b/src/core/SunSdrRadioConnection.h @@ -1041,6 +1041,12 @@ private slots: // Drei Versuche a 150 ms kosten im schlimmsten Fall 450 ms beim // Trennen und sind unschaedlich: der Stopp verstellt nichts, er // meldet nur ab. + // Die Antennenwahl geht nur hinaus, wenn der Betreiber es + // ausdruecklich will: die Auswahlbytes stammen von der DX/PRO und + // sind an der QRP nicht bestaetigt (2026-10-04). + bool m_antenneScharf{ + qEnvironmentVariableIntValue("LONGPATH_SUNSDR_ANTENNE") == 1}; + static constexpr int kStoppVersuche = 3; static constexpr qint64 kStoppQuittungFristMs = 150; @@ -1192,6 +1198,9 @@ private slots: { 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 Antennenwahl scharf schalten, ohne + /// eine Umgebungsvariable zu setzen. + void setAntenneScharfForTest(bool scharf) { m_antenneScharf = scharf; } }; } // namespace Longpath diff --git a/tests/tst_sunsdr_radio_connection.cpp b/tests/tst_sunsdr_radio_connection.cpp index 0fc6da6d9..cadc4ebcf 100644 --- a/tests/tst_sunsdr_radio_connection.cpp +++ b/tests/tst_sunsdr_radio_connection.cpp @@ -2150,6 +2150,71 @@ private slots: QCOMPARE(conn.aktiveEmpfaengerForTest(), 2); } + // ── Antennenwahl 0x15 ────────────────────────────────────────────── + // + // Gebaut, aber standardmaessig stumm: die Auswahlbytes stammen aus + // ArtemisSDR, also von der DX/PRO, und am 2026-10-03 hat sich gezeigt, + // dass die Opcode-Nummern der QRP andere sind (0x04 statt 0x05 beim + // Vorverstaerker, 0x07 statt 0x08 bei der DDC). Ungepruefte Bytes + // gehen nicht ungefragt an fremde Hardware. + void antennenwahlIstStandardmaessigStumm() + { + SunSdrRadioConnection conn; + conn.setFixedPortBindingEnabledForTest(false); + conn.init(); + conn.setDiscoveryBroadcastEnabledForTest(false); + conn.connectToRadio(someQrpInfo()); + handshake(conn); + const int vorher = conn.offeneRahmenForTest(); + + AntennaRouting r; + r.trxAnt = 2; + r.tx = false; + conn.setAntennaRouting(r); + + QCOMPARE(conn.offeneRahmenForTest(), vorher); + } + + // Scharf geschaltet geht der Rahmen hinaus -- vor der Verdrahtung war + // setAntennaRouting ein leerer Rumpf und hier passierte nie etwas. + void antennenwahlScharfSchicktDenRahmen() + { + SunSdrRadioConnection conn; + conn.setFixedPortBindingEnabledForTest(false); + conn.init(); + conn.setDiscoveryBroadcastEnabledForTest(false); + conn.connectToRadio(someQrpInfo()); + handshake(conn); + conn.setAntenneScharfForTest(true); + const int vorher = conn.offeneRahmenForTest(); + + AntennaRouting r; + r.trxAnt = 3; + r.tx = false; + conn.setAntennaRouting(r); + + QCOMPARE(conn.offeneRahmenForTest(), vorher + 1); + } + + // Eine Buchse, die es nicht gibt, wird nicht geraten. + void antennenwahlAusserhalbDerDreiBuchsenSchicktNichts() + { + SunSdrRadioConnection conn; + conn.setFixedPortBindingEnabledForTest(false); + conn.init(); + conn.setDiscoveryBroadcastEnabledForTest(false); + conn.connectToRadio(someQrpInfo()); + handshake(conn); + conn.setAntenneScharfForTest(true); + const int vorher = conn.offeneRahmenForTest(); + + AntennaRouting r; + r.trxAnt = 7; // gibt es nicht + conn.setAntennaRouting(r); + + QCOMPARE(conn.offeneRahmenForTest(), vorher); + } + // ── Mikrofon-PTT am Geraet ───────────────────────────────────────── // // Die zweite Empfangsluecke, geschlossen ohne Protokollwissen: der From a76b046e5d9c00632462709d8f6a5b5b0d5c201b Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 09:09:26 +0200 Subject: [PATCH 23/58] docs(sunsdr): Lueckenliste auf den Stand vom 2026-10-04 Der erste Tag, an dem Longpath selbst gegen die QRP lief statt nur Pruefstand und Messlauf. Drei Fehler, die kein Pruefstand gezeigt hat -- einer davon an einer Stelle, die gestern als erledigt galt: 88844941 die eingestellte Rate wurde beim Verbinden weggeworfen c8761850 ein toter Lautsprecher-Ausgang galt als offen 9c48cb5c der Abmelde-Rahmen wurde nicht nachgeschickt Dazu an der Liste nachgezogen: der veraltete 312-500-Hz-Kommentar ist beim Umbau verschwunden, und setAntennaRouting ist verdrahtet, aber stumm (8e54decb). Im Empfang fehlt unveraendert genau eines: micPttFromRadio, und das braucht den Mitschnitt. Zwei offene Beobachtungen festgehalten, beide ohne Antenne messbar: die Wiederholungen bei 96 kHz (1,2 statt 1,0) und die Lautstaerke -- bei letzterer ist nachgerechnet, dass die Probenumrechnung stimmt und keine geraeteeigene Pegel-Eichung fehlt; der Vergleich gegen die Anvelina ist auf Wunsch des Betreibers vertagt. Co-Authored-By: Claude Opus 5 --- .../2026-10-02-sunsdr-paritaet.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/docs/architecture/2026-10-02-sunsdr-paritaet.md b/docs/architecture/2026-10-02-sunsdr-paritaet.md index cabeec6d5..751fe7e48 100644 --- a/docs/architecture/2026-10-02-sunsdr-paritaet.md +++ b/docs/architecture/2026-10-02-sunsdr-paritaet.md @@ -271,3 +271,63 @@ nicht, und geraten wird sie nicht (Abschnitt 3a). 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 + +Unverändert **eines**: `micPttFromRadio`. Dafür braucht es den +Mitschnitt, weil die QRP den Zustand nirgends von sich aus meldet. + +## 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. From a8f11ad6e011e3a146f1d5a91b003ac5eadc12d2 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 09:23:53 +0200 Subject: [PATCH 24/58] docs(sunsdr): der Empfang ist fertig -- und ein Pruefplan fuer den Abschluss Zwei Dinge. ERSTENS eine Korrektur an mir selbst: ich hatte heute in die Lueckenliste geschrieben, im Empfang fehle "unveraendert eines: micPttFromRadio". Das war falsch -- ich hatte eine aeltere Aussage abgeschrieben, ohne den Code zu pruefen. Das Mikrofon-PTT ist seit 1e4eb060 gebaut und wird OHNE Protokollwissen aus dem Stromkopf abgeleitet (0xFD sendet, 0xFE empfaengt), mit Flankenerkennung und Abgrenzung gegen eigenes MOX, geprueft in sendezustandAmGeraetMeldetPtt. Damit ist der Empfang funktional vollstaendig. Offen ist nur die Bestaetigung am Geraet, und die braucht keinen Mitschnitt, sondern einen 50-Ohm-Abschluss: wer die Mikrofontaste drueckt, bringt das Geraet in den Sendezustand. ZWEITENS der Pruefplan dafuer. Alles Restliche haengt an diesem einen Abschluss, also soll es in EINEM Durchgang gehen: A Mikrofon-PTT bestaetigen (genau zwei Logzeilen je Tastendruck) B die vermuteten Sende-Opcodes EINZELN pruefen, gegen die Quittung des Geraets (15-50 ms, am 2026-10-03 gemessen) -- die Antennenwahl zuerst, weil sie als einzige nichts erzeugt C erst dann tasten, eine Sekunde Der Grund fuer die Einzelpruefung steht dort auch: die QRP benutzt nachweislich ANDERE Opcodes als die DX/PRO, aus der alle unbestaetigten Zahlen stammen (0x04 statt 0x05, 0x07 statt 0x08, 0x03 statt 0x32). Jede Sende-Zahl ist damit Verdacht, nicht Fakt. Und der Hinweis, der sonst im Betrieb ueberrascht: setTxDrive ist ein leerer Rumpf. Longpath kann die Leistung nicht stellen -- es gilt, was zuletzt in ExpertSDR2 stand. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 1 + .../2026-10-02-sunsdr-paritaet.md | 22 ++- .../development/sunsdr-abschluss-pruefplan.md | 128 ++++++++++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 docs/development/sunsdr-abschluss-pruefplan.md diff --git a/CLAUDE.md b/CLAUDE.md index 4c43d447f..c3b1a511d 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 751fe7e48..3e626a146 100644 --- a/docs/architecture/2026-10-02-sunsdr-paritaet.md +++ b/docs/architecture/2026-10-02-sunsdr-paritaet.md @@ -316,8 +316,26 @@ einer davon stand genau an der Stelle, die gestern als „erledigt" galt. ## Was im Empfang jetzt noch fehlt -Unverändert **eines**: `micPttFromRadio`. Dafür braucht es den -Mitschnitt, weil die QRP den Zustand nirgends von sich aus meldet. +**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 diff --git a/docs/development/sunsdr-abschluss-pruefplan.md b/docs/development/sunsdr-abschluss-pruefplan.md new file mode 100644 index 000000000..0754c68ad --- /dev/null +++ b/docs/development/sunsdr-abschluss-pruefplan.md @@ -0,0 +1,128 @@ +# 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` | + +Jede Zahl, die wir für das Senden benutzen, stammt aus derselben Quelle +wie die rechte Spalte. Sie ist also **Verdacht, nicht Fakt**: + +| Zweck | Vermuteter Opcode | Gebaut? | +| --- | --- | --- | +| MOX / PTT | `0x06` | Rahmenbauer fertig, nicht verdrahtet | +| Leistung | `0x17` | Rahmenbauer fertig, `setTxDrive` leer | +| PA freigeben | `0x24` | Rahmenbauer fertig, nicht verdrahtet | +| Antennenwahl | `0x15` | 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. + +--- + +## 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). From 311676eb402d25c5a95889afaa109f45b17d5a0b Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 09:27:11 +0200 Subject: [PATCH 25/58] fix(sunsdr): der Leistungsregler sagt jetzt, dass er nichts tut setTxDrive war ein leerer Rumpf -- und bedient ein Bedienelement. Der Betreiber dreht die Leistung herunter, sieht die Zahl sinken, und am Geraet aendert sich nichts. Beim Senden ist das kein Schoenheitsfehler, sondern eine Falle. Jetzt meldet Longpath einmal je Sitzung (nicht bei jedem Reglerschritt), dass der Wert NICHT hinausgegangen ist und dass gilt, was zuletzt in ExpertSDR2 eingestellt war. Warum nicht gleich verdrahten: der Rahmenbauer fuer 0x17 liegt fertig, aber die Nummer stammt von der DX/PRO, und die QRP benutzt nachweislich andere (0x04 statt 0x05 beim Vorverstaerker, 0x07 statt 0x08 bei der DDC, 0x03 statt 0x32 als erstes Byte). Verdrahtet wird sie, wenn sie am Geraet quittiert wurde -- der Weg dahin steht in docs/development/sunsdr-abschluss-pruefplan.md. 100/100. Co-Authored-By: Claude Opus 5 --- src/core/SunSdrRadioConnection.cpp | 28 +++++++++++++++++++++++++++- src/core/SunSdrRadioConnection.h | 3 +++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/core/SunSdrRadioConnection.cpp b/src/core/SunSdrRadioConnection.cpp index de7618cda..5bd4fda2c 100644 --- a/src/core/SunSdrRadioConnection.cpp +++ b/src/core/SunSdrRadioConnection.cpp @@ -1633,7 +1633,33 @@ void SunSdrRadioConnection::onDataWatchdogTick() void SunSdrRadioConnection::setTxFrequency(quint64) {} void SunSdrRadioConnection::setPreamp(bool) {} -void SunSdrRadioConnection::setTxDrive(int) {} + +// 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: gebaut, aber standardmaessig STUMM ──────────────────── // // Der Rahmenbauer (SunSdrProtocol::buildAntennaSelectFrame) liegt seit diff --git a/src/core/SunSdrRadioConnection.h b/src/core/SunSdrRadioConnection.h index b65b76bdb..444a55427 100644 --- a/src/core/SunSdrRadioConnection.h +++ b/src/core/SunSdrRadioConnection.h @@ -1044,6 +1044,9 @@ private slots: // Die Antennenwahl geht nur hinaus, wenn der Betreiber es // ausdruecklich will: die Auswahlbytes stammen von der DX/PRO und // sind an der QRP nicht bestaetigt (2026-10-04). + // Einmal je Sitzung melden, dass die Leistung nicht gestellt wird. + bool m_txDriveGemeldet{false}; + bool m_antenneScharf{ qEnvironmentVariableIntValue("LONGPATH_SUNSDR_ANTENNE") == 1}; From db9cf495f61d1594ed8007866657edf7762a209c Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 09:41:14 +0200 Subject: [PATCH 26/58] feat(sunsdr): der zweite Empfaenger -- er war nie stumm, Longpath hat ihn weggeworfen Der Betreiber hat heute richtiggestellt: "es waren immer beide rx und rx2". In ExpertSDR2 liefen also immer BEIDE Empfaenger -- und damit war der zweite Datenstrom, den Longpath bei zwei Stroemen bekommt, nie ein Raetsel, sondern schlicht RX2. Aus seinem Mitschnitt nachgerechnet, Kanal gegen Kanal: Kanal 0 -130,0 dBFS Q ungleich null 21,2 % Kanal 1 -127,9 dBFS Q ungleich null 31,5 % Kanal 1 traegt echtes I/Q, sogar etwas KRAEFTIGER als Kanal 0. Die Lueckenliste nannte ihn "wird angenommen, bleibt aber stumm" -- das war falsch, und zwar seit dem 2026-10-03. Zwei Aenderungen: 1. BoardCapabilities: maxReceivers 1 -> 2, maxSlices 1 -> 2. Bis dahin verwarf processStreamDatagram jeden Kanal >= m_aktiveEmpfaenger, und das war immer 1. 2. Der Stromstart-Modus haengt jetzt an BEIDEM -- Empfaengerzahl UND Rate -- statt nur an der Rate: 1 Empfaenger, 48 kHz ein Strom 2 Empfaenger, 48 kHz zwei Stroeme, je 48 1 oder 2, 96 kHz zwei Stroeme, je 96 Ohne das konnte ein zweiter Empfaenger bei 48 kHz gar nie Daten bekommen: der Rahmen haette weiter einen Strom angefordert. Dabei ist ein toter Zweig in setSampleRate weggefallen, den mein eigener Zwischenstand erzeugt hatte. Zwei Pruefungen, 102/102. Live am Geraet noch nicht bestaetigt -- das steht als naechstes an und wird angekuendigt. Co-Authored-By: Claude Opus 5 --- src/core/BoardCapabilities.cpp | 11 ++- src/core/SunSdrRadioConnection.cpp | 120 +++++++++++++++++++------- src/core/SunSdrRadioConnection.h | 5 ++ tests/tst_sunsdr_radio_connection.cpp | 54 ++++++++++++ 4 files changed, 159 insertions(+), 31 deletions(-) diff --git a/src/core/BoardCapabilities.cpp b/src/core/BoardCapabilities.cpp index 1afa55666..0ff82378b 100644 --- a/src/core/BoardCapabilities.cpp +++ b/src/core/BoardCapabilities.cpp @@ -1254,8 +1254,15 @@ const BoardCapabilities kSunSdr2Qrp = { // Revisit if/when a second-receiver capability is confirmed on the // bench. .adcCount = 1, - .maxReceivers = 1, - .maxSlices = 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, .userDdcCount = 1, .widebandAdcs = 0, // no wideband/panadapter-bypass stream documented // 48 000 Hz — am Geraet gemessen (2026-09-23/24), nicht uebernommen. diff --git a/src/core/SunSdrRadioConnection.cpp b/src/core/SunSdrRadioConnection.cpp index 5bd4fda2c..be2fb9dcb 100644 --- a/src/core/SunSdrRadioConnection.cpp +++ b/src/core/SunSdrRadioConnection.cpp @@ -14,6 +14,8 @@ #include "SunSdrRadioConnection.h" +#include "AppSettings.h" + #include #include #include @@ -231,7 +233,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; @@ -1027,8 +1068,46 @@ void SunSdrRadioConnection::setActiveReceiverCount(int count) const int neu = qBound(1, count, kMaxKanaele); if (neu == m_aktiveEmpfaenger) { return; } m_aktiveEmpfaenger = neu; - qCInfo(lcSunSdr) << "SunSdr: aktive Empfaenger ->" << m_aktiveEmpfaenger - << "(Kanaele darueber werden verworfen)"; + 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) @@ -1042,40 +1121,23 @@ void SunSdrRadioConnection::setSampleRate(int sampleRate) // 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. - SunSdr::StromModus modus; - if (sampleRate == 48000) { - modus = SunSdr::StromModus::EinStrom48; - } else if (sampleRate == 96000) { - // Zwei Stroeme je 96 kHz; der erste geht an den Empfaenger, der - // zweite wird verworfen, solange es keinen zweiten gibt (siehe - // processStreamDatagram). - modus = SunSdr::StromModus::ZweiStroemeJe96; - } else { + if (sampleRate != 48000 && sampleRate != 96000) { qCWarning(lcSunSdr) << "SunSdr: setSampleRate(" << sampleRate << ") -- fuer diese Rate ist kein Stromstart-Rahmen belegt. Es " - "bleibt bei" << (m_stromModus == SunSdr::StromModus::EinStrom48 - ? 48000 : 96000) + "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; - if (modus == m_stromModus) { - return; - } - m_stromModus = modus; - qCInfo(lcSunSdr) << "SunSdr: Abtastrate ->" << sampleRate - << "Hz (Stromstart-Rahmen wird umgestellt)"; - - // Steht die Verbindung schon, geht der Rahmen jetzt hinaus -- das - // Geraet startet den Strom dann neu und faengt die Folgenummern bei - // null an (am 2026-10-03 gemessen; auditStreamSeq erkennt das als - // Neuanfang). - if (m_running && !m_awaitingBeacon && m_profile) { - sendeSteuerrahmen(SunSdr::buildStromStartFrame(*m_profile, m_stromModus), - "Stromstart 0x01 (Rate umgestellt)"); - } + // 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(); } diff --git a/src/core/SunSdrRadioConnection.h b/src/core/SunSdrRadioConnection.h index 444a55427..decce2522 100644 --- a/src/core/SunSdrRadioConnection.h +++ b/src/core/SunSdrRadioConnection.h @@ -882,6 +882,10 @@ private slots: 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}; quint64 m_stoppGeschickt{0}; void auditStreamSeq(int kanal, quint16 seq, quint64 inhalt); @@ -1065,6 +1069,7 @@ private slots: bool nachschickbar = true); void pruefeOffeneRahmen(); bool rahmenNochOffen(quint8 opcode) const; + void stromModusNachziehen(); // ── Uebersteuerung aus dem I/Q erkennen ───────────────────────────── // diff --git a/tests/tst_sunsdr_radio_connection.cpp b/tests/tst_sunsdr_radio_connection.cpp index cadc4ebcf..6756a80e5 100644 --- a/tests/tst_sunsdr_radio_connection.cpp +++ b/tests/tst_sunsdr_radio_connection.cpp @@ -2215,6 +2215,60 @@ private slots: QCOMPARE(conn.offeneRahmenForTest(), vorher); } + // ── 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 + } + // ── Mikrofon-PTT am Geraet ───────────────────────────────────────── // // Die zweite Empfangsluecke, geschlossen ohne Protokollwissen: der From b33072d98fadaadb311dfa47a8454042b6b2099f Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 09:49:15 +0200 Subject: [PATCH 27/58] test(sunsdr): zwei Empfaenger am echten Geraet bestaetigt Schalter LONGPATH_SUNSDR_EMPFAENGER im Messlauf, und damit der Beleg, der db9cf495 noch fehlte. Am 2026-10-04 an der QRP gemessen: ein Empfaenger zwei Empfaenger Folgenummern 240/s 480/s nach oben 250/s 499/s Kanal 0 300/s, 0 verworfen 347/s, 0 verworfen Kanal 1 -- 297/s, 0 verworfen Verlust -- 0,00 % Im Log die ganze Kette: aktive Empfaenger -> 2, Stromstart-Rahmen -> zwei Stroeme je 48 kHz, und das Geraet startet den Strom neu (Folgenummern fangen neu an, 368 -> 2 -- die Neuanfang-Erkennung aus der Nacht greift dabei und meldet keinen Verlust). Beide Kanaele kommen oben an, nichts wird mehr verworfen. Der zweite Empfaenger der QRP ist damit in Betrieb -- er war nie stumm, Longpath hat ihn nur weggeworfen. Co-Authored-By: Claude Opus 5 --- tests/tst_sunsdr_messlauf.cpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/tst_sunsdr_messlauf.cpp b/tests/tst_sunsdr_messlauf.cpp index 74154e32e..f31166b10 100644 --- a/tests/tst_sunsdr_messlauf.cpp +++ b/tests/tst_sunsdr_messlauf.cpp @@ -218,6 +218,19 @@ private slots: // 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); From c87353866e0d43b96c0eee5ee02b39010c3334c2 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 10:12:23 +0200 Subject: [PATCH 28/58] fix(sunsdr): Pegelabgleich +20 -> +40 dB, am Geraet vom Betreiber eingestellt Der Betreiber hat den nativen Empfang mehrfach als zu leise gemeldet -- "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. Damit war belegt, dass es nicht an der fehlenden Antenne liegt. Ursache: die +20,0 dB im QRP-Profil wurden am 2026-09-25 ueber den TCI-Weg gemessen (rx_sensors). Der native Treiber ist ein anderer Weg mit anderer Skalierung, und dort reichen sie bei Weitem nicht. Statt eine Zahl zu RATEN wurde der Abgleich einstellbar gemacht (LONGPATH_SUNSDR_PEGEL, dazu der Schluessel SunSdrRxLevelTrimDb). Der Betreiber hat 40 gefahren und bestaetigt: "die lautstaerke passt". Im Log seines Laufs steht "SunSdr: Pegelabgleich 40.0 dB (eingestellt)". Seine Anforderung dazu, woertlich: "rauschen muss immer zu hoeren sein". Die vorhandene Pruefung qrpSamplesAreRaisedByTheMeasuredTwentyDb hat die alte Zahl festgehalten und ist beim Umstellen ROT geworden -- genau ihre Aufgabe. Sie heisst jetzt ...FortyDb und traegt die neue Messung samt Begruendung. Dazu eine zweite Pruefung in tst_sunsdr_protocol, die die Zahl im Profil festhaelt (DX und PRO bleiben bei 0 -- nie an dieser Hardware gemessen). AUSSERDEM ZURUECKGENOMMEN: die Antennenwahl von 8e54decb. Beim Bauen lief ich in einen Waechter, den dieses Projekt genau dafuer hat -- tst_sunsdr_protocol prueft, dass die DX-staemmigen Sende-Rahmenbauer KEINE Aufrufstelle haben: buildAntennaSelectFrame() traegt eine DX-Opcode-Nummer, die fuer die QRP nicht bestaetigt ist. Erst bestaetigen, dann verdrahten. Der Waechter hat recht, und ein Schalter, der standardmaessig aus ist, hebt ihn nicht auf: verdrahtet ist verdrahtet. setAntennaRouting ist wieder ein leerer Rumpf, jetzt mit der Begruendung und dem Verweis auf den Pruefplan, wo 0x15 der erste zu bestaetigende Befehl ist. 99/100 bzw. 36/36. Co-Authored-By: Claude Opus 5 --- src/core/SunSdrRadioConnection.cpp | 85 ++++++------------------- src/core/SunSdrRadioConnection.h | 15 +---- src/core/sunsdr/SunSdrProtocol.h | 35 ++++++++--- tests/tst_sunsdr_protocol.cpp | 14 +++++ tests/tst_sunsdr_radio_connection.cpp | 91 ++++++--------------------- 5 files changed, 83 insertions(+), 157 deletions(-) diff --git a/src/core/SunSdrRadioConnection.cpp b/src/core/SunSdrRadioConnection.cpp index be2fb9dcb..f8d446fc0 100644 --- a/src/core/SunSdrRadioConnection.cpp +++ b/src/core/SunSdrRadioConnection.cpp @@ -1722,74 +1722,29 @@ void SunSdrRadioConnection::setTxDrive(int prozent) .arg(prozent); } } -// ── Antennenwahl: gebaut, aber standardmaessig STUMM ──────────────────── +// ── Antennenwahl: bewusst NICHT verdrahtet ───────────────────────────── // -// Der Rahmenbauer (SunSdrProtocol::buildAntennaSelectFrame) liegt seit -// Langem fertig und ist sauber belegt -- aber seine Auswahlbytes stammen -// aus ArtemisSDR, also von der DX/PRO, und sind an der QRP NIE bestaetigt -// worden. Am 2026-10-03 hat sich gezeigt, dass die Opcode-Nummern der QRP -// nicht die der DX sind (Vorverstaerker 0x04 statt 0x05, DDC 0x07 statt -// 0x08, erstes Byte 0x03 statt 0x32 -- siehe die Tabelle in -// docs/architecture/2026-10-02-sunsdr-paritaet.md, Abschnitt 3a). Damit -// ist auch 0x15 ein Verdachtsfall und kein Fakt. +// 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 // -// Deshalb geht hier standardmaessig NICHTS hinaus. Der Weg ist vollstaendig -// verdrahtet und im Log nachvollziehbar; scharf wird er erst mit -// LONGPATH_SUNSDR_ANTENNE=1, und das gehoert an ein Geraet mit -// 50-Ohm-Abschluss, nicht an eine Antenne. +// 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. // -// A3 traegt die Falle, derentwegen die Tabelle ueberhaupt existiert: -// dasselbe Buchse, derselbe Opcode, ein ANDERES Byte je Richtung -// (RX 0x03, TX 0x02). -void SunSdrRadioConnection::setAntennaRouting(AntennaRouting routing) -{ - if (!m_profile) { return; } - - // trxAnt ist 1..3 und meint die gemeinsame Buchse. Die QRP hat genau - // drei, also ist die Abbildung eins zu eins -- aber ein Wert - // ausserhalb waere geraten, und geraten wird hier nicht. - SunSdr::AntennaPort buchse; - switch (routing.trxAnt) { - case 1: buchse = SunSdr::AntennaPort::A1; break; - case 2: buchse = SunSdr::AntennaPort::A2; break; - case 3: buchse = SunSdr::AntennaPort::A3; break; - default: - qCWarning(lcSunSdr) << "SunSdr: Antenne" << routing.trxAnt - << "gibt es an diesem Geraet nicht (1..3) --" - " nichts geschickt."; - return; - } - - QByteArray rahmen; - if (!SunSdr::buildAntennaSelectFrame(*m_profile, buchse, routing.tx, - &rahmen)) { - qCWarning(lcSunSdr) - << "SunSdr: fuer diese Buchse/Richtung ist kein Auswahlbyte " - "belegt -- nichts geschickt."; - return; - } - - if (!m_antenneScharf) { - qCInfo(lcSunSdr).noquote() - << QStringLiteral("SunSdr: Antennenwahl A%1 (%2) waere %3 -- " - "NICHT geschickt. Die Auswahlbytes stammen " - "von der DX/PRO und sind an der QRP nicht " - "bestaetigt; scharf mit " - "LONGPATH_SUNSDR_ANTENNE=1 und einem " - "50-Ohm-Abschluss.") - .arg(routing.trxAnt) - .arg(routing.tx ? QStringLiteral("Senden") - : QStringLiteral("Empfang"), - QString::fromLatin1(rahmen.toHex(' '))); - return; - } - - if (!m_running || m_awaitingBeacon || m_radioAddr.isNull()) { return; } - sendeSteuerrahmen(rahmen, "Antennenwahl 0x15"); - qCInfo(lcSunSdr) << "SunSdr: Antennenwahl A" << routing.trxAnt - << (routing.tx ? "(Senden)" : "(Empfang)") - << "geschickt -- VERSUCH, nicht bestaetigt."; -} +// 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. +void SunSdrRadioConnection::setAntennaRouting(AntennaRouting) {} void SunSdrRadioConnection::sendTxIq(const float*, int) {} void SunSdrRadioConnection::setTrxRelay(bool) {} void SunSdrRadioConnection::setMicBoost(bool) {} diff --git a/src/core/SunSdrRadioConnection.h b/src/core/SunSdrRadioConnection.h index decce2522..ff0360b8e 100644 --- a/src/core/SunSdrRadioConnection.h +++ b/src/core/SunSdrRadioConnection.h @@ -886,6 +886,9 @@ private slots: // 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}; quint64 m_stoppGeschickt{0}; void auditStreamSeq(int kanal, quint16 seq, quint64 inhalt); @@ -1045,15 +1048,6 @@ private slots: // Drei Versuche a 150 ms kosten im schlimmsten Fall 450 ms beim // Trennen und sind unschaedlich: der Stopp verstellt nichts, er // meldet nur ab. - // Die Antennenwahl geht nur hinaus, wenn der Betreiber es - // ausdruecklich will: die Auswahlbytes stammen von der DX/PRO und - // sind an der QRP nicht bestaetigt (2026-10-04). - // Einmal je Sitzung melden, dass die Leistung nicht gestellt wird. - bool m_txDriveGemeldet{false}; - - bool m_antenneScharf{ - qEnvironmentVariableIntValue("LONGPATH_SUNSDR_ANTENNE") == 1}; - static constexpr int kStoppVersuche = 3; static constexpr qint64 kStoppQuittungFristMs = 150; @@ -1206,9 +1200,6 @@ private slots: { 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 Antennenwahl scharf schalten, ohne - /// eine Umgebungsvariable zu setzen. - void setAntenneScharfForTest(bool scharf) { m_antenneScharf = scharf; } }; } // namespace Longpath diff --git a/src/core/sunsdr/SunSdrProtocol.h b/src/core/sunsdr/SunSdrProtocol.h index 7fd7ee954..74bdc56f8 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. diff --git a/tests/tst_sunsdr_protocol.cpp b/tests/tst_sunsdr_protocol.cpp index 560de581a..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() diff --git a/tests/tst_sunsdr_radio_connection.cpp b/tests/tst_sunsdr_radio_connection.cpp index 6756a80e5..a6a410458 100644 --- a/tests/tst_sunsdr_radio_connection.cpp +++ b/tests/tst_sunsdr_radio_connection.cpp @@ -733,17 +733,29 @@ 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() + // ── 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(), 10.0f); + QCOMPARE(conn.rxLevelGainForTest(), 100.0f); // 10^(40/20) const QHostAddress radio(QStringLiteral("192.0.2.200")); conn.feedControlDatagramForTest( @@ -764,8 +776,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 @@ -2150,71 +2162,6 @@ private slots: QCOMPARE(conn.aktiveEmpfaengerForTest(), 2); } - // ── Antennenwahl 0x15 ────────────────────────────────────────────── - // - // Gebaut, aber standardmaessig stumm: die Auswahlbytes stammen aus - // ArtemisSDR, also von der DX/PRO, und am 2026-10-03 hat sich gezeigt, - // dass die Opcode-Nummern der QRP andere sind (0x04 statt 0x05 beim - // Vorverstaerker, 0x07 statt 0x08 bei der DDC). Ungepruefte Bytes - // gehen nicht ungefragt an fremde Hardware. - void antennenwahlIstStandardmaessigStumm() - { - SunSdrRadioConnection conn; - conn.setFixedPortBindingEnabledForTest(false); - conn.init(); - conn.setDiscoveryBroadcastEnabledForTest(false); - conn.connectToRadio(someQrpInfo()); - handshake(conn); - const int vorher = conn.offeneRahmenForTest(); - - AntennaRouting r; - r.trxAnt = 2; - r.tx = false; - conn.setAntennaRouting(r); - - QCOMPARE(conn.offeneRahmenForTest(), vorher); - } - - // Scharf geschaltet geht der Rahmen hinaus -- vor der Verdrahtung war - // setAntennaRouting ein leerer Rumpf und hier passierte nie etwas. - void antennenwahlScharfSchicktDenRahmen() - { - SunSdrRadioConnection conn; - conn.setFixedPortBindingEnabledForTest(false); - conn.init(); - conn.setDiscoveryBroadcastEnabledForTest(false); - conn.connectToRadio(someQrpInfo()); - handshake(conn); - conn.setAntenneScharfForTest(true); - const int vorher = conn.offeneRahmenForTest(); - - AntennaRouting r; - r.trxAnt = 3; - r.tx = false; - conn.setAntennaRouting(r); - - QCOMPARE(conn.offeneRahmenForTest(), vorher + 1); - } - - // Eine Buchse, die es nicht gibt, wird nicht geraten. - void antennenwahlAusserhalbDerDreiBuchsenSchicktNichts() - { - SunSdrRadioConnection conn; - conn.setFixedPortBindingEnabledForTest(false); - conn.init(); - conn.setDiscoveryBroadcastEnabledForTest(false); - conn.connectToRadio(someQrpInfo()); - handshake(conn); - conn.setAntenneScharfForTest(true); - const int vorher = conn.offeneRahmenForTest(); - - AntennaRouting r; - r.trxAnt = 7; // gibt es nicht - conn.setAntennaRouting(r); - - QCOMPARE(conn.offeneRahmenForTest(), vorher); - } - // ── Zwei Empfaenger brauchen zwei Stroeme ────────────────────────── // // Am 2026-10-04 belegt: der zweite Strom ist NICHT stumm. Im From 30ef0dae4337518fb0b25731844e7fc5d5c94b63 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 10:21:13 +0200 Subject: [PATCH 29/58] docs(sunsdr): zwei Luecken geschlossen -- beide aus Richtigstellungen des Betreibers Der zweite Empfaenger war nie stumm (db9cf495, b33072d9), und die Lautstaerke war eine ueber den falschen Weg geeichte Zahl (c8735386). Dazu die Ruecknahme der Antennenwahl, weil ein Waechter zu Recht angeschlagen hat. Beide Funde kamen aus Saetzen von Martin, nicht aus eigener Analyse -- und zu beiden Punkten stand in diesem Dokument eine Behauptung, die nie am Geraet geprueft worden war. Das steht jetzt auch so da. Co-Authored-By: Claude Opus 5 --- .../2026-10-02-sunsdr-paritaet.md | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/docs/architecture/2026-10-02-sunsdr-paritaet.md b/docs/architecture/2026-10-02-sunsdr-paritaet.md index 3e626a146..5bc6d2a55 100644 --- a/docs/architecture/2026-10-02-sunsdr-paritaet.md +++ b/docs/architecture/2026-10-02-sunsdr-paritaet.md @@ -349,3 +349,59 @@ das Senden, und das ist ein eigenes Kapitel (Abschnitt 4). 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. From e3e2d1793603212c11d67360d022206d713dfc59 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 10:33:45 +0200 Subject: [PATCH 30/58] docs(sunsdr): Beacon kommt am Rechner an, aber nicht in der App -- offener Befund Der Betreiber kam am Vormittag mehrfach nicht an die QRP ("no beacon reply", Abbruch nach 3 s). Einzeln am Geraet ausgeschlossen: Geraet und Netz (mein Pruefstand verbindet Sekunden spaeter in 51 ms), haengende Sitzung (Stopp war quittiert), Port 1024 im Eintrag, MANUAL-Schluessel, TCI-Server auf 50001, seine Einstellungsdatei und seine installierte Fassung -- die letzten beiden mit SEINEN Dateien und SEINEM Binary nachgefahren, beide verbanden. Ein Socket-Beobachter (10 Abfragen/s) hat im Moment seines Klicks beide Ports gesehen, UDP *:50001 und *:50002, ohne Bindefehler. Der entscheidende Befund kommt aus einem tcpdump waehrend eines Fehlversuchs: VIER Beacon-Antworten des Geraets, korrekt adressiert an 192.168.16.100:50001 -- genau den gebundenen Port. Die App hat keine einzige verarbeitet. 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 waere, ist offen. Zweiter offener Punkt aus demselben Mitschnitt: der Code schickt die Anfrage laut sendDiscoveryBroadcast AUCH direkt an m_radioInfo.address. Im Mitschnitt steht davon nichts -- dieses Paket ging nie hinaus. Werkzeug fuers naechste Mal notiert: Start mit QT_LOGGING_RULES='longpath.sunsdr.debug=true', dann steht jedes empfangene Steuerdatagramm im Protokoll. Co-Authored-By: Claude Opus 5 --- .../2026-10-02-sunsdr-verbindungsablauf.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md index 7e9cd8e2d..f3fa42510 100644 --- a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md +++ b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md @@ -828,3 +828,52 @@ von Anfang an wacklig, und die Messung hat sie erledigt. 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. From 0b5f8869418729c72621ccb5cb890cb15f00c5f5 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 10:35:20 +0200 Subject: [PATCH 31/58] fix(sunsdr): ein Paket, das nicht hinausgeht, sagt es jetzt Am 2026-10-04 stand in einem tcpdump waehrend eines Fehlversuchs KEIN einziges Paket an die Adresse des Geraets -- obwohl sendDiscoveryBroadcast die Anfrage laut Code auch direkt dorthin schickt, nicht nur als Rundruf. Dieses Paket ging nie hinaus, und nichts hat es gesagt: der Rueckgabewert von writeDatagram wurde nirgends geprueft. Nach einer halben Stunde Suche war das der einzige Hinweis, der uebrig blieb -- und er kam aus einem Mitschnitt, nicht aus dem Programm. Jetzt meldet der Treiber drei Dinge, die vorher still blieben: * die direkte Anfrage ging nicht hinaus (mit errorString) * im Geraeteeintrag steht keine Adresse, es geht nur der Rundruf * keine Schnittstelle hatte eine Rundsendeadresse Kein Pruefstand dazu: der Fehlerfall ist ein fehlschlagendes writeDatagram, und das laesst sich ohne Socket-Attrappe nicht herbeifuehren. Das ist eine Meldung, kein Verhalten -- sie aendert nichts, ausser dass man es naechstes Mal im Log sieht statt im tcpdump. 99/99. Co-Authored-By: Claude Opus 5 --- src/core/SunSdrRadioConnection.cpp | 45 +++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/src/core/SunSdrRadioConnection.cpp b/src/core/SunSdrRadioConnection.cpp index f8d446fc0..857adb53a 100644 --- a/src/core/SunSdrRadioConnection.cpp +++ b/src/core/SunSdrRadioConnection.cpp @@ -429,19 +429,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() From 0cf3e353f16cbf2a14d61957bab6614129e5efb9 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 10:41:29 +0200 Subject: [PATCH 32/58] fix(sunsdr): waehrend der Suche verworfene Datagramme nicht mehr verschweigen Beim Warten auf den Beacon wurde jedes nicht passende Datagramm still verworfen. Am 2026-10-04 stand deshalb eine halbe Stunde die Frage im Raum, ob im Socket ueberhaupt etwas ankommt -- erst ein tcpdump zeigte vier Beacon-Antworten des Geraets, die im Programm nie angekommen sind. Mit eingeschaltetem Protokoll steht der Unterschied zwischen "nichts empfangen" und "etwas empfangen, aber verworfen" jetzt in einer Zeile: QT_LOGGING_RULES='longpath.sunsdr.debug=true' Debug-Stufe, kostet im Betrieb nichts. 99/99. Co-Authored-By: Claude Opus 5 --- src/core/SunSdrRadioConnection.cpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/core/SunSdrRadioConnection.cpp b/src/core/SunSdrRadioConnection.cpp index 857adb53a..40c23f8fe 100644 --- a/src/core/SunSdrRadioConnection.cpp +++ b/src/core/SunSdrRadioConnection.cpp @@ -1232,6 +1232,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; } From e91fa7056f9f22320069b5c39bef828823e1fd9b Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 11:26:34 +0200 Subject: [PATCH 33/58] fix(sunsdr): auch der Vorverstaerker-Schalter sagt, dass er nichts tut setPreamp(bool) war ein leerer Rumpf -- derselbe Fehler wie bei setTxDrive: ein Bedienelement, das nichts bewirkt und nichts sagt. Dieses Geraet kennt keinen An/Aus-Schalter fuer den Vorverstaerker. Es hat vier feste Stufen (-20/-10/0/+10 dB, Opcode 0x04, am 2026-09-25 in ExpertSDR2 durchgeschaltet und bestaetigt), gebaut als setPreampModeIndex(). Der OpenHPSDR-Weg passt hier schlicht nicht. Meldung einmal je Sitzung, nicht bei jedem Klick. 99/99. Co-Authored-By: Claude Opus 5 --- src/core/SunSdrRadioConnection.cpp | 22 +++++++++++++++++++++- src/core/SunSdrRadioConnection.h | 1 + 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/core/SunSdrRadioConnection.cpp b/src/core/SunSdrRadioConnection.cpp index 40c23f8fe..ecd15a67b 100644 --- a/src/core/SunSdrRadioConnection.cpp +++ b/src/core/SunSdrRadioConnection.cpp @@ -1744,7 +1744,27 @@ void SunSdrRadioConnection::onDataWatchdogTick() // setAttenuator() out of this block. void SunSdrRadioConnection::setTxFrequency(quint64) {} -void SunSdrRadioConnection::setPreamp(bool) {} +// 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 diff --git a/src/core/SunSdrRadioConnection.h b/src/core/SunSdrRadioConnection.h index ff0360b8e..e559596c2 100644 --- a/src/core/SunSdrRadioConnection.h +++ b/src/core/SunSdrRadioConnection.h @@ -889,6 +889,7 @@ private slots: // 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); From 6f15428a0f375be6a87b1bc1a53e20d4335be0dd Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 16:54:38 +0200 Subject: [PATCH 34/58] docs(sunsdr): die leeren Methoden sagen jetzt, WARUM sie leer sind Dreizehn leere Ruempfe standen als eine Liste da, ohne Unterschied zwischen "gibt es an diesem Geraet nicht" und "noch nicht gebaut". Die Lueckenliste nannte sie pauschal "16 leere Pflichtmethoden" -- eine Zahl, die mehr Arbeit suggeriert als da ist. Jetzt in drei Gruppen, je mit Grund: gibt es nicht setTrxRelay, setUserDigOut, setPuresignalRun, setWatchdogEnabled Senden, offen setAntennaRouting, sendTxIq Mikrofonweg, offen setMicBoost, setLineIn, setMicTipRing, setMicBias, setLineInGain, setMicPTTDisabled, setMicXlr Bei den offenen steht auch, warum nicht einfach gebaut wird: die Opcode-Nummern stammen von der DX/PRO, und die QRP benutzt nachweislich andere. Dazu der Verweis auf den Abschluss-Pruefplan und die Notiz, dass ich am 2026-10-04 genau diese Regel mit setAntennaRouting verletzt habe und zu Recht aufgelaufen bin. Reine Kommentare, kein Verhalten geaendert. 99/99. Co-Authored-By: Claude Opus 5 --- src/core/SunSdrRadioConnection.cpp | 33 ++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/core/SunSdrRadioConnection.cpp b/src/core/SunSdrRadioConnection.cpp index ecd15a67b..64744fae7 100644 --- a/src/core/SunSdrRadioConnection.cpp +++ b/src/core/SunSdrRadioConnection.cpp @@ -1814,19 +1814,44 @@ void SunSdrRadioConnection::setTxDrive(int prozent) // 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) {} // --------------------------------------------------------------------------- From 2c2e1a3db18b3763013bf7cb18c014516638e466 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 16:55:34 +0200 Subject: [PATCH 35/58] docs(sunsdr): die Vergleichstabelle nachgezaehlt statt geschaetzt Die Kopftabelle vom 2026-10-02 (1500 Zeilen, 4 Signale, 16 leere Methoden) war ueberholt und hat die Lage schlechter dargestellt, als sie ist. Am 2026-10-04 nachgezaehlt: ANAN Anvelina QRP Zeilen 4385 3774 2692 Signale 10 12 8 Leere Ruempfe 0 0 14 Dazu der Hinweis, dass die leeren Ruempfe nicht alle Arbeit sind: vier gibt es an diesem Geraet nicht, zwei gehoeren zum Senden, sieben zum Mikrofonweg -- und die beiden letzten Gruppen warten nicht auf Arbeit, sondern auf Bestaetigung der Opcode-Nummern. Die alte Tabelle bleibt stehen, mit dem Vermerk, dass sie ueberholt ist -- sie ist datiert und gehoert zum Verlauf. Co-Authored-By: Claude Opus 5 --- .../2026-10-02-sunsdr-paritaet.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/architecture/2026-10-02-sunsdr-paritaet.md b/docs/architecture/2026-10-02-sunsdr-paritaet.md index 5bc6d2a55..0fa9da182 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. From 4afe7ea62863a5db43609a8e74643fd31ceb02b8 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 16:58:34 +0200 Subject: [PATCH 36/58] fix(sunsdr): die Empfaengerzahl wird beim Verbinden auch fuer die QRP gesetzt Selbst eingebaut und selbst gefunden. RadioModel schob setActiveReceiverCount nur fuer Protokoll 1 hinaus -- fuer die SunSDR also nie. Solange der Treiber beim Verbinden auf 1 zuruecksetzte, fiel das nicht auf. Dieser Reset musste heute weg (88844941), weil er auch die gerade gesetzte Rate wegwarf. Damit blieb im Treiber stehen, was die VORIGE Sitzung gesetzt hatte: eine Sitzung mit zwei Empfaengern vererbte den zweiten Strom an die naechste, und ueber die Oberflaeche liess sich der zweite Empfaenger beim Verbinden ueberhaupt nicht einschalten -- nur nachtraeglich. Die SunSDR gehoert hier auf dieselbe Seite wie P1: ihr setActiveReceiverCount setzt keine DDCs frei, sondern waehlt den Stromstart-Modus (ein Strom oder zwei). Der Grund, aus dem P2 hier ausgenommen ist, trifft auf sie nicht zu. Keine neue Pruefung: der Fehler liegt in RadioModel::connectToRadio und braucht eine echte Verbindung, um sichtbar zu werden. Die Treiberseite ist durch zweiterEmpfaengerStelltAufZweiStroemeUm abgedeckt, und am Geraet ist der Weg am 2026-10-04 gemessen (b33072d9). 99/99 und 9/9. Co-Authored-By: Claude Opus 5 --- src/models/RadioModel.cpp | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/models/RadioModel.cpp b/src/models/RadioModel.cpp index fea2d12f1..0621842b7 100644 --- a/src/models/RadioModel.cpp +++ b/src/models/RadioModel.cpp @@ -8822,7 +8822,21 @@ 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(). - if (info.protocol == ProtocolVersion::Protocol1) { + // + // 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 + || info.protocol == ProtocolVersion::SunSdr) { QMetaObject::invokeMethod(m_connection, [conn = m_connection, activeRxCount]() { conn->setActiveReceiverCount(activeRxCount); }); From 0390d8d9f0bfb98265e0028f92d99898a1bcc38d Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 17:27:03 +0200 Subject: [PATCH 37/58] test(sunsdr): kommt Kanal 1 oben beim ZWEITEN Empfaenger an Luecke im eigenen Beleg geschlossen. Am Geraet gemessen war bisher nur die Treiberseite (b33072d9: beide Kanaele, 0 verworfen) -- ob der Kanal oben auch bei einem zweiten Empfaenger landet, haengt an ReceiverManager::rebuildHardwareMapping und war ungeprueft. Zwei Pruefungen, ohne Funkgeraet: kanalEinsOhneZweitenEmpfaengerLandetNirgends Waechter: ein zweiter Strom, den niemand hoert, darf NICHT in den ersten Empfaenger laufen kanalEinsLandetBeimZweitenEmpfaenger und zwar mit SEINEN Daten, nicht zweimal denselben Beim Schreiben gelernt: createReceiver() legt den Empfaenger nur an, activateReceiver() macht ihn aktiv -- ohne das baut rebuildHardwareMapping gar keine Abbildung, und auch Kanal 0 kommt nirgends an. Die erste Fassung der Pruefung ist genau darueber gestolpert. 4/4. Co-Authored-By: Claude Opus 5 --- tests/CMakeLists.txt | 1 + tests/tst_sunsdr_zweiter_empfaenger_oben.cpp | 71 ++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 tests/tst_sunsdr_zweiter_empfaenger_oben.cpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4d77361d1..b8c15b2fb 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) ───────────── 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" From 77102d1a71eb5785fb126354374321e164f8477b Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 17:28:59 +0200 Subject: [PATCH 38/58] docs+fix(sunsdr): wo die Kette zum zweiten Empfaenger wirklich endet Der Beleg vom Nachmittag war halb: gemessen war die TREIBERSEITE (beide Kanaele an, 0 verworfen). Am Geraet nachgeholt, mit activeRxCount = 2 und EINER Scheibe: 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" Geraet schickt zwei Stroeme, Treiber reicht beide hoch, WDSP hat zwei Kanaele -- und ReceiverManager kennt einen Empfaenger. Das ist kein Fehler, sondern die Bauweise: ein Empfaenger entsteht in RadioModel::syncReceiverToStream, und die wird gerufen, wenn sich eine SCHEIBE an einen Strom bindet. RX2 erscheint mit der zweiten Scheibe -- dafuer steht maxSlices seit heute auf 2. Die Meldung dazu sagte nur . Jetzt sagt sie, dass es oben keinen Empfaenger gibt und woran das liegt. Offen und notiert: mit einer Scheibe und activeRxCount = 2 fordert Longpath zwei Stroeme an und wirft den zweiten eine Ebene hoeher weg -- doppelte Netzlast ohne Gegenwert. Sauberer waere, den Modus an die Zahl der GEBUNDENEN Stroeme zu haengen. Co-Authored-By: Claude Opus 5 --- .../2026-10-02-sunsdr-paritaet.md | 38 +++++++++++++++++++ src/core/ReceiverManager.cpp | 14 +++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/docs/architecture/2026-10-02-sunsdr-paritaet.md b/docs/architecture/2026-10-02-sunsdr-paritaet.md index 0fa9da182..3ed0817c9 100644 --- a/docs/architecture/2026-10-02-sunsdr-paritaet.md +++ b/docs/architecture/2026-10-02-sunsdr-paritaet.md @@ -424,3 +424,41 @@ 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"`. 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; } From aea91972163b26f7747f3d141550bc9980dfd13a Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 17:44:00 +0200 Subject: [PATCH 39/58] docs+fix(sunsdr): der Aussetzer dauert eine Minute -- und kommt vom kill -9 Der Befund von heute Vormittag ("Beacon kommt am Rechner an, aber nicht in der App") hatte eine einfachere Ursache. Gemessen: Pruefstand verbindet und trennt sauber Geraet nach 3 s wieder da App: disconnect, dann SIGTERM sofort App: disconnect, dann kill -9 ~60 s In allen drei Faellen war der Abmelde-Rahmen quittiert. Der Unterschied ist nicht der Stopp, sondern das abrupte Schliessen der Sockets: nach kill -9 schickt das Geraet weiter an einen Port, den der Rechner mit ICMP zurueckweist -- und danach schweigt es eine Weile. Damit ist Martins Vormittag erklaert: jeder Fehlversuch lag in diesem Fenster, weil er sofort wieder geklickt hat. Das Aus- und Einschalten half nicht, weil es den Zustand loeste, sondern weil es Zeit kostete. Gefunden hat es die Protokollzeile aus 0cf3e353, die waehrend der Suche verworfene Datagramme meldet -- vorher war an der Stelle Stille. Dazu in diesem Commit: RadioModel schickt der SunSDR beim Verbinden nicht mehr den GESPEICHERTEN Empfaengerwert, sondern die Zahl der Empfaenger, die es oben wirklich gibt. Mit activeRxCount = 2 und einer Scheibe forderte Longpath sonst zwei Stroeme an und warf den zweiten eine Ebene hoeher weg. Co-Authored-By: Claude Opus 5 --- .../2026-10-02-sunsdr-verbindungsablauf.md | 35 +++++++++++++++++++ src/models/RadioModel.cpp | 27 ++++++++++++-- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md index f3fa42510..38c4f2778 100644 --- a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md +++ b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md @@ -877,3 +877,38 @@ davon **nichts** — dieses Paket ging nie hinaus. 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. diff --git a/src/models/RadioModel.cpp b/src/models/RadioModel.cpp index 0621842b7..d4885ed3d 100644 --- a/src/models/RadioModel.cpp +++ b/src/models/RadioModel.cpp @@ -8835,11 +8835,34 @@ void RadioModel::connectToRadio(const RadioInfo& info) // 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 - || info.protocol == ProtocolVersion::SunSdr) { + 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; From 42aa50f610ecab6d8d40ee22b81f171a387bbf3e Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 17:54:05 +0200 Subject: [PATCH 40/58] fix(sunsdr): die Fehlermeldung nennt jetzt die gemessene Ursache Vorher nannte sie drei Ursachen und als erste "haengt an einer frueheren Sitzung -- hilft nur Aus- und Einschalten". Das war die Vermutung von heute Vormittag, und sie war falsch: gemessen sperrt das Geraet nach einem abrupten Programmende rund EINE MINUTE und kommt dann von selbst zurueck. Nach einem sauberen Beenden sperrt es gar nicht. Die Meldung sagt das jetzt zuerst, mit der Zahl -- und vor allem, was zu tun ist: warten statt gleich wieder klicken. Genau das hat den Vormittag gekostet. 99/99. Co-Authored-By: Claude Opus 5 --- src/core/SunSdrRadioConnection.cpp | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/core/SunSdrRadioConnection.cpp b/src/core/SunSdrRadioConnection.cpp index 64744fae7..9e31e2ae8 100644 --- a/src/core/SunSdrRadioConnection.cpp +++ b/src/core/SunSdrRadioConnection.cpp @@ -565,16 +565,17 @@ void SunSdrRadioConnection::onConnectTimeout() // EINEN Client. Der dritte Fall gehoert also in // den Text, sonst sucht man an den ersten beiden. : QStringLiteral( - "SunSDR: keine Antwort des Geraets. Drei " - "Ursachen, in dieser Reihenfolge pruefen: " - "(1) das Geraet haengt noch an einer " - "frueheren Sitzung — es bedient nur einen " - "Client, und nach einem Absturz oder einem " - "harten Beenden hilft nur Aus- und " - "Einschalten; (2) ein anderes Programm ist " - "gerade mit ihm verbunden; (3) es ist " - "nicht erreichbar oder die Suchmeldung " - "wird im Netz geblockt.")); + "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() From 37c46a3b7d6289f85a9f9bb80f9a579c6b7cb1ac Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 17:58:41 +0200 Subject: [PATCH 41/58] feat(dev): die Automationsbruecke kann eine Scheibe anlegen 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. Verb: addSlice. Antwort traegt die neue Scheibennummer. Beim ersten Versuch mit Qt::BlockingQueuedConnection gebaut und damit sofort in eine Selbstblockade gelaufen: der Handler laeuft bereits im Hauptfaden (steht so an doConnect), und ein blockierender Aufruf in denselben Faden kommt nie zurueck -- die Antwort blieb aus, die Instanz hing. Jetzt direkt gerufen. Was der Lauf danach gezeigt hat, und was noch fehlt: die zweite Scheibe entsteht (slice: 1), teilt sich aber den Strom mit der ersten. Das ist die Platzierungslogik -- ein DDC kann mehrere Scheiben tragen, und solange beide im selben Ausschnitt liegen, braucht es keinen zweiten Strom. Fuer den Live-Beleg von RX2 muesste die zweite Scheibe weit genug weg liegen; die Bruecke kann die Frequenz noch nicht stellen. Die Routenfuehrung selbst ist geprueft (tst_sunsdr_zweiter_empfaenger_oben) und die Treiberseite am Geraet gemessen (b33072d9). Co-Authored-By: Claude Opus 5 --- src/core/DevAutomationServer.cpp | 26 +++++++++++++++++++++++++- src/core/DevAutomationServer.h | 6 ++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/core/DevAutomationServer.cpp b/src/core/DevAutomationServer.cpp index 7aad51c13..331131fc5 100644 --- a/src/core/DevAutomationServer.cpp +++ b/src/core/DevAutomationServer.cpp @@ -353,6 +353,9 @@ 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("disconnect")) { return doDisconnect(); } @@ -367,7 +370,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)")}}; } // ── doConnect / doDisconnect (2026-09-30) ──────────────────────────────────── @@ -380,6 +383,27 @@ 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::doConnect(const QString& macKeyOrEmpty) { if (m_radioModel.isNull()) { diff --git a/src/core/DevAutomationServer.h b/src/core/DevAutomationServer.h index 86c313419..e2c9dd8f1 100644 --- a/src/core/DevAutomationServer.h +++ b/src/core/DevAutomationServer.h @@ -137,6 +137,12 @@ 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(); + QLocalServer* m_server{nullptr}; QPointer m_radioModel; }; From 38988a857619d4a8d5bf3209f063c61a631ebc64 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 18:05:35 +0200 Subject: [PATCH 42/58] feat(sunsdr): zwei Empfaenger von Ende zu Ende -- userDdcCount war das letzte Glied Die Kette stand bis auf ein Glied: BoardCapabilities::userDdcCount war 1. Damit hat der Strompool (SliceStreamAllocator::configure) genau einen Platz, und eine zweite Scheibe kann nie einen eigenen Strom bekommen -- am Geraet sichtbar als Placement: slice 1 freq=14.1 MHz -> Rejected stream=-1 obwohl das Geraet zwei Stroeme liefert, der Treiber beide hochreicht und ReceiverManager sie richtig verteilt. Mit userDdcCount = 2 laeuft es durch, am 2026-10-04 am echten Geraet: Placement: slice 1 freq=14.1 MHz -> NewStream stream=1 SunSdr: aktive Empfaenger -> 2 SunSdr: Stromstart-Rahmen -> zwei Stroeme, je 48 kHz SunSdr: Folgenummern sauber -- 2401 Nummern in 5.0 s (480/s), 1.00 Kopien 480 Nummern/s statt 240, kein einziges "fallen weg" im Protokoll. Moeglich wurde der Beleg durch das neue Verb setFreq in der Automationsbruecke -- ohne Frequenzwechsel teilt sich die zweite Scheibe den Strom der ersten, und das ist richtig so (ein DDC traegt mehrere Scheiben, solange sie im selben Ausschnitt liegen). 9/9, 42/42, 32/32, 99/99, 4/4. Co-Authored-By: Claude Opus 5 --- src/core/BoardCapabilities.cpp | 8 +++++++- src/core/DevAutomationServer.cpp | 29 ++++++++++++++++++++++++++++- src/core/DevAutomationServer.h | 6 ++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/core/BoardCapabilities.cpp b/src/core/BoardCapabilities.cpp index 0ff82378b..f1a13ebcb 100644 --- a/src/core/BoardCapabilities.cpp +++ b/src/core/BoardCapabilities.cpp @@ -1263,7 +1263,13 @@ const BoardCapabilities kSunSdr2Qrp = { // faelschlich "stumm". .maxReceivers = 2, .maxSlices = 2, - .userDdcCount = 1, + // 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. // diff --git a/src/core/DevAutomationServer.cpp b/src/core/DevAutomationServer.cpp index 331131fc5..5798245c6 100644 --- a/src/core/DevAutomationServer.cpp +++ b/src/core/DevAutomationServer.cpp @@ -356,6 +356,14 @@ QJsonObject DevAutomationServer::handleLine(const QByteArray& line) 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(); } @@ -370,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, addSlice)")}}; + QStringLiteral(" (known: ping, dumpTree, grab, get, connect, disconnect, addSlice, setFreq)")}}; } // ── doConnect / doDisconnect (2026-09-30) ──────────────────────────────────── @@ -404,6 +412,25 @@ QJsonObject DevAutomationServer::doAddSlice() {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 e2c9dd8f1..f71d9bfdd 100644 --- a/src/core/DevAutomationServer.h +++ b/src/core/DevAutomationServer.h @@ -143,6 +143,12 @@ private slots: /// 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; }; From 807321c6f310122f608b39e019ae5cf34a9385b6 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 18:08:15 +0200 Subject: [PATCH 43/58] docs(sunsdr): auch die zweite Vermutung zu den Wiederholungen ist widerlegt Der als naechster Versuch notierte Gedanke -- bei zwei Stroemen auch ZWEI Stille-Stroeme zurueckschicken statt einem -- bringt nichts. A/B ueber je 10 s bei je96: eine Antwort: 111 / 115 / 101 Wiederholungen je Sekunde zwei Antworten: 108 / 117 / 106 Ununterscheidbar, Schalter wieder entfernt. Das ist etwas anderes als das Spiegeln des Kopfes von heute frueh (dort ein Paket mit fremdem Kopf, hier zwei kohaerente Stroeme) -- beide Wege sind jetzt gemessen und negativ. Damit sind beide Vermutungen aus diesem Dokument erledigt. Wer weitermacht, soll nicht noch einmal am Kopf der Blockantwort drehen. Der naechste sinnvolle Schritt waere ein Mitschnitt von ExpertSDR2 BEI 96 kHz: wiederholt es dort auch, ist es die Eigenart des Geraets und kein Mangel von Longpath. Co-Authored-By: Claude Opus 5 --- .../2026-10-02-sunsdr-verbindungsablauf.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md index 38c4f2778..bfdb73740 100644 --- a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md +++ b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md @@ -912,3 +912,27 @@ 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. From 36e44c8ec1a1d1b80aa2676ef940233d38a64e7c Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 18:23:04 +0200 Subject: [PATCH 44/58] docs(sunsdr): dritte Vermutung zu den Wiederholungen -- auch widerlegt Beim Durchlesen des eigenen Diffs aufgefallen: replyToBlock() steht HINTER dem Verwerfen. Bei zwei Stroemen und einem Empfaenger wird Kanal 1 verworfen und damit nie quittiert -- und unquittierte Bloecke sind genau das, was dieses Geraet wiederholt. Klang nach der Ursache. Gemessen (je96, ein Empfaenger, je 10 s): vorher: 111 / 115 / 101 Wiederholungen je Sekunde nachher: 110 / 109 / 107 / 103 Kein Unterschied. Zurueckgenommen, weil die Aenderung ohne Nutzen nur zusaetzliche Pakete nach oben erzeugt. Drei Vermutungen sind damit gemessen und negativ: Kopf spiegeln, zwei Stille-Stroeme, Quittung vor dem Verwerfen. Naechster Schritt ist ein Mitschnitt von ExpertSDR2 BEI 96 kHz. Co-Authored-By: Claude Opus 5 --- .../2026-10-02-sunsdr-verbindungsablauf.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md index bfdb73740..9802ba037 100644 --- a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md +++ b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md @@ -936,3 +936,25 @@ weitermacht, soll **nicht** noch einmal am Kopf der Blockantwort drehen 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. From 43747ccc568acff2773be5c5853c1b9d0d82972e Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 18:33:25 +0200 Subject: [PATCH 45/58] feat(sunsdr): das Verbinden erholt sich jetzt von selbst Betreiber: "das soll automatisch funktionieren". Bisher gab die Verbindung nach drei Sekunden auf, wenn kein Beacon kam. Am 2026-10-04 ist gemessen, dass das Geraet nach einem ABRUPTEN Programmende rund eine Minute sperrt und dann von selbst zurueckkommt (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. Jetzt wartet die Verbindung die Sperre ab und sucht von selbst erneut: fuenf Anlaeufe im Abstand von 18 s decken die gemessene Minute ab. Der Zustand bleibt dabei Connecting -- die Oberflaeche zeigt 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 laeuft wie bisher in den Fehlschlag. Drei vorhandene Prueflinien, die den Fehlschlag pruefen, haben das richtigerweise gemeldet (sie warteten ploetzlich 90 s). Sie schalten die Wiederholung jetzt ueber eine Pruef-Naht ab. Dazu zwei neue: ohneBeaconWirdDieSucheWiederholtStattAufzugeben -- kein Fehlschlag mehr ohneWiederholungGibtEsDenFehlschlagSofort -- Waechter: ohne die Wiederholung MUSS er noch kommen, sonst koennte die erste Linie auch dann gruen sein, wenn gar kein Fehlschlag mehr moeglich waere AUSSERDEM die drei Funde der Durchsicht behoben: * die Logzeile beim Handschlag sagte "Strommodus aus der Umgebung", obwohl der Modus im Normalfall aus Rate und Empfaengerzahl der Bedienung kommt -- sie nennt jetzt die wirkliche Herkunft, dazu Rate und Empfaengerzahl * ein per Umgebungsvariable gesetzter Modus konnte Rate und Empfaengerzahl widersprechen (setSampleRate kehrte bei unveraenderter Rate frueh zurueck und liess den Modus stehen) -- beide werden jetzt mitgezogen * die Obergrenze von rund einer Sekunde beim Trennen steht jetzt im Kopf, mit der Begruendung, warum sie in Kauf genommen wird 101/101. Co-Authored-By: Claude Opus 5 --- src/core/SunSdrRadioConnection.cpp | 74 +++++++++++++++++++++++++-- src/core/SunSdrRadioConnection.h | 22 ++++++++ tests/tst_sunsdr_radio_connection.cpp | 56 ++++++++++++++++++++ 3 files changed, 147 insertions(+), 5 deletions(-) diff --git a/src/core/SunSdrRadioConnection.cpp b/src/core/SunSdrRadioConnection.cpp index 9e31e2ae8..91e44cf37 100644 --- a/src/core/SunSdrRadioConnection.cpp +++ b/src/core/SunSdrRadioConnection.cpp @@ -195,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); @@ -330,7 +341,18 @@ void SunSdrRadioConnection::connectToRadio(const RadioInfo& info) // 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; @@ -496,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 @@ -1266,13 +1319,24 @@ void SunSdrRadioConnection::processControlDatagram(const QByteArray& data, 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 aus der Umgebung -- %1. Das ist " - "ein VERSUCH: die Rate kommt aus einem Mitschnitt " - "vom 2026-10-03 und ist am Geraet nicht " - "gegengeprueft, und der zweite Kanal hat oben noch " - "keinen Empfaenger.") + "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")); diff --git a/src/core/SunSdrRadioConnection.h b/src/core/SunSdrRadioConnection.h index e559596c2..d289d965b 100644 --- a/src/core/SunSdrRadioConnection.h +++ b/src/core/SunSdrRadioConnection.h @@ -590,6 +590,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; @@ -1049,6 +1052,20 @@ private slots: // 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; @@ -1201,6 +1218,11 @@ private slots: { 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/tests/tst_sunsdr_radio_connection.cpp b/tests/tst_sunsdr_radio_connection.cpp index a6a410458..8d8587623 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); @@ -1423,7 +1433,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); @@ -2216,6 +2231,47 @@ private slots: 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 From 79e01c6873c098b5c46a31e1e02ecf8fe0a46193 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 18:37:58 +0200 Subject: [PATCH 46/58] docs(sunsdr): Korrektur -- kill -9 ist NICHT die Ursache des Aussetzers Der Abschnitt von heute abend behauptete, das Geraet sperre nach einem abrupten Programmende rund eine Minute. Die Zahlen stammten aus JE EINEM Durchgang, und daraus habe ich eine Regel gemacht. Nachgemessen, drei Durchgaenge: verbinden, mitten im Strom kill -9, sofort wieder verbinden -- dreimal sofort wieder da. Dazu ein vierter ueber die ganze Anwendung: 6 Sekunden. Was wirklich gilt: der Aussetzer tritt MANCHMAL auf (zweimal am 2026-10-04 belegt), loest sich von selbst innerhalb etwa einer Minute, und die Ursache ist UNBEKANNT. An der Behebung aendert das nichts -- die selbsttaetige Wiederholung (43747ccc) wartet die Sperre ab, gleich warum sie entsteht. Es aendert, was wir behaupten duerfen. Zweimal an einem Tag habe ich aus einer Einzelmessung eine Ursache gemacht (vorher die Wiederholungen bei 96 kHz). Das steht jetzt als Warnung im Dokument. Co-Authored-By: Claude Opus 5 --- .../2026-10-02-sunsdr-verbindungsablauf.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md index 9802ba037..46a73fa64 100644 --- a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md +++ b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md @@ -958,3 +958,36 @@ 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. From 032f3a164fd96d206d858166ec5da71a189c6f23 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 18:40:30 +0200 Subject: [PATCH 47/58] docs(sunsdr): Mitschnitt-Anleitung nachgezogen -- zwei der drei Fragen sind weg Die Anleitung schickte den Betreiber noch nach zwei Dingen, die inzwischen ohne Mitschnitt geklaert sind: * der zweite Empfaenger -- es ist derselbe Rahmen 0x01, erstes Byte; zwei Empfaenger laufen seit 38988a85 von Ende zu Ende, am Geraet belegt * die Abtastrate -- ebenfalls 0x01, zweites Byte, drei Nutzlasten durchgemessen Uebrig bleiben zwei Fragen, und eine davon ist neu: wiederholt ExpertSDR2 bei 96 kHz auch? Longpath bekommt dort ~110 bytegleiche Wiederholungen je Sekunde, und drei Gegenmassnahmen sind gemessen und wirkungslos. Zeigt der Mitschnitt dasselbe bei ExpertSDR2, ist die Frage erledigt statt offen -- dafuer muss ExpertSDR2 aber auf 96 kHz stehen, das steht jetzt dabei. Co-Authored-By: Claude Opus 5 --- .../sunsdr-mitschnitt-anleitung.md | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/docs/development/sunsdr-mitschnitt-anleitung.md b/docs/development/sunsdr-mitschnitt-anleitung.md index db9b868b1..85b1c3668 100644 --- a/docs/development/sunsdr-mitschnitt-anleitung.md +++ b/docs/development/sunsdr-mitschnitt-anleitung.md @@ -1,15 +1,32 @@ # 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 muss ExpertSDR2 auf 96 kHz stehen**, nicht auf der Vorgabe. **Was dabei nicht gebraucht wird:** keine Antenne, kein Senden, keine Freigabe für HF. Es wird nur zugehört. From ad8a893e15daa1ad3ebccca302ca925c0bc5b369 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 18:40:51 +0200 Subject: [PATCH 48/58] docs(sunsdr): Bilanz am Ende des 2026-10-04 Der Empfang ist gleichwertig. Die Tabelle nennt je Posten, WIE er belegt ist -- am Geraet oder am Pruefstand -- und die zweite, woran das Offene haengt: 50-Ohm-Abschluss fuer alles Sendeseitige, zwei Minuten ExpertSDR2 mit Mitschnitt fuer den Rest. Dazu ausdruecklich, was niemand mehr versuchen soll: Kopf der Blockantwort spiegeln, zwei Stille-Stroeme, Quittung vor dem Verwerfen. Alle drei sind gemessen und wirkungslos. Und die Mahnung, die der Tag zweimal gekostet hat: aus EINER Messung wurde eine Ursache gemacht, bei den Wiederholungen und beim Verbindungsaussetzer. Beide Male war die Zahl richtig und der Schluss falsch. Co-Authored-By: Claude Opus 5 --- .../2026-10-02-sunsdr-paritaet.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs/architecture/2026-10-02-sunsdr-paritaet.md b/docs/architecture/2026-10-02-sunsdr-paritaet.md index 3ed0817c9..498ce2d34 100644 --- a/docs/architecture/2026-10-02-sunsdr-paritaet.md +++ b/docs/architecture/2026-10-02-sunsdr-paritaet.md @@ -462,3 +462,40 @@ 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. From b07d5ce92a0c1e2f2caa4614c8038b5e6c8690b2 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 4 Oct 2026 20:54:02 +0200 Subject: [PATCH 49/58] feat(tools): Wiederholungen im Mitschnitt zaehlen -- fuer die eine offene Frage Longpath bekommt bei 96 kHz rund 110 bytegleiche Wiederholungen je Sekunde, bei 48 kHz keine. Drei Gegenmassnahmen sind gemessen und wirkungslos (Kopf spiegeln, zwei Stille-Stroeme, Quittung vor dem Verwerfen). Die Frage ist damit: wiederholt ExpertSDR2 bei 96 kHz AUCH? python3 tools/sunsdr_handshake_diff.py --wiederholungen zaehlt sie und sagt, was das Ergebnis bedeutet -- statt dass jemand die Auswertung nochmal von Hand schreibt. Gegen Martins vorhandenen Mitschnitt geprueft (der stammt aus einer anderen Betriebsart, 609 Pakete/s, zwei Kanaele zu je 305/s): bytegleiche Wiederholungen: 0 (0,0/s) Das ist der erwartete Wert fuer diesen Mitschnitt und zeigt, dass die Auswertung nicht einfach alles als Wiederholung zaehlt. Der Lauf, auf den es ankommt, muss von ExpertSDR2 BEI 96 kHz stammen -- so steht es auch in der Mitschnitt-Anleitung. Co-Authored-By: Claude Opus 5 --- tools/sunsdr_handshake_diff.py | 92 ++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tools/sunsdr_handshake_diff.py b/tools/sunsdr_handshake_diff.py index 3d1023596..a0cafc5e5 100644 --- a/tools/sunsdr_handshake_diff.py +++ b/tools/sunsdr_handshake_diff.py @@ -322,6 +322,91 @@ def sammle(path): print(" keine -- dieselben Rahmen mit denselben Werten") +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 + if geraet and src != geraet: + continue + nutz = d[uo + 8:] + # 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?") + return + 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)) + 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.") + + def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) @@ -335,6 +420,10 @@ def main(): 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") @@ -344,6 +433,9 @@ 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 From bb400f1b00346e210802d90e6d5f4dc53e2d68d6 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Mon, 5 Oct 2026 07:35:00 +0200 Subject: [PATCH 50/58] feat(tools): Mitschnitt sagt selbst, ob er die Frage beantworten kann Die Wiederholungszaehlung von b07d5ce9 zaehlt richtig, sagt aber nicht, in WELCHER Betriebsart der Mitschnitt entstanden ist. Genau daran bin ich am 2026-10-04 gescheitert: aus einem Mitschnitt "0 bytegleiche Wiederholungen" gelesen und geschlossen, Longpath rechne falsch. Der Mitschnitt war in einer anderen Betriebsart entstanden; die Sonde am Geraet zeigte danach "Wiederholungen: 114, davon GANZ bytegleich: 114". Jetzt leitet das Werkzeug die Abtastrate je Kanal aus der Blockrate her (ein Block = 200 Proben, also 240/s -> 48 kHz, 480/s -> 96 kHz) und verweigert die Aussage, wenn die Betriebsart nicht Longpaths 96 kHz ist. Zwei Entscheidungen, beide gemessen begruendet: - Gezaehlt werden nur die NICHT wiederholten Bloecke. Die Wiederholungen blasen die Blockrate auf: im gebauten 96-kHz-Fall rohe 577/s je Kanal (das waere "nicht eindeutig"), einzeln 481/s -> 96 kHz. - Die Rate wird JE KANAL hergeleitet, nicht als Maximum. Das ist keine Feinheit, sondern der Befund an Martins Mitschnitten vom 2026-10-04: expert-A.pcap Kanal 0: 240/s -> 48 kHz Kanal 1: 480/s -> 96 kHz expert-B.pcap genauso ExpertSDR2 fahrt die beiden Empfaenger also mit VERSCHIEDENEN Raten -- eine Betriebsart, die Longpath nicht kennt (dort sind beide Stroeme 48 oder beide 96). Mit dem Maximum haette das Werkzeug "96 kHz" gemeldet, die 0 Wiederholungen als Antwort gelesen und denselben falschen Schluss wieder angeboten. Die offene Frage bleibt damit offen, aber ehrlich offen: es braucht einen Mitschnitt mit BEIDEN Stroemen auf 96 kHz. Dass das Geraet gemischte Raten kann, ist der Nebenbefund. Gegenprobe gegen die alte Fassung (Kopie der Datei, nicht Stash -- im Baum arbeiten mehrere Sitzungen): ALT, 48-kHz-Mitschnitt: "-> Praktisch keine Wiederholungen. Dann liegt es NICHT am Geraet [...]" NEU, derselbe: "-> ACHTUNG: [...] beantwortet sie NICHT" Die alte Fassung zieht also genau den Fehlschluss, um den es geht; der Faenger faengt. --selftest deckt die Zaehlung jetzt mit ab: zwei gebaute Mitschnitte mit bekannter Rate und bekannter Zahl von Kopien (96 kHz mit 384 Kopien, 48 kHz ohne). Beide werden richtig erkannt. Co-Authored-By: Claude Opus 5 --- tools/sunsdr_handshake_diff.py | 139 +++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/tools/sunsdr_handshake_diff.py b/tools/sunsdr_handshake_diff.py index a0cafc5e5..e6239a6c9 100644 --- a/tools/sunsdr_handshake_diff.py +++ b/tools/sunsdr_handshake_diff.py @@ -270,6 +270,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. @@ -345,6 +448,7 @@ def wiederholungen(pfad, geraet=None): return jeKanal = {} + jeKanalEinzig = {} inhalt = {} dubletten = 0 gesamt = 0 @@ -383,6 +487,11 @@ def wiederholungen(pfad, geraet=None): schluessel = (kanal, seq) if schluessel in inhalt and inhalt[schluessel] == h: dubletten += 1 + else: + # Nur die NICHT wiederholten Bloecke verraten die Abtastrate -- + # Wiederholungen blasen die Blockrate auf und wuerden 96 kHz + # vorspiegeln, wo 80 kHz gemeint waren. + jeKanalEinzig[kanal] = jeKanalEinzig.get(kanal, 0) + 1 inhalt[schluessel] = h if len(inhalt) > 400000: inhalt.clear() @@ -397,6 +506,36 @@ def wiederholungen(pfad, geraet=None): 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)) + + 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 " From ae3a06fd4f53063b9f61106444315648464cb86b Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Mon, 5 Oct 2026 07:37:45 +0200 Subject: [PATCH 51/58] docs(sunsdr): ExpertSDR2 fahrt die Empfaenger mit verschiedenen Raten Nachgezaehlt an den Mitschnitten vom 2026-10-04, die schon dalagen -- ich hatte aus ihnen nur die Rahmenfolge gelesen, nie die Blockraten je Kanal: expert-A.pcap (75,2 s) Kanal 0: 240 Bloecke/s -> 48 kHz Kanal 1: 480 Bloecke/s -> 96 kHz expert-B.pcap (23,5 s) genauso Beide Mitschnitte, zwei verschieden lange Laeufe, dasselbe Bild. Das Geraet kann also gemischte Raten; Longpath kann es nicht (StromModus kennt nur beide-48 oder beide-96). Wie ExpertSDR2 das anmeldet, ist noch offen -- keine der drei gemessenen 0x01-Nutzlasten ist diese Betriebsart. Und der Grund, warum das hier so ausfuehrlich steht: Kanal 1 laeuft mit 96 kHz und zeigt ueber 75 s EINE bytegleiche Wiederholung. Daraus "ExpertSDR2 wiederholt bei 96 kHz nicht, also macht Longpath etwas falsch" zu lesen, waere derselbe Fehler wie am 2026-10-04, nur eine Ebene feiner -- die Betriebsart ist nicht Longpaths 96 kHz. Dort laufen beide Stroeme mit 96 kHz (960 Bloecke/s), hier sind es 720/s. Das ist weniger als drei Viertel der Last, und genau die Last stand im Verdacht. Die Anleitung bekommt Durchgang D (beide Empfaenger auf 96 kHz) samt der Kontrolle, woran man die richtige Betriebsart erkennt: je Kanal 480 einzelne Bloecke/s. Dazu die zwei Auswertungsbefehle -- einer fuer die Wiederholungsfrage, einer fuer den Rahmen hinter den gemischten Raten (--vergleich A gegen D, dieselbe Methode, mit der 0x01 gefunden wurde). Ob Longpath gemischte Raten braucht, ist eine andere Frage. Der Betreiber hat es nie verlangt; es ist ein Protokollbefund, keine Luecke in der Gleichwertigkeit. Steht als "nicht von selbst bauen" im Dokument. Co-Authored-By: Claude Opus 5 --- .../2026-10-02-sunsdr-verbindungsablauf.md | 77 +++++++++++++++++++ .../sunsdr-mitschnitt-anleitung.md | 44 ++++++++++- 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md index 46a73fa64..4ca5d4465 100644 --- a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md +++ b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md @@ -991,3 +991,80 @@ 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 (2026-10-05) + +Die Mitschnitte vom 2026-10-04 lagen schon da; ich hatte aus ihnen nur +die Rahmenfolge gelesen, nie die Blockraten je Kanal. Nachgezählt +(`sunsdr_handshake_diff.py --wiederholungen`, `bb400f1b`): + +| Mitschnitt | Kanal 0 | Kanal 1 | +| --- | --- | --- | +| `expert-A.pcap` (75,2 s) | 240 Blöcke/s → **48 kHz** | 480 Blöcke/s → **96 kHz** | +| `expert-B.pcap` (23,5 s) | 240 Blöcke/s → **48 kHz** | 480 Blöcke/s → **96 kHz** | + +Ein Block trägt 200 Proben, also ist die Rate gleich Blockrate × 200. +Beide Mitschnitte zeigen dasselbe Bild, über zwei verschieden lange +Läufe — es ist kein Ausreißer. + +**Das Gerät kann gemischte Raten.** Die beiden Ströme sind in der Rate +unabhängig; `byte9` im Stromkopf unterscheidet sie, und offenbar auch +ihre Abtastung. Longpath kann das nicht: `StromModus` kennt nur +`EinStrom48`, `ZweiStroemeJe48`, `ZweiStroemeJe96` — beide Ströme immer +gleich. Der Rahmen `0x01` trägt die Ratenstufe als **ein** Byte +(zweites Nutzbyte), also ist noch unklar, wie ExpertSDR2 zwei +verschiedene Raten anmeldet: ein zweites Byte, ein zweiter Rahmen je +Kanal, oder eine Stufe, die „48/96" bedeutet. Die drei gemessenen +Nutzlasten geben es nicht her: + + 010000000c08040302020202 1 Strom, 48k + 020000000c08040302020202 2 × 48k + 020100000a06040302020201 2 × 96k + +Keine davon ist die gemischte Betriebsart. Sie steht im Mitschnitt, nur +habe ich den Rahmen dazu noch nicht herausgelesen. + +## Warum das die Wiederholungsfrage NICHT beantwortet + +Verlockend war es: Kanal 1 läuft in beiden Mitschnitten mit 96 kHz und +zeigt über 75 s **eine** bytegleiche Wiederholung. Daraus „ExpertSDR2 +wiederholt bei 96 kHz nicht, also macht Longpath etwas falsch" zu lesen, +wäre derselbe Fehler wie am 2026-10-04 — nur eine Ebene feiner: die +Betriebsart ist nicht Longpaths 96 kHz. Dort laufen **beide** Ströme mit +96 kHz, also 960 Blöcke/s; hier sind es 720/s. Das ist weniger als drei +Viertel der Last, und genau die Last stand im Verdacht. + +Das Werkzeug sagt das jetzt von selbst: es leitet die Rate **je Kanal** +her und verweigert die Aussage, wenn nicht jeder Kanal auf 96 kHz steht. +Zwei Entscheidungen dahinter, beide nötig: + +- **Nur nicht-wiederholte Blöcke zählen.** Die Wiederholungen blasen die + Blockrate auf — im gebauten Prüffall rohe 577/s je Kanal (das wäre + „nicht eindeutig"), einzeln 481/s → 96 kHz. Wer die Rate aus der rohen + Blockrate herleitet, bekommt sie bei genau dem Mitschnitt falsch, für + den er sie braucht. +- **Je Kanal, nicht als Maximum.** Mit dem Maximum hätte das Werkzeug + beide Mitschnitte als „96 kHz" gemeldet und die Fehlinterpretation + oben als Antwort angeboten. + +Gegenprobe gegen die alte Fassung (Kopie der Datei, nicht Stash): + + ALT, 48-kHz-Mitschnitt: „-> Praktisch keine Wiederholungen. Dann + liegt es NICHT am Geraet [...]" + NEU, derselbe: „-> ACHTUNG: [...] beantwortet sie NICHT" + +Die alte Fassung zieht den Fehlschluss, um den es geht. + +## Was daraus zu tun ist + +1. **Für die Wiederholungsfrage:** ein Mitschnitt mit **beiden** Strömen + auf 96 kHz. Das ist der einzige, der sie beantwortet. +2. **Für die gemischten Raten:** in demselben Mitschnitt steckt der + Rahmen, der sie anmeldet. `--vergleich` gegen einen Mitschnitt mit + beiden auf 96 kHz zeigt ihn — dieselbe Methode, mit der `0x01` als + Ratenrahmen gefunden wurde. +3. **Ob Longpath das braucht**, ist eine andere Frage. Der Betreiber hat + es nie verlangt; es ist ein Protokollbefund, keine Lücke in der + Gleichwertigkeit. Nicht von selbst bauen. diff --git a/docs/development/sunsdr-mitschnitt-anleitung.md b/docs/development/sunsdr-mitschnitt-anleitung.md index 85b1c3668..8dd214f27 100644 --- a/docs/development/sunsdr-mitschnitt-anleitung.md +++ b/docs/development/sunsdr-mitschnitt-anleitung.md @@ -26,7 +26,13 @@ Wiederholungen bei ExpertSDR2, ist es die Eigenart des Geräts und kein Mangel von Longpath — und die Frage ist erledigt statt offen. - **Dafür muss ExpertSDR2 auf 96 kHz stehen**, nicht auf der Vorgabe. + **Dafür müssen BEIDE Empfänger auf 96 kHz stehen**, nicht nur einer + und nicht die Vorgabe. Am 2026-10-05 nachgezählt: in den Mitschnitten + A und B läuft **Kanal 0 mit 48 kHz und Kanal 1 mit 96 kHz** — das + Gerät kann gemischte Raten, und in dieser Betriebsart liegt die Last + bei 720 Blöcken/s statt 960. Damit beantworten diese Mitschnitte die + Frage nicht. Das Werkzeug prüft das jetzt selbst und verweigert die + Aussage, wenn nicht jeder Kanal auf 96 kHz steht. **Was dabei nicht gebraucht wird:** keine Antenne, kein Senden, keine Freigabe für HF. Es wird nur zugehört. @@ -64,6 +70,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: @@ -88,6 +109,27 @@ 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. + +**Den Rahmen für die gemischten Raten** findet derselbe Vergleich, mit +dem `0x01` als Ratenrahmen gefunden wurde — A (gemischt) gegen D (beide +96 kHz): + +```bash +python3 ~/Longpath/NereusSDR/tools/sunsdr_handshake_diff.py ~/Desktop/expert-A.pcap --vergleich ~/Desktop/expert-96k.pcap +``` + ## 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 From 9061a810fac8f712e45ee6e2f4ee6177b51c1cb1 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Mon, 5 Oct 2026 07:50:22 +0200 Subject: [PATCH 52/58] fix(tools): Richtung filtern -- und die "gemischten Raten" zuruecknehmen Die Behauptung aus ae3a06fd ("ExpertSDR2 fahrt die Empfaenger mit verschiedenen Raten") ist FALSCH. Sie stand ein paar Stunden. Grund: die Richtung war nicht gefiltert. Beide Seiten sprechen Port 50002, und -- das ist das Neue -- ExpertSDR2 schickt SELBST 1210-Byte-Bloecke zurueck. Ohne Richtungsfilter zaehlt man die eigenen Antworten als Geraetedaten mit. Genau das ergab die "verschiedenen Raten": Kanal 1 schien mit 480/s zu laufen, es waren 240/s Geraet plus 240/s eigene Antworten, die zufaellig byte9=1 tragen. Mit Filter, dieselben Dateien: expert-A Geraet Kanal 0: 240/s -> 48 kHz Geraet Kanal 1: 240/s -> 48 kHz expert-B genauso expert-96k Geraet Kanal 0: 480/s -> 96 kHz (ein Strom, RX2 aus) Beide Kanaele laufen gleich schnell. Keine gemischten Raten. Zwei Fehler in findeRechner, beide aus derselben Wurzel: - Der Rueckfall lautete "die 1210-Byte-Pakete kommen aus dem Geraet". Widerlegt. Jetzt: die SCHWAECHERE der beiden Blockquellen ist der Rechner (480/s gegen 240/s), als benannte Heuristik, und bei Gleichstand gibt es keine Antwort statt einer geratenen. - Die Suchanfrage-Regel (0x00 kommt vom Rechner) griff auch auf dem STROMport, wo das Geraet 77-Byte-Rahmen schickt, die ebenfalls mit 0x00 beginnen -- damit lieferte die Erkennung genau verkehrt herum. Geprueft wird jetzt "beruehrt den Steuerport und nicht den Stromport", nicht sport == 50001: der Rechner darf einen beliebigen Quellport nehmen (ExpertSDR2 nimmt 50001, der Pruefstand 54000 -- daran ist die erste Fassung dieser Korrektur im --selftest aufgelaufen). ZWEI echte Funde, die dabei herausfallen: 1. ExpertSDR2 antwortet je Block ABWECHSELND mit einem vollen Stilleblock (1210 Byte) und einem BLOSSEN KOPF (10 Byte, Laengenfeld 0): 03 ff fe ff 00 00 00 00 01 00 In allen drei Mitschnitten 240/s voll + 240/s Kopf gegen 480/s vom Geraet. Longpath schickt auf JEDEN Block einen vollen Block, also rund das Doppelte an Rueckweg-Bytes. Der blosse Kopf ist ein Rahmen, den Longpath nie schickt. 2. expert-96k: 92197 Bloecke ueber 192 s, 0 bytegleiche Wiederholungen. Longpath bekommt bei 96 kHz rund 110 je Sekunde. Vorbehalt, und er steht jetzt auch im Werkzeug: hier laeuft EIN Strom, Longpath faehrt bei 96 kHz ZWEI. Noch nicht dieselbe Betriebsart, also noch keine Antwort -- aber zusammen mit Fund 1 zum ersten Mal eine konkrete Richtung: nicht mehr zurueckschicken, sondern weniger. Naechster Schritt steht im Dokument: den blossen Kopf nachbauen (jeden zweiten Block mit 10 statt 1210 Byte beantworten) und A/B bei 96 kHz messen. Das aendert nur replyToBlock und kostet nichts. --wiederholungen meldet jetzt zusaetzlich, was zurueckgeschickt wird (volle Bloecke und blosse Koepfe je Sekunde), und nennt die Stromzahl beim Urteil. --selftest bleibt gruen. Dreimal in zwei Tagen derselbe Fehler: eine Zahl aus einem Mitschnitt ist erst eine Aussage, wenn feststeht, WER gesendet hat und in WELCHER Betriebsart. Co-Authored-By: Claude Opus 5 --- .../2026-10-02-sunsdr-verbindungsablauf.md | 154 ++++++++++-------- .../sunsdr-mitschnitt-anleitung.md | 26 +-- tools/sunsdr_handshake_diff.py | 87 ++++++++-- 3 files changed, 173 insertions(+), 94 deletions(-) diff --git a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md index 4ca5d4465..5a751ced1 100644 --- a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md +++ b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md @@ -994,77 +994,89 @@ eine Ursache braucht mehr als einen Durchgang. --- -# ExpertSDR2 fährt die Empfänger mit VERSCHIEDENEN Raten (2026-10-05) +# ~~ExpertSDR2 fährt die Empfänger mit VERSCHIEDENEN Raten~~ — FALSCH, am selben Tag widerlegt (2026-10-05) -Die Mitschnitte vom 2026-10-04 lagen schon da; ich hatte aus ihnen nur -die Rahmenfolge gelesen, nie die Blockraten je Kanal. Nachgezählt -(`sunsdr_handshake_diff.py --wiederholungen`, `bb400f1b`): +**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.** -| Mitschnitt | Kanal 0 | Kanal 1 | +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) | 240 Blöcke/s → **48 kHz** | 480 Blöcke/s → **96 kHz** | -| `expert-B.pcap` (23,5 s) | 240 Blöcke/s → **48 kHz** | 480 Blöcke/s → **96 kHz** | - -Ein Block trägt 200 Proben, also ist die Rate gleich Blockrate × 200. -Beide Mitschnitte zeigen dasselbe Bild, über zwei verschieden lange -Läufe — es ist kein Ausreißer. - -**Das Gerät kann gemischte Raten.** Die beiden Ströme sind in der Rate -unabhängig; `byte9` im Stromkopf unterscheidet sie, und offenbar auch -ihre Abtastung. Longpath kann das nicht: `StromModus` kennt nur -`EinStrom48`, `ZweiStroemeJe48`, `ZweiStroemeJe96` — beide Ströme immer -gleich. Der Rahmen `0x01` trägt die Ratenstufe als **ein** Byte -(zweites Nutzbyte), also ist noch unklar, wie ExpertSDR2 zwei -verschiedene Raten anmeldet: ein zweites Byte, ein zweiter Rahmen je -Kanal, oder eine Stufe, die „48/96" bedeutet. Die drei gemessenen -Nutzlasten geben es nicht her: - - 010000000c08040302020202 1 Strom, 48k - 020000000c08040302020202 2 × 48k - 020100000a06040302020201 2 × 96k - -Keine davon ist die gemischte Betriebsart. Sie steht im Mitschnitt, nur -habe ich den Rahmen dazu noch nicht herausgelesen. - -## Warum das die Wiederholungsfrage NICHT beantwortet - -Verlockend war es: Kanal 1 läuft in beiden Mitschnitten mit 96 kHz und -zeigt über 75 s **eine** bytegleiche Wiederholung. Daraus „ExpertSDR2 -wiederholt bei 96 kHz nicht, also macht Longpath etwas falsch" zu lesen, -wäre derselbe Fehler wie am 2026-10-04 — nur eine Ebene feiner: die -Betriebsart ist nicht Longpaths 96 kHz. Dort laufen **beide** Ströme mit -96 kHz, also 960 Blöcke/s; hier sind es 720/s. Das ist weniger als drei -Viertel der Last, und genau die Last stand im Verdacht. - -Das Werkzeug sagt das jetzt von selbst: es leitet die Rate **je Kanal** -her und verweigert die Aussage, wenn nicht jeder Kanal auf 96 kHz steht. -Zwei Entscheidungen dahinter, beide nötig: - -- **Nur nicht-wiederholte Blöcke zählen.** Die Wiederholungen blasen die - Blockrate auf — im gebauten Prüffall rohe 577/s je Kanal (das wäre - „nicht eindeutig"), einzeln 481/s → 96 kHz. Wer die Rate aus der rohen - Blockrate herleitet, bekommt sie bei genau dem Mitschnitt falsch, für - den er sie braucht. -- **Je Kanal, nicht als Maximum.** Mit dem Maximum hätte das Werkzeug - beide Mitschnitte als „96 kHz" gemeldet und die Fehlinterpretation - oben als Antwort angeboten. - -Gegenprobe gegen die alte Fassung (Kopie der Datei, nicht Stash): - - ALT, 48-kHz-Mitschnitt: „-> Praktisch keine Wiederholungen. Dann - liegt es NICHT am Geraet [...]" - NEU, derselbe: „-> ACHTUNG: [...] beantwortet sie NICHT" - -Die alte Fassung zieht den Fehlschluss, um den es geht. - -## Was daraus zu tun ist - -1. **Für die Wiederholungsfrage:** ein Mitschnitt mit **beiden** Strömen - auf 96 kHz. Das ist der einzige, der sie beantwortet. -2. **Für die gemischten Raten:** in demselben Mitschnitt steckt der - Rahmen, der sie anmeldet. `--vergleich` gegen einen Mitschnitt mit - beiden auf 96 kHz zeigt ihn — dieselbe Methode, mit der `0x01` als - Ratenrahmen gefunden wurde. -3. **Ob Longpath das braucht**, ist eine andere Frage. Der Betreiber hat - es nie verlangt; es ist ein Protokollbefund, keine Lücke in der - Gleichwertigkeit. Nicht von selbst bauen. +| `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. diff --git a/docs/development/sunsdr-mitschnitt-anleitung.md b/docs/development/sunsdr-mitschnitt-anleitung.md index 8dd214f27..4498e3f62 100644 --- a/docs/development/sunsdr-mitschnitt-anleitung.md +++ b/docs/development/sunsdr-mitschnitt-anleitung.md @@ -27,12 +27,15 @@ 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. Am 2026-10-05 nachgezählt: in den Mitschnitten - A und B läuft **Kanal 0 mit 48 kHz und Kanal 1 mit 96 kHz** — das - Gerät kann gemischte Raten, und in dieser Betriebsart liegt die Last - bei 720 Blöcken/s statt 960. Damit beantworten diese Mitschnitte die - Frage nicht. Das Werkzeug prüft das jetzt selbst und verweigert die - Aussage, wenn nicht jeder Kanal auf 96 kHz steht. + 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. @@ -122,13 +125,10 @@ jedem Kanal 96 kHz, verweigert er die Aussage — absichtlich: am falschen Schluss gezogen, und am 2026-10-05 wäre es mit den gemischten Raten fast wieder passiert. -**Den Rahmen für die gemischten Raten** findet derselbe Vergleich, mit -dem `0x01` als Ratenrahmen gefunden wurde — A (gemischt) gegen D (beide -96 kHz): - -```bash -python3 ~/Longpath/NereusSDR/tools/sunsdr_handshake_diff.py ~/Desktop/expert-A.pcap --vergleich ~/Desktop/expert-96k.pcap -``` +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 diff --git a/tools/sunsdr_handshake_diff.py b/tools/sunsdr_handshake_diff.py index e6239a6c9..2e0872d9f 100644 --- a/tools/sunsdr_handshake_diff.py +++ b/tools/sunsdr_handshake_diff.py @@ -108,18 +108,42 @@ def findeRechner(path): fuenf Quittungen des Geraets. Belastbar ist die Suchanfrage: Opcode 0x00 geht immer VOM Rechner aus. - Fehlt sie im Mitschnitt, entscheidet der Datenstrom -- die - 1210-Byte-Pakete kommen aus dem Geraet. Bleibt auch das offen, muss es - --rechner sagen. + 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. """ - stromQuelle = None + bloecke = {} for ts, src, sport, dst, dport, pl in udpMitRichtung(path): - k = kopf(pl) + # 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 or dport == STREAM_PORT) and len(pl) > 1000: - stromQuelle = stromQuelle or dst # Ziel des Stroms = Rechner - return stromQuelle + 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): @@ -447,8 +471,21 @@ def wiederholungen(pfad, geraet=None): "(pcapng wird hier nicht gelesen).") return + # Die Richtung MUSS gefiltert werden. Beide Seiten sprechen Port 50002, + # und ExpertSDR2 schickt selbst 1210-Byte-Bloecke zurueck -- ohne Filter + # zaehlt man die eigenen Antworten als Geraetedaten mit. Am 2026-10-05 + # hat mich genau das eine falsche Behauptung gekostet ("die Kanaele + # laufen mit verschiedenen Raten"): Kanal 1 schien mit 480/s zu laufen, + # es waren 240/s Geraet plus 240/s eigene Antworten. + if geraet is None: + rechner = findeRechner(pfad) + else: + rechner = None + jeKanal = {} jeKanalEinzig = {} + pcAntworten = {} + pcLeer = 0 inhalt = {} dubletten = 0 gesamt = 0 @@ -469,9 +506,17 @@ def wiederholungen(pfad, geraet=None): src = ".".join(str(b) for b in d[26:30]) if sp != 50002: continue - if geraet and src != geraet: - 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 @@ -498,7 +543,17 @@ def wiederholungen(pfad, geraet=None): 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)) @@ -507,6 +562,12 @@ def wiederholungen(pfad, geraet=None): 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) @@ -544,6 +605,12 @@ def wiederholungen(pfad, geraet=None): 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(): From 286be3ec7fd3522c67a974d74c56049524246f5d Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Mon, 5 Oct 2026 07:55:58 +0200 Subject: [PATCH 53/58] feat(sunsdr): Kopfantwort gebaut -- jeden zweiten Block nur mit 10 Byte Erste konkrete Spur zu den ~110 Wiederholungen je Sekunde bei 96 kHz, nachdem drei Vermutungen gemessen und widerlegt sind. Sie kommt nicht aus einer Idee, sondern aus Martins Mitschnitten (2026-10-05): ExpertSDR2 beantwortet JEDEN Block des Geraets, aber abwechselnd ungerade Nummer -> voller Stilleblock, Laengenfeld 1200 (1210 Byte) gerade Nummer -> BLOSSER KOPF, Laengenfeld 0 (10 Byte) 03 ff fe ff 00 00 01 00 In allen drei Mitschnitten dasselbe: 240/s voll + 240/s Kopf gegen 480/s vom Geraet. Longpath schickt bisher auf jeden Block den vollen Block, also rund das Doppelte an Rueckweg-Bytes. Der blosse Kopf ist ein Rahmen, den Longpath nie geschickt hat. Vorgabe bleibt AUS. Gemessen ist, was ExpertSDR2 TUT -- nicht, dass es hilft. LONGPATH_SUNSDR_KOPFANTWORT=1 schaltet ein, die A/B-Messung am Geraet entscheidet (bei 96 kHz, mit LONGPATH_SUNSDR_PROBE gegen die Wiederholungen je Sekunde). buildIqHeader bekommt dafuer die Nutzlastlaenge als Parameter (Vorgabe unveraendert 1200). Der Rueckgabewert bleibt immer die 10 Byte des Kopfes; der Parameter setzt nur das LAENGENFELD. Gegenprobe gegen die zurueckgebaute Fassung (Kopie der Datei, nicht Stash -- im Baum arbeiten mehrere Sitzungen): ohne Kopfantwort: FAIL jedeZweiteAntwortIstNurDerKopf PASS ohneSchalterBleibtJedeAntwortDerVolleBlock mit Kopfantwort: beide PASS Die erste ist also ein Faenger, die zweite ein Waechter -- sie haelt die Vorgabe fest, solange die Messung nicht entschieden hat. Geprueft wird die GROESSE der Antwort, nicht ihre Zahl: die Zahl aendert sich nicht, nur das Gewicht. Eine Pruefung auf blockRepliesSentForTest() allein waere in beiden Fassungen gruen gewesen. tst_sunsdr_radio_connection 103/103, tst_sunsdr_protocol 36/36. Co-Authored-By: Claude Opus 5 --- src/core/SunSdrRadioConnection.cpp | 32 ++++++++++- src/core/SunSdrRadioConnection.h | 31 ++++++++++ src/core/sunsdr/SunSdrProtocol.cpp | 6 +- src/core/sunsdr/SunSdrProtocol.h | 7 ++- tests/tst_sunsdr_radio_connection.cpp | 81 +++++++++++++++++++++++++++ 5 files changed, 150 insertions(+), 7 deletions(-) diff --git a/src/core/SunSdrRadioConnection.cpp b/src/core/SunSdrRadioConnection.cpp index 91e44cf37..18ea06438 100644 --- a/src/core/SunSdrRadioConnection.cpp +++ b/src/core/SunSdrRadioConnection.cpp @@ -2056,15 +2056,41 @@ 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 AUS: gemessen ist, was ExpertSDR2 tut, nicht dass es hilft. + // Die A/B-Messung am Geraet entscheidet (Begruendung im Kopf). + m_kopfAntwortOn = qgetenv("LONGPATH_SUNSDR_KOPFANTWORT").trimmed() == "1"; + if (m_kopfAntwortOn) { + qCInfo(lcSunSdr) << "SunSdr: Kopfantwort an -- jede zweite Antwort " + "nur 10 Byte (LONGPATH_SUNSDR_KOPFANTWORT=1)"; + } + } + return m_kopfAntwortOn; +} + void SunSdrRadioConnection::probeFeed(quint16 seq, const QByteArray& payload) { ++m_probePackets; diff --git a/src/core/SunSdrRadioConnection.h b/src/core/SunSdrRadioConnection.h index d289d965b..080bf405f 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. @@ -705,7 +710,33 @@ 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. Also als + // Schalter gebaut, Vorgabe AUS, und die A/B-Messung am Geraet + // entscheidet. LONGPATH_SUNSDR_KOPFANTWORT=1 schaltet ein. + bool kopfAntwortEnabled(); + bool m_kopfAntwortChecked{false}; + bool m_kopfAntwortOn{false}; void replyToBlock(quint16 seq); // ── Mithoeren: was das Geraet von sich aus meldet ──────────────────── diff --git a/src/core/sunsdr/SunSdrProtocol.cpp b/src/core/sunsdr/SunSdrProtocol.cpp index fb886afdc..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; diff --git a/src/core/sunsdr/SunSdrProtocol.h b/src/core/sunsdr/SunSdrProtocol.h index 74bdc56f8..93eb13e21 100644 --- a/src/core/sunsdr/SunSdrProtocol.h +++ b/src/core/sunsdr/SunSdrProtocol.h @@ -311,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. diff --git a/tests/tst_sunsdr_radio_connection.cpp b/tests/tst_sunsdr_radio_connection.cpp index 8d8587623..4c66da955 100644 --- a/tests/tst_sunsdr_radio_connection.cpp +++ b/tests/tst_sunsdr_radio_connection.cpp @@ -743,6 +743,87 @@ private slots: QCOMPARE(conn.blockRepliesSentForTest(), quint64(0)); } + // 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()); + + 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); + } + + // Waechter, nicht Faenger: ohne den Schalter bleibt JEDE Antwort der + // volle Block. Diese Pruefung ist in beiden Fassungen gruen und soll das + // auch -- sie haelt die Vorgabe fest, solange die A/B-Messung am Geraet + // nicht entschieden hat. + void ohneSchalterBleibtJedeAntwortDerVolleBlock() + { + 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(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 From 5f55b224df9f8d895b87a9d0a350134140b430fc Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Mon, 5 Oct 2026 08:07:11 +0200 Subject: [PATCH 54/58] feat(sunsdr): Kopfantwort ist Vorgabe -- A/B am Geraet gemessen Drei Durchgaenge a 60 s an der echten QRP, 96 kHz, Messgeraet an. Der Betreiber hatte das Geraet freigegeben, ExpertSDR2 war sauber beendet. A ein Empfaenger (Kanal 1 verworfen) 492 Antw./s, alle 1210 B Wiederholungen 103..113 (110) B zwei Empfaenger 996 Antw./s, alle 1210 B Wiederholungen 193..219 (207) C zwei Empfaenger + Kopfantwort 996 Antw./s, 504/s mit 10 B Wiederholungen 200..222 (214) ZWEI Ergebnisse, beide negativ fuer die jeweilige Vermutung: 1. A gegen B widerlegt "die Quittung kommt zu spaet". In A stand m_aktiveEmpfaenger auf 1, Kanal 1 wurde VOR replyToBlock verworfen -- 53522 Pakete ohne eine einzige Antwort. Das sah nach der Ursache aus. Mit zwei Empfaengern wird jeder Block beantwortet, und die Wiederholungen VERDOPPELN sich mit der Blockzahl statt zu verschwinden. Der Anteil bleibt in beiden Faellen 1,2 Pakete je Nummer. Es ist eine Quote, keine Folge fehlender Quittungen. 2. C widerlegt den blossen Kopf. Er hat nachweislich gegriffen (30265 blosse Koepfe in 60 s), die Wiederholungen bleiben wo sie waren: 207 gegen 214/s, das ist Rauschen. Vierte Vermutung, vierte Widerlegung. Die Kopfantwort bleibt trotzdem an, aus einem anderen Grund: 30265 x 1200 Byte sind rund 36 MB in 60 s, also knapp 5 Mbit/s weniger auf dem Rueckweg -- und ExpertSDR2 macht es genauso. Vorgabe damit AN, LONGPATH_SUNSDR_KOPFANTWORT=0 schaltet aus. Die Waechter-Pruefung dreht sich entsprechend um: ohneSchalterIst- DieKopfantwortAn haelt die neue Vorgabe fest, mitNullBleibtJedeAntwort- DerVolleBlock den Schalter. Der Faenger von 286be3ec bleibt. Der Messlauf meldet jetzt den Rueckweg mit (Antworten/s, davon blosse Koepfe, Groesse der letzten) -- ohne diese Zeile sieht man am Ergebnis nicht, ob der Schalter ueberhaupt gegriffen hat. Nichts davon hat gesendet: PTT vom Geraet 0 Flanken, "Geraet sendet jetzt: nein" in allen drei Laeufen. Verlust 0,00-0,04 %. tst_sunsdr_radio_connection 104/104. Co-Authored-By: Claude Opus 5 --- .../2026-10-02-sunsdr-verbindungsablauf.md | 55 +++++++++++++++++++ src/core/SunSdrRadioConnection.cpp | 15 ++--- src/core/SunSdrRadioConnection.h | 18 +++++- tests/tst_sunsdr_messlauf.cpp | 10 ++++ tests/tst_sunsdr_radio_connection.cpp | 43 +++++++++++++-- 5 files changed, 126 insertions(+), 15 deletions(-) diff --git a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md index 5a751ced1..6d0dbc4e6 100644 --- a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md +++ b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md @@ -1080,3 +1080,58 @@ 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. diff --git a/src/core/SunSdrRadioConnection.cpp b/src/core/SunSdrRadioConnection.cpp index 18ea06438..3344759b2 100644 --- a/src/core/SunSdrRadioConnection.cpp +++ b/src/core/SunSdrRadioConnection.cpp @@ -2080,13 +2080,14 @@ bool SunSdrRadioConnection::kopfAntwortEnabled() { if (!m_kopfAntwortChecked) { m_kopfAntwortChecked = true; - // Vorgabe AUS: gemessen ist, was ExpertSDR2 tut, nicht dass es hilft. - // Die A/B-Messung am Geraet entscheidet (Begruendung im Kopf). - m_kopfAntwortOn = qgetenv("LONGPATH_SUNSDR_KOPFANTWORT").trimmed() == "1"; - if (m_kopfAntwortOn) { - qCInfo(lcSunSdr) << "SunSdr: Kopfantwort an -- jede zweite Antwort " - "nur 10 Byte (LONGPATH_SUNSDR_KOPFANTWORT=1)"; - } + // 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; } diff --git a/src/core/SunSdrRadioConnection.h b/src/core/SunSdrRadioConnection.h index 080bf405f..3210aa699 100644 --- a/src/core/SunSdrRadioConnection.h +++ b/src/core/SunSdrRadioConnection.h @@ -731,9 +731,21 @@ private slots: // 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. Also als - // Schalter gebaut, Vorgabe AUS, und die A/B-Messung am Geraet - // entscheidet. LONGPATH_SUNSDR_KOPFANTWORT=1 schaltet ein. + // 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}; diff --git a/tests/tst_sunsdr_messlauf.cpp b/tests/tst_sunsdr_messlauf.cpp index f31166b10..06b76e5bb 100644 --- a/tests/tst_sunsdr_messlauf.cpp +++ b/tests/tst_sunsdr_messlauf.cpp @@ -277,6 +277,16 @@ 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()) diff --git a/tests/tst_sunsdr_radio_connection.cpp b/tests/tst_sunsdr_radio_connection.cpp index 4c66da955..2aee0f2a1 100644 --- a/tests/tst_sunsdr_radio_connection.cpp +++ b/tests/tst_sunsdr_radio_connection.cpp @@ -788,11 +788,11 @@ private slots: SunSdr::kIqHeaderSize + SunSdr::kIqPayloadSize); } - // Waechter, nicht Faenger: ohne den Schalter bleibt JEDE Antwort der - // volle Block. Diese Pruefung ist in beiden Fassungen gruen und soll das - // auch -- sie haelt die Vorgabe fest, solange die A/B-Messung am Geraet - // nicht entschieden hat. - void ohneSchalterBleibtJedeAntwortDerVolleBlock() + // 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"); @@ -818,6 +818,39 @@ private slots: 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(), From 610c3ab1977b224d6875f00effc42f0bea7ac71c Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Mon, 5 Oct 2026 10:31:42 +0200 Subject: [PATCH 55/58] docs(sunsdr): drei TX-Opcodes sind an der QRP bestaetigt -- ohne zu senden Aus expert-96k.pcap (2026-10-05, 57 Steuerrahmen, vollstaendiger Verbindungsablauf vor dem ersten Strompaket bei 0,290 s). Bis heute galt: die TX-Opcodes stammen aus der DX/PRO, die QRP benutzt nachweislich andere Nummern (0x04 statt 0x05, 0x07 statt 0x08, 0x08 statt 0x09), also sind sie Verdacht und nicht Fakt. Der Mitschnitt zeigt drei davon an der QRP SELBST, von ExpertSDR2 geschickt und vom Geraet quittiert: 0x06 MOX/PTT 00000000 quittiert 0x15 Antennenwahl 00000000 quittiert 0x17 Drive 00000000 quittiert Alle drei gehen bei JEDEM Verbinden hinaus, mit Wert 0. Damit steht fest: die Opcodes existieren auf der QRP und werden angenommen, und der Wert 0 ist nachweislich harmlos -- ExpertSDR2 schickt ihn jedes Mal, ohne Antenne und ohne Sendezustand. Was NICHT feststeht: was sie bedeuten. Dass 0x06 angenommen wird, macht es nicht zu MOX, und was ein Wert ungleich 0 bewirkt, steht in keinem Mitschnitt, in dem niemand gesendet hat. Dafuer bleibt es beim Abschluss am Ausgang. 0x24 (PA freigeben) kommt im ganzen Verbindungsablauf NICHT vor. Diese Nummer bleibt reiner Verdacht aus der DX-Quelle, und das steht jetzt auch so im Pruefplan. Der Pruefplan aendert sich dadurch: sein Schritt B fing bei der Frage an, ob die DX-Nummern auf der QRP ueberhaupt gelten -- "die Antennenwahl zuerst, erst wenn 0x15 quittiert wird, hat die Vermutung Grundlage". Das ist jetzt ohne Abschluss und ohne Senden beantwortet. Der Durchgang beginnt damit direkt bei der Frage, die nur dort zu beantworten ist. Dazu die vollstaendige Liste der sechzehn Rahmen, die ExpertSDR2 schickt und Longpath nie, mit ihren Nutzlasten. Keiner ist fuer den Empfang noetig -- Longpath hoert ohne sie -- aber die Reihenfolge ist jetzt bekannt. Eine Beobachtung zu 0x05, damit niemand sie nachbaut: die 1200 Byte sind erkennbar nicht initialisierter Speicher von ExpertSDR2 (Zeigerwerte der Form ...ef7f0000, Textreste "de_AT", "POSIX", "en_GB", "pt_BR"). Der Inhalt ist offensichtlich egal. Nichts davon hat das Funkgeraet angefasst: reine Auswertung eines vorhandenen Mitschnitts, keine Antenne angeschlossen. Co-Authored-By: Claude Opus 5 --- .../2026-10-02-sunsdr-verbindungsablauf.md | 81 +++++++++++++++++++ .../development/sunsdr-abschluss-pruefplan.md | 39 ++++++--- 2 files changed, 109 insertions(+), 11 deletions(-) diff --git a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md index 6d0dbc4e6..d786b14c6 100644 --- a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md +++ b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md @@ -1135,3 +1135,84 @@ 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** — niemand sollte versuchen, diese Bytes +nachzubauen. Wenn `0x05` je gebraucht wird, tut es jede Füllung. + +## 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. diff --git a/docs/development/sunsdr-abschluss-pruefplan.md b/docs/development/sunsdr-abschluss-pruefplan.md index 0754c68ad..d2cafdd5a 100644 --- a/docs/development/sunsdr-abschluss-pruefplan.md +++ b/docs/development/sunsdr-abschluss-pruefplan.md @@ -66,15 +66,27 @@ benutzt als die DX/PRO, aus der alle unbestätigten Zahlen stammen: | VFO-Frequenz | `0x08` | `0x09` | | erstes Byte des Rahmens | `0x03` | `0x32` | -Jede Zahl, die wir für das Senden benutzen, stammt aus derselben Quelle -wie die rechte Spalte. Sie ist also **Verdacht, nicht Fakt**: - -| Zweck | Vermuteter Opcode | Gebaut? | -| --- | --- | --- | -| MOX / PTT | `0x06` | Rahmenbauer fertig, nicht verdrahtet | -| Leistung | `0x17` | Rahmenbauer fertig, `setTxDrive` leer | -| PA freigeben | `0x24` | Rahmenbauer fertig, nicht verdrahtet | -| Antennenwahl | `0x15` | verdrahtet, **stumm** (`LONGPATH_SUNSDR_ANTENNE=1`) | +**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 @@ -88,8 +100,13 @@ 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. +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. --- From 8097d041ae132f5b390fbf9fca819d570f2ae321 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Tue, 6 Oct 2026 18:32:11 +0200 Subject: [PATCH 56/58] docs(sunsdr): Telemetrie laesst sich nicht einschalten -- und eine Ruecknahme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anlass ist ein Widerspruch an Martins Schirm: ExpertSDR2 zeigt verbunden "U: 13,1 V | I: 0,3 A | 35,5 °C" und getrennt 0,0/0,0/0,0. Das sind Messwerte aus dem Geraet. Dieses Dokument behauptet seit dem 2026-10-03 das Gegenteil ("Im Empfang gibt es bei der QRP keine Geraetemesswerte"). Beides kann nicht stimmen, und Longpath zeigt dort entsprechend nichts. Vermutung: die Werte stecken in den 77-Byte-Rahmen, die das Geraet waehrend des Stroms rund dreimal je Sekunde schickt (612 ueber 192 s im Mitschnitt) und die Longpath wegwirft -- und man muss sie vielleicht mit einem der sechzehn Rahmen anfordern, die ExpertSDR2 schickt und wir nie. Gefahren am Geraet, 60 s und 45 s, OHNE Antenne, nichts getastet: die dreizehn kleinen Rahmen ueber LONGPATH_SUNSDR_PRE mit ihren echten Nutzlasten. 0x24 ist nicht darunter, 0x15 und 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, und auf dem Stromweg erscheint kein einziger 77-Byte-Rahmen. RUECKNAHME aus 610c3ab1: dort steht, der Inhalt von 0x05 sei "offensichtlich egal", weil es nicht initialisierter Speicher ist. Das war ein Schluss, keine Messung. Mit Nullnutzlast und richtiger Pruefsumme, an derselben Stelle im Ablauf, QUITTIERT das Geraet 0x05 und 0x12 nicht -- es verwirft sie, waehrend es ExpertSDR2s Fassungen annimmt. Der Inhalt ist also nicht beliebig. Das Laengenfeld scheidet als Erklaerung aus, es ist bei beiden 0 bei 1200 bzw. 1024 Byte Nutzlast. Offen bleibt damit ein echter Lueckenposten in der Gleichwertigkeit: ExpertSDR2 zeigt Spannung, Strom und Temperatur, Longpath nichts. Es braucht einen Mitschnitt, der zeigt, WANN die 77-Byte-Rahmen einsetzen, und die echten Bytes von 0x05 und 0x12 gleich mit. Co-Authored-By: Claude Opus 5 --- .../2026-10-02-sunsdr-verbindungsablauf.md | 58 ++++++++++++++++++- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md index d786b14c6..5416df912 100644 --- a/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md +++ b/docs/architecture/2026-10-02-sunsdr-verbindungsablauf.md @@ -1205,9 +1205,21 @@ 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** — niemand sollte versuchen, diese Bytes -nachzubauen. Wenn `0x05` je gebraucht wird, tut es jede Füllung. +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 @@ -1216,3 +1228,43 @@ Der Prüfplan sah vor, mit der Antennenwahl `0x15` anzufangen, um 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. From c048a9b7aea6cde45669c7422d504aa6315731de Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Tue, 6 Oct 2026 18:46:16 +0200 Subject: [PATCH 57/58] fix(gui): eine Schriftwahl statt acht -- "nicht ueberall gleich" behoben Der Betreiber: "das rendering ist bei longpath nicht ueberall gleich", und dazu "schau dir das LOG an". Im Protokoll stand: WRN: Populating font family aliases took N ms. Replace uses of missing font family "SF Mono" with one that exists. Nachgezaehlt gab es ACHT Schreibweisen fuer dieselbe Absicht, und auf seinem Mac 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 QFont("monospace") ohne Rueckfall 1x -> gar nicht Monospace QFont("sans-serif") ohne Rueckfall 1x -> gar nicht die gemeinte Geprueft, nicht vermutet: SF Mono ist auf dem Mac NICHT als Familie ansprechbar (system_profiler: 0 Treffer; Menlo 26). Consolas ist Windows. "monospace" und "sans-serif" sind CSS-Gattungsnamen, keine Qt-Familien -- Qt sucht eine Schrift dieses Namens, findet keine und nimmt die proportionale Vorgabe. Die beiden schlimmsten Faelle waren gut sichtbar: die Verbindungsanzeige in der TITELLEISTE und der PROTOKOLLBETRACHTER zeigten gar keine dicktengleiche Schrift. Jetzt eine Quelle: LP_MONO_QSS fuer Stilvorlagen, Style::monoFont() fuer QFont, beide mit derselben Kette (Menlo, DejaVu Sans Mono, monospace). monoFont() benutzt dafuer setFamilies statt setFamily -- mit setFamily stand dort nur "Menlo", und ohne Menlo nahm Qt die naechstbeste Schrift, nicht zwingend eine dicktengleiche. Nebenbei faellt die Protokollwarnung weg: "SF Mono" wird nirgends mehr genannt, also sucht Qt beim Start auch keine Schriftnamen-Aliase mehr. tst_schriftfamilien 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. Gegenprobe gegen die alte Fassung (git archive HEAD, nicht Stash -- im Baum arbeiten mehrere Sitzungen): ALT: 33 Stilvorlagen + 4 QFont ohne Rueckfallkette -> 2 FAIL NEU: 0 + 0 -> 4 PASS Beim ersten Lauf meldete die Pruefung jede richtige Zeile als Fund: das Muster "font-family:\s*[^\"]" darf null Leerzeichen nehmen und liest dann das Leerzeichen selbst als "kein Anfuehrungszeichen". Jetzt stehen die Leerzeichen ausdruecklich im Muster. Co-Authored-By: Claude Opus 5 --- src/gui/ConnectionPanel.cpp | 2 +- src/gui/KiwiWaterfallStripWidget.cpp | 2 +- src/gui/NetworkDiagnosticsDialog.cpp | 6 +- src/gui/SpotHubDialog.cpp | 2 +- src/gui/StyleConstants.h | 46 ++++- src/gui/SupportDialog.cpp | 7 +- src/gui/TitleBar.cpp | 8 +- src/gui/VaxFirstRunDialog.cpp | 4 +- src/gui/VaxLinuxFirstRunDialog.cpp | 2 +- .../diagnostics/DiagnosticsPhaseHPages.cpp | 3 +- src/gui/diagnostics/RadioStatusPage.cpp | 2 +- src/gui/instruments/FrequencyInstrument.cpp | 2 +- src/gui/setup/DisplaySetupPages.cpp | 2 +- src/gui/setup/hardware/Hl2IoBoardTab.cpp | 10 +- src/gui/setup/hardware/Hl2OptionsTab.cpp | 3 +- src/gui/widgets/AdcOverloadBadge.cpp | 5 +- src/gui/widgets/FilterPolicyDialog.cpp | 2 +- src/gui/widgets/LayoutThumbnail.cpp | 8 +- src/gui/widgets/MetricLabel.cpp | 5 +- src/gui/widgets/OverflowChip.cpp | 3 +- src/gui/widgets/RotorLogbookPanel.cpp | 2 +- src/gui/widgets/SpectrumStatusOverlay.cpp | 5 +- src/gui/widgets/StationBlock.cpp | 5 +- src/gui/widgets/StatusBadge.cpp | 2 +- src/gui/widgets/SwrSweepPanel.cpp | 2 +- tests/CMakeLists.txt | 11 ++ tests/tst_schriftfamilien.cpp | 168 ++++++++++++++++++ 27 files changed, 281 insertions(+), 38 deletions(-) create mode 100644 tests/tst_schriftfamilien.cpp 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 b8acbeab6..458aa706c 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). diff --git a/src/gui/SupportDialog.cpp b/src/gui/SupportDialog.cpp index aba8b3bc8..ea4abccbe 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); @@ -134,7 +134,10 @@ void SupportDialog::buildUI() m_logViewer = new QPlainTextEdit(this); m_logViewer->setReadOnly(true); m_logViewer->setMaximumBlockCount(kMaxLogViewLines); - m_logViewer->setFont(QFont(QStringLiteral("Consolas"), 9)); + // War QFont("Consolas") -- eine Windows-Schrift, ohne Rueckfall. Auf + // dem Mac zeigte der Protokollbetrachter damit eine proportionale + // Schrift, in der Protokollzeilen nicht untereinander stehen. + m_logViewer->setFont(Longpath::Style::monoFont(m_logViewer->font(), 12)); // §D: #0a0a14 = Style::kStatusBarBg, #203040 = Style::kBorderSubtle, #00b4d8 = Style::kAccent. // §D exception: fg #8aa8c0 (off-palette warm-blue for log text readability). m_logViewer->setStyleSheet(Style::themed( diff --git a/src/gui/TitleBar.cpp b/src/gui/TitleBar.cpp index db74992a8..cf1e784a2 100644 --- a/src/gui/TitleBar.cpp +++ b/src/gui/TitleBar.cpp @@ -347,7 +347,11 @@ void ConnectionSegment::paintEvent(QPaintEvent*) p.setBrush(QColor(Style::hexRole(Style::kAppBg))); // war das rohe #08080a p.drawRoundedRect(rect(), 3, 3); - p.setFont(QFont(QStringLiteral("SF Mono"), 10, QFont::DemiBold)); + // War QFont("SF Mono") OHNE Rueckfall. SF Mono ist auf dem Mac nicht + // als Familie ansprechbar, also nahm Qt die proportionale + // Vorgabeschrift -- dieses Feld war damit das einzige "Monospace" im + // Programm, das gar keines war (2026-10-06 im Protokoll gefunden). + p.setFont(Longpath::Style::monoFont(font(), 13, QFont::DemiBold)); // ── 1. State-encoding dot ────────────────────────────────────────────── const QRect dotRect(8, height() / 2 - 5, 10, 10); @@ -584,7 +588,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/LayoutThumbnail.cpp b/src/gui/widgets/LayoutThumbnail.cpp index 2e86c65f3..aa8f3788e 100644 --- a/src/gui/widgets/LayoutThumbnail.cpp +++ b/src/gui/widgets/LayoutThumbnail.cpp @@ -119,7 +119,13 @@ void LayoutThumbnail::paintEvent(QPaintEvent*) if (i < static_cast(sizeof(kLetters) - 1)) { p.setPen(textColor); - p.setFont(QFont(QStringLiteral("sans-serif"), 14, QFont::Bold)); + // War QFont("sans-serif") -- ein CSS-Gattungsname, keine + // Qt-Familie. Hier ist ohnehin die Oberflaechenschrift + // gemeint, nur groesser und fett. + QFont beschriftung = p.font(); + beschriftung.setPixelSize(14); + beschriftung.setWeight(QFont::Bold); + p.setFont(beschriftung); p.drawText(cells[i], Qt::AlignCenter, QString(QLatin1Char(kLetters[i]))); } } 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 454103655..672617ef5 100644 --- a/src/gui/widgets/RotorLogbookPanel.cpp +++ b/src/gui/widgets/RotorLogbookPanel.cpp @@ -1410,7 +1410,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/SpectrumStatusOverlay.cpp b/src/gui/widgets/SpectrumStatusOverlay.cpp index 36e65b6fe..15c6be593 100644 --- a/src/gui/widgets/SpectrumStatusOverlay.cpp +++ b/src/gui/widgets/SpectrumStatusOverlay.cpp @@ -169,7 +169,10 @@ void SpectrumStatusOverlay::paintEvent(QPaintEvent*) // Zahlenchips im Bandfilter: gelesen, nicht gedrueckt. Style::paintGlassChip(p, QRect(x, y + 1, kChTagWidth, 14), 5); p.setPen(QColor(Style::kTitleText)); - p.setFont(QFont(QStringLiteral("monospace"), 9, QFont::Bold)); + // War QFont("monospace") -- das ist ein CSS-Gattungsname, keine + // Qt-Familie. Qt sucht danach eine Schrift namens "monospace", + // findet keine und nimmt die Vorgabe (2026-10-06). + p.setFont(Style::monoFont(p.font(), 12, QFont::Bold)); p.drawText(QRect(x, y + 1, kChTagWidth, 14), Qt::AlignCenter, QStringLiteral("CH %1").arg(m_chainIndex)); x += kChTagWidth + kInterPillGap; 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/tests/CMakeLists.txt b/tests/CMakeLists.txt index b8c15b2fb..f19d682d9 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -346,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, 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" From dfba33cae1013eaacab95f9d025679cdec7fefa3 Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Tue, 6 Oct 2026 19:18:21 +0200 Subject: [PATCH 58/58] chore(ci): Lauf fuer c048a9b7 anstossen Der Push von c048a9b7 hat 45 Minuten lang keinen Lauf ausgeloest -- gh pr view meldet fuer diesen Kopf gar keine Pruefungen, obwohl der Lauf fuer den Commit davor (8097d041) sauber durchgelaufen ist. Ohne gruene Pruefung wird nicht gemergt (Regel nach #121), also hier ein leerer Commit, der den Lauf anstoesst. Co-Authored-By: Claude Opus 5