321 lines
20 KiB
Markdown
321 lines
20 KiB
Markdown
# Бинарный протокол 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/`,
|
||
дальше он сокращён до `<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` не
|
||
включаем, а устройству принудительно указываем использовать внешние 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<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. Почему поле мощности читается, но не используется
|
||
|
||
В парсере стоит:
|
||
|
||
```python
|
||
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`, выходные каналы, ограничения стадий |
|