Skip to content
Draft
22 changes: 22 additions & 0 deletions src/FSCommon.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,22 @@ void rmDir(const char *dirname)
*/
__attribute__((weak, noinline)) void preFSBegin() {}

#if defined(ARCH_NRF52)
// Default null; set by extFSInit() when external flash is available.
Adafruit_LittleFS *extFS = nullptr;

/**
* Default weak implementation — no external filesystem.
* Override in your firmware to mount a QSPI/SPI flash chip and assign extFS:
*
* void extFSInit() {
* static MyExternalFS myFS;
* if (myFS.begin()) extFS = &myFS;
* }
*/
__attribute__((weak, noinline)) void extFSInit() {}
#endif

void fsInit()
{
#ifdef FSCom
Expand All @@ -293,6 +309,12 @@ void fsInit()
LOG_ERROR("Filesystem mount failed");
// assert(0); This auto-formats the partition, so no need to fail here.
}
#if defined(ARCH_NRF52)
extFSInit();
if (extFS) {
LOG_DEBUG("External filesystem mounted OK");
}
#endif
#if defined(ARCH_ESP32)
LOG_DEBUG("Filesystem files (%d/%d Bytes):", FSCom.usedBytes(), FSCom.totalBytes());
#else
Expand Down
18 changes: 18 additions & 0 deletions src/FSCommon.h
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,25 @@ using namespace STM32_LittleFS_Namespace;
#include "InternalFileSystem.h"
#define FSCom InternalFS
#define FSBegin() FSCom.begin() // InternalFS formats on failure
#include "Adafruit_LittleFS.h"
using namespace Adafruit_LittleFS_Namespace;

/**
* Optional external LittleFS instance (e.g. QSPI flash on boards that define
* EXTERNAL_FLASH_USE_QSPI in their variant.h).
*
* Null by default. Set by extFSInit() when a board or firmware module
* initialises an external flash filesystem. XModem uses this pointer to
* route "/ext/" paths to external storage instead of InternalFS.
*/
extern Adafruit_LittleFS *extFS;

/**
* Called from fsInit() to initialise the external filesystem.
* The default weak implementation is a no-op; override in a platform or
* firmware module to mount the QSPI (or SPI) flash and assign extFS.
*/
void extFSInit();
#endif

void fsInit();
Expand Down
198 changes: 198 additions & 0 deletions src/platform/nrf52/extfs-nrf52.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/**
* @file extfs-nrf52.cpp
* @brief Optional external QSPI LittleFS for nRF52840 boards.
*
* Compiled only when BOTH of the following are defined in a board's variant.h:
*
* EXTERNAL_FLASH_USE_QSPI — chip is physically wired (hardware fact)
* MESHTASTIC_EXTERNAL_FLASH_FS — board opts into LittleFS on that chip
*
* The two-define pattern lets boards that use external flash for other purposes
* (raw storage, FAT, custom formats) declare the hardware without getting an
* unwanted LittleFS mount. If either define is absent this file is a no-op
* and extFS stays null.
*
* Required variant.h defines (already on QSPI-capable boards):
* PIN_QSPI_SCK, PIN_QSPI_CS, PIN_QSPI_IO0..IO3
* EXTERNAL_FLASH_USE_QSPI (hardware capability)
* MESHTASTIC_EXTERNAL_FLASH_FS (intent to use as LittleFS — add this)
*
* Optional build flag:
* EXTFLASH_SIZE_BYTES — total chip size in bytes (defaults to 2 MB)
*
* This file is intentionally self-contained so it can be proposed as a
* pull request to Meshtastic firmware without touching other platform files
* beyond the weak extFSInit() hook in FSCommon.cpp.
*/

#if defined(ARCH_NRF52) && defined(EXTERNAL_FLASH_USE_QSPI) && defined(MESHTASTIC_EXTERNAL_FLASH_FS)

#include "FSCommon.h"
#include "configuration.h"
#include <Arduino.h>
#include <nrfx_qspi.h>
#include <cstring>

// ── Chip geometry ─────────────────────────────────────────────────────────────

