Compare commits

..

2 Commits

Author SHA1 Message Date
LittleSam129
0ae04ae4a8 Close the remaining audit findings
Environment, README, gitignore and the source self-parsing in Lab041.

requirements.txt and requirements-hardware.txt pin the versions actually
verified today on Python 3.13.1, split so the project comes up on a
machine with no SDR: pyadi-iio and libiio are only needed from Lab042 on.

The gitignore contradiction is fixed in the direction that keeps the
project reproducible. data/raw/ was ignored while six files were tracked
anyway, and four of them are the source images Lab007, Lab009, Lab011 and
Lab012 read as input. Untracking them would have broken those labs, so
the rule now carries explicit exceptions instead. Recorded in the README:
lab026_rover_source.mp4 is genuinely absent and Lab026 and Lab027 cannot
run without it.

README replaced. It claimed the next task was to write the first protocol
module, 41 labs later. It now states what exists, that the transmitter
has never been switched on, how to install either environment, how to run
tests, the gate and a lab, and that rover/, ground_station/ and gnuradio/
are still empty. Added rule 9: every number in a report must come from
the current run, transferred values must name their source.

Lab041 no longer parses its own source to check that plot labels are in
Russian. That belonged to presentation, not to model correctness, and
turned the experiment into an analyser of its own text; ast, inspect and
re are no longer imported. Rerun: 180 combinations, 24/24 checks, five
CSV and seven PNG, unchanged.

125 tests pass, gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 15:33:14 +03:00
LittleSam129
92289bdf7c Cover control messages and session state; record the RF decision
Two more suites, 69 checks, bringing the fast tests to 125 in 0.3 s.

test_control_messages: roundtrip for all ten message types, a guard that
fails if a type is added without a test, wrong version, unknown type,
truncation, the control-state range rules including negative and
non-finite speed, and sequence comparison across the 32-bit wrap and the
ambiguous 2^31 boundary.

test_session_state: the safety properties that previously only ran inside
Lab041. Safe boot on both sides, status and authorization not being a
movement command, a new operator command being required, stale session,
boot and epoch identifiers, replay, sequence wrap, ambiguity, persisted
emergency intent surviving a restart, acknowledgement not clearing it,
ordinary commands not releasing the latch, idempotent reset, and reset
not restoring the previous command.

Two of these were literal zeros in the Lab041 report and measured
nothing: negative speed and speed above the limit are now genuinely
exercised against the encoder.

PROJECT_LOG entry 018 records the RF architecture decision: 200-250 MHz
with frequency hopping, a directional ground antenna and spread spectrum
for the command channel, with the rejected alternatives and why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 15:29:47 +03:00
9 changed files with 661 additions and 273 deletions

17
.gitignore vendored
View File

@@ -29,9 +29,24 @@ __pycache__/
venv/ venv/
env/ env/
# Необработанные данные и IQ-записи # Необработанные данные и IQ-записи.
# Правило закрывает крупные записи отсчётов и видео: они воспроизводятся
# заново и в историю не помещаются.
data/raw/ data/raw/
# Исключения: исходные изображения — это входные данные лабораторных, а не
# результат. Без них Lab007, Lab009, Lab011 и Lab012 не запускаются, поэтому
# они хранятся в репозитории. Суммарно около 1 МБ.
!data/raw/lab007_source.jpg
!data/raw/lab009_source.jpg
!data/raw/lab011_scene.jpg
!data/raw/lab012_safety_scene.jpg
# Метаданные и спектр записи Lab024a: сама запись отсчётов не хранится,
# поэтому они оставлены как след проведённого измерения.
!data/raw/lab024a_pluto_rx_100mhz.json
!data/raw/lab024a_pluto_rx_100mhz_spectrum.png
# Временные файлы редакторов # Временные файлы редакторов
*.tmp *.tmp
*.temp *.temp

View File

