From bec11db46a94dab75991e00cf124ac5f9b3e0cfb Mon Sep 17 00:00:00 2001 From: R0ck Date: Thu, 3 Sep 2026 16:27:12 +0100 Subject: [PATCH 1/7] take the SX1262 model from its own repository The chip model was a file in this variant, beside the firmware build, and three other things wanted it: QEMU's sx1262 device, Renode's C# peripheral and the simulator. They reached it by talking to radioserver over a socket, because that was the only way in. It now lives in MeshBench/virtual-sx1262 with a C ABI and its own tests, and comes in here as a submodule. Nothing about how this repository builds changes: the native node still links the class in process and radioserver still wraps it. What changes is that there is one copy and one history. Two copies of a chip model drift, and the day they do a native node and an emulated one stop being comparable, which is the whole reason both backends exist. The extracted model carries two fixes this copy did not have, both found while chasing why emulated boards received every advert and relayed none: - The detection flags were raised at RxDone, asserting that a carrier was present at the instant it stopped. MeshCore's isReceiving reads HEADER_VALID as "the channel is busy", so a repeater holding a packet to forward never saw a clear channel. They are raised as the signal arrives now and cleared when it goes. - A frame handed over while the chip was not listening waited in the inbox indefinitely, so a packet could arrive seconds after the air that carried it went quiet. It is bounded now, with a grace long enough for the driver's own standby-configure-SetRx re-arm and no longer. Both are covered by tests in that repository, which is the point of it having a bench of its own: these took a four minute emulated boot to see from here. Checked: radioserver and simple_repeater both build for linux-amd64 against the submodule. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q9HbD44EKWWTRYgxbFGxf6 --- .gitmodules | 3 + NOTICE.md | 5 + README.md | 2 +- build.sh | 17 +- variants/host/VirtualSX1262.cpp | 453 -------------------------------- variants/host/VirtualSX1262.h | 242 ----------------- vendor/virtual-sx1262 | 1 + 7 files changed, 23 insertions(+), 700 deletions(-) create mode 100644 .gitmodules delete mode 100644 variants/host/VirtualSX1262.cpp delete mode 100644 variants/host/VirtualSX1262.h create mode 160000 vendor/virtual-sx1262 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..42f16dc --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "vendor/virtual-sx1262"] + path = vendor/virtual-sx1262 + url = https://github.com/MeshBench/virtual-sx1262.git diff --git a/NOTICE.md b/NOTICE.md index 221f64f..69fe119 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -6,6 +6,11 @@ licensed, copyright Scott Powell / rippleradios.com. Their applications are compiled **unmodified**; everything this repository adds is the host variant in `variants/host/` and the bridge in `bridge/`, also MIT. +The SX1262 model is no longer here. It is +[MeshBench/virtual-sx1262](https://github.com/MeshBench/virtual-sx1262), MIT, +vendored as a submodule at `vendor/virtual-sx1262`, because QEMU and Renode need +the same chip and a second copy of a chip model drifts. + The commit each release was built from is recorded in that release's body. Third-party code compiled in: diff --git a/README.md b/README.md index f3c1164..1a93ad3 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Worth being exact about, because the interesting bugs live on the boundary. | MeshCore application and mesh logic | **real**, unmodified | | MeshCore radio driver — `CustomSX1262`, `RadioLibWrapper` | **real**, unmodified | | RadioLib 7.6.0 — the version MeshCore pins | **real**, vendored in `vendor/`, unmodified | -| The SX1262 chip | **ours** — `variants/host/VirtualSX1262` | +| The SX1262 chip | **ours**, `vendor/virtual-sx1262` (shared with QEMU and Renode) | | Arduino, board, filesystem, RTC, sensors, RNG | **ours** — `variants/host/` | | The air | whatever drives the bridge | diff --git a/build.sh b/build.sh index 960c41d..421596d 100755 --- a/build.sh +++ b/build.sh @@ -37,6 +37,15 @@ fi root=$(cd "$(dirname "$0")" && pwd) variant="$root/variants/host" +# The SX1262 model is shared with QEMU, Renode and the simulator, so it lives +# in its own repository rather than in this variant. A submodule keeps one copy +# and one history: two copies of a chip model drift, and the moment they do a +# native node and an emulated one stop being comparable. +vsx="$root/vendor/virtual-sx1262" +if [ ! -f "$vsx/src/VirtualSX1262.cpp" ]; then + echo "build.sh: vendor/virtual-sx1262 is empty; run: git submodule update --init" >&2 + exit 1 +fi src="${MESHCORE:-}/examples/$role" if [ "$role" != radioserver ]; then [ -d "$src" ] || { echo "no such role: $role (looked in $MESHCORE/examples)" >&2; exit 2; } @@ -94,9 +103,9 @@ if [ "$role" = radioserver ]; then bin="$out/radioserver-$os-$arch$exe" rs_flags=("${STD:--std=c++17}" -O2 -w ${extra_flags[@]+"${extra_flags[@]}"}) rs_objs=() - for f in "$variant/VirtualSX1262.cpp" "$root/bridge/radioserver.cpp"; do + for f in "$vsx/src/VirtualSX1262.cpp" "$root/bridge/radioserver.cpp"; do o="$obj/$(basename "${f%.cpp}").o" - if ! "$CXX" "${rs_flags[@]}" -I "$variant" -c "$f" -o "$o"; then + if ! "$CXX" "${rs_flags[@]}" -I "$variant" -I "$vsx/src" -c "$f" -o "$o"; then echo "build.sh: radioserver: $(basename "$f") did not compile for $os/$arch" >&2 exit 1 fi @@ -118,7 +127,7 @@ bin="$out/meshcore-$role-$os-$arch$exe" # so MeshCore's own radio driver runs against the library it was written for # rather than against a stand-in of ours. radiolib="$root/vendor/RadioLib/src" -inc=(-I "$variant" -I "$MESHCORE/src" -I "$src" -I "$CRYPTO" -I "$MESHCORE/lib/ed25519" -I "$radiolib") +inc=(-I "$variant" -I "$vsx/src" -I "$MESHCORE/src" -I "$src" -I "$CRYPTO" -I "$MESHCORE/lib/ed25519" -I "$radiolib") # -O2, not -Os: this build exists to be fast, and it is also the build whose # results get compared against the emulated one. Optimisation level is exactly # the kind of difference that would make that comparison meaningless if it @@ -204,7 +213,7 @@ done # Test programs in the variant directory are skipped: they carry their own # main(), so linking one into a role produces "multiple definition of main" and # takes down every role at once, which reads as the role not porting. -for f in "$variant"/*.cpp "$root/bridge/main.cpp"; do +for f in "$variant"/*.cpp "$vsx/src/VirtualSX1262.cpp" "$root/bridge/main.cpp"; do case "$(basename "$f")" in *_test.cpp) continue ;; esac o="$obj/$(basename "${f%.cpp}").o" # The bridge is the one file that includes windows.h - through winsock2.h - diff --git a/variants/host/VirtualSX1262.cpp b/variants/host/VirtualSX1262.cpp deleted file mode 100644 index b784dc4..0000000 --- a/variants/host/VirtualSX1262.cpp +++ /dev/null @@ -1,453 +0,0 @@ -#include "VirtualSX1262.h" - -#include - -#include - -// Opcodes, from the SX1262 datasheet. Only the ones RadioLib issues for a LoRa -// link are handled; anything else is acknowledged and ignored, which is the -// right default - an unhandled command should cost detail, not wedge a driver. -namespace { -constexpr uint8_t kSetSleep = 0x84; -constexpr uint8_t kSetStandby = 0x80; -constexpr uint8_t kSetTx = 0x83; -constexpr uint8_t kSetRx = 0x82; -constexpr uint8_t kSetCad = 0xC5; -constexpr uint8_t kSetRfFrequency = 0x86; -constexpr uint8_t kSetPacketType = 0x8A; -constexpr uint8_t kGetPacketType = 0x11; -constexpr uint8_t kSetTxParams = 0x8E; -constexpr uint8_t kSetModulationParams = 0x8B; -constexpr uint8_t kSetPacketParams = 0x8C; -constexpr uint8_t kSetCadParams = 0x88; -constexpr uint8_t kCalibrate = 0x89; -constexpr uint8_t kSetBufferBase = 0x8F; -constexpr uint8_t kWriteBuffer = 0x0E; -constexpr uint8_t kReadBuffer = 0x1E; -constexpr uint8_t kWriteRegister = 0x0D; -constexpr uint8_t kReadRegister = 0x1D; -constexpr uint8_t kSetDioIrqParams = 0x08; -constexpr uint8_t kGetIrqStatus = 0x12; -constexpr uint8_t kClearIrqStatus = 0x02; -constexpr uint8_t kGetRxBufferStatus = 0x13; -constexpr uint8_t kGetPacketStatus = 0x14; -constexpr uint8_t kGetStatus = 0xC0; -constexpr uint8_t kGetDeviceErrors = 0x17; -constexpr uint8_t kClearDeviceErrors = 0x07; -constexpr uint8_t kGetRssiInst = 0x15; - -// IRQ bits. -constexpr uint16_t kIrqTxDone = 1 << 0; -constexpr uint16_t kIrqRxDone = 1 << 1; -constexpr uint16_t kIrqPreambleDetected = 1 << 2; -constexpr uint16_t kIrqSyncWordValid = 1 << 3; -constexpr uint16_t kIrqHeaderValid = 1 << 4; -constexpr uint16_t kIrqHeaderErr = 1 << 5; -constexpr uint16_t kIrqCrcErr = 1 << 6; -constexpr uint16_t kIrqCadDone = 1 << 7; -constexpr uint16_t kIrqCadDetected = 1 << 8; -constexpr uint16_t kIrqTimeout = 1 << 9; - -// How far into a transmission a receiver locks onto the preamble, and how much -// later the header is demodulated. Both are in symbols and become milliseconds -// through the current modem settings, because that is what makes them behave -// like a radio rather than like a constant: at SF12 a preamble takes an age and -// at SF7 it is gone in a blink, and MeshCore's listen-before-talk times exactly -// that. -constexpr double kPreambleSymbols = 4.0; -constexpr double kHeaderSymbols = 12.0; -} // namespace - -VirtualSX1262::VirtualSX1262() { - // The chip has to introduce itself. RadioLib reads sixteen bytes at - // REG_VERSION_STRING and compares the first six against the chip type; a chip - // that answers zeroes is retried ten times and then reported as not present, - // which is exactly how this first failed to boot. - // - // The expected string for an SX1262 is "SX1261". That is not a mistake here - // or in RadioLib: the part genuinely reports its family that way, and a - // virtual chip that answered "SX1262" would be the one telling the lie. - static const char kVersion[] = "SX1261"; - memcpy(®s_[0x0320], kVersion, 6); - - // Receiver gain, which MeshCore reads back to report whether boosted mode is - // on. Power-on default is the non-boosted value. - regs_[0x08AC] = 0x94; - -#ifdef VIRTUAL_SX1262_STUCK_IRQ_MS - // A deliberately misbehaving chip, built as its own firmware variant. - // - // Real SX1262s sometimes latch the detection flags and refuse to clear them - - // the "4 second lock-up" MeshCore's release notes describe. A driver that - // trusts those flags then believes the channel is busy for ever and stops - // transmitting; 1.17 exists to time them out and recover. On a chip that - // behaves, 1.16 and 1.17 are indistinguishable, which is exactly what a - // twelve-run sweep found. This variant makes the fault reproducible, so the - // difference between the two versions can be measured rather than assumed. - stuckIrqMs_ = VIRTUAL_SX1262_STUCK_IRQ_MS; -#endif -} - -void VirtualSX1262::tick(uint64_t nowMs) { - // Time with the busy flags up, which is what the firmware is reacting to. - if ((irq_ & (kIrqPreambleDetected | kIrqHeaderValid)) && nowMs > lastBusyTickMs_) { - busyMs_ += (uint32_t)(nowMs - lastBusyTickMs_); - } - lastBusyTickMs_ = nowMs; - nowMs_ = nowMs; - - // The fault, on builds compiled to have it: a detection interrupt that fires - // with nothing on the air and then stays up. - // - // Clearing it works perfectly well - which is the whole point. A driver that - // clears the flag when it has been set implausibly long recovers; one that - // trusts it believes the channel is busy for ever and stops transmitting. - // That is the difference between MeshCore 1.16 and 1.17, and it cannot be - // seen on a chip that never lies. - if (stuckIrqMs_ > 0 && mode_ == 1 && nowMs_ >= nextSpuriousMs_) { - irq_ |= kIrqPreambleDetected; - spuriousRaises_++; - nextSpuriousMs_ = nowMs_ + stuckIrqMs_; - } - - if (mode_ == 1) deliverPending(); - - // Preamble and header detection, in receive mode only. A node cannot hear - // anything while its own transmitter is keyed - that is half duplex, and the - // engine already reports the channel as clear to a node that is transmitting. - if (mode_ != 1) return; - - const double symbolMs = (double)(1u << sf_) / (bwKHz_ > 0 ? bwKHz_ : 250.0); - if (channelBusy_) { - const double sinceMs = (double)(nowMs_ - busySinceMs_); - if (!preambleRaised_ && sinceMs >= kPreambleSymbols * symbolMs) { - irq_ |= kIrqPreambleDetected; - preambleRaised_ = true; - preambleRaises_++; - } - if (!headerRaised_ && sinceMs >= kHeaderSymbols * symbolMs) { - irq_ |= kIrqHeaderValid | kIrqSyncWordValid; - headerRaised_ = true; - } - } -} - -void VirtualSX1262::setChannelBusy(bool busy) { - if (busy && !channelBusy_) busySinceMs_ = nowMs_; - if (!busy) { - // The air went quiet: the signal that raised the preamble/header detection - // flags is gone, so a well-behaved chip's detection state clears with it. - // RxDone stays - that is a completed packet the firmware still has to read - - // but the "a carrier is present" flags must not outlive the carrier, or the - // driver's own channel-activity check (CustomSX1262::isReceiving reads - // HEADER_VALID) reports the air busy for the ~4 s it takes that check to time - // the stale flag out. On a mesh whose adverts arrive every few seconds that - // window never closes, and a repeater that has a packet to forward never - // sees a clear channel to send it on - which is why an emulated board - // received every advert and relayed none. - // - // The deliberately-misbehaving STUCK_IRQ variant is exempt: leaving its - // flags latched past the signal is the whole point of it, because that is - // the real "4 second lock-up" MeshCore 1.17's driver exists to recover from. - preambleRaised_ = false; - headerRaised_ = false; - if (stuckIrqMs_ == 0) { - irq_ &= (uint16_t)~(kIrqPreambleDetected | kIrqHeaderValid | kIrqSyncWordValid); - } - } - channelBusy_ = busy; -} - -void VirtualSX1262::transmitFinished() { - if (mode_ == 2) { - irq_ |= kIrqTxDone; - mode_ = 0; - } -} - -void VirtualSX1262::deliverPending() { - if (inbox.empty()) return; - auto& f = inbox.front(); - rxLen_ = (uint8_t)(f.size() > 255 ? 255 : f.size()); - memcpy(&buffer_[rxBase_], f.data(), rxLen_); - inbox.pop_front(); - irq_ |= kIrqRxDone; - // A delivered frame implies its preamble and header were seen, whatever the - // detector had got to. Without this a short packet can arrive before the - // detection thresholds elapse, and the driver sees a payload it never saw - // start. - irq_ |= kIrqPreambleDetected | kIrqHeaderValid | kIrqSyncWordValid; -} - -void VirtualSX1262::startRx() { mode_ = 1; } - -void VirtualSX1262::startTx() { - mode_ = 2; - // Latched here because this is the instant it decides anything. RadioLib - // raises the RF switch into transmit before issuing SetTx, so by now the line - // carries the answer to "does this transmission reach the antenna". - femAtTx_ = femEnabled_; - hasTransmitted_ = true; - pendingTx.assign(&buffer_[txBase_], &buffer_[txBase_] + txLenForSend_); - hasPendingTx = true; -} - -void VirtualSX1262::startCad() { - mode_ = 3; - // CAD answers in one go: the chip listens for a couple of symbols and reports. - irq_ |= kIrqCadDone; - if (channelBusy_) irq_ |= kIrqCadDetected; - mode_ = 0; -} - -uint32_t VirtualSX1262::estAirtimeMs(int lenBytes) const { - // Semtech's own airtime formula, from the parameters the firmware programmed. - const double bwHz = bwKHz_ * 1000.0; - const double tSym = (double)(1u << sf_) / bwHz; - const int de = (sf_ >= 11) ? 1 : 0; - const int crc = 1, header = 0, crDen = cr_; - double num = 8.0 * lenBytes - 4.0 * sf_ + 28 + 16 * crc - 20 * header; - double den = 4.0 * (sf_ - 2 * de); - double payloadSyms = 8 + fmax(ceil(num / den) * crDen, 0.0); - double t = (preambleSyms_ + 4.25 + payloadSyms) * tSym; - return (uint32_t)(t * 1000.0 + 0.5); -} - -void VirtualSX1262::applyModulation(const uint8_t* p) { - sf_ = p[0]; - // Bandwidth is an index in the datasheet's table; only the values MeshCore - // uses are mapped, and anything else keeps the current setting rather than - // silently becoming zero and making airtime infinite. - switch (p[1]) { - case 0x00: bwKHz_ = 7.81f; break; - case 0x08: bwKHz_ = 10.42f; break; - case 0x01: bwKHz_ = 15.63f; break; - case 0x09: bwKHz_ = 20.83f; break; - case 0x02: bwKHz_ = 31.25f; break; - case 0x0A: bwKHz_ = 41.67f; break; - case 0x03: bwKHz_ = 62.5f; break; - case 0x04: bwKHz_ = 125.0f; break; - case 0x05: bwKHz_ = 250.0f; break; - case 0x06: bwKHz_ = 500.0f; break; - default: break; - } - cr_ = p[2] ? (4 + p[2]) : cr_; -} - -void VirtualSX1262::applyPacketParams(const uint8_t* p) { - preambleSyms_ = ((uint32_t)p[0] << 8) | p[1]; - txLenForSend_ = p[3]; -} - -// Commands that answer with data rather than just a status byte. -// -// All of them are reads, and none of them changes any state, which is what -// makes it safe to evaluate a growing prefix on every byte. -static bool returnsData(uint8_t op) { - switch (op) { - case kReadBuffer: - case kReadRegister: - case kGetIrqStatus: - case kGetRxBufferStatus: - case kGetPacketStatus: - case kGetStatus: - case kGetDeviceErrors: - case kGetPacketType: - return true; - default: - return false; - } -} - -void VirtualSX1262::beginTransaction() { - txn_.clear(); - inTxn_ = true; -} - -uint8_t VirtualSX1262::transferByte(uint8_t out) { - if (!inTxn_) beginTransaction(); - txn_.push_back(out); - - const size_t i = txn_.size() - 1; - if (returnsData(txn_[0])) { - // Evaluated against a padded command, not against the bytes received so - // far. Several getters fill their reply only once the buffer is long - // enough - GetPacketStatus writes in[2..4] under a length guard - so a - // prefix would answer zero for a byte the buffer path answers properly. - // Which byte carries which value does not depend on the length, only the - // guard does, so padding gives the same answer the full command would. - uint8_t scratch[260] = {0}; - uint8_t padded[260] = {0}; - const size_t have = txn_.size() < sizeof(padded) ? txn_.size() : sizeof(padded); - memcpy(padded, txn_.data(), have); - const size_t n = have > 16 ? have : 16; - runCommand(padded, n, scratch); - return i < sizeof(scratch) ? scratch[i] : 0; - } - // Everything else answers with the status byte in position 1 and nothing - // else, exactly as the buffer path does. - return i == 1 ? 0x22 : 0x00; -} - -void VirtualSX1262::endTransaction() { - inTxn_ = false; - if (txn_.empty()) return; // a chip select with no command in it - if (returnsData(txn_[0])) { // already evaluated, and it changed nothing - txn_.clear(); - return; - } - uint8_t scratch[260] = {0}; - const size_t n = txn_.size() < sizeof(scratch) ? txn_.size() : sizeof(scratch); - runCommand(txn_.data(), n, scratch); - txn_.clear(); -} - -void VirtualSX1262::spiTransfer(const uint8_t* out, size_t len, uint8_t* in) { - if (len == 0) return; - memset(in, 0, len); - runCommand(out, len, in); -} - -void VirtualSX1262::runCommand(const uint8_t* out, size_t len, uint8_t* in) { - const uint8_t op = out[0]; - // Byte 1 of every reply is the status byte. RadioLib reads data from - // buffIn[cmdLen + 1], so a one-byte command puts its first data byte at - // in[2] - which is where the datasheet puts it too. - auto status = [&](uint8_t v) { if (len > 1) in[1] = v; }; - status(0x22); // standby, command completed - - switch (op) { - case kSetStandby: mode_ = 0; break; - case kSetSleep: mode_ = 0; break; - case kSetRx: startRx(); break; - case kSetTx: startTx(); break; - case kSetCad: startCad(); break; - - case kSetRfFrequency: - if (len >= 5) { - uint32_t raw = ((uint32_t)out[1] << 24) | ((uint32_t)out[2] << 16) | - ((uint32_t)out[3] << 8) | out[4]; - // The datasheet's PLL step: freq = raw * 32e6 / 2^25. - freqHz_ = (uint32_t)((double)raw * 32000000.0 / 33554432.0); - } - break; - - // The PA drive level, in dBm as a signed byte. Stored where it was - // discarded before: the engine used to take transmit power from the board - // profile alone, which is a datasheet figure and not a claim about what - // this firmware asked for. - case kSetTxParams: if (len >= 2) txPowerDbm_ = (int8_t)out[1]; break; - - // Calibration returns the receive gain register to its reset default. - // - // Modelled rather than ignored because it is the mechanism behind the fault - // MeshCore 1.17.1 fixed. sx126xResetAGC() runs a full CALIBRATE_ALL and then - // re-applies the compile-time SX126X_RX_BOOSTED_GAIN macro - so a variant - // that does not define the macro, generic-e22 among them, re-applies nothing - // and boosted gain is gone until the node reboots. The firmware's own prefs - // and its CLI go on reporting the setting the operator chose, so there is no - // symptom anywhere except sensitivity. - // - // "May" is doing work in MeshCore's own comment for this - SX126xReset.h - // calls it "RX settings that calibration may reset" - so what this models is - // the chip behaving the way the firmware's authors assumed. Section 9.6 of - // the SX126x datasheet is the authority, and it should be reconciled against - // this before any sensitivity figure derived from it is published. - case kCalibrate: regs_[kRegRxGain] = kRxGainPowerSaving; break; - - case kSetModulationParams: if (len >= 4) applyModulation(&out[1]); break; - case kSetPacketParams: if (len >= 7) applyPacketParams(&out[1]); break; - case kSetBufferBase: if (len >= 3) { txBase_ = out[1]; rxBase_ = out[2]; } break; - - case kWriteBuffer: - if (len >= 2) { - uint8_t offset = out[1]; - for (size_t i = 2; i < len && (size_t)offset + (i - 2) < sizeof(buffer_); i++) { - buffer_[offset + (i - 2)] = out[i]; - } - } - break; - - case kReadBuffer: - if (len >= 3) { - uint8_t offset = out[1]; - for (size_t i = 3; i < len; i++) { - size_t idx = (size_t)offset + (i - 3); - in[i] = idx < sizeof(buffer_) ? buffer_[idx] : 0; - } - } - break; - - case kWriteRegister: - if (len >= 3) { - uint16_t addr = ((uint16_t)out[1] << 8) | out[2]; - for (size_t i = 3; i < len && (size_t)addr + (i - 3) < sizeof(regs_); i++) { - regs_[addr + (i - 3)] = out[i]; - } - } - break; - - case kReadRegister: - if (len >= 4) { - uint16_t addr = ((uint16_t)out[1] << 8) | out[2]; - for (size_t i = 4; i < len; i++) { - size_t idx = (size_t)addr + (i - 4); - in[i] = idx < sizeof(regs_) ? regs_[idx] : 0; - } - } - break; - - case kSetDioIrqParams: - if (len >= 9) { - irqMask_ = ((uint16_t)out[1] << 8) | out[2]; - } - break; - - case kGetIrqStatus: - // [op][nop][status][irq hi][irq lo] - irqReads_++; - if (irq_ & (kIrqPreambleDetected | kIrqHeaderValid)) busyReads_++; - if (len >= 4) in[2] = (uint8_t)(irq_ >> 8); - if (len >= 5) in[3] = (uint8_t)(irq_ & 0xFF); - break; - - case kClearIrqStatus: - if (len >= 3) { - uint16_t clear = ((uint16_t)out[1] << 8) | out[2]; - irq_ &= (uint16_t)~clear; - if (clear & kIrqPreambleDetected) preambleRaised_ = false; - if (clear & kIrqHeaderValid) headerRaised_ = false; - } - break; - - case kGetRxBufferStatus: - // [op][nop][status][payload len][start ptr] - if (len >= 4) in[2] = rxLen_; - if (len >= 5) in[3] = rxBase_; - break; - - case kGetPacketStatus: - // RSSI and SNR as the datasheet encodes them, from what the engine - // measured - the one place a virtual chip can be exactly right. - // A byte is answerable as soon as the master clocks it: to receive - // in[2] it sends three bytes, not four. The guards were each one too - // strict, so signalRssiPkt came back as zero for the five-byte command - // RadioLib actually issues - which the streaming path exposed, because - // there the chip answers whatever is clocked and cannot consult a length - // it has not been told. - if (len >= 3) in[2] = (uint8_t)(-rssi_ * 2); - if (len >= 4) in[3] = (uint8_t)(int8_t)(snr_ * 4); - if (len >= 5) in[4] = (uint8_t)(-rssi_ * 2); - break; - - case kGetRssiInst: - if (len >= 3) in[2] = (uint8_t)(-rssi_ * 2); - break; - - case kGetStatus: break; - case kGetPacketType: if (len >= 3) in[2] = 0x01; break; // LoRa - case kGetDeviceErrors: break; - case kClearDeviceErrors: break; - default: break; // acknowledged and ignored - } -} diff --git a/variants/host/VirtualSX1262.h b/variants/host/VirtualSX1262.h deleted file mode 100644 index 2591cc1..0000000 --- a/variants/host/VirtualSX1262.h +++ /dev/null @@ -1,242 +0,0 @@ -#pragma once - -// A virtual SX1262: what answers RadioLib's SPI transactions. -// -// Everything above this line is the real software stack - MeshCore's -// Dispatcher, its CustomSX1262 driver, and RadioLib itself. This is where that -// stack meets the simulator, and it is deliberately the only place that knows -// both. -// -// The interesting part is not the command table, it is the IRQ flags. MeshCore -// decides whether the channel is busy by reading PREAMBLE_DETECTED and -// HEADER_VALID and timing how long they have been set - that is what 1.17's -// listen-before-talk rewrite changed, and it cannot be exercised by a shim that -// never sets the bits. So the flags are driven from the engine's view of what -// is on the air at this node, at the simulated instant each becomes true. -// -// What is modelled, and what is not: -// * modelled: the command set RadioLib issues for a LoRa link, the IRQ -// register, the data buffer, RSSI/SNR from the engine, CAD. -// * not modelled: BUSY (see SimHal), calibration timing, the analogue front -// end. A virtual chip is a model of a chip; the stack above it is real, the -// silicon is not. - -#include -#include - -#include -#include - -class VirtualSX1262 { - public: - VirtualSX1262(); - - // ---- the simulator's side, called by the bridge ---- - - // Frames the firmware has handed to the radio, waiting to go on the air. - bool hasPendingTx = false; - std::vector pendingTx; - - // Frames the engine has delivered to this node. - std::deque> inbox; - - // The engine says our waveform has left the antenna. The node cannot know - // this: how long a transmission occupied the channel is a property of the - // samples the engine generated. - void transmitFinished(); - - // Is another station on the air here, loud enough to detect? The engine is - // the only thing that can answer, and this is where that answer becomes - // something a driver can read. - void setChannelBusy(bool busy); - - // What the engine measured for the last frame it delivered. - void setLastSignal(float rssiDbm, float snrDb) { rssi_ = rssiDbm; snr_ = snrDb; } - - // ---- what the firmware has configured this radio to be ---- - // - // The board profile says what the hardware can do; these say what the - // firmware actually asked for, and the two diverge whenever the firmware has - // a bug. MeshCore 1.17.1 fixed one of each kind - receive gain reverting - // after an AGC reset, and a transmit-enable line that never went high - and - // neither was visible from outside the chip because nothing here was read. - // - // Reported raw rather than interpreted. Deciding that a given gain register - // value is worth 2 dB is the engine's business; the chip's business is to say - // what the register holds. - uint8_t rxGainReg() const { return regs_[kRegRxGain]; } - int8_t txPowerDbm() const { return txPowerDbm_; } - - // The front-end module's transmit-enable line, driven by the firmware as an - // ordinary GPIO rather than through the chip. - // - // Defaults to false because that is what a pin nobody has driven reads as. A - // board with no FEM has no such pin, and the engine knows which boards those - // are from the profile - answering true here to be kind would hide exactly - // the fault this exists to catch. - bool femEnabled() const { return femEnabled_; } - void setFemEnabled(bool on) { femEnabled_ = on; } - - // Whether the module was switched in at the moment transmission started. - // - // This, not femEnabled(), is what decides how much power left the board. The - // line is supposed to be low while the node listens - RadioLib drives it from - // its RF-switch table and only raises it just before SetTx - so reading the - // live level would dock a node for the ordinary state of receiving. What the - // T096 fault broke was the line being high at the moment it mattered, and - // that is a property of the transmission rather than of the node. - // Until the node has transmitted once there is no answer, and "the module was - // out" is the wrong one to invent: it would dock a node for not having spoken - // yet. hasTransmitted separates the two. - bool femAtTx() const { return femAtTx_; } - bool hasTransmitted() const { return hasTransmitted_; } - - // The rest of what this radio has been configured to be. Nothing above the - // chip can see any of it today, which is how a node can be set to the wrong - // spreading factor, the wrong bandwidth or a gain mode its operator did not - // choose, and look identical from outside to one that is right. - // - // Reported wholesale rather than field by field as each becomes interesting: - // the expensive part is the wire format, and widening it once costs less than - // widening it five times. - uint8_t mode() const { return mode_; } // 0 standby, 1 rx, 2 tx, 3 cad - int sf() const { return sf_; } - float bwKHz() const { return bwKHz_; } - int cr() const { return cr_; } - uint32_t preambleSyms() const { return preambleSyms_; } - uint32_t freqHz() const { return freqHz_; } - uint16_t irqMask() const { return irqMask_; } - uint16_t irqFlags() const { return irq_; } - - // Receive gain, at the address the datasheet gives it, with the two values - // RadioLib writes. Power saving is the reset default and what this chip comes - // up holding. - // - // Worth knowing why watching this register is enough. MeshCore does not use - // RadioLib's own resetAGC(), which restores the mode from cached runtime - // state; it has its own in helpers/radiolib/SX126xReset.h, which re-applies - // the compile-time SX126X_RX_BOOSTED_GAIN macro and so discards whatever the - // operator set at runtime. The firmware therefore writes the register itself - // after every AGC reset - we do not have to model what calibration does to - // silicon to see the fault, only to record what was written. - static constexpr uint16_t kRegRxGain = 0x08AC; - static constexpr uint8_t kRxGainBoosted = 0x96; - static constexpr uint8_t kRxGainPowerSaving = 0x94; - - // Advance internal timers to this simulated instant. - void tick(uint64_t nowMs); - - bool irqAsserted() const { return (irq_ & irqMask_) != 0; } - - // ---- what the firmware's channel decisions look like from below ---- - // - // MeshCore decides whether to defer by reading the IRQ register. Counting - // those reads, and how long the busy flags were up, is the only way to tell - // "the mesh is genuinely busy" from "our chip cries busy too readily" - and - // the second is a fault in the simulator that would look exactly like a - // finding about the firmware. - uint32_t irqReads() const { return irqReads_; } - uint32_t busyReads() const { return busyReads_; } - uint32_t busyMs() const { return busyMs_; } - uint32_t preambleRaises() const { return preambleRaises_; } - uint32_t spuriousRaises() const { return spuriousRaises_; } - - // Latch a flag once raised, as a misbehaving chip does. - // - // This is the fault MeshCore 1.17 exists to survive: a preamble or header - // flag that sets and never clears, so a driver that trusts it believes the - // channel is busy for ever and stops transmitting. 1.16 trusts it; 1.17 times - // it out. Without a way to reproduce the fault, the difference between them - // cannot be observed at all - which is exactly what twelve runs showed. - void setStuckIrqMs(uint32_t ms) { stuckIrqMs_ = ms; } - - // Airtime, from the parameters the firmware actually programmed. - uint32_t estAirtimeMs(int lenBytes) const; - - int spreadingFactor() const { return sf_; } - float bandwidthKHz() const { return bwKHz_; } - int codingRate() const { return cr_; } - - // ---- RadioLib's side ---- - - void spiTransfer(const uint8_t* out, size_t len, uint8_t* in); - - // The same chip, clocked a byte at a time. - // - // For an emulated MCU the SPI controller moves single bytes and the chip - // select delimits a command, so there is no buffer to hand over. These three - // rebuild one: the bytes are accumulated between the chip select falling and - // rising, answered from the same command decoder, and acted on once at the - // end - which is when a real chip acts on a command it has finished - // receiving. - void beginTransaction(); - uint8_t transferByte(uint8_t out); - void endTransaction(); - - private: - void runCommand(const uint8_t* out, size_t len, uint8_t* in); - void applyModulation(const uint8_t* p); - void applyPacketParams(const uint8_t* p); - void startRx(); - void startTx(); - void startCad(); - void deliverPending(); - - // Chip state. - uint8_t buffer_[256] = {0}; - uint8_t rxLen_ = 0; - uint16_t irq_ = 0; // IRQ status register - uint16_t irqMask_ = 0; // what is allowed to raise DIO1 - uint8_t mode_ = 0; // 0 standby, 1 rx, 2 tx, 3 cad - uint8_t txBase_ = 0, rxBase_ = 0; - // How many bytes the firmware said the next transmission is, from - // SetPacketParams. The buffer is 256 bytes and only this many are on the air. - uint8_t txLenForSend_ = 0; - uint8_t regs_[0x1000] = {0}; - - // Modem parameters, as programmed by the firmware. - int sf_ = 10; - float bwKHz_ = 250; - int cr_ = 5; - uint32_t preambleSyms_ = 16; - uint32_t freqHz_ = 869525000; - // What SetTxParams asked the PA for. Not the same as what leaves the - // antenna, which is this plus the board's front end - and the front end only - // contributes if the firmware remembered to switch it on. - // - // INT8_MIN until the firmware has said, because 0 dBm is a level a radio can - // legitimately be set to and a node that has not configured itself yet must - // not be read as one that chose silence. - int8_t txPowerDbm_ = -128; - bool femEnabled_ = false; - bool femAtTx_ = false; - bool hasTransmitted_ = false; - - // What the air is doing here, from the engine. - bool channelBusy_ = false; - uint64_t busySinceMs_ = 0; - // The transaction in flight on the streaming path, and whether anything - // arrived in it: a chip select that falls and rises with no bytes is not a - // command and must not be applied. - std::vector txn_; - bool inTxn_ = false; - - bool preambleRaised_ = false; - bool headerRaised_ = false; - - float rssi_ = -100, snr_ = 0; - uint64_t nowMs_ = 0; - - // Instrumentation. - uint32_t irqReads_ = 0; - uint32_t busyReads_ = 0; - uint32_t busyMs_ = 0; - uint32_t preambleRaises_ = 0; - uint64_t lastBusyTickMs_ = 0; - - // Fault injection: how long a raised flag refuses to clear. 0 is a chip that - // behaves. - uint32_t stuckIrqMs_ = 0; - uint64_t nextSpuriousMs_ = 0; - uint32_t spuriousRaises_ = 0; -}; diff --git a/vendor/virtual-sx1262 b/vendor/virtual-sx1262 new file mode 160000 index 0000000..77f5f60 --- /dev/null +++ b/vendor/virtual-sx1262 @@ -0,0 +1 @@ +Subproject commit 77f5f60231ec86a10fa1dbd478e2ab60cf657599 From 1d6b507e6de61ab83773321ec25a0cba3b50d861 Mon Sep 17 00:00:00 2001 From: R0ck Date: Thu, 3 Sep 2026 16:33:44 +0100 Subject: [PATCH 2/7] vendor: the model with the airtime-derived receive window Picks up the fix to how long an undeliverable frame waits for the chip to start listening. It was a fixed ten milliseconds, which is a simulator-time constant imposed on hosts that advance the clock at their own rate, and it could drop a frame the firmware would have received. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q9HbD44EKWWTRYgxbFGxf6 --- vendor/virtual-sx1262 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/virtual-sx1262 b/vendor/virtual-sx1262 index 77f5f60..dd5c164 160000 --- a/vendor/virtual-sx1262 +++ b/vendor/virtual-sx1262 @@ -1 +1 @@ -Subproject commit 77f5f60231ec86a10fa1dbd478e2ab60cf657599 +Subproject commit dd5c16498e9d8e31b15e8a9d8a204c809fe392d4 From 1fa8b89d264aac9d6de32a215309efef8623068e Mon Sep 17 00:00:00 2001 From: R0ck Date: Thu, 3 Sep 2026 16:48:08 +0100 Subject: [PATCH 3/7] vendor: the model in three files, and SetRx as a delivery point Two changes upstream matter here. The model is split into the datasheet, the radio and the wire, which is why build.sh globs its sources rather than naming one: a library splitting a file should not break a build in a repository its author is not in. And a frame is now handed over when the firmware arms its receiver as well as on a tick, which is the fault that made an emulated board relay less often than a native one - delivery on a tick alone made every reception depend on a tick landing after the SetRx, and radioserver's ticks arrive from the engine over a socket rather than from the same process. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q9HbD44EKWWTRYgxbFGxf6 --- build.sh | 7 +++++-- vendor/virtual-sx1262 | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/build.sh b/build.sh index 421596d..e047295 100755 --- a/build.sh +++ b/build.sh @@ -46,6 +46,9 @@ if [ ! -f "$vsx/src/VirtualSX1262.cpp" ]; then echo "build.sh: vendor/virtual-sx1262 is empty; run: git submodule update --init" >&2 exit 1 fi +# Globbed rather than listed, so the library splitting a file does not break +# this build with an undefined symbol in a repository its author is not in. +vsx_src=("$vsx"/src/*.cpp) src="${MESHCORE:-}/examples/$role" if [ "$role" != radioserver ]; then [ -d "$src" ] || { echo "no such role: $role (looked in $MESHCORE/examples)" >&2; exit 2; } @@ -103,7 +106,7 @@ if [ "$role" = radioserver ]; then bin="$out/radioserver-$os-$arch$exe" rs_flags=("${STD:--std=c++17}" -O2 -w ${extra_flags[@]+"${extra_flags[@]}"}) rs_objs=() - for f in "$vsx/src/VirtualSX1262.cpp" "$root/bridge/radioserver.cpp"; do + for f in "${vsx_src[@]}" "$root/bridge/radioserver.cpp"; do o="$obj/$(basename "${f%.cpp}").o" if ! "$CXX" "${rs_flags[@]}" -I "$variant" -I "$vsx/src" -c "$f" -o "$o"; then echo "build.sh: radioserver: $(basename "$f") did not compile for $os/$arch" >&2 @@ -213,7 +216,7 @@ done # Test programs in the variant directory are skipped: they carry their own # main(), so linking one into a role produces "multiple definition of main" and # takes down every role at once, which reads as the role not porting. -for f in "$variant"/*.cpp "$vsx/src/VirtualSX1262.cpp" "$root/bridge/main.cpp"; do +for f in "$variant"/*.cpp "${vsx_src[@]}" "$root/bridge/main.cpp"; do case "$(basename "$f")" in *_test.cpp) continue ;; esac o="$obj/$(basename "${f%.cpp}").o" # The bridge is the one file that includes windows.h - through winsock2.h - diff --git a/vendor/virtual-sx1262 b/vendor/virtual-sx1262 index dd5c164..efde3e9 160000 --- a/vendor/virtual-sx1262 +++ b/vendor/virtual-sx1262 @@ -1 +1 @@ -Subproject commit dd5c16498e9d8e31b15e8a9d8a204c809fe392d4 +Subproject commit efde3e9d5f00d025d02d1cff2f020fa4b92d50f1 From 0a7910ababbaace691b8fb6c21efc09113d7a536 Mon Sep 17 00:00:00 2001 From: R0ck Date: Thu, 3 Sep 2026 16:55:12 +0100 Subject: [PATCH 4/7] check out the submodule, and check a pull request at all Two faults, one of which would have broken the next release. build.yml's two compiling jobs checked out the repository without submodules. With the model vendored that is a bare gitlink, so build.sh would stop on its own guard and every platform's radioserver would fail to build - loudly, which is the guard working, but at release time. And nothing verified a pull request here: build.yml runs on a tag or a button only. That is how a change to build.sh and a new submodule got as far as a merge with no evidence they compiled. pr.yml builds radioserver on one platform, which is the whole of what this repository adds around the vendored model and needs no MeshCore checkout, so it costs about a minute. The submodule's presence is asserted before the build rather than inside it, so an empty submodule reads as an empty submodule instead of as a compile error in a file that is not there. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q9HbD44EKWWTRYgxbFGxf6 --- .github/workflows/build.yml | 10 ++++++++ .github/workflows/pr.yml | 46 +++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 .github/workflows/pr.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3e58c5f..b03b369 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -156,6 +156,11 @@ jobs: if: needs.discover.outputs.any == 'true' steps: - uses: actions/checkout@v4 + with: + # The SX1262 model is a submodule now. Without this the + # checkout is a bare gitlink and build.sh stops on its own + # guard, which is the loud failure but still a failure. + submodules: recursive - name: Toolchain if: matrix.target.apt != '' @@ -338,6 +343,11 @@ jobs: runs-on: ${{ matrix.target.runs_on }} steps: - uses: actions/checkout@v4 + with: + # The SX1262 model is a submodule now. Without this the + # checkout is a bare gitlink and build.sh stops on its own + # guard, which is the loud failure but still a failure. + submodules: recursive - name: Toolchain if: matrix.target.apt != '' diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..4d57b2e --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,46 @@ +# What a pull request here is checked against. +# +# build.yml only ever ran on a tag or a button, so nothing verified a change +# before it landed. That is how a change to build.sh and a new submodule reached +# a merge with no evidence they compiled: the guard in build.sh would have said +# so loudly, but only on the next release, which is the wrong moment to find out. +# +# Deliberately one platform and one role. radioserver is the whole of what this +# repository adds around the vendored chip model - the model itself, the bridge's +# socket, and the build wiring - and it needs no MeshCore checkout, so it +# compiles in about a minute. Cross-compiling for four platforms is the release's +# job, not a reviewer's. +name: pull request + +on: + pull_request: + workflow_dispatch: + +jobs: + radioserver: + name: radioserver compiles + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # The SX1262 model lives in MeshBench/virtual-sx1262. Without this the + # checkout is a bare gitlink and there is nothing to compile. + submodules: recursive + + - name: The submodule is actually here + run: | + set -euo pipefail + # Checked separately from the build so an empty submodule reads as an + # empty submodule rather than as a compile error in a missing file. + test -f vendor/virtual-sx1262/src/VirtualSX1262.cpp + echo "vendor/virtual-sx1262 at $(git -C vendor/virtual-sx1262 rev-parse --short HEAD)" + + - name: Build radioserver + run: ./build.sh radioserver out + + - name: It is an executable, not an empty file + run: | + set -euo pipefail + bin=$(find out -name 'radioserver-*' -type f) + test -x "$bin" + file "$bin" From b0baf7d1f696ffdc0b139f08f963c2904b72337e Mon Sep 17 00:00:00 2001 From: R0ck Date: Thu, 3 Sep 2026 16:56:06 +0100 Subject: [PATCH 5/7] put the model's other include root on the path The glob added in the last commit picks up abi.cpp, which includes the C ABI header from include/ rather than the class header from src/, and only src/ was on the include path. Caught by the pull request check in that same commit, on its first run, which is the argument for having it. Both roots are on the path now. Carrying the ABI in these binaries costs a few unreferenced symbols and means radioserver already exports the surface QEMU and Renode link against. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q9HbD44EKWWTRYgxbFGxf6 --- build.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/build.sh b/build.sh index e047295..736e60e 100755 --- a/build.sh +++ b/build.sh @@ -49,6 +49,12 @@ fi # Globbed rather than listed, so the library splitting a file does not break # this build with an undefined symbol in a repository its author is not in. vsx_src=("$vsx"/src/*.cpp) +# Both of the model's include roots. src/ holds the C++ class the host variant +# uses directly; include/ holds the C ABI, which abi.cpp needs and which the glob +# above therefore drags in. Carrying the ABI in these binaries costs a few +# unreferenced symbols and means radioserver already exports the surface QEMU and +# Renode link against. +vsx_inc=(-I "$vsx/src" -I "$vsx/include") src="${MESHCORE:-}/examples/$role" if [ "$role" != radioserver ]; then [ -d "$src" ] || { echo "no such role: $role (looked in $MESHCORE/examples)" >&2; exit 2; } @@ -108,7 +114,7 @@ if [ "$role" = radioserver ]; then rs_objs=() for f in "${vsx_src[@]}" "$root/bridge/radioserver.cpp"; do o="$obj/$(basename "${f%.cpp}").o" - if ! "$CXX" "${rs_flags[@]}" -I "$variant" -I "$vsx/src" -c "$f" -o "$o"; then + if ! "$CXX" "${rs_flags[@]}" -I "$variant" "${vsx_inc[@]}" -c "$f" -o "$o"; then echo "build.sh: radioserver: $(basename "$f") did not compile for $os/$arch" >&2 exit 1 fi @@ -130,7 +136,7 @@ bin="$out/meshcore-$role-$os-$arch$exe" # so MeshCore's own radio driver runs against the library it was written for # rather than against a stand-in of ours. radiolib="$root/vendor/RadioLib/src" -inc=(-I "$variant" -I "$vsx/src" -I "$MESHCORE/src" -I "$src" -I "$CRYPTO" -I "$MESHCORE/lib/ed25519" -I "$radiolib") +inc=(-I "$variant" "${vsx_inc[@]}" -I "$MESHCORE/src" -I "$src" -I "$CRYPTO" -I "$MESHCORE/lib/ed25519" -I "$radiolib") # -O2, not -Os: this build exists to be fast, and it is also the build whose # results get compared against the emulated one. Optimisation level is exactly # the kind of difference that would make that comparison meaningless if it From 2991ca969c708e52cc8b11b93a8bc5117b45f73f Mon Sep 17 00:00:00 2001 From: R0ck Date: Thu, 3 Sep 2026 16:57:38 +0100 Subject: [PATCH 6/7] check the native half too, not just radioserver radioserver and a native role link the same vendored model through different include paths and different source lists, and the glob two commits ago broke exactly one of them. Checking one and not the other would have found that fault by luck rather than by design. The native job takes MeshCore at its default branch rather than a pinned commit. This job asks whether our own wiring compiles; a second MeshCore pin to keep honest buys nothing, and a break caused by upstream moving is worth seeing on a pull request as well as at release time. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q9HbD44EKWWTRYgxbFGxf6 --- .github/workflows/pr.yml | 42 +++++++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4d57b2e..ace07fb 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -5,11 +5,12 @@ # a merge with no evidence they compiled: the guard in build.sh would have said # so loudly, but only on the next release, which is the wrong moment to find out. # -# Deliberately one platform and one role. radioserver is the whole of what this -# repository adds around the vendored chip model - the model itself, the bridge's -# socket, and the build wiring - and it needs no MeshCore checkout, so it -# compiles in about a minute. Cross-compiling for four platforms is the release's -# job, not a reviewer's. +# Deliberately one platform, and the two halves of what this repository actually +# builds: radioserver, which wraps the vendored chip model behind a socket, and +# one native role, which links the same model into MeshCore itself. They exercise +# different include paths and different source lists over the same library, and +# the first change to use a glob for those sources broke exactly one of them. +# Cross-compiling for four platforms is the release's job, not a reviewer's. name: pull request on: @@ -44,3 +45,34 @@ jobs: bin=$(find out -name 'radioserver-*' -type f) test -x "$bin" file "$bin" + + native: + name: a native role compiles + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + # The same refs build.yml uses, at whatever upstream's default branch is: + # this job asks whether our own wiring compiles, and pinning a MeshCore + # commit here would be a second pin to keep honest for no gain. A break + # caused by upstream moving is worth seeing on a pull request too. + - name: MeshCore and its crypto dependency + run: | + set -euo pipefail + git clone --depth 1 --filter=blob:none https://github.com/meshcore-dev/MeshCore MeshCore + git clone --depth 1 https://github.com/rweather/arduinolibs arduinolibs + + - name: Build simple_repeater + env: + MESHCORE: ${{ github.workspace }}/MeshCore + CRYPTO: ${{ github.workspace }}/arduinolibs/libraries/Crypto + run: ./build.sh simple_repeater out + + - name: It is an executable, not an empty file + run: | + set -euo pipefail + bin=$(find out -name 'meshcore-simple_repeater-*' -type f) + test -x "$bin" + file "$bin" From a899e37fcee802bf4a960f1c5b238d666b80f845 Mon Sep 17 00:00:00 2001 From: R0ck Date: Thu, 3 Sep 2026 17:11:39 +0100 Subject: [PATCH 7/7] vendor: DIO1 gated on the DIO1 mask The fault that made an emulated board hear every advert and relay one in three. SetDioIrqParams carries an IRQ enable mask and then a DIO1 routing mask; the model read the first and used it for the pin. RadioLib enables HeaderValid in the status register but routes only RxDone to DIO1, so the pin went high twelve symbols into the carrier and was still high when RxDone arrived - no rising edge, and RadioLib attaches that pin on the rising edge, so recvRaw never ran. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q9HbD44EKWWTRYgxbFGxf6 --- vendor/virtual-sx1262 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/virtual-sx1262 b/vendor/virtual-sx1262 index efde3e9..821139f 160000 --- a/vendor/virtual-sx1262 +++ b/vendor/virtual-sx1262 @@ -1 +1 @@ -Subproject commit efde3e9d5f00d025d02d1cff2f020fa4b92d50f1 +Subproject commit 821139fc42f1d15bd6133053912e4f4e2470cd79