#ifndef EXTFLASH_SIZE_BYTES
#define EXTFLASH_SIZE_BYTES (2 * 1024 * 1024) // Default: 2 MB (MX25R1635F / GD25Q16C)
#endif

#define EXTFLASH_PAGE_SIZE 256
#define EXTFLASH_SECTOR_SIZE 4096
#define EXTFLASH_BLOCK_COUNT (EXTFLASH_SIZE_BYTES / EXTFLASH_SECTOR_SIZE)

// ── QSPI hardware init ────────────────────────────────────────────────────────

static uint8_t _qspi_scratch[EXTFLASH_PAGE_SIZE] __attribute__((aligned(4)));
static bool _qspi_ready = false;

static bool _qspi_init()
{
if (_qspi_ready) return true;

#ifndef NRFX_QSPI_DEFAULT_CONFIG_IRQ_PRIORITY
#define NRFX_QSPI_DEFAULT_CONFIG_IRQ_PRIORITY 6
#endif

nrfx_qspi_config_t cfg = NRFX_QSPI_DEFAULT_CONFIG(
PIN_QSPI_SCK, PIN_QSPI_CS,
PIN_QSPI_IO0, PIN_QSPI_IO1, PIN_QSPI_IO2, PIN_QSPI_IO3);

// Conservative 8 MHz clock; avoids needing the QE status-register bit set
// for true quad I/O. Works reliably on all boards with QSPI flash.
cfg.phy_if.sck_freq = NRF_QSPI_FREQ_DIV8;

nrfx_err_t err = nrfx_qspi_init(&cfg, NULL, NULL); // blocking mode
if (err == NRFX_ERROR_INVALID_STATE) {
// Already initialised (e.g. by bootloader hand-off) — that's fine.
_qspi_ready = true;
return true;
}
if (err != NRFX_SUCCESS) {
LOG_ERROR("QSPI init failed: %d", (int)err);
return false;
}

_qspi_ready = true;
LOG_DEBUG("External QSPI flash ready (8 MHz)");
return true;
}

// ── LittleFS I/O callbacks ────────────────────────────────────────────────────

static int _ef_read(const struct lfs_config *c, lfs_block_t block,
lfs_off_t off, void *buf, lfs_size_t size)
{
(void)c;
uint32_t addr = block * EXTFLASH_SECTOR_SIZE + off;

if (((uintptr_t)buf & 3) == 0 && (size & 3) == 0) {
return (nrfx_qspi_read(buf, size, addr) == NRFX_SUCCESS) ? LFS_ERR_OK : LFS_ERR_IO;
}

// Unaligned: go through scratch
uint8_t *dst = (uint8_t *)buf;
while (size > 0) {
uint32_t chunk = (size > sizeof(_qspi_scratch)) ? sizeof(_qspi_scratch) : size;
uint32_t qchunk = (chunk + 3) & ~3u;
if (nrfx_qspi_read(_qspi_scratch, qchunk, addr) != NRFX_SUCCESS) return LFS_ERR_IO;
memcpy(dst, _qspi_scratch, chunk);
dst += chunk; addr += chunk; size -= chunk;
}
return LFS_ERR_OK;
}

static int _ef_prog(const struct lfs_config *c, lfs_block_t block,
lfs_off_t off, const void *buf, lfs_size_t size)
{
(void)c;
uint32_t addr = block * EXTFLASH_SECTOR_SIZE + off;

if (((uintptr_t)buf & 3) == 0 && (size & 3) == 0) {
if (nrfx_qspi_write(buf, size, addr) != NRFX_SUCCESS) return LFS_ERR_IO;
} else {
const uint8_t *src = (const uint8_t *)buf;
while (size > 0) {
uint32_t chunk = (size > sizeof(_qspi_scratch)) ? sizeof(_qspi_scratch) : size;
uint32_t qchunk = (chunk + 3) & ~3u;
memcpy(_qspi_scratch, src, chunk);
for (uint32_t i = chunk; i < qchunk; i++) _qspi_scratch[i] = 0xFF;
if (nrfx_qspi_write(_qspi_scratch, qchunk, addr) != NRFX_SUCCESS) return LFS_ERR_IO;
src += chunk; addr += chunk; size -= chunk;
}
}

while (nrfx_qspi_mem_busy_check() == NRFX_ERROR_BUSY) yield();
return LFS_ERR_OK;
}