@@ -434,3 +434,57 @@ git version 2.51.1.windows.1
## Следующий этап ## Следующий этап
Криптографическая аутентификация отложена. Сначала выполняется этап стабилизации: быстрые тесты `protocol/` вместо единственной точки входа через лабораторную, разделение быстрых проверок и экспериментов, воспроизводимое описание окружения. Криптографическая аутентификация отложена. Сначала выполняется этап стабилизации: быстрые тесты `protocol/` вместо единственной точки входа через лабораторную, разделение быстрых проверок и экспериментов, воспроизводимое описание окружения.
---
# Запись 018
## Дата
10 августа 2026 года
## Тема
Выбор радиочастотной архитектуры: рабочий диапазон, скачки по частоте и меры против подавления.
## Исходные данные
- Устройство опрошено напрямую: `Analog Devices PlutoSDR Rev.C (Z7010-AD9361)`. Приёмник перестраивается от 70 МГц до 6 ГГц, передатчик от 46,9 МГц. Минимальная частота дискретизации 2 083 333 Гц.
- Ограничений по разрешённым частотам у проекта нет.
- Усилителя мощности нет, покупка отложена до первых испытаний.
## Отвергнутые варианты и причины
**Выбор редкой фиксированной частоты.** Рассматривался как защита от подавления. Отклонён: одиночная несущая в пустом эфире обнаруживается сканирующим приёмником тривиально. Уязвимость создаёт не значение частоты, а её неизменность.
**Скачки по всему диапазону 70 МГц 6 ГГц.** Теоретически требуют от заградительной помехи на 42 дБ больше мощности, но выигрыш недостижим. Отношение крайних частот 86:1, одна антенна такой полосы не перекрывает; разброс потерь на 10 км составляет 39 дБ, проектировать пришлось бы по худшему краю; выше примерно 1 ГГц дифракция исчезает и для наземного ровера без прямой видимости эти каналы мертвы. Кроме того, подавление направлено на диапазон, в котором связь действительно работает, а не на тот, который способно настроить оборудование.
**Диапазоны 330433 МГц.** Отклонены как плотно занятые и общеизвестные.
## Принято
Рабочий диапазон **200250 МГц**. Обоснование: четвертьволновая антенна 33 см пригодна для установки на ровер, дифракция ещё эффективна, участок заметно свободнее любительского 144 МГц и УКВ выше 300 МГц. Потери на 10 км составляют около 99,5 дБ.
Совокупность мер против подавления:
1. **Скачки по частоте в пределах 200250 МГц.** Требуют от заградительной помехи на 22 дБ больше мощности при неизменной антенне и механике ровера.
2. **Направленная антенна наземной станции.** Даёт 1020 дБ усиления и пространственно подавляет помеху, приходящую с иного направления.
3. **Расширение спектра для команд управления.** Поток команд составляет 10,88 кбит/с, поэтому расширение для него реализуемо в узкой полосе, в отличие от видеопотока.
Видео остаётся некритичным классом: при подавлении управление сохраняется в узкой полосе, а ровер переходит к безопасному поведению по уже реализованной логике.
## Запрещённые участки
- 108137 МГц — авиационная связь.
- 243 МГц — международная аварийная частота.
## Ограничения решения
- Нижняя граница 70 МГц определяется приёмником Pluto и без смены оборудования не преодолевается.
- Скачки требуют согласованной последовательности у обеих сторон, то есть зависят от криптографической аутентификации, которая пока не реализована.
- Расчёты выполнены для свободного пространства. Потери на препятствиях 2040 дБ учтены оценочно и подлежат измерению.
- Дальность 10 км без усилителя мощности не достигается.
## Влияние на ближайшие работы
На Lab042 не влияет: там кабельная петля, несущая 435 МГц выбрана произвольно. Диапазон 200250 МГц вступает в силу начиная с эфирных испытаний.

363
README.md
View File

