diff --git a/.gitignore b/.gitignore index ae3844c..1a0b2f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .vscode/* bin/* +tests/*.exe !.vscode/launch.json log/* /.vscode diff --git a/IR_DecoderRaw.cpp b/IR_DecoderRaw.cpp index 325d52a..374db04 100644 --- a/IR_DecoderRaw.cpp +++ b/IR_DecoderRaw.cpp @@ -374,15 +374,45 @@ bool IR_DecoderRaw::rxTimeoutPipelineBusy() const return busy; } +bool IR_DecoderRaw::rxPipelineActive() const +{ + return rxLineActive() || rxTimeoutPipelineBusy(); +} + +uint8_t IR_DecoderRaw::currentRxMsgType() const +{ + if (i_dataBuffer < static_cast(msgBytes) * bitPerByte) + return 0xFFU; + return static_cast((dataBuffer[0] >> 5U) & IR_MASK_MSG_TYPE); +} + +void IR_DecoderRaw::noteRxTerminal(IR_RxTerminalReason reason, uint8_t msgType, bool hadLock) +{ + ++rxTerminalInfo.seq; + rxTerminalInfo.reason = reason; + rxTerminalInfo.msgType = msgType; + rxTerminalInfo.hadLock = hadLock; +} + void IR_DecoderRaw::listenStart() { if (rxTimeoutPipelineBusy()) return; - if (isReciveRaw && ((micros() - lastEdgeTime) > IR_timeout * 2U)) + const uint32_t nowUs = micros(); + if (isReciveRaw && ((nowUs - lastEdgeTime) > IR_timeout * 2U)) { #if defined(IRDEBUG_SERIAL_PACK) packTraceOnTimeoutOrAbort(true); #endif + if (isRecive) + { + const uint16_t expected = + (i_dataBuffer >= 8U) ? uint16_t(dataBuffer[0] & IR_MASK_MSG_INFO) : 0U; + rxBriefLog(RxBriefReason::Timeout, i_dataBuffer, expected, nowUs); + noteRxTerminal(IR_RxTerminalReason::LockedTimeout, currentRxMsgType(), true); + isRecive = false; + msgTypeReceive = 0; + } isReciveRaw = false; firstRX(); } @@ -403,6 +433,7 @@ inline void IR_DecoderRaw::checkTimeout() #endif const uint16_t expected = (i_dataBuffer >= 8U) ? uint16_t(dataBuffer[0] & IR_MASK_MSG_INFO) : 0U; rxBriefLog(RxBriefReason::Timeout, i_dataBuffer, expected, micros()); + noteRxTerminal(IR_RxTerminalReason::LockedTimeout, currentRxMsgType(), true); isRecive = false; // приём завершён msgTypeReceive = 0; // Как после listenStart(): без сброса isReciveRaw + firstRX() декодер остаётся @@ -473,6 +504,7 @@ void IR_DecoderRaw::tick() isSubBufferOverflow = false; listenStart(); checkTimeout(); + expirePreambleCandidateIfIdle(micros()); #if defined(IR_EDGE_TRACE) while (edgeTraceFlushChunk(Serial, 48) > 0) {} #endif @@ -480,6 +512,7 @@ void IR_DecoderRaw::tick() } // Если данных нет - ничего не делаем listenStart(); checkTimeout(); + expirePreambleCandidateIfIdle(micros()); #if IR_RX_BRIEF_LOG rxBriefFlushDeferredIsrLogs(); #endif @@ -778,6 +811,13 @@ void IR_DecoderRaw::writeToBuffer(bool bit, bool packTraceInvertFix) } if (isBufferOverflow || isPreamb || isWrongPack) { + const bool hadLock = + isRecive || isReciveRaw || preambleState == PreambleState::Locked; + const bool wasObservable = + hadLock || + (preambleState == PreambleState::Candidate && preambleWasObservable); + if (wasObservable) + noteRxTerminal(IR_RxTerminalReason::DecodeAbort, currentRxMsgType(), hadLock); // Как checkTimeout/listenStart: firstRX() сбрасывает буфер битов, преамбулу и // pulseFilterReset() — при IR_INPUT_MIN_PULSE_US > 0 иначе остаётся «хвост» в hold/filtered. isRecive = false; @@ -941,6 +981,9 @@ void IR_DecoderRaw::writeToBuffer(bool bit, bool packTraceInvertFix) #endif } #endif + noteRxTerminal(isAvailable ? IR_RxTerminalReason::FrameOk + : IR_RxTerminalReason::FrameCrcError, + currentRxMsgType(), true); #if defined(IRDEBUG_SERIAL_PACK) if (isAvailable) packTraceEmitEndOk(static_cast(packSize)); @@ -1583,6 +1626,7 @@ void IR_DecoderRaw::preambleResetToIdle() { preambleState = PreambleState::Idle; preambleGoodPeriods = 0; + preambleWasObservable = false; preambleMeanPeriod = 0; preambleCandidateLastEdgeTime = 0; preambleCandidateFirstRiseTime = 0; @@ -1597,6 +1641,10 @@ void IR_DecoderRaw::preambleStartCandidate(const FrontStorage &front) { preambleState = PreambleState::Candidate; preambleGoodPeriods = 0; + // The first post-silence rise already opens a potential frame epoch. + // Keep the line busy until that epoch locks or expires after real silence: + // even a badly distorted response may contain no coarse-valid rise period. + preambleWasObservable = true; preambleMeanPeriod = 0; preambleCandidateLastEdgeTime = front.time; preambleCandidateFirstRiseTime = front.time; @@ -1607,6 +1655,24 @@ void IR_DecoderRaw::preambleStartCandidate(const FrontStorage &front) isReciveRaw = false; } +void IR_DecoderRaw::expirePreambleCandidateIfIdle(uint32_t nowUs) +{ + if (preambleState != PreambleState::Candidate || rxTimeoutPipelineBusy()) + return; + + const uint32_t candTimeout = + IR_timeout * static_cast(IR_PREAMBLE_CANDIDATE_TIMEOUT_MULT); + if ((uint32_t)(nowUs - preambleCandidateLastEdgeTime) <= candTimeout) + return; + + const uint8_t goodPeriods = preambleGoodPeriods; + const bool wasObservable = preambleWasObservable; + rxBriefLog(RxBriefReason::Preamble, goodPeriods, 0, nowUs); + preambleResetToIdle(); + if (wasObservable) + noteRxTerminal(IR_RxTerminalReason::CandidateTimeout, 0xFFU, false); +} + bool IR_DecoderRaw::preambleProcessEdge(const FrontStorage &front) { const uint32_t longSilence = IR_timeout * 2U; @@ -1622,7 +1688,10 @@ 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) @@ -1630,7 +1699,10 @@ bool IR_DecoderRaw::preambleProcessEdge(const FrontStorage &front) if ((uint32_t)(front.time - preambleCandidateLastEdgeTime) > candTimeout) { rxBriefLog(RxBriefReason::Preamble, preambleGoodPeriods, 0, front.time); + if (preambleWasObservable) + noteRxTerminal(IR_RxTerminalReason::CandidateTimeout, 0xFFU, false); preambleStartCandidate(front); + return true; } preambleCandidateLastEdgeTime = front.time; @@ -1648,15 +1720,20 @@ bool IR_DecoderRaw::preambleProcessEdge(const FrontStorage &front) preambleCandidateFirstRiseTime = front.time; if (!preambleRisePeriodCoarseOk(period)) { + rxBriefLog(RxBriefReason::Preamble, preambleGoodPeriods, + irClampU16(period), front.time); preambleGoodPeriods = 0; preambleMeanPeriod = 0; - rxBriefLog(RxBriefReason::Preamble, 0, irClampU16(period), front.time); + // Keep preambleWasObservable sticky: this edge proves the medium is + // still active, but not that a possible physical frame has ended. + // Only silence timeout or a real locked terminal releases it. return true; } if (preambleGoodPeriods == 0) { preambleGoodPeriods = 1; + preambleWasObservable = true; preambleMeanPeriod = (uint16_t)period; } else @@ -1673,6 +1750,7 @@ bool IR_DecoderRaw::preambleProcessEdge(const FrontStorage &front) { rxBriefLog(RxBriefReason::Preamble, preambleGoodPeriods, irClampU16(period), front.time); preambleGoodPeriods = 1; + preambleWasObservable = true; preambleMeanPeriod = (uint16_t)period; } } diff --git a/IR_DecoderRaw.h b/IR_DecoderRaw.h index 19b7955..0f141cf 100644 --- a/IR_DecoderRaw.h +++ b/IR_DecoderRaw.h @@ -25,7 +25,30 @@ class Print; #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; +constexpr uint16_t IR_ResponseDelay = irproto::kMandatoryInterPacketQuietMs; + +/** Why the most recent observable receive attempt reached a terminal state. */ +enum class IR_RxTerminalReason : uint8_t +{ + None = 0, + FrameOk, + FrameCrcError, + LockedTimeout, + DecodeAbort, + CandidateTimeout +}; + +/** + * Monotonic receive-completion snapshot for schedulers polling after decoder.tick(). + * seq is allowed to wrap; consumers only compare it with their previous snapshot. + */ +struct IR_RxTerminalInfo +{ + uint32_t seq = 0; + IR_RxTerminalReason reason = IR_RxTerminalReason::None; + uint8_t msgType = 0xFFU; + bool hadLock = false; +}; class IR_Encoder; class IR_DecoderRaw : virtual public IR_FOX @@ -52,14 +75,28 @@ public: inline bool isOverflow() { return isBufferOverflow; }; // Буффер переполнился bool isSubOverflow(); volatile inline bool isReciving() { return isRecive; }; // Возвращает true, если происходит приём пакета - // Активность линии по СОСТОЯНИЮ (не по хардкод-длительности): кадр залочен ИЛИ формируется - // ВАЛИДНАЯ преамбула (>=1 совпавший по периоду фронт — отличает реальный кадр от одиночного - // шумового фронта, который лишь заводит Candidate, но не набирает goodPeriods). Для гейта заднего: - // «не стрелять, пока на линии идёт/формируется кадр (напр. ответ точки)». Аддитивно, const. + // Активность линии по СОСТОЯНИЮ (не по хардкод-длительности): кадр залочен ИЛИ открыт + // Candidate после первого post-silence rise. Даже сильно искажённый ответ может не дать ни + // одного coarse-valid периода, поэтому Candidate остаётся активным до lock/terminal либо + // доказанной тишины по candidate timeout. Для гейта заднего: «не стрелять, пока на линии + // идёт/формируется потенциальный кадр (напр. ответ точки)». Аддитивно, const. inline bool rxLineActive() const { return isRecive || - (preambleState == PreambleState::Candidate && preambleGoodPeriods >= 1U); + (preambleState == PreambleState::Candidate && preambleWasObservable); } + /** + * True while a real frame is active or ISR/filter work is still queued. + * This closes the one-loop ordering gap when Timer::tick() runs before + * decoder.tick(): a transmitter must not start while an unprocessed edge + * is already waiting in the receive pipeline. + */ + bool rxPipelineActive() const; + /** + * Last terminal RX transition. Updated from tick()/decode context, never from ISR. + * A frame that starts and finishes within one tick is observable through seq. + */ + IR_RxTerminalInfo rxLastTerminal() const { return rxTerminalInfo; } + uint32_t rxTerminalSeq() const { return rxTerminalInfo.seq; } // Объявленная длина ПРИНИМАЕМОГО кадра (байт) из ПЕРВОГО байта, если он уже принят и валиден; // иначе 0 (ещё не знаем / битый). До CRC это НЕДОВЕРЕННОЕ значение — потребитель, получив 0 // или чрезмерное, обязан брать rxMaxPackSize() (безопасно держать задний до конца макс.кадра). @@ -67,7 +104,9 @@ public: return (isRecive && packSize && !isWrongPack) ? packSize : 0; } // Протокольный МАКСИМУМ длины кадра (байт) — верхняя граница бюджета удержания заднего. - static constexpr uint16_t rxMaxPackSize() { return (uint16_t)dataByteSizeMax; } + static constexpr uint16_t rxMaxPackSize() { + return static_cast(irproto::kMaxWireFrameBytes); + } uint32_t pulseFilterDroppedByFilteredOverflow() const { return 0; } uint32_t pulseFilterDroppedByHoldOverflow() const { return pulseFilterDropHoldOverflow; } uint32_t pulseFilterDroppedGlitchPairs() const { return pulseFilterDropGlitchPairs; } @@ -127,6 +166,7 @@ private: volatile bool isSubBufferOverflow = false; bool isBufferOverflow = false; // Флаг переполнения буффера данных bool isWrongPack = false; // Флаг битого пакета + IR_RxTerminalInfo rxTerminalInfo; uint16_t riseSyncTime = bitTime; // Подстраиваемое время бита в мкс @@ -164,6 +204,10 @@ private: }; PreambleState preambleState = PreambleState::Idle; uint8_t preambleGoodPeriods = 0; + // Sticky potential-frame latch for one Candidate epoch. After one plausible + // rise period, coarse-invalid activity remains busy until proven silence; + // the scheduler's hard deadline may skip an optional tail under noise. + bool preambleWasObservable = false; uint16_t preambleMeanPeriod = 0; uint32_t preambleCandidateLastEdgeTime = 0; uint32_t preambleCandidateFirstRiseTime = 0; @@ -232,6 +276,9 @@ bool isReciveRaw = false; void preambleResetToIdle(); void preambleStartCandidate(const FrontStorage &front); bool preambleProcessEdge(const FrontStorage &front); + void expirePreambleCandidateIfIdle(uint32_t nowUs); + uint8_t currentRxMsgType() const; + void noteRxTerminal(IR_RxTerminalReason reason, uint8_t msgType, bool hadLock); /// @brief Проверка CRC. Проверяет len байт со значением crc, пришедшим в пакете /// @param len Длина в байтах проверяемых данных diff --git a/IR_Encoder.cpp b/IR_Encoder.cpp index c7c96c6..e28e360 100644 --- a/IR_Encoder.cpp +++ b/IR_Encoder.cpp @@ -1183,26 +1183,9 @@ uint8_t IR_Encoder::bitLow[2] = { uint32_t IR_Encoder::calculateSendTime(uint8_t packSize) const { - // Расчет времени отправки пакета в миллисекундах - - // Время преамбулы: preambPulse * 2 фронта * bitTakts тактов - uint32_t preambTime = preambPulse * 2 * bitTakts; - - // Время данных: количество бит * bitTakts тактов - uint32_t dataTime = packSize * 8 * bitTakts; - - // Время синхронизации: syncBits * 2 фронта * bitTakts тактов - uint32_t syncTime = syncBits * 2 * bitTakts; - - // Общее время в тактах - uint32_t totalTakts = preambTime + dataTime + syncTime; - - // Конвертируем в миллисекунды - // carrierPeriod - период несущей в микросекундах - // totalTakts * carrierPeriod / 1000 = время в миллисекундах - uint32_t sendTimeMs = (totalTakts * carrierPeriod) / 1000; - - return sendTimeMs; + // The TX FSM emits syncBits after every wire byte (including the last) + // and its preamble runs are preambToggle+1 logical ticks long. + return irproto::wireAirtimeMsCeil(packSize); } // Функции для тестирования времени отправки без фактической отправки diff --git a/IR_config.h b/IR_config.h index 31f439e..3aa1025 100644 --- a/IR_config.h +++ b/IR_config.h @@ -264,6 +264,194 @@ typedef uint16_t crc_t; #define bitTime (bitTakts * carrierPeriod) // Общая длительность бита #define tolerance 300U +namespace irproto +{ +/** Maximum complete frame length representable by the five header bits. */ +constexpr uint8_t kMaxWireFrameBytes = static_cast(IR_MASK_MSG_INFO); + +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 kAcceptFrameBytes = msgBytes + addrBytes + 1U + crcBytes; +constexpr uint8_t kRequestFrameBytes = msgBytes + addrBytes + addrBytes + crcBytes; + +constexpr uint8_t kMaxDataPayloadBytes = kMaxWireFrameBytes - kDataFrameOverheadBytes; +constexpr uint8_t kMaxBackPayloadBytes = kMaxWireFrameBytes - kBackFrameOverheadBytes; +constexpr uint8_t kMaxBackToPayloadBytes = kMaxWireFrameBytes - kBackToFrameOverheadBytes; + +/** Complete DATA frame size, or zero when payloadBytes cannot fit on wire. */ +constexpr uint8_t dataWireBytes(uint8_t payloadBytes) +{ + return payloadBytes <= kMaxDataPayloadBytes + ? static_cast(kDataFrameOverheadBytes + payloadBytes) + : 0U; +} + +/** Complete non-addressed BACK frame size, or zero when it cannot fit. */ +constexpr uint8_t backWireBytes(uint8_t payloadBytes) +{ + return payloadBytes <= kMaxBackPayloadBytes + ? static_cast(kBackFrameOverheadBytes + payloadBytes) + : 0U; +} + +/** Complete addressed BACK_TO frame size, or zero when it cannot fit. */ +constexpr uint8_t backToWireBytes(uint8_t payloadBytes) +{ + return payloadBytes <= kMaxBackToPayloadBytes + ? static_cast(kBackToFrameOverheadBytes + payloadBytes) + : 0U; +} + +/** Minimum complete frame size for a known message type; zero means reserved/unknown. */ +constexpr uint8_t minimumWireBytes(uint8_t msgType) +{ + return (msgType == IR_MSG_DATA_ACCEPT || msgType == IR_MSG_DATA_NOACCEPT) + ? kDataFrameOverheadBytes + : msgType == IR_MSG_BACK + ? kBackFrameOverheadBytes + : (msgType == IR_MSG_BACK_TO || msgType == IR_MSG_REQUEST) + ? kRequestFrameBytes + : msgType == IR_MSG_ACCEPT + ? kAcceptFrameBytes + : 0U; +} + +constexpr bool isTypedWireSizeValid(uint8_t msgType, uint8_t wireBytes) +{ + return minimumWireBytes(msgType) != 0U && + wireBytes >= minimumWireBytes(msgType) && + wireBytes <= kMaxWireFrameBytes; +} + +/* + * TX FSM timing contract. + * + * The FSM runs on 2*carrierFrec. The preamble contains preambPulse*2 + * constant runs; each run is preambToggle+1 ticks. Every data bit and every + * per-byte sync bit occupies bitTakts*2 ticks, independently of its value. + */ +constexpr uint32_t kTxLogicalClockHz = static_cast(carrierFrec) * 2U; +constexpr uint32_t kPreambleLogicalTicks = + static_cast(preambPulse * 2U) * static_cast(preambToggle + 1U); +constexpr uint32_t kEncodedBitLogicalTicks = static_cast(bitTakts * 2U); +constexpr uint32_t kWireByteLogicalTicks = + static_cast(bitPerByte + syncBits) * kEncodedBitLogicalTicks; + +constexpr uint32_t wireLogicalTicks(uint8_t wireBytes) +{ + return wireBytes != 0U && wireBytes <= kMaxWireFrameBytes + ? kPreambleLogicalTicks + static_cast(wireBytes) * kWireByteLogicalTicks + : 0U; +} + +constexpr uint32_t logicalTicksToUsCeil(uint32_t logicalTicks) +{ + return logicalTicks == 0U + ? 0U + : static_cast( + (static_cast(logicalTicks) * 1000000ULL + + static_cast(kTxLogicalClockHz) - 1ULL) / + static_cast(kTxLogicalClockHz)); +} + +constexpr uint32_t preambleAirtimeUsCeil() +{ + return logicalTicksToUsCeil(kPreambleLogicalTicks); +} + +// Decoder completion is published after the final data bit, while the TX FSM +// still emits the last byte's sync bits. Callers that schedule a following +// packet from a decoder terminal must include this physical tail. +constexpr uint32_t kTrailingByteSyncLogicalTicks = + static_cast(syncBits) * kEncodedBitLogicalTicks; + +constexpr uint32_t trailingByteSyncAirtimeUsCeil() +{ + return logicalTicksToUsCeil(kTrailingByteSyncLogicalTicks); +} + +/** Complete nominal on-air duration, rounded up to a whole microsecond. */ +constexpr uint32_t wireAirtimeUsCeil(uint8_t wireBytes) +{ + return logicalTicksToUsCeil(wireLogicalTicks(wireBytes)); +} + +constexpr uint32_t wireAirtimeMsCeil(uint8_t wireBytes) +{ + return wireAirtimeUsCeil(wireBytes) == 0U + ? 0U + : (wireAirtimeUsCeil(wireBytes) + 999U) / 1000U; +} + +/* Preserve the deployed library turn-around policy, but expose it by name. */ +constexpr uint16_t kMandatoryInterPacketQuietMs = + static_cast( + static_cast( + (static_cast(bitTime + tolerance) * + static_cast(bitPerByte + syncBits + 1U)) * + 2.7735) / + 1000U); +constexpr uint32_t kMandatoryInterPacketQuietUs = + static_cast(kMandatoryInterPacketQuietMs) * 1000U; + +constexpr uint32_t completedFrameTerminalToNextPacketGuardUs( + uint16_t requestedQuietMs) +{ + const uint16_t quietMs = requestedQuietMs > kMandatoryInterPacketQuietMs + ? requestedQuietMs + : kMandatoryInterPacketQuietMs; + return trailingByteSyncAirtimeUsCeil() + + static_cast(quietMs) * 1000U; +} + +constexpr uint32_t completedFrameTerminalToNextPacketGuardMsCeil( + uint16_t requestedQuietMs) +{ + return (completedFrameTerminalToNextPacketGuardUs(requestedQuietMs) + 999U) / + 1000U; +} + +constexpr uint16_t kDefaultTimingGuardPermille = 1150U; + +constexpr uint32_t addTimingGuardUs( + uint32_t durationUs, + uint16_t marginPermille = kDefaultTimingGuardPermille) +{ + return marginPermille == 0U + ? 0U + : static_cast( + (static_cast(durationUs) * marginPermille + 999ULL) / 1000ULL); +} + +/** Deadline for seeing enough preamble to know that a response has started. */ +constexpr uint32_t responseStartGuardUs( + uint16_t marginPermille = kDefaultTimingGuardPermille) +{ + return addTimingGuardUs(kMandatoryInterPacketQuietUs + preambleAirtimeUsCeil(), + marginPermille); +} + +/** Conservative deadline for receiving a complete response of maxWireBytes. */ +constexpr uint32_t responseFrameGuardUs( + uint8_t maxWireBytes, + uint16_t marginPermille = kDefaultTimingGuardPermille) +{ + return wireAirtimeUsCeil(maxWireBytes) == 0U + ? 0U + : addTimingGuardUs(kMandatoryInterPacketQuietUs + + wireAirtimeUsCeil(maxWireBytes), + marginPermille); +} + +static_assert(kMaxDataPayloadBytes == 24U, "DATA payload contract changed"); +static_assert(kMaxBackPayloadBytes == 26U, "BACK payload contract changed"); +static_assert(kPreambleLogicalTicks == 588U, "preamble timing contract changed"); +static_assert(kWireByteLogicalTicks == 814U, "wire-byte timing contract changed"); +static_assert(kTrailingByteSyncLogicalTicks == 222U, "trailing sync timing changed"); +static_assert(kMandatoryInterPacketQuietMs == 42U, "inter-packet quiet policy changed"); +} + constexpr uint16_t test_all_Time = bitTime; constexpr uint16_t test_all_Takts = bitTakts * 2; constexpr uint16_t test_hi = ((bitPauseTakts) * 2 - 0) + ((bitActiveTakts) * 2 - 0); diff --git a/tests/arduino_stubs/Arduino.h b/tests/arduino_stubs/Arduino.h new file mode 100644 index 0000000..717b0a7 --- /dev/null +++ b/tests/arduino_stubs/Arduino.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include + +class __FlashStringHelper; +#define F(value) reinterpret_cast(value) + +struct GPIO_TypeDef +{ + uint32_t BSRR = 0U; + uint32_t IDR = 0U; +}; + +using IRQn_Type = int; +enum TimerFormat_t : uint8_t { TICK_FORMAT = 0, MICROSEC_FORMAT, HERTZ_FORMAT }; + +constexpr uint8_t LOW = 0U; +constexpr uint8_t HIGH = 1U; +constexpr uint8_t INPUT = 0U; +constexpr uint8_t OUTPUT = 1U; + +class HardwareTimer +{ +public: + void pause() {} + void resume() {} + void setOverflow(uint32_t value, TimerFormat_t = TICK_FORMAT) { overflow_ = value; } + uint32_t getOverflow(TimerFormat_t = TICK_FORMAT) { return overflow_; } + uint32_t getPrescaleFactor() { return 1U; } + uint32_t getTimerClkFreq() { return 12000000U; } + void attachInterrupt(uint8_t, void (*)()) {} + +private: + uint32_t overflow_ = 1U; +}; + +inline GPIO_TypeDef arduinoStubPort; +inline GPIO_TypeDef *digitalPinToPort(uint8_t) { return &arduinoStubPort; } +inline uint16_t digitalPinToBitMask(uint8_t) { return 1U; } +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() {} + +inline uint32_t arduinoStubMicros = 0U; +inline uint32_t micros() { return arduinoStubMicros; } +inline uint32_t millis() { return arduinoStubMicros / 1000U; } + +class Print +{ +public: + template void print(const T &) {} + template void println(const T &) {} + void println() {} +}; + +using ArduinoSerialStub = Print; +inline ArduinoSerialStub Serial; diff --git a/tests/test_rx_terminal.cpp b/tests/test_rx_terminal.cpp new file mode 100644 index 0000000..eac10df --- /dev/null +++ b/tests/test_rx_terminal.cpp @@ -0,0 +1,386 @@ +#include "IR_config.h" +#include "RingBuffer.h" + +// Test only: inspect the decoder state machine without adding production hooks. +#define private public +#include "IR_DecoderRaw.h" +#undef private + +#include +#include +#include +#include + +namespace +{ +uint32_t decoderTimeoutUs(const IR_DecoderRaw &decoder) +{ + return static_cast(decoder.riseSyncTime + tolerance) * + static_cast(bitPerByte + syncBits + 1U); +} + +uint32_t candidateTimeoutUs(const IR_DecoderRaw &decoder) +{ + return decoderTimeoutUs(decoder) * + static_cast(IR_PREAMBLE_CANDIDATE_TIMEOUT_MULT); +} + +uint8_t crc8Local(const uint8_t *data, uint8_t start, uint8_t end, uint8_t poly) +{ + uint8_t crc = 0xFFU; + for (uint8_t i = start; i < end; ++i) + { + crc ^= data[i]; + for (uint8_t bit = 0; bit < 8U; ++bit) + crc = (crc & 0x80U) != 0U + ? static_cast((crc << 1U) ^ poly) + : static_cast(crc << 1U); + } + return crc; +} + +void primeObservableCandidate(IR_DecoderRaw &decoder, uint32_t lastEdgeUs) +{ + decoder.preambleState = IR_DecoderRaw::PreambleState::Candidate; + decoder.preambleGoodPeriods = 1U; + decoder.preambleWasObservable = true; + decoder.preambleMeanPeriod = bitTime; + decoder.preambleCandidateLastEdgeTime = lastEdgeUs; + decoder.preambleCandidateFirstRiseTime = lastEdgeUs; + decoder.preambleCandidateFirstRiseValid = true; + decoder.isPreamb = true; + decoder.isRecive = false; + decoder.isReciveRaw = false; +} + +void primeLocked(IR_DecoderRaw &decoder, uint8_t msgType, uint8_t wireBytes) +{ + decoder.preambleState = IR_DecoderRaw::PreambleState::Locked; + decoder.isPreamb = false; + decoder.isRecive = true; + decoder.isReciveRaw = true; + decoder.isWrongPack = false; + decoder.isBufferOverflow = false; + decoder.isAvailable = false; + decoder.packSize = wireBytes; + decoder.dataBuffer[0] = + static_cast((msgType << 5U) | (wireBytes & IR_MASK_MSG_INFO)); + decoder.i_dataBuffer = 8U; +} + +void verifyInitialSnapshot() +{ + IR_DecoderRaw decoder(1U, 42U, nullptr); + const IR_RxTerminalInfo terminal = decoder.rxLastTerminal(); + assert(terminal.seq == 0U); + assert(terminal.reason == IR_RxTerminalReason::None); + assert(terminal.msgType == 0xFFU); + assert(!terminal.hadLock); +} + +void verifyCandidateExpiresOnIdleTick() +{ + IR_DecoderRaw decoder(1U, 42U, nullptr); + const uint32_t lastEdgeUs = 1000U; + primeObservableCandidate(decoder, lastEdgeUs); + assert(decoder.rxLineActive()); + + arduinoStubMicros = lastEdgeUs + candidateTimeoutUs(decoder) + 1U; + decoder.tick(); + + const IR_RxTerminalInfo terminal = decoder.rxLastTerminal(); + assert(!decoder.rxLineActive()); + assert(decoder.preambleState == IR_DecoderRaw::PreambleState::Idle); + assert(terminal.seq == 1U); + assert(terminal.reason == IR_RxTerminalReason::CandidateTimeout); + assert(terminal.msgType == 0xFFU); + assert(!terminal.hadLock); + + ++arduinoStubMicros; + decoder.tick(); + assert(decoder.rxTerminalSeq() == terminal.seq); +} + +void emitEdge(IR_DecoderRaw &decoder, uint32_t timeUs, bool high) +{ + arduinoStubMicros = timeUs; + arduinoStubPort.IDR = high ? 1U : 0U; + decoder.isr(); + decoder.tick(); +} + +void queueEdge(IR_DecoderRaw &decoder, uint32_t timeUs, bool high) +{ + arduinoStubMicros = timeUs; + arduinoStubPort.IDR = high ? 1U : 0U; + decoder.isr(); +} + +void verifyCandidateIdleExpiryThroughPublicPipeline() +{ + IR_DecoderRaw decoder(1U, 42U, nullptr); + const uint32_t firstRiseUs = decoderTimeoutUs(decoder) * 2U + 1000U; + const uint32_t risePeriodUs = static_cast(bitTime) * 5U / 2U; + + emitEdge(decoder, firstRiseUs, true); + emitEdge(decoder, firstRiseUs + risePeriodUs / 2U, false); + emitEdge(decoder, firstRiseUs + risePeriodUs, true); + assert(decoder.rxLineActive()); + assert(decoder.rxTerminalSeq() == 0U); + + arduinoStubMicros = firstRiseUs + risePeriodUs + candidateTimeoutUs(decoder) + 1U; + decoder.tick(); + assert(!decoder.rxLineActive()); + assert(decoder.rxLastTerminal().reason == IR_RxTerminalReason::CandidateTimeout); + assert(decoder.rxTerminalSeq() == 1U); +} + +void verifyCoarseResetPublishesThroughBatchedPublicPipeline() +{ + IR_DecoderRaw decoder(1U, 42U, nullptr); + const uint32_t firstRiseUs = decoderTimeoutUs(decoder) * 2U + 1000U; + const uint32_t goodPeriodUs = static_cast(bitTime) * 5U / 2U; + const uint32_t badPeriodUs = static_cast(bitTime) * 4U; + + queueEdge(decoder, firstRiseUs, true); + queueEdge(decoder, firstRiseUs + goodPeriodUs / 2U, false); + queueEdge(decoder, firstRiseUs + goodPeriodUs, true); + queueEdge(decoder, firstRiseUs + goodPeriodUs + badPeriodUs / 2U, false); + queueEdge(decoder, firstRiseUs + goodPeriodUs + badPeriodUs, true); + decoder.tick(); + + assert(decoder.rxTerminalSeq() == 0U); + assert(decoder.rxLineActive()); + + // Continuing coarse-invalid edges below the timeout keep the potential + // frame busy. They manufacture no terminal; a Car gate reaches its bounded + // hard deadline and skips the optional tail instead of transmitting here. + const uint32_t nextRiseUs = + firstRiseUs + goodPeriodUs + badPeriodUs + badPeriodUs; + queueEdge(decoder, nextRiseUs - badPeriodUs / 2U, false); + queueEdge(decoder, nextRiseUs, true); + decoder.tick(); + assert(decoder.rxTerminalSeq() == 0U); + assert(decoder.rxLineActive()); + + arduinoStubMicros = nextRiseUs + candidateTimeoutUs(decoder) + 1U; + decoder.tick(); + const IR_RxTerminalInfo terminal = decoder.rxLastTerminal(); + assert(terminal.seq == 1U); + assert(terminal.reason == IR_RxTerminalReason::CandidateTimeout); + assert(!terminal.hadLock); + assert(!decoder.rxLineActive()); +} + +void verifyFreshCandidateWithOnlyCoarseInvalidEdgesStaysActive() +{ + IR_DecoderRaw decoder(1U, 42U, nullptr); + const uint32_t firstRiseUs = decoderTimeoutUs(decoder) * 2U + 1000U; + const uint32_t badPeriodUs = static_cast(bitTime) * 4U; + const uint32_t startDeadlineUs = firstRiseUs + 58000U; + const uint32_t hardDeadlineUs = firstRiseUs + 78000U; + + emitEdge(decoder, firstRiseUs, true); + assert(decoder.preambleState == IR_DecoderRaw::PreambleState::Candidate); + assert(decoder.preambleGoodPeriods == 0U); + assert(decoder.rxLineActive()); + assert(decoder.rxTerminalSeq() == 0U); + + uint32_t riseUs = firstRiseUs; + while (riseUs + badPeriodUs <= startDeadlineUs) + { + emitEdge(decoder, riseUs + badPeriodUs / 2U, false); + riseUs += badPeriodUs; + emitEdge(decoder, riseUs, true); + assert(decoder.preambleGoodPeriods == 0U); + assert(decoder.rxLineActive()); + assert(decoder.rxTerminalSeq() == 0U); + } + + arduinoStubMicros = startDeadlineUs; + decoder.tick(); + assert(decoder.rxLineActive()); + + while (riseUs + badPeriodUs <= hardDeadlineUs) + { + emitEdge(decoder, riseUs + badPeriodUs / 2U, false); + riseUs += badPeriodUs; + emitEdge(decoder, riseUs, true); + assert(decoder.preambleGoodPeriods == 0U); + assert(decoder.rxLineActive()); + assert(decoder.rxTerminalSeq() == 0U); + } + + arduinoStubMicros = hardDeadlineUs; + decoder.tick(); + assert(decoder.rxLineActive()); + + arduinoStubMicros = riseUs + candidateTimeoutUs(decoder); + decoder.tick(); + assert(decoder.rxLineActive()); + assert(decoder.rxTerminalSeq() == 0U); + + ++arduinoStubMicros; + decoder.tick(); + assert(!decoder.rxLineActive()); + assert(decoder.rxLastTerminal().reason == IR_RxTerminalReason::CandidateTimeout); + assert(decoder.rxTerminalSeq() == 1U); +} + +void verifyCandidateExpiryWaitsForPipelineDrain() +{ + IR_DecoderRaw decoder(1U, 42U, nullptr); + const uint32_t lastEdgeUs = 2000U; + primeObservableCandidate(decoder, lastEdgeUs); + decoder.pulseFilterHoldCount = 1U; + + const uint32_t expiredAt = lastEdgeUs + candidateTimeoutUs(decoder) + 1U; + decoder.expirePreambleCandidateIfIdle(expiredAt); + assert(decoder.rxLineActive()); + assert(decoder.rxTerminalSeq() == 0U); + + decoder.pulseFilterHoldCount = 0U; + decoder.expirePreambleCandidateIfIdle(expiredAt); + assert(!decoder.rxLineActive()); + assert(decoder.rxTerminalSeq() == 1U); +} + +void verifyCandidateExpiryAcrossMicrosWrap() +{ + IR_DecoderRaw decoder(1U, 42U, nullptr); + const uint32_t lastEdgeUs = std::numeric_limits::max() - 1000U; + primeObservableCandidate(decoder, lastEdgeUs); + + arduinoStubMicros = lastEdgeUs + candidateTimeoutUs(decoder) + 1U; + decoder.tick(); + assert(decoder.rxLastTerminal().reason == IR_RxTerminalReason::CandidateTimeout); + assert(!decoder.rxLineActive()); +} + +void verifyCandidateTimeoutBoundary() +{ + IR_DecoderRaw decoder(1U, 42U, nullptr); + const uint32_t lastEdgeUs = 2500U; + primeObservableCandidate(decoder, lastEdgeUs); + + arduinoStubMicros = lastEdgeUs + candidateTimeoutUs(decoder); + decoder.tick(); + assert(decoder.rxLineActive()); + assert(decoder.rxTerminalSeq() == 0U); + + ++arduinoStubMicros; + decoder.tick(); + assert(!decoder.rxLineActive()); + assert(decoder.rxLastTerminal().reason == IR_RxTerminalReason::CandidateTimeout); +} + +void verifyTimedOutCandidateRestartIsTerminal() +{ + IR_DecoderRaw decoder(1U, 42U, nullptr); + const uint32_t lastEdgeUs = 3000U; + primeObservableCandidate(decoder, lastEdgeUs); + + IR_DecoderRaw::FrontStorage nextEdge; + nextEdge.time = lastEdgeUs + candidateTimeoutUs(decoder) + 1U; + nextEdge.dir = true; + decoder.preambleProcessEdge(nextEdge); + + const IR_RxTerminalInfo terminal = decoder.rxLastTerminal(); + assert(terminal.seq == 1U); + assert(terminal.reason == IR_RxTerminalReason::CandidateTimeout); + assert(decoder.preambleState == IR_DecoderRaw::PreambleState::Candidate); + assert(decoder.preambleGoodPeriods == 0U); + assert(decoder.rxReasonCounters()[ + static_cast(IR_DecoderRaw::RxBriefReason::Preamble)] == 1U); +} + +void verifyLockedTimeoutPublishesHeaderTypeOnce() +{ + IR_DecoderRaw decoder(1U, 42U, nullptr); + primeLocked(decoder, IR_MSG_DATA_NOACCEPT, 10U); + decoder.lastEdgeTime = 5000U; + + arduinoStubMicros = decoder.lastEdgeTime + decoderTimeoutUs(decoder) * 2U + 1U; + decoder.tick(); + + const IR_RxTerminalInfo terminal = decoder.rxLastTerminal(); + assert(terminal.seq == 1U); + assert(terminal.reason == IR_RxTerminalReason::LockedTimeout); + assert(terminal.msgType == IR_MSG_DATA_NOACCEPT); + assert(terminal.hadLock); + assert(!decoder.rxLineActive()); + + ++arduinoStubMicros; + decoder.tick(); + assert(decoder.rxTerminalSeq() == terminal.seq); +} + +void verifyDecodeAbortPublishesTerminal() +{ + IR_DecoderRaw decoder(1U, 42U, nullptr); + primeLocked(decoder, IR_MSG_REQUEST, 7U); + decoder.isWrongPack = true; + decoder.writeToBuffer(false); + + const IR_RxTerminalInfo terminal = decoder.rxLastTerminal(); + assert(terminal.seq == 1U); + assert(terminal.reason == IR_RxTerminalReason::DecodeAbort); + assert(terminal.msgType == IR_MSG_REQUEST); + assert(terminal.hadLock); +} + +void finishBackFrame(IR_DecoderRaw &decoder, bool corruptCrc) +{ + constexpr uint8_t wireBytes = 5U; + primeLocked(decoder, IR_MSG_BACK, wireBytes); + + decoder.dataBuffer[1] = 0x12U; + decoder.dataBuffer[2] = 0x34U; + decoder.dataBuffer[3] = crc8Local(decoder.dataBuffer, 0U, 3U, poly1); + const uint8_t crcLow = crc8Local(decoder.dataBuffer, 0U, 4U, poly2); + const uint8_t finalBit = static_cast((crcLow & 1U) ^ (corruptCrc ? 1U : 0U)); + decoder.dataBuffer[4] = static_cast(crcLow & 0xFEU); + decoder.i_dataBuffer = wireBytes * bitPerByte - 1U; + decoder.bufBitPos = static_cast(decoder.i_dataBuffer); + decoder.nextControlBit = 0xFFFFU; + decoder.isData = true; + decoder.writeToBuffer(finalBit != 0U); +} + +void verifyCompleteFrameTerminalReasons() +{ + IR_DecoderRaw good(1U, 42U, nullptr); + finishBackFrame(good, false); + const IR_RxTerminalInfo ok = good.rxLastTerminal(); + assert(ok.seq == 1U); + assert(ok.reason == IR_RxTerminalReason::FrameOk); + assert(ok.msgType == IR_MSG_BACK); + assert(ok.hadLock); + + IR_DecoderRaw bad(1U, 42U, nullptr); + finishBackFrame(bad, true); + const IR_RxTerminalInfo crc = bad.rxLastTerminal(); + assert(crc.seq == 1U); + assert(crc.reason == IR_RxTerminalReason::FrameCrcError); + assert(crc.msgType == IR_MSG_BACK); + assert(crc.hadLock); +} +} + +int main() +{ + verifyInitialSnapshot(); + verifyCandidateExpiresOnIdleTick(); + verifyCandidateIdleExpiryThroughPublicPipeline(); + verifyCoarseResetPublishesThroughBatchedPublicPipeline(); + verifyFreshCandidateWithOnlyCoarseInvalidEdgesStaysActive(); + verifyCandidateExpiryWaitsForPipelineDrain(); + verifyCandidateExpiryAcrossMicrosWrap(); + verifyCandidateTimeoutBoundary(); + verifyTimedOutCandidateRestartIsTerminal(); + verifyLockedTimeoutPublishesHeaderTypeOnce(); + verifyDecodeAbortPublishesTerminal(); + verifyCompleteFrameTerminalReasons(); + std::cout << "IR RX terminal tests: OK\n"; + return 0; +} diff --git a/tests/test_timing_contract.cpp b/tests/test_timing_contract.cpp new file mode 100644 index 0000000..bae5241 --- /dev/null +++ b/tests/test_timing_contract.cpp @@ -0,0 +1,97 @@ +#include "IR_Encoder.h" +#include "IR_DecoderRaw.h" + +#include +#include +#include +#include + +// Link seams: these paths are not exercised by the pure host timing test. +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 +{ +static_assert(irproto::dataWireBytes(0U) == 7U, "empty DATA wire size changed"); +static_assert(irproto::dataWireBytes(3U) == 10U, "DATA wire size changed"); +static_assert(irproto::dataWireBytes(24U) == 31U, "maximum DATA wire size changed"); +static_assert(irproto::dataWireBytes(25U) == 0U, "oversized DATA must be rejected"); +static_assert(irproto::backWireBytes(1U) == 6U, "BACK wire size changed"); +static_assert(irproto::backWireBytes(26U) == 31U, "maximum BACK wire size changed"); +static_assert(irproto::backToWireBytes(24U) == 31U, "maximum BACK_TO wire size changed"); + +static_assert(irproto::wireLogicalTicks(6U) == 5472U, "6-byte tick count changed"); +static_assert(irproto::wireLogicalTicks(10U) == 8728U, "10-byte tick count changed"); +static_assert(irproto::wireLogicalTicks(31U) == 25822U, "31-byte tick count changed"); +static_assert(irproto::preambleAirtimeUsCeil() == 7737U, "preamble airtime changed"); +static_assert(irproto::trailingByteSyncAirtimeUsCeil() == 2922U, + "trailing sync airtime changed"); +static_assert(irproto::wireAirtimeUsCeil(6U) == 72000U, "6-byte airtime changed"); +static_assert(irproto::wireAirtimeUsCeil(10U) == 114843U, "10-byte airtime changed"); +static_assert(irproto::wireAirtimeUsCeil(31U) == 339764U, "31-byte airtime changed"); +static_assert(irproto::responseStartGuardUs() == 57198U, "response-start guard changed"); +static_assert(irproto::responseFrameGuardUs(6U) == 131100U, "response-frame guard changed"); +static_assert(irproto::completedFrameTerminalToNextPacketGuardUs(0U) == 44922U, + "mandatory physical post-terminal quiet changed"); +static_assert(irproto::completedFrameTerminalToNextPacketGuardMsCeil(0U) == 45U, + "mandatory post-terminal guard rounding changed"); +static_assert(irproto::completedFrameTerminalToNextPacketGuardMsCeil(60U) == 63U, + "configured post-terminal guard changed"); +static_assert(irproto::completedFrameTerminalToNextPacketGuardMsCeil(65535U) == + 65538U, + "large guard must not wrap uint16"); +static_assert(IR_DecoderRaw::rxMaxPackSize() == 31U, "RX max must be the wire max"); + +uint32_t sumLogicalTicks(const IrTxGateRun *runs, size_t count) +{ + uint32_t total = 0U; + for (size_t i = 0U; i < count; ++i) + total += runs[i].lenTicks; + return total; +} + +void verifyFormulaAgainstTxFsm() +{ + std::array frame{}; + std::array runs{}; + + for (uint8_t wireBytes = 1U; wireBytes <= irproto::kMaxWireFrameBytes; ++wireBytes) + { + for (uint8_t pattern = 0U; pattern < 4U; ++pattern) + { + for (uint8_t i = 0U; i < wireBytes; ++i) + { + frame[i] = pattern == 0U ? 0x00U + : pattern == 1U ? 0xFFU + : pattern == 2U ? static_cast((i & 1U) ? 0x55U : 0xAAU) + : static_cast(i * 73U + 19U); + } + const size_t count = IR_Encoder::buildGateRuns( + frame.data(), wireBytes, runs.data(), runs.size()); + assert(count != 0U); + assert(sumLogicalTicks(runs.data(), count) == irproto::wireLogicalTicks(wireBytes)); + } + } +} + +void verifyPublicSendTimeResults() +{ + IR_Encoder encoder(1U, 42U, nullptr, false); + uint8_t payload[26]{}; + + assert(encoder.testSendAccept(1U) == 72U); + assert(encoder.testSendTime(1U, payload, 3U) == 115U); + assert(encoder.testSendBack(payload, 26U) == 340U); +} +} + +int main() +{ + verifyFormulaAgainstTxFsm(); + verifyPublicSendTimeResults(); + std::cout << "IR timing contract tests: OK\n"; + return 0; +}