archive: freeze IR-protocol WIP before stepwise integration

This commit is contained in:
2026-08-28 13:27:46 +03:00
parent 96ffb91b97
commit 57db9c35b8
18 changed files with 1848 additions and 391 deletions

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