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
+883
View File
@@ -0,0 +1,883 @@
unit DXClusterClient;
{
DXClusterClient.pas — telnet-клиент DX-кластера (одно соединение).
Один рабочий поток: резолв → connect (неблокирующий + select, чтобы не висеть
минуту на мёртвом хосте) → логин позывным → чтение строк. Разобранные споты
кладутся ПРЯМО в TDXSpotStore (он потокобезопасен), лог соединения — во
внутреннее кольцо. В главный поток ничего не маршалим: UI сам замечает
изменения по Version стора и по LogVersion — как оверлеи замечают смену вида
по ключам кэша. Никаких LCL-зависимостей.
Разрыв → реконнект с backoff (RECONNECT_MIN..RECONNECT_MAX). Stop() делает
SockShutdown, что разблокирует recv в потоке (на Linux одного close мало —
см. WebUtils.SockShutdown).
Формат спота (общий для DXSpider / AR-Cluster / CC Cluster):
DX de UA3XYZ: 14025.0 DL1ABC CW 599 tnx qso 1832Z
Всё, что не начинается с 'DX de ', в стор не идёт — только в лог.
}
{$IFDEF FPC}
{$MODE Delphi}
{$LONGSTRINGS ON}
{$ENDIF}
interface
uses
Classes, SysUtils, SyncObjs, StrUtils, DateUtils,
WebUtils, // SockClose/SockShutdown/SockRecv/SockSend/SOCK_INVALID
DXSpotStore
{$IFDEF MSWINDOWS}
, Windows, WinSock2
{$ELSE}
, BaseUnix, Sockets, netdb
{$ENDIF};
const
DX_DEFAULT_HOST = 'cluster.dxfun.com';
DX_DEFAULT_PORT = 8000;
DX_CONNECT_MS = 8000; // потолок ожидания connect
DX_CONNECT_SLICE_MS = 200; // квант ожидания connect (чтобы Stop не ждал 8 с)
DX_RECV_MS = 1000; // квант recv: просыпаемся проверить Terminated/очередь
DX_RECONNECT_MIN = 5; // с
DX_RECONNECT_MAX = 60; // с
DX_LOGIN_GRACE_S = 6; // не увидели приглашение — шлём позывной сами
DX_POSTLOGIN_S = 2; // пауза после позывного перед post-login командами
DX_LOG_LINES = 300; // кольцо лога соединения
DX_SEND_RETRIES = 20; // повторов send по таймауту буфера, потом разрыв
type
TDXClusterState = (dxsOff, dxsConnecting, dxsLogin, dxsOnline, dxsRetry, dxsError);
TDXClusterClient = class
private
FThread: TThread;
FLock: TCriticalSection; // защищает конфиг, лог, очередь, состояние
FStopEvent: TEvent;
// конфиг (копия под FLock — поток читает её через GetConfig)
FHost: string;
FPort: Integer;
FLogin: string;
FPassword: string;
FPostLogin: string; // строки через LineEnding
FStore: TDXSpotStore; // не владеет
FState: TDXClusterState;
FStatusMsg: string;
FSpotCount: Int64; // сколько спотов принято за сессию
FLog: array[0..DX_LOG_LINES-1] of string;
FLogHead: Integer; // индекс следующей записи
FLogCount: Integer;
FLogVer: Int64;
FOutQueue: TStringList; // строки на отправку в кластер
procedure SetState(St: TDXClusterState; const Msg: string);
public
constructor Create(AStore: TDXSpotStore);
destructor Destroy; override;
procedure Configure(const AHost: string; APort: Integer;
const ALogin, APassword, APostLogin: string);
procedure Start;
procedure Stop;
function Running: Boolean;
// Отправить сырую строку в кластер (окно списка: sh/dx, set/filter, …).
procedure SendCommand(const S: string);
// Лог соединения — снимок в порядке приёма (старые → новые).
procedure GetLog(Dst: TStrings);
function LogVersion: Int64;
procedure AddLog(const S: string);
function State: TDXClusterState;
function StateText: string;
function StatusMessage: string;
function SpotsReceived: Int64;
property Store: TDXSpotStore read FStore;
end;
// Разбор строки кластера. True — это спот (Spot заполнен).
function ParseDXSpot(const Line: string; out Spot: TDXSpot): Boolean;
function DXStateName(St: TDXClusterState): string;
implementation
type
TDXClusterThread = class(TThread)
private
FOwner: TDXClusterClient;
FSocket: TSocket;
FRxBuf: string; // хвост неполной строки
// конфиг сессии (снимок на время соединения)
FHost: string;
FPort: Integer;
FLogin: string;
FPassword: string;
FPostLogin: string;
FLoginSent: Boolean;
FPassSent: Boolean;
FPostSent: Boolean;
FLoginAt: TDateTime;
FConnectAt: TDateTime;
function Resolve(const Host: string; out Addr: LongWord): Boolean;
function ConnectSock: Boolean;
procedure CloseSock;
function SendAll(const Data: string): Boolean;
function SendLine(const S: string): Boolean;
procedure FlushOutQueue;
procedure HandleChunk(const Data: string);
procedure HandleLine(const Line: string);
procedure CheckPrompts(const Tail: string);
function Session: Boolean; // одно соединение; False — выйти совсем
protected
procedure Execute; override;
public
constructor Create(AOwner: TDXClusterClient);
end;
function DXStateName(St: TDXClusterState): string;
begin
case St of
dxsOff: Result := 'OFF';
dxsConnecting: Result := 'CONNECTING';
dxsLogin: Result := 'LOGIN';
dxsOnline: Result := 'ONLINE';
dxsRetry: Result := 'RETRY';
else Result := 'ERROR';
end;
end;
function SockWouldBlock: Boolean;
// Последняя ошибка сокета означает «данных пока нет / истёк SO_RCVTIMEO /
// прерван сигналом» — соединение живо. Всё остальное (ECONNRESET, ENOTCONN,
// EPIPE…) — разрыв: без этой проверки поток крутил бы recv в пустом цикле,
// вместо того чтобы уйти на реконнект.
var E: Integer;
begin
{$IFDEF MSWINDOWS}
E := WSAGetLastError;
Result := (E = WSAEWOULDBLOCK) or (E = WSAETIMEDOUT) or (E = WSAEINTR);
{$ELSE}
E := fpgeterrno;
Result := (E = ESysEAGAIN) or (E = ESysEWOULDBLOCK) or (E = ESysEINTR);
{$ENDIF}
end;
{ ── Разбор строки спота ──────────────────────────────────────────────────── }
function ParseDXSpot(const Line: string; out Spot: TDXSpot): Boolean;
// 'DX de <spotter>: <кГц> <позывной> <комментарий> <HHMM>Z [грид]'
var
S, Rest, FreqStr, TimeTok: string;
P, i: Integer;
KHz: Double;
FS: TFormatSettings;
begin
Result := False;
FillChar(Spot, SizeOf(Spot), 0);
Spot.Call := ''; Spot.Spotter := ''; Spot.Comment := ''; Spot.TimeUTC := '';
S := Trim(Line);
if Length(S) < 12 then Exit;
if not SameText(Copy(S, 1, 6), 'DX de ') then Exit;
Rest := Copy(S, 7, MaxInt);
P := Pos(':', Rest);
if P <= 1 then Exit;
Spot.Spotter := Trim(Copy(Rest, 1, P - 1));
Rest := Trim(Copy(Rest, P + 1, MaxInt));
// частота (кГц) — первый токен
P := Pos(' ', Rest);
if P <= 1 then Exit;
FreqStr := Copy(Rest, 1, P - 1);
Rest := TrimLeft(Copy(Rest, P + 1, MaxInt));
FS := DefaultFormatSettings;
FS.DecimalSeparator := '.';
FreqStr := StringReplace(FreqStr, ',', '.', [rfReplaceAll]);
if not TryStrToFloat(FreqStr, KHz, FS) then Exit;
if (KHz <= 0) or (KHz > 100000000.0) then Exit; // мусор/битая строка
Spot.FreqHz := KHz * 1000.0;
// позывной DX — второй токен
P := Pos(' ', Rest);
if P > 0 then
begin
Spot.Call := Copy(Rest, 1, P - 1);
Rest := TrimLeft(Copy(Rest, P + 1, MaxInt));
end
else
begin
Spot.Call := Rest;
Rest := '';
end;
if Spot.Call = '' then Exit;
// Время: последний токен вида HHMMZ. Всё до него — комментарий, всё после
// (обычно грид спотера) приклеиваем к комментарию — терять жалко.
Rest := TrimRight(Rest);
P := 0;
for i := Length(Rest) - 4 downto 1 do
if (Rest[i] in ['0'..'9']) and (Rest[i+1] in ['0'..'9']) and
(Rest[i+2] in ['0'..'9']) and (Rest[i+3] in ['0'..'9']) and
(UpCase(Rest[i+4]) = 'Z') and
((i = 1) or (Rest[i-1] = ' ')) then
begin
P := i;
Break;
end;
if P > 0 then
begin
TimeTok := Copy(Rest, P, 4);
Spot.TimeUTC := TimeTok;
Spot.Comment := Trim(Copy(Rest, 1, P - 1));
Rest := Trim(Copy(Rest, P + 5, MaxInt));
if Rest <> '' then Spot.Comment := Trim(Spot.Comment + ' ' + Rest);
end
else
Spot.Comment := Trim(Rest);
Spot.Mode := DXModeFromComment(Spot.Comment);
Spot.Stamp := Now;
Result := True;
end;
{ ── TDXClusterClient ─────────────────────────────────────────────────────── }
constructor TDXClusterClient.Create(AStore: TDXSpotStore);
{$IFDEF MSWINDOWS}
var wsa: TWSAData;
{$ENDIF}
begin
inherited Create;
FStore := AStore;
FLock := TCriticalSection.Create;
FStopEvent := TEvent.Create(nil, True, False, '');
FOutQueue := TStringList.Create;
FHost := DX_DEFAULT_HOST;
FPort := DX_DEFAULT_PORT;
FState := dxsOff;
FLogHead := 0;
FLogCount := 0;
FLogVer := 0;
FSpotCount := 0;
{$IFDEF MSWINDOWS}
WSAStartup($0202, wsa);
{$ENDIF}
end;
destructor TDXClusterClient.Destroy;
begin
Stop;
FOutQueue.Free;
FStopEvent.Free;
FLock.Free;
{$IFDEF MSWINDOWS}
WSACleanup;
{$ENDIF}
inherited Destroy;
end;
procedure TDXClusterClient.Configure(const AHost: string; APort: Integer;
const ALogin, APassword, APostLogin: string);
begin
FLock.Enter;
try
// Смена адреса/учётки — команды, набранные для ПРЕЖНЕГО кластера, теряют
// смысл (и ушли бы на новый). Чистим очередь.
if (FHost <> Trim(AHost)) or (FPort <> APort) or (FLogin <> Trim(ALogin)) then
FOutQueue.Clear;
FHost := Trim(AHost);
FPort := APort;
FLogin := Trim(ALogin);
FPassword := APassword;
FPostLogin := APostLogin;
finally
FLock.Leave;
end;
end;
procedure TDXClusterClient.Start;
begin
if FThread <> nil then Exit;
FLock.Enter;
try
if (FHost = '') or (FLogin = '') then
begin
FState := dxsError;
FStatusMsg := 'host or callsign not set';
Exit;
end;
finally
FLock.Leave;
end;
FStopEvent.ResetEvent;
SetState(dxsConnecting, '');
FThread := TDXClusterThread.Create(Self);
end;
procedure TDXClusterClient.Stop;
var T: TThread;
begin
// Всё, что не успели отправить, к следующему соединению уже неактуально.
FLock.Enter;
try
FOutQueue.Clear;
finally
FLock.Leave;
end;
T := FThread;
if T = nil then
begin
SetState(dxsOff, '');
Exit;
end;
FThread := nil;
T.Terminate;
FStopEvent.SetEvent;
// Сокет закрывает сам поток (TDXClusterThread.CloseSock из Execute);
// FStopEvent + Terminate выводят его из ожиданий, а recv рвётся по таймауту.
T.WaitFor;
T.Free;
SetState(dxsOff, '');
end;
function TDXClusterClient.Running: Boolean;
begin
Result := FThread <> nil;
end;
procedure TDXClusterClient.SetState(St: TDXClusterState; const Msg: string);
begin
FLock.Enter;
try
FState := St;
FStatusMsg := Msg;
Inc(FLogVer); // UI перечитает и статус тоже
finally
FLock.Leave;
end;
end;
function TDXClusterClient.State: TDXClusterState;
begin
FLock.Enter;
try
Result := FState;
finally
FLock.Leave;
end;
end;
function TDXClusterClient.StateText: string;
begin
Result := DXStateName(State);
end;
function TDXClusterClient.StatusMessage: string;
begin
FLock.Enter;
try
Result := FStatusMsg;
finally
FLock.Leave;
end;
end;
function TDXClusterClient.SpotsReceived: Int64;
begin
FLock.Enter;
try
Result := FSpotCount;
finally
FLock.Leave;
end;
end;
procedure TDXClusterClient.SendCommand(const S: string);
begin
if Trim(S) = '' then Exit;
FLock.Enter;
try
FOutQueue.Add(S);
finally
FLock.Leave;
end;
end;
procedure TDXClusterClient.AddLog(const S: string);
begin
FLock.Enter;
try
FLog[FLogHead] := S;
FLogHead := (FLogHead + 1) mod DX_LOG_LINES;
if FLogCount < DX_LOG_LINES then Inc(FLogCount);
Inc(FLogVer);
finally
FLock.Leave;
end;
end;
procedure TDXClusterClient.GetLog(Dst: TStrings);
var i, Start: Integer;
begin
if Dst = nil then Exit;
FLock.Enter;
try
Start := (FLogHead - FLogCount + DX_LOG_LINES) mod DX_LOG_LINES;
for i := 0 to FLogCount - 1 do
Dst.Add(FLog[(Start + i) mod DX_LOG_LINES]);
finally
FLock.Leave;
end;
end;
function TDXClusterClient.LogVersion: Int64;
begin
FLock.Enter;
try
Result := FLogVer;
finally
FLock.Leave;
end;
end;
{ ── TDXClusterThread ─────────────────────────────────────────────────────── }
constructor TDXClusterThread.Create(AOwner: TDXClusterClient);
begin
FOwner := AOwner;
FSocket := SOCK_INVALID;
FreeOnTerminate := False;
inherited Create(False);
end;
function TDXClusterThread.Resolve(const Host: string; out Addr: LongWord): Boolean;
// Возвращает адрес в СЕТЕВОМ порядке байт (готов для sin_addr).
{$IFNDEF MSWINDOWS}
var HE: THostEntry; HA: THostAddr;
{$ELSE}
var PH: PHostEnt; L: LongWord;
{$ENDIF}
begin
Result := False;
Addr := 0;
if Host = '' then Exit;
{$IFDEF MSWINDOWS}
L := inet_addr(PChar(Host));
if L <> INADDR_NONE then // литеральный IP (уже в сетевом порядке)
begin
Addr := L;
Exit(True);
end;
PH := gethostbyname(PChar(Host));
if (PH = nil) or (PH^.h_addr_list = nil) or (PH^.h_addr_list^ = nil) then Exit;
Move(PH^.h_addr_list^^, Addr, 4); // hostent отдаёт сетевой порядок
Result := True;
{$ELSE}
// Литеральный IP: TryStrToHostAddr отдаёт ХОСТОВЫЙ порядок — разворачиваем.
if TryStrToHostAddr(Host, HA) then
begin
Addr := htonl(HA.s_addr);
Exit(True);
end;
// DNS (netdb читает /etc/resolv.conf и /etc/hosts). Здесь адрес приходит
// прямо из DNS-ответа, т.е. УЖЕ в сетевом порядке — второй раз не вертим.
if ResolveHostByName(Host, HE) then
begin
Addr := HE.Addr.s_addr;
Exit(True);
end;
{$ENDIF}
end;
function TDXClusterThread.ConnectSock: Boolean;
// Неблокирующий connect + select: мёртвый хост не держит поток минуту на
// системном таймауте TCP, и Stop() отрабатывает быстро.
var
Addr: {$IFDEF MSWINDOWS}TSockAddrIn{$ELSE}TInetSockAddr{$ENDIF};
IP: LongWord;
R, SoErr: Integer;
FDS: TFDSet;
TV: TTimeVal;
One, Waited: Integer;
{$IFDEF MSWINDOWS}ErrLen: Integer;{$ELSE}ErrLen: TSockLen;{$ENDIF}
begin
Result := False;
if not Resolve(FHost, IP) then
begin
FOwner.SetState(dxsRetry, 'cannot resolve ' + FHost);
FOwner.AddLog('*** DNS: cannot resolve ' + FHost);
Exit;
end;
{$IFDEF MSWINDOWS}
FSocket := socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
{$ELSE}
FSocket := fpSocket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
{$ENDIF}
if FSocket = SOCK_INVALID then
begin
FOwner.SetState(dxsRetry, 'cannot create socket');
Exit;
end;
FillChar(Addr, SizeOf(Addr), 0);
Addr.sin_family := AF_INET;
Addr.sin_port := htons(FPort);
Addr.sin_addr.s_addr := IP;
SockSetNonBlock(FSocket, True);
{$IFDEF MSWINDOWS}
R := WinSock2.connect(FSocket, @Addr, SizeOf(Addr));
{$ELSE}
R := fpConnect(FSocket, @Addr, SizeOf(Addr));
{$ENDIF}
if R <> 0 then
begin
// ожидаемо: EINPROGRESS / WSAEWOULDBLOCK — ждём готовности на запись.
// Ждём НЕ одним select на 8 с, а квантами по DX_CONNECT_SLICE_MS: иначе
// Stop() (закрытие программы) висел бы до конца таймаута соединения.
Waited := 0;
R := 0;
while (Waited < DX_CONNECT_MS) and (not Terminated) do
begin
TV.tv_sec := DX_CONNECT_SLICE_MS div 1000;
TV.tv_usec := (DX_CONNECT_SLICE_MS mod 1000) * 1000;
{$IFDEF MSWINDOWS}
FD_ZERO(FDS);
FD_SET(FSocket, FDS);
R := select(0, nil, @FDS, nil, @TV);
{$ELSE}
fpFD_ZERO(FDS);
fpFD_SET(FSocket, FDS);
R := fpSelect(FSocket + 1, nil, @FDS, nil, @TV);
{$ENDIF}
if R <> 0 then Break; // готов или ошибка select
Inc(Waited, DX_CONNECT_SLICE_MS);
end;
if Terminated then
begin
CloseSock;
Exit;
end;
if R <= 0 then
begin
CloseSock;
FOwner.SetState(dxsRetry, 'connect timeout: ' + FHost);
FOwner.AddLog('*** no answer from ' + FHost + ':' + IntToStr(FPort));
Exit;
end;
SoErr := 0; ErrLen := SizeOf(SoErr);
{$IFDEF MSWINDOWS}
getsockopt(FSocket, SOL_SOCKET, SO_ERROR, PChar(@SoErr), ErrLen);
{$ELSE}
fpGetSockOpt(FSocket, SOL_SOCKET, SO_ERROR, @SoErr, @ErrLen);
{$ENDIF}
if SoErr <> 0 then
begin
CloseSock;
FOwner.SetState(dxsRetry, 'connection refused');
FOwner.AddLog('*** refused: ' + FHost + ':' + IntToStr(FPort));
Exit;
end;
end;
SockSetNonBlock(FSocket, False);
SockSetRcvTimeout(FSocket, DX_RECV_MS);
SockSetSndTimeout(FSocket, 5000);
One := 1;
{$IFDEF MSWINDOWS}
setsockopt(FSocket, SOL_SOCKET, SO_KEEPALIVE, PChar(@One), SizeOf(One));
{$ELSE}
fpSetSockOpt(FSocket, SOL_SOCKET, SO_KEEPALIVE, @One, SizeOf(One));
{$ENDIF}
Result := True;
end;
procedure TDXClusterThread.CloseSock;
begin
if FSocket <> SOCK_INVALID then
begin
SockShutdown(FSocket);
SockClose(FSocket);
FSocket := SOCK_INVALID;
end;
end;
function TDXClusterThread.SendAll(const Data: string): Boolean;
// TCP вправе отправить меньше запрошенного — дописываем остаток. Таймаут
// (SO_SNDTIMEO) и EINTR — не ошибка соединения, пробуем ещё, но не бесконечно.
var
Sent, N, Tries: Integer;
begin
Result := False;
if (FSocket = SOCK_INVALID) or (Data = '') then Exit;
Sent := 0;
Tries := 0;
while Sent < Length(Data) do
begin
if Terminated then Exit;
N := SockSend(FSocket, @Data[Sent + 1], Length(Data) - Sent, 0);
if N > 0 then
begin
Inc(Sent, N);
Tries := 0;
end
else
begin
if not SockWouldBlock then Exit; // настоящая ошибка сокета
Inc(Tries);
if Tries > DX_SEND_RETRIES then Exit; // не отдаёт буфер — считаем разрывом
end;
end;
Result := True;
end;
function TDXClusterThread.SendLine(const S: string): Boolean;
begin
Result := SendAll(S + #13#10);
if not Result then
FOwner.AddLog('*** send failed, dropping connection');
end;
procedure TDXClusterThread.FlushOutQueue;
var
Pending: TStringList;
i: Integer;
begin
Pending := nil;
FOwner.FLock.Enter;
try
if FOwner.FOutQueue.Count > 0 then
begin
Pending := TStringList.Create;
Pending.Assign(FOwner.FOutQueue);
FOwner.FOutQueue.Clear;
end;
finally
FOwner.FLock.Leave;
end;
if Pending = nil then Exit;
try
for i := 0 to Pending.Count - 1 do
begin
if not SendLine(Pending[i]) then Break; // сокет умер — остальное на реконнект
FOwner.AddLog('> ' + Pending[i]);
end;
finally
Pending.Free;
end;
end;
procedure TDXClusterThread.CheckPrompts(const Tail: string);
// Приглашения логина И пароля обычно приходят БЕЗ перевода строки, поэтому
// ищем их и в незавершённом хвосте буфера, а не только в целых строках.
// Флаги FLoginSent/FPassSent держат отправку однократной: хвост проверяется
// заново с каждым пришедшим куском.
var U: string;
begin
if Tail = '' then Exit;
U := LowerCase(Tail);
if not FLoginSent then
begin
if (Pos('login:', U) > 0) or (Pos('call:', U) > 0) or
(Pos('callsign', U) > 0) or (Pos('your call', U) > 0) or
(Pos('enter your', U) > 0) then
begin
if not SendLine(FLogin) then Exit;
FOwner.AddLog('> ' + FLogin);
FLoginSent := True;
FLoginAt := Now;
FOwner.SetState(dxsLogin, '');
end;
Exit;
end;
if (FPassword <> '') and (not FPassSent) and (Pos('password', U) > 0) then
begin
if not SendLine(FPassword) then Exit;
FOwner.AddLog('> ********');
FPassSent := True;
end;
end;
procedure TDXClusterThread.HandleLine(const Line: string);
var
Spot: TDXSpot;
begin
if Line = '' then Exit;
FOwner.AddLog(Line);
if ParseDXSpot(Line, Spot) then
begin
FOwner.FStore.Add(Spot);
FOwner.FLock.Enter;
try
Inc(FOwner.FSpotCount);
finally
FOwner.FLock.Leave;
end;
if FOwner.State <> dxsOnline then FOwner.SetState(dxsOnline, '');
Exit;
end;
CheckPrompts(Line);
end;
procedure TDXClusterThread.HandleChunk(const Data: string);
var
P: Integer;
Line: string;
begin
FRxBuf := FRxBuf + Data;
while True do
begin
P := 1;
while (P <= Length(FRxBuf)) and not (FRxBuf[P] in [#10, #13]) do Inc(P);
if P > Length(FRxBuf) then Break; // перевода строки ещё нет
Line := Copy(FRxBuf, 1, P - 1);
// съедаем весь конец строки (CR, LF или CRLF)
while (P <= Length(FRxBuf)) and (FRxBuf[P] in [#10, #13]) do Inc(P);
Delete(FRxBuf, 1, P - 1);
HandleLine(TrimRight(Line));
end;
// Хвост без перевода строки — возможно, это приглашение логина или пароля.
if FRxBuf <> '' then CheckPrompts(FRxBuf);
// Защита от мусора без переводов строк.
if Length(FRxBuf) > 8192 then FRxBuf := '';
end;
function TDXClusterThread.Session: Boolean;
var
Buf: array[0..4095] of Byte;
N: Integer;
Chunk: string;
PL: TStringList;
i: Integer;
begin
Result := True;
FRxBuf := '';
FLoginSent := False;
FPassSent := False;
FPostSent := False;
FConnectAt := Now;
FOwner.SetState(dxsConnecting, FHost + ':' + IntToStr(FPort));
FOwner.AddLog('*** connecting to ' + FHost + ':' + IntToStr(FPort));
if not ConnectSock then Exit;
FOwner.AddLog('*** connected');
FOwner.SetState(dxsLogin, '');
while not Terminated do
begin
N := SockRecv(FSocket, @Buf[0], SizeOf(Buf), 0);
if N > 0 then
begin
SetLength(Chunk, N);
Move(Buf[0], Chunk[1], N);
HandleChunk(Chunk);
end
else if N = 0 then
begin
FOwner.AddLog('*** connection closed by peer');
Break;
end
else if not SockWouldBlock then
begin
// Не таймаут кванта recv, а настоящая ошибка сокета — уходим на реконнект.
FOwner.AddLog('*** connection lost (socket error)');
Break;
end;
if Terminated then Break;
// Приглашения не дождались — шлём позывной сами (часть кластеров молчит).
if (not FLoginSent) and (SecondsBetween(Now, FConnectAt) >= DX_LOGIN_GRACE_S) then
begin
if not SendLine(FLogin) then Break; // сокет умер — на реконнект
FOwner.AddLog('> ' + FLogin + ' (no prompt seen)');
FLoginSent := True;
FLoginAt := Now;
end;
// Post-login команды — один раз, чуть погодя после позывного.
if FLoginSent and (not FPostSent) and
(SecondsBetween(Now, FLoginAt) >= DX_POSTLOGIN_S) then
begin
FPostSent := True;
if Trim(FPostLogin) <> '' then
begin
PL := TStringList.Create;
try
PL.Text := FPostLogin;
for i := 0 to PL.Count - 1 do
if Trim(PL[i]) <> '' then
begin
if not SendLine(Trim(PL[i])) then Break;
FOwner.AddLog('> ' + Trim(PL[i]));
end;
finally
PL.Free;
end;
end;
if FOwner.State <> dxsOnline then FOwner.SetState(dxsOnline, '');
end;
FlushOutQueue;
end;
CloseSock;
Result := not Terminated;
end;
procedure TDXClusterThread.Execute;
var
Backoff: Integer;
begin
Backoff := DX_RECONNECT_MIN;
// Снимок конфига на всю жизнь потока: Configure во время работы применяется
// следующим Start (так же ведёт себя веб-сервер при смене порта).
FOwner.FLock.Enter;
try
FHost := FOwner.FHost;
FPort := FOwner.FPort;
FLogin := FOwner.FLogin;
FPassword := FOwner.FPassword;
FPostLogin := FOwner.FPostLogin;
finally
FOwner.FLock.Leave;
end;
while not Terminated do
begin
if not Session then Break;
if Terminated then Break;
FOwner.SetState(dxsRetry, 'reconnecting in ' + IntToStr(Backoff) + ' s');
FOwner.AddLog('*** reconnecting in ' + IntToStr(Backoff) + ' s');
if FOwner.FStopEvent.WaitFor(Backoff * 1000) = wrSignaled then Break;
Backoff := Backoff * 2;
if Backoff > DX_RECONNECT_MAX then Backoff := DX_RECONNECT_MAX;
end;
CloseSock;
end;
end.
+459
View File
@@ -0,0 +1,459 @@
unit DXClusterForm;
{
TDXClusterForm — окно DX-кластера: список принятых спотов, лог соединения и
строка команды в кластер (sh/dx, set/filter — диалект у каждого кластера свой,
поэтому команды не угадываем, а даём отправить руками).
Данные тянутся ПОЛЛИНГОМ: сетевой поток кладёт споты в TDXSpotStore и строки в
лог клиента, а форма раз в секунду сравнивает Version/LogVersion и перечитывает
только при изменении. Никакого Synchronize из потока в UI — та же схема, что у
оверлея спектра.
Двойной клик по споту (или Enter) — QSY: наружу через OnTuneSpot.
}
{$mode objfpc}{$H+}
interface
uses
Classes, SysUtils, Math,
Forms, Controls, Graphics, StdCtrls, ExtCtrls,
FlatButton, FlatEdit, FlatListBox, FlatMemo,
AppTheme, DpiUtils, DXSpotStore, DXClusterClient;
type
// QSY по споту: частота в display-Гц + мода (dxmUnknown — не трогать моду).
TDXTuneEvent = procedure(FreqHz: Double; Mode: TDXMode) of object;
TDXClusterForm = class(TForm)
private
FStore: TDXSpotStore; // не владеет
FClient: TDXClusterClient; // не владеет
FTheme: TAppTheme;
FTitle: TLabel;
FStatus: TLabel;
FBtnConn: TFlatButton;
FBtnClear: TFlatButton;
FBtnSort: TFlatButton;
FBtnClose: TFlatButton;
FList: TFlatListBox;
FLog: TFlatMemo;
FEdCmd: TFlatEdit;
FBtnSend: TFlatButton;
FTimer: TTimer;
FSpots: TDXSpotArray; // снимок, параллельный строкам FList
FSortFreq: Boolean; // False = по времени (свежие сверху)
FLastVer: Int64;
FLastLogVer: Int64;
FOnTune: TDXTuneEvent;
procedure BuildUI;
procedure StyleBtn(B: TFlatButton);
function FormatSpotLine(const S: TDXSpot): string;
procedure RefreshSpots;
procedure RefreshLog;
procedure RefreshStatus;
procedure TuneSelected;
procedure OnTimerTick(Sender: TObject);
procedure OnConnClick(Sender: TObject);
procedure OnClearClick(Sender: TObject);
procedure OnSortClick(Sender: TObject);
procedure OnCloseClick(Sender: TObject);
procedure OnSendClick(Sender: TObject);
procedure OnListDblClick(Sender: TObject);
procedure OnListKeyDown(Sender: TObject; var Key: Word; Shift: TShiftState);
procedure OnCmdKeyDown(Sender: TObject; var Key: Word; Shift: TShiftState);
procedure OnFormClose(Sender: TObject; var CloseAction: TCloseAction);
protected
procedure DoShow; override;
procedure DoHide; override;
public
constructor CreateWith(AOwner: TComponent; AStore: TDXSpotStore;
AClient: TDXClusterClient); reintroduce;
procedure ApplyTheme(const T: TAppTheme);
property OnTuneSpot: TDXTuneEvent read FOnTune write FOnTune;
end;
implementation
const
FORM_W = 860;
FORM_H = 560;
MARGIN = 12;
BTN_H = 26;
LOG_H = 130;
constructor TDXClusterForm.CreateWith(AOwner: TComponent; AStore: TDXSpotStore;
AClient: TDXClusterClient);
var
WorkArea: TRect;
begin
inherited CreateNew(AOwner);
FStore := AStore;
FClient := AClient;
FTheme := DarkTheme;
FSortFreq := False;
FLastVer := -1;
FLastLogVer := -1;
Scaled := False;
Caption := 'DX Cluster';
BorderStyle := bsSizeable;
if AOwner is TCustomForm then
begin
WorkArea := Screen.MonitorFromRect(TCustomForm(AOwner).BoundsRect).WorkareaRect;
Position := poOwnerFormCenter;
end
else
begin
WorkArea := Screen.PrimaryMonitor.WorkareaRect;
Position := poScreenCenter;
end;
Width := Min(DpiScale(FORM_W), WorkArea.Right - WorkArea.Left - DpiScale(48));
Height := Min(DpiScale(FORM_H), WorkArea.Bottom - WorkArea.Top - DpiScale(48));
Constraints.MinWidth := Min(DpiScale(620), Width);
Constraints.MinHeight := Min(DpiScale(380), Height);
OnClose := @OnFormClose;
BuildUI;
ApplyTheme(DarkTheme);
end;
procedure TDXClusterForm.BuildUI;
var
BtnW, Y: Integer;
begin
BtnW := DpiScale(96);
FTitle := TLabel.Create(Self);
FTitle.Parent := Self;
FTitle.SetBounds(DpiScale(MARGIN), DpiScale(10), DpiScale(300), DpiScale(22));
FTitle.Caption := 'DX CLUSTER';
FTitle.Font.Size := 9;
FTitle.Font.Style := [fsBold];
FStatus := TLabel.Create(Self);
FStatus.Parent := Self;
FStatus.SetBounds(DpiScale(MARGIN), DpiScale(31), DpiScale(520), DpiScale(18));
FStatus.Font.Size := 8;
FStatus.Anchors := [akLeft, akTop, akRight];
Y := DpiScale(10);
FBtnClose := TFlatButton.Create(Self);
FBtnClose.Parent := Self;
FBtnClose.SetBounds(ClientWidth - DpiScale(MARGIN) - BtnW, Y, BtnW, DpiScale(BTN_H));
FBtnClose.Caption := 'Close';
FBtnClose.Anchors := [akTop, akRight];
FBtnClose.OnClick := @OnCloseClick;
FBtnSort := TFlatButton.Create(Self);
FBtnSort.Parent := Self;
FBtnSort.SetBounds(FBtnClose.Left - BtnW - DpiScale(6), Y, BtnW, DpiScale(BTN_H));
FBtnSort.Caption := 'BY TIME';
FBtnSort.Anchors := [akTop, akRight];
FBtnSort.OnClick := @OnSortClick;
FBtnClear := TFlatButton.Create(Self);
FBtnClear.Parent := Self;
FBtnClear.SetBounds(FBtnSort.Left - BtnW - DpiScale(6), Y, BtnW, DpiScale(BTN_H));
FBtnClear.Caption := 'CLEAR';
FBtnClear.Anchors := [akTop, akRight];
FBtnClear.OnClick := @OnClearClick;
FBtnConn := TFlatButton.Create(Self);
FBtnConn.Parent := Self;
FBtnConn.SetBounds(FBtnClear.Left - BtnW - DpiScale(6), Y, BtnW, DpiScale(BTN_H));
FBtnConn.Caption := 'CONNECT';
FBtnConn.Anchors := [akTop, akRight];
FBtnConn.OnClick := @OnConnClick;
// Строка команды — внизу, над логом.
FBtnSend := TFlatButton.Create(Self);
FBtnSend.Parent := Self;
FBtnSend.SetBounds(ClientWidth - DpiScale(MARGIN) - BtnW,
ClientHeight - DpiScale(MARGIN + BTN_H), BtnW, DpiScale(BTN_H));
FBtnSend.Caption := 'SEND';
FBtnSend.Anchors := [akRight, akBottom];
FBtnSend.OnClick := @OnSendClick;
FEdCmd := TFlatEdit.Create(Self);
FEdCmd.Parent := Self;
FEdCmd.SetBounds(DpiScale(MARGIN), ClientHeight - DpiScale(MARGIN + BTN_H),
FBtnSend.Left - DpiScale(MARGIN + 6), DpiScale(BTN_H));
FEdCmd.TextHint := 'command to cluster (sh/dx, set/filter …)';
FEdCmd.Anchors := [akLeft, akRight, akBottom];
FEdCmd.OnKeyDown := @OnCmdKeyDown;
FLog := TFlatMemo.Create(Self);
FLog.Parent := Self;
FLog.SetBounds(DpiScale(MARGIN),
FEdCmd.Top - DpiScale(LOG_H + 6),
ClientWidth - DpiScale(MARGIN * 2), DpiScale(LOG_H));
FLog.Anchors := [akLeft, akRight, akBottom];
FLog.ReadOnly := True;
FLog.WordWrap := False;
FLog.Font.Name := 'Courier New';
FLog.Font.Size := 8;
FList := TFlatListBox.Create(Self);
FList.Parent := Self;
FList.SetBounds(DpiScale(MARGIN), DpiScale(56),
ClientWidth - DpiScale(MARGIN * 2),
FLog.Top - DpiScale(56 + 6));
FList.Anchors := [akLeft, akTop, akRight, akBottom];
FList.Font.Name := 'Courier New';
FList.Font.Size := 9;
FList.TabStop := True;
FList.OnDblClick := @OnListDblClick;
FList.OnKeyDown := @OnListKeyDown;
FTimer := TTimer.Create(Self);
FTimer.Interval := 1000;
FTimer.Enabled := False;
FTimer.OnTimer := @OnTimerTick;
end;
procedure TDXClusterForm.StyleBtn(B: TFlatButton);
begin
// TFlatButton темы не знает — цвета выставляются вручную, как в ChannelsForm.
if B = nil then Exit;
B.Font.Assign(Font);
B.Font.Size := 8;
B.Font.Style := [];
B.ClrNorm := FTheme.BtnNorm;
B.ClrActive := FTheme.BtnActive;
B.ClrHot := FTheme.BtnHot;
B.ClrBorder := FTheme.BtnBorderNorm;
B.ClrText := FTheme.BtnText;
B.ClrTextAct := FTheme.BtnTextActive;
B.Invalidate;
end;
procedure TDXClusterForm.ApplyTheme(const T: TAppTheme);
begin
FTheme := T;
Color := T.BG;
FTitle.Font.Color := T.Text;
FStatus.Font.Color := T.TextDim;
FList.SetAppTheme(T);
FLog.SetAppTheme(T);
FEdCmd.SetAppTheme(T);
StyleBtn(FBtnConn);
StyleBtn(FBtnClear);
StyleBtn(FBtnSort);
StyleBtn(FBtnClose);
StyleBtn(FBtnSend);
Invalidate;
end;
{ ── Наполнение ───────────────────────────────────────────────────────────── }
function TDXClusterForm.FormatSpotLine(const S: TDXSpot): string;
// Моноширинные колонки: частота | позывной | мода | UTC | спотер | комментарий.
var
FreqStr, Md: string;
begin
FreqStr := FormatFloat('0.0', S.FreqHz / 1000.0); // кГц, как в кластере
while Length(FreqStr) < 10 do FreqStr := ' ' + FreqStr;
Md := DXModeName(S.Mode);
while Length(Md) < 4 do Md := Md + ' ';
Result := Format('%s %-12s %s %-5s %-10s %s',
[FreqStr, S.Call, Md, S.TimeUTC, S.Spotter, S.Comment]);
end;
procedure TDXClusterForm.RefreshSpots;
var
i, j, Keep: Integer;
T: TDXSpot;
Sel: string;
begin
if FStore = nil then Exit;
Keep := FList.ItemIndex;
Sel := '';
if (Keep >= 0) and (Keep < Length(FSpots)) then Sel := FSpots[Keep].Call;
FStore.Snapshot(FSpots); // приходит отсортированным по частоте
if not FSortFreq then
// по времени, свежие сверху (вставками — список короткий, TTL его держит)
for i := 1 to High(FSpots) do
begin
T := FSpots[i];
j := i - 1;
while (j >= 0) and (FSpots[j].Stamp < T.Stamp) do
begin
FSpots[j + 1] := FSpots[j];
Dec(j);
end;
FSpots[j + 1] := T;
end;
FList.Items.BeginUpdate;
try
FList.Items.Clear;
for i := 0 to High(FSpots) do
FList.Items.Add(FormatSpotLine(FSpots[i]));
finally
FList.Items.EndUpdate;
end;
// Держим выделение на том же позывном, если он ещё в списке.
if Sel <> '' then
for i := 0 to High(FSpots) do
if SameText(FSpots[i].Call, Sel) then
begin
FList.ItemIndex := i;
Break;
end;
end;
procedure TDXClusterForm.RefreshLog;
var
L: TStringList;
begin
if FClient = nil then Exit;
L := TStringList.Create;
try
FClient.GetLog(L);
FLog.Lines.BeginUpdate;
try
FLog.Lines.Assign(L);
finally
FLog.Lines.EndUpdate;
end;
// прокрутка в конец — свежие строки внизу
FLog.SelStart := Length(FLog.Text);
finally
L.Free;
end;
end;
procedure TDXClusterForm.RefreshStatus;
var
St: TDXClusterState;
S: string;
begin
if FClient = nil then Exit;
St := FClient.State;
S := DXStateName(St);
if FClient.StatusMessage <> '' then S := S + ' — ' + FClient.StatusMessage;
S := S + Format(' | spots stored: %d received this session: %d',
[FStore.Count, FClient.SpotsReceived]);
FStatus.Caption := S;
if FClient.Running then FBtnConn.Caption := 'DISCONNECT'
else FBtnConn.Caption := 'CONNECT';
end;
{ ── События ──────────────────────────────────────────────────────────────── }
procedure TDXClusterForm.OnTimerTick(Sender: TObject);
begin
if (FStore <> nil) and (FStore.Version <> FLastVer) then
begin
FLastVer := FStore.Version;
RefreshSpots;
end;
if (FClient <> nil) and (FClient.LogVersion <> FLastLogVer) then
begin
FLastLogVer := FClient.LogVersion;
RefreshLog;
RefreshStatus;
end;
end;
procedure TDXClusterForm.OnConnClick(Sender: TObject);
begin
if FClient = nil then Exit;
if FClient.Running then FClient.Stop else FClient.Start;
RefreshStatus;
end;
procedure TDXClusterForm.OnClearClick(Sender: TObject);
begin
if FStore <> nil then FStore.Clear;
FLastVer := -1;
RefreshSpots;
end;
procedure TDXClusterForm.OnSortClick(Sender: TObject);
begin
FSortFreq := not FSortFreq;
if FSortFreq then FBtnSort.Caption := 'BY FREQ'
else FBtnSort.Caption := 'BY TIME';
RefreshSpots;
end;
procedure TDXClusterForm.OnCloseClick(Sender: TObject);
begin
Close;
end;
procedure TDXClusterForm.OnSendClick(Sender: TObject);
begin
if (FClient = nil) or (Trim(FEdCmd.Text) = '') then Exit;
FClient.SendCommand(Trim(FEdCmd.Text));
FEdCmd.Text := '';
end;
procedure TDXClusterForm.OnCmdKeyDown(Sender: TObject; var Key: Word;
Shift: TShiftState);
begin
if Key = 13 then
begin
Key := 0;
OnSendClick(nil);
end;
end;
procedure TDXClusterForm.TuneSelected;
var i: Integer;
begin
i := FList.ItemIndex;
if (i < 0) or (i > High(FSpots)) then Exit;
if Assigned(FOnTune) then FOnTune(FSpots[i].FreqHz, FSpots[i].Mode);
end;
procedure TDXClusterForm.OnListDblClick(Sender: TObject);
begin
TuneSelected;
end;
procedure TDXClusterForm.OnListKeyDown(Sender: TObject; var Key: Word;
Shift: TShiftState);
// Enter по выделенной строке = то же, что двойной клик (QSY на спот).
begin
if (Key = 13) or (Key = 10) then
begin
Key := 0;
TuneSelected;
end;
end;
procedure TDXClusterForm.OnFormClose(Sender: TObject; var CloseAction: TCloseAction);
begin
CloseAction := caHide;
end;
procedure TDXClusterForm.DoShow;
begin
inherited DoShow;
// Поллинг живёт только пока окно видимо (как анализатор лупы маяка).
FLastVer := -1;
FLastLogVer := -1;
RefreshSpots;
RefreshLog;
RefreshStatus;
FTimer.Enabled := True;
end;
procedure TDXClusterForm.DoHide;
begin
FTimer.Enabled := False;
inherited DoHide;
end;
end.
+507
View File
@@ -0,0 +1,507 @@
unit DXSpotOverlay;
{
Оверлей DX-спотов на спектре: позывной в «чипе» + вертикальный штрих к своей
частоте. Пересекающиеся подписи раскладываются лесенкой по рядам сверху вниз.
Производительность — ровно модель BandPlanOverlay/VfoOverlay, ничего нового:
• Кэшируется ТОЛЬКО полоса подписей (W × FBandH, вверху спектра) в key-color
битмап; пересборка идёт исключительно по dirty-ключу (версия стора / вид /
ширина / тема / фильтры / тик затухания), а каждый кадр — один композит
W × FBandH с постоянной альфой (BlendBitmapKey, тот же дворд-блендинг).
Полноэкранный кэш W × H на кадр стоил бы в разы дороже — поэтому штрихи
ниже полосы в кэш НЕ входят.
• Штрихи от полосы до низа спектра рисует вызывающая сторона теми же
примитивами, что и все прочие маркеры: CPU — RawVLine внутри RawBegin/
RawEnd (никакого Canvas на битмапе кадра), GL — DrawLine. Оверлей отдаёт
только готовый список X/цвет (TickCount/Tick), посчитанный при пересборке
кэша, — на кадр никакой арифметики по спотам.
• GL-путь берёт ТОТ ЖЕ битмап текстурой (DrawOverlayBitmap + UploadBitmap с
color-key), заливка — только при dirty. Вид CPU и GL совпадает.
Домен частот — display-Гц, как их видит спектр (GetViewWindow). Частота спота
кладётся как есть: на QO-100 кластеры постят downlink 10489.xxx, что совпадает
со шкалой панадаптера само собой.
}
{$mode objfpc}{$H+}
interface
uses
Classes, SysUtils, Graphics, Types, Math, Forms,
VfoOverlay, // BlendBitmapKey — общий keyed-композит (одна копия на проект)
DXSpotStore;
const
DXSPOT_ALPHA = 225; // альфа композита полосы подписей
DXSPOT_ROW_BASE_H = 15; // базовая высота ряда @96dpi
DXSPOT_MAX_ROWS = 6; // потолок рядов лесенки (настройка кладётся сюда)
type
// Один разложенный спот: где чип, где штрих, каким цветом.
TDXSpotItem = record
X: Integer; // X штриха (пиксель частоты)
Chip: TRect; // прямоугольник подписи (для хит-теста)
Color: TColor; // цвет с учётом возраста/своего позывного
Spot: TDXSpot;
end;
TDXSpotOverlay = class(TComponent)
private
FStore: TDXSpotStore; // не владеет
FCenterFreq: Double;
FSpanHz: Double;
FEnabled: Boolean;
FLightTheme: Boolean;
FOwnCall: string;
// фильтры
FModes: TDXModeSet;
FMaxAgeMin: Integer;
FMaxRows: Integer;
FCache: TBitmap;
FCacheW: Integer;
FCacheDirty: Boolean;
FBandH: Integer; // высота полосы подписей (px, масштаб по DPI)
FRowH: Integer;
// ключ кэша
FKeyCenter: Double;
FKeySpan: Double;
FKeyW: Integer;
FKeyEnabled: Boolean;
FKeyVersion: Int64;
FKeyLight: Boolean;
FKeyRows: Integer;
FKeyAgeTick: Int64;
FAgeTick: Int64; // бампается таймером UI — обновить затухание
FRenderVer: Int64; // ++ на каждую пересборку кэша — ключ GL-текстуры
FLayoutTTL: Integer; // горизонт затухания, снят один раз на раскладку
FItems: array of TDXSpotItem;
FItemCount: Integer;
FHidden: Integer; // сколько спотов не влезло в лесенку
function FreqToX(FreqHz: Double; W: Integer): Integer;
function AgeColor(const S: TDXSpot): TColor;
procedure Layout(C: TCanvas; W: Integer);
procedure DrawSelf(C: TCanvas; W: Integer);
procedure RebuildCache(W: Integer);
function NeedsRebuild(W: Integer): Boolean;
procedure SetMaxRows(V: Integer);
public
constructor Create(AOwner: TComponent); override;
destructor Destroy; override;
procedure Attach(AStore: TDXSpotStore);
procedure SetView(ACenterHz, ASpanHz: Double);
procedure SetEnabled(En: Boolean);
procedure SetLightTheme(Light: Boolean);
procedure SetOwnCall(const ACall: string);
procedure SetFilters(AModes: TDXModeSet; AMaxAgeMin: Integer);
// Тик затухания: дёргается редко (раз в десятки секунд) — только чтобы
// старые споты потускнели и ушедшие по TTL исчезли.
procedure TickAge;
procedure Invalidate;
function Active: Boolean;
// Пересобрать кэш+раскладку, если протух ключ. Вызывается В НАЧАЛЕ кадра:
// штрихи рисуются РАНЬШЕ композита полосы, а список штрихов рождается
// именно при пересборке — иначе первый кадр после нового спота рисовал бы
// старую раскладку.
procedure EnsureRendered(W: Integer);
// CPU-путь: композит полосы подписей вверху Target.
procedure DrawOverlay(Target: TBitmap; W, H: Integer);
// GL-путь: полоса подписей в Target-битмап (W × BandHeight) под текстуру.
procedure DrawOverlayBitmap(Target: TBitmap; W: Integer);
// Штрихи ниже полосы — рисует вызывающая сторона (RawVLine / GL DrawLine).
function TickCount: Integer;
procedure Tick(Idx: Integer; out X: Integer; out Color: TColor);
// Хит-тест по подписи (клик = настроиться на спот).
function SpotAtPixel(X, Y: Integer; out Spot: TDXSpot): Boolean;
property BandHeight: Integer read FBandH;
property CenterFreq: Double read FCenterFreq; // для GL-инвалидизации
property SpanHz: Double read FSpanHz;
property MaxRows: Integer read FMaxRows write SetMaxRows;
property Hidden: Integer read FHidden;
property CacheDirty: Boolean read FCacheDirty;
// Меняется ровно тогда, когда картинка полосы стала другой. GL-путь по нему
// решает, перезаливать ли текстуру: он не зависит от того, кто первым
// дёрнул пересборку в этом кадре (штрихи или композит).
property RenderVersion: Int64 read FRenderVer;
end;
implementation
const
KEY_COLOR = TColor($00FF00FF); // тот же ключ прозрачности, что у VfoOverlay
// Палитра (TColor = $00BBGGRR). Своя пара на тему: на тёмном спектре чип
// тёмный со светлой подписью, на светлом — наоборот, иначе подписи спотов
// на светлой теме сливались бы в грязное пятно.
// тёмная тема светлая тема
CLR_CHIP_BG: array[Boolean] of TColor = ($00202018, $00F2F2EC);
CLR_CHIP_TEXT: array[Boolean] of TColor = ($00F0F0F0, $00181818);
CLR_FRESH: array[Boolean] of TColor = ($00E0C040, $00A05800); // свежий
CLR_STALE: array[Boolean] of TColor = ($00706850, $00B0A898); // на исходе TTL
CLR_OWN: array[Boolean] of TColor = ($0040D0FF, $000060C8); // свой позывной
CHIP_PAD_X = 3;
CHIP_GAP = 3; // минимальный зазор между чипами в одном ряду
function Mix(A, B: TColor; Num, Den: Integer): TColor;
// A→B на Num/Den (0 = A, Den = B).
var ra, ga, ba, rb, gb, bb: Integer;
begin
if Den <= 0 then Exit(A);
ra := A and $FF; ga := (A shr 8) and $FF; ba := (A shr 16) and $FF;
rb := B and $FF; gb := (B shr 8) and $FF; bb := (B shr 16) and $FF;
ra := ra + (rb - ra) * Num div Den;
ga := ga + (gb - ga) * Num div Den;
ba := ba + (bb - ba) * Num div Den;
Result := TColor((ba shl 16) or (ga shl 8) or ra);
end;
{ ── Создание / параметры ─────────────────────────────────────────────────── }
constructor TDXSpotOverlay.Create(AOwner: TComponent);
begin
inherited Create(AOwner);
FRowH := Round(DXSPOT_ROW_BASE_H * Screen.PixelsPerInch / 96);
if FRowH < DXSPOT_ROW_BASE_H then FRowH := DXSPOT_ROW_BASE_H;
FMaxRows := 4;
FBandH := FRowH * FMaxRows + 4;
FCenterFreq := 0;
FSpanHz := 0;
FEnabled := False;
FModes := [];
FMaxAgeMin := 0;
FCache := TBitmap.Create;
FCache.PixelFormat := pf32bit;
FCacheW := 0;
FCacheDirty := True;
FKeyCenter := -1; FKeySpan := -1; FKeyW := -1; FKeyVersion := -1;
FKeyEnabled := False; FKeyLight := False; FKeyRows := -1; FKeyAgeTick := -1;
FAgeTick := 0;
FRenderVer := 0;
end;
destructor TDXSpotOverlay.Destroy;
begin
FCache.Free;
inherited Destroy;
end;
procedure TDXSpotOverlay.Attach(AStore: TDXSpotStore);
begin
FStore := AStore;
FCacheDirty := True;
end;
procedure TDXSpotOverlay.SetView(ACenterHz, ASpanHz: Double);
begin
if (Abs(ACenterHz - FCenterFreq) < 1.0) and (Abs(ASpanHz - FSpanHz) < 1.0) then Exit;
FCenterFreq := ACenterHz;
FSpanHz := ASpanHz;
FCacheDirty := True;
end;
procedure TDXSpotOverlay.SetEnabled(En: Boolean);
begin
if FEnabled = En then Exit;
FEnabled := En;
FCacheDirty := True;
end;
procedure TDXSpotOverlay.SetLightTheme(Light: Boolean);
begin
if FLightTheme = Light then Exit;
FLightTheme := Light;
FCacheDirty := True;
end;
procedure TDXSpotOverlay.SetOwnCall(const ACall: string);
begin
if SameText(FOwnCall, ACall) then Exit;
FOwnCall := UpperCase(Trim(ACall));
FCacheDirty := True;
end;
procedure TDXSpotOverlay.SetFilters(AModes: TDXModeSet; AMaxAgeMin: Integer);
begin
if (FModes = AModes) and (FMaxAgeMin = AMaxAgeMin) then Exit;
FModes := AModes;
FMaxAgeMin := AMaxAgeMin;
FCacheDirty := True;
end;
procedure TDXSpotOverlay.SetMaxRows(V: Integer);
begin
V := EnsureRange(V, 1, DXSPOT_MAX_ROWS);
if V = FMaxRows then Exit;
FMaxRows := V;
FBandH := FRowH * FMaxRows + 4;
FCacheDirty := True;
end;
procedure TDXSpotOverlay.TickAge;
begin
Inc(FAgeTick);
end;
procedure TDXSpotOverlay.Invalidate;
begin
FCacheDirty := True;
end;
function TDXSpotOverlay.Active: Boolean;
begin
Result := FEnabled and (FStore <> nil);
end;
function TDXSpotOverlay.FreqToX(FreqHz: Double; W: Integer): Integer;
begin
if FSpanHz <= 0 then Result := -1
else Result := Round((FreqHz - (FCenterFreq - FSpanHz / 2)) / FSpanHz * W);
end;
function TDXSpotOverlay.AgeColor(const S: TDXSpot): TColor;
// Свежий → CLR_FRESH, к концу окна TTL плавно уходит в CLR_STALE. Свой
// позывной выделен цветом (и тоже гаснет — иначе старый спот кричал бы
// громче свежих). TTL берётся из FLayoutTTL: он посчитан один раз на
// раскладку, чтобы не дёргать лок стора на каждый спот.
var
AgeMin, K: Integer;
Base: TColor;
begin
Base := CLR_FRESH[FLightTheme];
if (FOwnCall <> '') and SameText(S.Call, FOwnCall) then
Base := CLR_OWN[FLightTheme];
if FLayoutTTL <= 0 then Exit(Base);
AgeMin := Round((Now - S.Stamp) * 24 * 60);
K := EnsureRange(AgeMin * 100 div Max(FLayoutTTL, 1), 0, 100);
Result := Mix(Base, CLR_STALE[FLightTheme], K, 100);
end;
{ ── Раскладка лесенки ────────────────────────────────────────────────────── }
procedure TDXSpotOverlay.Layout(C: TCanvas; W: Integer);
// Споты приходят из стора отсортированными по частоте; каждому даём ПЕРВЫЙ
// ряд сверху, где подпись не наезжает на предыдущую. Не нашлось ряда —
// спот скрыт (считаем в FHidden), в лесенке дырок не оставляем.
var
Arr: TDXSpotArray;
N, i, r, TW, ChipW, ChipX, Row: Integer;
RowRight: array[0..DXSPOT_MAX_ROWS-1] of Integer;
LoHz, HiHz: Double;
begin
FItemCount := 0;
FHidden := 0;
if (FStore = nil) or (FSpanHz <= 0) or (W <= 0) then Exit;
// Горизонт затухания: явный фильтр по возрасту, иначе TTL стора (чтение
// под локом — поэтому один раз на раскладку, а не на каждый спот).
FLayoutTTL := FMaxAgeMin;
if FLayoutTTL <= 0 then FLayoutTTL := FStore.TTLMinutes;
LoHz := FCenterFreq - FSpanHz / 2;
HiHz := FCenterFreq + FSpanHz / 2;
N := FStore.SnapshotRange(LoHz, HiHz, FModes, FMaxAgeMin, Arr);
if N = 0 then Exit;
for r := 0 to FMaxRows - 1 do RowRight[r] := -CHIP_GAP - 1;
SetLength(FItems, N);
for i := 0 to N - 1 do
begin
TW := C.TextWidth(Arr[i].Call);
ChipW := TW + CHIP_PAD_X * 2;
ChipX := FreqToX(Arr[i].FreqHz, W) - ChipW div 2;
if ChipX < 0 then ChipX := 0;
if ChipX + ChipW > W then ChipX := W - ChipW;
if ChipX < 0 then Continue; // подпись шире окна — не рисуем
Row := -1;
for r := 0 to FMaxRows - 1 do
if ChipX > RowRight[r] + CHIP_GAP then
begin
Row := r;
Break;
end;
if Row < 0 then
begin
Inc(FHidden);
Continue;
end;
RowRight[Row] := ChipX + ChipW;
FItems[FItemCount].X := FreqToX(Arr[i].FreqHz, W);
FItems[FItemCount].Chip := Rect(ChipX, Row * FRowH + 2,
ChipX + ChipW, Row * FRowH + FRowH);
FItems[FItemCount].Color := AgeColor(Arr[i]);
FItems[FItemCount].Spot := Arr[i];
Inc(FItemCount);
end;
SetLength(FItems, FItemCount);
end;
procedure TDXSpotOverlay.DrawSelf(C: TCanvas; W: Integer);
// Рендер полосы подписей в канвас W × FBandH. Фон — KEY_COLOR (прозрачный
// при композите и в GL-текстуре).
var
i: Integer;
R: TRect;
Col: TColor;
begin
C.Brush.Color := KEY_COLOR;
C.Brush.Style := bsSolid;
C.Pen.Style := psClear;
C.FillRect(Rect(0, 0, W, FBandH));
// Шрифт привязан к высоте ряда (а не к point-size) — подпись влезает при
// любом DPI-масштабе, как в бэндплане.
C.Font.Name := 'Courier New';
C.Font.Style := [fsBold];
C.Font.Height := -Max(8, Round(FRowH * 0.62));
Layout(C, W);
for i := 0 to FItemCount - 1 do
begin
R := FItems[i].Chip;
Col := FItems[i].Color;
// Штрих от низа чипа до низа полосы (дальше его продолжает вызывающая
// сторона по TickCount/Tick — уже прямо в кадре).
if (FItems[i].X >= 0) and (FItems[i].X < W) then
begin
C.Pen.Style := psSolid; C.Pen.Color := Col; C.Pen.Width := 1;
C.MoveTo(FItems[i].X, R.Bottom);
C.LineTo(FItems[i].X, FBandH);
end;
// Чип: тёмная подложка + кромка цветом спота + позывной.
C.Brush.Color := CLR_CHIP_BG[FLightTheme]; C.Brush.Style := bsSolid;
C.Pen.Style := psSolid; C.Pen.Color := Col; C.Pen.Width := 1;
C.Rectangle(R.Left, R.Top, R.Right, R.Bottom);
C.Brush.Style := bsClear;
C.Font.Color := CLR_CHIP_TEXT[FLightTheme];
C.TextOut(R.Left + CHIP_PAD_X,
R.Top + (R.Bottom - R.Top - C.TextHeight('W')) div 2,
FItems[i].Spot.Call);
end;
C.Pen.Style := psSolid;
end;
{ ── Кэш ──────────────────────────────────────────────────────────────────── }
function TDXSpotOverlay.NeedsRebuild(W: Integer): Boolean;
var HalfPxHz: Double;
begin
if FStore = nil then Exit(False);
// Порог по центру/спану — полпикселя (как в бэндплане): суб-пиксельный
// дрейф центра при QO-100 decoder-lock не должен гонять полную пересборку.
HalfPxHz := 0.5 * FSpanHz / Max(W, 1);
if HalfPxHz < 1.0 then HalfPxHz := 1.0;
Result := FCacheDirty or (FCacheW <> W) or (FKeyW <> W)
or (FKeyEnabled <> FEnabled)
or (FKeyVersion <> FStore.Version)
or (FKeyLight <> FLightTheme)
or (FKeyRows <> FMaxRows)
or (FKeyAgeTick <> FAgeTick)
or (Abs(FKeyCenter - FCenterFreq) >= HalfPxHz)
or (Abs(FKeySpan - FSpanHz) >= HalfPxHz);
end;
procedure TDXSpotOverlay.RebuildCache(W: Integer);
begin
if (W <= 0) or (FStore = nil) then
begin
FCacheW := 0; FCacheDirty := False; FItemCount := 0;
Inc(FRenderVer);
Exit;
end;
if (FCache.Width <> W) or (FCache.Height <> FBandH) then
FCache.SetSize(W, FBandH);
DrawSelf(FCache.Canvas, W);
Inc(FRenderVer);
FCacheW := W;
FKeyCenter := FCenterFreq;
FKeySpan := FSpanHz;
FKeyW := W;
FKeyEnabled := FEnabled;
FKeyVersion := FStore.Version;
FKeyLight := FLightTheme;
FKeyRows := FMaxRows;
FKeyAgeTick := FAgeTick;
FCacheDirty := False;
end;
{ ── Рисование ────────────────────────────────────────────────────────────── }
procedure TDXSpotOverlay.EnsureRendered(W: Integer);
begin
if (not Active) or (W <= 0) then Exit;
if NeedsRebuild(W) then RebuildCache(W);
end;
procedure TDXSpotOverlay.DrawOverlay(Target: TBitmap; W, H: Integer);
begin
if (not Active) or (Target = nil) or (W <= 0) or (H <= FBandH) then Exit;
EnsureRendered(W);
if FCacheW <= 0 then Exit;
BlendBitmapKey(Target, FCache, 0, 0, DXSPOT_ALPHA);
end;
procedure TDXSpotOverlay.DrawOverlayBitmap(Target: TBitmap; W: Integer);
begin
if (Target = nil) or (W <= 0) then Exit;
// Раскладка/список штрихов должны быть свежими и в GL-пути.
EnsureRendered(W);
Target.PixelFormat := pf32bit;
Target.SetSize(W, FBandH);
Target.Canvas.Draw(0, 0, FCache);
end;
function TDXSpotOverlay.TickCount: Integer;
begin
if not Active then Result := 0 else Result := FItemCount;
end;
procedure TDXSpotOverlay.Tick(Idx: Integer; out X: Integer; out Color: TColor);
begin
if (Idx < 0) or (Idx >= FItemCount) then
begin
X := -1; Color := clNone;
Exit;
end;
X := FItems[Idx].X;
Color := FItems[Idx].Color;
end;
function TDXSpotOverlay.SpotAtPixel(X, Y: Integer; out Spot: TDXSpot): Boolean;
// Хит только по полосе подписей: ниже неё живут тюнинг/маркеры/слайсы, и
// перехватывать там клик было бы поперёк привычного поведения спектра.
var i: Integer;
begin
Result := False;
if (not Active) or (Y < 0) or (Y > FBandH) then Exit;
for i := 0 to FItemCount - 1 do
if (X >= FItems[i].Chip.Left - 2) and (X <= FItems[i].Chip.Right + 2) and
(Y >= FItems[i].Chip.Top - 2) and (Y <= FItems[i].Chip.Bottom + 2) then
begin
Spot := FItems[i].Spot;
Exit(True);
end;
end;
end.
+382
View File
@@ -0,0 +1,382 @@
unit DXSpotStore;
{
DXSpotStore.pas потокобезопасное хранилище DX-спотов.
Единственная точка обмена между сетевым потоком кластера (TDXClusterClient,
пишет) и UI (оверлей спектра + окно списка, читают). Поэтому здесь НЕТ ни
Synchronize, ни очередей в главный поток: поток кластера кладёт спот прямо
сюда под критической секцией, а UI замечает изменение по монотонному
счётчику Version ровно так же, как оверлеи замечают смену вида по своим
ключам кэша. Никаких LCL-зависимостей (юнит компилируется и в headless).
Дедуп: спот с тем же позывным в пределах DEDUP_HZ считается тем же самым
обновляем частоту/время/комментарий, а не плодим строки. Тот же позывной на
другом диапазоне отдельная запись.
TTL: споты старше FTTLMinutes выбрасываются при каждой правке/снимке.
}
{$IFDEF FPC}
{$MODE Delphi}
{$LONGSTRINGS ON}
{$ENDIF}
interface
uses
SysUtils, Classes, SyncObjs, DateUtils, StrUtils; // StrUtils — PosEx
const
DX_DEDUP_HZ = 5000.0; // тот же позывной в этом окне = тот же спот
DX_DEFAULT_TTL = 30; // мин
DX_DEFAULT_MAX = 500; // потолок записей
type
// Мода спота. Определяется по комментарию кластера (FT8/CW/…), при неудаче —
// остаётся dxmUnknown: гадать по частоте не пытаемся, бэндпланы разные.
TDXMode = (dxmUnknown, dxmCW, dxmSSB, dxmDigi, dxmFT8, dxmFT4, dxmRTTY,
dxmPSK, dxmFM, dxmSSTV);
TDXModeSet = set of TDXMode;
TDXSpot = record
FreqHz: Double; // частота DX-станции, Гц (кластер отдаёт кГц)
Call: string; // позывной DX
Spotter: string; // кто заспотил
Comment: string; // комментарий кластера (уже без времени)
TimeUTC: string; // 'HHMM' как пришло от кластера
Stamp: TDateTime; // локальное время приёма — для TTL и затухания
Mode: TDXMode;
end;
TDXSpotArray = array of TDXSpot;
TDXSpotStore = class
private
FLock: TCriticalSection;
FSpots: TDXSpotArray;
FCount: Integer;
FVersion: Int64;
FTTLMinutes: Integer;
FMaxSpots: Integer;
procedure PurgeLocked;
procedure DropOldestLocked;
function GetTTLMinutes: Integer;
procedure SetTTLMinutes(V: Integer);
function GetMaxSpots: Integer;
procedure SetMaxSpots(V: Integer);
public
constructor Create;
destructor Destroy; override;
// Добавить/обновить спот (вызывается из потока кластера).
procedure Add(const S: TDXSpot);
procedure Clear;
// Снимок всех спотов, отсортированный по частоте.
function Snapshot(out Arr: TDXSpotArray): Integer;
// Снимок окна [LoHz..HiHz] с фильтром по моде (Modes = [] — без фильтра)
// и по возрасту (MaxAgeMin <= 0 — только общий TTL). Для оверлея.
function SnapshotRange(LoHz, HiHz: Double; Modes: TDXModeSet;
MaxAgeMin: Integer; out Arr: TDXSpotArray): Integer;
// Монотонный счётчик правок — ключ инвалидации кэша оверлея/списка.
function Version: Int64;
function Count: Integer;
// Читаются сетевым потоком (PurgeLocked/Add), пишутся из UI — только через
// критическую секцию, как и всё остальное состояние стора.
property TTLMinutes: Integer read GetTTLMinutes write SetTTLMinutes;
property MaxSpots: Integer read GetMaxSpots write SetMaxSpots;
end;
// Мода по комментарию кластера ('FT8 -12 dB' → dxmFT8). Пусто/непонятно —
// dxmUnknown.
function DXModeFromComment(const Comment: string): TDXMode;
function DXModeName(M: TDXMode): string;
implementation
const
MODE_NAMES: array[TDXMode] of string =
('', 'CW', 'SSB', 'DIGI', 'FT8', 'FT4', 'RTTY', 'PSK', 'FM', 'SSTV');
function DXModeName(M: TDXMode): string;
begin
Result := MODE_NAMES[M];
end;
function DXModeFromComment(const Comment: string): TDXMode;
// Ищем маркер моды как ОТДЕЛЬНОЕ слово: 'CW' внутри 'CWOPS' или позывного
// модой не является. Порядок проверки — от длинных/специфичных к общим.
var
U: string;
function HasWord(const W: string): Boolean;
var P, L, N: Integer; OkL, OkR: Boolean;
begin
Result := False;
L := Length(W); N := Length(U);
P := Pos(W, U);
while P > 0 do
begin
OkL := (P = 1) or not (U[P-1] in ['A'..'Z', '0'..'9', '/', '-']);
OkR := (P + L > N) or not (U[P+L] in ['A'..'Z', '0'..'9', '/', '-']);
if OkL and OkR then Exit(True);
P := PosEx(W, U, P + 1);
end;
end;
begin
Result := dxmUnknown;
if Comment = '' then Exit;
U := UpperCase(Comment);
if HasWord('FT8') then Result := dxmFT8
else if HasWord('FT4') then Result := dxmFT4
else if HasWord('RTTY') then Result := dxmRTTY
else if HasWord('PSK') or HasWord('PSK31') or HasWord('BPSK') then Result := dxmPSK
else if HasWord('SSTV') then Result := dxmSSTV
else if HasWord('CW') then Result := dxmCW
else if HasWord('SSB') or HasWord('USB') or HasWord('LSB') then Result := dxmSSB
else if HasWord('FM') then Result := dxmFM
else if HasWord('JT65') or HasWord('JT9') or HasWord('JS8') or
HasWord('MSK144') or HasWord('OLIVIA') or HasWord('DIGI') then
Result := dxmDigi;
end;
{ ── TDXSpotStore ─────────────────────────────────────────────────────────── }
constructor TDXSpotStore.Create;
begin
inherited Create;
FLock := TCriticalSection.Create;
FCount := 0;
FVersion := 0;
FTTLMinutes := DX_DEFAULT_TTL;
FMaxSpots := DX_DEFAULT_MAX;
SetLength(FSpots, DX_DEFAULT_MAX);
end;
destructor TDXSpotStore.Destroy;
begin
FLock.Free;
inherited Destroy;
end;
procedure TDXSpotStore.PurgeLocked;
// Выбрасывает споты старше TTL, уплотняя массив на месте.
var
i, Dst: Integer;
Cutoff: TDateTime;
begin
if FTTLMinutes <= 0 then Exit;
Cutoff := IncMinute(Now, -FTTLMinutes);
Dst := 0;
for i := 0 to FCount - 1 do
if FSpots[i].Stamp >= Cutoff then
begin
if Dst <> i then FSpots[Dst] := FSpots[i];
Inc(Dst);
end;
if Dst <> FCount then
begin
FCount := Dst;
Inc(FVersion);
end;
end;
procedure TDXSpotStore.DropOldestLocked;
var i, Oldest: Integer;
begin
if FCount <= 0 then Exit;
Oldest := 0;
for i := 1 to FCount - 1 do
if FSpots[i].Stamp < FSpots[Oldest].Stamp then Oldest := i;
for i := Oldest to FCount - 2 do FSpots[i] := FSpots[i + 1];
Dec(FCount);
end;
procedure TDXSpotStore.Add(const S: TDXSpot);
var
i: Integer;
UCall: string;
begin
if S.Call = '' then Exit;
FLock.Enter;
try
PurgeLocked;
UCall := UpperCase(S.Call);
for i := 0 to FCount - 1 do
if (UpperCase(FSpots[i].Call) = UCall) and
(Abs(FSpots[i].FreqHz - S.FreqHz) <= DX_DEDUP_HZ) then
begin
FSpots[i] := S; // тот же спот — обновляем целиком
Inc(FVersion);
Exit;
end;
if Length(FSpots) < FMaxSpots then SetLength(FSpots, FMaxSpots);
while FCount >= FMaxSpots do DropOldestLocked;
FSpots[FCount] := S;
Inc(FCount);
Inc(FVersion);
finally
FLock.Leave;
end;
end;
procedure TDXSpotStore.Clear;
begin
FLock.Enter;
try
if FCount = 0 then Exit;
FCount := 0;
Inc(FVersion);
finally
FLock.Leave;
end;
end;
function CompareSpotFreq(const A, B: TDXSpot): Integer;
begin
if A.FreqHz < B.FreqHz then Result := -1
else if A.FreqHz > B.FreqHz then Result := 1
else Result := 0;
end;
procedure SortByFreq(var Arr: TDXSpotArray; N: Integer);
// Вставками: N здесь десятки-сотни и массив почти всегда уже почти
// отсортирован (споты приходят вперемешку, но окно узкое).
var i, j: Integer; T: TDXSpot;
begin
for i := 1 to N - 1 do
begin
T := Arr[i];
j := i - 1;
while (j >= 0) and (CompareSpotFreq(Arr[j], T) > 0) do
begin
Arr[j + 1] := Arr[j];
Dec(j);
end;
Arr[j + 1] := T;
end;
end;
function TDXSpotStore.Snapshot(out Arr: TDXSpotArray): Integer;
var i: Integer;
begin
FLock.Enter;
try
PurgeLocked;
SetLength(Arr, FCount);
for i := 0 to FCount - 1 do Arr[i] := FSpots[i];
Result := FCount;
finally
FLock.Leave;
end;
SortByFreq(Arr, Result);
end;
function TDXSpotStore.SnapshotRange(LoHz, HiHz: Double; Modes: TDXModeSet;
MaxAgeMin: Integer; out Arr: TDXSpotArray): Integer;
var
i, N: Integer;
Cutoff: TDateTime;
UseAge: Boolean;
begin
N := 0;
UseAge := MaxAgeMin > 0;
Cutoff := 0;
if UseAge then Cutoff := IncMinute(Now, -MaxAgeMin);
FLock.Enter;
try
PurgeLocked;
SetLength(Arr, FCount);
for i := 0 to FCount - 1 do
begin
if (FSpots[i].FreqHz < LoHz) or (FSpots[i].FreqHz > HiHz) then Continue;
if (Modes <> []) and not (FSpots[i].Mode in Modes) then Continue;
if UseAge and (FSpots[i].Stamp < Cutoff) then Continue;
Arr[N] := FSpots[i];
Inc(N);
end;
SetLength(Arr, N);
Result := N;
finally
FLock.Leave;
end;
SortByFreq(Arr, Result);
end;
function TDXSpotStore.GetTTLMinutes: Integer;
begin
FLock.Enter;
try
Result := FTTLMinutes;
finally
FLock.Leave;
end;
end;
procedure TDXSpotStore.SetTTLMinutes(V: Integer);
begin
if V < 1 then V := 1;
FLock.Enter;
try
if FTTLMinutes = V then Exit;
FTTLMinutes := V;
PurgeLocked; // укоротили TTL — лишнее выбрасываем сразу
finally
FLock.Leave;
end;
end;
function TDXSpotStore.GetMaxSpots: Integer;
begin
FLock.Enter;
try
Result := FMaxSpots;
finally
FLock.Leave;
end;
end;
procedure TDXSpotStore.SetMaxSpots(V: Integer);
begin
if V < 16 then V := 16;
FLock.Enter;
try
if FMaxSpots = V then Exit;
FMaxSpots := V;
if Length(FSpots) < FMaxSpots then SetLength(FSpots, FMaxSpots);
while FCount > FMaxSpots do
begin
DropOldestLocked;
Inc(FVersion);
end;
finally
FLock.Leave;
end;
end;
function TDXSpotStore.Version: Int64;
begin
FLock.Enter;
try
Result := FVersion;
finally
FLock.Leave;
end;
end;
function TDXSpotStore.Count: Integer;
begin
FLock.Enter;
try
Result := FCount;
finally
FLock.Leave;
end;
end;
end.
+4
View File
@@ -69,6 +69,10 @@ type
property Visible; property Visible;
property OnClick; property OnClick;
property OnDblClick; property OnDblClick;
// Клавиатура: собственный KeyDown обрабатывает стрелки/PgUp/Home, а
// необработанные клавиши (Enter и прочее) достаются владельцу через
// inherited — поэтому событие имеет смысл публиковать.
property OnKeyDown;
end; end;
implementation implementation
+4
View File
@@ -23,6 +23,7 @@ type
FScrollBar: TOverlayScrollBar; FScrollBar: TOverlayScrollBar;
FTheme: TAppTheme; FTheme: TAppTheme;
FSyncing: Boolean; FSyncing: Boolean;
FOnChange: TNotifyEvent;
function GetLines: TStrings; function GetLines: TStrings;
function GetReadOnly: Boolean; function GetReadOnly: Boolean;
procedure SetReadOnly(AValue: Boolean); procedure SetReadOnly(AValue: Boolean);
@@ -65,6 +66,8 @@ type
property TabOrder; property TabOrder;
property TabStop; property TabStop;
property Visible; property Visible;
// Правка текста пользователем (проброс OnChange внутреннего TMemo).
property OnChange: TNotifyEvent read FOnChange write FOnChange;
end; end;
implementation implementation
@@ -192,6 +195,7 @@ end;
procedure TFlatMemo.MemoChange(Sender: TObject); procedure TFlatMemo.MemoChange(Sender: TObject);
begin begin
UpdateScrollBar; UpdateScrollBar;
if Assigned(FOnChange) then FOnChange(Self);
end; end;
procedure TFlatMemo.MemoKeyUp(Sender: TObject; var Key: Word; procedure TFlatMemo.MemoKeyUp(Sender: TObject; var Key: Word;
+254 -3
View File
@@ -22,8 +22,9 @@ unit MainForm;
interface interface
uses uses
Classes, SysUtils, StrUtils, FreqDisplay, DeviceForm, FilterPopup, Classes, SysUtils, StrUtils, DateUtils, FreqDisplay, DeviceForm, FilterPopup,
VfoOverlay, SampleRateOverlay, BandPlanOverlay, VfoOverlay, SampleRateOverlay, BandPlanOverlay,
DXSpotStore, DXClusterClient, DXSpotOverlay, DXClusterForm,
FlatButton, FlatSlider, FlatDropDown, OverlayScrollBar, AppTheme, FlatButton, FlatSlider, FlatDropDown, OverlayScrollBar, AppTheme,
Forms, Controls, Graphics, Dialogs, Forms, Controls, Graphics, Dialogs,
StdCtrls, ExtCtrls, Buttons, Menus, Math, Types, SyncObjs, StdCtrls, ExtCtrls, Buttons, Menus, Math, Types, SyncObjs,
@@ -139,7 +140,18 @@ type
FSliceDrag: Boolean; // тащим маркер несущей слайса по спектру FSliceDrag: Boolean; // тащим маркер несущей слайса по спектру
FSliceDragId: Integer; // какой слайс тащим FSliceDragId: Integer; // какой слайс тащим
FSampleRateOverlay: TSampleRateOverlay; // оверлей span/hide слева вверху спектра 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 = левая панель скрыта FPanelHidden: Boolean; // True = левая панель скрыта
FLeftPanelW: Integer; // ширина панели до скрытия (DPI-safe restore) FLeftPanelW: Integer; // ширина панели до скрытия (DPI-safe restore)
FPendingBoardType: Integer; // BoardType устройства для подключения FPendingBoardType: Integer; // BoardType устройства для подключения
@@ -263,6 +275,7 @@ type
BtnDiscover: TFlatButton; BtnDiscover: TFlatButton;
BtnStartStop: TFlatButton; BtnStartStop: TFlatButton;
BtnSettings: TFlatButton; BtnSettings: TFlatButton;
BtnDXCluster: TFlatButton; // ЛКМ — споты на спектре, ПКМ — окно кластера
// ---- Left panel ---- // ---- Left panel ----
PanelLeft: TPanel; PanelLeft: TPanel;
@@ -497,6 +510,19 @@ type
procedure BtnRxMuteClick(Sender: TObject); procedure BtnRxMuteClick(Sender: TObject);
procedure UpdateRxMuteButton; // видимость (Pluto+full-duplex) + стиль кнопки RX MUTE procedure UpdateRxMuteButton; // видимость (Pluto+full-duplex) + стиль кнопки RX MUTE
procedure UpdateBandPlanOverlay; // вкл/выкл полоски бэндплана QO-100 по InQO100 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 BtnTUNClick(Sender: TObject);
procedure ApplyTUN(Active: Boolean); procedure ApplyTUN(Active: Boolean);
// PureSignal: ЛКМ по PS = вкл/выкл, ПКМ = поповер настроек. // PureSignal: ЛКМ по PS = вкл/выкл, ПКМ = поповер настроек.
@@ -1373,6 +1399,17 @@ begin
PanelSplitter := nil; PanelSplitter := nil;
PbWaterfall := nil; PbWaterfall := nil;
PbPanZoom := 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 DestroyAllExtraPans; // доп. паны (DDC/движок/панели) — до пана 0
FreeAndNil(FPan); FreeAndNil(FPan);
FPans[0] := nil; FPans[0] := nil;
@@ -1976,6 +2013,10 @@ begin
BtnDiscover := MakeBtn(PanelToolbar, 'DISCOVER', X, 3, 80, BTN_H, BtnDiscoverClick); BtnDiscover := MakeBtn(PanelToolbar, 'DISCOVER', X, 3, 80, BTN_H, BtnDiscoverClick);
BtnStartStop := MakeBtn(PanelToolbar, 'START', X+84, 3, 76, BTN_H, BtnStartStopClick); BtnStartStop := MakeBtn(PanelToolbar, 'START', X+84, 3, 76, BTN_H, BtnStartStopClick);
BtnSettings := MakeBtn(PanelToolbar, 'SETUP', X+164, 3, 66, BTN_H, BtnSettingsClick); 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 — синеватый акцент // DISCOVER — синеватый акцент
BtnDiscover.ClrNorm := TColor($00101828); BtnDiscover.ClrNorm := TColor($00101828);
@@ -2641,6 +2682,9 @@ begin
FBandPlanOverlay.SetView(FController.FCenterFreq, FController.FSpanHz); FBandPlanOverlay.SetView(FController.FCenterFreq, FController.FSpanHz);
FSpecView.BandPlanOverlay := FBandPlanOverlay; FSpecView.BandPlanOverlay := FBandPlanOverlay;
// DX-кластер: база спотов + сетевой поток + оверлей подписей.
InitDXCluster;
// S-метр — внутри PanelToolbar, справа // S-метр — внутри PanelToolbar, справа
PanelSMeterRight := TPanel.Create(Self); PanelSMeterRight := TPanel.Create(Self);
PanelSMeterRight.Parent := PanelToolbar; PanelSMeterRight.Parent := PanelToolbar;
@@ -2763,6 +2807,9 @@ begin
SafeLeft := MARGIN; SafeLeft := MARGIN;
if BtnSettings <> nil then if BtnSettings <> nil then
SafeLeft := BtnSettings.Left + BtnSettings.Width + SETTINGS_GAP; 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; SafeRight := ClientWidth - MARGIN;
if PanelLeft <> nil then if PanelLeft <> nil then
begin begin
@@ -3301,6 +3348,9 @@ begin
BtnStartStop.ClrTextAct:= T.TbStartText; BtnStartStop.ClrTextAct:= T.TbStartText;
BtnStartStop.Invalidate; BtnStartStop.Invalidate;
StyleButton(BtnSettings, False); 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); 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 FCWMsgForm <> nil then TCWMessagesForm(FCWMsgForm).ApplyTheme(T);
if FCWTermForm <> nil then TCWTerminalForm(FCWTermForm).ApplyTheme(T); if FCWTermForm <> nil then TCWTerminalForm(FCWTermForm).ApplyTheme(T);
if FBeaconScopeForm <> nil then FBeaconScopeForm.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.SaveTheme(V);
FController.FSettings.Save; FController.FSettings.Save;
if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then
@@ -3690,6 +3742,9 @@ begin
// отсекает неизменное → пересборки кэша на каждый вызов нет. // отсекает неизменное → пересборки кэша на каждый вызов нет.
if Assigned(FBandPlanOverlay) then if Assigned(FBandPlanOverlay) then
FBandPlanOverlay.SetView(ViewCenter, ViewSpan); FBandPlanOverlay.SetView(ViewCenter, ViewSpan);
// Подписи спотов — тот же видимый домен, что у бэндплана.
if Assigned(FDXSpotOverlay) then
FDXSpotOverlay.SetView(ViewCenter, ViewSpan);
if UpdateWidebandFrequencyView and FController.FShowWideband and (PbWideband <> nil) then if UpdateWidebandFrequencyView and FController.FShowWideband and (PbWideband <> nil) then
PbWideband.Invalidate; PbWideband.Invalidate;
UpdatePanZoomBar; // линейка пана следует за частотой/зумом (no-op если без изменений) UpdatePanZoomBar; // линейка пана следует за частотой/зумом (no-op если без изменений)
@@ -3714,6 +3769,9 @@ var
PeakDiff, MinDiff: Double; PeakDiff, MinDiff: Double;
PeakAlpha, MinAlpha: Double; PeakAlpha, MinAlpha: Double;
begin begin
// Затухание спотов не зависит от того, крутится ли приём.
ServiceDXCluster;
if not FController.FRunning then if not FController.FRunning then
begin begin
if PbSMeterRight <> nil then PbSMeterRight.Invalidate; if PbSMeterRight <> nil then PbSMeterRight.Invalidate;
@@ -4947,6 +5005,186 @@ begin
StyleButton(BtnRxMute, FController.FRxMuteOnTx); StyleButton(BtnRxMute, FController.FRxMuteOnTx);
end; 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; procedure TMainForm.UpdateBandPlanOverlay;
// Полоска бэндплана видна только в QO-100 (Pluto + full-duplex транспондер). // Полоска бэндплана видна только в QO-100 (Pluto + full-duplex транспондер).
begin begin
@@ -5251,7 +5489,9 @@ end;
// Спектр // Спектр
procedure TMainForm.PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton; procedure TMainForm.PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer); Shift: TShiftState; X, Y: Integer);
var VC, VS: Double; var
VC, VS: Double;
DXSpot: TDXSpot;
begin begin
if FPan.DispatchFlagsMouseDown(Button, X, Y) then if FPan.DispatchFlagsMouseDown(Button, X, Y) then
begin begin
@@ -5261,6 +5501,15 @@ begin
if Assigned(FSampleRateOverlay) and if Assigned(FSampleRateOverlay) and
FSampleRateOverlay.HandleMouseDown(Button, X, Y) then Exit; 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+ЛКМ по спектру — добавить софт-слайс на этой частоте (в пределах захвата). // Ctrl+ЛКМ по спектру — добавить софт-слайс на этой частоте (в пределах захвата).
if (Button = mbLeft) and (ssCtrl in Shift) and (PbSpectrum.Width > 0) then if (Button = mbLeft) and (ssCtrl in Shift) and (PbSpectrum.Width > 0) then
begin begin
@@ -9286,6 +9535,7 @@ begin
SF.OnWfAGCNFChange := ApplyWfAGCNF; SF.OnWfAGCNFChange := ApplyWfAGCNF;
SF.OnADCChange := ApplyADCSettings; SF.OnADCChange := ApplyADCSettings;
SF.OnWebSettingsChange := ApplyWebSettings; SF.OnWebSettingsChange := ApplyWebSettings;
SF.OnDXClusterChange := OnDXClusterSettingsChange;
end; end;
SF := TSettingsForm(FSettingsForm); SF := TSettingsForm(FSettingsForm);
PushTXProfilesToSettings; PushTXProfilesToSettings;
@@ -9340,6 +9590,7 @@ begin
SF.LoadADCSettings(FController.FDitherEnabled, FController.FRandomEnabled); SF.LoadADCSettings(FController.FDitherEnabled, FController.FRandomEnabled);
SF.LoadWebSettings(FWebEnabled, FWebPort, FWebBindAddr, FWebUser, FWebPass, SF.LoadWebSettings(FWebEnabled, FWebPort, FWebBindAddr, FWebUser, FWebPass,
FWebSpecPixels); FWebSpecPixels);
SF.LoadDXClusterSettings(FDXCfg);
SF.LoadFPS(FDisplayFPS); SF.LoadFPS(FDisplayFPS);
SF.LoadLightTheme(FLightTheme); SF.LoadLightTheme(FLightTheme);
SF.LoadFreqMhzDigits(FFreqMhzDigits); SF.LoadFreqMhzDigits(FFreqMhzDigits);
+71
View File
@@ -427,6 +427,25 @@ type
end; end;
// Web-сервер — глобальные настройки (не привязаны к устройству, секция "web"). // 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 TWebSettings = record
Enabled: Boolean; Enabled: Boolean;
Port: Integer; // 1..65535 Port: Integer; // 1..65535
@@ -754,6 +773,9 @@ type
function LoadPans(const MAC: array of Byte; const Ctx: string; out P: TPansConfig): Boolean; 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); procedure SavePans(const MAC: array of Byte; const Ctx: string; const P: TPansConfig);
// Web-сервер — глобальные настройки (секция "web" в корне JSON). // 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); class procedure DefaultWeb(out W: TWebSettings);
procedure LoadWebSettings(out W: TWebSettings); procedure LoadWebSettings(out W: TWebSettings);
procedure SaveWebSettings(const W: TWebSettings); procedure SaveWebSettings(const W: TWebSettings);
@@ -2873,6 +2895,55 @@ begin
end; end;
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); class procedure TSettingsManager.DefaultWeb(out W: TWebSettings);
begin begin
W.Enabled := True; W.Enabled := True;
+258 -1
View File
@@ -28,7 +28,7 @@ uses
Forms, Controls, Graphics, Dialogs, Forms, Controls, Graphics, Dialogs,
StdCtrls, ExtCtrls, LCLType, StdCtrls, ExtCtrls, LCLType,
FlatButton, FlatCheckBox, FlatComboBox, FlatEdit, FlatSpinEdit, FlatFloatSpinEdit, FlatButton, FlatCheckBox, FlatComboBox, FlatEdit, FlatSpinEdit, FlatFloatSpinEdit,
FlatRadioButton, FlatRadioButton, FlatMemo,
EqualizerControl, OverlayScrollBar, AudioOutput, AudioInput, AppTheme, Settings, EqualizerControl, OverlayScrollBar, AudioOutput, AudioInput, AppTheme, Settings,
BoardUtils, DpiUtils; BoardUtils, DpiUtils;
@@ -86,6 +86,10 @@ type
TOnThemeChange = procedure(LightTheme: Boolean) of object; TOnThemeChange = procedure(LightTheme: Boolean) of object;
TOnWfAGCNFChange = procedure(WfAGC, WfNF: Boolean) of object; TOnWfAGCNFChange = procedure(WfAGC, WfNF: Boolean) of object;
TOnADCChange = procedure(Dither, Random: Boolean) of object; TOnADCChange = procedure(Dither, Random: Boolean) of object;
// Настройки DX-кластера уезжают наружу целиком записью: полей много и
// добавлять их по одному в сигнатуру (как у веб-сервера) уже неудобно.
TOnDXClusterChange = procedure(const D: TDXClusterSettings) of object;
TOnWebSettingsChange = procedure(Enabled: Boolean; Port: Integer; TOnWebSettingsChange = procedure(Enabled: Boolean; Port: Integer;
const BindAddr, User, Pass: string; SpecPixels: Integer) of object; const BindAddr, User, Pass: string; SpecPixels: Integer) of object;
TOnCATChange = procedure( TOnCATChange = procedure(
@@ -124,6 +128,19 @@ type
FPageWaterfall: TScrollBox; FPageWaterfall: TScrollBox;
FPagePA: TScrollBox; FPagePA: TScrollBox;
FPageAdvanced: 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; FPageCAT: TScrollBox;
FPageSlices: TScrollBox; FPageSlices: TScrollBox;
FPageTransmit: TScrollBox; FPageTransmit: TScrollBox;
@@ -133,6 +150,7 @@ type
FNavWaterfall: TFlatButton; FNavWaterfall: TFlatButton;
FNavPA: TFlatButton; FNavPA: TFlatButton;
FNavAdvanced: TFlatButton; FNavAdvanced: TFlatButton;
FNavDXCluster: TFlatButton;
FNavCAT: TFlatButton; FNavCAT: TFlatButton;
FNavSlices: TFlatButton; FNavSlices: TFlatButton;
FNavTransmit: TFlatButton; FNavTransmit: TFlatButton;
@@ -465,6 +483,7 @@ type
FOnVHFCalChange: TOnVHFCalChange; FOnVHFCalChange: TOnVHFCalChange;
FOnADCChange: TOnADCChange; FOnADCChange: TOnADCChange;
FOnWebSettingsChange: TOnWebSettingsChange; FOnWebSettingsChange: TOnWebSettingsChange;
FOnDXClusterChange: TOnDXClusterChange;
FOnDisplayChange: TOnDisplayParamChange; FOnDisplayChange: TOnDisplayParamChange;
FOnWaterfallChange: TOnWaterfallParamChange; FOnWaterfallChange: TOnWaterfallParamChange;
FOnWfRenderChange: TOnWfRenderChange; FOnWfRenderChange: TOnWfRenderChange;
@@ -572,6 +591,8 @@ type
OnChange: TNotifyEvent): TFlatComboBox; OnChange: TNotifyEvent): TFlatComboBox;
function MakeGroupPanel(AParent: TWinControl; const Cap: string; function MakeGroupPanel(AParent: TWinControl; const Cap: string;
ALeft, ATop, AW, AH: Integer): TPanel; ALeft, ATop, AW, AH: Integer): TPanel;
procedure BuildDXClusterTab;
procedure OnDXAnyChange(Sender: TObject);
function MakeScrollPage: TScrollBox; function MakeScrollPage: TScrollBox;
procedure AddPageBottomSpace(APage: TScrollBox); procedure AddPageBottomSpace(APage: TScrollBox);
procedure UpdatePageBottomSpace(APage: TScrollBox); procedure UpdatePageBottomSpace(APage: TScrollBox);
@@ -696,6 +717,7 @@ type
ResLimit, SpeedDiv: Integer); ResLimit, SpeedDiv: Integer);
procedure LoadSpecMSAA(Samples: Integer); procedure LoadSpecMSAA(Samples: Integer);
procedure LoadADCSettings(Dither, Random: Boolean); procedure LoadADCSettings(Dither, Random: Boolean);
procedure LoadDXClusterSettings(const D: TDXClusterSettings);
procedure LoadWebSettings(Enabled: Boolean; Port: Integer; procedure LoadWebSettings(Enabled: Boolean; Port: Integer;
const BindAddr, User, Pass: string; SpecPixels: Integer); const BindAddr, User, Pass: string; SpecPixels: Integer);
@@ -734,6 +756,7 @@ type
property OnWfAGCNFChange: TOnWfAGCNFChange read FOnWfAGCNFChange write FOnWfAGCNFChange; property OnWfAGCNFChange: TOnWfAGCNFChange read FOnWfAGCNFChange write FOnWfAGCNFChange;
property OnADCChange: TOnADCChange read FOnADCChange write FOnADCChange; property OnADCChange: TOnADCChange read FOnADCChange write FOnADCChange;
property OnWebSettingsChange: TOnWebSettingsChange read FOnWebSettingsChange write FOnWebSettingsChange; property OnWebSettingsChange: TOnWebSettingsChange read FOnWebSettingsChange write FOnWebSettingsChange;
property OnDXClusterChange: TOnDXClusterChange read FOnDXClusterChange write FOnDXClusterChange;
end; end;
implementation implementation
@@ -1113,6 +1136,7 @@ begin
FNavCAT := MakeNavButton('CAT', 496); FNavCAT := MakeNavButton('CAT', 496);
FNavSlices := MakeNavButton('Slices', 528); FNavSlices := MakeNavButton('Slices', 528);
FNavAdvanced := MakeNavButton('Advanced', 560); FNavAdvanced := MakeNavButton('Advanced', 560);
FNavDXCluster := MakeNavButton('DX Cluster', 592);
FContentPanel := TPanel.Create(Self); FContentPanel := TPanel.Create(Self);
FContentPanel.Parent := Self; FContentPanel.Parent := Self;
@@ -1135,6 +1159,7 @@ begin
FPagePA := MakeScrollPage; FPagePA := MakeScrollPage;
FPageCalib := MakeScrollPage; FPageCalib := MakeScrollPage;
FPageAdvanced := MakeScrollPage; FPageAdvanced := MakeScrollPage;
FPageDXCluster := MakeScrollPage;
FPageCAT := MakeScrollPage; FPageCAT := MakeScrollPage;
FPageSlices := MakeScrollPage; FPageSlices := MakeScrollPage;
FPageAlex := MakeScrollPage; FPageAlex := MakeScrollPage;
@@ -1154,6 +1179,7 @@ begin
BuildPATab; BuildPATab;
BuildCalibrationTab; BuildCalibrationTab;
BuildAdvancedTab; BuildAdvancedTab;
BuildDXClusterTab;
BuildCATTab; BuildCATTab;
BuildSlicesTab; BuildSlicesTab;
BuildAlexTab; BuildAlexTab;
@@ -1168,6 +1194,7 @@ begin
AddPageBottomSpace(FPageWaterfall); AddPageBottomSpace(FPageWaterfall);
AddPageBottomSpace(FPageCalib); AddPageBottomSpace(FPageCalib);
AddPageBottomSpace(FPageCAT); AddPageBottomSpace(FPageCAT);
AddPageBottomSpace(FPageDXCluster);
AddPageBottomSpace(FPageOC); AddPageBottomSpace(FPageOC);
FBtnClose := TFlatButton.Create(Self); FBtnClose := TFlatButton.Create(Self);
@@ -1390,6 +1417,7 @@ begin
ResizePage(FPagePA, CardWidth); ResizePage(FPagePA, CardWidth);
ResizePage(FPageCalib, CardWidth); ResizePage(FPageCalib, CardWidth);
ResizePage(FPageAdvanced, CardWidth); ResizePage(FPageAdvanced, CardWidth);
ResizePage(FPageDXCluster, CardWidth);
ResizePage(FPageCAT, CardWidth); ResizePage(FPageCAT, CardWidth);
ResizePage(FPageSlices, CardWidth); ResizePage(FPageSlices, CardWidth);
ResizePage(FPageAlex, CardWidth); ResizePage(FPageAlex, CardWidth);
@@ -1408,6 +1436,7 @@ begin
else if Sender = FNavPA then SelectPage(FPagePA, FNavPA) else if Sender = FNavPA then SelectPage(FPagePA, FNavPA)
else if Sender = FNavCalib then SelectPage(FPageCalib, FNavCalib) else if Sender = FNavCalib then SelectPage(FPageCalib, FNavCalib)
else if Sender = FNavAdvanced then SelectPage(FPageAdvanced, FNavAdvanced) 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 = FNavCAT then SelectPage(FPageCAT, FNavCAT)
else if Sender = FNavSlices then SelectPage(FPageSlices, FNavSlices) else if Sender = FNavSlices then SelectPage(FPageSlices, FNavSlices)
else if Sender = FNavAlex then SelectPage(FPageAlex, FNavAlex) else if Sender = FNavAlex then SelectPage(FPageAlex, FNavAlex)
@@ -1429,6 +1458,7 @@ begin
FPagePA.Visible := APage = FPagePA; FPagePA.Visible := APage = FPagePA;
FPageCalib.Visible := APage = FPageCalib; FPageCalib.Visible := APage = FPageCalib;
FPageAdvanced.Visible := APage = FPageAdvanced; FPageAdvanced.Visible := APage = FPageAdvanced;
FPageDXCluster.Visible := APage = FPageDXCluster;
FPageCAT.Visible := APage = FPageCAT; FPageCAT.Visible := APage = FPageCAT;
FPageSlices.Visible := APage = FPageSlices; FPageSlices.Visible := APage = FPageSlices;
FPageAlex.Visible := APage = FPageAlex; FPageAlex.Visible := APage = FPageAlex;
@@ -1444,6 +1474,7 @@ begin
FNavPA.Active := ANav = FNavPA; FNavPA.Active := ANav = FNavPA;
FNavCalib.Active := ANav = FNavCalib; FNavCalib.Active := ANav = FNavCalib;
FNavAdvanced.Active := ANav = FNavAdvanced; FNavAdvanced.Active := ANav = FNavAdvanced;
FNavDXCluster.Active := ANav = FNavDXCluster;
FNavCAT.Active := ANav = FNavCAT; FNavCAT.Active := ANav = FNavCAT;
FNavSlices.Active := ANav = FNavSlices; FNavSlices.Active := ANav = FNavSlices;
FNavAlex.Active := ANav = FNavAlex; FNavAlex.Active := ANav = FNavAlex;
@@ -3412,6 +3443,227 @@ begin
Lbl.Font.Size := 8; Lbl.Font.Size := 8;
end; 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); procedure TSettingsForm.OnADCChkChange(Sender: TObject);
begin begin
if FLoading then Exit; if FLoading then Exit;
@@ -3540,6 +3792,11 @@ procedure TSettingsForm.ApplyTheme(const T: TAppTheme);
TFlatFloatSpinEdit(Ctrl).SetAppTheme(T) TFlatFloatSpinEdit(Ctrl).SetAppTheme(T)
else if Ctrl is TFlatRadioButton then else if Ctrl is TFlatRadioButton then
TFlatRadioButton(Ctrl).SetAppTheme(T) TFlatRadioButton(Ctrl).SetAppTheme(T)
// TFlatMemo — обёртка над TMemo со своим scrollbar: тему знает сама,
// рекурсивный обход внутрь ей только навредил бы (внутренний TMemo не
// подходит ни под одну ветку и остался бы серым).
else if Ctrl is TFlatMemo then
TFlatMemo(Ctrl).SetAppTheme(T)
else if Ctrl is TListBox then else if Ctrl is TListBox then
begin begin
TListBox(Ctrl).Color := T.BG; TListBox(Ctrl).Color := T.BG;
+32 -1
View File
@@ -20,7 +20,7 @@ interface
uses uses
Classes, SysUtils, Graphics, GraphType, ExtCtrls, Controls, Math, Classes, SysUtils, Graphics, GraphType, ExtCtrls, Controls, Math,
AppTheme, AppTheme,
AlertOverlay, SampleRateOverlay, VfoOverlay, BandPlanOverlay, AlertOverlay, SampleRateOverlay, VfoOverlay, BandPlanOverlay, DXSpotOverlay,
WaterfallView, SMeterView, RulerView, RadioModes, WaterfallView, SMeterView, RulerView, RadioModes,
Settings; // FilterEdgesFromBW — единственная таблица знака боковой Settings; // FilterEdgesFromBW — единственная таблица знака боковой
@@ -106,6 +106,7 @@ type
FVfoOverlay: TVfoOverlay; FVfoOverlay: TVfoOverlay;
FSliceOverlays: TFPList; // доп. слайс-флаги (B+); не владеет (владелец MainForm) FSliceOverlays: TFPList; // доп. слайс-флаги (B+); не владеет (владелец MainForm)
FBandPlanOverlay: TBandPlanOverlay; FBandPlanOverlay: TBandPlanOverlay;
FDXSpotOverlay: TDXSpotOverlay; // споты DX-кластера (полоса подписей + штрихи)
// ── Marker ──────────────────────────────────────────────────────────────── // ── Marker ────────────────────────────────────────────────────────────────
FMarkerActive: Boolean; FMarkerActive: Boolean;
FMarkerX: Integer; FMarkerX: Integer;
@@ -172,6 +173,9 @@ type
procedure DrawSliceFilterLinesRaw(W, H: Integer); procedure DrawSliceFilterLinesRaw(W, H: Integer);
procedure DrawBandLetterRaw(X1, X2: Integer; L: Char; Clr: TColor); procedure DrawBandLetterRaw(X1, X2: Integer; L: Char; Clr: TColor);
procedure DrawBeaconMarkersRaw(W, H: Integer); procedure DrawBeaconMarkersRaw(W, H: Integer);
// Штрихи DX-спотов ниже полосы подписей. Раскладка уже посчитана оверлеем
// при пересборке кэша — здесь только N вертикальных линий.
procedure DrawDXSpotTicksRaw(W, H: Integer);
// 2TON/IMD: замер пиков по TX-буферу (общий для CPU/GL рендеров). // 2TON/IMD: замер пиков по TX-буферу (общий для CPU/GL рендеров).
// Возвращает число валидных маркеров в M (0 если измерять нечего): // Возвращает число валидных маркеров в M (0 если измерять нечего):
// [0..1] тона, [2..3] IMD3 (2f1f2, 2f2f1). Обновляет FIMDSummary. // [0..1] тона, [2..3] IMD3 (2f1f2, 2f2f1). Обновляет FIMDSummary.
@@ -318,6 +322,7 @@ type
function SliceOverlayCount: Integer; function SliceOverlayCount: Integer;
function SliceOverlayAt(Index: Integer): TVfoOverlay; function SliceOverlayAt(Index: Integer): TVfoOverlay;
property BandPlanOverlay: TBandPlanOverlay read FBandPlanOverlay write FBandPlanOverlay; property BandPlanOverlay: TBandPlanOverlay read FBandPlanOverlay write FBandPlanOverlay;
property DXSpotOverlay: TDXSpotOverlay read FDXSpotOverlay write FDXSpotOverlay;
// ── Данные от DSP ───────────────────────────────────────────────────────── // ── Данные от DSP ─────────────────────────────────────────────────────────
procedure SetSpectrumData(const Pixels: array of Single; Count: Integer); virtual; procedure SetSpectrumData(const Pixels: array of Single; Count: Integer); virtual;
@@ -669,6 +674,23 @@ begin
FBeaconDecHalf := HalfHz; FBeaconDecHalf := HalfHz;
end; end;
procedure TSpectrumView.DrawDXSpotTicksRaw(W, H: Integer);
// Штрих от низа полосы подписей до низа спектра — по одному на видимый спот.
// Пунктиром, чтобы не спорить с кривой сигнала и краями фильтра. Раскладка
// готова (EnsureRendered в начале кадра), здесь только N линий.
var
i, X: Integer;
Col: TColor;
begin
if (FDXSpotOverlay = nil) or (not FDXSpotOverlay.Active) then Exit;
for i := 0 to FDXSpotOverlay.TickCount - 1 do
begin
FDXSpotOverlay.Tick(i, X, Col);
if (X < 0) or (X >= W) then Continue;
RawVLine(X, FDXSpotOverlay.BandHeight, H - 1, Col, 1, 2, 3);
end;
end;
procedure TSpectrumView.DrawBeaconMarkersRaw(W, H: Integer); procedure TSpectrumView.DrawBeaconMarkersRaw(W, H: Integer);
// Две вертикали: опорная частота маяка (зелёная пунктир) и отслеживаемый // Две вертикали: опорная частота маяка (зелёная пунктир) и отслеживаемый
// центроид (оранжевая сплошная). Расхождение видно глазом → понятно, сел ли // центроид (оранжевая сплошная). Расхождение видно глазом → понятно, сел ли
@@ -1705,6 +1727,10 @@ begin
FSpPts[W] := Point(W-1, H); FSpPts[W+1] := Point(0, H); FSpPts[W] := Point(W-1, H); FSpPts[W+1] := Point(0, H);
// Раскладка спотов — ДО RawBegin: штрихи в фазе raw берут из неё готовые X,
// а сама пересборка идёт на своём кэш-битмапе и только по dirty-ключу.
if Assigned(FDXSpotOverlay) then FDXSpotOverlay.EnsureRendered(W);
// ═══ Фаза 1: raw — весь кадр одним локом ═════════════════════════════════ // ═══ Фаза 1: raw — весь кадр одним локом ═════════════════════════════════
{$IFDEF DARWIN} {$IFDEF DARWIN}
// Cocoa-вариант CopyGridToSpectrum идёт через Canvas.Draw — до RawBegin. // Cocoa-вариант CopyGridToSpectrum идёт через Canvas.Draw — до RawBegin.
@@ -1794,6 +1820,8 @@ begin
if FMarkerActive then DrawMarkerLineRaw(W, H); if FMarkerActive then DrawMarkerLineRaw(W, H);
if FBeaconMarkActive or FBeaconDecActive then DrawBeaconMarkersRaw(W, H); if FBeaconMarkActive or FBeaconDecActive then DrawBeaconMarkersRaw(W, H);
// Штрихи DX-спотов — под маркерами наведения, но над кривой.
DrawDXSpotTicksRaw(W, H);
// 2TON: кружки с уровнями на пиках тонов/IMD3 (активирует MainForm при // 2TON: кружки с уровнями на пиках тонов/IMD3 (активирует MainForm при
// 2TON+TX+DUP — реальный сигнал после PA виден на RX-спектре) // 2TON+TX+DUP — реальный сигнал после PA виден на RX-спектре)
if FIMDActive then DrawIMDMarkersRaw(W, H, DBmax, InvRange); if FIMDActive then DrawIMDMarkersRaw(W, H, DBmax, InvRange);
@@ -1809,6 +1837,9 @@ begin
// Бэндплан QO-100 — полоска внизу спектра, под панелями оверлеев. // Бэндплан QO-100 — полоска внизу спектра, под панелями оверлеев.
if Assigned(FBandPlanOverlay) then if Assigned(FBandPlanOverlay) then
FBandPlanOverlay.DrawOverlay(FSpectrumBitmap, W, H); FBandPlanOverlay.DrawOverlay(FSpectrumBitmap, W, H);
// Полоса подписей DX-спотов — вверху спектра, под флагами VFO.
if Assigned(FDXSpotOverlay) then
FDXSpotOverlay.DrawOverlay(FSpectrumBitmap, W, H);
if Assigned(FSampleRateOverlay) then if Assigned(FSampleRateOverlay) then
FSampleRateOverlay.DrawOverlay(FSpectrumBitmap, C, W, H); FSampleRateOverlay.DrawOverlay(FSpectrumBitmap, C, W, H);
if Assigned(FVfoOverlay) then if Assigned(FVfoOverlay) then
+53
View File
@@ -18,6 +18,7 @@ uses
Classes, SysUtils, Graphics, Controls, Math, Types, Classes, SysUtils, Graphics, Controls, Math, Types,
OpenGLContextEx, GL, OpenGLContextEx, GL,
AppTheme, AlertOverlay, SpectrumView, VfoOverlay, BandPlanOverlay, AppTheme, AlertOverlay, SpectrumView, VfoOverlay, BandPlanOverlay,
DXSpotOverlay,
WaterfallView, WaterfallViewOpengl; WaterfallView, WaterfallViewOpengl;
type type
@@ -36,6 +37,7 @@ type
FSampleOverlayDirty: Boolean; FSampleOverlayDirty: Boolean;
FVfoOverlayDirty: Boolean; FVfoOverlayDirty: Boolean;
FBandOverlayDirty: Boolean; FBandOverlayDirty: Boolean;
FDXOverlayDirty: Boolean;
FGridLabelTex: TGLTextureCache; FGridLabelTex: TGLTextureCache;
FAGCLabelTex: TGLTextureCache; FAGCLabelTex: TGLTextureCache;
FAGCHangLabelTex: TGLTextureCache; FAGCHangLabelTex: TGLTextureCache;
@@ -44,6 +46,7 @@ type
FSampleOverlayTex: TGLTextureCache; FSampleOverlayTex: TGLTextureCache;
FVfoOverlayTex: TGLTextureCache; FVfoOverlayTex: TGLTextureCache;
FBandOverlayTex: TGLTextureCache; FBandOverlayTex: TGLTextureCache;
FDXOverlayTex: TGLTextureCache;
// Текстуры флагов слайсов (B+), привязка по указателю оверлея. // Текстуры флагов слайсов (B+), привязка по указателю оверлея.
FSliceTex: array of record FSliceTex: array of record
Overlay: TVfoOverlay; Overlay: TVfoOverlay;
@@ -72,6 +75,8 @@ type
FLastBandW: Integer; FLastBandW: Integer;
FLastBandCenter: Double; FLastBandCenter: Double;
FLastBandSpan: Double; FLastBandSpan: Double;
FLastDXW: Integer;
FLastDXRender: Int64;
FGLSpectrumW: Integer; FGLSpectrumW: Integer;
FGLSpectrumH: Integer; FGLSpectrumH: Integer;
FSpY: array of Integer; FSpY: array of Integer;
@@ -93,6 +98,7 @@ type
procedure DrawSpectrumCurve(W, H: Integer; DBmax, InvRange: Double); procedure DrawSpectrumCurve(W, H: Integer; DBmax, InvRange: Double);
procedure DrawMarker(W, H: Integer); procedure DrawMarker(W, H: Integer);
procedure DrawBeaconMarkersGL(W, H: Integer); procedure DrawBeaconMarkersGL(W, H: Integer);
procedure DrawDXSpotTicksGL(W, H: Integer);
procedure DrawCircleGL(CX, CY: Integer; R: Single; C: TColor); procedure DrawCircleGL(CX, CY: Integer; R: Single; C: TColor);
procedure DrawIMDMarkersGL(W, H: Integer; DBmax, InvRange: Double); procedure DrawIMDMarkersGL(W, H: Integer; DBmax, InvRange: Double);
procedure DrawADCOverlay(W, H: Integer); procedure DrawADCOverlay(W, H: Integer);
@@ -152,8 +158,10 @@ begin
FSampleOverlayDirty := True; FSampleOverlayDirty := True;
FVfoOverlayDirty := True; FVfoOverlayDirty := True;
FBandOverlayDirty := True; FBandOverlayDirty := True;
FDXOverlayDirty := True;
FLastBandW := -MaxInt; FLastBandW := -MaxInt;
FLastBandCenter := -1; FLastBandSpan := -1; FLastBandCenter := -1; FLastBandSpan := -1;
FLastDXW := -MaxInt; FLastDXRender := -1;
FLastAGCY := -MaxInt; FLastAGCY := -MaxInt;
FLastAGCHangY := -MaxInt; FLastAGCHangY := -MaxInt;
FLastMarkerX := -MaxInt; FLastMarkerX := -MaxInt;
@@ -173,6 +181,7 @@ begin
DeleteTexture(FSampleOverlayTex); DeleteTexture(FSampleOverlayTex);
DeleteTexture(FVfoOverlayTex); DeleteTexture(FVfoOverlayTex);
DeleteTexture(FBandOverlayTex); DeleteTexture(FBandOverlayTex);
DeleteTexture(FDXOverlayTex);
for i := 0 to High(FSliceTex) do DeleteTexture(FSliceTex[i].Tex); for i := 0 to High(FSliceTex) do DeleteTexture(FSliceTex[i].Tex);
for i := 0 to High(FLetterTex) do DeleteTexture(FLetterTex[i]); for i := 0 to High(FLetterTex) do DeleteTexture(FLetterTex[i]);
for i := 0 to High(FIMDLabelTex) do DeleteTexture(FIMDLabelTex[i]); for i := 0 to High(FIMDLabelTex) do DeleteTexture(FIMDLabelTex[i]);
@@ -454,6 +463,7 @@ begin
Zap(FADCOverlayTex); Zap(FADCOverlayTex);
Zap(FSampleOverlayTex); Zap(FSampleOverlayTex);
Zap(FVfoOverlayTex); Zap(FVfoOverlayTex);
Zap(FDXOverlayTex);
Zap(FBandOverlayTex); Zap(FBandOverlayTex);
for i := 0 to High(FSliceTex) do Zap(FSliceTex[i].Tex); for i := 0 to High(FSliceTex) do Zap(FSliceTex[i].Tex);
for i := 0 to High(FLetterTex) do Zap(FLetterTex[i]); for i := 0 to High(FLetterTex) do Zap(FLetterTex[i]);
@@ -469,6 +479,7 @@ begin
FSampleOverlayDirty := True; FSampleOverlayDirty := True;
FVfoOverlayDirty := True; FVfoOverlayDirty := True;
FBandOverlayDirty := True; FBandOverlayDirty := True;
FDXOverlayDirty := True;
FGLSpectrumW := 0; FGLSpectrumW := 0;
FGLSpectrumH := 0; FGLSpectrumH := 0;
// Водопад — свой контекст/текстуры (история + маркер). При reparent (pop-out) // Водопад — свой контекст/текстуры (история + маркер). При reparent (pop-out)
@@ -820,6 +831,25 @@ begin
end; end;
end; end;
procedure TSpectrumViewOpenGL.DrawDXSpotTicksGL(W, H: Integer);
// GL-двойник DrawDXSpotTicksRaw: те же X и цвета из раскладки оверлея, только
// линиями GL. EnsureRendered здесь и держит раскладку свежей — текстуру полосы
// DrawCachedOverlays перезальёт по RenderVersion, кто бы ни пересобрал кэш.
var
i, X, Y0: Integer;
Col: TColor;
begin
if (FDXSpotOverlay = nil) or (not FDXSpotOverlay.Active) then Exit;
FDXSpotOverlay.EnsureRendered(W);
Y0 := FDXSpotOverlay.BandHeight;
for i := 0 to FDXSpotOverlay.TickCount - 1 do
begin
FDXSpotOverlay.Tick(i, X, Col);
if (X < 0) or (X >= W) then Continue;
DrawLine(X, Y0, X, H, Col, 1, True); // пунктир — как в CPU-пути
end;
end;
procedure TSpectrumViewOpenGL.DrawCircleGL(CX, CY: Integer; R: Single; C: TColor); procedure TSpectrumViewOpenGL.DrawCircleGL(CX, CY: Integer; R: Single; C: TColor);
// Залитый кружок (triangle fan, 20 сегментов) — маркер пика 2TON/IMD. // Залитый кружок (triangle fan, 20 сегментов) — маркер пика 2TON/IMD.
var var
@@ -947,6 +977,28 @@ begin
DrawTexture(FBandOverlayTex, 0, H - FBandOverlayTex.H); DrawTexture(FBandOverlayTex, 0, H - FBandOverlayTex.H);
end; end;
// Полоса подписей DX-спотов — вверху спектра. Тот же битмап, что в CPU-пути,
// заливается текстурой ТОЛЬКО при dirty (новый спот меняет CacheDirty
// оверлея, вид — центр/спан/ширину).
if Assigned(FDXSpotOverlay) and FDXSpotOverlay.Active then
begin
if FOverlayDirty or FDXOverlayDirty or FDXOverlayTex.Dirty or
(FLastDXW <> W) or (FLastDXRender <> FDXSpotOverlay.RenderVersion) then
begin
B := TBitmap.Create;
try
FDXSpotOverlay.DrawOverlayBitmap(B, W);
UploadBitmap(FDXOverlayTex, B, True, DXSPOT_ALPHA);
finally
B.Free;
end;
FLastDXW := W;
FLastDXRender := FDXSpotOverlay.RenderVersion;
FDXOverlayDirty := False;
end;
DrawTexture(FDXOverlayTex, 0, 0);
end;
if Assigned(FSampleRateOverlay) then if Assigned(FSampleRateOverlay) then
begin begin
OW := Min(FSampleRateOverlay.Width, W - FSampleRateOverlay.Left); OW := Min(FSampleRateOverlay.Width, W - FSampleRateOverlay.Left);
@@ -1098,6 +1150,7 @@ begin
DrawSpectrumCurve(W, H, DBmax, InvRange); DrawSpectrumCurve(W, H, DBmax, InvRange);
DrawMarker(W, H); DrawMarker(W, H);
if FBeaconMarkActive or FBeaconDecActive then DrawBeaconMarkersGL(W, H); if FBeaconMarkActive or FBeaconDecActive then DrawBeaconMarkersGL(W, H);
DrawDXSpotTicksGL(W, H);
// 2TON: кружки с уровнями на пиках тонов/IMD3 (активирует MainForm при // 2TON: кружки с уровнями на пиках тонов/IMD3 (активирует MainForm при
// 2TON+TX+DUP — реальный сигнал после PA виден на RX-спектре) // 2TON+TX+DUP — реальный сигнал после PA виден на RX-спектре)
if FIMDActive then DrawIMDMarkersGL(W, H, DBmax, InvRange); if FIMDActive then DrawIMDMarkersGL(W, H, DBmax, InvRange);
+6
View File
@@ -283,6 +283,12 @@ type
property OnInvalidate: TNotifyEvent read FOnInvalidate write FOnInvalidate; property OnInvalidate: TNotifyEvent read FOnInvalidate write FOnInvalidate;
end; end;
{ BlendBitmapKey общий keyed-композит кэш-битмапа оверлея в кадр (дворд-
блендинг, магента = прозрачность). Живёт здесь исторически; вынесен в
interface, чтобы другие оверлеи той же модели (DXSpotOverlay) не заводили
вторую копию этого же цикла. }
procedure BlendBitmapKey(Target, Source: TBitmap; DstX, DstY: Integer; Alpha: Byte);
implementation implementation
function SliceColor(L: Char): TColor; function SliceColor(L: Char): TColor;
+20
View File
@@ -59,6 +59,11 @@ procedure SockSetNonBlock(S: TSocket; NB: Boolean);
если буфер клиента переполнен, держа при этом FClientLock и блокируя Stop(). } если буфер клиента переполнен, держа при этом FClientLock и блокируя Stop(). }
procedure SockSetSndTimeout(S: TSocket; Ms: Integer); procedure SockSetSndTimeout(S: TSocket; Ms: Integer);
{ SockSetRcvTimeout ограничивает время блокирующего SockRecv. Нужен клиентам,
которым между пакетами надо просыпаться самим (проверить Terminated, отдать
накопившиеся команды): recv возвращает -1 по таймауту, соединение живо. }
procedure SockSetRcvTimeout(S: TSocket; Ms: Integer);
{ ── SHA-1 ─────────────────────────────────────────────────────────────────── } { ── SHA-1 ─────────────────────────────────────────────────────────────────── }
type type
@@ -123,6 +128,13 @@ begin
setsockopt(S, SOL_SOCKET, SO_SNDTIMEO, @T, SizeOf(T)); setsockopt(S, SOL_SOCKET, SO_SNDTIMEO, @T, SizeOf(T));
end; end;
procedure SockSetRcvTimeout(S: TSocket; Ms: Integer);
var T: DWORD;
begin
T := Ms;
setsockopt(S, SOL_SOCKET, SO_RCVTIMEO, @T, SizeOf(T));
end;
{$ELSE} {$ELSE}
function SockClose(S: TSocket): Integer; function SockClose(S: TSocket): Integer;
@@ -165,6 +177,14 @@ begin
fpSetSockOpt(S, SOL_SOCKET, SO_SNDTIMEO, @TV, SizeOf(TV)); fpSetSockOpt(S, SOL_SOCKET, SO_SNDTIMEO, @TV, SizeOf(TV));
end; 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} {$ENDIF}
{ ═══════════════════════════════════════════════════════════════════════════ { ═══════════════════════════════════════════════════════════════════════════