@@ -4,330 +4,169 @@
**SDR Rover Link** — учебно-исследовательский проект по разработке цифрового радиоканала для наземного робототехнического комплекса с использованием программно-определяемого радио — SDR, Software Defined Radio. **SDR Rover Link** — учебно-исследовательский проект по разработке цифрового радиоканала для наземного робототехнического комплекса с использованием программно-определяемого радио — SDR, Software Defined Radio.
Проект должен пройти полный путь от передачи простого текстового пакета до передачи телеметрии и отдельных JPEG-изображений через реальный радиоканал. Проект должен пройти полный путь от передачи простого текстового пакета до передачи телеметрии и изображений через реальный радиоканал.
Основная задача проекта — не только получить работающую систему, но и последовательно изучить все этапы цифровой радиосвязи: Основная задача — не только получить работающую систему, но и последовательно изучить все этапы цифровой радиосвязи: формирование данных, разбиение на пакеты, контроль целостности, исправление ошибок, цифровую модуляцию, формирование IQ-сигнала, передачу через SDR, приём, демодуляцию и восстановление исходных данных.
- формирование данных;
- разбиение данных на пакеты;
- контроль целостности;
- исправление ошибок;
- цифровую модуляцию;
- формирование IQ-сигнала;
- передачу через SDR;
- приём и демодуляцию;
- восстановление исходных данных.
--- ---
## 2. Целевой сценарий ## 2. Целевой сценарий
Разрабатываемая система предназначена для исследовательского наземного ровера.
Целевые требования:
| Параметр | Требование | | Параметр | Требование |
|---|---| |---|---|
| Дальность связи | до 10 км | | Дальность связи | до 10 км |
| Скорость ровера | до 25 км/ч | | Скорость ровера | до 25 км/ч |
| Прямая радиовидимость | может отсутствовать | | Прямая радиовидимость | может отсутствовать |
| Рабочие частоты | ниже 350 МГц | | Рабочий диапазон | 200250 МГц, см. Запись 018 журнала |
| Основной тип данных | телеметрия и отдельные изображения | | Основной тип данных | телеметрия, команды и изображения |
| Формат изображения | JPEG | | Формат изображения | JPEG |
| Частота передачи изображений | отдельные кадры, не видеопоток |
| Действие при потере связи | безопасная остановка ровера | | Действие при потере связи | безопасная остановка ровера |
Дальность 10 км без прямой видимости является целевым исследовательским требованием, а не гарантированной характеристикой первой версии системы. Дальность 10 км без прямой видимости целевое исследовательское требование, а не гарантированная характеристика первой версии.
--- ---
## 3. Почему не используется обычное FPV-видео ## 3. Почему не используется обычное FPV-видео
Типовые FPV-системы работают преимущественно в диапазонах 2,4 и 5,8 ГГц. Типовые FPV-системы работают в диапазонах 2,4 и 5,8 ГГц. Для наземного робота это плохо: велики потери у поверхности земли, сильное затенение рельефом и растительностью, а главное — на таких частотах практически нет дифракции, поэтому за препятствием связь пропадает.
Для наземного робота такие диапазоны имеют ряд ограничений: В этом проекте вместо непрерывного видеопотока передаются отдельные сжатые изображения, что резко снижает требуемую скорость канала.
- высокие потери при работе у поверхности земли; ---
- затенение рельефом, зданиями и растительностью;
- быстрое ухудшение изображения при слабом сигнале;
- высокая загруженность распространённых диапазонов;
- зависимость от постоянного широкополосного видеопотока.
В этом проекте вместо непрерывного видео предполагается передавать отдельные сжатые изображения. ## 4. Текущее состояние
Пример: **Выполнено:** лабораторные Lab001Lab041. Пройден путь от структуры пакета до сеансов связи с защитой от пакетов предыдущего сеанса.
```text Что реализовано и проверено:
Камера
- пакетный протокол с CRC32, фрагментация и сборка изображений;
JPEG-изображение - модуляция и демодуляция BPSK, формирующий фильтр, кадровая синхронизация, оценка и коррекция ухода несущей;
- блочное исправление стираний над GF(256), перемежение, подбор параметров;
Разбиение на пакеты - приоритетное обслуживание команд, телеметрии и видео;
- двухступенчатая безопасная остановка, сторожевой таймер, постоянное аварийное намерение, безопасный сброс;
Передача через SDR - идентификаторы сеанса, безопасный запуск, отклонение пакетов предыдущего сеанса.
Сборка изображения на наземной станции **Что ещё не сделано:** передатчик ни разу не включался. Весь тракт проверен на моделях и на приёме, но не в эфире.
**Ближайшая работа:** Lab042 — передача JPEG через кабельную петлю на одном PlutoSDR. Техническое задание: [docs/lab042_hardware_loopback_spec.md](docs/lab042_hardware_loopback_spec.md).
Хронология решений и результатов: [PROJECT_LOG.md](PROJECT_LOG.md).
---
## 5. Оборудование
| Позиция | Состояние |
|---|---|
| **Pluto+** — приёмопередатчик, чип AD9361, 70 МГц 6 ГГц | есть |
| RTL-SDR — приёмный донгл | есть |
| Аттенюатор AT30S, 30 дБ, SMA | есть |
| Кабели SMA, штыревые антенны | есть |
| Усилитель мощности около 1 Вт | планируется после первых испытаний |
| Raspberry Pi как бортовой вычислитель | планируется |
Устройство подключается по USB и появляется в системе как сетевой адаптер с адресом `192.168.2.1`.
**Проверять связь с Pluto командой `ping` бесполезно.** При активном VPN трафик уходит в туннель, и «успешный» отклик приходит даже от несуществующего адреса. Достоверна только успешная установка контекста `iio.Context("ip:192.168.2.1")`.
---
## 6. Установка окружения
Расчётная часть работает на компьютере без SDR:
```bash
python -m pip install -r requirements.txt
``` ```
Такой подход позволяет существенно снизить требуемую скорость радиоканала. Для лабораторных, работающих с устройством, дополнительно:
```bash
python -m pip install -r requirements-hardware.txt
```
Версии зафиксированы по проверенному окружению, Python 3.13.
--- ---
## 4. Текущее оборудование ## 7. Как запускать
### SDR-оборудование **Быстрые проверки ядра** — секунды, оборудование не нужно:
- RTL-SDR — приёмник; ```bash
- ADALM-PlutoSDR — приёмопередатчик, ожидается поступление; python -m pytest tests/ -q
- HackRF One — может временно использоваться при необходимости. ```
### Вычислительное оборудование **Дешёвый шлюз** — синтаксис, импорт `protocol`, функциональные проверки лабораторных:
- компьютер под управлением Windows; ```bash
- Raspberry Pi — планируется для установки на ровер; python tools/quick_gate.py
- бортовой вычислитель ровера — будет выбран позднее. ```
### Программное обеспечение **Лабораторный эксперимент** — минуты, пишет результаты в `data/processed/`:
- Python 3.12; ```bash
- Anaconda; python -m experiments.lab041_session_restart_safety
- Spyder; ```
- Git;
- GNU Radio — будет установлен на следующем этапе;
- libiio и драйверы PlutoSDR — будут установлены после получения устройства.
--- ---
## 5. Предварительно рассматриваемые диапазоны ## 8. Структура репозитория
В рамках проекта рассматриваются:
- 27 МГц;
- 50 МГц;
- 144 МГц;
- другие разрешённые для лабораторных испытаний частоты ниже 350 МГц.
Конкретный диапазон будет выбран после анализа:
- доступной полосы;
- требований законодательства;
- размеров антенны;
- потерь распространения;
- уровня внешних помех;
- возможностей выходного радиочастотного тракта;
- допустимой мощности передачи.
Передача по эфиру должна выполняться только на разрешённых частотах и с соблюдением допустимой мощности. Первые опыты следует проводить через аттенюатор, эквивалент нагрузки или в экранированной лабораторной конфигурации.
---
## 6. Основные этапы проекта
### Этап 0. Подготовка среды
- создание репозитория;
- настройка Git;
- настройка Python и Spyder;
- подготовка структуры каталогов;
- ведение документации.
### Этап 1. Пакетный протокол
- структура пакета;
- типы сообщений;
- порядковый номер пакета;
- длина данных;
- CRC;
- кодирование и декодирование.
### Этап 2. Виртуальный канал
- передача пакетов между Python-программами;
- имитация потерь;
- имитация повреждения битов;
- измерение числа ошибочных пакетов.
### Этап 3. Передача файлов
- разбиение файла на фрагменты;
- нумерация фрагментов;
- подтверждение приёма;
- повторная передача;
- сборка исходного файла.
### Этап 4. Передача изображений
- загрузка JPEG;
- разбиение JPEG на пакеты;
- передача через виртуальный канал;
- восстановление изображения;
- обработка потерянных фрагментов.
### Этап 5. Физический уровень
- BPSK;
- QPSK;
- формирование символов;
- IQ-сэмплы;
- синхронизация;
- фильтрация;
- оценка BER и PER.
### Этап 6. PlutoSDR
- подключение устройства;
- передача несущей;
- передача тестовой последовательности;
- передача цифрового пакета;
- приём сигнала RTL-SDR или вторым SDR.
### Этап 7. Радиоканал передачи изображений
- передача JPEG через SDR;
- измерение времени доставки;
- испытания при различных уровнях сигнала;
- испытания при наличии помех;
- подбор скорости и модуляции.
### Этап 8. Полевые испытания
- испытания на малой мощности;
- испытания на малой дистанции;
- постепенное увеличение расстояния;
- испытания с ретранслятором;
- испытания на наземном ровере.
---
## 7. Структура репозитория
```text ```text
SDR_Rover SDR_Rover
├── protocol/ реализация протокола и радиотракта
├── README.md ├── experiments/ лабораторные Lab001Lab041
├── docs ├── tests/ быстрые автоматические проверки
├── protocol ├── tools/ вспомогательные программы и шлюз
├── rover ├── docs/ технические задания и исследования
├── ground_station ├── data/ исходные данные и результаты
├── gnuradio ├── rover/ пока пусто
├── tests ├── ground_station/ пока пусто
└── tools └── gnuradio/ пока пусто
``` ```
### `docs`
Документация, лабораторные работы, схемы и отчёты.
### `protocol` ### `protocol`
Реализация пакетного протокола: Ядро системы. Пакет с CRC32, фрагментация изображений, кадр канала, исправление стираний, приоритеты, аварийная логика, сеансы связи, примитивы BPSK-радиотракта.
- структура пакета; ### `experiments`
- CRC;
- кодирование;
- декодирование;
- типы сообщений.
### `rover` Лабораторные работы. Это исследовательские программы: они моделируют канал, выполняют сотни повторов, строят CSV, графики и отчёты. **Быстрыми тестами они не являются.** При импорте создают каталоги и пишут файлы, поэтому импортировать их не следует — для повторного использования кода служит `protocol/`.
Программы, которые будут работать на борту ровера:
- получение изображения;
- сжатие JPEG;
- передача телеметрии;
- формирование радиопакетов.
### `ground_station`
Программы наземной станции:
- приём пакетов;
- сборка изображений;
- отображение телеметрии;
- управление передачей.
### `gnuradio`
Схемы GNU Radio Companion:
- передатчик;
- приёмник;
- BPSK-модем;
- QPSK-модем;
- тестовые генераторы.
### `tests` ### `tests`
Автоматические и лабораторные тесты. Настоящие быстрые проверки `protocol/`. Выполняются за доли секунды, оборудования не требуют, запускаются через `pytest`.
### `tools` ### `rover`, `ground_station`, `gnuradio`
Вспомогательные программы: Каталоги созданы под будущую прикладную часть и пока пусты. Бортовая программа ровера, программа наземной станции и схемы GNU Radio появятся после того, как радиоканал заработает в эфире.
- анализ пакетов;
- генераторы тестовых данных;
- построение графиков;
- анализ журналов.
--- ---
## 8. Принципы разработки ## 9. Данные
В проекте применяются следующие правила: - `data/raw` — исходные данные. Крупные записи отсчётов и видео в репозиторий не помещаются, исходные изображения лабораторных хранятся.
- `data/processed` — результаты лабораторных: CSV, отчёты и графики.
**Ограничение воспроизводимости.** Исходное видео `data/raw/lab026_rover_source.mp4` (около 7,8 МБ) в репозитории не хранится. Без него Lab026 и Lab027 не запускаются, файл нужно получить отдельно.
---
## 10. Принципы разработки
1. Каждый новый термин сначала объясняется, затем используется. 1. Каждый новый термин сначала объясняется, затем используется.
2. Каждый этап должен завершаться работающим результатом. 2. Каждый этап завершается работающим результатом.
3. Радиоканал и пакетный протокол разрабатываются отдельно. 3. Радиоканал и пакетный протокол разрабатываются отдельно.
4. Код проверяется автоматическими тестами. 4. Код проверяется автоматическими тестами.
5. Все существенные решения фиксируются в документации. 5. Все существенные решения фиксируются в журнале.
6. Сложность увеличивается постепенно. 6. Сложность увеличивается постепенно.
7. Испытания по эфиру начинаются только после проверки через виртуальный канал. 7. Испытания по эфиру начинаются только после проверки через модель.
8. Передатчик сначала проверяется через аттенюатор или эквивалент нагрузки. 8. Передатчик сначала проверяется через аттенюатор или эквивалент нагрузки.
9. **Каждое число в отчёте должно вычисляться из измерений текущего запуска.** Перенесённые из других лабораторных величины сопровождаются указанием источника. Вписанные вручную результаты недопустимы.
--- Девятое правило добавлено после аудита достоверности, см. Запись 017 журнала.
## 9. Текущий статус
Выполнено:
- установлен Python;
- установлена Anaconda;
- выбран Spyder;
- установлен Git;
- создан локальный Git-репозиторий;
- создана базовая структура каталогов.
Следующая задача:
> Создать первый Python-модуль пакетного протокола и передать сообщение через виртуальный канал без SDR-оборудования.
---
## 10. Ближайший контрольный результат
Первая рабочая версия должна выполнять следующий цикл:
```text
Текстовое сообщение
Преобразование в байты
Формирование пакета
Добавление CRC
Имитация радиоканала
Проверка CRC
Восстановление сообщения
```
Первое тестовое сообщение:
```text
HELLO SDR
```
--- ---
@@ -337,4 +176,4 @@ HELLO SDR
НИЯУ МИФИ НИЯУ МИФИ
2026 2026

View File

@@ -324,7 +324,7 @@ Lab041 — сеансы связи, безопасный запуск и защ
Созданные файлы Созданные файлы
- protocol/control_messages.py - protocol/control_messages.py
- protocol/session_state.py - protocol/session_state.py
- tests/lab041_session_restart_safety.py - experiments/lab041_session_restart_safety.py
- data/processed/lab041/lab041_summary.csv - data/processed/lab041/lab041_summary.csv
- data/processed/lab041/lab041_session_metrics.csv - data/processed/lab041/lab041_session_metrics.csv
- data/processed/lab041/lab041_rejection_metrics.csv - data/processed/lab041/lab041_rejection_metrics.csv

View File

@@ -2,13 +2,10 @@
from __future__ import annotations from __future__ import annotations
import ast
import csv import csv
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from enum import IntEnum from enum import IntEnum
import inspect
from pathlib import Path from pathlib import Path
import re
from typing import Iterable from typing import Iterable
import cv2 import cv2
@@ -1486,12 +1483,11 @@ def validate_outputs(
assert not any(path.suffix.lower() in forbidden for path in actual) assert not any(path.suffix.lower() in forbidden for path in actual)
assert not any(path.name == "__pycache__" for path in OUTPUT_DIRECTORY.rglob("*")) assert not any(path.name == "__pycache__" for path in OUTPUT_DIRECTORY.rglob("*"))
source = inspect.getsource(save_plots) # Прежде здесь лабораторная разбирала собственный исходный код, чтобы
tree = ast.parse(source) # убедиться, что подписи на графиках русские. Проверка снята: она
for node in ast.walk(tree): # относится к оформлению, а не к корректности модели, и превращала
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr in {"set_title", "set_xlabel", "set_ylabel"}: # эксперимент в анализатор своего же текста. Подписи видны на готовых
if node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str): # PNG и проверяются при просмотре результатов.
assert re.search("[А-Яа-яЁё]", node.args[0].value)
def main() -> None: def main() -> None:

