17 Commits

Author SHA1 Message Date
57db9c35b8 archive: freeze IR-protocol WIP before stepwise integration 2026-08-28 13:27:46 +03:00
96ffb91b97 fix(tx): приоритет IRQ DMA-TX явно ниже SysTick/EXTI — millis() замедлялся под передачей
HAL_NVIC_EnableIRQ без SetPriority оставлял DMA-каналы TX на приоритете 0 (уровень
SysTick на G4). Длинный refill кольца BSRR (полубуфер 2048 слов = ~3.3мс при HCLK
12МГц) не вытеснялся, тики SysTick слипались: millis() замедлялся в 1.6x (один
излучатель) / 2.2x (два) на всё время передачи. На машинке это ~37% вранья часов
в каждый ИК-кадр: PID dt, планировщик, тайминги протокола.

Мерено на стенде TofSickHunt 2026-08-11 (Car raw/idle_cur_vs_coup*): прогоны «60с»
шли 85-95 реальных секунд. Фикс: irproto::kDmaTxIrqPriority=8 — ниже EXTI приёмника
(6), выше дна; SysTick честный. ISR-TX путь (F401/КУ) не подвержен: его refill на
TIM-приоритете 14. Верификация дилатации на стенде — раунд 4 (прерван, перезапустить).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:33:07 +03:00
08ea71eb5e feat(rx): always-on счётчики причин RX-событий (rxReasonCnt) + RXSTAT-сводка
rxBriefLog теперь безусловен: ВСЕГДА инкрементирует rxReasonCnt[reason]
(наблюдаемость по контракту живучести — работает в проде без печати),
печать события — только при IR_RX_BRIEF_LOG. 14 точек вызова развёрнуты
из-под #if (Glitch/Timing/Preamble/Sync/BufOverflow/Timeout/Crc/Ok +
pulse-filter пути); ISR-агрегатные MuteBegin/End/RawOverflow остаются
только при логе (их флаш живёт в brief-механике). Публичное API:
rxReasonCounters() / rxReasonCountersClear() / printRxReasonStats(Print&)
-> 'RXSTAT,GLITCH=..,TIME=..,...,OK=..'.
Компил-чек: LaserTestCheck (G4, флаг выкл) и Plan_B (F4, флаг вкл) — чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 23:05:50 +03:00
5bcaf4092a perf(rx): батч-выемка фронтов в tick (IR_RX_TICK_BATCH=16) — приём развязан от частоты loop
Было: 1 сырой фронт за tick() -> RX-пропускная способность привязана к loop;
медленный loop (телеметрия/дисплей/КУ-печать) переполнял ISR-буфер 250 фронтов
(~120мс эфира) и кадры гибли молча (вероятная составляющая 'КУ отвечает не на
каждый пакет'). Стало: до IR_RX_TICK_BATCH фронтов за tick; idle-семантика
сохранена (flush пульс-фильтра только при пустом буфере; listenStart/
checkTimeout как раньше). Переопределяемо дефайном до include.
Компил-чек: LaserTestCheck (G491) собирается.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 18:22:44 +03:00
873277e59f docs 2026-07-01 15:33:49 +03:00
f93f4101c3 fix(tx): correct multi-byte BACK frames (packSize used min(1,len))
_sendBack и testSendBack считали packSize с min(uint8_t(1),len) → в кадр входил только 1 байт данных, остальные обрезались (и затирались CRC). Многобайтовый back (speed + customBackData/sendOrderedBack) слался битым. Теперь packSize = ...+len+crcBytes, плюс валидация полного packSize<=31 (IR_MASK_MSG_INFO). Для len=1 поведение не меняется.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 15:20:27 +03:00
bf14b3d7ad fix(rx/tx): B5 pop-by-value, B6 ISR micros mask, B7 crc parens, B10 div guard, send length validation
B5 (MEDIUM): RingBuffer::pop(T&) копирует под одной критсекцией; tick() перешёл на неё → нет торн-рида (внутренний interrupts() в T* pop снимал внешнюю защиту до чтения *ptr). T* pop() оставлен (не используется).
B6 (MEDIUM): убрана лишняя noInterrupts/interrupts вокруг micros() в EXTI-ISR (снимала PRIMASK посреди ISR). std::function-диспетчеризация attachInterrupt — структурна, не трогаю.
B7 (LOW): скобки в crcCheck (== & 0xFF по приоритету).
B10 (LOW): guard деления на 0 в ceil_div (актуально только при freeFrec — НЕ включаю).
send: sendDataFULL — валидация полного packSize<=31 (было len>bytePerPack=31, packSize=7+len оборачивался → тихая потеря Data payload 25..31).
B8 (isSubBufferOverflow) — уже volatile, потеря флага безвредна (диагностика), не трогаю. B9 (prevRise==0 при micros()==0) — уже обработан веткой в preambleProcessEdge, намеренно.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:38:51 +03:00
a8d32bbcef fix(rx): guard sub-minimum packSize before crcCheck (B1 OOB read)
packSize==1 давало crcCheck(1-crcBytes) → uint8 len=255 → crc8 читает dataBuffer[0..256] при массиве 38 (OOB-чтение ~217 байт). Триггер — лок преамбулы + первый байт с младшими 5 битами=1 (~1/32 ложных локов). Двойная защита: (1) ранний reject packSize 1..2 (< msg+crc) как битого; (2) конец кадра требует packSize>crcBytes, поэтому crcCheck(packSize-crcBytes) не уходит в underflow. Валидные кадры (packSize>=5) и будущие компактные (>=3) не затронуты.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:13:27 +03:00
fc3510c425 fix(rx): guard null encoder in auto-accept (B2), init accept fields (B4), fix dataBuffer OOB off-by-one (B3)
B2 (HIGH): IR_Decoder::_tick — encoder->sendAccept() без null-check; у Car decoder создан с encoder==nullptr → HardFault при приёме IR_MSG_DATA_ACCEPT. Добавлен guard.
B4 (MEDIUM): acceptSendTimer/isWaitingAcceptSend/addrAcceptSendTo/acceptCustomByte не инициализировались → мусор мог спонтанно дёрнуть sendAccept. Дефолты в .h.
B3 (HIGH): writeToBuffer — '>' вместо '>=' → при i_dataBuffer==dataByteSizeMax*8 (304) запись dataBuffer[38] за границей 38-байтового массива (packSize==0 runaway). Валидные кадры закрываются при <=248, не затронуты.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:34:12 +03:00
e5ed029cfd fix(ir-dma): __DSB() barriers before DMA arm and after circular refill (#8)
Перестраховка корректности (на M4F без D-кэша обычно не обязательно): завершить запись dmaBuf/BSRR до HAL_DMA_Start_IT и refill полубуфера до прохода циркулярного DMA.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:12:28 +03:00
55935b8b92 fix(ir-dma): volatile active + errorCount() observability; live poll() in longData example
- IrDmaTxStm32.h: TxStream::active -> volatile (делится поток<->ISR: busy()/poll()/колбэки/спин applyCarrierMultiply)
- IrDmaTxStm32.h: + volatile errors_ и errorCount(), инкремент в dmaErrorCb (наблюдаемость Transfer-Error)
- test_examples/longData: dmaBackend.poll() в loop, иначе watchdog завершения DMA мёртв в эталонном примере

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:25:36 +03:00
7176fe5321 fix(ir-dma): eliminate isSending latch — free-running TIM, completion watchdog, busy() OR
C: remove racy activeCount_ + shared-timer gating; TIM17 free-running, per-stream DMA arming only (HAL_DMA_Start_IT/Abort_IT). Sets active before arming; never stops TIM on a shared counter.

D: poll() watchdog detects ticksOutput stall (>=stallTimeoutMs_, default 100ms) and forceStop()s the stuck stream under a per-channel NVIC mask; recoveryCount()/setStallTimeoutMs() accessors. dmaErrorCb now checks !active for symmetric single-stop.

E: busy() now returns true if ANY stream active (was AND) — honest contract for irAnyTxBusy()/carrier retune; does not serialize the send path.

Bug: IR TX rarely latched (LED frozen on/off) because stopStream was reachable only from the DMA HT/CPLT IRQ. Hardware bring-up (logic analyzer, recoveryCount telemetry) pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 15:26:52 +03:00
9935ac192a Merge pull request #11 from Show-maket/fix-DMA-and-Analyzer
Fix dma and analyzer
2026-04-21 07:05:29 +00:00
8361828c44 tx error tracking and inline glitch filter 2026-04-21 10:00:46 +03:00
38d93edd1b upd 2026-04-20 17:47:33 +03:00
2f5b57680b Add documentation for IR TX modes in the library 2026-04-20 14:49:38 +03:00
31ac7a3625 Refactor IR decoder and encoder for improved pulse filtering and ISR handling. Removed unused filtered sub-buffer, updated pulse filter methods, and added support for buffered ISR storage in the encoder. Enhanced documentation for clarity on DMA TX backend and ISR modes. 2026-04-20 14:48:45 +03:00
31 changed files with 3066 additions and 513 deletions

1
.gitignore vendored
View File

@ -10,3 +10,4 @@ Analyzer/raw/dll/*.so
Analyzer/raw/dll/*.dylib Analyzer/raw/dll/*.dylib
/Analyzer/raw/IR_Fox/.github /Analyzer/raw/IR_Fox/.github
**/.build **/.build
graphify-out/*

View File

@ -162,11 +162,13 @@ void IR_Decoder::_tick()
if (addrAcceptSendTo && addrAcceptSendTo < IR_Broadcast) if (addrAcceptSendTo && addrAcceptSendTo < IR_Broadcast)
isWaitingAcceptSend = true; isWaitingAcceptSend = true;
} }
gotRaw.set(&packInfo, id); // Raw keeps the decoder's common minimum-size contract. Known packet
// layouts are validated by their typed BasePack::set calls above.
gotRaw.set(&packInfo, id, false);
} }
if (isWaitingAcceptSend && millis() - acceptSendTimer > acceptDelay) if (isWaitingAcceptSend && millis() - acceptSendTimer > acceptDelay)
{ {
encoder->sendAccept(addrAcceptSendTo, acceptCustomByte); if (encoder != nullptr) encoder->sendAccept(addrAcceptSendTo, acceptCustomByte); // B2: у Car decoder без парного энкодера (nullptr) → был HardFault
isWaitingAcceptSend = false; isWaitingAcceptSend = false;
} }
} }

View File

@ -10,12 +10,12 @@ private:
static std::list<IR_Decoder*>& get_dec_list(); static std::list<IR_Decoder*>& get_dec_list();
void _tick(); void _tick();
uint32_t acceptSendTimer; uint32_t acceptSendTimer = 0;
bool isWaitingAcceptSend; bool isWaitingAcceptSend = false; // B4: без инициализации мусор мог дёрнуть sendAccept при старте
uint16_t addrAcceptSendTo; uint16_t addrAcceptSendTo = 0;
uint16_t acceptDelay = IR_ResponseDelay; uint16_t acceptDelay = IR_ResponseDelay;
uint8_t acceptCustomByte; uint8_t acceptCustomByte = 0;
bool extiEnabled_ = false; bool extiEnabled_ = false;
bool rxExtiPreemptConfigured_ = false; bool rxExtiPreemptConfigured_ = false;

View File

@ -1,5 +1,6 @@
#include "IR_DecoderRaw.h" #include "IR_DecoderRaw.h"
#include "IR_Encoder.h" #include "IR_Encoder.h"
#include "IrInterruptGuard.h"
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
@ -53,9 +54,8 @@ IR_DecoderRaw::IR_DecoderRaw(const uint8_t pin, uint16_t addr, IR_Encoder *encPa
bool IR_DecoderRaw::isSubOverflow() bool IR_DecoderRaw::isSubOverflow()
{ {
noInterrupts(); IrInterruptGuard guard;
volatile bool ret = isSubBufferOverflow; const bool ret = isSubBufferOverflow;
interrupts();
return ret; return ret;
} }
@ -74,7 +74,6 @@ bool IR_DecoderRaw::availableRaw()
void IR_DecoderRaw::pulseFilterResetStats() void IR_DecoderRaw::pulseFilterResetStats()
{ {
pulseFilterDropFilteredOverflow = 0;
pulseFilterDropHoldOverflow = 0; pulseFilterDropHoldOverflow = 0;
pulseFilterDropGlitchPairs = 0; pulseFilterDropGlitchPairs = 0;
} }
@ -104,7 +103,7 @@ void IR_DecoderRaw::refreshPairMuteState()
++active; ++active;
} }
const uint32_t nowUs = micros(); const uint32_t nowUs = micros();
noInterrupts(); IrInterruptGuard guard;
const bool wasActive = (isPairSending != 0); const bool wasActive = (isPairSending != 0);
isPairSending = active; isPairSending = active;
#if IR_RX_BRIEF_LOG #if IR_RX_BRIEF_LOG
@ -122,7 +121,6 @@ void IR_DecoderRaw::refreshPairMuteState()
rxBriefMuteBlockedEdges = 0; rxBriefMuteBlockedEdges = 0;
} }
#endif #endif
interrupts();
} }
#if IR_RX_BRIEF_LOG #if IR_RX_BRIEF_LOG
@ -143,12 +141,23 @@ const __FlashStringHelper *IR_DecoderRaw::rxBriefReasonTag(RxBriefReason reason)
case RxBriefReason::Timeout: return F("TIMEOUT"); case RxBriefReason::Timeout: return F("TIMEOUT");
case RxBriefReason::Crc: return F("CRC"); case RxBriefReason::Crc: return F("CRC");
case RxBriefReason::Ok: return F("OK"); case RxBriefReason::Ok: return F("OK");
case RxBriefReason::Count: return F("UNK");
default: return F("UNK"); default: return F("UNK");
} }
} }
#endif // IR_RX_BRIEF_LOG (rxBriefReasonTag)
// Безусловна: always-on счёт причин RX-событий (наблюдаемость по контракту
// живучести — счётчики живут и в проде без печати); печать — только под флагом.
void IR_DecoderRaw::rxBriefLog(RxBriefReason reason, uint16_t a, uint16_t b, uint32_t tUs) void IR_DecoderRaw::rxBriefLog(RxBriefReason reason, uint16_t a, uint16_t b, uint32_t tUs)
{ {
const uint8_t ri = (uint8_t)reason;
if (ri < kRxBriefReasonCount)
rxReasonCnt[ri]++;
#if !IR_RX_BRIEF_LOG
(void)a; (void)b; (void)tUs;
return;
#else
#if IR_RX_BRIEF_LOG_REJECT_ONLY #if IR_RX_BRIEF_LOG_REJECT_ONLY
if (reason == RxBriefReason::Ok || reason == RxBriefReason::Preamble) if (reason == RxBriefReason::Ok || reason == RxBriefReason::Preamble)
return; return;
@ -219,10 +228,33 @@ void IR_DecoderRaw::rxBriefLog(RxBriefReason reason, uint16_t a, uint16_t b, uin
Serial.print(b); Serial.print(b);
} }
break; break;
case RxBriefReason::Count:
break;
} }
Serial.println(); Serial.println();
#endif // IR_RX_BRIEF_LOG (печать)
} }
void IR_DecoderRaw::printRxReasonStats(Print &out) const
{
static const char *const kTags[] = {"?", "MUTEB", "MUTEE", "QRAW", "QFLT", "HOLD",
"GLITCH", "TIME", "PREAMB", "SYNC", "BUF",
"TIMEOUT", "CRC", "OK"};
static_assert(sizeof(kTags) / sizeof(kTags[0]) == kRxBriefReasonCount,
"RX reason tag table must match RxBriefReason::Count");
out.print(F("RXSTAT"));
for (uint8_t i = static_cast<uint8_t>(RxBriefReason::MuteBegin);
i < kRxBriefReasonCount; ++i)
{
out.print(',');
out.print(kTags[i]);
out.print('=');
out.print(rxReasonCnt[i]);
}
out.println();
}
#if IR_RX_BRIEF_LOG
void IR_DecoderRaw::rxBriefNoteMuteBlockedIsr(uint32_t tUs) void IR_DecoderRaw::rxBriefNoteMuteBlockedIsr(uint32_t tUs)
{ {
(void)tUs; (void)tUs;
@ -246,22 +278,23 @@ void IR_DecoderRaw::rxBriefFlushDeferredIsrLogs()
uint16_t muteEndCnt = 0; uint16_t muteEndCnt = 0;
uint16_t rawCnt = 0; uint16_t rawCnt = 0;
uint32_t rawLastUs = 0; uint32_t rawLastUs = 0;
noInterrupts(); {
muteBeginPending = rxBriefMuteBeginPending; IrInterruptGuard guard;
muteBeginUs = rxBriefMuteBeginUs; muteBeginPending = rxBriefMuteBeginPending;
rxBriefMuteBeginPending = false; muteBeginUs = rxBriefMuteBeginUs;
rxBriefMuteBeginUs = 0; rxBriefMuteBeginPending = false;
muteEndPending = rxBriefMuteEndPending; rxBriefMuteBeginUs = 0;
muteEndUs = rxBriefMuteEndUs; muteEndPending = rxBriefMuteEndPending;
muteEndCnt = rxBriefMuteEndCount; muteEndUs = rxBriefMuteEndUs;
rxBriefMuteEndPending = false; muteEndCnt = rxBriefMuteEndCount;
rxBriefMuteEndUs = 0; rxBriefMuteEndPending = false;
rxBriefMuteEndCount = 0; rxBriefMuteEndUs = 0;
rawCnt = rxBriefRawOverflowDrops; rxBriefMuteEndCount = 0;
rawLastUs = rxBriefRawOverflowLastUs; rawCnt = rxBriefRawOverflowDrops;
rxBriefRawOverflowDrops = 0; rawLastUs = rxBriefRawOverflowLastUs;
rxBriefRawOverflowLastUs = 0; rxBriefRawOverflowDrops = 0;
interrupts(); rxBriefRawOverflowLastUs = 0;
}
if (muteBeginPending) if (muteBeginPending)
rxBriefLog(RxBriefReason::MuteBegin, 0, 0, muteBeginUs); rxBriefLog(RxBriefReason::MuteBegin, 0, 0, muteBeginUs);
if (muteEndPending) if (muteEndPending)
@ -277,10 +310,8 @@ void IR_DecoderRaw::isr()
// Интервалы между соседними фронтами считаются как (uint32_t)(t1 - t0) — корректно при // Интервалы между соседними фронтами считаются как (uint32_t)(t1 - t0) — корректно при
// паузе < ~35 мин между фронтами; условие «тишина > longSilence» в preambleProcessEdge // паузе < ~35 мин между фронтами; условие «тишина > longSilence» в preambleProcessEdge
// переписано без front.time > prevRise (оно ломается при wrap). // переписано без front.time > prevRise (оно ломается при wrap).
uint32_t t; // B6: micros() в ISR не требует маскировки; прежний interrupts() посреди ISR снимал PRIMASK.
noInterrupts(); const uint32_t t = micros();
t = micros();
interrupts();
FrontStorage edge; FrontStorage edge;
edge.dir = port->IDR & mask; edge.dir = port->IDR & mask;
@ -334,7 +365,7 @@ void IR_DecoderRaw::firstRX()
#ifdef IRDEBUG #ifdef IRDEBUG
wrCounter = 0; wrCounter = 0;
#endif #endif
memset(dataBuffer, 0x00, dataByteSizeMax); memset(dataBuffer, 0x00, irproto::kMaxWireFrameBytes);
pulseFilterReset(); pulseFilterReset();
preambleResetToIdle(); preambleResetToIdle();
} }
@ -343,9 +374,8 @@ bool IR_DecoderRaw::rxTimeoutPipelineBusy() const
{ {
if (pulseFilterHoldCount != 0U) if (pulseFilterHoldCount != 0U)
return true; return true;
noInterrupts(); IrInterruptGuard guard;
const bool busy = !subBuffer.isEmpty() || !filteredSubBuffer.isEmpty(); const bool busy = !subBuffer.isEmpty();
interrupts();
return busy; return busy;
} }
@ -353,7 +383,7 @@ void IR_DecoderRaw::listenStart()
{ {
if (rxTimeoutPipelineBusy()) if (rxTimeoutPipelineBusy())
return; return;
if (isReciveRaw && ((micros() - lastEdgeTime) > IR_timeout * 2U)) if (isReciveRaw && ((micros() - lastEdgeTime) > receiveSilenceTimeoutUs()))
{ {
#if defined(IRDEBUG_SERIAL_PACK) #if defined(IRDEBUG_SERIAL_PACK)
packTraceOnTimeoutOrAbort(true); packTraceOnTimeoutOrAbort(true);
@ -371,15 +401,13 @@ inline void IR_DecoderRaw::checkTimeout()
if (rxTimeoutPipelineBusy()) if (rxTimeoutPipelineBusy())
return; return;
if (micros() - lastEdgeTime > IR_timeout * 2U) if (micros() - lastEdgeTime > receiveSilenceTimeoutUs())
{ {
#if defined(IRDEBUG_SERIAL_PACK) #if defined(IRDEBUG_SERIAL_PACK)
packTraceOnTimeoutOrAbort(false); packTraceOnTimeoutOrAbort(false);
#endif #endif
#if IR_RX_BRIEF_LOG
const uint16_t expected = (i_dataBuffer >= 8U) ? uint16_t(dataBuffer[0] & IR_MASK_MSG_INFO) : 0U; const uint16_t expected = (i_dataBuffer >= 8U) ? uint16_t(dataBuffer[0] & IR_MASK_MSG_INFO) : 0U;
rxBriefLog(RxBriefReason::Timeout, i_dataBuffer, expected, micros()); rxBriefLog(RxBriefReason::Timeout, i_dataBuffer, expected, micros());
#endif
isRecive = false; // приём завершён isRecive = false; // приём завершён
msgTypeReceive = 0; msgTypeReceive = 0;
// Как после listenStart(): без сброса isReciveRaw + firstRX() декодер остаётся // Как после listenStart(): без сброса isReciveRaw + firstRX() декодер остаётся
@ -403,42 +431,49 @@ void IR_DecoderRaw::tick()
// Не в начале до pop: иначе после checkTimeout lastEdgeTime vs micros() расходятся // Не в начале до pop: иначе после checkTimeout lastEdgeTime vs micros() расходятся
// с метками ISR из очереди → ложные TIMEOUT (bits=0) каждый пакет. // с метками ISR из очереди → ложные TIMEOUT (bits=0) каждый пакет.
FrontStorage currentFront;
bool hasCurrentFront = false;
FrontStorage rawFront; FrontStorage rawFront;
bool hasRawFront = false; bool processedFront = false;
noInterrupts(); // Батч-выемка (IR_RX_TICK_BATCH): раньше 1 фронт/tick — приём был привязан
FrontStorage *rawPtr = subBuffer.pop(); // к частоте loop, буфер 250 фронтов (~120мс эфира) переполнялся на медленном
if (rawPtr != nullptr) // loop и кадры гибли молча. Семантика idle-пути сохранена: flush
// пульс-фильтра — только когда буфер пуст (как прежняя else-ветка).
uint8_t batchBudget = IR_RX_TICK_BATCH;
bool hasRawFront = subBuffer.pop(rawFront); // B5: копия под одной критсекцией (без торн-рида)
while (hasRawFront)
{ {
rawFront = *rawPtr; if (IR_INPUT_MIN_PULSE_US > 0U)
hasRawFront = true;
}
interrupts();
if (IR_INPUT_MIN_PULSE_US > 0U)
{
if (hasRawFront)
pulseFilterFeedOneRaw(rawFront);
else
pulseFilterFlushTimeout(micros());
noInterrupts();
FrontStorage *flt = filteredSubBuffer.pop();
if (flt != nullptr)
{ {
currentFront = *flt; pulseFilterPushRaw(rawFront);
hasCurrentFront = true; FrontStorage confirmedFront;
while (pulseFilterTryTakeConfirmed(confirmedFront))
{
processDecodedFront(confirmedFront);
processedFront = true;
}
} }
interrupts(); else
{
processDecodedFront(rawFront);
processedFront = true;
}
if (--batchBudget == 0U)
{
break;
}
hasRawFront = subBuffer.pop(rawFront);
} }
else if (hasRawFront) if (!hasRawFront && IR_INPUT_MIN_PULSE_US > 0U)
{ {
currentFront = rawFront; const uint32_t nowUs = micros();
hasCurrentFront = true; FrontStorage confirmedFront;
while (pulseFilterTryFlushOne(nowUs, confirmedFront))
{
processDecodedFront(confirmedFront);
processedFront = true;
}
} }
if (!hasCurrentFront) if (!processedFront)
{ {
isSubBufferOverflow = false; isSubBufferOverflow = false;
listenStart(); listenStart();
@ -448,11 +483,22 @@ void IR_DecoderRaw::tick()
#endif #endif
return; return;
} // Если данных нет - ничего не делаем } // Если данных нет - ничего не делаем
listenStart();
checkTimeout();
#if IR_RX_BRIEF_LOG
rxBriefFlushDeferredIsrLogs();
#endif
#if defined(IR_EDGE_TRACE)
while (edgeTraceFlushChunk(Serial, 48) > 0) {}
#endif
}
void IR_DecoderRaw::processDecodedFront(const FrontStorage &currentFront)
{
if (preambleProcessEdge(currentFront)) if (preambleProcessEdge(currentFront))
{ {
lastEdgeTime = currentFront.time; lastEdgeTime = currentFront.time;
goto END; return;
} }
lastEdgeTime = currentFront.time; // запоминаем любой фронт lastEdgeTime = currentFront.time; // запоминаем любой фронт
@ -472,13 +518,11 @@ void IR_DecoderRaw::tick()
if (short_low_glitch) if (short_low_glitch)
{ {
errors.other++; errors.other++;
#if IR_RX_BRIEF_LOG
rxBriefLog(RxBriefReason::Glitch, 1, 0, currentFront.time); rxBriefLog(RxBriefReason::Glitch, 1, 0, currentFront.time);
#endif
#if IR_GLITCH_REJECT_PHASE_NUDGE #if IR_GLITCH_REJECT_PHASE_NUDGE
irGlitchPhaseNudge(currentFront.time, riseSyncTime, prevRise); irGlitchPhaseNudge(currentFront.time, riseSyncTime, prevRise);
#endif #endif
goto END; return;
} }
#endif #endif
#if IR_MICRO_GAP_RISE_REJECT #if IR_MICRO_GAP_RISE_REJECT
@ -489,22 +533,18 @@ void IR_DecoderRaw::tick()
if (micro_gap_rise) if (micro_gap_rise)
{ {
errors.other++; errors.other++;
#if IR_RX_BRIEF_LOG
rxBriefLog(RxBriefReason::Glitch, 1, 0, currentFront.time); rxBriefLog(RxBriefReason::Glitch, 1, 0, currentFront.time);
#endif
#if IR_GLITCH_REJECT_PHASE_NUDGE #if IR_GLITCH_REJECT_PHASE_NUDGE
irGlitchPhaseNudge(currentFront.time, riseSyncTime, prevRise); irGlitchPhaseNudge(currentFront.time, riseSyncTime, prevRise);
#endif #endif
goto END; return;
} }
#endif #endif
if (candRp <= riseTimeMax / 4U && !highCount && !lowCount) if (candRp <= riseTimeMax / 4U && !highCount && !lowCount)
{ {
errors.other++; errors.other++;
#if IR_RX_BRIEF_LOG
rxBriefLog(RxBriefReason::Timing, irClampU16(candRp), 0, currentFront.time); rxBriefLog(RxBriefReason::Timing, irClampU16(candRp), 0, currentFront.time);
#endif return;
goto END;
} }
if (candRp > riseTimeMax / 4 || highCount || lowCount) if (candRp > riseTimeMax / 4 || highCount || lowCount)
@ -557,7 +597,7 @@ void IR_DecoderRaw::tick()
} }
} }
#ifdef IRDEBUG #ifdef IRDEBUG
// goto END; //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // return; //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
#endif #endif
//---------------------------------------------------------------------------------- //----------------------------------------------------------------------------------
#ifdef IRDEBUG #ifdef IRDEBUG
@ -567,12 +607,10 @@ void IR_DecoderRaw::tick()
if (risePeriod > IR_timeout || isBufferOverflow || risePeriod < riseTimeMin || isWrongPack) if (risePeriod > IR_timeout || isBufferOverflow || risePeriod < riseTimeMin || isWrongPack)
// ~Мы в пределах таймаута и буффер не переполнен и fix дроблёных единиц // ~Мы в пределах таймаута и буффер не переполнен и fix дроблёных единиц
{ {
#if IR_RX_BRIEF_LOG
if (!isBufferOverflow && !isWrongPack) if (!isBufferOverflow && !isWrongPack)
rxBriefLog(RxBriefReason::Timing, irClampU16((uint32_t)risePeriod), rxBriefLog(RxBriefReason::Timing, irClampU16((uint32_t)risePeriod),
irClampU16((uint32_t)highTime), currentFront.time); irClampU16((uint32_t)highTime), currentFront.time);
#endif return;
goto END;
} }
// определить направление фронта // определить направление фронта
@ -727,17 +765,6 @@ void IR_DecoderRaw::tick()
else else
{ // Если ```\__ ↓ { // Если ```\__ ↓
} }
////////////////////////////////////////////////////////////////////////////////////////////////////////////
END:;
listenStart();
checkTimeout();
#if IR_RX_BRIEF_LOG
rxBriefFlushDeferredIsrLogs();
#endif
#if defined(IR_EDGE_TRACE)
while (edgeTraceFlushChunk(Serial, 48) > 0) {}
#endif
} }
void IR_DecoderRaw::writeToBuffer(bool bit, bool packTraceInvertFix) void IR_DecoderRaw::writeToBuffer(bool bit, bool packTraceInvertFix)
@ -745,12 +772,10 @@ void IR_DecoderRaw::writeToBuffer(bool bit, bool packTraceInvertFix)
#if !defined(IRDEBUG_SERIAL_PACK) #if !defined(IRDEBUG_SERIAL_PACK)
(void)packTraceInvertFix; (void)packTraceInvertFix;
#endif #endif
if (i_dataBuffer > dataByteSizeMax * 8) if (i_dataBuffer >= irproto::kMaxWireFrameBytes * 8U)
{ // проверка переполнения { // >=: не даёт записать бит за пределом 5-битной wire-длины.
isBufferOverflow = true; isBufferOverflow = true;
#if IR_RX_BRIEF_LOG
rxBriefLog(RxBriefReason::BufferOverflow, i_dataBuffer, 0, micros()); rxBriefLog(RxBriefReason::BufferOverflow, i_dataBuffer, 0, micros());
#endif
#if defined(IRDEBUG_SERIAL_PACK) #if defined(IRDEBUG_SERIAL_PACK)
if (packTraceOpen) if (packTraceOpen)
packTraceEmitErrorFlash(F("ERROR: buffer overflow")); packTraceEmitErrorFlash(F("ERROR: buffer overflow"));
@ -827,9 +852,7 @@ void IR_DecoderRaw::writeToBuffer(bool bit, bool packTraceInvertFix)
#endif #endif
{ {
isWrongPack = true; isWrongPack = true;
#if IR_RX_BRIEF_LOG
rxBriefLog(RxBriefReason::Sync, err_syncBit, 0, micros()); rxBriefLog(RxBriefReason::Sync, err_syncBit, 0, micros());
#endif
#if defined(IRDEBUG_SERIAL_PACK) #if defined(IRDEBUG_SERIAL_PACK)
packTraceEmitErrorFlash(F("ERROR: Wrong sync bit")); packTraceEmitErrorFlash(F("ERROR: Wrong sync bit"));
#endif #endif
@ -857,6 +880,11 @@ void IR_DecoderRaw::writeToBuffer(bool bit, bool packTraceInvertFix)
if (i_dataBuffer == 8 * msgBytes) if (i_dataBuffer == 8 * msgBytes)
{ // Ппервый байт { // Ппервый байт
packSize = dataBuffer[0] & IR_MASK_MSG_INFO; packSize = dataBuffer[0] & IR_MASK_MSG_INFO;
// B1: под-минимальная длина (1..2) физически не несёт CRC (min кадр = msg+crc = 3 байта) → шум/битьё.
// Без отсева packSize==1 даёт crcCheck(1-2) → len=255 → OOB-чтение wire-буфера.
// packSize>=3 (в т.ч. будущие компактные кадры) обрабатываются как обычно.
if (packSize != 0 && packSize < msgBytes + crcBytes)
isWrongPack = true;
} }
// Тип приёма (для isReceive): выставляем сразу после первого байта, ДО проверки «Конец». // Тип приёма (для isReceive): выставляем сразу после первого байта, ДО проверки «Конец».
@ -867,8 +895,8 @@ void IR_DecoderRaw::writeToBuffer(bool bit, bool packTraceInvertFix)
msgTypeReceive = (dataBuffer[0] >> 5) | 0b11111000; msgTypeReceive = (dataBuffer[0] >> 5) | 0b11111000;
} }
if (packSize && (i_dataBuffer == packSize * bitPerByte)) if (packSize > crcBytes && (i_dataBuffer == packSize * bitPerByte))
{ // Конец { // Конец (B1: packSize>crcBytes → crcCheck(packSize-crcBytes) не уходит в underflow/OOB)
packInfo.buffer = dataBuffer; packInfo.buffer = dataBuffer;
packInfo.crc = crcValue; packInfo.crc = crcValue;
packInfo.err = errors; packInfo.err = errors;
@ -887,7 +915,7 @@ void IR_DecoderRaw::writeToBuffer(bool bit, bool packTraceInvertFix)
uint8_t packTraceBfBit = 0; uint8_t packTraceBfBit = 0;
bool packTraceBfMark = false; bool packTraceBfMark = false;
if (!isAvailable) // Исправление первого бита // Очень большая затычка... if (!isAvailable) // Исправление первого бита // Очень большая затычка...
for (size_t i = 0; i < min(uint16_t(packSize - crcBytes * 2U), uint16_t(dataByteSizeMax)); ++i) for (size_t i = 0; i < min(uint16_t(packSize - crcBytes * 2U), uint16_t(irproto::kMaxWireFrameBytes)); ++i)
{ {
for (int j = 0; j < 8; ++j) for (int j = 0; j < 8; ++j)
{ {
@ -895,7 +923,7 @@ void IR_DecoderRaw::writeToBuffer(bool bit, bool packTraceInvertFix)
dataBuffer[i] ^= 1 << j; dataBuffer[i] ^= 1 << j;
isAvailable = isAvailable =
crcCheck(min(uint16_t(packSize - crcBytes), uint16_t(dataByteSizeMax - 1U)), crcValue); crcCheck(min(uint16_t(packSize - crcBytes), uint16_t(irproto::kMaxWireFrameBytes - 1U)), crcValue);
// обратно инвертируем бит в исходное состояние // обратно инвертируем бит в исходное состояние
if (isAvailable) if (isAvailable)
@ -926,13 +954,11 @@ void IR_DecoderRaw::writeToBuffer(bool bit, bool packTraceInvertFix)
#endif #endif
const uint16_t errSum = const uint16_t errSum =
uint16_t(errors.lowSignal) + uint16_t(errors.highSignal) + uint16_t(errors.other); uint16_t(errors.lowSignal) + uint16_t(errors.highSignal) + uint16_t(errors.other);
#if IR_RX_BRIEF_LOG
if (isAvailable) if (isAvailable)
rxBriefLog(RxBriefReason::Ok, packSize, errSum, micros()); rxBriefLog(RxBriefReason::Ok, packSize, errSum, micros());
else else
rxBriefLog(RxBriefReason::Crc, packSize, errSum, micros()); rxBriefLog(RxBriefReason::Crc, packSize, errSum, micros());
#endif if (!isAvailable && packSize > 0 && packSize <= irproto::kMaxWireFrameBytes) {
if (!isAvailable && packSize > 0 && packSize <= dataByteSizeMax) {
memcpy(rejectBuffer, dataBuffer, packSize); memcpy(rejectBuffer, dataBuffer, packSize);
rejectPackSize = static_cast<uint8_t>(packSize); rejectPackSize = static_cast<uint8_t>(packSize);
isRejectAvailable = true; isRejectAvailable = true;
@ -951,7 +977,7 @@ bool IR_DecoderRaw::crcCheck(uint8_t len, crc_t &crc)
crc = (crc8(dataBuffer, 0, len, poly1) << 8) & ~((crc_t)0xFF); crc = (crc8(dataBuffer, 0, len, poly1) << 8) & ~((crc_t)0xFF);
crc |= crc8(dataBuffer, 0, len + 1, poly2) & (crc_t)0xFF; crc |= crc8(dataBuffer, 0, len + 1, poly2) & (crc_t)0xFF;
if (dataBuffer[len] == (crc >> 8) & 0xFF && if (dataBuffer[len] == ((crc >> 8) & 0xFF) && // B7: скобки — иначе (== & 0xFF) по приоритету
dataBuffer[len + 1] == (crc & 0xFF)) dataBuffer[len + 1] == (crc & 0xFF))
{ {
crcOK = true; crcOK = true;
@ -974,6 +1000,7 @@ bool IR_DecoderRaw::availableReject()
uint16_t IR_DecoderRaw::ceil_div(uint16_t val, uint16_t divider) uint16_t IR_DecoderRaw::ceil_div(uint16_t val, uint16_t divider)
{ {
if (divider == 0) return 0; // B10: защита от деления на 0 (актуально при freeFrec — сейчас выкл, не включаем)
int ret = val / divider; int ret = val / divider;
if ((val << 4) / divider - (ret << 4) >= 8) if ((val << 4) / divider - (ret << 4) >= 8)
ret++; ret++;
@ -985,38 +1012,34 @@ uint16_t IR_DecoderRaw::ceil_div(uint16_t val, uint16_t divider)
void IR_DecoderRaw::edgeTracePush(uint32_t t_us, uint8_t level, uint8_t flags) void IR_DecoderRaw::edgeTracePush(uint32_t t_us, uint8_t level, uint8_t flags)
{ {
const uint16_t cap = static_cast<uint16_t>(IR_EDGE_TRACE_CAPACITY); const uint16_t cap = static_cast<uint16_t>(IR_EDGE_TRACE_CAPACITY);
noInterrupts(); IrInterruptGuard guard;
const uint16_t w = edgeTrace_w; const uint16_t w = edgeTrace_w;
const uint16_t r = edgeTrace_r; const uint16_t r = edgeTrace_r;
const uint16_t next = static_cast<uint16_t>((w + 1u) % cap); const uint16_t next = static_cast<uint16_t>((w + 1u) % cap);
if (next == r) if (next == r)
{ {
edgeTrace_overflow = true; edgeTrace_overflow = true;
interrupts();
return; return;
} }
edgeTrace_buf[w].t_us = t_us; edgeTrace_buf[w].t_us = t_us;
edgeTrace_buf[w].level = level ? 1u : 0u; edgeTrace_buf[w].level = level ? 1u : 0u;
edgeTrace_buf[w].flags = flags; edgeTrace_buf[w].flags = flags;
edgeTrace_w = next; edgeTrace_w = next;
interrupts();
} }
void IR_DecoderRaw::edgeTraceClear() void IR_DecoderRaw::edgeTraceClear()
{ {
noInterrupts(); IrInterruptGuard guard;
edgeTrace_w = 0; edgeTrace_w = 0;
edgeTrace_r = 0; edgeTrace_r = 0;
edgeTrace_overflow = false; edgeTrace_overflow = false;
interrupts();
} }
uint16_t IR_DecoderRaw::edgeTracePendingCount() const uint16_t IR_DecoderRaw::edgeTracePendingCount() const
{ {
noInterrupts(); IrInterruptGuard guard;
const uint16_t w = edgeTrace_w; const uint16_t w = edgeTrace_w;
const uint16_t r = edgeTrace_r; const uint16_t r = edgeTrace_r;
interrupts();
const uint16_t cap = static_cast<uint16_t>(IR_EDGE_TRACE_CAPACITY); const uint16_t cap = static_cast<uint16_t>(IR_EDGE_TRACE_CAPACITY);
if (w >= r) if (w >= r)
return static_cast<uint16_t>(w - r); return static_cast<uint16_t>(w - r);
@ -1032,27 +1055,28 @@ uint16_t IR_DecoderRaw::edgeTraceFlushChunk(Print &out, uint16_t maxRec)
maxRec = kStackCap; maxRec = kStackCap;
const uint16_t cap = static_cast<uint16_t>(IR_EDGE_TRACE_CAPACITY); const uint16_t cap = static_cast<uint16_t>(IR_EDGE_TRACE_CAPACITY);
noInterrupts();
const uint16_t w = edgeTrace_w;
const uint16_t r = edgeTrace_r;
uint16_t avail = (w >= r) ? static_cast<uint16_t>(w - r) : static_cast<uint16_t>(cap - r + w);
uint16_t toCopy = (avail > maxRec) ? maxRec : avail;
const bool truncated = (avail > toCopy);
if (toCopy == 0)
{
interrupts();
return 0;
}
uint8_t tmp[kStackCap * 6]; uint8_t tmp[kStackCap * 6];
for (uint16_t i = 0; i < toCopy; ++i) uint16_t toCopy = 0U;
bool truncated = false;
bool ovf = false;
{ {
const uint16_t idx = static_cast<uint16_t>((r + i) % cap); IrInterruptGuard guard;
memcpy(tmp + i * 6u, &edgeTrace_buf[idx], 6u); const uint16_t w = edgeTrace_w;
const uint16_t r = edgeTrace_r;
const uint16_t avail = (w >= r) ? static_cast<uint16_t>(w - r)
: static_cast<uint16_t>(cap - r + w);
toCopy = (avail > maxRec) ? maxRec : avail;
truncated = (avail > toCopy);
if (toCopy == 0U)
return 0U;
for (uint16_t i = 0; i < toCopy; ++i)
{
const uint16_t idx = static_cast<uint16_t>((r + i) % cap);
memcpy(tmp + i * 6u, &edgeTrace_buf[idx], 6u);
}
edgeTrace_r = static_cast<uint16_t>((r + toCopy) % cap);
ovf = edgeTrace_overflow;
} }
edgeTrace_r = static_cast<uint16_t>((r + toCopy) % cap);
const bool ovf = edgeTrace_overflow;
interrupts();
uint8_t meta = 0; uint8_t meta = 0;
if (ovf) if (ovf)
@ -1282,7 +1306,7 @@ void IR_DecoderRaw::packTraceForceEndSyncPhase()
void IR_DecoderRaw::packTraceEmitHex(uint8_t byteCount) const void IR_DecoderRaw::packTraceEmitHex(uint8_t byteCount) const
{ {
Serial.print(F("IR hex:")); Serial.print(F("IR hex:"));
for (uint8_t i = 0; i < byteCount && i < dataByteSizeMax; i++) for (uint8_t i = 0; i < byteCount && i < irproto::kMaxWireFrameBytes; i++)
{ {
Serial.print(' '); Serial.print(' ');
ptPrintHexU8(dataBuffer[i]); ptPrintHexU8(dataBuffer[i]);
@ -1380,8 +1404,8 @@ void IR_DecoderRaw::packTraceEmitErrorFlash(const __FlashStringHelper *msg)
Serial.println(msg); Serial.println(msg);
{ {
uint16_t nb = i_dataBuffer / 8u; uint16_t nb = i_dataBuffer / 8u;
if (nb > dataByteSizeMax) if (nb > irproto::kMaxWireFrameBytes)
nb = dataByteSizeMax; nb = irproto::kMaxWireFrameBytes;
packTraceEmitHex(static_cast<uint8_t>(nb)); packTraceEmitHex(static_cast<uint8_t>(nb));
} }
packTraceResetFrame(); packTraceResetFrame();
@ -1414,8 +1438,8 @@ void IR_DecoderRaw::packTraceOnTimeoutOrAbort(bool fromListenStart)
return; return;
const uint16_t expected = (i_dataBuffer >= 8) ? uint16_t(dataBuffer[0] & IR_MASK_MSG_INFO) : 0; const uint16_t expected = (i_dataBuffer >= 8) ? uint16_t(dataBuffer[0] & IR_MASK_MSG_INFO) : 0;
uint16_t gotBytes = i_dataBuffer / 8; uint16_t gotBytes = i_dataBuffer / 8;
if (gotBytes > dataByteSizeMax) if (gotBytes > irproto::kMaxWireFrameBytes)
gotBytes = dataByteSizeMax; gotBytes = irproto::kMaxWireFrameBytes;
Serial.println(); Serial.println();
packTraceEmitRawBitsLine(false); packTraceEmitRawBitsLine(false);
Serial.print(F(" => ERROR: TIMEOUT, rx_data_size = ")); Serial.print(F(" => ERROR: TIMEOUT, rx_data_size = "));
@ -1455,34 +1479,18 @@ void IR_DecoderRaw::pulseFilterShiftLeft(uint8_t n)
pulseFilterHoldCount = newCount; pulseFilterHoldCount = newCount;
} }
bool IR_DecoderRaw::pulseFilterEmit(const FrontStorage &e)
{
if (filteredSubBuffer.isFull())
{
pulseFilterDropFilteredOverflow++;
#if IR_RX_BRIEF_LOG
rxBriefLog(RxBriefReason::FilterOverflow, irClampU16(pulseFilterDropFilteredOverflow), 0, e.time);
#endif
return false;
}
filteredSubBuffer.push(e);
return true;
}
void IR_DecoderRaw::pulseFilterReset() void IR_DecoderRaw::pulseFilterReset()
{ {
pulseFilterHoldCount = 0; pulseFilterHoldCount = 0;
pulseFilterLastRawValid = false; pulseFilterLastRawValid = false;
pulseFilterLastRawTime = 0; pulseFilterLastRawTime = 0;
while (filteredSubBuffer.pop() != nullptr) {}
} }
void IR_DecoderRaw::pulseFilterFeedOneRaw(const FrontStorage &e) void IR_DecoderRaw::pulseFilterPushRaw(const FrontStorage &e)
{ {
const uint32_t minUs = IR_INPUT_MIN_PULSE_US; const uint32_t minUs = IR_INPUT_MIN_PULSE_US;
if (minUs == 0U) if (minUs == 0U)
{ {
pulseFilterEmit(e);
return; return;
} }
@ -1492,47 +1500,52 @@ void IR_DecoderRaw::pulseFilterFeedOneRaw(const FrontStorage &e)
if (pulseFilterHoldCount >= kPulseFilterHoldCap) if (pulseFilterHoldCount >= kPulseFilterHoldCap)
{ {
pulseFilterDropHoldOverflow++; pulseFilterDropHoldOverflow++;
#if IR_RX_BRIEF_LOG
rxBriefLog(RxBriefReason::HoldOverflow, irClampU16(pulseFilterDropHoldOverflow), 0, e.time); rxBriefLog(RxBriefReason::HoldOverflow, irClampU16(pulseFilterDropHoldOverflow), 0, e.time);
#endif
pulseFilterEmit(pulseFilterHoldEdges[0]);
pulseFilterShiftLeft(1); pulseFilterShiftLeft(1);
} }
pulseFilterHoldEdges[pulseFilterHoldCount++] = e; pulseFilterHoldEdges[pulseFilterHoldCount++] = e;
const uint8_t holdback = irPulseFilterHoldbackEdges(); }
bool IR_DecoderRaw::pulseFilterTryTakeConfirmed(FrontStorage &out, uint32_t logTime)
{
const uint32_t minUs = IR_INPUT_MIN_PULSE_US;
if (minUs == 0U)
return false;
const uint8_t holdback = irPulseFilterHoldbackEdges();
if (logTime == 0U)
logTime = pulseFilterLastRawTime;
for (;;) for (;;)
{ {
if (pulseFilterHoldCount < 2) if (pulseFilterHoldCount < 2)
return; return false;
const uint32_t dt = pulseFilterHoldEdges[1].time - pulseFilterHoldEdges[0].time; const uint32_t dt = pulseFilterHoldEdges[1].time - pulseFilterHoldEdges[0].time;
if (dt < minUs) if (dt < minUs)
{ {
pulseFilterDropGlitchPairs++; pulseFilterDropGlitchPairs++;
#if IR_RX_BRIEF_LOG rxBriefLog(RxBriefReason::Glitch, irClampU16(pulseFilterDropGlitchPairs), 0, logTime);
rxBriefLog(RxBriefReason::Glitch, irClampU16(pulseFilterDropGlitchPairs), 0, e.time);
#endif
pulseFilterShiftLeft(2); pulseFilterShiftLeft(2);
continue; continue;
} }
if (pulseFilterHoldCount <= holdback) if (pulseFilterHoldCount <= holdback)
return; return false;
pulseFilterEmit(pulseFilterHoldEdges[0]); out = pulseFilterHoldEdges[0];
pulseFilterShiftLeft(1); pulseFilterShiftLeft(1);
return true;
} }
} }
void IR_DecoderRaw::pulseFilterFlushTimeout(uint32_t nowUs) bool IR_DecoderRaw::pulseFilterTryFlushOne(uint32_t nowUs, FrontStorage &out)
{ {
if (IR_INPUT_MIN_PULSE_US == 0U || !pulseFilterLastRawValid || pulseFilterHoldCount == 0) if (IR_INPUT_MIN_PULSE_US == 0U || !pulseFilterLastRawValid || pulseFilterHoldCount == 0)
return; return false;
const uint32_t waitUs = IR_INPUT_MIN_PULSE_US * (uint32_t)IR_INPUT_FILTER_TIMEOUT_MULT; const uint32_t waitUs = IR_INPUT_MIN_PULSE_US * (uint32_t)IR_INPUT_FILTER_TIMEOUT_MULT;
if ((uint32_t)(nowUs - pulseFilterLastRawTime) < waitUs) if ((uint32_t)(nowUs - pulseFilterLastRawTime) < waitUs)
return; return false;
while (pulseFilterHoldCount > 0) while (pulseFilterHoldCount > 0)
{ {
@ -1542,16 +1555,16 @@ void IR_DecoderRaw::pulseFilterFlushTimeout(uint32_t nowUs)
if (dt < IR_INPUT_MIN_PULSE_US) if (dt < IR_INPUT_MIN_PULSE_US)
{ {
pulseFilterDropGlitchPairs++; pulseFilterDropGlitchPairs++;
#if IR_RX_BRIEF_LOG
rxBriefLog(RxBriefReason::Glitch, irClampU16(pulseFilterDropGlitchPairs), 0, nowUs); rxBriefLog(RxBriefReason::Glitch, irClampU16(pulseFilterDropGlitchPairs), 0, nowUs);
#endif
pulseFilterShiftLeft(2); pulseFilterShiftLeft(2);
continue; continue;
} }
} }
pulseFilterEmit(pulseFilterHoldEdges[0]); out = pulseFilterHoldEdges[0];
pulseFilterShiftLeft(1); pulseFilterShiftLeft(1);
return true;
} }
return false;
} }
uint32_t IR_DecoderRaw::preambleJitterTolUs(uint32_t baselineUs) const uint32_t IR_DecoderRaw::preambleJitterTolUs(uint32_t baselineUs) const
@ -1598,7 +1611,7 @@ void IR_DecoderRaw::preambleStartCandidate(const FrontStorage &front)
bool IR_DecoderRaw::preambleProcessEdge(const FrontStorage &front) bool IR_DecoderRaw::preambleProcessEdge(const FrontStorage &front)
{ {
const uint32_t longSilence = IR_timeout * 2U; const uint32_t longSilence = receiveSilenceTimeoutUs();
const uint32_t candTimeout = IR_timeout * (uint32_t)IR_PREAMBLE_CANDIDATE_TIMEOUT_MULT; const uint32_t candTimeout = IR_timeout * (uint32_t)IR_PREAMBLE_CANDIDATE_TIMEOUT_MULT;
if (preambleState == PreambleState::Idle) if (preambleState == PreambleState::Idle)
@ -1618,9 +1631,7 @@ bool IR_DecoderRaw::preambleProcessEdge(const FrontStorage &front)
{ {
if ((uint32_t)(front.time - preambleCandidateLastEdgeTime) > candTimeout) if ((uint32_t)(front.time - preambleCandidateLastEdgeTime) > candTimeout)
{ {
#if IR_RX_BRIEF_LOG
rxBriefLog(RxBriefReason::Preamble, preambleGoodPeriods, 0, front.time); rxBriefLog(RxBriefReason::Preamble, preambleGoodPeriods, 0, front.time);
#endif
preambleStartCandidate(front); preambleStartCandidate(front);
} }
@ -1641,9 +1652,7 @@ bool IR_DecoderRaw::preambleProcessEdge(const FrontStorage &front)
{ {
preambleGoodPeriods = 0; preambleGoodPeriods = 0;
preambleMeanPeriod = 0; preambleMeanPeriod = 0;
#if IR_RX_BRIEF_LOG
rxBriefLog(RxBriefReason::Preamble, 0, irClampU16(period), front.time); rxBriefLog(RxBriefReason::Preamble, 0, irClampU16(period), front.time);
#endif
return true; return true;
} }
@ -1664,9 +1673,7 @@ bool IR_DecoderRaw::preambleProcessEdge(const FrontStorage &front)
} }
else else
{ {
#if IR_RX_BRIEF_LOG
rxBriefLog(RxBriefReason::Preamble, preambleGoodPeriods, irClampU16(period), front.time); rxBriefLog(RxBriefReason::Preamble, preambleGoodPeriods, irClampU16(period), front.time);
#endif
preambleGoodPeriods = 1; preambleGoodPeriods = 1;
preambleMeanPeriod = (uint16_t)period; preambleMeanPeriod = (uint16_t)period;
} }
@ -1691,7 +1698,7 @@ bool IR_DecoderRaw::preambleProcessEdge(const FrontStorage &front)
err_syncBit = 0; err_syncBit = 0;
isWrongPack = false; isWrongPack = false;
msgTypeReceive = 0; msgTypeReceive = 0;
memset(dataBuffer, 0x00, dataByteSizeMax); memset(dataBuffer, 0x00, irproto::kMaxWireFrameBytes);
preambleState = PreambleState::Locked; preambleState = PreambleState::Locked;
isPreamb = false; isPreamb = false;

View File

@ -20,12 +20,13 @@ class Print;
///////////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////////
#define riseTime riseSyncTime //* bitTime */ 893U // TODO: Должно высчитываться медианой #define riseTime riseSyncTime //* bitTime */ 893U // TODO: Должно высчитываться медианой
#define riseTolerance tolerance /* 250U */ // погрешность #define riseTolerance IR_TIMING_TOLERANCE_US /* 250U */ // погрешность
#define riseTimeMax (riseTime + riseTolerance) #define riseTimeMax (riseTime + riseTolerance)
#define riseTimeMin (riseTime - riseTolerance) #define riseTimeMin (riseTime - riseTolerance)
#define aroundRise(t) (riseTimeMin < t && t < riseTimeMax) #define aroundRise(t) (riseTimeMin < t && t < riseTimeMax)
#define IR_timeout (riseTimeMax * (8 + syncBits + 1)) // us // таймаут в 8 data + 3 sync + 1 // Compatibility aliases. The named contracts and their geometry live in IR_config.h.
constexpr uint16_t IR_ResponseDelay = ((uint16_t)(((bitTime+riseTolerance) * (8 + syncBits + 1))*2.7735))/1000; #define IR_timeout (::irproto::rxInterEdgeTimeoutUs(riseTime))
constexpr uint16_t IR_ResponseDelay = irproto::kDefaultResponseTurnaroundDelayMs;
class IR_Encoder; class IR_Encoder;
class IR_DecoderRaw : virtual public IR_FOX class IR_DecoderRaw : virtual public IR_FOX
@ -51,8 +52,15 @@ public:
inline bool isOverflow() { return isBufferOverflow; }; // Буффер переполнился inline bool isOverflow() { return isBufferOverflow; }; // Буффер переполнился
bool isSubOverflow(); bool isSubOverflow();
volatile inline bool isReciving() { return isRecive; }; // Возвращает true, если происходит приём пакета inline bool isReciving() const { return isRecive; } // Возвращает true, если происходит приём пакета
uint32_t pulseFilterDroppedByFilteredOverflow() const { return pulseFilterDropFilteredOverflow; } /** Current adaptive silence threshold that terminates an RX candidate. */
inline uint32_t receiveSilenceTimeoutUs() const {
return irproto::rxSilenceTimeoutUs(riseTime);
}
inline uint32_t receiveSilenceTimeoutMsCeil() const {
return irproto::microsToMillisCeil(receiveSilenceTimeoutUs());
}
uint32_t pulseFilterDroppedByFilteredOverflow() const { return 0; }
uint32_t pulseFilterDroppedByHoldOverflow() const { return pulseFilterDropHoldOverflow; } uint32_t pulseFilterDroppedByHoldOverflow() const { return pulseFilterDropHoldOverflow; }
uint32_t pulseFilterDroppedGlitchPairs() const { return pulseFilterDropGlitchPairs; } uint32_t pulseFilterDroppedGlitchPairs() const { return pulseFilterDropGlitchPairs; }
void pulseFilterResetStats(); void pulseFilterResetStats();
@ -65,6 +73,15 @@ public:
uint16_t edgeTraceFlushChunk(Print &out, uint16_t maxRec = 48); uint16_t edgeTraceFlushChunk(Print &out, uint16_t maxRec = 48);
#endif #endif
/// Always-on счётчики RX-событий по причинам (см. RxBriefReason: 6=Glitch,
/// 7=Timing, 8=Preamble, 9=Sync, 10=BufOverflow, 11=Timeout, 12=Crc, 13=Ok).
/// MuteBegin/End и RawOverflow(1..3) тикают только при IR_RX_BRIEF_LOG (ISR-агрегат).
static constexpr uint8_t rxReasonCounterCount() { return kRxBriefReasonCount; }
const uint16_t *rxReasonCounters() const { return rxReasonCnt; }
void rxReasonCountersClear() { for (uint8_t i = 0; i < kRxBriefReasonCount; ++i) rxReasonCnt[i] = 0; }
/// Однострочная сводка: "RXSTAT,GLITCH=..,TIME=..,PREAMB=..,SYNC=..,BUF=..,TIMEOUT=..,CRC=..,OK=.."
void printRxReasonStats(Print &out) const;
/// Кадр собран по длине из заголовка, но CRC не сошёлся — один раз можно прочитать копию сырых байтов. /// Кадр собран по длине из заголовка, но CRC не сошёлся — один раз можно прочитать копию сырых байтов.
bool availableReject(); bool availableReject();
uint8_t getRejectSize() const { return rejectPackSize; } uint8_t getRejectSize() const { return rejectPackSize; }
@ -86,12 +103,14 @@ private:
BufferOverflow = 10, BufferOverflow = 10,
Timeout = 11, Timeout = 11,
Crc = 12, Crc = 12,
Ok = 13 Ok = 13,
Count
}; };
static constexpr uint8_t kRxBriefReasonCount = static_cast<uint8_t>(RxBriefReason::Count);
bool isRejectAvailable = false; bool isRejectAvailable = false;
uint8_t rejectPackSize = 0; uint8_t rejectPackSize = 0;
uint8_t rejectBuffer[dataByteSizeMax]{}; uint8_t rejectBuffer[irproto::kMaxWireFrameBytes]{};
ErrorsStruct errors; ErrorsStruct errors;
bool isAvailable = false; bool isAvailable = false;
@ -122,8 +141,6 @@ private:
// volatile FrontStorage subBuffer[subBufferSize]; // вспомогательный буфер для хранения необработанных фронтов/спадов // volatile FrontStorage subBuffer[subBufferSize]; // вспомогательный буфер для хранения необработанных фронтов/спадов
RingBuffer<FrontStorage, subBufferSize> subBuffer; RingBuffer<FrontStorage, subBufferSize> subBuffer;
/** Очередь фронтов после потокового анти-глитча; tick() читает из неё при включённом фильтре. */
RingBuffer<FrontStorage, subBufferSize> filteredSubBuffer;
IR_Encoder *pairMuteEncoders[IR_PAIR_MUTE_MAX_ENCODERS]{}; IR_Encoder *pairMuteEncoders[IR_PAIR_MUTE_MAX_ENCODERS]{};
uint8_t pairMuteEncoderCount = 0; uint8_t pairMuteEncoderCount = 0;
static constexpr uint8_t kPulseFilterHoldCap = 6; static constexpr uint8_t kPulseFilterHoldCap = 6;
@ -131,7 +148,6 @@ private:
uint8_t pulseFilterHoldCount = 0; uint8_t pulseFilterHoldCount = 0;
bool pulseFilterLastRawValid = false; bool pulseFilterLastRawValid = false;
uint32_t pulseFilterLastRawTime = 0; uint32_t pulseFilterLastRawTime = 0;
uint32_t pulseFilterDropFilteredOverflow = 0;
uint32_t pulseFilterDropHoldOverflow = 0; uint32_t pulseFilterDropHoldOverflow = 0;
uint32_t pulseFilterDropGlitchPairs = 0; uint32_t pulseFilterDropGlitchPairs = 0;
static constexpr uint8_t kPreambleLockNeed = (uint8_t)IR_PREAMBLE_LOCK_RISE_PERIODS; static constexpr uint8_t kPreambleLockNeed = (uint8_t)IR_PREAMBLE_LOCK_RISE_PERIODS;
@ -174,7 +190,7 @@ private:
#endif #endif
//////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////
uint8_t dataBuffer[dataByteSizeMax]{0}; // Буффер данных uint8_t dataBuffer[irproto::kMaxWireFrameBytes]{0}; // Буффер полного wire-кадра
volatile uint32_t prevRise, prevPrevRise, prevFall, prevPrevFall; // Время предыдущих фронтов/спадов volatile uint32_t prevRise, prevPrevRise, prevFall, prevPrevFall; // Время предыдущих фронтов/спадов
volatile uint32_t risePeriod; volatile uint32_t risePeriod;
@ -197,12 +213,12 @@ bool isReciveRaw = false;
void checkTimeout(); // void checkTimeout(); //
/** В очередях/hold фильтра ещё есть фронты — не оценивать таймаут по micros()-lastEdgeTime (ложный TIMEOUT). */ /** В очередях/hold фильтра ещё есть фронты — не оценивать таймаут по micros()-lastEdgeTime (ложный TIMEOUT). */
bool rxTimeoutPipelineBusy() const; bool rxTimeoutPipelineBusy() const;
/** Один сырой фронт из subBuffer -> потоковый holdback-антиглитч. */ void pulseFilterPushRaw(const FrontStorage &e);
void pulseFilterFeedOneRaw(const FrontStorage &e); bool pulseFilterTryTakeConfirmed(FrontStorage &out, uint32_t logTime = 0);
void pulseFilterFlushTimeout(uint32_t nowUs); bool pulseFilterTryFlushOne(uint32_t nowUs, FrontStorage &out);
bool pulseFilterEmit(const FrontStorage &e);
void pulseFilterShiftLeft(uint8_t n); void pulseFilterShiftLeft(uint8_t n);
void pulseFilterReset(); void pulseFilterReset();
void processDecodedFront(const FrontStorage &currentFront);
static uint32_t absDiffU32(uint32_t a, uint32_t b); static uint32_t absDiffU32(uint32_t a, uint32_t b);
bool registerPairMuteEncoder(IR_Encoder *enc); bool registerPairMuteEncoder(IR_Encoder *enc);
void refreshPairMuteState(); void refreshPairMuteState();
@ -238,9 +254,13 @@ bool isReciveRaw = false;
/// @return Результат /// @return Результат
uint16_t ceil_div(uint16_t val, uint16_t divider); uint16_t ceil_div(uint16_t val, uint16_t divider);
// rxBriefLog безусловен: ВСЕГДА инкрементирует rxReasonCnt[reason]
// (always-on наблюдаемость по контракту живучести), печать события —
// только при IR_RX_BRIEF_LOG. Вызовы в местах отказов тоже безусловны.
void rxBriefLog(RxBriefReason reason, uint16_t a = 0, uint16_t b = 0, uint32_t tUs = 0);
uint16_t rxReasonCnt[kRxBriefReasonCount] = {}; // индекс = RxBriefReason, 1..Count-1
#if IR_RX_BRIEF_LOG #if IR_RX_BRIEF_LOG
static const __FlashStringHelper *rxBriefReasonTag(RxBriefReason reason); static const __FlashStringHelper *rxBriefReasonTag(RxBriefReason reason);
void rxBriefLog(RxBriefReason reason, uint16_t a = 0, uint16_t b = 0, uint32_t tUs = 0);
void rxBriefNoteMuteBlockedIsr(uint32_t tUs); void rxBriefNoteMuteBlockedIsr(uint32_t tUs);
void rxBriefNoteRawOverflowIsr(uint32_t tUs); void rxBriefNoteRawOverflowIsr(uint32_t tUs);
void rxBriefFlushDeferredIsrLogs(); void rxBriefFlushDeferredIsrLogs();
@ -254,7 +274,7 @@ bool isReciveRaw = false;
#if defined(IRDEBUG_SERIAL_PACK) #if defined(IRDEBUG_SERIAL_PACK)
static constexpr uint16_t kPackTraceBufCap = static constexpr uint16_t kPackTraceBufCap =
uint16_t(dataByteSizeMax) * (uint16_t(bitPerByte) + uint16_t(syncBits)) + 48u; uint16_t(irproto::kMaxWireFrameBytes) * (uint16_t(bitPerByte) + uint16_t(syncBits)) + 48u;
void packTraceResetFrame(); void packTraceResetFrame();
void packTracePushBit(bool bit); void packTracePushBit(bool bit);

