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
+71
View File
@@ -427,6 +427,25 @@ type
end;
// Web-сервер — глобальные настройки (не привязаны к устройству, секция "web").
// DX-кластер: телнет-соединение + вид спотов на панадаптере.
// Хранится глобально (секция "dxcluster"), не per-device: кластер и позывной
// от железа не зависят.
TDXClusterSettings = record
Enabled: Boolean; // подключаться при старте
ShowSpots: Boolean; // рисовать оверлей на спектре
Host: string;
Port: Integer; // 1..65535
Login: string; // позывной для логина в кластер (он же «свой» в оверлее)
Password: string; // редкий кластер спрашивает — обычно пусто
PostLogin: string; // команды после логина (по строке; сюда же set/filter)
TTLMinutes: Integer; // сколько держать спот (он же горизонт затухания)
MaxRows: Integer; // рядов лесенки подписей (1..DXSPOT_MAX_ROWS)
// Фильтр по модам. Пустая маска = показывать все (в т.ч. неопознанные).
// Биты соответствуют TDXMode: 1=CW,2=SSB,3=DIGI,4=FT8,5=FT4,6=RTTY,7=PSK,
// 8=FM,9=SSTV; бит 0 = споты без распознанной моды.
ModeMask: LongWord;
end;
TWebSettings = record
Enabled: Boolean;
Port: Integer; // 1..65535
@@ -754,6 +773,9 @@ type
function LoadPans(const MAC: array of Byte; const Ctx: string; out P: TPansConfig): Boolean;
procedure SavePans(const MAC: array of Byte; const Ctx: string; const P: TPansConfig);
// Web-сервер — глобальные настройки (секция "web" в корне JSON).
class procedure DefaultDXCluster(out D: TDXClusterSettings);
procedure LoadDXClusterSettings(out D: TDXClusterSettings);
procedure SaveDXClusterSettings(const D: TDXClusterSettings);
class procedure DefaultWeb(out W: TWebSettings);
procedure LoadWebSettings(out W: TWebSettings);
procedure SaveWebSettings(const W: TWebSettings);
@@ -2873,6 +2895,55 @@ begin
end;
end;
class procedure TSettingsManager.DefaultDXCluster(out D: TDXClusterSettings);
begin
D.Enabled := False; // без позывного подключаться всё равно нечем
D.ShowSpots := True;
D.Host := 'cluster.dxfun.com';
D.Port := 8000;
D.Login := '';
D.Password := '';
D.PostLogin := '';
D.TTLMinutes := 30;
D.MaxRows := 4;
D.ModeMask := 0; // 0 = без фильтра по модам
end;
procedure TSettingsManager.LoadDXClusterSettings(out D: TDXClusterSettings);
var O: TJSONObject;
begin
DefaultDXCluster(D);
if FRoot.Find('dxcluster') = nil then Exit;
O := EnsureObj(FRoot, 'dxcluster');
D.Enabled := JB(O, 'enabled', False);
D.ShowSpots := JB(O, 'show_spots', True);
D.Host := JS(O, 'host', 'cluster.dxfun.com');
D.Port := EnsureRange(JI(O, 'port', 8000), 1, 65535);
D.Login := JS(O, 'login', '');
D.Password := JS(O, 'password', '');
D.PostLogin := JS(O, 'post_login', '');
D.TTLMinutes := EnsureRange(JI(O, 'ttl_minutes', 30), 1, 1440);
D.MaxRows := EnsureRange(JI(O, 'max_rows', 4), 1, 6);
D.ModeMask := LongWord(JI(O, 'mode_mask', 0));
end;
procedure TSettingsManager.SaveDXClusterSettings(const D: TDXClusterSettings);
var O: TJSONObject;
begin
O := EnsureObj(FRoot, 'dxcluster');
JW(O, 'enabled', D.Enabled);
JW(O, 'show_spots', D.ShowSpots);
JWS(O, 'host', D.Host);
JW(O, 'port', D.Port);
JWS(O, 'login', D.Login);
JWS(O, 'password', D.Password);
JWS(O, 'post_login', D.PostLogin);
JW(O, 'ttl_minutes', D.TTLMinutes);
JW(O, 'max_rows', D.MaxRows);
JW(O, 'mode_mask', Integer(D.ModeMask));
Save;
end;
class procedure TSettingsManager.DefaultWeb(out W: TWebSettings);
begin
W.Enabled := True;