# Бинарный протокол LibreVNA: что шлёт и парсит наш драйвер Разбор USB-протокола LibreVNA и того, какое его подмножество использует `python_app/hardware_full/librevna_multi_device_driver`. Про переключение стимула внутри частотной точки — отдельный документ, [per_point_switching.md](per_point_switching.md). Прошивка в репозитории лежит под `device_firmware/multi_libreVNA_firmware/reproduce/LibreVNA/Software/VNA_embedded/Application/`, дальше он сокращён до ``. Версия протокола, заявленная прошивкой — `Protocol::Version = 14` (`/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. Структура кадра Одна и та же почти для всех типов пакетов (`/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 мкс — это основная часть времени кодирования и передачи точки (`/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 — `/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` не включаем, а устройству принудительно указываем использовать внешние 10 МГц. Строит `build_reference_settings_payload` (`protocol.py:48`), отправляет `controller.py`. ### `SweepSettings` (2) Структура — `Protocol.hpp:155`: ```c 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](per_point_switching.md). ### `SetIdle` (20) Без payload. Выключает активный режим и останавливает развёртку. Используется как служебная команда: остановить непрерывную развёртку, убраться при ошибке, `close()`. ### `Ack` (7) / `Nack` (10) Ответы устройства на команды хоста. `wait_for_acknowledgement` (`transport.py:96`) читает входящие пакеты, на `Ack` считает команду применённой, на `Nack` бросает исключение, прочие асинхронные пакеты пропускает. ## 5. `VNADatapoint` (27) — поток измерений Шаблон `VNADatapoint` — `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`). ### Байт дескриптора Смещения битов — `/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`. На каждую стадию приходит ровно три значения (`/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. Почему поле мощности читается, но не используется В парсере стоит: ```python frequency_hz, _power_level_centidecibels_milliwatt, point_index = struct.unpack_from( "