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
+53
View File
@@ -18,6 +18,7 @@ uses
Classes, SysUtils, Graphics, Controls, Math, Types,
OpenGLContextEx, GL,
AppTheme, AlertOverlay, SpectrumView, VfoOverlay, BandPlanOverlay,
DXSpotOverlay,
WaterfallView, WaterfallViewOpengl;
type
@@ -36,6 +37,7 @@ type
FSampleOverlayDirty: Boolean;
FVfoOverlayDirty: Boolean;
FBandOverlayDirty: Boolean;
FDXOverlayDirty: Boolean;
FGridLabelTex: TGLTextureCache;
FAGCLabelTex: TGLTextureCache;
FAGCHangLabelTex: TGLTextureCache;
@@ -44,6 +46,7 @@ type
FSampleOverlayTex: TGLTextureCache;
FVfoOverlayTex: TGLTextureCache;
FBandOverlayTex: TGLTextureCache;
FDXOverlayTex: TGLTextureCache;
// Текстуры флагов слайсов (B+), привязка по указателю оверлея.
FSliceTex: array of record
Overlay: TVfoOverlay;
@@ -72,6 +75,8 @@ type
FLastBandW: Integer;
FLastBandCenter: Double;
FLastBandSpan: Double;
FLastDXW: Integer;
FLastDXRender: Int64;
FGLSpectrumW: Integer;
FGLSpectrumH: Integer;
FSpY: array of Integer;
@@ -93,6 +98,7 @@ type
procedure DrawSpectrumCurve(W, H: Integer; DBmax, InvRange: Double);
procedure DrawMarker(W, H: Integer);
procedure DrawBeaconMarkersGL(W, H: Integer);
procedure DrawDXSpotTicksGL(W, H: Integer);
procedure DrawCircleGL(CX, CY: Integer; R: Single; C: TColor);
procedure DrawIMDMarkersGL(W, H: Integer; DBmax, InvRange: Double);
procedure DrawADCOverlay(W, H: Integer);
@@ -152,8 +158,10 @@ begin
FSampleOverlayDirty := True;
FVfoOverlayDirty := True;
FBandOverlayDirty := True;
FDXOverlayDirty := True;
FLastBandW := -MaxInt;
FLastBandCenter := -1; FLastBandSpan := -1;
FLastDXW := -MaxInt; FLastDXRender := -1;
FLastAGCY := -MaxInt;
FLastAGCHangY := -MaxInt;
FLastMarkerX := -MaxInt;
@@ -173,6 +181,7 @@ begin
DeleteTexture(FSampleOverlayTex);
DeleteTexture(FVfoOverlayTex);
DeleteTexture(FBandOverlayTex);
DeleteTexture(FDXOverlayTex);
for i := 0 to High(FSliceTex) do DeleteTexture(FSliceTex[i].Tex);
for i := 0 to High(FLetterTex) do DeleteTexture(FLetterTex[i]);
for i := 0 to High(FIMDLabelTex) do DeleteTexture(FIMDLabelTex[i]);
@@ -454,6 +463,7 @@ begin
Zap(FADCOverlayTex);
Zap(FSampleOverlayTex);
Zap(FVfoOverlayTex);
Zap(FDXOverlayTex);
Zap(FBandOverlayTex);
for i := 0 to High(FSliceTex) do Zap(FSliceTex[i].Tex);
for i := 0 to High(FLetterTex) do Zap(FLetterTex[i]);
@@ -469,6 +479,7 @@ begin
FSampleOverlayDirty := True;
FVfoOverlayDirty := True;
FBandOverlayDirty := True;
FDXOverlayDirty := True;
FGLSpectrumW := 0;
FGLSpectrumH := 0;
// Водопад — свой контекст/текстуры (история + маркер). При reparent (pop-out)
@@ -820,6 +831,25 @@ begin
end;
end;
procedure TSpectrumViewOpenGL.DrawDXSpotTicksGL(W, H: Integer);
// GL-двойник DrawDXSpotTicksRaw: те же X и цвета из раскладки оверлея, только
// линиями GL. EnsureRendered здесь и держит раскладку свежей — текстуру полосы
// DrawCachedOverlays перезальёт по RenderVersion, кто бы ни пересобрал кэш.
var
i, X, Y0: Integer;
Col: TColor;
begin
if (FDXSpotOverlay = nil) or (not FDXSpotOverlay.Active) then Exit;
FDXSpotOverlay.EnsureRendered(W);
Y0 := FDXSpotOverlay.BandHeight;
for i := 0 to FDXSpotOverlay.TickCount - 1 do
begin
FDXSpotOverlay.Tick(i, X, Col);
if (X < 0) or (X >= W) then Continue;
DrawLine(X, Y0, X, H, Col, 1, True); // пунктир — как в CPU-пути
end;
end;
procedure TSpectrumViewOpenGL.DrawCircleGL(CX, CY: Integer; R: Single; C: TColor);
// Залитый кружок (triangle fan, 20 сегментов) — маркер пика 2TON/IMD.
var
@@ -947,6 +977,28 @@ begin
DrawTexture(FBandOverlayTex, 0, H - FBandOverlayTex.H);
end;
// Полоса подписей DX-спотов — вверху спектра. Тот же битмап, что в CPU-пути,
// заливается текстурой ТОЛЬКО при dirty (новый спот меняет CacheDirty
// оверлея, вид — центр/спан/ширину).
if Assigned(FDXSpotOverlay) and FDXSpotOverlay.Active then
begin
if FOverlayDirty or FDXOverlayDirty or FDXOverlayTex.Dirty or
(FLastDXW <> W) or (FLastDXRender <> FDXSpotOverlay.RenderVersion) then
begin
B := TBitmap.Create;
try
FDXSpotOverlay.DrawOverlayBitmap(B, W);
UploadBitmap(FDXOverlayTex, B, True, DXSPOT_ALPHA);
finally
B.Free;
end;
FLastDXW := W;
FLastDXRender := FDXSpotOverlay.RenderVersion;
FDXOverlayDirty := False;
end;
DrawTexture(FDXOverlayTex, 0, 0);
end;
if Assigned(FSampleRateOverlay) then
begin
OW := Min(FSampleRateOverlay.Width, W - FSampleRateOverlay.Left);
@@ -1098,6 +1150,7 @@ begin
DrawSpectrumCurve(W, H, DBmax, InvRange);
DrawMarker(W, H);
if FBeaconMarkActive or FBeaconDecActive then DrawBeaconMarkersGL(W, H);
DrawDXSpotTicksGL(W, H);
// 2TON: кружки с уровнями на пиках тонов/IMD3 (активирует MainForm при
// 2TON+TX+DUP — реальный сигнал после PA виден на RX-спектре)
if FIMDActive then DrawIMDMarkersGL(W, H, DBmax, InvRange);