20
requirements-hardware.txt Normal file
View File

@@ -0,0 +1,20 @@
# SDR Rover Link — аппаратное окружение.
#
# Нужно только для лабораторных, работающих с PlutoSDR:
# Lab024a, Lab024b, Lab025 и всё, начиная с Lab042.
#
# Устанавливается поверх расчётного окружения:
# python -m pip install -r requirements.txt
# python -m pip install -r requirements-hardware.txt
#
# Проверено 10 августа 2026 года на устройстве
# Analog Devices PlutoSDR Rev.C (Z7010-AD9361), прошивка v0.32,
# подключение по USB как RNDIS-адаптер, адрес ip:192.168.2.1.
#
# Замечание по диагностике: наличие связи с устройством проверяется
# только успешным подключением через iio.Context. Команда ping и проверка
# открытых портов недостоверны — при активном VPN трафик уходит в туннель
# и создаёт видимость доступности несуществующего адреса.
pyadi-iio==0.0.21
pylibiio==0.25

21
requirements.txt Normal file
View File

@@ -0,0 +1,21 @@
# SDR Rover Link — расчётное окружение.
#
# Достаточно для запуска быстрых проверок (pytest), всех расчётных
# лабораторных и построения графиков. Оборудование не требуется:
# проект поднимается на компьютере без SDR.
#
# Установка:
# python -m pip install -r requirements.txt
#
# Версии зафиксированы по фактически проверенному окружению
# от 10 августа 2026 года, Python 3.13.1.
#
# Аппаратные лабораторные (Lab024, Lab025, Lab042 и далее) требуют
# дополнительно requirements-hardware.txt.
numpy==2.2.6
scipy==1.18.0
matplotlib==3.11.1
opencv-python==4.12.0
Pillow==12.0.0
pytest==9.1.1

View File

