mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 20:37:33 +00:00
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:
@@ -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:<rx>,0;` раз в
|
||||
500 мс, максимум пять раз, и ждёт `vfo:<rx>,0,<частота>`. Не дождался —
|
||||
«Error To Initialize TCI Server». Других причин у этой ошибки нет.
|
||||
|
||||
Поэтому стенд отвечает на вопрос «почему MSHV не подключается» одной строкой, а
|
||||
заодно ловит то, что иначе проявляется молча: без `tx_enable` со **своим**
|
||||
номером приёмника у MSHV не работает PTT (`set_ptt` начинается с
|
||||
`if (!tci_tx_enable) return;`).
|
||||
|
||||
По умолчанию стенд безопасен на приём: ни `TRX`, ни `TUNE`, ни `START`/`STOP`,
|
||||
ни TX-аудио он не шлёт. Передачу включает явный `--tx`.
|
||||
|
||||
@@ -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")
|
||||
|
||||
Executable
+283
@@ -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:<rx>,0;` раз в
|
||||
500 мс, максимум 5 попыток, и ждёт `vfo:<rx>,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:<rx>,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())
|
||||
+176
-2
@@ -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;');
|
||||
|
||||
Reference in New Issue
Block a user