From 603513df177cc079c2e48e09d8a5d4048e30cb4f Mon Sep 17 00:00:00 2001 From: R0ck Date: Sat, 15 Aug 2026 00:33:37 +0100 Subject: [PATCH] the chip reports what it has been configured to be kRadioStats carried four counters. It now carries the radio: receive gain register, transmit power, front-end line, mode, spreading factor, coding rate, frequency, bandwidth, preamble, IRQ mask and flags. The engine reads it on length, so a peer that stops at sixteen bytes still parses and an older node still runs. The reason is that a board profile is a datasheet claim about hardware and not a claim about the firmware running on it. Until the chip reported its own state there was no way to tell a node whose firmware configured it correctly from one whose firmware did not - which is exactly the pair of faults MeshCore 1.17.1 fixed, receive gain reverting after an AGC reset and a transmit-enable line that never went high. The front-end line is three states rather than a flag, and it is latched at the moment of transmission rather than read live. RadioLib holds the line low while receiving and raises it just before SetTx, so the current level says only whether the node happens to be listening; reading it live docked an idle node 25 dB for the ordinary state of waiting. A node that has not transmitted has not answered the question, and says so. bridge/main.cpp writes the same payload in the same order. Two writers of one wire format is the arrangement RadioServerSX1262 exists to avoid and is only tolerable while they stay identical - an emulated node and a native one reporting different shapes would make every comparison between them a comparison of our own code. main.cpp reports the front end as "not answered" rather than "module out", because a native node has no such pin wired and reporting the line low would be true of the pin and false about the board. VirtualSX1262 also handles two commands it had defined and never acted on. SetTxParams is why transmit power was readable at all. Calibrate returns the gain register to its reset default, which is the mechanism behind the 1.17.1 fault: MeshCore's own AGC reset re-applies a compile-time macro over whatever the operator set at runtime, and on a variant without that macro - this one included - re-applies nothing at all. kSetFem carries the enable line in from the emulator. It arrives as a command rather than over SPI because the firmware drives it as an ordinary GPIO: the module sits beside the radio, not inside it, and the chip has no idea whether its output reaches an antenna. Co-Authored-By: Claude Opus 5 --- bridge/main.cpp | 34 +++++++++++++- bridge/radioserver.cpp | 69 ++++++++++++++++++++++++---- variants/host/VirtualSX1262.cpp | 29 ++++++++++++ variants/host/VirtualSX1262.h | 81 +++++++++++++++++++++++++++++++++ 4 files changed, 202 insertions(+), 11 deletions(-) diff --git a/bridge/main.cpp b/bridge/main.cpp index 9fd124c..891fcf1 100644 --- a/bridge/main.cpp +++ b/bridge/main.cpp @@ -291,13 +291,45 @@ int main(int argc, char** argv) { // What the chip has seen, alongside the acknowledgement. Cheap, and // it turns "the mesh went quiet" into a question with an answer: // was the channel busy, or did our chip only say so? + // + // The same payload radioserver sends, in the same order. Two writers + // of one wire format is the arrangement RadioServerSX1262 was written + // to avoid, and it is only tolerable while they are kept identical: + // an emulated node and a native one reporting different shapes would + // make every comparison between them a comparison of our own code. auto& c = sim_hal.chip(); uint32_t st[4] = {c.irqReads(), c.busyReads(), c.busyMs(), c.spuriousRaises()}; - uint8_t sb[16]; + uint8_t sb[37]; for (int k = 0; k < 4; k++) { sb[k*4+0] = (uint8_t)(st[k] >> 24); sb[k*4+1] = (uint8_t)(st[k] >> 16); sb[k*4+2] = (uint8_t)(st[k] >> 8); sb[k*4+3] = (uint8_t)st[k]; } + auto put32 = [&](int at, uint32_t v) { + sb[at+0] = (uint8_t)(v >> 24); sb[at+1] = (uint8_t)(v >> 16); + sb[at+2] = (uint8_t)(v >> 8); sb[at+3] = (uint8_t)v; + }; + auto put16 = [&](int at, uint16_t v) { + sb[at+0] = (uint8_t)(v >> 8); sb[at+1] = (uint8_t)v; + }; + sb[16] = c.rxGainReg(); + sb[17] = (uint8_t)c.txPowerDbm(); + sb[18] = c.femEnabled() ? 1 : 0; + sb[19] = c.mode(); + sb[20] = (uint8_t)c.sf(); + sb[21] = (uint8_t)c.cr(); + put32(22, c.freqHz()); + put32(26, (uint32_t)(c.bwKHz() * 1000.0f + 0.5f)); + put16(30, (uint16_t)c.preambleSyms()); + put16(32, c.irqMask()); + put16(34, c.irqFlags()); + // Always "not answered" here, never "the module was out". + // + // A native node has no front-end module wired: SimHal owns an array + // of pins and nothing drives an enable line into it. Reporting the + // line as low would be true of the pin and false about the board, and + // the engine would dock every native node on a board that has a + // module for a fault it did not have. + sb[36] = 0; writeMsg(gFd, kRadioStats, sb, sizeof(sb)); } if (!writeMsg(gFd, kAck, payload.data(), 4)) goto done; diff --git a/bridge/radioserver.cpp b/bridge/radioserver.cpp index 367ddbe..b84d1fa 100644 --- a/bridge/radioserver.cpp +++ b/bridge/radioserver.cpp @@ -86,6 +86,11 @@ enum : uint8_t { // and a pin nothing drives is a node that configures its radio and then // sits there for ever. kReadIrq = 0x05, + // The board's front-end module enable line, with its level in the next + // byte. It arrives here rather than over SPI because the firmware drives it + // as an ordinary GPIO - the module is beside the radio, not inside it, and + // the chip has no idea whether its output reaches an antenna. + kSetFem = 0x06, }; // The engine side, shared with the simulator's Go half and with bridge/main.cpp. @@ -136,6 +141,52 @@ bool writeMsg(sock_t fd, uint8_t kind, const uint8_t* p, size_t n) { return n == 0 || writeAll(fd, p, n); } +void put32(uint8_t* p, uint32_t v) { + p[0] = (uint8_t)(v >> 24); p[1] = (uint8_t)(v >> 16); + p[2] = (uint8_t)(v >> 8); p[3] = (uint8_t)v; +} +void put16(uint8_t* p, uint16_t v) { + p[0] = (uint8_t)(v >> 8); p[1] = (uint8_t)v; +} + +// Sixteen bytes of counters, then everything this radio has been configured to +// be. The engine reads it on length, so a peer that stops at sixteen still +// parses - and bridge/main.cpp writes the same payload in the same order, +// because an emulated node and a native one reporting different shapes would +// make every comparison between them a comparison of our own code. +// +// It exists because a board profile is a datasheet claim about hardware and not +// a claim about the firmware running on it. Until the chip reported its own +// state there was no way here to tell a node configured correctly from one that +// was not. +void writeRadioStats(sock_t fd) { + uint8_t sb[37]; + put32(&sb[0], gChip.irqReads()); + put32(&sb[4], gChip.busyReads()); + put32(&sb[8], gChip.busyMs()); + put32(&sb[12], gChip.spuriousRaises()); + + sb[16] = gChip.rxGainReg(); + sb[17] = (uint8_t)gChip.txPowerDbm(); + sb[18] = gChip.femEnabled() ? 1 : 0; + sb[19] = gChip.mode(); + sb[20] = (uint8_t)gChip.sf(); + sb[21] = (uint8_t)gChip.cr(); + put32(&sb[22], gChip.freqHz()); + // Bandwidth in whole hertz. The chip holds it in kHz as a float because the + // datasheet's table is fractional - 7.81, 10.42 - and rounding it to kHz here + // would make two distinct settings look like one. + put32(&sb[26], (uint32_t)(gChip.bwKHz() * 1000.0f + 0.5f)); + put16(&sb[30], (uint16_t)gChip.preambleSyms()); + put16(&sb[32], gChip.irqMask()); + put16(&sb[34], gChip.irqFlags()); + // Three states, because "has not transmitted" is not "transmitted with the + // module out": 0 no transmission yet, 1 module out, 2 module in. + sb[36] = !gChip.hasTransmitted() ? 0 : (gChip.femAtTx() ? 2 : 1); + + writeMsg(fd, kRadioStats, sb, sizeof(sb)); +} + // Anything the firmware handed its radio goes out to the engine now. // // Transmission reaches the channel immediately and is *not* immediately @@ -224,16 +275,7 @@ bool serviceBridge(sock_t fd) { gChip.tick(gSimMillis); drainTx(fd); - uint32_t st[4] = {gChip.irqReads(), gChip.busyReads(), gChip.busyMs(), - gChip.spuriousRaises()}; - uint8_t sb[16]; - for (int k = 0; k < 4; k++) { - sb[k * 4 + 0] = (uint8_t)(st[k] >> 24); - sb[k * 4 + 1] = (uint8_t)(st[k] >> 16); - sb[k * 4 + 2] = (uint8_t)(st[k] >> 8); - sb[k * 4 + 3] = (uint8_t)st[k]; - } - writeMsg(fd, kRadioStats, sb, sizeof(sb)); + writeRadioStats(fd); if (!writeMsg(fd, kAck, payload.data(), 4)) return false; break; } @@ -303,6 +345,13 @@ bool serviceQemu(sock_t fd, uint64_t* transactions, uint64_t* bytes) { return writeAll(fd, &irq, 1); } + case kSetFem: { + uint8_t level = 0; + if (!readAll(fd, &level, 1)) return false; + gChip.setFemEnabled(level != 0); + return true; + } + case kReadBusy: { // Always clear, which is what the native path does too: SimHal holds BUSY // low and VirtualSX1262 does not model the time a real chip spends diff --git a/variants/host/VirtualSX1262.cpp b/variants/host/VirtualSX1262.cpp index 88744b2..d805bf3 100644 --- a/variants/host/VirtualSX1262.cpp +++ b/variants/host/VirtualSX1262.cpp @@ -20,6 +20,7 @@ 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; @@ -168,6 +169,11 @@ 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; } @@ -312,6 +318,29 @@ void VirtualSX1262::runCommand(const uint8_t* out, size_t len, uint8_t* in) { } 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; diff --git a/variants/host/VirtualSX1262.h b/variants/host/VirtualSX1262.h index b7d45ca..2591cc1 100644 --- a/variants/host/VirtualSX1262.h +++ b/variants/host/VirtualSX1262.h @@ -53,6 +53,76 @@ class VirtualSX1262 { // 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); @@ -130,6 +200,17 @@ class VirtualSX1262 { 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;