1 Commits

Author SHA1 Message Date
57db9c35b8 archive: freeze IR-protocol WIP before stepwise integration 2026-08-28 13:27:46 +03:00
16 changed files with 1780 additions and 592 deletions

View File

@ -1,5 +1,6 @@
#include "IR_DecoderRaw.h"
#include "IR_Encoder.h"
#include "IrInterruptGuard.h"
#include <cstdio>
#include <cstring>
@ -53,9 +54,8 @@ IR_DecoderRaw::IR_DecoderRaw(const uint8_t pin, uint16_t addr, IR_Encoder *encPa
bool IR_DecoderRaw::isSubOverflow()
{
noInterrupts();
volatile bool ret = isSubBufferOverflow;
interrupts();
IrInterruptGuard guard;
const bool ret = isSubBufferOverflow;
return ret;
}
@ -103,7 +103,7 @@ void IR_DecoderRaw::refreshPairMuteState()
++active;
}
const uint32_t nowUs = micros();
noInterrupts();
IrInterruptGuard guard;
const bool wasActive = (isPairSending != 0);
isPairSending = active;
#if IR_RX_BRIEF_LOG
@ -121,7 +121,6 @@ void IR_DecoderRaw::refreshPairMuteState()
rxBriefMuteBlockedEdges = 0;
}
#endif
interrupts();
}
#if IR_RX_BRIEF_LOG
@ -142,6 +141,7 @@ const __FlashStringHelper *IR_DecoderRaw::rxBriefReasonTag(RxBriefReason reason)
case RxBriefReason::Timeout: return F("TIMEOUT");
case RxBriefReason::Crc: return F("CRC");
case RxBriefReason::Ok: return F("OK");
case RxBriefReason::Count: return F("UNK");
default: return F("UNK");
}
}
@ -152,7 +152,7 @@ const __FlashStringHelper *IR_DecoderRaw::rxBriefReasonTag(RxBriefReason reason)
void IR_DecoderRaw::rxBriefLog(RxBriefReason reason, uint16_t a, uint16_t b, uint32_t tUs)
{
const uint8_t ri = (uint8_t)reason;
if (ri < 14U)
if (ri < kRxBriefReasonCount)
rxReasonCnt[ri]++;
#if !IR_RX_BRIEF_LOG
(void)a; (void)b; (void)tUs;
@ -228,6 +228,8 @@ void IR_DecoderRaw::rxBriefLog(RxBriefReason reason, uint16_t a, uint16_t b, uin
Serial.print(b);
}
break;
case RxBriefReason::Count:
break;
}
Serial.println();
#endif // IR_RX_BRIEF_LOG (печать)
@ -235,11 +237,14 @@ void IR_DecoderRaw::rxBriefLog(RxBriefReason reason, uint16_t a, uint16_t b, uin
void IR_DecoderRaw::printRxReasonStats(Print &out) const
{
static const char *const kTags[14] = {"?", "MUTEB", "MUTEE", "QRAW", "QFLT", "HOLD",
"GLITCH", "TIME", "PREAMB", "SYNC", "BUF",
"TIMEOUT", "CRC", "OK"};
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 = 1; i < 14U; i++)
for (uint8_t i = static_cast<uint8_t>(RxBriefReason::MuteBegin);
i < kRxBriefReasonCount; ++i)
{
out.print(',');
out.print(kTags[i]);
@ -273,22 +278,23 @@ void IR_DecoderRaw::rxBriefFlushDeferredIsrLogs()
uint16_t muteEndCnt = 0;
uint16_t rawCnt = 0;
uint32_t rawLastUs = 0;
noInterrupts();
muteBeginPending = rxBriefMuteBeginPending;
muteBeginUs = rxBriefMuteBeginUs;
rxBriefMuteBeginPending = false;
rxBriefMuteBeginUs = 0;
muteEndPending = rxBriefMuteEndPending;
muteEndUs = rxBriefMuteEndUs;
muteEndCnt = rxBriefMuteEndCount;
rxBriefMuteEndPending = false;
rxBriefMuteEndUs = 0;
rxBriefMuteEndCount = 0;
rawCnt = rxBriefRawOverflowDrops;
rawLastUs = rxBriefRawOverflowLastUs;
rxBriefRawOverflowDrops = 0;
rxBriefRawOverflowLastUs = 0;
interrupts();
{
IrInterruptGuard guard;
muteBeginPending = rxBriefMuteBeginPending;
muteBeginUs = rxBriefMuteBeginUs;
rxBriefMuteBeginPending = false;
rxBriefMuteBeginUs = 0;
muteEndPending = rxBriefMuteEndPending;
muteEndUs = rxBriefMuteEndUs;
muteEndCnt = rxBriefMuteEndCount;
rxBriefMuteEndPending = false;
rxBriefMuteEndUs = 0;
rxBriefMuteEndCount = 0;
rawCnt = rxBriefRawOverflowDrops;
rawLastUs = rxBriefRawOverflowLastUs;
rxBriefRawOverflowDrops = 0;
rxBriefRawOverflowLastUs = 0;
}
if (muteBeginPending)
rxBriefLog(RxBriefReason::MuteBegin, 0, 0, muteBeginUs);
if (muteEndPending)
@ -359,7 +365,7 @@ void IR_DecoderRaw::firstRX()
#ifdef IRDEBUG
wrCounter = 0;
#endif
memset(dataBuffer, 0x00, dataByteSizeMax);
memset(dataBuffer, 0x00, irproto::kMaxWireFrameBytes);
pulseFilterReset();
preambleResetToIdle();
}
@ -368,9 +374,8 @@ bool IR_DecoderRaw::rxTimeoutPipelineBusy() const
{
if (pulseFilterHoldCount != 0U)
return true;
noInterrupts();
IrInterruptGuard guard;
const bool busy = !subBuffer.isEmpty();
interrupts();
return busy;
}
@ -378,7 +383,7 @@ void IR_DecoderRaw::listenStart()
{
if (rxTimeoutPipelineBusy())
return;
if (isReciveRaw && ((micros() - lastEdgeTime) > IR_timeout * 2U))
if (isReciveRaw && ((micros() - lastEdgeTime) > receiveSilenceTimeoutUs()))
{
#if defined(IRDEBUG_SERIAL_PACK)
packTraceOnTimeoutOrAbort(true);
@ -396,14 +401,13 @@ inline void IR_DecoderRaw::checkTimeout()
if (rxTimeoutPipelineBusy())
return;
if (micros() - lastEdgeTime > IR_timeout * 2U)
if (micros() - lastEdgeTime > receiveSilenceTimeoutUs())
{
#if defined(IRDEBUG_SERIAL_PACK)
packTraceOnTimeoutOrAbort(false);
#endif
const uint16_t expected = (i_dataBuffer >= 8U) ? uint16_t(dataBuffer[0] & IR_MASK_MSG_INFO) : 0U;
rxBriefLog(RxBriefReason::Timeout, i_dataBuffer, expected, micros());
noteRxEnd(RxEndReason::Timeout, micros());
isRecive = false; // приём завершён
msgTypeReceive = 0;
// Как после listenStart(): без сброса isReciveRaw + firstRX() декодер остаётся
@ -418,62 +422,6 @@ inline void IR_DecoderRaw::checkTimeout()
}
// ====================================================================
void IR_DecoderRaw::noteRxEnd(RxEndReason reason, uint32_t tUs)
{
rxEnd.seq++;
rxEnd.reason = reason;
rxEnd.msgType = (i_dataBuffer >= 8U * msgBytes) ? (uint8_t)((dataBuffer[0] >> 5) & IR_MASK_MSG_TYPE) : 0xFF;
rxEnd.packSize = (uint8_t)packSize;
rxEnd.tUs = tUs;
rxEnd.expectedEndUs = (packSize >= msgBytes + crcBytes)
? rxLockTimeUsVal + irLockToDecodeEndUs((uint8_t)packSize) + irTicksToUs((uint32_t)syncBits * irBitTicks)
: 0U;
}
void IR_DecoderRaw::abortFrame(uint32_t tUs)
{
#if defined(IRDEBUG_SERIAL_PACK)
packTraceOnTimeoutOrAbort(false);
#endif
noteRxEnd(RxEndReason::Abort, tUs);
isRecive = false;
isReciveRaw = false;
msgTypeReceive = 0;
firstRX();
releasePreambleGuard(tUs);
}
// После обрыва кадра «длинная тишина» (IR_timeout × 2 ≈ 30 мс) перед новым кандидатом преамбулы не требуется.
// prevRise — последний ДЕКОДИРОВАННЫЙ фронт; после abort он свежий, а фронты, отброшенные гвардом, его не двигают,
// поэтому валидный кадр, начавшийся через <30 мс после обрыва мусора, проглатывался целиком без счётчика
// (стенд 09.09: КУ теряла пинг машинки после обрывков чужого заднего и всплеска её дальномера за 24 мс до пинга).
// Ложных захватов это не добавляет: хвост оборванного кадра (период фронтов 962 мкс, синхробиты ~1100) не проходит
// грубый фильтр периода преамбулы (2116…3270 мкс), а настоящая преамбула перезапускает кандидата по паузе > IR_timeout.
// После чистого конца кадра гвард остаётся: там он отсекает хвост синхробитов.
void IR_DecoderRaw::releasePreambleGuard(uint32_t tUs)
{
prevRise = tUs - IR_timeout * 2U - 1U; // «тишина уже была»: (front.time - prevRise) > longSilence для следующего фронта
}
void IR_DecoderRaw::expirePreambleCandidate()
{
if (preambleState != PreambleState::Candidate || rxTimeoutPipelineBusy())
return;
if ((micros() - preambleCandidateLastEdgeTime) > IR_timeout * (uint32_t)IR_PREAMBLE_CANDIDATE_TIMEOUT_MULT)
{
if (preambleGoodPeriods)
rxBriefLog(RxBriefReason::Preamble, preambleGoodPeriods, 0, micros());
preambleResetToIdle();
}
}
uint32_t IR_DecoderRaw::rxExpectedEndUs() const
{
if (!isRecive || preambleState != PreambleState::Locked || isWrongPack || packSize < msgBytes + crcBytes)
return 0;
return rxLockTimeUsVal + irLockToDecodeEndUs((uint8_t)packSize);
}
void IR_DecoderRaw::tick()
{
#if IR_RX_BRIEF_LOG
@ -528,16 +476,15 @@ void IR_DecoderRaw::tick()
if (!processedFront)
{
isSubBufferOverflow = false;
checkTimeout();
listenStart();
expirePreambleCandidate();
checkTimeout();
#if defined(IR_EDGE_TRACE)
while (edgeTraceFlushChunk(Serial, 48) > 0) {}
#endif
return;
} // Если данных нет - ничего не делаем
checkTimeout();
listenStart();
checkTimeout();
#if IR_RX_BRIEF_LOG
rxBriefFlushDeferredIsrLogs();
#endif
@ -825,8 +772,8 @@ void IR_DecoderRaw::writeToBuffer(bool bit, bool packTraceInvertFix)
#if !defined(IRDEBUG_SERIAL_PACK)
(void)packTraceInvertFix;
#endif
if (i_dataBuffer >= dataByteSizeMax * 8)
{ // проверка переполнения (>=: иначе при i_dataBuffer==dataByteSizeMax*8 запись dataBuffer[38] за границей массива — B3)
if (i_dataBuffer >= irproto::kMaxWireFrameBytes * 8U)
{ // >=: не даёт записать бит за пределом 5-битной wire-длины.
isBufferOverflow = true;
rxBriefLog(RxBriefReason::BufferOverflow, i_dataBuffer, 0, micros());
#if defined(IRDEBUG_SERIAL_PACK)
@ -838,7 +785,10 @@ void IR_DecoderRaw::writeToBuffer(bool bit, bool packTraceInvertFix)
{
// Как checkTimeout/listenStart: firstRX() сбрасывает буфер битов, преамбулу и
// pulseFilterReset() — при IR_INPUT_MIN_PULSE_US > 0 иначе остаётся «хвост» в hold/filtered.
abortFrame(micros());
isRecive = false;
isReciveRaw = false;
msgTypeReceive = 0;
firstRX();
return;
}
@ -906,8 +856,6 @@ void IR_DecoderRaw::writeToBuffer(bool bit, bool packTraceInvertFix)
#if defined(IRDEBUG_SERIAL_PACK)
packTraceEmitErrorFlash(F("ERROR: Wrong sync bit"));
#endif
abortFrame(micros()); // битый кадр не удерживает приёмник до таймаута
return;
}
}
}
@ -933,14 +881,10 @@ void IR_DecoderRaw::writeToBuffer(bool bit, bool packTraceInvertFix)
{ // Ппервый байт
packSize = dataBuffer[0] & IR_MASK_MSG_INFO;
// B1: под-минимальная длина (1..2) физически не несёт CRC (min кадр = msg+crc = 3 байта) → шум/битьё.
// Без отсева packSize==1 даёт crcCheck(1-2) → len=255 → OOB-чтение dataBuffer[0..256] (массив 38).
// Без отсева packSize==1 даёт crcCheck(1-2) → len=255 → OOB-чтение wire-буфера.
// packSize>=3 (в т.ч. будущие компактные кадры) обрабатываются как обычно.
if (packSize < msgBytes + crcBytes) // 0..2: кадр физически не несёт CRC — шум/битьё
{
if (packSize != 0 && packSize < msgBytes + crcBytes)
isWrongPack = true;
abortFrame(micros());
return;
}
}
// Тип приёма (для isReceive): выставляем сразу после первого байта, ДО проверки «Конец».
@ -964,7 +908,6 @@ void IR_DecoderRaw::writeToBuffer(bool bit, bool packTraceInvertFix)
preambleResetToIdle();
msgTypeReceive = 0;
isAvailable = crcCheck(packSize - crcBytes, crcValue);
noteRxEnd(isAvailable ? RxEndReason::Ok : RxEndReason::Crc, micros());
#ifdef BRUTEFORCE_CHECK
{
@ -972,7 +915,7 @@ void IR_DecoderRaw::writeToBuffer(bool bit, bool packTraceInvertFix)
uint8_t packTraceBfBit = 0;
bool packTraceBfMark = false;
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)
{
@ -980,7 +923,7 @@ void IR_DecoderRaw::writeToBuffer(bool bit, bool packTraceInvertFix)
dataBuffer[i] ^= 1 << j;
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)
@ -1015,7 +958,7 @@ void IR_DecoderRaw::writeToBuffer(bool bit, bool packTraceInvertFix)
rxBriefLog(RxBriefReason::Ok, packSize, errSum, micros());
else
rxBriefLog(RxBriefReason::Crc, packSize, errSum, micros());
if (!isAvailable && packSize > 0 && packSize <= dataByteSizeMax) {
if (!isAvailable && packSize > 0 && packSize <= irproto::kMaxWireFrameBytes) {
memcpy(rejectBuffer, dataBuffer, packSize);
rejectPackSize = static_cast<uint8_t>(packSize);
isRejectAvailable = true;
@ -1069,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)
{
const uint16_t cap = static_cast<uint16_t>(IR_EDGE_TRACE_CAPACITY);
noInterrupts();
IrInterruptGuard guard;
const uint16_t w = edgeTrace_w;
const uint16_t r = edgeTrace_r;
const uint16_t next = static_cast<uint16_t>((w + 1u) % cap);
if (next == r)
{
edgeTrace_overflow = true;
interrupts();
return;
}
edgeTrace_buf[w].t_us = t_us;
edgeTrace_buf[w].level = level ? 1u : 0u;
edgeTrace_buf[w].flags = flags;
edgeTrace_w = next;
interrupts();
}
void IR_DecoderRaw::edgeTraceClear()
{
noInterrupts();
IrInterruptGuard guard;
edgeTrace_w = 0;
edgeTrace_r = 0;
edgeTrace_overflow = false;
interrupts();
}
uint16_t IR_DecoderRaw::edgeTracePendingCount() const
{
noInterrupts();
IrInterruptGuard guard;
const uint16_t w = edgeTrace_w;
const uint16_t r = edgeTrace_r;
interrupts();
const uint16_t cap = static_cast<uint16_t>(IR_EDGE_TRACE_CAPACITY);
if (w >= r)
return static_cast<uint16_t>(w - r);
@ -1116,27 +1055,28 @@ uint16_t IR_DecoderRaw::edgeTraceFlushChunk(Print &out, uint16_t maxRec)
maxRec = kStackCap;
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];
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);
memcpy(tmp + i * 6u, &edgeTrace_buf[idx], 6u);
IrInterruptGuard guard;
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;
if (ovf)
@ -1366,7 +1306,7 @@ void IR_DecoderRaw::packTraceForceEndSyncPhase()
void IR_DecoderRaw::packTraceEmitHex(uint8_t byteCount) const
{
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(' ');
ptPrintHexU8(dataBuffer[i]);
@ -1464,8 +1404,8 @@ void IR_DecoderRaw::packTraceEmitErrorFlash(const __FlashStringHelper *msg)
Serial.println(msg);
{
uint16_t nb = i_dataBuffer / 8u;
if (nb > dataByteSizeMax)
nb = dataByteSizeMax;
if (nb > irproto::kMaxWireFrameBytes)
nb = irproto::kMaxWireFrameBytes;
packTraceEmitHex(static_cast<uint8_t>(nb));
}
packTraceResetFrame();
@ -1498,8 +1438,8 @@ void IR_DecoderRaw::packTraceOnTimeoutOrAbort(bool fromListenStart)
return;
const uint16_t expected = (i_dataBuffer >= 8) ? uint16_t(dataBuffer[0] & IR_MASK_MSG_INFO) : 0;
uint16_t gotBytes = i_dataBuffer / 8;
if (gotBytes > dataByteSizeMax)
gotBytes = dataByteSizeMax;
if (gotBytes > irproto::kMaxWireFrameBytes)
gotBytes = irproto::kMaxWireFrameBytes;
Serial.println();
packTraceEmitRawBitsLine(false);
Serial.print(F(" => ERROR: TIMEOUT, rx_data_size = "));
@ -1671,7 +1611,7 @@ void IR_DecoderRaw::preambleStartCandidate(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;
if (preambleState == PreambleState::Idle)
@ -1684,10 +1624,7 @@ bool IR_DecoderRaw::preambleProcessEdge(const FrontStorage &front)
if (!isReciveRaw && front.dir &&
((prevRise == 0U && front.time > longSilence) ||
(prevRise != 0U && (uint32_t)(front.time - prevRise) > longSilence)))
{
preambleStartCandidate(front);
return true;
}
}
if (preambleState == PreambleState::Candidate)
@ -1761,15 +1698,13 @@ bool IR_DecoderRaw::preambleProcessEdge(const FrontStorage &front)
err_syncBit = 0;
isWrongPack = false;
msgTypeReceive = 0;
memset(dataBuffer, 0x00, dataByteSizeMax);
memset(dataBuffer, 0x00, irproto::kMaxWireFrameBytes);
preambleState = PreambleState::Locked;
isPreamb = false;
isRecive = true;
isReciveRaw = true;
risePeriod = preambleMeanPeriod;
rxLockSeqCnt++;
rxLockTimeUsVal = front.time;
#if defined(IRDEBUG_SERIAL_PACK)
packTraceResetFrame();
packTraceOpen = true;

View File

@ -20,12 +20,13 @@ class Print;
/////////////////////////////////////////////////////////////////////////////////////////////////
#define riseTime riseSyncTime //* bitTime */ 893U // TODO: Должно высчитываться медианой
#define riseTolerance tolerance /* 250U */ // погрешность
#define riseTolerance IR_TIMING_TOLERANCE_US /* 250U */ // погрешность
#define riseTimeMax (riseTime + riseTolerance)
#define riseTimeMin (riseTime - riseTolerance)
#define aroundRise(t) (riseTimeMin < t && t < riseTimeMax)
#define IR_timeout (riseTimeMax * (8 + syncBits + 1)) // us // таймаут в 8 data + 3 sync + 1
constexpr uint16_t IR_ResponseDelay = ((uint16_t)(((bitTime+riseTolerance) * (8 + syncBits + 1))*2.7735))/1000;
// Compatibility aliases. The named contracts and their geometry live in IR_config.h.
#define IR_timeout (::irproto::rxInterEdgeTimeoutUs(riseTime))
constexpr uint16_t IR_ResponseDelay = irproto::kDefaultResponseTurnaroundDelayMs;
class IR_Encoder;
class IR_DecoderRaw : virtual public IR_FOX
@ -51,41 +52,14 @@ public:
inline bool isOverflow() { return isBufferOverflow; }; // Буффер переполнился
bool isSubOverflow();
volatile inline bool isReciving() { return isRecive; }; // Возвращает true, если происходит приём пакета
// Активность линии по СОСТОЯНИЮ (не по хардкод-длительности): кадр залочен ИЛИ формируется
// ВАЛИДНАЯ преамбула (>=1 совпавший по периоду фронт — отличает реальный кадр от одиночного
// шумового фронта, который лишь заводит Candidate, но не набирает goodPeriods). Для гейта заднего:
// «не стрелять, пока на линии идёт/формируется кадр (напр. ответ точки)». Аддитивно, const.
inline bool rxLineActive() const {
return isRecive ||
(preambleState == PreambleState::Candidate && preambleGoodPeriods >= 1U);
inline bool isReciving() const { return isRecive; } // Возвращает true, если происходит приём пакета
/** Current adaptive silence threshold that terminates an RX candidate. */
inline uint32_t receiveSilenceTimeoutUs() const {
return irproto::rxSilenceTimeoutUs(riseTime);
}
// Объявленная длина ПРИНИМАЕМОГО кадра (байт) из ПЕРВОГО байта, если он уже принят и валиден;
// иначе 0 (ещё не знаем / битый). До CRC это НЕДОВЕРЕННОЕ значение — потребитель, получив 0
// или чрезмерное, обязан брать rxMaxPackSize() (безопасно держать задний до конца макс.кадра).
inline uint16_t rxDeclaredPackSize() const {
return (isRecive && packSize && !isWrongPack) ? packSize : 0;
inline uint32_t receiveSilenceTimeoutMsCeil() const {
return irproto::microsToMillisCeil(receiveSilenceTimeoutUs());
}
// Протокольный МАКСИМУМ длины кадра (байт) — верхняя граница бюджета удержания заднего.
static constexpr uint16_t rxMaxPackSize() { return (uint16_t)irMaxPackSize; }
// ---- Наблюдаемость приёма по СОСТОЯНИЮ: лок / ожидаемый конец / факт завершения с причиной ----
enum class RxEndReason : uint8_t { None = 0, Ok, Crc, Timeout, Abort };
struct RxEndInfo {
uint16_t seq = 0; // номер завершения (растёт на каждом терминале)
RxEndReason reason = RxEndReason::None;
uint8_t msgType = 0xFF; // 0xFF = первый байт не был принят
uint8_t packSize = 0; // объявленная длина (0 = неизвестна)
uint32_t tUs = 0; // micros() терминала
uint32_t expectedEndUs = 0; // расчётный конец кадра в эфире (0 = неизвестен)
};
uint16_t rxLockSeq() const { return rxLockSeqCnt; } // ++ в момент лока преамбулы
uint32_t rxLockTimeUs() const { return rxLockTimeUsVal; } // метка фронта лока (ISR-время)
/// Тип принимаемого кадра (3 бита) после первого байта; 0xFF пока неизвестен / приём не идёт.
uint8_t rxMsgType() const { return (isRecive && packSize) ? (uint8_t)((dataBuffer[0] >> 5) & IR_MASK_MSG_TYPE) : 0xFF; }
const RxEndInfo &rxLastEnd() const { return rxEnd; }
/// Расчётный момент последнего бита данных текущего кадра (по объявленной длине); 0 = не Locked / длина неизвестна.
uint32_t rxExpectedEndUs() const;
uint32_t pulseFilterDroppedByFilteredOverflow() const { return 0; }
uint32_t pulseFilterDroppedByHoldOverflow() const { return pulseFilterDropHoldOverflow; }
uint32_t pulseFilterDroppedGlitchPairs() const { return pulseFilterDropGlitchPairs; }
@ -102,8 +76,9 @@ public:
/// 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 < 14; i++) rxReasonCnt[i] = 0; }
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;
@ -128,12 +103,14 @@ private:
BufferOverflow = 10,
Timeout = 11,
Crc = 12,
Ok = 13
Ok = 13,
Count
};
static constexpr uint8_t kRxBriefReasonCount = static_cast<uint8_t>(RxBriefReason::Count);
bool isRejectAvailable = false;
uint8_t rejectPackSize = 0;
uint8_t rejectBuffer[dataByteSizeMax]{};
uint8_t rejectBuffer[irproto::kMaxWireFrameBytes]{};
ErrorsStruct errors;
bool isAvailable = false;
@ -181,13 +158,6 @@ private:
Locked = 2
};
PreambleState preambleState = PreambleState::Idle;
uint16_t rxLockSeqCnt = 0;
uint32_t rxLockTimeUsVal = 0;
RxEndInfo rxEnd;
void noteRxEnd(RxEndReason reason, uint32_t tUs); // терминал: фиксирует тип/длину/расчётный конец
void abortFrame(uint32_t tUs); // немедленный сброс битого кадра (sync/длина/overflow)
void releasePreambleGuard(uint32_t tUs); // после abort: новый кандидат преамбулы без ожидания длинной тишины
void expirePreambleCandidate(); // кандидат без фронтов дольше таймаута → Idle
uint8_t preambleGoodPeriods = 0;
uint16_t preambleMeanPeriod = 0;
uint32_t preambleCandidateLastEdgeTime = 0;
@ -220,7 +190,7 @@ private:
#endif
////////////////////////////////////////////////////////////////////////
uint8_t dataBuffer[dataByteSizeMax]{0}; // Буффер данных
uint8_t dataBuffer[irproto::kMaxWireFrameBytes]{0}; // Буффер полного wire-кадра
volatile uint32_t prevRise, prevPrevRise, prevFall, prevPrevFall; // Время предыдущих фронтов/спадов
volatile uint32_t risePeriod;
@ -288,7 +258,7 @@ bool isReciveRaw = false;
// (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[14] = {}; // индекс = (uint8_t)RxBriefReason, 1..13
uint16_t rxReasonCnt[kRxBriefReasonCount] = {}; // индекс = RxBriefReason, 1..Count-1
#if IR_RX_BRIEF_LOG
static const __FlashStringHelper *rxBriefReasonTag(RxBriefReason reason);
void rxBriefNoteMuteBlockedIsr(uint32_t tUs);
@ -304,7 +274,7 @@ bool isReciveRaw = false;
#if defined(IRDEBUG_SERIAL_PACK)
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 packTracePushBit(bool bit);

File diff suppressed because it is too large Load Diff

View File

@ -18,20 +18,88 @@ enum class IR_SendStatus : uint8_t {
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 {
bool success; // Флаг успешности отправки
uint32_t sendTimeMs; // Время отправки пакета в миллисекундах
bool success; // true: backend принял и запустил эту операцию
uint32_t sendTimeMs; // ceil(plannedAirtimeUs / 1000), compatibility field
IR_SendStatus status; // Детализированный статус старта передачи
uint32_t operationId; // 0, если новая операция не создавалась
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)
: success(success), sendTimeMs(sendTimeMs), status(status) {}
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;
@ -53,6 +121,12 @@ public:
using ExternalTxBusyFn = bool (*)(void *ctx);
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:
// uint16_t id; /// @brief Адрес передатчика
public:
@ -62,10 +136,6 @@ public:
/// @param decPair Если задан, конструктор регистрирует этот один приёмник как blind-decoder
/// (аналог setBlindDecoders() для одного RX).
IR_Encoder(uint8_t pin, uint16_t addr = 0, IR_DecoderRaw *decPair = nullptr, bool autoHandle = true);
/// Публичная оценка airtime кадра (мс) по его полной длине в байтах (packSize). Чистая функция
/// протокольных констант — подходит и для приёма (напр. бюджет удержания заднего по объявленному
/// в 1-м байте размеру принимаемого ответа). БЕЗ +30% компенсации занижения sync — добавляет потребитель.
uint32_t packAirtimeMs(uint8_t packSize) const { return calculateSendTime(packSize); }
static void isr();
static void begin(HardwareTimer* timer, uint8_t channel, IRQn_Type IRQn, uint8_t priority, void(*isrCallback)() = nullptr);
/**
@ -115,14 +185,32 @@ public:
/** Optional: register external TX backend (e.g. DMA driver). */
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();
/** 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 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);
/** 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 disable();
@ -136,6 +224,7 @@ public:
setBlindDecoders(decoders, static_cast<uint8_t>(N));
}
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 *data = nullptr, uint8_t len = 0, bool needAccept = false);
@ -159,7 +248,11 @@ public:
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;
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();
@ -174,6 +267,7 @@ private:
static void carrierPauseIfIdle();
static ExternalTxStartFn externalTxStartFn;
static ExternalTxStartFnV2 externalTxStartFnV2;
static ExternalTxBusyFn externalTxBusyFn;
static void *externalTxCtx;
IR_SendResult _sendBack(bool isAdressed, uint16_t addrTo, uint8_t *data, uint8_t len);
@ -214,8 +308,15 @@ private:
static bool txAdvanceBoundary(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);
template <typename Emit>
static bool txWalkRuns(TxFsmState &st, const uint8_t *sendBufferLocal, Emit emit);
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 storeTxFsmToMembers(const TxFsmState &st);
bool shouldUseBufferedIsr() const;
@ -224,9 +325,8 @@ private:
uint16_t txPowerSnap_ = 1;
uint16_t txMultiplySnap_ = 2;
/** Legacy: физических тиков на один логический шаг FSM = multiply/2. */
uint16_t legacyPhysPerLogical_ = 1;
uint16_t legacyPhysCounter_ = 0;
/** Fractional 2×Fc -> multiply×Fc phase accumulator (also exact for odd multiply). */
uint32_t legacyScaleAccumulator_ = 0;
uint16_t legacySlotInPeriod_ = 0;
volatile uint16_t powerNumerator_ = 1;
@ -241,9 +341,27 @@ private:
uint8_t decodersCount = 0;
uint8_t sendLen = 0;
uint8_t sendBuffer[dataByteSizeMax]{0}; /// @brief Буффер данных для отправки
uint8_t sendBuffer[irproto::kMaxWireFrameBytes]{0}; /// @brief Буффер полного wire-кадра
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 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;
};
}

View File

@ -15,8 +15,6 @@ constexpr size_t kDefaultDmaTxMaxStreams = 4U;
constexpr uint32_t kDmaTxIrqPriority = 8U;
/** Кольцевой буфер BSRR-слов для ISR-TX (как у DMA: два полублока). Чётное число. */
constexpr uint16_t kIsrTxBsrrWordCount = 256U;
/** Максимум RLE-сегментов для buildGateRuns при ISR-TX. */
constexpr size_t kIsrTxMaxGateRuns = 512U;
static_assert((kIsrTxBsrrWordCount & 1U) == 0U, "kIsrTxBsrrWordCount must be even");
}
@ -101,7 +99,7 @@ msg type:
                                //  ----------
                                // | xxx..... | = тип сообщения (биты 7..5)
                                // | ...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_ACCEPT 1U // | 001..... | = подтверждение
@ -111,7 +109,7 @@ msg type:
// #define IR_MSG_ 5U // | 101..... | = ??
#define IR_MSG_DATA_NOACCEPT 6U // | 110..... | = данные, не требующие подтверждения
#define IR_MSG_DATA_ACCEPT 7U // | 111..... | = данные требующие подтверждения
; /*   // ----------
/*   // ----------
/``````````````````````````````` подтверждение `````````````````````````````\      /``````````````````````````````````````` запрос ``````````````````````````````````\
                                                                                                                      
@ -159,15 +157,25 @@ msg type:
*/
#define IR_MASK_MSG_TYPE 0b00000111
#define IR_MASK_MSG_INFO 0b00011111
namespace irproto {
/** 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;
// #define BRUTEFORCE_CHECK // Перепроверяет пакет на 1 битные ошибки //TODO: зависает
#define bytePerPack (31) // колличество байтов в пакете
#ifndef freeFrec
#define freeFrec false
#endif
@ -231,11 +239,9 @@ typedef uint16_t crc_t;
#ifndef IR_PREAMBLE_PERIOD_MAX_FACTOR_PCT
#define IR_PREAMBLE_PERIOD_MAX_FACTOR_PCT 340U
#endif
/** Таймаут окна кандидата преамбулы: IR_timeout * mult. Кандидат без фронтов дольше таймаута байта
преамбулой быть не может; при 3× линия считалась занятой (rxLineActive) ещё 45 мс после последнего
паразитного фронта (напр. засветка своим дальномером) и откладывала передачу. */
/** Таймаут окна кандидата преамбулы: IR_timeout * mult. */
#ifndef IR_PREAMBLE_CANDIDATE_TIMEOUT_MULT
#define IR_PREAMBLE_CANDIDATE_TIMEOUT_MULT 1U
#define IR_PREAMBLE_CANDIDATE_TIMEOUT_MULT 3U
#endif
#define preambPulse 3
@ -250,8 +256,6 @@ typedef uint16_t crc_t;
#define poly2 0x8C
#define syncBits 3U // количество битов синхронизации
#define dataByteSizeMax (msgBytes + addrBytes + addrBytes + bytePerPack + crcBytes)
#define preambFronts (preambPulse * 2) // количество фронтов преамбулы (Приём)
#define preambToggle ((bitPauseTakts * 2 + bitActiveTakts) * 2 - 1) // колличество переключений преамбулы (Передача)
@ -264,33 +268,123 @@ typedef uint16_t crc_t;
#define bitTakts (bitActiveTakts + bitPauseTakts) // Общая длительность бита в тактах
#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;
// ---- Длительности и размеры кадра ФОРМУЛАМИ из FSM передатчика (IR_Encoder::txAdvanceBoundary) ----
// Логический такт TX = полпериода несущей (toggleCounter считает полупериоды). Преамбула = 6 ран по
// (preambToggle+1) тактов; лок декодера — на 3-м RISE (конец 5-й раны); байт = (8 данных + 3 sync) бит по 74 такта.
constexpr uint32_t irTxTickNs = 1000000000UL / (carrierFrec * 2U);
constexpr uint32_t irPreambleTicks = (uint32_t)preambPulse * 2U * ((uint32_t)preambToggle + 1U);
constexpr uint32_t irLockTicks = ((uint32_t)preambPulse * 2U - 1U) * ((uint32_t)preambToggle + 1U);
constexpr uint32_t irBitTicks = (uint32_t)bitTakts * 2U;
constexpr uint32_t irByteTicks = ((uint32_t)bitPerByte + (uint32_t)syncBits) * irBitTicks;
constexpr uint32_t irTicksToUs(uint32_t ticks) { return (uint32_t)(((uint64_t)ticks * irTxTickNs + 500U) / 1000U); }
/// Полное время кадра в эфире (от первой несущей до последнего sync-бита), мкс.
constexpr uint32_t irFrameAirtimeUs(uint8_t packSize) { return irTicksToUs(irPreambleTicks + (uint32_t)packSize * irByteTicks); }
/// От старта кадра до последнего БИТА ДАННЫХ (момент, когда декодер отдаёт кадр), мкс.
constexpr uint32_t irFrameDecodeEndUs(uint8_t packSize) { return irTicksToUs(irPreambleTicks + (uint32_t)packSize * irByteTicks - (uint32_t)syncBits * irBitTicks); }
/// От лока декодера (3-й RISE преамбулы) до последнего бита данных, мкс.
constexpr uint32_t irLockToDecodeEndUs(uint8_t packSize) { return irTicksToUs(irPreambleTicks - irLockTicks + (uint32_t)packSize * irByteTicks - (uint32_t)syncBits * irBitTicks); }
/// Латентность лока: от первой несущей чужого кадра до лока декодера, мкс.
constexpr uint32_t irLockLatencyUs = irTicksToUs(irLockTicks);
/// Таймаут байта декодера (как IR_timeout при номинальном bitTime) и тишина, по которой декодер обрывает приём.
constexpr uint32_t irRxByteTimeoutUs = ((uint32_t)bitTime + tolerance) * ((uint32_t)bitPerByte + syncBits + 1U);
constexpr uint32_t irRxAbortSilenceUs = 2U * irRxByteTimeoutUs;
/// Протокольный максимум длины кадра (5-битное поле длины).
constexpr uint8_t irMaxPackSize = IR_MASK_MSG_INFO;
/// Размер кадра по полезной нагрузке: DATA (from+to) и BACK (только from).
constexpr uint8_t irDataPackSize(uint8_t payload) { return (uint8_t)(msgBytes + addrBytes * 2 + payload + crcBytes); }
constexpr uint8_t irBackPackSize(uint8_t payload) { return (uint8_t)(msgBytes + addrBytes + payload + crcBytes); }
/** 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_Takts = bitTakts * 2;

View File

@ -120,7 +120,23 @@ public:
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);
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;
@ -192,6 +208,7 @@ private:
uint32_t totalTicks = 0;
volatile uint32_t ticksOutput = 0;
uint32_t operationId = 0;
// Fix D (watchdog): прогресс ticksOutput против стенных часов (контекст потока).
uint32_t lastTicks = 0;
@ -204,20 +221,23 @@ private:
ticksOutput = 0;
totalTicks = 0;
runCount = 0;
operationId = 0;
}
IR_DMA_TX_HOT void fill(uint32_t* dst, uint16_t count) {
wave.fill(dst, count);
}
void onHalf() {
void advanceHalf() {
ticksOutput += halfLen;
}
void refillFirstHalf() {
fill(&dmaBuf[0], halfLen);
__DSB(); // Fix #8: refill первой половины виден DMA до следующего прохода кольца
}
void onComplete() {
ticksOutput += halfLen;
void refillSecondHalf() {
fill(&dmaBuf[halfLen], halfLen);
__DSB(); // Fix #8: refill второй половины виден DMA до следующего прохода кольца
}
@ -262,7 +282,7 @@ private:
void forceStop(TxStream& s) {
HAL_NVIC_DisableIRQ(s.dmaIrq);
if (s.active) {
stopStream(s);
stopStream(s, IR_SendStatus::DmaStalled);
recoveries_++;
}
HAL_NVIC_EnableIRQ(s.dmaIrq);
@ -281,18 +301,22 @@ private:
static void dmaHalfCpltCb(DMA_HandleTypeDef* hdma) {
auto* s = streamFromDma(hdma);
if (s == nullptr || !s->active) return;
s->onHalf();
s->advanceHalf();
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) {
auto* s = streamFromDma(hdma);
if (s == nullptr || !s->active) return;
s->onComplete();
s->advanceHalf();
if (s_instance != nullptr && s->ticksOutput >= s->totalTicks) {
s_instance->stopStream(*s);
s_instance->stopStream(*s, IR_SendStatus::Success);
} else {
s->refillSecondHalf();
}
}
@ -302,7 +326,7 @@ private:
s->onError();
if (s_instance != nullptr) {
s_instance->errors_++; // Fix #5: наблюдаемость аварийных завершений по Transfer-Error
s_instance->stopStream(*s);
s_instance->stopStream(*s, IR_SendStatus::DmaTransferError);
}
}
@ -348,21 +372,29 @@ private:
return true;
}
IR_SendStatus startStream(TxStream& s, const uint8_t* packet, uint8_t len) {
IR_SendStatus startStream(TxStream& s,
const uint8_t* packet,
uint8_t len,
const IR_TxPlan& expectedPlan,
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();
const uint16_t mult = IR_Encoder::carrierMultiply();
s.runCount = IR_Encoder::buildPhysicalGateRuns(packet, len, s.runs, s.maxRuns, mult);
if (s.runCount == 0) return IR_SendStatus::BuildGateRunsFailed;
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 = expectedPlan.carrierMultiply;
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;
uint16_t pwr = mult / 2U;
if (s.enc != nullptr) {
@ -388,6 +420,7 @@ private:
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) {
s.active = false;
s.operationId = 0U;
return IR_SendStatus::DmaStartFailed;
}
@ -395,10 +428,12 @@ private:
return IR_SendStatus::Success;
}
void stopStream(TxStream& s) {
void stopStream(TxStream& s, IR_SendStatus terminalStatus) {
if (!s.active) return;
const uint32_t operationId = s.operationId;
s.active = false;
s.operationId = 0U;
HAL_DMA_Abort_IT(&s.hdma);
if (s.port != nullptr) {
@ -406,7 +441,7 @@ private:
}
if (s.enc != nullptr) {
s.enc->externalFinishSend();
s.enc->externalFinishSend(operationId, terminalStatus);
}
// Fix C: TIM НЕ останавливаем — он free-running, без разделяемого счётчика.
}

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

@ -65,63 +65,13 @@ public:
return out;
}
// Заполнение по ранам, а не по словам: тот же поток слов, что даёт nextWord() (состояние
// runIndex_/ticksLeftInRun_/slotInPeriod_ переносится через границы порций), но пауза (gate=0)
// пишется одним циклом записи, а несущая — копией готового шаблона периода. На 12 МГц это
// ~1 мс на 4096 слов вместо ~16 (пословный автомат) — и в предзаполнении перед стартом DMA,
// и в ISR-дозаполнении половин буфера во время передачи.
IR_TX_BSRR_WAVE_HOT void fill(uint32_t* dst, uint16_t count) {
if (dst == nullptr || count == 0) {
return;
}
while (count != 0) {
if (runIndex_ >= runCount) {
do { *dst++ = resetWord; } while (--count != 0);
return;
}
const bool gate = runs[runIndex_].gate;
uint16_t n = ticksLeftInRun_; // слов до конца текущего рана
if (n == 0) n = 1; // ран нулевой длины: nextWord() выдаёт одно слово и переходит дальше
if (n > count) n = count;
if (!gate) {
slotInPeriod_ = 0;
uint16_t k = n;
do { *dst++ = resetWord; } while (--k != 0);
} else {
uint16_t k = n;
// добить текущий период до слота 0 (если ран начался посреди периода на границе порции)
while (k != 0 && slotInPeriod_ != 0) {
*dst++ = (slotInPeriod_ < powerN_) ? setWord : resetWord;
if (++slotInPeriod_ >= multiply_) slotInPeriod_ = 0;
k--;
}
// целые периоды: powerN_ слов setWord, остальные resetWord
while (k >= multiply_) {
uint16_t i = 0;
for (; i < powerN_; ++i) *dst++ = setWord;
for (; i < multiply_; ++i) *dst++ = resetWord;
k = (uint16_t)(k - multiply_);
}
// хвост неполного периода
while (k != 0) {
*dst++ = (slotInPeriod_ < powerN_) ? setWord : resetWord;
if (++slotInPeriod_ >= multiply_) slotInPeriod_ = 0;
k--;
}
}
count = (uint16_t)(count - n);
if (ticksLeftInRun_ > n) {
ticksLeftInRun_ = (uint16_t)(ticksLeftInRun_ - n);
} else {
ticksLeftInRun_ = 0;
}
if (ticksLeftInRun_ == 0) {
runIndex_++;
if (runIndex_ < runCount) {
ticksLeftInRun_ = runs[runIndex_].lenTicks;
}
}
}
do {
*dst++ = nextWord();
} while (--count != 0);
}
private:

View File

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

View File

@ -20,7 +20,7 @@ static constexpr uint16_t kIrDeviceAddr = 0;
static constexpr uint8_t kCmdVersion = 0x5E;
static constexpr uint32_t kSerialBaud = 115200;
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 IR_Encoder enc(PIN_IR_ENC_FORWARD, kIrDeviceAddr, nullptr);
@ -30,15 +30,23 @@ static HardwareTimer irTimer(TIM17);
namespace {
constexpr size_t kIrDmaStreams = 1;
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 IR_Encoder::IR_TxGateRun s_irGateRuns[kIrDmaTxMaxGateRuns];
} // namespace
static IrDmaTxStm32<kIrDmaStreams> dmaBackend;
static bool txBusy(void * /*ctx*/) { return dmaBackend.busy(); }
static bool txStart(void * /*ctx*/, IR_Encoder *e, const uint8_t *packet, uint8_t len) {
return dmaBackend.start(e, packet, len);
static IR_SendStatus txStart(void * /*ctx*/,
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
@ -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.
static const char kPayload16[] = "Car_v4.3.9_[12MH";
// 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() {
s_irPayload[0] = kCmdVersion;
@ -110,7 +122,7 @@ void setup() {
Serial.println(F("[IR_DMA] init FAILED"));
return;
}
IR_Encoder::setExternalTxBackend(txStart, txBusy, nullptr);
IR_Encoder::setExternalTxBackendV2(txStart, txBusy, nullptr);
#elif LONGDATA_LEGACY_ISR
IR_Encoder::begin(&irTimer, 1, TIM17_IRQn, 0);
#else

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;
}