diff --git a/RadioController.pas b/RadioController.pas index 0ce84ed..8540a05 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -958,10 +958,13 @@ type 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); + // Заявка на передачу ОТ ИМЕНИ слайса (CAT-порт слайса, TCI с номером + // приёмника). Единственная точка, где внешняя PTT может сменить TX-источник, + // поэтому здесь же — защита «в эфире одновременно только один слайс». + // Tune = True — то же самое, но несущей настройки (TUN) вместо модуляции. + // Выполнять в потоке контроллера. + procedure RequestSliceTx(SliceId: Integer; On_: Boolean; + Tune: Boolean = False); // Диапазон захваченной полосы пана (0 = главный), куда можно ставить слайс. procedure CapturedSpan(out LoHz, HiHz: Double; PanId: Integer = 0); function SliceFitsCapture(TargetHz: Double; PanId: Integer = 0): Boolean; @@ -3313,11 +3316,13 @@ begin end; end; -procedure TRadioController.RequestSliceTx(SliceId: Integer; On_: Boolean); -// PTT, пришедшая по CAT-порту слайса. Аналог «Auto Switch TX Slice» в SmartSDR -// CAT: с Auto TX слайс сам становится TX-источником, без него — передаёт, -// только если оператор уже выбрал его источником (бейдж TX на флаге). -// Выполняется в потоке контроллера (CAT маршалит через Invoke), поэтому +procedure TRadioController.RequestSliceTx(SliceId: Integer; On_: Boolean; + Tune: Boolean = False); +// PTT, пришедшая по CAT-порту слайса или по TCI с номером приёмника. Аналог +// «Auto Switch TX Slice» в SmartSDR CAT: с Auto TX слайс сам становится +// TX-источником, без него — передаёт, только если оператор уже выбрал его +// источником (бейдж TX на флаге). +// Выполняется в потоке контроллера (CAT и TCI маршалят через Invoke), поэтому // проверка «уже в эфире» и смена источника атомарны относительно GUI. var idx: Integer; begin @@ -3347,7 +3352,7 @@ begin SetTxSlice(SliceId); if FTxSliceId <> SliceId then Exit; // SetTxSlice отказал (DMR/нет слайса) end; - SetMOX(True); + if Tune then SetTune(True) else SetMOX(True); end; // Снимок кадра для web-зеркала: потолок точек задаёт настройка web.spec_pixels @@ -5836,7 +5841,12 @@ var RateKsps: Word; HangMs: Word; begin - if not (FNetwork.Connected and FNetwork.Running) then Exit; + // Assigned обязателен: сюда приходят и по PTT/TUN, а нажать их можно и до + // того, как устройство выбрано, — тогда FNetwork ещё nil (то же и с движком + // ниже). Раньше на этом месте была Access violation, и разбирать её приходил + // уже кто-то другой: SetMOX зовёт SyncCWKeyer, а тот — эту процедуру. + if not (Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running) then Exit; + if not (FWDSPReady and Assigned(FDSPEngine)) then Exit; FillChar(DUCPkt, SizeOf(DUCPkt), 0); // DUC0 rate в ksps берём из WDSP-движка (фиксированный TX rate, не RX). RateKsps := FDSPEngine.TXSampleRate div 1000; @@ -5938,7 +5948,7 @@ begin FDriveLevel := CalcDriveByte; if FWDSPReady then FDSPEngine.SetDriveLevel(EnsureRange(FTXSettings.TUNLevel, 0, 100) / 100.0); - if FNetwork.Connected and FNetwork.Running then + if Assigned(FNetwork) and FNetwork.Connected and FNetwork.Running then begin FNetwork.UpdateState(XvtrTranslate(FCenterFreq), TXTuneFreqHz, FDriveLevel, FTransmitting, True, True, FTuning, FTwoTone); diff --git a/TCIAdapter.pas b/TCIAdapter.pas index 31092e0..3f11a52 100644 --- a/TCIAdapter.pas +++ b/TCIAdapter.pas @@ -47,7 +47,16 @@ uses DXSpotStore, TCIProtocol, TCIServer, TCIStreams; const - TCI_MAX_RX = MAX_PANS; // приёмник TCI = панадаптер + // ★Приёмник TCI: 0 — главный тракт (каналы A/B = VFO A/B), N — слайс СЛОТА + // N−1, то есть буквы B..G с флага, независимо от того, на каком он пане. + // Панадаптером приёмник быть перестал: у Pluto пан ровно один (MaxPans=1), + // и «второй приёмник» там существует только как слайс главного пана — при + // нумерации по панам он был бы недоступен вовсе. А клиенты умеют мало + // номеров: у MSHV в настройках всего rx1/rx2, то есть приёмники 0 и 1. + // Со слотами правило «первый добавленный слайс = приёмник 1» держится на + // любом железе: на openHPSDR слайс второго пана и на Pluto слайс главного + // одинаково занимают слот B. Пан стал свойством слайса (его центр и IQ). + TCI_MAX_RX = 1 + MAX_SLICES; // Аудио на выходе движка всегда 48 кГц (TWDSPEngine.Create), от него и // считаются все прореживания и пересчёты потоков. TCI_AUDIO_ENGINE_RATE = 48000; @@ -170,6 +179,8 @@ type FsInt3: Integer; FsBool: Boolean; FsBool2: Boolean; + FsBool3: Boolean; + FsRes: Boolean; // ОБРАТНО из Sync-метода (под тем же FLock) FsStr: string; // ── Sync-методы (поток контроллера) ── @@ -179,7 +190,6 @@ type procedure SyncSetFilter; procedure SyncSetTRX; procedure SyncStopTRX; - procedure SyncSetTune; procedure SyncSetDrive; procedure SyncSetTuneDrive; procedure SyncSetSplit; @@ -235,6 +245,9 @@ type function RxCount: Integer; function ValidRx(Rx: Integer): Boolean; function RxActive(Rx: Integer): Boolean; + function LiveRx(Rx: Integer): Boolean; // номер в потолке И приёмник существует + function RxSlot(Rx: Integer): Integer; // слот слайса; −1 = главный тракт + function RxPanId(Rx: Integer): Integer; // пан приёмника; −1 = приёмника нет procedure CalcFreqLimits(out LoHz, HiHz: Double); // живые (поток контроллера) procedure FreqLimits(out LoHz, HiHz: Double); // из снимка (любой поток) function FreqSaneLive(Hz: Double): Boolean; // проверка перед установкой @@ -250,6 +263,7 @@ type function SliceIdLive(Rx, Ch: Integer): Integer; // из живой таблицы (Sync*) function RxSlice(Rx: Integer; out S: TSliceView): Boolean; function SliceRxCh(Id: Integer; out Rx, Ch: Integer): Boolean; + function TxRx: Integer; // приёмник, чей слайс сейчас источник передачи { Захват параметра (§3.5). True — параметр наш (свободен, наш или отпущен по таймауту), захват продлевается. False — им сейчас управляет другой. } function Claim(const Key: string; Owner: TTCIClient): Boolean; @@ -277,8 +291,8 @@ type function StrDds(Rx: Integer): string; function StrModulation(Rx: Integer): string; function StrFilterBand(Rx: Integer): string; - function StrTrx: string; - function StrTune: string; + function StrTrx(Rx: Integer): string; + function StrTune(Rx: Integer): string; function StrDrive: string; function StrVolume: string; function StrMute: string; @@ -294,6 +308,7 @@ type function StrANF(Rx: Integer): string; { Полный набор строк по приёмнику — рассылка после правки слайса. } procedure BroadcastRxState(Rx, Ch: Integer); + procedure BroadcastTxEnable; // всем живым приёмникам, каждому со своим номером procedure SendInit(Client: TTCIClient); procedure SendState(Client: TTCIClient); @@ -480,14 +495,15 @@ begin end; { ═══════════════════════════════════════════════════════════════════════════ - Модель: приёмник = панадаптер, канал = VFO A/B (пан 0) или слайс (паны 1..) + Модель: приёмник 0 = главный тракт (каналы = VFO A/B), приёмник N = слайс + слота N−1 (буквы B..G). См. комментарий у TCI_MAX_RX. ═══════════════════════════════════════════════════════════════════════════ } function TTCIAdapter.RxCount: Integer; +// Потолок, а не число живых: TRX_COUNT протокол объявляет один раз, при +// подключении (§4.1). Про приёмник, которого сейчас нет, просто молчим. begin - Result := DevSnap.MaxPans; - if Result < 1 then Result := 1; - if Result > TCI_MAX_RX then Result := TCI_MAX_RX; + Result := TCI_MAX_RX; end; function TTCIAdapter.ValidRx(Rx: Integer): Boolean; @@ -495,13 +511,34 @@ begin Result := (Rx >= 0) and (Rx < RxCount); end; -function TTCIAdapter.RxActive(Rx: Integer): Boolean; -// Приёмник существует физически. TRX_COUNT в протоколе объявляется один раз и -// равен потолку железа, но пан из этого потолка может быть ещё не создан — -// врать про его частоту нельзя (см. SendState). +function TTCIAdapter.LiveRx(Rx: Integer): Boolean; +// Адресат команды. Про приёмник, которого нет (слот пуст), молчим целиком: +// ответ с нулями клиент принял бы за настоящее состояние. begin - if Rx = 0 then Result := True - else Result := ValidRx(Rx) and FController.PanDDCActive(Rx); + Result := ValidRx(Rx) and RxActive(Rx); +end; + +function TTCIAdapter.RxSlot(Rx: Integer): Integer; +// Слот слайса, стоящего за приёмником Rx. −1 — это главный тракт (rx0) либо +// номер вне потолка. +begin + if (Rx <= 0) or (Rx >= TCI_MAX_RX) then Result := -1 else Result := Rx - 1; +end; + +function TTCIAdapter.RxActive(Rx: Integer): Boolean; +// Приёмник существует. Для слайса это значит «слот занят»: удалили слайс — +// приёмник исчез, и врать про его частоту нельзя (см. SendState). +var Slot: Integer; +begin + if Rx = 0 then Exit(True); + Slot := RxSlot(Rx); + if Slot < 0 then Exit(False); + FSliceLock.Enter; + try + Result := FSliceSnap[Slot].Used; + finally + FSliceLock.Leave; + end; end; procedure TTCIAdapter.CalcFreqLimits(out LoHz, HiHz: Double); @@ -670,40 +707,51 @@ begin begin if not RxActive(Rx) then Continue; N := ChanCount(Rx); + FServer.Broadcast(StrDds(Rx)); for Ch := 0 to N - 1 do BroadcastRxState(Rx, Ch); FServer.Broadcast(TCIBuild('rx_channel_enable', [TCIIntStr(Rx), '1', TCIBoolStr(N > 1)])); + // ★С адресом приёмника — иначе клиент его не увидит. Клиенты фильтруют + // входящие по arg1 (см. StrTrx), а MSHV на этом ещё и завязывает передачу: + // `set_ptt` начинается с `if (!tci_tx_enable) return;`. Слайс, созданный + // ПОСЛЕ подключения клиента, без этой строки получал бы молча мёртвую PTT. + FServer.Broadcast(StrTxEnable(Rx)); end; end; function TTCIAdapter.SnapSlice(Rx, Ch: Integer; out V: TSliceView): Boolean; -// Слайс канала Ch приёмника Rx из снимка. Для Rx=0 слайса нет: главный тракт -// живёт в полях контроллера. -var i, Seen: Integer; +// Слайс приёмника Rx из снимка. У приёмника-слайса канал один — A: второго +// «саб-приёмника» внутри слайса не бывает. Для Rx=0 слайса нет вовсе: главный +// тракт живёт в полях контроллера. +var Slot: Integer; begin Result := False; FillChar(V, SizeOf(V), 0); - if (Rx <= 0) or (Ch < 0) then Exit; - Seen := 0; + Slot := RxSlot(Rx); + if (Slot < 0) or (Ch <> 0) then Exit; FSliceLock.Enter; try - for i := 0 to MAX_SLICES - 1 do - if FSliceSnap[i].Used and (FSliceSnap[i].V.PanId = Rx) then - begin - if Seen = Ch then - begin - V := FSliceSnap[i].V; - Exit(True); - end; - Inc(Seen); - end; + if FSliceSnap[Slot].Used then + begin + V := FSliceSnap[Slot].V; + Result := True; + end; finally FSliceLock.Leave; end; end; +function TTCIAdapter.RxPanId(Rx: Integer): Integer; +// На каком пане живёт приёмник: у главного тракта это пан 0, у слайса — тот, +// где он стоит. Отсюда берутся его центр (DDS) и поток IQ. +var V: TSliceView; +begin + if Rx = 0 then Exit(0); + if SnapSlice(Rx, 0, V) then Result := V.PanId else Result := -1; +end; + function TTCIAdapter.SliceIdOf(Rx, Ch: Integer): Integer; -// Слайсы пана Rx по порядку в таблице: 0-й = канал A, 1-й = канал B. +// Слайс приёмника Rx (канал A) из снимка. var V: TSliceView; begin if SnapSlice(Rx, Ch, V) then Result := V.Id else Result := 0; @@ -713,9 +761,12 @@ function TTCIAdapter.SliceIdLive(Rx, Ch: Integer): Integer; // То же, но из живой таблицы. Только для Sync-методов: они идут в потоке // контроллера, где чтение безопасно, а снимок мог бы отстать на такт — команда // на установку обязана попасть в тот слайс, который есть сейчас. +var Slot: Integer; begin - if Rx <= 0 then Result := 0 - else Result := FController.PanSliceId(Rx, Ch); + Result := 0; + Slot := RxSlot(Rx); + if (Slot < 0) or (Ch <> 0) then Exit; + Result := FController.SliceIdBySlot(Slot); end; function TTCIAdapter.RxSlice(Rx: Integer; out S: TSliceView): Boolean; @@ -726,35 +777,21 @@ end; function TTCIAdapter.SliceRxCh(Id: Integer; out Rx, Ch: Integer): Boolean; // Обратное отображение слайс → (приёмник, канал). Нужно уведомлениям: контроллер -// сообщает об изменении Id, а клиенту адресуются номера TCI. -var i, Seen, Pan: Integer; +// сообщает об изменении Id, а клиенту адресуются номера TCI. Слот слайса и есть +// его номер приёмника — искать по панам больше нечего. +var i: Integer; begin Result := False; Rx := 0; Ch := 0; if Id <= 0 then Exit; - Pan := -1; FSliceLock.Enter; try for i := 0 to MAX_SLICES - 1 do if FSliceSnap[i].Used and (FSliceSnap[i].V.Id = Id) then begin - Pan := FSliceSnap[i].V.PanId; - Break; - end; - // Пан 0 в модели TCI — это VFO A/B главного приёмника, а не его слайсы: - // слайсу на главном пане в протоколе места нет. - if (Pan <= 0) or not ValidRx(Pan) then Exit; - Seen := 0; - for i := 0 to MAX_SLICES - 1 do - if FSliceSnap[i].Used and (FSliceSnap[i].V.PanId = Pan) then - begin - if FSliceSnap[i].V.Id = Id then - begin - Rx := Pan; - Ch := Seen; - Exit(Seen < TCI_CHANNELS); - end; - Inc(Seen); + Rx := i + 1; + Ch := 0; + Exit(True); end; finally FSliceLock.Leave; @@ -830,22 +867,11 @@ begin end; function TTCIAdapter.ChanCount(Rx: Integer): Integer; -var i: Integer; +// У главного тракта два канала (VFO A/B), у приёмника-слайса — один: канал B +// в протоколе это второй саб-приёмник, а внутри слайса такого нет. begin - if Rx = 0 then begin Result := 2; Exit; end; // VFO A/B всегда есть - Result := 0; - FSliceLock.Enter; - try - for i := 0 to MAX_SLICES - 1 do - if FSliceSnap[i].Used and (FSliceSnap[i].V.PanId = Rx) then Inc(Result); - finally - FSliceLock.Leave; - end; - if Result > TCI_CHANNELS then Result := TCI_CHANNELS; - // Канал A в TCI выключить нечем: он есть у приёмника всегда. Пока пан жив, но - // слайсов на нём не осталось, показываем канал A на центре пана — иначе после - // удаления последнего слайса клиент навсегда остался бы с его частотой. - if (Result = 0) and RxActive(Rx) then Result := 1; + if Rx = 0 then Exit(2); + if RxActive(Rx) then Result := 1 else Result := 0; end; function TTCIAdapter.ChanFreq(Rx, Ch: Integer): Double; @@ -857,15 +883,21 @@ begin if Ch = 1 then Result := FController.FVfoB else Result := FController.FVfoA; Exit; end; - if SnapSlice(Rx, Ch, S) then Exit(S.TargetHz); - // Слайса нет: у живого пана канал A стоит на его центре (см. ChanCount). - if (Ch = 0) and RxActive(Rx) then Result := RxCenterHz(Rx); + // Слайса нет — приёмника нет: частоту не выдумываем (SendState про такой + // номер молчит целиком). + if SnapSlice(Rx, Ch, S) then Result := S.TargetHz; end; function TTCIAdapter.RxCenterHz(Rx: Integer): Double; +// DDS приёмника — центр панорамы, на которой он живёт. У слайса это центр его +// пана: для главного пана — FCenterFreq, для дополнительного — его DDC. +var Pan: Integer; begin - if Rx = 0 then Result := FController.FCenterFreq - else Result := FController.PanDDCFreq(Rx); + Result := 0; + Pan := RxPanId(Rx); + if Pan < 0 then Exit; + if Pan = 0 then Result := FController.FCenterFreq + else Result := FController.PanDDCFreq(Pan); end; function TTCIAdapter.RxMode(Rx: Integer): Integer; @@ -1002,14 +1034,36 @@ begin Result := TCIBuild('rx_filter_band', [TCIIntStr(Rx), TCIIntStr(Lo), TCIIntStr(Hi)]); end; -function TTCIAdapter.StrTrx: string; +function TTCIAdapter.TxRx: Integer; +// Номер приёмника, чей слайс СЕЙЧАС источник передачи (0 = главный VFO). +// Ответы и уведомления TRX/TUNE обязаны называть именно его: клиент на +// приёмнике 1, получивший «trx:0,true», решил бы, что его команда не прошла, а +// клиент на приёмнике 0 — что в эфире он, хотя передаёт чужой слайс. +var + Rx, Ch: Integer; begin - Result := TCIBuild('trx', ['0', TCIBoolStr(FController.FTransmitting)]); + Result := 0; + if FController = nil then Exit; + if (FController.TxSliceId > 0) and SliceRxCh(FController.TxSliceId, Rx, Ch) then + Result := Rx; end; -function TTCIAdapter.StrTune: string; +function TTCIAdapter.StrTrx(Rx: Integer): string; +// Состояние передатчика ДЛЯ ПРИЁМНИКА Rx: «true» значит, что в эфире именно +// его слайс. Клиенты фильтруют входящие строки по номеру приёмника (MSHV, +// например, отбрасывает всё, что адресовано не ему), поэтому автору команды +// отвечаем ЕГО номером: `trx:1,false` — это «твоя заявка не прошла», а +// `trx:0,false` он бы просто не увидел. В рассылку уходит номер того, чей +// слайс сейчас источник передачи (`TxRx`). begin - Result := TCIBuild('tune', ['0', TCIBoolStr(FController.FTuning)]); + Result := TCIBuild('trx', [TCIIntStr(Rx), + TCIBoolStr(FController.FTransmitting and (TxRx = Rx))]); +end; + +function TTCIAdapter.StrTune(Rx: Integer): string; +begin + Result := TCIBuild('tune', [TCIIntStr(Rx), + TCIBoolStr(FController.FTuning and (TxRx = Rx))]); end; function TTCIAdapter.StrDrive: string; @@ -1107,6 +1161,18 @@ begin FServer.Broadcast(StrSqlLevel(Rx)); end; +procedure TTCIAdapter.BroadcastTxEnable; +// TX_ENABLE — величина всего радио, но адресуется приёмником, и клиент читает +// только строки со СВОИМ номером. Одной строки `tx_enable:0,…` мало: клиент на +// приёмнике 1 её отбрасывает, а у MSHV на этом флаге висит вся передача +// (`set_ptt`: `if (!tci_tx_enable) return;`). +var Rx: Integer; +begin + if (FServer = nil) or (FServer.ClientCount = 0) then Exit; + for Rx := 0 to RxCount - 1 do + if RxActive(Rx) then FServer.Broadcast(StrTxEnable(Rx)); +end; + { ═══════════════════════════════════════════════════════════════════════════ Подключение клиента: инициализация + текущее состояние ═══════════════════════════════════════════════════════════════════════════ } @@ -1206,8 +1272,9 @@ begin end; Reply(Client, TCIBuild('split_enable', ['0', TCIBoolStr(FController.FSplitTxB)])); - Reply(Client, StrTrx); - Reply(Client, StrTune); + // В пачке инициализации называем того, чей слайс сейчас источник передачи. + Reply(Client, StrTrx(TxRx)); + Reply(Client, StrTune(TxRx)); Reply(Client, StrDrive); Reply(Client, TCIBuild('tune_drive', ['0', TCIIntStr(FController.FTXSettings.TUNLevel)])); Reply(Client, StrVolume); @@ -1295,11 +1362,15 @@ end; procedure TTCIAdapter.SyncSetCenter; begin - // Окончательная проверка — здесь: см. FreqSaneLive. Ни SetCenter, ни - // SetPanDDCFreq границ не клампят, число уходит прямо в backend. + // Окончательная проверка — здесь: см. FreqSaneLive. SetCenter границ не + // клампит, число уходит прямо в backend. if not FreqSaneLive(FsFreq) then Exit; - if FsInt = 0 then FController.SetCenter(FsFreq) - else FController.SetPanDDCFreq(FsInt, FsFreq); + // Двигать можно только центр главной панорамы — она и есть приёмник 0. + // У приёмника-слайса DDS только читается: панорама под ним общая, и увести + // её по просьбе одного клиента значит утащить за собой всех соседей по пану + // и картинку оператора. Слайсу двигаться незачем — за окном DDC следит + // TuneSliceInBand (см. SyncSetVfo). + if FsInt = 0 then FController.SetCenter(FsFreq); end; procedure TTCIAdapter.SyncSetMode; @@ -1319,12 +1390,71 @@ begin end; procedure TTCIAdapter.SyncSetTRX; +// Поток контроллера. FsInt — номер приёмника из arg1, FsBool3 — это TUNE. +// Наружу отдаёт FsRes: «эфир подняла ИМЕННО ЭТА команда» — по нему в потоке +// клиента назначается хозяин передачи. +// +// ★Все решения принимаются здесь, а не в потоке клиента. Между разбором +// команды и её исполнением оператор успевает нажать PTT, и снимок «шла ли +// передача», снятый заранее, соврал бы — а по нему назначается хозяин эфира, +// то есть право снять чужую передачу. +var + Id: Integer; + WasTx, Started: Boolean; begin + FsRes := False; + WasTx := FController.FTransmitting or FController.FTuning; + // Источник модуляции ставим ДО SetMOX — именно он его и читает при выборе // микрофона. Выключение передачи флаг снимает всегда: следующий раз оператор // может нажать PTT сам, и тогда в эфир должен идти его микрофон. - FController.TCIMicRequested := FsBool and FsBool2; - FController.SetMOX(FsBool); + // ★Ставим его ТОЛЬКО когда эфир поднимаем мы сами. SetMOX(True) поверх уже + // идущей передачи не выходит рано — он заново выбирает микрофон, и + // «trx:0,true,tci» посреди передачи оператора молча уводил модуляцию в ринг + // TCI. Хозяином эфира клиент при этом не становился, так что его уход + // оставлял в эфире несущую с тишиной. У TUNE источника нет вовсе (несущую + // даёт генератор тона), и трогать чужой выбор микрофона он не вправе. + if not FsBool3 then + begin + if not FsBool then FController.TCIMicRequested := False + else if not WasTx then FController.TCIMicRequested := FsBool2; + end; + + // Приёмник 0 — это главный VFO, то есть «радио целиком»: ведём себя как + // обычный CAT-порт (в эфир уходит выбранный оператором TX-источник). Приёмник + // N > 0 адресует слайс канала A своего пана — заявка идёт через контроллер, + // который один имеет право сменить TX-источник. + Id := SliceIdLive(FsInt, 0); + if FsBool and WasTx then + begin + // Передатчик занят — чужую передачу не перехватываем и поверх неё ничего не + // включаем: то же правило, что внутри RequestSliceTx, только оно обязано + // работать и для приёмника 0. Иначе «tune:0,true» подмешивал настроечный + // тон в чужую передачу, а хозяином TUN не становился никто — и после ухода + // клиента тон оставался в эфире. Своя же передача и так идёт: повторная + // команда — no-op. + end + else if FsInt > 0 then + begin + // Пан жив, но слайса на канале A может не быть (пан без слайсов показывает + // центр) — тогда просьба просто не исполняется. Подменять её главным VFO + // нельзя: это чужая частота, а то и чужой диапазон. + if Id > 0 then FController.RequestSliceTx(Id, FsBool, FsBool3); + end + else if FsBool3 then + FController.SetTune(FsBool) + else + FController.SetMOX(FsBool); + + Started := FsBool and (not WasTx) and + (FController.FTransmitting or FController.FTuning); + FsRes := Started; + // Заявку могли отклонить (нет Auto TX у слайса, DMR, запрет передачи на + // диапазоне) — тогда снимаем и источник модуляции: иначе следующая PTT + // оператора ушла бы в эфир с микрофоном ушедшего клиента вместо его + // собственного. + if FsBool and (not FsBool3) and (not WasTx) and (not Started) then + FController.TCIMicRequested := False; end; procedure TTCIAdapter.SyncStopTRX; @@ -1332,9 +1462,14 @@ procedure TTCIAdapter.SyncStopTRX; // решение принято там, где известно, кто её начал, а здесь остаётся только // проверить, что она вообще идёт (оператор мог отпустить PTT сам). begin - if (FController = nil) or not FController.FTransmitting then Exit; + if FController = nil then Exit; + if not (FController.FTransmitting or FController.FTuning) then Exit; FController.TCIMicRequested := False; - FController.SetMOX(False); + // TUN снимаем именно SetTune: он гасит и тон, и PTT, и возвращает drive. + // SetMOX(False) поверх включённого TUN оставил бы взведённым генератор тона, + // а FTuning — поднятым, то есть аппарат в состоянии, которого на экране нет. + if FController.FTuning then FController.SetTune(False) + else FController.SetMOX(False); end; procedure TTCIAdapter.SyncTaps; @@ -1352,11 +1487,6 @@ begin end; end; -procedure TTCIAdapter.SyncSetTune; -begin - FController.SetTune(FsBool); -end; - procedure TTCIAdapter.SyncSetDrive; begin FController.SetDrive(FsInt); @@ -1546,6 +1676,11 @@ begin if not TCITryArgInt(M, 1, Ch) then Ch := 0; if not ValidRx(Rx) then Exit; if (Ch < 0) or (Ch >= TCI_CHANNELS) then Exit; + // ★Про приёмник, которого нет (слайс не создан или уже удалён), молчим + // целиком — и на чтение тоже. Ответ «vfo:1,0,0» клиент принял бы за + // настоящую частоту, а MSHV именно этим ответом завершает инициализацию + // (network.cpp, Network::initAll) — то есть подключился бы к пустоте. + if (Ch >= ChanCount(Rx)) or not RxActive(Rx) then Exit; // Частота ставится, только если её удалось разобрать И она годная: «vfo:0,0,abc» // раньше превращалось в честный ноль и уводило приёмник на 0 Гц. @@ -1682,7 +1817,7 @@ procedure TTCIAdapter.DispatchCommand(Client: TTCIClient; const M: TTCIMessage); var Rx, Ch, V: Integer; Lo, Hi: Integer; - B, FromTCI: Boolean; + B, FromTCI, Tune, Started: Boolean; D: Double; Name: string; begin @@ -1708,8 +1843,14 @@ begin if M.Name = 'DDS' then begin if not TCITryArgInt(M, 0, Rx) then Exit; - if not ValidRx(Rx) then Exit; - if (M.ArgCount >= 2) and TCITryArgFloat(M, 1, D) and FreqSane(D) and + if not LiveRx(Rx) then Exit; + // Двигать центр можно только у приёмника 0 — у главной панорамы. У + // приёмника-слайса DDS читается (центр его пана), но не пишется: панорама + // под ним общая, и увести её по просьбе одного клиента значит утащить + // соседей по пану и картинку оператора. Ответ уходит всегда — с текущим + // значением, как и у AGC_GAIN доп. приёмника (§3.1). + if (Rx = 0) and + (M.ArgCount >= 2) and TCITryArgFloat(M, 1, D) and FreqSane(D) and Claim(HoldKey('DDS', Rx, 0), Client) then begin FLock.Enter; @@ -1726,7 +1867,7 @@ begin if M.Name = 'MODULATION' then begin if not TCITryArgInt(M, 0, Rx) then Exit; - if not ValidRx(Rx) then Exit; + if not LiveRx(Rx) then Exit; if M.ArgCount >= 2 then begin V := TCIModeIndex(TCIArg(M, 1), RxMode(Rx), ChanFreq(Rx, 0)); @@ -1746,7 +1887,7 @@ begin if M.Name = 'RX_FILTER_BAND' then begin if not TCITryArgInt(M, 0, Rx) then Exit; - if not ValidRx(Rx) then Exit; + if not LiveRx(Rx) then Exit; // Обе кромки обязаны разобраться, и нижняя обязана быть ниже верхней: // «rx_filter_band:0,x,y» иначе схлопывал фильтр в 0..0 и приёмник глох. if (M.ArgCount >= 3) and TCITryArgInt(M, 1, Lo) and TCITryArgInt(M, 2, Hi) and @@ -1765,51 +1906,75 @@ begin end; // ── Передача ── - if M.Name = 'TRX' then + // ★arg1 у TRX/TUNE — НОМЕР ПЕРЕДАТЧИКА, и он не декорация: клиент, работающий + // на приёмнике N, просит эфир своему слайсу, а не тому, который оператор + // выбрал мышкой. Игнорировать его значит передать на чужой частоте, а с + // кросс-бандовым мультислайс-TX — и на чужом диапазоне, с чужими антенной и + // фильтрами. Поэтому номер разбирается и проверяется, как везде, а исполнение + // для N > 0 идёт через RequestSliceTx — ту же дверь, что у CAT-порта слайса + // (там же и «в эфире только один», и уважение к флагу Auto TX). + if (M.Name = 'TRX') or (M.Name = 'TUNE') then begin - // arg3 — источник сигнала. Наш только 'tci': модуляция берётся из - // аудиопотока этого клиента. Остальные значения (mic1/mic2/micpc/ecoder2) - // называют физические входы ExpertSDR3, которых у нас нет, — они значат - // «микрофон, выбранный в программе», то есть ровно то, что и без arg3. - // Требование «включен аудиопоток по TCI» (§4.2) проверяем буквально: - // без AUDIO_START модулировать нечем, и молча оставить оператора с - // тишиной в эфире хуже, чем передавать с его микрофона. + if not TCITryArgInt(M, 0, Rx) or not ValidRx(Rx) then + begin + Reply(Client, TCIBuild('tci_error', [LowerCase(M.Name), 'bad receiver'])); + Exit; + end; + // Номер объявлен (TRX_COUNT = потолок железа), но пана под ним может не + // быть вовсе. Свести такую просьбу к главному VFO — то же самое, что + // передать на чужой частоте, только молча. + if (Rx > 0) and not RxActive(Rx) then + begin + Reply(Client, TCIBuild('tci_error', + [LowerCase(M.Name), 'receiver is not running'])); + Exit; + end; + // Ключ захвата (§3.5) — общий на радио, а не на приёмник: передатчик один, + // и два клиента, дёргающие эфир с разных приёмников, спорят именно за него. if TCITryArgBool(M, 1, B) and Claim(HoldKey('TRX', 0, 0), Client) then begin - Name := LowerCase(Trim(TCIArg(M, 2))); - FromTCI := B and (Name = 'tci') and HasAudioStream(Client); + // arg3 — источник сигнала (только у TRX). Наш только 'tci': модуляция + // берётся из аудиопотока этого клиента. Остальные значения + // (mic1/mic2/micpc/ecoder2) называют физические входы ExpertSDR3, которых + // у нас нет, — они значат «микрофон, выбранный в программе», то есть ровно + // то, что и без arg3. Требование «включен аудиопоток по TCI» (§4.2) + // проверяем буквально: без AUDIO_START модулировать нечем, и молча + // оставить оператора с тишиной в эфире хуже, чем передавать с его + // микрофона. + Tune := M.Name = 'TUNE'; + Name := LowerCase(Trim(TCIArg(M, 2))); + FromTCI := B and (not Tune) and (Name = 'tci') and HasAudioStream(Client); FTxLock.Enter; try if FromTCI then FTxClient := Client - else if FTxClient = Client then FTxClient := nil; - // Кто поставил трансивер в эфир: его уход обязан эфир и снять - // (см. StopTxOf). Источник модуляции тут ни при чём — несущую без - // хозяина оставлять нельзя в любом случае. - if B then FTrxOwner := Client - else if FTrxOwner = Client then FTrxOwner := nil; + else if (not Tune) and (FTxClient = Client) then FTxClient := nil; finally FTxLock.Leave; end; + Started := False; FLock.Enter; try + FsInt := Rx; FsBool := B; FsBool2 := FromTCI; + FsBool3 := Tune; + FsRes := False; if CanInvoke then FController.Invoke(SyncSetTRX); + Started := FsRes; // ответ Sync-метода — под тем же локом, что и вопрос finally FLock.Leave; end; - end; - Reply(Client, StrTrx); - Exit; - end; - - if M.Name = 'TUNE' then - begin - if TCITryArgBool(M, 1, B) and Claim(HoldKey('TUNE', 0, 0), Client) then - begin - FLock.Enter; + // Кто поставил трансивер в эфир: его уход обязан эфир и снять (см. + // StopTxOf). Источник модуляции тут ни при чём — несущую без хозяина + // оставлять нельзя в любом случае. ★Хозяином записываемся, только если + // эфир и правда начался ИМЕННО от этой команды — а знает об этом только + // сам Sync-метод: он один видит состояние передатчика до и после, на + // потоке контроллера и без окна, в которое влезает PTT оператора. + FTxLock.Enter; try - FsBool := B; - if CanInvoke then FController.Invoke(SyncSetTune); - finally FLock.Leave; end; + if Started then FTrxOwner := Client + else if (not B) and (FTrxOwner = Client) then FTrxOwner := nil; + finally FTxLock.Leave; end; end; - Reply(Client, StrTune); + // ★Автору отвечаем ЕГО номером приёмника (см. StrTrx): клиент фильтрует + // входящие по arg1, и отказ, названный чужим номером, до него не дойдёт. + if M.Name = 'TUNE' then Reply(Client, StrTune(Rx)) else Reply(Client, StrTrx(Rx)); Exit; end; @@ -1893,7 +2058,7 @@ begin if M.Name = 'RX_MUTE' then begin if not TCITryArgInt(M, 0, Rx) then Exit; - if not ValidRx(Rx) then Exit; + if not LiveRx(Rx) then Exit; if TCITryArgBool(M, 1, B) and Claim(HoldKey('RXMUTE', Rx, 0), Client) then begin FLock.Enter; @@ -1911,7 +2076,7 @@ begin if not TCITryArgInt(M, 0, Rx) then Exit; if not TCITryArgInt(M, 1, Ch) then Ch := 0; Ch := EnsureRange(Ch, 0, TCI_CHANNELS - 1); - if not ValidRx(Rx) then Exit; + if not LiveRx(Rx) then Exit; if (M.ArgCount >= 3) and TCITryArgFloat(M, 2, D) and Claim(HoldKey('RXVOL', Rx, Ch), Client) then begin @@ -1933,7 +2098,7 @@ begin if not TCITryArgInt(M, 0, Rx) then Exit; if not TCITryArgInt(M, 1, Ch) then Ch := 0; Ch := EnsureRange(Ch, 0, TCI_CHANNELS - 1); - if not ValidRx(Rx) then Exit; + if not LiveRx(Rx) then Exit; FEchoLock.Enter; try if (M.ArgCount >= 3) and TCITryArgInt(M, 2, V) then @@ -1980,7 +2145,7 @@ begin if M.Name = 'AGC_MODE' then begin if not TCITryArgInt(M, 0, Rx) then Exit; - if not ValidRx(Rx) then Exit; + if not LiveRx(Rx) then Exit; if M.ArgCount >= 2 then begin Name := LowerCase(TCIArg(M, 1)); @@ -2004,7 +2169,7 @@ begin if M.Name = 'AGC_GAIN' then begin if not TCITryArgInt(M, 0, Rx) then Exit; - if not ValidRx(Rx) then Exit; + if not LiveRx(Rx) then Exit; // AGC-T (порог АРУ) в ewsdr один на приёмный тракт: у слайса своего нет. // Правка от имени доп. приёмника трогала бы главный — не делаем этого, // просто отвечаем текущим значением (ограничение, см. doc/TCI.md). @@ -2025,7 +2190,7 @@ begin (M.Name = 'RX_ANF_ENABLE') then begin if not TCITryArgInt(M, 0, Rx) then Exit; - if not ValidRx(Rx) then Exit; + if not LiveRx(Rx) then Exit; if TCITryArgBool(M, 1, B) and Claim(HoldKey(M.Name, Rx, 0), Client) then begin FLock.Enter; @@ -2045,7 +2210,7 @@ begin if M.Name = 'RX_NB_PARAM' then begin if not TCITryArgInt(M, 0, Rx) then Exit; - if not ValidRx(Rx) then Exit; + if not LiveRx(Rx) then Exit; FEchoLock.Enter; try if M.ArgCount >= 3 then @@ -2069,7 +2234,7 @@ begin (M.Name = 'RX_NF_ENABLE') then begin if not TCITryArgInt(M, 0, Rx) then Exit; - if not ValidRx(Rx) then Exit; + if not LiveRx(Rx) then Exit; FEchoLock.Enter; try if TCITryArgBool(M, 1, B) then @@ -2096,7 +2261,7 @@ begin if M.Name = 'LOCK' then begin if not TCITryArgInt(M, 0, Rx) then Exit; - if not ValidRx(Rx) then Exit; + if not LiveRx(Rx) then Exit; if TCITryArgBool(M, 1, B) and Claim(HoldKey('LOCK', 0, 0), Client) then begin FLock.Enter; @@ -2112,7 +2277,7 @@ begin if M.Name = 'SQL_ENABLE' then begin if not TCITryArgInt(M, 0, Rx) then Exit; - if not ValidRx(Rx) then Exit; + if not LiveRx(Rx) then Exit; if TCITryArgBool(M, 1, B) and Claim(HoldKey('SQL', Rx, 0), Client) then begin FLock.Enter; @@ -2128,7 +2293,7 @@ begin if M.Name = 'SQL_LEVEL' then begin if not TCITryArgInt(M, 0, Rx) then Exit; - if not ValidRx(Rx) then Exit; + if not LiveRx(Rx) then Exit; if TCITryArgFloat(M, 1, D) and Claim(HoldKey('SQLLEV', Rx, 0), Client) then begin FLock.Enter; @@ -2146,7 +2311,7 @@ begin if (M.Name = 'RIT_ENABLE') or (M.Name = 'XIT_ENABLE') then begin if not TCITryArgInt(M, 0, Rx) then Exit; - if not ValidRx(Rx) then Exit; + if not LiveRx(Rx) then Exit; FEchoLock.Enter; try if TCITryArgBool(M, 1, B) then @@ -2165,7 +2330,7 @@ begin if (M.Name = 'RIT_OFFSET') or (M.Name = 'XIT_OFFSET') then begin if not TCITryArgInt(M, 0, Rx) then Exit; - if not ValidRx(Rx) then Exit; + if not LiveRx(Rx) then Exit; FEchoLock.Enter; try if TCITryArgInt(M, 1, V) then @@ -2188,7 +2353,7 @@ begin // вторым каналом был бы второй слайс (создание слайсов по TCI — этап 2). if not TCITryArgInt(M, 0, Rx) then Exit; if not TCITryArgInt(M, 1, Ch) then Ch := 1; - if not ValidRx(Rx) then Exit; + if not LiveRx(Rx) then Exit; if TCITryArgBool(M, 2, B) then begin FEchoLock.Enter; @@ -2533,7 +2698,9 @@ begin // держать при этом FStreamLock значило бы связать два лока без нужды. if K = tstIQ then begin - SrcRate := FController.IQTapRateHz(Rx); + // ★Частота IQ — свойство ПАНОРАМЫ, а приёмник теперь слайс: спрашиваем + // рейт того пана, на котором он стоит. + SrcRate := FController.IQTapRateHz(RxPanId(Rx)); WantRate := C.IQRate; Chans := 2; // IQ комплексный по определению ST := tsyFloat32; // формат IQ в TCI не настраивается @@ -2695,19 +2862,26 @@ begin end; function TTCIAdapter.StreamRxOf(PanId, SliceId: Integer): Integer; -// Какому приёмнику TCI принадлежит это аудио. Главный тракт — приёмник 0. -// У доп. пана в потоках участвует только канал A (первый слайс): «аудиопоток -// приёмника» в протоколе один на приёмник, второго канала у него нет. -// Зовётся из DSP-потока и берёт FSliceLock — ДО FStreamLock (порядок!). +// Какому приёмнику TCI принадлежит это аудио. Главный тракт (SliceId = 0 на +// пане 0) — приёмник 0, любой слайс — приёмник своего слота, на каком бы пане +// он ни стоял. Зовётся из DSP-потока и берёт FSliceLock — ДО FStreamLock +// (порядок!). +var i: Integer; begin Result := -1; - if PanId <= 0 then + if SliceId <= 0 then begin - if SliceId = 0 then Result := 0; // слайсы главного пана в модель не входят + if PanId = 0 then Result := 0; Exit; end; - if not ValidRx(PanId) then Exit; - if SliceIdOf(PanId, 0) = SliceId then Result := PanId; + FSliceLock.Enter; + try + for i := 0 to MAX_SLICES - 1 do + if FSliceSnap[i].Used and (FSliceSnap[i].V.Id = SliceId) then + Exit(i + 1); + finally + FSliceLock.Leave; + end; end; procedure TTCIAdapter.OnAudioTap(Kind: TRadioAudioKind; PanId, SliceId: Integer; @@ -2742,14 +2916,23 @@ procedure TTCIAdapter.OnIQTap(PanId: Integer; PI_, PQ_: PDouble; N, RateHz: Integer); // DSP-поток, один вызов на накопленный блок. У пана этот вызов идёт под // FSliceLock движка, поэтому здесь тем более нельзя ждать. -var i: Integer; +// +// IQ — величина ПАНОРАМЫ, а приёмник теперь слайс: поток получают все +// приёмники, чьи слайсы стоят на этом пане (и приёмник 0, если пан главный). +// Карту «приёмник → пан» строим ДО FStreamLock: порядок локов один на весь +// адаптер — FSliceLock, потом FStreamLock. +var + i: Integer; + Mine: array[0..TCI_MAX_RX-1] of Boolean; begin if (N <= 0) or (RateHz <= 0) then Exit; - if (PanId < 0) or (PanId >= TCI_MAX_RX) then Exit; + if (PanId < 0) or (PanId >= MAX_PANS) then Exit; + for i := 0 to TCI_MAX_RX - 1 do Mine[i] := RxPanId(i) = PanId; FStreamLock.Enter; try for i := 0 to High(FStreams) do - if (FStreams[i].Kind = tstIQ) and (FStreams[i].Rx = PanId) then + if (FStreams[i].Kind = tstIQ) and (FStreams[i].Rx >= 0) and + (FStreams[i].Rx < TCI_MAX_RX) and Mine[FStreams[i].Rx] then begin // Rate устройства могли сменить уже после START (смена sample rate, // другой rate DDC пана): пересчитываем прореживание на месте, иначе @@ -3181,8 +3364,11 @@ begin rfVolume: Claim(HoldKey('VOL', 0, 0), nil); rfMute: Claim(HoldKey('MUTE', 0, 0), nil); rfDrive: Claim(HoldKey('DRIVE', 0, 0), nil); - rfTransmitting: Claim(HoldKey('TRX', 0, 0), nil); - rfTuning: Claim(HoldKey('TUNE', 0, 0), nil); + // Передатчик один — и ключ захвата у него один на всех, кто его дёргает: + // и TRX, и TUNE, и оператор. Отдельный ключ 'TUNE' делал защиту дырявой — + // TUN поверх чужого MOX не спорил с ним ни за что. + rfTransmitting, + rfTuning: Claim(HoldKey('TRX', 0, 0), nil); rfVfoLock: Claim(HoldKey('LOCK', 0, 0), nil); rfCenterFreq: Claim(HoldKey('DDS', 0, 0), nil); rfSliceFreq: @@ -3212,8 +3398,8 @@ begin rfVolume: FServer.Broadcast(StrVolume); rfMute: FServer.Broadcast(StrMute); rfDrive: FServer.Broadcast(StrDrive); - rfTransmitting: FServer.Broadcast(StrTrx); - rfTuning: FServer.Broadcast(StrTune); + rfTransmitting: FServer.Broadcast(StrTrx(TxRx)); + rfTuning: FServer.Broadcast(StrTune(TxRx)); rfVfoLock: begin FServer.Broadcast(StrLock(0)); @@ -3253,10 +3439,12 @@ begin if FController.FRunning then FServer.Broadcast(TCIBuild('start')) else FServer.Broadcast(TCIBuild('stop')); rfPanFreq: - // Центр пана уехал: у его каналов изменилась и IF (она отсчитывается - // от центра), хотя абсолютная частота могла остаться прежней. + // Центр пана уехал: у приёмников, которые на нём стоят, изменилась IF + // (она отсчитывается от центра), хотя абсолютная частота могла остаться + // прежней. Какие это приёмники — знает RxPanId: слайсы одного пана + // раскиданы по слотам, подряд они не лежат. for Rx := 1 to RxCount - 1 do - if FController.PanDDCActive(Rx) then + if RxPanId(Rx) > 0 then begin FServer.Broadcast(StrDds(Rx)); for Ch := 0 to ChanCount(Rx) - 1 do FServer.Broadcast(StrIf(Rx, Ch)); @@ -3268,7 +3456,7 @@ begin BroadcastRxState(Rx, Ch); rfBand, rfXvtr: begin - FServer.Broadcast(StrTxEnable(0)); + BroadcastTxEnable; FServer.Broadcast(StrVfo(0, 0)); end; rfActiveVfo: @@ -3312,7 +3500,7 @@ begin if TxEnabled <> FLastTxEnable then begin FLastTxEnable := TxEnabled; - FServer.Broadcast(StrTxEnable(0)); + BroadcastTxEnable; end; end; end; diff --git a/doc/TCI.md b/doc/TCI.md index 3e297f4..17780c2 100644 --- a/doc/TCI.md +++ b/doc/TCI.md @@ -165,12 +165,37 @@ UI — вкладка **CAT → TCI Server**, справа от «TCP CAT Server | TCI | EWSDR | |---|---| -| приёмник (`TRX_COUNT`) | панадаптер: 0 = главный тракт, 1.. = доп. DDC-паны. Число = `BackendCaps.MaxPans` | -| канал A/B (`CHANNEL_COUNT` = 2) | у приёмника 0 — VFO A / VFO B; у панов 1.. — первый и второй слайс пана | -| `DDS` | центр панадаптера (`SetCenter` / `SetPanDDCFreq`) | -| `IF` | смещение канала от центра панорамы | -| `VFO` | абсолютная частота канала | -| передатчик (`arg1` у TRX/TUNE/DRIVE) | всегда один — главный тракт | +| приёмник (`TRX_COUNT` = 7) | 0 — главный тракт; N — **слайс слота N−1**, то есть буквы B..G с флага, на каком бы пане он ни стоял. Потолок = 1 + `MAX_SLICES` | +| канал A/B (`CHANNEL_COUNT` = 2) | у приёмника 0 — VFO A / VFO B; у приёмника-слайса канал один (A): второго саб-приёмника внутри слайса не бывает | +| панорама | не адресуется: стала свойством приёмника — от неё берутся `DDS` и поток IQ | +| `DDS` | центр панорамы, на которой стоит приёмник. Пишется только у приёмника 0 (`SetCenter`); у слайса читается | +| `IF` | смещение канала от центра его панорамы | +| `VFO` | абсолютная частота канала (у слайса — `TuneSliceInBand`) | +| передатчик (`arg1` у TRX/TUNE) | передатчик один, но `arg1` называет, ЧЕЙ слайс должен в него попасть: 0 — главный VFO (как обычный CAT-порт), N — слайс слота N−1 через `RequestSliceTx`. См. §2.7 | +| `arg1` у `DRIVE`, `TUNE_DRIVE` | мощность одна на радио — номер не адресует ничего | + +★**Почему приёмник — слайс, а не панорама.** Сперва приёмником был панадаптер, +как в ExpertSDR3, где приёмник = DDC. У нас это разошлось с жизнью сразу на +двух концах. У Pluto панорама ровно одна (`BackendCaps.MaxPans = 1`), и второй +приёмник существует там **только** как слайс главного пана — при нумерации по +панам он был бы недоступен вообще, а `TRX_COUNT` навсегда равнялся единице. С +другого конца — клиенты: у MSHV в настройках всего две модели, «TCI Client rx1» +и «rx2», то есть приёмники 0 и 1, и никакого третьего номера ввести некуда +(`hvrigcontrol.cpp`: `netServPort[3]`/`[4]`, `network.cpp`: `tci_trx`). Значит +«первый добавленный слайс» обязан быть приёмником **1** на любом железе. + +Слот слайса даёт ровно это. Слоты глобальные и стабильные, у каждого своя +буква на флаге (`SliceIdBySlot`, `SliceSlotLetter`), и по ним же раздаются +порты слайс-CAT — то есть номер приёмника TCI совпадает с буквой, которую +оператор видит на экране: B → 1, C → 2, … Первый созданный слайс занимает слот +B независимо от того, где он живёт: на openHPSDR это может быть слайс второго +пана, на Pluto — слайс главного, и в обоих случаях он приёмник 1. + +Цена: панорама без единого слайса из TCI пропадает (слушать там нечего, но и +её IQ клиенту недоступен), а второй слайс панорамы перестал быть «каналом B» и +стал самостоятельным приёмником — что скорее выигрыш: у канала B в протоколе +есть только частота, IF и громкость, а у приёмника — мода, фильтр, АРУ, +шумодавы, squelch, свои потоки и `TRX`. --- @@ -209,19 +234,20 @@ UI — вкладка **CAT → TCI Server**, справа от «TCP CAT Server — особенно важно при split и TX-слайсе), текущий `APP_FOCUS` и `VFO_LOCK` на каждый канал. -**У живого пана канал A есть всегда.** Выключить канал A в TCI нечем — он -существует по определению, поэтому пан без слайсов показывает канал A на своём -центре (`DDS`), а не хранит частоту удалённого слайса. Команды на такой канал -игнорируются: слайса под ним нет. Как только слайс появится, канал станет -настоящим. +**Приёмник живёт ровно столько, сколько его слайс.** Удалили слайс — приёмник +исчез вместе с ним: ни частоты, ни состояния, ни потоков. Создали снова — он +займёт первый свободный слот, то есть, как правило, тот же номер (так же +устроены и порты слайс-CAT, привязанные к слоту). -**Приёмники, которых ещё нет, молчат.** `TRX_COUNT` объявляет потолок железа -(`BackendCaps.MaxPans`) один раз и навсегда, а пан из этого потолка может быть -не создан. Для несуществующего пана не шлётся ничего (раньше уходили -`dds`/`vfo`/`if` с нулём, и клиент принимал ноль за настоящую частоту); -состояние приходит, когда пан появится — с `rfPanFreq`/`rfSliceState`. У -существующего пана без слайсов есть только `DDS`. Показания измерителей для -таких приёмников тоже не отправляются. +**Приёмники, которых ещё нет, молчат.** `TRX_COUNT` объявляет потолок (1 + 6 +слотов слайсов) один раз и навсегда, а слот из этого потолка может быть пуст. +Про пустой слот не шлётся ничего — ни `vfo`, ни `dds`, ни показания +измерителей, и команды к нему тоже игнорируются целиком (`LiveRx`). Раньше +уходили `dds`/`vfo`/`if` с нулём, и клиент принимал ноль за настоящую частоту; +для MSHV это вообще фатально — именно ответом на `vfo:,0;` он завершает +инициализацию, то есть подключился бы к пустоте. Состояние появляется в момент +создания слайса: `PushChannelMap` рассылает картину нового приёмника целиком, +включая `tx_enable` с его номером. Список видов связи: `am,sam,dsb,lsb,usb,cw,nfm,wfm,digl,digu,dmr,fmraw`. `dmr`/`fmraw` — наше расширение (протокол расширяемый, §1.4). `CWL`/`CWU` @@ -264,8 +290,8 @@ UI — вкладка **CAT → TCI Server**, справа от «TCP CAT Server | `DDS` | `SetCenter` / `SetPanDDCFreq` | | `IF`, `VFO` | `SetVfoA/B`, `TuneSliceInBand` | | `MODULATION` | `SetMode` / `SetSliceMode` | -| `TRX` | `SetMOX` | -| `TUNE` | `SetTune` | +| `TRX` | `SetMOX` (приёмник 0) / `RequestSliceTx` (приёмник N) | +| `TUNE` | `SetTune` (приёмник 0) / `RequestSliceTx(…, Tune)` (приёмник N) | | `DRIVE` | `SetDrive` | | `TUNE_DRIVE` | `FTXSettings.TUNLevel` через `SetTXSettings` | | `SPLIT_ENABLE` | `SetSplit` | @@ -494,6 +520,71 @@ ExpertSDR3 давно бы не было. скорости и не знает прос-знаков. Доотправка позывного (`cw_msg:arg1;`) игнорируется: уже отданный в очередь текст не редактируется. +### 2.7 Кто именно уходит в эфир (`TRX`, `TUNE`) + +★`arg1` у этих двух команд — **номер передатчика**, и он не декорация. Клиент +живёт на своём приёмнике: скиммер на пане 2, цифра на пане 1, логгер на +главном. Нажимая передачу, он просит эфир **своему** слайсу — а не тому, +который оператор выбрал мышкой минуту назад. Пока номер игнорировался, +`trx:2,true` уводил в эфир чужой слайс: другая частота, а с кросс-бандовым +мультислайс-TX — и другой диапазон, то есть чужие антенна и фильтры. Клиент об +этом не узнавал никак (ответ всегда говорил `trx:0,…`), а оператор видел +передачу там, где её никто не просил. + +Как устроено теперь: + +- номер разбирается и проверяется, как у всех остальных команд: + не число или вне `TRX_COUNT` — `tci_error … bad receiver`; номер в потолке, + но пана под ним нет — `receiver is not running`. Свести такую просьбу к + главному VFO нельзя: молча передать не на той частоте хуже, чем отказать; +- **приёмник 0** — это «радио целиком», то есть главный VFO. Ведём себя как + обычный CAT-порт: в эфир уходит выбранный оператором TX-источник; +- **приёмник N > 0** адресует слайс слота N−1, и заявка идёт через + `TRadioController.RequestSliceTx` — ту же дверь, что у CAT-порта слайса + (см. `doc/CAT_SLICES.md`). Оттуда бесплатно достаются оба правила: «в эфире + одновременно только один» (чужую передачу не перехватываем) и уважение к + флагу **Auto TX** (без него слайс сам себя источником не назначает); +- `TUNE` ходит той же дорогой — `RequestSliceTx(…, Tune := True)`: несущая + настройки обязана вставать на тот же слайс, что и модуляция; +- **ответ автору называет ЕГО номер приёмника**, а состояние в нём — «в эфире + именно твой слайс» (`FTransmitting and (TxRx = Rx)`). Так надо потому, что + клиенты фильтруют входящие строки по `arg1`: MSHV, например, отбрасывает + всё, что адресовано не его приёмнику (`network.cpp`: + `if (ls2.at(0)!=tci_trx) continue;`), — и отказ, названный чужим номером, до + него бы просто не дошёл. В **рассылку** уходит номер того, чей слайс сейчас + источник передачи (`TxRx` по `TxSliceId`): клиент на приёмнике 1 по ней + видит, что в эфире не он. Смена TX-источника оператором доходит до клиентов + сама: `SetTxSlice` заканчивается `Changed(rfTransmitting)`; +- ключ захвата (§3.5) — **один на передатчик**, `TRX/0/0`: за него спорят и + `TRX`, и `TUNE`, и изменения от оператора (`rfTransmitting`, `rfTuning`). + Отдельный ключ `TUNE` делал защиту дырявой — TUN поверх чужого MOX не спорил + ни с кем; +- **чужую передачу не трогаем вовсе.** Пока передатчик занят (оператор, другой + клиент, аппаратная PTT), `TRX:…,true` и `TUNE:…,true` не делают ничего — то же + правило, что внутри `RequestSliceTx`, только теперь оно работает и для + приёмника 0. Дырок было две: `SetMOX(True)` **не выходит рано** на уже идущей + передаче — он заново выбирает источник модуляции, и `trx:0,true,tci` посреди + передачи оператора уводил её в ринг TCI (а уход клиента оставлял в эфире + несущую с тишиной: хозяином он при этом не становился, снимать было некому); + `tune:0,true` таким же образом подмешивал настроечный тон в чужую передачу и + оставался в эфире после ухода; +- хозяином эфира (`FTrxOwner`, чей уход эфир снимает) клиент записывается, + только если передача началась **именно от его команды**. Знает об этом лишь + сам Sync-метод — он один видит состояние передатчика до и после, на потоке + контроллера. Снимок «шла ли передача», взятый в потоке клиента до `Invoke`, + врал бы: между разбором команды и её исполнением оператор успевает нажать + PTT, и хозяином стал бы не тот. Поэтому решение принимается внутри, а наружу + возвращается один флаг (`FsRes`, под тем же `FLock`, что и аргументы); +- по той же причине отказ откатывает `TCIMicRequested`: иначе следующая PTT + оператора ушла бы в эфир с микрофоном ушедшего клиента. + +Попутно на этой дороге нашлась Access violation, не связанная с TCI: +`SendDUCSpecificFromSettings` трогала `FNetwork` без `Assigned`, а зовут её по +любому PTT/TUN (`SetMOX` → `SyncCWKeyer` → она), в том числе **до подключения +устройства**. В GUI падение маскировалось, у TCI ошибку глотал обработчик +команды — и «передача» жила только в ответе клиенту. Теперь там обычная +проверка, как в остальных двенадцати местах контроллера. + --- ## 3. Осознанные заглушки («эхо») @@ -512,24 +603,78 @@ ExpertSDR3 давно бы не было. | `DIGL_OFFSET`, `DIGU_OFFSET` | смещения цифровых мод не реализованы | | `RX_CHANNEL_ENABLE` | канал B главного приёмника — это VFO B, он есть всегда; создание второго слайса на пане по TCI не делаем (см. §4) | +**Почему именно заглушки, а не «сделать по-быстрому».** Правило одно: *TCI не +заводит в радио состояние, которого оператор не видит на экране и не может +снять*. Клиент подключается и отключается когда ему вздумается, а оставленный +им перекос остаётся в аппарате навсегда — искать его будет человек, у которого +нет ни кнопки, ни индикации. Поэтому настоящими сделаны ровно те параметры, у +которых в интерфейсе есть орган управления (`NR`/`NB`/`ANF`, АРУ, фильтр, +громкость, squelch, скорость CW), а остальные приняты, сохранены и разосланы +ради честной синхронизации клиентов между собой — и только. + +Дальше — построчно, потому что причины у них разные: + +- **`RIT`/`XIT`.** Расстройка — не кнопка, а сквозной сдвиг: `VFO` → DDC/DUC, + панорама, флаг слайса, CAT, каналы памяти. Сделанная **только** ради TCI, она + сразу разошлась бы с тем, что показывает экран и что отвечает CAT-логгеру, — + и это худший вид ошибки в аппарате: слышно одно, написано другое. Заводить + надо целиком, как фичу радио, тогда TCI получит её даром. +- **`RX_BIN_ENABLE`, `RX_BALANCE`.** Единственные два, где тракт-то есть: + `SetRXAPanelBinaural` и `SetRXAPanelPan` в биндингах WDSP лежат готовые. Но в + EWSDR нет ни органа управления, ни поля в профиле, ни персиста, — включённое + клиентом псевдостерео или уехавший баланс оператор не найдёт чем вернуть. + Порядок обратный: сперва UI, потом TCI. +- **`RX_ANC_ENABLE`, `RX_APF_ENABLE`, `RX_DSE_ENABLE`, `RX_NF_ENABLE`.** Этих + узлов в тракте нет физически. У нас `NR`/`NR2` (EMNR), `NB`/`NB2` (ANB/SNBA) + и `ANF` — они в TCI настоящие. `ANC` и `DSE` — блоки собственной обработки + Expert, не WDSP; `APF` и `NF` были бы новыми узлами в цепочке. +- **`RX_NB_PARAM`.** Пороги шумодава у нас — подобранные константы + `FILTER_NB_*`, одинаковые для всех каналов и не выведенные даже в интерфейс. + Клиент, покрутивший их по TCI, оставил бы шумодав расстроенным насовсем. +- **`DIGL_OFFSET`, `DIGU_OFFSET`.** `DIGU`/`DIGL` у нас — обычные SSB-моды со + своим фильтром, отдельного смещения в тракте нет. Введённое ради одной + команды, оно опять развело бы частоту на панораме с той, что слышно. + ## 3.1 Ограничения, о которых честнее знать заранее | Что | Как ведёт себя | |---|---| | `AGC_GAIN` у приёмника > 0 | AGC-T в ewsdr один на приёмный тракт, у слайса своего нет. Команда от имени доп. приёмника **игнорируется** (раньше молча правила главный), в ответ уходит текущее значение | +| `DDS` у приёмника-слайса | только читается (центр его панорамы): панорама под слайсом общая, и увести её по просьбе одного клиента значит утащить соседей по пану и картинку оператора. Слайсу двигаться незачем — за окном DDC следит `TuneSliceInBand` | +| Панорама без слайсов | в TCI не видна вовсе: приёмник = слайс. Слушать там нечего, но и IQ такой панорамы клиенту недоступен | | Цвет спота (`SPOT`, arg4 ARGB) | не читается: `TDXSpot` цвета не хранит, подписи красятся по моде/возрасту | -| `KEYER`, `TX_FOOTSWITCH` | не реализованы: своего ключа-уведомления и опроса педали наружу у контроллера нет | -| Мода, фильтр, АРУ, шумодавы у канала B доп. пана | в TCI это свойства **приёмника**, а не канала: они относятся к каналу A. У канала B по протоколу есть только частота, IF и громкость | +| `KEYER`, `TX_FOOTSWITCH` | не реализованы, причины разные — см. ниже под таблицей | +| Канал B | есть только у приёмника 0 (VFO B). У приёмника-слайса канал один: слайс — это и есть «приёмник» целиком, со своими модой, фильтром, АРУ, шумодавами и потоками | | Захват параметра (§3.5) | реализован для того, что клиенты действительно перетягивают (частота, DDS, мода, фильтр, TRX/TUNE/DRIVE, split, громкости, АРУ, шумодавы, squelch, скорость CW). Эхо-параметры (RIT/XIT, BIN/ANC/…) не захватываются: на радио они не влияют | | Браузерные клиенты | отвергаются по `Origin` (403), см. §1.1. Web-интерфейсу ewsdr TCI не нужен — у него свой канал | -| `TRX_COUNT` | равен `BackendCaps.MaxPans`, а не числу живых панов: протокол объявляет его один раз. Про несуществующий приёмник просто ничего не шлётся (§2.1) | +| `TRX_COUNT` | равен потолку 1 + `MAX_SLICES` = 7, а не числу живых приёмников: протокол объявляет его один раз. Про несуществующий приёмник просто ничего не шлётся (§2.1) | | Потоки на передаче | RX-аудио и линейный выход на TX замолкают — движок не зовёт аудио-колбэки, пока идёт передача (кроме дуплекса с самоконтролем). То же и с IQ: RX-пакеты на TX дропаются на входе DSP. Это поведение приёмного тракта, а не потоков | | `MUTE` и линейный выход | глушит и поток: движок под мьютом не зовёт `OnAudio`. Аудиопоток приёмника (`AUDIO_START`) мьют не трогает — он снимается до громкости | -| DIGL/DIGU, 2 канала | по §3.4 в цифровых модах два канала должны нести комплексный сигнал; у нас это обычное стерео с выхода WDSP. Комплексный вывод демодулятора наружу не выведен | -| Поток канала B доп. пана | не бывает: в протоколе аудиопоток один на приёмник, и мы отдаём канал A (первый слайс) | +| DIGL/DIGU, 2 канала | по §3.4 в цифровых модах два канала должны нести комплексный сигнал; у нас это обычное стерео с выхода WDSP — оба тапа стоят там, где сигнал уже вещественный: `rakDemod` сразу за демодулятором, `rakLineOut` за громкостью. Комплексный выход потребовал бы отдельной ветки панели RXA и третьего маршрута тапа, а цифровые клиенты берут `IQ_START` — он честно комплексный | +| Поток канала B | не бывает: в протоколе аудиопоток один на приёмник, и он всегда про канал A | | Формат IQ | всегда float32, два канала — `AUDIO_STREAM_SAMPLE_TYPE` относится к аудио (§4.3), а ExpertSDR3 IQ иначе и не шлёт | | `IQ_SAMPLERATE` 384 кГц на Pluto 576/960 кГц | нацело не делится, поэтому уходит 192 кГц (см. §2.5). Клиент обязан читать частоту из заголовка блока, а не считать её равной запрошенной | -| MP3 у рекордера | не поддержан (кодера в проекте нет): `LINE_OUT_RECORDER_SAVE` с `.mp3` отвечает `tci_error` | +| MP3 у рекордера | не поддержан: кодера в проекте нет, а тащить внешний (lame) ради рекордера — это новая зависимость и её лицензия в сборке, которых у ewsdr сейчас нигде нет. WAV пишется без потерь и открывается всем; на `.mp3` уходит честный `tci_error`, а не молчаливый WAV с чужим расширением | + +**`KEYER` и `TX_FOOTSWITCH` — почему их нет.** Команды противоположные по +направлению, и мешать их в один пункт «не сделано» неправильно. + +`KEYER` шлёт **клиент**: это его «клоподав», прокинутый к нам, и третий +аргумент — длительность предыдущего знака — существует ровно затем, чтобы +телеграфное ядро воспроизвело тайминг точно. У нас несущую в CW даёт либо +прошивка (бит CWX / аппаратный кейер), либо программный кейер для Pluto, и оба +берут фронты **как пришли**. Прокинуть в них фронты из WebSocket значит +отправить в эфир сетевой джиттер, а `arg3` подставить некуда: команды «сыграй +знак длиной 142 мс» у openHPSDR нет. Точка входа готова (`CWXKeyEvent`), но +делать это надо вместе с очередью знаков по `arg3` — и после того, как весь +передающий тракт TCI пройдёт живого клиента (он ещё не проверялся). + +`TX_FOOTSWITCH` шлёт **сервер**, и это просто не выведено: флаг у контроллера +уже есть — `HPS_PTT` из HP-статуса ловится по фронту (`FHPpHWPTT` → +`FHWPTTActive`), — но события наружу нет. Плюс две оговорки, из-за которых +уведомление было бы полуправдой: бит не отличает педаль от тангенты микрофона, +а у Pluto такой линии нет вовсе. Работы тут — новый `rf`-эвент контроллера и +строчка в адаптере. Отдельно: у `TX_SENSORS` второй аргумент — уровень микрофона; измерителя микрофона в EWSDR нет, шлём нижнюю границу шкалы (-60 дБм), чтобы клиент не @@ -548,13 +693,24 @@ ExpertSDR3 давно бы не было. Бинарные потоки (§3.4) реализованы целиком — см. §2.5. Открыто: 1. **`RX_CHANNEL_ENABLE` как реальное создание/удаление второго слайса пана.** - Сейчас это эхо: канал B главного приёмника — VFO B, он есть всегда, а - заводить слайс по команде клиента значит отдать ему управление раскладкой - панорамы оператора. -2. **`KEYER`** — своего события «ключ нажат» у контроллера нет. + Сейчас это эхо, и причин две. Первая техническая: слайс **заводит MainForm** + (`AddSliceAtFreq` + `CreateSliceFlag` — флаг, раскладка, персист), у + контроллера есть только `RemoveSlice`. Адаптер TCI, как и CAT, до MainForm + не дотягивается и не имеет права: он обязан собираться в демоне, где LCL + нет вовсе. Вторая по существу: канал B у ExpertSDR3 — второй приёмник внутри + того же DDC, а у нас слайс — полноценная сущность со своим флагом, звуком, + входом и CAT-портом. Клиент, «выключивший канал B», снёс бы оператору + рабочий слайс вместе с его модой, фильтром и портом. Правильный порядок: + сперва завести создание слайса в контроллер (от этого выиграет и демон), и + только потом привязать к нему команду. +2. **`KEYER`** — см. разбор под таблицей §3.1: нужна очередь знаков по `arg3`, + а не проброс сетевых фронтов в ключ. 3. **TCI в демоне.** Юниты LCL-free (стенд собирает и гоняет их вместе с `TRadioController` без единого виджета), подключается одной строкой в - `ewsdrd.lpr`, как web. + `ewsdrd.lpr`, как web. Не сделано намеренно и в этом порядке: в headless + сперва попадает то, что уже прошло живого клиента в GUI, где отказ виден + глазами. Цена ошибки в демоне выше — грабля с `SIGPIPE` (см. конец §5) + убивала именно `ewsdrd`, целиком и молча. 4. **Проверка на железе и с настоящим клиентом** — главное, см. конец §5. ## 5. Проверено @@ -617,7 +773,7 @@ ExpertSDR3 давно бы не было. движков и сети валится с AV — клиент получает `tci_error`, соединение живо) и неразрывность пачки инициализации под крутящейся ручкой. -### Стенд этапа 2 (бинарные потоки) — 112 проверок, все зелёные +### Стенд этапа 2 (бинарные потоки) — 158 проверок, все зелёные Отдельная программа (`test/tci/tcitest.pas`, прогон — `test/tci/run.sh`, внешних библиотек не требует) проверяет потоки на четырёх уровнях: @@ -646,12 +802,25 @@ ExpertSDR3 давно бы не было. сыром сокете): отказ на несуществующий приёмник и на нечисловой аргумент, отказ на старт потока с незапущенного пана, подтверждение и отбраковка параметров, `SAVE` без записи и `SAVE` в `.mp3` отвечают ошибкой, - `TRX:0,true,tci` без аудиопотока модуляцию не берёт, а с потоком берёт и - снимает её по `TRX:0,false` и по уходу клиента; чужой бинарный блок не рвёт - соединение; без передачи маркеров `TX_CHRONO` нет. Отдельно — согласование + `TRX:0,true,tci` без аудиопотока модуляцию не берёт, а с потоком берёт, + реально поднимает передачу и снимает её по `TRX:0,false` и по уходу клиента; + чужой бинарный блок не рвёт соединение; без передачи маркеров `TX_CHRONO` + нет. Номер передатчика (§2.7): `trx:9`, `trx:abc` и `tune:9` отвечают ошибкой + и **не поднимают эфир**, приёмник без живого пана — тоже; ответ называет + передающий приёмник; `TUNE` через TCI включается и гасится, не оставляя за + собой ни тона, ни поднятой PTT. Чужая передача: поверх MOX оператора `TRX` + не уводит микрофон и не снимает передачу, `TUNE` не включает тон, а уход + такого клиента передачу оператора не гасит. Конкурирующий TCI-клиент + не получает `TX_CHRONO` чужой передачи, а его уход не снимает эфир + первого клиента. Отдельно — согласование частот: на источнике 192 кГц просьба «384» подтверждается как 192, смена rate устройства сама переобъявляет и `iq_samplerate`, и `if_limits`, а на 576 кГц (Pluto) та же просьба даёт законные 192 кГц. +- **Слайс как приёмник** (часть E, живой движок): созданный на ГЛАВНОМ пане + слайс становится приёмником 1, отвечает на `vfo:1,0;` своей частотой (это и + есть вся инициализация MSHV), слушается командой `vfo:1,0,<Гц>`, отдаёт своё + аудио блоками с `receiver = 1`, а после удаления слайса приёмник 1 замолкает + целиком — вместо прежнего `vfo:1,0,0`. - **Сквозной прогон через живой WDSP:** синтетический 24-битный IQ подаётся в движок, а клиент по WebSocket получает блоки RX-аудио 12 кГц и IQ 48 кГц с верными заголовками; после `AUDIO_STOP`/`IQ_STOP` блоки прекращаются. diff --git a/test/tci/README.md b/test/tci/README.md index 0d83f35..7ca62bc 100644 --- a/test/tci/README.md +++ b/test/tci/README.md @@ -45,3 +45,28 @@ python3 -u test/tci/live_rx_test.py --visible --commands-only --dwell 1.5 line-out для всех обнаруженных приёмников. Он никогда не посылает `START`, `STOP`, установку `TRX`/`TUNE`, TX-аудио и CW-текст. Если радио уже на передаче, прогон аварийно прекращается. + +## Стенд «как MSHV» + +```sh +test/tci/mshv_sim.py --rx 0 # в MSHV это «TCI Client rx1» +test/tci/mshv_sim.py --rx 1 --audio # «TCI Client rx2» +``` + +`mshv_sim.py` повторяет логику TCI-клиента MSHV по его исходникам — включая обе +особенности, из-за которых он ведёт себя не так, как наш стенд: + +* **фильтр по номеру приёмника** — `if (ls2.at(0)!=tci_trx) continue;`: строка, + адресованная не его приёмнику, для MSHV не существует (и то же самое для + бинарных блоков, по полю `receiver` заголовка); +* **инициализация — один запрос и один ответ**: он шлёт `vfo:,0;` раз в + 500 мс, максимум пять раз, и ждёт `vfo:,0,<частота>`. Не дождался — + «Error To Initialize TCI Server». Других причин у этой ошибки нет. + +Поэтому стенд отвечает на вопрос «почему MSHV не подключается» одной строкой, а +заодно ловит то, что иначе проявляется молча: без `tx_enable` со **своим** +номером приёмника у MSHV не работает PTT (`set_ptt` начинается с +`if (!tci_tx_enable) return;`). + +По умолчанию стенд безопасен на приём: ни `TRX`, ни `TUNE`, ни `START`/`STOP`, +ни TX-аудио он не шлёт. Передачу включает явный `--tx`. diff --git a/test/tci/live_rx_test.py b/test/tci/live_rx_test.py index 3ef88c9..d87aacd 100644 --- a/test/tci/live_rx_test.py +++ b/test/tci/live_rx_test.py @@ -339,6 +339,21 @@ class LiveTest: f"rx_volume:{rx},{ch},{new_vol};", f"rx_volume:{rx},{ch},{old_vol};", dwell) + def visible_vfo_b(self, channels, dwell): + """Move hardware VFO-B; this is not a UI slice on the main pan.""" + rx, ch = 0, 1 + self.ok("TCI VFO-B channel is present", ch in channels.get(rx, []), + str(channels)) + if ch not in channels.get(rx, []): + return + print(f"VFO-B only: +15 kHz for {dwell:.1f}s, then restore") + vfo = self.query(f"vfo:{rx},{ch};", "vfo") + if len(vfo) >= 3: + old_vfo = int(float(vfo[2])) + self.visible_change("VFO-B", "vfo", + f"vfo:{rx},{ch},{old_vfo + 15000};", + f"vfo:{rx},{ch},{old_vfo};", dwell) + def sample_stats(self, payload, fmt, length): data = payload[HDR.size:] count = min(length, 4096) @@ -362,7 +377,8 @@ class LiveTest: rms = math.sqrt(sum(v * v for v in finite) / len(finite)) return rms, max(abs(v) for v in finite), len(finite) == len(vals) - def stream(self, rx, start, stop, wanted_type, requested_length=None): + def stream(self, rx, start, stop, wanted_type, requested_length=None, + expected_rate=None): self.binary.clear() self.text.clear() self.ws.send_text(f"{start}:{rx};") @@ -400,6 +416,8 @@ class LiveTest: if not finite: bad += 1 rms_values.append(rms) + if expected_rate is not None and rates != {expected_rate}: + bad += 1 self.ok(label + " headers", bad == 0, f"blocks={len(blocks)}, bad={bad}, rates={sorted(rates)}") rms = max(rms_values) if rms_values else 0.0 @@ -452,6 +470,28 @@ class LiveTest: finally: self.seconds = old_seconds + def iq_rate_matrix(self, receivers): + """Compare negotiated IQ rate with every live block header.""" + rx = receivers[0] + old_seconds = self.seconds + self.seconds = 0.8 + print(f"IQ rate matrix on rx={rx}") + try: + for requested in (48000, 96000, 192000, 384000): + self.text.clear() + self.ws.send_text(f"iq_samplerate:{requested};") + got, args = self.wait_text("iq_samplerate") + effective = int(args[0]) if got and args else 0 + self.ok(f"IQ rate request {requested}", effective > 0, + got or "no response") + if effective > 0: + self.stream(rx, "iq_start", "iq_stop", 0, + expected_rate=effective) + finally: + self.ws.send_text("iq_samplerate:48000;") + self.wait_text("iq_samplerate") + self.seconds = old_seconds + def main(): ap = argparse.ArgumentParser() @@ -465,10 +505,16 @@ def main(): help="skip command matrix and capture only IQ/audio") ap.add_argument("--format-matrix", action="store_true", help="test every audio sample type/channel count and rate") + ap.add_argument("--iq-rate-matrix", action="store_true", + help="test negotiated IQ rates against binary headers") ap.add_argument("--visible", action="store_true", help="visibly change RX parameters and restore every value") + ap.add_argument("--vfo-b-visible", action="store_true", + help="move hardware VFO-B by 15 kHz and restore it") ap.add_argument("--dwell", type=float, default=1.5, help="seconds to leave each visible test value active") + ap.add_argument("--expect-channels", type=int, default=0, + help="require this exact total TCI channel count") args = ap.parse_args() print(f"Connecting to ws://{args.host}:{args.port} (RX-only safety mode)") @@ -491,16 +537,27 @@ def main(): receivers = sorted({int(row[0]) for row in test.initial_by_name("dds") if row}) print("Discovered receivers/channels:", receivers, channels) test.ok("live receivers found", bool(receivers), str(receivers)) - test.ok("three slices/channels visible", sum(map(len, channels.values())) >= 3, - str(channels)) + slice_count = sum(map(len, channels.values())) + if args.expect_channels > 0: + test.ok(f"expected {args.expect_channels} TCI channels visible", + slice_count == args.expect_channels, str(channels)) + else: + test.ok("at least one slice/channel visible", slice_count > 0, + str(channels)) - if args.format_matrix: + if args.vfo_b_visible: + test.visible_vfo_b(channels, args.dwell) + elif args.iq_rate_matrix: + test.iq_rate_matrix(receivers) + elif args.format_matrix: test.format_matrix(receivers) elif args.visible: test.visible_checks(receivers, channels, args.dwell) elif not args.streams_only: test.command_checks(receivers, channels) - if not args.commands_only and not args.visible and not args.format_matrix: + if (not args.commands_only and not args.visible and + not args.vfo_b_visible and + not args.format_matrix and not args.iq_rate_matrix): test.stream_checks(receivers) print("Skipped by RF-safety policy: START, STOP, TRX/TUNE setters, TX audio, " "CW text/keying, SPOT_CLEAR and SET_IN_FOCUS") diff --git a/test/tci/mshv_sim.py b/test/tci/mshv_sim.py new file mode 100755 index 0000000..6ecd15d --- /dev/null +++ b/test/tci/mshv_sim.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +"""Стенд «как MSHV»: повторяет TCI-клиента MSHV по его исходникам и говорит, +почему он не подключается. + +Зачем: MSHV — самый распространённый TCI-клиент для цифры, и ведёт он себя не +так, как наш собственный стенд. Две его особенности определяют всё: + + 1. ВСЕ входящие строки он фильтрует по номеру приёмника: + if (ls2.at(0)!=tci_trx) continue; // network.cpp + то есть клиенту в режиме RX2 (tci_trx = "1") строка `trx:0,false` + не существует в природе. + + 2. Инициализация — это ожидание ОДНОГО ответа. Он шлёт `vfo:,0;` раз в + 500 мс, максимум 5 попыток, и ждёт `vfo:,0,<частота>`. Не дождался — + «Error To Initialize TCI Server» (network.cpp, Network::initAll, + ветка s_ModelID==3||4). Никаких других причин у этой ошибки нет. + +Соответствие настроек MSHV: «TCI Client rx1» → приёмник 0, «TCI Client rx2» → +приёмник 1 (hvrigcontrol.cpp: netServPort[3]/[4], network.cpp: tci_trx). + +Стенд по умолчанию БЕЗОПАСЕН на приём: ни TRX, ни TUNE, ни START/STOP, ни +TX-аудио не отправляются. Передающую часть включает явный --tx. + +Запуск (приложение уже работает, TCI-сервер поднят): + test/tci/mshv_sim.py --rx 1 + test/tci/mshv_sim.py --rx 0 --audio +Только стандартная библиотека; транспорт переиспользуется из live_rx_test.py. +""" + +import argparse +import os +import struct +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from live_rx_test import WS, split_commands, parse_command # noqa: E402 + +HDR = struct.Struct("<16I") + +# Умолчания MSHV из его же диалога настроек (hvrigcontrol.cpp): каналов 2, +# сэмплов 2048, тип float32, частота 48 кГц, буфер TX 24 мс. +MSHV_CHANNELS = "2" +MSHV_SAMPLES = "2048" +MSHV_TYPE = "float32" +MSHV_RATE = "48000" +MSHV_TXBUF = "24" + + +class Verdict: + def __init__(self): + self.ok_count = 0 + self.fail_count = 0 + + def ok(self, label, detail=""): + self.ok_count += 1 + print(f" ok {label}" + (f" — {detail}" if detail else "")) + + def fail(self, label, detail=""): + self.fail_count += 1 + print(f" FAIL {label}" + (f" — {detail}" if detail else "")) + + def note(self, label, detail=""): + print(f" .. {label}" + (f" — {detail}" if detail else "")) + + +class MshvClient: + """Ровно та логика, что в Network::* у MSHV — включая её ограничения.""" + + def __init__(self, ws, rx, verdict): + self.ws = ws + self.rx = str(rx) + self.v = verdict + # Состояние, которое MSHV ведёт по ФИЛЬТРОВАННОМУ потоку строк. + self.tx_enable = False + self.rx_enable = False + self.rx_mute = False + self.split_enable = False + self.drive = 0 + self.is_trx = False + self.vfo_hz = None + self.mode = None + self.device = "NONE" + self.vendor = "ExpertSDR3" + self.proto_id = 0 # 0, 1 = 1.5.0 и выше, 2 = 1.9.0 и выше + self.audio_blocks = 0 + self.audio_foreign = 0 # блоки чужих приёмников (MSHV их выбрасывает) + self.seen_for_me = [] # что вообще дошло до логики MSHV + self.seen_all = [] + + # ── приём ──────────────────────────────────────────────────────────── + def pump(self, seconds): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + frame = self.ws.recv_frame(min(0.2, deadline - time.monotonic())) + if frame is None: + continue + opcode, payload = frame + if opcode == 1: + for command in split_commands(payload.decode("utf-8", "replace")): + self.on_text(command) + elif opcode == 2: + self.on_binary(payload) + + def on_text(self, command): + name, args = parse_command(command) + self.seen_all.append(command) + args = args + ["", "", ""] + + # protocol разбирается ДО фильтра по приёмнику (так же у MSHV). + if name == "protocol": + self.vendor = args[0] + parts = (args[1].split(".") + ["0", "0", "0"])[:3] + v0, v1, v2 = (int(p) if p.isdigit() else 0 for p in parts) + self.proto_id = 2 + if v0 < 2 and v1 < 9 and v2 < 1000: + self.proto_id = 1 + if v0 < 2 and v1 < 5 and v2 < 1000: + self.proto_id = 0 + return + if name == "device": + self.device = args[0] + return + + # ★Фильтр MSHV: всё, адресованное не нашему приёмнику, не существует. + if args[0] != self.rx: + return + self.seen_for_me.append(command) + + if name == "vfo" and args[1] == "0": + self.vfo_hz = args[2] + elif name == "modulation": + self.mode = args[1] + elif name == "trx": + self.is_trx = args[1] == "true" + elif name == "tx_enable": + self.tx_enable = args[1] == "true" + elif name == "rx_enable": + self.rx_enable = args[1] == "true" + elif name == "rx_mute": + self.rx_mute = args[1] == "true" + elif name == "split_enable": + self.split_enable = args[1] == "true" + elif name == "drive": + self.drive = int(args[1]) if args[1].lstrip("-").isdigit() else 0 + + def on_binary(self, payload): + if len(payload) < HDR.size: + return + head = HDR.unpack(payload[:HDR.size]) + # MSHV: if (pStream->receiver != tci_trx) return; + if head[0] != int(self.rx): + self.audio_foreign += 1 + return + self.audio_blocks += 1 + + # ── инициализация: единственная причина «Error To Initialize» ──────── + def initialize(self): + for attempt in range(1, 6): + self.ws.send_text(f"vfo:{self.rx},0;") + self.pump(0.5) + if self.vfo_hz is not None: + self.v.ok(f"инициализация прошла с попытки {attempt}", + f"vfo:{self.rx},0,{self.vfo_hz}") + return True + self.v.note(f"попытка {attempt}: ответа vfo:{self.rx},0,… нет") + self.v.fail("Error To Initialize TCI Server", + f"за 5 попыток не пришло vfo:{self.rx},0,<частота> — " + f"приёмника {self.rx} для сервера не существует") + return False + + # ── то, что MSHV делает сразу после успешной инициализации ─────────── + def after_connect(self): + if not self.tx_enable: + self.ws.send_text(f"tx_enable:{self.rx},true;") + if not self.rx_enable: + self.ws.send_text(f"rx_enable:{self.rx},true;") + if self.rx_mute: + self.ws.send_text(f"rx_mute:{self.rx},false;") + if self.split_enable: + self.ws.send_text(f"split_enable:{self.rx},false;") + if self.drive <= 0: + self.ws.send_text(f"drive:{self.rx},25;") + self.pump(1.0) + + def start_audio(self): + self.ws.send_text(f"audio_samplerate:{MSHV_RATE};") + if self.proto_id > 1: + self.ws.send_text(f"audio_stream_sample_type:{MSHV_TYPE};") + self.ws.send_text(f"audio_stream_channels:{MSHV_CHANNELS};") + self.ws.send_text(f"audio_stream_samples:{MSHV_SAMPLES};") + self.ws.send_text(f"tx_stream_audio_buffering:{MSHV_TXBUF};") + self.ws.send_text(f"audio_start:{self.rx};") + + def stop_audio(self): + self.ws.send_text(f"audio_stop:{self.rx};") + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--host", default="127.0.0.1") + ap.add_argument("--port", type=int, default=40001) + ap.add_argument("--rx", type=int, default=0, + help="приёмник TCI: 0 = «TCI Client rx1» в MSHV, 1 = «rx2»") + ap.add_argument("--audio", action="store_true", + help="запросить аудиопоток, как это делает MSHV") + ap.add_argument("--seconds", type=float, default=3.0, + help="сколько слушать поток") + ap.add_argument("--tx", action="store_true", + help="★ВКЛЮЧАЕТ ПЕРЕДАЧУ: короткий trx:,true,tci") + args = ap.parse_args() + + v = Verdict() + print(f"Стенд MSHV: приёмник {args.rx} " + f"(в MSHV это «TCI Client rx{args.rx + 1}»)") + try: + ws = WS(args.host, args.port) + except Exception as exc: + v.fail("подключение к TCI-серверу", str(exc)) + print(f"\nИтого: {v.ok_count} ok, {v.fail_count} FAIL") + return 1 + + client = MshvClient(ws, args.rx, v) + try: + # Пачка инициализации приходит сама; MSHV её тоже читает. + client.pump(1.5) + v.ok("пачка инициализации получена", + f"{client.vendor} {client.device}, protocol id {client.proto_id}") + + if not client.initialize(): + return 1 + + client.after_connect() + + # Дальше — то, без чего MSHV работает молча неправильно. + if client.tx_enable: + v.ok(f"tx_enable для приёмника {args.rx} получен") + else: + v.fail(f"tx_enable для приёмника {args.rx} НЕ получен", + "MSHV начинает set_ptt со строки `if (!tci_tx_enable) return;` " + "— PTT будет молча мёртв") + if client.mode: + v.ok("модуляция известна", client.mode) + else: + v.fail("modulation для этого приёмника не приходила", + "MSHV не узнает вид связи") + + if args.audio: + client.start_audio() + client.pump(args.seconds) + if client.audio_blocks > 0: + v.ok("аудиопоток идёт", f"{client.audio_blocks} блоков") + else: + v.fail("блоков аудио нет", + f"чужих (не нашего приёмника) отброшено: {client.audio_foreign}") + client.stop_audio() + client.pump(0.3) + + if args.tx: + print(" .. ★передача: trx:%d,true,tci" % args.rx) + client.ws.send_text(f"trx:{args.rx},true,tci;") + client.pump(0.7) + if client.is_trx: + v.ok("MSHV увидел бы себя в эфире", f"trx:{args.rx},true") + else: + v.fail("подтверждения передачи для своего приёмника нет", + "MSHV фильтрует ответ по номеру приёмника: " + "строку с чужим номером он не увидит") + client.ws.send_text(f"trx:{args.rx},false;") + client.pump(0.5) + + print(f"\nСтрок, дошедших до логики MSHV: {len(client.seen_for_me)} " + f"из {len(client.seen_all)} присланных") + finally: + ws.close() + + print(f"\nИтого: {v.ok_count} ok, {v.fail_count} FAIL") + return 1 if v.fail_count else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/tci/tcitest.pas b/test/tci/tcitest.pas index 8805841..4e401f6 100644 --- a/test/tci/tcitest.pas +++ b/test/tci/tcitest.pas @@ -997,11 +997,115 @@ begin C.SendText('audio_start:0;'); C.SendText('trx:0,true,tci;'); - C.WaitText('trx:', 1000); + C.Pump(300); Check('trx tci с потоком берёт модуляцию', Ctrl.TCIMicRequested); + // ★Проверяем и сам эфир, а не только флаг источника: раньше SetMOX по этой + // дороге падал в Access violation (SyncCWKeyer → SendDUCSpecificFromSettings + // при FNetwork = nil), ошибку глотал обработчик команды, и «передача» жила + // только в ответе клиенту. + Check('trx:0,true поднял передачу', Ctrl.FTransmitting); + + // Второй TCI-клиент не должен перехватывать ни аудио, ни + // владение уже идущей TCI-передачей. Ждём истечения hold, + // иначе вторая команда будет отклонена ещё до этой проверки. + C2 := TRawClient.Create; + try + Check('конкурирующий клиент подключился', C2.Connect(PORT)); + Check('конкурирующему пришёл ready', C2.WaitText('ready;', 2000) <> ''); + C2.SendText('audio_start:0;'); + Sleep(300); + C2.SendText('trx:0,true,tci;'); + C2.Pump(300); + GotBinary := False; + while C2.NextFrame(Op, Pay) do + if Op = $02 then GotBinary := True; + Check('конкурирующий клиент не получил TX_CHRONO', not GotBinary); + C2.Close_; + Sleep(600); + Check('уход второго TCI-клиента не снял эфир первого', + Ctrl.FTransmitting); + Check('после ухода второго остался один клиент', + Ad.ClientCount = 1, IntToStr(Ad.ClientCount)); + finally + C2.Close_; + C2.Free; + end; + C.SendText('trx:0,false;'); - C.WaitText('trx:', 1000); + C.Pump(300); Check('снятие TRX снимает источник', not Ctrl.TCIMicRequested); + Check('снятие TRX сняло передачу', not Ctrl.FTransmitting); + + // ★arg1 у TRX/TUNE — номер передатчика, и он обязан разбираться, как всюду: + // раньше он игнорировался целиком, и клиент доп. приёмника уводил в эфир + // слайс оператора (чужая частота, а с кросс-бандом — чужой диапазон). + C.SendText('trx:9,true;'); + S := C.WaitText('tci_error', 1500); + Check('trx:9 → ошибка, а не эфир', Pos('bad receiver', S) > 0, S); + Check('trx:9 не поднял передачу', not Ctrl.FTransmitting); + + C.SendText('trx:abc,true;'); + S := C.WaitText('tci_error', 1500); + Check('trx:abc → ошибка', Pos('bad receiver', S) > 0, S); + Check('trx:abc не поднял передачу', not Ctrl.FTransmitting); + + // Приёмник 1 без подключённого радио: либо вне потолка, либо не запущен — + // но в эфир по нему уходить нельзя ни в каком случае. + C.SendText('trx:1,true;'); + S := C.WaitText('tci_error', 1500); + Check('trx на несуществующий приёмник → ошибка', + (Pos('not running', S) > 0) or (Pos('bad receiver', S) > 0), S); + Check('trx на несуществующий приёмник не поднял передачу', + not Ctrl.FTransmitting); + + C.SendText('tune:9,true;'); + S := C.WaitText('tci_error', 1500); + Check('tune:9 → ошибка', Pos('bad receiver', S) > 0, S); + Check('tune:9 не включил TUN', not Ctrl.FTuning); + + // Ответ называет приёмник, чей слайс реально в эфире. Без радио источник + // передачи — главный VFO, то есть 0. + C.SendText('trx:0;'); + S := C.WaitText('trx:', 1000); + Check('ответ trx называет передающий приёмник', Pos('trx:0,', S) > 0, S); + C.SendText('tune:0;'); + S := C.WaitText('tune:', 1000); + Check('ответ tune называет передающий приёмник', Pos('tune:0,', S) > 0, S); + + // TUN через TCI: включается и гасится, и снятие возвращает всё на место. + // Ждём временем, а не WaitText: на каждую смену состояния уходит ДВА + // сообщения (ответ автору + рассылка по rfTuning), и WaitText поймал бы + // хвост предыдущей команды, не дождавшись текущей. + C.SendText('tune:0,true;'); + C.Pump(300); + Check('tune:0,true включил TUN', Ctrl.FTuning); + C.SendText('tune:0,false;'); + C.Pump(300); + Check('tune:0,false выключил TUN', not Ctrl.FTuning); + Check('после TUN передача снята', not Ctrl.FTransmitting); + + // ★Передача, начатая ОПЕРАТОРОМ: клиент не вправе ни увести из неё + // микрофон, ни подмешать в неё настроечный тон. SetMOX(True) поверх уже + // идущей передачи не выходит рано — он заново выбирает источник модуляции, + // и «trx:0,true,tci» посреди чужой передачи уводил её в ринг TCI, не + // становясь при этом хозяином эфира. + // Пауза перед командой обязательна: смену состояния оператором адаптер + // тоже захватывает (§3.5, 200 мс), и без неё команду отклонили бы по + // совсем другой причине — проверка прошла бы вхолостую. + Ctrl.SetMOX(True); + Check('оператор в эфире', Ctrl.FTransmitting); + Sleep(300); + C.SendText('trx:0,true,tci;'); + C.Pump(300); + Check('trx поверх чужой передачи не уводит микрофон', + not Ctrl.TCIMicRequested); + Check('trx поверх чужой передачи её не трогает', Ctrl.FTransmitting); + Sleep(300); + C.SendText('tune:0,true;'); + C.Pump(300); + Check('tune поверх чужой передачи не включает тон', not Ctrl.FTuning); + Ctrl.SetMOX(False); + Check('передачу оператора снимает оператор', not Ctrl.FTransmitting); // Бинарный блок не нашего типа обязан быть проигнорирован, а соединение — // остаться рабочим (клиент шлёт их пачками, рвать связь нельзя). @@ -1019,6 +1123,13 @@ begin if Op = $02 then GotBinary := True; Check('без передачи маркеров нет', not GotBinary); + // ★Уход клиента снимает только ЕГО передачу. Ставим в эфир оператора, + // клиент безуспешно просит TRX поверх — и уходит: передача обязана остаться. + Ctrl.SetMOX(True); + Sleep(300); + C.SendText('trx:0,true,tci;'); + C.Pump(300); + // ★Обычный TCP-разрыв БЕЗ close-кадра: так уходит и упавший клиент, и // выдернутый кабель. recv отдаёт 0, и это EOF, а не таймаут — errno при // нём не трогается и вполне может нести EAGAIN от прошлого истёкшего @@ -1026,6 +1137,8 @@ begin // после нескольких аварийных отключений новые клиенты не подключались. C.Close_; Sleep(300); + Check('уход клиента не снял чужую передачу', Ctrl.FTransmitting); + Ctrl.SetMOX(False); Check('после ухода клиента источник снят', not Ctrl.TCIMicRequested); Check('TCP-разрыв без close-кадра освобождает слот', Ad.ClientCount = 0, IntToStr(Ad.ClientCount)); @@ -1083,6 +1196,9 @@ var Pay: TBytes; H: TTCIStreamHeader; AudioBlocks, IQBlocks, BadHdr: Integer; + SliceId: Integer; + SV: TSliceView; + S: string; begin WriteLn('E. Сквозной прогон через движок'); @@ -1183,6 +1299,64 @@ begin Check('сквозной: блоки IQ пришли', IQBlocks > 0, IntToStr(IQBlocks)); Check('сквозной: заголовки верны', BadHdr = 0, IntToStr(BadHdr)); + // ── ★Слайс главного пана = приёмник 1 ──────────────────────────────── + // Ровно случай Pluto: панов больше одного там не бывает, и «второй + // приёмник» существует только как слайс. Приёмник = слот слайса, поэтому + // первый созданный слайс (слот 0, буква B) обязан стать приёмником 1 — + // единственным номером сверх нулевого, который умеют клиенты вроде MSHV. + SliceId := Ctrl.AddSlice(Ctrl.FCenterFreq + 3000, MODE_USB, 200, 2800, + agcMedium, 0.5, -1, '', 0); + Check('слайс на главном пане создан', SliceId > 0, IntToStr(SliceId)); + C.Pump(300); + while C.NextFrame(Op, Pay) do ; // выгребаем рассылку о появлении + + // Инициализация MSHV — это ровно один запрос и ровно один ответ + // (network.cpp, Network::initAll: `vfo:,0;` пять раз, потом + // «Error To Initialize TCI Server»). + C.SendText('vfo:1,0;'); + S := C.WaitText('vfo:1,0,', 1500); + Check('слайс отвечает на vfo:1,0 (инициализация MSHV)', S <> '', S); + Check('слайс отдаёт свою частоту', + Pos(IntToStr(Round(Ctrl.FCenterFreq + 3000)), S) > 0, S); + + // Управление: то, чего от слайса и хотят. + C.SendText('vfo:1,0,' + IntToStr(Round(Ctrl.FCenterFreq + 5000)) + ';'); + C.Pump(300); + Check('слайсом можно управлять по TCI', + Ctrl.GetSliceView(SliceId, SV) and + (Abs(SV.TargetHz - (Ctrl.FCenterFreq + 5000)) < 2), + FloatToStr(SV.TargetHz)); + + // Аудиопоток приёмника 1 — это звук слайса, и в заголовке стоит его номер + // (MSHV отбрасывает блоки с чужим receiver: network.cpp:231). + C.SendText('audio_start:1;'); + C.Pump(100); + for k := 0 to 40 do + begin + Ctrl.FDSPEngine.PushDDCPacket(Pkt, 0, PAIRS); + Sleep(2); + end; + C.Pump(400); + n := 0; + while C.NextFrame(Op, Pay) do + if (Op = $02) and (Length(Pay) >= SizeOf(H)) then + begin + Move(Pay[0], H, SizeOf(H)); + if (H.StreamType = LongWord(Ord(tstRXAudio))) and (H.Receiver = 1) then + Inc(n); + end; + Check('аудио слайса идёт под номером 1', n > 0, IntToStr(n)); + C.SendText('audio_stop:1;'); + C.Pump(200); + + // Удалили слайс — приёмник исчез, и врать про его частоту нельзя. + Ctrl.RemoveSlice(SliceId); + C.Pump(300); + while C.NextFrame(Op, Pay) do ; + C.SendText('vfo:1,0;'); + S := C.WaitText('vfo:1,0,', 800); + Check('после удаления слайса приёмник 1 молчит', S = '', S); + // Остановка потока обязана прекратить подачу. C.SendText('audio_stop:0;'); C.SendText('iq_stop:0;');