static int _ef_erase(const struct lfs_config *c, lfs_block_t block)
{
(void)c;
uint32_t addr = block * EXTFLASH_SECTOR_SIZE;
if (nrfx_qspi_erase(NRF_QSPI_ERASE_LEN_4KB, addr) != NRFX_SUCCESS) return LFS_ERR_IO;
while (nrfx_qspi_mem_busy_check() == NRFX_ERROR_BUSY) yield();
return LFS_ERR_OK;
}

static int _ef_sync(const struct lfs_config *c)
{
(void)c;
while (nrfx_qspi_mem_busy_check() == NRFX_ERROR_BUSY) yield();
return LFS_ERR_OK;
}

// ── LittleFS instance ─────────────────────────────────────────────────────────

static uint8_t _read_buf [EXTFLASH_PAGE_SIZE] __attribute__((aligned(4)));
static uint8_t _prog_buf [EXTFLASH_PAGE_SIZE] __attribute__((aligned(4)));
static uint8_t _lookahead_buf[64] __attribute__((aligned(4)));

static struct lfs_config _cfg = {
.context = NULL,
.read = _ef_read,
.prog = _ef_prog,
.erase = _ef_erase,
.sync = _ef_sync,

.read_size = EXTFLASH_PAGE_SIZE,
.prog_size = EXTFLASH_PAGE_SIZE,
.block_size = EXTFLASH_SECTOR_SIZE,
.block_count = EXTFLASH_BLOCK_COUNT,
.lookahead = 512,

.read_buffer = _read_buf,
.prog_buffer = _prog_buf,
.lookahead_buffer = _lookahead_buf,
.file_buffer = NULL,
};

static Adafruit_LittleFS _extLittleFS(&_cfg);

// ── extFSInit() — overrides the weak no-op in FSCommon.cpp ───────────────────

void extFSInit()
{
if (!_qspi_init()) return;

if (_extLittleFS.begin()) {
LOG_INFO("External QSPI LittleFS mounted (%u blocks x %u B)",
EXTFLASH_BLOCK_COUNT, EXTFLASH_SECTOR_SIZE);
extFS = &_extLittleFS;
return;
}

// First boot or corrupted — format then remount
LOG_WARN("External QSPI LittleFS mount failed, formatting...");
if (!_extLittleFS.format() || !_extLittleFS.begin()) {
LOG_ERROR("External QSPI LittleFS format/mount failed");
return;
}

LOG_INFO("External QSPI LittleFS formatted and mounted");
extFS = &_extLittleFS;
}

#endif // ARCH_NRF52 && EXTERNAL_FLASH_USE_QSPI && MESHTASTIC_EXTERNAL_FLASH_FS
68 changes: 59 additions & 9 deletions src/xmodem.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@

#include "xmodem.h"
#include "SPILock.h"
#include <cstdio>

#ifdef FSCom

