#!/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())