diff --git a/.gitignore b/.gitignore index 9663919..ae3844c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ Analyzer/raw/dll/*.so Analyzer/raw/dll/*.dylib /Analyzer/raw/IR_Fox/.github **/.build +graphify-out/* diff --git a/docs_analysis/IR_protocol_analysis.md b/docs_analysis/IR_protocol_analysis.md new file mode 100644 index 0000000..350705d --- /dev/null +++ b/docs_analysis/IR_protocol_analysis.md @@ -0,0 +1,142 @@ +# IR-protocol — глубокий анализ (RX-производительность, обработка ошибок, протокол, мины) + +> Дата: 2026-07-01. Только чтение исходников. Цель: после переноса TX на DMA приём (RX) стал узким местом — разобрать RX-конвейер, обработку ошибок сигнала, дизайн протокола и найти баги/мины. Тайминги: `carrierFrec=38000`, `bitTakts=37` → `carrierPeriod=26µs`, `bitTime≈962µs` (комментарий про 1100 в `IR_DecoderRaw.h:107` устарел), `riseTime=962`, окно ±300µs, `IR_timeout≈15144µs`, `IR_ResponseDelay≈42мс`. Числа длительностей — оценки (замеров в коде нет). + +## 0. Резюме (главное) +1. **Найдены реальные баги, триггеримые ШУМОМ (не только валидным трафиком):** + - **CRITICAL — OOB-чтение** в `crcCheck` при `packSize==1` (`IR_DecoderRaw.cpp:887`): `packSize - crcBytes` = `1-2` → `uint8_t len=255` → `crc8` читает `dataBuffer[0..254]` при массиве 38 → OOB ~217 байт + `dataBuffer[255/256]`. Мусорный CRC (редко — ложно-валидный кадр) или HardFault у границы SRAM. Нижняя граница `packSize` нигде не проверяется. + - **HIGH — реальный крэш на Car: null-deref** `encoder->sendAccept()` без проверки (`IR_Decoder.cpp:169`). У Car декодер создан с `encoder==nullptr` (`Car/src/IR/IR.cpp:31`). Приходит валидный кадр `IR_MSG_DATA_ACCEPT`, адресованный машинке (addrTo==id, addrFrom≠0 и ` вместо `>=`): при `packSize==0` конец кадра не наступает, `i_dataBuffer` растёт до 304, запись `dataBuffer[304/8=38]` затирает соседнее поле `prevRise`. + - **MEDIUM — неинициализированные** `isWaitingAcceptSend`/`acceptSendTimer`/… (`IR_Decoder.cpp:72-79`): мусор при старте → возможен спонтанный `sendAccept` (и крэш по null-deref) без приёма чего-либо. +2. **RX-bottleneck — НЕ ISR и НЕ тяжёлый декод в прерывании** (декода в ISR нет, ISR лёгкий ~2-4µs). Узкое место структурное: **`tick()` вынимает РОВНО один сырой фронт за вызов** → скорость слива кольца привязана к частоте `loop()`; при медленном loop 250-элементный буфер (~120мс запаса) переполняется и рвёт кадры. Плюс битовый CRC ×2 на конец кадра и маленький hold-фильтр. +3. **Коррекции ошибок по сути нет** — detect-and-drop: 2×CRC8 (~16 бит), FEC/повтор отсутствует, однобитный bruteforce выключен («зависает»). Нет seq/дедупа/ретраев; ACK задуман, но отправитель его игнорирует (мёртвый механизм). 5-битная длина молча обрезает payload >24 байт (send возвращает Success, кадр теряется). + +--- + +## 1. RX-конвейер и производительность + +### Стадии (кто где исполняется) +- **A. EXTI-фронт → ISR** (`IR_DecoderRaw::isr`, `.cpp:274-308`; `attachInterrupt(pin, CHANGE)` в `IR_Decoder::enable :88-89`). Приёмник демодулирующий (TSOP), EXTI по фронтам огибающей. В ISR: `micros()` (в `noInterrupts/interrupts`), `dir = port->IDR&mask` (пост-фактум чтение уровня — при коротком импульсе возможна неверная dir), mute-гейт (`isPairSending` → фронт только в edgeTrace и return), `subBuffer.push(edge)`. +- **B. Сырое кольцо** `RingBuffer` (`.h:124`, `subBufferSize=250`, `IR_config.h:169`), ~8 байт/фронт ≈ 2КБ/декодер. `push/pop` под своей критсекцией. **`pop()` — один элемент за вызов.** +- **C. Фильтр импульсов** (`tick :417-444`) — по умолчанию **выключен** (`IR_INPUT_MIN_PULSE_US=0`), сырой фронт идёт прямо в декод. Включённый: hold-массив `[6]`, отсев пар `IR_timeout*2`. +- **E. Декод бита** (`processDecodedFront :466-746`): инлайн-отсев глитчей, окна таймингов, реконструкция пропущенных бит через `ceil_div`. +- **F. Сборка/sync/CRC** (`writeToBuffer :748-949`): биты в `dataBuffer[38]`, sync 3 бита/байт, длина из первого байта, на конце — `crcCheck`. +- **G. Диспетчеризация** (`IR_Decoder::_tick :130-172`): разбор msgType → gotData/gotBack/gotAccept/gotRequest/gotRaw; авто-ACK для DATA_ACCEPT. + +**Весь декод (C–G) — в `tick()`/loop, не в ISR.** Это осознанная разгрузка прерывания. + +### Узкое место +- ISR лёгкий (~2-4µs, оценка), даже при 5-6к фронтов/с ~1.5-2% CPU. Не он предел. +- **Структурный предел: 1 сырой pop/tick** (`.cpp:409-415`) → RX-throughput = частота `loop()`. Полный кадр ≈ 836 фронтов ≈ 836 tick'ов. Медленный loop (Serial-отладка, моторы, дисплей) < частоты фронтов → кольцо копится → overflow → рваные кадры → CRC/timeout reject. +- Буфер 250 = ~120мс запаса (250/2080 фр/с). Хватает от коротких стопоров, не от устойчивого отставания. +- Финальный CRC — битовый `crc8` дважды по всей длине (~5-10µs, оценка). `BRUTEFORCE_CHECK` (выкл, «зависает») при включении — O(len²·64), отдельная мина. + +### Переполнение/backpressure +- `subBuffer` полон → `push`=false, `isSubBufferOverflow=true` (транзиентный, сбрасывается в tick), счётчик `rxBriefRawOverflowDrops` (u16, насыщение), лог `QRAW`. +- hold-фильтр(6) переполнение → `pulseFilterDropHoldOverflow++` (стойкий), лог `HOLD`. +- Битовый буфер кадра → `isBufferOverflow`, лог `BUF`, сброс. +- `QFLT`/`pulseFilterDroppedByFilteredOverflow()` — **мёртвый код**, всегда 0. +- **Стойкого счётчика потерянных ПАКЕТОВ нет** — потери видны косвенно (CRC/TIMEOUT/SYNC в RX-brief-логе), последний битый кадр — в `rejectBuffer`. + +### RX во время своего TX (mute) +- `isPairSending` (число busy-энкодеров, пересчёт `refreshPairMuteState` под критсекцией) → в ISR фронт отбрасывается на входе (`:293-299`). Глушится **любой** чужой кадр на всё время своей передачи (`isSending`). Для DMA-TX снятие mute зависит от `externalFinishSend()` — задержка удлиняет «глухоту» на хвост. +- NVIC: библиотека приоритеты почти не трогает; RX-EXTI — дефолт ядра, поднять `setReceiveExtiPreemptPriority` (`IR_Decoder.cpp:44-62`). **Нужно: preempt RX-EXTI срочнее DMA-TX**, иначе чужой DMA/IRQ сдвигает метку фронта → искажение `risePeriod` (окно всего ±300µs). + +### Идеи ускорения RX (trade-off) +1. **Таймер input-capture + DMA вместо софт-EXTI** — аппаратный штамп фронтов, убирает per-edge ISR и джиттер метки. Дорого (канал таймера, wrap, восстановление dir), но чистейшие тайминги. +2. **Батч-pop сырых фронтов за tick** (с лимитом на итерацию) — снимает привязку к частоте loop. Главный дешёвый выигрыш. +3. **subBuffer 250→512/1024** — больше запаса (RAM: 1024≈8КБ). +4. **Табличный CRC** вместо битового ×2 (~8× быстрее, +~512Б flash). +5. **Поднять preempt RX-EXTI выше DMA-TX** (механизм есть). +6. **Дешёвая метка** DWT->CYCCNT вместо `micros()`+критсекции в ISR. + +--- + +## 2. Обработка ошибок сигнала и протокол + +### CRC — это НЕ CRC16-CCITT, а два сцепленных CRC8 +`crc8()` (`IR_config.cpp:17-33`) битовый MSB-first, init=0xFF. Полиномы `poly1=0x31`, `poly2=0x8C` (=битовое зеркало 0x31, но алгоритм тот же → фактически два разных CRC8). Сборка (`crcCheck :951-970`): `crc = crc8(0..len,poly1)<<8 | crc8(0..len+1,poly2)`. Асимметрия: байт2 покрывает данные+байт1, байт1 — только данные. Суммарно ~16 бит, вероятность пропуска ~1/65536, но гарантированной хэмминг-дистанции реального CRC16 нет. Мелочь `:959`: `dataBuffer[len] == (crc>>8) & 0xFF` парсится как `(==)&0xFF` — работает случайно (хрупко при смене `crc_t`). + +### Фильтры/устойчивость +- Pulse-filter по длительности — **по умолчанию выкл** (`IR_INPUT_MIN_PULSE_US=0`). +- Инлайн-отсев глитчей в декодере — вкл: `IR_SHORT_LOW_GLITCH_REJECT`, `IR_MICRO_GAP_RISE_REJECT` (`:484-516`), «подтяжка фазы». +- Преамбула устойчива к случайному шуму (2 согласованных периода в узком окне). Sync 3 бита/байт — ловит расстройку сетки (до 3 ошибок → reject), но это не коррекция. + +### Коррекции нет +- FEC отсутствует. Есть эвристическая реконструкция бит по времени (`:636-741`) — угадывание, финально валидируется CRC. +- `BRUTEFORCE_CHECK` (однобитная коррекция перебором) — **выключен, `//TODO: зависает`** (`IR_config.h:162`). При 16-битном контроле ещё и рискует ложной коррекцией. +- `rejectBuffer` — не коррекция, а «detect-and-expose» битого кадра приложению. + +### Подтверждения (Accept) — механизм есть, но мёртвый +Приёмник на `IR_MSG_DATA_ACCEPT` планирует `sendAccept(from, crc8(данные))` через `acceptDelay` (`IR_Decoder::_tick :157-171`). **Отправитель Accept игнорирует** — логика ожидания/ретрая закомментирована (`IR_Encoder.cpp:736-741`, `rawSend:906 «TODO»`). Ретраев/дедупа/seq нет. Гарантий доставки нет. + +### Форматы кадров +Первый байт `(msgType<<5)|(packSize&0x1F)`, `packSize` — полный размер кадра, длина 5 бит (0..31). Адреса big-endian. + +| Тип | Класс | Поля | data offset | CRC | +|---|---|---|---|---| +| BACK 0 | DataBack | msg, addrFrom(1-2) | 3 | 2 | +| ACCEPT 1 | Accept | msg, addrFrom, customByte(3) | — | 2 | +| REQUEST 2 | Request | msg, addrFrom, addrTo | — | 2 | +| BACK_TO 4 | DataBack | msg, addrFrom, addrTo | 5 | 2 | +| DATA_NOACCEPT 6 | Data | msg, addrFrom, addrTo | 5 | 2 | +| DATA_ACCEPT 7 | Data (нужен Accept) | как 6 | 5 | 2 | + +Адресация (`IR_config`): Broadcast=65000+; id==0 = promiscuous (принимает всё); машинки 1..31999, КТ 32000..63999, пульты 64000..64999. + +### Что теряется/портится молча +1. **Переполнение 5-битной длины (серьёзно):** накладные Data = 7 байт → payload ≤24, но send разрешает `len` до `bytePerPack=31` (`IR_Encoder.cpp:691`). При len 25..31 `packSize` пишется как `&0x1F` (оборот) → декодер читает короткий кадр → CRC не сойдётся → пакет теряется, **а send вернул Success**. (Комментарий `config:96` сам предупреждает про «31 vs 24».) +2. **Нет seq** → дубликаты/пропуски незаметны (любой будущий ретрай = повтор действия). +3. **Коллизии не детектируются** (half-duplex без CSMA/CD; `isPairSending` глушит только свой RX). TODO «отложить TX после приёма» (`IR_Encoder.cpp:5`) не реализован. +4. Тайминговая реконструкция может собрать «правдоподобный» буфер, редко проходящий CRC на неверных данных (~1/65536). +5. Accept-customByte — 1 байт CRC8 (1/256 ложного подтверждения), но т.к. игнорируется — без эффекта. + +--- + +## 3. Мины/баги (severity, подтверждено по коду) + +| # | Severity | Файл:line | Суть | Триггер | +|---|----------|-----------|------|---------| +| B1 | **CRITICAL** | `IR_DecoderRaw.cpp:887,951-970` | `crcCheck(packSize-crcBytes)` при `packSize==1` → len=255 → OOB-чтение `dataBuffer[0..256]` (массив 38) | шум: первый байт с младш. 5 бит =1 (0x01,0x21,…) после лока преамбулы | +| B2 | **HIGH** | `IR_Decoder.cpp:167-169` | `encoder->sendAccept()` без null-check; у Car `encoder==nullptr` (`IR.cpp:31`) → HardFault | валидный DATA_ACCEPT машинке (детерминированно) или шум (1/65536) | +| B3 | **HIGH** | `IR_DecoderRaw.cpp:753` | off-by-one `>` вместо `>=` → OOB-запись `dataBuffer[38]` (затирает `prevRise`) при `packSize==0` | шум: первый байт с младш. 5 бит =0, ~38 «дата»-байт подряд | +| B4 | **MEDIUM** | `IR_Decoder.cpp:72-79` (конструктор) | `isWaitingAcceptSend`/`acceptSendTimer`/`addrAcceptSendTo`/`acceptCustomByte` не инициализированы → спонтанный `sendAccept` (+ B2 крэш) | мусор в RAM при старте | +| B5 | **MEDIUM** | `RingBuffer.h:28-37` + `IR_DecoderRaw.cpp:408-415` | вложенные критсекции не вкладываются (`interrupts()` в `pop()` снимает внешний `noInterrupts()` из `tick()`) → торн-рид `*rawPtr`, слот уже свободен для ISR | заполненное кольцо в узком окне | +| B6 | MEDIUM | `IR_Decoder.cpp:112-115`, `IR_DecoderRaw.cpp:280-282` | джиттер метки фронта: `std::function` indirect-call + лишний `noInterrupts/interrupts` вокруг `micros()` в ISR → разброс `risePeriod` | нагрузка/параллельный DMA | +| B7 | LOW | `IR_DecoderRaw.cpp:959` | приоритет операторов в сравнении CRC — работает случайно | смена `crc_t` | +| B8 | LOW | `IR_DecoderRaw.cpp:448` | `isSubBufferOverflow=false` без критсекции параллельно ISR `=true` → потеря флага (диагностика) | — | +| B9 | LOW | `IR_DecoderRaw.cpp:1612` | sentinel `prevRise==0` при `micros()==0` (раз в ~71.6 мин) → разовый глитч преамбулы | редко | +| B10 | LOW (спит) | `IR_DecoderRaw.cpp:980-986`, `:1676` | `ceil_div` /`riseTime`; при `freeFrec==true` `riseSyncTime` может уйти <300 → `riseTimeMin` overflow / деление на 0 | только при `freeFrec` (по умолч. выкл) | + +**Опровергнуто (REFUTED):** wrap `micros()` в таймаутах (wrap-safe, `(uint32_t)(a-b)`); переполнение subBuffer за границы (push проверяет isFull); `packSize>dataByteSizeMax` (5 бит → max 31 < 38, опасна только НИЖНЯЯ граница); overflow `highCount/lowCount` (гейт по IR_timeout); аллокации в ISR (bind SBO на attachInterrupt, в ISR только indirect-call); реентерабельность одной EXTI (M4 не вытесняет сам себя). + +**Требует железной проверки:** джиттер метки фронта под параллельным DMA-TX; NVIC preempt RX>TX; воспроизведение B1/B3 (подать первый байт 0x01 / 0x00 под отладчиком); RAM-бюджет subBuffer (2КБ/декодер) на F4-пульте с несколькими декодерами. + +--- + +## 4. Приоритетный список доработок + +### Сначала — баги (кандидаты на фикс, все в общей библиотеке → blast radius на все проекты) +1. **B1 CRITICAL:** валидировать `packSize >= crcBytes` (и разумную нижнюю границу) ДО `crcCheck`; отбрасывать кадр иначе. Однострочная защита от OOB. +2. **B2 HIGH:** `if (encoder) encoder->sendAccept(...)` (null-check). Спасает Car от HardFault. +3. **B3 HIGH:** `>=` вместо `>` в границе `writeToBuffer:753` (+ обрабатывать `packSize==0` как невалидный). +4. **B4 MEDIUM:** инициализировать accept-поля в конструкторе (`isWaitingAcceptSend=false` и т.д.). +5. **B5 MEDIUM:** `pop()` отдавать по значению (out-параметр) под одной критсекцией, не указателем во внутренний слот. + +### Затем — производительность RX (эффект/сложность) +- Батч-pop за tick (высокий эффект / низкая сложность) → снять привязку к частоте loop. +- Табличный CRC; больше subBuffer; preempt RX>TX; (крупно) input-capture+DMA. + +### Протокол (эффект/сложность) +1. **Валидация полной длины в send** (высокий/низкий): проверять `packSize<=31` (Data ≤24, BACK ≤26), возвращать `PayloadTooLarge` — убирает тихую потерю. +2. **Seq + дедуп** (высокий/средний): 1 байт seq, дедуп по (addrFrom, seq). Предпосылка для любого ретрая. +3. **Реальный ACK+ретрай на отправителе** (высокий/средне-высокий): механизм генерации Accept уже есть, замкнуть петлю (нужен seq). +4. **Listen-before-talk** (средний/низкий): отложить TX пока `isReciving()` — TODO уже помечен. +5. **Заголовочный CRC/дублирование длины** (средний/низкий): битая длина не должна ломать границу кадра. +6. Кадровый повтор как лёгкий FEC (в паре с seq); CRC16-CCITT вместо 2×CRC8 (маргинально, ломает совместимость); фрагментация >31 байт с ACK (если реально нужны большие payload); починить/включить bruteforce (сомнительно). + +--- + +## Ключевые файлы +`IR-protocol/`: `IR_DecoderRaw.{h,cpp}` (ISR, декод, CRC, reject, преамбула, mute), `IR_Decoder.{h,cpp}` (диспетчеризация, Accept-петля, EXTI/NVIC), `IR_Encoder.{h,cpp}` (сборка кадра, CRC, send, isSending), `PacketTypes.{h,cpp}` (форматы, адресация), `IR_config.{h,cpp}` (тайминги, framing, crc8), `RingBuffer.h`, `IrTxIsrBufferedStorage.h`. diff --git a/docs_analysis/Signal_physics_analysis.md b/docs_analysis/Signal_physics_analysis.md new file mode 100644 index 0000000..921286e --- /dev/null +++ b/docs_analysis/Signal_physics_analysis.md @@ -0,0 +1,72 @@ +# Анализ физики сигнала: TX-форма против RX-коррекций + +## 1. Резюме + +Система в основном **не борется сама с собой**: и TX, и RX выведены из одних констант `IR_config.h`, форма на передаче при `multiply=2` (дефолт, чётный) — **бит-точная, без округлений**, а подавляющая часть RX-эвристик ловит деформации, которые реально вносит ИК-канал через демодулятор (TSOP-АРУ, отражения, фон, расхождение кварцев). Различение 0/1 идёт по скважности (`highTime>lowTime`, IR_DecoderRaw.cpp:620-633) внутри периода и **не зависит** от широкого окна периода — это архитектурно здоровое решение. + +Но есть **три очага самопорождённых ошибок**, которые RX молча компенсирует: + +1. **Главный (чистый баг, не канал):** RX строит всю временную базу на усечённой константе `carrierPeriod = 1e6/38000 = 26` (bitTime=962 µs), тогда как TX физически излучает на реальной частоте таймера — `26.316 µs`, bitTime≈973.7 µs. Систематический **односторонний сдвиг ~11.7 µs/бит (1.2%)**, который целиком съедается допуском `tolerance=300`. Это TX/модель кривит — RX терпит. +2. **Конфиг-зависимый:** ISR-путь вносит программный джиттер фронтов (латентность/вытеснение, IR_Encoder.cpp:1037-1042), которого нет у DMA-пути. Часть glitch/reconstruction-логики RX страхует именно этот самопорождённый джиттер. +3. **Спровоцированный дизайном модуляции:** протокол использует **длинные непрерывные MARK** (преамбула 49 периодов ≈1290 µs, бит '1' = 25 периодов ≈658 µs). Длинные непрерывные burst'ы — классический триггер АРУ TSOP-демодулятора (просадка огибающей в середине долгого mark) → «рваная единица», которую чинит реконструкция E/G. Деформацию вносит канал (TSOP), но провоцирует её выбор формы на TX. + +Остальное — стартовая фаза (≤26 µs), дрейф периода, дребезг фронтов — это то, что **обязан** обрабатывать любой ИК-приёмник; здесь самолечения нет. + +## 2. Карта механизмов RX + +| Механизм RX (file:line) | Какую деформацию ловит/чинит | Детект/Коррекция | Источник деформации | Вердикт | +|---|---|---|---|---| +| **A.** pulse-filter, минимум ширины (DecoderRaw 1493-1555) | иголка/дребезг компаратора, пара «↑↓» короче минимума | детект | реальный канал (front-end, дребезг) | оправданно (но дефолт `MIN_PULSE_US=0` — выключен) | +| **B.** short-LOW glitch reject + phase nudge (484-499, 8-16) | короткий ложный ↑ после микро-провала огибающей | детект + коррекция фазы | реальный канал (TSOP-АРУ, просадка) | оправданно | +| **C.** micro-gap rise reject + nudge (500-516) | распад фронта на два близких | детект + коррекция фазы | реальный канал; **частично** ISR-джиттер | в основном оправданно | +| **D.** отсев короткого стартового периода / спада (517-524, 566-573) | сжатый ↑→↑ без контекста, дребезг спада | детект | реальный канал (шум/дребезг) | оправданно | +| **E.** сбор рваной единицы (526-557) | длинный HIGH-бит, распавшийся из-за пропадания несущей | подготовка к коррекции | канал (TSOP-АРУ), **спровоцировано длинными MARK на TX** | оправданно, но триггер — дизайн формы | +| **F.** окно валидности периода (h:24-27; 583-592) | грубо растянутый/сжатый период | детект/брак | реальный канал (потеря кадра) | оправданно | +| **G.** реконструкция ceil_div, invertErr, «лишний ноль/единица» (617-741, 980-986) | пропущенные ↑-фронты, N слипшихся бит, дроблёный/короткий бит | **коррекция (ядро)** | канал (дропаут несущей); косвенно ISR-джиттер | оправданно под канал; часть работы — следствие TX-дизайна | +| **H.** восстановление сетки по sync (804-853) | сдвиг сетки data↔sync из-за лишнего/недостающего бита | детект срыва + локальная коррекция на 1 бит | канал (потеря бита); маркер `{!L,L,!M}` от TX детерминирован | оправданно (детект реальной потери) | +| **I.** преамбула Idle→Candidate→Locked, EMA периода, якорь фазы, freeFrec (1557-1723) | дрейф периода, разброс кварцев, старт-фаза, чужая частота | коррекция базы/фазы | **смешанный:** реальный (кварцы/дрейф) + самопорождённый (старт-фаза TIM ≤26 µs, сдвиг 1.2% из carrierPeriod=26) | необходимо всегда; но частично адаптируется к TX-багу | + +## 3. Где Даша сама порождает ошибку формы и потом её чинит + +**(а) Усечённый `carrierPeriod=26` — самое чистое «самолечение».** +Физическая генерация НЕ использует эту константу: `setOverflow(carrierFrec*multiply, HERTZ_FORMAT)` (IR_Encoder.cpp:411) даёт реальные ~26.316 µs. А вся сетка RX (`riseSyncTime=962`, `riseTimeMin/Max`, `aroundRise`, `IR_timeout`) выведена из `carrierPeriod=26`. Результат: TX-биты систематически **на 1.2% длиннее**, чем ждёт RX. Сдвиг **односторонний** (всегда в плюс), поэтому запас `aroundRise` со стороны `riseTimeMax=1262` меньше, чем со стороны `riseTimeMin=662`. На бит это 11.7 µs, на преамбулу — ~15 µs (1274 vs 1289.5 µs). Это не канальная ошибка — это несогласованность двух представлений одной частоты внутри одного кода. **RX-допуск `tolerance=300` существует в т.ч. чтобы это не всплыло.** Исправляется тривиально (общий реальный период), после чего часть запаса окна освобождается под настоящий канал. + +**(б) ISR-путь как источник джиттера, который чинит glitch-логика.** +DMA-путь детерминирован (аппаратная пересылка BSRR по TIM_UP, IrDmaTxStm32.h:246-253). ISR-путь плавает по времени из-за латентности прерывания и обхода списка энкодеров (IR_Encoder.cpp:1037-1042). Плавающие фронты — ровно то, что подхватывают B/C (nudge) и частично G. То есть при работе через ISR приёмник страхует **собственный** TX-джиттер, а не канал. Лечение: всегда использовать DMA-путь (или признать ISR как деградированный режим). + +**(в) Длинные непрерывные MARK провоцируют TSOP-АРУ → «рваная единица».** +Кодирование pulse-width с MARK до 25 периодов и преамбулой 49 периодов непрерывной несущей — это осознанный выбор формы на TX. TSOP-демодуляторы имеют АРУ и штатно «проседают» на длинных непрерывных burst'ах, порождая ложные провалы огибающей в середине mark. Реконструкция E + G (ceil_div, `highCount==0 && highTime>riseTime/3 → highCount++`, 641-648) существует во многом чтобы это дособрать. Деформацию физически вносит канал (TSOP), но её вероятность задаёт форма на TX. Это не баг, но это «дизайн, требующий костыля на приёме». + +**(г) Что НЕ является самолечением (важно не перепутать):** +- Стартовая фаза кадра ≤1 период несущей (free-running TIM, IrDmaTxStm32.h:241-253) — компенсируется якорем фазы преамбулы (I, `prevRise=front.time+mean/2`, 1709). Но приёмник **в принципе** не знает абсолютную фазу передатчика и обязан выводить её из преамбулы — это необходимая функция, а не борьба с собой. +- Маркер sync `{!L, L, !M}` детерминирован (IR_Encoder.cpp:305-331), буфер зануляется — H ловит **реальную** потерю/вставку бита в канале, не TX-артефакт. +- Квантование фронтов сеткой 13.16 µs (DMA/BSRR) — тонет в допуске TSOP (десятки µs) и погоды не делает. + +## 4. Широкие ли допуски и что они маскируют + +`aroundRise` = 662..1262 µs при бите 962 → **±31%**. По логике подсчёта бит это **не** широко и путаницы 1-бит/2-бита не создаёт: 2 бита ≈1924 µs, порог `ceil_div` на 2 бита ≈1443 µs; между концом окна одного бита (1262) и порогом двух (1443) есть зазор ~180 µs — перекрытия нет. Значение бита определяется по duty, а не по длине периода, поэтому широкое окно значения не портит. + +Куда реально уходит ±300 µs: +- **Легитимно:** асимметрия leading/trailing фронтов TSOP-демодулятора (типично десятки–сотни µs), разброс кварцев RX/TX, дрожание огибающей, задержка АРУ. Для настоящего ИК-канала запас в этом порядке нужен. +- **Маскирует баг:** самопорождённый односторонний сдвиг 1.2% (пункт 3а) полностью прячется в этом окне. Именно поэтому баг `carrierPeriod=26` не проявляется как отказ — допуск его глотает. Это и есть «система не замечает, что борется сама с собой». + +Слабое место (согласен с RX-разбором): при `freeFrec=false` база `riseTime` жёстко=962 и **не** подстраивается по ходу кадра (медиана — «TODO», IR_DecoderRaw.h:22). Односторонний TX-сдвиг + систематическое смещение периода передатчика съедают запас именно со стороны `riseTimeMax`; на краю окна шум легче перекидывает интервал в ветку реконструкции G, накручивая `errors.*`. Также джиттер-полки преамбулы (18%, но не менее 80 µs) **жёстче** рабочего окна декодера (31%) — залочиться труднее, чем потом удержать биты; это перекос порогов, а не форма. + +## 5. Рекомендации + +**Чинить на TX (чтобы соответствующие RX-эвристики стали не нужны):** + +1. **Согласовать период несущей между TX и RX.** Убрать усечение `carrierPeriod=26` из временной модели RX: выводить `bitTime`/`riseSyncTime` из той же реальной частоты, что даёт `setOverflow(carrierFrec*multiply,...)` (дробно, напр. фиксированная точка). Устраняет односторонний сдвиг 1.2% и освобождает ~12 µs/бит запаса окна. *Trade-off:* дробная арифметика на MCU; но можно один раз посчитать реальный период при инициализации. +2. **Всегда использовать DMA-путь; ISR — только как явный деградированный fallback.** Снимает программный джиттер фронтов и часть нагрузки с B/C/G. *Trade-off:* требует свободного канала DMA+таймера; на платах без ресурса — остаётся ISR с его джиттером. +3. **Пересмотреть длину непрерывных MARK/преамбулы под конкретный TSOP.** Если приёмник — демодулирующий TSOP с АРУ, длинные burst'ы (658/1290 µs) провоцируют «рваную единицу». Сократить максимальный непрерывный mark или разбить преамбулу на более частые фронты в пределах спецификации TSOP по burst-length. *Trade-off:* меняет протокол/энергетику кадра, требует ревизии окон преамбулы RX; выигрыш — можно ослабить E/G. +4. **(Опционально) фазовое выравнивание старта кадра** (сброс счётчика TIM при старте потока). Уберёт стартовый сдвиг ≤26 µs. *Trade-off:* минимальный выигрыш (преамбула всё равно якорит фазу) — низкий приоритет. + +**Что из «коррекций» оставить под настоящий канал (даже после идеального TX):** + +- **G (ceil_div реконструкция) и E (сбор рваной единицы)** — оставить: дропаут несущей и просадка АРУ на реальной дистанции/отражениях никуда не денутся. Это ядро устойчивости к каналу. +- **B/C (glitch reject + phase nudge)** и **A (pulse-filter)** — оставить: дребезг/иголки front-end реальны; A стоит **включить** (`IR_INPUT_MIN_PULSE_US>0`) на STM32, где сейчас фронты идут в декодер напрямую (tick 440-444). +- **H (sync-grid recovery)** — оставить: детектирует реальную потерю/вставку бита; маркер детерминирован, ложных срабатываний от TX не будет. +- **I (преамбула: EMA периода, якорь фазы)** — оставить: адаптация под кварцы/дрейф и вывод фазы обязательны для любого RX. Рекомендуется **выровнять пороги**: сделать джиттер-полку преамбулы не жёстче рабочего окна декодера, иначе лок труднее удержания. +- **Кандидаты на сужение после фикса TX:** окно `aroundRise` можно сузить в сторону `riseTimeMax` (убрать заложенный под 1.2% односторонний запас) и реализовать бегущую медиану периода (IR_DecoderRaw.h:22 TODO) — тогда допуск начнёт отражать реальный канал, а не прятать несогласованность констант. + +**Итог-вердикт:** коррекции RX в массе **оправданны под реальный ИК-канал**, но система содержит одно явное самолечение (усечённый `carrierPeriod` → 1.2% сдвиг, спрятанный в `tolerance`) и два условных (ISR-джиттер; длинные MARK, провоцирующие TSOP-АРУ). Широкий допуск ±31% сам по себе безопасен для подсчёта бит, но именно он маскирует баг с частотой — то есть система действительно частично борется сама с собой, просто настолько тихо, что это не видно в `errors.*`. \ No newline at end of file diff --git a/docs_analysis/crc_combined_sim.py b/docs_analysis/crc_combined_sim.py new file mode 100644 index 0000000..d715abc --- /dev/null +++ b/docs_analysis/crc_combined_sim.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Точное сравнение CRC-схем на бит-слипе. crc8 как в IR_config.cpp. +# ВАЖНО: CRC в протоколе считается над БАЙТАМИ данных (sync НЕ входит — подтверждено кодом), +# поэтому здесь sync в CRC-математику не включён (это корректно для оценки самой CRC). +# Второй ниббл — СО СЦЕПКОЙ: poly2 над (data + crc1_байт), как в оригинале crcCheck. +# Сквозной анализ с sync-фильтром требует модели FSM декодера (resync/‘исправление лишнего нуля’) — см. заметку. +import random +random.seed(3) +POLY1=0x31; POLY2=0x8C +def crc8(d,poly): + c=0xFF + for b in d: + c^=b + for _ in range(8): c=((c<<1)^poly)&0xFF if c&0x80 else (c<<1)&0xFF + return c +def fold4(x): return (x^(x>>4))&0x0F +def single(d): return (crc8(d,POLY1),) +def double(d): c1=crc8(d,POLY1); return (c1, crc8(d+[c1],POLY2)) # текущая 2 байта, сцепка +def combo_indep(d): return ((fold4(crc8(d,POLY1))<<4)|fold4(crc8(d,POLY2)),) # 1 байт, БЕЗ сцепки +def combo_chain(d): c1=crc8(d,POLY1); return ((fold4(c1)<<4)|fold4(crc8(d+[c1],POLY2)),) # 1 байт, СО сцепкой (как ориг.) +def bitsof(d): return [x for b in d for x in [(b>>(7-j))&1 for j in range(8)]] +def bytesof(b): return [int(''.join(map(str,b[i:i+8])),2) for i in range(0,len(b),8)] +def slip(d,p): b=bitsof(d); return bytesof(b[:p]+b[p+1:]+[0]) +S=[("single 1б",single),("double 2б (текущая, сцепка)",double), + ("combo 1б БЕЗ сцепки",combo_indep),("combo 1б СО сцепкой (как ориг.)",combo_chain)] +for L in (4,6,8): + und={n:0 for n,_ in S}; ch=0 + for _ in range(300000): + d=[random.randint(0,255) for _ in range(L)]; p=random.randint(L*2,L*6) + c=slip(d,p) + if c==d: continue + ch+=1 + for n,f in S: + if f(c)==f(d): und[n]+=1 + print(f"данные {L}б, {ch} слипов:") + for n,_ in S: print(f" {n:34s}: {und[n]/ch*100:.4f}% необнаружено") diff --git a/docs_analysis/crc_shift_findings.md b/docs_analysis/crc_shift_findings.md new file mode 100644 index 0000000..667318d --- /dev/null +++ b/docs_analysis/crc_shift_findings.md @@ -0,0 +1,33 @@ +# CRC при сдвиге битов — симуляция (воспроизведение и оценка сжатия) + +> Скрипт: `crc_shift_sim.py` (рядом). `crc8` точно как в `IR_config.cpp` (MSB-first, init=0xFF, без final-xor). `poly1=0x31`, `poly2=0x8C` (битовое зеркало 0x31). Модель ошибки: сдвиг битов в СЕРЕДИНЕ данных (бит-слип / циклический сдвиг), длина кадра сохраняется; «чек» сверяется как `check(искажённые данные) == check(оригинал)`. 200000 попыток на модель, данные 3/5/8 байт. + +## 1. Твой исторический баг ВОСПРОИЗВЕДён +Одиночный CRC8 (один полином) реально пропускает сдвиг в середине пакета. Примеры (данные → сдвиг → искажённые): +- `26 0b 05` → `26 16 0a`: `crc1=0x78` совпал → **single ПРОШЁЛ**; двойная схема: `crc2 0xd8≠0x5c` → **поймала**. +- `cc4fb8f633` → `cc5f71ec66`: `crc1=0x20` совпал → single прошёл; double поймала (`crc2 0xfc≠0x90`). +- `b983315b416440c3` → `b983315e82c88186`: `crc1=0x3a` совпал → single прошёл; double поймала. + +## 2. Частота пропуска (доля НЕобнаруженных сдвигов) +| Схема | slip-delete | rotate-all | rotate-mid-window | +|---|---|---|---| +| **single** (1×CRC8, 8 бит) | 0.29–0.41 % | ~0.78 % | 0.55–0.78 % | +| **double** (2×CRC8, 16 бит) — текущая | **0.007–0.015 %** | **0.025–0.03 %** | **0.008–0.026 %** | +| nibble-fold (4+4 бит) | 0.76–0.80 % | ~1.58 % | 1.18–1.55 % | +| nibble-trunc (4+4 бит) | 1.37–1.57 % | ~1.53 % | 1.22–1.57 % | + +Вывод: **double примерно в 20–40× надёжнее single** и ловит практически все сдвиги, которые single пропускает. Это подтверждает, зачем добавляли второй полином (0x8C = зеркало 0x31 ловит «зеркальные» сдвиговые ошибки, слепые для 0x31). + +## 3. Идея «склеить 2 байта в 1» (полбайта на полином) — ХУЖЕ, чем есть +- **nibble-fold** (свернуть каждый CRC8 в 4 бита xor'ом) и **nibble-trunc** (взять по 4 бита) дают **0.6–1.6 % пропусков — это ХУЖЕ даже одиночного полного CRC8** и в ~50–150× хуже текущей двойной схемы. +- Burst-ошибки (подряд искажённые биты — типичный IR-сбой: бит-слипы/всплески). Гарантия обнаружения = ширине контроля: + - single 8 бит и double — надёжны на коротких burst (≥8 бит), эмпирически в выборке не пропускали и длиннее; + - **nibble-fold** — гарантия ~4 бита (эмпирически до 7); + - **nibble-trunc** — **пропускает даже ОДИНОЧНЫЙ бит** (усечение теряет старший ниббл) → так делать нельзя. + +## 4. Рекомендация +- **CRC — не то место, где стоит экономить байт.** Сжатие контроля до 1 байта повышает пропуск сдвигов/burst в ~50–150× (с ~0.01 % до ~1 %) — прямо в том классе ошибок, ради которого второй полином и вводили. +- Если байт очень нужен — забирать его **не из CRC**, а из уже выявленного резерва: свободные `msgType` (3,5), «пустая» зона длины (компактные кадры), или переупаковка адресов/полей. 16-битный контроль (2×CRC8) сохранить. +- Если всё же сжимать CRC до 1 байта — только **fold (xor нибблов)**, никогда не truncate; и принять ~1 % пропуска на сдвигах (в ~50–100× хуже текущего). Как отдельный компромисс — обсуждать вместе с обратной совместимостью. + +(Обратную совместимость версий протокола обсудим отдельно — по запросу Даши.) diff --git a/docs_analysis/crc_shift_sim.py b/docs_analysis/crc_shift_sim.py new file mode 100644 index 0000000..1343bda --- /dev/null +++ b/docs_analysis/crc_shift_sim.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +# Симуляция CRC-схем IR-protocol на "сдвиговых" ошибках. +# crc8 воспроизводит IR_config.cpp: MSB-first, init=0xFF, без final-xor. +import random +random.seed(1) +POLY1 = 0x31 +POLY2 = 0x8C # битовое зеркало 0x31 + +def crc8(data, poly): + crc = 0xFF + for b in data: + crc ^= b + for _ in range(8): + crc = ((crc << 1) ^ poly) & 0xFF if (crc & 0x80) else (crc << 1) & 0xFF + return crc + +def fold4(x): # свернуть 8-бит CRC в 4 бита (xor старший^младший ниббл) + return (x ^ (x >> 4)) & 0x0F + +# --- схемы контроля: считают "чек" от данных и сверяют --- +def single_chk(d): return (crc8(d, POLY1),) # 8 бит, 1 полином +def double_chk(d): c1 = crc8(d, POLY1); return (c1, crc8(d + [c1], POLY2)) # текущая: 16 бит +def nib_fold_chk(d):return ((fold4(crc8(d, POLY1)) << 4) | fold4(crc8(d, POLY2)),) # предложенная: 4+4 бит +def nib_trunc_chk(d):return (((crc8(d, POLY1) & 0x0F) << 4) | (crc8(d, POLY2) & 0x0F),) + +def bits_of(data): + return [(b >> (7 - j)) & 1 for b in data for j in range(8)] +def bytes_of(bits): + return [sum(bits[i + j] << (7 - j) for j in range(8)) for i in range(0, len(bits), 8)] + +# --- модели "сдвига" в середине пакета (над битами данных, длина сохраняется) --- +def slip_delete(data, p): # выкинуть бит p, сдвинуть хвост влево, добить 0 в конец + b = bits_of(data); b = b[:p] + b[p+1:] + [0]; return bytes_of(b) +def rotate_all_right(data, _): # циклический сдвиг ВСЕХ бит вправо на 1 + b = bits_of(data); b = [b[-1]] + b[:-1]; return bytes_of(b) +def rotate_mid_window(data, p): # циклический сдвиг окна [p, p+16) на 1 (локальный сдвиг в середине) + b = bits_of(data); q = min(p+16, len(b)) + w = b[p:q]; w = [w[-1]] + w[:-1]; b = b[:p] + w + b[q:]; return bytes_of(b) + +MODELS = [("slip-delete (бит-слип)", slip_delete), + ("rotate-all (сдвиг всего на 1)", rotate_all_right), + ("rotate-mid-window (сдвиг окна в середине)", rotate_mid_window)] +SCHEMES = [("single (1×CRC8, 8бит)", single_chk), + ("double (2×CRC8, 16бит) — текущая", double_chk), + ("nibble-fold (4+4бит)", nib_fold_chk), + ("nibble-trunc (4+4бит)", nib_trunc_chk)] + +def run(datalen, trials): + print(f"\n===== данные {datalen} байт, {trials} попыток на модель =====") + for mname, model in MODELS: + # для каждой схемы: сколько ошибок НЕ обнаружено (чек совпал, а данные изменились) + undetected = {s[0]: 0 for s in SCHEMES} + changed = 0 + first_single_miss = None + for _ in range(trials): + d = [random.randint(0, 255) for _ in range(datalen)] + p = random.randint(datalen*8//4, datalen*8*3//4) # позиция в середине + c = model(d, p) + if c == d: + continue + changed += 1 + for sname, chk in SCHEMES: + if chk(c) == chk(d): + undetected[sname] += 1 + if sname.startswith("single") and first_single_miss is None: + first_single_miss = (d[:], c[:], p) + print(f" модель: {mname} (изменённых кадров: {changed})") + for sname, _ in SCHEMES: + u = undetected[sname] + rate = u / changed if changed else 0 + print(f" {sname:38s}: пропущено {u:6d} ({rate*100:.4f}%)") + if first_single_miss: + d, c, p = first_single_miss + print(f" ПРИМЕР пропуска single (poly1): data={bytes(d).hex()} -> shift@{p} -> {bytes(c).hex()}") + print(f" single: crc1(orig)={crc8(d,POLY1):02x} == crc1(shift)={crc8(c,POLY1):02x} -> ПРОШЁЛ") + print(f" double: crc2(orig)={double_chk(d)[1]:02x} vs crc2(shift)={double_chk(c)[1]:02x} -> {'ПРОШЁЛ (не поймал!)' if double_chk(d)==double_chk(c) else 'ПОЙМАЛ'}") + +for L in (3, 5, 8): + run(L, 200000) + +# Гарантированное покрытие burst-ошибок (подряд идущие искажённые биты) — ключ для IR (бит-слипы/всплески) +print("\n===== Гарантия обнаружения BURST-ошибок (100% всех burst данной длины?) =====") +def burst_guaranteed(chk, datalen, blen): + # перебор всех burst длины blen по всем позициям на нескольких случайных базах; ищем ХОТЬ ОДИН пропуск + for _ in range(300): + d = [random.randint(0,255) for _ in range(datalen)] + base = bits_of(d) + for start in range(len(base)-blen+1): + b2 = base[:] + for k in range(blen): + b2[start+k] ^= 1 + c = bytes_of(b2) + if c != d and chk(c) == chk(d): + return False # найден необнаруженный burst + return True +for sname, chk in SCHEMES: + maxb = 0 + for blen in range(1, 25): + if burst_guaranteed(chk, 5, blen): + maxb = blen + else: + break + print(f" {sname:38s}: 100%% ловит burst до {maxb} бит подряд") diff --git a/docs_analysis/crc_shift_visualize.py b/docs_analysis/crc_shift_visualize.py new file mode 100644 index 0000000..72a03a1 --- /dev/null +++ b/docs_analysis/crc_shift_visualize.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +# Визуализация кадров и сдвигов битов (с синхробитами и подписями). +# crc8 как в IR_config.cpp. Синхро (syncBits=3) логически = [~last, last, ~next] (addSync, send_HIGH=1). +POLY1=0x31; POLY2=0x8C +def crc8(data,poly): + c=0xFF + for b in data: + c^=b + for _ in range(8): + c=((c<<1)^poly)&0xFF if c&0x80 else (c<<1)&0xFF + return c +def bits(b): return [(b>>(7-j))&1 for j in range(8)] +def bstr(bl): return ''.join(str(x) for x in bl) + +def onair(frame_bytes, labels): + """строка вида: dddddddd sss dddddddd sss ... + строка подписей под байтами""" + rows=[] + line=''; lab='' + for i,by in enumerate(frame_bytes): + db=bstr(bits(by)) + # синхрогруппа ПОСЛЕ байта (между байтами), кроме последнего + if i < len(frame_bytes)-1: + last=bits(by)[7]; nxt=bits(frame_bytes[i+1])[0] + sy=f"{1-last}{last}{1-nxt}" + piece=db+' ['+sy+'] ' + else: + piece=db + # подпись байта, центрируем под 8 бит + L=labels[i] if i