Expand Down Expand Up @@ -119,18 +120,42 @@ void XModemAdapter::handlePacket(meshtastic_XModem xmodemPacket)
case meshtastic_XModem_Control_STX:
if ((xmodemPacket.seq == 0) && !isReceiving && !isTransmitting) {
// NULL packet has the destination filename
memcpy(filename, &xmodemPacket.buffer.bytes, xmodemPacket.buffer.size);
memset(filename, 0, sizeof(filename));
size_t filenameLen = xmodemPacket.buffer.size;
if (filenameLen >= sizeof(filename)) filenameLen = sizeof(filename) - 1;
memcpy(filename, xmodemPacket.buffer.bytes, filenameLen);
filename[filenameLen] = '\0';

if (xmodemPacket.control == meshtastic_XModem_Control_SOH) { // Receive this file and put to Flash
spiLock->lock();
file = FSCom.open(filename, FILE_O_WRITE);
if (recvCommitPending) {
FSCom.remove(recvTmpPath);
recvCommitPending = false;
}
int plen = snprintf(recvTmpPath, sizeof(recvTmpPath), "%s.tmp", filename);
if (plen < 0 || (size_t)plen >= sizeof(recvTmpPath)) {
spiLock->unlock();
LOG_WARN("XModem: Receive path too long for .tmp suffix");
sendControl(meshtastic_XModem_Control_NAK);
isReceiving = false;
break;
}
if (FSCom.exists(recvTmpPath) && !FSCom.remove(recvTmpPath)) {
spiLock->unlock();
LOG_WARN("XModem: Failed to remove existing temp before receive: %s", recvTmpPath);
sendControl(meshtastic_XModem_Control_NAK);
isReceiving = false;
break;
}
file = FSCom.open(recvTmpPath, FILE_O_WRITE);
spiLock->unlock();
if (file) {
sendControl(meshtastic_XModem_Control_ACK);
isReceiving = true;
packetno = 1;
break;
}
LOG_WARN("XModem: Failed to open temp for receive: %s", recvTmpPath);
sendControl(meshtastic_XModem_Control_NAK);
isReceiving = false;
break;
Expand Down Expand Up @@ -186,24 +211,49 @@ void XModemAdapter::handlePacket(meshtastic_XModem xmodemPacket)
}
break;
case meshtastic_XModem_Control_EOT:
// End of transmission
sendControl(meshtastic_XModem_Control_ACK);
// End of transmission (receive): flush temp, rename to final, ACK only after commit
if (!isReceiving && !recvCommitPending)
break;
if (recvCommitPending) {
if (!renameFile(recvTmpPath, filename)) {
LOG_WARN("XModem: rename temp to final failed (retry): %s -> %s", recvTmpPath, filename);
sendControl(meshtastic_XModem_Control_NAK);
break;
}
sendControl(meshtastic_XModem_Control_ACK);
recvCommitPending = false;
memset(recvTmpPath, 0, sizeof(recvTmpPath));
break;
}
spiLock->lock();
file.flush();
file.close();
spiLock->unlock();
isReceiving = false;
if (!renameFile(recvTmpPath, filename)) {
LOG_WARN("XModem: rename temp to final failed: %s -> %s", recvTmpPath, filename);
sendControl(meshtastic_XModem_Control_NAK);
recvCommitPending = true;
break;
}
sendControl(meshtastic_XModem_Control_ACK);
memset(recvTmpPath, 0, sizeof(recvTmpPath));
break;
case meshtastic_XModem_Control_CAN:
// Cancel transmission and remove file
// Cancel receive: drop partial temp only (final path unchanged)
if (!isReceiving && !recvCommitPending)
break;
sendControl(meshtastic_XModem_Control_ACK);
spiLock->lock();
file.flush();
file.close();

FSCom.remove(filename);
if (isReceiving) {
file.flush();
file.close();
}
FSCom.remove(recvTmpPath);
spiLock->unlock();
isReceiving = false;
recvCommitPending = false;
memset(recvTmpPath, 0, sizeof(recvTmpPath));
break;
case meshtastic_XModem_Control_ACK:
// Acknowledge Send the next packet
Expand Down
3 changes: 3 additions & 0 deletions src/xmodem.h
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class XModemAdapter

private:
bool isReceiving = false;
bool recvCommitPending = false;
bool isTransmitting = false;
bool isEOT = false;

Expand All @@ -68,6 +69,8 @@ class XModemAdapter
#endif

char filename[sizeof(meshtastic_XModem_buffer_t::bytes)] = {0};
/** Receive scratch path (`filename` + `.tmp`); commit via renameFile on EOT. */
char recvTmpPath[sizeof(filename) + 5] = {0};

protected:
meshtastic_XModem xmodemStore = meshtastic_XModem_init_zero;
Expand Down
1 change: 1 addition & 0 deletions variants/nrf52840/nano-g2-ultra/variant.h
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ External serial flash W25Q16JV_IQ
// On-board QSPI Flash
#define EXTERNAL_FLASH_DEVICES W25Q16JV_IQ
#define EXTERNAL_FLASH_USE_QSPI
#define MESHTASTIC_EXTERNAL_FLASH_FS

/*
* Lora radio
Expand Down
Loading