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
+254 -3
View File
@@ -22,8 +22,9 @@ unit MainForm;
interface
uses
Classes, SysUtils, StrUtils, FreqDisplay, DeviceForm, FilterPopup,
Classes, SysUtils, StrUtils, DateUtils, FreqDisplay, DeviceForm, FilterPopup,
VfoOverlay, SampleRateOverlay, BandPlanOverlay,
DXSpotStore, DXClusterClient, DXSpotOverlay, DXClusterForm,
FlatButton, FlatSlider, FlatDropDown, OverlayScrollBar, AppTheme,
Forms, Controls, Graphics, Dialogs,
StdCtrls, ExtCtrls, Buttons, Menus, Math, Types, SyncObjs,
@@ -139,7 +140,18 @@ type
FSliceDrag: Boolean; // тащим маркер несущей слайса по спектру
FSliceDragId: Integer; // какой слайс тащим
FSampleRateOverlay: TSampleRateOverlay; // оверлей span/hide слева вверху спектра
FBandPlanOverlay: TBandPlanOverlay; // полоска бэндплана QO-100 внизу спектра
FBandPlanOverlay: TBandPlanOverlay; // полоска бэндплана QO-100
// ── DX-кластер ────────────────────────────────────────────────────────
FDXStore: TDXSpotStore; // общая база спотов (поток кластера пишет)
FDXClient: TDXClusterClient; // telnet-соединение (свой поток)
FDXSpotOverlay: TDXSpotOverlay; // подписи спотов на спектре
FDXClusterForm: TDXClusterForm; // окно списка (ПКМ по кнопке DX)
FDXCfg: TDXClusterSettings;
FDXConnCfg: TDXClusterSettings; // параметры, с которыми поднят клиент
FDXPendingCfg: TDXClusterSettings; // правки SETUP, ждущие тишины
FDXPending: Boolean;
FDXPendingAt: TDateTime;
FDXAgeTickAt: TDateTime; // когда последний раз гасили старые споты внизу спектра
FPanelHidden: Boolean; // True = левая панель скрыта
FLeftPanelW: Integer; // ширина панели до скрытия (DPI-safe restore)
FPendingBoardType: Integer; // BoardType устройства для подключения
@@ -263,6 +275,7 @@ type
BtnDiscover: TFlatButton;
BtnStartStop: TFlatButton;
BtnSettings: TFlatButton;
BtnDXCluster: TFlatButton; // ЛКМ — споты на спектре, ПКМ — окно кластера
// ---- Left panel ----
PanelLeft: TPanel;
@@ -497,6 +510,19 @@ type
procedure BtnRxMuteClick(Sender: TObject);
procedure UpdateRxMuteButton; // видимость (Pluto+full-duplex) + стиль кнопки RX MUTE
procedure UpdateBandPlanOverlay; // вкл/выкл полоски бэндплана QO-100 по InQO100
// ── DX-кластер ────────────────────────────────────────────────────────
procedure InitDXCluster; // стор+клиент+оверлей, чтение настроек
procedure ServiceDXCluster; // тик затухания спотов (редкий)
procedure ApplyDXClusterSettings(const D: TDXClusterSettings);
procedure UpdateDXClusterButton;
procedure BtnDXClusterClick(Sender: TObject);
procedure BtnDXClusterMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure ShowDXClusterForm;
procedure ApplyDXConnection(const D: TDXClusterSettings);
procedure OnDXClusterSettingsChange(const D: TDXClusterSettings);
procedure CommitDXClusterSettings;
procedure OnDXTuneSpot(FreqHz: Double; Mode: TDXMode);
procedure BtnTUNClick(Sender: TObject);
procedure ApplyTUN(Active: Boolean);
// PureSignal: ЛКМ по PS = вкл/выкл, ПКМ = поповер настроек.
@@ -1373,6 +1399,17 @@ begin
PanelSplitter := nil;
PbWaterfall := nil;
PbPanZoom := nil;
// DX-кластер: незакоммиченную правку SETUP дописываем в конфиг (соединение
// при этом не трогаем — программа закрывается), гасим сетевой поток (его
// Stop делает shutdown сокета и ждёт поток), потом базу спотов — оверлей
// к этому моменту уже мёртв вместе с view пана.
if FDXPending then
begin
FDXPending := False;
FController.FSettings.SaveDXClusterSettings(FDXPendingCfg);
end;
FreeAndNil(FDXClient);
FreeAndNil(FDXStore);
DestroyAllExtraPans; // доп. паны (DDC/движок/панели) — до пана 0
FreeAndNil(FPan);
FPans[0] := nil;
@@ -1976,6 +2013,10 @@ begin
BtnDiscover := MakeBtn(PanelToolbar, 'DISCOVER', X, 3, 80, BTN_H, BtnDiscoverClick);
BtnStartStop := MakeBtn(PanelToolbar, 'START', X+84, 3, 76, BTN_H, BtnStartStopClick);
BtnSettings := MakeBtn(PanelToolbar, 'SETUP', X+164, 3, 66, BTN_H, BtnSettingsClick);
// DX — споты кластера: ЛКМ включает/выключает подписи на спектре, ПКМ
// открывает окно списка (та же семантика, что у BEACON → окно констелляции).
BtnDXCluster := MakeBtn(PanelToolbar, 'DX', X+234, 3, 46, BTN_H, BtnDXClusterClick);
BtnDXCluster.OnMouseDown := BtnDXClusterMouseDown;
// DISCOVER — синеватый акцент
BtnDiscover.ClrNorm := TColor($00101828);
@@ -2641,6 +2682,9 @@ begin
FBandPlanOverlay.SetView(FController.FCenterFreq, FController.FSpanHz);
FSpecView.BandPlanOverlay := FBandPlanOverlay;
// DX-кластер: база спотов + сетевой поток + оверлей подписей.
InitDXCluster;
// S-метр — внутри PanelToolbar, справа
PanelSMeterRight := TPanel.Create(Self);
PanelSMeterRight.Parent := PanelToolbar;
@@ -2763,6 +2807,9 @@ begin
SafeLeft := MARGIN;
if BtnSettings <> nil then
SafeLeft := BtnSettings.Left + BtnSettings.Width + SETTINGS_GAP;
// Кнопка DX стоит правее SETUP — группа VFO начинается уже за ней.
if (BtnDXCluster <> nil) and BtnDXCluster.Visible then
SafeLeft := Max(SafeLeft, BtnDXCluster.Left + BtnDXCluster.Width + SETTINGS_GAP);
SafeRight := ClientWidth - MARGIN;
if PanelLeft <> nil then
begin
@@ -3301,6 +3348,9 @@ begin
BtnStartStop.ClrTextAct:= T.TbStartText;
BtnStartStop.Invalidate;
StyleButton(BtnSettings, False);
// DX — тумблер подписей спотов: состояние берём из самой кнопки, как у
// прочих тумблеров ниже (BtnCTun/BtnBeacon).
if BtnDXCluster <> nil then StyleButton(BtnDXCluster, BtnDXCluster.Active);
// Обычные кнопки — все массивы
for i := 0 to BAND_COUNT-1 do StyleButton(BtnBand[i], BtnBand[i].Active);
@@ -3451,6 +3501,8 @@ begin
if FCWMsgForm <> nil then TCWMessagesForm(FCWMsgForm).ApplyTheme(T);
if FCWTermForm <> nil then TCWTerminalForm(FCWTermForm).ApplyTheme(T);
if FBeaconScopeForm <> nil then FBeaconScopeForm.ApplyTheme(T);
if FDXClusterForm <> nil then FDXClusterForm.ApplyTheme(T);
if FDXSpotOverlay <> nil then FDXSpotOverlay.SetLightTheme(FLightTheme);
FController.FSettings.SaveTheme(V);
FController.FSettings.Save;
if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then
@@ -3690,6 +3742,9 @@ begin
// отсекает неизменное → пересборки кэша на каждый вызов нет.
if Assigned(FBandPlanOverlay) then
FBandPlanOverlay.SetView(ViewCenter, ViewSpan);
// Подписи спотов — тот же видимый домен, что у бэндплана.
if Assigned(FDXSpotOverlay) then
FDXSpotOverlay.SetView(ViewCenter, ViewSpan);
if UpdateWidebandFrequencyView and FController.FShowWideband and (PbWideband <> nil) then
PbWideband.Invalidate;
UpdatePanZoomBar; // линейка пана следует за частотой/зумом (no-op если без изменений)
@@ -3714,6 +3769,9 @@ var
PeakDiff, MinDiff: Double;
PeakAlpha, MinAlpha: Double;
begin
// Затухание спотов не зависит от того, крутится ли приём.
ServiceDXCluster;
if not FController.FRunning then
begin
if PbSMeterRight <> nil then PbSMeterRight.Invalidate;
@@ -4947,6 +5005,186 @@ begin
StyleButton(BtnRxMute, FController.FRxMuteOnTx);
end;
// ═══════════════════════════════════════════════════════════════════════════
// DX-кластер: база спотов + telnet-клиент + оверлей подписей на спектре
// ═══════════════════════════════════════════════════════════════════════════
function DXModeSetFromMask(Mask: LongWord): TDXModeSet;
// Маска настроек → множество мод. 0 = без фильтра (показываем всё).
var M: TDXMode;
begin
Result := [];
if Mask = 0 then Exit;
for M := Low(TDXMode) to High(TDXMode) do
if (Mask and (LongWord(1) shl Ord(M))) <> 0 then Include(Result, M);
end;
procedure TMainForm.InitDXCluster;
begin
FDXStore := TDXSpotStore.Create;
FDXClient := TDXClusterClient.Create(FDXStore);
FDXSpotOverlay := TDXSpotOverlay.Create(Self);
FDXSpotOverlay.Attach(FDXStore);
FDXSpotOverlay.SetLightTheme(FLightTheme);
FDXSpotOverlay.SetView(FController.FCenterFreq, FController.FSpanHz);
FSpecView.DXSpotOverlay := FDXSpotOverlay;
FDXAgeTickAt := Now;
FController.FSettings.LoadDXClusterSettings(FDXCfg);
FDXStore.TTLMinutes := FDXCfg.TTLMinutes;
ApplyDXClusterSettings(FDXCfg);
// FDXConnCfg пуст → ApplyDXConnection увидит смену параметров и поднимет
// соединение, если оно включено и позывной задан.
ApplyDXConnection(FDXCfg);
end;
procedure TMainForm.ApplyDXClusterSettings(const D: TDXClusterSettings);
// Вид: оверлей + кнопка. Применяется сразу на каждую правку в SETUP — это
// дёшево, обратимо и даёт живой отклик. Всё, что МЕНЯЕТ ДАННЫЕ (TTL стора) или
// трогает соединение, откладывается до CommitDXClusterSettings: набирая «30» в
// поле TTL, пользователь на миг проходит через «3», а это выбросило бы из базы
// все споты старше трёх минут — необратимо.
begin
FDXCfg := D;
if FDXSpotOverlay <> nil then
begin
FDXSpotOverlay.MaxRows := D.MaxRows;
FDXSpotOverlay.SetOwnCall(D.Login);
FDXSpotOverlay.SetFilters(DXModeSetFromMask(D.ModeMask), D.TTLMinutes);
FDXSpotOverlay.SetEnabled(D.ShowSpots);
FDXSpotOverlay.Invalidate;
end;
UpdateDXClusterButton;
FSpectrumDirty := True;
end;
procedure TMainForm.ApplyDXConnection(const D: TDXClusterSettings);
// Соединение: конфиг в клиент + поднять/положить/переподнять. Поток снимает
// конфиг один раз на сессию, поэтому смена адреса/учётки требует рестарта —
// как у веб-сервера при смене порта.
var Reconnect: Boolean;
begin
if FDXClient = nil then Exit;
Reconnect := (D.Host <> FDXConnCfg.Host) or (D.Port <> FDXConnCfg.Port) or
(D.Login <> FDXConnCfg.Login) or
(D.Password <> FDXConnCfg.Password) or
(D.PostLogin <> FDXConnCfg.PostLogin);
FDXConnCfg := D;
FDXClient.Configure(D.Host, D.Port, D.Login, D.Password, D.PostLogin);
// Без позывного логиниться нечем — не поднимаем соединение вообще.
if (not D.Enabled) or (Trim(D.Login) = '') then
begin
if FDXClient.Running then FDXClient.Stop;
Exit;
end;
if FDXClient.Running then
begin
if not Reconnect then Exit;
FDXClient.Stop;
end;
FDXClient.Start;
end;
procedure TMainForm.OnDXClusterSettingsChange(const D: TDXClusterSettings);
// Каждая правка в SETUP прилетает сюда ПОСИМВОЛЬНО (OnChange поля). Вид
// применяем сразу, а запись в конфиг и переподключение откладываем: иначе
// набор позывного «UA3XYZ» дёргал бы шесть реконнектов и шесть записей JSON.
begin
ApplyDXClusterSettings(D);
FDXPendingCfg := D;
FDXPendingAt := Now;
FDXPending := True;
end;
procedure TMainForm.CommitDXClusterSettings;
// Отложенный коммит правок SETUP: пользователь перестал печатать.
begin
FDXPending := False;
FController.FSettings.SaveDXClusterSettings(FDXPendingCfg);
if FDXStore <> nil then FDXStore.TTLMinutes := FDXPendingCfg.TTLMinutes;
ApplyDXConnection(FDXPendingCfg);
end;
procedure TMainForm.ServiceDXCluster;
// Раз в DX_AGE_TICK_SEC подталкиваем оверлей пересобраться: споты тускнеют с
// возрастом и уходят по TTL, а без нового спота версия стора не меняется и
// повода для пересборки бы не было. Реже — незаметно, чаще — впустую.
const
DX_AGE_TICK_SEC = 30;
DX_SETTINGS_QUIET_MS = 1200; // тишина в SETUP, после которой коммитим
begin
// Отложенный коммит настроек: пользователь перестал печатать в SETUP.
if FDXPending and (MilliSecondsBetween(Now, FDXPendingAt) >= DX_SETTINGS_QUIET_MS) then
CommitDXClusterSettings;
if FDXSpotOverlay = nil then Exit;
if SecondsBetween(Now, FDXAgeTickAt) < DX_AGE_TICK_SEC then Exit;
FDXAgeTickAt := Now;
if not FDXSpotOverlay.Active then Exit;
FDXSpotOverlay.TickAge;
FSpectrumDirty := True;
end;
procedure TMainForm.UpdateDXClusterButton;
begin
if BtnDXCluster = nil then Exit;
StyleButton(BtnDXCluster,
(FDXSpotOverlay <> nil) and FDXSpotOverlay.Active);
end;
procedure TMainForm.BtnDXClusterClick(Sender: TObject);
// ЛКМ — показывать/не показывать подписи спотов на спектре (настройка живёт
// в конфиге, чтобы состояние пережило перезапуск).
begin
if FDXSpotOverlay = nil then Exit;
FDXCfg.ShowSpots := not FDXCfg.ShowSpots;
FDXSpotOverlay.SetEnabled(FDXCfg.ShowSpots);
FController.FSettings.SaveDXClusterSettings(FDXCfg);
UpdateDXClusterButton;
FSpectrumDirty := True;
end;
procedure TMainForm.BtnDXClusterMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
begin
if Button = mbRight then ShowDXClusterForm;
end;
procedure TMainForm.ShowDXClusterForm;
begin
if FDXClusterForm = nil then
begin
FDXClusterForm := TDXClusterForm.CreateWith(Self, FDXStore, FDXClient);
FDXClusterForm.OnTuneSpot := OnDXTuneSpot;
end;
FDXClusterForm.ApplyTheme(CurrentAppTheme);
if FDXClusterForm.Visible then FDXClusterForm.Hide
else FDXClusterForm.Show;
end;
procedure TMainForm.OnDXTuneSpot(FreqHz: Double; Mode: TDXMode);
// QSY по споту: клик по подписи на спектре или двойной клик в окне списка.
// Мода ставится только когда она однозначна из комментария кластера; при
// dxmUnknown текущую не трогаем — гадать хуже, чем не менять.
var NewMode: Integer;
begin
if FreqHz <= 0 then Exit;
ApplyVfoA(Round(FreqHz));
NewMode := -1;
case Mode of
dxmCW: NewMode := MODE_CWU;
// Боковая по общепринятому правилу: ниже 10 МГц — LSB, выше — USB.
dxmSSB: if FreqHz < 10000000.0 then NewMode := MODE_LSB else NewMode := MODE_USB;
dxmFT8, dxmFT4, dxmDigi, dxmRTTY, dxmPSK: NewMode := MODE_DIGU;
dxmSSTV: NewMode := MODE_USB;
dxmFM: NewMode := MODE_FM;
end;
if (NewMode >= 0) and (NewMode <> FController.FMode) then
FController.SetMode(NewMode);
end;
procedure TMainForm.UpdateBandPlanOverlay;
// Полоска бэндплана видна только в QO-100 (Pluto + full-duplex транспондер).
begin
@@ -5251,7 +5489,9 @@ end;
// Спектр
procedure TMainForm.PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
var VC, VS: Double;
var
VC, VS: Double;
DXSpot: TDXSpot;
begin
if FPan.DispatchFlagsMouseDown(Button, X, Y) then
begin
@@ -5261,6 +5501,15 @@ begin
if Assigned(FSampleRateOverlay) and
FSampleRateOverlay.HandleMouseDown(Button, X, Y) then Exit;
// ЛКМ по подписи DX-спота — QSY на него. Проверяется до тюнинга/драга, но
// только в полосе подписей вверху: ниже неё поведение спектра прежнее.
if (Button = mbLeft) and Assigned(FDXSpotOverlay) and
FDXSpotOverlay.SpotAtPixel(X, Y, DXSpot) then
begin
OnDXTuneSpot(DXSpot.FreqHz, DXSpot.Mode);
Exit;
end;
// Ctrl+ЛКМ по спектру — добавить софт-слайс на этой частоте (в пределах захвата).
if (Button = mbLeft) and (ssCtrl in Shift) and (PbSpectrum.Width > 0) then
begin
@@ -9286,6 +9535,7 @@ begin
SF.OnWfAGCNFChange := ApplyWfAGCNF;
SF.OnADCChange := ApplyADCSettings;
SF.OnWebSettingsChange := ApplyWebSettings;
SF.OnDXClusterChange := OnDXClusterSettingsChange;
end;
SF := TSettingsForm(FSettingsForm);
PushTXProfilesToSettings;
@@ -9340,6 +9590,7 @@ begin
SF.LoadADCSettings(FController.FDitherEnabled, FController.FRandomEnabled);
SF.LoadWebSettings(FWebEnabled, FWebPort, FWebBindAddr, FWebUser, FWebPass,
FWebSpecPixels);
SF.LoadDXClusterSettings(FDXCfg);
SF.LoadFPS(FDisplayFPS);
SF.LoadLightTheme(FLightTheme);
SF.LoadFreqMhzDigits(FFreqMhzDigits);