@@ -0,0 +1,155 @@
"""Быстрые проверки формата управляющих сообщений: protocol/control_messages.py.
Перенесено из функциональных проверок Lab041, где эти свойства
проверялись только при полном запуске лабораторной.
"""
from __future__ import annotations
import pytest
from protocol.control_messages import (
COMMON_SIZE,
ControlMessageError,
ControlStateMessage,
EmergencyAck,
EmergencyStop,
MessageContext,
MessageType,
MovementAuthorizeAck,
MovementAuthorizeRequest,
ResetAck,
ResetRequest,
RoverSafetyCode,
SequenceComparison,
SessionHello,
SessionReject,
SessionRejectReason,
SessionStatus,
compare_sequence,
decode_message,
encode_message,
)
CONTEXT = MessageContext(11, 22, 33, 44)
ALL_MESSAGES = (
SessionHello(CONTEXT, True, 101),
SessionStatus(CONTEXT, RoverSafetyCode.ROVER_SESSION_SYNCED_SAFE, False, 0),
MovementAuthorizeRequest(CONTEXT, 201),
MovementAuthorizeAck(CONTEXT, 201, True),
ControlStateMessage(CONTEXT, 1.25, -0.5, False, True),
EmergencyStop(CONTEXT, 301),
EmergencyAck(CONTEXT, 301),
ResetRequest(CONTEXT, 301, 401, 0.0, False),
ResetAck(CONTEXT, 301, 401, True, SessionRejectReason.ACCEPTED),
SessionReject(CONTEXT, MessageType.CONTROL_STATE, SessionRejectReason.CONTROL_EPOCH),
)
@pytest.mark.parametrize("message", ALL_MESSAGES, ids=lambda m: type(m).__name__)
def test_roundtrip_is_exact(message) -> None:
assert decode_message(encode_message(message)) == message
def test_every_message_type_is_covered() -> None:
"""Если добавится тип сообщения, этот тест обязан упасть."""
assert len(ALL_MESSAGES) == len(MessageType)
@pytest.mark.parametrize("message", ALL_MESSAGES, ids=lambda m: type(m).__name__)
def test_encoding_starts_with_the_common_header(message) -> None:
encoded = encode_message(message)
assert len(encoded) >= COMMON_SIZE
assert encoded[0] == 1
def test_wrong_version_is_rejected() -> None:
payload = bytearray(encode_message(SessionHello(CONTEXT, False, 0)))
payload[0] = 2
with pytest.raises(ControlMessageError):
decode_message(bytes(payload))
def test_unknown_type_is_rejected() -> None:
payload = bytearray(encode_message(SessionHello(CONTEXT, False, 0)))
payload[1] = 255
with pytest.raises(ControlMessageError):
decode_message(bytes(payload))
def test_truncated_message_is_rejected() -> None:
payload = encode_message(SessionHello(CONTEXT, False, 0))
for cut in (0, 1, COMMON_SIZE - 1, len(payload) - 1):
with pytest.raises(ControlMessageError):
decode_message(payload[:cut])
# ------------------------------------------------------- диапазоны команд движения
@pytest.mark.parametrize(
"speed,turn,braking,permitted",
[
(15.1, 0.0, False, True), # выше предела
(-0.1, 0.0, False, True), # отрицательная скорость
(-5.0, 0.0, False, True),
(1.0, 1.1, False, True), # поворот вне диапазона
(1.0, -1.1, False, True),
(1.0, 0.0, True, False), # торможение при ненулевой скорости
(1.0, 0.0, False, False), # движение без разрешения
],
)
def test_invalid_control_state_is_rejected(speed, turn, braking, permitted) -> None:
with pytest.raises(ControlMessageError):
encode_message(ControlStateMessage(CONTEXT, speed, turn, braking, permitted))
@pytest.mark.parametrize(
"speed,turn,braking,permitted",
[
(0.0, 0.0, True, False),
(15.0, 0.0, False, True),
(0.0, 0.0, False, False),
(7.5, 1.0, False, True),
(7.5, -1.0, False, True),
],
)
def test_valid_control_state_is_accepted(speed, turn, braking, permitted) -> None:
message = ControlStateMessage(CONTEXT, speed, turn, braking, permitted)
assert decode_message(encode_message(message)) == message
@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")])
def test_non_finite_speed_is_rejected(bad: float) -> None:
with pytest.raises(ControlMessageError):
encode_message(ControlStateMessage(CONTEXT, bad, 0.0, False, True))
# ------------------------------------------------------- сравнение номеров сообщений
def test_sequence_comparison_basic_order() -> None:
assert compare_sequence(5, 4) is SequenceComparison.NEWER
assert compare_sequence(4, 5) is SequenceComparison.NOT_NEWER
assert compare_sequence(4, 4) is SequenceComparison.NOT_NEWER
def test_sequence_comparison_wraps_at_32_bits() -> None:
"""Переход через 0xFFFFFFFF обязан считаться новым, а не откатом."""
assert compare_sequence(0, 0xFFFFFFFF) is SequenceComparison.NEWER
assert compare_sequence(1, 0xFFFFFFFE) is SequenceComparison.NEWER
def test_ambiguous_half_range_is_rejected() -> None:
"""Разность ровно 2^31 неотличима от отката и должна отклоняться."""
assert compare_sequence(0x80000000, 0) is SequenceComparison.AMBIGUOUS
assert compare_sequence(0, 0x80000000) is SequenceComparison.AMBIGUOUS
def test_just_below_the_ambiguous_boundary_is_newer() -> None:
assert compare_sequence(0x7FFFFFFF, 0) is SequenceComparison.NEWER

