openscribe/firmware/src/storage.cpp
Laurence 87a00265a3
Some checks failed
ci / server (pull_request) Failing after 27s
ci / firmware (pull_request) Failing after 36s
feat(firmware): M2 WiFi + on-device REST API
Adds the WiFi-to-app sync path and the on-device open REST API implementing the
device paths of api/openapi.yaml, plus persistent config in NVS.

What changed:
- firmware/src/config.{h,cpp}: NVS (Preferences) store - stable device id from MAC,
  WiFi SSID/PSK, API bearer token, upload settings. Secrets stay off the SD card.
- firmware/src/net_wifi.{h,cpp}: station join with stored creds; SoftAP provisioning
  fallback when no creds / join fails; mDNS openscribe.local; reconnect in loop().
- firmware/src/api_http.{h,cpp}: synchronous WebServer on :80. Routes: GET /device,
  GET/PUT /device/config, GET /recordings, GET /recordings/{id}, GET
  /recordings/{id}/audio (HTTP Range -> 206 + Content-Range), DELETE /recordings/{id},
  POST /record/start|stop. Bearer-token auth on mutating calls (open when no token).
- firmware/src/storage.{h,cpp}: list/read/delete/count helpers + path builders.
- firmware/src/main.cpp: wire config + WiFi + API into setup/loop; bump fw to 0.2.0.
- firmware/platformio.ini: add bblanchon/ArduinoJson@^7 (only new dep).
- state/: ARCHITECTURE, NOTES, TODO updated; security + NTP follow-ups recorded.

Why:
- This is the WiFi transfer channel and the device half of the "completely open API":
  clients can list, download (resumable), delete and control without any cloud.

Notes:
- Not compiled locally (no PlatformIO on the dev host) or hardware-verified; CI builds
  both board profiles. Security follow-ups logged: API is open when no token is set,
  and the SoftAP uses a fixed default passphrase (make user-set at M4/BLE).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:09:41 +01:00

150 lines
4 KiB
C++

// SPDX-License-Identifier: GPL-3.0-only
#include "storage.h"
#include <SPI.h>
#include "audio_config.h"
#include "pins.h"
namespace {
// A dedicated SPI bus for the SD card so it does not clash with other peripherals.
SPIClass sdSpi(HSPI);
// Write a 44-byte canonical PCM WAV header. dataBytes may be 0 at open (patched on close).
void writeWavHeader(File &f, uint32_t sampleRate, uint16_t bits, uint16_t channels,
uint32_t dataBytes) {
const uint32_t byteRate = sampleRate * channels * (bits / 8);
const uint16_t blockAlign = channels * (bits / 8);
const uint32_t riffSize = 36 + dataBytes;
auto u32 = [&](uint32_t v) { f.write(reinterpret_cast<uint8_t *>(&v), 4); };
auto u16 = [&](uint16_t v) { f.write(reinterpret_cast<uint8_t *>(&v), 2); };
f.write(reinterpret_cast<const uint8_t *>("RIFF"), 4);
u32(riffSize);
f.write(reinterpret_cast<const uint8_t *>("WAVE"), 4);
f.write(reinterpret_cast<const uint8_t *>("fmt "), 4);
u32(16); // PCM fmt chunk size
u16(1); // audio format = PCM
u16(channels);
u32(sampleRate);
u32(byteRate);
u16(blockAlign);
u16(bits);
f.write(reinterpret_cast<const uint8_t *>("data"), 4);
u32(dataBytes);
}
} // namespace
namespace storage {
bool begin() {
sdSpi.begin(PIN_SD_SCK, PIN_SD_MISO, PIN_SD_MOSI, PIN_SD_CS);
if (!SD.begin(PIN_SD_CS, sdSpi)) {
return false;
}
if (!SD.exists(OSC_REC_DIR)) {
SD.mkdir(OSC_REC_DIR);
}
return true;
}
uint64_t totalBytes() { return SD.totalBytes(); }
uint64_t freeBytes() { return SD.totalBytes() - SD.usedBytes(); }
bool WavWriter::open(const String &path, uint32_t sampleRate, uint16_t bitsPerSample,
uint16_t channels) {
file_ = SD.open(path, FILE_WRITE);
if (!file_) return false;
sampleRate_ = sampleRate;
bits_ = bitsPerSample;
channels_ = channels;
dataBytes_ = 0;
writeWavHeader(file_, sampleRate_, bits_, channels_, 0); // placeholder sizes
return true;
}
size_t WavWriter::write(const uint8_t *data, size_t len) {
if (!file_) return 0;
size_t n = file_.write(data, len);
dataBytes_ += n;
return n;
}
bool WavWriter::close() {
if (!file_) return false;
// Patch RIFF size (offset 4) and data size (offset 40) now that we know the length.
file_.flush();
file_.seek(4);
uint32_t riffSize = 36 + dataBytes_;
file_.write(reinterpret_cast<uint8_t *>(&riffSize), 4);
file_.seek(40);
file_.write(reinterpret_cast<uint8_t *>(&dataBytes_), 4);
file_.close();
return true;
}
bool writeMetadata(const String &path, const String &json) {
File f = SD.open(path, FILE_WRITE);
if (!f) return false;
f.print(json);
f.close();
return true;
}
String recWavPath(const String &id) { return String(OSC_REC_DIR) + "/" + id + ".wav"; }
String recMetaPath(const String &id) { return String(OSC_REC_DIR) + "/" + id + ".json"; }
String readFile(const String &path) {
File f = SD.open(path, FILE_READ);
if (!f) return String();
String s;
s.reserve(f.size() + 1);
while (f.available()) s += static_cast<char>(f.read());
f.close();
return s;
}
bool removeRecording(const String &id) {
bool a = SD.remove(recWavPath(id));
bool b = SD.remove(recMetaPath(id));
return a || b;
}
uint32_t countRecordings() {
uint32_t n = 0;
File dir = SD.open(OSC_REC_DIR);
if (!dir) return 0;
for (File f = dir.openNextFile(); f; f = dir.openNextFile()) {
if (String(f.name()).endsWith(".wav")) n++;
f.close();
}
dir.close();
return n;
}
String listRecordingsJson() {
String out = "{\"items\":[";
bool first = true;
File dir = SD.open(OSC_REC_DIR);
if (dir) {
for (File f = dir.openNextFile(); f; f = dir.openNextFile()) {
if (String(f.name()).endsWith(".json")) {
String content;
content.reserve(f.size() + 1);
while (f.available()) content += static_cast<char>(f.read());
if (!first) out += ",";
out += content;
first = false;
}
f.close();
}
dir.close();
}
out += "],\"next_cursor\":null}";
return out;
}
} // namespace storage