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
+20
View File
@@ -59,6 +59,11 @@ procedure SockSetNonBlock(S: TSocket; NB: Boolean);
если буфер клиента переполнен, держа при этом FClientLock и блокируя Stop(). }
procedure SockSetSndTimeout(S: TSocket; Ms: Integer);
{ SockSetRcvTimeout — ограничивает время блокирующего SockRecv. Нужен клиентам,
которым между пакетами надо просыпаться самим (проверить Terminated, отдать
накопившиеся команды): recv возвращает -1 по таймауту, соединение живо. }
procedure SockSetRcvTimeout(S: TSocket; Ms: Integer);
{ ── SHA-1 ─────────────────────────────────────────────────────────────────── }
type
@@ -123,6 +128,13 @@ begin
setsockopt(S, SOL_SOCKET, SO_SNDTIMEO, @T, SizeOf(T));
end;
procedure SockSetRcvTimeout(S: TSocket; Ms: Integer);
var T: DWORD;
begin
T := Ms;
setsockopt(S, SOL_SOCKET, SO_RCVTIMEO, @T, SizeOf(T));
end;
{$ELSE}
function SockClose(S: TSocket): Integer;
@@ -165,6 +177,14 @@ begin
fpSetSockOpt(S, SOL_SOCKET, SO_SNDTIMEO, @TV, SizeOf(TV));
end;
procedure SockSetRcvTimeout(S: TSocket; Ms: Integer);
var TV: TTimeVal;
begin
TV.tv_sec := Ms div 1000;
TV.tv_usec := (Ms mod 1000) * 1000;
fpSetSockOpt(S, SOL_SOCKET, SO_RCVTIMEO, @TV, SizeOf(TV));
end;
{$ENDIF}
{ ═══════════════════════════════════════════════════════════════════════════