From b173b7f542e58e79f44908d33f756eeb0020e452 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Mon, 24 Aug 2026 19:23:13 +0300 Subject: [PATCH] =?UTF-8?q?feat(tx):=20=D1=82=D0=B8=D1=85=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=B4=D0=B5=D1=82=D0=B5=D0=BA=D1=82=D0=BE=D1=80=20=D0=B3=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BE=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B8=20TX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Всплеск, рождённый нашим трактом, невозможен без одного события: очередь DUC простаивает дольше подушки отправителя (DUC_FIFO_THROTTLE = 2000 отсчётов при 192 кГц = 10.42 мс). Съём 2026-08-21 это показал прямо: шесть всплесков — шесть простоев 10.3-23.5 мс, а паузы 8 мс и короче не дали ни одного. Значит счётчик таких простоев — полный детектор «наш/не наш», и оператору больше не нужно называть интервал на глаз. Новый юнит TxHealth.pas. Включён всегда: переменной окружения нет намеренно — прибор, который надо не забыть включить, к моменту дефекта выключен. * поток отправителя DUC мерит каждый простой очереди на передаче — одно чтение монотонных часов на ПЕРЕХОД, ни файлов, ни локов (обе ветки, win и unix); * планировщик TCI публикует рядом состояние пейсинга (аванс, долг, в полёте, квант, шаг, период пачек клиента) — простыми записями, без лока: числа диагностические, а лок в потоке отправителя DUC недопустим для тайминга; * сетевой поток считает бит HPS_FIFO_EMPTY из HP-статуса радио — поле ГЛУБИНЫ FIFO на этой прошивке статично (256) и веры ему нет, а бит мы не читали вовсе; * ★журнал пишет ТОЛЬКО потребитель — таймер UI (100 мс) и цикл демона через TxHealthService. В тракте передачи диска нет по построению. Последнее — правило, оплаченное регрессом: прошлый прибор (TxTrace) сбрасывал дамп синхронно в хвосте SetMOX, и эти десятки миллисекунд закрывали гонку в пейсинге TCI (лечение — fa38fa7). Инструмент чинил дефект собой. Журнал ~/.config/ewsdr/txhealth.log, строка на передачу: «чисто» либо «ОПАСНО (первое на N мс)» плюс подробности с контекстом пейсинга. В статусной строке UI — «·сухо N» за сеанс, чтобы не помнить, какая посылка была плохой. Стенд: секция G в test/tci (порог подушки, сводка, момент от фронта PTT, контекст рядом с событием, молчание вне передачи) — 276/276. Диск стенд не трогает намеренно: берёт сводки через TxHealthTake/TxHealthDetail, мимо TxHealthService, иначе писал бы в настоящий журнал пользователя. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Bkwwyj7xVRrqnSVEseTRfV --- HPSDRNetwork.pas | 23 ++- MainForm.pas | 22 ++- RadioController.pas | 24 ++- TCIAdapter.pas | 8 +- TxHealth.pas | 358 +++++++++++++++++++++++++++++++++++++++++++ ewsdrd.lpr | 5 +- test/hpsdr/README.md | 49 ++++++ test/tci/tcitest.pas | 80 +++++++++- 8 files changed, 559 insertions(+), 10 deletions(-) create mode 100644 TxHealth.pas diff --git a/HPSDRNetwork.pas b/HPSDRNetwork.pas index 1197059..1e98e91 100644 --- a/HPSDRNetwork.pas +++ b/HPSDRNetwork.pas @@ -19,7 +19,7 @@ uses {$ELSE} Sockets, BaseUnix, {$ENDIF} - HPSDRProtocol, SyncObjs, Settings, RadioBackend; + HPSDRProtocol, SyncObjs, Settings, RadioBackend, TxHealth, PlatformUtils; const DUC_TX_QUEUE_SIZE = 256; // power of two, bounded latency on TX underrun/overrun @@ -497,6 +497,7 @@ const DUC_WIN_SPIN_US = 350; // final part of 1.25 ms interval: avoid Sleep jitter var Pkt: TDUCIQPacket; + DryFrom: Int64; // детектор: начало простоя очереди, мкс (см. TxHealth) {$IFDEF WINDOWS} QPCFreq: Int64; QPCValue: Int64; @@ -551,11 +552,15 @@ begin if not QueryPerformanceFrequency(QPCFreq) or (QPCFreq <= 0) then QPCFreq := 1000; NextDueUs := ClockUs; + DryFrom := 0; while not Terminated do begin if not FNet.DequeueDUCIQ(Pkt) then begin + // Детектор голодания: очередь пуста на передаче. Стоимость — одно чтение + // монотонных часов на ПЕРЕХОД, ни файлов, ни локов (см. TxHealth.pas). + if FNet.FIsTransmitting and (DryFrom = 0) then DryFrom := MonotonicUs; RTLEventWaitFor(FNet.FDUCIQSem, 20); NowUs := ClockUs; if NextDueUs < NowUs - DUC_PACKET_INTERVAL_US then @@ -568,6 +573,12 @@ begin if not FNet.FIsTransmitting then Continue; + if DryFrom <> 0 then + begin + TxHealthDrought(DryFrom, MonotonicUs, FNet.FDUCIQCount); + DryFrom := 0; + end; + FNet.PackSeqBytes(Pkt.Seq, FNet.NextSeq(FNet.FSeqDUCIQ)); FNet.DoSendTo(FNet.FSocket, Pkt, SizeOf(Pkt), FNet.FDevice.IPAddress, FNet.FPortDUCIQ); @@ -580,12 +591,16 @@ begin {$ELSE} VirtualSamples := 0; LastTick := GetTickCount64; + DryFrom := 0; while not Terminated do begin if not FNet.DequeueDUCIQ(Pkt) then begin DrainVirtualFIFO; + // Детектор голодания: очередь пуста на передаче. Стоимость — одно чтение + // монотонных часов на ПЕРЕХОД, ни файлов, ни локов (см. TxHealth.pas). + if FNet.FIsTransmitting and (DryFrom = 0) then DryFrom := MonotonicUs; // Очередь пуста. НЕ инжектируем нулевые пакеты — 1.25мс тишины прямо // в поток сэмплов = разрыв данных = всплеск на водопаде/в эфире. Как // dl1ycf pihpsdr (txiq_thread: пустая очередь → просто sem_wait): ждём @@ -604,6 +619,12 @@ begin if not FNet.FIsTransmitting then Continue; + if DryFrom <> 0 then + begin + TxHealthDrought(DryFrom, MonotonicUs, FNet.FDUCIQCount); + DryFrom := 0; + end; + FNet.PackSeqBytes(Pkt.Seq, FNet.NextSeq(FNet.FSeqDUCIQ)); FNet.DoSendTo(FNet.FSocket, Pkt, SizeOf(Pkt), FNet.FDevice.IPAddress, FNet.FPortDUCIQ); diff --git a/MainForm.pas b/MainForm.pas index b3d20f9..1f7f80f 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -40,7 +40,7 @@ uses PanZoomBar, PanafallPanel, PanDisplayPopup, PureSignalPopup, FlatPopupMenu, WidebandView, StatusBar, - PlatformUtils, + PlatformUtils, TxHealth, RadioModes, WinFirewall, BoardUtils, WisdomBuilder, UISync, @@ -3822,9 +3822,14 @@ var PeakTarget, MinTarget: Double; PeakDiff, MinDiff: Double; PeakAlpha, MinAlpha: Double; + TxhSuffix: string; begin // Затухание спотов не зависит от того, крутится ли приём. ServiceDXCluster; + // Детектор голодания очереди TX: сводки за прошедшие передачи → журнал. + // ★Это ЕДИНСТВЕННОЕ место, где прибор трогает диск, и оно намеренно вынесено + // из тракта передачи в таймер UI (см. TxHealth.pas). + TxHealthService; if not FController.FRunning then begin @@ -3877,17 +3882,24 @@ begin if FController.FWDSPReady then begin + // Опасные простои очереди TX за сеанс: столько раз очередь пустела дольше + // подушки отправителя, то есть столько раз в эфир могла уйти голая несущая. + // Ноль — значит всплеск, если он был виден, родился НЕ в нашем тракте. + if TxHealthDangerTotal > 0 then + TxhSuffix := Format(' ·сухо %d', [TxHealthDangerTotal]) + else + TxhSuffix := ''; if FController.FTransmitting then begin case FController.FDSPEngine.TXMicSource of - txmsSoundCard: SetStatusText(5, 'TX sound card'); - txmsWeb: SetStatusText(5, 'TX web mic'); + txmsSoundCard: SetStatusText(5, 'TX sound card' + TxhSuffix); + txmsWeb: SetStatusText(5, 'TX web mic' + TxhSuffix); else - SetStatusText(5, 'TX radio mic'); + SetStatusText(5, 'TX radio mic' + TxhSuffix); end; end else - SetStatusText(5, 'TX idle'); + SetStatusText(5, 'TX idle' + TxhSuffix); end; end; diff --git a/RadioController.pas b/RadioController.pas index babcc53..f918c75 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -51,7 +51,7 @@ uses HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings, WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, BeaconFEC, DMRDecoder, Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore, CWMorse, CWKeyer, - CWDecoder; + CWDecoder, TxHealth; // Панадаптеры (этап 3): потолок MAX_PANS живёт в WDSPEngine (общий для // движка/контроллера/UI). Фактический лимит бэкенда — Caps.MaxPans (Pluto=1). @@ -1864,6 +1864,11 @@ begin FHPpPLL := (Status.StatusBits and HPS_PLL_LOCKED) <> 0; FHPpHWPTT := (Status.StatusBits and HPS_PTT) <> 0; FHPpOverload := Status.ADCOverload; + // ★Жалоба самого радио: бит «DUC FIFO пуст». Поле ГЛУБИНЫ FIFO (байты 35-36) + // на этой прошивке статично и веры ему нет, а бит мы не читали вовсе — + // если прошивка его заполняет, это независимый свидетель голодания. + if FTransmitting and ((Status.StatusBits and HPS_FIFO_EMPTY) <> 0) then + TxHealthFifoEmpty; // Состояние ключа/манипулятора в разъёме трансивера (сетевой поток). // Касание обрывает программную передачу — как в Thetis/pihpsdr («key hit» // останавливает CAT CW), иначе оператор не может вклиниться. ★Обрываем по @@ -6292,6 +6297,7 @@ end; procedure TRadioController.SetMOX(On_: Boolean); var WasTransmitting: Boolean; + TxhSrc: Integer; begin // DMR support is receive-only. This guard also covers CAT, web and hardware // PTT. FM RAW is a flat external-modem transmit path. @@ -6426,6 +6432,19 @@ begin if FTransmitting and FDisplayDuplex then FDSPEngine.SetDisplaySourceTX(False); end; + // Детектор голодания очереди TX: фронт «в эфир» открывает передачу для + // счётчиков. Стоит ПОСЛЕ блока движка — источник модуляции решается там. + // Ввода-вывода тут нет и быть не может: журнал пишет потребитель (таймер UI / + // цикл демона), см. TxHealth.pas. + if FTransmitting and (not WasTransmitting) then + begin + if FTCIMicActive then TxhSrc := txhSrcTCI + else if FWebMicActive then TxhSrc := txhSrcWeb + else if Assigned(FDSPEngine) and (FDSPEngine.TXMicSource = txmsSoundCard) then + TxhSrc := txhSrcCard + else TxhSrc := txhSrcRadio; + TxHealthPtt(True, TxhSrc); + end; // Сетевая PTT-последовательность TX-off: сначала снимаем PTT, затем реле RX. if (not FTransmitting) and Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then begin @@ -6443,6 +6462,9 @@ begin // (оператор держит T/R руками) — вооружение обязано пойти за этой кнопкой. SyncCWKeyer; Changed(rfTransmitting); + // Передача кончилась — складываем сводку в память. Диск не трогаем (★прошлый + // прибор писал дамп ровно здесь и тем самым прикрывал гонку в пейсинге). + if WasTransmitting and (not FTransmitting) then TxHealthPtt(False, 0); end; procedure TRadioController.SetTune(On_: Boolean); diff --git a/TCIAdapter.pas b/TCIAdapter.pas index 891a347..c444956 100644 --- a/TCIAdapter.pas +++ b/TCIAdapter.pas @@ -44,7 +44,7 @@ interface uses Classes, SysUtils, DateUtils, Math, SyncObjs, RadioController, RadioBackend, WDSPEngine, Settings, PlatformUtils, - DXSpotStore, TCIProtocol, TCIServer, TCIStreams; + DXSpotStore, TCIProtocol, TCIServer, TCIStreams, TxHealth; const // ★Приёмник TCI: 0 — главный тракт (каналы A/B = VFO A/B), N — слайс СЛОТА @@ -3997,6 +3997,12 @@ begin TxNoteRequest(NowUs); Inc(Sent); end; + // Снимок пейсинга для детектора голодания: поток отправителя DUC приложит + // его к очередному опасному простою и тем ответит, чей это простой — + // аванса, зернистости клиента или планировщика. Простые записи, без лока и + // без ввода-вывода (см. TxHealth.pas). + TxHealthPublishTci(FTxLead, Round(FTxOwed), Round(FTxInFlight), FTxQuantum, + Integer(PeriodUs), Round(FTxGapUs)); finally FTxLock.Leave; end; diff --git a/TxHealth.pas b/TxHealth.pas new file mode 100644 index 0000000..045fe97 --- /dev/null +++ b/TxHealth.pas @@ -0,0 +1,358 @@ +unit TxHealth; + +{ + TxHealth.pas — тихий детектор голодания очереди TX. + + ЗАЧЕМ. Всплеск своего сигнала, рождённый НАШИМ трактом, физически невозможен + без одного события: очередь DUC простаивает дольше подушки отправителя + (DUC_FIFO_THROTTLE = 2000 отсчётов при 192 кГц = 10.42 мс). Доказано pcap- + съёмом: шесть всплесков в дампе — шесть простоев 10.3-23.5 мс, а паузы 8 мс и + короче не дали ни одного. Значит счётчик таких простоев — ПОЛНЫЙ детектор: + молчит он — всплеск не наш (смотреть приёмный тракт, PA, реле, спуры), + сработал — наш, и момент известен с точностью до микросекунд. + + ★ГЛАВНОЕ ПРАВИЛО, оплаченное регрессом. Прошлый прибор (TxTrace) писал дамп + СИНХРОННО в хвосте SetMOX — десятки миллисекунд ровно в том окне, где жила + гонка FTxRunning, и тем самым ЧИНИЛ дефект собой: с зондами всплесков не было, + без них они возвращались. Поэтому здесь в тракте передачи не делается НИЧЕГО, + кроме записи в память: + * потоки передачи (отправитель DUC, сетевой поток статуса, планировщик TCI) + только пишут в кольцо и в счётчики — ни файлов, ни локов, ни форматирования; + * ВСЮ работу с диском делает ПОТРЕБИТЕЛЬ (таймер UI 100 мс / цикл демона), + вызывая TxHealthService. Своих потоков юнит не заводит. + + Детектор включён ВСЕГДА: переменной окружения нет намеренно — прибор, который + надо не забыть включить, к моменту дефекта выключен. +} + +{$IFDEF FPC} + {$MODE Delphi} +{$ENDIF} + +interface + +const + // Подушка отправителя: DUC_FIFO_THROTTLE = 2000 отсчётов при DUC_SAMPLE_RATE + // = 192000 (копия из HPSDRNetwork.pas — юнит намеренно ни от чего не зависит). + // Простой ДЛИННЕЕ неё гарантированно осушает FIFO радио: в эфире остаётся + // голая несущая гетеродина DUC. + TXH_CUSHION_US = 10417; + + // Источник модуляции в сводке (что именно кормило передатчик). + txhSrcRadio = 0; + txhSrcCard = 1; + txhSrcWeb = 2; + txhSrcTCI = 3; + +type + // Один опасный простой очереди — с контекстом пейсинга TCI на тот момент. + TTxDrought = record + Session: LongInt; // к какой передаче относится + AtMs: Integer; // от фронта PTT + LenUs: Integer; // длительность простоя + Queue: Integer; // глубина очереди DUC на выходе из простоя + Lead: Integer; // аванс, кадров (0 — модуляция не из TCI) + Owed: Integer; // долг, кадров + InFlight: Integer; // в полёте, кадров + Quantum: Integer; // квант запроса, кадров + StepUs: Integer; // шаг планировщика + GapUs: Integer; // период между пачками клиента + end; + + // Сводка по одной передаче. + TTxSession = record + Id: LongInt; + StartedAt: TDateTime; + LenMs: Integer; + Source: Integer; // txhSrc* + Droughts: Integer; // простоев всего + Dangerous: Integer; // из них длиннее подушки + LongestUs: Integer; + FirstDangerMs: Integer; // -1 — опасных не было + FifoEmpty: Integer; // HP-статусов с битом «DUC FIFO пуст» + end; + +// ── Пишут потоки передачи (только память) ─────────────────────────────────── +procedure TxHealthPtt(On_: Boolean; Source: Integer); +procedure TxHealthDrought(FromUs, ToUs: Int64; QueueDepth: Integer); +procedure TxHealthFifoEmpty; +procedure TxHealthPublishTci(Lead, Owed, InFlight, Quantum, StepUs, + GapUs: Integer); + +// ── Зовёт потребитель (UI-таймер / цикл демона) ───────────────────────────── +{ Забрать готовые сводки и дописать их в ~/.config/ewsdr/txhealth.log. + Возвращает число записанных передач. Работа с диском — только здесь. } +function TxHealthService: Integer; +{ Снять следующую сводку, не трогая диск (Service стоит на этих двух; стенду и + любому иному потребителю — тоже). } +function TxHealthTake(out S: TTxSession): Boolean; +{ Index-й опасный простой этой передачи, пока он ещё жив в кольце. } +function TxHealthDetail(Id: LongInt; Index: Integer; out D: TTxDrought): Boolean; +{ Опасных простоев за весь сеанс — для индикации в UI. } +function TxHealthDangerTotal: Integer; +{ Последняя записанная сводка (для UI/стенда), False — сводок ещё не было. } +function TxHealthLastSession(out S: TTxSession): Boolean; +{ Путь журнала. } +function TxHealthLogPath: string; + +implementation + +uses + SysUtils, Math, PlatformUtils; + +const + RING_SIZE = 256; // опасных простоев в памяти (хвост важнее) + RING_MASK = RING_SIZE - 1; + SESS_SIZE = 32; // сводок, ждущих потребителя + SESS_MASK = SESS_SIZE - 1; + MAX_DETAIL = 24; // строк подробностей на передачу в журнале + +var + // Кольцо опасных простоев: пишет поток отправителя DUC, читает потребитель. + Ring: array[0..RING_SIZE - 1] of TTxDrought; + RingSeq: LongInt = 0; + + // Кольцо сводок: пишет поток, снимающий PTT, читает потребитель. + Sess: array[0..SESS_SIZE - 1] of TTxSession; + SessSeq: LongInt = 0; // сколько сводок сложено + SessRead: LongInt = 0; // сколько забрал потребитель + + // Текущая передача. + CurId: LongInt = 0; + CurStartUs: Int64 = 0; + CurStartAt: TDateTime = 0; + CurSource: Integer = txhSrcRadio; + CurOn: Boolean = False; + CurDroughts, CurDanger, CurLongest, CurFirstMs, CurFifoEmpty: Integer; + + // Снимок пейсинга TCI — публикует планировщик, читает поток отправителя. + // Простые записи без лока: числа диагностические, рвань в них безвредна, а + // лок в потоке отправителя DUC — недопустимый риск для тайминга. + PubLead, PubOwed, PubFlight, PubQuantum, PubStep, PubGap: Integer; + + DangerTotal: Integer = 0; + LastSess: TTxSession; + HaveLast: Boolean = False; + +procedure TxHealthPtt(On_: Boolean; Source: Integer); +// Фронт PTT. Только память: несколько записей, никакого ввода-вывода. +var + S: TTxSession; + Idx: LongInt; +begin + if On_ then + begin + if CurOn then Exit; + InterlockedIncrement(CurId); + CurStartUs := MonotonicUs; + CurStartAt := Now; + CurSource := Source; + CurDroughts := 0; + CurDanger := 0; + CurLongest := 0; + CurFirstMs := -1; + CurFifoEmpty := 0; + CurOn := True; + Exit; + end; + + if not CurOn then Exit; + CurOn := False; + S.Id := CurId; + S.StartedAt := CurStartAt; + S.LenMs := Integer((MonotonicUs - CurStartUs) div 1000); + S.Source := CurSource; + S.Droughts := CurDroughts; + S.Dangerous := CurDanger; + S.LongestUs := CurLongest; + S.FirstDangerMs := CurFirstMs; + S.FifoEmpty := CurFifoEmpty; + Idx := InterlockedIncrement(SessSeq) - 1; + Sess[Idx and SESS_MASK] := S; +end; + +procedure TxHealthDrought(FromUs, ToUs: Int64; QueueDepth: Integer); +// Поток отправителя DUC, на выходе очереди из простоя. Дороже всего здесь одно +// вычитание: время простоя измерил вызывающий, он же держит его границы. +var + LenUs, AtMs: Integer; + Idx: LongInt; + P: ^TTxDrought; +begin + if not CurOn then Exit; + LenUs := Integer(ToUs - FromUs); + if LenUs < 0 then Exit; + Inc(CurDroughts); + if LenUs > CurLongest then CurLongest := LenUs; + if LenUs <= TXH_CUSHION_US then Exit; + + Inc(CurDanger); + InterlockedIncrement(DangerTotal); + AtMs := Integer((FromUs - CurStartUs) div 1000); + if CurFirstMs < 0 then CurFirstMs := AtMs; + + Idx := InterlockedIncrement(RingSeq) - 1; + P := @Ring[Idx and RING_MASK]; + P^.Session := CurId; + P^.AtMs := AtMs; + P^.LenUs := LenUs; + P^.Queue := QueueDepth; + P^.Lead := PubLead; + P^.Owed := PubOwed; + P^.InFlight := PubFlight; + P^.Quantum := PubQuantum; + P^.StepUs := PubStep; + P^.GapUs := PubGap; +end; + +procedure TxHealthFifoEmpty; +// Сетевой поток, разбор HP-статуса: радио говорит, что его DUC FIFO пуст. +// ★На этой прошивке поле ГЛУБИНЫ FIFO статично (всегда 256) и веры ему нет — +// а вот бит в байте статуса мы раньше не читали вовсе. Если прошивка его +// заполняет, получаем жалобу самого железа как независимого свидетеля. +begin + if CurOn then Inc(CurFifoEmpty); +end; + +procedure TxHealthPublishTci(Lead, Owed, InFlight, Quantum, StepUs, + GapUs: Integer); +// Планировщик TCI. Публикация состояния пейсинга для контекста простоев. +begin + PubLead := Lead; + PubOwed := Owed; + PubFlight := InFlight; + PubQuantum := Quantum; + PubStep := StepUs; + PubGap := GapUs; +end; + +function TxHealthDangerTotal: Integer; +begin + Result := DangerTotal; +end; + +function TxHealthLastSession(out S: TTxSession): Boolean; +begin + Result := HaveLast; + if Result then S := LastSess; +end; + +function TxHealthLogPath: string; +begin + Result := GetAppCfgDir + 'txhealth.log'; +end; + +function SourceName(Src: Integer): string; +begin + case Src of + txhSrcTCI: Result := 'TCI'; + txhSrcWeb: Result := 'web'; + txhSrcCard: Result := 'звуковая карта'; + else + Result := 'микрофон радио'; + end; +end; + +function TxHealthTake(out S: TTxSession): Boolean; +var Have: LongInt; +begin + Have := SessSeq; + if Have <= SessRead then Exit(False); + // Сводки, которые кольцо успело затереть, пропускаем: журнал честно отражает + // хвост, а отстать на SESS_SIZE передач потребитель может лишь простояв + // секунды — чего с таймером 100 мс не бывает. + if Have - SessRead > SESS_SIZE then SessRead := Have - SESS_SIZE; + S := Sess[SessRead and SESS_MASK]; + Inc(SessRead); + LastSess := S; + HaveLast := True; + Result := True; +end; + +function TxHealthDetail(Id: LongInt; Index: Integer; out D: TTxDrought): Boolean; +var + i, Seen, Total: LongInt; +begin + Result := False; + Total := RingSeq; + Seen := 0; + for i := Max(0, Total - RING_SIZE) to Total - 1 do + begin + if Ring[i and RING_MASK].Session <> Id then Continue; + if Seen = Index then + begin + D := Ring[i and RING_MASK]; + Exit(True); + end; + Inc(Seen); + end; +end; + +procedure WriteSession(var F: TextFile; const S: TTxSession); +// Одна передача: строка сводки плюс подробности по опасным простоям. +var + Shown: Integer; + R: TTxDrought; + Verdict: string; +begin + if S.Dangerous > 0 then + Verdict := 'ОПАСНО' + else + Verdict := 'чисто'; + if S.FirstDangerMs >= 0 then + Verdict := Verdict + Format(' (первое на %d мс)', [S.FirstDangerMs]); + WriteLn(F, Format('%s %s передача %.1f с, источник %s: осушений %d, ' + + 'опасных %d, самое длинное %.1f мс, FIFO-EMPTY %d', + [FormatDateTime('yyyy-mm-dd hh:nn:ss', S.StartedAt), Verdict, + S.LenMs / 1000.0, SourceName(S.Source), S.Droughts, S.Dangerous, + S.LongestUs / 1000.0, S.FifoEmpty])); + if S.Dangerous <= 0 then Exit; + + Shown := 0; + while TxHealthDetail(S.Id, Shown, R) do + begin + Inc(Shown); + if Shown > MAX_DETAIL then + begin + WriteLn(F, ' … остальные опущены'); + Break; + end; + WriteLn(F, Format(' опасный #%d: на %d мс от PTT, %.1f мс, очередь %d, ' + + 'аванс %d, долг %d, в полёте %d, квант %d, шаг %d мкс, ' + + 'пачка клиента %.1f мс', + [Shown, R.AtMs, R.LenUs / 1000.0, R.Queue, R.Lead, R.Owed, + R.InFlight, R.Quantum, R.StepUs, R.GapUs / 1000.0])); + end; +end; + +function TxHealthService: Integer; +// ★Единственное место с диском, и зовут его только потребители (таймер UI, +// цикл демона) — никогда не поток передачи. +var + F: TextFile; + S: TTxSession; +begin + Result := 0; + if SessSeq <= SessRead then Exit; + + AssignFile(F, TxHealthLogPath); + try + if FileExists(TxHealthLogPath) then Append(F) else Rewrite(F); + try + while TxHealthTake(S) do + begin + WriteSession(F, S); + Inc(Result); + end; + finally + CloseFile(F); + end; + except + // Журнал — вспомогательный: не пишется, так и ладно. Сводки при этом + // считаем забранными, иначе они копились бы и пытались писаться каждые + // 100 мс. + on E: Exception do SessRead := SessSeq; + end; +end; + +end. diff --git a/ewsdrd.lpr b/ewsdrd.lpr index f04e51c..beea708 100644 --- a/ewsdrd.lpr +++ b/ewsdrd.lpr @@ -28,7 +28,7 @@ uses {$IFDEF UNIX}cthreads, BaseUnix,{$ENDIF} Classes, SysUtils, Math, RadioController, WebServer, WebAdapter, WDSPEngine, - HPSDRNetwork, Settings, WDSP, PlatformUtils, DeviceStore, BoardUtils; + HPSDRNetwork, Settings, WDSP, PlatformUtils, DeviceStore, BoardUtils, TxHealth; const PUSH_INTERVAL_MS = 50; // период исходящего web-зеркала (как GUI-таймер) @@ -470,6 +470,9 @@ begin // Телеграф: фронты «железо в эфире» (у Pluto HP-статуса нет вовсе, и // отпустить реле T/R после выдержки больше некому). FCtrl.ServiceCWKeyed; + // Детектор голодания очереди TX: сводки за прошедшие передачи → журнал. + // Единственное место, где прибор трогает диск (см. TxHealth.pas). + TxHealthService; lastBeacon := nowMs; end; if nowMs - lastPush >= PUSH_INTERVAL_MS then diff --git a/test/hpsdr/README.md b/test/hpsdr/README.md index 6089f09..aa2ed62 100644 --- a/test/hpsdr/README.md +++ b/test/hpsdr/README.md @@ -105,6 +105,55 @@ TX голодала, в эфире дыры. Тут же диапазон глу * `capture.sh` — кольцевой съём tcpdump + лог потерь ядра. * `pcap_tx_scan.py` — разбор pcap, чистый Python без зависимостей (~40 МБ/с). +## Тихий детектор голодания очереди TX (включён всегда) + +Всплеск, рождённый НАШИМ трактом, невозможен без одного события: очередь DUC +простаивает дольше подушки отправителя (`DUC_FIFO_THROTTLE` = 2000 отсчётов при +192 кГц = **10.42 мс**). Это доказано съёмом 2026-08-21: шесть всплесков — шесть +простоев 10.3–23.5 мс, паузы 8 мс и короче не дали ни одного. Поэтому счётчик +таких простоев — **полный** детектор, и он теперь живёт в самой программе +(`TxHealth.pas`), без переменных окружения: прибор, который надо не забыть +включить, к моменту дефекта выключен. + +Что делает: + +* поток отправителя DUC мерит каждый простой очереди на передаче — одно чтение + монотонных часов на переход, ни файлов, ни локов; +* планировщик TCI публикует рядом состояние пейсинга (аванс, долг, в полёте, + квант, шаг, период пачек клиента) — простыми записями; +* сетевой поток считает биты «DUC FIFO пуст» из HP-статуса радио (поле ГЛУБИНЫ + на этой прошивке статично и веры ему нет, а бит раньше не читался вовсе); +* журнал пишет **потребитель** — таймер UI (100 мс) или цикл демона, никогда не + тракт передачи. + +★Последнее — правило, оплаченное регрессом: прошлый прибор (`TxTrace`) сбрасывал +дамп синхронно в хвосте `SetMOX`, и эти десятки миллисекунд закрывали гонку в +пейсинге TCI. С зондами всплесков не было, без них они возвращались — +инструмент чинил дефект собой (лечение: коммит `fa38fa7`). + +Журнал: `~/.config/ewsdr/txhealth.log`, по строке на передачу. + +``` +2026-08-24 19:08:03 ОПАСНО (первое на 100 мс) передача 12.4 с, источник TCI: осушений 17, опасных 1, самое длинное 18.0 мс, FIFO-EMPTY 0 + опасный #1: на 100 мс от PTT, 18.0 мс, очередь 0, аванс 3584, долг 2200, в полёте 1024, квант 512, шаг 5333 мкс, пачка клиента 44.1 мс +``` + +Как читать: + +* **`чисто`** — за эту передачу очередь ни разу не пересыхала дольше подушки. + Значит увиденный всплеск родился НЕ в нашем тракте: смотреть приёмный тракт + (дыра в DDC — на экране есть, в эфире нет), PA, реле, спуры. +* **`ОПАСНО`** — простой был, и подробная строка называет виновника по контексту: + пачка клиента длиннее аванса (`пачка клиента` > `аванс`/48 мс) — зернистость + MSHV; `долг` у потолка при малом `в полёте` — планировщик не успевает; + `шаг` заметно больше половины кванта — проспал поток планировщика. +* Строка статуса в UI показывает `·сухо N` — сколько опасных простоев набралось + за сеанс, чтобы не приходилось помнить, какая посылка была плохой. + +Правило разбора то же, что и с pcap: **дальше сопоставлять**. Детектор говорит +«очередь сохла в такой-то момент», `pcap_tx_scan.py` по тому же куску времени +скажет, ушёл ли всплеск в эфир или это артефакт приёма. + ## Приборы внутри процесса: кольцевая трассировка TX-тракта ★**Сняты из кода 2026-08-24** — пейсинг TX проверен на железе, всплесков нет, и diff --git a/test/tci/tcitest.pas b/test/tci/tcitest.pas index 44aaf42..bb292e4 100644 --- a/test/tci/tcitest.pas +++ b/test/tci/tcitest.pas @@ -28,7 +28,7 @@ program tcitest; uses cthreads, Classes, SysUtils, Math, SyncObjs, Sockets, BaseUnix, TCIProtocol, TCIStreams, TCIServer, TCIAdapter, RadioController, - WsClient, WebUtils, WDSPEngine, Settings, CWMorse; + WsClient, WebUtils, WDSPEngine, Settings, CWMorse, TxHealth, PlatformUtils; var Passed, Failed: Integer; @@ -2207,6 +2207,83 @@ begin end; end; +procedure TestTxHealth; +// Детектор голодания очереди TX (TxHealth.pas). Стенд проверяет его отдельно от +// сети: порог «опасного» простоя, сводку по передаче, контекст пейсинга рядом с +// событием и то, что вне передачи прибор молчит. +// ★Диска здесь нет намеренно: TxHealthService писал бы в НАСТОЯЩИЙ журнал +// пользователя, поэтому стенд берёт сводки через TxHealthTake. +var + Base: Int64; + S: TTxSession; + D: TTxDrought; + Was: Integer; +begin + WriteLn('G. Детектор голодания очереди TX'); + + // Сводки, накопленные прошлыми частями стенда (они гоняют SetMOX), забираем, + // чтобы мерить своё. + while TxHealthTake(S) do ; + Was := TxHealthDangerTotal; + + // Вне передачи прибор обязан молчать: простой на приёме — это норма, DUC-поток + // тогда не шлёт вообще ничего. + Base := MonotonicUs; + TxHealthDrought(Base, Base + 50000, 0); + TxHealthFifoEmpty; + Check('детектор: вне передачи простой не считается', + TxHealthDangerTotal = Was, IntToStr(TxHealthDangerTotal - Was)); + + TxHealthPtt(True, txhSrcTCI); + TxHealthPublishTci(3584, 2200, 1024, 512, 5333, 44100); + Base := MonotonicUs; + // Короткий простой: подушка отправителя его покрывает, в эфир он не выходит. + TxHealthDrought(Base, Base + 8000, 7); + Check('детектор: простой короче подушки не опасен', + TxHealthDangerTotal = Was, IntToStr(TxHealthDangerTotal - Was)); + // Длинный: FIFO радио сохнет, в эфире голая несущая. + TxHealthDrought(Base + 100000, Base + 118000, 0); + TxHealthFifoEmpty; + Check('детектор: простой длиннее подушки опасен', + TxHealthDangerTotal = Was + 1, IntToStr(TxHealthDangerTotal - Was)); + TxHealthPtt(False, 0); + + Check('детектор: сводка передачи снялась', TxHealthTake(S)); + Check('детектор: простоев посчитано верно', S.Droughts = 2, IntToStr(S.Droughts)); + Check('детектор: опасных посчитано верно', S.Dangerous = 1, IntToStr(S.Dangerous)); + Check('детектор: самое длинное — длиннее подушки', + S.LongestUs = 18000, IntToStr(S.LongestUs)); + Check('детектор: момент первого опасного — от фронта PTT', + (S.FirstDangerMs >= 90) and (S.FirstDangerMs <= 130), + IntToStr(S.FirstDangerMs)); + Check('детектор: источник модуляции в сводке', S.Source = txhSrcTCI); + Check('детектор: жалоба радио (FIFO EMPTY) считается только на передаче', + S.FifoEmpty = 1, IntToStr(S.FifoEmpty)); + + // ★Ради этого прибор и делался: рядом с опасным простоем лежит состояние + // пейсинга — по нему видно, чей простой (аванса, зернистости клиента или + // планировщика), и оператору не нужно называть интервал на глаз. + Check('детектор: контекст пейсинга лежит рядом с событием', + TxHealthDetail(S.Id, 0, D)); + Check('детектор: в контексте аванс и пачка клиента', + (D.Lead = 3584) and (D.GapUs = 44100) and (D.Quantum = 512) and + (D.LenUs = 18000), + Format('аванс %d, пачка %d, квант %d, простой %d', + [D.Lead, D.GapUs, D.Quantum, D.LenUs])); + Check('детектор: второго опасного в этой передаче нет', + not TxHealthDetail(S.Id, 1, D)); + + // Чистая передача: сводка обязана прийти с честным «опасных 0» — именно она и + // снимает подозрение с нашего тракта. + TxHealthPtt(True, txhSrcCard); + Base := MonotonicUs; + TxHealthDrought(Base, Base + 3000, 9); + TxHealthPtt(False, 0); + Check('детектор: чистая передача помечена как чистая', + TxHealthTake(S) and (S.Dangerous = 0) and (S.Droughts = 1) and + (S.FirstDangerMs < 0), Format('опасных %d', [S.Dangerous])); +end; + procedure TestEndToEnd; const PORT = 40098; @@ -2657,6 +2734,7 @@ begin TestServer; TestEndToEnd; TestTxPacing; + TestTxHealth; WriteLn; WriteLn(Format('Итого: %d проверок, провалено %d', [Passed + Failed, Failed])); if Failed > 0 then Halt(1);