mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 20:27: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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user