Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a577f8b
Add littleFS support for external flash on nRF
benallfree Apr 12, 2026
697b131
Add FS routing support for virtual mount points in FSCommon
benallfree Apr 12, 2026
488d405
fix(xmodem): truncate file on open instead of appending
benallfree Apr 12, 2026
187538d
Implement file removal before writing in XModemAdapter to prevent app…
benallfree Apr 12, 2026
d6dad77
path creation fix
benallfree Apr 13, 2026
b5de332
tdeck fixes
benallfree Apr 13, 2026
d89a5a2
fix: file name buffer sanitization
benallfree Apr 15, 2026
c4d89a9
Merge branch 'develop' into fix/xmodem-truncate-on-write
benallfree Apr 15, 2026
fe704a7
fix: implement temporary file handling for XModem reception
benallfree Apr 15, 2026
3edf9e8
Merge branch 'fix/xmodem-truncate-on-write' of github.com:MeshEnvy/fi…
benallfree Apr 15, 2026
a73c80c
Merge branch 'develop' into feat/nrf-external-flash
benallfree Apr 15, 2026
1d43380
Merge branch 'develop' into feat/xmodem-external-flash
benallfree Apr 15, 2026
2204375
Merge branch 'fix/xmodem-truncate-on-write' into feat/nrf-external-flash
benallfree Apr 15, 2026
4483454
Merge branch 'feat/nrf-external-flash' of github.com:MeshEnvy/firmwar…
benallfree Apr 15, 2026
5942c0f
Merge branch 'feat/nrf-external-flash' into feat/xmodem-external-flash
benallfree Apr 15, 2026
d72f53d
Merge branch 'feat/xmodem-external-flash' of github.com:MeshEnvy/firm…
benallfree Apr 15, 2026
b2391b9
refactor: streamline file copy and rename functions with fsRoute inte…
benallfree Apr 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
260 changes: 191 additions & 69 deletions src/FSCommon.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 <vector>

/**
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
*/
Expand Down
68 changes: 67 additions & 1 deletion src/FSCommon.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<meshtastic_FileInfo> 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();
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
3 changes: 2 additions & 1 deletion src/SerialConsole.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading