Restore G3 startup tolerance and G2-compatible flash mode

This commit is contained in:
mikecarper committed 2026-10-18 13:58:02 -07:00
1 parent 5d741318f5
commit 015eff4c22
7 files changed
+334 -16

No files matched your search

+1 -1
View File
@@ -14,7 +14,7 @@
],
"f_cpu": "240000000L",
"f_flash": "80000000L",
"flash_mode": "qio",
"flash_mode": "dio",
"hwids": [["0x303A", "0x1001"]],
"mcu": "esp32s3",
"variant": "esp32s3"
+4
View File
@@ -1861,6 +1861,10 @@ required in this recovery console; it is not the normal running CLI. KISS
firmware uses binary slot commands during normal operation, documented in the
[KISS protocol](kiss_modem_protocol.md).
If G2 firmware works on a G3 but the G3 image behaves differently, see the
[G2/G3 compatibility checks](test-results/station_g3_g2_compatibility.md) for
the verified pin assignments, startup fixes, and saved amplifier settings.
---
### System
@@ -0,0 +1,174 @@
<!-- meshcore-hosted-doc-link:start -->
<p class="meshcore-hosted-doc-link"><a href="https://mikecarper.github.io/MeshCore/test-results/station_g3_g2_compatibility/">View this page on MeshCore Docs</a>.</p>
<!-- meshcore-hosted-doc-link:end -->
# Station G3 and G2 compatibility audit
Source audit on 2026-10-18, starting from
`1147c28b279f01443c9ec44c3b71cc767f11482e`. A user reported that G2 firmware
works on a physical G3 while G3 firmware has problems. The comparison below
identifies differences in configuration and startup; it does not establish the
cause of that device's symptoms or claim a successful hardware retest.
## Radio and peripheral pins
The G2 radio map matches G3 physical slot 1 exactly. G3's `_r2` suffix refers
to physical socket 2, not motherboard revision 2. Current G3 firmware detects
the populated socket at boot and retains both legacy target names.
| Signal or build macro | G2 | G3 slot 1 | G3 slot 2 |
| --- | --- | --- | --- |
| SX1262 DIO1 / interrupt | 48 | 48 | 2 |
| SPI NSS / chip select | 11 | 11 | 43 |
| SX1262 reset | 21 | 21 | 44 |
| SX1262 BUSY | 47 | 47 | 1 |
| SPI SCK | 12 | 12 | 39 |
| SPI MISO | 14 | 14 | 41 |
| SPI MOSI | 13 | 13 | 40 |
| External LNA control | Not controlled by G2 firmware | 10, active low | 42, active low |
| PA PL1 control | Not controlled by G2 firmware | 9, shared; high selects higher level | 9, shared |
| I2C SDA / SCL | 5 / 6 | 5 / 6 | 5 / 6 |
| User button | 38 | 38 | 38 |
| `PIN_GPS_RX` / `PIN_GPS_TX` | 15 / 7 | 15 / 7 | 15 / 7 |
The radio and FEM maps agree with the manufacturer's
[ESP32-S3 pinout viewer](https://tools.bqvoy.com/stationg3/pinout/?board=BQESP32V1M-40pin)
and its [pin data](https://tools.bqvoy.com/stationg3/pinout/boards.json), and the tracked
[G2 recipe](https://github.com/mikecarper/MeshCore/blob/1147c28b279f01443c9ec44c3b71cc767f11482e/variants/station_g2/platformio.ini),
[G3 recipes](https://github.com/mikecarper/MeshCore/blob/1147c28b279f01443c9ec44c3b71cc767f11482e/variants/station_g3_esp32/platformio.ini), and
[runtime socket table](https://github.com/mikecarper/MeshCore/blob/1147c28b279f01443c9ec44c3b71cc767f11482e/variants/station_g3_esp32/StationG3RadioSlot.h).
The GPS macros name the peripheral's signals: the ESP32 startup calls
`Serial1.setPins(PIN_GPS_TX, PIN_GPS_RX)`, giving MCU RX GPIO7 and TX GPIO15.
No incorrect radio map was established. Both base recipes select a 1.8 V
TCXO and disable the SX1262's internal boosted RX gain by default. The base TX
power is 7 dBm in both; their configured maximum chip-drive limits differ
(G2: 19 dBm; G3: 22 dBm). This audit does not change those power limits.
## Ordinary-profile flash compatibility
Before this change, both board manifests declared QIO flash, but the G2 recipe
overrode it to DIO. Ordinary G3 profiles inherited QIO. The G3 board manifest
now selects DIO as well, following the known-working G2 startup policy.
All G3 role recipes, including the legacy `_r2` targets, inherit that setting.
ESP32-S3 Full builds already force DIO through
[the Full partition policy](https://github.com/mikecarper/MeshCore/blob/1147c28b279f01443c9ec44c3b71cc767f11482e/scripts/esp32_full_partition.py). This change
therefore corrects an ordinary-profile difference; it does not explain a
failure in an already verified DIO Full image. The PSRAM memory type remains
`qio_opi`. Flash capacity, partition layouts, USB configuration, and radio pin
assignments are unchanged.
Ordinary G2 firmware uses the legacy `default.csv` partition table, while G3
uses `default_16MB.csv`. Electrical compatibility does not make those layouts
interchangeable. When moving a node that currently runs a G2 image to a G3
image, check its installed layout and use the documented
[partition migration](../esp32_wifi_partition_migration.md) if needed to retain
its identity and settings. This fix does not change either layout.
The two board manifests otherwise have matching build/upload settings; their
board names and manufacturer-page URLs distinguish them. DIO is a compatibility
choice here, not a claim that QIO caused the user's reported problem.
## Bounded radio startup probe
The G3-only socket probe added in `f765604cb8` imposed a 100 ms BUSY wait and
only three RC-standby attempts before rejecting a socket. G2 goes directly
through RadioLib's normal initialization, which permits a longer reset and
RC-standby settling interval. This is a separate, reproducible startup
compatibility gap, independent of the flash-mode difference.
The corrected probe shares one rollover-safe 1,000 ms deadline across reset,
BUSY waits, and standby/version retries. It services cooperative USB while
waiting. It retains identification-only operation, safe chip-select/reset
cleanup, explicit socket overrides, and the existing fallback rules.
The native production-code fixture models both sockets and timer rollover.
Elapsed times below are simulated fixture clocks, not physical measurements:
| Modeled condition | Previous probe | Corrected probe |
| --- | --- | --- |
| BUSY remains high for 200 ms after reset | Rejected at 111 ms, no version-register reads | Detected at 202 ms, 16 bytes read |
| First five standby commands ignored | Rejected at 44 ms, 48 bytes read | Detected at 67 ms, 96 bytes read |
| BUSY never clears | Rejected early | Rejected at exactly 1,000 ms, no register reads |
| Socket absent | Rejected after three attempts | Rejected at exactly 1,000 ms |
| 800 ms BUSY followed by persistent command refusal | Rejected early | Rejected at exactly 1,000 ms total |
An immediately responsive chip still completes its probe in under 20 ms in
the fixture. Negative controls restoring the old short BUSY budget or the
three-attempt limit fail the delayed-startup regressions. These results prove
the software tolerance gap; they do not prove it caused the user's hardware
failure. No transmit or TCXO-setting override was introduced.
## Saved FEM and socket settings
G3 firmware actively drives its LNA and PA-level GPIOs. G2 firmware does not,
so a saved FEM setting can have a visible effect only after switching to G3
firmware. Once the normal CLI has started, read these values before changing
settings:
```text
get radio.slot
get radio.fem.rxgain
get radio.fem.txgain
```
If startup reports `Station G3 radio unavailable`, the normal CLI has not
started. Press Enter and use the limited USB recovery commands instead:
```text
!g3 help
!g3 get radio.slot
```
That recovery console accepts only socket selection, help, and reboot; the
normal FEM getters are unavailable there.
`radio.slot` reports the saved selector, active socket, and detected sockets.
An explicit socket override does not silently fall back to another socket.
`radio.fem.rxgain` controls the external LNA and is distinct from
`radio.rxgain`, which controls the SX1262's internal boosted gain.
Preserve the saved values and jumper positions while diagnosing the device.
The manufacturer documents GPIO control as effective with the corresponding
LNA P/LNA S or PA PL1 jumper removed; PA PL2 selects the hardware power-level
pair. Changing gain or jumpers is an operator choice, not an automatic part of
this compatibility fix.
## Git history and attribution
| Change | Introducing commit and author |
| --- | --- |
| Initial G2 support and board manifest | [acac20dc89](https://github.com/meshcore-dev/MeshCore/commit/acac20dc89b6bc183125bcb9341eb33c56e1ece8), Rastislav Vysoky, 2025-03-07 |
| G2 radio pin flags moved into the variant recipe | [7cb2e0863a](https://github.com/meshcore-dev/MeshCore/commit/7cb2e0863a8171a300c062547d95697e1c69f009), recrof, 2025-10-12 |
| Original G3 slot-1 map, PA9 and LNA10 | [75fb07fc2c](https://github.com/meshcore-dev/MeshCore/commit/75fb07fc2c9d87238c1a2c88142d7e1e64415979), Chris / Cisien, 2026-05-09; merged in [upstream PR #2515](https://github.com/meshcore-dev/MeshCore/pull/2515) |
| G2 DIO recipe override | [1a43123d2c](https://github.com/mikecarper/MeshCore/commit/1a43123d2c5ca409e7f310d74e4495ea1c7f6d52), mikecarper, 2026-08-24 |
| G3 second-slot recipes and GPIO map | [d8d72b7833](https://github.com/mikecarper/MeshCore/commit/d8d72b783346b2d775a87dbda87588e48eacc51a), Adam Gessaman / agessaman, 2026-09-05 |
| Runtime socket discovery and shared pin binding | [f765604cb8](https://github.com/mikecarper/MeshCore/commit/f765604cb83eb2556228efd5a09e829186aee093), mikecarper, 2026-10-15 |
`git blame` and the introducing diffs were checked, rather than attributing
copied pin values to the latest refactor. Original G3 support is present in
upstream `dev`; the second-slot recipes and
runtime discovery were added in this fork. The original G3 pull request reports
hardware testing by its author. That historical report does not qualify the
current firmware or the user's device.
## Validation boundary
The host recipe regression resolves every tracked G3 environment, checks that
ordinary roles cannot override DIO, and verifies that `qio_opi` PSRAM remains
unchanged. It also executes the existing Full policy for canonical Repeater,
legacy `_r2` Repeater, and Full Companion configurations. A deliberate nested
QIO override must fail that check.
| Check | Status |
| --- | --- |
| G3 recipe suite, including all 16 current role environments and the deliberate QIO override | PASS, 10 test methods |
| Existing ESP32 Full partition policy suite | PASS, 11 test methods |
| Native socket-detection suite, including 144 selection cases and 20 delayed/deadline cases | PASS, 6 test methods, ASan/UBSan |
| Combined socket detection/settings, boot recovery, radio pins, KISS slot and GPIO-hold suites | PASS, 36 test methods, including the 6 detection methods above |
| Ordinary Repeater, legacy `_r2` Repeater and KISS firmware builds | PASS; all bootloader and application image headers verified as DIO |
| Canonical Full Companion package | PASS; 24 linked capability checks, internal RAM and application-slot checks passed; bootloader/application and merged-image headers verified as DIO |
| Physical G3 boot and RF receive/transmit | Not performed for this change |
No successful physical G3 boot, RF receive/transmit trial, or reproduction of
the user's original problem is claimed by these host checks.
+62 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Host contracts for the Station G3 Sensor role on each RF daughterboard slot.
"""Host contracts for Station G3 boot recipes and each Sensor RF slot.
Resolve tracked source recipes without starting PlatformIO, building firmware,
flashing hardware, or changing a release catalog. These fixtures check routing
@@ -15,6 +15,7 @@ import tempfile
import unittest
from test_upstream_observer_recipes import Recipes
from test_esp32_full_partition import apply_policy
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
@@ -63,6 +64,66 @@ class StationG3SensorRecipesTests(unittest.TestCase):
def setUpClass(cls):
cls.recipes = Recipes()
def assert_dio_boot_recipes(self, recipes):
board = json.loads((ROOT / "boards/station-g3-esp32.json").read_text())
self.assertEqual(board["build"]["flash_mode"], "dio")
self.assertEqual(board["build"]["arduino"]["memory_type"], "qio_opi")
targets = [section for section in recipes.config.sections()
if section.startswith("env:Station_G3_ESP32_")]
self.assertGreaterEqual(len(targets), 16)
for slot in SLOTS:
for role in ("repeater", "room_server", "sensor",
"repeater_observer_mqtt", "room_server_observer_mqtt"):
self.assertIn("env:" + slot + "_" + role, targets)
for role in ("companion_radio_usb", "companion_radio_ble",
"companion_radio_wifi", "companion_radio_full", "kiss_modem"):
self.assertIn("env:Station_G3_ESP32_" + role, targets)
for section in targets:
with self.subTest(target=section):
self.assertEqual(recipes.value(section, "board"), "station-g3-esp32")
flash_mode = recipes.value(section, "board_build.flash_mode")
self.assertEqual(flash_mode or board["build"]["flash_mode"], "dio")
memory_type = recipes.value(section, "board_build.arduino.memory_type")
self.assertEqual(memory_type or board["build"]["arduino"]["memory_type"], "qio_opi")
psram_type = recipes.value(section, "board_build.psram_type")
self.assertIn(psram_type, ("", "opi"))
for target in ("Station_G3_ESP32_repeater", "Station_G3_ESP32_r2_repeater",
"Station_G3_ESP32_companion_radio_full"):
section = "env:" + target
values = {
"build.mcu": board["build"]["mcu"],
"build.flash_mode": recipes.value(section, "board_build.flash_mode")
or board["build"]["flash_mode"],
"build.arduino.memory_type": board["build"]["arduino"]["memory_type"],
"build.partitions": recipes.value(section, "board_build.partitions"),
"upload.flash_size": board["upload"]["flash_size"],
}
with self.subTest(full_target=target):
# build.sh injects this existing policy for Full promotion;
# ordinary tracked recipes do not list it in extra_scripts.
full, _ = apply_policy(values, companion="companion_radio" in target)
self.assertEqual(full.values["build.flash_mode"], "dio")
self.assertEqual(full.values["build.arduino.memory_type"], "qio_opi")
def test_all_g3_roles_inherit_dio_without_changing_psram_or_full_policy(self):
self.assert_dio_boot_recipes(self.recipes)
def test_nested_role_override_cannot_silently_restore_qio(self):
recipes = Recipes()
recipes.config.set("env:Station_G3_ESP32_r2_sensor", "board_build.flash_mode", "qio")
# Exercise resolved inheritance, not a textual manifest-only assertion.
# subTest records failures instead of raising, so collect this control
# with an independent TestResult whose failure must be observable.
control = StationG3SensorRecipesTests(
"test_all_g3_roles_inherit_dio_without_changing_psram_or_full_policy")
control.recipes = recipes
result = unittest.TestResult()
control.run(result)
self.assertEqual(len(result.failures), 1)
self.assertFalse(result.errors)
self.assertIn("'qio' != 'dio'", result.failures[0][1])
def test_each_sensor_inherits_its_own_slot_and_only_one_pin_set(self):
text = (ROOT / "variants/station_g3_esp32/platformio.ini").read_text()
for slot, pins in SLOTS.items():
+65 -6
View File
@@ -13,7 +13,9 @@ VARIANT = ROOT / "variants/station_g3_esp32"
def harness(mutation=None):
source = (VARIANT / "target.cpp").read_text()
methods = "\n".join(body(source, signature) for signature in (
start = source.index("constexpr uint32_t kProbeTimeoutMs =")
methods = source[start:source.index(";", start) + 1] + "\n"
methods += "\n".join(body(source, signature) for signature in (
"bool waitForProbeReady(", "bool probeRadioSlot(",
"bool initializeSlot(", "bool radio_init()"))
if mutation == "accept-empty":
@@ -24,6 +26,10 @@ def harness(mutation=None):
guard = "delayMicroseconds(1);"
assert guard in methods, "production post-command BUSY guard missing"
methods = methods.replace(guard, "", 1)
if mutation == "old-busy-timeout":
methods = methods.replace("kProbeTimeoutMs = 1000", "kProbeTimeoutMs = 100")
if mutation == "old-attempt-limit":
methods = methods.replace("while (!found)", "for (unsigned attempt=0; attempt<3 && !found; ++attempt)", 1)
return r'''
#include <cassert>
#include <cstdio>
@@ -55,6 +61,8 @@ static unsigned now_ms=0,present=0,working=0,stuck=0,reads=0,recoveries=0,rx=0,r
static uint64_t now_us=0,busy_asserts_at[3]={},busy_clears_at[3]={};
static bool busy_scheduled[3]={};
static unsigned settle_guards=0,busy_high_samples=0;
static uint64_t reset_released_at[3]={},startup_busy_us=0;
static unsigned standby_count[3]={},refused_standby_count=0,usb_services=0;
static bool returned=false;
static int levels[64];
static std::vector<unsigned> initializations;
@@ -64,13 +72,19 @@ void delayMicroseconds(unsigned us) { assert(us==1);++settle_guards;now_us+=us;n
void pinMode(int,int) {}
void digitalWrite(int pin,int level) {
assert(pin>=0 && pin<64);levels[pin]=level;
for(unsigned slot=1;slot<=2;++slot)
if(pin==station_g3::pinsForSlot(slot).reset && level==LOW) busy_scheduled[slot]=false;
for(unsigned slot=1;slot<=2;++slot) {
if(pin!=station_g3::pinsForSlot(slot).reset) continue;
if(level==LOW) { busy_scheduled[slot]=false;standby_count[slot]=0; }
else reset_released_at[slot]=now_us;
}
}
int digitalRead(int pin) {
for(unsigned slot=1;slot<=2;++slot) {
if(pin!=station_g3::pinsForSlot(slot).busy) continue;
if(stuck&(1<<(slot-1))) return HIGH;
// Independent reset/command-readiness model. Some supported modules are
// busy longer at startup, or ignore several initial standby commands.
if((present&(1<<(slot-1))) && now_us<reset_released_at[slot]+startup_busy_us) return HIGH;
// Immediately after NSS rises BUSY still reads LOW. It asserts only
// after the guard interval, then remains HIGH during command handling.
if(busy_scheduled[slot] && now_us>=busy_asserts_at[slot] && now_us<busy_clears_at[slot]) {
@@ -113,6 +127,7 @@ struct SPI {
if(offset>=4) {
++reads;
if(!(present&(1<<(slot-1)))) return 0xff;
if(standby_count[slot]<=refused_standby_count) return 0xff;
return offset-4<6?"SX1261"[offset-4]:0;
}
return 0x22;
@@ -121,6 +136,7 @@ struct SPI {
assert(transaction && levels[station_g3::pinsForSlot(slot).nss]==HIGH);
assert(index==(command==0x80?2:20));transaction=false;
if(command==0x80 && (present&(1<<(slot-1)))) {
++standby_count[slot];
busy_scheduled[slot]=true;
busy_asserts_at[slot]=now_us+1;
busy_clears_at[slot]=now_us+51;
@@ -148,7 +164,7 @@ namespace station_g3 {
void rememberActiveSlot(uint8_t slot) { remembered=slot;slotState().last=slot; }
void serviceBootRecovery(StationG3Board&,bool first) { assert(first);++recoveries;throw Recovery(); }
}
namespace mesh { void serviceUsbLoggingPort() {} void serviceUsbTerminalPort() {} }
namespace mesh { void serviceUsbLoggingPort() {++usb_services;} void serviceUsbTerminalPort() {++usb_services;} }
@METHODS@
void reset(unsigned mask,unsigned successful,unsigned mode,unsigned last,unsigned busy=0) {
station_g3::slotState()=station_g3::SlotState();
@@ -157,8 +173,10 @@ void reset(unsigned mask,unsigned successful,unsigned mode,unsigned last,unsigne
spi.end();initializations.clear();
now_ms=reads=recoveries=rx=remembered=0;present=mask;working=successful;stuck=busy;
now_us=settle_guards=busy_high_samples=0;
startup_busy_us=refused_standby_count=usb_services=0;
for(unsigned slot=1;slot<=2;++slot) {
busy_scheduled[slot]=false;busy_asserts_at[slot]=busy_clears_at[slot]=0;
reset_released_at[slot]=standby_count[slot]=0;
}
for(int& value:levels) value=HIGH;
returned=false;
@@ -185,16 +203,47 @@ int main() {
assert(reads==oldreads && initializations.back()==expected);
assert(levels[station_g3::pinsForSlot(expected==1?2:1).reset]==LOW);
}
assert(now_ms<500);
assert(now_ms<=2000); // One shared one-second probe budget per socket.
}
reset(3,3,0,0,1);assert(radio_init());
assert(station_g3::slotState().detected==2 && station_g3::slotState().active==2);
assert(now_ms<500);
assert(now_ms<=2000);
// A present device exercises both the 1-us post-NSS guard and the ensuing
// real BUSY wait. Neither a no-op delay shim nor an early LOW is enough.
reset(1,1,0,0);assert(probeRadioSlot(1));
assert(settle_guards==1 && busy_high_samples==1 && reads==16 && now_ms>=12);
assert(!spi.transaction && levels[station_g3::pinsForSlot(1).reset]==LOW);
assert(now_ms<20); // No new delay for a fast, immediately responsive chip.
for(unsigned slot=1;slot<=2;++slot) for(bool wrap:{false,true}) {
for(unsigned scenario=0;scenario<5;++scenario) {
const unsigned bit=1U<<(slot-1);
reset(scenario==3?0:bit,bit,0,0,scenario==2?bit:0);
if(wrap) { now_us=uint64_t(UINT32_MAX-100)*1000;now_ms=now_us/1000; }
if(scenario==0) startup_busy_us=200000;
if(scenario==1) refused_standby_count=5;
// Mixed startup BUSY and command refusal must not each get a fresh
// one-second budget: this chip stays unresponsive after BUSY clears.
if(scenario==4) { startup_busy_us=800000;refused_standby_count=1000; }
const uint32_t started=millis();
const bool found=probeRadioSlot(slot);
const uint32_t elapsed=uint32_t(millis()-started);
if(scenario==0) {
assert(found && "200 ms startup BUSY must be tolerated");
assert(elapsed>=200 && elapsed<220 && usb_services>0);
} else if(scenario==1) {
assert(found && "five ignored startup commands must be retried");
assert(reads==96 && elapsed>=50 && elapsed<100 && usb_services>0);
} else {
assert(!found && elapsed==1000 && usb_services>0);
if(scenario==2) assert(reads==0); // Stuck BUSY never permits SPI reads.
}
assert(!spi.transaction && spi.slot==0);
assert(levels[station_g3::pinsForSlot(slot).reset]==LOW);
assert(levels[11]==HIGH && levels[43]==HIGH);
assert(initializations.empty() && rx==0 && remembered==0);
}
}
// Corrupt policy cannot shift outside its mask or silently become auto.
station_g3::slotState().bootMode=255;
assert(station_g3::slotState().choose(3)==0);
@@ -219,6 +268,10 @@ class StationG3DetectionTests(unittest.TestCase):
self.assertNotEqual(run.returncode,0,"negative control unexpectedly passed")
if mutation=="no-busy-settle":
self.assertIn("ReadRegister before delayed BUSY completed",run.stderr)
elif mutation=="old-busy-timeout":
self.assertIn("200 ms startup BUSY must be tolerated",run.stderr)
elif mutation=="old-attempt-limit":
self.assertIn("five ignored startup commands must be retried",run.stderr)
else:self.assertEqual(run.returncode,0,run.stdout+run.stderr)
def test_real_probe_selection_matrix_recovery_and_busy_deadlines(self):
@@ -233,5 +286,11 @@ class StationG3DetectionTests(unittest.TestCase):
def test_delayed_busy_requires_post_command_guard_negative_control(self):
self.run_case("no-busy-settle")
def test_old_short_busy_budget_rejects_supported_delayed_startup(self):
self.run_case("old-busy-timeout")
def test_old_three_attempt_limit_rejects_initial_command_refusals(self):
self.run_case("old-attempt-limit")
if __name__=="__main__":unittest.main()
+2
View File
@@ -8,6 +8,8 @@
[Station_G3_ESP32_common]
extends = esp32_base
board = station-g3-esp32
; The board manifest uses DIO for the known-working G2/ESP32-S3 Full startup
; policy, including ordinary and legacy _r2 roles. qio_opi PSRAM is unchanged.
board_build.partitions = default_16MB.csv
build_flags =
${esp32_base.build_flags}
+26 -8
View File
@@ -49,19 +49,25 @@ AutoDiscoverRTCClock rtc_clock(fallback_clock);
#endif
namespace {
bool waitForProbeReady(const station_g3::RadioPins& pins) {
const uint32_t started = millis();
constexpr uint32_t kProbeTimeoutMs = 1000;
bool waitForProbeReady(const station_g3::RadioPins& pins, uint32_t started) {
while (digitalRead(pins.busy)) {
if (static_cast<uint32_t>(millis() - started) >= 100) return false;
if (static_cast<uint32_t>(millis() - started) >= kProbeTimeoutMs) return false;
#if MESH_USB_CONSOLE_COOPERATIVE
mesh::serviceUsbLoggingPort();
mesh::serviceUsbTerminalPort();
#endif
delay(1);
}
return true;
return static_cast<uint32_t>(millis() - started) < kProbeTimeoutMs;
}
// Identification only: no SetTx, CAD, packet send, or saved profile changes.
// Use a small bounded transaction rather than RadioLib's 10 reset attempts
// (each may wait a second) on every empty socket.
bool probeRadioSlot(uint8_t slot) {
const uint32_t started = millis();
const auto& pins = station_g3::pinsForSlot(slot);
spi.end();
station_g3::slotState().candidate = slot;
@@ -79,8 +85,11 @@ bool probeRadioSlot(uint8_t slot) {
delay(10);
spi.begin(pins.sck, pins.miso, pins.mosi);
bool found = false;
for (unsigned attempt = 0; attempt < 3 && !found; ++attempt) {
if (!waitForProbeReady(pins)) break;
// Match RadioLib's reset/RC-standby tolerance: some radios ignore their
// first commands after reset. Share one budget across BUSY and all retries,
// so an empty socket remains bounded without resetting a slow chip again.
while (!found) {
if (!waitForProbeReady(pins, started)) break;
spi.beginTransaction(SPISettings(2000000, MSBFIRST, SPI_MODE0));
digitalWrite(pins.nss, LOW);
spi.transfer(RADIOLIB_SX126X_CMD_SET_STANDBY);
@@ -89,7 +98,7 @@ bool probeRadioSlot(uint8_t slot) {
spi.endTransaction();
// SX126x BUSY can assert just after NSS rises (RadioLib uses the same guard).
delayMicroseconds(1);
if (!waitForProbeReady(pins)) break;
if (!waitForProbeReady(pins, started)) break;
char version[16] = {};
spi.beginTransaction(SPISettings(2000000, MSBFIRST, SPI_MODE0));
digitalWrite(pins.nss, LOW);
@@ -102,7 +111,16 @@ bool probeRadioSlot(uint8_t slot) {
spi.endTransaction();
// SX1262 intentionally uses RadioLib's SX1261 silicon version signature.
found = memcmp(version, RADIOLIB_SX1262_CHIP_TYPE, 6) == 0;
if (!found) delay(10);
if (!found) {
#if MESH_USB_CONSOLE_COOPERATIVE
mesh::serviceUsbLoggingPort();
mesh::serviceUsbTerminalPort();
#endif
const uint32_t elapsed = static_cast<uint32_t>(millis() - started);
if (elapsed >= kProbeTimeoutMs) break;
const uint32_t remaining = kProbeTimeoutMs - elapsed;
delay(remaining < 10 ? remaining : 10);
}
}
// Leave the unselected socket in reset, not receiving or driving an IRQ.
// The chosen socket's std_init will perform its own verified reset.