feat(cat): CAT-порт на каждый слайс + Auto TX (эталон SmartSDR CAT)

Внешняя программа управляет отдельным слайсом как отдельным трансивером
через свой TCP-порт. COM-порты и 19090 остаются за главным приёмником
(слайс A) и не тронуты.

Протокол не дублируется: TCATEngine — чистый парсер поверх записи
callback'ов TCATContext, TCATTcpServer — транспорт поверх движка, поэтому
слайс-CAT = тот же движок с другим контекстом. Новый TCATSliceEndpoint
(движок + TCP-сервер на слот), массивом владеет TCATAdapter. Привязка к
СЛОТУ (буква B..G), не к Id: порт живёт, даже когда слайса нет (PS0).

Auto TX = «Auto Switch TX Slice» из SmartSDR CAT. Вся логика в одной точке
— TRadioController.RequestSliceTx, исполняется в потоке контроллера:
  • пока кто-то уже в эфире (любой источник) — заявка игнорируется,
    перехвата передачи нет никогда;
  • без Auto TX слайс передаёт, только если уже выбран TX-источником;
  • RX; снимает только СВОЮ передачу;
  • TX-источник после отпускания остаётся на слайсе (как в SmartSDR).
На флаге слайса бейдж TX → AutoTX.

Частота слайса — TuneSliceInBand: свобода в пределах ВКЛЮЧЁННОГО диапазона
(трансвертер → его FreqBegin/FreqEnd, иначе band-план), другой диапазон —
отказ. Вне захваченной полосы окно DDC переезжает: доп. пан — центром на
цель (rfPanFreq → UI перекладывает флаги/шапку/зум-бар), пан 0 — центр
посередине между целью и главным VFO и только если оба влезают (общее
железное окно, главный приёмник не оглушаем).

Настройки: вкладка Slices (enable/порт 19091../Auto TX на слайс), персист
в секции cat + device-blob.

Попутно:
  • ZZFL/ZZFH больше не заглушки — новые callback'и кромок фильтра
    (у главного nil → прежний дефолт режима), хелпер SignedPad;
  • FilterIdxFor, AGCModeToUI, BoardUtils.BandEdges;
  • DSP-состояние слайса (NR/NB/SNB/ANF) зеркалится в TCtrlSlice — флаг
    слайса рисовал нули и AGC главного;
  • FUIReady в TMainForm.OnControllerState: адаптеры, создаваемые по ходу
    FormCreate, больше не могут уронить старт рендером до постройки
    виджетов (на этом падал Auto TX из настроек).

Проверено вживую: все порты 19091..19096 слушают, PS;/ZZFA;/MD;/ID;/SM0;/
ZZTX; отвечают; старт со всеми включёнными слайсами чист под gdb.
Документ — doc/CAT_SLICES.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 13:56:36 +03:00
co-authored by Claude Opus 5
parent 8611898ad1
commit 2d15da0462
13 changed files with 1357 additions and 34 deletions
+30 -15
View File
@@ -106,6 +106,12 @@ type
SetAGCTop: TCATSetIntProc; // AGC-T порог, dB
GetCTun: TCATGetBoolFunc; // Click-tune (CTUN) вкл/выкл
SetCTun: TCATSetBoolProc;
// Кромки фильтра в Гц (ZZFL/ZZFH). Главный приёмник настраивается пресетами
// (callback'и nil → команды отвечают дефолтом режима), слайс — кромками.
GetFilterLow: TCATGetIntFunc;
GetFilterHigh: TCATGetIntFunc;
SetFilterLow: TCATSetIntProc;
SetFilterHigh: TCATSetIntProc;
// Andromeda front panel input (ZZZD/ZZZU/ZZZE/ZZZP/ZZZS)
OnPanelVfoStep: TCATSetIntProc; // знаковое число шагов энкодера VFO
OnPanelEncoder: TCATPanelEncoderProc; // номер энкодера (0-based) + знаковый шаг
@@ -119,6 +125,7 @@ type
FLock: TCriticalSection;
function Pad(V: Int64; W: Integer): string;
function SignedPad(V: Int64; W: Integer): string; // '+nnn' / '-nnn'
function FreqToStr(Hz: Double): string;
function StrToFreq(const S: string): Double;
function ModeToK(M: Integer): Integer;
@@ -363,6 +370,13 @@ begin
if Length(Result) > W then Result := Copy(Result, Length(Result) - W + 1, W);
end;
function TCATEngine.SignedPad(V: Int64; W: Integer): string;
// Знаковое поле фиксированной ширины (ZZAR/ZZFL/ZZFH): знак + W цифр.
begin
if V >= 0 then Result := '+' + Pad(V, W)
else Result := '-' + Pad(-V, W);
end;
function TCATEngine.FreqToStr(Hz: Double): string;
begin
Result := Pad(Round(Hz), 11);
@@ -1447,9 +1461,7 @@ begin
if Assigned(FCtx.SetAGCTop) then FCtx.SetAGCTop(v);
Result := '';
end else if Length(s) = 0 then begin
v := SafeGetAGCTop;
if v >= 0 then Result := '+' + Pad(v, 3)
else Result := '-' + Pad(-v, 3);
Result := SignedPad(SafeGetAGCTop, 3);
end else
Result := CAT_ERROR;
end;
@@ -1653,26 +1665,29 @@ function TCATEngine.ZZFI(const s: string): string;
begin Result := ZZBI(s); end;
function TCATEngine.ZZFL(const s: string): string;
// ZZFL: filter low cut (signed Hz, e.g. "-150")
// STUB: ewsdr uses indexed filters, not arbitrary Hz cutoffs
// ZZFL: filter low cut (signed Hz, e.g. "-0150"). Работает, если контекст умеет
// кромки (слайс-CAT); у главного приёмника фильтр индексный — заглушка.
begin
if Length(s) >= 4 then
Result := '' // accept but ignore — STUB
else if Length(s) = 0 then begin
if SafeGetMode in [MODE_CWL, MODE_CWU] then Result := '-0500'
if Length(s) >= 4 then begin
if Assigned(FCtx.SetFilterLow) then FCtx.SetFilterLow(StrToIntDef(s, 0));
Result := '';
end else if Length(s) = 0 then begin
if Assigned(FCtx.GetFilterLow) then Result := SignedPad(FCtx.GetFilterLow, 4)
else if SafeGetMode in [MODE_CWL, MODE_CWU] then Result := '-0500'
else Result := '-1500';
end else
Result := CAT_ERROR;
end;
function TCATEngine.ZZFH(const s: string): string;
// ZZFH: filter high cut (signed Hz)
// STUB: ewsdr uses indexed filters, not arbitrary Hz cutoffs
// ZZFH: filter high cut (signed Hz) — см. ZZFL.
begin
if Length(s) >= 4 then
Result := '' // accept but ignore — STUB
else if Length(s) = 0 then begin
if SafeGetMode in [MODE_CWL, MODE_CWU] then Result := '+0500'
if Length(s) >= 4 then begin
if Assigned(FCtx.SetFilterHigh) then FCtx.SetFilterHigh(StrToIntDef(s, 0));
Result := '';
end else if Length(s) = 0 then begin
if Assigned(FCtx.GetFilterHigh) then Result := SignedPad(FCtx.GetFilterHigh, 4)
else if SafeGetMode in [MODE_CWL, MODE_CWU] then Result := '+0500'
else Result := '+3000';
end else
Result := CAT_ERROR;