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..ace07fb --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,78 @@ +# 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 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: + 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" + + 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" 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..736e60e 100755 --- a/build.sh +++ b/build.sh @@ -37,6 +37,24 @@ 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 +# 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; } @@ -94,9 +112,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[@]}" "$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" "${vsx_inc[@]}" -c "$f" -o "$o"; then echo "build.sh: radioserver: $(basename "$f") did not compile for $os/$arch" >&2 exit 1 fi @@ -118,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 "$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 @@ -204,7 +222,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[@]}" "$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..821139f --- /dev/null +++ b/vendor/virtual-sx1262 @@ -0,0 +1 @@ +Subproject commit 821139fc42f1d15bd6133053912e4f4e2470cd79