File diff suppressed because it is too large Load Diff

View File

@ -1,19 +1,109 @@
#pragma once #pragma once
#include "IR_config.h" #include "IR_config.h"
#include "IrTxBsrrWave.h" #include "IrTxGateTypes.h"
// TODO: Отложенная передача после завершения приема // TODO: Отложенная передача после завершения приема
enum class IR_SendStatus : uint8_t {
Success = 0,
PayloadTooLarge,
EncoderBusy,
BufferTooLarge,
ExternalBackendBusy,
ExternalStartFailed,
ExternalNoStream,
ExternalInvalidConfig,
BuildGateRunsFailed,
ScaleGateRunsFailed,
DmaStartFailed,
EncoderPinUnavailable,
BufferedStorageInvalid,
InvalidArgument,
TimingOverflow,
PlanMismatch,
DmaTransferError,
DmaStalled,
};
const char* irSendStatusToString(IR_SendStatus status);
enum class IR_TxState : uint8_t {
Idle = 0,
Preparing,
Transmitting,
Completed,
Failed,
};
enum class IR_TxClockBasis : uint8_t {
Nominal = 0,
ConfiguredTimer,
};
/**
* Deterministic PHY plan produced by the same FSM that builds the actual
* carrier-gate stream. airtimeUs is rounded up, so it is safe as a deadline
* component; it does not include backend preparation or release latency.
*/
struct IR_TxPlan {
IR_SendStatus status = IR_SendStatus::InvalidArgument;
uint8_t wireBytes = 0;
uint16_t carrierMultiply = 0;
IR_TxClockBasis clockBasis = IR_TxClockBasis::Nominal;
uint32_t tickClockHz = 0; // rational tick rate numerator
uint32_t tickDivider = 1; // rational tick rate denominator
uint32_t physicalTicks = 0;
uint32_t gateRunCount = 0;
uint32_t airtimeUs = 0;
bool valid() const { return status == IR_SendStatus::Success; }
uint32_t tickHzFloor() const { return tickDivider == 0U ? 0U : tickClockHz / tickDivider; }
uint32_t airtimeMsCeil() const { return (airtimeUs + 999U) / 1000U; }
};
/** Coherent main-context snapshot of one encoder's latest accepted operation. */
struct IR_TxSnapshot {
uint32_t operationId = 0;
IR_TxState state = IR_TxState::Idle;
IR_SendStatus status = IR_SendStatus::Success;
uint16_t carrierMultiply = 0;
IR_TxClockBasis clockBasis = IR_TxClockBasis::Nominal;
uint32_t plannedPhysicalTicks = 0;
uint32_t plannedAirtimeUs = 0;
uint32_t acceptedAtUs = 0;
uint32_t armedAtUs = 0;
uint32_t terminalAtUs = 0;
bool active() const {
return state == IR_TxState::Preparing || state == IR_TxState::Transmitting;
}
bool terminal() const {
return state == IR_TxState::Completed || state == IR_TxState::Failed;
}
};
// Структура для возврата результата отправки // Структура для возврата результата отправки
struct IR_SendResult { struct IR_SendResult {
bool success; // Флаг успешности отправки bool success; // true: backend принял и запустил эту операцию
uint32_t sendTimeMs; // Время отправки пакета в миллисекундах uint32_t sendTimeMs; // ceil(plannedAirtimeUs / 1000), compatibility field
IR_SendStatus status; // Детализированный статус старта передачи
IR_SendResult(bool success = false, uint32_t sendTimeMs = 0) uint32_t operationId; // 0, если новая операция не создавалась
: success(success), sendTimeMs(sendTimeMs) {} uint32_t plannedAirtimeUs; // PHY airtime; без подготовки/release backend-а
IR_TxClockBasis clockBasis;
IR_SendResult(bool success = false,
uint32_t sendTimeMs = 0,
IR_SendStatus status = IR_SendStatus::ExternalStartFailed,
uint32_t operationId = 0,
uint32_t plannedAirtimeUs = 0,
IR_TxClockBasis clockBasis = IR_TxClockBasis::Nominal)
: success(success), sendTimeMs(sendTimeMs), status(status),
operationId(operationId), plannedAirtimeUs(plannedAirtimeUs),
clockBasis(clockBasis) {}
}; };
class IR_DecoderRaw; class IR_DecoderRaw;
class IrTxIsrBufferedStorageBase;
class IR_Encoder : public IR_FOX class IR_Encoder : public IR_FOX
{ {
friend IR_DecoderRaw; friend IR_DecoderRaw;
@ -24,9 +114,19 @@ public:
static HardwareTimer* IR_Timer; static HardwareTimer* IR_Timer;
using IR_TxGateRun = IrTxGateRun; using IR_TxGateRun = IrTxGateRun;
enum class TxIsrMode : uint8_t {
Legacy = 0,
Buffered = 1
};
using ExternalTxBusyFn = bool (*)(void *ctx); using ExternalTxBusyFn = bool (*)(void *ctx);
using ExternalTxStartFn = bool (*)(void *ctx, IR_Encoder *enc, const uint8_t *packet, uint8_t len); using ExternalTxStartFn = IR_SendStatus (*)(void *ctx, IR_Encoder *enc, const uint8_t *packet, uint8_t len);
using ExternalTxStartFnV2 = IR_SendStatus (*)(void *ctx,
IR_Encoder *enc,
const uint8_t *packet,
uint8_t len,
const IR_TxPlan& plan,
uint32_t operationId);
private: private:
// uint16_t id; /// @brief Адрес передатчика // uint16_t id; /// @brief Адрес передатчика
public: public:
@ -57,7 +157,7 @@ public:
/** p∈[0,100] → ближайший допустимый числитель; 100% даёт N = maxPowerNumerator(). */ /** p∈[0,100] → ближайший допустимый числитель; 100% даёт N = maxPowerNumerator(). */
void setPowerPercent(uint8_t p); void setPowerPercent(uint8_t p);
/** После buildGateRuns: lenTicks в тактах 2×Fc → физические тики (carrierFrec×multiply). Может разбить сегменты. */ /** Legacy helper: lenTicks в тактах 2×Fc → физические тики (carrierFrec×multiply). Может разбить сегменты. */
static bool scaleGateRunsToPhysical(IR_TxGateRun* runs, size_t* ioCount, size_t maxRuns, uint16_t multiply); static bool scaleGateRunsToPhysical(IR_TxGateRun* runs, size_t* ioCount, size_t maxRuns, uint16_t multiply);
/** Configure timer frequency for TX clock (carrierFrec × multiply) without attaching ISR. */ /** Configure timer frequency for TX clock (carrierFrec × multiply) without attaching ISR. */
@ -67,21 +167,50 @@ public:
static void tick(); static void tick();
/** /**
* Режим внутреннего TX без DMA: false — BSRR + кольцо (buildGateRuns + scaleGateRunsToPhysical); * Режим внутреннего TX без DMA: false — BSRR + кольцо (direct physical gate-runs builder);
* true — FSM «налету» + скважность несущей как у буферного пути (подшаги multiply/2 на шаг FSM). * true — FSM «налету» + скважность несущей как у буферного пути (подшаги multiply/2 на шаг FSM).
* Выставить до begin/rawSend (глобально на все IR_Encoder). Игнорируется при externalTxStartFn. * По умолчанию включён legacy=true для обратной совместимости. Вызов меняет default и обновляет
* все зарегистрированные encoder-объекты. Buffered ISR реально используется только если у encoder
* привязан storage через attachBufferedIsrStorage()/enableBufferedIsr().
* Выставить до begin/rawSend. Игнорируется при externalTxStartFn.
*/ */
static void setTxIsrLegacyMode(bool legacy); static void setTxIsrLegacyMode(bool legacy);
static bool txIsrLegacyMode(); static bool txIsrLegacyMode();
void attachBufferedIsrStorage(IrTxIsrBufferedStorageBase& storage);
void detachBufferedIsrStorage();
bool hasBufferedIsrStorage() const;
void enableBufferedIsr(IrTxIsrBufferedStorageBase& storage);
void disableBufferedIsr();
TxIsrMode txIsrMode() const;
/** Optional: register external TX backend (e.g. DMA driver). */ /** Optional: register external TX backend (e.g. DMA driver). */
static void setExternalTxBackend(ExternalTxStartFn startFn, ExternalTxBusyFn busyFn, void *ctx); static void setExternalTxBackend(ExternalTxStartFn startFn, ExternalTxBusyFn busyFn, void *ctx);
/** Token-aware backend contract. Prefer this overload for every new backend. */
static void setExternalTxBackendV2(ExternalTxStartFnV2 startFn, ExternalTxBusyFn busyFn, void *ctx);
/** Called by external TX backend on actual end of transmission. */ /** Legacy completion hook. It cannot reject a stale completion; retained for source compatibility. */
void externalFinishSend(); void externalFinishSend();
/** Complete exactly operationId; stale/duplicate completions are ignored. */
void externalFinishSend(uint32_t operationId, IR_SendStatus terminalStatus);
/** Build RLE runs of carrier gate for a packet (no HW access). */ /** Build RLE runs of carrier gate for a packet in logical 2×Fc ticks (no HW access). */
static size_t buildGateRuns(const uint8_t *packet, uint8_t len, IR_TxGateRun *outRuns, size_t maxRuns); static size_t buildGateRuns(const uint8_t *packet, uint8_t len, IR_TxGateRun *outRuns, size_t maxRuns);
/** Build RLE runs directly in physical carrierFrec×multiply ticks (DMA/buffered ISR path). */
static size_t buildPhysicalGateRuns(const uint8_t *packet, uint8_t len, IR_TxGateRun *outRuns, size_t maxRuns, uint16_t multiply);
/** Preflight the exact physical stream without allocating or touching hardware. */
static IR_TxPlan planPhysicalTransmission(const uint8_t *packet, uint8_t len, uint16_t multiply);
/** Build into caller storage and report both required runs and exact timing. */
static IR_TxPlan buildPhysicalTransmission(const uint8_t *packet,
uint8_t len,
IR_TxGateRun *outRuns,
size_t maxRuns,
uint16_t multiply);
/** Replace nominal tick rate with an exact rational backend clock. */
static bool applyTickClock(IR_TxPlan& plan,
uint32_t clockNumeratorHz,
uint32_t clockDivider,
IR_TxClockBasis basis = IR_TxClockBasis::ConfiguredTimer);
IR_TxPlan planTransmission(const uint8_t *packet, uint8_t len) const;
void enable(); void enable();
void disable(); void disable();
@ -94,7 +223,8 @@ public:
"IR_Encoder::setBlindDecoders: array size exceeds IR_PAIR_MUTE_MAX_ENCODERS"); "IR_Encoder::setBlindDecoders: array size exceeds IR_PAIR_MUTE_MAX_ENCODERS");
setBlindDecoders(decoders, static_cast<uint8_t>(N)); setBlindDecoders(decoders, static_cast<uint8_t>(N));
} }
void rawSend(uint8_t *ptr, uint8_t len); IR_SendStatus rawSend(uint8_t *ptr, uint8_t len);
IR_SendResult rawSendTracked(uint8_t *ptr, uint8_t len);
IR_SendResult sendData(uint16_t addrTo, uint8_t dataByte, bool needAccept = false); IR_SendResult sendData(uint16_t addrTo, uint8_t dataByte, bool needAccept = false);
IR_SendResult sendData(uint16_t addrTo, uint8_t *data = nullptr, uint8_t len = 0, bool needAccept = false); IR_SendResult sendData(uint16_t addrTo, uint8_t *data = nullptr, uint8_t len = 0, bool needAccept = false);
@ -118,7 +248,11 @@ public:
uint32_t testSendBack(uint8_t *data = nullptr, uint8_t len = 0) const; uint32_t testSendBack(uint8_t *data = nullptr, uint8_t len = 0) const;
uint32_t testSendBackTo(uint16_t addrTo, uint8_t *data = nullptr, uint8_t len = 0) const; uint32_t testSendBackTo(uint16_t addrTo, uint8_t *data = nullptr, uint8_t len = 0) const;
inline bool isBusy() const { return isSending;} inline bool isBusy() const { return isSending; }
/** Main-context coherent snapshot. Do not spin on this from an ISR. */
IR_TxSnapshot txSnapshot() const;
bool isOperationTerminal(uint32_t operationId) const;
bool isOperationComplete(uint32_t operationId) const;
~IR_Encoder(); ~IR_Encoder();
@ -133,6 +267,7 @@ private:
static void carrierPauseIfIdle(); static void carrierPauseIfIdle();
static ExternalTxStartFn externalTxStartFn; static ExternalTxStartFn externalTxStartFn;
static ExternalTxStartFnV2 externalTxStartFnV2;
static ExternalTxBusyFn externalTxBusyFn; static ExternalTxBusyFn externalTxBusyFn;
static void *externalTxCtx; static void *externalTxCtx;
IR_SendResult _sendBack(bool isAdressed, uint16_t addrTo, uint8_t *data, uint8_t len); IR_SendResult _sendBack(bool isAdressed, uint16_t addrTo, uint8_t *data, uint8_t len);
@ -173,27 +308,32 @@ private:
static bool txAdvanceBoundary(TxFsmState &st, const uint8_t *sendBufferLocal); static bool txAdvanceBoundary(TxFsmState &st, const uint8_t *sendBufferLocal);
static bool txAdvanceAfterOutput(TxFsmState &st, const uint8_t *sendBufferLocal); static bool txAdvanceAfterOutput(TxFsmState &st, const uint8_t *sendBufferLocal);
static bool txEmitTick(TxFsmState &st, const uint8_t *sendBufferLocal, bool &gateOut); static bool txEmitTick(TxFsmState &st, const uint8_t *sendBufferLocal, bool &gateOut);
static TxFsmState initialTxFsm(uint8_t len);
static IR_TxPlan buildPhysicalPlan(const uint8_t *packet,
uint8_t len,
IR_TxGateRun *outRuns,
size_t maxRuns,
uint16_t multiply,
bool emitRuns);
static bool calculateAirtimeUs(IR_TxPlan& plan);
static void applyConfiguredTimerClock(IR_TxPlan& plan);
void loadTxFsmFromMembers(TxFsmState &st) const; void loadTxFsmFromMembers(TxFsmState &st) const;
void storeTxFsmToMembers(const TxFsmState &st); void storeTxFsmToMembers(const TxFsmState &st);
bool shouldUseBufferedIsr() const;
IrTxBsrrWave txBsrrWave_{};
IR_TxGateRun txGateRuns_[irproto::kIsrTxMaxGateRuns]{};
uint32_t txBsrrWords_[irproto::kIsrTxBsrrWordCount]{};
uint16_t txBsrrReadIdx_ = 0;
uint16_t txBsrrHalfLen_ = 0;
uint32_t txBsrrTotalTicks_ = 0;
uint32_t txBsrrTicksSent_ = 0;
/** Снимок на старт TX (буферный и legacy путь). */ /** Снимок на старт TX (буферный и legacy путь). */
uint16_t txPowerSnap_ = 1; uint16_t txPowerSnap_ = 1;
uint16_t txMultiplySnap_ = 2; uint16_t txMultiplySnap_ = 2;
/** Legacy: физических тиков на один логический шаг FSM = multiply/2. */ /** Fractional 2×Fc -> multiply×Fc phase accumulator (also exact for odd multiply). */
uint16_t legacyPhysPerLogical_ = 1; uint32_t legacyScaleAccumulator_ = 0;
uint16_t legacyPhysCounter_ = 0;
uint16_t legacySlotInPeriod_ = 0; uint16_t legacySlotInPeriod_ = 0;
volatile uint16_t powerNumerator_ = 1; volatile uint16_t powerNumerator_ = 1;
IrTxIsrBufferedStorageBase* txBufferedCtx_ = nullptr;
IrTxIsrBufferedStorageBase* txActiveBufferedCtx_ = nullptr;
TxIsrMode txIsrMode_ = TxIsrMode::Legacy;
bool txUseBufferedIsr_ = false;
IR_DecoderRaw *decPair = nullptr; IR_DecoderRaw *decPair = nullptr;
IR_DecoderRaw *singleBlindDecoder = nullptr; IR_DecoderRaw *singleBlindDecoder = nullptr;
@ -201,9 +341,27 @@ private:
uint8_t decodersCount = 0; uint8_t decodersCount = 0;
uint8_t sendLen = 0; uint8_t sendLen = 0;
uint8_t sendBuffer[dataByteSizeMax]{0}; /// @brief Буффер данных для отправки uint8_t sendBuffer[irproto::kMaxWireFrameBytes]{0}; /// @brief Буффер полного wire-кадра
volatile bool isSending = false; volatile bool isSending = false;
// Single-writer-at-a-time record (main starts, ISR/backend terminates).
// The byte seqlock makes a coherent main-context snapshot without heap/locks.
volatile uint8_t txRecordVersion_ = 0;
volatile IR_TxState txState_ = IR_TxState::Idle;
volatile IR_SendStatus txTerminalStatus_ = IR_SendStatus::Success;
volatile uint32_t txOperationId_ = 0;
volatile uint32_t txPlannedPhysicalTicks_ = 0;
volatile uint32_t txPlannedAirtimeUs_ = 0;
volatile IR_TxClockBasis txClockBasis_ = IR_TxClockBasis::Nominal;
volatile uint32_t txAcceptedAtUs_ = 0;
volatile uint32_t txArmedAtUs_ = 0;
volatile uint32_t txTerminalAtUs_ = 0;
uint32_t txNextOperationId_ = 0;
uint32_t beginTxOperation(const IR_TxPlan& plan);
void markTxArmed(uint32_t operationId);
bool finishTxOperation(uint32_t operationId, IR_SendStatus terminalStatus);
volatile bool state = LOW; /// @brief Текущий уровень генерации volatile bool state = LOW; /// @brief Текущий уровень генерации
volatile uint8_t dataByteCounter = 0; volatile uint8_t dataByteCounter = 0;

