feat(dxcluster): споты DX-кластера на панадаптере

Telnet-клиент DX-кластера, база спотов и подписи позывных прямо на спектре
на своих частотах. Разложено на три слоя, как бэндплан и лупа маяка.

DXClusterClient.pas — один рабочий поток: резолв, неблокирующий connect с
select квантами по 200 мс (Stop не ждёт таймаут соединения), логин позывным,
чтение строк, реконнект с backoff. Приглашения логина И пароля ловятся в
незавершённом хвосте буфера — типичный telnet-prompt приходит без CR/LF.
Ошибки recv отличаются от таймаута кванта (EAGAIN/EINTR/WSAETIMEDOUT), иначе
на ECONNRESET поток крутился бы в пустом цикле вместо реконнекта. Отправка
дописывает частичный send. LCL-free.

DXSpotStore.pas — потокобезопасная база: дедуп по позывному, TTL, потолок
записей, монотонный Version. Единственная точка обмена потока с UI: никакого
Synchronize, UI сам замечает правки по Version, как оверлеи — по ключам кэша.

DXSpotOverlay.pas — рендер по модели BandPlanOverlay/VfoOverlay: кэшируется
только полоса подписей (W × BandH) в key-color битмап, пересборка строго по
dirty-ключу, на кадр — один keyed-композит. Штрихи от полосы до низа спектра
рисует вызывающая сторона теми же примитивами, что и прочие маркеры: CPU —
RawVLine внутри RawBegin/RawEnd, GL — DrawLine по готовому списку X/цвет.
GL берёт тот же битмап текстурой и заливает её только при смене RenderVersion.
Пересекающиеся подписи раскладываются лесенкой, цвет гаснет с возрастом,
свой позывной выделен; палитра парная под тёмную и светлую тему.

DXClusterForm.pas — окно списка: споты, лог соединения, строка команды
кластеру (диалект set/filter у всех свой — не угадываем). Двойной клик или
Enter = QSY. Данные тянутся поллингом по Version/LogVersion.

Интеграция: кнопка DX в тулбаре (ЛКМ — подписи на спектре, ПКМ — окно),
клик по подписи спота = QSY с автовыбором моды по комментарию кластера,
страница SETUP → DX Cluster с персистом в секции "dxcluster". Правки SETUP
прилетают посимвольно, поэтому запись конфига, TTL стора и переподключение
откладываются до паузы в наборе — иначе набор позывного стоил бы шесть
реконнектов, а промежуточный TTL «3» необратимо выбросил бы споты.

Частота спота кладётся как есть и сравнивается с GetViewWindow: на QO-100
кластеры постят downlink 10489.xxx, что совпадает со шкалой пана само собой.

Побочно в общих юнитах: WebUtils.SockSetRcvTimeout, FlatMemo.OnChange,
FlatListBox.OnKeyDown, BlendBitmapKey вынесен в interface VfoOverlay (одна
копия дворд-блендера на проект).

