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:

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.

ValueBackendNotes
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.

ParameterTypeDefaultMeaning
maxBytessize_t0Quota 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.

ParameterTypeDefaultMeaning
csPinuint8_t0SPI chip-select GPIO wired to the SD adapter's CS.
sckPinuint8_t4SPI clock GPIO (SD SCK).
misoPinuint8_t5SPI MISO GPIO (SD DO / data out).
mosiPinuint8_t6SPI MOSI GPIO (SD DI / data in).
spiHzuint32_t1000000SPI 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.

ParameterTypeMeaning
whereStorageStorage::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.

ParameterTypeMeaning
whereStorageTarget storage: Internal or External.
pathconst char *Absolute path, e.g. "/config.json". Parent directories must already exist (see mkdir).
dataconst uint8_t *Bytes to write. For text, cast a char*: reinterpret_cast<const uint8_t*>(str).
lensize_tNumber 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).

ParameterTypeMeaning
whereStorageTarget storage.
pathconst char *Absolute path of the file to read.
bufuint8_t *Destination buffer. No NUL terminator is written — for text, reserve one extra byte and add '\0' at the returned length.
maxLensize_tBuffer 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.

ParameterTypeMeaning
whereStorageTarget storage.
pathconst char *Directory to list, e.g. "/captures". Only immediate children are reported, not nested entries.
cbDirEntryCallbackCalled once per entry. Its args: name (entry name), isDir (true for a folder), size (bytes, 0 for dirs), ctx (your pointer).
ctxvoid *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.

ParameterTypeDefaultMeaning
whereStorageTarget storage.
pathconst char *Absolute path to open for writing.
appendboolfalsefalse 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::InternalStorage::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.