20 KiB
Бинарный протокол LibreVNA: что шлёт и парсит наш драйвер
Разбор USB-протокола LibreVNA и того, какое его подмножество использует
python_app/hardware_full/librevna_multi_device_driver. Про переключение
стимула внутри частотной точки — отдельный документ,
per_point_switching.md.
Прошивка в репозитории лежит под
device_firmware/multi_libreVNA_firmware/reproduce/LibreVNA/Software/VNA_embedded/Application/,
дальше он сокращён до <FW>. Версия протокола, заявленная прошивкой —
Protocol::Version = 14 (<FW>/Communication/Protocol.hpp:13).
1. Транспорт
Поверх USB у LibreVNA свой бинарный протокол, а не tty/serial. Endpoints:
| Endpoint | Направление |
|---|---|
0x01 |
хост -> устройство |
0x81 |
устройство -> хост |
0x82 |
отладочные ASCII-логи |
Наш драйвер шлёт в 0x01, читает из 0x81, 0x82 не использует. Приёмный
поток и очередь пакетов — в librevna_multi_device_driver/transport.py.
Очередь принятых пакетов ограничена и работает по принципу drop-oldest:
блокировать USB-поток чтения нельзя, а устаревшие пакеты всё равно
выбрасываются предсборочным дренажом (transport.py:109).
2. Структура кадра
Одна и та же почти для всех типов пакетов
(<FW>/Communication/PacketConstants.h):
| Поле | Длина | Значение |
|---|---|---|
| Header | 1 | всегда 0x5A |
| Length | 2 | длина всего кадра, little-endian |
| Type | 1 | номер типа пакета |
| Payload | переменная | |
| CRC32 | 4 | по всему кадру, включая header |
Итого накладных расходов 8 байт: длина кадра = 8 + len(payload).
Кодирование и разбор на хосте — в общем модуле одиночного драйвера, а не в
multi-device: python_app/hardware_full/librevna_driver/protocol/frame.py,
функции encode_frame (строка 25) и decode_frame (строка 49). Поток байт
разбирает FrameScanner, который умеет ресинхронизироваться после битого кадра,
не роняя устройство.
CRC: у VNADatapoint особый случай
Прошивка не считает CRC для VNADatapoint и пишет туда ноль, потому что
расчёт занимает около 18 мкс — это основная часть времени кодирования и
передачи точки (<FW>/Communication/Protocol.cpp:149). На приёме она,
наоборот, требует ровно нуля: непустой CRC у datapoint считается ошибкой
(Protocol.cpp:69 и Protocol.cpp:80).
Наш хост повторяет это поведение точно: decode_frame проверяет CRC32 для всех
типов, а для VNADatapoint требует ноль (frame.py:20 — список исключений).
Дополнительно multi-device парсер отбраковывает пакет, у которого тело не
кратно 9 байтам на значение: раз CRC у datapoint нет, это единственная защита
от ресинхронизации на 0x5A, случайно оказавшийся внутри payload
(librevna_multi_device_driver/protocol.py:137).
3. Типы пакетов
Полный enum — <FW>/Communication/Protocol.hpp:573, там 30+ типов. Наш драйвер
использует шесть (librevna_multi_device_driver/protocol.py:17):
| Тип | Номер | Зачем |
|---|---|---|
SweepSettings |
2 | настройка развёртки, она же её запуск |
Ack |
7 | подтверждение команды |
Nack |
10 | отказ |
Reference |
11 | выбор источника опорной частоты |
SetIdle |
20 | остановить развёртку, перевести в idle |
VNADatapoint |
27 | поток измерений |
То есть это узкий VNA-sweep-драйвер, а не полная реализация всех режимов
прибора. Не используются RequestDeviceInfo, проверка версии протокола,
SetTrigger/ClearTrigger, генератор, анализатор спектра, калибровочные
пакеты.
4. Пакеты, которые шлём
Reference (11)
Структура — Protocol.hpp:186: uint32 ExtRefOuputFreq плюс битовое поле
AutomaticSwitch и UseExternalRef.
Шлём output_frequency_hz = 0 (выход Ref Out не включаем) и UseExternalRef из
multi_device.force_external_reference профиля; AutomaticSwitch всегда 0. Во всех
профилях сейчас false, то есть приборы работают от внутренней опоры.
Строит build_reference_settings_payload (protocol.py:48), отправляет
controller.py.
SweepSettings (2)
Структура — Protocol.hpp:155:
uint64 f_start, f_stop;
uint16 points;
uint32 if_bandwidth;
int16 cdbm_excitation_start;
uint8 standby:1, syncMaster:1, suppressPeaks:1, fixedPowerSetting:1,
logSweep:1, syncMode:2, unused1:1;
uint16 stages:3, port1Stage:3, port2Stage:3, port3Stage:3, port4Stage:3, unused2:1;
int16 cdbm_excitation_stop;
uint16 dwell_time; // в микросекундах
Что кладём (build_sweep_settings_payload, protocol.py:58):
f_start/f_stop/points/if_bandwidth— изSweepConfiguration;- мощность старта и стопа одинаковая,
fixedPowerSetting = 1— аттенюатор и мощность PLL не меняются по развёртке; suppressPeaks = 1,logSweep = 0,standby = 0;syncMode = 3(триггерная синхронизация), если синхронизация включена;syncMaster = 1только у мастера;stages— номер последней стадии, не их количество;- порт-стадии — см. ниже.
Про syncMode = 3: в стоковой прошивке рядом с полем стоит комментарий
«Trigger synchronization (not supported yet by hardware)». В нашем форке этот
режим и используется — устройство переводит обе триггерные линии FPGA в Hi-Z.
Порт-стадии — не то, что было в первой версии драйвера. Раньше мастер
получал port1Stage = 0, а слейв 1. Сейчас:
- мастеру отправляется первая стадия блока порта (
index * substage_count); без под-стадий это просто индекс порта, то есть стоковая раскладка; - приёмным устройствам обе порт-стадии выставляются в
DISABLED_PORT_STAGE = 7(protocol.py:36) — «порт выключен»; - ширина блока под-стадий передаётся как
log2в неиспользуемое полеport3Stage.
Почему именно так и почему семёрка безопасна — в per_point_switching.md.
SetIdle (20)
Без payload. Выключает активный режим и останавливает развёртку. Используется
как служебная команда: остановить непрерывную развёртку, убраться при ошибке,
close().
Ack (7) / Nack (10)
Ответы устройства на команды хоста. wait_for_acknowledgement
(transport.py:96) читает входящие пакеты, на Ack считает команду
применённой, на Nack бросает исключение, прочие асинхронные пакеты
пропускает.
5. VNADatapoint (27) — поток измерений
Шаблон VNADatapoint<s> — Protocol.hpp:25; у прошивки s = 32, то есть до 32
значений в точке (три на стадию при восьми стадиях).
Payload:
| Поле | Тип | Длина |
|---|---|---|
frequency |
uint64 | 8 |
cdBm |
int16 | 2 |
pointNum |
uint16 | 2 |
real_values |
n × float32 | 4n |
imag_values |
n × float32 | 4n |
descr_values |
n × uint8 | n |
Фиксированная часть — 12 байт, дальше по 9 байт на значение, причём не
чередуясь: сначала все реальные части, потом все мнимые, потом все
дескрипторы. Отсюда n = (len(payload) - 12) / 9.
Разбирает parse_vna_datapoint_payload (protocol.py:137).
Байт дескриптора
Смещения битов — <FW>/Communication/PacketConstants.h:42:
| Биты | Значение |
|---|---|
| 0 | порт 1 |
| 1 | порт 2 |
| 2 | порт 3 |
| 3 | порт 4 |
| 4 | опорный приёмник (Reference) |
| 5..7 | номер стадии |
То же отражено в enum class Source (Protocol.hpp:17): Port1 = 0x01,
Port2 = 0x02, Port3 = 0x04, Port4 = 0x08, Reference = 0x10.
Прошивка собирает байт как stage << 5 | sourceMask.
На каждую стадию приходит ровно три значения (<FW>/VNA.cpp:407): порт 1, порт
2 и опорный приёмник. Ловушка: у опорного значения маска не 0x10, а
Port1 | Port2 | Reference = 0x13 — то есть биты портов у него тоже стоят.
Поэтому отбирать сигнальное значение только по биту порта нельзя, надо ещё
требовать сброшенный бит 4; именно так и сделано в find_receiver_value.
Ищет значение по этому байту find_receiver_value (protocol.py:174) —
фильтрует по стадии, по признаку опорного приёмника и, если задан, по порту.
Например: «стадия 0 + ref» — опорный приёмник мастера; «стадия 0 + порт 1, не
ref» — сигнал на первом приёмнике.
6. Как из этого получаются S-параметры
Устройство S-параметры не считает — оно отдаёт только сырые комплексные
отсчёты приёмников. Считает хост, в
librevna_multi_device_driver/cycle_collection.py.
Нормировка (calculate_s_parameters): каждый канал прохождения
делится на опорный приёмник мастера, а не на локальную опору слейва,
потому что излучает мастер. Опорные измерения хранятся по выходному каналу
(master_reference_measurements_by_output): при переключении стимула выходов
больше, чем физических портов мастера.
Отражения мастера приложение не собирает и не использует для отбраковки циклов.
Ключ канала — пара (input_pos, output_pos) с нумерацией от нуля. Входы слейвов
идут подряд: два порта первого слейва, затем два порта второго. Выход равен
стадии мастера. Эти же физические номера используются в ComboKey коллекции.
Одиночный LibreVNA использует Python-драйвер librevna_driver: порт 1 излучает
на стадии 0, порт 2 выключен (port2Stage = 7), port3Stage = 0 означает
отсутствие под-стадий. Хост сохраняет S21 = отсчёт порта 2 / опорный отсчёт
той же стадии. Опора распознаётся по биту Reference даже при маске 0x13;
она никогда не подменяет сигнальный отсчёт из-за одновременно выставленных
битов портов. Физические номера антенных ключей остаются заданными конфигом.
Бюджет ожидания одиночного свипа зависит от числа точек, 1 / IFBW и dwell,
с запасом на установление частоты и обмен по USB. Отдельный тайм-аут отсутствия
новых точек прерывает зависший прибор независимо от общего бюджета свипа.
В multi-device collect_complete_running_sweep_cycle собирает один цикл.
Окно коллекции и всех её трасс берётся по получению от мастера точки 0 и
последней принятой точки этого цикла. Метка точки 0 фиксируется до ожидания
слейвов на барьере синхронизации. Слив очередей, ожидание начала цикла и
повторные попытки сбора в окно не входят.
Формат коллекций между процессами хоста
Это отдельный формат от USB-пакетов прибора. RAW3 и PRP3 — четыре ASCII-байта
magic (0x33574152 и 0x33505250 little-endian). В каждой трассе идут массив
float32 частот и массив complex64 S21: 12 байт на точку. Окна съёма коллекции
и отдельных трасс обязательны: после массивов идут два u64 окна коллекции,
u32 числа трасс и по два u64 на каждую трассу. Усечённый хвост отвергается,
нулевые окна разрешены в бандлах калибровки/референса. Полный размер кадра:
44 + число_пар × (28 + 12 × число_точек) байт. Старые RAW2/PPR2 не принимаются;
runtime-бандлы пересобираются из NPZ при Start. Старые NPZ с лишними S11-массивами
читаются по S21 без изменения файлов. Формат пакетов LibreVNA этим не меняется.
Кадр результата — RSL3 (0x334c5352, ASCII little-endian). Его заголовок
занимает 64 байта, все поля little-endian, без выравнивающих промежутков:
| Смещение | Поле | Тип |
|---|---|---|
| 0 | magic | u32 |
| 4 | collection_id | u64 |
| 12 | monotonic_ns (завершение обработки) | u64 |
| 20 | processing_duration_ns | u64 |
| 28 | settings_revision | u64 |
| 36 | capture_start_ns | u64 |
| 44 | capture_end_ns | u64 |
| 52 | speed_m_s | f32 |
| 56 | число payload коллекции | u32 |
| 60 | число блоков пар | u32 |
После заголовка идут payload'ы коллекции и блоки пар. Блок содержит номера
входа и выхода, число payload'ов (три u32), затем сами payload'ы. Каждый payload
начинается с типа u8 и имени (длина u16 и байты UTF-8); далее идут данные типа:
комплексная трасса, скаляр f32, изображение f32 или таблица f32. Идентичность
свипа на всех стадиях — (collection_id, capture_start_ns), время стадии её
не меняет.
RSL2 отвергается; Python и C++ обновляются одновременно после остановки процессов.
7. Почему поле мощности читается, но не используется
В парсере стоит:
frequency_hz, _power_level_centidecibels_milliwatt, point_index = struct.unpack_from(
"<QhH", payload, 0
)
Поле мощности в payload реально существует и стоит между частотой и номером
точки, поэтому прочитать его необходимо, чтобы правильно достать point_index.
Подчёркивание в имени — обычный питоновский признак «прочитали намеренно, но не
используем».
По смыслу оно не нужно: для сбора циклов достаточно частоты, индекса точки и
массива отсчётов, а S-параметры считаются как отношение сигнала к опоре.
К тому же развёртка идёт с фиксированной мощностью (cdbm_excitation_start == cdbm_excitation_stop, fixedPowerSetting = 1), так что поле лишь дублирует
уже известное из настройки.
Понадобилось бы оно при переменной по частоте мощности, при логировании фактической мощности каждой точки или при проверке, что устройство действительно выдаёт ожидаемый профиль.
8. Чего в драйвере сознательно нет
- запроса
DeviceInfoи проверки версии протокола устройства; - отладочного endpoint
0x82; - пакетов калибровки, генератора, анализатора спектра;
SetTrigger/ClearTrigger— синхронизация настраивается полемsyncModeвSweepSettings, а сама петля триггера аппаратная.
CRC входящих пакетов, в отличие от первой версии драйвера, проверяется — см. раздел 2.
9. Карта модулей хоста
| Файл | Роль |
|---|---|
librevna_driver/protocol/frame.py |
кадр: кодирование, разбор, CRC, ресинхронизация |
librevna_multi_device_driver/protocol.py |
номера пакетов, сборка payload, парсер datapoint, поиск по дескриптору |
librevna_multi_device_driver/transport.py |
USB bulk, приёмный поток, очередь, ожидание Ack |
librevna_multi_device_driver/controller.py |
высокоуровневые команды: настроить развёртку, собрать циклы, остановить |
librevna_multi_device_driver/cycle_collection.py |
сбор точек в полные циклы, нормировка в S-параметры, имена трасс |
librevna_multi_device_driver/models.py |
SweepConfiguration, выходные каналы, ограничения стадий |