diff --git a/BoardUtils.pas b/BoardUtils.pas index 1a6759a..d35a361 100644 --- a/BoardUtils.pas +++ b/BoardUtils.pas @@ -24,6 +24,8 @@ function FreqToBandIdx(Hz: Double; Pluto: Boolean = False): Integer; function BandName(Idx: Integer; Pluto: Boolean = False): string; // Частота по умолчанию при входе в диапазон (если нет сохранённой/в диапазоне). function BandDefaultFreq(Idx: Integer; Pluto: Boolean = False): Double; +// Границы диапазона по плану. False = слот пуст/индекс вне плана (Lo=Hi=0). +function BandEdges(Idx: Integer; Pluto: Boolean; out LoHz, HiHz: Double): Boolean; // Полный band-план активного бэкенда (имена + дефолтные частоты). function BuildBandPlan(Pluto: Boolean): TBandPlanArray; @@ -91,6 +93,16 @@ begin else Result := 'AD936x SDR'; end; +function BandEdges(Idx: Integer; Pluto: Boolean; out LoHz, HiHz: Double): Boolean; +begin + LoHz := 0; HiHz := 0; + Result := (Idx >= 0) and (Idx < BAND_COUNT); + if not Result then Exit; + if Pluto then begin LoHz := VU_LO[Idx]; HiHz := VU_HI[Idx]; end + else begin LoHz := HF_LO[Idx]; HiHz := HF_HI[Idx]; end; + Result := HiHz > LoHz; // пустой 12-й слот HF-плана +end; + function FreqToBandIdx(Hz: Double; Pluto: Boolean): Integer; var i: Integer; diff --git a/CATAdapter.pas b/CATAdapter.pas index 7682c29..ecc6014 100644 --- a/CATAdapter.pas +++ b/CATAdapter.pas @@ -20,8 +20,8 @@ interface uses Classes, SysUtils, - RadioController, CATEngine, CATSerial, CATTcp, Settings, WDSPEngine, - AndromedaPanel; + RadioController, CATEngine, CATSerial, CATTcp, CATSliceEndpoint, Settings, + WDSPEngine, AndromedaPanel; type TCATAdapter = class @@ -30,6 +30,9 @@ type FEngine: TCATEngine; FSerial: TCATSerialManager; FTcp: TCATTcpServer; + // CAT дополнительных слайсов: свой движок+TCP-порт на слот (B..). Порты + // главного (serial + FTcp) остаются за главным приёмником — слайс A. + FSliceEps: array[0..CAT_SLICE_SLOTS-1] of TCATSliceEndpoint; FPanel: TAndromedaPanel; // передняя панель Andromeda/G2 (если включена) // Параметры команды для маршалинга в поток контроллера (через Invoke). FSyncFreq: Double; @@ -130,6 +133,7 @@ type procedure SyncFMStep; procedure SyncAGCTop; procedure SyncCTun; + procedure ApplySliceSettings(const G: TGlobalSettings); // CAT-порты слайсов public constructor Create(AController: TRadioController); destructor Destroy; override; @@ -142,6 +146,7 @@ implementation constructor TCATAdapter.Create(AController: TRadioController); var Ctx: TCATContext; + i: Integer; begin inherited Create; FController := AController; @@ -211,13 +216,18 @@ begin FSerial := TCATSerialManager.Create(FEngine); FTcp := TCATTcpServer.Create(FEngine); + for i := 0 to CAT_SLICE_SLOTS - 1 do + FSliceEps[i] := TCATSliceEndpoint.Create(FController, i); + // Передняя панель: отправляет наружу через Andromeda-порт, слушает состояние. FPanel := TAndromedaPanel.Create(FController, @FSerial.SendToAndromeda); FController.AddStateListener(@FPanel.OnControllerState); end; destructor TCATAdapter.Destroy; +var i: Integer; begin + for i := 0 to CAT_SLICE_SLOTS - 1 do FreeAndNil(FSliceEps[i]); if Assigned(FTcp) then begin FTcp.Stop; FreeAndNil(FTcp); end; if Assigned(FSerial) then begin FSerial.StopAll; FreeAndNil(FSerial); end; FreeAndNil(FEngine); @@ -253,6 +263,28 @@ begin FTcp.Port := G.CATTcpPort; FTcp.Start; end; + ApplySliceSettings(G); +end; + +procedure TCATAdapter.ApplySliceSettings(const G: TGlobalSettings); +// CAT слайсов: Auto TX — в контроллер (его читают TX-логика и флаг слайса), +// порт — в свой эндпоинт. Совпадающие порты не поднимаем: второй bind всё +// равно упадёт, а так поведение предсказуемо (выигрывает слот меньшей буквы). +var + i, j: Integer; + Enable: Boolean; +begin + for i := 0 to CAT_SLICE_SLOTS - 1 do + begin + FController.SetSliceSlotAutoTx(i, G.CATSliceAutoTX[i]); + Enable := G.CATSliceEnabled[i] and (G.CATSlicePort[i] > 0); + if Enable and G.CATTcpEnabled and (G.CATSlicePort[i] = G.CATTcpPort) then + Enable := False; + for j := 0 to i - 1 do + if G.CATSliceEnabled[j] and (G.CATSlicePort[j] = G.CATSlicePort[i]) then + Enable := False; + FSliceEps[i].Apply(Enable, G.CATSlicePort[i]); + end; end; // ── Геттеры ────────────────────────────────────────────────────────────────── diff --git a/CATEngine.pas b/CATEngine.pas index 2a1ccbe..7993e69 100644 --- a/CATEngine.pas +++ b/CATEngine.pas @@ -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; diff --git a/CATSliceEndpoint.pas b/CATSliceEndpoint.pas new file mode 100644 index 0000000..2c2ec41 --- /dev/null +++ b/CATSliceEndpoint.pas @@ -0,0 +1,571 @@ +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. diff --git a/MainForm.pas b/MainForm.pas index 7f8e4ca..5fc2b96 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -235,6 +235,10 @@ type // --- CAT --- FCATAdapter: TCATAdapter; // адаптер CAT поверх контроллера (движок+транспорты+контекст) FCATLastGlobal: TGlobalSettings; // текущие CAT-настройки (UI/persist; ApplySettings → адаптер) + // Виджеты построены (конец FormCreate). До этого рендер состояния запрещён: + // адаптеры (CAT/web), создаваемые по ходу FormCreate, могут дёрнуть + // Changed() раньше, чем появятся кнопки/панадаптер. + FUIReady: Boolean; // Временные поля для передачи параметров в Synchronize-методы FWebSyncFreq: Double; FWebSyncInt: Integer; @@ -574,6 +578,18 @@ type const SerBaud, SerDataBits, SerStopBits, SerParity: array of Integer; const SerAndromeda: array of Boolean; TcpEnabled: Boolean; TcpPort: Integer); + procedure OnSliceCATSettingsChange( + const Enabled: array of Boolean; + const Port: array of Integer; + const AutoTX: array of Boolean; + const OutDev: array of string; + const InDev: array of string); + // Выбор устройства во флаге слайса = устройство его слота: зеркалим в + // конфиг, чтобы вкладка Slices показывала то же самое. + procedure StoreSliceSlotAudio(SliceId: Integer); + // Аудио слотов из конфига → контроллер (старт и смена устройства): слайсы + // восстанавливаются позже и должны встать на свои звуковухи. + procedure ApplySliceSlotAudio; procedure OnAlexSettingsChange(const A: TAlexSettings); procedure OnXvtrSettingsChange(const X: TXvtrSettings); procedure PushXvtrToWeb; @@ -1102,6 +1118,8 @@ begin PushBandsToWeb; FillChar(FCATLastGlobal, SizeOf(FCATLastGlobal), 0); FCATLastGlobal.CATTcpPort := 19090; + for i := 0 to CFG_CAT_SLICE_COUNT - 1 do + FCATLastGlobal.CATSlicePort[i] := CFG_CAT_SLICE_PORT0 + i; // 19091, 19092, … for i := 0 to 3 do begin FCATLastGlobal.CATSerialBaud[i] := 9600; @@ -1117,6 +1135,7 @@ begin FLightTheme := FController.FSettings.LoadTheme; FCATAdapter := TCATAdapter.Create(FController); FCATAdapter.ApplySettings(FCATLastGlobal); + ApplySliceSlotAudio; FSMeterPeak := -130; FSMeterMin := -130; FSMeterAvg := -130; @@ -1266,6 +1285,7 @@ begin {$IFDEF WINDOWS} FirewallEnsureAllowed(ParamStr(0), 'OpenHPSDR Transceiver'); {$ENDIF} + FUIReady := True; // с этого момента состояние контроллера можно рендерить end; procedure TMainForm.FormDestroy(Sender: TObject); @@ -1329,6 +1349,7 @@ var i: Integer; SavedFilter, SavedFilterBW: Integer; begin + if not FUIReady then Exit; // виджетов ещё нет — рисовать нечего FSyncingFromController := True; try case Field of @@ -1667,6 +1688,7 @@ begin // возвращаем живое значение в persist-буфер, чтобы blob нёс актуальную тему. FController.FLoadedGlobal.LightTheme := FLightTheme; FCATAdapter.ApplySettings(FController.FLoadedGlobal); + ApplySliceSlotAudio; RelabelBands; // HF ↔ VHF/UHF подписи под тип бэкенда ApplyBackendCapsToUI; // пресеты sample rate + гейтинг полей RebuildXvtrButtons; @@ -1703,6 +1725,20 @@ begin end; end; + rfPanFreq: + begin + // Центр доп. пана переехал не из UI (CAT слайса перестроил его за + // пределы захваченной полосы) — переложить флаги/шапку/зум-бар. + for i := 0 to MAX_PANS - 1 do + if FPans[i] <> nil then + begin + SyncPanZoomUI(FPans[i]); + FPans[i].LayoutFlags; + FPans[i].PushAllSliceFlagStates; + MarkPanDirty(FPans[i]); + end; + UpdatePanHeaders; + end; rfDeviceList: begin // Discovery обновил список устройств (или завершился без них). @@ -5808,6 +5844,7 @@ begin if CurSliceId <> 0 then begin FController.SetSliceDevice(CurSliceId, DevIndex, DevName); + StoreSliceSlotAudio(CurSliceId); // вкладка Slices показывает то же устройство PushFlagStateAllPans(CurSliceId); // обновить подсветку текущего устройства слайса Exit; end; @@ -5822,6 +5859,7 @@ begin if CurSliceId <> 0 then begin FController.SetSliceInDevice(CurSliceId, DevIndex, DevName); + StoreSliceSlotAudio(CurSliceId); PushFlagStateAllPans(CurSliceId); Exit; end; @@ -8449,6 +8487,7 @@ begin SF.OnAnt936xChange := OnAnt936xChange; SF.OnVHFCalChange := OnVHFCalSettingsChange; SF.OnCATChange := OnCATSettingsChange; + SF.OnSliceCATChange := OnSliceCATSettingsChange; SF.OnThemeChange := SetLightTheme; SF.OnFreqMhzDigitsChange := ApplyFreqMhzDigits; SF.OnTXChange := OnTXSettingsChange; @@ -8486,6 +8525,12 @@ begin FCATLastGlobal.CATSerialAndromeda, FCATLastGlobal.CATTcpEnabled, FCATLastGlobal.CATTcpPort); + SF.LoadSliceCATSettings( + FCATLastGlobal.CATSliceEnabled, + FCATLastGlobal.CATSlicePort, + FCATLastGlobal.CATSliceAutoTX, + FCATLastGlobal.CATSliceOutDev, + FCATLastGlobal.CATSliceInDev); // Перечисляем PA устройства SF.RefreshAudioDevices(FController.FAudioOut, FController.FAudioIn); SF.LoadVisibility(FController.FShowSpectrum, FController.FShowWaterfall, FController.FShowWideband, FController.FWidebandFill, @@ -8588,4 +8633,64 @@ begin FController.FSettings.Save; end; +procedure TMainForm.OnSliceCATSettingsChange( + const Enabled: array of Boolean; + const Port: array of Integer; + const AutoTX: array of Boolean; + const OutDev: array of string; + const InDev: array of string); +// CAT дополнительных слайсов. Хранится там же, где остальные CAT-настройки +// (корневая секция + device-blob), применяется тем же ApplySettings: адаптер +// поднимет/опустит порты эндпоинтов и положит Auto TX в контроллер. +var i: Integer; +begin + for i := 0 to CFG_CAT_SLICE_COUNT - 1 do + begin + if i <= High(Enabled) then FCATLastGlobal.CATSliceEnabled[i] := Enabled[i]; + if i <= High(Port) then FCATLastGlobal.CATSlicePort[i] := Port[i]; + if i <= High(AutoTX) then FCATLastGlobal.CATSliceAutoTX[i] := AutoTX[i]; + if i <= High(OutDev) then FCATLastGlobal.CATSliceOutDev[i] := OutDev[i]; + if i <= High(InDev) then FCATLastGlobal.CATSliceInDev[i] := InDev[i]; + // Аудио слота — сразу в контроллер: слайс, стоящий на слоте, переезжает + // на новое устройство, следующий получит его при создании. + FController.SetSliceSlotAudio(i, FCATLastGlobal.CATSliceOutDev[i], + FCATLastGlobal.CATSliceInDev[i]); + end; + FController.FLoadedGlobal.CATSliceEnabled := FCATLastGlobal.CATSliceEnabled; + FController.FLoadedGlobal.CATSlicePort := FCATLastGlobal.CATSlicePort; + FController.FLoadedGlobal.CATSliceAutoTX := FCATLastGlobal.CATSliceAutoTX; + FController.FLoadedGlobal.CATSliceOutDev := FCATLastGlobal.CATSliceOutDev; + FController.FLoadedGlobal.CATSliceInDev := FCATLastGlobal.CATSliceInDev; + FCATAdapter.ApplySettings(FCATLastGlobal); + FController.FSettings.SaveCATSettings(FCATLastGlobal); + FController.SaveGlobalSettings; + FController.FSettings.Save; + RefreshTxIndicators; // бейджи AutoTX на флагах слайсов — сразу + for i := 0 to MAX_PANS - 1 do // подсветка текущего устройства во флагах + if FPans[i] <> nil then FPans[i].PushAllSliceFlagStates; +end; + +procedure TMainForm.ApplySliceSlotAudio; +var i: Integer; +begin + for i := 0 to CFG_CAT_SLICE_COUNT - 1 do + FController.SetSliceSlotAudio(i, FCATLastGlobal.CATSliceOutDev[i], + FCATLastGlobal.CATSliceInDev[i]); +end; + +procedure TMainForm.StoreSliceSlotAudio(SliceId: Integer); +// Слот слайса и его устройства уже обновлены контроллером (SetSliceDevice/ +// SetSliceInDevice) — здесь только зеркалим их в конфиг и сохраняем. +var Slot: Integer; +begin + Slot := FController.SliceSlotOf(SliceId); + if (Slot < 0) or (Slot >= CFG_CAT_SLICE_COUNT) then Exit; + FCATLastGlobal.CATSliceOutDev[Slot] := FController.SliceSlotOutDev(Slot); + FCATLastGlobal.CATSliceInDev[Slot] := FController.SliceSlotInDev(Slot); + FController.FLoadedGlobal.CATSliceOutDev := FCATLastGlobal.CATSliceOutDev; + FController.FLoadedGlobal.CATSliceInDev := FCATLastGlobal.CATSliceInDev; + FController.FSettings.SaveCATSettings(FCATLastGlobal); + FController.FSettings.Save; +end; + end. diff --git a/PanafallPanel.pas b/PanafallPanel.pas index adaebc4..ee21ac2 100644 --- a/PanafallPanel.pas +++ b/PanafallPanel.pas @@ -1339,12 +1339,15 @@ begin else BW := S.FilterHi - S.FilterLo; end; O.SetState(S.Mode, BW, S.TargetHz, FController.SliceSMeter(SliceId)); - O.SetDSPState(0, 0, False, False, FController.FAGCMode); + O.SetDSPState(S.NRMode, S.NBMode, S.SNB, S.ANF, + TRadioController.AGCModeToUI(S.AGC)); O.SetSquelchState(S.FMSQOn, S.FMSQLevel); // TxSel: слайс — источник передачи; Tx: передача идёт именно с него. O.SetExtState(Round(S.Volume * 100), S.Muted, False, FController.FTransmitting and (FController.FTxSliceId = SliceId), FController.FTxSliceId = SliceId); + // Auto TX (настройка CAT-порта слайса) — бейдж TX подписан AutoTX. + O.SetAutoTxState(FController.SliceAutoTx(SliceId)); O.SetRxMuteTxState(S.RxMuteOnTx); // бейдж RXM // Список аудио-устройств (общий PA-список) + текущее устройство слайса. if Assigned(FController.FAudioOut) then diff --git a/RadioController.pas b/RadioController.pas index 62a428d..ba9b1d2 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -84,7 +84,8 @@ type rfADCOverload, // ADC overload (рендер индикатора) rfBeaconLock, // QO-100 beacon lock: статус/коррекция rfDevice, // подключённое устройство сменилось - rfDeviceList // список discovered устройств обновился + rfDeviceList, // список discovered устройств обновился + rfPanFreq // центр доп. пана уехал (ретюн DDC извне) ); TRadioStateEvent = procedure(Sender: TObject; Field: TRadioField) of object; @@ -162,6 +163,12 @@ type RxMuteOnTx: Boolean; // True (умолч.) = динамик слайса молчит на передаче FMSQOn: Boolean; // FM-squelch включён (слайс в режиме FM) FMSQLevel: Integer; // порог squelch 0..100 + // Зеркало DSP-состояния слайса (движок его не отдаёт обратно): нужно UI + // флага и CAT-геттерам. Пишется только через SetSliceDSP. + NRMode: Integer; // 0=выкл, 1=NR, 2=NR2 (как у главного) + NBMode: Integer; // 0=выкл, 1=NB, 2=NB2 + SNB: Boolean; + ANF: Boolean; DeviceIndex: Integer; // PortAudio-индекс вывода (-1 = default) DevName: string; Audio: TAudioOutput; // владеет; свой поток вывода @@ -330,6 +337,15 @@ type FTwoTone: Boolean; // 2TON активен FSplitTxB: Boolean; FTxSliceId: Integer; // 0 = главный VFO (A/B split); >0 = слайс-источник TX + // Auto TX слайса (индекс = СЛОТ слайса, не Id): PTT по CAT-порту слайса сам + // назначает его TX-источником. Флаг живёт на слоте, а не на слайсе, потому + // что настройка привязана к букве (B..G) и переживает удаление слайса. + FSliceAutoTx: array[0..MAX_SLICES-1] of Boolean; + // Аудио-устройства слота (буквы B..G): '' = общий выход приложения / общий + // вход. Это НЕ шаблон, а привязка слота: применяется к живому слайсу сразу + // и достаётся новому слайсу, который встанет на этот слот. + FSliceOutDev: array[0..MAX_SLICES-1] of string; + FSliceInDev: array[0..MAX_SLICES-1] of string; FVfoLock: Boolean; // блокировка перестройки VFO (Andromeda Lock) FDisplayDuplex: Boolean; // QO-100 full-duplex self-monitor: True — RX-аудио глушится на TX (по умолч.), @@ -563,6 +579,9 @@ type procedure ApplyBandDSP(const B: TBandSettings); // Полоса фильтра для (режим, индекс фильтра) — выбор per-mode таблицы. function FilterBWFor(M, Idx: Integer): Integer; + // Обратное отображение: ближайший индекс пресета к полосе BW (для CAT/UI, + // где полоса задана в Гц, а протокол оперирует индексом). + function FilterIdxFor(M, BW: Integer): Integer; // ---- XVTR (трансвертер) ---- // ApplyXvtrToNetwork — XVTR enable bit + DisablePA + RX ant в сеть. @@ -644,6 +663,7 @@ type procedure SetSliceModeBW(Id, Mode, BW: Integer); // режим+полоса → кромки фильтра class procedure FilterEdgesFor(Mode, BW: Integer; out Lo, Hi: Integer); class function AGCModeFromUI(M: Integer): TWDSPAGCMode; // UI-индекс 0..4 → WDSP enum + class function AGCModeToUI(A: TWDSPAGCMode): Integer; // и обратно procedure SetSliceAGCMode(Id: Integer; AGC: TWDSPAGCMode); procedure SetSliceVolume(Id: Integer; Vol: Double); // 0..1 procedure SetSliceMute(Id: Integer; Mute: Boolean); @@ -675,9 +695,35 @@ type function ActiveMicInDevName: string; function SliceCount: Integer; function GetSlice(Id: Integer; out S: TCtrlSlice): Boolean; + // Слот ↔ слайс. Слот 0..MAX_SLICES-1 = буква B..G и не зависит от того, + // создан ли слайс сейчас: на слот вешаются настройки (CAT-порт, Auto TX). + class function SliceSlotLetter(Slot: Integer): Char; + function SliceIdBySlot(Slot: Integer): Integer; // 0 = слот пуст + function SliceSlotOf(Id: Integer): Integer; // -1 = нет такого слайса + // Auto TX слайса (см. FSliceAutoTx). Геттер по слоту — для UI настроек, + // по Id — для флага слайса и CAT. + function SliceSlotAutoTx(Slot: Integer): Boolean; + procedure SetSliceSlotAutoTx(Slot: Integer; On_: Boolean); + function SliceAutoTx(Id: Integer): Boolean; + // Аудио-устройства слота. Сеттер применяет их к слайсу, который стоит на + // слоте прямо сейчас (имя → PA-индекс текущей сессии); '' = общий. + function SliceSlotOutDev(Slot: Integer): string; + function SliceSlotInDev(Slot: Integer): string; + procedure SetSliceSlotAudio(Slot: Integer; const OutDev, InDev: string); + // Заявка на передачу ОТ ИМЕНИ слайса (CAT-порт слайса). Единственная точка, + // где внешняя PTT может сменить TX-источник, поэтому здесь же — защита + // «в эфире одновременно только один слайс». Выполнять в потоке контроллера. + procedure RequestSliceTx(SliceId: Integer; On_: Boolean); // Диапазон захваченной полосы пана (0 = главный), куда можно ставить слайс. procedure CapturedSpan(out LoHz, HiHz: Double; PanId: Integer = 0); function SliceFitsCapture(TargetHz: Double; PanId: Integer = 0): Boolean; + // Границы ВКЛЮЧЁННОГО сейчас диапазона (трансвертер — его FreqBegin/FreqEnd, + // иначе band-план бэкенда). False = диапазон неизвестен. + function CurrentBandEdges(out LoHz, HiHz: Double): Boolean; + // Перестроить слайс на TargetHz В ПРЕДЕЛАХ текущего диапазона: если частота + // вне захваченной полосы пана — двигаем окно DDC. False = отказ (другой + // диапазон либо окно не подвинуть, не оглушив главный приёмник). + function TuneSliceInBand(Id: Integer; TargetHz: Double): Boolean; // ---- Панадаптеры на аппаратных DDC (этап 3.1; UI/движок — 3.2/3.3) ---- // Создаёт пан PanId (1..MAX_PANS-1) на своём DDC: FreqHz = центр в ВИДИМЫХ @@ -1592,6 +1638,72 @@ begin Result := (TargetHz >= Lo) and (TargetHz <= Hi); end; +function TRadioController.CurrentBandEdges(out LoHz, HiHz: Double): Boolean; +begin + LoHz := 0; HiHz := 0; + if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) and + FXvtrSettings.Entries[FCurrentXvtr].Enabled then + begin + // Трансвертер: диапазон — его собственный (2 м = 144..146 и т.п.). + LoHz := FXvtrSettings.Entries[FCurrentXvtr].FreqBegin; + HiHz := FXvtrSettings.Entries[FCurrentXvtr].FreqEnd; + Result := HiHz > LoHz; + end + else + Result := BandEdges(FCurrentBand, IsPluto, LoHz, HiHz); +end; + +function TRadioController.TuneSliceInBand(Id: Integer; TargetHz: Double): Boolean; +// Свобода внешней программы: она двигает слайс куда угодно ВНУТРИ включённого +// диапазона (2 м на трансвертере: 144.174 при окне на 145.5 — можно), но сменить +// диапазон (7 МГц) — нет: это решение оператора, а не управляющего ПО. +// +// Если цель вне захваченной полосы — окно DDC переезжает центром на цель: +// • доп. пан (свой DDC) — свободно; +// • пан 0 — только если главный приёмник останется внутри нового окна +// (одно железное окно на двоих; иначе главный оглох бы). +var + idx, PanId: Integer; + Lo, Hi, Half, Dist, NewCenter, VisLo, VisHi: Double; +begin + Result := False; + idx := FindSliceIndex(Id); + if idx < 0 then Exit; + if not CurrentBandEdges(Lo, Hi) then Exit; + if (TargetHz < Lo) or (TargetHz > Hi) then Exit; // другой диапазон — отказ + + PanId := FSlices[idx].PanId; + if SliceFitsCapture(TargetHz, PanId) then + begin + SetSliceTarget(Id, TargetHz); + Exit(True); + end; + + if PanId > 0 then + begin + if not PanDDCActive(PanId) then Exit; + VisibleFreqBounds(VisLo, VisHi); + NewCenter := EnsureRange(TargetHz, VisLo, VisHi); + SetPanDDCFreq(PanId, NewCenter); + SetSliceTarget(Id, TargetHz); + Changed(rfPanFreq); // UI: шапка/зум-бар/флаги пана под новый центр + Result := True; + end + else + begin + // Пан 0: центр общий с главным приёмником. Ставим его посередине между + // целью и активным VFO — тогда в окно попадают оба; не помещаются — + // отказываем (перестраивать главный ради слайса нельзя). + Half := FSampleRate * 0.475; + Dist := Abs(TargetHz - ActiveVfoHz); + if Dist > Half * 2 * 0.95 then Exit; + NewCenter := (TargetHz + ActiveVfoHz) / 2; + SetCenter(NewCenter); // несёт Changed(rfCenterFreq) + сдвиги слайсов + SetSliceTarget(Id, TargetHz); + Result := True; + end; +end; + { ---- Панадаптеры на аппаратных DDC (этап 3.1) ---- } procedure TRadioController.ResetPanDDCMaps; @@ -1916,6 +2028,8 @@ function TRadioController.AddSlice(TargetHz: Double; DevIndex: Integer; const DevName: string; APanId: Integer): Integer; var i, slot, Id: Integer; + DevIndex_: Integer; + DevName_: string; AO: TAudioOutput; begin Result := -1; @@ -1930,6 +2044,20 @@ begin if not FSlices[i].Active then begin slot := i; Break; end; if slot < 0 then Exit; // нет свободных слотов + // Устройство слота (настройка вкладки Slices) сильнее того, что попросил + // вызывающий: слот — это буква флага, и звук у неё всегда один и тот же. + if FSliceOutDev[slot] <> '' then + begin + DevName_ := FSliceOutDev[slot]; + DevIndex_ := -1; + if Assigned(FAudioOut) then DevIndex_ := FAudioOut.FindDeviceByName(DevName_); + end + else + begin + DevName_ := DevName; + DevIndex_ := DevIndex; + end; + Id := FNextSliceId; if not FDSPEngine.AddSlice(Id, Mode, FilterLo, FilterHi, TargetHz - PanCenterHz(APanId), Vol, AGC, @@ -1939,7 +2067,7 @@ begin // Свой аудио-выход слайса на выбранной звуковухе. AO := TAudioOutput.Create(48000); AO.OutputBufferSize := FSettings.LoadAudioBufferSize; - AO.DeviceIndex := DevIndex; + AO.DeviceIndex := DevIndex_; if FLocalAudio then AO.Open; FSlices[slot].Active := True; @@ -1958,8 +2086,12 @@ begin FSlices[slot].RxMuteOnTx := True; // себя на передаче по умолчанию не слышим FSlices[slot].FMSQOn := False; // squelch по умолчанию выключен FSlices[slot].FMSQLevel := 40; - FSlices[slot].DeviceIndex := DevIndex; - FSlices[slot].DevName := DevName; + FSlices[slot].NRMode := 0; // DSP слайса стартует выключенным + FSlices[slot].NBMode := 0; + FSlices[slot].SNB := False; + FSlices[slot].ANF := False; + FSlices[slot].DeviceIndex := DevIndex_; + FSlices[slot].DevName := DevName_; FSlices[slot].Audio := AO; // Вход слайса по умолчанию общий (FAudioIn); свой ставится SetSliceInDevice. FSlices[slot].InDeviceIndex := -1; @@ -1969,6 +2101,11 @@ begin if Mode = MODE_DMR then FSlices[slot].DMR := TDMRSliceDecoder.Create(Self, Id); + // Вход слота (настройка вкладки Slices) — тем же правилом, что и выход. + if (FSliceInDev[slot] <> '') and Assigned(FAudioIn) then + SetSliceInDeviceIdx(slot, FAudioIn.FindDeviceByName(FSliceInDev[slot]), + FSliceInDev[slot]); + Result := Id; Changed(rfDevice); end; @@ -2115,6 +2252,10 @@ var idx: Integer; begin idx := FindSliceIndex(Id); if idx < 0 then Exit; + FSlices[idx].NRMode := NRMode; + FSlices[idx].NBMode := NBMode; + FSlices[idx].SNB := SNBOn; + FSlices[idx].ANF := ANFOn; if Assigned(FDSPEngine) then begin FDSPEngine.SetSliceNRMode(Id, NRMode); @@ -2216,6 +2357,7 @@ var idx: Integer; begin idx := FindSliceIndex(Id); if idx < 0 then Exit; + FSliceOutDev[idx] := DevName; // выбор во флаге = устройство слота (одна истина) FSlices[idx].DeviceIndex := DevIndex; FSlices[idx].DevName := DevName; if FSlices[idx].Audio <> nil then @@ -2309,6 +2451,7 @@ procedure TRadioController.SetSliceInDeviceIdx(Idx, DevIndex: Integer; begin if (Idx < 0) or (Idx >= MAX_SLICES) or (not FSlices[Idx].Active) then Exit; if DevName = '' then DevIndex := -1; + FSliceInDev[Idx] := DevName; // выбор во флаге = вход слота (одна истина) ReleaseMicInput(FSlices[Idx].AudioIn); FSlices[Idx].InDeviceIndex := DevIndex; FSlices[Idx].InDevName := DevName; @@ -2383,6 +2526,129 @@ begin if Result then S := FSlices[idx]; end; +// --------------------------------------------------------------------------- +// Слоты слайсов: настройки (CAT-порт, Auto TX) висят на слоте/букве, а слайс +// на слоте может появляться и исчезать. +// --------------------------------------------------------------------------- + +class function TRadioController.SliceSlotLetter(Slot: Integer): Char; +begin + // Слайс A — главный приёмник, поэтому слоты нумеруются с 'B' (как AddSlice). + if (Slot < 0) or (Slot >= MAX_SLICES) then Exit('?'); + Result := Chr(Ord('B') + Slot); +end; + +function TRadioController.SliceIdBySlot(Slot: Integer): Integer; +begin + Result := 0; + if (Slot < 0) or (Slot >= MAX_SLICES) then Exit; + if FSlices[Slot].Active then Result := FSlices[Slot].Id; +end; + +function TRadioController.SliceSlotOf(Id: Integer): Integer; +begin + Result := FindSliceIndex(Id); +end; + +function TRadioController.SliceSlotAutoTx(Slot: Integer): Boolean; +begin + Result := (Slot >= 0) and (Slot < MAX_SLICES) and FSliceAutoTx[Slot]; +end; + +procedure TRadioController.SetSliceSlotAutoTx(Slot: Integer; On_: Boolean); +// Без Changed(): единственный вызывающий — TCATAdapter.ApplySettings, а он +// работает и на старте (до постройки виджетов), и на connect. Перерисовку +// бейджей делает UI-путь настроек (MainForm.OnSliceCATSettingsChange). +begin + if (Slot < 0) or (Slot >= MAX_SLICES) then Exit; + FSliceAutoTx[Slot] := On_; +end; + +function TRadioController.SliceAutoTx(Id: Integer): Boolean; +begin + Result := SliceSlotAutoTx(FindSliceIndex(Id)); +end; + +function TRadioController.SliceSlotOutDev(Slot: Integer): string; +begin + if (Slot >= 0) and (Slot < MAX_SLICES) then Result := FSliceOutDev[Slot] + else Result := ''; +end; + +function TRadioController.SliceSlotInDev(Slot: Integer): string; +begin + if (Slot >= 0) and (Slot < MAX_SLICES) then Result := FSliceInDev[Slot] + else Result := ''; +end; + +procedure TRadioController.SetSliceSlotAudio(Slot: Integer; const OutDev, InDev: string); +// Настройка = устройство слота, поэтому применяем сразу: слайс, который стоит +// на слоте, переезжает на новое устройство (как выбор во вкладке AUD флага). +var Id: Integer; +begin + if (Slot < 0) or (Slot >= MAX_SLICES) then Exit; + Id := SliceIdBySlot(Slot); + if FSliceOutDev[Slot] <> OutDev then + begin + FSliceOutDev[Slot] := OutDev; + if Id > 0 then + begin + if (OutDev <> '') and Assigned(FAudioOut) then + SetSliceDevice(Id, FAudioOut.FindDeviceByName(OutDev), OutDev) + else + SetSliceDevice(Id, -1, ''); // общий выход приложения + end; + end; + if FSliceInDev[Slot] <> InDev then + begin + FSliceInDev[Slot] := InDev; + if Id > 0 then + begin + if (InDev <> '') and Assigned(FAudioIn) then + SetSliceInDevice(Id, FAudioIn.FindDeviceByName(InDev), InDev) + else + SetSliceInDevice(Id, -1, ''); // общий вход приложения + end; + end; +end; + +procedure TRadioController.RequestSliceTx(SliceId: Integer; On_: Boolean); +// PTT, пришедшая по CAT-порту слайса. Аналог «Auto Switch TX Slice» в SmartSDR +// CAT: с Auto TX слайс сам становится TX-источником, без него — передаёт, +// только если оператор уже выбрал его источником (бейдж TX на флаге). +// Выполняется в потоке контроллера (CAT маршалит через Invoke), поэтому +// проверка «уже в эфире» и смена источника атомарны относительно GUI. +var idx: Integer; +begin + idx := FindSliceIndex(SliceId); + if idx < 0 then Exit; + + if not On_ then + begin + // Отпускание PTT снимает только СВОЮ передачу: RX; на порту слайса C не + // должен обрывать эфир слайса B. + if (FTxSliceId = SliceId) and (FTransmitting or FTuning) then + begin + if FTuning then SetTune(False) else SetMOX(False); + end; + Exit; + end; + + // Передатчик один: пока кто-то в эфире (другой слайс, главный VFO, кнопка + // MOX/TUNE, аппаратная PTT) — чужую передачу не перехватываем и источник не + // переключаем. Своя же передача уже идёт, повторный TX; — no-op. + if FTransmitting or FTuning then Exit; + if FSlices[idx].Mode = MODE_DMR then Exit; // AMBE-передатчика нет + + if FTxSliceId <> SliceId then + begin + if not FSliceAutoTx[idx] then Exit; // без Auto TX слайс сам себя не назначает + SetTxSlice(SliceId); + if FTxSliceId <> SliceId then Exit; // SetTxSlice отказал (DMR/нет слайса) + end; + SetMOX(True); +end; + // Снимок кадра для web-зеркала: протокол фиксирован на 1024 точки, поэтому // при большем разрешении анализатора децимируем max-детектором (не обрезаем). procedure SnapshotPixels(const Pixels: array of Single; Count: Integer; @@ -2966,6 +3232,27 @@ begin end; end; +function TRadioController.FilterIdxFor(M, BW: Integer): Integer; +// Обратно к FilterBWFor: индекс пресета с минимальным отклонением от BW. +var + i, Cnt, D, Best: Integer; +begin + Result := 0; + case M of + MODE_FM: Cnt := FILT_FM_COUNT; + MODE_WFM: Cnt := FILT_WFM_COUNT; + MODE_FMRAW: Cnt := FILT_RAW_COUNT; + MODE_DMR: Exit; + else Cnt := FILT_COUNT; + end; + Best := MaxInt; + for i := 0 to Cnt - 1 do + begin + D := Abs(FilterBWFor(M, i) - BW); + if D < Best then begin Best := D; Result := i; end; + end; +end; + // ===================================================================== // Команды (Фаза 1 — каркас). Полная логика переносится из TMainForm. // ===================================================================== @@ -3744,6 +4031,18 @@ begin Result := AGCModes[M]; end; +class function TRadioController.AGCModeToUI(A: TWDSPAGCMode): Integer; +// Обратно к AGCModeFromUI: 0=FAST,1=MED,2=SLOW,3=LONG,4=OFF (шкала UI и CAT). +begin + case A of + agcFast: Result := 0; + agcSlow: Result := 2; + agcLong: Result := 3; + agcOff: Result := 4; + else Result := 1; // agcMedium + end; +end; + procedure TRadioController.ApplyAGCToEngine; begin if FWDSPReady and Assigned(FDSPEngine) and (FAGCMode >= 0) and (FAGCMode <= 4) then diff --git a/Settings.pas b/Settings.pas index 33108f2..fa8020a 100644 --- a/Settings.pas +++ b/Settings.pas @@ -16,6 +16,8 @@ const CFG_MODE_MIN = RadioModes.MODE_MIN; CFG_MODE_MAX = RadioModes.MODE_MAX; CFG_PANS_MAX = 4; // потолок панадаптеров (пан 0 + доп.) — = WDSPEngine.MAX_PANS + CFG_CAT_SLICE_COUNT = 6; // слотов доп. слайсов (B..G) — = WDSPEngine.MAX_SLICES + CFG_CAT_SLICE_PORT0 = 19091; // порт слайса B по умолчанию (главный — 19090) QO100_SLOT = CFG_XVTR_COUNT - 1; // последний слот зарезервирован под QO-100 // (split-LO транспондер; настраивается на // отдельной странице, не в общей XVTR-таблице) @@ -448,9 +450,21 @@ type CATSerialStopBits: array[0..3] of Integer; // 1 или 2 CATSerialParity: array[0..3] of Integer; // 0=None, 1=Odd, 2=Even CATSerialAndromeda:array[0..3] of Boolean; // порт — передняя панель Andromeda/G2 - // TCP CAT сервер (один, многоклиентский) + // TCP CAT сервер (один, многоклиентский) — главный приёмник (слайс A) CATTcpEnabled: Boolean; CATTcpPort: Integer; // порт, по умолчанию 19090 + // Слайс-CAT: свой TCP-порт на каждый ДОПОЛНИТЕЛЬНЫЙ слайс (B, C, …). + // Индекс = слот слайса (0 = слайс B), см. CFG_CAT_SLICE_COUNT. + CATSliceEnabled: array[0..CFG_CAT_SLICE_COUNT-1] of Boolean; + CATSlicePort: array[0..CFG_CAT_SLICE_COUNT-1] of Integer; // 19091, 19092, … + // Auto TX (аналог «Auto Switch TX Slice» в SmartSDR CAT): команда TX по + // CAT-порту слайса сама назначает этот слайс источником передачи. + CATSliceAutoTX: array[0..CFG_CAT_SLICE_COUNT-1] of Boolean; + // Аудио слота слайса: имена PortAudio-устройств ('' = общий выход + // приложения / общий вход). Имена, а не индексы: порядок перечисления + // устройств меняется от запуска к запуску. + CATSliceOutDev: array[0..CFG_CAT_SLICE_COUNT-1] of string; + CATSliceInDev: array[0..CFG_CAT_SLICE_COUNT-1] of string; end; TSettingsManager = class @@ -652,6 +666,13 @@ begin end; G.CATTcpEnabled := False; G.CATTcpPort := 19090; + for i := 0 to CFG_CAT_SLICE_COUNT-1 do begin + G.CATSliceEnabled[i] := False; + G.CATSlicePort[i] := CFG_CAT_SLICE_PORT0 + i; + G.CATSliceAutoTX[i] := False; + G.CATSliceOutDev[i] := ''; + G.CATSliceInDev[i] := ''; + end; // ADC — включены по умолчанию: поддерживаются на Hermes, Angelia, Orion G.DitherEnabled := True; G.RandomEnabled := True; @@ -905,6 +926,13 @@ begin G.CATSerialParity[i] := JI(GObj,'cat_serial_parity_'+IntToStr(i), 0); G.CATSerialAndromeda[i]:= JB(GObj,'cat_serial_androm_'+IntToStr(i), False); end; + for i := 0 to CFG_CAT_SLICE_COUNT-1 do begin + G.CATSliceEnabled[i] := JB(GObj,'cat_slice_en_' +IntToStr(i), False); + G.CATSlicePort[i] := JI(GObj,'cat_slice_port_' +IntToStr(i), CFG_CAT_SLICE_PORT0 + i); + G.CATSliceAutoTX[i] := JB(GObj,'cat_slice_autotx_'+IntToStr(i), False); + G.CATSliceOutDev[i] := JS(GObj,'cat_slice_out_' +IntToStr(i), ''); + G.CATSliceInDev[i] := JS(GObj,'cat_slice_in_' +IntToStr(i), ''); + end; G.CATTcpEnabled := JB(GObj,'cat_tcp_enabled', False); G.CATTcpPort := JI(GObj,'cat_tcp_port', 19090); // ADC settings @@ -1039,6 +1067,13 @@ begin JW(O,'cat_serial_parity_'+IntToStr(i),G.CATSerialParity[i]); JW(O,'cat_serial_androm_'+IntToStr(i),G.CATSerialAndromeda[i]); end; + for i := 0 to CFG_CAT_SLICE_COUNT-1 do begin + JW(O,'cat_slice_en_' +IntToStr(i), G.CATSliceEnabled[i]); + JW(O,'cat_slice_port_' +IntToStr(i), G.CATSlicePort[i]); + JW(O,'cat_slice_autotx_'+IntToStr(i), G.CATSliceAutoTX[i]); + JWS(O,'cat_slice_out_' +IntToStr(i), G.CATSliceOutDev[i]); + JWS(O,'cat_slice_in_' +IntToStr(i), G.CATSliceInDev[i]); + end; JW(O,'cat_tcp_enabled',G.CATTcpEnabled); JW(O,'cat_tcp_port',G.CATTcpPort); // ADC settings @@ -1200,6 +1235,14 @@ begin JW(O, 'ser_parity_'+IntToStr(i), G.CATSerialParity[i]); JW(O, 'ser_androm_'+IntToStr(i), G.CATSerialAndromeda[i]); end; + for i := 0 to CFG_CAT_SLICE_COUNT-1 do + begin + JW(O, 'slice_en_' +IntToStr(i), G.CATSliceEnabled[i]); + JW(O, 'slice_port_' +IntToStr(i), G.CATSlicePort[i]); + JW(O, 'slice_autotx_'+IntToStr(i), G.CATSliceAutoTX[i]); + JWS(O,'slice_out_' +IntToStr(i), G.CATSliceOutDev[i]); + JWS(O,'slice_in_' +IntToStr(i), G.CATSliceInDev[i]); + end; end; procedure TSettingsManager.LoadCATSettings(var G: TGlobalSettings); @@ -1223,6 +1266,14 @@ begin G.CATSerialParity[i] := JI(O, 'ser_parity_'+IntToStr(i), 0); G.CATSerialAndromeda[i]:= JB(O, 'ser_androm_'+IntToStr(i), False); end; + for i := 0 to CFG_CAT_SLICE_COUNT-1 do + begin + G.CATSliceEnabled[i] := JB(O, 'slice_en_' +IntToStr(i), False); + G.CATSlicePort[i] := JI(O, 'slice_port_' +IntToStr(i), CFG_CAT_SLICE_PORT0 + i); + G.CATSliceAutoTX[i] := JB(O, 'slice_autotx_'+IntToStr(i), False); + G.CATSliceOutDev[i] := JS(O, 'slice_out_' +IntToStr(i), ''); + G.CATSliceInDev[i] := JS(O, 'slice_in_' +IntToStr(i), ''); + end; end; // --------------------------------------------------------------------------- diff --git a/SettingsForm.pas b/SettingsForm.pas index 452c33b..e7f8254 100644 --- a/SettingsForm.pas +++ b/SettingsForm.pas @@ -76,6 +76,13 @@ type const SerParity: array of Integer; const SerAndromeda: array of Boolean; TcpEnabled: Boolean; TcpPort: Integer) of object; + // CAT дополнительных слайсов: индекс = слот (0 = слайс B). + TOnSliceCATChange = procedure( + const Enabled: array of Boolean; + const Port: array of Integer; + const AutoTX: array of Boolean; + const OutDev: array of string; + const InDev: array of string) of object; TOnAlexChange = procedure(const A: TAlexSettings) of object; TOnXvtrChange = procedure(const X: TXvtrSettings) of object; @@ -92,6 +99,7 @@ type FPagePA: TScrollBox; FPageAdvanced: TScrollBox; FPageCAT: TScrollBox; + FPageSlices: TScrollBox; FPageTransmit: TScrollBox; FNavAudio: TFlatButton; FNavDisplay: TFlatButton; @@ -100,6 +108,7 @@ type FNavPA: TFlatButton; FNavAdvanced: TFlatButton; FNavCAT: TFlatButton; + FNavSlices: TFlatButton; FNavTransmit: TFlatButton; // ---- Audio tab controls ---- @@ -335,6 +344,14 @@ type FCATSerialAndromeda:array[0..3] of TFlatCheckBox; FCATTcpEn: TFlatCheckBox; FCATTcpPort: TFlatSpinEdit; + // ---- Slices tab controls (CAT доп. слайсов; индекс = слот, 0 = слайс B) ---- + FSliceCATEn: array[0..CFG_CAT_SLICE_COUNT-1] of TFlatCheckBox; + FSliceCATPort: array[0..CFG_CAT_SLICE_COUNT-1] of TFlatSpinEdit; + FSliceCATAutoTx: array[0..CFG_CAT_SLICE_COUNT-1] of TFlatCheckBox; + // Аудио слота: списки — общие с главными комбо (RefreshAudioDevices), + // первый пункт «Default»/«None» = общий выход/вход приложения. + FSliceOutDev: array[0..CFG_CAT_SLICE_COUNT-1] of TFlatComboBox; + FSliceInDev: array[0..CFG_CAT_SLICE_COUNT-1] of TFlatComboBox; // ---- Advanced tab controls ---- FChkDither: TFlatCheckBox; @@ -350,6 +367,7 @@ type // ---- Callbacks ---- FOnCATChange: TOnCATChange; + FOnSliceCATChange: TOnSliceCATChange; FOnPAChange: TOnPASettingsChange; FOnPlutoTxChange: TOnPlutoTxChange; FOnAnt936xChange: TOnAnt936xChange; @@ -393,6 +411,7 @@ type procedure CollectCalFromUI; procedure OnCalAnyChange(Sender: TObject); procedure BuildCATTab; + procedure BuildSlicesTab; procedure BuildTransmitTab; procedure BuildAlexTab; procedure BuildAdvancedTab; @@ -459,6 +478,10 @@ type procedure OnCATSerialChange(Sender: TObject); procedure OnCATTcpChange(Sender: TObject); procedure FireCATChange; + procedure OnSliceCATCtrlChange(Sender: TObject); + procedure SelectDevInCombo(Cmb: TFlatComboBox; const DevName: string); + procedure FillSliceDevCombos(AudioOut: TAudioOutput; AudioIn: TAudioInput); + procedure FireSliceCATChange; procedure BtnCloseClick(Sender: TObject); procedure FireDisplayChange; @@ -508,6 +531,13 @@ type // Таблица разъёмов + имена/видимость слотов трансвертеров. procedure LoadAnt936x(const A: TAD936xAnt; const X: TXvtrSettings); procedure LoadVHFCalSettings(const VHFCal: array of Double); + // Слайс-CAT: enable/порт/AutoTX по слотам (0 = слайс B). + procedure LoadSliceCATSettings( + const Enabled: array of Boolean; + const Port: array of Integer; + const AutoTX: array of Boolean; + const OutDev: array of string; + const InDev: array of string); procedure LoadCATSettings( const SerEnabled: array of Boolean; const SerPort: array of string; @@ -526,6 +556,7 @@ type const BindAddr, User, Pass: string); property OnCATChange: TOnCATChange read FOnCATChange write FOnCATChange; + property OnSliceCATChange: TOnSliceCATChange read FOnSliceCATChange write FOnSliceCATChange; property OnAlexChange: TOnAlexChange read FOnAlexChange write FOnAlexChange; property OnXvtrChange: TOnXvtrChange read FOnXvtrChange write FOnXvtrChange; property OnPAChange: TOnPASettingsChange read FOnPAChange write FOnPAChange; @@ -571,6 +602,9 @@ const BTN_H = 24; GRP_TITLE_H = 22; // высота заголовка в group-панелях (title label + divider) + // Первый пункт комбо аудио слайса = «как у приложения» (пустое имя в конфиге). + SLICE_DEV_DEFAULT = 'Default (app output)'; + SLICE_DEV_NONE = 'None (app input)'; CAT_BAUD_VALS: array[0..7] of Integer = (1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200); CAT_BAUD_NAMES: array[0..7] of string = ('1200','2400','4800','9600','19200','38400','57600','115200'); @@ -781,9 +815,10 @@ begin FNavCalib := MakeNavButton('Calibration', 290); FNavAdvanced := MakeNavButton('Advanced', 326); FNavCAT := MakeNavButton('CAT', 362); - FNavAlex := MakeNavButton('Antenna / Alex', 398); - FNavXvtr := MakeNavButton('Transverter', 434); - FNavQO := MakeNavButton('QO-100', 470); + FNavSlices := MakeNavButton('Slices', 398); + FNavAlex := MakeNavButton('Antenna / Alex', 434); + FNavXvtr := MakeNavButton('Transverter', 470); + FNavQO := MakeNavButton('QO-100', 506); FContentPanel := TPanel.Create(Self); FContentPanel.Parent := Self; @@ -800,6 +835,7 @@ begin FPageCalib := MakeScrollPage; FPageAdvanced := MakeScrollPage; FPageCAT := MakeScrollPage; + FPageSlices := MakeScrollPage; FPageAlex := MakeScrollPage; FPageXvtr := MakeScrollPage; FPageQO := MakeScrollPage; @@ -816,6 +852,7 @@ begin BuildCalibrationTab; BuildAdvancedTab; BuildCATTab; + BuildSlicesTab; BuildAlexTab; BuildXvtrTab; BuildQOTab; @@ -849,6 +886,7 @@ begin else if Sender = FNavCalib then SelectPage(FPageCalib, FNavCalib) else if Sender = FNavAdvanced then SelectPage(FPageAdvanced, FNavAdvanced) else if Sender = FNavCAT then SelectPage(FPageCAT, FNavCAT) + else if Sender = FNavSlices then SelectPage(FPageSlices, FNavSlices) else if Sender = FNavAlex then SelectPage(FPageAlex, FNavAlex) else if Sender = FNavXvtr then SelectPage(FPageXvtr, FNavXvtr) else if Sender = FNavQO then SelectPage(FPageQO, FNavQO); @@ -865,6 +903,7 @@ begin FPageCalib.Visible := APage = FPageCalib; FPageAdvanced.Visible := APage = FPageAdvanced; FPageCAT.Visible := APage = FPageCAT; + FPageSlices.Visible := APage = FPageSlices; FPageAlex.Visible := APage = FPageAlex; FPageXvtr.Visible := APage = FPageXvtr; FPageQO.Visible := APage = FPageQO; @@ -878,6 +917,7 @@ begin FNavCalib.Active := ANav = FNavCalib; FNavAdvanced.Active := ANav = FNavAdvanced; FNavCAT.Active := ANav = FNavCAT; + FNavSlices.Active := ANav = FNavSlices; FNavAlex.Active := ANav = FNavAlex; FNavXvtr.Active := ANav = FNavXvtr; FNavQO.Active := ANav = FNavQO; @@ -2817,6 +2857,54 @@ begin FLoading := False; Names.Free; end; + FillSliceDevCombos(AudioOut, AudioIn); +end; + +procedure TSettingsForm.FillSliceDevCombos(AudioOut: TAudioOutput; AudioIn: TAudioInput); +// Списки устройств в комбо вкладки Slices — те же, что у главных комбо. Текущий +// выбор держим по ИМЕНИ: порядок перечисления PortAudio между запусками плавает. +var + Names: TStringList; + Idx: array[0..63] of Integer; + Cnt, i, k: Integer; + Cur: string; +begin + if FSliceOutDev[0] = nil then Exit; // вкладка ещё не построена + Names := TStringList.Create; + FLoading := True; + try + if AudioOut <> nil then + begin + Cnt := 0; Names.Clear; + AudioOut.EnumOutputDevices(Names, Idx, Cnt); + for i := 0 to CFG_CAT_SLICE_COUNT - 1 do + begin + Cur := FSliceOutDev[i].Text; + if FSliceOutDev[i].ItemIndex = 0 then Cur := ''; + FSliceOutDev[i].Items.Clear; + FSliceOutDev[i].Items.Add(SLICE_DEV_DEFAULT); + for k := 0 to Cnt - 1 do FSliceOutDev[i].Items.Add(Names[k]); + SelectDevInCombo(FSliceOutDev[i], Cur); + end; + end; + if AudioIn <> nil then + begin + Cnt := 0; Names.Clear; + AudioIn.EnumInputDevices(Names, Idx, Cnt); + for i := 0 to CFG_CAT_SLICE_COUNT - 1 do + begin + Cur := FSliceInDev[i].Text; + if FSliceInDev[i].ItemIndex = 0 then Cur := ''; + FSliceInDev[i].Items.Clear; + FSliceInDev[i].Items.Add(SLICE_DEV_NONE); + for k := 0 to Cnt - 1 do FSliceInDev[i].Items.Add(Names[k]); + SelectDevInCombo(FSliceInDev[i], Cur); + end; + end; + finally + FLoading := False; + Names.Free; + end; end; // --------------------------------------------------------------------------- @@ -3522,6 +3610,219 @@ begin end; end; + +// --------------------------------------------------------------------------- +// BuildSlicesTab — CAT дополнительных слайсов (B..): свой TCP-порт на слайс. +// --------------------------------------------------------------------------- + +procedure TSettingsForm.BuildSlicesTab; +const + MARGIN = 22; + PAD = 18; + LW = 120; + CX = PAD + LW + 8; + GRP_W = 380; + GRP_H = 240; + GRP_GAP = 16; + CMB_W = 210; + R1 = 42; + STEP = 32; +var + i, col, row, gx, gy: Integer; + Grp: TPanel; + Chk: TFlatCheckBox; + Spin: TFlatSpinEdit; + Cmb: TFlatComboBox; +begin + with TLabel.Create(Self) do + begin + Parent := FPageSlices; + Caption := 'Slices'; + SetBounds(MARGIN, 18, 400, 28); + Font.Name := UI_FONT; Font.Size := 16; + Font.Style := [fsBold]; Font.Color := CLR_TEXT; + end; + with TLabel.Create(Self) do + begin + Parent := FPageSlices; + Caption := 'A separate CAT TCP port per additional slice — external software ' + + 'controls its own slice as a standalone transceiver.'; + SetBounds(MARGIN, 48, 760, 20); + Font.Name := UI_FONT; Font.Size := 9; Font.Color := CLR_TEXTDIM; + end; + with TLabel.Create(Self) do + begin + Parent := FPageSlices; + Caption := 'Slice A is the main receiver — it is configured on the CAT tab ' + + '(serial ports and the main TCP port).'; + SetBounds(MARGIN, 66, 760, 20); + Font.Name := UI_FONT; Font.Size := 9; Font.Color := CLR_TEXTDIM; + end; + + for i := 0 to CFG_CAT_SLICE_COUNT - 1 do + begin + row := i div 2; + col := i mod 2; + gx := MARGIN + col * (GRP_W + GRP_GAP); + gy := 96 + row * (GRP_H + GRP_GAP); + + Grp := MakeGroupPanel(FPageSlices, 'Slice ' + Chr(Ord('B') + i), gx, gy, GRP_W, GRP_H); + + Chk := TFlatCheckBox.Create(Self); + Chk.Parent := Grp; + Chk.Caption := 'Enable CAT TCP server'; + Chk.SetBounds(PAD, R1, GRP_W - PAD * 2, 22); + Chk.Font.Color := CLR_TEXT; + Chk.Font.Name := UI_FONT; Chk.Font.Size := 9; + Chk.Tag := i; + Chk.OnChange := OnSliceCATCtrlChange; + FSliceCATEn[i] := Chk; + + MakeLbl(Grp, 'Port:', PAD, R1 + STEP + 6, LW); + Spin := TFlatSpinEdit.Create(Self); + Spin.Parent := Grp; + Spin.SetBounds(CX, R1 + STEP, 100, BTN_H + 2); + Spin.Color := CLR_INPUT; Spin.Font.Color := CLR_INPUT_TEXT; + Spin.Font.Name := UI_FONT; Spin.Font.Size := 9; + Spin.MinValue := 1; Spin.MaxValue := 65535; + Spin.Value := CFG_CAT_SLICE_PORT0 + i; + Spin.Tag := i; + Spin.OnChange := OnSliceCATCtrlChange; + FSliceCATPort[i] := Spin; + + // Auto TX = «Auto Switch TX Slice» из SmartSDR CAT: команда TX по этому + // порту сама делает слайс источником передачи. Пока кто-то уже в эфире, + // заявка игнорируется — передаёт всегда только один слайс. Подпись короткая + // (в ширину группы), пояснение — строкой ниже. + Chk := TFlatCheckBox.Create(Self); + Chk.Parent := Grp; + Chk.Caption := 'Auto TX'; + Chk.SetBounds(PAD, R1 + 2 * STEP, GRP_W - PAD * 2, 22); + Chk.Font.Color := CLR_TEXT; + Chk.Font.Name := UI_FONT; Chk.Font.Size := 9; + Chk.Tag := i; + Chk.OnChange := OnSliceCATCtrlChange; + FSliceCATAutoTx[i] := Chk; + + MakeLbl(Grp, 'CAT TX takes transmit to this slice', + PAD + 2, R1 + 2 * STEP + 24, GRP_W - PAD * 2 - 2); + + // Аудио слота: не шаблон, а привязка буквы — применяется к слайсу, который + // стоит на слоте сейчас, и достаётся следующему. + MakeLbl(Grp, 'Audio out:', PAD, R1 + 3 * STEP + 26, LW); + Cmb := MakeCombo(Grp, CX, R1 + 3 * STEP + 20, CMB_W, OnSliceCATCtrlChange); + Cmb.Tag := i; + Cmb.Items.Add(SLICE_DEV_DEFAULT); + Cmb.ItemIndex := 0; + FSliceOutDev[i] := Cmb; + + MakeLbl(Grp, 'Audio in:', PAD, R1 + 4 * STEP + 26, LW); + Cmb := MakeCombo(Grp, CX, R1 + 4 * STEP + 20, CMB_W, OnSliceCATCtrlChange); + Cmb.Tag := i; + Cmb.Items.Add(SLICE_DEV_NONE); + Cmb.ItemIndex := 0; + FSliceInDev[i] := Cmb; + end; + + gy := 96 + ((CFG_CAT_SLICE_COUNT + 1) div 2) * (GRP_H + GRP_GAP); + with TLabel.Create(Self) do + begin + Parent := FPageSlices; + Caption := 'Audio out/in belong to the slice letter: the setting moves the ' + + 'slice sitting on that slot right away and the next one inherits it.'; + SetBounds(MARGIN, gy, 760, 20); + Font.Name := UI_FONT; Font.Size := 9; Font.Color := CLR_TEXTDIM; + end; + Inc(gy, 18); + with TLabel.Create(Self) do + begin + Parent := FPageSlices; + Caption := 'Only one slice transmits at a time: while anything is already on ' + + 'the air the request is ignored — no take-over.'; + SetBounds(MARGIN, gy, 760, 20); + Font.Name := UI_FONT; Font.Size := 9; Font.Color := CLR_TEXTDIM; + end; + with TLabel.Create(Self) do + begin + Parent := FPageSlices; + Caption := 'Without Auto TX a slice keys up over CAT only when it is already ' + + 'the transmit source (TX badge on the slice flag).'; + SetBounds(MARGIN, gy + 18, 760, 20); + Font.Name := UI_FONT; Font.Size := 9; Font.Color := CLR_TEXTDIM; + end; + with TLabel.Create(Self) do + begin + Parent := FPageSlices; + Caption := 'The port is opened even when the slice does not exist yet — until ' + + 'it is created CAT answers "radio off" (PS0).'; + SetBounds(MARGIN, gy + 36, 760, 20); + Font.Name := UI_FONT; Font.Size := 9; Font.Color := CLR_TEXTDIM; + end; +end; + +procedure TSettingsForm.FireSliceCATChange; +var + En, Auto: array[0..CFG_CAT_SLICE_COUNT-1] of Boolean; + Prt: array[0..CFG_CAT_SLICE_COUNT-1] of Integer; + OutD, InD: array[0..CFG_CAT_SLICE_COUNT-1] of string; + i: Integer; +begin + if FLoading then Exit; + if not Assigned(FOnSliceCATChange) then Exit; + for i := 0 to CFG_CAT_SLICE_COUNT - 1 do + begin + En[i] := FSliceCATEn[i].Checked; + Prt[i] := FSliceCATPort[i].Value; + Auto[i] := FSliceCATAutoTx[i].Checked; + // Первый пункт списка — «общий выход/вход приложения» (пустое имя). + if FSliceOutDev[i].ItemIndex <= 0 then OutD[i] := '' + else OutD[i] := FSliceOutDev[i].Text; + if FSliceInDev[i].ItemIndex <= 0 then InD[i] := '' + else InD[i] := FSliceInDev[i].Text; + end; + FOnSliceCATChange(En, Prt, Auto, OutD, InD); +end; + +procedure TSettingsForm.OnSliceCATCtrlChange(Sender: TObject); +begin FireSliceCATChange; end; + +procedure TSettingsForm.LoadSliceCATSettings( + const Enabled: array of Boolean; + const Port: array of Integer; + const AutoTX: array of Boolean; + const OutDev: array of string; + const InDev: array of string); +var i: Integer; +begin + FLoading := True; + try + for i := 0 to CFG_CAT_SLICE_COUNT - 1 do + begin + if i <= High(Enabled) then FSliceCATEn[i].Checked := Enabled[i]; + if (i <= High(Port)) and (Port[i] > 0) then + FSliceCATPort[i].Value := EnsureRange(Port[i], 1, 65535); + if i <= High(AutoTX) then FSliceCATAutoTx[i].Checked := AutoTX[i]; + if i <= High(OutDev) then SelectDevInCombo(FSliceOutDev[i], OutDev[i]); + if i <= High(InDev) then SelectDevInCombo(FSliceInDev[i], InDev[i]); + end; + finally + FLoading := False; + end; +end; + +procedure TSettingsForm.SelectDevInCombo(Cmb: TFlatComboBox; const DevName: string); +// Выбор устройства по ИМЕНИ. Сохранённого устройства может не быть в системе +// (кабель не поднят, гарнитура не воткнута) — тогда добавляем имя в список, +// чтобы привязка слота не потерялась молча при первом же открытии настроек. +var i: Integer; +begin + if Cmb = nil then Exit; + if DevName = '' then begin Cmb.ItemIndex := 0; Exit; end; + i := Cmb.Items.IndexOf(DevName); + if i < 0 then i := Cmb.Items.Add(DevName); + Cmb.ItemIndex := i; +end; + // --------------------------------------------------------------------------- // BuildAlexTab // --------------------------------------------------------------------------- diff --git a/VfoOverlay.pas b/VfoOverlay.pas index 3bc84fc..07ec639 100644 --- a/VfoOverlay.pas +++ b/VfoOverlay.pas @@ -82,6 +82,7 @@ type FTx: Boolean; // передача идёт (красный) FTxSel: Boolean; // этот слайс выбран для передачи (янтарный) FRxMuteTx: Boolean; // RXM: приём слайса глушится на передаче (умолч. True) + FAutoTx: Boolean; // Auto TX: PTT по CAT-порту слайса сама берёт передачу FSlice: Char; // буква слайса ('A') FSMeterThreshold: Double; // мин. изменение S-метра (дБ) для перерисовки FDMRText: string; // стабильный краткий статус рядом с полосой @@ -205,6 +206,8 @@ type procedure SetSquelchState(AOn: Boolean; ALevel: Integer); procedure SetExtState(AVolume: Integer; AMuted, ASplit, ATx, ATxSel: Boolean); procedure SetRxMuteTxState(AOn: Boolean); // бейдж RXM (self-monitor на TX) + // Auto TX слайса (настройка CAT): бейдж TX превращается в AutoTX. + procedure SetAutoTxState(AOn: Boolean); procedure SetAudioDevices(Names: TStrings; const Indices: array of Integer; const CurName: string); // Список устройств ВВОДА + текущий выбор ('' = общий вход приложения). @@ -1091,8 +1094,8 @@ end; procedure TVfoOverlay.DrawSelf(C: TCanvas; W, H: Integer); var - FreqStr, SStr, BWStr, DMRStr: string; - CurY, X, RW, i, DMRX, DMRRight, TxRight: Integer; + FreqStr, SStr, BWStr, DMRStr, TxStr: string; + CurY, X, RW, i, DMRX, DMRRight, TxRight, TxW: Integer; R: TRect; BarL, BarW, MeterX: Integer; begin @@ -1117,6 +1120,11 @@ begin // Кнопка закрытия × — только у неглавных слайсов (главный приёмник не закрыть). // Она крайняя справа, TX и RXM выстраиваются левее неё. TxRight := W - PAD; + // Слайс с Auto TX (PTT по своему CAT-порту сама берёт передачу) подписан + // AutoTX — бейдж шире, остальные едут левее. + TxStr := 'TX'; + TxW := 26; + if FAutoTx and (not FIsMain) then begin TxStr := 'AutoTX'; TxW := 46; end; if not FIsMain then begin R := Rect(W - PAD - 14, CurY, W - PAD, CurY + HDR_BADGE_H); @@ -1126,7 +1134,7 @@ begin // RXM — приём слайса на СВОЕЙ передаче. Подсвечен (умолч.) = глушим, т.е. // себя не слышим; погашен = self-monitor (свой downlink через транспондер). // У главного приёмника то же самое делает кнопка RX MUTE на левой панели. - R := Rect(TxRight - 26 - 4 - 32, CurY, TxRight - 26 - 4, CurY + HDR_BADGE_H); + R := Rect(TxRight - TxW - 4 - 32, CurY, TxRight - TxW - 4, CurY + HDR_BADGE_H); if FRxMuteTx then DrawBadge(C, R, 'RXM', CLR_ACCENT, CLR_BADGE_TXT) else DrawBadge(C, R, 'RXM', CLR_BTN_NORM, CLR_DIM); RegisterHit(R, HIT_RXMUTE); @@ -1144,7 +1152,7 @@ begin begin DMRStr := FDMRText; DMRX := X + 22 + C.TextWidth(BWStr) + 8; - DMRRight := TxRight - 26 - 4 - 32 - 5; // до бейджей RXM, TX и × + DMRRight := TxRight - TxW - 4 - 32 - 5; // до бейджей RXM, TX и × while (Length(DMRStr) > 1) and (C.TextWidth(DMRStr) > DMRRight - DMRX) do SetLength(DMRStr, Length(DMRStr) - 1); @@ -1153,12 +1161,12 @@ begin C.TextOut(DMRX, CurY + 1, DMRStr); end; // TX badge: a DMR slice is visibly disabled and has no hit target. - R := Rect(TxRight - 26, CurY, TxRight, CurY + HDR_BADGE_H); + R := Rect(TxRight - TxW, CurY, TxRight, CurY + HDR_BADGE_H); if not TxSelectionAllowed then - DrawBadge(C, R, 'TX', CLR_CARD_BG, CLR_DIM) - else if FTx then DrawBadge(C, R, 'TX', CLR_TX_ON, clWhite) - else if FTxSel then DrawBadge(C, R, 'TX', CLR_TX_SEL, CLR_BADGE_TXT) - else DrawBadge(C, R, 'TX', CLR_BTN_NORM, CLR_DIM); + DrawBadge(C, R, TxStr, CLR_CARD_BG, CLR_DIM) + else if FTx then DrawBadge(C, R, TxStr, CLR_TX_ON, clWhite) + else if FTxSel then DrawBadge(C, R, TxStr, CLR_TX_SEL, CLR_BADGE_TXT) + else DrawBadge(C, R, TxStr, CLR_BTN_NORM, CLR_DIM); if TxSelectionAllowed then RegisterHit(R, HIT_TX); // SPLIT (левее TX) — кликабельно; только у главного: split = TX на VFO B, // у слайса своего B-VFO нет (кросс-частотный TX делается TX-бейджем слайса). @@ -1653,6 +1661,13 @@ begin RequestInvalidate; end; +procedure TVfoOverlay.SetAutoTxState(AOn: Boolean); +begin + if FAutoTx = AOn then Exit; + FAutoTx := AOn; + RequestInvalidate; +end; + procedure TVfoOverlay.SetSliceInfo(ASliceId: Integer; ALetter: Char; AIsMain: Boolean); begin if (FSliceId = ASliceId) and (FSlice = ALetter) and (FIsMain = AIsMain) then Exit; diff --git a/doc/CAT_SLICES.md b/doc/CAT_SLICES.md new file mode 100644 index 0000000..19d80d7 --- /dev/null +++ b/doc/CAT_SLICES.md @@ -0,0 +1,180 @@ +# CAT дополнительных слайсов (Slice CAT) + +Ветка: `feature/slice-cat`. Дата: 2026-07-25. + +Внешняя программа (WSJT-X, FLDIGI, лог) управляет **отдельным слайсом** EWSDR +как отдельным трансивером — через свой TCP-порт. COM-порты и основной TCP-порт +(19090) остаются за главным приёмником (слайс A) и не тронуты. + +Эталон поведения — **SmartSDR CAT** (FlexRadio): порт привязан к слайсу +(«VFO A Slice»), а флаг **Auto TX** — это их «Auto Switch TX Slice»: + +> When enabled Auto Switch TX Slice automatically switches the Transmit Slice to +> the VFO A Slice selected above when a CAT TX command is sent on the com port… +> If this option is not enabled, the operator will have to manually designate the +> Transmit Slice. + +--- + +## 1. Архитектура + +Протокол **не дублируется**. `TCATEngine` — чистый парсер/формировщик поверх +плоской записи callback'ов `TCATContext`, `TCATTcpServer` — транспорт поверх +движка. Значит «CAT слайса» = **тот же движок с другим контекстом**: + +``` +TCATAdapter ──┬── главный контекст ──── TCATEngine ── serial ×4 + TCP 19090 (слайс A) + ├── TCATSliceEndpoint ─── TCATEngine ── TCP 19091 (слайс B) + ├── TCATSliceEndpoint ─── TCATEngine ── TCP 19092 (слайс C) + └── … по слоту на букву (B..G, потолок = WDSPEngine.MAX_SLICES) +``` + +| Файл | Что добавлено | +|---|---| +| `CATSliceEndpoint.pas` (новый) | `TCATSliceEndpoint` — контекст поверх `FSlices[слот]`, свой движок + свой TCP-сервер. Константа `CAT_SLICE_SLOTS`. | +| `CATAdapter.pas` | Владеет массивом эндпоинтов; `ApplySliceSettings` поднимает/опускает порты и кладёт Auto TX в контроллер. | +| `CATEngine.pas` | `ZZFL`/`ZZFH` перестали быть заглушками: новые callback'и `Get/SetFilterLow/High` (у главного nil → прежний дефолт режима). Хелпер `SignedPad`. | +| `RadioController.pas` | `RequestSliceTx`, `FSliceAutoTx[слот]`, слот-хелперы, `FilterIdxFor`, `AGCModeToUI`, зеркало DSP-состояния слайса в `TCtrlSlice`. | +| `Settings.pas` | `CATSliceEnabled/CATSlicePort/CATSliceAutoTX[0..5]`, персист в корневой секции `cat` и в device-blob. | +| `SettingsForm.pas` | Вкладка **Slices**. | +| `VfoOverlay.pas`, `PanafallPanel.pas` | Бейдж `AutoTX` вместо `TX` на флаге слайса. | + +**Привязка к слоту, а не к Id слайса.** Слот 0..5 = буква B..G. Слайс на слоте +создаётся/удаляется в любой момент, порт при этом остаётся поднятым: пока слайса +нет, эндпоинт отвечает «радио выключено» (`PS0;`) и игнорирует команды. Настройка +(порт, Auto TX) переживает удаление слайса. + +**Потокобезопасность** — как у главного адаптера: геттеры читают состояние в +CAT-потоке, сеттеры кладут параметр под локом эндпоинта и маршалятся в поток +контроллера через `Invoke` (GUI = `TThread.Synchronize`). + +--- + +## 2. Auto TX и защита передачи + +Вся логика — в **одной** точке: `TRadioController.RequestSliceTx(SliceId, On_)`, +исполняется в потоке контроллера, поэтому проверка «уже в эфире» и смена +TX-источника атомарны относительно GUI. + +`TX;` / `ZZTX1;` на порту слайса: + +1. Слайса на слоте нет → игнор. +2. **Кто-то уже передаёт** (другой слайс, главный VFO, кнопка MOX/TUNE, + аппаратная PTT) → игнор. Перехвата эфира нет никогда — передаёт всегда + только один слайс. +3. Режим слайса `DMR` → игнор (AMBE-передатчика нет). +4. Слайс не выбран TX-источником: + * **Auto TX включён** → `SetTxSlice(слайс)`, затем `SetMOX(True)`; + * **Auto TX выключен** → игнор (оператор назначает источник руками — + бейдж `TX` на флаге слайса). +5. Слайс уже TX-источник → просто `SetMOX(True)`. + +`RX;` / `ZZTX0;`: снимает передачу **только свою** — если TX-источник именно этот +слайс. `RX;` на порту слайса C не обрывает эфир слайса B. TUNE снимается тоже. + +Выбор TX-источника после отпускания PTT **остаётся** на этом слайсе (как в +SmartSDR: Transmit Slice переключён, а не одолжен). Бейдж на флаге это показывает. + +`GetTransmitting` (для `TX`/`IF`/`ZZTX`) на порту слайса = «передаёт **этот** +слайс». Чужой эфир виден как приём — иначе управляющее ПО решило бы, что его PTT +прошла. + +--- + +## 3. Что поддерживает CAT-порт слайса + +| Команды | Маппинг на слайс | +|---|---| +| `FA` `ZZFA` (VFO A) | `TuneSliceInBand` — свободно в пределах включённого диапазона, см. §3.1 | +| `FB` `ZZFB` (VFO B) | теневое значение: читается/пишется, эфир не двигает (второго VFO у слайса нет) | +| `MD` `ZZMD` `ZZMA` | `SetSliceMode` | +| `FW` `SH` `SL` `ZZFI` (индекс фильтра) | `FilterIdxFor`/`FilterBWFor` ↔ `SetSliceModeBW` | +| `ZZFL` `ZZFH` (кромки, Гц) | `FilterLo/FilterHi` ↔ `SetSliceFilter` | +| `GT` `ZZGT` (AGC) | `AGC` ↔ `SetSliceAGCMode` (`AGCModeToUI`/`FromUI`) | +| `AG` `ZZAG` (громкость) | `Volume` ↔ `SetSliceVolume` | +| `ZZNR` `ZZNB` `ZZNS` `ZZNT` (DSP) | `SetSliceDSP` (состояние зеркалится в `TCtrlSlice`) | +| `SQ` `ZZSQ` `ZZSO` (squelch) | `FMSQOn/FMSQLevel` ↔ `SetSliceFMSquelch` | +| `SM` `ZZSM` | `SliceSMeter` | +| `TX` `RX` `ZZTX` | `RequestSliceTx` (см. §2) | +| `PS` | слайс существует **и** радио запущено | +| `IF` `ID` | составной статус/идентификатор по данным слайса | +| `UP` `DN` `RU` `RD` | шаг ±10 Гц по частоте слайса | +| `PC` `ZZPC` (мощность), `ZZBS` (диапазон, read) | глобальные — передатчик один на всё радио | + +### 3.1 Частота слайса: свобода в пределах диапазона + +`TRadioController.TuneSliceInBand(Id, TargetHz)` — единственная точка перестройки +слайса из CAT: + +1. **Другой диапазон → отказ.** Границы берутся у включённого сейчас диапазона: + активный трансвертер → его `FreqBegin/FreqEnd` (2 м = 144…146 МГц), иначе + band-план бэкенда (`BoardUtils.BandEdges`). Стоим на 2 м, программа просит + 145.500 → 144.174 — можно; 7.074 МГц — команда отбрасывается. Смена диапазона + остаётся решением оператора. +2. Цель внутри захваченной полосы (`SliceFitsCapture`) → просто `SetSliceTarget`. +3. Цель внутри диапазона, но вне окна DDC → **окно переезжает центром на цель**: + * слайс на дополнительном пане (свой DDC) — свободно (`SetPanDDCFreq` + + `Changed(rfPanFreq)`, UI перекладывает флаги/шапку/зум-бар); + * слайс на пане 0 — окно общее с главным приёмником, поэтому центр ставится + ПОСЕРЕДИНЕ между целью и активным VFO: так в окно попадают оба. Если они + дальше, чем окно (`|цель − VFO| > 0.95·полоса`), команда отбрасывается — + перестраивать главный приёмник ради слайса нельзя. + +Практический вывод: чтобы внешняя программа гуляла по всему диапазону, слайс +лучше держать на своём пане (дополнительный DDC). На пане 0 её свобода — +в пределах общего окна вокруг главного VFO. + +**Осознанно не проброшено** (движок ответит валидной заглушкой): split/`FR`/`FT` +(второго VFO нет), смена диапазона `BU`/`BD`/`ZZBS`-set (диапазон — свойство пана, +на котором живёт слайс), CTUN, AGC-T, CTCSS/репитер/FM-шаг (настройки главного +тракта), Andromeda (панель одна и висит на главном). + +--- + +## 4. Настройки + +Вкладка **Slices**: на каждый слайс B..G — `Enable CAT TCP server`, `Port` +(по умолчанию 19091, 19092, …), `Auto TX`, `Audio out`, `Audio in`. + +### 4.1 Аудио слота + +`Audio out`/`Audio in` — это **устройство слота (буквы)**, а не шаблон: + +* смена настройки применяется немедленно к слайсу, который стоит на слоте + сейчас (`TRadioController.SetSliceSlotAudio`); +* слайс, который встанет на слот позже (создание/восстановление из персиста), + получает то же устройство — оно сильнее того, что попросил вызывающий + `AddSlice`; +* выбор во вкладке `AUD` флага слайса пишется обратно в настройку слота + (`SetSliceDevice`/`SetSliceInDevice` → `MainForm.StoreSliceSlotAudio`), так + что вкладка всегда показывает реальное устройство — одна истина, два места + правки; +* пусто (`Default (app output)` / `None (app input)`) — как раньше: общий выход + приложения и общий вход. + +Хранятся **имена** PortAudio-устройств, а не индексы: порядок перечисления между +запусками плавает. Имя отсутствующего сейчас устройства (кабель не поднят) не +теряется — комбо добавляет его в список и держит выбранным. + +* Хранятся вместе с остальным CAT: корневая секция `cat` (`slice_en_N`, + `slice_port_N`, `slice_autotx_N`) + device-blob (`cat_slice_*`), не привязаны + к MAC — доступны до подключения железа. +* Порт, совпадающий с основным TCP-портом или с портом слайса меньшей буквы, + **не поднимается** (иначе второй `bind` просто упал бы молча). +* Правка одной строки не рвёт живые сессии остальных: эндпоинт перезапускается + только при реальном изменении своего enable/порта. + +--- + +## 5. Проверка + +``` +# слайс B на 19091 +printf 'ZZFA;' | nc -q1 127.0.0.1 19091 # частота слайса +printf 'ZZMD;' | nc -q1 127.0.0.1 19091 # режим +printf 'TX;' | nc -q1 127.0.0.1 19091 # PTT (нужен Auto TX либо выбранный TX-слайс) +printf 'RX;' | nc -q1 127.0.0.1 19091 +``` + +Статус: собрано (`lazbuild --ws=qt6` + `build-ewsdrd.sh`), **на эфире и на экране +не проверено**. diff --git a/doc/CAT_STATUS.md b/doc/CAT_STATUS.md index c243a72..ec2c214 100644 --- a/doc/CAT_STATUS.md +++ b/doc/CAT_STATUS.md @@ -188,6 +188,10 @@ CTCSS on/tone, FM repeater dir/offset, FM step, AGC-T, CTUN. ## 5. Транспорты +> CAT **дополнительных слайсов** (свой TCP-порт на слайс B..G + Auto TX) вынесен +> в отдельный документ: [`CAT_SLICES.md`](CAT_SLICES.md). Тот же движок, другой +> контекст; транспорты ниже принадлежат главному приёмнику (слайс A). + - **Serial** (`CATSerial.pas`): до 4 портов, параметры (порт/скорость/биты/ чётность/стоп) из `TGlobalSettings.CATSerial*`, применяются `TCATAdapter.ApplySettings`. На текущей платформе сборки может быть diff --git a/ewsdr.lpi b/ewsdr.lpi index 4792265..20314b0 100644 --- a/ewsdr.lpi +++ b/ewsdr.lpi @@ -17,9 +17,9 @@ - + - + @@ -137,6 +137,8 @@ + + @@ -358,6 +360,10 @@ + + + +