OXRecorder::OXFileManager
Unified read/write access to internal flash and the external SD card
Overview
OXFileManager is the firmware's single entry point for file
storage. It manages two very different backends behind one path-based
API, and you choose which one every call targets by passing a
Storage value:
- Internal — the ESP32-S3 on-chip flash via LittleFS. A configurable byte quota guarantees the firmware can never overfill the flash partition.
-
External — a removable SD card via
SdFat (
SdFs/FsFile), which supports exFAT and large (>32 GB) cards that the FAT-only coreSDdriver cannot.
Routing is explicit per call — readFile(Storage::Internal, …)
vs writeFile(Storage::External, …) — so the choice of
filesystem is always visible at the call site. For large or continuous
output (such as audio capture to SD) use
openWrite(), which returns a
streaming OXFileStream handle that writes in chunks without
buffering the whole file in RAM.
Declared in include/OXFileManager.h. The portable core lives
in src/OXFileManager.cpp; the device backends are in
src/backends/. Diagnostics are emitted through
OXLog.
Storage enum class
Selects which physical storage an operation targets.
| Value | Backend | Notes |
|---|---|---|
Internal |
LittleFS on internal flash | Quota-bounded so the firmware cannot overfill the partition. |
External |
SdFat on SD card (SPI) | FAT16/FAT32/exFAT; bounded only by card capacity. |
API reference
Mounting
bool beginInternal(size_t maxBytes = 0)
Mounts LittleFS on internal flash (formatting on first failure).
maxBytes caps how much of the partition the firmware may
use; 0 means use the whole partition. The effective quota
is clamped to the real partition size. Returns true on
success.
| Parameter | Type | Default | Meaning |
|---|---|---|---|
maxBytes | size_t | 0 | Quota in bytes the firmware may write to flash. 0 = use the whole partition. Clamped to the real partition size. |
bool beginExternal(uint8_t csPin = 10, uint8_t sckPin = 11, uint8_t misoPin = 13, uint8_t mosiPin = 12, uint32_t spiHz = 1000000)
Mounts the SD card over SPI. The default pins match the validated
ESP32-S3 Super Mini SD adapter wiring (CS=10, SCK=11, MISO=13, MOSI=12); override them
for your board. spiHz is the SPI clock in Hz. Returns
true on success.
| Parameter | Type | Default | Meaning |
|---|---|---|---|
csPin | uint8_t | 0 | SPI chip-select GPIO wired to the SD adapter's CS. |
sckPin | uint8_t | 4 | SPI clock GPIO (SD SCK). |
misoPin | uint8_t | 5 | SPI MISO GPIO (SD DO / data out). |
mosiPin | uint8_t | 6 | SPI MOSI GPIO (SD DI / data in). |
spiHz | uint32_t | 1000000 | SPI clock in Hz. 1 MHz is conservative and reliable; raise it for faster cards if your wiring is clean. |
bool isMounted(Storage where) const
Reports whether the selected storage is mounted and ready.
| Parameter | Type | Meaning |
|---|---|---|
where | Storage | Storage::Internal (flash) or Storage::External (SD). |
One-shot file operations
bool writeFile(Storage where, const char *path, const uint8_t *data, size_t len)
Writes len bytes to path, replacing any
existing contents. On Internal the write is rejected (and
an OXLog::warn is emitted) if it would exceed the quota;
because a truncating write replaces the file, the existing file's bytes
count as reclaimable space.
| Parameter | Type | Meaning |
|---|---|---|
where | Storage | Target storage: Internal or External. |
path | const char * | Absolute path, e.g. "/config.json". Parent directories must already exist (see mkdir). |
data | const uint8_t * | Bytes to write. For text, cast a char*: reinterpret_cast<const uint8_t*>(str). |
len | size_t | Number of bytes from data to write (no trailing NUL for text). |
bool appendFile(Storage where, const char *path, const uint8_t *data, size_t len)
Appends len bytes to path (created if absent).
Quota-checked on internal flash.
int readFile(Storage where, const char *path, uint8_t *buf, size_t maxLen)
Reads up to maxLen bytes into buf. Returns the
number of bytes read, or -1 on error (missing file or
unmounted storage).
| Parameter | Type | Meaning |
|---|---|---|
where | Storage | Target storage. |
path | const char * | Absolute path of the file to read. |
buf | uint8_t * | Destination buffer. No NUL terminator is written — for text, reserve one extra byte and add '\0' at the returned length. |
maxLen | size_t | Buffer capacity; at most this many bytes are read. A larger file is truncated to maxLen. |
Returns: byte count read (≥ 0), or -1 on error.
bool exists(Storage where, const char *path)
Returns true if a file or directory exists at path.
bool mkdir(Storage where, const char *path)
Creates path, including any missing parent directories
(e.g. "/a/b/c" creates /a, /a/b,
then /a/b/c).
bool remove(Storage where, const char *path)
Deletes a file or (empty) directory at path.
size_t fileSize(Storage where, const char *path)
Returns the size of path in bytes, or 0 if absent.
Capacity
size_t totalBytes(Storage where)
size_t usedBytes(Storage where)
size_t freeBytes(Storage where)
Capacity accounting in bytes. For Internal these report
against the configured quota, not the raw partition, so
freeBytes() reflects how much the firmware may still write.
bool listDir(Storage where, const char *path, DirEntryCallback cb, void *ctx)
Enumerates the entries directly under path, invoking
cb(name, isDir, size, ctx) once per child.
DirEntryCallback is
void (*)(const char *name, bool isDir, size_t size, void *ctx);
ctx is passed through unchanged.
| Parameter | Type | Meaning |
|---|---|---|
where | Storage | Target storage. |
path | const char * | Directory to list, e.g. "/captures". Only immediate children are reported, not nested entries. |
cb | DirEntryCallback | Called once per entry. Its args: name (entry name), isDir (true for a folder), size (bytes, 0 for dirs), ctx (your pointer). |
ctx | void * | Opaque pointer forwarded to every cb call (e.g. a counter or accumulator). Pass nullptr if unused. |
Streaming write
OXFileStream openWrite(Storage where, const char *path, bool append = false)
Opens path for streaming writes and returns an
OXFileStream handle. Use this for large or continuous
output where buffering the whole file in RAM is not an option. The
returned stream reports isOpen() == false if the storage is
not mounted or the file could not be created.
| Parameter | Type | Default | Meaning |
|---|---|---|---|
where | Storage | — | Target storage. |
path | const char * | — | Absolute path to open for writing. |
append | bool | false | false truncates the file to empty; true keeps existing contents and appends after them (resume a recording). |
Returns: an OXFileStream by value; check isOpen() before writing.
class OXFileStream
Move-only handle representing an open streaming write. Methods:
bool isOpen() const— whether a file is open.-
size_t write(const uint8_t *data, size_t len)— appends bytes and returns the number actually written. On an internal-flash stream a short return means the quota boundary was reached. bool flush()— commits buffered bytes to the media.size_t written() const— total bytes written so far.void close()— closes the file; safe to call twice.
The stream closes itself when it goes out of scope, so an explicit
close() is optional but recommended after a final
flush().
Test seam
void setBackend(Storage where, IStorageBackend *backend)
Injects a backend for one storage slot (does not take ownership). Used
internally by beginInternal()/beginExternal()
on-device, and by the host unit tests to substitute an in-memory
backend so the class can be tested with no hardware.
Examples
Try it
Drop any snippet below into src/main.cpp (inside
setup()/loop()), then
pio run -e esp32-s3-super-mini -t upload && pio device monitor.
The portable logic is also covered by host tests —
pio test -e native runs them with no hardware.
The examples below are grouped by task. They all assume the manager is a
file-scope object and has been mounted as shown in
Mounting. Every method takes a
Storage argument, so the same call works
against internal flash or the SD card just by switching
Storage::Internal ↔ Storage::External.
A note on the API shape: the one-shot methods take and return raw bytes
(const uint8_t * / uint8_t *). For text you simply
reinterpret the char buffer as bytes — strings and bytes are
the same thing on the wire; the only difference is whether you treat the
result as printable. The readFile() family does
not append a NUL terminator, so for text you must add one
yourself (shown below).
1. Mounting both storages
Mount once in setup(). beginInternal() takes an
optional byte quota (0 = whole partition); beginExternal()
takes SPI pins that default to the validated ESP32-S3 Super Mini wiring. Always check
the return value — every later call fails safely on an unmounted storage,
but mounting is where you find out the SD card is missing.
#include <Arduino.h>
#include <string.h>
#include "OXFileManager.h"
#include "OXLog.h"
using OXRecorder::OXFileManager;
using OXRecorder::OXFileStream;
using OXRecorder::OXLog;
using OXRecorder::Storage;
OXFileManager fm;
void setup() {
OXLog::begin(115200);
// Internal flash via LittleFS, capped at 512 KB of the partition.
// Pass 0 (or omit) to use the whole partition.
if (!fm.beginInternal(512 * 1024)) {
OXLog::error("internal flash mount failed");
}
// SD card via SdFat on the default pins (CS=10, SCK=11, MISO=13, MOSI=12).
// The default board map is CS=10, SCK=11, MISO=13, MOSI=12.
if (!fm.beginExternal()) {
OXLog::error("SD card mount failed (card inserted? wiring?)");
}
OXLog::info("internal mounted: %d, external mounted: %d",
fm.isMounted(Storage::Internal),
fm.isMounted(Storage::External));
}
2. Creating / overwriting a text file
writeFile() creates the file if absent and replaces its
contents if it already exists. There is no separate "create" call — writing
is creating. Pass the text as bytes; do not include a trailing NUL (you are
storing the characters, not a C string).
// Write a config string to internal flash (truncates any existing file).
const char *json = "{\"gain\":2,\"rate\":16000}";
bool ok = fm.writeFile(Storage::Internal, "/config.json",
reinterpret_cast<const uint8_t *>(json),
strlen(json));
if (!ok) {
// On internal flash this also fails if the write would exceed the quota.
OXLog::error("failed to write /config.json");
}
// The identical call targets the SD card — only the Storage value changes.
fm.writeFile(Storage::External, "/notes.txt",
reinterpret_cast<const uint8_t *>(json), strlen(json));
3. Appending text (e.g. a log file)
appendFile() adds to the end of a file, creating it if it does
not exist. This is the building block for line-oriented logs — append each
line (include your own \n). On internal flash each append is
quota-checked.
// Append three log lines to a file on the SD card.
const char *lines[] = {
"08:00:01 boot\n",
"08:00:02 sensors ok\n",
"08:00:03 recording started\n",
};
for (auto *line : lines) {
fm.appendFile(Storage::External, "/logs/session.log",
reinterpret_cast<const uint8_t *>(line), strlen(line));
}
// Build a line with snprintf, then append it.
char entry[64];
int len = snprintf(entry, sizeof(entry), "battery=%d%%\n", 87);
fm.appendFile(Storage::External, "/logs/session.log",
reinterpret_cast<const uint8_t *>(entry), len);
Note: /logs/session.log requires the /logs
directory to exist first — see section 7. Appending
to a path whose parent is missing fails on the SD backend.
4. Reading a text file back
readFile() fills your buffer with up to maxLen
bytes and returns the count (or -1 on error). It never writes a
terminator, so reserve one extra byte and add the \0 yourself
before treating the result as a string.
char buf[128];
int n = fm.readFile(Storage::Internal, "/config.json",
reinterpret_cast<uint8_t *>(buf), sizeof(buf) - 1);
if (n >= 0) {
buf[n] = '\0'; // terminate for printing
OXLog::info("config (%d bytes): %s", n, buf);
} else {
OXLog::error("/config.json not found or unreadable");
}
For files larger than your buffer, readFile() returns at most
maxLen bytes. To read an entire large file, size the buffer
from fileSize() (section 8) or read it in
chunks.
5. Writing binary data (one-shot)
Binary data uses the exact same methods — the API is byte-oriented, so there is nothing special to do. This writes a small binary record (a packed struct) to flash; the same pattern works for an image, a thumbnail, a calibration table, or any blob that fits in a buffer.
// A packed binary header, e.g. for a recording file.
struct __attribute__((packed)) WavHeader {
char riff[4]; // "RIFF"
uint32_t fileSize;
char wave[4]; // "WAVE"
uint32_t sampleRate;
};
WavHeader hdr = { {'R','I','F','F'}, 0, {'W','A','V','E'}, 16000 };
// Write the raw struct bytes. reinterpret the object as a byte pointer.
fm.writeFile(Storage::External, "/captures/take01.wav",
reinterpret_cast<const uint8_t *>(&hdr), sizeof(hdr));
// Or an arbitrary byte buffer (e.g. a JPEG already in RAM).
uint8_t image[512];
memset(image, 0x5A, sizeof(image)); // stand-in for real image bytes
fm.writeFile(Storage::Internal, "/thumb.jpg", image, sizeof(image));
6. Reading binary data back
Read into a typed buffer and check the byte count matches what you expect.
For a fixed-layout struct, read exactly sizeof bytes; a short
read means a truncated or wrong file.
WavHeader hdr;
int n = fm.readFile(Storage::External, "/captures/take01.wav",
reinterpret_cast<uint8_t *>(&hdr), sizeof(hdr));
if (n == sizeof(hdr) && memcmp(hdr.riff, "RIFF", 4) == 0) {
OXLog::info("valid WAV, sample rate %u", (unsigned)hdr.sampleRate);
} else {
OXLog::error("unexpected header (read %d bytes)", n);
}
7. Appending binary data & streaming large writes
For a few extra bytes, appendFile() works the same as it does
for text. But for large or continuous output — audio
capture, long recordings — opening the file once and writing many chunks is
far more efficient than reopening it per chunk. That is what
openWrite() and the OXFileStream handle are for.
// (a) Append a small binary record.
uint8_t footer[4] = { 0xDE, 0xAD, 0xBE, 0xEF };
fm.appendFile(Storage::External, "/captures/take01.wav", footer,
sizeof(footer));
// (b) Stream a large binary file in chunks (the recommended path for audio).
OXFileStream out = fm.openWrite(Storage::External, "/captures/long.pcm");
if (out.isOpen()) {
uint8_t block[512];
for (int i = 0; i < 64; ++i) {
// ... fill `block` with the next 512 bytes of samples ...
memset(block, i, sizeof(block));
size_t w = out.write(block, sizeof(block));
if (w != sizeof(block)) {
OXLog::warn("short write (%u/%u) — disk full?", (unsigned)w,
(unsigned)sizeof(block));
break;
}
if ((i & 0x0F) == 0) out.flush(); // periodically commit to media
}
out.flush();
out.close(); // also happens automatically at scope exit
OXLog::info("streamed %u bytes", (unsigned)out.written());
}
To resume an existing file instead of truncating it, pass
append = true:
fm.openWrite(Storage::External, "/captures/long.pcm", true).
On internal flash a stream is quota-bounded — write() returns a
short count when the quota boundary is reached.
8. Creating & listing directories
mkdir() creates every missing parent in the path, so a single
call makes a deep tree. listDir() enumerates the direct
children of a directory through a callback.
// Create a nested folder structure in one call (makes /data, /data/2026, ...).
fm.mkdir(Storage::External, "/data/2026/captures");
fm.mkdir(Storage::External, "/logs");
// listDir invokes this callback once per immediate child. `ctx` is your own
// pass-through pointer (here a running file count).
void onEntry(const char *name, bool isDir, size_t size, void *ctx) {
auto *count = static_cast<int *>(ctx);
(*count)++;
if (isDir) {
OXLog::info(" [dir] %s", name);
} else {
OXLog::info(" [file] %s (%u bytes)", name, (unsigned)size);
}
}
int count = 0;
fm.listDir(Storage::External, "/captures", onEntry, &count);
OXLog::info("%d entries under /captures", count);
9. Existence, size, deletion & free space
The remaining helpers cover the common housekeeping checks. On internal
flash, totalBytes()/freeBytes() report against the
configured quota, not the raw partition.
// Guard before reading; size the buffer from the file itself.
if (fm.exists(Storage::Internal, "/config.json")) {
size_t sz = fm.fileSize(Storage::Internal, "/config.json");
OXLog::info("/config.json is %u bytes", (unsigned)sz);
}
// Delete a file (or an empty directory).
if (fm.remove(Storage::External, "/captures/take01.wav")) {
OXLog::info("deleted take01.wav");
}
// Capacity accounting, in bytes.
OXLog::info("flash: %u/%u used, %u free",
(unsigned)fm.usedBytes(Storage::Internal),
(unsigned)fm.totalBytes(Storage::Internal),
(unsigned)fm.freeBytes(Storage::Internal));
OXLog::info("SD free: %u bytes",
(unsigned)fm.freeBytes(Storage::External));
Sample output
Running sections 1–9 in order prints roughly:
[31][I] internal mounted: 1, external mounted: 1
[42][I] config (23 bytes): {"gain":2,"rate":16000}
[55][I] valid WAV, sample rate 16000
[78][I] streamed 32768 bytes
[80][I] [file] take01.wav (28 bytes)
[80][I] [file] long.pcm (32768 bytes)
[81][I] 2 entries under /captures
[83][I] /config.json is 23 bytes
[84][I] deleted take01.wav
[85][I] flash: 535/524288 used, 523753 free
[86][I] SD free: 64483328 bytes
Exact byte counts depend on your flash quota and SD card. The bracketed
numbers are OXLog timestamps and level tags.