View File

@ -30,4 +30,4 @@ uint8_t IR_FOX::crc8(uint8_t *data, uint8_t start, uint8_t end, uint8_t poly)
} }
} }
return crc; return crc;
}; }

View File

@ -6,10 +6,15 @@
/** Число потоков DMA-TX задаётся шаблоном: IrDmaTxStm32<2>, см. IrDmaTxStm32.h и irproto::kDefaultDmaTxMaxStreams. */ /** Число потоков DMA-TX задаётся шаблоном: IrDmaTxStm32<2>, см. IrDmaTxStm32.h и irproto::kDefaultDmaTxMaxStreams. */
namespace irproto { namespace irproto {
constexpr size_t kDefaultDmaTxMaxStreams = 4U; constexpr size_t kDefaultDmaTxMaxStreams = 4U;
/** NVIC-приоритет IRQ DMA-каналов TX (refill кольца BSRR-слов). Refill длинный (полубуфер
* 2048 слов = миллисекунды на небыстрых SYSCLK), а его дедлайн мягкий (~9 мс при 38кГц×6),
* поэтому он обязан сидеть НИЖЕ SysTick (0) и EXTI приёмника (6): с приоритетом по умолчанию
* (0, HAL_NVIC_EnableIRQ без SetPriority) он не вытеснялся SysTick'ом, тики слипались и
* millis() замедлялся в 1.6×/2.2× (один/два потока) на всё время передачи — мерено на стенде
* TofSickHunt 2026-08-11. Меньше число = выше приоритет; 8 = ниже EXTI, выше самого дна. */
constexpr uint32_t kDmaTxIrqPriority = 8U;
/** Кольцевой буфер BSRR-слов для ISR-TX (как у DMA: два полублока). Чётное число. */ /** Кольцевой буфер BSRR-слов для ISR-TX (как у DMA: два полублока). Чётное число. */
constexpr uint16_t kIsrTxBsrrWordCount = 256U; constexpr uint16_t kIsrTxBsrrWordCount = 256U;
/** Максимум RLE-сегментов для buildGateRuns при ISR-TX. */
constexpr size_t kIsrTxMaxGateRuns = 512U;
static_assert((kIsrTxBsrrWordCount & 1U) == 0U, "kIsrTxBsrrWordCount must be even"); static_assert((kIsrTxBsrrWordCount & 1U) == 0U, "kIsrTxBsrrWordCount must be even");
} }
@ -21,7 +26,7 @@ static_assert((kIsrTxBsrrWordCount & 1U) == 0U, "kIsrTxBsrrWordCount must be eve
// Краткий лог причин, почему физический сигнал не дошёл до распознанного пакета. // Краткий лог причин, почему физический сигнал не дошёл до распознанного пакета.
// Формат и коды: ref/IR_RX_BRIEF_LOG.md // Формат и коды: ref/IR_RX_BRIEF_LOG.md
#ifndef IR_RX_BRIEF_LOG #ifndef IR_RX_BRIEF_LOG
#define IR_RX_BRIEF_LOG 1 #define IR_RX_BRIEF_LOG 0
#endif #endif
// 1: печатать только отклонённые/ошибочные события; успехи и шумовые PREAMB скрыть. // 1: печатать только отклонённые/ошибочные события; успехи и шумовые PREAMB скрыть.
#ifndef IR_RX_BRIEF_LOG_REJECT_ONLY #ifndef IR_RX_BRIEF_LOG_REJECT_ONLY
@ -94,7 +99,7 @@ msg type:
                                //  ----------                                 //  ----------
                                // | xxx..... | = тип сообщения (биты 7..5)                                 // | xxx..... | = тип сообщения (биты 7..5)
                                // | ...xxxxx | = полная длина кадра в байтах (5 бит, 0..31, IR_MASK_MSG_INFO), не «31 бит» и не отдельный лимит «24 байта»                                 // | ...xxxxx | = полная длина кадра в байтах (5 бит, 0..31, IR_MASK_MSG_INFO), не «31 бит» и не отдельный лимит «24 байта»
                                // Полезная нагрузка в data pack: до bytePerPack байт (см. #define bytePerPack). // Полезная нагрузка в data pack: до irproto::kMaxDataPayloadBytes байт.
                                //  ---------- */                                 //  ---------- */
#define IR_MSG_BACK 0U // | 000...... | = Задний сигнал машинки #define IR_MSG_BACK 0U // | 000...... | = Задний сигнал машинки
#define IR_MSG_ACCEPT 1U // | 001..... | = подтверждение #define IR_MSG_ACCEPT 1U // | 001..... | = подтверждение
@ -104,7 +109,7 @@ msg type:
// #define IR_MSG_ 5U // | 101..... | = ?? // #define IR_MSG_ 5U // | 101..... | = ??
#define IR_MSG_DATA_NOACCEPT 6U // | 110..... | = данные, не требующие подтверждения #define IR_MSG_DATA_NOACCEPT 6U // | 110..... | = данные, не требующие подтверждения
#define IR_MSG_DATA_ACCEPT 7U // | 111..... | = данные требующие подтверждения #define IR_MSG_DATA_ACCEPT 7U // | 111..... | = данные требующие подтверждения
; /*   // ---------- /*   // ----------
/``````````````````````````````` подтверждение `````````````````````````````\      /``````````````````````````````````````` запрос ``````````````````````````````````\ /``````````````````````````````` подтверждение `````````````````````````````\      /``````````````````````````````````````` запрос ``````````````````````````````````\
                                                                                                                                                                                                                                             
@ -152,15 +157,25 @@ msg type:
*/ */
#define IR_MASK_MSG_TYPE 0b00000111 namespace irproto {
#define IR_MASK_MSG_INFO 0b00011111 /** Three high header bits, shifted down, encode the message type. */
constexpr uint8_t kMessageTypeMask = 0x07U;
/** Five low header bits encode the complete on-wire frame length. */
constexpr uint8_t kWireFrameLengthBits = 5U;
constexpr uint8_t kWireFrameLengthMask =
static_cast<uint8_t>((1U << kWireFrameLengthBits) - 1U);
constexpr uint8_t kMaxWireFrameBytes = kWireFrameLengthMask;
}
// Source-compatible aliases. New code should use the typed irproto constants.
#define IR_MASK_MSG_TYPE (::irproto::kMessageTypeMask)
#define IR_MASK_MSG_INFO (::irproto::kWireFrameLengthMask)
/* /*
/////////////////////////////////////////////////////////////////////////////////////*/ /////////////////////////////////////////////////////////////////////////////////////*/
typedef uint16_t crc_t; typedef uint16_t crc_t;
// #define BRUTEFORCE_CHECK // Перепроверяет пакет на 1 битные ошибки //TODO: зависает // #define BRUTEFORCE_CHECK // Перепроверяет пакет на 1 битные ошибки //TODO: зависает
#define bytePerPack (31) // колличество байтов в пакете
#ifndef freeFrec #ifndef freeFrec
#define freeFrec false #define freeFrec false
#endif #endif
@ -179,6 +194,13 @@ typedef uint16_t crc_t;
#ifndef IR_INPUT_MIN_PULSE_US #ifndef IR_INPUT_MIN_PULSE_US
#define IR_INPUT_MIN_PULSE_US 0 #define IR_INPUT_MIN_PULSE_US 0
#endif #endif
/** Сколько сырых фронтов забирать из ISR-буфера за один tick() (батч-выемка).
* 1 фронт/tick привязывал RX-пропускную способность к частоте loop: медленный
* loop (телеметрия/дисплей) переполнял буфер 250 фронтов (~120мс эфира) и
* кадры терялись молча. Батч развязывает приём от темпа loop. */
#ifndef IR_RX_TICK_BATCH
#define IR_RX_TICK_BATCH 16U
#endif
/** Сколько подтверждённых фронтов держать перед выпуском в декодер (потоковая задержка). */ /** Сколько подтверждённых фронтов держать перед выпуском в декодер (потоковая задержка). */
#ifndef IR_INPUT_FILTER_HOLDBACK_EDGES #ifndef IR_INPUT_FILTER_HOLDBACK_EDGES
#define IR_INPUT_FILTER_HOLDBACK_EDGES 3U #define IR_INPUT_FILTER_HOLDBACK_EDGES 3U
@ -234,8 +256,6 @@ typedef uint16_t crc_t;
#define poly2 0x8C #define poly2 0x8C
#define syncBits 3U // количество битов синхронизации #define syncBits 3U // количество битов синхронизации
#define dataByteSizeMax (msgBytes + addrBytes + addrBytes + bytePerPack + crcBytes)
#define preambFronts (preambPulse * 2) // количество фронтов преамбулы (Приём) #define preambFronts (preambPulse * 2) // количество фронтов преамбулы (Приём)
#define preambToggle ((bitPauseTakts * 2 + bitActiveTakts) * 2 - 1) // колличество переключений преамбулы (Передача) #define preambToggle ((bitPauseTakts * 2 + bitActiveTakts) * 2 - 1) // колличество переключений преамбулы (Передача)
@ -248,7 +268,123 @@ typedef uint16_t crc_t;
#define bitTakts (bitActiveTakts + bitPauseTakts) // Общая длительность бита в тактах #define bitTakts (bitActiveTakts + bitPauseTakts) // Общая длительность бита в тактах
#define bitTime (bitTakts * carrierPeriod) // Общая длительность бита #define bitTime (bitTakts * carrierPeriod) // Общая длительность бита
#define tolerance 300U namespace irproto {
constexpr uint8_t kDataFrameOverheadBytes = msgBytes + addrBytes + addrBytes + crcBytes;
constexpr uint8_t kBackFrameOverheadBytes = msgBytes + addrBytes + crcBytes;
constexpr uint8_t kBackToFrameOverheadBytes = msgBytes + addrBytes + addrBytes + crcBytes;
constexpr uint8_t kMaxDataPayloadBytes = kMaxWireFrameBytes - kDataFrameOverheadBytes;
constexpr uint8_t kMaxBackPayloadBytes = kMaxWireFrameBytes - kBackFrameOverheadBytes;
constexpr uint8_t kMaxBackToPayloadBytes = kMaxWireFrameBytes - kBackToFrameOverheadBytes;
/** RX timing geometry shared by adaptive and nominal decoder paths. */
constexpr uint16_t kRxTimingToleranceUs = 300U;
constexpr uint8_t kRxInterEdgeTimeoutGuardBitWindows = 1U;
constexpr uint8_t kRxInterEdgeTimeoutBitWindows =
static_cast<uint8_t>(bitPerByte + syncBits + kRxInterEdgeTimeoutGuardBitWindows);
constexpr uint8_t kRxSilenceTimeoutInterEdgeWindows = 2U;
/**
* Largest accepted rise-to-rise interval for one decoder byte window.
* adaptiveBitPeriodUs is riseSyncTime when free-frequency tracking is used.
*/
constexpr uint32_t rxInterEdgeTimeoutUs(uint32_t adaptiveBitPeriodUs)
{
return (adaptiveBitPeriodUs + static_cast<uint32_t>(kRxTimingToleranceUs)) *
static_cast<uint32_t>(kRxInterEdgeTimeoutBitWindows);
}
/** Silence after which an unfinished RX candidate is retired. */
constexpr uint32_t rxSilenceTimeoutUs(uint32_t adaptiveBitPeriodUs)
{
return rxInterEdgeTimeoutUs(adaptiveBitPeriodUs) *
static_cast<uint32_t>(kRxSilenceTimeoutInterEdgeWindows);
}
constexpr uint32_t microsToMillisCeil(uint32_t us)
{
return (us + 999U) / 1000U;
}
constexpr uint32_t kNominalRxInterEdgeTimeoutUs = rxInterEdgeTimeoutUs(bitTime);
constexpr uint32_t kNominalRxSilenceTimeoutUs = rxSilenceTimeoutUs(bitTime);
/**
* Deployed response/ACK turn-around policy.
*
* This is empirical, not a PHY invariant. Commit 1353ab6 replaced the older
* fixed 75 ms with a floating expression whose only reproducible result at the
* nominal PHY is 42 ms; no measurement or physical derivation was recorded.
* Keep the deployed value until a hardware gap campaign establishes a new
* channel-turn-around contract.
*/
constexpr uint16_t kDefaultResponseTurnaroundDelayMs = 42U;
/**
* Conservative logical run bound: preamble transitions plus two gate runs for
* every data/sync bit. Physical splitting for unusually large multiply values
* is reported by IR_TxPlan::gateRunCount and may require custom storage.
*/
constexpr size_t kMaxLogicalGateRuns =
static_cast<size_t>(preambPulse * 2U) +
static_cast<size_t>(kMaxWireFrameBytes) *
static_cast<size_t>((bitPerByte + syncBits) * 2U);
constexpr size_t kIsrTxMaxGateRuns = kMaxLogicalGateRuns;
/**
* Compile-time PHY storage contract.
*
* Every data and sync bit occupies bitTakts * 2 ticks on the logical
* 2*carrierFrec clock, independently of its value. A physical gate run is
* stored in uint16_t and can therefore split at UINT16_MAX ticks. The bound
* below includes the worst possible number of such split pieces; applications
* can size fixed DMA/ISR storage from the protocol instead of duplicating a
* packet-size constant.
*/
constexpr uint32_t kPreambleLogicalTicks =
static_cast<uint32_t>(preambPulse * 2U) *
static_cast<uint32_t>(preambToggle + 1U);
constexpr uint32_t kEncodedBitLogicalTicks =
static_cast<uint32_t>(bitTakts * 2U);
constexpr uint32_t kMaxLogicalTransmissionTicks =
kPreambleLogicalTicks +
static_cast<uint32_t>(kMaxWireFrameBytes) *
static_cast<uint32_t>(bitPerByte + syncBits) *
kEncodedBitLogicalTicks;
constexpr uint16_t normalizedCarrierMultiply(uint16_t multiply)
{
return multiply < 2U ? 2U : multiply;
}
constexpr uint64_t maxPhysicalTransmissionTicks(uint16_t multiply)
{
return (static_cast<uint64_t>(kMaxLogicalTransmissionTicks) *
static_cast<uint64_t>(normalizedCarrierMultiply(multiply)) +
1U) /
2U;
}
constexpr size_t maxPhysicalGateRunCapacity(uint16_t multiply)
{
return kMaxLogicalGateRuns +
static_cast<size_t>(maxPhysicalTransmissionTicks(multiply) /
static_cast<uint64_t>(UINT16_MAX));
}
static_assert(kMaxDataPayloadBytes == 24U, "IR DATA payload contract changed");
static_assert(kMaxBackPayloadBytes == 26U, "IR BACK payload contract changed");
static_assert(kNominalRxInterEdgeTimeoutUs == 15144U, "IR RX timeout contract changed");
static_assert(kNominalRxSilenceTimeoutUs == 30288U, "IR RX silence contract changed");
static_assert(kMaxLogicalTransmissionTicks <= UINT32_MAX,
"IR maximum transmission no longer fits IR_TxPlan");
}
// Deprecated source-compatible names. They are aliases only and no longer
// define independent storage/payload limits. bytePerPack historically meant
// 31; preserving that value avoids silently changing external sketches.
#define bytePerPack (::irproto::kMaxWireFrameBytes)
#define dataByteSizeMax (::irproto::kMaxWireFrameBytes)
#define IR_TIMING_TOLERANCE_US (::irproto::kRxTimingToleranceUs)
constexpr uint16_t test_all_Time = bitTime; constexpr uint16_t test_all_Time = bitTime;
constexpr uint16_t test_all_Takts = bitTakts * 2; constexpr uint16_t test_all_Takts = bitTakts * 2;

