Skip to content

LoRa driver unification: replace custom SX1262 driver with upstream micropython-lib + adapter + framework watchdog #229

Description

@ThomasFarstrike

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

  1. Desktop build: make build-mpos-unix — verifies imports, adapter logic, lora package structure
  2. Lint: make lint — no regressions
  3. Existing tests: python3 scripts/test_runner.py tests/test_*.py — nothing breaks
  4. 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
  5. MeshCore external app: verify import change + reset_chip() still works

Refs: PR #222, upstream lora driver

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions