Files
ewsdr/CATSliceEndpoint.pas
ew8bakandClaude Opus 5 2d15da0462 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>
2026-07-25 13:56:36 +03:00

572 lines
20 KiB
ObjectPascal

unit CATSliceEndpoint;
// -----------------------------------------------------------------------------
// TCATSliceEndpoint — CAT-порт ОДНОГО дополнительного слайса (B, C, …).
//
// Протокол не дублируется: движок TCATEngine работает поверх плоской записи
// callback'ов TCATContext, поэтому «CAT слайса» — это тот же движок с другим
// контекстом, где геттеры/сеттеры смотрят в FSlices[слот], а не в главный RX.
// Транспорт — тот же TCATTcpServer, просто на своём порту.
//
// TCATAdapter ──┬── главный контекст ── TCATEngine ── serial + TCP 19090
// └── TCATSliceEndpoint ── TCATEngine ── TCP 19091 (слайс B)
// TCATSliceEndpoint ── TCATEngine ── TCP 19092 (слайс C) …
//
// Эталон поведения — SmartSDR CAT: порт привязан к слайсу, а флаг Auto TX
// («Auto Switch TX Slice») разрешает команде TX самой сделать этот слайс
// источником передачи. Вся защита «в эфире одновременно только один слайс»
// живёт в TRadioController.RequestSliceTx (выполняется в потоке контроллера).
//
// Привязка — к СЛОТУ (букве), а не к Id слайса: слайс на слоте может быть
// создан/удалён в любой момент, порт при этом остаётся поднятым. Когда слайса
// нет, эндпоинт отвечает «выключенным радио» (PS0) и игнорирует команды.
// -----------------------------------------------------------------------------
{$mode objfpc}{$H+}
interface
uses
Classes, SysUtils, Math, SyncObjs,
RadioController, CATEngine, CATTcp, Settings, WDSPEngine, RadioModes;
const
// Слотов слайс-CAT = потолок слайсов движка (буквы B..). Настройки хранят
// ровно столько же полей — иначе персист разъедется с рантаймом.
CAT_SLICE_SLOTS = MAX_SLICES;
{$IF CAT_SLICE_SLOTS <> CFG_CAT_SLICE_COUNT}
{$ERROR CFG_CAT_SLICE_COUNT must match WDSPEngine.MAX_SLICES}
{$IFEND}
type
TCATSliceEndpoint = class
private
FController: TRadioController;
FSlot: Integer; // 0 = слайс B
FEngine: TCATEngine;
FTcp: TCATTcpServer;
FPort: Integer;
FEnabled: Boolean;
// Теневой VFO B: своего второго VFO у слайса нет, но управляющее ПО любит
// его читать/писать (split-режимы). Храним значение, эфир им не двигаем.
FShadowVfoB: Double;
// Параметры команды для маршалинга в поток контроллера (Invoke). Один
// эндпоинт может обслуживать несколько TCP-клиентов, поэтому «положить
// параметр + вызвать Invoke» защищено локом.
FCmdLock: TCriticalSection;
FSyncFreq: Double;
FSyncInt: Integer;
FSyncBool: Boolean;
function GetLetter: Char;
function SliceId: Integer; // 0 = слайса на слоте нет
function Slice(out S: TCtrlSlice): Boolean;
function SliceBW(const S: TCtrlSlice): Integer; // ширина фильтра под режим
// ── Геттеры (CAT-поток, read-only) ──
function GetVfoA: Double;
function GetVfoB: Double;
function GetMode: Integer;
function GetFilterIdx: Integer;
function GetFilterBW: Integer;
function GetFilterLow: Integer;
function GetFilterHigh: Integer;
function GetAGCMode: Integer;
function GetVolume: Integer;
function GetDriveLevel: Integer;
function GetNRMode: Integer;
function GetNBMode: Integer;
function GetSNB: Boolean;
function GetANF: Boolean;
function GetTX: Boolean;
function GetRunning: Boolean;
function GetSMeter: Double;
function GetBand: Integer;
function GetSquelchOn: Boolean;
function GetSquelchLevel: Integer;
// ── Сеттеры (CAT-поток → Invoke в поток контроллера) ──
procedure SetVfoA(V: Double);
procedure SetVfoB(V: Double);
procedure SetMode(V: Integer);
procedure SetFilterIdx(V: Integer);
procedure SetFilterLow(V: Integer);
procedure SetFilterHigh(V: Integer);
procedure SetAGCMode(V: Integer);
procedure SetVolume(V: Integer);
procedure SetDriveLevel(V: Integer);
procedure SetNRMode(V: Integer);
procedure SetNBMode(V: Integer);
procedure SetSNB(V: Boolean);
procedure SetANF(V: Boolean);
procedure SetTransmitting(V: Boolean);
procedure SetSquelchOn(V: Boolean);
procedure SetSquelchLevel(V: Integer);
procedure DoTuneUp;
procedure DoTuneDown;
// ── Sync-методы (поток контроллера) ──
procedure SyncVfoA;
procedure SyncMode;
procedure SyncFilterIdx;
procedure SyncFilterLow;
procedure SyncFilterHigh;
procedure SyncAGC;
procedure SyncVolume;
procedure SyncDrive;
procedure SyncNR;
procedure SyncNB;
procedure SyncSNB;
procedure SyncANF;
procedure SyncTx;
procedure SyncSquelchOn;
procedure SyncSquelchLevel;
procedure SyncTuneBy;
// Параметр команды + Invoke под локом: между «положить» и «исполнить»
// не должен вклиниться второй клиент этого же порта.
procedure CmdFreq(V: Double; P: TThreadMethod);
procedure CmdInt (V: Integer; P: TThreadMethod);
procedure CmdBool(V: Boolean; P: TThreadMethod);
procedure ApplyDSP(NR, NB: Integer; SNBOn, ANFOn: Boolean);
public
constructor Create(AController: TRadioController; ASlot: Integer);
destructor Destroy; override;
// Поднять/опустить TCP-порт эндпоинта. Перезапуск только при реальном
// изменении — иначе правка чужой строки настроек рвала бы живые сессии.
procedure Apply(AEnabled: Boolean; APort: Integer);
property Slot: Integer read FSlot;
property Port: Integer read FPort;
property Enabled: Boolean read FEnabled;
property Letter: Char read GetLetter;
end;
implementation
constructor TCATSliceEndpoint.Create(AController: TRadioController; ASlot: Integer);
var
Ctx: TCATContext;
begin
inherited Create;
FController := AController;
FSlot := ASlot;
FPort := 0;
FEnabled := False;
FShadowVfoB := 0;
FCmdLock := TCriticalSection.Create;
FillChar(Ctx, SizeOf(Ctx), 0);
// Слайс-скоуп.
Ctx.GetVfoA := @GetVfoA;
Ctx.GetVfoB := @GetVfoB;
Ctx.GetMode := @GetMode;
Ctx.GetFilterIdx := @GetFilterIdx;
Ctx.GetFilterBW := @GetFilterBW;
Ctx.GetFilterLow := @GetFilterLow;
Ctx.GetFilterHigh := @GetFilterHigh;
Ctx.GetAGCMode := @GetAGCMode;
Ctx.GetVolume := @GetVolume;
Ctx.GetNRMode := @GetNRMode;
Ctx.GetNBMode := @GetNBMode;
Ctx.GetSNBEnabled := @GetSNB;
Ctx.GetANFEnabled := @GetANF;
Ctx.GetTransmitting := @GetTX;
Ctx.GetRunning := @GetRunning;
Ctx.GetSMeter := @GetSMeter;
Ctx.GetSquelchOn := @GetSquelchOn;
Ctx.GetSquelchLevel := @GetSquelchLevel;
Ctx.SetVfoA := @SetVfoA;
Ctx.SetVfoB := @SetVfoB;
Ctx.SetMode := @SetMode;
Ctx.SetFilterIdx := @SetFilterIdx;
Ctx.SetFilterLow := @SetFilterLow;
Ctx.SetFilterHigh := @SetFilterHigh;
Ctx.SetAGCMode := @SetAGCMode;
Ctx.SetVolume := @SetVolume;
Ctx.SetNRMode := @SetNRMode;
Ctx.SetNBMode := @SetNBMode;
Ctx.SetSNBEnabled := @SetSNB;
Ctx.SetANFEnabled := @SetANF;
Ctx.SetTransmitting := @SetTransmitting;
Ctx.SetSquelchOn := @SetSquelchOn;
Ctx.SetSquelchLevel := @SetSquelchLevel;
Ctx.DoTuneUp := @DoTuneUp;
Ctx.DoTuneDown := @DoTuneDown;
// Глобальные (общие с главным) параметры: передатчик один на всё радио.
Ctx.GetDriveLevel := @GetDriveLevel;
Ctx.SetDriveLevel := @SetDriveLevel;
Ctx.GetCurrentBand := @GetBand;
// Осознанно НЕ проброшены: split/VFO-переключение (у слайса нет второго VFO),
// смена диапазона (диапазон — свойство пана слайса), CTUN/AGC-T/CTCSS/репитер
// (настройки главного тракта). Движок ответит на них валидной заглушкой.
FEngine := TCATEngine.Create(Ctx);
FTcp := TCATTcpServer.Create(FEngine);
end;
destructor TCATSliceEndpoint.Destroy;
begin
if Assigned(FTcp) then begin FTcp.Stop; FreeAndNil(FTcp); end;
FreeAndNil(FEngine);
FreeAndNil(FCmdLock);
inherited Destroy;
end;
function TCATSliceEndpoint.GetLetter: Char;
begin
Result := TRadioController.SliceSlotLetter(FSlot);
end;
procedure TCATSliceEndpoint.Apply(AEnabled: Boolean; APort: Integer);
begin
APort := EnsureRange(APort, 1, 65535);
if (AEnabled = FEnabled) and (APort = FPort) and (FTcp.Running = AEnabled) then Exit;
FTcp.Stop;
FEnabled := AEnabled;
FPort := APort;
if FEnabled then
begin
FTcp.Port := FPort;
FTcp.Start;
end;
end;
// ── Доступ к слайсу слота ────────────────────────────────────────────────────
function TCATSliceEndpoint.SliceId: Integer;
begin
Result := FController.SliceIdBySlot(FSlot);
end;
function TCATSliceEndpoint.Slice(out S: TCtrlSlice): Boolean;
var Id: Integer;
begin
Id := SliceId;
Result := (Id > 0) and FController.GetSlice(Id, S);
end;
function TCATSliceEndpoint.SliceBW(const S: TCtrlSlice): Integer;
// Как в UI-флаге: у односторонних режимов полоса = «своя» кромка.
begin
case S.Mode of
MODE_LSB, MODE_DIGL: Result := -S.FilterLo;
MODE_USB, MODE_DIGU: Result := S.FilterHi;
else Result := S.FilterHi - S.FilterLo;
end;
end;
// ── Геттеры ──────────────────────────────────────────────────────────────────
function TCATSliceEndpoint.GetVfoA: Double;
var S: TCtrlSlice;
begin
if Slice(S) then Result := S.TargetHz else Result := 0;
end;
function TCATSliceEndpoint.GetVfoB: Double;
begin
Result := FShadowVfoB;
if Result <= 0 then Result := GetVfoA;
end;
function TCATSliceEndpoint.GetMode: Integer;
var S: TCtrlSlice;
begin
if Slice(S) then Result := S.Mode else Result := MODE_USB;
end;
function TCATSliceEndpoint.GetFilterIdx: Integer;
var S: TCtrlSlice;
begin
if Slice(S) then Result := FController.FilterIdxFor(S.Mode, SliceBW(S))
else Result := 0;
end;
function TCATSliceEndpoint.GetFilterBW: Integer;
var S: TCtrlSlice;
begin
if Slice(S) then Result := SliceBW(S) else Result := 0;
end;
function TCATSliceEndpoint.GetFilterLow: Integer;
var S: TCtrlSlice;
begin
if Slice(S) then Result := S.FilterLo else Result := 0;
end;
function TCATSliceEndpoint.GetFilterHigh: Integer;
var S: TCtrlSlice;
begin
if Slice(S) then Result := S.FilterHi else Result := 0;
end;
function TCATSliceEndpoint.GetAGCMode: Integer;
var S: TCtrlSlice;
begin
if Slice(S) then Result := TRadioController.AGCModeToUI(S.AGC)
else Result := 1;
end;
function TCATSliceEndpoint.GetVolume: Integer;
var S: TCtrlSlice;
begin
if Slice(S) then Result := EnsureRange(Round(S.Volume * 100), 0, 100)
else Result := 0;
end;
function TCATSliceEndpoint.GetDriveLevel: Integer;
begin Result := FController.FDrivePercent; end;
function TCATSliceEndpoint.GetNRMode: Integer;
var S: TCtrlSlice;
begin
if Slice(S) then Result := S.NRMode else Result := 0;
end;
function TCATSliceEndpoint.GetNBMode: Integer;
var S: TCtrlSlice;
begin
if Slice(S) then Result := S.NBMode else Result := 0;
end;
function TCATSliceEndpoint.GetSNB: Boolean;
var S: TCtrlSlice;
begin
Result := Slice(S) and S.SNB;
end;
function TCATSliceEndpoint.GetANF: Boolean;
var S: TCtrlSlice;
begin
Result := Slice(S) and S.ANF;
end;
function TCATSliceEndpoint.GetTX: Boolean;
// «Передаю» для этого порта = передаёт именно ЭТОТ слайс. Чужой эфир на
// CAT-порту слайса виден как приём (иначе управляющее ПО решит, что его PTT
// прошла, и будет ждать несуществующую передачу).
var Id: Integer;
begin
Id := SliceId;
Result := (Id > 0) and FController.FTransmitting and (FController.FTxSliceId = Id);
end;
function TCATSliceEndpoint.GetRunning: Boolean;
begin
Result := (SliceId > 0) and FController.FRunning;
end;
function TCATSliceEndpoint.GetSMeter: Double;
var Id: Integer;
begin
Id := SliceId;
if Id > 0 then Result := FController.SliceSMeter(Id) else Result := -140;
end;
function TCATSliceEndpoint.GetBand: Integer;
begin Result := FController.FCurrentBand; end;
function TCATSliceEndpoint.GetSquelchOn: Boolean;
var S: TCtrlSlice;
begin
Result := Slice(S) and S.FMSQOn;
end;
function TCATSliceEndpoint.GetSquelchLevel: Integer;
var S: TCtrlSlice;
begin
if Slice(S) then Result := S.FMSQLevel else Result := 0;
end;
// ── Сеттеры (маршалинг в поток контроллера) ──────────────────────────────────
procedure TCATSliceEndpoint.CmdFreq(V: Double; P: TThreadMethod);
begin
FCmdLock.Acquire;
try FSyncFreq := V; FController.Invoke(P); finally FCmdLock.Release; end;
end;
procedure TCATSliceEndpoint.CmdInt(V: Integer; P: TThreadMethod);
begin
FCmdLock.Acquire;
try FSyncInt := V; FController.Invoke(P); finally FCmdLock.Release; end;
end;
procedure TCATSliceEndpoint.CmdBool(V: Boolean; P: TThreadMethod);
begin
FCmdLock.Acquire;
try FSyncBool := V; FController.Invoke(P); finally FCmdLock.Release; end;
end;
procedure TCATSliceEndpoint.SetVfoA(V: Double);
begin CmdFreq(V, @SyncVfoA); end;
procedure TCATSliceEndpoint.SyncVfoA;
// Свобода в пределах ВКЛЮЧЁННОГО диапазона: за захваченную полосу окно DDC
// переедет само (TuneSliceInBand), за границы диапазона — команда отбрасывается.
var Id: Integer;
begin
Id := SliceId;
if Id > 0 then FController.TuneSliceInBand(Id, FSyncFreq);
end;
procedure TCATSliceEndpoint.SetVfoB(V: Double);
begin FShadowVfoB := V; end; // теневой VFO: эфир не трогаем
procedure TCATSliceEndpoint.SetMode(V: Integer);
begin CmdInt(V, @SyncMode); end;
procedure TCATSliceEndpoint.SyncMode;
var Id: Integer;
begin
Id := SliceId;
if (Id > 0) and (FSyncInt >= MODE_MIN) and (FSyncInt <= MODE_MAX) then
FController.SetSliceMode(Id, FSyncInt);
end;
procedure TCATSliceEndpoint.SetFilterIdx(V: Integer);
begin CmdInt(V, @SyncFilterIdx); end;
procedure TCATSliceEndpoint.SyncFilterIdx;
var Id: Integer; S: TCtrlSlice;
begin
Id := SliceId;
if (Id > 0) and FController.GetSlice(Id, S) then
FController.SetSliceModeBW(Id, S.Mode, FController.FilterBWFor(S.Mode, FSyncInt));
end;
procedure TCATSliceEndpoint.SetFilterLow(V: Integer);
begin CmdInt(V, @SyncFilterLow); end;
procedure TCATSliceEndpoint.SyncFilterLow;
var Id: Integer; S: TCtrlSlice;
begin
Id := SliceId;
if (Id > 0) and FController.GetSlice(Id, S) and (FSyncInt < S.FilterHi) then
FController.SetSliceFilter(Id, FSyncInt, S.FilterHi);
end;
procedure TCATSliceEndpoint.SetFilterHigh(V: Integer);
begin CmdInt(V, @SyncFilterHigh); end;
procedure TCATSliceEndpoint.SyncFilterHigh;
var Id: Integer; S: TCtrlSlice;
begin
Id := SliceId;
if (Id > 0) and FController.GetSlice(Id, S) and (FSyncInt > S.FilterLo) then
FController.SetSliceFilter(Id, S.FilterLo, FSyncInt);
end;
procedure TCATSliceEndpoint.SetAGCMode(V: Integer);
begin CmdInt(V, @SyncAGC); end;
procedure TCATSliceEndpoint.SyncAGC;
var Id: Integer;
begin
Id := SliceId;
if Id > 0 then
FController.SetSliceAGCMode(Id, TRadioController.AGCModeFromUI(FSyncInt));
end;
procedure TCATSliceEndpoint.SetVolume(V: Integer);
begin CmdInt(V, @SyncVolume); end;
procedure TCATSliceEndpoint.SyncVolume;
var Id: Integer;
begin
Id := SliceId;
if Id > 0 then
FController.SetSliceVolume(Id, EnsureRange(FSyncInt, 0, 100) / 100.0);
end;
procedure TCATSliceEndpoint.SetDriveLevel(V: Integer);
begin CmdInt(V, @SyncDrive); end;
procedure TCATSliceEndpoint.SyncDrive;
begin FController.SetDrive(FSyncInt); end;
procedure TCATSliceEndpoint.ApplyDSP(NR, NB: Integer; SNBOn, ANFOn: Boolean);
// SetSliceDSP принимает весь набор сразу — недостающее берём из состояния слайса.
var Id: Integer;
begin
Id := SliceId;
if Id > 0 then FController.SetSliceDSP(Id, NR, NB, SNBOn, ANFOn);
end;
procedure TCATSliceEndpoint.SetNRMode(V: Integer);
begin CmdInt(V, @SyncNR); end;
procedure TCATSliceEndpoint.SyncNR;
begin ApplyDSP(FSyncInt, GetNBMode, GetSNB, GetANF); end;
procedure TCATSliceEndpoint.SetNBMode(V: Integer);
begin CmdInt(V, @SyncNB); end;
procedure TCATSliceEndpoint.SyncNB;
begin ApplyDSP(GetNRMode, FSyncInt, GetSNB, GetANF); end;
procedure TCATSliceEndpoint.SetSNB(V: Boolean);
begin CmdBool(V, @SyncSNB); end;
procedure TCATSliceEndpoint.SyncSNB;
begin ApplyDSP(GetNRMode, GetNBMode, FSyncBool, GetANF); end;
procedure TCATSliceEndpoint.SetANF(V: Boolean);
begin CmdBool(V, @SyncANF); end;
procedure TCATSliceEndpoint.SyncANF;
begin ApplyDSP(GetNRMode, GetNBMode, GetSNB, FSyncBool); end;
procedure TCATSliceEndpoint.SetTransmitting(V: Boolean);
begin CmdBool(V, @SyncTx); end;
procedure TCATSliceEndpoint.SyncTx;
// Вся логика Auto TX и защита «передаёт только один» — в контроллере.
var Id: Integer;
begin
Id := SliceId;
if Id > 0 then FController.RequestSliceTx(Id, FSyncBool);
end;
procedure TCATSliceEndpoint.SetSquelchOn(V: Boolean);
begin CmdBool(V, @SyncSquelchOn); end;
procedure TCATSliceEndpoint.SyncSquelchOn;
var Id: Integer; S: TCtrlSlice;
begin
Id := SliceId;
if (Id > 0) and FController.GetSlice(Id, S) then
FController.SetSliceFMSquelch(Id, FSyncBool, S.FMSQLevel);
end;
procedure TCATSliceEndpoint.SetSquelchLevel(V: Integer);
begin CmdInt(V, @SyncSquelchLevel); end;
procedure TCATSliceEndpoint.SyncSquelchLevel;
var Id: Integer; S: TCtrlSlice; Lvl: Integer;
begin
Id := SliceId;
if (Id > 0) and FController.GetSlice(Id, S) then
begin
Lvl := EnsureRange(FSyncInt, 0, 100);
// Как у главного тракта: ненулевой порог включает шумоподавитель.
FController.SetSliceFMSquelch(Id, S.FMSQOn or (Lvl > 0), Lvl);
end;
end;
procedure TCATSliceEndpoint.DoTuneUp;
begin CmdInt( 10, @SyncTuneBy); end;
procedure TCATSliceEndpoint.DoTuneDown;
begin CmdInt(-10, @SyncTuneBy); end;
procedure TCATSliceEndpoint.SyncTuneBy;
var Id: Integer; S: TCtrlSlice;
begin
Id := SliceId;
if (Id > 0) and FController.GetSlice(Id, S) then
FController.TuneSliceInBand(Id, S.TargetHz + FSyncInt);
end;
end.