View File

@ -5,6 +5,14 @@
#if defined(ARDUINO_ARCH_STM32) && defined(STM32G4xx) #if defined(ARDUINO_ARCH_STM32) && defined(STM32G4xx)
#if defined(_MSC_VER)
#define IRPROTO_DMA_PRAGMA_MESSAGE(text) __pragma(message(text))
#else
#define IRPROTO_DMA_PRAGMA_MESSAGE(text) _Pragma(#text)
#endif
IRPROTO_DMA_PRAGMA_MESSAGE(message("[IR-protocol] TX path available: built-in DMA"))
#include <Arduino.h> #include <Arduino.h>
#include <HardwareTimer.h> #include <HardwareTimer.h>
@ -77,9 +85,12 @@ public:
} }
s_instance = this; s_instance = this;
activeCount_ = 0;
for (uint8_t i = 0; i < streamCount_; i++) { for (uint8_t i = 0; i < streamCount_; i++) {
// Приоритет ЯВНО и ДО включения: без SetPriority канал остаётся на 0 (уровень
// SysTick), длинный refill не вытесняется и глотает тики millis. Обоснование
// и измерения — у irproto::kDmaTxIrqPriority (IR_config.h).
HAL_NVIC_SetPriority(streams_[i].dmaIrq, irproto::kDmaTxIrqPriority, 0);
HAL_NVIC_EnableIRQ(streams_[i].dmaIrq); HAL_NVIC_EnableIRQ(streams_[i].dmaIrq);
} }
@ -90,24 +101,47 @@ public:
return s_instance; return s_instance;
} }
// Fix E (контракт busy): "занят" = АКТИВЕН ХОТЯ БЫ ОДИН поток (OR).
// Раньше было AND (все потоки сразу) — почти всегда false, не отражало занятости и было
// избыточно (per-stream s.active в startStream + per-encoder isSending уже не дают повторный
// старт того же потока). OR нужен, чтобы irAnyTxBusy()/applyCarrierMultiply реально дожидались
// простоя ВСЕХ эмиттеров перед ретюном несущей.
// ВНИМАНИЕ: это НЕ сериализует независимые front/rear — путь отправки гейтится per-stream;
// внешний busy()-гейт в IR_Encoder::rawSend намеренно нейтрализован в прошивке (txBusy()=false).
// Чтобы ВКЛЮЧИТЬ сериализацию front/rear — вернуть txBusy()=busy() в Car/src/IR/IR.cpp.
bool busy() const { bool busy() const {
if (streamCount_ == 0) return false;
for (uint8_t i = 0; i < streamCount_; i++) { for (uint8_t i = 0; i < streamCount_; i++) {
if (!streams_[i].active) return false; if (streams_[i].active) return true;
}
return true;
}
bool start(IR_Encoder* enc, const uint8_t* packet, uint8_t len) {
if (enc == nullptr) return false;
for (uint8_t i = 0; i < streamCount_; i++) {
if (streams_[i].enc == enc) {
return startStream(streams_[i], packet, len);
}
} }
return false; return false;
} }
IR_SendStatus start(IR_Encoder* enc, const uint8_t* packet, uint8_t len) {
if (enc == nullptr) return IR_SendStatus::ExternalNoStream;
for (uint8_t i = 0; i < streamCount_; i++) {
if (streams_[i].enc == enc) {
const IR_TxSnapshot snapshot = enc->txSnapshot();
const IR_TxPlan plan = enc->planTransmission(packet, len);
return startStream(streams_[i], packet, len, plan, snapshot.operationId);
}
}
return IR_SendStatus::ExternalNoStream;
}
IR_SendStatus startTracked(IR_Encoder* enc,
const uint8_t* packet,
uint8_t len,
const IR_TxPlan& plan,
uint32_t operationId) {
if (enc == nullptr) return IR_SendStatus::ExternalNoStream;
for (uint8_t i = 0; i < streamCount_; i++) {
if (streams_[i].enc == enc) {
return startStream(streams_[i], packet, len, plan, operationId);
}
}
return IR_SendStatus::ExternalNoStream;
}
void irqForStream(size_t streamIndex) { void irqForStream(size_t streamIndex) {
if (streamIndex >= streamCount_) return; if (streamIndex >= streamCount_) return;
HAL_DMA_IRQHandler(&streams_[streamIndex].hdma); HAL_DMA_IRQHandler(&streams_[streamIndex].hdma);
@ -118,6 +152,37 @@ public:
return &streams_[streamIndex].hdma; return &streams_[streamIndex].hdma;
} }
// Fix D: не-IRQ страховка завершения. Вызывать из главного цикла (IR_Module::tick()).
// Штатно isSending сбрасывает только DMA HT/CPLT IRQ; если он не сработает (пропуск/слипание
// флагов, Transfer-Error, последствие любой гонки) — поток «залипает» навсегда. Здесь ловим
// застой прогресса ticksOutput по стенным часам и форсим stopStream как аварийное
// восстановление. Это recovery (не prevention): фикс C убирает саму гонку, а poll() страхует
// структурный SPOF завершения и делает редкий баг наблюдаемым (recoveryCount()).
void poll() {
const uint32_t now = millis();
for (uint8_t i = 0; i < streamCount_; i++) {
TxStream& s = streams_[i];
if (!s.active) continue; // atomic: bool
const uint32_t t = s.ticksOutput; // atomic: выровненный u32 на Cortex-M
if (t != s.lastTicks) {
s.lastTicks = t;
s.lastProgressMs = now;
continue;
}
if ((uint32_t)(now - s.lastProgressMs) >= stallTimeoutMs_) {
forceStop(s);
}
}
}
/** Порог застоя для watchdog (мс). По умолчанию 100; должен быть заметно больше времени
вывода одной половины DMA-буфера на самой медленной несущей. 0 игнорируется. */
void setStallTimeoutMs(uint32_t ms) { if (ms != 0U) stallTimeoutMs_ = ms; }
/** Сколько раз watchdog аварийно восстановил залипший поток (наблюдаемость/логи). */
uint32_t recoveryCount() const { return recoveries_; }
/** Сколько раз DMA-передача завершилась по Transfer-Error (наблюдаемость/логи). */
uint32_t errorCount() const { return errors_; }
private: private:
struct TxStream { struct TxStream {
DMA_HandleTypeDef hdma{}; DMA_HandleTypeDef hdma{};
@ -143,28 +208,38 @@ private:
uint32_t totalTicks = 0; uint32_t totalTicks = 0;
volatile uint32_t ticksOutput = 0; volatile uint32_t ticksOutput = 0;
uint32_t operationId = 0;
bool active = false; // Fix D (watchdog): прогресс ticksOutput против стенных часов (контекст потока).
uint32_t lastTicks = 0;
uint32_t lastProgressMs = 0;
volatile bool active = false; // Fix: делится поток<->ISR (busy()/poll()/колбэки/спин applyCarrierMultiply) — запрет кэширования компилятором
void resetWave() { void resetWave() {
wave.configure(setWord, resetWord, nullptr, 0, 2, 1); wave.configure(setWord, resetWord, nullptr, 0, 2, 1);
ticksOutput = 0; ticksOutput = 0;
totalTicks = 0; totalTicks = 0;
runCount = 0; runCount = 0;
operationId = 0;
} }
IR_DMA_TX_HOT void fill(uint32_t* dst, uint16_t count) { IR_DMA_TX_HOT void fill(uint32_t* dst, uint16_t count) {
wave.fill(dst, count); wave.fill(dst, count);
} }
void onHalf() { void advanceHalf() {
ticksOutput += halfLen; ticksOutput += halfLen;
fill(&dmaBuf[0], halfLen);
} }
void onComplete() { void refillFirstHalf() {
ticksOutput += halfLen; fill(&dmaBuf[0], halfLen);
__DSB(); // Fix #8: refill первой половины виден DMA до следующего прохода кольца
}
void refillSecondHalf() {
fill(&dmaBuf[halfLen], halfLen); fill(&dmaBuf[halfLen], halfLen);
__DSB(); // Fix #8: refill второй половины виден DMA до следующего прохода кольца
} }
void onError() {} void onError() {}
@ -178,29 +253,39 @@ private:
TxStream streams_[MaxStreams]{}; TxStream streams_[MaxStreams]{};
uint8_t streamCount_ = 0; uint8_t streamCount_ = 0;
volatile uint8_t activeCount_ = 0; // Fix C: разделяемый activeCount_ удалён — он и был источником гонки поток↔ISR.
uint32_t stallTimeoutMs_ = 100; // Fix D
uint32_t recoveries_ = 0; // Fix D (только контекст потока)
volatile uint32_t errors_ = 0; // Fix #5: счётчик Transfer-Error (пишется в ISR dmaErrorCb, читается из потока)
static uint32_t u32ptr(const volatile void* p) { static uint32_t u32ptr(const volatile void* p) {
return (uint32_t)(uintptr_t)p; return (uint32_t)(uintptr_t)p;
} }
void startTimerIfNeeded() { // Fix C: TIM держим free-running. Включаем ИДЕМПОТЕНТНО при старте потока и НИКОГДА не
// останавливаем по разделяемому счётчику (счётчик убран → гонка поток↔ISR устранена).
// Счётчик TIM НЕ сбрасываем: сброс глитчил бы уже активный поток; для ИК ≤1 период несущей
// фазового сдвига пренебрежим. В простое (ни один DMA-канал не заармлен) update-события TIM
// генерят DMA-запросы «в никуда» — безвредно, в GPIO ничего не пишется, светодиод не дёргается.
void ensureTimerRunning() {
if (htim_ == nullptr) return; if (htim_ == nullptr) return;
if (activeCount_ != 1) return;
__HAL_TIM_DISABLE_DMA(htim_, TIM_DMA_UPDATE);
__HAL_TIM_CLEAR_FLAG(htim_, TIM_FLAG_UPDATE);
__HAL_TIM_SET_COUNTER(htim_, 0);
__HAL_TIM_ENABLE_DMA(htim_, TIM_DMA_UPDATE); __HAL_TIM_ENABLE_DMA(htim_, TIM_DMA_UPDATE);
HAL_TIM_Base_Start(htim_); if ((htim_->Instance->CR1 & TIM_CR1_CEN) == 0U) {
__HAL_TIM_CLEAR_FLAG(htim_, TIM_FLAG_UPDATE);
HAL_TIM_Base_Start(htim_);
}
} }
void stopTimerIfIdle() { // Fix D: аварийный стоп залипшего потока из контекста потока (не ISR). Маскируем ИМЕННО IRQ
if (htim_ == nullptr) return; // этого DMA-канала, чтобы штатный HT/CPLT/Error-обработчик не вытеснил нас посреди stopStream
if (activeCount_ != 0) return; // (иначе двойной abort/externalFinishSend). Другие потоки и прочие IRQ не трогаем.
void forceStop(TxStream& s) {
__HAL_TIM_DISABLE_DMA(htim_, TIM_DMA_UPDATE); HAL_NVIC_DisableIRQ(s.dmaIrq);
HAL_TIM_Base_Stop(htim_); if (s.active) {
stopStream(s, IR_SendStatus::DmaStalled);
recoveries_++;
}
HAL_NVIC_EnableIRQ(s.dmaIrq);
} }
static TxStream* streamFromDma(DMA_HandleTypeDef* hdma) { static TxStream* streamFromDma(DMA_HandleTypeDef* hdma) {
@ -216,27 +301,32 @@ private:
static void dmaHalfCpltCb(DMA_HandleTypeDef* hdma) { static void dmaHalfCpltCb(DMA_HandleTypeDef* hdma) {
auto* s = streamFromDma(hdma); auto* s = streamFromDma(hdma);
if (s == nullptr || !s->active) return; if (s == nullptr || !s->active) return;
s->onHalf(); s->advanceHalf();
if (s_instance != nullptr && s->ticksOutput >= s->totalTicks) { if (s_instance != nullptr && s->ticksOutput >= s->totalTicks) {
s_instance->stopStream(*s); s_instance->stopStream(*s, IR_SendStatus::Success);
} else {
s->refillFirstHalf();
} }
} }
static void dmaCpltCb(DMA_HandleTypeDef* hdma) { static void dmaCpltCb(DMA_HandleTypeDef* hdma) {
auto* s = streamFromDma(hdma); auto* s = streamFromDma(hdma);
if (s == nullptr || !s->active) return; if (s == nullptr || !s->active) return;
s->onComplete(); s->advanceHalf();
if (s_instance != nullptr && s->ticksOutput >= s->totalTicks) { if (s_instance != nullptr && s->ticksOutput >= s->totalTicks) {
s_instance->stopStream(*s); s_instance->stopStream(*s, IR_SendStatus::Success);
} else {
s->refillSecondHalf();
} }
} }
static void dmaErrorCb(DMA_HandleTypeDef* hdma) { static void dmaErrorCb(DMA_HandleTypeDef* hdma) {
auto* s = streamFromDma(hdma); auto* s = streamFromDma(hdma);
if (s == nullptr) return; if (s == nullptr || !s->active) return; // симметрично HT/CPLT: одиночный stop, без двойного finish
s->onError(); s->onError();
if (s_instance != nullptr) { if (s_instance != nullptr) {
s_instance->stopStream(*s); s_instance->errors_++; // Fix #5: наблюдаемость аварийных завершений по Transfer-Error
s_instance->stopStream(*s, IR_SendStatus::DmaTransferError);
} }
} }
@ -282,28 +372,30 @@ private:
return true; return true;
} }
bool startStream(TxStream& s, const uint8_t* packet, uint8_t len) { IR_SendStatus startStream(TxStream& s,
if (s.enc == nullptr || s.port == nullptr || s.mask == 0) return false; const uint8_t* packet,
if (s.active) return false; uint8_t len,
if (s.dmaBuf == nullptr || s.bufLen < 2 || s.halfLen == 0) return false; const IR_TxPlan& expectedPlan,
if (s.runs == nullptr || s.maxRuns == 0) return false; uint32_t operationId) {
if (s.enc == nullptr || s.port == nullptr || s.mask == 0) return IR_SendStatus::ExternalInvalidConfig;
if (s.active) return IR_SendStatus::EncoderBusy;
if (!expectedPlan.valid() || operationId == 0U) return IR_SendStatus::ExternalInvalidConfig;
if (s.dmaBuf == nullptr || s.bufLen < 2 || s.halfLen == 0) return IR_SendStatus::ExternalInvalidConfig;
if (s.runs == nullptr || s.maxRuns == 0) return IR_SendStatus::ExternalInvalidConfig;
s.resetWave(); s.resetWave();
s.runCount = IR_Encoder::buildGateRuns(packet, len, s.runs, s.maxRuns); const uint16_t mult = expectedPlan.carrierMultiply;
if (s.runCount == 0) return false; const IR_TxPlan built = IR_Encoder::buildPhysicalTransmission(
packet, len, s.runs, s.maxRuns, mult);
if (!built.valid()) return built.status;
if (built.physicalTicks != expectedPlan.physicalTicks ||
built.gateRunCount != expectedPlan.gateRunCount)
return IR_SendStatus::PlanMismatch;
s.runCount = static_cast<size_t>(built.gateRunCount);
s.totalTicks = built.physicalTicks;
s.operationId = operationId;
size_t rc = s.runCount;
if (!IR_Encoder::scaleGateRunsToPhysical(s.runs, &rc, s.maxRuns, IR_Encoder::carrierMultiply())) {
return false;
}
s.runCount = rc;
uint32_t total = 0;
for (size_t i = 0; i < s.runCount; i++) total += s.runs[i].lenTicks;
s.totalTicks = total;
const uint16_t mult = IR_Encoder::carrierMultiply();
uint16_t pwr = mult / 2U; uint16_t pwr = mult / 2U;
if (s.enc != nullptr) { if (s.enc != nullptr) {
const uint16_t want = s.enc->powerNumerator(); const uint16_t want = s.enc->powerNumerator();
@ -317,21 +409,31 @@ private:
s.port->BSRR = s.resetWord; s.port->BSRR = s.resetWord;
// Fix C/D: помечаем active и инициализируем watchdog ДО арминга DMA — иначе ранний
// HT/CPLT IRQ при active==false выйдет из callback'а и пропустит refill → недосчёт
// ticksOutput → латч. TIM уже (или станет) free-running, старт по счётчику не нужен.
s.lastTicks = 0;
s.lastProgressMs = millis();
s.active = true;
__DSB(); // Fix #8: завершить запись dmaBuf (fill) и BSRR до арминга DMA
const uint32_t dst = u32ptr(&s.port->BSRR); const uint32_t dst = u32ptr(&s.port->BSRR);
if (HAL_DMA_Start_IT(&s.hdma, (uint32_t)(uintptr_t)s.dmaBuf, dst, s.bufLen) != HAL_OK) { if (HAL_DMA_Start_IT(&s.hdma, (uint32_t)(uintptr_t)s.dmaBuf, dst, s.bufLen) != HAL_OK) {
return false; s.active = false;
s.operationId = 0U;
return IR_SendStatus::DmaStartFailed;
} }
s.active = true; ensureTimerRunning();
activeCount_++; return IR_SendStatus::Success;
startTimerIfNeeded();
return true;
} }
void stopStream(TxStream& s) { void stopStream(TxStream& s, IR_SendStatus terminalStatus) {
if (!s.active) return; if (!s.active) return;
const uint32_t operationId = s.operationId;
s.active = false; s.active = false;
s.operationId = 0U;
HAL_DMA_Abort_IT(&s.hdma); HAL_DMA_Abort_IT(&s.hdma);
if (s.port != nullptr) { if (s.port != nullptr) {
@ -339,11 +441,9 @@ private:
} }
if (s.enc != nullptr) { if (s.enc != nullptr) {
s.enc->externalFinishSend(); s.enc->externalFinishSend(operationId, terminalStatus);
} }
// Fix C: TIM НЕ останавливаем — он free-running, без разделяемого счётчика.
if (activeCount_ > 0) activeCount_--;
stopTimerIfIdle();
} }
}; };

51
IrInterruptGuard.h Normal file
View File

@ -0,0 +1,51 @@
#pragma once
#include <Arduino.h>
#if defined(__AVR__)
#include <avr/interrupt.h>
#include <avr/io.h>
#endif
/**
* Nest-safe interrupt guard for the short ISR/main shared-state sections used
* by IR-protocol. Unlike a noInterrupts()/interrupts() pair it restores the
* previous state and therefore never enables interrupts from inside an ISR.
*/
class IrInterruptGuard final
{
public:
IrInterruptGuard()
{
#if defined(__arm__) || defined(__thumb__) || defined(ARDUINO_ARCH_STM32)
state_ = __get_PRIMASK();
__disable_irq();
#elif defined(__AVR__)
state_ = SREG;
cli();
#else
noInterrupts();
#endif
}
~IrInterruptGuard()
{
#if defined(__arm__) || defined(__thumb__) || defined(ARDUINO_ARCH_STM32)
if ((state_ & 1U) == 0U)
__enable_irq();
#elif defined(__AVR__)
SREG = static_cast<uint8_t>(state_);
#else
interrupts();
#endif
}
IrInterruptGuard(const IrInterruptGuard&) = delete;
IrInterruptGuard& operator=(const IrInterruptGuard&) = delete;
private:
#if defined(__arm__) || defined(__thumb__) || defined(ARDUINO_ARCH_STM32) || \
defined(__AVR__)
uint32_t state_ = 0U;
#endif
};

View File

@ -4,8 +4,8 @@
/** /**
* Один RLE-сегмент огибающей несущей. * Один RLE-сегмент огибающей несущей.
* В buildGateRuns: lenTicks в тактах логической шкалы 2×carrierFrec (как раньше). * В legacy buildGateRuns: lenTicks в тактах логической шкалы 2×carrierFrec.
* После IR_Encoder::scaleGateRunsToPhysical — в физических тиках carrierFrec×multiply. * В современном DMA/buffered ISR пути buildPhysicalGateRuns строит lenTicks сразу в физических тиках carrierFrec×multiply.
*/ */
struct IrTxGateRun { struct IrTxGateRun {
uint16_t lenTicks; uint16_t lenTicks;

64
IrTxIsrBufferedStorage.h Normal file
View File

@ -0,0 +1,64 @@
#pragma once
#include "IR_config.h"
#include "IrTxBsrrWave.h"
class IrTxIsrBufferedStorageBase {
public:
IrTxGateRun* gateRuns = nullptr;
size_t maxGateRuns = 0;
uint32_t* bsrrWords = nullptr;
uint16_t wordCount = 0;
IrTxBsrrWave wave{};
uint16_t readIdx = 0;
uint16_t halfLen = 0;
uint32_t totalTicks = 0;
uint32_t ticksSent = 0;
bool isValid() const {
return gateRuns != nullptr &&
maxGateRuns != 0U &&
bsrrWords != nullptr &&
wordCount >= 2U &&
(wordCount & 1U) == 0U;
}
void resetRuntimeState() {
readIdx = 0;
halfLen = static_cast<uint16_t>(wordCount / 2U);
totalTicks = 0;
ticksSent = 0;
}
};
class IrTxIsrBufferedStorageView : public IrTxIsrBufferedStorageBase {
public:
IrTxIsrBufferedStorageView(IrTxGateRun* runs, size_t runCount, uint32_t* words, uint16_t wordsCount) {
gateRuns = runs;
maxGateRuns = runCount;
bsrrWords = words;
wordCount = wordsCount;
resetRuntimeState();
}
};
template<size_t MaxGateRuns = irproto::kIsrTxMaxGateRuns, uint16_t WordCount = irproto::kIsrTxBsrrWordCount>
class IrTxIsrBufferedStorage : public IrTxIsrBufferedStorageBase {
static_assert(MaxGateRuns > 0U, "IrTxIsrBufferedStorage: MaxGateRuns > 0");
static_assert(WordCount >= 2U, "IrTxIsrBufferedStorage: WordCount >= 2");
static_assert((WordCount & 1U) == 0U, "IrTxIsrBufferedStorage: WordCount must be even");
public:
IrTxIsrBufferedStorage() {
gateRuns = gateRunsStorage_;
maxGateRuns = MaxGateRuns;
bsrrWords = bsrrWordsStorage_;
wordCount = WordCount;
resetRuntimeState();
}
private:
IrTxGateRun gateRunsStorage_[MaxGateRuns]{};
uint32_t bsrrWordsStorage_[WordCount]{};
};

View File

@ -2,12 +2,49 @@
namespace PacketTypes namespace PacketTypes
{ {
bool BasePack::checkAddress() { return true; }; uint8_t minimumPacketSize(uint8_t msgType)
void BasePack::set(IR_FOX::PackInfo *packInfo, uint16_t id)
{ {
switch (msgType)
{
case IR_MSG_DATA_ACCEPT:
case IR_MSG_DATA_NOACCEPT:
case IR_MSG_BACK_TO:
case IR_MSG_REQUEST:
return uint8_t(msgBytes + addrBytes + addrBytes + crcBytes);
case IR_MSG_BACK:
return uint8_t(msgBytes + addrBytes + crcBytes);
case IR_MSG_ACCEPT:
return uint8_t(msgBytes + addrBytes + 1U + crcBytes);
default:
return 0;
}
}
bool isTypedPacketSizeValid(uint8_t msgType, uint8_t packSize)
{
const uint8_t minimum = minimumPacketSize(msgType);
return minimum != 0 && packSize >= minimum;
}
bool BasePack::checkAddress() { return true; }
bool BasePack::set(IR_FOX::PackInfo *packInfo, uint16_t id, bool requireTypedSize)
{
isAvailable = false;
isRawAvailable = false;
this->packInfo = packInfo; this->packInfo = packInfo;
this->id = id; this->id = id;
if (packInfo == nullptr || packInfo->buffer == nullptr)
{
return false;
}
const uint8_t msgType = (packInfo->buffer[msgOffset] >> 5) & IR_MASK_MSG_TYPE;
if (requireTypedSize && !isTypedPacketSizeValid(msgType, packInfo->packSize))
{
return false;
}
if (checkAddress()) if (checkAddress())
{ {
isAvailable = true; isAvailable = true;
@ -23,29 +60,65 @@ namespace PacketTypes
Serial.print(" NOT-OK "); Serial.print(" NOT-OK ");
#endif #endif
} }
return isAvailable;
} }
uint16_t BasePack::_getAddrFrom(BasePack *obj) uint16_t BasePack::_getAddrFrom(BasePack *obj)
{ {
if (obj == nullptr || obj->packInfo == nullptr || obj->packInfo->buffer == nullptr ||
obj->packInfo->packSize < crcBytes ||
uint16_t(obj->addressFromOffset) + 1U >= uint16_t(obj->packInfo->packSize - crcBytes))
{
return 0;
}
return (obj->packInfo->buffer[obj->addressFromOffset] << 8) | obj->packInfo->buffer[obj->addressFromOffset + 1]; return (obj->packInfo->buffer[obj->addressFromOffset] << 8) | obj->packInfo->buffer[obj->addressFromOffset + 1];
}; }
uint16_t BasePack::_getAddrTo(BasePack *obj) uint16_t BasePack::_getAddrTo(BasePack *obj)
{ {
if (obj == nullptr || obj->packInfo == nullptr || obj->packInfo->buffer == nullptr ||
obj->packInfo->packSize < crcBytes ||
uint16_t(obj->addressToOffset) + 1U >= uint16_t(obj->packInfo->packSize - crcBytes))
{
return 0;
}
return (obj->packInfo->buffer[obj->addressToOffset] << 8) | obj->packInfo->buffer[obj->addressToOffset + 1]; return (obj->packInfo->buffer[obj->addressToOffset] << 8) | obj->packInfo->buffer[obj->addressToOffset + 1];
}; }
uint8_t BasePack::_getDataSize(BasePack *obj) uint8_t BasePack::_getDataSize(BasePack *obj)
{ {
return obj->packInfo->packSize - crcBytes - obj->DataOffset; if (obj == nullptr || obj->packInfo == nullptr || obj->packInfo->buffer == nullptr)
}; {
return 0;
}
const uint16_t frameOverhead = uint16_t(crcBytes) + uint16_t(obj->DataOffset);
if (uint16_t(obj->packInfo->packSize) <= frameOverhead)
{
return 0;
}
return uint8_t(uint16_t(obj->packInfo->packSize) - frameOverhead);
}
uint8_t *BasePack::_getDataPrt(BasePack *obj) uint8_t *BasePack::_getDataPrt(BasePack *obj)
{ {
if (obj == nullptr || obj->packInfo == nullptr || obj->packInfo->buffer == nullptr ||
obj->packInfo->packSize < crcBytes)
{
return nullptr;
}
const uint16_t dataEnd = uint16_t(obj->packInfo->packSize) - uint16_t(crcBytes);
if (uint16_t(obj->DataOffset) > dataEnd)
{
return nullptr;
}
return obj->packInfo->buffer + obj->DataOffset; return obj->packInfo->buffer + obj->DataOffset;
}; }
uint8_t BasePack::_getDataRawSize(BasePack *obj) uint8_t BasePack::_getDataRawSize(BasePack *obj)
{ {
if (obj == nullptr || obj->packInfo == nullptr)
{
return 0;
}
return obj->packInfo->packSize; return obj->packInfo->packSize;
}; }
bool BasePack::available() bool BasePack::available()
{ {
@ -59,7 +132,7 @@ namespace PacketTypes
{ {
return false; return false;
} }
}; }
bool BasePack::availableRaw() bool BasePack::availableRaw()
{ {
if (isRawAvailable) if (isRawAvailable)
@ -71,7 +144,7 @@ namespace PacketTypes
{ {
return false; return false;
} }
}; }
bool Data::checkAddress() bool Data::checkAddress()
{ {
@ -104,4 +177,4 @@ namespace PacketTypes
IR_FOX::checkAddressRuleApply(getAddrTo(), this->id, ret); IR_FOX::checkAddressRuleApply(getAddrTo(), this->id, ret);
return ret; return ret;
} }
} }

View File

@ -4,25 +4,34 @@
class IR_Decoder; class IR_Decoder;
namespace PacketTypes namespace PacketTypes
{ {
/**
* Minimum complete frame size (header, addresses/data required by the type,
* and CRC). Unknown/reserved message types return 0.
*/
uint8_t minimumPacketSize(uint8_t msgType);
/** True only for a known typed packet whose complete frame is long enough. */
bool isTypedPacketSizeValid(uint8_t msgType, uint8_t packSize);
class BasePack class BasePack
{ {
friend IR_Decoder; friend IR_Decoder;
protected: protected:
bool isAvailable; bool isAvailable = false;
bool isRawAvailable; bool isRawAvailable = false;
bool isNeedAccept; bool isNeedAccept = false;
uint8_t msgOffset; uint8_t msgOffset = 0;
uint8_t addressFromOffset; uint8_t addressFromOffset = 0;
uint8_t addressToOffset; uint8_t addressToOffset = 0;
uint8_t DataOffset; uint8_t DataOffset = 0;
IR_FOX::PackInfo *packInfo; IR_FOX::PackInfo *packInfo = nullptr;
uint16_t id; uint16_t id = 0;
virtual bool checkAddress(); virtual bool checkAddress();
void set(IR_FOX::PackInfo *packInfo, uint16_t id); bool set(IR_FOX::PackInfo *packInfo, uint16_t id, bool requireTypedSize = true);
static uint16_t _getAddrFrom(BasePack *obj); static uint16_t _getAddrFrom(BasePack *obj);
static uint16_t _getAddrTo(BasePack *obj); static uint16_t _getAddrTo(BasePack *obj);

View File

@ -1,5 +1,5 @@
#pragma once #pragma once
#include "Arduino.h" #include "IrInterruptGuard.h"
template <typename T, unsigned int BufferSize> template <typename T, unsigned int BufferSize>
class RingBuffer { class RingBuffer {
public: public:
@ -15,28 +15,39 @@ public:
bool push(T element) { bool push(T element) {
bool pushed = false; bool pushed = false;
noInterrupts(); IrInterruptGuard guard;
if (!isFull()) { if (!isFull()) {
data[end] = element; data[end] = element;
end = (end + 1) % BufferSize; end = (end + 1) % BufferSize;
pushed = true; pushed = true;
} }
interrupts();
return pushed; return pushed;
} }
T* pop() { T* pop() {
noInterrupts(); IrInterruptGuard guard;
T* value = nullptr; T* value = nullptr;
if (!isEmpty()) { if (!isEmpty()) {
value = &data[start]; value = &data[start];
start = (start + 1) % BufferSize; start = (start + 1) % BufferSize;
} }
interrupts();
return value; return value;
} }
// B5: безопасный pop — копирует элемент под ОДНОЙ критсекцией и отдаёт по значению.
// (T* pop() отдаёт указатель во внутренний слот; после выхода слот снова может быть перезаписан.)
bool pop(T &out) {
bool popped = false;
IrInterruptGuard guard;
if (!isEmpty()) {
out = data[start];
start = (start + 1) % BufferSize;
popped = true;
}
return popped;
}
private: private:
T data[BufferSize]; T data[BufferSize];
unsigned int start, end; unsigned int start, end;
}; };

View File

@ -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 и <broadcast) через `acceptDelay` `sendAccept` по nullptr HardFault. Детерминированно, если кто-то шлёт машинке DATA_ACCEPT; плюс шумом (1/65536 на ложный кадр нужного типа).
- **HIGH OOB-запись** `dataBuffer[38]` (off-by-one) при `packSize==0` (`IR_DecoderRaw.cpp:753` `>` вместо `>=`): при `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<FrontStorage,250>` (`.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]`, отсев пар <minUs как глитч.
- **D. FSM преамбулы** (`preambleProcessEdge :1599-1723`): IdleCandidateLocked, нужно 2 согласованных периода riserise в окне 220-340% bitTime после «тишины» >`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.
**Весь декод (CG) — в `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: 10248КБ).
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`.

View File

@ -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.*`.

View File

@ -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}% необнаружено")

View File

@ -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.290.41 % | ~0.78 % | 0.550.78 % |
| **double** (2×CRC8, 16 бит) — текущая | **0.0070.015 %** | **0.0250.03 %** | **0.0080.026 %** |
| nibble-fold (4+4 бит) | 0.760.80 % | ~1.58 % | 1.181.55 % |
| nibble-trunc (4+4 бит) | 1.371.57 % | ~1.53 % | 1.221.57 % |
Вывод: **double примерно в 2040× надёжнее single** и ловит практически все сдвиги, которые single пропускает. Это подтверждает, зачем добавляли второй полином (0x8C = зеркало 0x31 ловит «зеркальные» сдвиговые ошибки, слепые для 0x31).
## 3. Идея «склеить 2 байта в 1» (полбайта на полином) — ХУЖЕ, чем есть
- **nibble-fold** (свернуть каждый CRC8 в 4 бита xor'ом) и **nibble-trunc** (взять по 4 бита) дают **0.61.6 % пропусков — это ХУЖЕ даже одиночного полного CRC8** и в ~50150× хуже текущей двойной схемы.
- Burst-ошибки (подряд искажённые биты — типичный IR-сбой: бит-слипы/всплески). Гарантия обнаружения = ширине контроля:
- single 8 бит и double — надёжны на коротких burst (≥8 бит), эмпирически в выборке не пропускали и длиннее;
- **nibble-fold** — гарантия ~4 бита (эмпирически до 7);
- **nibble-trunc** — **пропускает даже ОДИНОЧНЫЙ бит** (усечение теряет старший ниббл) → так делать нельзя.
## 4. Рекомендация
- **CRC — не то место, где стоит экономить байт.** Сжатие контроля до 1 байта повышает пропуск сдвигов/burst в ~50150× (с ~0.01 % до ~1 %) — прямо в том классе ошибок, ради которого второй полином и вводили.
- Если байт очень нужен — забирать его **не из CRC**, а из уже выявленного резерва: свободные `msgType` (3,5), «пустая» зона длины (компактные кадры), или переупаковка адресов/полей. 16-битный контроль (2×CRC8) сохранить.
- Если всё же сжимать CRC до 1 байта — только **fold (xor нибблов)**, никогда не truncate; и принять ~1 % пропуска на сдвигах (в ~50100× хуже текущего). Как отдельный компромисс — обсуждать вместе с обратной совместимостью.
(Обратную совместимость версий протокола обсудим отдельно — по запросу Даши.)

View File

@ -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} бит подряд")

View File

@ -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<len(labels) else ''
lab+=(L[:8]).center(8)+ (' s ' if i<len(frame_bytes)-1 else '')
line+=piece
return line, lab
def show_frame(title, frame_bytes, labels):
print(f"\n### {title}")
print(" байты (hex):", ' '.join(f"{b:02x}" for b in frame_bytes))
print(" поля :", ' '.join((labels[i] if i<len(labels) else '?')[:2].ljust(2) for i in range(len(frame_bytes))),
" (по 1 байту)")
line,lab=onair(frame_bytes,labels)
print(" эфир (data 8 бит + [sync 3]) и подписи:")
print(" "+line)
print(" "+lab)
def slip_delete_databits(data, p):
b=[x for by in data for x in bits(by)]
b=b[:p]+b[p+1:]+[0]
return [int(''.join(map(str,b[i:i+8])),2) for i in range(0,len(b),8)]
def show_shift(title, data, p):
print(f"\n### {title}")
shifted=slip_delete_databits(data,p)
b0=[x for by in data for x in bits(by)]
b1=[x for by in shifted for x in bits(by)]
# выравненные строки битов по байтам
def grp(bl): return ' '.join(bstr(bl[i:i+8]) for i in range(0,len(bl),8))
print(" ДО :", ' '.join(f"{x:02x}" for x in data), " crc1(poly1)=%02x"%crc8(data,POLY1),
" crc2(poly2 over data+crc1)=%02x"%crc8(data+[crc8(data,POLY1)],POLY2))
print(" ПОСЛЕ :", ' '.join(f"{x:02x}" for x in shifted), " crc1(poly1)=%02x"%crc8(shifted,POLY1),
" crc2=%02x"%crc8(shifted+[crc8(shifted,POLY1)],POLY2))
print(" биты ДО :", grp(b0))
# маркер позиции удалённого бита p
mk=[' ']*len(b0);
pos = p + p//8 # +пробелы между байтами
mk_line=''
# построим маркерную строку под "биты ДО"
s=grp(b0)
# индекс символа для бита p:
char_idx = p + (p//8) # каждый байт(8) + 1 пробел
mk_line=' '*char_idx+'^'
print(" ", mk_line, f"<- бит #{p} удалён, всё правее сдвинуто влево на 1")
print(" биты ПОСЛЕ:", grp(b1))
# различия побитно
diff=''.join('X' if (i<len(b1) and b0[i]!=b1[i]) else '.' for i in range(len(b0)))
print(" дельта :", ' '.join(diff[i:i+8] for i in range(0,len(diff),8)), " (X=бит изменился)")
same1 = crc8(data,POLY1)==crc8(shifted,POLY1)
c2o=crc8(data+[crc8(data,POLY1)],POLY2); c2s=crc8(shifted+[crc8(shifted,POLY1)],POLY2)
print(f" ВЕРДИКТ: single(poly1) {'ПРОШЁЛ (не заметил сдвиг!)' if same1 else 'поймал'};"
f" double(+poly2) {'НЕ поймал' if (same1 and c2o==c2s) else 'ПОЙМАЛ'} (crc2 {c2o:02x}{'=='if c2o==c2s else ''}{c2s:02x})")
print("="*70)
print("ЧАСТЬ 1. Реалистичный кадр на эфире (структура + синхробиты)")
print("="*70)
# BACK-кадр: msgType=0(BACK), packSize=5 → [hdr][addrFrom_hi][addrFrom_lo][crc1][crc2]
# hdr = (0<<5)|5 = 0x05 ; addrFrom = 300 = 0x012C
data_back=[0x05, 0x01, 0x2C]
c1=crc8(data_back,POLY1); c2=crc8(data_back+[c1],POLY2)
frame_back=data_back+[c1,c2]
show_frame("BACK, addrFrom=300, packSize=5", frame_back,
["HDR","AfHi","AfLo","CRC1","CRC2"])
print(" (HDR = 000 00101: msgType=0(BACK), len=5=весь пакет; sync между байтами = [¬last,last,¬next])")
print("\n"+"="*70)
print("ЧАСТЬ 2. Сдвиги, где ОДИНОЧНЫЙ CRC8 слеп (из симуляции), в битах")
print("="*70)
show_shift("Пример A: data 26 0b 05, слип бита #10", [0x26,0x0b,0x05], 10)
show_shift("Пример B: data 3b 7c 43, слип бита #14 (окно в середине)", [0x3b,0x7c,0x43], 14)
show_shift("Пример C: data cc 4f b8 f6 33, слип бита #11", [0xcc,0x4f,0xb8,0xf6,0x33], 11)

View File

@ -1,5 +1,7 @@
# Контракт бэкенда DMA-TX ИК (`IrDmaTxStm32`) # Контракт бэкенда DMA-TX ИК (`IrDmaTxStm32`)
См. также: [IR_TX_MODES.md](IR_TX_MODES.md) — общая схема выбора `legacy ISR`, `buffered ISR` и `external backend`.
Платформа: **STM32G4**, Arduino STM32. Передача: **DMA memory → GPIO BSRR**, запрос от **TIM UPDATE** (частота `carrierFrec×2` из `IR_Encoder::beginClockOnly`). Платформа: **STM32G4**, Arduino STM32. Передача: **DMA memory → GPIO BSRR**, запрос от **TIM UPDATE** (частота `carrierFrec×2` из `IR_Encoder::beginClockOnly`).
### Число потоков (шаблон) ### Число потоков (шаблон)

View File

@ -55,7 +55,7 @@ IRRX t=1234988 rsn=MUTE_END cnt=42
## Когда смотреть подробный debug ## Когда смотреть подробный debug
- `listenStart` / `checkTimeout` — в конце обработки фронта (`END:`) и во ветке «нет фронта» в `tick()`; не в начале до `pop`, иначе после таймаута `lastEdgeTime` расходится с метками ISR из очереди → ложные `TIMEOUT` (`bits=0`). - `listenStart` / `checkTimeout` — в конце обработки фронта (`END:`) и во ветке «нет фронта» в `tick()`; не в начале до `pop`, иначе после таймаута `lastEdgeTime` расходится с метками ISR из очереди → ложные `TIMEOUT` (`bits=0`).
- Пока в `subBuffer` / `filteredSubBuffer` или в hold фильтра есть необработанные фронты, таймаут по `micros() - lastEdgeTime` **не оценивается** (`rxTimeoutPipelineBusy`): иначе при хвосте очереди «тихая пауза» считается слишком длинной и снова ложный `TIMEOUT`. - Пока в `subBuffer` или в hold фильтра есть необработанные фронты, таймаут по `micros() - lastEdgeTime` **не оценивается** (`rxTimeoutPipelineBusy`): иначе при хвосте очереди «тихая пауза» считается слишком длинной и снова ложный `TIMEOUT`.
- Если нужен полный поток битов и sync: включать `IRDEBUG_SERIAL_PACK` - Если нужен полный поток битов и sync: включать `IRDEBUG_SERIAL_PACK`
- Если нужно понять, какие именно фронты пришли в ISR: включать `IR_EDGE_TRACE` - Если нужно понять, какие именно фронты пришли в ISR: включать `IR_EDGE_TRACE`
- `IR_RX_BRIEF_LOG` нужен как короткий always-on-ish индикатор сути проблемы, без длинного дампа - `IR_RX_BRIEF_LOG` нужен как короткий always-on-ish индикатор сути проблемы, без длинного дампа

146
ref/IR_TX_MODES.md Normal file
View File

@ -0,0 +1,146 @@
# Режимы IR TX в библиотеке
Этот документ описывает, как в библиотеке выбирается путь передачи IR и как его правильно использовать в проектах Arduino STM32.
## Кратко
У библиотеки есть три варианта TX:
- `legacy ISR` — внутренний ISR-путь без внешнего backend. Это путь по умолчанию для обратной совместимости.
- `buffered ISR` — внутренний ISR-путь с предварительной подготовкой BSRR-слов и кольцевым буфером.
- `external backend` — передача делегируется проекту через `IR_Encoder::setExternalTxBackend(...)`, например в DMA backend.
Порядок выбора такой:
1. Если зарегистрирован `external backend`, используется он.
2. Иначе используется внутренний ISR библиотеки.
3. Для внутреннего ISR:
- по умолчанию включён `legacy ISR`
- `buffered ISR` включается явно: нужно привязать storage к encoder и переключить режим
## 1. Legacy ISR
Это режим по умолчанию. Старые проекты могут ничего не менять:
```cpp
static HardwareTimer timer(TIM11);
static IR_Encoder enc(PA9, 42, &dec);
void setup() {
IR_Encoder::begin(&timer, 1, TIM11_IRQn, 0);
enc.enable();
}
```
Если проект не регистрирует внешний backend и не переключает режим явно, библиотека работает в `legacy ISR`.
Для явного выбора можно написать:
```cpp
IR_Encoder::setTxIsrLegacyMode(true);
IR_Encoder::begin(&timer, 1, TIM11_IRQn, 0);
```
## 2. Buffered ISR
Этот режим использует внутренний буферный ISR-путь библиотеки. Он включается только явно:
```cpp
#include <IrTxIsrBufferedStorage.h>
static IrTxIsrBufferedStorage<> txStorage;
enc.enableBufferedIsr(txStorage);
IR_Encoder::begin(&timer, 1, TIM11_IRQn, 0);
```
Нижнеуровневый вариант API — отдельно привязать storage через `attachBufferedIsrStorage(...)`, но в обычном проекте удобнее использовать `enableBufferedIsr(...)`.
Смысл режима:
- пакет сначала превращается в `gate runs`
- затем в поток слов `GPIO->BSRR`
- ISR выдаёт готовые слова из кольцевого буфера
### Важно про RAM
В текущей реализации память под буферный ISR вынесена из `IR_Encoder` в отдельный storage-объект.
То есть:
- `legacy ISR` не тянет buffered-буферы в RAM самого `IR_Encoder`
- память под `gate runs` и `BSRR words` появляется только там, где проект сам создал `IrTxIsrBufferedStorage<>`
Это важно для STM32 с небольшим объёмом RAM, например для `STM32F401`.
## 3. External backend
Если проект хочет полностью взять TX на себя, библиотека позволяет зарегистрировать внешний backend:
```cpp
static bool txBusy(void* ctx);
static bool txStart(void* ctx, IR_Encoder* enc, const uint8_t* packet, uint8_t len);
void setup() {
IR_Encoder::beginClockOnly(&timer);
IR_Encoder::setExternalTxBackend(txStart, txBusy, nullptr);
enc.enable();
}
```
После вызова `setExternalTxBackend(...)` библиотека больше не использует свои внутренние ISR-пути для фактической передачи.
В этом режиме:
- `setTxIsrLegacyMode(true/false)` игнорируется
- завершение передачи должен сигнализировать сам backend через `enc->externalFinishSend()`
Подробности по встроенному DMA backend для `STM32G4xx`: см. [IR_DMA_TX_backend.md](IR_DMA_TX_backend.md).
## Когда какой режим использовать
### Старый проект, который ничего не настраивает
Использовать как есть:
```cpp
IR_Encoder::begin(...);
```
Итог: `legacy ISR`
### Нужен новый внутренний буферный ISR
Включить явно:
```cpp
#include <IrTxIsrBufferedStorage.h>
static IrTxIsrBufferedStorage<> txStorage;
enc.enableBufferedIsr(txStorage);
IR_Encoder::begin(...);
```
Итог: `buffered ISR`
### Нужен проектный DMA или другой свой транспорт
Подключить внешний backend:
```cpp
IR_Encoder::beginClockOnly(...);
IR_Encoder::setExternalTxBackend(...);
```
Итог: `external backend`
## Рекомендация для совместимости
Для старых проектов безопаснее не вызывать `setTxIsrLegacyMode(false)`, если нет явной причины переходить на buffered ISR.
Если задача — сохранить старое поведение без неожиданного роста нагрузки на TX-логику, оставляйте default `legacy ISR` или задавайте его явно:
```cpp
IR_Encoder::setTxIsrLegacyMode(true);
```

View File

@ -20,7 +20,7 @@ static constexpr uint16_t kIrDeviceAddr = 0;
static constexpr uint8_t kCmdVersion = 0x5E; static constexpr uint8_t kCmdVersion = 0x5E;
static constexpr uint32_t kSerialBaud = 115200; static constexpr uint32_t kSerialBaud = 115200;
static constexpr uint32_t kSendPeriodMs = 500; static constexpr uint32_t kSendPeriodMs = 500;
static constexpr uint8_t kMaxPayload = bytePerPack; static constexpr uint8_t kMaxPayload = irproto::kMaxDataPayloadBytes;
static constexpr uint8_t kMaxParamBytes = kMaxPayload - 1; static constexpr uint8_t kMaxParamBytes = kMaxPayload - 1;
static IR_Encoder enc(PIN_IR_ENC_FORWARD, kIrDeviceAddr, nullptr); static IR_Encoder enc(PIN_IR_ENC_FORWARD, kIrDeviceAddr, nullptr);
@ -30,15 +30,23 @@ static HardwareTimer irTimer(TIM17);
namespace { namespace {
constexpr size_t kIrDmaStreams = 1; constexpr size_t kIrDmaStreams = 1;
constexpr uint16_t kIrDmaTxWordCount = 4096U; constexpr uint16_t kIrDmaTxWordCount = 4096U;
constexpr size_t kIrDmaTxMaxGateRuns = 1024U; // This example accepts the full uint8_t carrier-multiply configuration range.
constexpr uint16_t kIrDmaMaxCarrierMultiply = UINT8_MAX;
constexpr size_t kIrDmaTxMaxGateRuns =
irproto::maxPhysicalGateRunCapacity(kIrDmaMaxCarrierMultiply);
static uint32_t s_irDmaWords[kIrDmaTxWordCount]; static uint32_t s_irDmaWords[kIrDmaTxWordCount];
static IR_Encoder::IR_TxGateRun s_irGateRuns[kIrDmaTxMaxGateRuns]; static IR_Encoder::IR_TxGateRun s_irGateRuns[kIrDmaTxMaxGateRuns];
} // namespace } // namespace
static IrDmaTxStm32<kIrDmaStreams> dmaBackend; static IrDmaTxStm32<kIrDmaStreams> dmaBackend;
static bool txBusy(void * /*ctx*/) { return dmaBackend.busy(); } static bool txBusy(void * /*ctx*/) { return dmaBackend.busy(); }
static bool txStart(void * /*ctx*/, IR_Encoder *e, const uint8_t *packet, uint8_t len) { static IR_SendStatus txStart(void * /*ctx*/,
return dmaBackend.start(e, packet, len); IR_Encoder *e,
const uint8_t *packet,
uint8_t len,
const IR_TxPlan& plan,
uint32_t operationId) {
return dmaBackend.startTracked(e, packet, len, plan, operationId);
} }
#endif #endif
@ -51,7 +59,11 @@ static bool s_sendLongerFrame = false;
// 24 байта total: msg(1)+addr(2)+addr(2)+data(17)+crc(2), где data=0x5E + 16 ASCII. // 24 байта total: msg(1)+addr(2)+addr(2)+data(17)+crc(2), где data=0x5E + 16 ASCII.
static const char kPayload16[] = "Car_v4.3.9_[12MH"; static const char kPayload16[] = "Car_v4.3.9_[12MH";
// 25 байт total: как выше, но data=0x5E + 17 ASCII. // 25 байт total: как выше, но data=0x5E + 17 ASCII.
static const char kPayload17[] = "Car_v4.3.9_[12MHz]_G491"; static const char kPayload17[] = "Car_v4.3.9_[12MHz";
static_assert(sizeof(kPayload16) - 1U == 16U, "24-byte frame fixture changed");
static_assert(sizeof(kPayload17) - 1U == 17U, "25-byte frame fixture changed");
static_assert(kMaxParamBytes == irproto::kMaxDataPayloadBytes - 1U,
"longData command parameter capacity must follow the DATA wire contract");
static void rebuildIrPayload() { static void rebuildIrPayload() {
s_irPayload[0] = kCmdVersion; s_irPayload[0] = kCmdVersion;
@ -110,7 +122,7 @@ void setup() {
Serial.println(F("[IR_DMA] init FAILED")); Serial.println(F("[IR_DMA] init FAILED"));
return; return;
} }
IR_Encoder::setExternalTxBackend(txStart, txBusy, nullptr); IR_Encoder::setExternalTxBackendV2(txStart, txBusy, nullptr);
#elif LONGDATA_LEGACY_ISR #elif LONGDATA_LEGACY_ISR
IR_Encoder::begin(&irTimer, 1, TIM17_IRQn, 0); IR_Encoder::begin(&irTimer, 1, TIM17_IRQn, 0);
#else #else
@ -132,6 +144,7 @@ void setup() {
void loop() { void loop() {
#if LONGDATA_USE_DMA #if LONGDATA_USE_DMA
IR_Encoder::tick(); IR_Encoder::tick();
dmaBackend.poll(); // Fix D: watchdog завершения DMA-передачи (как в Car/src/IR/IR.cpp)
#endif #endif
const uint32_t now = millis(); const uint32_t now = millis();

View File

@ -0,0 +1,134 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
struct GPIO_TypeDef
{
uint32_t BSRR = 0;
uint32_t IDR = 0;
};
class __FlashStringHelper;
#define F(value) (reinterpret_cast<const __FlashStringHelper *>(value))
class Print
{
public:
size_t print(const __FlashStringHelper *value)
{
return append(reinterpret_cast<const char *>(value));
}
size_t print(const char *value) { return append(value); }
size_t print(char value)
{
buffer_.push_back(value);
return 1U;
}
template <typename T>
size_t print(T value)
{
return append(std::to_string(value).c_str());
}
size_t println()
{
buffer_.push_back('\n');
return 1U;
}
size_t write(uint8_t value)
{
buffer_.push_back(static_cast<char>(value));
return 1U;
}
const std::string &str() const { return buffer_; }
void clear() { buffer_.clear(); }
private:
size_t append(const char *value)
{
if (value == nullptr)
return 0U;
const size_t oldSize = buffer_.size();
buffer_ += value;
return buffer_.size() - oldSize;
}
std::string buffer_;
};
using IRQn_Type = int;
enum TimerFormat_t : uint8_t { TICK_FORMAT = 0, MICROSEC_FORMAT, HERTZ_FORMAT };
constexpr uint8_t LOW = 0;
constexpr uint8_t HIGH = 1;
constexpr uint8_t INPUT = 0;
constexpr uint8_t OUTPUT = 1;
class HardwareTimer
{
public:
void pause() {}
void resume() {}
void setOverflow(uint32_t value, TimerFormat_t format = TICK_FORMAT)
{
if (format == HERTZ_FORMAT && value != 0U)
{
prescale_ = 1U;
overflow_ = timerClockHz_ / value;
if (overflow_ == 0U) overflow_ = 1U;
}
else
{
overflow_ = value == 0U ? 1U : value;
}
}
uint32_t getOverflow(TimerFormat_t = TICK_FORMAT) { return overflow_; }
uint32_t getPrescaleFactor() { return prescale_; }
uint32_t getTimerClkFreq() { return timerClockHz_; }
void attachInterrupt(uint8_t, void (*)()) {}
uint32_t timerClockHz_ = 12000000U;
uint32_t prescale_ = 1U;
uint32_t overflow_ = 1U;
};
inline GPIO_TypeDef *digitalPinToPort(uint8_t)
{
return nullptr;
}
inline uint16_t digitalPinToBitMask(uint8_t)
{
return 0;
}
inline void pinMode(uint8_t, uint8_t) {}
inline void digitalWrite(uint8_t, uint8_t) {}
inline void NVIC_SetPriority(IRQn_Type, uint8_t) {}
inline void noInterrupts() {}
inline void interrupts() {}
struct ArduinoSerialStub
{
template <typename T> void print(const T&) {}
template <typename T> void println(const T&) {}
void println() {}
};
inline ArduinoSerialStub Serial;
inline uint32_t arduino_stub_micros = 0U;
inline unsigned long millis()
{
return arduino_stub_micros / 1000U;
}
inline unsigned long micros() { return arduino_stub_micros; }

148
tests/test_packet_types.cpp Normal file
View File

@ -0,0 +1,148 @@
#include "PacketTypes.h"
#include <cassert>
#include <cstdint>
#include <iostream>
namespace
{
template <typename Packet>
class ExposedPacket : public Packet
{
public:
bool attach(IR_FOX::PackInfo *info, uint16_t id = 0, bool requireTypedSize = true)
{
return this->set(info, id, requireTypedSize);
}
};
IR_FOX::PackInfo frame(uint8_t *buffer, uint8_t msgType, uint8_t size)
{
buffer[0] = uint8_t((msgType << 5) | (size & IR_MASK_MSG_INFO));
IR_FOX::PackInfo info;
info.buffer = buffer;
info.packSize = size;
return info;
}
template <typename Packet>
void checkTypedBoundary(uint8_t msgType, uint8_t minimum)
{
uint8_t buffer[irproto::kMaxWireFrameBytes] = {};
ExposedPacket<Packet> packet;
IR_FOX::PackInfo shortInfo = frame(buffer, msgType, uint8_t(minimum - 1U));
assert(!packet.attach(&shortInfo));
assert(!packet.available());
assert(!packet.availableRaw());
IR_FOX::PackInfo minimumInfo = frame(buffer, msgType, minimum);
assert(packet.attach(&minimumInfo));
assert(packet.available());
}
void testMinimumSizes()
{
struct Case
{
uint8_t msgType;
uint8_t minimum;
};
const Case cases[] = {
{IR_MSG_DATA_ACCEPT, 7},
{IR_MSG_DATA_NOACCEPT, 7},
{IR_MSG_BACK, 5},
{IR_MSG_BACK_TO, 7},
{IR_MSG_REQUEST, 7},
{IR_MSG_ACCEPT, 6},
};
for (const Case &item : cases)
{
assert(PacketTypes::minimumPacketSize(item.msgType) == item.minimum);
assert(!PacketTypes::isTypedPacketSizeValid(item.msgType, uint8_t(item.minimum - 1U)));
assert(PacketTypes::isTypedPacketSizeValid(item.msgType, item.minimum));
assert(PacketTypes::isTypedPacketSizeValid(item.msgType, uint8_t(item.minimum + 1U)));
}
assert(PacketTypes::minimumPacketSize(3) == 0);
assert(PacketTypes::minimumPacketSize(5) == 0);
assert(!PacketTypes::isTypedPacketSizeValid(3, 31));
assert(!PacketTypes::isTypedPacketSizeValid(5, 31));
checkTypedBoundary<PacketTypes::Data>(IR_MSG_DATA_ACCEPT, 7);
checkTypedBoundary<PacketTypes::Data>(IR_MSG_DATA_NOACCEPT, 7);
checkTypedBoundary<PacketTypes::DataBack>(IR_MSG_BACK, 5);
checkTypedBoundary<PacketTypes::DataBack>(IR_MSG_BACK_TO, 7);
checkTypedBoundary<PacketTypes::Request>(IR_MSG_REQUEST, 7);
checkTypedBoundary<PacketTypes::Accept>(IR_MSG_ACCEPT, 6);
}
void testPayloadAccessSaturates()
{
uint8_t buffer[irproto::kMaxWireFrameBytes] = {};
ExposedPacket<PacketTypes::Data> packet;
for (uint8_t size = 0; size < 7; ++size)
{
IR_FOX::PackInfo tooShort = frame(buffer, IR_MSG_DATA_ACCEPT, size);
assert(!packet.attach(&tooShort));
assert(packet.getDataSize() == 0);
assert(packet.getDataPrt() == nullptr);
}
IR_FOX::PackInfo emptyPayload = frame(buffer, IR_MSG_DATA_ACCEPT, 7);
assert(packet.attach(&emptyPayload));
assert(packet.getDataSize() == 0);
assert(packet.getDataPrt() == buffer + 5);
IR_FOX::PackInfo oneBytePayload = frame(buffer, IR_MSG_DATA_ACCEPT, 8);
assert(packet.attach(&oneBytePayload));
assert(packet.getDataSize() == 1);
assert(packet.getDataPrt() == buffer + 5);
IR_FOX::PackInfo nullBuffer;
nullBuffer.packSize = 31;
assert(!packet.attach(&nullBuffer));
assert(packet.getDataSize() == 0);
assert(packet.getDataPrt() == nullptr);
}
void testBackPayloadOffsets()
{
uint8_t buffer[irproto::kMaxWireFrameBytes] = {};
ExposedPacket<PacketTypes::DataBack> packet;
IR_FOX::PackInfo addressed = frame(buffer, IR_MSG_BACK_TO, 7);
assert(packet.attach(&addressed));
assert(packet.getDataSize() == 0);
assert(packet.getDataPrt() == buffer + 5);
IR_FOX::PackInfo broadcast = frame(buffer, IR_MSG_BACK, 5);
assert(packet.attach(&broadcast));
assert(packet.getDataSize() == 0);
assert(packet.getDataPrt() == buffer + 3);
}
void testRawContractIsIndependent()
{
uint8_t buffer[irproto::kMaxWireFrameBytes] = {};
ExposedPacket<PacketTypes::BasePack> raw;
IR_FOX::PackInfo info = frame(buffer, IR_MSG_DATA_ACCEPT, 3);
assert(raw.attach(&info, 0, false));
assert(raw.availableRaw());
assert(raw.getDataRawSize() == 3);
}
} // namespace
int main()
{
testMinimumSizes();
testPayloadAccessSaturates();
testBackPayloadOffsets();
testRawContractIsIndependent();
std::cout << "packet type boundary tests: OK\n";
return 0;
}

View File

@ -0,0 +1,69 @@
#include "IR_DecoderRaw.h"
#include <cassert>
#include <cstdint>
#include <iostream>
namespace {
// Reproduces the removed 2025 expression exactly, but with integer arithmetic:
// 2.7735 == 27735 / 10000. It is a provenance golden, not a new PHY rule.
constexpr uint32_t removedLegacyResponseExpressionMs()
{
const uint64_t scaledUs =
static_cast<uint64_t>(irproto::kNominalRxInterEdgeTimeoutUs) * 27735U / 10000U;
return static_cast<uint16_t>(scaledUs) / 1000U;
}
static_assert(irproto::kWireFrameLengthBits == 5U, "wire length field changed");
static_assert(irproto::kWireFrameLengthMask == 31U, "wire length mask changed");
static_assert(irproto::kMaxWireFrameBytes == 31U, "wire frame limit changed");
static_assert(irproto::kDataFrameOverheadBytes == 7U, "DATA overhead changed");
static_assert(irproto::kBackFrameOverheadBytes == 5U, "BACK overhead changed");
static_assert(irproto::kBackToFrameOverheadBytes == 7U, "BACK_TO overhead changed");
static_assert(irproto::kMaxDataPayloadBytes == 24U, "DATA payload limit changed");
static_assert(irproto::kMaxBackPayloadBytes == 26U, "BACK payload limit changed");
static_assert(irproto::kMaxBackToPayloadBytes == 24U, "BACK_TO payload limit changed");
static_assert(irproto::kMaxLogicalGateRuns == 688U, "logical max-frame run bound changed");
static_assert(irproto::maxPhysicalGateRunCapacity(UINT8_MAX) == 738U,
"uint8 carrier-multiply storage bound changed");
static_assert(IR_MASK_MSG_TYPE == irproto::kMessageTypeMask, "legacy type mask diverged");
static_assert(IR_MASK_MSG_INFO == irproto::kWireFrameLengthMask, "legacy length mask diverged");
static_assert(bytePerPack == irproto::kMaxWireFrameBytes,
"legacy bytePerPack value must remain source-compatible");
static_assert(dataByteSizeMax == irproto::kMaxWireFrameBytes,
"legacy storage alias must follow the wire limit");
static_assert(irproto::kRxInterEdgeTimeoutBitWindows == 12U,
"8 data + 3 sync + 1 guard geometry changed");
static_assert(irproto::kNominalRxInterEdgeTimeoutUs == 15144U,
"nominal inter-edge timeout changed");
static_assert(irproto::kNominalRxSilenceTimeoutUs == 30288U,
"nominal RX silence timeout changed");
static_assert(irproto::microsToMillisCeil(irproto::kNominalRxSilenceTimeoutUs) == 31U,
"RX silence ceil-ms conversion changed");
static_assert(IR_ResponseDelay == 42U, "deployed response turn-around changed");
static_assert(removedLegacyResponseExpressionMs() == IR_ResponseDelay,
"named empirical response delay no longer matches its legacy provenance");
void testAdaptiveTimingGeometry()
{
assert(irproto::rxInterEdgeTimeoutUs(700U) == 12000U);
assert(irproto::rxSilenceTimeoutUs(700U) == 24000U);
assert(irproto::rxInterEdgeTimeoutUs(1000U) == 15600U);
assert(irproto::rxSilenceTimeoutUs(1000U) == 31200U);
assert(irproto::microsToMillisCeil(0U) == 0U);
assert(irproto::microsToMillisCeil(1U) == 1U);
assert(irproto::microsToMillisCeil(1000U) == 1U);
assert(irproto::microsToMillisCeil(1001U) == 2U);
}
} // namespace
int main()
{
testAdaptiveTimingGeometry();
std::cout << "IR protocol geometry contract tests: OK\n";
return 0;
}

View File

@ -0,0 +1,79 @@
#include "IR_config.h"
#include "RingBuffer.h"
#include <cassert>
#include <cstdint>
#include <iostream>
#include <type_traits>
// Test-only visibility: exercise the private reason enum and logging bound without
// widening the production API. Dependencies are included first so this macro
// cannot rewrite access specifiers in the standard library.
#define private public
#include "IR_DecoderRaw.h"
#undef private
namespace {
constexpr const char kZeroStats[] =
"RXSTAT,MUTEB=0,MUTEE=0,QRAW=0,QFLT=0,HOLD=0,GLITCH=0,TIME=0,"
"PREAMB=0,SYNC=0,BUF=0,TIMEOUT=0,CRC=0,OK=0\n";
constexpr const char kOneEachStats[] =
"RXSTAT,MUTEB=1,MUTEE=1,QRAW=1,QFLT=1,HOLD=1,GLITCH=1,TIME=1,"
"PREAMB=1,SYNC=1,BUF=1,TIMEOUT=1,CRC=1,OK=1\n";
static_assert(IR_DecoderRaw::rxReasonCounterCount() > 0U,
"RX reason counter storage must not be empty");
static_assert(IR_DecoderRaw::rxReasonCounterCount() ==
static_cast<uint8_t>(IR_DecoderRaw::RxBriefReason::Count),
"public RX reason count must follow the enum sentinel");
static_assert(static_cast<uint8_t>(IR_DecoderRaw::RxBriefReason::Count) ==
static_cast<uint8_t>(IR_DecoderRaw::RxBriefReason::Ok) + 1U,
"RX reason Count must remain one past the final reason");
static_assert(std::extent<decltype(IR_DecoderRaw::rxReasonCnt)>::value ==
IR_DecoderRaw::rxReasonCounterCount(),
"RX reason counter array must follow the enum-derived count");
void testStatsWireFormatAndClearCoverage()
{
IR_DecoderRaw decoder(0U, 0U);
Print out;
decoder.printRxReasonStats(out);
assert(out.str() == kZeroStats);
const uint8_t first = static_cast<uint8_t>(IR_DecoderRaw::RxBriefReason::MuteBegin);
const uint8_t count = IR_DecoderRaw::rxReasonCounterCount();
for (uint8_t i = first; i < count; ++i)
decoder.rxBriefLog(static_cast<IR_DecoderRaw::RxBriefReason>(i));
// The sentinel is a bound, not a loggable reason.
decoder.rxBriefLog(IR_DecoderRaw::RxBriefReason::Count);
const uint16_t *const counters = decoder.rxReasonCounters();
assert(counters[0] == 0U);
for (uint8_t i = first; i < count; ++i)
assert(counters[i] == 1U);
out.clear();
decoder.printRxReasonStats(out);
assert(out.str() == kOneEachStats);
decoder.rxReasonCountersClear();
for (uint8_t i = 0U; i < count; ++i)
assert(counters[i] == 0U);
out.clear();
decoder.printRxReasonStats(out);
assert(out.str() == kZeroStats);
}
} // namespace
int main()
{
testStatsWireFormatAndClearCoverage();
std::cout << "RX reason counter/tag contract tests: OK\n";
return 0;
}

328
tests/test_tx_contract.cpp Normal file
View File

@ -0,0 +1,328 @@
#include "IR_Encoder.h"
#include "IR_DecoderRaw.h"
#include <array>
#include <cassert>
#include <cstdint>
#include <iostream>
// Link-only seams for planner/lifecycle host tests. The real implementations
// are irrelevant here; no decoder or legacy sendByte helper is exercised.
bool IR_DecoderRaw::registerPairMuteEncoder(IR_Encoder *) { return true; }
void IR_DecoderRaw::refreshPairMuteState() {}
void IR_Encoder::send_HIGH(bool) {}
void IR_Encoder::send_LOW() {}
void IR_Encoder::send_EMPTY(uint8_t) {}
namespace
{
constexpr size_t kRunCapacity = 2048U;
constexpr size_t kFullMultiplyRunCapacity =
irproto::maxPhysicalGateRunCapacity(UINT16_MAX);
static_assert(irproto::kMaxLogicalTransmissionTicks == 25822U,
"golden maximum PHY duration changed");
static_assert(irproto::maxPhysicalTransmissionTicks(2U) == 25822U,
"nominal physical tick conversion changed");
static_assert(irproto::maxPhysicalGateRunCapacity(UINT8_MAX) <= 1024U,
"uint8_t carrier-multiply domain no longer fits the legacy Car allocation");
uint32_t sumTicks(const IrTxGateRun *runs, uint32_t count)
{
uint32_t total = 0U;
for (uint32_t i = 0; i < count; ++i)
total += runs[i].lenTicks;
return total;
}
void fillPattern(uint8_t *frame, uint8_t len, uint8_t pattern)
{
for (uint8_t i = 0; i < len; ++i)
{
switch (pattern)
{
case 0: frame[i] = 0x00U; break;
case 1: frame[i] = 0xFFU; break;
case 2: frame[i] = (i & 1U) ? 0x55U : 0xAAU; break;
default: frame[i] = static_cast<uint8_t>(i * 73U + 19U); break;
}
}
}
void testPlannerMatchesBuiltStream()
{
std::array<uint8_t, irproto::kMaxWireFrameBytes> frame{};
std::array<IrTxGateRun, kRunCapacity> runs{};
const uint16_t multiplies[] = {2U, 3U, 6U};
for (uint16_t multiply : multiplies)
{
for (uint8_t len = 1U; len <= irproto::kMaxWireFrameBytes; ++len)
{
uint32_t durationForLength = 0U;
uint32_t ticksForLength = 0U;
for (uint8_t pattern = 0U; pattern < 4U; ++pattern)
{
fillPattern(frame.data(), len, pattern);
const IR_TxPlan planned =
IR_Encoder::planPhysicalTransmission(frame.data(), len, multiply);
const IR_TxPlan built = IR_Encoder::buildPhysicalTransmission(
frame.data(), len, runs.data(), runs.size(), multiply);
assert(planned.valid());
assert(built.valid());
assert(planned.physicalTicks == built.physicalTicks);
assert(planned.gateRunCount == built.gateRunCount);
assert(planned.airtimeUs == built.airtimeUs);
assert(sumTicks(runs.data(), built.gateRunCount) == built.physicalTicks);
if (pattern == 0U)
{
durationForLength = planned.airtimeUs;
ticksForLength = planned.physicalTicks;
}
else
{
assert(planned.airtimeUs == durationForLength);
assert(planned.physicalTicks == ticksForLength);
}
}
}
}
}
void testGoldenNominalTimings()
{
std::array<uint8_t, irproto::kMaxWireFrameBytes> frame{};
struct Golden { uint8_t bytes; uint32_t logicalTicks; uint32_t usCeil; };
const Golden golden[] = {
{6U, 5472U, 72000U},
{10U, 8728U, 114843U},
{31U, 25822U, 339764U},
};
for (const Golden& item : golden)
{
const IR_TxPlan plan =
IR_Encoder::planPhysicalTransmission(frame.data(), item.bytes, 2U);
assert(plan.valid());
assert(plan.physicalTicks == item.logicalTicks);
assert(plan.airtimeUs == item.usCeil);
assert(plan.airtimeMsCeil() == (item.usCeil + 999U) / 1000U);
}
}
void testCapacityAndClockContracts()
{
std::array<uint8_t, irproto::kMaxWireFrameBytes> frame{};
std::array<IrTxGateRun, kRunCapacity> logicalRuns{};
fillPattern(frame.data(), frame.size(), 2U);
assert(IR_Encoder::buildGateRuns(
frame.data(), static_cast<uint8_t>(frame.size()),
logicalRuns.data(), logicalRuns.size()) != 0U);
std::array<uint8_t, irproto::kMaxWireFrameBytes + 1U> oversizedFrame{};
assert(IR_Encoder::buildGateRuns(
oversizedFrame.data(), static_cast<uint8_t>(oversizedFrame.size()),
logicalRuns.data(), logicalRuns.size()) == 0U);
const IR_TxPlan planned = IR_Encoder::planPhysicalTransmission(
frame.data(), static_cast<uint8_t>(frame.size()), 6U);
assert(planned.valid());
assert(planned.gateRunCount <= irproto::kIsrTxMaxGateRuns);
IrTxGateRun oneRun{};
const IR_TxPlan tooSmall = IR_Encoder::buildPhysicalTransmission(
frame.data(), static_cast<uint8_t>(frame.size()), &oneRun, 1U, 6U);
assert(!tooSmall.valid());
assert(tooSmall.status == IR_SendStatus::BuildGateRunsFailed);
assert(tooSmall.gateRunCount == planned.gateRunCount);
assert(tooSmall.physicalTicks == planned.physicalTicks);
std::array<uint8_t, 10U> tenBytes{};
IR_TxPlan configured = IR_Encoder::planPhysicalTransmission(
tenBytes.data(), static_cast<uint8_t>(tenBytes.size()), 6U);
assert(configured.airtimeUs == 114843U);
assert(IR_Encoder::applyTickClock(configured, 12000000U, 52U));
assert(configured.clockBasis == IR_TxClockBasis::ConfiguredTimer);
assert(configured.airtimeUs == 113464U);
assert(!IR_Encoder::planPhysicalTransmission(nullptr, 1U, 2U).valid());
assert(!IR_Encoder::planPhysicalTransmission(frame.data(), 0U, 2U).valid());
assert(!IR_Encoder::planPhysicalTransmission(
frame.data(), static_cast<uint8_t>(irproto::kMaxWireFrameBytes + 1U), 2U).valid());
}
void testDerivedFixedStorageCapacity()
{
std::array<uint8_t, irproto::kMaxWireFrameBytes> frame{};
std::array<IrTxGateRun, kFullMultiplyRunCapacity> runs{};
const uint16_t multiplies[] = {2U, 3U, 6U, UINT8_MAX, UINT16_MAX};
for (uint16_t multiply : multiplies)
{
const size_t capacity = irproto::maxPhysicalGateRunCapacity(multiply);
for (uint8_t pattern = 0U; pattern < 4U; ++pattern)
{
fillPattern(frame.data(), static_cast<uint8_t>(frame.size()), pattern);
const IR_TxPlan planned = IR_Encoder::planPhysicalTransmission(
frame.data(), static_cast<uint8_t>(frame.size()), multiply);
const IR_TxPlan built = IR_Encoder::buildPhysicalTransmission(
frame.data(), static_cast<uint8_t>(frame.size()),
runs.data(), capacity, multiply);
assert(planned.valid());
assert(built.valid());
assert(built.gateRunCount <= capacity);
assert(built.gateRunCount == planned.gateRunCount);
assert(built.physicalTicks == planned.physicalTicks);
assert(sumTicks(runs.data(), built.gateRunCount) == built.physicalTicks);
}
}
}
void testInPlacePhysicalScaling()
{
std::array<uint8_t, irproto::kMaxWireFrameBytes> frame{};
std::array<IrTxGateRun, kRunCapacity> logical{};
std::array<IrTxGateRun, kRunCapacity> expected{};
fillPattern(frame.data(), static_cast<uint8_t>(frame.size()), 3U);
const size_t logicalCount = IR_Encoder::buildGateRuns(
frame.data(), static_cast<uint8_t>(frame.size()),
logical.data(), logical.size());
assert(logicalCount != 0U);
for (uint16_t multiply : {2U, 3U, 6U})
{
auto scaled = logical;
size_t scaledCount = logicalCount;
const IR_TxPlan built = IR_Encoder::buildPhysicalTransmission(
frame.data(), static_cast<uint8_t>(frame.size()),
expected.data(), expected.size(), multiply);
assert(built.valid());
assert(IR_Encoder::scaleGateRunsToPhysical(
scaled.data(), &scaledCount, scaled.size(), multiply));
assert(scaledCount == built.gateRunCount);
for (size_t i = 0; i < scaledCount; ++i)
{
assert(scaled[i].gate == expected[i].gate);
assert(scaled[i].lenTicks == expected[i].lenTicks);
}
}
// Expansion beyond uint16_t is also in-place and preserves chunk order.
std::array<IrTxGateRun, 8U> longRun{};
longRun[0] = {65535U, true};
size_t longCount = 1U;
assert(IR_Encoder::scaleGateRunsToPhysical(
longRun.data(), &longCount, longRun.size(), 7U));
assert(longCount == 4U);
assert(longRun[0].lenTicks == 65535U);
assert(longRun[1].lenTicks == 65535U);
assert(longRun[2].lenTicks == 65535U);
assert(longRun[3].lenTicks == 32768U);
assert(sumTicks(longRun.data(), static_cast<uint32_t>(longCount)) == 229373U);
std::array<IrTxGateRun, 2U> tooSmall{{{65535U, true}, {1U, false}}};
size_t tooSmallCount = 1U;
assert(!IR_Encoder::scaleGateRunsToPhysical(
tooSmall.data(), &tooSmallCount, tooSmall.size(), 6U));
assert(tooSmallCount == 1U);
}
struct FakeBackend
{
IR_SendStatus startStatus = IR_SendStatus::Success;
bool finishSynchronously = false;
IR_Encoder *encoder = nullptr;
uint32_t operationId = 0U;
IR_TxPlan plan{};
};
IR_SendStatus fakeStart(void *opaque,
IR_Encoder *encoder,
const uint8_t *,
uint8_t,
const IR_TxPlan& plan,
uint32_t operationId)
{
auto& backend = *static_cast<FakeBackend *>(opaque);
backend.encoder = encoder;
backend.operationId = operationId;
backend.plan = plan;
if (backend.startStatus == IR_SendStatus::Success && backend.finishSynchronously)
encoder->externalFinishSend(operationId, IR_SendStatus::Success);
return backend.startStatus;
}
void testTokenLifecycle()
{
FakeBackend backend;
IR_Encoder::setExternalTxBackendV2(fakeStart, nullptr, &backend);
IR_Encoder encoder(1U, 42U, nullptr, false);
uint8_t payload = 0x5EU;
arduino_stub_micros = 100U;
const IR_SendResult first = encoder.sendData(IR_Broadcast, &payload, 1U);
assert(first.success);
assert(first.operationId != 0U);
assert(first.plannedAirtimeUs == backend.plan.airtimeUs);
assert(encoder.isBusy());
IR_TxSnapshot snapshot = encoder.txSnapshot();
assert(snapshot.operationId == first.operationId);
assert(snapshot.state == IR_TxState::Transmitting);
encoder.externalFinishSend(first.operationId + 1U, IR_SendStatus::Success);
assert(encoder.isBusy());
arduino_stub_micros = 200U;
encoder.externalFinishSend(first.operationId, IR_SendStatus::DmaTransferError);
snapshot = encoder.txSnapshot();
assert(!encoder.isBusy());
assert(snapshot.state == IR_TxState::Failed);
assert(snapshot.status == IR_SendStatus::DmaTransferError);
assert(snapshot.terminalAtUs == 200U);
encoder.externalFinishSend(first.operationId, IR_SendStatus::Success);
assert(encoder.txSnapshot().status == IR_SendStatus::DmaTransferError);
arduino_stub_micros = 300U;
const IR_SendResult second = encoder.sendData(IR_Broadcast, &payload, 1U);
assert(second.success && second.operationId != first.operationId);
encoder.externalFinishSend(first.operationId, IR_SendStatus::Success);
assert(encoder.isBusy());
encoder.externalFinishSend(second.operationId, IR_SendStatus::Success);
assert(encoder.isOperationComplete(second.operationId));
backend.startStatus = IR_SendStatus::DmaStartFailed;
const IR_SendResult rejectedAfterOwnership = encoder.sendData(IR_Broadcast, &payload, 1U);
assert(!rejectedAfterOwnership.success);
assert(rejectedAfterOwnership.operationId != 0U);
snapshot = encoder.txSnapshot();
assert(snapshot.state == IR_TxState::Failed);
assert(snapshot.status == IR_SendStatus::DmaStartFailed);
backend.startStatus = IR_SendStatus::Success;
const IR_SendResult active = encoder.sendData(IR_Broadcast, &payload, 1U);
const IR_SendResult busy = encoder.sendData(IR_Broadcast, &payload, 1U);
assert(active.success);
assert(!busy.success && busy.status == IR_SendStatus::EncoderBusy);
assert(busy.operationId == 0U);
encoder.externalFinishSend(active.operationId, IR_SendStatus::Success);
backend.finishSynchronously = true;
const IR_SendResult synchronous = encoder.sendData(IR_Broadcast, &payload, 1U);
assert(synchronous.success);
assert(encoder.isOperationComplete(synchronous.operationId));
assert(!encoder.isBusy());
IR_Encoder::setExternalTxBackendV2(nullptr, nullptr, nullptr);
}
} // namespace
int main()
{
testPlannerMatchesBuiltStream();
testGoldenNominalTimings();
testCapacityAndClockContracts();
testDerivedFixedStorageCapacity();
testInPlacePhysicalScaling();
testTokenLifecycle();
std::cout << "IR TX contract tests: OK\n";
return 0;
}