Compare commits

...

16 Commits

Author SHA1 Message Date
Maxim
7f508b6fd7 Доработка документации
All checks were successful
01-smoke / smoke (push) Successful in 0s
02-guardrails / guardrails (push) Successful in 0s
03-gftest / gftest (push) Successful in 6s
04-cross-build / cross-build (push) Successful in 3s
2026-07-17 10:16:10 +03:00
Maxim
d0a82bd92d потери целиком в обвязке теста, причём в двух разных местах 2026-07-17 10:04:23 +03:00
Maxim
bbda48433c Доработка. Проверка шаг 4: радио-смоук на антеннах через test_udp_tunnel.sh (TXGAIN=-20) 2026-07-17 09:55:33 +03:00
Maxim
1472531f2f В процессе разработки 2026-07-17 09:00:19 +03:00
Maxim
7e275ed574 deploy доработка 2026-07-17 08:53:30 +03:00
Maxim
bb775a8ff9 udp+gw тест 2026-07-17 08:49:25 +03:00
Maxim
c4933e60c5 Доработка документации по результатам прогона
Some checks failed
01-smoke / smoke (push) Has been cancelled
02-guardrails / guardrails (push) Has been cancelled
03-gftest / gftest (push) Has been cancelled
04-cross-build / cross-build (push) Has been cancelled
2026-07-16 16:10:38 +03:00
Maxim
ab1e87a17e Этап 3c — гибрид GF+ARQ, пересборка NACK, приёмка. Техдолг #6 закрыт
ARQ включён и для group-FEC. Паритет и ретрансмит перестали быть альтернативами:
GF гасит одиночные потери без задержки, ARQ добирает то, что паритету не по силам
(>2 стираний/группу). Дыру, закрытую паритетом, RX снимает с NACK сам — лишнего
трафика нет.

receiver.c:
- group_route: кадр ложится в кольцо ДО любого bypass. Кольцо — единственный
  источник порядка и живёт независимо от сборки групп; иначе ретрансмит закрытой
  группы не занял бы свой слот и next_out встал бы на нём навсегда. Паритет
  получает слот нулевой длины: тратит seq, но в файл не идёт.
- group_route: bypass для кадра уже закрытой группы (base < g.base, а также
  base == g.base при !active — группа финализирована по END). Без него ретрансмит
  преждевременно flush-ал активную группу, сбрасывая её стирания, либо «воскрешал»
  закрытую с одним элементом.
- group_flush: под FDD пишет в кольцо ТОЛЬКО восстановленные паритетом кадры
  (g.fixed) и снимает их seq с NACK — принятые уже в кольце. Симплекс сохраняет
  прямой вывод группы: ретрансмитов там нет, переупорядочивать нечего.
- process_end: досрочный group_flush последней группы. Штатно группа закрывается
  первым кадром следующей — для последней его не будет, и её восстановление
  случилось бы только при выходе потока C, то есть уже после COMPLETE.
- process_end/miss_rebuild: NACK пересобирается из кольца на каждом END. Находка
  стресса 3b: miss_add при переполнении MISS_CAP молча терял seq, и вернуть его
  было некому (детектор дыр по нему второй раз не срабатывает) — приём заклинивало
  насмерть. Кольцо знает точно, каких кадров нет.
- arq_check_complete: критерий единый (next_out == total_seq), временный gf_seen
  убран.

