Background
We currently carry two separate, divergent SX1262 LoRa drivers:
| Board |
Driver |
Lines |
SPI API |
fri3d_2026 |
lib/drivers/lora/sx1262.py |
2426 |
SPI.Device (lvgl_micropython Bus/Device split) |
lilygo_t_watch_s3_plus |
lib/drivers/lora/micropySX126X/ |
1695 total |
Raw SPI pins, with SoftSPI fallback |
Both are forks of older community drivers (micropySX126X by ehong-tl), diverged for board-specific reasons (SPI API differences, instability workarounds, blocking/non-blocking patterns). They share no code and drift independently.
The "sword of Damocles" is that neither tracks upstream, and both have accumulated board-specific hacks that make them fragile and hard to maintain. PR #222 patched one symptom (CS held low across BUSY wait) but did not address the underlying duplication.
The goal: replace both with the official upstream micropython-lib lora driver by Angus Gratton, using thin adapter layers + .patch files (only if unavoidable) so we can easily follow upstream releases.
Scope
This issue covers the fri3d_2026 driver replacement + framework additions. The lilygo_t_watch_s3_plus driver is deferred to a follow-up PR to keep scope tight.
Phase 1: Copy upstream files (verbatim)
Copy 4 files from ~/sources/micropython-lib/micropython/lora/ into internal_filesystem/lib/lora/:
| Source |
Destination |
Lines |
lora/lora/modem.py |
lib/lora/modem.py |
474 |
lora-sx126x/lora/sx126x.py |
lib/lora/sx126x.py |
899 |
lora-sync/lora/sync_modem.py |
lib/lora/sync_modem.py |
86 |
lora/lora/__init__.py |
lib/lora/__init__.py |
~20 |
__init__.py trimmed to import only sx126x (drop sx127x, stm32wl5).
- These files are never modified directly. Any needed changes go in the adapter or as
.patch files.
Phase 2: SPI adapter
New file: lib/mpos/lora_spi_adapter.py (~30 lines)
The upstream driver uses standard MicroPython SPI primitives (write_readinto, write, readinto). Our lvgl_micropython fork uses a split SPI.Bus / SPI.Device API where each call is independently bus-arbitrated. This adapter wraps a SPI.Device to expose the standard SPI method signatures:
class SPIAdapter:
def __init__(self, spi_device):
def write_readinto(self, wr_buf, rd_buf): # full-duplex, uses Device.read(1, byte)
def write(self, buf): # Device.write(bytes([b])) per byte
def readinto(self, buf, fill): # Device.read(1, fill) per byte
The upstream driver's single write_readinto() call per command is actually better than our current multi-call pattern with manual CS — it minimizes the shared-bus collision window. The "complete fix" for shared SPI bus locking (PR #222) still needs C-level changes in lvgl_micropython, but that's orthogonal to this PR.
Phase 3: MPOS adapter wrapper
New file: lib/mpos/lora_adapter.py (~250 lines)
Wraps the upstream lora.SX1262 class to expose the API that existing callers (fri3d_2026.py, lora_chat.py, meshcore) already use. All board-specific quirks live here, not in the upstream files.
class MPOSLoRa:
# Constructor accepts the current positional API:
# SX1262(spi_device, irq, rst, gpio, cs_pin)
# Translates to upstream:
# SX1262(spi=SPIAdapter(device), cs=Pin(cs_pin), busy=Pin(gpio),
# dio1=Pin(irq), dio2_rf_sw=False, reset=None, ...)
#
# dio2_rf_sw=False because fri3d_2026 uses a separate GPIO (pin 46) as RF switch.
# reset=None because fri3d_2026 LoRa reset is driven by the CH32 IO expander at boot.
# Preserved API:
def begin(freq, bw, sf, cr, syncWord, power, currentLimit, ...):
# stores kwargs for watchdog reinit
def send(data) -> (length, status):
def recv(len=0, timeout_en=False, timeout_ms=0) -> (bytes, status):
def sleep(retainConfig=True):
def setBlockingCallback(blocking, callback=None):
def setDio2AsRfSwitch(enable): # no-op (set at constructor time)
def getRSSI(), getSNR(), getStatus():
def getPacketStatus():
def getIrqStatus(), clearIrqStatus(): # needed by apps doing manual polling
def startReceive(): # needed by apps doing manual RX restart
# Constants for backward compat:
STATUS = {...} # error code -> name dict
TX_DONE, RX_DONE = ... # IRQ bit masks
Phase 4: Framework-level LoRaManager enhancements
Modify: lib/mpos/lora_manager.py (grows from 4 -> ~130 lines)
Resource lock
Prevents multiple apps from colliding on the single physical radio:
LoRaManager.acquire(app_name) -> bool # claim exclusive use
LoRaManager.release(app_name) # release, sleeps radio if held
LoRaManager.holder -> str | None # which app holds the radio
- Same app can
acquire() multiple times (reentrant).
release() from non-holder is a no-op.
release() puts the radio to sleep.
CH32 reset
The fri3d_2026 LoRa reset pin is driven by the CH32 IO expander (config register 0x16), not a direct GPIO. This is the only reliable way to recover from hard wedges (BUSY never clears, getStatus() returns 0x00):
LoRaManager.reset_chip():
# exp.config = 0x03 -> LoRa held in reset, LCD on, aux on (100ms)
# exp.config = 0x13 -> LoRa released, LCD on, aux on (100ms)
On non-fri3d_2026 boards, this is a no-op (or can be overridden per-board).
Watchdog
Optional auto-recovery for apps that want it. Apps that do their own polling (like meshcore) can leave the watchdog off and call reset_chip() directly.
LoRaManager.start_watchdog(interval_ms=2000):
# Spawns a low-priority _thread that every interval_ms:
# 1. Probes radioChip.getStatus()
# 2. If mode != 0x50 (RX) -> increment counter
# 3. Counter >= 3 or status == 0x00 (hard wedge) -> reinit:
# reset_chip() -> radioChip.begin(**stored_kwargs) -> setBlockingCallback()
# 4. Rate-limited: max one reinit per 5 seconds
# 5. Stores last status byte for is_healthy()
LoRaManager.stop_watchdog():
# Kills the watchdog thread
LoRaManager.is_healthy() -> bool:
# True if last getStatus() mode byte was a known good value
The watchdog only runs while an app holds the lock. It auto-stops on release().
Auto-release safety net
If getStatus() returns 0x00 (chip unresponsive) for >30s of continuous watchdog checks despite reinit attempts, auto-release the lock. This prevents a hung chip from permanently blocking other apps.
Phase 5: Update callers
lib/mpos/board/fri3d_2026.py
- from drivers.lora.sx1262 import SX1262
+ from mpos.lora_adapter import MPOSLoRa as SX1262
Constructor call unchanged — same positional args.
apps/com.micropythonos.lora_chat/lora_chat.py
- from drivers.lora.sx1262 import SX1262
+ from mpos.lora_adapter import MPOSLoRa as SX1262
Add lock acquire/release:
def onResume(self):
if not LoRaManager.acquire("lora_chat"):
# Show message: "LoRa in use by <holder>", disable send
return
LoRaManager.start_watchdog() # optional: let the framework auto-recover
_thread.start_new_thread(self.receive_thread, ())
def onPause(self):
LoRaManager.stop_watchdog()
LoRaManager.release("lora_chat")
external_apps/fri3d-meshcore (separate repo)
Needs an import change from drivers.lora.sx1262 to mpos.lora_adapter in meshcore_manager.py. The app can choose to either:
- Keep its own watchdog (call
LoRaManager.reset_chip() directly, leave framework watchdog off) — minimal change.
- Migrate to framework watchdog (remove
_rx_watchdog, _attempt_reinit, _reset_lora_via_ch32 from meshcore, let LoRaManager handle it) — cleaner long-term, but more work. Can be done in a follow-up.
Add lock acquire/release around radio init/teardown:
if not LoRaManager.acquire("meshcore"):
return # cannot start, another app holds the radio
Phase 6: Delete old driver
git rm internal_filesystem/lib/drivers/lora/sx1262.py
The micropySX126X/ directory (lilygo watcher driver) remains untouched — it is handled in a follow-up PR.
What this PR does NOT change
| Item |
Status |
lib/drivers/lora/micropySX126X/ |
Untouched - separate PR for lilygo |
lilygo_t_watch_s3_plus.py board init |
Untouched - separate PR |
Shared SPI bus locking (C-level fix in lvgl_micropython) |
Orthogonal - PR #222 noted this, needs separate work |
| MeshCore app migration to framework watchdog |
Follow-up (app in external repo) |
Patches
No .patch files needed for the driver itself. All board-specific divergences (SPI adapter, CH32 reset, dio2_rf_sw=False, TCXO voltage, constructor translation) live in the two adapter files. If a future upstream change forces a source modification, we add a .patch file in the repo root (beside the existing lvgl_micropython/*.patch files) and apply it in build_mpos.sh.
Future work: lilygo_t_watch_s3_plus
The lilygo watch uses micropySX126X/sx1262.py with SoftSPI fallback. The same upstream driver can serve it via a different adapter that:
- Uses raw
machine.SPI or SoftSPI (no SPI.Bus/SPI.Device split needed on that board)
- Passes actual GPIO pins for reset (no CH32 expander on that board)
- Handles its own pin mapping
The LoRaManager lock + watchdog work the same way — they are board-agnostic.
Testing plan
- Desktop build:
make build-mpos-unix — verifies imports, adapter logic, lora package structure
- Lint:
make lint — no regressions
- Existing tests:
python3 scripts/test_runner.py tests/test_*.py — nothing breaks
- On-device (fri3d_2026):
- Launch lora_chat -> verify begin/send/recv work
- Launch meshcore -> verify begin/RX/watchdog recovery works
- Verify
LoRaManager.acquire() blocks second app from configuring radio
- Induce a wedge (heavy LCD SPI traffic) -> verify watchdog recovers
- MeshCore external app: verify import change +
reset_chip() still works
Refs: PR #222, upstream lora driver
Background
We currently carry two separate, divergent SX1262 LoRa drivers:
fri3d_2026lib/drivers/lora/sx1262.pySPI.Device(lvgl_micropython Bus/Device split)lilygo_t_watch_s3_pluslib/drivers/lora/micropySX126X/SPIpins, with SoftSPI fallbackBoth are forks of older community drivers (
micropySX126Xby ehong-tl), diverged for board-specific reasons (SPI API differences, instability workarounds, blocking/non-blocking patterns). They share no code and drift independently.The "sword of Damocles" is that neither tracks upstream, and both have accumulated board-specific hacks that make them fragile and hard to maintain. PR #222 patched one symptom (CS held low across BUSY wait) but did not address the underlying duplication.
The goal: replace both with the official upstream
micropython-liblora driver by Angus Gratton, using thin adapter layers +.patchfiles (only if unavoidable) so we can easily follow upstream releases.Scope
This issue covers the fri3d_2026 driver replacement + framework additions. The lilygo_t_watch_s3_plus driver is deferred to a follow-up PR to keep scope tight.
Phase 1: Copy upstream files (verbatim)
Copy 4 files from
~/sources/micropython-lib/micropython/lora/intointernal_filesystem/lib/lora/:lora/lora/modem.pylib/lora/modem.pylora-sx126x/lora/sx126x.pylib/lora/sx126x.pylora-sync/lora/sync_modem.pylib/lora/sync_modem.pylora/lora/__init__.pylib/lora/__init__.py__init__.pytrimmed to import onlysx126x(dropsx127x,stm32wl5)..patchfiles.Phase 2: SPI adapter
New file:
lib/mpos/lora_spi_adapter.py(~30 lines)The upstream driver uses standard MicroPython SPI primitives (
write_readinto,write,readinto). Ourlvgl_micropythonfork uses a splitSPI.Bus/SPI.DeviceAPI where each call is independently bus-arbitrated. This adapter wraps aSPI.Deviceto expose the standard SPI method signatures:The upstream driver's single
write_readinto()call per command is actually better than our current multi-call pattern with manual CS — it minimizes the shared-bus collision window. The "complete fix" for shared SPI bus locking (PR #222) still needs C-level changes inlvgl_micropython, but that's orthogonal to this PR.Phase 3: MPOS adapter wrapper
New file:
lib/mpos/lora_adapter.py(~250 lines)Wraps the upstream
lora.SX1262class to expose the API that existing callers (fri3d_2026.py,lora_chat.py, meshcore) already use. All board-specific quirks live here, not in the upstream files.Phase 4: Framework-level LoRaManager enhancements
Modify:
lib/mpos/lora_manager.py(grows from 4 -> ~130 lines)Resource lock
Prevents multiple apps from colliding on the single physical radio:
acquire()multiple times (reentrant).release()from non-holder is a no-op.release()puts the radio to sleep.CH32 reset
The fri3d_2026 LoRa reset pin is driven by the CH32 IO expander (config register
0x16), not a direct GPIO. This is the only reliable way to recover from hard wedges (BUSY never clears,getStatus()returns0x00):On non-fri3d_2026 boards, this is a no-op (or can be overridden per-board).
Watchdog
Optional auto-recovery for apps that want it. Apps that do their own polling (like meshcore) can leave the watchdog off and call
reset_chip()directly.The watchdog only runs while an app holds the lock. It auto-stops on
release().Auto-release safety net
If
getStatus()returns0x00(chip unresponsive) for >30s of continuous watchdog checks despite reinit attempts, auto-release the lock. This prevents a hung chip from permanently blocking other apps.Phase 5: Update callers
lib/mpos/board/fri3d_2026.pyConstructor call unchanged — same positional args.
apps/com.micropythonos.lora_chat/lora_chat.pyAdd lock acquire/release:
external_apps/fri3d-meshcore(separate repo)Needs an import change from
drivers.lora.sx1262tompos.lora_adapterinmeshcore_manager.py. The app can choose to either:LoRaManager.reset_chip()directly, leave framework watchdog off) — minimal change._rx_watchdog,_attempt_reinit,_reset_lora_via_ch32from meshcore, letLoRaManagerhandle it) — cleaner long-term, but more work. Can be done in a follow-up.Add lock acquire/release around radio init/teardown:
Phase 6: Delete old driver
The
micropySX126X/directory (lilygo watcher driver) remains untouched — it is handled in a follow-up PR.What this PR does NOT change
lib/drivers/lora/micropySX126X/lilygo_t_watch_s3_plus.pyboard initlvgl_micropython)Patches
No
.patchfiles needed for the driver itself. All board-specific divergences (SPI adapter, CH32 reset,dio2_rf_sw=False, TCXO voltage, constructor translation) live in the two adapter files. If a future upstream change forces a source modification, we add a.patchfile in the repo root (beside the existinglvgl_micropython/*.patchfiles) and apply it inbuild_mpos.sh.Future work: lilygo_t_watch_s3_plus
The lilygo watch uses
micropySX126X/sx1262.pywith SoftSPI fallback. The same upstream driver can serve it via a different adapter that:machine.SPIorSoftSPI(noSPI.Bus/SPI.Devicesplit needed on that board)The
LoRaManagerlock + watchdog work the same way — they are board-agnostic.Testing plan
make build-mpos-unix— verifies imports, adapter logic, lora package structuremake lint— no regressionspython3 scripts/test_runner.py tests/test_*.py— nothing breaksLoRaManager.acquire()blocks second app from configuring radioreset_chip()still worksRefs: PR #222, upstream lora driver