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>
150 lines
4 KiB
C++
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
|