remove sideload plugin

This commit is contained in:
Ben Allfree
2026-04-13 04:18:50 -07:00
parent 07482650da
commit 2b77dc3b44
11 changed files with 0 additions and 1250 deletions
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 MeshEnvy
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-117
View File
@@ -1,117 +0,0 @@
# meshforge-sideload
A PlatformIO plugin used by the [MeshForge](https://meshforge.org) web flasher to sideload data files to Meshtastic devices after flashing.
**This is not a C++ library.** It adds no code to device firmware. Instead it:
1. Registers a `sideload` PlatformIO build target that transfers data files to a connected device via Meshtastic's built-in XModem file transfer protocol
2. Ships the Meshtastic firmware patches that enable `/__ext__/` and `/__int__/` path routing in XModem (see `patches/`)
## Protocol
Frames are delimited by `0xBB` and coexist safely with Meshtastic's serial API (which uses `0x94C3`) and KISS framing (which uses `0xC0`).
```
Frame: 0xBB <cmd:1> <len:2 LE> <payload:len> <crc8:1>
Response: 0xBB 0x80 <status:1> (0x00 = OK, 0x01 = ERR)
Commands:
0x00 PING payload=[] → OK + "MESHFORGE-SIDELOAD:0.1\n"
0x01 OPEN payload=[pathLen:1][path][size:4 LE]
0x02 DATA payload=[bytes, max 256]
0x03 CLOSE payload=[]
0x04 MKDIR payload=[path]
```
## Path routing
| Destination prefix | nRF52 | ESP32 |
|--------------------|-------|-------|
| `/ext/foo` | External QSPI LittleFS | LittleFS (prefix stripped) |
| `/int/foo` | InternalFS | LittleFS (prefix stripped) |
| `/foo` (bare) | InternalFS | LittleFS |
On ESP32 both prefixes are equivalent — the device has one filesystem. The prefix convention lets `meshforge.yaml` use a single universal path (e.g. `/ext/bbs/kb`) that works correctly on both platforms.
## Installation
```ini
; platformio.ini
[env:your_target]
lib_deps =
meshenvy/meshforge-sideload@^0.1.0
```
## Meshtastic integration
The library has no dependency on Meshtastic. Integrate it by calling `begin()` from your module's `setup()` and `poll()` from `runOnce()`.
### 1. Add the library
```ini
lib_deps =
meshenvy/meshforge-sideload@^0.1.0
```
### 2. Override the external FS (nRF52 only)
By default `/ext/` paths fall back to `InternalFS`. If your firmware has an external QSPI LittleFS, override the weak function anywhere in your source:
```cpp
// In your module .cpp, after including your QSPI flash header:
#if defined(MFSL_PLATFORM_NRF52)
Adafruit_LittleFS_Namespace::Adafruit_LittleFS& meshforgeSideloadExtFS() {
return myQspiFs; // your Adafruit_LittleFS instance, already mounted
}
#endif
```
The library never calls `begin()` on this FS — mount it before calling `MeshForgeSideload::begin()`.
### 3. Wire into your module
```cpp
#include "MeshForgeSideload.h"
static MeshForgeSideload meshForgeSideload;
void YourModule::setup() {
// ... your existing setup ...
meshForgeSideload.begin();
}
int32_t YourModule::runOnce() {
meshForgeSideload.poll();
// ... your existing logic ...
return interval;
}
```
`poll()` is non-blocking and returns immediately when no `0xBB` frame is present on Serial.
### 4. Declare data files in meshforge.yaml
Add a `meshforge.yaml` to your firmware repo root:
```yaml
meshforge:
data:
- my-data/*.bin:/ext/my/path
```
The MeshForge CI bundler includes these files in the firmware `.tar.gz`. The MeshForge web flasher reads the yaml from the bundle, waits for the device to boot after flashing, and sideloads the files automatically.
## MeshCore
The library is firmware-agnostic — it depends only on the Arduino `Serial` object and standard Adafruit nRF52 BSP / ESP-IDF Arduino filesystem APIs. MeshCore runs on the same Adafruit nRF52 BSP, so the integration should work identically: override `meshforgeSideloadExtFS()` if needed and call `poll()` from your main loop. Formal MeshCore testing has not been done yet.
## Platforms
| Platform | Support |
|----------|---------|
| nRF52840 (Adafruit BSP) | Supported — InternalFS + optional QSPI ext FS |
| ESP32 / ESP32-S3 | Supported — LittleFS |
| Other Arduino targets | Compile-time no-op with `#pragma message` warning |
## License
MIT — see [LICENSE](LICENSE).
@@ -1,97 +0,0 @@
"""
register_sideload_target.py — PlatformIO extra_script that registers the
`sideload` custom target for the meshforge-sideload library.
Transfers data files declared in meshforge.yaml to the device using
Node.uploadFile() from the meshtastic Python library.
Usage:
pio run -t sideload
pio run -t upload -t sideload
pio run -t sideload --upload-port /dev/cu.usbmodem101
MESHFORGE_PORT=/dev/cu.usbmodemXXXX pio run -t sideload
MESHFORGE_BOOT_WAIT=5 pio run -t sideload
"""
Import('env') # noqa: F821
import os
import sys
import time
def _ensure_vendor_meshtastic():
"""Add vendor/meshtastic-python to sys.path so we can import it."""
project_dir = env.subst('$PROJECT_DIR') # noqa: F821
# firmware/ → TinyBBS/ → vendor/ → mesh-forge root
candidate = os.path.normpath(os.path.join(project_dir, '..', '..', '..', 'vendor', 'meshtastic-python'))
if os.path.isdir(os.path.join(candidate, 'meshtastic')) and candidate not in sys.path:
sys.path.insert(0, candidate)
# Also add tools/ dir of meshforge-sideload for mf_protocol
libdeps = env.subst('$PROJECT_LIBDEPS_DIR') # noqa: F821
pioenv = env.subst('$PIOENV') # noqa: F821
tools_dir = os.path.join(libdeps, pioenv, 'meshforge-sideload', 'tools')
if os.path.isdir(tools_dir) and tools_dir not in sys.path:
sys.path.insert(0, tools_dir)
def _autodetect_port():
try:
import serial.tools.list_ports
KNOWN = ['RAK', 'nRF52', 'Adafruit', 'Nordic', 'Meshtastic', 'WisMesh',
'T-Echo', 'CP210', 'CH340', 'FTDI', 'USB Serial', 'JTAG',
'LilyGO', 'Espressif']
ports = list(serial.tools.list_ports.comports())
for p in ports:
desc = (p.description or '') + ' ' + (p.manufacturer or '')
if any(k.lower() in desc.lower() for k in KNOWN):
return p.device
for p in ports:
if 'usbmodem' in p.device or 'usbserial' in p.device:
return p.device
except ImportError:
pass
return None
def _sideload(source, target, env): # noqa: F821
_ensure_vendor_meshtastic()
try:
from mf_protocol import run_sideload
except ImportError as e:
print(f'meshforge-sideload: ERROR importing mf_protocol: {e}')
raise SystemExit(1)
project_dir = env['PROJECT_DIR'] # noqa: F821
port_name = (
env.get('UPLOAD_PORT')
or os.environ.get('MESHFORGE_PORT')
or _autodetect_port()
)
if not port_name:
print(
'meshforge-sideload: ERROR — no serial port found.\n'
' Pass one: pio run -t sideload --upload-port /dev/cu.usbmodemXXXX\n'
' Or set: MESHFORGE_PORT=/dev/cu.usbmodemXXXX pio run -t sideload'
)
raise SystemExit(1)
boot_wait = float(os.environ.get('MESHFORGE_BOOT_WAIT', '3'))
baud = int(env.get('MONITOR_SPEED', 115200))
run_sideload(
project_dir=project_dir,
port_name=port_name,
baud=baud,
boot_wait_s=boot_wait,
)
env.AddCustomTarget( # noqa: F821
name='sideload',
dependencies=None,
actions=_sideload,
title='MeshForge Sideload',
description='Upload meshforge.yaml data files to device via Meshtastic XModem',
)
-22
View File
@@ -1,22 +0,0 @@
{
"name": "meshforge-sideload",
"version": "0.1.0",
"description": "PlatformIO plugin that registers a 'sideload' build target for uploading data files to Meshtastic devices via XModem. Also ships the Meshtastic firmware patches required to enable /__ext__/ and /__int__/ path routing.",
"keywords": ["meshtastic", "meshtastic-module", "sideload", "xmodem"],
"authors": [
{
"name": "MeshEnvy",
"url": "https://github.com/meshenvy"
}
],
"repository": {
"type": "git",
"url": "https://github.com/meshenvy/mesh-forge"
},
"license": "MIT",
"frameworks": ["arduino"],
"platforms": ["nordicnrf52", "espressif32"],
"build": {
"extraScript": "extra_scripts/register_sideload_target.py"
}
}
@@ -1,30 +0,0 @@
From 488d40597572d54b19245e23282fcf2b1cd2140c Mon Sep 17 00:00:00 2001
From: Ben Allfree <ben@benallfree.com>
Date: Sun, 12 Apr 2026 13:33:44 -0700
Subject: [PATCH] fix(xmodem): truncate file on open instead of appending
FILE_O_WRITE on nRF52 (Adafruit LittleFS) appends to existing files
rather than truncating. Remove the file before opening for write so
repeated XModem uploads always produce the correct file size.
Made-with: Cursor
---
src/xmodem.cpp | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/xmodem.cpp b/src/xmodem.cpp
index 1d8c77760..cdbbaefe1 100644
--- a/src/xmodem.cpp
+++ b/src/xmodem.cpp
@@ -123,6 +123,8 @@ void XModemAdapter::handlePacket(meshtastic_XModem xmodemPacket)
if (xmodemPacket.control == meshtastic_XModem_Control_SOH) { // Receive this file and put to Flash
spiLock->lock();
+ // Remove existing file first so we truncate rather than append
+ if (FSCom.exists(filename)) FSCom.remove(filename);
file = FSCom.open(filename, FILE_O_WRITE);
spiLock->unlock();
if (file) {
--
2.50.1
@@ -1,354 +0,0 @@
From a577f8b8774f3a517c72b526dc8dc2b67cd0aea5 Mon Sep 17 00:00:00 2001
From: Ben Allfree <ben@benallfree.com>
Date: Sun, 12 Apr 2026 11:29:40 -0700
Subject: [PATCH] Add littleFS support for external flash on nRF
---
src/FSCommon.cpp | 22 +++
src/FSCommon.h | 18 ++
src/platform/nrf52/extfs-nrf52.cpp | 198 +++++++++++++++++++++
variants/nrf52840/nano-g2-ultra/variant.h | 1 +
variants/nrf52840/rak_wismeshtap/variant.h | 1 +
variants/nrf52840/t-echo-lite/variant.h | 1 +
variants/nrf52840/t-echo-plus/variant.h | 1 +
variants/nrf52840/t-echo/variant.h | 1 +
8 files changed, 243 insertions(+)
create mode 100644 src/platform/nrf52/extfs-nrf52.cpp
diff --git a/src/FSCommon.cpp b/src/FSCommon.cpp
index f215be80f..2c14f308c 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 fdc0b76ec..9b3de20c2 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 000000000..f316da5fc
--- /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 <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
diff --git a/variants/nrf52840/nano-g2-ultra/variant.h b/variants/nrf52840/nano-g2-ultra/variant.h
index 631af72d8..93f5e4011 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 358117cd5..106fce89d 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 54c7bdfb5..01d26f2d4 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 7ebdf48c0..0cca07efc 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 f4644c6de..b13046121 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
--
2.50.1
@@ -1,217 +0,0 @@
diff --git a/src/FSCommon.cpp b/src/FSCommon.cpp
index 2c14f308c..79e927e64 100644
--- a/src/FSCommon.cpp
+++ b/src/FSCommon.cpp
@@ -324,6 +324,100 @@ 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
+}
+
+#endif // FSCom
+
/**
* Initializes the SD card and mounts the file system.
*/
diff --git a/src/FSCommon.h b/src/FSCommon.h
index 9b3de20c2..1ac0d04a1 100644
--- a/src/FSCommon.h
+++ b/src/FSCommon.h
@@ -73,4 +73,44 @@ 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();
\ 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);
+#endif // FSCom
\ No newline at end of file
diff --git a/src/xmodem.cpp b/src/xmodem.cpp
index 1d8c77760..847fe49c8 100644
--- a/src/xmodem.cpp
+++ b/src/xmodem.cpp
@@ -120,10 +120,13 @@ void XModemAdapter::handlePacket(meshtastic_XModem xmodemPacket)
if ((xmodemPacket.seq == 0) && !isReceiving && !isTransmitting) {
// NULL packet has the destination filename
memcpy(filename, &xmodemPacket.buffer.bytes, xmodemPacket.buffer.size);
+ 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);
+ // Remove existing file first so we truncate rather than append
+ if (FSCom.exists(filename)) FSCom.remove(filename);
+ file = fsOpenWrite(activeRoute_);
spiLock->unlock();
if (file) {
sendControl(meshtastic_XModem_Control_ACK);
@@ -137,7 +140,7 @@ void XModemAdapter::handlePacket(meshtastic_XModem xmodemPacket)
} 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;
@@ -200,8 +203,7 @@ void XModemAdapter::handlePacket(meshtastic_XModem xmodemPacket)
spiLock->lock();
file.flush();
file.close();
-
- FSCom.remove(filename);
+ fsRemove(activeRoute_);
spiLock->unlock();
isReceiving = false;
break;
@@ -255,7 +257,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 4cfcb43e1..48ff25fbb 100644
--- a/src/xmodem.h
+++ b/src/xmodem.h
@@ -67,7 +67,8 @@ class XModemAdapter
File file;
#endif
- char filename[sizeof(meshtastic_XModem_buffer_t::bytes)] = {0};
+ char filename[sizeof(meshtastic_XModem_buffer_t::bytes)] = {0};
+ FSRoute activeRoute_; // resolved once at SOH, reused for DATA/EOT/CAN
protected:
meshtastic_XModem xmodemStore = meshtastic_XModem_init_zero;
@@ -1,83 +0,0 @@
commit b5de3323b3e106edb28efcda815d86a066ef34f4
Author: Ben Allfree <ben@benallfree.com>
Date: Sun Apr 12 21:31:37 2026 -0700
tdeck fixes
diff --git a/src/SerialConsole.cpp b/src/SerialConsole.cpp
index e24aa3c57..c81854a7b 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
@@ -92,7 +93,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/xmodem.cpp b/src/xmodem.cpp
index ccd6a1b95..3155a6526 100644
--- a/src/xmodem.cpp
+++ b/src/xmodem.cpp
@@ -152,8 +152,12 @@ 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
@@ -201,6 +205,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)) {
diff --git a/src/xmodem.h b/src/xmodem.h
index 48ff25fbb..5b8c4930d 100644
--- a/src/xmodem.h
+++ b/src/xmodem.h
@@ -51,6 +51,7 @@ class XModemAdapter
void handlePacket(meshtastic_XModem xmodemPacket);
meshtastic_XModem getForPhone();
void resetForPhone();
+ bool isActive() const { return isReceiving || isTransmitting; }
private:
bool isReceiving = false;
@@ -61,6 +62,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
-81
View File
@@ -1,81 +0,0 @@
# MeshForge Meshtastic patches
Two patches against Meshtastic firmware that enable MeshForge data sideloading.
Both are pending upstream merge. Once merged, these patches and the apply script
become unnecessary.
---
## 00-xmodem-truncate-fix.patch
**PR title:** `fix(xmodem): truncate file on open instead of appending`
Standalone 2-line fix. `FILE_O_WRITE` on nRF52 (Adafruit LittleFS) appends to
existing files rather than truncating. Removes the file before opening for write
so repeated XModem uploads always produce the correct file size. Independent of
the other two patches — can be reviewed and merged on its own.
**Sentinel:** `Remove existing file first so we truncate`
---
## 01-nrf-external-flash.patch
**PR title:** `feat(nrf52): optional external QSPI LittleFS filesystem`
Adds infrastructure for mounting the external QSPI flash chip as a LittleFS
volume on nRF52840 boards that have it wired:
- `src/FSCommon.h``extern Adafruit_LittleFS* extFS` pointer + `extFSInit()` declaration
- `src/FSCommon.cpp``extFS = nullptr` global, weak `extFSInit()` no-op, called from `fsInit()`
- `src/platform/nrf52/extfs-nrf52.cpp` — actual QSPI init + LittleFS mount using `nrfx_qspi`
- `variants/nrf52840/{t-echo,t-echo-lite,t-echo-plus,nano-g2-ultra,rak_wismeshtap}/variant.h` — add `MESHTASTIC_EXTERNAL_FLASH_FS`
**Sentinel:** `extFSInit`
---
## 02-xmodem-vfs-routing.patch
**PR title:** `feat(nrf52): virtual filesystem mount points + XModem path routing`
Depends on patch 01. Adds a lightweight VFS routing layer and wires XModem into it:
- `src/FSCommon.h``FsMount` enum, `FSRoute` struct, `fsRoute()` + helpers
- `src/FSCommon.cpp` — routing and helper implementations
- `src/xmodem.h` / `src/xmodem.cpp` — XModem uses `fsRoute()` for `/__ext__/` and `/__int__/` paths
Path prefix convention:
| Prefix | Destination |
|--------|-------------|
| `/__ext__/foo` | `extFS` if mounted, else internal |
| `/__int__/foo` | Internal flash (InternalFS / LittleFS) |
| `/__sd__/foo` | SD card (reserved, falls back to internal) |
| `/foo` | Internal flash — bare paths unchanged |
**Sentinel:** `fsRoute`
---
## Applying
```bash
# From within a Meshtastic firmware checkout:
git apply meshforge-sideload/patches/01-nrf-external-flash.patch
git apply meshforge-sideload/patches/02-xmodem-vfs-routing.patch
```
Or via PlatformIO extra_scripts (applied automatically at build time):
```ini
[env]
extra_scripts = pre:path/to/meshforge-sideload/patches/apply_patches.py
```
## Reverting
```bash
git apply -R meshforge-sideload/patches/02-xmodem-vfs-routing.patch
git apply -R meshforge-sideload/patches/01-nrf-external-flash.patch
```
@@ -1,86 +0,0 @@
"""
apply_patches.py — PlatformIO pre: extra_script.
Applies the MeshForge Meshtastic patches to the firmware source tree in order:
01-nrf-external-flash.patch — extFSInit hook, extFS pointer, extfs-nrf52.cpp
02-xmodem-vfs-routing.patch — FSRoute, fsRoute(), XModem /__ext__/ routing
Each patch is skipped if its sentinel string is already present (idempotent).
Safe to run on every build.
Add to your firmware's platformio.ini:
extra_scripts =
pre:path/to/apply_patches.py
"""
Import('env') # noqa: F821
import os
import subprocess
import sys
_PATCHES = [
('00-xmodem-truncate-fix.patch', 'Remove existing file first so we truncate'), # standalone fix
('01-nrf-external-flash.patch', 'extFSInit'), # PR1: external flash FS
('02-xmodem-vfs-routing.patch', 'fsRoute'), # PR2: VFS routing + XModem
]
_PATCH_DIR = os.path.dirname(os.path.abspath(__file__))
def _project_dir():
return env.subst('$PROJECT_DIR') # noqa: F821
def _sentinel_present(sentinel: str) -> bool:
"""Return True if the sentinel string already exists in the source tree."""
for dirpath, _, filenames in os.walk(os.path.join(_project_dir(), 'src')):
for fname in filenames:
if not (fname.endswith('.h') or fname.endswith('.cpp')):
continue
try:
with open(os.path.join(dirpath, fname), encoding='utf-8', errors='replace') as f:
if sentinel in f.read():
return True
except OSError:
pass
return False
def _apply(patch_file: str) -> None:
patch_path = os.path.join(_PATCH_DIR, patch_file)
if not os.path.isfile(patch_path):
print(f'[meshforge-patches] WARNING: {patch_file} not found')
return
# Dry-run first
check = subprocess.run(
['git', 'apply', '--check', '--whitespace=nowarn', patch_path],
cwd=_project_dir(),
capture_output=True,
)
if check.returncode != 0:
print(f'[meshforge-patches] WARNING: {patch_file} cannot apply cleanly — skipping '
f'(already applied or conflict)')
return
result = subprocess.run(
['git', 'apply', '--whitespace=nowarn', patch_path],
cwd=_project_dir(),
capture_output=True,
)
if result.returncode == 0:
print(f'[meshforge-patches] Applied {patch_file}')
else:
print(f'[meshforge-patches] ERROR applying {patch_file}:\n{result.stderr.decode()}')
sys.exit(1)
for patch_file, sentinel in _PATCHES:
if _sentinel_present(sentinel):
print(f'[meshforge-patches] {patch_file} already applied — skipping')
else:
print(f'[meshforge-patches] Applying {patch_file}...')
_apply(patch_file)
-142
View File
@@ -1,142 +0,0 @@
"""
mf_protocol.py — MeshForge sideload via meshtastic-python XModem.
Uses Node.uploadFile() from the meshtastic Python library (vendored at
vendor/meshtastic-python) for all file transfers.
Requires: pip install pyserial (meshtastic is loaded from the vendor submodule)
"""
import glob as globmod
import os
import re
import sys
import time
# ── Resolve the vendored meshtastic-python library ────────────────────────────
# Walk up from this file to find the mesh-forge repo root, then add the
# vendored library to sys.path.
def _find_vendor_meshtastic():
here = os.path.dirname(os.path.abspath(__file__))
# tools/ → meshforge-sideload/ → mesh-forge root
candidate = os.path.normpath(os.path.join(here, '..', '..', 'vendor', 'meshtastic-python'))
if os.path.isdir(os.path.join(candidate, 'meshtastic')):
return candidate
return None
_vendor_path = _find_vendor_meshtastic()
if _vendor_path and _vendor_path not in sys.path:
sys.path.insert(0, _vendor_path)
# ── meshforge.yaml parser ─────────────────────────────────────────────────────
def parse_data_entries(yaml_text: str) -> list:
"""Return list of (glob_pattern, device_dest) from meshforge.yaml data: section."""
entries = []
in_mf = in_data = False
for raw in yaml_text.splitlines():
line = re.sub(r'#.*$', '', raw).rstrip()
if not line.strip():
continue
indent = len(line) - len(line.lstrip())
content = line.strip()
if indent == 0:
in_mf = (content == 'meshforge:')
in_data = False
elif in_mf and indent == 2:
in_data = (content == 'data:')
elif in_mf and in_data and indent == 4:
m = re.match(r'^-\s+(.+)$', content)
if m:
entry = m.group(1).strip().strip('"\'')
colon = entry.find(':')
if colon > 0:
entries.append((entry[:colon].strip(), entry[colon + 1:].strip()))
return entries
# ── Autodetect port ───────────────────────────────────────────────────────────
def autodetect_port():
try:
import serial.tools.list_ports
KNOWN = ['RAK', 'nRF52', 'Adafruit', 'Nordic', 'Meshtastic', 'WisMesh',
'T-Echo', 'CP210', 'CH340', 'FTDI', 'USB Serial', 'JTAG',
'LilyGO', 'Espressif']
ports = list(serial.tools.list_ports.comports())
for p in ports:
desc = (p.description or '') + ' ' + (p.manufacturer or '')
if any(k.lower() in desc.lower() for k in KNOWN):
return p.device
for p in ports:
if 'usbmodem' in p.device or 'usbserial' in p.device:
return p.device
except ImportError:
pass
return None
# ── Main sideload routine ─────────────────────────────────────────────────────
def run_sideload(project_dir: str, port_name: str, baud: int = 115200,
boot_wait_s: float = 3.0) -> None:
try:
import meshtastic.serial_interface
except ImportError:
print('ERROR: meshtastic library not found.\n'
'Install with: pip install meshtastic\n'
'Or ensure vendor/meshtastic-python is in sys.path')
sys.exit(1)
yaml_path = os.path.join(project_dir, 'meshforge.yaml')
if not os.path.isfile(yaml_path):
print('meshforge-sideload: no meshforge.yaml — nothing to sideload')
return
with open(yaml_path, encoding='utf-8') as f:
entries = parse_data_entries(f.read())
if not entries:
print('meshforge-sideload: no data: entries — nothing to sideload')
return
transfers = []
for glob_pat, dest in entries:
for src in sorted(globmod.glob(os.path.join(project_dir, glob_pat), recursive=True)):
if os.path.isfile(src):
transfers.append((src, dest.rstrip('/') + '/' + os.path.basename(src)))
if not transfers:
print('meshforge-sideload: no files matched — nothing to sideload')
return
print(f'meshforge-sideload: waiting {boot_wait_s}s for device to boot...')
time.sleep(boot_wait_s)
print(f'meshforge-sideload: connecting to {port_name}...')
iface = meshtastic.serial_interface.SerialInterface(port_name)
try:
for i, (src, device_path) in enumerate(transfers):
size_kb = os.path.getsize(src) / 1024
name = os.path.basename(src)
print(f' [{i + 1}/{len(transfers)}] {name} ({size_kb:.1f} KB) → {device_path}')
def progress(sent, total, _name=name):
pct = 100 * sent // total
bar = '#' * (pct // 5) + '.' * (20 - pct // 5)
print(f'\r [{bar}] {pct}%', end='', flush=True)
ok = iface.localNode.uploadFile(src, device_path, on_progress=progress)
if ok:
print(f'\r [{"#" * 20}] 100% done')
else:
print(f'\r FAILED')
raise RuntimeError(f'Upload failed: {src}{device_path}')
finally:
iface.close()
print(f'meshforge-sideload: {len(transfers)} file(s) uploaded successfully')