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
+258 -1
View File
@@ -28,7 +28,7 @@ uses
Forms, Controls, Graphics, Dialogs,
StdCtrls, ExtCtrls, LCLType,
FlatButton, FlatCheckBox, FlatComboBox, FlatEdit, FlatSpinEdit, FlatFloatSpinEdit,
FlatRadioButton,
FlatRadioButton, FlatMemo,
EqualizerControl, OverlayScrollBar, AudioOutput, AudioInput, AppTheme, Settings,
BoardUtils, DpiUtils;
@@ -86,6 +86,10 @@ type
TOnThemeChange = procedure(LightTheme: Boolean) of object;
TOnWfAGCNFChange = procedure(WfAGC, WfNF: Boolean) of object;
TOnADCChange = procedure(Dither, Random: Boolean) of object;
// Настройки DX-кластера уезжают наружу целиком записью: полей много и
// добавлять их по одному в сигнатуру (как у веб-сервера) уже неудобно.
TOnDXClusterChange = procedure(const D: TDXClusterSettings) of object;
TOnWebSettingsChange = procedure(Enabled: Boolean; Port: Integer;
const BindAddr, User, Pass: string; SpecPixels: Integer) of object;
TOnCATChange = procedure(
@@ -124,6 +128,19 @@ type
FPageWaterfall: TScrollBox;
FPagePA: TScrollBox;
FPageAdvanced: TScrollBox;
FPageDXCluster: TScrollBox;
// DX-кластер
FChkDXEnabled: TFlatCheckBox;
FChkDXShow: TFlatCheckBox;
FEdDXHost: TFlatEdit;
FEdDXPort: TFlatSpinEdit;
FEdDXLogin: TFlatEdit;
FEdDXPass: TFlatEdit;
FEdDXPostLogin: TFlatMemo;
FEdDXTTL: TFlatSpinEdit;
FEdDXRows: TFlatSpinEdit;
FChkDXMode: array[0..9] of TFlatCheckBox; // индекс = Ord(TDXMode)
FDXCfg: TDXClusterSettings;
FPageCAT: TScrollBox;
FPageSlices: TScrollBox;
FPageTransmit: TScrollBox;
@@ -133,6 +150,7 @@ type
FNavWaterfall: TFlatButton;
FNavPA: TFlatButton;
FNavAdvanced: TFlatButton;
FNavDXCluster: TFlatButton;
FNavCAT: TFlatButton;
FNavSlices: TFlatButton;
FNavTransmit: TFlatButton;
@@ -465,6 +483,7 @@ type
FOnVHFCalChange: TOnVHFCalChange;
FOnADCChange: TOnADCChange;
FOnWebSettingsChange: TOnWebSettingsChange;
FOnDXClusterChange: TOnDXClusterChange;
FOnDisplayChange: TOnDisplayParamChange;
FOnWaterfallChange: TOnWaterfallParamChange;
FOnWfRenderChange: TOnWfRenderChange;
@@ -572,6 +591,8 @@ type
OnChange: TNotifyEvent): TFlatComboBox;
function MakeGroupPanel(AParent: TWinControl; const Cap: string;
ALeft, ATop, AW, AH: Integer): TPanel;
procedure BuildDXClusterTab;
procedure OnDXAnyChange(Sender: TObject);
function MakeScrollPage: TScrollBox;
procedure AddPageBottomSpace(APage: TScrollBox);
procedure UpdatePageBottomSpace(APage: TScrollBox);
@@ -696,6 +717,7 @@ type
ResLimit, SpeedDiv: Integer);
procedure LoadSpecMSAA(Samples: Integer);
procedure LoadADCSettings(Dither, Random: Boolean);
procedure LoadDXClusterSettings(const D: TDXClusterSettings);
procedure LoadWebSettings(Enabled: Boolean; Port: Integer;
const BindAddr, User, Pass: string; SpecPixels: Integer);
@@ -734,6 +756,7 @@ type
property OnWfAGCNFChange: TOnWfAGCNFChange read FOnWfAGCNFChange write FOnWfAGCNFChange;
property OnADCChange: TOnADCChange read FOnADCChange write FOnADCChange;
property OnWebSettingsChange: TOnWebSettingsChange read FOnWebSettingsChange write FOnWebSettingsChange;
property OnDXClusterChange: TOnDXClusterChange read FOnDXClusterChange write FOnDXClusterChange;
end;
implementation
@@ -1113,6 +1136,7 @@ begin
FNavCAT := MakeNavButton('CAT', 496);
FNavSlices := MakeNavButton('Slices', 528);
FNavAdvanced := MakeNavButton('Advanced', 560);
FNavDXCluster := MakeNavButton('DX Cluster', 592);
FContentPanel := TPanel.Create(Self);
FContentPanel.Parent := Self;
@@ -1135,6 +1159,7 @@ begin
FPagePA := MakeScrollPage;
FPageCalib := MakeScrollPage;
FPageAdvanced := MakeScrollPage;
FPageDXCluster := MakeScrollPage;
FPageCAT := MakeScrollPage;
FPageSlices := MakeScrollPage;
FPageAlex := MakeScrollPage;
@@ -1154,6 +1179,7 @@ begin
BuildPATab;
BuildCalibrationTab;
BuildAdvancedTab;
BuildDXClusterTab;
BuildCATTab;
BuildSlicesTab;
BuildAlexTab;
@@ -1168,6 +1194,7 @@ begin
AddPageBottomSpace(FPageWaterfall);
AddPageBottomSpace(FPageCalib);
AddPageBottomSpace(FPageCAT);
AddPageBottomSpace(FPageDXCluster);
AddPageBottomSpace(FPageOC);
FBtnClose := TFlatButton.Create(Self);
@@ -1390,6 +1417,7 @@ begin
ResizePage(FPagePA, CardWidth);
ResizePage(FPageCalib, CardWidth);
ResizePage(FPageAdvanced, CardWidth);
ResizePage(FPageDXCluster, CardWidth);
ResizePage(FPageCAT, CardWidth);
ResizePage(FPageSlices, CardWidth);
ResizePage(FPageAlex, CardWidth);
@@ -1408,6 +1436,7 @@ begin
else if Sender = FNavPA then SelectPage(FPagePA, FNavPA)
else if Sender = FNavCalib then SelectPage(FPageCalib, FNavCalib)
else if Sender = FNavAdvanced then SelectPage(FPageAdvanced, FNavAdvanced)
else if Sender = FNavDXCluster then SelectPage(FPageDXCluster, FNavDXCluster)
else if Sender = FNavCAT then SelectPage(FPageCAT, FNavCAT)
else if Sender = FNavSlices then SelectPage(FPageSlices, FNavSlices)
else if Sender = FNavAlex then SelectPage(FPageAlex, FNavAlex)
@@ -1429,6 +1458,7 @@ begin
FPagePA.Visible := APage = FPagePA;
FPageCalib.Visible := APage = FPageCalib;
FPageAdvanced.Visible := APage = FPageAdvanced;
FPageDXCluster.Visible := APage = FPageDXCluster;
FPageCAT.Visible := APage = FPageCAT;
FPageSlices.Visible := APage = FPageSlices;
FPageAlex.Visible := APage = FPageAlex;
@@ -1444,6 +1474,7 @@ begin
FNavPA.Active := ANav = FNavPA;
FNavCalib.Active := ANav = FNavCalib;
FNavAdvanced.Active := ANav = FNavAdvanced;
FNavDXCluster.Active := ANav = FNavDXCluster;
FNavCAT.Active := ANav = FNavCAT;
FNavSlices.Active := ANav = FNavSlices;
FNavAlex.Active := ANav = FNavAlex;
@@ -3412,6 +3443,227 @@ begin
Lbl.Font.Size := 8;
end;
procedure TSettingsForm.BuildDXClusterTab;
const
MARGIN = 22;
GRP_PAD = 18;
LBL_W = 110;
ED_W = 220;
ROW_H = 42;
R1 = 42;
// Подписи для чекбоксов фильтра мод. Порядок = Ord(TDXMode), включая
// dxmUnknown («без моды») — иначе фильтр молча съедал бы такие споты.
MODE_CAPS: array[0..9] of string =
('no mode', 'CW', 'SSB', 'DIGI', 'FT8', 'FT4', 'RTTY', 'PSK', 'FM', 'SSTV');
var
Grp: TPanel;
Chk: TFlatCheckBox;
Ed: TFlatEdit;
Spin: TFlatSpinEdit;
Lbl: TLabel;
Y, i, CX, CY: Integer;
begin
TSettingsManager.DefaultDXCluster(FDXCfg);
MakePageHeader(FPageDXCluster, 'DX Cluster',
'Telnet connection to a DX cluster and how spots look on the panadapter.');
// ── Соединение ────────────────────────────────────────────────────────────
Grp := MakeGroupPanel(FPageDXCluster, 'Connection', MARGIN, 86, SETTINGS_CARD_W, 366);
Y := R1;
Chk := TFlatCheckBox.Create(Self);
Chk.Parent := Grp;
Chk.Caption := 'Connect at startup';
Chk.SetBounds(DpiScale(GRP_PAD), DpiScale(Y), DpiScale(300), DpiScale(22));
Chk.Font.Size := 9;
Chk.Font.Color := CLR_TEXT;
Chk.OnChange := OnDXAnyChange;
FChkDXEnabled := Chk;
Y := Y + ROW_H;
MakeLbl(Grp, 'Host:', GRP_PAD, Y + 4, LBL_W);
Ed := TFlatEdit.Create(Self);
Ed.Parent := Grp;
Ed.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(ED_W), DpiScale(BTN_H));
Ed.Color := CLR_INPUT;
Ed.Font.Color := CLR_INPUT_TEXT;
Ed.Font.Size := 9;
Ed.Text := FDXCfg.Host;
Ed.OnChange := OnDXAnyChange;
FEdDXHost := Ed;
Y := Y + ROW_H;
MakeLbl(Grp, 'Port:', GRP_PAD, Y + 4, LBL_W);
Spin := TFlatSpinEdit.Create(Self);
Spin.Parent := Grp;
Spin.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(90), DpiScale(BTN_H));
Spin.Color := CLR_INPUT;
Spin.Font.Color := CLR_INPUT_TEXT;
Spin.Font.Size := 9;
Spin.MinValue := 1;
Spin.MaxValue := 65535;
Spin.Value := FDXCfg.Port;
Spin.OnChange := OnDXAnyChange;
FEdDXPort := Spin;
Y := Y + ROW_H;
MakeLbl(Grp, 'Callsign:', GRP_PAD, Y + 4, LBL_W);
Ed := TFlatEdit.Create(Self);
Ed.Parent := Grp;
Ed.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(140), DpiScale(BTN_H));
Ed.Color := CLR_INPUT;
Ed.Font.Color := CLR_INPUT_TEXT;
Ed.Font.Size := 9;
Ed.OnChange := OnDXAnyChange;
FEdDXLogin := Ed;
Lbl := MakeLbl(Grp, 'used to log in; spots for this call are highlighted',
GRP_PAD + LBL_W + 12 + 150, Y + 4, 340);
Lbl.Font.Size := 8;
Y := Y + ROW_H;
MakeLbl(Grp, 'Password:', GRP_PAD, Y + 4, LBL_W);
Ed := TFlatEdit.Create(Self);
Ed.Parent := Grp;
Ed.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(140), DpiScale(BTN_H));
Ed.Color := CLR_INPUT;
Ed.Font.Color := CLR_INPUT_TEXT;
Ed.Font.Size := 9;
Ed.PasswordChar := '*';
Ed.OnChange := OnDXAnyChange;
FEdDXPass := Ed;
Lbl := MakeLbl(Grp, 'usually not needed', GRP_PAD + LBL_W + 12 + 150, Y + 4, 200);
Lbl.Font.Size := 8;
Y := Y + ROW_H;
MakeLbl(Grp, 'After login:', GRP_PAD, Y + 4, LBL_W);
FEdDXPostLogin := TFlatMemo.Create(Self);
FEdDXPostLogin.Parent := Grp;
FEdDXPostLogin.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y),
DpiScale(ED_W + 120), DpiScale(70));
FEdDXPostLogin.Color := CLR_INPUT;
FEdDXPostLogin.Font.Color := CLR_INPUT_TEXT;
FEdDXPostLogin.Font.Size := 9;
FEdDXPostLogin.WordWrap := False;
FEdDXPostLogin.OnChange := OnDXAnyChange;
Lbl := MakeLbl(Grp, 'one command per line (set/filter, sh/dx …) — dialects differ between clusters',
GRP_PAD, Y + 76, 520);
Lbl.Font.Size := 8;
// ── Отображение ───────────────────────────────────────────────────────────
Grp := MakeGroupPanel(FPageDXCluster, 'Spots on panadapter', MARGIN, 468,
SETTINGS_CARD_W, 292);
Y := R1;
Chk := TFlatCheckBox.Create(Self);
Chk.Parent := Grp;
Chk.Caption := 'Show spots on the spectrum';
Chk.SetBounds(DpiScale(GRP_PAD), DpiScale(Y), DpiScale(300), DpiScale(22));
Chk.Font.Size := 9;
Chk.Font.Color := CLR_TEXT;
Chk.OnChange := OnDXAnyChange;
FChkDXShow := Chk;
Y := Y + ROW_H;
MakeLbl(Grp, 'Keep for, min:', GRP_PAD, Y + 4, LBL_W);
Spin := TFlatSpinEdit.Create(Self);
Spin.Parent := Grp;
Spin.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(90), DpiScale(BTN_H));
Spin.Color := CLR_INPUT;
Spin.Font.Color := CLR_INPUT_TEXT;
Spin.Font.Size := 9;
Spin.MinValue := 1;
Spin.MaxValue := 1440;
Spin.Value := FDXCfg.TTLMinutes;
Spin.OnChange := OnDXAnyChange;
FEdDXTTL := Spin;
Lbl := MakeLbl(Grp, 'also the fade horizon: a spot dims and then disappears',
GRP_PAD + LBL_W + 114, Y + 4, 360);
Lbl.Font.Size := 8;
Y := Y + ROW_H;
MakeLbl(Grp, 'Label rows:', GRP_PAD, Y + 4, LBL_W);
Spin := TFlatSpinEdit.Create(Self);
Spin.Parent := Grp;
Spin.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(90), DpiScale(BTN_H));
Spin.Color := CLR_INPUT;
Spin.Font.Color := CLR_INPUT_TEXT;
Spin.Font.Size := 9;
Spin.MinValue := 1;
Spin.MaxValue := 6;
Spin.Value := FDXCfg.MaxRows;
Spin.OnChange := OnDXAnyChange;
FEdDXRows := Spin;
Lbl := MakeLbl(Grp, 'ladder height: more rows means fewer hidden spots',
GRP_PAD + LBL_W + 114, Y + 4, 360);
Lbl.Font.Size := 8;
Y := Y + ROW_H;
MakeLbl(Grp, 'Modes:', GRP_PAD, Y + 4, LBL_W);
CX := GRP_PAD + LBL_W + 12;
CY := Y;
for i := 0 to High(FChkDXMode) do
begin
Chk := TFlatCheckBox.Create(Self);
Chk.Parent := Grp;
Chk.Caption := MODE_CAPS[i];
Chk.SetBounds(DpiScale(CX), DpiScale(CY), DpiScale(90), DpiScale(22));
Chk.Font.Size := 9;
Chk.Font.Color := CLR_TEXT;
Chk.OnChange := OnDXAnyChange;
FChkDXMode[i] := Chk;
CX := CX + 96;
if CX > 480 then begin CX := GRP_PAD + LBL_W + 12; CY := CY + 26; end;
end;
Lbl := MakeLbl(Grp, 'none checked — every spot is shown',
GRP_PAD + LBL_W + 12, CY + 28, 360);
Lbl.Font.Size := 8;
end;
procedure TSettingsForm.OnDXAnyChange(Sender: TObject);
var i: Integer;
begin
if FLoading then Exit;
if not Assigned(FOnDXClusterChange) then Exit;
FDXCfg.Enabled := FChkDXEnabled.Checked;
FDXCfg.ShowSpots := FChkDXShow.Checked;
FDXCfg.Host := Trim(FEdDXHost.Text);
FDXCfg.Port := FEdDXPort.Value;
FDXCfg.Login := Trim(FEdDXLogin.Text);
FDXCfg.Password := FEdDXPass.Text;
FDXCfg.PostLogin := FEdDXPostLogin.Text;
FDXCfg.TTLMinutes := FEdDXTTL.Value;
FDXCfg.MaxRows := FEdDXRows.Value;
FDXCfg.ModeMask := 0;
for i := 0 to High(FChkDXMode) do
if (FChkDXMode[i] <> nil) and FChkDXMode[i].Checked then
FDXCfg.ModeMask := FDXCfg.ModeMask or (LongWord(1) shl i);
FOnDXClusterChange(FDXCfg);
end;
procedure TSettingsForm.LoadDXClusterSettings(const D: TDXClusterSettings);
var i: Integer;
begin
FLoading := True;
try
FDXCfg := D;
FChkDXEnabled.Checked := D.Enabled;
FChkDXShow.Checked := D.ShowSpots;
FEdDXHost.Text := D.Host;
FEdDXPort.Value := EnsureRange(D.Port, 1, 65535);
FEdDXLogin.Text := D.Login;
FEdDXPass.Text := D.Password;
FEdDXPostLogin.Text := D.PostLogin;
FEdDXTTL.Value := EnsureRange(D.TTLMinutes, 1, 1440);
FEdDXRows.Value := EnsureRange(D.MaxRows, 1, 6);
for i := 0 to High(FChkDXMode) do
if FChkDXMode[i] <> nil then
FChkDXMode[i].Checked := (D.ModeMask and (LongWord(1) shl i)) <> 0;
finally
FLoading := False;
end;
end;
procedure TSettingsForm.OnADCChkChange(Sender: TObject);
begin
if FLoading then Exit;
@@ -3540,6 +3792,11 @@ procedure TSettingsForm.ApplyTheme(const T: TAppTheme);
TFlatFloatSpinEdit(Ctrl).SetAppTheme(T)
else if Ctrl is TFlatRadioButton then
TFlatRadioButton(Ctrl).SetAppTheme(T)
// TFlatMemo — обёртка над TMemo со своим scrollbar: тему знает сама,
// рекурсивный обход внутрь ей только навредил бы (внутренний TMemo не
// подходит ни под одну ветку и остался бы серым).
else if Ctrl is TFlatMemo then
TFlatMemo(Ctrl).SetAppTheme(T)
else if Ctrl is TListBox then
begin
TListBox(Ctrl).Color := T.BG;