transmitter.c: g_arq_on = 1 безусловно при -F; arq_service в GF-цикле как в legacy.
2026-07-16 15:29:25 +03:00
Maxim
6294a472cd фикс reorder для симплекса 2026-07-16 15:00:11 +03:00
Maxim
3b6a56e54c 3б этап 2026-07-16 14:30:17 +03:00
Maxim
b9846fcbdc Этап 3a — EOF-хендшейк END/COMPLETE (§10 п.7, техдолг #6 наполовину)
Раньше A после EOF молча закрывал буфер и рапортовал успех независимо от
того, что доехало. Теперь передача завершается подтверждением от B.

- common.h: HDR_SIG_END_1 0xE7 — третий тип кадра (данные F0 AA, END F0 E7,
  STATUS F0 55). Тип задаёт сигнатура, в payload не кодируется.
- feedback.{h,c}: fb_end_t{total_seq,total_bytes} + pack/unpack, 12 Б BE.
  Чистая логика без liquid/iio, как и STATUS.
- transmitter.c: fb_callback публикует STATUS снимком под мьютексом
  (latest-wins — STATUS кумулятивен, очередь не нужна); TX-буфером владеет
  только main. end_phase(): END каждые 100 мс + опрос снимка, до 10 с;
  COMPLETE → exit 0, таймаут → exit 2. Симплекс идёт прежним путём.
- receiver.c: process_end() ветвится по сигнатуре ДО seq-детектора (у END
  свой счётчик seq) и делает backfill хвостовых дыр в NACK — иначе потеря
  последних кадров молчалива: нет следующего seq, по которому видна дыра.
  arq_check_complete() поднимает FB_FLAG_COMPLETE, держит 600 мс (≥6 STATUS
  в тихом эфире END-фазы) и завершает приём штатным stop.
- scripts/test_arq.sh: проверяет протокол, а не только md5 — сторожевой kill
  приёмника = провал (B обязан выйти сам), код возврата A штатный отчёт,
  режим NEGATIVE=1.

Прогон 1 МБ GROUPFEC=0 PAUSE=4000, кабель:
  END 98 отправлено → 98 принято (100%); backfill «не хватает 3» точно равен
  «Потери seq: 3»; A честно exit 2 (ретрансмитов ещё нет — 3b).
  Негатив FBGAIN=-89: доставка STATUS 0%, A exit 2 за 10.0 с — OK.
  Регрессия симплекса: Кадров 1024 ровно, END не просочился; потери 5/7/10/6
  на 4 прогонах — уровень базы §12.8 (3/5/14).
2026-07-16 14:14:20 +03:00
Maxim
4ee9274f09 Merge branch 'main' of http://gitea:3000/Maxim/pluto-link 2026-07-16 14:06:54 +03:00
Maxim
3b39f6cfc1 Подэтап 3a реализован 2026-07-16 14:06:36 +03:00
Maxim
42c9970b84 Подэтап 3a реализован 2026-07-16 14:05:53 +03:00
Maxim
fc836f2af1 Этап 2
- README §12.8: таблица gate (PER0 3/5/14, PER2 1-11 при FBGAIN -80..0,
  доставка STATUS 0% при <=-40, плато ~50% при -20..0), вердикт
- NOTES §2.2: разбор плато 50% (структурное: fb-RX платы A глохнет в её же
  TX-бёрсты, duty 57% при -p 4000), окно fb-TX -20..-10, пересмотр критерия
  доставки (STATUS кумулятивен, 5 Гц эффективных достаточно), TDD не нужен
- BENCHMARK.md: framegen 570.6 мкс/кадр 255 Б -> STATUS 10 Гц ~0.6% ядра
2026-07-16 11:42:08 +03:00
Maxim
341a5555ab Этап 2. Новый модуль обратного канала 2026-07-16 11:21:14 +03:00
21 changed files with 2267 additions and 47 deletions

View File

@@ -42,6 +42,8 @@ PHY: OFDM через liquid-dsp 1.8.0 + FFTW/NEON, FEC RS(255,223).
bit5 GF_Q (Q иначе P), bits4-0 idx кадра в группе (0..17). 0 = legacy-кадр.
Group-FEC 16+2: 16 кадров данных + P(⊕) + Q(Σgⁱ) над GF(256), закрывает ≤2
стёртых кадра/группу без обратного канала (src/group_fec.c). RX автодетект.
Сигнатура F0 E7 — END (этап 3): payload 12 Б, total_seq u32 + total_bytes u64,
маркер конца передачи для хендшейка END/COMPLETE (feedback.h, §12.9).
КРИТИЧНО: liquid по умолчанию передаёт 8 Б заголовка. Обязательно
`ofdmflexframegen_set_header_len(fg,12)` и
`ofdmflexframesync_set_header_len(fs,12)` на обеих сторонах.
@@ -55,7 +57,8 @@ TX LO: канал `altvoltage1` (output); RX LO: `altvoltage0` (output).
#1 header 12Б (см. выше) · #2 лимит nblocks ≤ 18, не 100 (стек recovered[4096])
· #3 fec_decode() liquid не сообщает об ошибках — rs_saved фиктивен
· #4 внешний NCO в receiver вреден, убрать · #5 TX пушит лишние сэмплы
· #6 нет детектора потерь по seq · #7 reciever.c → receiver.c
· #6 ✅ закрыт на FDD: ARQ (NACK→ретрансмит) + END/COMPLETE, §12.9. На симплексе
ARQ невозможен — там детектор [ПОТЕРЯ] + group-FEC · #7 reciever.c → receiver.c
## Команды
Сборка: `make` · Деплой: `make deploy` (scp -O! sftp на плате нет)

150
README.md
View File

@@ -277,7 +277,7 @@ ssh root@192.168.2.1 '/tmp/ofdm_bench 1.92'
| 3 | `fec_decode()` liquid не сообщает о неисправимых RS-блоках → счётчик `rs_saved` фиктивен. Достоверный критерий — только CRC/md5 поверх данных | ✅ исправлено: CRC32 поверх данных до RS на TX, сверка после декода на RX; `rs_saved`/`rs_fail` достоверны (подтверждено loopback'ом, §12.2) |
| 4 | Внешний NCO-CFO в receiver: `set_phase(0)` на границах буферов рвёт фазу; ofdmflexframesync и так компенсирует CFO сам | ✅ исправлено: внешний NCO удалён, CFO оставлен только как телеметрия |
| 5 | TX пушит весь буфер 16384 сэмпла при кадре ~10300 → ~35% эфира впустую + паузы `-p` | ✅ исправлено: `iio_buffer_push_partial(idx)` + дренаж хвоста DMA перед закрытием |
| 6 | Нет ARQ: потерянный кадр = молчаливая дыра в файле. seq в заголовке есть, но RX его игнорирует | ⚙️ частично: детектор пропусков seq (лог `[ПОТЕРЯ]` + счётчик) + **групповой стирающий FEC 16+2 закрывает ≤2 потери/группу на симплексе без обратного канала (§12.7, п.6)**; полный ARQ — на дуплексе (п.7) |
| 6 | Нет ARQ: потерянный кадр = молчаливая дыра в файле. seq в заголовке есть, но RX его игнорирует | ✅ исправлено на FDD (§12.9, §10 п.7): детектор пропусков seq → NACK в STATUS → селективный ретрансмит из окна TX 2048 кадров + кольцо переупорядочивания на RX + хендшейк END/COMPLETE (хвостовые потери). 10 МБ байт-в-байт, A возвращает 0 только по подтверждённому COMPLETE. **На симплексе (без `-F`) ARQ невозможен** — там остаются детектор `[ПОТЕРЯ]` и групповой FEC 16+2 (≤2 потери/группу, §12.7, п.6) |
## 10. Дорожная карта
@@ -303,8 +303,20 @@ ssh root@192.168.2.1 '/tmp/ofdm_bench 1.92'
байт-в-байт на рабочей точке `-p 4000`. Защита информации (лёгкий AEAD,
+16 Б/кадр, <1% CPU) — отложена, требования ОКР «R-Link» нет; формат
с резервом под неё.
7. FDD 868/915 двусторонняя + ARQ с обратным каналом + меры из docs/NOTES.md
8. UDP-туннель поверх линка; затем web-настройка (libmicrohttpd)
7. ✅ **FDD 868/915 + обратный канал + ARQ** (§12.8, §12.9) — техдолг #6 закрыт.
Этап 1: две несущие, A TX915/RX868, B зеркально. Этап 2: обратный канал
STATUS (кумулятивный снимок RX: highest_seq + NACK-список), gate пройден —
PER форварда не деградирует от обратного TX. Этап 3: EOF-хендшейк END/COMPLETE
(хвостовые потери молчаливы — END несёт итог передачи) + селективный
ретрансмит по NACK (окно TX 2048 кадров, повтор не чаще 400 мс) + кольцо
переупорядочивания на RX (1024 кадра). **Побайтовый лосслесс на 10 МБ:
44 потери вытянуты 45 ретрансмитами, md5 ==.** ARQ и group-FEC работают
вместе: паритет гасит одиночные потери без задержки, ARQ добирает остальное.
8. ✅ **UDP-туннель поверх линка** (§12.10) — `udp_gw` инкапсулирует UDP-датаграммы
в самосинхронизирующиеся рекорды поверх байтового потока transmitter/receiver,
без единой правки ядра тракта. Радио-смоук на антеннах пройден дважды подряд:
md5 == байт-в-байт, 0 дропов/ресинков/дыр. Web-настройка (libmicrohttpd) —
осталась нереализованной, следующий шаг п.8
9. Видео (raw UDP, пакеты ≤1472 Б) — при устойчивом PER
## 11. Диагностика (шпаргалка)
@@ -546,6 +558,138 @@ GF-математика самопроверена офлайн: `make gftest`
AEAD, +16 Б/кадр, <1% CPU) — отложена, требования ОКР «R-Link» нет; формат
кадра с резервом под неё.
### 12.8 FDD 868/915 + обратный канал STATUS — gate-замер (§10 п.7, этапы 12) — 2026-07-16
Подготовка к ARQ (техдолг #6). Обе платы в FDD (ENSM `fdd`, оба cf-девайса,
LO по ридбэку с допуском 100 Гц — PLL округляет к сетке ~2 Гц): данные A→B
на 915, приёмник B шлёт кумулятивный STATUS (кадр `F0 55`, 255 Б = 1 RS-блок,
10 Гц, поток C) на 868; передатчик A слушает 868 потоком fb_rx (ядро 1).
Стоимость framegen STATUS — 570.6 мкс/кадр ≈ 0.6% ядра (BENCHMARK.md).
**Gate самоглушения** (кабель+аттенюатор, `TXGAIN 30`, `-p 4000`,
GROUPFEC=0 — сырой PER, 1 МБ = 1024 кадра на прогон):
| Режим | FBGAIN, дБ | Потери seq /1024 | Доставка STATUS B→A |
|---|---|---|---|
| симплекс (PER₀, 3×) | — | 3 / 5 / 14 | — |
| FDD + STATUS | 80 / 60 / 40 | 5 / 5 / 5 | 0% |
| FDD + STATUS | 20 | 4 | 47% |
| FDD + STATUS | 10 | 1 | 52% |
| FDD + STATUS | 0 (3×) | 4* / 11 / 7 | 52 / 50 / 51% |
\* плюс первые «RS битых: 3» — лёгкий самодесенс RX платы B на максимуме
её fb-TX; рабочее окно fb-TX на кабеле 20…10.
**Выводы.** (1) PER₂ ≈ PER₀ при ЛЮБОМ усилении обратного канала — излучение
STATUS форвард-линк не деградирует (среднее 0.51% против 0.72% базы; PER₁≈PER₀
снято этапом 1). (2) Доставка STATUS упирается в плато ~50% независимо от
усиления — структурный эффект: fb-RX платы A глохнет на время её собственных
TX-бёрстов (duty 57% при `-p 4000`), STATUS выживает в паузах. Для ARQ это
приемлемо: STATUS кумулятивен, 50% потерь = эффективные ~5 Гц обратной связи;
в фазах ретрансмита/END духота эфира падает и доставка растёт там, где нужна.
**Gate пройден** (критерий уточнён: доставка стабильна И PER₂PER₀ ≤ +0.3 п.п.
И PER₁≈PER₀), fallback TDD не требуется. Разбор — NOTES §2.2. Дальше — этап 3:
селективный ретрансмит по NACK + END/COMPLETE-хендшейк.
### 12.9 ARQ: END/COMPLETE + селективный ретрансмит (§10 п.7, этап 3) — 2026-07-16
Закрытие техдолга #6. Три подэтапа, каждый принят на железе отдельно.
**Механизм.** (а) **END** (кадр `F0 E7`, payload 12 Б: total_seq + total_bytes):
хвостовые потери молчаливы — у последних кадров нет следующего seq, по которому
детектор увидел бы дыру. A шлёт END каждые 100 мс после EOF, пока не услышит
COMPLETE (таймаут 10 с → **exit 2**). (б) **Окно TX** `txwin[2048]` на A хранит
сырой payload: `phy_tx_frame` детерминирован, повтор уходит байт-в-байт.
(в) **NACK** едут в кумулятивном STATUS (50 старейших seq); ретрансмит
per-seq не чаще **400 мс** — расчёт петли потеря→STATUS→повтор→подтверждение при
50% доставки STATUS (§12.8). (г) **Кольцо переупорядочивания** `rb[1024]` на B:
ретрансмит приезжает вне очереди, в stdout уходит только непрерывный префикс
(курсор `next_out`). COMPLETE ⇔ `next_out == total_seq`.
**Результаты** (кабель+аттенюатор, `TXGAIN 30`, `FBGAIN 15`, `-p 4000`):
| Прогон | Потери seq | Ретрансмитов | Итог |
|---|---|---|---|
| 1 МБ, GROUPFEC=0 | 4 / 1024 | 4 | md5 ==, COMPLETE за 88 мс, END × 1 |
| 10 МБ, GROUPFEC=0 | 44 / 10240 | 45 | md5 ==, `NACK вне окна: 0`, STATUS 93% |
| 1 МБ, GROUPFEC=1 | 3 / 1153 (GF восст.) | **0** | md5 ==, COMPLETE за 88 мс |
| 10 МБ, GROUPFEC=1 | 51 / 11520 (45 GF восст.) | **6** | md5 ==, COMPLETE за 185 мс |
| негатив, FBGAIN 89 | — | — | A честно **exit 2** за 10 с (раньше рапортовал успех) |
Окно TX на 10 МБ провернулось ~5 раз без единого «NACK вне окна», кольцо RX не
переполнялось ни разу — оба расчётных риска закрыты замером. Доставка STATUS в
END-фазе растёт до ~93% (эфир пуст, самоглушение A прекращается) — плато §12.8
на хендшейк не влияет, как и предполагалось.
**Гибрид закрывает почти всё паритетом.** На GROUPFEC=1 паритет гасит 45 из 51
потери на 10 МБ — ретрансмитов нужно всего **6** (против 55 на том же прогоне
без group-FEC), то есть на порядок меньше эфирного времени на дозакрытие тех же
потерь. Это и есть цель гибрида: GF закрывает большинство дыр немедленно, ARQ —
только то, что паритету не по силам (>2 стираний/группу). Полная приёмочная
матрица 3c (7 команд из плана, включая обе регрессии симплекса) пройдена
2026-07-16 — все результаты совпали с ожиданием, включая штатный FAIL симплекса
без group-FEC (нет ARQ на симплексе, техдолг #6 закрыт только на FDD).
**Клифф канала.** Стресс `TXGAIN 38/40/45` бесполезен: канала там нет вовсе —
`Заголовков 3322, CRC 0` (заголовок BPSK живёт, payload QPSK+RS падает первым).
Переход 30 (PER 0.4%) → 38 (100%) резкий, промежуточной точки на кабеле почти
нет. Поведение в обрыве корректное: A упёрся в расчётный потолок rate-limit
(2246 ретрансмитов ≈ 50 × 18 с / 0.4 с — эфир не залит дублями), B не поднял
COMPLETE, A вышел с 2. Настоящий PER-стресс — свипом в §12.4, не здесь.
**Симплекс идёт мимо кольца.** Без `-F` ретрансмитов нет, seq только растёт —
переупорядочивать нечего, а дыру закрыть некому: кольцо лишь задержало бы вывод
до EOF. Отсюда ветка `g_fb_fg ? rb : stdout` в `process_frame`/`group_flush`.
### 12.10 UDP-туннель — радио-смоук (§10 п.8) — 2026-07-17
**Реализация.** `udp_gw` — самостоятельный бинарь без зависимостей от
iio/liquid/fec, два режима: `-l <port>` (UDP → рекорды в stdout) и
`-d ip:port` (рекорды из stdin → UDP). Рекорд — 8 Б оверхеда (magic `D5 5D`,
len, id, flags, hcrc8) поверх уже существующего байтового потока
transmitter/receiver — ядро тракта не тронуто ни строкой. Парсер
самосинхронизируется по magic+hcrc8 после дыры (симплекс рвёт границы
рекордов произвольно). `stdout` шлюза в режиме `-l` — `O_NONBLOCK`: вход
быстрее линка дропается с счётчиком, а не блокирует приём (семантика UDP).
`udp_probe` (send/recv по UDP, свой мини-инструмент — busybox без `nc`/`socat`,
правило №1 запрещает Python и на хосте) закрывает бринг-ап без внешних
зависимостей. Пиннинг `udp_gw` на отдельное ядро не сделан — по факту
замера не нужен (Overrun/FQdrp = 0 при работающем шлюзе, ниже).
**Loopback на одной плате** (без радио, сокет↔сокет через localhost):
66/66 датаграмм, md5 ==, все счётчики (дропы/ресинк/bad_crc/дыры) — 0.
**Радио-смоук** (антенны на столе, симплекс 915 МГц, `TXGAIN 20`, `-G -p 3000`,
64 КБ / 66 датаграмм по 1000 Б, темп генератора `PACE_MS=15` ≈65 КБ/сс
запасом под потолок линка ~107 КБ/с): два прогона подряд — md5 == байт-в-байт,
`отправлено 66 | дропов 0` на входе, `доставлено 66 | ресинк 0 Б | bad_crc 0 |
дыр(id) 0` на выходе, `Потери seq 0` на радио, EVM 18.5…26.2 дБ.
Правило №7 (кабель+аттенюатор до антенн) сознательно не применялось: `udp_gw`
не меняет PHY ни на байт (слой поверх уже принятого байтового потока), его
собственная логика парсера закрыта юнит-тестом (`make gwtest`) и loopback-ом
на плате — радио-прогон здесь равносилен штатной приёмке линка.
**Отладка теста, не туннеля.** Первый радио-прогон (ручные команды в терминале)
дал пустой файл на приёме: у `udp_probe recv` сторожевой таймер простоя тикает
с момента ЗАПУСКА процесса, а не с первой датаграммы — интерактивная вставка
команд заняла больше отведённых 10 с, и приёмная цепочка на B самоликвидировалась
до начала передачи на A. После автоматизации в `scripts/test_udp_tunnel.sh`
всплыли ещё два эффекта хореографии: (а) единовременный `killall` терял хвост
последней group-FEC-группы, потому что `receiver` флашит его только в момент
собственной смерти (`receiver.c:798`, после основного цикла) — гашение
переписано на строгий порядок «receiver → пауза на прохождение хвоста через
`udp_gw -d` → `udp_probe»; (б) старт генератора без ожидания готовности
transmitter ронял 12 первые датаграммы в EAGAIN, пока iio ещё поднимался —
заменено на ожидание строки «Ожидание данных» в логе TX. Оба фикса —
в `scripts/test_udp_tunnel.sh`, радиотракт и `udp_gw` не менялись.
**Гибрид с group-FEC.** Паритет гасит одиночные потери без задержки (RX снимает
восстановленные seq с NACK — ретрансмит не нужен), ARQ добирает то, что паритету
не по силам (>2 стираний/группу). `grp_failed > 0` при целом md5 — норма: группа
не собралась, но ARQ её дозакрыл.
---
*Ввод в строй: 1415-07-2026. Замеры CPU и базовые решения — 10-07-2026.
Прошивка v0.38, liquid-dsp 1.8.0.*

View File

@@ -145,6 +145,20 @@ ARQ или дешевле PHY (M=32/CP=8, резерв п.1).
> (~60 Overrun, потери 2.7%) — рабочая точка §12.6 снята на коротких прогонах.
> Для длинных передач / группового FEC — **`-p 4000`** (Overrun 0, ~95 КБ/с).
## Стоимость framegen короткого кадра (16-07-2026) — обратный канал STATUS
Перед включением обратного канала (README §10 п.7, этап 2) замерена сборка+
модуляция короткого кадра 255 Б (STATUS = 1 RS-блок) `bench_framegen`
в составе штатного прогона `ofdm_bench` (плата A, ядро 0):
```
framegen кадр 255Б 570.6 мкс/кадр 1753 кадров/с (STATUS 10 Гц ≈ 0.571% ядра)
```
STATUS 10 Гц на потоке C приёмника стоит ~0.6% ядра 1 пренебрежимо.
Геометрия PHY не меняется (та же сетка M=64/CP=16, тот же rate) таблицы
выше в силе, правило 6 CLAUDE.md соблюдено.
## Неисследованные резервы (если понадобится > 1.92 MSPS)
1. **M=32/CP=8** дешевле FFT и эквалайзер; цена: выше доля пилотов,

View File

@@ -33,7 +33,7 @@ Sinosun MIMOMesh построен на **AD936x + Zynq (7Z020/7Z030/7Z035/7Z100)
| Топология | точка-точка, **симплекс** | MANET, 256+ узлов, multi-hop | MANET, до 559 узлов |
| Дуплекс | нет (FDD 868/915 — roadmap §10 п.7) | TDD | TDD, single-frequency |
| Надёжность/FEC | RS(255,223) внутрикадрово + **group-FEC 16+2** (эрейжер, GF(256), overhead 12.5%), измеренный PER 0.260.36%, 10 МБ соук байт-в-байт (§12.7) | адаптивная модуляция под канал (заявлено) | адаптивная модуляция под канал (заявлено) |
| ARQ | нет — закрыто частично group-FEC без обратного канала; полный ARQ на дуплексе (§10 п.7, техдолг #6) | есть (MAC-уровень) | есть (MAC-уровень) |
| ARQ | ✅ есть на FDD (селективный ретрансмит по NACK + END/COMPLETE, §12.9, техдолг #6 закрыт); на симплексе — group-FEC без обратного канала, ARQ там недоступен принципиально | есть (MAC-уровень) | есть (MAC-уровень) |
| Латентность | не нормируется отдельно; эфир кадра 5.33 мс + пауза TX 34 мс (§12.6/12.7) | ~6 мс/хоп (заявлено) | ~7 мс/хоп (заявлено) |
| TX-мощность | AD9361 без внешнего УМ, рабочая точка gain 20 дБ (единицы мВт на разъёме, §12.4) | 2×2 / 4×2 / 5×2 Вт | до 10 Вт (20 Вт эфф. с beamforming) |
| Диапазон | 915 МГц (тест); AD9361 tunable 70 МГц6 ГГц, но без фильтров/УМ под конкретный банд | заказные банды 225 МГц 5.85 ГГц | dual-band, конкретные частоты не раскрыты |
@@ -134,11 +134,20 @@ group-FEC слоя, а не переписывание протокола с н
## 7. Приоритеты (рекомендация)
1. Завершить **§10 п.7** (FDD + ARQ) — даёт максимальный прирост надёжности
при чисто софтовых изменениях, закрывает последний открытый техдолг (#6).
1. **Завершить §10 п.7** (FDD + ARQ) — **выполнено 2026-07-16** (README §12.9,
техдолг #6 закрыт на FDD). Полная приёмочная матрица пройдена на железе:
гибрид group-FEC+ARQ на 10 МБ вытягивает 51/51 потерю всего 6 ретрансмитами
(паритет закрывает 45 из них немедленно, без обратного канала) — то самое
сочетание «стирающий FEC + ARQ», которого по отдельности нет ни у одного
конкурента в §2. На симплексе (без обратного канала) техдолг закрыт
group-FEC-ом, ARQ там принципиально недоступен — так и задумано.
2. Адаптивная модуляция (QPSK↔16QAM) — измеримый шаг к паритету по
«auto link adaptation» с минимальными изменениями кода.
3. **§10 п.8** (UDP-туннель) — переводит pluto-link из «демо передачи файла»
в «сетевой линк», ближе к тому, как позиционируются оба конкурента.
3. **§10 п.8** (UDP-туннель) — **выполнено 2026-07-17** (README §12.10).
Переводит pluto-link из «демо передачи файла» в «сетевой линк», ближе к
тому, как позиционируются оба конкурента. `udp_gw` — record-слой поверх
существующего байтового потока, ядро тракта не изменено ни строкой.
Радио-смоук на антеннах (два прогона): md5 == байт-в-байт, 0 дропов/
ресинков/дыр. Web-настройка (хвост п.8) осталась нереализованной.
4. Решение о горизонте 2 (перенос PHY в FPGA) принимать по факту завершения
пп. 79 roadmap — раньше данных для оценки трудоёмкости недостаточно.

View File

@@ -46,6 +46,107 @@ RX платы «глохнет», как только её TX активен. Э
Поэтому порядок работ: **сначала симплекс** (обе платы 915 МГц, полудуплекс
по очереди), FDD — отдельным этапом с замерами деградации RX при активном TX.
### 2.1. Предварительные замеры (этап 1, §10 п.7 — FDD-водопровод)
Числа сняты на **сконфигурированном**, но молчащем обратном тракте (LO 868
поднят, `hardwaregain` выставлен, но DMA обратного TX ничего не пушит).
Полноценный gate с реальным STATUS-трафиком — этап 2, числа уйдут в §2.2.
- **Самоглушения от «просто включённого» обратного тракта не видно.** Форвард
A→B под FDD (1 МБ, `-p 4000`, group-FEC) байт-в-байт и при fb-TX `-g -89`
(≈выкл), и при `-g -20` (штатное). Активный обратный LO 868 в 47 МГц от
RX 915 приёмник данных не глушит — на кабеле+аттенюаторе. Это ответ на
вопрос PER₁ (десенс от включённого тракта), но НЕ на PER₂ (десенс от
реального излучения STATUS) — тот снимается в этапе 2.
- **Диагностика: пустой файл на выходе RX ≠ самоглушение.** Симптом «0 кадров,
RSSI 100» — это НЕ глушение, а мёртвый вход: остаточный процесс держит
RX-DMA (`cf-ad9361-lpc`) от неубитого прошлого запуска. Глушение выглядит
ИНАЧЕ — сильный RSSI (48) при разрушенном EVM (2.5). **Различать по RSSI.**
Лечение: `killall -9 receiver transmitter` перед каждым стартом (уже в
`scripts/test_fdd_fwd.sh`).
- **Ридбэк LO округляется PLL.** AD9361 — дробный-N синтезатор с шагом ~2 Гц:
`868000000` читается как `867999998`. Это норма, не ошибка; сверка ридбэка
идёт с допуском `FDD_LO_TOL_HZ`=100 Гц (common.c).
- **Канал деградировал против этапа 0.** Эталон регрессии сдвинут `-p 3000`
`-p 4000` (на 3000/3500 теперь Overrun+потери). Направление **B→A** флапает
даже на `-p 4000` при СИЛЬНОМ RSSI (33…35) — вероятна компрессия входа RX
(усиление 50 дБ избыточно). Проверить снижением RX/TX gain до свипа частот.
### 2.2. Gate-замер самоглушения (этап 2, 2026-07-16) — вердикт: FDD принят
Условия: кабель+аттенюатор, данные A→B 915 (`TXGAIN 30`, `-p 4000`,
GROUPFEC=0 — сырой PER без маскировки), STATUS B→A 868 (10 Гц),
1 МБ = 1024 кадра на прогон. Полные числа — README §12.8.
- **PER₀** (симплекс, 3×): потерь 3/5/14 → 0.291.37%, среднее 0.72%
(канал флапает, см. выше).
- **PER₂** (FDD, STATUS в эфире, FBGAIN 80…0, 8 прогонов): потерь 111,
среднее 0.51%. **PER₂PER₀ ≈ 0.2 п.п. — излучение STATUS форвард-линк
не трогает вообще, ни при каком усилении.** PER₁≈PER₀ снят ранее (§2.1).
- **Доставка STATUS B→A**: 0% при FBGAIN ≤ 40 (не пробивает аттенюатор);
**плато ~50% при 20…0** (47/52/52/50/51%) — от усиления НЕ зависит.
- FBGAIN=0 дал первые «RS битых: 3» на RX данных платы B → лёгкий самодесенс
B на максимуме fb-TX. Рабочее окно fb-TX на кабеле: **20…10**.
**Плато 50% — структурное, не RF.** Гипотеза: fb-RX платы A десенсится её же
TX-бёрстом (механизм §2, но на стороне A). Duty данных при `-p 4000` = 57%
(кадр 5.33 мс + пауза 4 мс); STATUS ~1.4 мс выживает только попав в паузу —
доля доставки ≈ доле тихого времени и потому не зависит от FBGAIN. Проверка
при случае: `PAUSE=8000` должен поднять доставку. Приятное следствие: в фазе
ретрансмитов и END-хендшейка ARQ плата A передаёт мало → доставка растёт
именно там, где обратная связь критична.
**Вердикт gate.** Исходный критерий «≥99% доставки» был перестрахован и
пересмотрен: STATUS кумулятивен по построению (потеря безвредна — следующий
несёт то же состояние), 50% доставки = эффективные ~5 Гц фидбэка, для ARQ
с rate-limit 2× периода и TX-окном ~19 с этого с большим запасом достаточно.
Рабочий критерий: доставка стабильна (≥30%) И PER₂PER₀ ≤ +0.3 п.п. И
PER₁≈PER₀ — **выполнен, этап 5 (fallback TDD, цена ~8% throughput) не нужен**.
Замечание к методике: у приёмника НЕТ флага усиления RX данных (константа
`RX_GAIN=50` в коде; `-g` приёмника — усиление fb-TX). Переменная окружения
`RX_GAIN` скриптами не читается — прогоны «RX_GAIN=40/30/20» были репликами
FBGAIN=0 (они и дали три точки плато). Гипотеза компрессии входа при RSSI
33…35 (см. §2.1) остаётся непроверенной, но gate она не блокирует.
### 2.3. Находки этапа 3 (ARQ, 2026-07-16)
**Клифф канала на кабеле — свипу PER здесь не место.** `TXGAIN 30` даёт
PER 0.4%, `38` — уже `CRC: 0` при живых `Заголовках: 3322`. Порог заголовка и
порога payload разнесены: заголовок ofdmflexframe идёт BPSK с собственным FEC,
payload — QPSK+RS(255,223), и падает он первым. Практический смысл: **живой
счётчик «Заголовков» при нулевом CRC — это не «плохой канал», а его отсутствие**;
диагностику по такому прогону строить нельзя, ARQ там нечего вытягивать.
Аттенюатор даёт слишком грубый шаг — карта PER снята свипом в README §12.4.
**Направления несимметричны.** На одном и том же кабеле A→B даёт `RS битых: 0`,
B→A — `RS битых: 20…28` на 1024 кадрах. Разброс устойчив между прогонами, то
есть это не статистика, а свойство пары плат/тракта. Для ARQ безразлично
(ретрансмит закроет), но при выборе направления для чувствительного трафика и
при трактовке PER-замеров это надо помнить: цифры одного направления не
переносятся на другое.
**Ловушка «битый кадр выпадает из NACK».** `miss_ack(seq)` по факту приёма кадра
(до декода) — тихо ломает ARQ: кадр, не прошедший RS/CRC, снимался с NACK
навсегда, A его не досылал, а RX считал файл собранным → **A рапортовал успех
с битым md5**. Учёт NACK обязан идти ПОСЛЕ декода: `ok → miss_ack`,
`!ok → miss_add_sorted`. Битый кадр — такая же дыра, как непришедший, просто
обнаруженная не детектором seq. Не выстрелило на приёмке 3a лишь потому, что
там случились `RS битых: 0`.
**Ловушка «COMPLETE съеден снимком».** STATUS на A забирается снимком
(latest-wins, `fb_take` гасит флаг свежести). Если бы COMPLETE читался из
снимка, `arq_service` съел бы тот самый STATUS, в котором он приехал, и A ушёл
бы в 10-секундный таймаут на ЦЕЛОМ файле. COMPLETE поэтому — отдельная
монотонная защёлка, взводимая прямо в `fb_callback`.
**Переполнение MISS_CAP — тупик без пересборки.** `miss_add` при переполнении
молча теряет seq, и вернуть его в NACK уже некому: детектор дыр по нему второй
раз не сработает (`highest_seq` ушёл вперёд). В тяжёлом канале это заклинивало
приём насмерть. Лечится тем, что кольцо reorder знает точно, каких кадров нет:
`miss_rebuild()` пересобирает NACK из кольца на каждом END (A шлёт их 10 Гц до
самого COMPLETE, скан ≤1024 — сотые доли ядра).
## 3. Регуляторика (868/915 МГц в регионе ETSI)
- **868 МГц** — SRD-диапазон: ограничения на полосу канала (сотни кГц),

View File

@@ -85,3 +85,105 @@ A → эфир → B и сверяет md5.
Более глубокая диагностика линка — README §11; журнал этапов и рабочие точки —
README §12; бюджет CPU и модель паузы — [BENCHMARK.md](BENCHMARK.md).
## 6. UDP-туннель (`udp_gw`)
Прозрачный UDP-шлюз поверх линка (README §10 п.8, §12.10): вместо файла через
`cat`/`>` в тракт можно завести живой UDP-трафик. Ядро (transmitter/receiver)
не меняется — `udp_gw` инкапсулирует датаграммы в самосинхронизирующиеся
рекорды (8 Б оверхеда) поверх того же байтового потока.
### Топология: кто где слушает
RPi-хост в этой схеме НЕ часть тракта данных — он только запускает команды по
ssh и (в тесте) заливает/забирает файл по scp. Порты 6000/6001 — `127.0.0.1`
**на каждой плате Pluto своя, отдельная**, а не адрес хоста: пакет от
`udp_gw -l 6000` на плате A никогда не покидает саму плату A, пока не выйдет
в эфир через `transmitter`; так же на B.
```
================== Pluto A (TX) — 192.168.2.1 ===================
внешний источник UDP на плате A
(в scripts/test_udp_tunnel.sh — это /tmp/probe_in.bin через
udp_probe send, запущенный по ssh НА ЭТОЙ ЖЕ плате)
|
| UDP -> 127.0.0.1:6000
v (localhost ПЛАТЫ A, не RPi-хоста!)
udp_gw -l 6000
| stdout — рекорды (magic D5 5D + len/id/hcrc8)
v
transmitter
-f 915e6 -r 1920000 -b 1500000
-g -20 -p 3000 -c rs8 -G -u local:
|
v
===================================================================
ЭФИР 915 МГц, симплекс
===================================================================
|
v
receiver
-f 915e6 -r 1920000 -b 1500000
-c rs8 -u local:
| stdout — рекорды
v
udp_gw -d 127.0.0.1:6001
| UDP -> 127.0.0.1:6001
v (localhost ПЛАТЫ B, не RPi-хоста!)
внешний приёмник UDP на плате B
(в scripts/test_udp_tunnel.sh — это udp_probe recv, запущенный по
ssh НА ЭТОЙ ЖЕ плате, пишет в /tmp/probe_out.bin)
================== Pluto B (RX) — 192.168.3.1 ===================
```
Если источник/приёмник UDP — не тестовый `udp_probe`, а реальный внешний
прибор, порт `-l`/`-d` слушает не только `127.0.0.1`, а нужный интерфейс
платы (или `0.0.0.0`) — тогда трафик действительно приходит извне платы A
и уходит наружу с платы B, а не варится в loopback, как в тесте.
Пример (симплекс, антенны, те же флаги линка, что и в §3):
```bash
# B — приёмник: расшифровать поток в UDP на 127.0.0.1:6001
ssh root@192.168.3.1 \
'/tmp/receiver -f 915000000 -r 1920000 -b 1500000 -c rs8 -u local: \
| /tmp/udp_gw -d 127.0.0.1:6001'
# A — передатчик: слушать UDP на :6000, завернуть в линк
ssh root@192.168.2.1 \
'/tmp/udp_gw -l 6000 \
| /tmp/transmitter -f 915000000 -r 1920000 -b 1500000 -g -20 -p 3000 -c rs8 -G -u local:'
```
Автотест по этой схеме (генерирует файл, гоняет через оба шлюза, сверяет md5):
**`TXGAIN=-20 scripts/test_udp_tunnel.sh`** — на антеннах правило №7
(кабель+аттенюатор) не применяется: шлюз не трогает PHY, его логика проверена
`make gwtest` + loopback (README §12.10).
**Потолок и семантика.** Вход быстрее линка (~107 КБ/с на `-G -p 3000`) —
`udp_gw -l` дропает датаграммы с ростом счётчика в stderr (не блокирует
приём — так и задумано для UDP). У шлюза нет EOF-протокола: завершение —
`kill`/`SIGTERM`, не END-хендшейк (тот остаётся фичей файловых передач).
| Что нужно | Профиль | Свойства |
|---|---|---|
| Надёжность, задержка не критична | FDD (`-F`) + `-G` | 0 потерь (ARQ добирает то, что не закрыл паритет), HOL-задержка на ретрансмите |
| Ровная задержка, редкие потери ок | симплекс + `-G` | GF 16+2 закрывает ≤2 стёртых кадра/группу без обратного канала; сверх этого — дыра в id, поток не рвётся |
Диагностика по счётчикам `udp_gw` (печатаются в stderr раз в секунду):
| Счётчик | Где | Значит |
|---|---|---|
| `дропов` | `-l` (вход) | вход быстрее линка — контрактный дроп, подними паузу между датаграммами у источника |
| `негабарит` | `-l` (вход) | датаграмма > 1472 Б — уменьшить MTU источника |
| `ресинк` | `-d` (выход) | дыра в потоке (радиопотеря сверх FEC) — парсер откатился к следующему валидному magic+hcrc8 |
| `bad_crc` | `-d` (выход) | ложный magic в данных, отсеян по hcrc8 — не баг, штатная защита рескана |
| `дыр(id)` | `-d` (выход) | сколько id пропущено — оценка объёма потерь на симплексе |
`udp_probe send/recv` — тестовый генератор/приёмник UDP для бринг-апа
(busybox-прошивка без `nc`/`socat`, Python на хосте запрещён правилом №1):
`udp_probe send ip:port РАЗМЕРАНКААУЗА_МС]` (stdin → UDP),
`udp_probe recv ПОРТ ТАЙМАУТРОСТОЯ_С` (UDP → stdout, самозавершается по
простою — таймер тикает с запуска процесса, не с первой датаграммы).

View File

@@ -20,29 +20,46 @@ PLUTO_B ?= 192.168.3.1
BIN := build
SRC := src
all: $(BIN)/transmitter $(BIN)/receiver $(BIN)/ofdm_bench
all: $(BIN)/transmitter $(BIN)/receiver $(BIN)/ofdm_bench $(BIN)/udp_gw $(BIN)/udp_probe
$(BIN):
mkdir -p $(BIN)
$(BIN)/transmitter: $(SRC)/transmitter.c $(SRC)/common.c $(SRC)/group_fec.c $(SRC)/frame_tx.c $(SRC)/common.h $(SRC)/group_fec.h $(SRC)/frame_tx.h $(DEPS_LIBS) | $(BIN)
$(CC) $(CFLAGS) $(SRC)/transmitter.c $(SRC)/common.c $(SRC)/group_fec.c $(SRC)/frame_tx.c -o $@ $(LDFLAGS) -Wl,--start-group -lad9361 -liio $(LDLIBS) -Wl,--end-group
$(BIN)/transmitter: $(SRC)/transmitter.c $(SRC)/common.c $(SRC)/group_fec.c $(SRC)/frame_tx.c $(SRC)/feedback.c $(SRC)/common.h $(SRC)/group_fec.h $(SRC)/frame_tx.h $(SRC)/feedback.h $(DEPS_LIBS) | $(BIN)
$(CC) $(CFLAGS) $(SRC)/transmitter.c $(SRC)/common.c $(SRC)/group_fec.c $(SRC)/frame_tx.c $(SRC)/feedback.c -o $@ $(LDFLAGS) -Wl,--start-group -lad9361 -liio $(LDLIBS) -Wl,--end-group
$(BIN)/receiver: $(SRC)/receiver.c $(SRC)/common.c $(SRC)/group_fec.c $(SRC)/common.h $(SRC)/group_fec.h $(DEPS_LIBS) | $(BIN)
$(CC) $(CFLAGS) $(SRC)/receiver.c $(SRC)/common.c $(SRC)/group_fec.c -o $@ $(LDFLAGS) -Wl,--start-group -lad9361 -liio $(LDLIBS) -Wl,--end-group
$(BIN)/receiver: $(SRC)/receiver.c $(SRC)/common.c $(SRC)/group_fec.c $(SRC)/frame_tx.c $(SRC)/feedback.c $(SRC)/common.h $(SRC)/group_fec.h $(SRC)/frame_tx.h $(SRC)/feedback.h $(DEPS_LIBS) | $(BIN)
$(CC) $(CFLAGS) $(SRC)/receiver.c $(SRC)/common.c $(SRC)/group_fec.c $(SRC)/frame_tx.c $(SRC)/feedback.c -o $@ $(LDFLAGS) -Wl,--start-group -lad9361 -liio $(LDLIBS) -Wl,--end-group
$(BIN)/ofdm_bench: $(SRC)/ofdm_bench.c | $(BIN)
$(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) $(LDLIBS)
# UDP-туннель (roadmap §10 п.8) — самостоятельный бинарь, без iio/liquid/fec.
# Работает НА плате (piped с transmitter/receiver), поэтому та же кросс-сборка.
$(BIN)/udp_gw: $(SRC)/udp_gw.c | $(BIN)
$(CC) $(CFLAGS) $< -o $@
# Тестовый генератор/приёмник UDP для бринг-апа udp_gw (busybox без nc/socat).
$(BIN)/udp_probe: $(SRC)/udp_probe.c | $(BIN)
$(CC) $(CFLAGS) $< -o $@
# Оффлайн-самопроверка GF(256) — нативная сборка (gcc хоста, напр. RPi/aarch64)
$(BIN)/gftest: $(SRC)/group_fec.c $(SRC)/group_fec.h | $(BIN)
cc -O2 -Wall -DGF_SELFTEST $(SRC)/group_fec.c -o $@
# Оффлайн-самопроверка парсера udp_gw — та же логика, нативная сборка.
$(BIN)/gwtest: $(SRC)/udp_gw.c | $(BIN)
cc -O2 -Wall -Wextra -DGW_SELFTEST $(SRC)/udp_gw.c -o $@
tx: $(BIN)/transmitter
rx: $(BIN)/receiver
bench: $(BIN)/ofdm_bench
gw: $(BIN)/udp_gw
probe: $(BIN)/udp_probe
gftest: $(BIN)/gftest
$(BIN)/gftest
gwtest: $(BIN)/gwtest
$(BIN)/gwtest
# --- деплой (scp -O: на прошивке v0.38 нет sftp-server) ---
deploy: all
@@ -64,13 +81,14 @@ clean:
rm -rf $(BIN)
help:
@echo "make [all] — собрать transmitter, receiver, ofdm_bench (static armhf)"
@echo "make tx|rx|bench — собрать отдельную цель"
@echo "make [all] — собрать transmitter, receiver, ofdm_bench, udp_gw, udp_probe (static armhf)"
@echo "make tx|rx|bench|gw|probe — собрать отдельную цель"
@echo "make gftest — оффлайн-самопроверка group-FEC (нативный cc)"
@echo "make gwtest — оффлайн-самопроверка парсера udp_gw (нативный cc)"
@echo "make deploy — залить бинарники на обе платы (scp -O в /tmp)"
@echo "make test — e2e-тест передачи файла с md5-сверкой"
@echo "make check-env — проверить тулчейн и $(XARM)"
@echo "make clean — удалить build/"
@echo "переменные: PLUTO_A=$(PLUTO_A) PLUTO_B=$(PLUTO_B) XARM=$(XARM)"
.PHONY: all tx rx bench gftest deploy test check-env clean help
.PHONY: all tx rx bench gw probe gftest gwtest deploy test check-env clean help

View File

@@ -17,7 +17,7 @@ else
SCP=(scp -O)
fi
FILES=(build/transmitter build/receiver build/ofdm_bench)
FILES=(build/transmitter build/receiver build/ofdm_bench build/udp_gw build/udp_probe)
TARGETS=()
case "${1:-}" in

146
scripts/test_arq.sh Executable file
View File

@@ -0,0 +1,146 @@
#!/usr/bin/env bash
# test_arq.sh — e2e-тест ARQ и EOF-хендшейка (этап 3, §10 п.7, техдолг #6).
#
# Отличие от test_fdd_fwd.sh: там успех = md5 совпал, а завершение обеих сторон
# организует скрипт (killall + сторожевой kill). Здесь проверяется сам ПРОТОКОЛ:
# • B, собрав файл целиком, поднимает FB_FLAG_COMPLETE в STATUS и выходит САМ;
# • A ждёт этот COMPLETE и возвращает 0; не дождался за 10 с → exit 2.
# Поэтому сторожевой kill приёмника = провал (B не смог завершиться сам), а
# ненулевой код A — не аварийная ситуация, а штатный отчёт «файл не подтверждён».
#
# Использование: scripts/test_arq.sh [ipA] [ipB] [размер_КБ]
# SIZE_KB=10240 — длинный прогон (или третьим позиционным аргументом)
# TXGAIN=-33 — стресс: высокий PER, ARQ обязан вытянуть md5. Клифф на
# кабеле резкий: 30 ≈ PER 0.4%, 38 и глубже — CRC 0
# (канала нет вовсе, проверять там нечего)
# NEGATIVE=1 FBGAIN=-89 — обратный канал заглушён: успех = A НЕ подтвердил
# FBGAIN (деф. 15) — середина рабочего окна 20…10 (README §12.8)
# FBFREQ, PAUSE, TXGAIN, FEC, GROUPFEC — как в test_fdd_fwd.sh
# ВНИМАНИЕ: сначала кабель + аттенюатор 3040 дБ!
set -euo pipefail
source "$(dirname "$0")/../toolchain.env"
A="${1:-$PLUTO_A}" # передатчик данных (TX 915 / fb-RX 868)
B="${2:-$PLUTO_B}" # приёмник данных (RX 915 / fb-TX 868)
SIZE_KB="${3:-${SIZE_KB:-1024}}"
FREQ=915000000
FBFREQ="${FBFREQ:-868000000}"
RATE=1920000 # НЕ поднимать: бюджет CPU, docs/BENCHMARK.md
BW=1500000
TXGAIN="${TXGAIN:--30}"
FBGAIN="${FBGAIN:--15}" # штатный обратный канал; 89 ≈ выкл (NEGATIVE)
FEC="${FEC:-rs8}"
PAUSE="${PAUSE:-4000}" # §12.7: соук/маргинальный канал — только 4000, не 3000
GROUPFEC="${GROUPFEC:-1}"
NEGATIVE="${NEGATIVE:-0}"
[ "$GROUPFEC" = 1 ] && GFLAG="-G" || GFLAG=""
# +40 (против +20 у test_fdd_fwd): END-фаза до 10 с плюс запас на ретрансмиты.
TIMEOUT=$(( SIZE_KB / 85 + 40 ))
if command -v sshpass >/dev/null; then
SSH=(sshpass -p "$PLUTO_PASS" ssh -o StrictHostKeyChecking=no)
SCP=(sshpass -p "$PLUTO_PASS" scp -O -o StrictHostKeyChecking=no)
else
SSH=(ssh); SCP=(scp -O)
echo "подсказка: apt install sshpass — и пароль вводить не придётся"
fi
echo "=== ARQ e2e: A=$A (TX $((FREQ/1000000))/fb-RX $((FBFREQ/1000000))) → B=$B ==="
echo " ${SIZE_KB} КБ TXGAIN=$TXGAIN FBGAIN=$FBGAIN PAUSE=$PAUSE GROUPFEC=$GROUPFEC NEGATIVE=$NEGATIVE"
echo "=== [1/6] Тестовый файл ${SIZE_KB} КБ ==="
dd if=/dev/urandom of=/tmp/arq_test.bin bs=1024 count="$SIZE_KB" status=none
MD5_SRC=$(md5sum /tmp/arq_test.bin | cut -d' ' -f1)
echo "md5: $MD5_SRC"
echo "=== [2/6] Зачистка хвостов на обеих платах ==="
# Остаточный процесс держит RX-DMA → пустой out.bin (грабля из README §12.6).
"${SSH[@]}" "root@$A" "killall -9 transmitter receiver 2>/dev/null || true"
"${SSH[@]}" "root@$B" "killall -9 transmitter receiver 2>/dev/null || true"
echo "=== [3/6] Заливка файла на Pluto A ($A) ==="
"${SCP[@]}" /tmp/arq_test.bin "root@$A:/tmp/"
echo "=== [4/6] Старт приёмника на Pluto B ($B), сторож ${TIMEOUT}с ==="
# busybox без `timeout` → сторожевой sleep+kill на плате. Штатно B выходит САМ по
# COMPLETE задолго до сторожа; сторож снимается сразу после выхода B, иначе ssh
# висел бы до конца sleep и скрывал реальное время завершения.
"${SSH[@]}" "root@$B" \
"rm -f /tmp/out.bin; /tmp/receiver -f $FREQ -r $RATE -b $BW -c $FEC \
-F $FBFREQ -g $FBGAIN -u local: > /tmp/out.bin 2>/tmp/rx_arq.log & \
RXP=\$!; (sleep $TIMEOUT; kill \$RXP 2>/dev/null) & WDP=\$!; \
wait \$RXP; RC=\$?; kill \$WDP 2>/dev/null; exit \$RC" &
RX_PID=$!
sleep 4 # дать RX прогреться (FDD-инициализация двух трактов дольше)
echo "=== [5/6] Передача с Pluto A + EOF-хендшейк ==="
TX_RC=0
"${SSH[@]}" "root@$A" \
"cat /tmp/arq_test.bin | /tmp/transmitter -f $FREQ -r $RATE -b $BW \
-g $TXGAIN -p $PAUSE -c $FEC $GFLAG -F $FBFREQ -u local: 2>/tmp/tx_arq.log" \
|| TX_RC=$?
echo "код возврата передатчика: $TX_RC (0 = COMPLETE подтверждён, 2 = таймаут)"
wait "$RX_PID" 2>/dev/null || true # B выходит сам либо снимается сторожем
"${SSH[@]}" "root@$B" "killall receiver 2>/dev/null || true" # страховка
echo "=== [6/6] Сверка ==="
MD5_DST=$("${SSH[@]}" "root@$B" "md5sum /tmp/out.bin 2>/dev/null | cut -d' ' -f1" || echo ет_файла")
SIZE_DST=$("${SSH[@]}" "root@$B" "wc -c < /tmp/out.bin 2>/dev/null" || echo 0)
echo "источник: $MD5_SRC ($((SIZE_KB*1024)) байт)"
echo "приёмник: $MD5_DST ($SIZE_DST байт)"
# Самозавершение B: маркер печатается ТОЛЬКО когда COMPLETE ушёл в эфир и B
# закрывается штатно. Нет маркера → B досидел до сторожевого kill = провал 3a.
SELF_DONE=0
"${SSH[@]}" "root@$B" "grep -q 'завершаю приём' /tmp/rx_arq.log 2>/dev/null" && SELF_DONE=1 || true
echo "--- Хендшейк ---"
"${SSH[@]}" "root@$B" "grep ARQ /tmp/rx_arq.log 2>/dev/null | sed 's/^/B: /'" || true
"${SSH[@]}" "root@$A" "grep ARQ /tmp/tx_arq.log 2>/dev/null | sed 's/^/A: /'" || true
echo "B самозавершился: $([ "$SELF_DONE" = 1 ] && echo да || echo 'НЕТ (снят сторожем)')"
echo "--- Обратный канал STATUS (B→A) ---"
SENT=$("${SSH[@]}" "root@$B" "grep -o 'отправлено STATUS: [0-9]*' /tmp/rx_arq.log 2>/dev/null | grep -o '[0-9]*'" || echo "")
RECV=$("${SSH[@]}" "root@$A" "grep -o 'принято STATUS: [0-9]*' /tmp/tx_arq.log 2>/dev/null | grep -o '[0-9]*'" || echo "")
SENT=${SENT:-0}; RECV=${RECV:-0}
if [ "$SENT" -gt 0 ]; then
echo "B отправил: $SENT | A принял: $RECV | доставка ≈ $(( RECV * 100 / SENT ))%"
else
echo "B отправил: $SENT | A принял: $RECV"
fi
echo "--- RX-статистика (PER) ---"
"${SSH[@]}" "root@$B" "grep '^Кадров:' /tmp/rx_arq.log 2>/dev/null | tail -1" || true
# Ретрансмиты — главная метрика 3b: сырой PER остаётся, но md5 обязан сойтись.
"${SSH[@]}" "root@$A" "grep 'ретрансмитов' /tmp/tx_arq.log 2>/dev/null | tail -1 | sed 's/^/A: /'" || true
echo "=== РЕЗУЛЬТАТ ==="
if [ "$NEGATIVE" = 1 ]; then
# Обратный канал заглушён: B файл соберёт и выйдет сам, но подтвердить A
# некому. Проверяем именно честность отказа — раньше A рапортовал успех.
if [ "$TX_RC" != 0 ]; then
echo "OK (негатив) — A не получил COMPLETE и честно вышел с кодом $TX_RC"
else
echo "FAIL (негатив) — A вернул 0 без обратного канала: хендшейк фиктивен"
exit 1
fi
else
if [ "$MD5_SRC" = "$MD5_DST" ] && [ "$SELF_DONE" = 1 ] && [ "$TX_RC" = 0 ]; then
echo "OK — md5 совпал, B завершился по COMPLETE, A подтвердил"
else
echo "FAIL: md5 $([ "$MD5_SRC" = "$MD5_DST" ] && echo ок || echo РАСХОЖДЕНИЕ) |" \
"самозавершение $([ "$SELF_DONE" = 1 ] && echo ок || echo НЕТ) | код A $TX_RC"
echo "Диагностика:"
echo " - md5 разошёлся при коде A=0 → COMPLETE поднят рано (проверь miss/next_out)."
echo " - код A=2 при целом md5 → COMPLETE не долетел: подними FBGAIN к 10."
echo " - размер 0 → RX не декодит: остаточный процесс на B или самоглушение."
echo " - CRC: 0 при живых 'Заголовках' → канал за клиффом (TXGAIN ≤ 38 на кабеле):"
echo " заголовок BPSK ещё держится, payload QPSK — нет. Это не стресс ARQ, а обрыв."
echo " - 'NACK вне окна' > 0 на B → дыра пережила TX-окно (2048 кадров ≈ 21 с)."
echo "--- хвост rx_arq.log (B) ---"
"${SSH[@]}" "root@$B" "tail -8 /tmp/rx_arq.log" || true
exit 1
fi
fi

View File

@@ -83,6 +83,23 @@ echo "--- FDD-ридбэк ---"
"${SSH[@]}" "root@$A" "grep 'FDD ридбэк' /tmp/tx_fdd.log 2>/dev/null | sed 's/^/A: /'" || true
"${SSH[@]}" "root@$B" "grep 'FDD ридбэк' /tmp/rx_fdd.log 2>/dev/null | sed 's/^/B: /'" || true
# Обратный канал (этап 2): сколько STATUS B отправил и сколько A декодировал.
# Доля доставки — метрика gate самоглушения (§10 п.7).
echo "--- Обратный канал STATUS (B→A) ---"
SENT=$("${SSH[@]}" "root@$B" "grep -o 'отправлено STATUS: [0-9]*' /tmp/rx_fdd.log 2>/dev/null | grep -o '[0-9]*'" || echo "")
RECV=$("${SSH[@]}" "root@$A" "grep -o 'принято STATUS: [0-9]*' /tmp/tx_fdd.log 2>/dev/null | grep -o '[0-9]*'" || echo "")
SENT=${SENT:-0}; RECV=${RECV:-0}
if [ "$SENT" -gt 0 ]; then
echo "B отправил: $SENT | A принял: $RECV | доставка ≈ $(( RECV * 100 / SENT ))%"
else
echo "B отправил: $SENT | A принял: $RECV"
fi
# Итоговая RX-статистика — источник PER (Потери seq / Байт), нужна каждый прогон,
# особенно при GROUPFEC=0 (сырой PER без маскировки групповым FEC).
echo "--- RX-статистика (PER) ---"
"${SSH[@]}" "root@$B" "grep '^Кадров:' /tmp/rx_fdd.log 2>/dev/null | tail -1" || true
if [ "$MD5_SRC" = "$MD5_DST" ]; then
echo "РЕЗУЛЬТАТ: OK — форвард-линк под FDD цел (FBGAIN=$FBGAIN)"
else

145
scripts/test_udp_tunnel.sh Normal file
View File

@@ -0,0 +1,145 @@
#!/usr/bin/env bash
# test_udp_tunnel.sh — e2e-тест UDP-туннеля (§10 п.8):
# хост-файл → udp_probe send → UDP → udp_gw -l → transmitter → ЭФИР →
# receiver → udp_gw -d → UDP → udp_probe recv → файл → md5-сверка
# Использование: scripts/test_udp_tunnel.sh [ipA] [ipB] [размер_КБ]
# По умолчанию: адреса из toolchain.env, 64 КБ, симплекс 915 МГц.
#
# ВНИМАНИЕ: правило №7 (кабель+аттенюатор) для udp_gw не применимо — шлюз не
# трогает PHY ни на байт (record-слой поверх уже принятого байтового потока),
# его логика проверена `make gwtest` + loopback на плате. Прогон штатно идёт
# на антеннах: TXGAIN=-20 scripts/test_udp_tunnel.sh
set -euo pipefail
source "$(dirname "$0")/../toolchain.env"
A="${1:-$PLUTO_A}" # передатчик
B="${2:-$PLUTO_B}" # приёмник
SIZE_KB="${3:-64}"
FREQ=915000000
RATE=1920000 # НЕ поднимать: бюджет CPU, docs/BENCHMARK.md
BW=1500000
TXGAIN="${TXGAIN:--30}" # OTA: переопредели, напр. TXGAIN=-20 scripts/test_udp_tunnel.sh
FEC="${FEC:-rs8}"
PAUSE="${PAUSE:-3000}" # ≤1 МБ — 3000 чист (§12.6); длинные прогоны — 4000
GROUPFEC="${GROUPFEC:-1}"
[ "$GROUPFEC" = 1 ] && GFLAG="-G" || GFLAG=""
PORT_A="${PORT_A:-6000}" # udp_gw -l на плате A (вход туннеля)
PORT_B="${PORT_B:-6001}" # udp_probe recv на плате B (выход туннеля)
CHUNK="${CHUNK:-1000}" # размер датаграммы; >1016 Б рекорд рвётся на 2 кадра
PACE_MS="${PACE_MS:-15}" # 1000 Б / 15 мс ≈ 65 КБ/сс запасом под потолок
# `-G -p 3000` ≈107 КБ/с. На 10 мс (≈100 КБ/с) вход
# упирается в потолок и шлюз штатно дропает.
IDLE="${IDLE:-30}" # сторожевой таймер udp_probe recv. ВАЖНО: тикает
# с момента СТАРТА, не с первой датаграммы → зазор
# старт-B→передача должен быть скриптованным (~4 с),
# а не ручной вставкой команд. Штатное завершение —
# активный killall из шага [5], не по таймауту.
if command -v sshpass >/dev/null; then
SSH=(sshpass -p "$PLUTO_PASS" ssh -o StrictHostKeyChecking=no)
else
SSH=(ssh)
echo "подсказка: apt install sshpass — и пароль вводить не придётся"
fi
if command -v sshpass >/dev/null; then
SCP=(sshpass -p "$PLUTO_PASS" scp -O -o StrictHostKeyChecking=no)
else
SCP=(scp -O)
fi
echo "=== [1/6] Тестовый файл ${SIZE_KB} КБ (чанк ${CHUNK} Б, темп ${PACE_MS} мс) ==="
dd if=/dev/urandom of=/tmp/probe_in.bin bs=1024 count="$SIZE_KB" status=none
MD5_SRC=$(md5sum /tmp/probe_in.bin | cut -d' ' -f1)
DGRAMS=$(( (SIZE_KB * 1024 + CHUNK - 1) / CHUNK ))
echo "md5: $MD5_SRC | ожидается датаграмм: $DGRAMS"
echo "=== [2/6] Заливка файла на Pluto A ($A) ==="
"${SCP[@]}" /tmp/probe_in.bin "root@$A:/tmp/"
echo "=== [3/6] Старт цепочки на Pluto B ($B): receiver | udp_gw -d → udp_probe recv ==="
# heredoc в `sh -s`: stdin ssh занят скриптом → фоновый ssh не ловит SIGTTIN
# (без этого `ssh ... &` уходит в «Остановлен» при попытке читать терминал).
"${SSH[@]}" "root@$B" 'sh -s' <<EOF &
rm -f /tmp/probe_out.bin /tmp/rx.log /tmp/gwd.log /tmp/probe_rx.log
/tmp/receiver -f $FREQ -r $RATE -b $BW -c $FEC -u local: 2>/tmp/rx.log \
| /tmp/udp_gw -d 127.0.0.1:$PORT_B 2>/tmp/gwd.log &
/tmp/udp_probe recv $PORT_B $IDLE > /tmp/probe_out.bin 2>/tmp/probe_rx.log
killall receiver udp_gw 2>/dev/null || true
EOF
B_PID=$!
sleep 4 # дать приёмнику прогреться до старта TX
echo "=== [4/6] Передача с Pluto A: udp_probe send → udp_gw -l → transmitter ==="
# mkfifo: у пайпа `$!` — только последний процесс, а гейтвей надо убить отдельно
# (его выход = EOF транзмиттеру → штатное завершение передачи).
"${SSH[@]}" "root@$A" 'sh -s' <<EOF
rm -f /tmp/tx.log /tmp/gwl.log /tmp/probe_tx.log /tmp/gw.fifo
mkfifo /tmp/gw.fifo
/tmp/udp_gw -l $PORT_A > /tmp/gw.fifo 2>/tmp/gwl.log &
GWP=\$!
/tmp/transmitter -f $FREQ -r $RATE -b $BW -g $TXGAIN -p $PAUSE \
-c $FEC $GFLAG -u local: < /tmp/gw.fifo 2>/tmp/tx.log &
TXP=\$!
# Ждём готовности транзмиттера, а не спим вслепую: инициализация iio (LO, буферы)
# занимает секунды, а из пайпа он до неё не читает. Пайп 64 КБ ≈ весь тестовый
# файл → шлюз штатно дропает хвост по бэкпрешеру, если начать лить раньше.
i=0
while [ \$i -lt 20 ]; do
grep -q "Ожидание данных" /tmp/tx.log 2>/dev/null && break
i=\$((i+1)); sleep 1
done
/tmp/udp_probe send 127.0.0.1:$PORT_A $CHUNK $PACE_MS \
< /tmp/probe_in.bin 2>/tmp/probe_tx.log
sleep 2
kill \$GWP 2>/dev/null || true
wait \$TXP
rm -f /tmp/gw.fifo
EOF
echo "=== [5/6] Гашение цепочки B ==="
sleep 3 # дохлебать хвост эфира
# ПОРЯДОК ГАШЕНИЯ КРИТИЧЕН. receiver.c:798 — group_flush()+rb_flush_all() стоят
# ПОСЛЕ основного цикла: хвост незавершённой группы (до 16 кадров) уходит в
# stdout только в момент смерти приёмника. Поэтому гасим строго по течению:
# receiver (флашит хвост) → пауза на прохождение через udp_gw -d → udp_probe.
# Убить их разом = потерять последнюю группу (было: доставлено 64, принято 48).
"${SSH[@]}" -n "root@$B" "killall receiver 2>/dev/null || true" || true
sleep 3 # хвост: receiver → пайп → udp_gw -d (выйдет сам по EOF) → UDP → probe
"${SSH[@]}" -n "root@$B" "killall udp_probe udp_gw 2>/dev/null || true" || true
wait "$B_PID" 2>/dev/null || true
echo "=== [6/6] Сверка ==="
MD5_DST=$("${SSH[@]}" -n "root@$B" "md5sum /tmp/probe_out.bin 2>/dev/null | cut -d' ' -f1" || echo ет_файла")
SIZE_DST=$("${SSH[@]}" -n "root@$B" "wc -c < /tmp/probe_out.bin 2>/dev/null" || echo 0)
echo "источник: $MD5_SRC ($((SIZE_KB*1024)) байт)"
echo "туннель: $MD5_DST ($SIZE_DST байт)"
echo
echo "--- A: udp_probe send ---"; "${SSH[@]}" -n "root@$A" "cat /tmp/probe_tx.log" || true
echo "--- A: udp_gw -l (вход) ---"; "${SSH[@]}" -n "root@$A" "tail -2 /tmp/gwl.log" || true
echo "--- A: transmitter ---"; "${SSH[@]}" -n "root@$A" "tail -4 /tmp/tx.log" || true
echo "--- B: receiver ---"; "${SSH[@]}" -n "root@$B" "tail -6 /tmp/rx.log" || true
echo "--- B: udp_gw -d (выход) ---";"${SSH[@]}" -n "root@$B" "tail -2 /tmp/gwd.log" || true
echo "--- B: udp_probe recv ---"; "${SSH[@]}" -n "root@$B" "cat /tmp/probe_rx.log" || true
echo
if [ "$MD5_SRC" = "$MD5_DST" ]; then
echo "РЕЗУЛЬТАТ: OK — туннель прозрачен, $DGRAMS датаграмм доставлены байт-в-байт"
else
echo "РЕЗУЛЬТАТ: FAIL — разбор по счётчикам выше:"
echo " - 'дыр(id)' > 0 или 'ресинк' > 0 при живом потоке → НЕ баг туннеля:"
echo " радиопотери сверх group-FEC 16+2 (на симплексе ARQ нет, техдолг #6)."
echo " Контракт соблюдён: дыры посчитаны, поток рекордов не развалился."
echo " Меры: TXGAIN ближе к 20, антенны разнести/переориентировать."
echo " - 'дропов' > 0 у 'udp_gw -l (вход)' → вход быстрее линка (~107 КБ/с"
echo " на -G -p 3000): подними PACE_MS. Это контрактный дроп, семантика UDP."
echo " Если дропов ровно 1-2 в конце — транзмиттер ещё не читал пайп на"
echo " старте (см. ожидание 'Ожидание данных' в шаге [4])."
echo " - 'доставлено' > принятых probe → гашение обогнало хвост приёмника"
echo " (receiver.c:798 флашит последнюю группу только при выходе)."
echo " - размер 0 и в rx.log 0 кадров → приёмник не работал во время передачи:"
echo " частота/усиление/антенны, либо цепочка B умерла по IDLE (=$IDLE с)."
echo " - размер совпал, md5 нет → искажение полезной нагрузки: техдолг #1/#3."
fi

View File

@@ -46,9 +46,12 @@
#define TX_URI_DEFAULT "local:"
#define RX_URI_DEFAULT "local:"
// Формат заголовка кадра
// Формат заголовка кадра. hdr[0..1] различает тип: данные/паритет GF — F0 AA,
// END (маркер конца передачи, этап 3) — F0 E7, STATUS обратного канала — F0 55
// (FB_SIG_* в feedback.h). Байт 0 общий у всех типов.
#define HDR_SIGNATURE_0 0xF0
#define HDR_SIGNATURE_1 0xAA
#define HDR_SIG_END_1 0xE7
#define HDR_SIZE 12
// Макросы логирования

70
src/feedback.c Normal file
View File

@@ -0,0 +1,70 @@
// feedback.c — упаковка/распаковка STATUS обратного канала (этап 2, §10 п.7).
// Чистая логика без зависимостей от liquid/iio → пригодна к офлайн-юнит-тесту.
#include "feedback.h"
#include <string.h>
static void put_u32(uint8_t *p, uint32_t v) {
p[0] = (v >> 24) & 0xFF; p[1] = (v >> 16) & 0xFF;
p[2] = (v >> 8) & 0xFF; p[3] = v & 0xFF;
}
static uint32_t get_u32(const uint8_t *p) {
return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) |
((uint32_t)p[2] << 8) | (uint32_t)p[3];
}
static void put_u64(uint8_t *p, uint64_t v) {
put_u32(p, (uint32_t)(v >> 32));
put_u32(p + 4, (uint32_t)(v & 0xFFFFFFFFu));
}
static uint64_t get_u64(const uint8_t *p) {
return ((uint64_t)get_u32(p) << 32) | (uint64_t)get_u32(p + 4);
}
size_t fb_status_pack(const fb_status_t *s, uint8_t *out) {
uint8_t cnt = s->nack_cnt > FB_MAX_NACK ? FB_MAX_NACK : s->nack_cnt;
out[0] = FB_TYPE_STATUS;
out[1] = s->flags;
out[2] = cnt;
out[3] = 0; // резерв
put_u32(out + 4, s->highest_seq);
put_u32(out + 8, s->rx_frames);
put_u32(out + 12, s->overruns);
for (uint8_t i = 0; i < cnt; i++)
put_u32(out + FB_STATUS_HDR + (size_t)i * 4, s->nack[i]);
return (size_t)FB_STATUS_HDR + (size_t)cnt * 4;
}
int fb_status_unpack(const uint8_t *in, size_t len, fb_status_t *s) {
if (len < FB_STATUS_HDR) return -1;
if (in[0] != FB_TYPE_STATUS) return -1;
uint8_t cnt = in[2];
if (cnt > FB_MAX_NACK) return -1;
if (len < (size_t)FB_STATUS_HDR + (size_t)cnt * 4) return -1;
memset(s, 0, sizeof(*s));
s->type = in[0];
s->flags = in[1];
s->nack_cnt = cnt;
s->highest_seq = get_u32(in + 4);
s->rx_frames = get_u32(in + 8);
s->overruns = get_u32(in + 12);
for (uint8_t i = 0; i < cnt; i++)
s->nack[i] = get_u32(in + FB_STATUS_HDR + (size_t)i * 4);
return 0;
}
size_t fb_end_pack(const fb_end_t *e, uint8_t *out) {
put_u32(out, e->total_seq);
put_u64(out + 4, e->total_bytes);
return FB_END_LEN;
}
int fb_end_unpack(const uint8_t *in, size_t len, fb_end_t *e) {
if (len < FB_END_LEN) return -1;
e->total_seq = get_u32(in);
e->total_bytes = get_u64(in + 4);
return 0;
}

63
src/feedback.h Normal file
View File

@@ -0,0 +1,63 @@
// feedback.h — обратный канал STATUS (roadmap §10 п.7, этап 2).
// STATUS — кумулятивный СНИМОК состояния RX, а не поток событий: потеря одного
// STATUS безвредна, следующий несёт то же состояние (ARQ-над-ARQ не нужен).
// Идёт B→A на частоте обратного канала (868); сигнатура кадра F0 55 в hdr[0..1]
// (данные — F0 AA, END — F0 E7). Payload RS(255,223)+CRC32, как у данных.
#ifndef FEEDBACK_H
#define FEEDBACK_H
#include <stdint.h>
#include <stddef.h>
#define FB_TYPE_STATUS 0x01
#define FB_MAX_NACK 50 // потолок NACK в одном STATUS (влезает в 1 RS-блок)
#define FB_FLAG_END_SEEN 0x01 // RX видел END-кадр (этап 3)
#define FB_FLAG_COMPLETE 0x02 // все кадры собраны, файл цел (этап 3)
// Сигнатура STATUS-кадра в hdr[0..1].
#define FB_SIG_0 0xF0
#define FB_SIG_1 0x55
// Снимок состояния приёмника. nack[] — отсортированные по возрастанию seq
// отсутствующих кадров данных (старейшие первыми), не более FB_MAX_NACK.
typedef struct {
uint8_t type; // FB_TYPE_STATUS
uint8_t flags; // FB_FLAG_END_SEEN | FB_FLAG_COMPLETE
uint8_t nack_cnt; // число валидных элементов nack[] (≤ FB_MAX_NACK)
uint32_t highest_seq; // макс. принятый seq (0xFFFFFFFF = ничего)
uint32_t rx_frames; // кадров данных записано в stdout
uint32_t overruns; // Overrun ядра захвата (диагностика линии)
uint32_t nack[FB_MAX_NACK];
} fb_status_t;
// Раскладка payload (сетевой порядок, BE):
// 0 type | 1 flags | 2 nack_cnt | 3 rsv | 4..7 highest | 8..11 rx_frames
// 12..15 overruns | 16.. nack[i] u32
#define FB_STATUS_HDR 16 // фикс. часть
#define FB_STATUS_MAXLEN (FB_STATUS_HDR + FB_MAX_NACK * 4) // 216 ≤ 219 → 1 RS-блок
// Упаковка → out (≥ FB_STATUS_MAXLEN). Возврат — длина payload в байтах.
size_t fb_status_pack(const fb_status_t *s, uint8_t *out);
// Распаковка. Возврат 0 при успехе, -1 при кривой длине/типе.
int fb_status_unpack(const uint8_t *in, size_t len, fb_status_t *s);
// --- END-кадр A→B (этап 3, сигнатура F0 E7 в hdr[0..1]) -----------------
// Хвостовые потери молчаливы: у последних кадров нет следующего seq, по которому
// видна дыра. END несёт итог передачи — RX добивает недостающие seq в NACK и
// понимает, когда файл собран целиком (COMPLETE). Идёт тем же путём RS+CRC32,
// что и данные (1 RS-блок). Тип не кодируется в payload — его задаёт сигнатура.
typedef struct {
uint32_t total_seq; // отправлено кадров: валидные seq = 0..total_seq-1
uint64_t total_bytes; // исходных байт всего (сверка длины файла)
} fb_end_t;
// Раскладка payload (BE): 0..3 total_seq | 4..11 total_bytes
#define FB_END_LEN 12
// Упаковка → out (≥ FB_END_LEN). Возврат — длина payload.
size_t fb_end_pack(const fb_end_t *e, uint8_t *out);
// Распаковка. Возврат 0 при успехе, -1 при кривой длине.
int fb_end_unpack(const uint8_t *in, size_t len, fb_end_t *e);
#endif

View File

@@ -68,3 +68,33 @@ void phy_tx_frame(ofdmflexframegen fg, struct iio_buffer *buf, int buf_samples,
if (idx > 0) iio_buffer_push_partial(buf, idx);
if (pause_us > 0) usleep(pause_us);
}
int phy_rx_decode(const uint8_t *hdr, const uint8_t *pay, unsigned pay_len,
fec dec, uint8_t *out, size_t out_cap, size_t *out_len) {
if (!dec || pay_len < RS_ENC) return 0;
size_t nblocks = pay_len / RS_ENC;
if (nblocks == 0 || nblocks * RS_DATA > out_cap) return 0;
for (size_t b = 0; b < nblocks; b++)
// fec_decode() liquid не const-correct и «всегда успешен» — код возврата
// бесполезен, целостность проверяем CRC ниже (техдолг #3).
fec_decode(dec, RS_DATA, (unsigned char *)pay + b * RS_ENC,
out + b * RS_DATA);
uint16_t original_len = (uint16_t)((hdr[6] << 8) | hdr[7]);
uint8_t last_block_bytes = hdr[10];
size_t framed_len = (nblocks - 1) * RS_DATA +
(last_block_bytes > 0 ? last_block_bytes : RS_DATA);
if (framed_len > nblocks * RS_DATA) framed_len = nblocks * RS_DATA;
if (original_len == 0 || (size_t)original_len + 4 > framed_len) return 0;
unsigned int rx_crc = crc_generate_key(LIQUID_CRC_32, out, original_len);
unsigned int tx_crc = ((uint32_t)out[original_len] << 24) |
((uint32_t)out[original_len + 1] << 16) |
((uint32_t)out[original_len + 2] << 8) |
(uint32_t)out[original_len + 3];
if (rx_crc != tx_crc) return 0;
*out_len = original_len;
return 1;
}

View File

@@ -17,4 +17,14 @@ void phy_tx_frame(ofdmflexframegen fg, struct iio_buffer *buf, int buf_samples,
fec enc, const uint8_t sig[2], uint32_t seq, uint8_t hdr11,
const uint8_t *pl, size_t n, float amp, int pause_us);
// Обратная операция к phy_tx_frame для кадра, собранного с enc: RS-декод всех
// блоков payload + проверка CRC32-поверх-RS (единственный надёжный критерий,
// техдолг #3). hdr — 12 Б заголовка (нужны байты 6-7 original_len и 10
// last_block_bytes); pay/pay_len — сырой RS-payload из ofdmflexframesync; dec —
// декодер RS(255,223); out (≥ out_cap) принимает декодированные данные, out_len
// — исходная длина. Возврат 1 при пройденном CRC, 0 иначе. Используется обратным
// каналом (fb_rx) на TX-стороне; RX-данные пока декодит свой инлайн в receiver.c.
int phy_rx_decode(const uint8_t *hdr, const uint8_t *pay, unsigned pay_len,
fec dec, uint8_t *out, size_t out_cap, size_t *out_len);
#endif

View File

@@ -165,6 +165,35 @@ static void bench_rs(int nerr, double frame_us)
fec_destroy(enc); fec_destroy(dec);
}
/* Стоимость генерации короткого кадра обратного канала (STATUS: RS-payload ≈ 1
* блок = 255 Б). Этап 2 §10 п.7: модуляция фидбэка идёт на ядре 1 приёмника
* (поток C) — убедиться, что assemble+write дёшевы и 10 Гц STATUS не грузят ядро.
* Меряем полную генерацию кадра (assemble + write всех символов). */
static void bench_framegen(ofdmflexframegen fg, unsigned pay_bytes)
{
const unsigned sym = OFDM_M + OFDM_CP;
unsigned char hdr[12] = {0xF0, 0x55, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
unsigned char pay[512];
if (pay_bytes > sizeof(pay)) pay_bytes = sizeof(pay);
for (unsigned i = 0; i < pay_bytes; i++) pay[i] = (unsigned char)(i & 0xFF);
float complex buf[OFDM_M + OFDM_CP];
unsigned long long frames = 0;
double t0 = tnow(), t1;
do {
ofdmflexframegen_assemble(fg, hdr, pay, pay_bytes);
int done = 0;
while (!done) done = ofdmflexframegen_write(fg, buf, sym);
frames++;
t1 = tnow();
} while (t1 - t0 < 2.0);
double us = (t1 - t0) * 1e6 / (double)frames;
/* STATUS 10 Гц → доля ядра = 10·us/1e6, в процентах = us·1e-3 */
printf("framegen кадр %uБ %7.1f мкс/кадр %8.0f кадров/с (STATUS 10 Гц ≈ %.3f%% ядра)\n",
pay_bytes, us, (double)frames / (t1 - t0), us * 10.0 / 1e6 * 100.0);
}
int main(int argc, char **argv)
{
int main_cpu = 0, use_hog = 0, opt;
@@ -271,6 +300,10 @@ int main(int argc, char **argv)
bench_rs(4, frame_us);
bench_rs(16, frame_us);
/* обратный канал: генерация короткого STATUS-кадра (§10 п.7, этап 2) */
printf("\n");
bench_framegen(fg, 255); /* RS-payload STATUS ≈ 1 блок */
/* p_min: минимальная TX-пауза для realtime @ 1.92 при чистой sync-only цепочке
* (без RS, без контеншна если без -H). d/S + (1-d)/N = 1/1.92; d = frm/(frm+p) */
printf("\nМодель realtime @ %.2f Msps (sync-only%s):\n",

View File

@@ -9,6 +9,8 @@
#include "common.h" // _GNU_SOURCE/pthread/sched — уже подключены здесь
#include "group_fec.h"
#include "frame_tx.h" // phy_tx_frame — отправка STATUS обратного канала
#include "feedback.h" // fb_status_t / pack — телеметрия B→A (этап 2, §10 п.7)
#define BUF_SAMPLES 32768
#define RING_SLOTS 16 // 16 × 17 мс = 273 мс поглощения всплесков
@@ -73,6 +75,9 @@ static uint64_t grp_recovered = 0, grp_failed = 0; // group-FEC: восст.
static uint64_t sig_bad = 0; // hdr[0..1] не совпал с известной сигнатурой
static uint32_t highest_seq = 0; // максимальный увиденный seq (не откатывается назад)
static int seq_init = 0;
static uint32_t next_out = 0; // курсор вывода: следующий seq в stdout (этап 3b)
static uint64_t rb_over = 0; // кадров отброшено переполнением окна reorder
static uint64_t miss_rebuilt = 0; // пересборок NACK из кольца (этап 3c)
// Разделяемых на запись счётчиков нет. main читает их раз в секунду для
// телеметрии без синхронизации: на 32-бит ARM возможен косметический «рваный»
// показ 64-бит значения — на итоговую строку (печатается после join) не влияет.
@@ -80,6 +85,136 @@ static int seq_init = 0;
static fec decoder = NULL;
static int fec_on = 0;
// --- Missing-tracker обратного канала (этап 2, §10 п.7) -----------------
// Отсортированный по возрастанию набор seq отсутствующих кадров данных. Пишется
// ТОЛЬКО потоком C (в process_frame и maybe_send_status) — синхронизации нет,
// как у прочего C-состояния. Дыры seq → miss_add (по возрастанию), приход кадра
// (в т.ч. будущий ретрансмит) → miss_ack. В STATUS уходят FB_MAX_NACK старейших.
#define MISS_CAP 512
static uint32_t miss[MISS_CAP];
static int miss_cnt = 0;
static uint64_t miss_over = 0; // дыр не влезло в MISS_CAP
static void miss_add(uint32_t seq) {
if (miss_cnt >= MISS_CAP) { miss_over++; return; } // старейшие дыры важнее
miss[miss_cnt++] = seq; // highest_seq только растёт → массив сортирован
}
// Вставка с сохранением порядка и дедупом — для seq, который МЕНЬШЕ фронта:
// битый кадр (rs_fail) пришёл, дырой в seq не выглядит, но дослать его надо.
// miss_add для такого seq сломал бы сортировку (в STATUS уходят FB_MAX_NACK
// первых элементов — они обязаны быть старейшими).
static void miss_add_sorted(uint32_t seq) {
int i;
for (i = 0; i < miss_cnt; i++) {
if (miss[i] == seq) return; // уже в списке
if (miss[i] > seq) break; // точка вставки
}
if (miss_cnt >= MISS_CAP) { miss_over++; return; }
memmove(&miss[i + 1], &miss[i], (size_t)(miss_cnt - i) * sizeof(miss[0]));
miss[i] = seq;
miss_cnt++;
}
static void miss_ack(uint32_t seq) {
for (int i = 0; i < miss_cnt; i++) {
if (miss[i] == seq) {
memmove(&miss[i], &miss[i + 1], (size_t)(miss_cnt - i - 1) * sizeof(miss[0]));
miss_cnt--;
return;
}
}
}
// --- EOF-хендшейк (этап 3, поток C) -------------------------------------
// END (F0 E7) несёт итог передачи: сколько кадров и байт ушло в эфир. Без него
// хвостовые потери молчаливы — у последних кадров нет следующего seq, по которому
// детектор видит дыру. Приняв END, RX добивает недостающие seq в NACK, а собрав
// всё — поднимает FB_FLAG_COMPLETE в STATUS и завершается сам.
#define ARQ_COMPLETE_HOLD_MS 600 // держать COMPLETE ≥6 периодов STATUS: в
// тихом эфире END-фазы доставка ~100%, но
// повтор дешевле, чем таймаут A на 10 с
static int end_seen = 0;
static uint32_t end_total_seq = 0;
static uint64_t end_total_bytes = 0;
static uint64_t end_rx = 0; // принято валидных END (диагностика)
static int64_t complete_since_ms = 0; // 0 = файл ещё не собран
// --- Отправитель STATUS на feedback-TX (только при -F, поток C) ----------
// Свой framegen + буфер на txfb (868), свой RS-энкодер. Кадр STATUS шлётся не
// чаще FB_PERIOD_MS. Данные RX и обратный TX — независимые cf-девайсы (FDD),
// поэтому push сюда не конфликтует с приёмом.
#define FB_BUF_SAMPLES 16384
#define FB_PERIOD_MS 100
#define FB_AMP 0.2f
static ofdmflexframegen g_fb_fg = NULL;
static struct iio_buffer *g_fb_buf = NULL;
static fec g_fb_enc = NULL;
static uint32_t g_fb_seq = 0;
static int64_t g_fb_last_ms = 0;
static const uint8_t SIG_STATUS[2] = { FB_SIG_0, FB_SIG_1 };
static int64_t now_ms(void) {
struct timespec t;
clock_gettime(CLOCK_MONOTONIC, &t);
return (int64_t)t.tv_sec * 1000 + t.tv_nsec / 1000000;
}
// Файл собран? Критерий один для обоих режимов (этап 3c): курсор вывода дошёл до
// конца. Это сильнее, чем «дыр нет»: next_out двигается только по непрерывному
// префиксу реально записанных кадров. Паритетные кадры group-FEC занимают слот
// нулевой длины — они тратят seq, но в файл не идут, и курсор их проходит.
// Держим флаг ARQ_COMPLETE_HOLD_MS, чтобы A успел услышать хотя бы один STATUS,
// затем завершаемся штатным путём через stop (main → join A→B→C).
static void arq_check_complete(void) {
if (!g_fb_fg || !end_seen) return; // симплекс либо передача ещё идёт
int done = (end_total_seq == 0) ||
(miss_cnt == 0 && next_out == end_total_seq);
if (!done) { complete_since_ms = 0; return; }
int64_t t = now_ms();
if (!complete_since_ms) {
complete_since_ms = t; // с этого момента STATUS несёт COMPLETE
fprintf(stderr, "\n[ARQ] COMPLETE: %u кадров, %llu байт — держу флаг %d мс\n",
end_total_seq, (unsigned long long)end_total_bytes,
ARQ_COMPLETE_HOLD_MS);
}
if (t - complete_since_ms >= ARQ_COMPLETE_HOLD_MS) {
fprintf(stderr, "[ARQ] COMPLETE отправлен — завершаю приём\n");
stop = 1;
}
}
static void maybe_send_status(void) {
if (!g_fb_fg) return; // симплекс — обратного канала нет
arq_check_complete(); // до гейта периода: решение о завершении
// не должно ждать своего слота STATUS
int64_t t = now_ms();
if (g_fb_last_ms && t - g_fb_last_ms < FB_PERIOD_MS) return;
g_fb_last_ms = t;
fb_status_t st;
memset(&st, 0, sizeof(st));
st.type = FB_TYPE_STATUS;
st.flags = (end_seen ? FB_FLAG_END_SEEN : 0) |
(complete_since_ms ? FB_FLAG_COMPLETE : 0);
st.highest_seq = seq_init ? highest_seq : 0xFFFFFFFFu;
st.rx_frames = (uint32_t)crc_final;
st.overruns = (uint32_t)ring.overruns; // телеметрия, гонка косметическая
int cnt = miss_cnt < FB_MAX_NACK ? miss_cnt : FB_MAX_NACK;
st.nack_cnt = (uint8_t)cnt;
for (int i = 0; i < cnt; i++) st.nack[i] = miss[i];
uint8_t pay[FB_STATUS_MAXLEN];
size_t n = fb_status_pack(&st, pay);
phy_tx_frame(g_fb_fg, g_fb_buf, FB_BUF_SAMPLES, g_fb_enc,
SIG_STATUS, g_fb_seq++, 0, pay, n, FB_AMP, 0);
}
// callback потока B — лёгкий: телеметрия + копия сырого кадра в очередь C.
static int callback(unsigned char *hdr, int hdr_valid,
unsigned char *pay, unsigned int pay_len,
@@ -122,12 +257,119 @@ static int callback(unsigned char *hdr, int hdr_valid,
return 0;
}
// --- Кольцо переупорядочивания вывода (поток C, этап 3b) ----------------
// До ARQ кадры приходили строго по возрастанию seq и писались в stdout сразу.
// Ретрансмит ломает это: кадр 5 приедет после кадра 40 — прямая запись сдвинула
// бы данные и убила md5. Кольцо по seq % RB_CAP + курсор next_out: в stdout
// уходит только непрерывный префикс. Дыра держит вывод, пока ARQ её не закроет.
// len == 0 — «слот занят, но в файл ничего»: паритетный кадр GF тратит seq, но
// данных не несёт (нужно для этапа 3c, чтобы next_out доходил до total_seq).
#define RB_CAP 1024 // ~4 с эфира при 9.3 мс/кадр; TXWIN у A вдвое
// больше — досылка для любого seq в окне есть
typedef struct {
uint32_t seq;
uint16_t len;
uint8_t filled;
uint8_t data[GF_CHUNK];
} rbslot_t;
static rbslot_t rb[RB_CAP]; // ~1 МБ .bss (next_out/rb_over — выше, к ним
// обращается arq_check_complete до этой секции)
// Выдать в stdout непрерывный префикс, начиная с next_out.
static void rb_drain(void) {
int wrote = 0;
for (;;) {
rbslot_t *s = &rb[next_out % RB_CAP];
if (!s->filled || s->seq != next_out) break; // дыра — дальше нельзя
if (s->len) {
fwrite(s->data, 1, s->len, stdout);
total_bytes += s->len;
crc_final++;
}
s->filled = 0;
next_out++;
wrote = 1;
}
if (wrote) fflush(stdout);
}
// Принудительно продвинуть курсор до seq `to`, схлопывая дыры: то, что есть —
// в stdout, чего нет — пропало навсегда (прежняя семантика «потеря = сдвиг»).
static void rb_force_to(uint32_t to) {
while (next_out < to) {
rbslot_t *s = &rb[next_out % RB_CAP];
if (s->filled && s->seq == next_out && s->len) {
fwrite(s->data, 1, s->len, stdout);
total_bytes += s->len;
crc_final++;
}
s->filled = 0;
next_out++;
}
fflush(stdout);
}
// Вызывается ТОЛЬКО на FDD-пути (симплекс пишет в stdout напрямую — там нечего
// переупорядочивать), поэтому досылка по NACK всегда доступна.
static void rb_insert(uint32_t seq, const uint8_t *d, size_t len) {
if (len > GF_CHUNK) len = GF_CHUNK; // защита в глубину: len берётся из
// hdr, слот кольца — ровно GF_CHUNK
if (seq < next_out) return; // уже выведен: дубль ретрансмита
if (seq - next_out >= RB_CAP) {
// Дыра в голове старше окна: дропаем новый кадр и возвращаем его seq в
// NACK — A дошлёт, когда голова разъедется. Схлопнуть окно нельзя: это
// молча потеряло бы кадр, который ARQ ещё способен закрыть.
rb_over++;
miss_add_sorted(seq);
return;
}
rbslot_t *s = &rb[seq % RB_CAP];
s->seq = seq;
s->len = (uint16_t)len;
s->filled = 1;
if (len) memcpy(s->data, d, len);
}
// Досдать всё, что осталось в кольце (поток C на выходе): приём кончился, ARQ
// уже не поможет — дыры схлопываются, хвост уходит в файл.
static void rb_flush_all(void) {
uint32_t last = next_out;
for (int i = 0; i < RB_CAP; i++)
if (rb[i].filled && rb[i].seq >= last) last = rb[i].seq + 1;
rb_force_to(last);
}
// Пересобрать NACK-набор из кольца (этап 3c, только FDD, только по END).
// Зачем: miss_add при переполнении MISS_CAP молча теряет seq, и вернуть его туда
// уже некому — детектор дыр по нему не сработает второй раз (highest_seq ушёл
// вперёд). В тяжёлом канале это тупик: A не знает, что дослать, next_out стоит,
// COMPLETE не наступает, B досиживает до сторожа. Кольцо же знает точно, каких
// кадров нет: слот либо занят своим seq, либо это дыра.
// Скан ограничен окном кольца: seq дальше next_out+RB_CAP всё равно принять
// некуда (rb_insert их отбросит), просить их у A рано.
static void miss_rebuild(void) {
if (!g_fb_fg || !end_seen) return;
if (end_total_seq <= next_out) { miss_cnt = 0; return; } // всё выведено; заодно
// страховка от переполнения вычитания ниже
uint32_t to = end_total_seq;
if (to - next_out > RB_CAP) to = next_out + RB_CAP;
miss_cnt = 0;
for (uint32_t s = next_out; s < to && miss_cnt < MISS_CAP; s++) {
rbslot_t *sl = &rb[s % RB_CAP];
if (sl->filled && sl->seq == s) continue; // кадр уже принят
miss[miss_cnt++] = s; // возрастает → сортировка цела
}
miss_rebuilt++;
}
// --- Групповой стирающий FEC (поток C, README §12.7) --------------------
// Собирает группу из 16 кадров данных + 2 паритетных (P,Q). Пропавший на эфире
// или битый по CRC кадр = стирание; ≤2 стираний на группу закрываются gf_recover.
// Всё состояние — только поток C (как остальные C-счётчики), синхронизации нет.
static struct {
int active;
int seen; // хоть одна группа была: g.base осмыслен (этап 3c)
uint32_t base; // seq первого кадра группы (= seq idx)
int k; // число кадров данных (из меты паритета)
int k_known; // k получен из паритета
@@ -135,6 +377,7 @@ static struct {
uint8_t data[GF_K][GF_CHUNK];
uint16_t dlen[GF_K]; // фактическая длина каждого кадра данных
uint8_t have[GF_K]; // 1 = кадр idx получен и валиден
uint8_t fixed[GF_K]; // 1 = кадр восстановлен паритетом, а не принят
uint8_t P[GF_CHUNK], Q[GF_CHUNK];
int haveP, haveQ;
uint16_t tail_len; // длина последнего кадра данных (из меты)
@@ -142,8 +385,9 @@ static struct {
static void group_reset(uint32_t base) {
memset(g.have, 0, sizeof(g.have));
memset(g.fixed, 0, sizeof(g.fixed));
memset(g.dlen, 0, sizeof(g.dlen));
g.active = 1; g.base = base; g.k = 0; g.k_known = 0;
g.active = 1; g.seen = 1; g.base = base; g.k = 0; g.k_known = 0;
g.maxidx = -1; g.haveP = g.haveQ = 0; g.tail_len = 0;
}
@@ -163,20 +407,38 @@ static void group_flush(void) {
// прочие кадры полные 1024, последний — tail_len
g.dlen[i] = (i == k - 1 && g.tail_len) ? g.tail_len : GF_CHUNK;
g.have[i] = 1;
g.fixed[i] = 1; // закрыт паритетом — досылать по ARQ не нужно
}
} else {
grp_failed++; // >2 стираний либо не хватило паритета
}
}
for (int i = 0; i < k; i++) {
if (!g.have[i]) continue; // невосстановимая дыра
uint16_t len = g.dlen[i] > GF_CHUNK ? GF_CHUNK : g.dlen[i];
fwrite(g.data[i], 1, len, stdout);
total_bytes += len;
crc_final++;
if (g_fb_fg) {
// FDD (гибрид GF+ARQ, этап 3c): порядок держит кольцо, и принятые кадры
// уже легли в него на приёме (group_route). Отсюда идут ТОЛЬКО кадры,
// поднятые паритетом, — и их seq снимаются с NACK: GF закрыл дыру,
// ретрансмит был бы лишним трафиком. Невосстановимые остаются в miss —
// их дошлёт ARQ, для того он здесь и включён.
for (int i = 0; i < k; i++) {
if (!g.fixed[i]) continue;
rb_insert(g.base + i, g.data[i], g.dlen[i]);
miss_ack(g.base + i);
}
rb_drain();
} else {
// Симплекс: кольца нет (ретрансмитов не будет — переупорядочивать нечего),
// группа сама себе буфер. Пишем её целиком в порядке idx, восстановленные
// встают на свои места. Невосстановимая дыра = сдвиг, прежняя семантика.
for (int i = 0; i < k; i++) {
if (!g.have[i]) continue;
uint16_t len = g.dlen[i] > GF_CHUNK ? GF_CHUNK : g.dlen[i];
fwrite(g.data[i], 1, len, stdout);
total_bytes += len;
crc_final++;
}
fflush(stdout);
}
fflush(stdout);
g.active = 0;
}
@@ -187,6 +449,24 @@ static void group_route(uint8_t flags, uint32_t seq,
int is_par = flags & GF_FLAG_PAR;
uint32_t base = seq - idx; // idx=k для P, k+1 для Q → та же база
// FDD: кольцо — единственный источник порядка, и оно живёт независимо от
// сборки групп. Кладём кадр в слот ДО всякого bypass: даже ретрансмит давно
// закрытой группы обязан занять своё место, иначе next_out встанет на нём
// навсегда и COMPLETE не наступит. Паритет тратит seq, но данных не несёт —
// ему слот нулевой длины, курсор его проходит насквозь.
if (ok && g_fb_fg) {
rb_insert(seq, is_par ? NULL : dec, is_par ? 0 : dec_len);
rb_drain();
}
// Ретрансмит кадра уже закрытой группы (этап 3c). Пересобирать нечего: в FDD
// кадр только что лёг в кольцо, а группа нужна лишь ради паритета. Ловим два
// случая: старая группа (base < g.base) — иначе она преждевременно flush-нула
// бы активную и сбросила её стирания; и текущая, уже финализированная по END
// (base == g.base && !g.active) — иначе кадр «воскресил» бы группу с одним
// элементом, наврав в grp_failed.
if (g.seen && (base < g.base || (base == g.base && !g.active))) return;
if (!g.active || base != g.base) { // новая группа → финализировать прежнюю
group_flush();
group_reset(base);
@@ -211,6 +491,62 @@ static void group_route(uint8_t flags, uint32_t seq,
}
}
// Разбор END-кадра (поток C, этап 3). Зафиксировать итог передачи и добить
// хвостовые дыры в NACK. Повторы END (A шлёт их каждые 100 мс до COMPLETE)
// идемпотентны: состояние берём с первого валидного.
static void process_end(const uint8_t *hdr, uint8_t *pay, unsigned pay_len,
int pay_valid) {
static uint8_t edec[RS_ENC]; // END = 12 Б → ровно 1 RS-блок (223 Б)
fb_end_t e;
if (fec_on && decoder) {
size_t elen = 0;
if (!phy_rx_decode(hdr, pay, pay_len, decoder, edec, sizeof(edec), &elen))
return; // RS/CRC не прошли — ждём следующий END
if (fb_end_unpack(edec, elen, &e) != 0) return;
} else { // -c none: payload как есть
uint16_t olen = (uint16_t)((hdr[6] << 8) | hdr[7]);
if (!pay_valid || olen < FB_END_LEN || olen > pay_len) return;
if (fb_end_unpack(pay, olen, &e) != 0) return;
}
end_rx++;
if (!end_seen) {
end_seen = 1;
end_total_seq = e.total_seq;
end_total_bytes = e.total_bytes;
// Хвостовые потери молчаливы: у последних кадров нет следующего seq, по
// которому детектор увидел бы разрыв. Двигаем фронт на конец файла —
// дальше приходить могут только ретрансмиты (seq ≤ highest_seq), ложных
// дыр они не породят.
if (e.total_seq > 0 && (!seq_init || highest_seq < e.total_seq - 1)) {
lost += e.total_seq - (seq_init ? highest_seq + 1 : 0);
seq_init = 1;
highest_seq = e.total_seq - 1;
}
}
// Досрочно финализировать последнюю группу (group-FEC + FDD). Штатно группа
// закрывается приходом первого кадра СЛЕДУЮЩЕЙ группы — а для последней его
// не будет, и без этого её восстановление паритетом случилось бы только при
// выходе потока C, то есть уже после COMPLETE. Дыры пришлось бы закрывать
// ретрансмитом вместо готового паритета. END значит «новых данных не будет»
// (дальше только повторы), поэтому финализировать безопасно; повторные END
// попадают в no-op (g.active уже 0).
group_flush();
// NACK пересобираем на КАЖДОМ END, а не только на первом (этап 3c). A шлёт
// END каждые 100 мс до самого COMPLETE, так что это ещё и штатный способ
// вернуть в запрос seq, выпавшие из miss[] по MISS_CAP: пока хвост пуст,
// дыры видит только кольцо. Скан ≤1024 при 10 Гц — сотые доли ядра.
miss_rebuild();
if (end_rx == 1)
fprintf(stderr, "\n[ARQ] END принят: всего %u кадров, %llu байт; не хватает %d\n",
e.total_seq, (unsigned long long)e.total_bytes, miss_cnt);
}
// Разбор одного кадра в потоке C: seq-детектор + RS + CRC-поверх-RS, затем
// маршрутизация (прямая запись для legacy, сборка группы для group-FEC).
// Единственный писатель в stdout — этот поток, порядок сохранён (fq FIFO).
@@ -220,10 +556,16 @@ static void process_frame(fq_slot_t *fs) {
unsigned pay_len = fs->pay_len;
int pay_valid = fs->pay_valid;
// END (F0 E7) — маркер конца передачи. Ветвимся ДО seq-детектора: у END свой
// счётчик seq, в пространстве seq данных его быть не должно.
if (hdr[0] == HDR_SIGNATURE_0 && hdr[1] == HDR_SIG_END_1) {
process_end(hdr, pay, pay_len, pay_valid);
return;
}
// Проверка сигнатуры hdr[0..1]: liquid-CRC (hdr_valid) страхует только
// целостность байт, но не то, что это наш протокол. Пока в эфире один тип
// кадра (данные/паритет GF, F0 AA) — это no-op; готовит почву под доп.
// типы кадров обратного канала (roadmap §10 п.7: STATUS/END на др. частоте).
// целостность байт, но не то, что это наш протокол. Отсеивает чужие типы
// кадров (roadmap §10 п.7) и мусор, прошедший CRC заголовка.
if (hdr[0] != HDR_SIGNATURE_0 || hdr[1] != HDR_SIGNATURE_1) {
sig_bad++;
return;
@@ -241,7 +583,15 @@ static void process_frame(fq_slot_t *fs) {
if (!seq_init) {
seq_init = 1;
highest_seq = seq;
if (seq > 0) { // приём начался не с нуля: кадры до
for (uint32_t m = 0; m < seq; m++) miss_add(m); // первого пойманного
lost += seq; // потеряны, а дырой не выглядят —
fprintf(stderr, "\n[ПОТЕРЯ] старт с seq %u, дыра %u кадров в начале\n",
seq, seq); // им не предшествовал никакой seq
}
} else if (seq > highest_seq + 1) {
// Пропавшие seq → NACK-набор обратного канала (этап 2) + счётчик потерь.
for (uint32_t m = highest_seq + 1; m < seq; m++) miss_add(m);
lost += seq - highest_seq - 1;
fprintf(stderr, "\n[ПОТЕРЯ] seq %u→%u, дыра %u кадров\n",
highest_seq, seq, seq - highest_seq - 1);
@@ -250,6 +600,8 @@ static void process_frame(fq_slot_t *fs) {
highest_seq = seq;
}
// seq ≤ highest_seq: старый/повторный кадр — highest_seq не трогаем.
// Снятие seq с NACK — НЕ здесь, а после декода (см. метку route): факт
// приёма кадра ещё не значит, что он целый.
uint16_t original_len = (hdr[6] << 8) | hdr[7];
uint8_t last_block_bytes = hdr[10];
@@ -300,11 +652,24 @@ static void process_frame(fq_slot_t *fs) {
}
route:
// Учёт NACK — ТОЛЬКО после декода. Раньше seq снимался с NACK по факту
// приёма кадра, и битый кадр (rs_fail) выпадал из списка навсегда: ARQ его
// уже не дослал бы, а RX считал бы файл собранным. Битый кадр — такая же
// дыра, как непришедший, просто обнаруженная не детектором seq.
if (ok) miss_ack(seq);
else miss_add_sorted(seq);
if (flags & GF_FLAG_MODE) {
group_route(flags, seq, dec, dec_len, ok);
} else {
} else if (ok) {
if (g.active) group_flush(); // TX не мешает режимы, но подстрахуемся
if (ok) {
if (g_fb_fg) {
rb_insert(seq, dec, dec_len); // FDD: ретрансмит приходит вне
rb_drain(); // очереди — порядок держит кольцо
} else {
// Симплекс: ретрансмитов нет, seq всегда растёт — переупорядочивать
// нечего. Кольцо здесь только задержало бы вывод до EOF (дыра стопорит
// next_out, а закрыть её некому). Пишем сразу, как до этапа 3b.
fwrite(dec, 1, dec_len, stdout);
fflush(stdout);
total_bytes += dec_len;
@@ -396,13 +761,28 @@ static void *dsp_thread(void *arg) {
return NULL;
}
// Поток C (ядро 1): RS-декод + CRC + запись из очереди кадров.
// Поток C (ядро 1): RS-декод + CRC + запись из очереди кадров + периодический
// STATUS обратного канала. Ждём кадр таймингованно (50 мс), чтобы STATUS уходил
// и в простое (именно тогда TX и нужно знать про дыры). maybe_send_status —
// no-op в симплексе (g_fb_fg==NULL), поэтому поведение без -F не меняется.
static void *fec_thread(void *arg) {
(void)arg;
pin_to_cpu(1);
for (;;) {
if (!wait_slot(&fq.mtx, &fq.not_empty, &fq.count, &fq.eof))
break;
pthread_mutex_lock(&fq.mtx);
while (fq.count == 0 && !fq.eof && !stop) {
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_nsec += 50000000L; // 50 мс
if (ts.tv_nsec >= 1000000000L) { ts.tv_sec++; ts.tv_nsec -= 1000000000L; }
pthread_cond_timedwait(&fq.not_empty, &fq.mtx, &ts);
if (fq.count == 0 && !fq.eof && !stop) { // всё ещё пусто → STATUS без кадра
pthread_mutex_unlock(&fq.mtx);
maybe_send_status();
pthread_mutex_lock(&fq.mtx);
}
}
if (fq.count == 0) { pthread_mutex_unlock(&fq.mtx); break; } // eof/stop
fq_slot_t *fs = &fq_slots[fq.head];
pthread_mutex_unlock(&fq.mtx);
@@ -412,9 +792,12 @@ static void *fec_thread(void *arg) {
fq.head = (fq.head + 1) % FQ_SLOTS;
fq.count--;
pthread_mutex_unlock(&fq.mtx);
maybe_send_status(); // под нагрузкой — период гейтит частоту
}
group_flush(); // дописать последнюю группу файла (нет кадра-триггера)
return NULL;
rb_flush_all(); // и хвост кольца: приём кончился, дыры схлопываем —
return NULL; // досылать их уже некому
}
int main(int argc, char *argv[]) {
@@ -457,7 +840,7 @@ int main(int argc, char *argv[]) {
fprintf(stderr, "Усиление: %.0f дБ (ручное)\n", RX_GAIN);
fprintf(stderr, "CFO фильтр: α=%.2f\n", CFO_FILTER_ALPHA);
fprintf(stderr, "Дуплекс: %s\n", fb_freq
? "FDD (обратный TX настроен, трафик — этап 2)" : "симплекс");
? "FDD (обратный канал: передача STATUS на 868)" : "симплекс");
if (fb_freq) fprintf(stderr, "Обр. TX LO: %.1f МГц, усил. %.0f дБ\n", fb_freq / 1e6, fb_gain);
fprintf(stderr, "URI: %s\n", uri);
fprintf(stderr, "Конвейер: 3 стадии (A захват / B sync / C FEC+IO)\n");
@@ -519,6 +902,27 @@ int main(int argc, char *argv[]) {
// Тот же 12-байтовый заголовок, что и на TX (иначе байты 8..11 — мусор)
ofdmflexframesync_set_header_len(fs, HDR_SIZE);
// Обратный канал (этап 2, §10 п.7): framegen + буфер на feedback-TX (868).
// Кадры STATUS (F0 55) шлёт поток C через maybe_send_status. set_header_len
// 12 обязателен и здесь — иначе TX-сторона прочтёт заголовок как мусор.
if (fb_freq && txfb) {
iio_channel_enable(iio_device_find_channel(txfb, "voltage0", true));
iio_channel_enable(iio_device_find_channel(txfb, "voltage1", true));
g_fb_buf = iio_device_create_buffer(txfb, FB_BUF_SAMPLES, false);
if (!g_fb_buf) {
fprintf(stderr, "[warn] буфер обратного канала не создан — STATUS отключён\n");
} else {
ofdmflexframegenprops_s fp;
ofdmflexframegenprops_init_default(&fp);
fp.check = LIQUID_CRC_32;
g_fb_fg = ofdmflexframegen_create(OFDM_M, OFDM_CP, OFDM_TAPER, NULL, &fp);
ofdmflexframegen_set_header_len(g_fb_fg, HDR_SIZE);
g_fb_enc = fec_create(LIQUID_FEC_RS_M8, NULL);
fprintf(stderr, "Обратный канал: STATUS каждые %d мс (F0 55, RS+CRC32)\n",
FB_PERIOD_MS);
}
}
fprintf(stderr, "Приёмник готов. Ожидание сигнала...\n\n");
fprintf(stderr, "Кадры | Загол. | CRC | RS испр | RS бит | SigBd | Потери | Overr | FQdrp | Rec | GrpF | Вывод | RSSI | EVM | CFO\n");
fprintf(stderr, "--------+--------+------+---------+--------+-------+--------+--------+--------+--------+--------+--------+-------+-------+----------\n");
@@ -565,10 +969,22 @@ int main(int argc, char *argv[]) {
(unsigned long long)ring.overruns, (unsigned long long)fq.drops,
(unsigned long long)grp_recovered, (unsigned long long)grp_failed,
(unsigned long long)crc_final, (unsigned long long)total_bytes);
if (fb_freq) {
fprintf(stderr, "Обратный канал: отправлено STATUS: %u\n", g_fb_seq);
fprintf(stderr, "ARQ: END принято: %llu | ожидалось кадров: %u | выведено seq: %u | не хватает: %d | COMPLETE: %s\n",
(unsigned long long)end_rx, end_total_seq, next_out, miss_cnt,
complete_since_ms ? "да" : "НЕТ");
fprintf(stderr, "ARQ: окно reorder переполнено: %llu | NACK переполнен: %llu | пересборок NACK: %llu\n",
(unsigned long long)rb_over, (unsigned long long)miss_over,
(unsigned long long)miss_rebuilt);
}
if (decoder) fec_destroy(decoder);
ofdmflexframesync_destroy(fs);
iio_buffer_destroy(buf);
if (g_fb_fg) ofdmflexframegen_destroy(g_fb_fg);
if (g_fb_enc) fec_destroy(g_fb_enc);
if (g_fb_buf) iio_buffer_destroy(g_fb_buf);
iio_context_destroy(ctx);
return 0;
}

View File

@@ -8,6 +8,7 @@
#include "common.h"
#include "group_fec.h"
#include "frame_tx.h"
#include "feedback.h" // приём STATUS обратного канала (этап 2, §10 п.7)
// Параметры передатчика по умолчанию
#define DEFAULT_GAIN -30.0 // Усиление передатчика: -30 дБ
@@ -23,6 +24,219 @@ extern volatile sig_atomic_t stop;
// Сигнатура кадров данных/паритета GF (см. frame_tx.h — тип кадра различает
// hdr[0..1]; END/STATUS обратного канала используют другие сигнатуры, §10 п.7).
static const uint8_t SIG_DATA[2] = { HDR_SIGNATURE_0, HDR_SIGNATURE_1 };
static const uint8_t SIG_END[2] = { HDR_SIGNATURE_0, HDR_SIG_END_1 };
// Параметры EOF-хендшейка (этап 3). После EOF A шлёт END каждые END_PERIOD_MS и
// ждёт STATUS с флагом COMPLETE. В этой фазе эфир 915 почти пуст (END ≈ 1.4 мс
// на 100 мс), поэтому fb-RX платы A больше не глохнет в собственные бёрсты и
// слышит практически все STATUS (плато 50% из §12.8 — эффект duty 57%).
#define END_PERIOD_MS 100
#define END_TIMEOUT_MS 10000 // 100 попыток; молчание = честный отказ (exit 2)
static int64_t now_ms(void) {
struct timespec t;
clock_gettime(CLOCK_MONOTONIC, &t);
return (int64_t)t.tv_sec * 1000 + t.tv_nsec / 1000000;
}
// --- Обратный канал: приём STATUS на 868 (этап 2, §10 п.7) --------------
// Отдельный поток на ядре 1 (данные TX — ядро 0). Свой синхронизатор с
// header_len 12 и свой RS-декодер. Слушает rxfb — второй cf-девайс, открытый
// pluto_init_fdd. Декодированный STATUS публикуется снимком для main: пока его
// читает только END-фаза (этап 3a), ретрансмит по NACK — этап 3b.
#define FB_RX_BUF_SAMPLES 16384
static struct iio_buffer *g_fbrx_buf = NULL;
static ofdmflexframesync g_fbrx_fs = NULL;
static fec g_fbrx_dec = NULL;
static volatile sig_atomic_t fb_stop = 0;
static uint64_t g_fb_status_rx = 0; // сколько STATUS декодировано (для итога)
// Снимок последнего STATUS для main (этап 3). STATUS кумулятивен — это состояние
// RX целиком, а не событие, поэтому очередь не нужна: latest-wins. Пишет поток
// fb_rx, читает main; TX-буфером по-прежнему владеет ТОЛЬКО main (передавать из
// callback нельзя — это ядро 1 и чужой буфер).
static pthread_mutex_t g_fb_mtx = PTHREAD_MUTEX_INITIALIZER;
static fb_status_t g_fb_st;
static int g_fb_fresh = 0; // 1 = снимок не прочитан main'ом
// COMPLETE — отдельная защёлка, а не поле снимка: снимок «съедает» arq_service,
// и подтверждение, пришедшее в том же STATUS, потерялось бы. Флаг монотонный —
// once complete, always complete.
static volatile sig_atomic_t g_fb_complete = 0;
// Забрать свежий снимок. Возврат 1 — *out заполнен, 0 — нового STATUS не было.
static int fb_take(fb_status_t *out) {
int fresh;
pthread_mutex_lock(&g_fb_mtx);
fresh = g_fb_fresh;
if (fresh) { *out = g_fb_st; g_fb_fresh = 0; }
pthread_mutex_unlock(&g_fb_mtx);
return fresh;
}
// callback синхронизатора обратного канала: отсев по сигнатуре F0 55, RS+CRC
// декод, распаковка, лог и публикация снимка для main.
static int fb_callback(unsigned char *hdr, int hdr_valid,
unsigned char *pay, unsigned int pay_len,
int pay_valid, framesyncstats_s stats, void *user) {
(void)pay_valid; (void)stats; (void)user;
if (!hdr_valid) return 0;
if (hdr[0] != FB_SIG_0 || hdr[1] != FB_SIG_1) return 0; // не STATUS
static uint8_t dec[RS_ENC * 2]; // STATUS ≤ 1 RS-блок, запас на будущее
size_t dlen = 0;
if (!phy_rx_decode(hdr, pay, pay_len, g_fbrx_dec, dec, sizeof(dec), &dlen))
return 0; // RS/CRC не прошли — молча дропаем
fb_status_t st;
if (fb_status_unpack(dec, dlen, &st) != 0) return 0;
g_fb_status_rx++;
if (st.flags & FB_FLAG_COMPLETE) g_fb_complete = 1;
pthread_mutex_lock(&g_fb_mtx);
g_fb_st = st;
g_fb_fresh = 1;
pthread_mutex_unlock(&g_fb_mtx);
fprintf(stderr, "\n[FB] highest=%u rx_frames=%u overruns=%u%s%s nack=%u{",
st.highest_seq, st.rx_frames, st.overruns,
(st.flags & FB_FLAG_END_SEEN) ? " END" : "",
(st.flags & FB_FLAG_COMPLETE) ? " COMPLETE" : "",
st.nack_cnt);
for (int i = 0; i < st.nack_cnt && i < 8; i++)
fprintf(stderr, "%s%u", i ? "," : "", st.nack[i]);
if (st.nack_cnt > 8) fprintf(stderr, ",…");
fprintf(stderr, "}\n");
return 0;
}
static void *fb_rx_thread(void *arg) {
(void)arg;
pin_to_cpu(1);
static liquid_float_complex conv[FB_RX_BUF_SAMPLES];
while (!fb_stop && !stop) {
ssize_t nb = iio_buffer_refill(g_fbrx_buf);
if (nb < 0) { if (fb_stop || stop) break; usleep(500); continue; }
int16_t *bb = (int16_t *)iio_buffer_start(g_fbrx_buf);
int ns = (iio_buffer_end(g_fbrx_buf) - iio_buffer_start(g_fbrx_buf))
/ (int)sizeof(int16_t) / 2;
if (ns > FB_RX_BUF_SAMPLES) ns = FB_RX_BUF_SAMPLES;
for (int i = 0; i < ns; i++)
conv[i] = ((float)bb[2*i] / 2048.0f) +
((float)bb[2*i+1] / 2048.0f) * _Complex_I;
ofdmflexframesync_execute(g_fbrx_fs, conv, ns);
}
return NULL;
}
// --- TX-окно и ARQ (этап 3b) --------------------------------------------
// Копия каждого переданного кадра: без неё дослать нечего — stdin уже прочитан
// и не перематывается. Кольцо по seq % TXWIN, хранит СЫРОЙ payload (до RS/OFDM):
// phy_tx_frame детерминирован, поэтому повтор уходит в эфир байт-в-байт.
// TXWIN(2048) > RB_CAP(1024) приёмника — кадр, который B ещё может принять в
// своё окно переупорядочивания, гарантированно есть у нас в окне.
#define TXWIN 2048
#define ARQ_RETX_MS 400 // per-seq пауза между повторами одного seq.
// 400 = петля «потеря → детект (~10 мс) → слот STATUS (~50 мс) → доставка при
// p≈0.5 (~100300 мс) → ретрансмит (~30 мс) → подтверждение (~150250 мс)».
// Меньше — дубли жгут эфир (кадр 9.3 мс); больше — дыра дольше держит окно B.
typedef struct {
uint32_t seq;
int64_t last_ms; // когда seq последний раз уходил в эфир
uint16_t len;
uint8_t hdr11; // флаги group-FEC (0 = legacy)
uint8_t valid;
uint8_t pay[GF_PAR_PAY]; // 1026: паритетный кадр GF — самый большой
} txslot_t;
static txslot_t txwin[TXWIN]; // ~2.1 МБ .bss (из 1 ГБ платы)
static int g_arq_on = 0; // ARQ включён (требует -F: без обратного канала
// NACK взять неоткуда). Работает и с group-FEC.
static uint64_t g_retx = 0; // отправлено ретрансмитов
static uint64_t g_retx_old = 0; // NACK на seq, вышедший из окна (≈21 с эфира)
// Последний известный список дыр B. Живёт между снимками: пока не пришёл новый
// STATUS, дожимаем прежний — от дублей защищает ARQ_RETX_MS, а не свежесть.
static uint32_t g_nack[FB_MAX_NACK];
static int g_nack_cnt = 0;
// Передать НОВЫЙ кадр данных/паритета, попутно сохранив его в окне.
static void tx_send_data(ofdmflexframegen fg, struct iio_buffer *buf, fec enc,
uint32_t seq, uint8_t hdr11, const uint8_t *pl, size_t n,
float amp, int pause_us) {
txslot_t *s = &txwin[seq % TXWIN];
s->seq = seq; s->hdr11 = hdr11; s->len = (uint16_t)n; s->valid = 1;
memcpy(s->pay, pl, n);
phy_tx_frame(fg, buf, BUF_SAMPLES, enc, SIG_DATA, seq, hdr11, pl, n, amp, pause_us);
s->last_ms = now_ms(); // после отправки: паузу считаем от конца кадра
}
// Досылка по NACK. budget — потолок ретрансмитов за один вызов: очередь дыр
// не должна вытеснять новые данные (голодание фронта). Вызывается ТОЛЬКО из
// main — TX-буфер один и владеет им этот поток.
static void arq_service(ofdmflexframegen fg, struct iio_buffer *buf, fec enc,
float amp, int pause_us, int budget) {
if (!g_arq_on) return;
fb_status_t st;
if (fb_take(&st)) { // свежий снимок → обновить список дыр
g_nack_cnt = st.nack_cnt < FB_MAX_NACK ? st.nack_cnt : FB_MAX_NACK;
memcpy(g_nack, st.nack, (size_t)g_nack_cnt * sizeof(g_nack[0]));
}
int64_t t = now_ms();
for (int i = 0; i < g_nack_cnt && budget > 0; i++) {
uint32_t seq = g_nack[i];
txslot_t *s = &txwin[seq % TXWIN];
if (!s->valid || s->seq != seq) { // окно провернулось — дослать нечем
g_retx_old++; // → END-таймаут, честный отказ
continue;
}
if (t - s->last_ms < ARQ_RETX_MS) continue; // повтор ещё в полёте
phy_tx_frame(fg, buf, BUF_SAMPLES, enc, SIG_DATA, seq, s->hdr11,
s->pay, s->len, amp, pause_us);
t = now_ms();
s->last_ms = t;
g_retx++;
budget--;
}
}
// EOF-хендшейк (этап 3, ядро 0/main): шлём END с итогом передачи, пока B не
// подтвердит COMPLETE флагом в STATUS. Возврат 0 — подтверждено, 2 — таймаут или
// прерывание: на той стороне файл неполон. Честный отказ вместо тихой дыры
// (техдолг #6) — раньше A просто закрывал буфер и рапортовал успех.
static int end_phase(ofdmflexframegen fg, struct iio_buffer *buf, fec enc,
float amp, int pause_us, uint32_t total_seq, uint64_t total_bytes) {
fb_end_t e = { .total_seq = total_seq, .total_bytes = total_bytes };
uint8_t pay[FB_END_LEN];
size_t n = fb_end_pack(&e, pay);
uint32_t eseq = 0; // свой счётчик: RX ветвится по сигнатуре ДО
// seq-детектора данных → дыр от END не будет
int64_t t0 = now_ms();
int sent = 0;
fprintf(stderr, "[ARQ] END: кадров %u, байт %llu — жду COMPLETE (таймаут %d мс)\n",
total_seq, (unsigned long long)total_bytes, END_TIMEOUT_MS);
while (!stop && !g_fb_complete && now_ms() - t0 < END_TIMEOUT_MS) {
phy_tx_frame(fg, buf, BUF_SAMPLES, enc, SIG_END, eseq++, 0, pay, n, amp, 0);
sent++;
// Бюджет выше, чем в data-фазе: новых данных больше нет, эфир свободен,
// и закрыть остаток дыр надо именно сейчас — таймаут тикает.
arq_service(fg, buf, enc, amp, pause_us, 4);
int64_t w = now_ms();
while (!stop && !g_fb_complete && now_ms() - w < END_PERIOD_MS)
usleep(5000); // пауза между END: опрос защёлки, не sleep
}
if (g_fb_complete) {
fprintf(stderr, "[ARQ] COMPLETE подтверждён: END × %d, ретрансмитов %llu, %lld мс\n",
sent, (unsigned long long)g_retx, (long long)(now_ms() - t0));
return 0;
}
fprintf(stderr, "[ARQ] таймаут COMPLETE: END × %d за %lld мс — приём НЕ подтверждён\n",
sent, (long long)(now_ms() - t0));
return 2;
}
int main(int argc, char *argv[]) {
// Инициализация параметров
@@ -74,7 +288,7 @@ int main(int argc, char *argv[]) {
fprintf(stderr, "Пауза: %d мкс\n", pause_us);
fprintf(stderr, "Group-FEC: %s\n", group_fec ? "16+2 (§10 п.6)" : "выкл");
fprintf(stderr, "Дуплекс: %s\n", fb_freq
? "FDD (обратный RX настроен, трафик — этап 2)" : "симплекс");
? "FDD (обратный канал: приём STATUS на 868)" : "симплекс");
if (fb_freq) fprintf(stderr, "Обр. RX LO: %.1f МГц\n", fb_freq / 1e6);
fprintf(stderr, "URI: %s\n", uri);
fprintf(stderr, "═══════════════════════════════════════════════════\n");
@@ -122,6 +336,37 @@ int main(int argc, char *argv[]) {
// last_block_bytes) без этого в эфир не уходят. RX выставляет то же.
ofdmflexframegen_set_header_len(fg, HDR_SIZE);
// Обратный канал (этап 2): поток fb_rx слушает STATUS на 868 (rxfb). Пока
// только логирует. Данные TX уводим на ядро 0, fb_rx — на ядро 1 (§affinity).
pthread_t fb_tid;
int fb_started = 0;
if (fb_freq) {
iio_channel_enable(iio_device_find_channel(rxfb, "voltage0", false));
iio_channel_enable(iio_device_find_channel(rxfb, "voltage1", false));
g_fbrx_buf = iio_device_create_buffer(rxfb, FB_RX_BUF_SAMPLES, false);
if (!g_fbrx_buf) {
LOG_ERROR("Буфер обратного канала не создан — STATUS не принимаются");
} else {
g_fbrx_fs = ofdmflexframesync_create(OFDM_M, OFDM_CP, OFDM_TAPER,
NULL, fb_callback, NULL);
ofdmflexframesync_set_header_len(g_fbrx_fs, HDR_SIZE);
g_fbrx_dec = fec_create(LIQUID_FEC_RS_M8, NULL);
pin_to_cpu(0); // TX-данные — ядро 0
pthread_create(&fb_tid, NULL, fb_rx_thread, NULL);
fb_started = 1;
// Этап 3c: ARQ работает и с group-FEC. Ретрансмит кадра старой группы
// больше не ломает сборку на RX (правило base < g.base в group_route),
// а дыры, закрытые паритетом, RX снимает с NACK сам — досылать их не
// придётся. GF и ARQ дополняют друг друга: GF гасит одиночные потери
// без задержки, ARQ добирает то, что паритету не по силам.
g_arq_on = 1;
fprintf(stderr, "Обратный канал: слушаю STATUS на %.1f МГц (ядро 1)\n",
fb_freq / 1e6);
fprintf(stderr, "ARQ: вкл (окно %d кадров, повтор не чаще %d мс)\n",
TXWIN, ARQ_RETX_MS);
}
}
// Счётчики статистики
uint64_t frames = 0, bytes_tx = 0;
uint32_t seq = 0;
@@ -140,8 +385,12 @@ int main(int argc, char *argv[]) {
usleep(10000);
continue;
}
phy_tx_frame(fg, buf, BUF_SAMPLES, enc, SIG_DATA, seq++, 0, raw, n, amp, pause_us);
tx_send_data(fg, buf, enc, seq++, 0, raw, n, amp, pause_us);
frames++; bytes_tx += n;
// Досылка вперемешку с новыми данными: дыра закрывается, пока B ещё
// держит её в окне переупорядочивания. Бюджет 2 — потолок, чтобы
// очередь NACK не вытеснила фронт передачи.
arq_service(fg, buf, enc, amp, pause_us, 2);
if (time(NULL) > last) {
fprintf(stderr, "\r📤 Передано: %llu кадров, %llu байт",
(unsigned long long)frames, (unsigned long long)bytes_tx);
@@ -168,9 +417,10 @@ int main(int argc, char *argv[]) {
if (k == GF_K || (done_reading && k > 0)) {
// k кадров данных: idx=0..k-1
for (int i = 0; i < k; i++) {
phy_tx_frame(fg, buf, BUF_SAMPLES, enc, SIG_DATA, seq++, GF_FLAG_MODE | i,
gdata[i], glen[i], amp, pause_us);
tx_send_data(fg, buf, enc, seq++, GF_FLAG_MODE | i,
gdata[i], glen[i], amp, pause_us);
frames++; bytes_tx += glen[i];
arq_service(fg, buf, enc, amp, pause_us, 2); // как в legacy
}
// паритеты P (idx=k) и Q (idx=k+1); мета = k + длина хвоста
uint8_t P[GF_CHUNK], Q[GF_CHUNK], par[GF_PAR_PAY];
@@ -178,12 +428,13 @@ int main(int argc, char *argv[]) {
uint16_t meta = GF_META_PACK(k, glen[k - 1]);
par[0] = meta >> 8; par[1] = meta & 0xFF;
memcpy(par + GF_META, P, GF_CHUNK);
phy_tx_frame(fg, buf, BUF_SAMPLES, enc, SIG_DATA, seq++, GF_FLAG_MODE | GF_FLAG_PAR | k,
par, GF_PAR_PAY, amp, pause_us);
tx_send_data(fg, buf, enc, seq++, GF_FLAG_MODE | GF_FLAG_PAR | k,
par, GF_PAR_PAY, amp, pause_us);
memcpy(par + GF_META, Q, GF_CHUNK);
phy_tx_frame(fg, buf, BUF_SAMPLES, enc, SIG_DATA, seq++, GF_FLAG_MODE | GF_FLAG_PAR | GF_FLAG_Q | (k + 1),
par, GF_PAR_PAY, amp, pause_us);
tx_send_data(fg, buf, enc, seq++, GF_FLAG_MODE | GF_FLAG_PAR | GF_FLAG_Q | (k + 1),
par, GF_PAR_PAY, amp, pause_us);
frames += 2;
arq_service(fg, buf, enc, amp, pause_us, 2);
k = 0;
if (time(NULL) > last) {
fprintf(stderr, "\r📤 Передано: %llu кадров, %llu байт (group-FEC)",
@@ -198,6 +449,13 @@ int main(int argc, char *argv[]) {
fprintf(stderr, "\nПередача завершена: %llu кадров, %llu байт\n",
(unsigned long long)frames, (unsigned long long)bytes_tx);
// EOF-хендшейк (этап 3). Только при живом обратном канале: без него COMPLETE
// услышать нечем и END-фаза всегда упиралась бы в таймаут. Симплекс идёт
// прежним путём — молча в дренаж, exit 0 (регрессия §12 не меняется).
int rc = 0;
if (fb_started)
rc = end_phase(fg, buf, enc, amp, pause_us, seq, bytes_tx);
// Дренаж: выпихнуть последний кадр из kernel-буферов DMA перед закрытием.
// Без паузы iio_buffer_destroy() срезает хвост файла в эфире (техдолг #5).
{
@@ -207,10 +465,24 @@ int main(int argc, char *argv[]) {
usleep(100000); // ~4 kernel-буфера при 1.92 MSPS ≈ 35 мс, с запасом
}
// Остановить обратный канал: fb_rx выходит по fb_stop в течение одного
// refill (~8.5 мс на 16384 сэмпл @ 1.92 Msps), затем join.
if (fb_started) {
fb_stop = 1;
pthread_join(fb_tid, NULL);
fprintf(stderr, "Обратный канал: принято STATUS: %llu\n",
(unsigned long long)g_fb_status_rx);
fprintf(stderr, "ARQ: ретрансмитов: %llu | NACK вне окна: %llu\n",
(unsigned long long)g_retx, (unsigned long long)g_retx_old);
}
// Освобождение ресурсов
if (enc) fec_destroy(enc);
ofdmflexframegen_destroy(fg);
iio_buffer_destroy(buf);
if (g_fbrx_fs) ofdmflexframesync_destroy(g_fbrx_fs);
if (g_fbrx_dec) fec_destroy(g_fbrx_dec);
if (g_fbrx_buf) iio_buffer_destroy(g_fbrx_buf);
iio_context_destroy(ctx);
return 0;
}
return rc;
}

487
src/udp_gw.c Normal file
View File

@@ -0,0 +1,487 @@
// udp_gw.c — UDP-туннель поверх байтового потока pluto-link
// (roadmap §10 п.8, COMPARISON.md §7 п.3). Самостоятельный POSIX-бинарь без
// iio/liquid/fec — работает по обе стороны линка как shell-фильтр:
//
// приём: receiver ... | udp_gw -d ip:port (кадры линка → датаграммы)
// отдача: udp_gw -l port | transmitter ... (датаграммы → кадры линка)
//
// Ядро линка (transmitter/receiver/group_fec/frame_tx) не меняется ни на
// байт: рекорды — это тонкий слой поверх уже существующего байтового потока
// stdin→stdout, свободно пересекающий границы 1024-байтных чанков.
//
// Формат рекорда (8 Б заголовка + payload ≤1472 Б, Ethernet MTU — см.
// CLAUDE.md «видео — raw UDP»):
// 0-1 magic 0xD5 0x5D | 2-3 len u16 BE | 4-5 id u16 BE | 6 флаги (0) | 7 hcrc8
// Целостность payload гарантирует сам линк (CRC32-поверх-RS, frame_tx.c) —
// свой CRC данных не нужен; hcrc8 нужен только чтобы отличить настоящий magic
// от случайных байт 0xD5 0x5D в данных при ресинхронизации потока после дыры
// (радио-потеря кадра на симплексе рвёт границы рекордов произвольно).
//
// Самопроверка парсера (аналог group_fec.c/GF_SELFTEST, нативная сборка на
// хосте, без кросс-тулчейна): cc -O2 -Wall -DGW_SELFTEST src/udp_gw.c -o gwtest
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
#include <errno.h>
#include <time.h>
#include <signal.h>
#include <fcntl.h>
#include <getopt.h>
#include <sys/select.h>
#include <sys/time.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define GW_MAGIC0 0xD5
#define GW_MAGIC1 0x5D
#define GW_HDR 8
#define GW_MAX_PAYLOAD 1472 // Ethernet MTU 1500 IP 20 UDP 8
#define GW_MAX_RECORD (GW_HDR + GW_MAX_PAYLOAD)
#define GW_IO_CHUNK 8192 // размер одного read() в deliver-режиме
// Буфер парсера: перед каждым push() в нём остаётся не более одного неполного
// рекорда (полные рекорды дренирует next() в том же цикле вызывающего) — то
// есть максимум GW_MAX_RECORD1 байт хвоста + один read() ≤ GW_IO_CHUNK.
// Запас исключает штатное срабатывание аварийного ресинка в gw_parser_push.
#define GW_PARSE_BUF (GW_MAX_RECORD * 2 + GW_IO_CHUNK)
// --- запись: pack/CRC8/парсер (общая часть, тестируется офлайн) -----------
static uint8_t gw_crc8(const uint8_t *d, size_t n) {
uint8_t c = 0;
for (size_t i = 0; i < n; i++) {
c ^= d[i];
for (int b = 0; b < 8; b++)
c = (uint8_t)((c & 0x80) ? ((c << 1) ^ 0x07) : (c << 1));
}
return c;
}
// Упаковка одной датаграммы в запись. out ≥ GW_HDR + n. Возврат — длина записи.
static size_t gw_pack(uint8_t *out, uint16_t id, const uint8_t *pl, uint16_t n) {
out[0] = GW_MAGIC0; out[1] = GW_MAGIC1;
out[2] = (uint8_t)(n >> 8); out[3] = (uint8_t)(n & 0xFF);
out[4] = (uint8_t)(id >> 8); out[5] = (uint8_t)(id & 0xFF);
out[6] = 0; // флаги — резерв
out[7] = gw_crc8(out, 7);
memcpy(out + GW_HDR, pl, n);
return (size_t)GW_HDR + n;
}
// Разбор потока записей с ресинхронизацией: линк — байтовый поток, потеря
// кадра на симплексе рвёт границы записей произвольно. magic+hcrc8 находит
// следующую настоящую запись, отбрасывая мусор между дырой и ней.
typedef struct {
uint8_t buf[GW_PARSE_BUF];
size_t len, pos; // валидные байты [0,len); курсор сканирования
uint64_t records; // успешно извлечено
uint64_t resync_bytes; // байт пропущено при поиске magic (не-magic)
uint64_t bad_crc; // кандидатов magic отсеяно по hcrc8/длине
uint64_t gaps; // сумма пропущенных id (wrap/переупорядочение не считаются)
int have_last_id;
uint16_t last_id;
} gw_parser_t;
static void gw_parser_init(gw_parser_t *p) { memset(p, 0, sizeof(*p)); }
static void gw_compact(gw_parser_t *p) {
if (p->pos == 0) return;
memmove(p->buf, p->buf + p->pos, p->len - p->pos);
p->len -= p->pos;
p->pos = 0;
}
// Добавляет сырые байты в буфер парсера, освобождая место компактированием.
// Штатно не переполняется (см. GW_PARSE_BUF); аварийный сдвиг на 1 байт —
// только защита от зависания на потоке без единого валидного magic.
static void gw_parser_push(gw_parser_t *p, const uint8_t *data, size_t n) {
while (n > 0) {
gw_compact(p);
if (p->len == sizeof(p->buf)) {
p->pos = 1;
gw_compact(p);
p->resync_bytes++;
}
size_t space = sizeof(p->buf) - p->len;
size_t take = n < space ? n : space;
memcpy(p->buf + p->len, data, take);
p->len += take;
data += take; n -= take;
}
}
// Извлекает следующую запись. Возврат 1 — *id/*pl/*pl_len заполнены (pl —
// указатель ВНУТРЬ буфера парсера, валиден только до следующего push/next).
// Возврат 0 — данных пока не хватает, жди push.
static int gw_parser_next(gw_parser_t *p, uint16_t *id, const uint8_t **pl, uint16_t *pl_len) {
for (;;) {
while (p->pos + 1 < p->len &&
!(p->buf[p->pos] == GW_MAGIC0 && p->buf[p->pos + 1] == GW_MAGIC1)) {
p->pos++;
p->resync_bytes++;
}
if (p->pos + GW_HDR > p->len) return 0; // заголовок ещё не весь пришёл
const uint8_t *h = p->buf + p->pos;
if (gw_crc8(h, 7) != h[7]) { // ложный magic в данных
p->pos++;
p->bad_crc++;
continue;
}
uint16_t n = (uint16_t)(((uint16_t)h[2] << 8) | h[3]);
if (n > GW_MAX_PAYLOAD) { // валидный hcrc8, но нелепая длина —
p->pos++; // тоже ложное совпадение (редко, 1/256)
p->bad_crc++;
continue;
}
if (p->pos + GW_HDR + n > p->len) return 0; // тело записи ещё не пришло целиком
uint16_t rid = (uint16_t)(((uint16_t)h[4] << 8) | h[5]);
if (p->have_last_id) {
uint16_t gap = (uint16_t)(rid - p->last_id - 1);
if (gap != 0 && gap < 0x8000) p->gaps += gap; // ≥0x8000 — переупорядочение/wrap, не дыра
}
p->have_last_id = 1;
p->last_id = rid;
p->records++;
*id = rid; *pl = h + GW_HDR; *pl_len = n;
p->pos += (size_t)GW_HDR + n;
return 1;
}
}
#ifndef GW_SELFTEST
// --- сетевой ввод-вывод -----------------------------------------------
static volatile sig_atomic_t g_stop = 0;
static void on_signal(int s) { (void)s; g_stop = 1; }
static void setup_signals(void) {
signal(SIGINT, on_signal);
signal(SIGTERM, on_signal);
signal(SIGPIPE, SIG_IGN);
}
// Вход: UDP-датаграммы → записи в stdout. stdout в O_NONBLOCK: если приёмник
// (transmitter через пайп) не успевает — новые датаграммы дропаются со
// счётчиком (семантика UDP: контрактный дроп лучше блокировки recv-цикла).
static int mode_listen(int port) {
int fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd < 0) { perror("socket"); return 1; }
int one = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons((uint16_t)port);
if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind"); close(fd); return 1;
}
int fl = fcntl(STDOUT_FILENO, F_GETFL, 0);
fcntl(STDOUT_FILENO, F_SETFL, fl | O_NONBLOCK);
uint16_t id = 0;
uint64_t sent = 0, dropped = 0, oversize = 0;
uint8_t dgram[GW_MAX_PAYLOAD + 1]; // +1: отличить датаграмму ≥1473 Б от валидной 1472
uint8_t rec[GW_MAX_RECORD];
fprintf(stderr, "udp_gw: слушаю UDP :%d -> stdout (записи <= %d Б)\n", port, GW_MAX_RECORD);
time_t last = time(NULL);
while (!g_stop) {
fd_set rfds; FD_ZERO(&rfds); FD_SET(fd, &rfds);
struct timeval tv = { .tv_sec = 1, .tv_usec = 0 };
int sel = select(fd + 1, &rfds, NULL, NULL, &tv);
if (sel < 0) { if (errno == EINTR) continue; break; }
if (sel > 0 && FD_ISSET(fd, &rfds)) {
ssize_t n = recvfrom(fd, dgram, sizeof(dgram), 0, NULL, NULL);
if (n < 0) {
if (errno != EINTR && errno != EAGAIN) break;
} else if ((size_t)n > GW_MAX_PAYLOAD) {
oversize++;
} else {
size_t rl = gw_pack(rec, id++, dgram, (uint16_t)n);
ssize_t w = write(STDOUT_FILENO, rec, rl);
if (w < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) dropped++;
else if (errno != EINTR) break;
} else if ((size_t)w < rl) {
dropped++; // частичная запись — парсер на другом конце сам ресинхронизируется
} else {
sent++;
}
}
}
time_t now = time(NULL);
if (now > last) {
fprintf(stderr, "\rотправлено %llu | дропов %llu | негабарит %llu",
(unsigned long long)sent, (unsigned long long)dropped,
(unsigned long long)oversize);
fflush(stderr);
last = now;
}
}
fprintf(stderr, "\nudp_gw (listen) завершён: отправлено %llu | дропов %llu | негабарит %llu\n",
(unsigned long long)sent, (unsigned long long)dropped, (unsigned long long)oversize);
close(fd);
return 0;
}
// Выход: записи из stdin → UDP-датаграммы на dst. Парсер сам ресинхронизируется
// после дыры в потоке (см. gw_parser_next).
static int mode_deliver(const char *hostport) {
const char *colon = strrchr(hostport, ':');
if (!colon || colon == hostport) {
fprintf(stderr, "udp_gw: -d ожидает ip:port, получено '%s'\n", hostport);
return 1;
}
char host[64];
size_t hlen = (size_t)(colon - hostport);
if (hlen >= sizeof(host)) { fprintf(stderr, "udp_gw: адрес слишком длинный\n"); return 1; }
memcpy(host, hostport, hlen); host[hlen] = 0;
int port = atoi(colon + 1);
struct sockaddr_in dst;
memset(&dst, 0, sizeof(dst));
dst.sin_family = AF_INET;
dst.sin_port = htons((uint16_t)port);
if (inet_pton(AF_INET, host, &dst.sin_addr) != 1) {
fprintf(stderr, "udp_gw: не разобрал IP '%s' (только числовой IPv4)\n", host);
return 1;
}
int fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd < 0) { perror("socket"); return 1; }
gw_parser_t p;
gw_parser_init(&p);
uint8_t iobuf[GW_IO_CHUNK];
fprintf(stderr, "udp_gw: stdin -> UDP %s:%d\n", host, port);
time_t last = time(NULL);
while (!g_stop) {
ssize_t n = read(STDIN_FILENO, iobuf, sizeof(iobuf));
if (n < 0) { if (errno == EINTR) continue; break; }
if (n == 0) break; // EOF — источник (receiver) завершился
gw_parser_push(&p, iobuf, (size_t)n);
uint16_t id, len; const uint8_t *pl;
while (gw_parser_next(&p, &id, &pl, &len))
sendto(fd, pl, len, 0, (struct sockaddr *)&dst, sizeof(dst));
time_t now = time(NULL);
if (now > last) {
fprintf(stderr, "\rдоставлено %llu | ресинк %llu Б | bad_crc %llu | дыр(id) %llu",
(unsigned long long)p.records, (unsigned long long)p.resync_bytes,
(unsigned long long)p.bad_crc, (unsigned long long)p.gaps);
fflush(stderr);
last = now;
}
}
fprintf(stderr, "\nudp_gw (deliver) завершён: доставлено %llu | ресинк %llu Б | "
"bad_crc %llu | дыр(id) %llu\n",
(unsigned long long)p.records, (unsigned long long)p.resync_bytes,
(unsigned long long)p.bad_crc, (unsigned long long)p.gaps);
close(fd);
return 0;
}
static void usage(const char *prog) {
fprintf(stderr, "udp_gw — UDP-туннель поверх pluto-link (roadmap §10 п.8)\n\n");
fprintf(stderr, "Вход в линк: %s -l ПОРТ | transmitter ...\n", prog);
fprintf(stderr, "Выход из линка: receiver ... | %s -d ip:port\n\n", prog);
fprintf(stderr, " -l ПОРТ слушать UDP на ПОРТ, писать записи в stdout\n");
fprintf(stderr, " -d ip:port читать записи из stdin, слать UDP на ip:port\n");
fprintf(stderr, " -h эта справка\n");
}
int main(int argc, char *argv[]) {
setup_signals();
int opt;
int listen_port = -1;
const char *deliver_dst = NULL;
while ((opt = getopt(argc, argv, "l:d:h")) != -1) {
switch (opt) {
case 'l': listen_port = atoi(optarg); break;
case 'd': deliver_dst = optarg; break;
case 'h': usage(argv[0]); return 0;
default: usage(argv[0]); return 1;
}
}
if (listen_port > 0 && !deliver_dst) return mode_listen(listen_port);
if (deliver_dst && listen_port < 0) return mode_deliver(deliver_dst);
usage(argv[0]);
return 1;
}
#else
// --- Оффлайн-самопроверка парсера: cc -O2 -DGW_SELFTEST udp_gw.c -o gwtest ---
// По духу — как group_fec.c/GF_SELFTEST: чистая логика без сети/iio, нативная
// сборка на хосте (RPi/aarch64), кросс-тулчейн не нужен.
static int fails = 0;
#define CHECK(cond, ...) do { if (!(cond)) { printf("FAIL: " __VA_ARGS__); printf("\n"); fails++; } } while (0)
// Прогоняет payloads[i] (i=0..cnt-1) через pack, склеивает в один поток,
// прогоняет через парсер (кусками по `chunk` байт, 0 = одним куском —
// имитация разной гранулярности read()), сверяет извлечённые записи 1-в-1.
static void run_stream(const char *name, const uint8_t **payloads, const uint16_t *lens, int cnt,
size_t chunk) {
uint8_t *stream = malloc((size_t)cnt * GW_MAX_RECORD);
size_t slen = 0;
for (int i = 0; i < cnt; i++)
slen += gw_pack(stream + slen, (uint16_t)i, payloads[i], lens[i]);
gw_parser_t p; gw_parser_init(&p);
size_t off = 0;
while (off < slen) {
size_t take = chunk ? (chunk < slen - off ? chunk : slen - off) : slen - off;
gw_parser_push(&p, stream + off, take);
off += take;
}
int got = 0;
uint16_t id, len; const uint8_t *pl;
while (gw_parser_next(&p, &id, &pl, &len)) {
CHECK(id == got, "%s: id %u != ожид. %d", name, id, got);
CHECK(len == lens[got], "%s: len %u != ожид. %u (rec %d)", name, len, lens[got], got);
CHECK(memcmp(pl, payloads[got], len) == 0, "%s: данные не совпали (rec %d)", name, got);
got++;
}
CHECK(got == cnt, "%s: извлечено %d записей, ожидалось %d", name, got, cnt);
CHECK(p.bad_crc == 0, "%s: ложных bad_crc %llu на чистом потоке", name, (unsigned long long)p.bad_crc);
CHECK(p.gaps == 0, "%s: ложных дыр id %llu на непрерывном потоке", name, (unsigned long long)p.gaps);
free(stream);
}
int main(void) {
srand(42);
// 1. Обычный поток, размеры 1 / 1024 / 1472 / случайные, разными чанками read().
{
uint16_t lens[6] = { 1, 1024, 1472, 37, 500, 1471 };
uint8_t *bufs[6];
const uint8_t *pl[6];
for (int i = 0; i < 6; i++) {
bufs[i] = malloc(lens[i]);
for (int j = 0; j < lens[i]; j++) bufs[i][j] = (uint8_t)(rand() & 0xFF);
pl[i] = bufs[i];
}
run_stream("одним куском", pl, lens, 6, 0);
run_stream("по 1 байту", pl, lens, 6, 1);
run_stream("по 3 байта", pl, lens, 6, 3);
run_stream("по 4096 Б", pl, lens, 6, 4096);
for (int i = 0; i < 6; i++) free(bufs[i]);
}
// 2. Дыра посреди потока (потерянный кадр линка на симплексе): вырезаем
// запись #1 целиком → парсер должен ресинхронизироваться на следующей
// и правильно посчитать ровно одну дыру id.
{
uint16_t lens[4] = { 200, 300, 200, 300 };
uint8_t *bufs[4]; const uint8_t *pl[4];
for (int i = 0; i < 4; i++) {
bufs[i] = malloc(lens[i]);
for (int j = 0; j < lens[i]; j++) bufs[i][j] = (uint8_t)(rand() & 0xFF);
pl[i] = bufs[i];
}
uint8_t stream[4 * (300 + GW_HDR)];
size_t slen = 0;
for (int i = 0; i < 4; i++) slen += gw_pack(stream + slen, (uint16_t)i, pl[i], lens[i]);
size_t rec1_off = GW_HDR + lens[0]; // где начинается запись #1
size_t rec1_len = GW_HDR + lens[1]; // её полная длина
uint8_t cut[sizeof(stream)];
size_t clen = rec1_off;
memcpy(cut, stream, rec1_off);
memcpy(cut + clen, stream + rec1_off + rec1_len, slen - rec1_off - rec1_len);
clen += slen - rec1_off - rec1_len;
gw_parser_t p; gw_parser_init(&p);
gw_parser_push(&p, cut, clen);
int got = 0; uint16_t exp_ids[3] = { 0, 2, 3 };
uint16_t id, len; const uint8_t *rpl;
while (gw_parser_next(&p, &id, &rpl, &len)) {
CHECK(id == exp_ids[got], "дыра: id %u != ожид. %u (rec %d)", id, exp_ids[got], got);
got++;
}
CHECK(got == 3, "дыра: извлечено %d записей, ожидалось 3", got);
CHECK(p.gaps == 1, "дыра: gaps %llu != 1 (потеряна ровно запись #1)", (unsigned long long)p.gaps);
for (int i = 0; i < 4; i++) free(bufs[i]);
}
// 3. Случайный magic в данных (ложное совпадение) — hcrc8 должен отсеять
// ГАРАНТИРОВАННО (crc намеренно испорчен), ресинк не теряет настоящую запись.
{
uint8_t junk[64];
for (int i = 0; i < 64; i++) junk[i] = (uint8_t)(rand() & 0xFF);
junk[10] = GW_MAGIC0; junk[11] = GW_MAGIC1;
uint8_t want = gw_crc8(junk + 10, 7);
junk[17] = (uint8_t)(want + 1); // намеренно НЕ совпадает с валидным hcrc8
uint16_t plen = 50;
uint8_t plbuf[50];
for (int i = 0; i < 50; i++) plbuf[i] = (uint8_t)(rand() & 0xFF);
uint8_t stream[64 + GW_HDR + 50];
memcpy(stream, junk, 64);
size_t slen = 64 + gw_pack(stream + 64, 7, plbuf, plen);
gw_parser_t p; gw_parser_init(&p);
gw_parser_push(&p, stream, slen);
uint16_t id, len; const uint8_t *rpl;
int ok = gw_parser_next(&p, &id, &rpl, &len);
CHECK(ok == 1, "ложный magic: запись не найдена");
CHECK(id == 7, "ложный magic: id %u != 7", id);
CHECK(len == plen && memcmp(rpl, plbuf, plen) == 0, "ложный magic: данные не совпали");
CHECK(p.bad_crc >= 1, "ложный magic: bad_crc не сработал (%llu)", (unsigned long long)p.bad_crc);
}
// 4. Обрыв хвоста (payload/заголовок не влез целиком) — парсер должен
// вернуть 0 и НЕ прочитать частичные данные как запись, пока хвост не доедет.
{
uint16_t plen = 500;
uint8_t plbuf[500];
for (int i = 0; i < 500; i++) plbuf[i] = (uint8_t)(rand() & 0xFF);
uint8_t rec[GW_HDR + 500];
size_t rl = gw_pack(rec, 3, plbuf, plen);
gw_parser_t p; gw_parser_init(&p);
gw_parser_push(&p, rec, rl - 10); // не хватает 10 последних байт
uint16_t id, len; const uint8_t *rpl;
CHECK(gw_parser_next(&p, &id, &rpl, &len) == 0, "обрыв хвоста: запись найдена преждевременно");
gw_parser_push(&p, rec + rl - 10, 10); // дослали остаток
CHECK(gw_parser_next(&p, &id, &rpl, &len) == 1, "обрыв хвоста: запись не найдена после докладки");
CHECK(id == 3 && len == plen, "обрыв хвоста: данные после докладки не совпали");
}
// 5. Wrap-around id (u16): 65534,65535,0,1 — без ложных "дыр" на переходе.
{
uint8_t pl0[10] = {0}, pl1[10] = {0}, pl2[10] = {0}, pl3[10] = {0};
uint8_t stream[4 * (GW_HDR + 10)];
size_t slen = 0;
slen += gw_pack(stream + slen, 65534, pl0, 10);
slen += gw_pack(stream + slen, 65535, pl1, 10);
slen += gw_pack(stream + slen, 0, pl2, 10);
slen += gw_pack(stream + slen, 1, pl3, 10);
gw_parser_t p; gw_parser_init(&p);
gw_parser_push(&p, stream, slen);
int got = 0;
uint16_t id, len; const uint8_t *rpl;
while (gw_parser_next(&p, &id, &rpl, &len)) got++;
CHECK(got == 4, "wrap: извлечено %d записей, ожидалось 4", got);
CHECK(p.gaps == 0, "wrap: ложные дыры на переходе через 0: %llu", (unsigned long long)p.gaps);
}
printf("gwtest: %s\n", fails ? "ПРОВАЛ" : "OK");
return fails ? 1 : 0;
}
#endif

137
src/udp_probe.c Normal file
View File

@@ -0,0 +1,137 @@
// udp_probe.c — минимальный генератор/приёмник UDP для тестирования udp_gw
// (roadmap §10 п.8). НЕ часть штатного тракта — только инструмент бринг-апа
// и E2E-тестов: busybox-rootfs Pluto без nc/socat (проверено на железе),
// а Python на хосте запрещён (CLAUDE.md правило №1) — значит свой маленький
// C-бинарь, тот же кросс-компилятор, что и весь тракт.
//
// send: udp_probe send ip:port РАЗМЕРАНКААУЗА_МС] stdin -> UDP
// recv: udp_probe recv ПОРТ ТАЙМАУТРОСТОЯ_С UDP -> stdout
//
// recv сам завершается после ТАЙМАУТРОСТОЯ_С секунд без новых датаграмм
// (busybox без `timeout` — та же причина, что и в scripts/test_file_link.sh).
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
#include <errno.h>
#include <signal.h>
#include <time.h>
#include <sys/select.h>
#include <sys/time.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
static volatile sig_atomic_t g_stop = 0;
static void on_signal(int s) { (void)s; g_stop = 1; }
static int parse_hostport(const char *hostport, struct sockaddr_in *out) {
const char *colon = strrchr(hostport, ':');
if (!colon || colon == hostport) return -1;
char host[64];
size_t hlen = (size_t)(colon - hostport);
if (hlen >= sizeof(host)) return -1;
memcpy(host, hostport, hlen); host[hlen] = 0;
memset(out, 0, sizeof(*out));
out->sin_family = AF_INET;
out->sin_port = htons((uint16_t)atoi(colon + 1));
return inet_pton(AF_INET, host, &out->sin_addr) == 1 ? 0 : -1;
}
// stdin читается чанками по `chunk` байт, каждый чанк — одна UDP-датаграмма.
// Короткий чанк (fread < chunk) — признак EOF (как fread в transmitter.c).
static int mode_send(const char *hostport, int chunk, int pause_ms) {
struct sockaddr_in dst;
if (parse_hostport(hostport, &dst) != 0) {
fprintf(stderr, "udp_probe: не разобрал '%s' (ожидался ip:port)\n", hostport);
return 1;
}
if (chunk <= 0 || chunk > 65000) {
fprintf(stderr, "udp_probe: размер чанка вне диапазона\n");
return 1;
}
int fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd < 0) { perror("socket"); return 1; }
uint8_t *buf = malloc((size_t)chunk);
uint64_t sent = 0, bytes = 0;
for (;;) {
size_t n = fread(buf, 1, (size_t)chunk, stdin);
if (n == 0) break;
if (sendto(fd, buf, n, 0, (struct sockaddr *)&dst, sizeof(dst)) < 0)
fprintf(stderr, "udp_probe: sendto: %s\n", strerror(errno));
else { sent++; bytes += n; }
if (n < (size_t)chunk) break; // короткое чтение = EOF
if (pause_ms > 0) usleep((useconds_t)pause_ms * 1000);
}
fprintf(stderr, "udp_probe send: отправлено %llu датаграмм, %llu байт\n",
(unsigned long long)sent, (unsigned long long)bytes);
free(buf);
close(fd);
return 0;
}
// Дампит содержимое каждой полученной датаграммы в stdout как есть (порядок
// прихода). Для сверки с исходником через md5sum, как в остальных e2e-тестах
// репозитория — если ничего не потерялось и не переупорядочилось, конкатенация
// совпадёт байт-в-байт.
static int mode_recv(int port, int idle_timeout_s) {
int fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd < 0) { perror("socket"); return 1; }
int one = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons((uint16_t)port);
if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind"); close(fd); return 1;
}
signal(SIGINT, on_signal);
signal(SIGTERM, on_signal);
uint8_t buf[65536];
uint64_t recvd = 0, bytes = 0;
time_t last_rx = time(NULL);
while (!g_stop) {
fd_set rfds; FD_ZERO(&rfds); FD_SET(fd, &rfds);
struct timeval tv = { .tv_sec = 1, .tv_usec = 0 };
int sel = select(fd + 1, &rfds, NULL, NULL, &tv);
if (sel < 0) { if (errno == EINTR) continue; break; }
if (sel > 0 && FD_ISSET(fd, &rfds)) {
ssize_t n = recvfrom(fd, buf, sizeof(buf), 0, NULL, NULL);
if (n < 0) { if (errno == EINTR) continue; break; }
fwrite(buf, 1, (size_t)n, stdout);
fflush(stdout);
recvd++; bytes += (uint64_t)n;
last_rx = time(NULL);
}
if (idle_timeout_s > 0 && time(NULL) - last_rx >= idle_timeout_s) break;
}
fprintf(stderr, "udp_probe recv: получено %llu датаграмм, %llu байт\n",
(unsigned long long)recvd, (unsigned long long)bytes);
close(fd);
return 0;
}
static void usage(const char *prog) {
fprintf(stderr, "udp_probe — тестовый генератор/приёмник UDP (busybox без nc)\n\n");
fprintf(stderr, " %s send ip:port РАЗМЕРАНКААУЗА_МС] stdin -> UDP\n", prog);
fprintf(stderr, " %s recv ПОРТ ТАЙМАУТРОСТОЯ_С UDP -> stdout\n", prog);
}
int main(int argc, char *argv[]) {
if (argc >= 4 && !strcmp(argv[1], "send"))
return mode_send(argv[2], atoi(argv[3]), argc >= 5 ? atoi(argv[4]) : 0);
if (argc >= 4 && !strcmp(argv[1], "recv"))
return mode_recv(atoi(argv[2]), atoi(argv[3]));
usage(argv[0]);
return 1;
}