Проверено на локальном фейковом кластере: логин и пароль по prompt без CR/LF,
разбор спотов (включая QO-100), уход в RETRY по RST, Stop за 200 мс на
висящем connect. На железе рендер не проверялся.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 15:47:26 +03:00
co-authored by Claude Opus 5
parent ab848404b1
commit a2fdc3a741
13 changed files with 2933 additions and 5 deletions
+32 -1
View File
@@ -20,7 +20,7 @@ interface
uses
Classes, SysUtils, Graphics, GraphType, ExtCtrls, Controls, Math,
AppTheme,
AlertOverlay, SampleRateOverlay, VfoOverlay, BandPlanOverlay,
AlertOverlay, SampleRateOverlay, VfoOverlay, BandPlanOverlay, DXSpotOverlay,
WaterfallView, SMeterView, RulerView, RadioModes,
Settings; // FilterEdgesFromBW — единственная таблица знака боковой
@@ -106,6 +106,7 @@ type
FVfoOverlay: TVfoOverlay;
FSliceOverlays: TFPList; // доп. слайс-флаги (B+); не владеет (владелец MainForm)
FBandPlanOverlay: TBandPlanOverlay;
FDXSpotOverlay: TDXSpotOverlay; // споты DX-кластера (полоса подписей + штрихи)
// ── Marker ────────────────────────────────────────────────────────────────
FMarkerActive: Boolean;
FMarkerX: Integer;
@@ -172,6 +173,9 @@ type
procedure DrawSliceFilterLinesRaw(W, H: Integer);
procedure DrawBandLetterRaw(X1, X2: Integer; L: Char; Clr: TColor);
procedure DrawBeaconMarkersRaw(W, H: Integer);
// Штрихи DX-спотов ниже полосы подписей. Раскладка уже посчитана оверлеем
// при пересборке кэша — здесь только N вертикальных линий.
procedure DrawDXSpotTicksRaw(W, H: Integer);
// 2TON/IMD: замер пиков по TX-буферу (общий для CPU/GL рендеров).
// Возвращает число валидных маркеров в M (0 если измерять нечего):
// [0..1] тона, [2..3] IMD3 (2f1f2, 2f2f1). Обновляет FIMDSummary.
@@ -318,6 +322,7 @@ type
function SliceOverlayCount: Integer;
function SliceOverlayAt(Index: Integer): TVfoOverlay;
property BandPlanOverlay: TBandPlanOverlay read FBandPlanOverlay write FBandPlanOverlay;
property DXSpotOverlay: TDXSpotOverlay read FDXSpotOverlay write FDXSpotOverlay;
// ── Данные от DSP ─────────────────────────────────────────────────────────
procedure SetSpectrumData(const Pixels: array of Single; Count: Integer); virtual;
@@ -669,6 +674,23 @@ begin
FBeaconDecHalf := HalfHz;
end;
procedure TSpectrumView.DrawDXSpotTicksRaw(W, H: Integer);
// Штрих от низа полосы подписей до низа спектра — по одному на видимый спот.
// Пунктиром, чтобы не спорить с кривой сигнала и краями фильтра. Раскладка
// готова (EnsureRendered в начале кадра), здесь только N линий.
var
i, X: Integer;
Col: TColor;
begin
if (FDXSpotOverlay = nil) or (not FDXSpotOverlay.Active) then Exit;
for i := 0 to FDXSpotOverlay.TickCount - 1 do
begin
FDXSpotOverlay.Tick(i, X, Col);
if (X < 0) or (X >= W) then Continue;
RawVLine(X, FDXSpotOverlay.BandHeight, H - 1, Col, 1, 2, 3);
end;
end;
procedure TSpectrumView.DrawBeaconMarkersRaw(W, H: Integer);
// Две вертикали: опорная частота маяка (зелёная пунктир) и отслеживаемый
// центроид (оранжевая сплошная). Расхождение видно глазом → понятно, сел ли
@@ -1705,6 +1727,10 @@ begin
FSpPts[W] := Point(W-1, H); FSpPts[W+1] := Point(0, H);
// Раскладка спотов — ДО RawBegin: штрихи в фазе raw берут из неё готовые X,
// а сама пересборка идёт на своём кэш-битмапе и только по dirty-ключу.
if Assigned(FDXSpotOverlay) then FDXSpotOverlay.EnsureRendered(W);
// ═══ Фаза 1: raw — весь кадр одним локом ═════════════════════════════════
{$IFDEF DARWIN}
// Cocoa-вариант CopyGridToSpectrum идёт через Canvas.Draw — до RawBegin.
@@ -1794,6 +1820,8 @@ begin
if FMarkerActive then DrawMarkerLineRaw(W, H);
if FBeaconMarkActive or FBeaconDecActive then DrawBeaconMarkersRaw(W, H);
// Штрихи DX-спотов — под маркерами наведения, но над кривой.
DrawDXSpotTicksRaw(W, H);
// 2TON: кружки с уровнями на пиках тонов/IMD3 (активирует MainForm при
// 2TON+TX+DUP — реальный сигнал после PA виден на RX-спектре)
if FIMDActive then DrawIMDMarkersRaw(W, H, DBmax, InvRange);
@@ -1809,6 +1837,9 @@ begin
// Бэндплан QO-100 — полоска внизу спектра, под панелями оверлеев.
if Assigned(FBandPlanOverlay) then
FBandPlanOverlay.DrawOverlay(FSpectrumBitmap, W, H);
// Полоса подписей DX-спотов — вверху спектра, под флагами VFO.
if Assigned(FDXSpotOverlay) then
FDXSpotOverlay.DrawOverlay(FSpectrumBitmap, W, H);
if Assigned(FSampleRateOverlay) then
FSampleRateOverlay.DrawOverlay(FSpectrumBitmap, C, W, H);
if Assigned(FVfoOverlay) then