M3 step 3: the Radio Service, receive only

One task owns the SX1262 and does all its SPI behind the card's bus lock;
the main loop posts requests and DIO1 only wakes the task. It listens
while a client asks (App, Capture, Console) and sleeps otherwise, with a
ring of the last 32 packets (9.8 KB, freed when idle). No transmit path.
The Cap's antenna switch (expander P0) is set on the main loop, which
owns the I2C bus. `lora probe` now runs on the radio task and checks the
DIO1 interrupt with a receive timeout; `lora status`, `lora rx on|off`,
`lora preset`, and `lora custom` for other LoRa settings.

Measured: DIO1 works (timeout after 105 ms); four 1.7 MB uploads and
Gemini pages to the card while listening, no radio or card errors; task
stack peak 2.0 KB. Twenty minutes on LongFast and LoRaWAN: no packets,
and a noise floor of -83 to -94 dBm at the desk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-05 19:49:46 +02:00
co-authored by Claude Opus 5.5
parent 30a827070b
commit 594748e99a
5 changed files with 489 additions and 138 deletions
-124
View File
@@ -1,124 +0,0 @@
#include "lora_probe.h"
#include <M5Cardputer.h>
#include <RadioLib.h>
#include "pins.h"
#include "shared_spi.h"
namespace roro {
namespace {
// The antenna switch enable M5Stack's page describes: P0 of a PI4IOE5V6408 on the internal I2C bus.
constexpr uint8_t kExpanderAddresses[] = {0x43, 0x44};
constexpr uint32_t kI2cFreq = 400000;
enum ExpanderReg : uint8_t { kId = 0x01, kDirection = 0x03, kOutput = 0x05, kHighZ = 0x07, kInput = 0x0F };
// Meshtastic EU868 LongFast (Q95).
constexpr float kFreq = 869.525f, kBw = 250.0f;
constexpr uint8_t kSf = 11, kCr = 5, kSyncWord = 0x2B;
constexpr uint16_t kPreamble = 16;
Module& module() {
// SPI transactions take the bus lock the SD card uses (Q93); the HAL doesn't re-begin the bus.
static ArduinoHal hal(sharedSpi(), SPISettings(8000000, MSBFIRST, SPI_MODE0));
static Module m(&hal, pins::kLoraCs, pins::kLoraIrq, pins::kLoraReset, pins::kLoraBusy);
return m;
}
SX1262& radio() {
static SX1262 sx(&module());
return sx;
}
// Mean and spread of the instantaneous RSSI while receiving: the noise floor where nothing transmits.
void noiseFloor(SX1262& sx, float& mean, float& low, float& high) {
sx.startReceive();
delay(20);
float sum = 0;
low = 0, high = -200;
constexpr int kSamples = 64;
for (int i = 0; i < kSamples; ++i) {
float r = sx.getRSSI(false);
sum += r;
low = std::min(low, r), high = std::max(high, r);
delay(3);
}
mean = sum / kSamples;
sx.standby();
}
int findExpander() {
for (uint8_t a : kExpanderAddresses)
if (M5.In_I2C.scanID(a, kI2cFreq)) return a;
return -1;
}
void setP0High(uint8_t addr) {
auto& i2c = M5.In_I2C;
i2c.writeRegister8(addr, kOutput, i2c.readRegister8(addr, kOutput, kI2cFreq) | 1, kI2cFreq);
i2c.writeRegister8(addr, kHighZ, i2c.readRegister8(addr, kHighZ, kI2cFreq) & ~1, kI2cFreq);
i2c.writeRegister8(addr, kDirection, i2c.readRegister8(addr, kDirection, kI2cFreq) | 1, kI2cFreq);
}
} // namespace
void loraProbe(Print& out) {
// The internal I2C bus: what answers, and the expander's registers if there is one.
bool found[120] = {};
M5.In_I2C.scanID(found, kI2cFreq);
out.print("lora probe: i2c (internal bus, 8/9):");
for (int a = 8; a < 120; ++a)
if (found[a]) out.printf(" 0x%02X", a);
out.println();
int expander = findExpander();
if (expander < 0) {
out.println("lora probe: expander none at 0x43/0x44");
} else {
auto r = [&](uint8_t reg) { return M5.In_I2C.readRegister8(expander, reg, kI2cFreq); };
out.printf("lora probe: expander 0x%02X id %02X dir %02X out %02X highz %02X in %02X\n", expander, r(kId),
r(kDirection), r(kOutput), r(kHighZ), r(kInput));
}
// The chip, with the TCXO first and the crystal as the fallback (Meshtastic's TCXO_OPTIONAL).
SX1262& sx = radio();
float tcxo = 1.8f;
uint32_t t0 = millis();
int16_t state = sx.begin(kFreq, kBw, kSf, kCr, kSyncWord, 0, kPreamble, tcxo, false);
if (state != RADIOLIB_ERR_NONE) {
out.printf("lora probe: begin with TCXO 1.8 V: error %d, trying the crystal\n", state);
tcxo = 0;
state = sx.begin(kFreq, kBw, kSf, kCr, kSyncWord, 0, kPreamble, tcxo, false);
}
if (state != RADIOLIB_ERR_NONE) return (void)out.printf("lora probe: error %d, no SX1262 found\n", state);
char version[17] = {};
module().SPIreadRegisterBurst(RADIOLIB_SX126X_REG_VERSION_STRING, 16, reinterpret_cast<uint8_t*>(version));
out.printf("lora probe: %s, %s, ready in %u ms\n", version, tcxo > 0 ? "TCXO 1.8 V" : "crystal (no TCXO)",
(unsigned)(millis() - t0));
out.printf("lora probe: %.3f MHz, BW %.0f kHz, SF %u, CR 4/%u, sync 0x%02X (LongFast)\n", kFreq, kBw, kSf, kCr,
kSyncWord);
// The antenna switch, by measuring: a receive path that isn't connected to the antenna is
// quieter, so the arrangement with the higher noise floor is the one that hears the antenna.
auto measure = [&](const char* what) {
float mean, low, high;
noiseFloor(sx, mean, low, high);
out.printf("lora probe: noise %-24s %7.1f dBm (%.1f to %.1f)\n", what, mean, low, high);
};
sx.setDio2AsRfSwitch(false);
measure("DIO2 off");
sx.setDio2AsRfSwitch(true);
measure("DIO2 on");
if (expander >= 0) {
setP0High(expander);
measure("DIO2 on, expander P0 high");
sx.setDio2AsRfSwitch(false);
measure("DIO2 off, expander P0 high");
sx.setDio2AsRfSwitch(true);
}
sx.sleep();
out.println("lora probe: done, radio asleep");
}
} // namespace roro
-11
View File
@@ -1,11 +0,0 @@
#pragma once
#include <Print.h>
namespace roro {
// `lora probe` (M3 step 1, Q91): finds the SX1262 on the Cap and how its oscillator and antenna
// switch are wired, by measuring. Receive only; leaves the radio asleep.
void loraProbe(Print& out);
} // namespace roro