288
tests/test_session_state.py Normal file
View File

@@ -0,0 +1,288 @@
"""Проверки автоматов сеанса: protocol/session_state.py.
Свойства безопасности, перенесённые из функциональных проверок Lab041.
До этого они проверялись только при полном запуске лабораторной на 180
сочетаний, а пять из них в отчёте вообще не измерялись.
"""
from __future__ import annotations
import pytest
from protocol.control_messages import (
ControlStateMessage,
MessageContext,
ResetRequest,
SequenceComparison,
compare_sequence,
)
from protocol.session_state import (
GroundSessionController,
GroundSessionState,
RejectionCause,
RoverSessionController,
RoverSessionState,
deterministic_identifier,
)
CONTROL_STREAM_ID = 20
def active_pair(seed: int) -> tuple[GroundSessionController, RoverSessionController]:
"""Довести обе стороны до состояния, в котором разрешено движение."""
ground = GroundSessionController(100 + seed)
rover = RoverSessionController(200 + seed)
status = rover.receive_hello(ground.start_synchronization())
assert ground.receive_status(status)
acknowledgement = rover.authorize_movement(ground.request_movement_authorization(1))
assert ground.receive_movement_authorize_ack(acknowledgement)
return ground, rover
def control_from(ground, rover, sequence_number: int, speed: float = 1.0) -> ControlStateMessage:
context = MessageContext(ground.ground_session_id, rover.rover_boot_id, rover.control_epoch, sequence_number)
return ControlStateMessage(context, speed, 0.0, False, True)
# ------------------------------------------------------------------ безопасный запуск
def test_both_sides_boot_without_movement() -> None:
ground = GroundSessionController(7)
rover = RoverSessionController(8)
assert ground.state is GroundSessionState.GROUND_BOOT
assert not ground.movement_permitted
assert rover.state is RoverSessionState.ROVER_BOOT_SAFE
assert rover.safe
def test_session_status_alone_does_not_permit_movement() -> None:
ground = GroundSessionController(9)
rover = RoverSessionController(10)
assert ground.receive_status(rover.receive_hello(ground.start_synchronization()))
assert ground.state is GroundSessionState.GROUND_SAFE_SYNCHRONIZED
assert not ground.movement_permitted
assert rover.safe
def test_authorization_ack_does_not_restore_motion() -> None:
ground, rover = active_pair(11)
assert ground.current_speed_mps == 0.0
assert not ground.movement_permitted
assert rover.safe
def test_movement_requires_a_new_operator_command() -> None:
ground, rover = active_pair(12)
assert rover.safe
assert rover.receive_control(ground.new_operator_control(1.0), CONTROL_STREAM_ID).accepted
assert rover.speed_mps == 1.0
def test_restarted_rover_is_immediately_safe() -> None:
ground, rover = active_pair(13)
rover.receive_control(ground.new_operator_control(1.0), CONTROL_STREAM_ID)
restarted = RoverSessionController(999)
assert restarted.state is RoverSessionState.ROVER_BOOT_SAFE
assert restarted.safe
def test_new_ground_session_stops_the_old_command_stream() -> None:
old_ground, rover = active_pair(14)
old_command = old_ground.new_operator_control(1.0)
new_ground = GroundSessionController(1000)
rover.receive_hello(new_ground.start_synchronization())
decision = rover.receive_control(old_command, CONTROL_STREAM_ID)
assert decision.reason is RejectionCause.GROUND_SESSION_ID
def test_duplicate_hello_is_idempotent() -> None:
ground = GroundSessionController(30)
rover = RoverSessionController(31)
hello = ground.start_synchronization()
first = rover.receive_hello(hello)
second = rover.receive_hello(hello)
assert first == second
assert rover.duplicate_hellos == 1
# ------------------------------------------------------------------ старые пакеты
@pytest.mark.parametrize(
"field,expected",
[
("ground_session_id", RejectionCause.GROUND_SESSION_ID),
("rover_boot_id", RejectionCause.ROVER_BOOT_ID),
("control_epoch", RejectionCause.CONTROL_EPOCH),
],
)
def test_stale_identifier_is_rejected(field: str, expected: RejectionCause) -> None:
"""Пакет предыдущего сеанса, запуска или периода управления не принимается."""
ground, rover = active_pair(hash(field) % 500)
values = {
"ground_session_id": ground.ground_session_id,
"rover_boot_id": rover.rover_boot_id,
"control_epoch": rover.control_epoch,
"sequence_number": 1,
}
values[field] -= 1
message = ControlStateMessage(MessageContext(**values), 1.0, 0.0, False, True)
assert rover.receive_control(message, CONTROL_STREAM_ID).reason is expected
def test_sequence_zero_is_accepted_in_a_new_session() -> None:
ground, rover = active_pair(1)
assert rover.receive_control(control_from(ground, rover, 0), CONTROL_STREAM_ID).accepted
def test_sequence_wrap_is_treated_as_newer() -> None:
ground, rover = active_pair(5)
key = (ground.ground_session_id, rover.rover_boot_id, rover.control_epoch, CONTROL_STREAM_ID)
rover.last_sequences[key] = 0xFFFFFFFF
assert rover.receive_control(control_from(ground, rover, 0), CONTROL_STREAM_ID).accepted
assert compare_sequence(0, 0xFFFFFFFF) is SequenceComparison.NEWER
def test_ambiguous_half_range_is_rejected() -> None:
ground, rover = active_pair(6)
key = (ground.ground_session_id, rover.rover_boot_id, rover.control_epoch, CONTROL_STREAM_ID)
rover.last_sequences[key] = 0
decision = rover.receive_control(control_from(ground, rover, 0x80000000), CONTROL_STREAM_ID)
assert not decision.accepted
assert decision.ambiguous_sequence
def test_replayed_command_is_rejected() -> None:
ground, rover = active_pair(21)
assert rover.receive_control(control_from(ground, rover, 10), CONTROL_STREAM_ID).accepted
replay = rover.receive_control(control_from(ground, rover, 10), CONTROL_STREAM_ID)
assert not replay.accepted
assert replay.reason is RejectionCause.SEQUENCE_NUMBER
# ------------------------------------------------------------------ аварийное намерение
def test_persisted_emergency_intent_survives_a_ground_restart() -> None:
ground = GroundSessionController(15, persisted_emergency_intent=True, persisted_emergency_event_id=77)
assert ground.emergency_intent
assert not ground.movement_permitted
hello = ground.start_synchronization()
assert hello.emergency_intent
assert hello.emergency_event_id == 77
def test_emergency_acknowledgement_does_not_clear_the_intent() -> None:
ground, rover = active_pair(16)
acknowledgement = rover.receive_emergency(ground.request_emergency(88))
assert acknowledgement is not None
assert ground.receive_emergency_ack(acknowledgement)
assert ground.emergency_intent
assert not ground.movement_permitted
def test_ordinary_command_does_not_release_the_latch() -> None:
ground, rover = active_pair(20)
rover.receive_emergency(ground.request_emergency(96))
decision = rover.receive_control(control_from(ground, rover, 100), CONTROL_STREAM_ID)
assert not decision.accepted
assert rover.state is RoverSessionState.EMERGENCY_LATCHED
def test_movement_authorization_is_refused_while_intent_stands() -> None:
ground = GroundSessionController(40, persisted_emergency_intent=True, persisted_emergency_event_id=5)
rover = RoverSessionController(41)
ground.receive_status(rover.receive_hello(ground.start_synchronization()))
with pytest.raises(RuntimeError):
ground.request_movement_authorization(1)
# ------------------------------------------------------------------ сброс
def test_stale_reset_request_is_refused() -> None:
ground, rover = active_pair(17)
assert rover.receive_emergency(ground.request_emergency(90)) is not None
request = ground.request_reset(91)
stale_context = MessageContext(
request.context.ground_session_id - 1,
request.context.rover_boot_id,
request.context.control_epoch,
request.context.sequence_number,
)
stale = ResetRequest(stale_context, request.emergency_event_id, request.reset_request_id, 0.0, False)
assert not rover.receive_reset(stale).acknowledgement.accepted
def test_repeated_reset_is_idempotent() -> None:
ground, rover = active_pair(18)
rover.receive_emergency(ground.request_emergency(92))
request = ground.request_reset(93)
first = rover.receive_reset(request)
assert first.performed
repeated = rover.receive_reset(request)
assert repeated.duplicate
assert not repeated.performed
assert repeated.acknowledgement.accepted
def test_reset_requires_an_emergency_intent() -> None:
ground, _ = active_pair(50)
with pytest.raises(RuntimeError):
ground.request_reset(1)
def test_reset_does_not_restore_the_previous_command() -> None:
ground, rover = active_pair(51)
rover.receive_control(ground.new_operator_control(2.0), CONTROL_STREAM_ID)
rover.receive_emergency(ground.request_emergency(60))
assert rover.speed_mps == 0.0
decision = rover.receive_reset(ground.request_reset(61))
assert decision.performed
assert ground.receive_reset_ack(decision.acknowledgement)
assert not ground.movement_permitted
assert ground.current_speed_mps == 0.0
assert rover.safe
# ------------------------------------------------------------------ идентификаторы
def test_identifiers_are_reproducible_and_nonzero() -> None:
assert deterministic_identifier(42, "ground") == deterministic_identifier(42, "ground")
assert deterministic_identifier(42, "ground") != deterministic_identifier(42, "rover")
assert deterministic_identifier(42, "ground") != deterministic_identifier(43, "ground")
for seed in range(50):
assert 0 < deterministic_identifier(seed, "ground") <= 0xFFFFFFFFFFFFFFFF
def test_identifier_rejects_a_non_integer_seed() -> None:
with pytest.raises(TypeError):
deterministic_identifier("не число", "ground")
# ------------------------------------------------------------------ границы скорости
def test_negative_operator_speed_is_refused() -> None:
"""Свойство, которое в отчёте Lab041 стояло литеральным нулём."""
ground, _ = active_pair(70)
for speed in (-0.1, -5.0, 0.0):
with pytest.raises(ValueError):
ground.new_operator_control(speed)
def test_speed_above_the_limit_is_refused_by_encoding() -> None:
from protocol.control_messages import ControlMessageError, encode_message
ground, rover = active_pair(71)
with pytest.raises(ControlMessageError):
encode_message(control_from(ground, rover, 1, speed=15.1))