diff --git a/src/FSCommon.cpp b/src/FSCommon.cpp index f215be80fb6..b90b051cdb9 100644 --- a/src/FSCommon.cpp +++ b/src/FSCommon.cpp @@ -30,75 +30,6 @@ SPIClass SPI_HSPI(HSPI); #endif // HAS_SDCARD -/** - * @brief Copies a file from one location to another. - * - * @param from The path of the source file. - * @param to The path of the destination file. - * @return true if the file was successfully copied, false otherwise. - */ -bool copyFile(const char *from, const char *to) -{ -#ifdef FSCom - // take SPI Lock - concurrency::LockGuard g(spiLock); - unsigned char cbuffer[16]; - - File f1 = FSCom.open(from, FILE_O_READ); - if (!f1) { - LOG_ERROR("Failed to open source file %s", from); - return false; - } - - File f2 = FSCom.open(to, FILE_O_WRITE); - if (!f2) { - LOG_ERROR("Failed to open destination file %s", to); - return false; - } - - while (f1.available() > 0) { - byte i = f1.read(cbuffer, 16); - f2.write(cbuffer, i); - } - - f2.flush(); - f2.close(); - f1.close(); - return true; -#endif -} - -/** - * Renames a file from pathFrom to pathTo. - * - * @param pathFrom The original path of the file. - * @param pathTo The new path of the file. - * - * @return True if the file was successfully renamed, false otherwise. - */ -bool renameFile(const char *pathFrom, const char *pathTo) -{ -#ifdef FSCom - -#ifdef ARCH_ESP32 - // take SPI Lock - spiLock->lock(); - // rename was fixed for ESP32 IDF LittleFS in April - bool result = FSCom.rename(pathFrom, pathTo); - spiLock->unlock(); - return result; -#else - // copyFile does its own locking. - if (copyFile(pathFrom, pathTo) && FSCom.remove(pathFrom)) { - return true; - } else { - return false; - } -#endif - -#endif -} - #include /** @@ -284,6 +215,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 +240,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 @@ -302,6 +255,175 @@ void fsInit() #endif } +// ── FSRoute virtual mount-point routing ────────────────────────────────────── + +#ifdef FSCom + +FSRoute fsRoute(const char *path) +{ + FSRoute r; + if (strncmp(path, "/__ext__/", 9) == 0) { + r.mount = FsMount::External; + r.path[0] = '/'; + strlcpy(r.path + 1, path + 9, sizeof(r.path) - 1); + } else if (strncmp(path, "/__int__/", 9) == 0) { + r.mount = FsMount::Internal; + r.path[0] = '/'; + strlcpy(r.path + 1, path + 9, sizeof(r.path) - 1); + } else if (strncmp(path, "/__sd__/", 8) == 0) { + r.mount = FsMount::SD; + r.path[0] = '/'; + strlcpy(r.path + 1, path + 8, sizeof(r.path) - 1); + } else { + r.mount = FsMount::Internal; + strlcpy(r.path, path, sizeof(r.path)); + } + return r; +} + +// ── FS selection helper ─────────────────────────────────────────────────────── +// Returns the correct FS object for the given mount, falling back to FSCom +// when the requested mount is unavailable. + +#if defined(ARCH_NRF52) +static Adafruit_LittleFS &_fsForMount(FsMount mount) +{ + if (mount == FsMount::External && extFS != nullptr) + return *extFS; + // SD: future + return (Adafruit_LittleFS &)FSCom; +} +#endif + +// ── Public helpers ──────────────────────────────────────────────────────────── + +File fsOpenRead(const FSRoute &r) +{ +#if defined(ARCH_NRF52) + return _fsForMount(r.mount).open(r.path, FILE_O_READ); +#else + (void)r.mount; + return FSCom.open(r.path, FILE_O_READ); +#endif +} + +File fsOpenWrite(const FSRoute &r) +{ +#if defined(ARCH_NRF52) + return _fsForMount(r.mount).open(r.path, FILE_O_WRITE); +#else + (void)r.mount; + return FSCom.open(r.path, FILE_O_WRITE); +#endif +} + +bool fsRemove(const FSRoute &r) +{ +#if defined(ARCH_NRF52) + return _fsForMount(r.mount).remove(r.path); +#else + (void)r.mount; + return FSCom.remove(r.path); +#endif +} + +bool fsMkdir(const FSRoute &r) +{ +#if defined(ARCH_NRF52) + return _fsForMount(r.mount).mkdir(r.path); +#else + (void)r.mount; + return FSCom.mkdir(r.path); +#endif +} + +bool fsExists(const FSRoute &r) +{ +#if defined(ARCH_NRF52) + return _fsForMount(r.mount).exists(r.path); +#else + (void)r.mount; + return FSCom.exists(r.path); +#endif +} + +/** Caller must hold spiLock (avoids deadlock if fsRename falls back to copy). */ +static bool fsStreamFileCopyUnlocked(const FSRoute &from, const FSRoute &to) +{ + File f1 = fsOpenRead(from); + if (!f1) { + LOG_ERROR("fsCopy: failed to open source %s", from.path); + return false; + } + File f2 = fsOpenWrite(to); + if (!f2) { + LOG_ERROR("fsCopy: failed to open dest %s", to.path); + f1.close(); + return false; + } + uint8_t buf[128]; + while (f1.available() > 0) { + size_t n = f1.read(buf, sizeof(buf)); + if (n == 0) { + if (f1.available() > 0) { + f1.close(); + f2.close(); + return false; + } + break; + } + if (f2.write(buf, n) != n) { + f1.close(); + f2.close(); + return false; + } + } + f2.flush(); + f2.close(); + f1.close(); + return true; +} + +bool fsCopy(const FSRoute &from, const FSRoute &to) +{ + concurrency::LockGuard g(spiLock); + return fsStreamFileCopyUnlocked(from, to); +} + +bool fsRename(const FSRoute &from, const FSRoute &to) +{ +#if defined(ARCH_NRF52) + if (from.mount != to.mount) { + LOG_WARN("fsRename: mount mismatch (use fsCopy then fsRemove)"); + return false; + } +#endif + concurrency::LockGuard g(spiLock); +#if defined(ARCH_NRF52) + return _fsForMount(from.mount).rename(from.path, to.path); +#elif defined(ARCH_ESP32) + return FSCom.rename(from.path, to.path); +#else + if (FSCom.rename(from.path, to.path)) + return true; + if (!fsStreamFileCopyUnlocked(from, to)) + return false; + return fsRemove(from); +#endif +} + +bool copyFile(const char *from, const char *to) +{ + return fsCopy(fsRoute(from), fsRoute(to)); +} + +bool renameFile(const char *pathFrom, const char *pathTo) +{ + return fsRename(fsRoute(pathFrom), fsRoute(pathTo)); +} + +#endif // FSCom + /** * Initializes the SD card and mounts the file system. */ diff --git a/src/FSCommon.h b/src/FSCommon.h index fdc0b76ecd1..bfb5e80c161 100644 --- a/src/FSCommon.h +++ b/src/FSCommon.h @@ -45,14 +45,80 @@ 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(); void fsListFiles(); +/** Resolve paths with fsRoute() then fsCopy (may cross mounts). */ bool copyFile(const char *from, const char *to); +/** Resolve paths with fsRoute() then fsRename (nRF52: same mount only). */ bool renameFile(const char *pathFrom, const char *pathTo); std::vector getFiles(const char *dirname, uint8_t levels); void listDir(const char *dirname, uint8_t levels, bool del = false); void rmDir(const char *dirname); -void setupSDCard(); \ No newline at end of file +void setupSDCard(); + +#ifdef FSCom +/** + * Virtual filesystem mount-point routing. + * + * Path-prefix convention (double-underscore delimiters avoid collisions): + * /__int__/foo → internal flash (InternalFS / LittleFS) + * /__ext__/foo → external flash (QSPI LittleFS if mounted, else internal) + * /__sd__/foo → SD card (if mounted, else internal) + * /foo → internal flash (bare paths passed through unchanged) + * + * This provides a lightweight mount-point convention without a full VFS. + * All consumers call fsRoute() + the helpers below; when Meshtastic gains a + * proper VFS layer these functions become the adapter to it. + */ +enum class FsMount { Internal, External, SD }; + +struct FSRoute { + FsMount mount = FsMount::Internal; + char path[128] = {}; // real path after prefix stripped +}; + +/** Resolve a path string to an FSRoute (mount + stripped path). */ +FSRoute fsRoute(const char *path); + +/** Open a file for reading on the routed filesystem. */ +File fsOpenRead(const FSRoute &r); + +/** Open a file for writing on the routed filesystem. */ +File fsOpenWrite(const FSRoute &r); + +/** Remove a file on the routed filesystem. Returns true on success. */ +bool fsRemove(const FSRoute &r); + +/** Create a directory (and parents) on the routed filesystem. */ +bool fsMkdir(const FSRoute &r); + +/** Return true if the path exists on the routed filesystem. */ +bool fsExists(const FSRoute &r); + +/** Rename within one mounted FS (stripped paths). Fails if mounts differ on nRF52 (different physical FS). */ +bool fsRename(const FSRoute &from, const FSRoute &to); + +/** Byte copy; may cross mounts (opens each side on the correct FS). */ +bool fsCopy(const FSRoute &from, const FSRoute &to); +#endif // FSCom \ No newline at end of file diff --git a/src/SerialConsole.cpp b/src/SerialConsole.cpp index 2a3f08cbc85..0188b098809 100644 --- a/src/SerialConsole.cpp +++ b/src/SerialConsole.cpp @@ -5,6 +5,7 @@ #include "Throttle.h" #include "configuration.h" #include "time.h" +#include "xmodem.h" #if defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT #define IS_USB_SERIAL @@ -95,7 +96,7 @@ int32_t SerialConsole::runOnce() #if defined(SERIAL_HAS_ON_RECEIVE) || defined(CONFIG_IDF_TARGET_ESP32S2) return Port.available() ? delay : INT32_MAX; #elif defined(IS_USB_SERIAL) - return HWCDC::isPlugged() ? delay : (1000 * 20); + return (HWCDC::isPlugged() || xModem.isActive() || delay < 250) ? delay : (1000 * 20); #else return delay; #endif 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..699a3dee1bd 100644 --- a/src/xmodem.cpp +++ b/src/xmodem.cpp @@ -50,9 +50,44 @@ #include "xmodem.h" #include "SPILock.h" +#include +#include #ifdef FSCom +/** Create every parent directory for @p route.path (file path) on the routed FS. */ +static bool mkdirParentsForRoute(const FSRoute &route) +{ + char tmp[sizeof(route.path)]; + strlcpy(tmp, route.path, sizeof(tmp)); + if (tmp[0] != '/') + return false; + + // Drop basename — only directories are created here. + char *slash = strrchr(tmp + 1, '/'); + if (!slash) + return true; // "/file.bin" at FS root + + *slash = '\0'; + if (strlen(tmp) <= 1) + return true; + + // Prefix mkdir at each internal '/', then the full dirname (best-effort; already-exists is OK). + for (char *s = tmp + 1; *s; s++) { + if (*s != '/') + continue; + *s = '\0'; + FSRoute d = route; + strlcpy(d.path, tmp, sizeof(d.path)); + *s = '/'; + fsMkdir(d); + } + FSRoute d = route; + strlcpy(d.path, tmp, sizeof(d.path)); + fsMkdir(d); + return true; +} + XModemAdapter xModem; XModemAdapter::XModemAdapter() {} @@ -118,12 +153,38 @@ void XModemAdapter::handlePacket(meshtastic_XModem xmodemPacket) case meshtastic_XModem_Control_SOH: 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); + // NULL packet has the destination filename (protobuf bytes are not NUL-terminated) + size_t n = xmodemPacket.buffer.size; + if (n >= sizeof(filename)) + n = sizeof(filename) - 1; + memcpy(filename, xmodemPacket.buffer.bytes, n); + filename[n] = '\0'; + activeRoute_ = fsRoute(filename); 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) { + fsRemove(fsRoute(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; + } + FSRoute tmpRoute = fsRoute(recvTmpPath); + if (fsExists(tmpRoute) && !fsRemove(tmpRoute)) { + spiLock->unlock(); + LOG_WARN("XModem: Failed to remove existing temp before receive: %s", recvTmpPath); + sendControl(meshtastic_XModem_Control_NAK); + isReceiving = false; + break; + } + mkdirParentsForRoute(tmpRoute); + file = fsOpenWrite(tmpRoute); spiLock->unlock(); if (file) { sendControl(meshtastic_XModem_Control_ACK); @@ -131,13 +192,14 @@ 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; } else { // Transmit this file from Flash LOG_INFO("XModem: Transmit file %s", filename); spiLock->lock(); - file = FSCom.open(filename, FILE_O_READ); + file = fsOpenRead(activeRoute_); spiLock->unlock(); if (file) { packetno = 1; @@ -163,6 +225,16 @@ void XModemAdapter::handlePacket(meshtastic_XModem xmodemPacket) } } else { if (isReceiving) { + if (xmodemPacket.seq == 0) { + // Duplicate OPEN retry (client re-sent while already receiving) — re-ACK so Python proceeds. + sendControl(meshtastic_XModem_Control_ACK); + break; + } + if (xmodemPacket.seq + 1 == packetno) { + // Already-delivered packet still in flight (stale serial buffer retry) — re-ACK. + sendControl(meshtastic_XModem_Control_ACK); + break; + } // normal file data packet if ((xmodemPacket.seq == packetno) && check(xmodemPacket.buffer.bytes, xmodemPacket.buffer.size, xmodemPacket.crc16)) { @@ -186,24 +258,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 (!fsRename(fsRoute(recvTmpPath), activeRoute_)) { + 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 (!fsRename(fsRoute(recvTmpPath), activeRoute_)) { + 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(); + } + fsRemove(fsRoute(recvTmpPath)); spiLock->unlock(); isReceiving = false; + recvCommitPending = false; + memset(recvTmpPath, 0, sizeof(recvTmpPath)); break; case meshtastic_XModem_Control_ACK: // Acknowledge Send the next packet @@ -255,7 +352,6 @@ void XModemAdapter::handlePacket(meshtastic_XModem xmodemPacket) xmodemStore.seq = packetno; spiLock->lock(); file.seek((packetno - 1) * sizeof(meshtastic_XModem_buffer_t::bytes)); - xmodemStore.buffer.size = file.read(xmodemStore.buffer.bytes, sizeof(meshtastic_XModem_buffer_t::bytes)); spiLock->unlock(); xmodemStore.crc16 = crc16_ccitt(xmodemStore.buffer.bytes, xmodemStore.buffer.size); diff --git a/src/xmodem.h b/src/xmodem.h index 4cfcb43e18b..872498aafad 100644 --- a/src/xmodem.h +++ b/src/xmodem.h @@ -51,9 +51,11 @@ class XModemAdapter void handlePacket(meshtastic_XModem xmodemPacket); meshtastic_XModem getForPhone(); void resetForPhone(); + bool isActive() const { return isReceiving || isTransmitting; } private: bool isReceiving = false; + bool recvCommitPending = false; bool isTransmitting = false; bool isEOT = false; @@ -61,6 +63,7 @@ class XModemAdapter uint16_t packetno = 0; +// Adafruit nRF/STM32 File can be constructed bound to FSCom; Arduino-ESP32 fs::File cannot. #if defined(ARCH_NRF52) || defined(ARCH_STM32WL) File file = File(FSCom); #else @@ -68,6 +71,9 @@ class XModemAdapter #endif char filename[sizeof(meshtastic_XModem_buffer_t::bytes)] = {0}; + FSRoute activeRoute_; // final path; resolved at SOH seq 0, reused for transmit + EOT commit + /** Logical receive scratch path (`filename` + `.tmp`); commit via fsRename 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