mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 20:37:33 +00:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user