diff --git a/src/FSCommon.cpp b/src/FSCommon.cpp index f215be80fb6..2c14f308c88 100644 --- a/src/FSCommon.cpp +++ b/src/FSCommon.cpp @@ -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 @@ -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 diff --git a/src/FSCommon.h b/src/FSCommon.h index fdc0b76ecd1..9b3de20c2be 100644 --- a/src/FSCommon.h +++ b/src/FSCommon.h @@ -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(); diff --git a/src/platform/nrf52/extfs-nrf52.cpp b/src/platform/nrf52/extfs-nrf52.cpp new file mode 100644 index 00000000000..f316da5fc4d --- /dev/null +++ b/src/platform/nrf52/extfs-nrf52.cpp @@ -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 +#include +#include + +// ── 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 diff --git a/src/xmodem.cpp b/src/xmodem.cpp index 1d8c777600c..26d633e910d 100644 --- a/src/xmodem.cpp +++ b/src/xmodem.cpp @@ -50,6 +50,7 @@ #include "xmodem.h" #include "SPILock.h" +#include #ifdef FSCom @@ -119,11 +120,34 @@ 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); @@ -131,6 +155,7 @@ void XModemAdapter::handlePacket(meshtastic_XModem xmodemPacket) packetno = 1; break; } + LOG_WARN("XModem: Failed to open temp for receive: %s", recvTmpPath); sendControl(meshtastic_XModem_Control_NAK); isReceiving = false; break; @@ -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 diff --git a/src/xmodem.h b/src/xmodem.h index 4cfcb43e18b..8a68df3419c 100644 --- a/src/xmodem.h +++ b/src/xmodem.h @@ -54,6 +54,7 @@ class XModemAdapter private: bool isReceiving = false; + bool recvCommitPending = false; bool isTransmitting = false; bool isEOT = false; @@ -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; diff --git a/variants/nrf52840/nano-g2-ultra/variant.h b/variants/nrf52840/nano-g2-ultra/variant.h index 631af72d865..93f5e401177 100644 --- a/variants/nrf52840/nano-g2-ultra/variant.h +++ b/variants/nrf52840/nano-g2-ultra/variant.h @@ -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 diff --git a/variants/nrf52840/rak_wismeshtap/variant.h b/variants/nrf52840/rak_wismeshtap/variant.h index 358117cd5e8..106fce89d00 100644 --- a/variants/nrf52840/rak_wismeshtap/variant.h +++ b/variants/nrf52840/rak_wismeshtap/variant.h @@ -154,6 +154,7 @@ static const uint8_t SCK = PIN_SPI_SCK; // On-board QSPI Flash #define EXTERNAL_FLASH_DEVICES IS25LP080D #define EXTERNAL_FLASH_USE_QSPI +#define MESHTASTIC_EXTERNAL_FLASH_FS /* @note RAK5005-O GPIO mapping to RAK4631 GPIO ports RAK5005-O <-> nRF52840 diff --git a/variants/nrf52840/t-echo-lite/variant.h b/variants/nrf52840/t-echo-lite/variant.h index 54c7bdfb510..01d26f2d4c2 100644 --- a/variants/nrf52840/t-echo-lite/variant.h +++ b/variants/nrf52840/t-echo-lite/variant.h @@ -98,6 +98,7 @@ static const uint8_t A0 = PIN_A0; // On-board QSPI Flash #define EXTERNAL_FLASH_DEVICES ZD25WQ32CEIGR #define EXTERNAL_FLASH_USE_QSPI +#define MESHTASTIC_EXTERNAL_FLASH_FS // Lora radio diff --git a/variants/nrf52840/t-echo-plus/variant.h b/variants/nrf52840/t-echo-plus/variant.h index 7ebdf48c02f..0cca07efc69 100644 --- a/variants/nrf52840/t-echo-plus/variant.h +++ b/variants/nrf52840/t-echo-plus/variant.h @@ -77,6 +77,7 @@ static const uint8_t A0 = PIN_A0; // On-board QSPI Flash #define EXTERNAL_FLASH_DEVICES MX25R1635F #define EXTERNAL_FLASH_USE_QSPI +#define MESHTASTIC_EXTERNAL_FLASH_FS // LoRa SX1262 #define USE_SX1262 diff --git a/variants/nrf52840/t-echo/variant.h b/variants/nrf52840/t-echo/variant.h index f4644c6dee8..b13046121f8 100644 --- a/variants/nrf52840/t-echo/variant.h +++ b/variants/nrf52840/t-echo/variant.h @@ -121,6 +121,7 @@ External serial flash WP25R1635FZUIL0 // On-board QSPI Flash #define EXTERNAL_FLASH_DEVICES MX25R1635F #define EXTERNAL_FLASH_USE_QSPI +#define MESHTASTIC_EXTERNAL_FLASH_FS /* * Lora radio