feat(tci): приёмник = слот слайса; TRX/TUNE адресуют передатчик; стенд «как MSHV»

Модель приёмников переделана: приёмник TCI — это СЛОТ СЛАЙСА, а не панадаптер.
rx0 — главный тракт (каналы A/B = VFO A/B), rx N — слайс слота N−1, то есть
буквы B..G с флага, на каком бы пане он ни стоял. Панорама стала свойством
приёмника: от неё берутся DDS (у слайса только чтение) и поток IQ.

Почему: у Pluto панорама ровно одна (MaxPans = 1), и второй приёмник там
существует ТОЛЬКО как слайс главного пана — при нумерации по панам он был
недоступен вовсе, а TRX_COUNT навсегда равнялся единице. С другого конца —
клиенты: у MSHV в настройках всего «TCI Client rx1/rx2», то есть приёмники 0 и
1, третьего номера ввести некуда. Со слотами правило «первый созданный слайс =
приёмник 1» держится на любом железе: на openHPSDR слайс второго пана и на
Pluto слайс главного одинаково занимают слот B. Номер совпадает с буквой на
экране и с портом слайс-CAT. Цена: панорама без слайсов из TCI пропала, а
второй слайс пана перестал быть «каналом B» и стал своим приёмником — у канала
B в протоколе только частота, IF и громкость, у приёмника же всё.

TRX/TUNE раньше игнорировали arg1 (номер передатчика) целиком: клиент доп.
приёмника уводил в эфир слайс ОПЕРАТОРА — чужая частота, а с кросс-бандовым
мультислайс-TX и чужой диапазон, с чужими антенной и фильтрами; trx:9,true жал
PTT. Теперь номер разбирается и проверяется, приёмник N > 0 идёт через
RequestSliceTx (та же дверь, что у CAT-порта слайса: «в эфире только один» и
Auto TX), у контроллера появился параметр Tune для TUN тем же путём. Чужую
передачу не трогаем вовсе — ни источник модуляции, ни тон: SetMOX(True) поверх
идущей передачи не выходит рано, а заново выбирает микрофон. Хозяином эфира
клиент становится, только если передача началась именно от его команды, и
решает это Sync-метод в потоке контроллера (снимок «шла ли передача», взятый в
потоке клиента, врал: между разбором и исполнением влезает PTT оператора).

Разбор исходников MSHV (он фильтрует ВСЕ строки и бинарные блоки по номеру
приёмника) дал ещё три правки:
  * ответ на TRX/TUNE адресуется номером АВТОРА, состояние в нём — «в эфире
    именно твой слайс»; в рассылку идёт номер реально передающего;
  * tx_enable рассылается каждому живому приёмнику со своим номером и входит в
    картину нового приёмника — без этого у MSHV молча мёртвая PTT
    (set_ptt начинается с `if (!tci_tx_enable) return;`);
  * про несуществующий приёмник молчим целиком (LiveRx), в том числе на чтение:
    ответ «vfo:1,0,0» MSHV принимал бы за конец инициализации.

Попутно, вне TCI: SendDUCSpecificFromSettings трогала FNetwork без Assigned, а
зовут её по любому PTT/TUN (SetMOX → SyncCWKeyer → она) — до подключения
устройства это была Access violation, у TCI её глотал обработчик команды.

Стенды: новый test/tci/mshv_sim.py — точная копия логики клиента MSHV, отвечает
на вопрос «почему он не подключается» одной строкой (на живом приложении
воспроизвёл ошибку инициализации для rx2 до правки). tcitest — 158/158, в
сквозном прогоне добавлено создание слайса на главном пане: он становится
приёмником 1, отвечает на vfo:1,0, слушается командой, отдаёт аудио с
receiver = 1 и замолкает после удаления.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 22:10:02 +03:00
co-authored by Claude Opus 5
parent dc6f996e70
commit bb48f5d3fa
7 changed files with 1121 additions and 215 deletions
+176 -2
View File
@@ -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:<rx>,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;');