Compare commits
6 Commits
e411c934d5
...
tmp
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d26b66490 | |||
| e06b429db9 | |||
| 0014e8697f | |||
| 8a44887b6f | |||
| 243419b401 | |||
| 7ad00e452c |
@@ -0,0 +1,350 @@
|
|||||||
|
# Audio Logging Design
|
||||||
|
|
||||||
|
This document defines how the Hub writes the audio it collects from the
|
||||||
|
Nodes to the SD card. It is the contract that the future Node-collection task
|
||||||
|
(producer) and the existing `StorageTask` (consumer) are written against.
|
||||||
|
|
||||||
|
The deliverable for this design is:
|
||||||
|
|
||||||
|
* `src/storage/data_logger.h` - the public interface (skeleton).
|
||||||
|
* `src/storage/data_logger.cpp` - stub bodies + fully implemented WAV metadata
|
||||||
|
helpers. The chunk-pool/queue/file logic is marked `TODO` and is the
|
||||||
|
remaining work.
|
||||||
|
* The constants in `src/storage/storage_config.h` under the "Audio logging"
|
||||||
|
section.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Data model
|
||||||
|
|
||||||
|
| Parameter | Value |
|
||||||
|
|-----------------------|------------|
|
||||||
|
| Sample rate | 48 kHz |
|
||||||
|
| Bit depth | 16 bit (signed little-endian PCM) |
|
||||||
|
| Microphones per node | 4 |
|
||||||
|
| Max nodes | 10 |
|
||||||
|
| **Total channels** | **40** |
|
||||||
|
|
||||||
|
One microphone == one channel == one "track". A single file holds all of them
|
||||||
|
as an interleaved multichannel WAV.
|
||||||
|
|
||||||
|
## 2. Throughput budget
|
||||||
|
|
||||||
|
```
|
||||||
|
raw stream rate = 40 ch x 48 kHz x 2 B = 3,840,000 B/s (3.66 MiB/s)
|
||||||
|
```
|
||||||
|
|
||||||
|
* SDIO 4-bit @ 40 MHz sustains roughly 8-12 MB/s -> the card write has ample
|
||||||
|
headroom at less than half of its budget.
|
||||||
|
* WiFi (AP) carries the same 3.84 MB/s inbound as UDP payload, which the
|
||||||
|
design already budgets for.
|
||||||
|
* The 128 KB chunk pool represents **~34 ms** of audio. The write must be able
|
||||||
|
to absorb bursts longer than that only by dropping (Section 8).
|
||||||
|
|
||||||
|
## 3. File format: single multichannel WAV
|
||||||
|
|
||||||
|
The stream is a **RIFF/WAVE file with 40 interleaved channels**. The 44-byte
|
||||||
|
header is the only metadata; no per-chunk headers are written to the file.
|
||||||
|
|
||||||
|
Header layout (all little-endian):
|
||||||
|
|
||||||
|
| Offset | Size | Value |
|
||||||
|
|--------|------|--------------------------------------------|
|
||||||
|
| 0 | 4 | `"RIFF"` |
|
||||||
|
| 4 | 4 | chunk size = file size - 8 (patched) |
|
||||||
|
| 8 | 4 | `"WAVE"` |
|
||||||
|
| 12 | 4 | `"fmt "` |
|
||||||
|
| 16 | 4 | 16 (PCM fmt chunk size) |
|
||||||
|
| 20 | 2 | 1 (PCM) |
|
||||||
|
| 22 | 2 | 40 (channels) |
|
||||||
|
| 24 | 4 | 48000 (sample rate) |
|
||||||
|
| 28 | 4 | 3,840,000 (byte rate = rate x blockAlign) |
|
||||||
|
| 32 | 2 | 80 (block align = channels x 2) |
|
||||||
|
| 34 | 2 | 16 (bits per sample) |
|
||||||
|
| 36 | 4 | `"data"` |
|
||||||
|
| 40 | 4 | data size = file size - 44 (patched) |
|
||||||
|
|
||||||
|
`buildWavHeader()` and `finalizeWavHeader()` in `data_logger.h/cpp` produce
|
||||||
|
these bytes and the two 32-bit values patched at offsets 4 and 40.
|
||||||
|
|
||||||
|
### Header finalization
|
||||||
|
|
||||||
|
The sizes at offsets 4 and 40 are unknown while the file is being written.
|
||||||
|
They are patched when the file is closed (rotation or session end):
|
||||||
|
|
||||||
|
```
|
||||||
|
file.seek(4); file.write(riffSize, 4);
|
||||||
|
file.seek(40); file.write(dataSize, 4);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Crash tolerance
|
||||||
|
|
||||||
|
If power is lost mid-file, the header still contains the placeholder sizes.
|
||||||
|
PCM decoders that read to EOF (Audacity, ffmpeg, Python `wave`) play the valid
|
||||||
|
audio regardless; the last partial sector may be zero-padded. This satisfies
|
||||||
|
the design.md requirement that a mid-recording power loss still yields usable
|
||||||
|
audio.
|
||||||
|
|
||||||
|
### Opening the file on a PC
|
||||||
|
|
||||||
|
* **Audacity** / **ffmpeg** / **Python `wave`**: handle 40 channels.
|
||||||
|
* VLC and stock Windows players generally will not render 40 channels even
|
||||||
|
though the file is structurally valid. Convert with ffmpeg first if needed.
|
||||||
|
|
||||||
|
## 4. Architecture: producer / consumer with a chunk pool
|
||||||
|
|
||||||
|
```
|
||||||
|
Node collection task (core 1) StorageTask (core 0)
|
||||||
|
assembles one round of 40 channels | loop:
|
||||||
|
interleaved into frame order v
|
||||||
|
+----------------------+ +----------------------+
|
||||||
|
| c = acquireChunk(5ms)| <-- freeQ ---- | pool: 8 x 16 KB |
|
||||||
|
| fill c->data[...] | | (MALLOC_CAP_DMA) |
|
||||||
|
| submitChunk(c) | ---- filledQ -->| nextChunk(1000ms) |
|
||||||
|
+----------------------+ | writeChunk(c) |
|
||||||
|
| releaseChunk(c) |
|
||||||
|
| rotateIfNeeded() |
|
||||||
|
| updateStats() |
|
||||||
|
+----------------------+
|
||||||
|
|
||||||
|
DataLogger owns both queues and the pool. Producers and the consumer never
|
||||||
|
touch the file or the card directly - only DataLogger does.
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Pool**: 8 chunks x 16 KB = 128 KB, allocated once in `begin()` with
|
||||||
|
`heap_caps_malloc(MALLOC_CAP_DMA)`.
|
||||||
|
* **Queues**: `freeQ` holds pointers to empty chunks, `filledQ` holds pointers
|
||||||
|
to chunks waiting to be written. Both are FreeRTOS queues of
|
||||||
|
`AudioChunk*`.
|
||||||
|
* **Cores**: the producer runs on core 1 (network), the consumer on core 0
|
||||||
|
(the existing pinned `StorageTask`). SD card work never touches core 1.
|
||||||
|
|
||||||
|
## 5. The interface
|
||||||
|
|
||||||
|
Types (from `data_logger.h`):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
struct AudioChunk
|
||||||
|
{
|
||||||
|
uint8_t* data; // DMA-capable buffer owned by the pool
|
||||||
|
uint32_t capacity; // buffer size in bytes (STORAGE_LOG_CHUNK_SIZE)
|
||||||
|
uint32_t length; // valid bytes written by the producer
|
||||||
|
uint32_t sequence; // monotonic order, for diagnostics only
|
||||||
|
};
|
||||||
|
|
||||||
|
class DataLogger
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
bool begin(fs::FS& files); // allocate pool, create log dir
|
||||||
|
void end(); // finalize current file, free pool
|
||||||
|
|
||||||
|
bool openSession(); // create rec_<uptime>_<n>.wav
|
||||||
|
bool closeSession(); // finalize + close current file
|
||||||
|
bool rotateIfNeeded(); // called by the consumer
|
||||||
|
|
||||||
|
// Producer (core 1)
|
||||||
|
AudioChunk* acquireChunk(TickType_t timeout);
|
||||||
|
void submitChunk(AudioChunk* chunk);
|
||||||
|
|
||||||
|
// Consumer (core 0)
|
||||||
|
AudioChunk* nextChunk(TickType_t timeout);
|
||||||
|
bool writeChunk(AudioChunk* chunk);
|
||||||
|
void releaseChunk(AudioChunk* chunk);
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
uint32_t bytesWritten() const;
|
||||||
|
uint32_t chunksWritten() const;
|
||||||
|
uint32_t droppedChunks() const;
|
||||||
|
uint32_t writeSpeedBps() const;
|
||||||
|
bool overflowing() const;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Producer contract (Node collection task, not yet written)
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
AudioChunk* c = logger.acquireChunk(pdMS_TO_TICKS(5));
|
||||||
|
if (c == nullptr) { /* collection is behind; drop this round */ return; }
|
||||||
|
|
||||||
|
// Fill c->data[0..c->length) with interleaved PCM, frame order:
|
||||||
|
// frame0: ch0..ch39, frame1: ch0..ch39, ...
|
||||||
|
// 40 samples of 2 bytes per frame = 80 bytes per frame.
|
||||||
|
c->length = interleavedBytes;
|
||||||
|
c->sequence = nextSeq++;
|
||||||
|
logger.submitChunk(c);
|
||||||
|
```
|
||||||
|
|
||||||
|
* Chunks **must be submitted in strict stream order** (single producer, FIFO
|
||||||
|
queue -> order is preserved automatically).
|
||||||
|
* A chunk is not required to end on a frame boundary, but `c->length` should
|
||||||
|
stay a multiple of 80 bytes so the file is always frame-aligned.
|
||||||
|
* `acquireChunk` can return a chunk even when the pool is momentarily empty;
|
||||||
|
see the drop-oldest rule in Section 8.
|
||||||
|
|
||||||
|
### Consumer contract (StorageTask on core 0, future)
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
AudioChunk* c = logger.nextChunk(pdMS_TO_TICKS(1000));
|
||||||
|
if (c != nullptr)
|
||||||
|
{
|
||||||
|
logger.writeChunk(c); // one file.write(c->data, c->length)
|
||||||
|
logger.releaseChunk(c); // returns the chunk to freeQ
|
||||||
|
}
|
||||||
|
logger.rotateIfNeeded();
|
||||||
|
logger.updateStats(storageState); // bps + drops -> dashboard
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
* The 1 s timeout lets the loop pulse for stats/capacity updates even when no
|
||||||
|
audio is flowing (this replaces the current 1 s `vTaskDelayUntil` heartbeat).
|
||||||
|
* `writeChunk` is the only place the card is touched. It appends the chunk's
|
||||||
|
PCM directly to the WAV data section in one `file.write`.
|
||||||
|
|
||||||
|
### Ownership rules
|
||||||
|
|
||||||
|
* The pool owns the buffers; producers and the consumer borrow them.
|
||||||
|
* A borrowed chunk is in exactly one place at a time: a producer (between
|
||||||
|
acquire and submit), a queue, or the consumer (between next and release).
|
||||||
|
* `releaseChunk` returns it to `freeQ`. `writeChunk` never frees.
|
||||||
|
|
||||||
|
## 6. Upstream interleaving contract
|
||||||
|
|
||||||
|
The WAV must be written frame-interleaved, but the Nodes deliver one UDP
|
||||||
|
packet per Node (4 contiguous mono channels). Frames cannot be interleaved
|
||||||
|
until all Nodes in a round have been collected. That re-ordering is the job of
|
||||||
|
the collection task, **not** the logger:
|
||||||
|
|
||||||
|
1. Collect all 10 Node dumps for round `n`.
|
||||||
|
2. A missing/offline Node contributes silence: zero-fill its 4 channels.
|
||||||
|
3. Re-order into frame order: `frame j = node0[mic0..3], node1[mic0..3], ...`
|
||||||
|
4. Feed the resulting bytes into chunks and submit them in order.
|
||||||
|
|
||||||
|
Consequences the collection task must honor:
|
||||||
|
|
||||||
|
* **Round size**: with the 128 KB pool, a round must stay <= ~96 KB
|
||||||
|
(~25 ms of audio) so the pool can hold more than one round. See the sizing
|
||||||
|
math in Section 7.
|
||||||
|
* **Silence for offline Nodes** comes from this zero-fill; the logger never
|
||||||
|
invents data.
|
||||||
|
|
||||||
|
## 7. Chunk pool and RAM budget
|
||||||
|
|
||||||
|
```
|
||||||
|
chunk size = STORAGE_LOG_CHUNK_SIZE = 16 KiB
|
||||||
|
pool depth = STORAGE_LOG_POOL_SIZE = 8
|
||||||
|
pool total = 128 KiB (MALLOC_CAP_DMA)
|
||||||
|
|
||||||
|
buffering = 128 KiB / 3.84 MB/s ~= 34 ms
|
||||||
|
round size = 48 kHz x 2 B x 40 ch x round_s
|
||||||
|
25 ms round -> 96 KB (~6 chunks) [fits the pool with slack]
|
||||||
|
50 ms round -> 192 KB [does NOT fit - must shrink]
|
||||||
|
```
|
||||||
|
|
||||||
|
The collection round duration is therefore bounded by the pool unless the pool
|
||||||
|
grows. Keep rounds at <= 25 ms, or raise `STORAGE_LOG_POOL_SIZE`/chunk size and
|
||||||
|
re-run the math.
|
||||||
|
|
||||||
|
The round's interleave buffer lives in the **collection task's own RAM** (up
|
||||||
|
to 96 KB), not in the logger pool; it is freed after the round is submitted.
|
||||||
|
|
||||||
|
## 8. Overflow: drop-oldest + warning
|
||||||
|
|
||||||
|
If the SD card cannot keep up, the pool drains and the producer has no chunk.
|
||||||
|
Policy (chosen): **drop the oldest buffered chunk until caught up** - never
|
||||||
|
block, never halt.
|
||||||
|
|
||||||
|
Mechanism inside `acquireChunk`: on timeout, the logger pops one chunk off the
|
||||||
|
back of `filledQ` (the oldest unwritten data), returns it to `freeQ`, hands it
|
||||||
|
to the producer, increments `droppedChunks`, and raises the overflow warning.
|
||||||
|
The stream keeps flowing with the newest data at the cost of a gap.
|
||||||
|
|
||||||
|
The warning is visible two ways:
|
||||||
|
|
||||||
|
* **Serial**: a rate-limited `[Logger] X chunks dropped, Y MB behind` line.
|
||||||
|
* **LED on GPIO `STORAGE_WARN_LED_GPIO`** (default 4, active-high): on while
|
||||||
|
`overflowing()`, off once the queue drains below a low-water mark again.
|
||||||
|
|
||||||
|
## 9. Rotation, naming, flush
|
||||||
|
|
||||||
|
* **Rotate by size**: when `bytesThisFile >= STORAGE_LOG_ROTATE_BYTES`
|
||||||
|
(default 1 GiB, ~4.6 min at 3.84 MB/s) the consumer calls `rotateIfNeeded()`:
|
||||||
|
finalize + close the current file, open the next.
|
||||||
|
* **Files**: created under `STORAGE_LOG_DIR` (`/sdcard/audio`), named
|
||||||
|
`rec_<uptimeSeconds>_<n>.wav`. `n` increments per rotation within a boot;
|
||||||
|
the uptime prefix keeps names unique across boots. If a name already exists,
|
||||||
|
skip forward until it does not (never overwrite).
|
||||||
|
* **Flush**: `file.flush()` (f_sync) every `STORAGE_LOG_FLUSH_BYTES`
|
||||||
|
(default 16 MiB, ~4.3 s) so an unclean power-off loses at most that window
|
||||||
|
and never corrupts earlier data.
|
||||||
|
* **Card full**: rotation cannot create a file -> report a fatal error, light
|
||||||
|
the warning LED solid, and halt (matches design.md's SD-failure stance).
|
||||||
|
|
||||||
|
## 10. DMA requirements
|
||||||
|
|
||||||
|
The SDIO path (production) uses the SDMMC controller's internal IDMA engine:
|
||||||
|
block data moves to the card without CPU cycles. Two rules make this work:
|
||||||
|
|
||||||
|
* Buffers must be in DMA-capable memory: allocate the pool with
|
||||||
|
`heap_caps_malloc(MALLOC_CAP_DMA)` (guarantees internal DRAM + alignment).
|
||||||
|
* Never hand the card a buffer that lives in PSRAM or a stack array.
|
||||||
|
|
||||||
|
The SPI path (`STORAGE_IFACE_SPI`, current bring-up) does **not** use DMA -
|
||||||
|
the Arduino SPI driver busy-waits the FIFO. It is bring-up only and cannot
|
||||||
|
sustain the 3.84 MB/s target; production logging must run on SDIO.
|
||||||
|
|
||||||
|
## 11. Metering -> dashboard
|
||||||
|
|
||||||
|
`writeChunk` accumulates `bytesWritten`/`chunksWritten`; `writeSpeedBps` is
|
||||||
|
derived from a sliding 1 s window of real writes. The consumer publishes
|
||||||
|
`writeSpeedBps` and `droppedChunks` into `StorageState` each loop, replacing
|
||||||
|
the boot-time `measureWriteSpeed()` benchmark (whose interval is already 0).
|
||||||
|
Add a `droppedChunks` field to `StorageSnapshot` and surface it on the
|
||||||
|
dashboard when the logging loop lands.
|
||||||
|
|
||||||
|
## 12. Future integration points (StorageTask)
|
||||||
|
|
||||||
|
When the consumer loop is implemented, `StorageTask::run()`:
|
||||||
|
|
||||||
|
1. After mount: `logger.begin(storage.fs())`, `logger.openSession()`.
|
||||||
|
2. Replace the 1 s heartbeat loop with the consumer loop in Section 5.
|
||||||
|
3. Stop calling `measureWriteSpeed()` (the logger provides real bps).
|
||||||
|
|
||||||
|
## 13. SPI -> SDIO migration checklist
|
||||||
|
|
||||||
|
Already documented in `src/storage/storage_config.h`. Restated for logging:
|
||||||
|
|
||||||
|
1. `STORAGE_IFACE` SPI -> SDMMC.
|
||||||
|
2. Wire SD to `STORAGE_SDMMC_*` pins (freely re-routable on classic ESP32).
|
||||||
|
3. Keep 4-bit mode (`STORAGE_SDMMC_MODE_1BIT == false`) - 1-bit halves the
|
||||||
|
throughput margin.
|
||||||
|
4. `MALLOC_CAP_DMA` buffers work unchanged; nothing else moves.
|
||||||
|
|
||||||
|
## 14. Open TODOs (to finish the feature)
|
||||||
|
|
||||||
|
- [ ] `data_logger.cpp`: chunk pool + queue creation in `begin()`.
|
||||||
|
- [ ] `data_logger.cpp`: `acquireChunk` drop-oldest path + overflow warning
|
||||||
|
(Serial + LED on `STORAGE_WARN_LED_GPIO`).
|
||||||
|
- [ ] `data_logger.cpp`: `writeChunk`/`rotateIfNeeded`/`closeSession` file
|
||||||
|
handling + header finalization + flush cadence.
|
||||||
|
- [ ] `data_logger.cpp`: `writeSpeedBps` sliding window.
|
||||||
|
- [ ] `StorageTask`: consumer loop (Section 5).
|
||||||
|
- [ ] `StorageState`/dashboard: `droppedChunks` field.
|
||||||
|
- [ ] Node collection task: round assembly + interleave + zero-fill
|
||||||
|
(Section 6).
|
||||||
|
|
||||||
|
## Appendix: constants
|
||||||
|
|
||||||
|
| Constant | Default | Meaning |
|
||||||
|
|-----------------------------------|--------------|---------------------------------|
|
||||||
|
| `STORAGE_AUDIO_SAMPLE_RATE_HZ` | 48000 | WAV sample rate |
|
||||||
|
| `STORAGE_AUDIO_CHANNELS` | 40 | = nodes x mics, WAV channels |
|
||||||
|
| `STORAGE_AUDIO_BITS` | 16 | WAV bit depth |
|
||||||
|
| `STORAGE_LOG_CHUNK_SIZE` | 16 * 1024 | pool chunk size (bytes) |
|
||||||
|
| `STORAGE_LOG_POOL_SIZE` | 8 | pool chunk count |
|
||||||
|
| `STORAGE_LOG_DIR` | "/sdcard/audio" | recording directory |
|
||||||
|
| `STORAGE_LOG_ROTATE_BYTES` | 1 GiB | rotate when a file reaches this |
|
||||||
|
| `STORAGE_LOG_FLUSH_BYTES` | 16 MiB | f_sync cadence |
|
||||||
|
| `STORAGE_WARN_LED_GPIO` | 4 | overflow warning LED |
|
||||||
|
| `STORAGE_WARN_LED_ACTIVE_HIGH` | true | LED polarity |
|
||||||
+4
-2
@@ -14,7 +14,9 @@ board = esp32dev
|
|||||||
framework = arduino
|
framework = arduino
|
||||||
lib_deps =
|
lib_deps =
|
||||||
WebSockets
|
WebSockets
|
||||||
|
;board_build.f_cpu = 160000000L
|
||||||
|
;build_type = debug
|
||||||
|
|
||||||
; upload via OTA
|
; upload via OTA
|
||||||
upload_protocol = espota
|
;upload_protocol = espota
|
||||||
upload_port = 192.168.4.1
|
;upload_port = 192.168.4.1
|
||||||
|
|||||||
Binary file not shown.
Executable
+309
@@ -0,0 +1,309 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Simulate one audio Node for the ESP32 Hub over the node UDP protocol.
|
||||||
|
|
||||||
|
The Hub (see src/network/node_protocol.h) runs:
|
||||||
|
* control plane on UDP 4210 (PROBE / START / STOP / POLL)
|
||||||
|
* data plane on UDP 4211 (DATA packets from Nodes)
|
||||||
|
|
||||||
|
This script behaves like a Node with 4 microphones:
|
||||||
|
* answers PROBE with a PROBE_RESP (so the Hub registers it)
|
||||||
|
* streams 4 interleaved 48 kHz / 16-bit sine tones (one per mic) as
|
||||||
|
DATA packets while recording, paced to real time so the WAV plays back
|
||||||
|
at the correct pitch and duration
|
||||||
|
* sends HEARTBEATs so the Hub keeps it marked online
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# PC connected to the Hub's AP (192.168.4.x), or any host on the subnet
|
||||||
|
python3 scripts/simulate_node.py
|
||||||
|
|
||||||
|
# multiple virtual nodes (distinct tones):
|
||||||
|
python3 scripts/simulate_node.py --node-id 2 --frequencies 330,415,495,660
|
||||||
|
|
||||||
|
The four default tones are an A-major chord, one per mic, so each channel is
|
||||||
|
clearly distinguishable when the 40-channel WAV is played back.
|
||||||
|
|
||||||
|
Requires only the Python standard library.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import math
|
||||||
|
import select
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
# --- Protocol constants (mirror src/network/network_config.h + node_protocol.h)
|
||||||
|
|
||||||
|
PROTOCOL_VERSION = 1
|
||||||
|
CONTROL_PORT = 4210
|
||||||
|
DATA_PORT = 4211
|
||||||
|
|
||||||
|
MSG_PROBE = 0x01
|
||||||
|
MSG_PROBE_RESP = 0x02
|
||||||
|
MSG_START = 0x03
|
||||||
|
MSG_STOP = 0x04
|
||||||
|
MSG_HEARTBEAT = 0x05
|
||||||
|
MSG_POLL = 0x06
|
||||||
|
MSG_POLL_ACK = 0x07
|
||||||
|
MSG_DATA = 0x10
|
||||||
|
|
||||||
|
STATUS_IDLE = 0
|
||||||
|
STATUS_RECORDING = 1
|
||||||
|
|
||||||
|
MICS_PER_NODE = 1
|
||||||
|
SAMPLE_RATE = 16000
|
||||||
|
BITS_PER_SAMPLE = 16
|
||||||
|
FRAME_BYTES = MICS_PER_NODE * 2 # 8 bytes per frame (4 ch x 2 B)
|
||||||
|
|
||||||
|
# Frames per DATA packet: (header 8 B) + n * 8 B must stay below 1472.
|
||||||
|
FRAMES_PER_PACKET = 100
|
||||||
|
|
||||||
|
AMPLITUDE = 8000.0 # ~0.25 x full scale
|
||||||
|
HEARTBEAT_MS = 2000
|
||||||
|
|
||||||
|
|
||||||
|
def u16(x):
|
||||||
|
return struct.pack('<H', x & 0xFFFF)
|
||||||
|
|
||||||
|
|
||||||
|
def u32(x):
|
||||||
|
return struct.pack('<I', x & 0xFFFFFFFF)
|
||||||
|
|
||||||
|
|
||||||
|
def build_probe_resp(node_id, mac, firmware, mic_count, sample_rate,
|
||||||
|
bits_per_sample, buffer_bytes, status):
|
||||||
|
fw = firmware.encode('ascii')[:15].ljust(15, b'\x00') + b'\x00'
|
||||||
|
return (bytes([MSG_PROBE_RESP, PROTOCOL_VERSION, node_id])
|
||||||
|
+ mac
|
||||||
|
+ fw
|
||||||
|
+ bytes([mic_count])
|
||||||
|
+ u32(sample_rate)
|
||||||
|
+ u16(bits_per_sample)
|
||||||
|
+ u32(buffer_bytes)
|
||||||
|
+ bytes([status]))
|
||||||
|
|
||||||
|
|
||||||
|
def build_heartbeat(node_id, uptime_ms, buffer_fill, status):
|
||||||
|
return (bytes([MSG_HEARTBEAT, PROTOCOL_VERSION, node_id])
|
||||||
|
+ u32(uptime_ms)
|
||||||
|
+ u32(buffer_fill)
|
||||||
|
+ bytes([status]))
|
||||||
|
|
||||||
|
|
||||||
|
def build_data(node_id, mic_count, frame_count, pcm):
|
||||||
|
return (bytes([MSG_DATA, PROTOCOL_VERSION, node_id, mic_count])
|
||||||
|
+ u32(frame_count)
|
||||||
|
+ pcm)
|
||||||
|
|
||||||
|
|
||||||
|
class SineGenerator:
|
||||||
|
"""Four independent phase accumulators producing interleaved 16-bit PCM."""
|
||||||
|
|
||||||
|
def __init__(self, frequencies, amplitude):
|
||||||
|
self.frequencies = frequencies
|
||||||
|
self.amplitude = amplitude
|
||||||
|
self.phase = [0.0] * len(frequencies)
|
||||||
|
self._two_pi = 2.0 * math.pi
|
||||||
|
|
||||||
|
def frames(self, count):
|
||||||
|
"""Generate `count` interleaved frames (count * FRAME_BYTES bytes)."""
|
||||||
|
pcm = bytearray(count * FRAME_BYTES)
|
||||||
|
step = [self._two_pi * f / SAMPLE_RATE for f in self.frequencies]
|
||||||
|
|
||||||
|
for f in range(count):
|
||||||
|
for ch in range(len(self.frequencies)):
|
||||||
|
self.phase[ch] += step[ch]
|
||||||
|
if self.phase[ch] >= self._two_pi:
|
||||||
|
self.phase[ch] -= self._two_pi
|
||||||
|
sample = int(self.amplitude * math.sin(self.phase[ch]))
|
||||||
|
off = (f * MICS_PER_NODE + ch) * 2
|
||||||
|
pcm[off] = sample & 0xFF
|
||||||
|
pcm[off + 1] = (sample >> 8) & 0xFF
|
||||||
|
|
||||||
|
return bytes(pcm)
|
||||||
|
|
||||||
|
|
||||||
|
class SimulatedNode:
|
||||||
|
def __init__(self, args):
|
||||||
|
self.node_id = args.node_id
|
||||||
|
self.mac = bytes([0x02, 0x00, 0x00, 0x00, 0x00, 0x10 + (args.node_id & 0xEF)])
|
||||||
|
self.firmware = "py-node-v1"
|
||||||
|
self.buffer_bytes = 64 * 1024
|
||||||
|
|
||||||
|
self.hub_ip = args.hub_ip
|
||||||
|
self.recording = False
|
||||||
|
self.status = STATUS_IDLE
|
||||||
|
self.uptime_ms = 0
|
||||||
|
self.started_at = 0.0
|
||||||
|
self.produced = 0
|
||||||
|
self.last_heartbeat = 0.0
|
||||||
|
self.running = True
|
||||||
|
|
||||||
|
self.sine = SineGenerator(args.frequencies, args.amplitude)
|
||||||
|
self.stop_after = args.duration
|
||||||
|
|
||||||
|
self.ctrl = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
self.ctrl.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
try:
|
||||||
|
self.ctrl.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self.ctrl.bind(('', CONTROL_PORT))
|
||||||
|
|
||||||
|
self.data = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
try:
|
||||||
|
self.data.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --- helpers ----------------------------------------------------------
|
||||||
|
|
||||||
|
def send_probe_resp(self):
|
||||||
|
pkt = build_probe_resp(self.node_id, self.mac, self.firmware,
|
||||||
|
MICS_PER_NODE, SAMPLE_RATE, BITS_PER_SAMPLE,
|
||||||
|
self.buffer_bytes, self.status)
|
||||||
|
self.ctrl.sendto(pkt, (self.hub_ip, CONTROL_PORT))
|
||||||
|
print(f"[node {self.node_id}] PROBE_RESP sent to {self.hub_ip}")
|
||||||
|
|
||||||
|
def send_heartbeat(self):
|
||||||
|
pkt = build_heartbeat(self.node_id, self.uptime_ms,
|
||||||
|
self.buffered_frames() * FRAME_BYTES, self.status)
|
||||||
|
self.ctrl.sendto(pkt, (self.hub_ip, CONTROL_PORT))
|
||||||
|
|
||||||
|
def buffered_frames(self):
|
||||||
|
if not self.recording:
|
||||||
|
return 0
|
||||||
|
return max(0, int(time.monotonic() - self.started_at) * SAMPLE_RATE
|
||||||
|
- self.produced)
|
||||||
|
|
||||||
|
def stream_pacing(self):
|
||||||
|
"""Send the frames that real time says are due, in DATA packets."""
|
||||||
|
if not self.recording or self.hub_ip is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
target = int((time.monotonic() - self.started_at) * SAMPLE_RATE)
|
||||||
|
while self.produced < target:
|
||||||
|
n = min(target - self.produced, FRAMES_PER_PACKET)
|
||||||
|
pcm = self.sine.frames(n)
|
||||||
|
pkt = build_data(self.node_id, MICS_PER_NODE, n, pcm)
|
||||||
|
self.data.sendto(pkt, (self.hub_ip, DATA_PORT))
|
||||||
|
self.produced += n
|
||||||
|
|
||||||
|
# --- control packet handling ------------------------------------------
|
||||||
|
|
||||||
|
def handle(self, packet, src):
|
||||||
|
if len(packet) < 2:
|
||||||
|
return
|
||||||
|
msg, ver = packet[0], packet[1]
|
||||||
|
if ver != PROTOCOL_VERSION:
|
||||||
|
return
|
||||||
|
|
||||||
|
if src[0] != self.hub_ip:
|
||||||
|
self.hub_ip = src[0] # learn the Hub from any control packet
|
||||||
|
print(f"[node {self.node_id}] learned Hub at {self.hub_ip}")
|
||||||
|
|
||||||
|
if msg == MSG_PROBE:
|
||||||
|
self.send_probe_resp()
|
||||||
|
|
||||||
|
elif msg == MSG_START:
|
||||||
|
self.recording = True
|
||||||
|
self.status = STATUS_RECORDING
|
||||||
|
self.started_at = time.monotonic()
|
||||||
|
self.produced = 0
|
||||||
|
print(f"[node {self.node_id}] START - streaming 4 tones at "
|
||||||
|
f"{SAMPLE_RATE} Hz, {MICS_PER_NODE} ch")
|
||||||
|
|
||||||
|
elif msg == MSG_STOP:
|
||||||
|
if self.recording:
|
||||||
|
print(f"[node {self.node_id}] STOP - sent "
|
||||||
|
f"{self.produced} frames")
|
||||||
|
self.recording = False
|
||||||
|
self.status = STATUS_IDLE
|
||||||
|
|
||||||
|
elif msg == MSG_POLL:
|
||||||
|
# We stream continuously; nothing extra to send.
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --- main loop --------------------------------------------------------
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
print(f"[node {self.node_id}] listening on UDP {CONTROL_PORT}...")
|
||||||
|
print(f"[node {self.node_id}] 4 tones: {self.sine.frequencies} Hz")
|
||||||
|
if self.hub_ip:
|
||||||
|
print(f"[node {self.node_id}] Hub set to {self.hub_ip}")
|
||||||
|
else:
|
||||||
|
print("[node %d] waiting for a PROBE to learn the Hub IP "
|
||||||
|
"(PC must be on the Hub's AP network)" % self.node_id)
|
||||||
|
|
||||||
|
while self.running:
|
||||||
|
ready, _, _ = select.select([self.ctrl], [], [], 0.002)
|
||||||
|
if ready:
|
||||||
|
try:
|
||||||
|
packet, src = self.ctrl.recvfrom(2048)
|
||||||
|
self.handle(packet, src)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if self.recording:
|
||||||
|
self.stream_pacing()
|
||||||
|
if self.stop_after is not None:
|
||||||
|
if time.monotonic() - self.started_at >= self.stop_after:
|
||||||
|
print(f"[node {self.node_id}] --duration reached, "
|
||||||
|
"waiting for STOP")
|
||||||
|
self.recording = False
|
||||||
|
self.status = STATUS_IDLE
|
||||||
|
|
||||||
|
now_ms = int(time.monotonic() * 1000)
|
||||||
|
self.uptime_ms = now_ms
|
||||||
|
if self.hub_ip and now_ms - self.last_heartbeat >= HEARTBEAT_MS:
|
||||||
|
self.last_heartbeat = now_ms
|
||||||
|
self.send_heartbeat()
|
||||||
|
|
||||||
|
self.ctrl.close()
|
||||||
|
self.data.close()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv):
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Simulate a 4-mic audio Node for the ESP32 Hub.')
|
||||||
|
parser.add_argument('--node-id', type=int, default=1,
|
||||||
|
help='Node ID (default 1). Overrides the matching '
|
||||||
|
'built-in simulator node.')
|
||||||
|
parser.add_argument('--hub-ip', default=None,
|
||||||
|
help='Hub IP, e.g. 192.168.4.1 (auto-learned from '
|
||||||
|
'the first control packet if omitted).')
|
||||||
|
parser.add_argument('--frequencies', default='440,554.37,659.25,880',
|
||||||
|
help='Comma-separated sine frequencies per mic '
|
||||||
|
'(default an A-major chord).')
|
||||||
|
parser.add_argument('--amplitude', type=float, default=AMPLITUDE,
|
||||||
|
help='Sine amplitude 0..32767 (default 8000).')
|
||||||
|
parser.add_argument('--duration', type=float, default=None,
|
||||||
|
help='Auto-stop after N seconds of recording '
|
||||||
|
'(default: until the Hub sends STOP).')
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
args = parse_args(argv)
|
||||||
|
args.frequencies = [float(f) for f in args.frequencies.split(',')]
|
||||||
|
if len(args.frequencies) < MICS_PER_NODE:
|
||||||
|
print('error: provide at least %d frequencies' % MICS_PER_NODE,
|
||||||
|
file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
node = SimulatedNode(args)
|
||||||
|
|
||||||
|
def on_sigint(_sig, _frame):
|
||||||
|
node.running = False
|
||||||
|
print('\n[node %d] shutting down' % args.node_id)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, on_sigint)
|
||||||
|
node.run()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
@@ -11,31 +11,17 @@ void DiagnosticsState::update()
|
|||||||
ESP.getMinFreeHeap();
|
ESP.getMinFreeHeap();
|
||||||
|
|
||||||
current.cpuFrequency =
|
current.cpuFrequency =
|
||||||
ESP.getCpuFreqMHz();
|
ESP.getCpuFreqMHz();
|
||||||
|
|
||||||
history[historyIndex] = current;
|
|
||||||
|
|
||||||
historyIndex++;
|
|
||||||
if(historyIndex >= DIAGNOSTIC_HISTORY_SIZE)
|
|
||||||
{
|
|
||||||
historyIndex = 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
DiagnosticSample DiagnosticsState::getCurrent()
|
DiagnosticSample DiagnosticsState::getCurrent()
|
||||||
{
|
{
|
||||||
return current;
|
DiagnosticSample sample;
|
||||||
}
|
sample.timestamp = current.timestamp;
|
||||||
|
sample.freeHeap = current.freeHeap;
|
||||||
|
sample.minimumFreeHeap = current.minimumFreeHeap;
|
||||||
|
sample.cpuFrequency = current.cpuFrequency;
|
||||||
DiagnosticSample DiagnosticsState::getHistory(uint8_t index)
|
return sample;
|
||||||
{
|
|
||||||
if(index >= DIAGNOSTIC_HISTORY_SIZE)
|
|
||||||
{
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
return history[index];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ public:
|
|||||||
|
|
||||||
|
|
||||||
DiagnosticSample getCurrent();
|
DiagnosticSample getCurrent();
|
||||||
DiagnosticSample getHistory(uint8_t index);
|
//DiagnosticSample getHistory(uint8_t index);
|
||||||
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|
||||||
DiagnosticSample current;
|
volatile DiagnosticSample current;
|
||||||
DiagnosticSample history[DIAGNOSTIC_HISTORY_SIZE];
|
//DiagnosticSample history[DIAGNOSTIC_HISTORY_SIZE];
|
||||||
uint8_t historyIndex = 0;
|
//uint8_t historyIndex = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
#include "node_registry.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
|
||||||
|
// --- RecordingController -----------------------------------------------------
|
||||||
|
|
||||||
|
void RecordingController::begin()
|
||||||
|
{
|
||||||
|
recording = false;
|
||||||
|
startRequested = false;
|
||||||
|
stopRequested = false;
|
||||||
|
startedAt = 0;
|
||||||
|
stoppedAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void RecordingController::requestStart()
|
||||||
|
{
|
||||||
|
if (!recording)
|
||||||
|
{
|
||||||
|
startRequested = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void RecordingController::requestStop()
|
||||||
|
{
|
||||||
|
if (recording)
|
||||||
|
{
|
||||||
|
stopRequested = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool RecordingController::consumeStartRequest()
|
||||||
|
{
|
||||||
|
if (startRequested)
|
||||||
|
{
|
||||||
|
startRequested = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool RecordingController::consumeStopRequest()
|
||||||
|
{
|
||||||
|
if (stopRequested)
|
||||||
|
{
|
||||||
|
stopRequested = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void RecordingController::onStartBroadcastSent()
|
||||||
|
{
|
||||||
|
recording = true;
|
||||||
|
startedAt = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void RecordingController::onStopBroadcastSent()
|
||||||
|
{
|
||||||
|
recording = false;
|
||||||
|
stoppedAt = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool RecordingController::isRecording() const
|
||||||
|
{
|
||||||
|
return recording;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
uint32_t RecordingController::startedAtMs() const
|
||||||
|
{
|
||||||
|
return startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
uint32_t RecordingController::stoppedAtMs() const
|
||||||
|
{
|
||||||
|
return stoppedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- NodeRegistry ------------------------------------------------------------
|
||||||
|
|
||||||
|
void NodeRegistry::begin()
|
||||||
|
{
|
||||||
|
for (int i = 0; i < NET_MAX_NODES; i++)
|
||||||
|
{
|
||||||
|
NodeInfo& n = const_cast<NodeInfo&>(nodes[i]);
|
||||||
|
n.present = false;
|
||||||
|
n.online = false;
|
||||||
|
n.nodeId = 0xFF;
|
||||||
|
n.micCount = 0;
|
||||||
|
n.lastSeenMs = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
int NodeRegistry::findByMac(const uint8_t* mac) const
|
||||||
|
{
|
||||||
|
for (int i = 0; i < NET_MAX_NODES; i++)
|
||||||
|
{
|
||||||
|
if (!nodes[i].present)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool same = true;
|
||||||
|
for (int b = 0; b < 6; b++)
|
||||||
|
{
|
||||||
|
if (nodes[i].mac[b] != mac[b])
|
||||||
|
{
|
||||||
|
same = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (same)
|
||||||
|
{
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
int NodeRegistry::findById(uint8_t nodeId) const
|
||||||
|
{
|
||||||
|
for (int i = 0; i < NET_MAX_NODES; i++)
|
||||||
|
{
|
||||||
|
if (nodes[i].present && nodes[i].nodeId == nodeId)
|
||||||
|
{
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
int NodeRegistry::upsert(const nprot::ProbeResponse& resp, const IPAddress& ip, uint32_t nowMs)
|
||||||
|
{
|
||||||
|
int idx = findByMac(resp.mac);
|
||||||
|
|
||||||
|
if (idx < 0)
|
||||||
|
{
|
||||||
|
idx = findById(resp.nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idx < 0)
|
||||||
|
{
|
||||||
|
// New node: take the first free slot.
|
||||||
|
for (int i = 0; i < NET_MAX_NODES; i++)
|
||||||
|
{
|
||||||
|
if (!nodes[i].present)
|
||||||
|
{
|
||||||
|
idx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idx < 0)
|
||||||
|
{
|
||||||
|
return -1; // table full
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NodeInfo& n = const_cast<NodeInfo&>(nodes[idx]);
|
||||||
|
n.present = true;
|
||||||
|
n.online = true;
|
||||||
|
n.nodeId = resp.nodeId;
|
||||||
|
memcpy(n.mac, resp.mac, 6);
|
||||||
|
n.ip = ip;
|
||||||
|
memset(n.firmware, 0, sizeof(n.firmware));
|
||||||
|
memcpy(n.firmware, resp.firmware, 15);
|
||||||
|
n.micCount = resp.micCount;
|
||||||
|
n.sampleRate = resp.sampleRate;
|
||||||
|
n.bitsPerSample = resp.bitsPerSample;
|
||||||
|
n.bufferBytes = resp.bufferBytes;
|
||||||
|
n.status = resp.status;
|
||||||
|
n.lastSeenMs = nowMs;
|
||||||
|
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void NodeRegistry::touch(uint8_t nodeId, uint32_t nowMs)
|
||||||
|
{
|
||||||
|
int idx = findById(nodeId);
|
||||||
|
if (idx >= 0)
|
||||||
|
{
|
||||||
|
NodeInfo& n = const_cast<NodeInfo&>(nodes[idx]);
|
||||||
|
n.lastSeenMs = nowMs;
|
||||||
|
n.online = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void NodeRegistry::updateOnline(uint32_t nowMs)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < NET_MAX_NODES; i++)
|
||||||
|
{
|
||||||
|
if (nodes[i].present)
|
||||||
|
{
|
||||||
|
NodeInfo& n = const_cast<NodeInfo&>(nodes[i]);
|
||||||
|
n.online = (nowMs - n.lastSeenMs) < NET_OFFLINE_MS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
int NodeRegistry::count() const
|
||||||
|
{
|
||||||
|
int c = 0;
|
||||||
|
for (int i = 0; i < NET_MAX_NODES; i++)
|
||||||
|
{
|
||||||
|
if (nodes[i].present) c++;
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
int NodeRegistry::onlineCount() const
|
||||||
|
{
|
||||||
|
int c = 0;
|
||||||
|
for (int i = 0; i < NET_MAX_NODES; i++)
|
||||||
|
{
|
||||||
|
if (nodes[i].present && nodes[i].online) c++;
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
NodeInfo NodeRegistry::get(int index) const
|
||||||
|
{
|
||||||
|
NodeInfo result = {};
|
||||||
|
|
||||||
|
if (index < 0 || index >= NET_MAX_NODES)
|
||||||
|
{
|
||||||
|
return result; // zeroed, present == false
|
||||||
|
}
|
||||||
|
|
||||||
|
result.present = nodes[index].present;
|
||||||
|
result.online = nodes[index].online;
|
||||||
|
result.nodeId = nodes[index].nodeId;
|
||||||
|
for (int b = 0; b < 6; b++) result.mac[b] = nodes[index].mac[b];
|
||||||
|
{
|
||||||
|
// IPAddress is a class, so copy its 4 payload bytes directly.
|
||||||
|
const volatile uint8_t* srcIp =
|
||||||
|
reinterpret_cast<const volatile uint8_t*>(&nodes[index].ip);
|
||||||
|
for (int b = 0; b < 4; b++) result.ip[b] = srcIp[b];
|
||||||
|
}
|
||||||
|
for (int b = 0; b < 16; b++) result.firmware[b] = nodes[index].firmware[b];
|
||||||
|
result.micCount = nodes[index].micCount;
|
||||||
|
result.sampleRate = nodes[index].sampleRate;
|
||||||
|
result.bitsPerSample= nodes[index].bitsPerSample;
|
||||||
|
result.bufferBytes = nodes[index].bufferBytes;
|
||||||
|
result.status = nodes[index].status;
|
||||||
|
result.lastSeenMs = nodes[index].lastSeenMs;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Shared node registry + recording state.
|
||||||
|
//
|
||||||
|
// Written by the DiscoveryService / CollectionService (core 1) and read by
|
||||||
|
// the WebService (core 1) and StorageTask (core 0). Like the other *State
|
||||||
|
// classes, values live in a volatile snapshot so cross-core reads are sane.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
#include "../network/network_config.h"
|
||||||
|
#include "../network/node_protocol.h"
|
||||||
|
|
||||||
|
|
||||||
|
// One discovered Node. `present` marks an occupied slot; `online` means it was
|
||||||
|
// heard from within the offline timeout.
|
||||||
|
struct NodeInfo
|
||||||
|
{
|
||||||
|
bool present;
|
||||||
|
bool online;
|
||||||
|
uint8_t nodeId;
|
||||||
|
uint8_t mac[6];
|
||||||
|
IPAddress ip;
|
||||||
|
char firmware[16];
|
||||||
|
uint8_t micCount;
|
||||||
|
uint32_t sampleRate;
|
||||||
|
uint16_t bitsPerSample;
|
||||||
|
uint32_t bufferBytes;
|
||||||
|
uint8_t status;
|
||||||
|
uint32_t lastSeenMs;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Simple start/stop state machine shared between the FrontPanelService
|
||||||
|
// (requestor), DiscoveryService (broadcast sender) and StorageTask (watcher).
|
||||||
|
class RecordingController
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
void begin();
|
||||||
|
|
||||||
|
// Called by FrontPanelService on button press.
|
||||||
|
void requestStart();
|
||||||
|
void requestStop();
|
||||||
|
|
||||||
|
// Consumed by DiscoveryService: true once per pending start/stop.
|
||||||
|
bool consumeStartRequest();
|
||||||
|
bool consumeStopRequest();
|
||||||
|
|
||||||
|
// Called by DiscoveryService once the broadcast has been sent.
|
||||||
|
void onStartBroadcastSent();
|
||||||
|
void onStopBroadcastSent();
|
||||||
|
|
||||||
|
bool isRecording() const;
|
||||||
|
uint32_t startedAtMs() const;
|
||||||
|
uint32_t stoppedAtMs() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
volatile bool recording;
|
||||||
|
volatile bool startRequested;
|
||||||
|
volatile bool stopRequested;
|
||||||
|
volatile uint32_t startedAt;
|
||||||
|
volatile uint32_t stoppedAt;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Fixed-size table of discovered nodes (NET_MAX_NODES). Index == slot in the
|
||||||
|
// 40-channel interleave order is NOT assumed; the collector keys on nodeId.
|
||||||
|
class NodeRegistry
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
void begin();
|
||||||
|
|
||||||
|
// Index of a node by MAC, or -1. Used to detect new clients.
|
||||||
|
int findByMac(const uint8_t* mac) const;
|
||||||
|
|
||||||
|
// Index of a node by nodeId, or -1.
|
||||||
|
int findById(uint8_t nodeId) const;
|
||||||
|
|
||||||
|
// Creates or updates a node from a probe response. Returns the slot index
|
||||||
|
// or -1 when the table is full (no free slot and MAC unknown).
|
||||||
|
int upsert(const nprot::ProbeResponse& resp, const IPAddress& ip, uint32_t nowMs);
|
||||||
|
|
||||||
|
// Refreshes lastSeenMs for a node (heartbeat / probe response).
|
||||||
|
void touch(uint8_t nodeId, uint32_t nowMs);
|
||||||
|
|
||||||
|
// Recomputes `online` from lastSeenMs vs NET_OFFLINE_MS.
|
||||||
|
void updateOnline(uint32_t nowMs);
|
||||||
|
|
||||||
|
int count() const; // occupied slots
|
||||||
|
int onlineCount() const;
|
||||||
|
NodeInfo get(int index) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
volatile NodeInfo nodes[NET_MAX_NODES];
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
#include "storage_state.h"
|
||||||
|
|
||||||
|
|
||||||
|
void StorageState::begin()
|
||||||
|
{
|
||||||
|
current.mounted = false;
|
||||||
|
current.timestamp = 0;
|
||||||
|
current.totalMB = 0;
|
||||||
|
current.usedMB = 0;
|
||||||
|
current.freeMB = 0;
|
||||||
|
current.writeSpeedBps = 0;
|
||||||
|
current.droppedChunks = 0;
|
||||||
|
current.bytesWritten = 0;
|
||||||
|
current.cardType[0] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void StorageState::setMounted(bool mounted)
|
||||||
|
{
|
||||||
|
current.mounted = mounted;
|
||||||
|
current.timestamp = millis();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void StorageState::setCardType(const char* type)
|
||||||
|
{
|
||||||
|
size_t i = 0;
|
||||||
|
for (i = 0; i < sizeof(current.cardType) - 1 && type[i] != '\0'; i++)
|
||||||
|
{
|
||||||
|
current.cardType[i] = type[i];
|
||||||
|
}
|
||||||
|
current.cardType[i] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void StorageState::setCapacity(uint64_t totalBytes, uint64_t usedBytes)
|
||||||
|
{
|
||||||
|
uint64_t freeBytes = (totalBytes > usedBytes) ? (totalBytes - usedBytes) : 0;
|
||||||
|
|
||||||
|
current.totalMB = (uint32_t)(totalBytes >> 20);
|
||||||
|
current.usedMB = (uint32_t)(usedBytes >> 20);
|
||||||
|
current.freeMB = (uint32_t)(freeBytes >> 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void StorageState::setWriteSpeedBps(uint32_t bytesPerSecond)
|
||||||
|
{
|
||||||
|
current.writeSpeedBps = bytesPerSecond;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void StorageState::setLoggingStats(uint32_t droppedChunks, uint32_t bytesWritten)
|
||||||
|
{
|
||||||
|
current.droppedChunks = droppedChunks;
|
||||||
|
current.bytesWritten = bytesWritten;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
StorageSnapshot StorageState::getCurrent()
|
||||||
|
{
|
||||||
|
StorageSnapshot snapshot;
|
||||||
|
|
||||||
|
snapshot.mounted = current.mounted;
|
||||||
|
snapshot.timestamp = current.timestamp;
|
||||||
|
snapshot.totalMB = current.totalMB;
|
||||||
|
snapshot.usedMB = current.usedMB;
|
||||||
|
snapshot.freeMB = current.freeMB;
|
||||||
|
snapshot.writeSpeedBps = current.writeSpeedBps;
|
||||||
|
snapshot.droppedChunks = current.droppedChunks;
|
||||||
|
snapshot.bytesWritten = current.bytesWritten;
|
||||||
|
|
||||||
|
for (size_t i = 0; i < sizeof(snapshot.cardType); i++)
|
||||||
|
{
|
||||||
|
snapshot.cardType[i] = current.cardType[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
|
||||||
|
// Cross-task snapshot of the SD card, filled by the StorageTask (core 0) and
|
||||||
|
// read by the WebService (core 1) for the dashboard broadcast. Values are
|
||||||
|
// stored as 32-bit quantities (MB / Bps) so each field reads atomically.
|
||||||
|
struct StorageSnapshot
|
||||||
|
{
|
||||||
|
bool mounted;
|
||||||
|
uint32_t timestamp;
|
||||||
|
uint32_t totalMB;
|
||||||
|
uint32_t usedMB;
|
||||||
|
uint32_t freeMB;
|
||||||
|
uint32_t writeSpeedBps;
|
||||||
|
uint32_t droppedChunks;
|
||||||
|
uint32_t bytesWritten;
|
||||||
|
char cardType[16];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
class StorageState
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
void begin();
|
||||||
|
|
||||||
|
void setMounted(bool mounted);
|
||||||
|
void setCardType(const char* type);
|
||||||
|
void setCapacity(uint64_t totalBytes, uint64_t usedBytes);
|
||||||
|
void setWriteSpeedBps(uint32_t bytesPerSecond);
|
||||||
|
void setLoggingStats(uint32_t droppedChunks, uint32_t bytesWritten);
|
||||||
|
|
||||||
|
StorageSnapshot getCurrent();
|
||||||
|
|
||||||
|
private:
|
||||||
|
volatile StorageSnapshot current;
|
||||||
|
};
|
||||||
+43
-1
@@ -5,43 +5,85 @@
|
|||||||
|
|
||||||
#include "core/dashboard_state.h"
|
#include "core/dashboard_state.h"
|
||||||
#include "core/diagnostics_state.h"
|
#include "core/diagnostics_state.h"
|
||||||
|
#include "core/storage_state.h"
|
||||||
|
#include "core/node_registry.h"
|
||||||
|
|
||||||
#include "services/service_manager.h"
|
#include "services/service_manager.h"
|
||||||
#include "services/wifi_service.h"
|
#include "services/wifi_service.h"
|
||||||
#include "services/ota_service.h"
|
#include "services/ota_service.h"
|
||||||
#include "services/web_service.h"
|
#include "services/web_service.h"
|
||||||
|
#include "services/front_panel_service.h"
|
||||||
|
|
||||||
|
#include "network/network_config.h"
|
||||||
|
#include "network/discovery_service.h"
|
||||||
|
#include "network/collection_service.h"
|
||||||
|
#undef NET_SIM_ENABLED
|
||||||
|
#if NET_SIM_ENABLED
|
||||||
|
#include "network/node_simulator.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include "storage/sd_manager.h"
|
||||||
|
#include "storage/data_logger.h"
|
||||||
|
|
||||||
#include "tasks/system_task.h"
|
#include "tasks/system_task.h"
|
||||||
#include "tasks/diagnostics_task.h"
|
#include "tasks/diagnostics_task.h"
|
||||||
|
#include "tasks/storage_task.h"
|
||||||
|
|
||||||
|
|
||||||
DashboardState dashboardState;
|
DashboardState dashboardState;
|
||||||
DiagnosticsState diagnosticsState;
|
DiagnosticsState diagnosticsState;
|
||||||
|
StorageState storageState;
|
||||||
|
NodeRegistry nodeRegistry;
|
||||||
|
RecordingController recordingController;
|
||||||
|
|
||||||
// Simple service scheduler nice for grouping tasks
|
// Simple service scheduler nice for grouping tasks
|
||||||
ServiceManager services;
|
ServiceManager services;
|
||||||
|
|
||||||
WiFiService wifi;
|
WiFiService wifi;
|
||||||
OTAService ota;
|
OTAService ota;
|
||||||
WebService web(dashboardState, diagnosticsState);
|
WebService web(dashboardState, diagnosticsState, storageState,
|
||||||
|
nodeRegistry, recordingController);
|
||||||
|
DiscoveryService discovery(nodeRegistry, recordingController);
|
||||||
|
|
||||||
|
DataLogger dataLogger;
|
||||||
|
CollectionService collection(nodeRegistry, recordingController, dataLogger);
|
||||||
|
|
||||||
|
FrontPanelService panel(recordingController);
|
||||||
|
|
||||||
|
#if NET_SIM_ENABLED
|
||||||
|
NodeSimulator simulator(nodeRegistry, recordingController);
|
||||||
|
#endif
|
||||||
|
|
||||||
// Actual FreeRTOS tasks that are scheduled
|
// Actual FreeRTOS tasks that are scheduled
|
||||||
SystemTask systemTask(services);
|
SystemTask systemTask(services);
|
||||||
|
|
||||||
DiagnosticsTask diagnosticsTask(diagnosticsState);
|
DiagnosticsTask diagnosticsTask(diagnosticsState);
|
||||||
|
|
||||||
|
SDManager sdManager;
|
||||||
|
StorageTask storageTask(sdManager, storageState, dataLogger, recordingController);
|
||||||
|
|
||||||
void setup()
|
void setup()
|
||||||
{
|
{
|
||||||
Serial.begin(115200);
|
Serial.begin(115200);
|
||||||
|
|
||||||
|
nodeRegistry.begin();
|
||||||
|
recordingController.begin();
|
||||||
|
|
||||||
services.add(&wifi);
|
services.add(&wifi);
|
||||||
services.add(&ota);
|
services.add(&ota);
|
||||||
services.add(&web);
|
services.add(&web);
|
||||||
|
services.add(&discovery);
|
||||||
|
services.add(&collection);
|
||||||
|
services.add(&panel);
|
||||||
|
|
||||||
|
#if NET_SIM_ENABLED
|
||||||
|
services.add(&simulator);
|
||||||
|
collection.setSource(&simulator);
|
||||||
|
#endif
|
||||||
|
|
||||||
systemTask.start();
|
systemTask.start();
|
||||||
diagnosticsTask.start();
|
diagnosticsTask.start();
|
||||||
|
storageTask.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
#include "collection_service.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "node_protocol.h"
|
||||||
|
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr size_t FRAME_BYTES = NET_MICS_PER_NODE * 2; // 8
|
||||||
|
constexpr size_t ROUND_BYTES = NET_ROUND_FRAMES * FRAME_BYTES; // 768
|
||||||
|
constexpr size_t OUTPUT_FRAME = NET_MAX_NODES * NET_MICS_PER_NODE * 2; // 80
|
||||||
|
|
||||||
|
// One interleave round is written into exactly one chunk. If the round ever
|
||||||
|
// exceeds the chunk capacity, produceRound() overflows the DMA pool and
|
||||||
|
// corrupts the heap, so the two sizes are bound together at build time.
|
||||||
|
static_assert((size_t)NET_ROUND_FRAMES * OUTPUT_FRAME
|
||||||
|
<= (size_t)STORAGE_LOG_CHUNK_SIZE,
|
||||||
|
"Interleave round exceeds the DataLogger chunk: reduce "
|
||||||
|
"NET_ROUND_FRAMES or enlarge STORAGE_LOG_CHUNK_SIZE");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
CollectionService::CollectionService(NodeRegistry& registry,
|
||||||
|
RecordingController& recorder,
|
||||||
|
DataLogger& logger,
|
||||||
|
NodeSource* source)
|
||||||
|
:
|
||||||
|
Service("Collect", 10),
|
||||||
|
nodes(registry),
|
||||||
|
recorder(recorder),
|
||||||
|
logger(logger),
|
||||||
|
source(source),
|
||||||
|
lastPollIndex(-1),
|
||||||
|
lastPollMs(0),
|
||||||
|
blockedSinceMs(0),
|
||||||
|
chunkSequence(0),
|
||||||
|
started(false)
|
||||||
|
{
|
||||||
|
memset(stages, 0, sizeof(stages));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void CollectionService::setSource(NodeSource* src)
|
||||||
|
{
|
||||||
|
source = src;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void CollectionService::begin()
|
||||||
|
{
|
||||||
|
dataUdp.begin(NET_DATA_PORT);
|
||||||
|
|
||||||
|
Serial.printf("[Collector] Data UDP on port %d\n", NET_DATA_PORT);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void CollectionService::update()
|
||||||
|
{
|
||||||
|
uint32_t now = millis();
|
||||||
|
|
||||||
|
if (recorder.isRecording())
|
||||||
|
{
|
||||||
|
if (!started)
|
||||||
|
{
|
||||||
|
started = true;
|
||||||
|
Serial.println("[Collector] Recording started, polling nodes");
|
||||||
|
}
|
||||||
|
|
||||||
|
drainDataSocket(now);
|
||||||
|
pullSimulator(now);
|
||||||
|
pollNextNode(now);
|
||||||
|
interleaveAvailable(now);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (started)
|
||||||
|
{
|
||||||
|
started = false;
|
||||||
|
Serial.println("[Collector] Recording stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < NET_MAX_NODES; i++)
|
||||||
|
{
|
||||||
|
stages[i].len = 0;
|
||||||
|
stages[i].present = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockedSinceMs = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool CollectionService::isOnline(const NodeInfo& n)
|
||||||
|
{
|
||||||
|
return n.present && n.online;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void CollectionService::drainDataSocket(uint32_t nowMs)
|
||||||
|
{
|
||||||
|
uint8_t buf[NET_MAX_UDP_PAYLOAD];
|
||||||
|
|
||||||
|
int len;
|
||||||
|
while ((len = dataUdp.parsePacket()) > 0)
|
||||||
|
{
|
||||||
|
int n = dataUdp.read(buf, sizeof(buf));
|
||||||
|
if (n < 8)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
nprot::DataHeader h;
|
||||||
|
if (!nprot::parseDataHeader(buf, n, h))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (h.micCount != NET_MICS_PER_NODE)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int idx = nodes.findById(h.nodeId);
|
||||||
|
if (idx < 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
appendPcmToStage(idx, buf + 8, (size_t)(n - 8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void CollectionService::pullSimulator(uint32_t nowMs)
|
||||||
|
{
|
||||||
|
if (source == nullptr)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int idx = 0; idx < NET_MAX_NODES; idx++)
|
||||||
|
{
|
||||||
|
NodeInfo ni = nodes.get(idx);
|
||||||
|
if (!isOnline(ni))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
NodeStage& s = stages[idx];
|
||||||
|
if (!s.present || s.nodeId != ni.nodeId)
|
||||||
|
{
|
||||||
|
s.present = true;
|
||||||
|
s.nodeId = ni.nodeId;
|
||||||
|
s.len = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t free = sizeof(s.data) - s.len;
|
||||||
|
size_t want = free > 2 * ROUND_BYTES ? 2 * ROUND_BYTES : free;
|
||||||
|
if (want < FRAME_BYTES)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t tmp[2 * ROUND_BYTES];
|
||||||
|
size_t got = source->readAudio(ni.nodeId, tmp, want);
|
||||||
|
if (got > 0)
|
||||||
|
{
|
||||||
|
appendPcmToStage(idx, tmp, got);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void CollectionService::pollNextNode(uint32_t nowMs)
|
||||||
|
{
|
||||||
|
if (nodes.onlineCount() == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nowMs - lastPollMs < NET_POLL_PERIOD_MS)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastPollMs = nowMs;
|
||||||
|
|
||||||
|
int start = (lastPollIndex + 1) % NET_MAX_NODES;
|
||||||
|
|
||||||
|
for (int i = 0; i < NET_MAX_NODES; i++)
|
||||||
|
{
|
||||||
|
int idx = (start + i) % NET_MAX_NODES;
|
||||||
|
NodeInfo ni = nodes.get(idx);
|
||||||
|
|
||||||
|
if (isOnline(ni))
|
||||||
|
{
|
||||||
|
lastPollIndex = idx;
|
||||||
|
sendPoll(ni);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void CollectionService::sendPoll(const NodeInfo& node)
|
||||||
|
{
|
||||||
|
uint8_t buf[8];
|
||||||
|
size_t n = nprot::buildPoll(buf, sizeof(buf), node.nodeId);
|
||||||
|
|
||||||
|
dataUdp.beginPacket(node.ip, NET_CONTROL_PORT);
|
||||||
|
dataUdp.write(buf, n);
|
||||||
|
dataUdp.endPacket();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void CollectionService::interleaveAvailable(uint32_t nowMs)
|
||||||
|
{
|
||||||
|
bool allReady = true;
|
||||||
|
|
||||||
|
for (int idx = 0; idx < NET_MAX_NODES; idx++)
|
||||||
|
{
|
||||||
|
NodeInfo ni = nodes.get(idx);
|
||||||
|
if (isOnline(ni) && stages[idx].len < ROUND_BYTES)
|
||||||
|
{
|
||||||
|
allReady = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allReady)
|
||||||
|
{
|
||||||
|
// A node fell behind (e.g. its DATA packets were lost). Wait up to a
|
||||||
|
// full poll cycle, then force a round with silence for stragglers so
|
||||||
|
// the pipeline never stalls.
|
||||||
|
if (blockedSinceMs == 0)
|
||||||
|
{
|
||||||
|
blockedSinceMs = nowMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nowMs - blockedSinceMs >= NET_POLL_PERIOD_MS)
|
||||||
|
{
|
||||||
|
blockedSinceMs = 0;
|
||||||
|
produceRound();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockedSinceMs = 0;
|
||||||
|
|
||||||
|
while (produceRound())
|
||||||
|
{
|
||||||
|
allReady = true;
|
||||||
|
for (int idx = 0; idx < NET_MAX_NODES; idx++)
|
||||||
|
{
|
||||||
|
NodeInfo ni = nodes.get(idx);
|
||||||
|
if (isOnline(ni) && stages[idx].len < ROUND_BYTES)
|
||||||
|
{
|
||||||
|
allReady = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!allReady)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool CollectionService::produceRound()
|
||||||
|
{
|
||||||
|
AudioChunk* c = logger.acquireChunk(0);
|
||||||
|
if (c == nullptr)
|
||||||
|
{
|
||||||
|
return false; // pool drained; SD is catching up
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t* out = c->data;
|
||||||
|
uint8_t silence[FRAME_BYTES] = {0};
|
||||||
|
|
||||||
|
for (uint32_t f = 0; f < NET_ROUND_FRAMES; f++)
|
||||||
|
{
|
||||||
|
for (int idx = 0; idx < NET_MAX_NODES; idx++)
|
||||||
|
{
|
||||||
|
NodeInfo ni = nodes.get(idx);
|
||||||
|
|
||||||
|
if (isOnline(ni) && stages[idx].present && stages[idx].len >= ROUND_BYTES)
|
||||||
|
{
|
||||||
|
memcpy(out, stages[idx].data + f * FRAME_BYTES, FRAME_BYTES);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
memcpy(out, silence, FRAME_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
out += FRAME_BYTES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int idx = 0; idx < NET_MAX_NODES; idx++)
|
||||||
|
{
|
||||||
|
NodeInfo ni = nodes.get(idx);
|
||||||
|
if (!isOnline(ni))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
NodeStage& s = stages[idx];
|
||||||
|
if (s.present && s.len >= ROUND_BYTES)
|
||||||
|
{
|
||||||
|
size_t remainder = s.len - ROUND_BYTES;
|
||||||
|
memmove(s.data, s.data + ROUND_BYTES, remainder);
|
||||||
|
s.len = (uint16_t)remainder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c->length = NET_ROUND_FRAMES * OUTPUT_FRAME;
|
||||||
|
c->sequence = chunkSequence++;
|
||||||
|
|
||||||
|
logger.submitChunk(c);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void CollectionService::appendPcmToStage(int idx, const uint8_t* p, size_t n)
|
||||||
|
{
|
||||||
|
if (idx < 0 || idx >= NET_MAX_NODES)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NodeStage& s = stages[idx];
|
||||||
|
NodeInfo ni = nodes.get(idx);
|
||||||
|
|
||||||
|
if (!s.present || s.nodeId != ni.nodeId)
|
||||||
|
{
|
||||||
|
s.present = true;
|
||||||
|
s.nodeId = ni.nodeId;
|
||||||
|
s.len = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n >= sizeof(s.data))
|
||||||
|
{
|
||||||
|
memcpy(s.data, p + (n - sizeof(s.data)), sizeof(s.data));
|
||||||
|
s.len = sizeof(s.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.len + n > sizeof(s.data))
|
||||||
|
{
|
||||||
|
size_t drop = s.len + n - sizeof(s.data);
|
||||||
|
memmove(s.data, s.data + drop, s.len - drop);
|
||||||
|
s.len -= (uint16_t)drop;
|
||||||
|
}
|
||||||
|
|
||||||
|
memcpy(s.data + s.len, p, n);
|
||||||
|
s.len += (uint16_t)n;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CollectionService - data plane of the node protocol.
|
||||||
|
//
|
||||||
|
// Round-robin POLLs online nodes, reassembles their DATA packets into
|
||||||
|
// per-node staging buffers, interleaves 40 channels into frame order, and
|
||||||
|
// feeds the resulting PCM to the DataLogger as chunks. Missing/offline nodes
|
||||||
|
// contribute silence (zero fill), per design.md.
|
||||||
|
//
|
||||||
|
// Audio sources plug in behind the NodeSource interface so the built-in
|
||||||
|
// NodeSimulator and real UDP nodes share the exact same path.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#include <WiFiUdp.h>
|
||||||
|
|
||||||
|
#include "../services/service.h"
|
||||||
|
#include "../core/node_registry.h"
|
||||||
|
#include "../storage/data_logger.h"
|
||||||
|
#include "network_config.h"
|
||||||
|
|
||||||
|
|
||||||
|
// Anything that produces node-interleaved (4 ch) PCM for a node. Real nodes
|
||||||
|
// arrive via the data UDP socket; the simulator generates PCM in software.
|
||||||
|
class NodeSource
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
virtual ~NodeSource() {}
|
||||||
|
|
||||||
|
// Writes up to maxBytes of PCM for `nodeId` into `out`. Returns bytes
|
||||||
|
// written (a multiple of 2 * NET_MICS_PER_NODE). Called on core 1.
|
||||||
|
virtual size_t readAudio(uint8_t nodeId, uint8_t* out, size_t maxBytes) = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionService : public Service
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
CollectionService(NodeRegistry& registry,
|
||||||
|
RecordingController& recorder,
|
||||||
|
DataLogger& logger,
|
||||||
|
NodeSource* source = nullptr);
|
||||||
|
|
||||||
|
void begin() override;
|
||||||
|
void update() override;
|
||||||
|
|
||||||
|
// Registered by main.cpp so the simulator feeds this collector.
|
||||||
|
void setSource(NodeSource* source);
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct NodeStage
|
||||||
|
{
|
||||||
|
bool present;
|
||||||
|
uint8_t nodeId;
|
||||||
|
uint8_t data[NET_STAGE_BYTES];
|
||||||
|
uint16_t len;
|
||||||
|
};
|
||||||
|
|
||||||
|
void drainDataSocket(uint32_t nowMs);
|
||||||
|
void pullSimulator(uint32_t nowMs);
|
||||||
|
void pollNextNode(uint32_t nowMs);
|
||||||
|
void interleaveAvailable(uint32_t nowMs);
|
||||||
|
bool produceRound();
|
||||||
|
void sendPoll(const NodeInfo& node);
|
||||||
|
void appendPcmToStage(int idx, const uint8_t* p, size_t n);
|
||||||
|
static bool isOnline(const NodeInfo& n);
|
||||||
|
|
||||||
|
NodeRegistry& nodes;
|
||||||
|
RecordingController& recorder;
|
||||||
|
DataLogger& logger;
|
||||||
|
NodeSource* source;
|
||||||
|
WiFiUDP dataUdp;
|
||||||
|
|
||||||
|
NodeStage stages[NET_MAX_NODES];
|
||||||
|
int lastPollIndex;
|
||||||
|
uint32_t lastPollMs;
|
||||||
|
uint32_t blockedSinceMs;
|
||||||
|
uint32_t chunkSequence;
|
||||||
|
bool started;
|
||||||
|
};
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
#include "discovery_service.h"
|
||||||
|
|
||||||
|
#include <esp_wifi.h>
|
||||||
|
#include <esp_netif.h>
|
||||||
|
|
||||||
|
|
||||||
|
DiscoveryService::DiscoveryService(NodeRegistry& registry, RecordingController& recorder)
|
||||||
|
:
|
||||||
|
Service("Discovery", 100),
|
||||||
|
nodes(registry),
|
||||||
|
recorder(recorder),
|
||||||
|
lastScanMs(0),
|
||||||
|
lastBeaconMs(0)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void DiscoveryService::begin()
|
||||||
|
{
|
||||||
|
udp.begin(NET_CONTROL_PORT);
|
||||||
|
|
||||||
|
Serial.printf("[Discovery] Control UDP on port %d\n", NET_CONTROL_PORT);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void DiscoveryService::update()
|
||||||
|
{
|
||||||
|
uint32_t now = millis();
|
||||||
|
|
||||||
|
handlePacket(now);
|
||||||
|
|
||||||
|
// Front panel asked to start/stop: broadcast it.
|
||||||
|
if (recorder.consumeStartRequest())
|
||||||
|
{
|
||||||
|
sendStart();
|
||||||
|
recorder.onStartBroadcastSent();
|
||||||
|
Serial.println("[Discovery] START broadcast sent");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recorder.consumeStopRequest())
|
||||||
|
{
|
||||||
|
sendStop();
|
||||||
|
recorder.onStopBroadcastSent();
|
||||||
|
Serial.println("[Discovery] STOP broadcast sent");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe any station that just associated.
|
||||||
|
if (now - lastScanMs >= NET_PROBE_SCAN_MS)
|
||||||
|
{
|
||||||
|
lastScanMs = now;
|
||||||
|
scanStations(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety-net beacon so nodes that missed a unicast probe still answer.
|
||||||
|
if (now - lastBeaconMs >= NET_BEACON_MS)
|
||||||
|
{
|
||||||
|
lastBeaconMs = now;
|
||||||
|
broadcastBeacon(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.updateOnline(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void DiscoveryService::scanStations(uint32_t nowMs)
|
||||||
|
{
|
||||||
|
wifi_sta_list_t staList;
|
||||||
|
esp_netif_sta_list_t netifList;
|
||||||
|
|
||||||
|
if (esp_wifi_ap_get_sta_list(&staList) != ESP_OK)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (esp_netif_get_sta_list(&staList, &netifList) != ESP_OK)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < netifList.num; i++)
|
||||||
|
{
|
||||||
|
uint32_t ipRaw = netifList.sta[i].ip.addr;
|
||||||
|
if (ipRaw == 0)
|
||||||
|
{
|
||||||
|
continue; // not yet assigned by DHCP
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodes.findByMac(netifList.sta[i].mac) < 0)
|
||||||
|
{
|
||||||
|
IPAddress ip(ipRaw);
|
||||||
|
sendProbeTo(ip);
|
||||||
|
Serial.printf("[Discovery] New client %s, sending probe\n",
|
||||||
|
ip.toString().c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void DiscoveryService::sendProbeTo(const IPAddress& ip)
|
||||||
|
{
|
||||||
|
uint8_t buf[8];
|
||||||
|
size_t n = nprot::buildProbe(buf, sizeof(buf), recorder.isRecording());
|
||||||
|
|
||||||
|
udp.beginPacket(ip, NET_CONTROL_PORT);
|
||||||
|
udp.write(buf, n);
|
||||||
|
udp.endPacket();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void DiscoveryService::broadcastBeacon(uint32_t nowMs)
|
||||||
|
{
|
||||||
|
IPAddress bcast;
|
||||||
|
bcast.fromString(NET_AP_BCAST_IP);
|
||||||
|
sendProbeTo(bcast);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void DiscoveryService::handlePacket(uint32_t nowMs)
|
||||||
|
{
|
||||||
|
int len = udp.parsePacket();
|
||||||
|
if (len <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t buf[256];
|
||||||
|
int n = udp.read(buf, sizeof(buf));
|
||||||
|
if (n <= 1)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IPAddress remoteIp = udp.remoteIP();
|
||||||
|
|
||||||
|
switch (buf[0])
|
||||||
|
{
|
||||||
|
case nprot::MSG_PROBE_RESP:
|
||||||
|
{
|
||||||
|
nprot::ProbeResponse resp;
|
||||||
|
if (nprot::parseProbeResponse(buf, n, resp))
|
||||||
|
{
|
||||||
|
int idx = nodes.upsert(resp, remoteIp, nowMs);
|
||||||
|
if (idx >= 0)
|
||||||
|
{
|
||||||
|
Serial.printf("[Discovery] Node %u online (%u mics, %u Hz)\n",
|
||||||
|
resp.nodeId, resp.micCount, resp.sampleRate);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Serial.println("[Discovery] Node table full, node ignored");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case nprot::MSG_HEARTBEAT:
|
||||||
|
{
|
||||||
|
nprot::Heartbeat hb;
|
||||||
|
if (nprot::parseHeartbeat(buf, n, hb))
|
||||||
|
{
|
||||||
|
nodes.touch(hb.nodeId, nowMs);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void DiscoveryService::broadcast(const uint8_t* buf, size_t len)
|
||||||
|
{
|
||||||
|
IPAddress bcast;
|
||||||
|
bcast.fromString(NET_AP_BCAST_IP);
|
||||||
|
|
||||||
|
udp.beginPacket(bcast, NET_CONTROL_PORT);
|
||||||
|
udp.write(buf, len);
|
||||||
|
udp.endPacket();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void DiscoveryService::sendStart()
|
||||||
|
{
|
||||||
|
uint8_t buf[8];
|
||||||
|
size_t n = nprot::buildStart(buf, sizeof(buf));
|
||||||
|
broadcast(buf, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void DiscoveryService::sendStop()
|
||||||
|
{
|
||||||
|
uint8_t buf[8];
|
||||||
|
size_t n = nprot::buildStop(buf, sizeof(buf));
|
||||||
|
broadcast(buf, n);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DiscoveryService - control plane of the node protocol.
|
||||||
|
//
|
||||||
|
// Detects new WiFi clients (AP station list -> unicast PROBE), discovers
|
||||||
|
// nodes that missed a probe via periodic broadcast beacons, registers
|
||||||
|
// PROBE_RESP / HEARTBEAT packets in the NodeRegistry, tracks offline state,
|
||||||
|
// and broadcasts START / STOP when the RecordingController requests it.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#include <WiFiUdp.h>
|
||||||
|
|
||||||
|
#include "../services/service.h"
|
||||||
|
#include "../core/node_registry.h"
|
||||||
|
#include "network_config.h"
|
||||||
|
|
||||||
|
|
||||||
|
class DiscoveryService : public Service
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
DiscoveryService(NodeRegistry& registry, RecordingController& recorder);
|
||||||
|
|
||||||
|
void begin() override;
|
||||||
|
void update() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void scanStations(uint32_t nowMs);
|
||||||
|
void sendProbeTo(const IPAddress& ip);
|
||||||
|
void broadcastBeacon(uint32_t nowMs);
|
||||||
|
void handlePacket(uint32_t nowMs);
|
||||||
|
void broadcast(const uint8_t* buf, size_t len);
|
||||||
|
void sendStart();
|
||||||
|
void sendStop();
|
||||||
|
|
||||||
|
NodeRegistry& nodes;
|
||||||
|
RecordingController& recorder;
|
||||||
|
WiFiUDP udp;
|
||||||
|
|
||||||
|
uint32_t lastScanMs;
|
||||||
|
uint32_t lastBeaconMs;
|
||||||
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Network configuration for the Hub <-> Node UDP protocol.
|
||||||
|
// See docs/network_protocol.md for the wire format.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// --- UDP ports --------------------------------------------------------------
|
||||||
|
|
||||||
|
// Control plane: discovery, status, start/stop, polling.
|
||||||
|
#define NET_CONTROL_PORT 4210
|
||||||
|
|
||||||
|
// Data plane: audio DATA packets.
|
||||||
|
#define NET_DATA_PORT 4211
|
||||||
|
|
||||||
|
// Subnet broadcast address for the soft-AP (192.168.4.1/24).
|
||||||
|
#define NET_AP_BCAST_IP "192.168.4.255"
|
||||||
|
|
||||||
|
// --- Node registry ----------------------------------------------------------
|
||||||
|
|
||||||
|
#define NET_MAX_NODES 10 // 10 nodes x 4 mics = 40 channels
|
||||||
|
|
||||||
|
// --- Discovery --------------------------------------------------------------
|
||||||
|
|
||||||
|
// How often to scan the AP station list for newly-connected clients and send
|
||||||
|
// them a unicast PROBE (ms).
|
||||||
|
#define NET_PROBE_SCAN_MS 1000
|
||||||
|
|
||||||
|
// How often to broadcast a PROBE to the whole subnet as a fallback so nodes
|
||||||
|
// that missed a unicast probe are still discovered (ms).
|
||||||
|
#define NET_BEACON_MS 10000
|
||||||
|
|
||||||
|
// A node that has not been heard from in this long is marked offline (ms).
|
||||||
|
#define NET_OFFLINE_MS 5000
|
||||||
|
|
||||||
|
// --- Data collection --------------------------------------------------------
|
||||||
|
|
||||||
|
// Round-robin: interval between sending POLL to consecutive nodes (ms).
|
||||||
|
#define NET_POLL_PERIOD_MS 200
|
||||||
|
|
||||||
|
// How long to wait for a polled node's DATA packets before moving on (ms).
|
||||||
|
#define NET_DATA_RX_TIMEOUT_MS 30
|
||||||
|
|
||||||
|
// Maximum UDP payload accepted for DATA packets (1472 keeps below the
|
||||||
|
// Ethernet MTU so no IP fragmentation occurs).
|
||||||
|
#define NET_MAX_UDP_PAYLOAD 1472
|
||||||
|
|
||||||
|
// Microphones per node. Fixed by the hardware design (4 mics x 10 nodes).
|
||||||
|
#define NET_MICS_PER_NODE 4
|
||||||
|
|
||||||
|
// Frames per interleave round. One round is ROUND_FRAMES x 40 ch x 2 B
|
||||||
|
// = 7680 bytes, sized to fit an 8 KB DataLogger chunk (STORAGE_LOG_CHUNK_SIZE).
|
||||||
|
// Enforced at compile time by a static_assert in the collector.
|
||||||
|
#define NET_ROUND_FRAMES 96
|
||||||
|
|
||||||
|
// Per-node staging buffer (bytes) used to reassemble interleave rounds.
|
||||||
|
// A round is only 1536 B/node, so 4 KB absorbs network burstiness without
|
||||||
|
// blowing the DRAM budget (10 nodes x 4 KB = 40 KB static).
|
||||||
|
#define NET_STAGE_BYTES 4096
|
||||||
|
|
||||||
|
// --- Node simulator ---------------------------------------------------------
|
||||||
|
|
||||||
|
// 1 = build the built-in NodeSimulator (virtual nodes generating sample
|
||||||
|
// audio). No node hardware or PC required to produce sample data.
|
||||||
|
#define NET_SIM_ENABLED 1
|
||||||
|
|
||||||
|
#define NET_SIM_NODES 10
|
||||||
|
|
||||||
|
#define NET_SIM_SAMPLE_RATE_HZ 48000
|
||||||
|
|
||||||
|
// --- Front panel (LED / button) --------------------------------------------
|
||||||
|
|
||||||
|
// Recording status LED. GPIO 12 is a strapping pin: wire it ACTIVE-HIGH
|
||||||
|
// (GPIO12 -> LED -> 220R -> GND) and keep it low/floating at boot.
|
||||||
|
#define NET_LED_GPIO 22
|
||||||
|
|
||||||
|
// Start/stop button. GPIO 34 is input-only with NO internal pull-up: wire a
|
||||||
|
// 10k pull-up to 3.3V and the button between the pin and GND (active-low).
|
||||||
|
#define NET_BUTTON_GPIO 34
|
||||||
|
|
||||||
|
// Button debounce window (ms).
|
||||||
|
#define NET_BUTTON_DEBOUNCE_MS 50
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hub <-> Node wire protocol (see docs/network_protocol.md).
|
||||||
|
//
|
||||||
|
// Self-contained: the node firmware project should be able to include this
|
||||||
|
// header as-is. All multi-byte values are little-endian. Packets are:
|
||||||
|
//
|
||||||
|
// [0] message type (1 byte)
|
||||||
|
// [1] protocol version (1 byte)
|
||||||
|
// [2..] payload
|
||||||
|
//
|
||||||
|
// Control plane lives on NET_CONTROL_PORT, audio data on NET_DATA_PORT.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
|
||||||
|
namespace nprot
|
||||||
|
{
|
||||||
|
|
||||||
|
constexpr uint8_t PROTOCOL_VERSION = 1;
|
||||||
|
|
||||||
|
// --- Message types ----------------------------------------------------------
|
||||||
|
|
||||||
|
enum MsgType : uint8_t
|
||||||
|
{
|
||||||
|
MSG_PROBE = 0x01, // Hub -> Node (unicast on connect, or beacon)
|
||||||
|
MSG_PROBE_RESP = 0x02, // Node -> Hub (status + capabilities)
|
||||||
|
MSG_START = 0x03, // Hub -> Node (broadcast: begin recording)
|
||||||
|
MSG_STOP = 0x04, // Hub -> Node (broadcast: stop recording)
|
||||||
|
MSG_HEARTBEAT = 0x05, // Node -> Hub (keep-alive + status)
|
||||||
|
MSG_POLL = 0x06, // Hub -> Node (unicast: send buffered audio)
|
||||||
|
MSG_POLL_ACK = 0x07, // Node -> Hub (response sizing, optional)
|
||||||
|
MSG_DATA = 0x10 // Node -> Hub (audio payload, data port)
|
||||||
|
};
|
||||||
|
|
||||||
|
enum NodeStatus : uint8_t
|
||||||
|
{
|
||||||
|
STATUS_IDLE = 0,
|
||||||
|
STATUS_RECORDING = 1,
|
||||||
|
STATUS_ERROR = 2
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Packet sizes -----------------------------------------------------------
|
||||||
|
|
||||||
|
constexpr size_t PROBE_RESP_PAYLOAD = 1 + 6 + 16 + 1 + 4 + 2 + 4 + 1; // 35
|
||||||
|
constexpr size_t HEARTBEAT_PAYLOAD = 1 + 4 + 4 + 1; // 10
|
||||||
|
constexpr size_t POLL_ACK_PAYLOAD = 1 + 4 + 4; // 9
|
||||||
|
constexpr size_t DATA_HEADER_PAYLOAD = 1 + 1 + 4; // 6
|
||||||
|
|
||||||
|
// --- Control plane builders (Hub side) --------------------------------------
|
||||||
|
|
||||||
|
// PROBE: [ver][flags] flags bit0 = recording
|
||||||
|
inline size_t buildProbe(uint8_t* buf, size_t cap, bool recording)
|
||||||
|
{
|
||||||
|
if (cap < 3) return 0;
|
||||||
|
buf[0] = MSG_PROBE;
|
||||||
|
buf[1] = PROTOCOL_VERSION;
|
||||||
|
buf[2] = recording ? 0x01 : 0x00;
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// START / STOP: [ver]
|
||||||
|
inline size_t buildStart(uint8_t* buf, size_t cap)
|
||||||
|
{
|
||||||
|
if (cap < 2) return 0;
|
||||||
|
buf[0] = MSG_START;
|
||||||
|
buf[1] = PROTOCOL_VERSION;
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline size_t buildStop(uint8_t* buf, size_t cap)
|
||||||
|
{
|
||||||
|
if (cap < 2) return 0;
|
||||||
|
buf[0] = MSG_STOP;
|
||||||
|
buf[1] = PROTOCOL_VERSION;
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POLL: [ver][nodeId]
|
||||||
|
inline size_t buildPoll(uint8_t* buf, size_t cap, uint8_t nodeId)
|
||||||
|
{
|
||||||
|
if (cap < 3) return 0;
|
||||||
|
buf[0] = MSG_POLL;
|
||||||
|
buf[1] = PROTOCOL_VERSION;
|
||||||
|
buf[2] = nodeId;
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Node side builders -----------------------------------------------------
|
||||||
|
|
||||||
|
struct ProbeResponse
|
||||||
|
{
|
||||||
|
uint8_t nodeId;
|
||||||
|
uint8_t mac[6];
|
||||||
|
char firmware[16];
|
||||||
|
uint8_t micCount;
|
||||||
|
uint32_t sampleRate;
|
||||||
|
uint16_t bitsPerSample;
|
||||||
|
uint32_t bufferBytes;
|
||||||
|
uint8_t status;
|
||||||
|
};
|
||||||
|
|
||||||
|
// PROBE_RESP: [ver][nodeId][mac(6)][firmware(16)][mics][rate(4)][bits(2)][buffer(4)][status]
|
||||||
|
inline size_t buildProbeResponse(uint8_t* buf, size_t cap, const ProbeResponse& r)
|
||||||
|
{
|
||||||
|
if (cap < PROBE_RESP_PAYLOAD + 1) return 0;
|
||||||
|
size_t o = 0;
|
||||||
|
buf[o++] = MSG_PROBE_RESP;
|
||||||
|
buf[o++] = PROTOCOL_VERSION;
|
||||||
|
buf[o++] = r.nodeId;
|
||||||
|
memcpy(buf + o, r.mac, 6); o += 6;
|
||||||
|
memcpy(buf + o, r.firmware, 16); o += 16;
|
||||||
|
buf[o++] = r.micCount;
|
||||||
|
buf[o++] = r.sampleRate & 0xFF; buf[o++] = (r.sampleRate >> 8) & 0xFF;
|
||||||
|
buf[o++] = (r.sampleRate >> 16) & 0xFF; buf[o++] = (r.sampleRate >> 24) & 0xFF;
|
||||||
|
buf[o++] = r.bitsPerSample & 0xFF; buf[o++] = (r.bitsPerSample >> 8) & 0xFF;
|
||||||
|
buf[o++] = r.bufferBytes & 0xFF; buf[o++] = (r.bufferBytes >> 8) & 0xFF;
|
||||||
|
buf[o++] = (r.bufferBytes >> 16) & 0xFF; buf[o++] = (r.bufferBytes >> 24) & 0xFF;
|
||||||
|
buf[o++] = r.status;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Heartbeat
|
||||||
|
{
|
||||||
|
uint8_t nodeId;
|
||||||
|
uint32_t uptimeMs;
|
||||||
|
uint32_t bufferFillBytes;
|
||||||
|
uint8_t status;
|
||||||
|
};
|
||||||
|
|
||||||
|
// HEARTBEAT: [ver][nodeId][uptime(4)][bufferFill(4)][status]
|
||||||
|
inline size_t buildHeartbeat(uint8_t* buf, size_t cap, const Heartbeat& h)
|
||||||
|
{
|
||||||
|
if (cap < HEARTBEAT_PAYLOAD + 1) return 0;
|
||||||
|
size_t o = 0;
|
||||||
|
buf[o++] = MSG_HEARTBEAT;
|
||||||
|
buf[o++] = PROTOCOL_VERSION;
|
||||||
|
buf[o++] = h.nodeId;
|
||||||
|
buf[o++] = h.uptimeMs & 0xFF; buf[o++] = (h.uptimeMs >> 8) & 0xFF;
|
||||||
|
buf[o++] = (h.uptimeMs >> 16) & 0xFF; buf[o++] = (h.uptimeMs >> 24) & 0xFF;
|
||||||
|
buf[o++] = h.bufferFillBytes & 0xFF; buf[o++] = (h.bufferFillBytes >> 8) & 0xFF;
|
||||||
|
buf[o++] = (h.bufferFillBytes >> 16) & 0xFF; buf[o++] = (h.bufferFillBytes >> 24) & 0xFF;
|
||||||
|
buf[o++] = h.status;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POLL_ACK: [ver][nodeId][seq(4)][framesReady(4)]
|
||||||
|
inline size_t buildPollAck(uint8_t* buf, size_t cap, uint8_t nodeId, uint32_t seq, uint32_t framesReady)
|
||||||
|
{
|
||||||
|
if (cap < POLL_ACK_PAYLOAD + 1) return 0;
|
||||||
|
size_t o = 0;
|
||||||
|
buf[o++] = MSG_POLL_ACK;
|
||||||
|
buf[o++] = PROTOCOL_VERSION;
|
||||||
|
buf[o++] = nodeId;
|
||||||
|
for (int i = 0; i < 4; i++) buf[o++] = (seq >> (8 * i)) & 0xFF;
|
||||||
|
for (int i = 0; i < 4; i++) buf[o++] = (framesReady >> (8 * i)) & 0xFF;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Data plane -------------------------------------------------------------
|
||||||
|
|
||||||
|
struct DataHeader
|
||||||
|
{
|
||||||
|
uint8_t nodeId;
|
||||||
|
uint8_t micCount;
|
||||||
|
uint32_t frameCount; // number of frames per mic in this packet
|
||||||
|
};
|
||||||
|
|
||||||
|
inline uint16_t dataFrameSize(uint8_t micCount)
|
||||||
|
{
|
||||||
|
return (uint16_t)micCount * 2; // 16-bit samples
|
||||||
|
}
|
||||||
|
|
||||||
|
// DATA: [ver][nodeId][mics][frameCount(4)] then PCM (frameCount * mics * 2 bytes)
|
||||||
|
inline size_t buildData(uint8_t* buf, size_t cap, const DataHeader& h, const void* pcm)
|
||||||
|
{
|
||||||
|
size_t payload = DATA_HEADER_PAYLOAD + (size_t)h.frameCount * dataFrameSize(h.micCount);
|
||||||
|
if (cap < payload + 1) return 0;
|
||||||
|
size_t o = 0;
|
||||||
|
buf[o++] = MSG_DATA;
|
||||||
|
buf[o++] = PROTOCOL_VERSION;
|
||||||
|
buf[o++] = h.nodeId;
|
||||||
|
buf[o++] = h.micCount;
|
||||||
|
for (int i = 0; i < 4; i++) buf[o++] = (h.frameCount >> (8 * i)) & 0xFF;
|
||||||
|
memcpy(buf + o, pcm, (size_t)h.frameCount * dataFrameSize(h.micCount));
|
||||||
|
return payload + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Parsers (Hub side) -----------------------------------------------------
|
||||||
|
|
||||||
|
inline uint32_t readU32(const uint8_t* b) { return b[0] | (b[1] << 8) | (b[2] << 16) | ((uint32_t)b[3] << 24); }
|
||||||
|
inline uint16_t readU16(const uint8_t* b) { return b[0] | (b[1] << 8); }
|
||||||
|
|
||||||
|
inline bool parseProbeResponse(const uint8_t* buf, size_t len, ProbeResponse& out)
|
||||||
|
{
|
||||||
|
if (len < PROBE_RESP_PAYLOAD + 1) return false;
|
||||||
|
if (buf[0] != MSG_PROBE_RESP || buf[1] != PROTOCOL_VERSION) return false;
|
||||||
|
size_t o = 2;
|
||||||
|
out.nodeId = buf[o++];
|
||||||
|
memcpy(out.mac, buf + o, 6); o += 6;
|
||||||
|
memcpy(out.firmware, buf + o, 16); o += 16;
|
||||||
|
out.micCount = buf[o++];
|
||||||
|
out.sampleRate = readU32(buf + o); o += 4;
|
||||||
|
out.bitsPerSample = readU16(buf + o); o += 2;
|
||||||
|
out.bufferBytes = readU32(buf + o); o += 4;
|
||||||
|
out.status = buf[o];
|
||||||
|
out.firmware[15] = '\0';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool parseHeartbeat(const uint8_t* buf, size_t len, Heartbeat& out)
|
||||||
|
{
|
||||||
|
if (len < HEARTBEAT_PAYLOAD + 1) return false;
|
||||||
|
if (buf[0] != MSG_HEARTBEAT || buf[1] != PROTOCOL_VERSION) return false;
|
||||||
|
size_t o = 2;
|
||||||
|
out.nodeId = buf[o++];
|
||||||
|
out.uptimeMs = readU32(buf + o); o += 4;
|
||||||
|
out.bufferFillBytes = readU32(buf + o); o += 4;
|
||||||
|
out.status = buf[o];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool parseDataHeader(const uint8_t* buf, size_t len, DataHeader& out)
|
||||||
|
{
|
||||||
|
if (len < DATA_HEADER_PAYLOAD + 1) return false;
|
||||||
|
if (buf[0] != MSG_DATA || buf[1] != PROTOCOL_VERSION) return false;
|
||||||
|
size_t o = 2;
|
||||||
|
out.nodeId = buf[o++];
|
||||||
|
out.micCount = buf[o++];
|
||||||
|
out.frameCount = readU32(buf + o);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace nprot
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
#include "node_simulator.h"
|
||||||
|
|
||||||
|
#include <math.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr float SIM_TWO_PI = 6.28318530718f;
|
||||||
|
|
||||||
|
// PCM frame size for one node (4 ch x 2 B), matches CollectionService.
|
||||||
|
constexpr size_t FRAME_BYTES = NET_MICS_PER_NODE * 2;
|
||||||
|
|
||||||
|
// Mic frequencies step by 30 Hz so each mic of a node is distinct.
|
||||||
|
constexpr float MIC_STEP_HZ = 30.0f;
|
||||||
|
|
||||||
|
// Amplitude ~0.25 FS; the DC marker per node is small and inaudible but easy
|
||||||
|
// to spot in a waveform editor when verifying channel mapping.
|
||||||
|
constexpr float AMPLITUDE = 8000.0f;
|
||||||
|
constexpr float DC_MARKER_STEP = 50.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
NodeSimulator::NodeSimulator(NodeRegistry& registry, RecordingController& recorder)
|
||||||
|
:
|
||||||
|
Service("Sim", 500),
|
||||||
|
nodes(registry),
|
||||||
|
recorder(recorder)
|
||||||
|
{
|
||||||
|
memset(lastCallMs, 0, sizeof(lastCallMs));
|
||||||
|
memset(phase, 0, sizeof(phase));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void NodeSimulator::begin()
|
||||||
|
{
|
||||||
|
uint32_t now = millis();
|
||||||
|
|
||||||
|
for (uint8_t i = 0; i < NET_SIM_NODES; i++)
|
||||||
|
{
|
||||||
|
nprot::ProbeResponse resp;
|
||||||
|
memset(&resp, 0, sizeof(resp));
|
||||||
|
|
||||||
|
resp.nodeId = i;
|
||||||
|
resp.mac[0] = 0x02;
|
||||||
|
resp.mac[5] = i;
|
||||||
|
memcpy(resp.firmware, "node-sim", 9);
|
||||||
|
resp.micCount = NET_MICS_PER_NODE;
|
||||||
|
resp.sampleRate = NET_SIM_SAMPLE_RATE_HZ;
|
||||||
|
resp.bitsPerSample = 16;
|
||||||
|
resp.bufferBytes = 64 * 1024;
|
||||||
|
resp.status = nprot::STATUS_IDLE;
|
||||||
|
|
||||||
|
IPAddress ip(192, 168, 4, 200 + i);
|
||||||
|
nodes.upsert(resp, ip, now);
|
||||||
|
lastCallMs[i] = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[Sim] %d virtual nodes registered\n", NET_SIM_NODES);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void NodeSimulator::update()
|
||||||
|
{
|
||||||
|
uint32_t now = millis();
|
||||||
|
|
||||||
|
// Keep the virtual nodes "online" so discovery/dashboard show them.
|
||||||
|
for (uint8_t i = 0; i < NET_SIM_NODES; i++)
|
||||||
|
{
|
||||||
|
nodes.touch(i, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
float NodeSimulator::baseFrequency(uint8_t nodeId)
|
||||||
|
{
|
||||||
|
return 440.0f + nodeId * 40.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
size_t NodeSimulator::readAudio(uint8_t nodeId, uint8_t* out, size_t maxBytes)
|
||||||
|
{
|
||||||
|
if (!recorder.isRecording() || nodeId >= NET_SIM_NODES)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t now = millis();
|
||||||
|
|
||||||
|
if (lastCallMs[nodeId] == 0)
|
||||||
|
{
|
||||||
|
lastCallMs[nodeId] = now;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t elapsed = now - lastCallMs[nodeId];
|
||||||
|
lastCallMs[nodeId] = now;
|
||||||
|
|
||||||
|
size_t maxFrames = maxBytes / FRAME_BYTES;
|
||||||
|
uint32_t frames = (uint32_t)(((uint64_t)elapsed * NET_SIM_SAMPLE_RATE_HZ) / 1000ULL);
|
||||||
|
|
||||||
|
if (frames > maxFrames)
|
||||||
|
{
|
||||||
|
frames = (uint32_t)maxFrames;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frames == 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
float base = baseFrequency(nodeId);
|
||||||
|
float dc = DC_MARKER_STEP * nodeId;
|
||||||
|
|
||||||
|
uint8_t* p = out;
|
||||||
|
|
||||||
|
for (uint32_t f = 0; f < frames; f++)
|
||||||
|
{
|
||||||
|
for (uint8_t mic = 0; mic < NET_MICS_PER_NODE; mic++)
|
||||||
|
{
|
||||||
|
float& ph = phase[nodeId][mic];
|
||||||
|
float freq = base + MIC_STEP_HZ * mic;
|
||||||
|
|
||||||
|
ph += SIM_TWO_PI * freq / (float)NET_SIM_SAMPLE_RATE_HZ;
|
||||||
|
if (ph >= SIM_TWO_PI)
|
||||||
|
{
|
||||||
|
ph -= SIM_TWO_PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
int16_t sample = (int16_t)(AMPLITUDE * sinf(ph) + dc);
|
||||||
|
|
||||||
|
p[0] = (uint8_t)(sample & 0xFF);
|
||||||
|
p[1] = (uint8_t)((sample >> 8) & 0xFF);
|
||||||
|
p += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return frames * FRAME_BYTES;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// NodeSimulator - virtual nodes that generate sample audio in software.
|
||||||
|
//
|
||||||
|
// Implements NodeSource so the CollectionService feeds simulated PCM through
|
||||||
|
// the exact same path as real UDP nodes. Each virtual node emits a distinct
|
||||||
|
// sine tone per mic plus a small DC marker per node, so channel -> track
|
||||||
|
// mapping is easy to verify in the WAV on a PC.
|
||||||
|
//
|
||||||
|
// Enable/disable with NET_SIM_ENABLED (network_config.h). Use the simulator
|
||||||
|
// OR real nodes, not both (node IDs would collide).
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#include "../services/service.h"
|
||||||
|
#include "../core/node_registry.h"
|
||||||
|
#include "collection_service.h"
|
||||||
|
#include "network_config.h"
|
||||||
|
|
||||||
|
|
||||||
|
class NodeSimulator : public Service, public NodeSource
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
NodeSimulator(NodeRegistry& registry, RecordingController& recorder);
|
||||||
|
|
||||||
|
void begin() override;
|
||||||
|
void update() override;
|
||||||
|
|
||||||
|
// NodeSource
|
||||||
|
size_t readAudio(uint8_t nodeId, uint8_t* out, size_t maxBytes) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
static float baseFrequency(uint8_t nodeId);
|
||||||
|
|
||||||
|
NodeRegistry& nodes;
|
||||||
|
RecordingController& recorder;
|
||||||
|
|
||||||
|
uint32_t lastCallMs[NET_SIM_NODES];
|
||||||
|
float phase[NET_SIM_NODES][NET_MICS_PER_NODE];
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#include "front_panel_service.h"
|
||||||
|
|
||||||
|
|
||||||
|
FrontPanelService::FrontPanelService(RecordingController& recorder)
|
||||||
|
:
|
||||||
|
Service("Panel", 20),
|
||||||
|
recorder(recorder),
|
||||||
|
lastBlinkMs(0),
|
||||||
|
ledOn(false),
|
||||||
|
buttonRaw(false),
|
||||||
|
buttonDebounced(false),
|
||||||
|
buttonChangeMs(0)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void FrontPanelService::begin()
|
||||||
|
{
|
||||||
|
pinMode(NET_LED_GPIO, OUTPUT);
|
||||||
|
digitalWrite(NET_LED_GPIO, LOW);
|
||||||
|
|
||||||
|
// GPIO34 is input-only; the external 10k pull-up is required.
|
||||||
|
pinMode(NET_BUTTON_GPIO, INPUT);
|
||||||
|
|
||||||
|
buttonRaw = (digitalRead(NET_BUTTON_GPIO) == LOW);
|
||||||
|
buttonDebounced = buttonRaw;
|
||||||
|
|
||||||
|
Serial.printf("[Panel] LED on GPIO%d, button on GPIO%d\n",
|
||||||
|
NET_LED_GPIO, NET_BUTTON_GPIO);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void FrontPanelService::update()
|
||||||
|
{
|
||||||
|
uint32_t now = millis();
|
||||||
|
|
||||||
|
updateLed(now);
|
||||||
|
updateButton(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void FrontPanelService::updateLed(uint32_t nowMs)
|
||||||
|
{
|
||||||
|
if (recorder.isRecording())
|
||||||
|
{
|
||||||
|
digitalWrite(NET_LED_GPIO, HIGH); // solid
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 Hz blink while idle (500 ms on / 500 ms off).
|
||||||
|
if (nowMs - lastBlinkMs >= 500)
|
||||||
|
{
|
||||||
|
lastBlinkMs = nowMs;
|
||||||
|
ledOn = !ledOn;
|
||||||
|
digitalWrite(NET_LED_GPIO, ledOn ? HIGH : LOW);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void FrontPanelService::updateButton(uint32_t nowMs)
|
||||||
|
{
|
||||||
|
bool raw = (digitalRead(NET_BUTTON_GPIO) == LOW);
|
||||||
|
|
||||||
|
if (raw != buttonRaw)
|
||||||
|
{
|
||||||
|
buttonRaw = raw;
|
||||||
|
buttonChangeMs = nowMs;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nowMs - buttonChangeMs < NET_BUTTON_DEBOUNCE_MS)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw != buttonDebounced)
|
||||||
|
{
|
||||||
|
buttonDebounced = raw;
|
||||||
|
|
||||||
|
if (buttonDebounced)
|
||||||
|
{
|
||||||
|
// Pressed edge: toggle recording.
|
||||||
|
if (recorder.isRecording())
|
||||||
|
{
|
||||||
|
recorder.requestStop();
|
||||||
|
Serial.println("[Panel] Button: STOP requested");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
recorder.requestStart();
|
||||||
|
Serial.println("[Panel] Button: START requested");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FrontPanelService - user-facing start/stop + status LED.
|
||||||
|
//
|
||||||
|
// GPIO12 LED: blinks at 1 Hz while idle, solid while recording. Active-high
|
||||||
|
// (GPIO12 -> LED -> 220R -> GND; the pin is a strapping pin, keep it low).
|
||||||
|
//
|
||||||
|
// GPIO34 button: debounced, active-low (10k pull-up to 3.3V, button to GND).
|
||||||
|
// Toggles recording start/stop via the RecordingController.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#include "../services/service.h"
|
||||||
|
#include "../core/node_registry.h"
|
||||||
|
#include "../network/network_config.h"
|
||||||
|
|
||||||
|
|
||||||
|
class FrontPanelService : public Service
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
FrontPanelService(RecordingController& recorder);
|
||||||
|
|
||||||
|
void begin() override;
|
||||||
|
void update() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void updateLed(uint32_t nowMs);
|
||||||
|
void updateButton(uint32_t nowMs);
|
||||||
|
|
||||||
|
RecordingController& recorder;
|
||||||
|
|
||||||
|
uint32_t lastBlinkMs;
|
||||||
|
bool ledOn;
|
||||||
|
|
||||||
|
bool buttonRaw;
|
||||||
|
bool buttonDebounced;
|
||||||
|
uint32_t buttonChangeMs;
|
||||||
|
};
|
||||||
+180
-16
@@ -1,10 +1,14 @@
|
|||||||
#include "web_service.h"
|
#include "web_service.h"
|
||||||
|
|
||||||
WebService::WebService(DashboardState& state, DiagnosticsState& diag_state)
|
WebService::WebService(DashboardState& state, DiagnosticsState& diag_state, StorageState& storage_state,
|
||||||
|
NodeRegistry& nodes, RecordingController& recorder)
|
||||||
:
|
:
|
||||||
Service("Web", 10),
|
Service("Web", 10),
|
||||||
dashboardState(state),
|
dashboardState(state),
|
||||||
diagnosticsState(diag_state),
|
diagnosticsState(diag_state),
|
||||||
|
storageState(storage_state),
|
||||||
|
nodes(nodes),
|
||||||
|
recorder(recorder),
|
||||||
server(80),
|
server(80),
|
||||||
webSocket(81)
|
webSocket(81)
|
||||||
{
|
{
|
||||||
@@ -64,6 +68,11 @@ h1 {
|
|||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#sd_status {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -82,11 +91,14 @@ function connectWebSocket() {
|
|||||||
socket.onmessage = function(event) {
|
socket.onmessage = function(event) {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
document.getElementById("uptime").innerHTML = data.system.uptime;
|
document.getElementById("uptime").innerHTML = "Uptime: " + data.system.uptime;
|
||||||
document.getElementById("firmware_version").innerHTML = data.system.version;
|
document.getElementById("firmware_version").innerHTML = "Firmware Version: " + data.system.version;
|
||||||
document.getElementById("free_heap").innerHTML = data.diagnostics.free_heap;
|
document.getElementById("free_heap").innerHTML = "Free Heap: " + data.diagnostics.free_heap;
|
||||||
document.getElementById("minimum_free_heap").innerHTML = data.diagnostics.minimum_free_heap;
|
document.getElementById("minimum_free_heap").innerHTML = "Minimum Free Heap: " + data.diagnostics.minimum_free_heap;
|
||||||
document.getElementById("cpu_frequency").innerHTML = data.diagnostics.cpu_frequency;
|
document.getElementById("cpu_frequency").innerHTML = "CPU Frequency: " + data.diagnostics.cpu_frequency + "MHz";
|
||||||
|
|
||||||
|
updateStorage(data.storage);
|
||||||
|
updateNetwork(data.network);
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.onclose = function() {
|
socket.onclose = function() {
|
||||||
@@ -95,8 +107,60 @@ function connectWebSocket() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytesMB(mb) {
|
||||||
|
const value = Number(mb);
|
||||||
|
if (value >= 1024) {
|
||||||
|
return (value / 1024).toFixed(2) + " GB";
|
||||||
|
}
|
||||||
|
return value.toFixed(0) + " MB";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStorage(storage) {
|
||||||
|
const sdStatus = document.getElementById("sd_status");
|
||||||
|
|
||||||
|
if (storage && storage.mounted === "true") {
|
||||||
|
sdStatus.innerHTML = "SD card found";
|
||||||
|
sdStatus.style.color = "#00ff99";
|
||||||
|
|
||||||
|
document.getElementById("sd_type").innerHTML = "Card Type: " + storage.card_type;
|
||||||
|
document.getElementById("sd_free").innerHTML = "Space left: " +
|
||||||
|
formatBytesMB(storage.free_mb) + " free of " + formatBytesMB(storage.total_mb) +
|
||||||
|
" (" + Math.round(storage.used_mb / storage.total_mb * 100) + "% used)";
|
||||||
|
document.getElementById("sd_speed").innerHTML = "Estimated write speed: " +
|
||||||
|
(storage.write_speed_bps / 1048576).toFixed(2) + " MB/s";
|
||||||
|
} else {
|
||||||
|
sdStatus.innerHTML = "SD not found";
|
||||||
|
sdStatus.style.color = "red";
|
||||||
|
|
||||||
|
document.getElementById("sd_type").innerHTML = "-";
|
||||||
|
document.getElementById("sd_free").innerHTML = "-";
|
||||||
|
document.getElementById("sd_speed").innerHTML = "-";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
window.onload = connectWebSocket;
|
window.onload = connectWebSocket;
|
||||||
|
|
||||||
|
function updateNetwork(network) {
|
||||||
|
if (!network) return;
|
||||||
|
|
||||||
|
document.getElementById("rec_status").innerHTML =
|
||||||
|
network.recording === "true" ? "Recording: ACTIVE" : "Recording: idle";
|
||||||
|
document.getElementById("rec_status").style.color =
|
||||||
|
network.recording === "true" ? "#00ff99" : "#ffcc00";
|
||||||
|
|
||||||
|
document.getElementById("node_count").innerHTML =
|
||||||
|
"Connected: " + network.online_count + " / " + network.node_count;
|
||||||
|
|
||||||
|
let html = "";
|
||||||
|
for (const n of network.nodes) {
|
||||||
|
const color = n.online === "true" ? "#00ff99" : "#ff5555";
|
||||||
|
html += '<div style="color:' + color + '">Node ' + n.id +
|
||||||
|
" @ " + n.ip + " (" + n.mics + " mics)" +
|
||||||
|
(n.online === "true" ? " online" : " OFFLINE") + "</div>";
|
||||||
|
}
|
||||||
|
document.getElementById("node_list").innerHTML = html || "(no nodes)";
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
@@ -104,23 +168,34 @@ window.onload = connectWebSocket;
|
|||||||
<body>
|
<body>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>ESP32 Dashboard OTA</h1>
|
<h1>ESP32 Dashboard OTA</h1>
|
||||||
<p>Device uptime:</p>
|
<div id="uptime">Device uptime: Loading...</div>
|
||||||
<div id="uptime">Loading...</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>Hub Diagnostics:</h1>
|
<h1>Hub Diagnostics:</h1>
|
||||||
<p>Free heap:</p>
|
<div id=free_heap>Free heap: Loading...</div>
|
||||||
<div id=free_heap>Loading...</div>
|
<div id=minimum_free_heap>Minimum free heap: Loading...</div>
|
||||||
<p>Minimum free heap:</p>
|
<div id=cpu_frequency>CPU Frequency: Loading...</div>
|
||||||
<div id=minimum_free_heap>Loading...</div>
|
</div>
|
||||||
<p>CPU Frequency:</p>
|
|
||||||
<div id=cpu_frequency>Loading...</div>
|
<div class="card">
|
||||||
|
<h1>SD Storage:</h1>
|
||||||
|
<div id=sd_status>Status: Loading...</div>
|
||||||
|
<div id=sd_type>Card type: Loading...</div>
|
||||||
|
<div id=sd_free>Space left: Loading...</div>
|
||||||
|
<div id=sd_speed>Estimated write speed: Loading...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h1>Network:</h1>
|
||||||
|
<div id=rec_status>Recording: Loading...</div>
|
||||||
|
<div id=node_count>Connected: Loading...</div>
|
||||||
|
<div id=node_list>(no nodes)</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
<footer>
|
<footer>
|
||||||
Firmware Version: <div id="firmware_version">Loading...</div>
|
<div id="firmware_version">Firmware Version: Loading...</div>
|
||||||
</footer>
|
</footer>
|
||||||
</html>
|
</html>
|
||||||
)rawliteral";
|
)rawliteral";
|
||||||
@@ -128,6 +203,7 @@ Firmware Version: <div id="firmware_version">Loading...</div>
|
|||||||
void WebService::broadcastState()
|
void WebService::broadcastState()
|
||||||
{
|
{
|
||||||
DiagnosticSample diagnostics = diagnosticsState.getCurrent();
|
DiagnosticSample diagnostics = diagnosticsState.getCurrent();
|
||||||
|
StorageSnapshot storage = storageState.getCurrent();
|
||||||
String json = "{";
|
String json = "{";
|
||||||
|
|
||||||
// Opening system tag:
|
// Opening system tag:
|
||||||
@@ -148,6 +224,7 @@ void WebService::broadcastState()
|
|||||||
|
|
||||||
json += "\"free_heap\":\"";
|
json += "\"free_heap\":\"";
|
||||||
json += diagnostics.freeHeap;
|
json += diagnostics.freeHeap;
|
||||||
|
//json += ESP.getFreeHeap();
|
||||||
json += "\",";
|
json += "\",";
|
||||||
|
|
||||||
json += "\"minimum_free_heap\":\"";
|
json += "\"minimum_free_heap\":\"";
|
||||||
@@ -160,12 +237,89 @@ void WebService::broadcastState()
|
|||||||
|
|
||||||
json += "}"; // Clost diagnostic tag
|
json += "}"; // Clost diagnostic tag
|
||||||
|
|
||||||
|
// Opening storage tag:
|
||||||
|
json += ",\"storage\":{";
|
||||||
|
|
||||||
|
json += "\"mounted\":\"";
|
||||||
|
json += storage.mounted ? "true" : "false";
|
||||||
|
json += "\",";
|
||||||
|
|
||||||
|
json += "\"card_type\":\"";
|
||||||
|
json += storage.cardType;
|
||||||
|
json += "\",";
|
||||||
|
|
||||||
|
json += "\"total_mb\":\"";
|
||||||
|
json += storage.totalMB;
|
||||||
|
json += "\",";
|
||||||
|
|
||||||
|
json += "\"free_mb\":\"";
|
||||||
|
json += storage.freeMB;
|
||||||
|
json += "\",";
|
||||||
|
|
||||||
|
json += "\"used_mb\":\"";
|
||||||
|
json += storage.usedMB;
|
||||||
|
json += "\",";
|
||||||
|
|
||||||
|
json += "\"write_speed_bps\":\"";
|
||||||
|
json += storage.writeSpeedBps;
|
||||||
|
json += "\"";
|
||||||
|
|
||||||
|
json += "}"; // Close storage tag
|
||||||
|
|
||||||
|
// Opening network tag:
|
||||||
|
json += ",\"network\":{";
|
||||||
|
|
||||||
|
json += "\"recording\":\"";
|
||||||
|
json += recorder.isRecording() ? "true" : "false";
|
||||||
|
json += "\",";
|
||||||
|
|
||||||
|
json += "\"node_count\":\"";
|
||||||
|
json += nodes.count();
|
||||||
|
json += "\",";
|
||||||
|
|
||||||
|
json += "\"online_count\":\"";
|
||||||
|
json += nodes.onlineCount();
|
||||||
|
json += "\",";
|
||||||
|
|
||||||
|
json += "\"nodes\":[";
|
||||||
|
|
||||||
|
bool first = true;
|
||||||
|
for (int i = 0; i < NET_MAX_NODES; i++)
|
||||||
|
{
|
||||||
|
NodeInfo node = nodes.get(i);
|
||||||
|
|
||||||
|
if (!node.present)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!first)
|
||||||
|
{
|
||||||
|
json += ",";
|
||||||
|
}
|
||||||
|
first = false;
|
||||||
|
|
||||||
|
json += "{\"id\":\"";
|
||||||
|
json += node.nodeId;
|
||||||
|
json += "\",\"ip\":\"";
|
||||||
|
json += node.ip.toString();
|
||||||
|
json += "\",\"mics\":\"";
|
||||||
|
json += node.micCount;
|
||||||
|
json += "\",\"online\":\"";
|
||||||
|
json += node.online ? "true" : "false";
|
||||||
|
json += "\"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
json += "]";
|
||||||
|
|
||||||
|
json += "}"; // Close network tag
|
||||||
|
|
||||||
|
|
||||||
// Final close bracket
|
// Final close bracket
|
||||||
json += "}";
|
json += "}";
|
||||||
|
|
||||||
|
|
||||||
Serial.println(json);
|
//Serial.println(json);
|
||||||
webSocket.broadcastTXT(json);
|
webSocket.broadcastTXT(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +333,16 @@ void WebService::begin() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
#ifdef DEBUGGING
|
||||||
|
// Prints out paths that are requested but not found.
|
||||||
|
server.onNotFound([this]() {
|
||||||
|
Serial.print("HTTP not found: ");
|
||||||
|
Serial.println(server.uri());
|
||||||
|
|
||||||
|
server.send(404, "text/plain", "Not found");
|
||||||
|
});
|
||||||
|
#endif
|
||||||
|
|
||||||
server.begin();
|
server.begin();
|
||||||
|
|
||||||
webSocket.begin();
|
webSocket.begin();
|
||||||
|
|||||||
@@ -7,12 +7,15 @@
|
|||||||
|
|
||||||
#include "../core/dashboard_state.h"
|
#include "../core/dashboard_state.h"
|
||||||
#include "../core/diagnostics_state.h"
|
#include "../core/diagnostics_state.h"
|
||||||
|
#include "../core/storage_state.h"
|
||||||
|
#include "../core/node_registry.h"
|
||||||
|
|
||||||
class WebService : public Service {
|
class WebService : public Service {
|
||||||
|
|
||||||
public:
|
public:
|
||||||
|
|
||||||
WebService(DashboardState& state, DiagnosticsState& diag_state);
|
WebService(DashboardState& state, DiagnosticsState& diag_state, StorageState& storage_state,
|
||||||
|
NodeRegistry& nodes, RecordingController& recorder);
|
||||||
|
|
||||||
void begin() override;
|
void begin() override;
|
||||||
void update() override;
|
void update() override;
|
||||||
@@ -22,8 +25,11 @@ private:
|
|||||||
WebServer server;
|
WebServer server;
|
||||||
WebSocketsServer webSocket;
|
WebSocketsServer webSocket;
|
||||||
|
|
||||||
DashboardState dashboardState;
|
DashboardState& dashboardState;
|
||||||
DiagnosticsState diagnosticsState;
|
DiagnosticsState& diagnosticsState;
|
||||||
|
StorageState& storageState;
|
||||||
|
NodeRegistry& nodes;
|
||||||
|
RecordingController& recorder;
|
||||||
|
|
||||||
void handleWebSocketMessage(uint8_t clientNum, uint8_t *payload, size_t length);
|
void handleWebSocketMessage(uint8_t clientNum, uint8_t *payload, size_t length);
|
||||||
void broadcastState();
|
void broadcastState();
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
#include "data_logger.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include <esp_heap_caps.h>
|
||||||
|
|
||||||
|
|
||||||
|
// --- WAV metadata helpers ----------------------------------------------------
|
||||||
|
|
||||||
|
void buildWavHeader(WavHeader& header,
|
||||||
|
uint16_t numChannels,
|
||||||
|
uint32_t sampleRate,
|
||||||
|
uint16_t bitsPerSample,
|
||||||
|
uint32_t dataSize)
|
||||||
|
{
|
||||||
|
uint16_t blockAlign = numChannels * (bitsPerSample / 8);
|
||||||
|
|
||||||
|
memset(&header, 0, sizeof(header));
|
||||||
|
|
||||||
|
memcpy(header.riff, "RIFF", 4);
|
||||||
|
header.riffSize = sizeof(header) + dataSize - 8;
|
||||||
|
memcpy(header.wave, "WAVE", 4);
|
||||||
|
|
||||||
|
memcpy(header.fmt, "fmt ", 4);
|
||||||
|
header.fmtChunkSize = 16;
|
||||||
|
header.audioFormat = 1; // PCM
|
||||||
|
header.numChannels = numChannels;
|
||||||
|
header.sampleRate = sampleRate;
|
||||||
|
header.byteRate = sampleRate * blockAlign;
|
||||||
|
header.blockAlign = blockAlign;
|
||||||
|
header.bitsPerSample = bitsPerSample;
|
||||||
|
|
||||||
|
memcpy(header.data, "data", 4);
|
||||||
|
header.dataSize = dataSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void finalizeWavHeader(uint32_t fileSize,
|
||||||
|
uint32_t& riffSizeOut,
|
||||||
|
uint32_t& dataSizeOut)
|
||||||
|
{
|
||||||
|
riffSizeOut = fileSize - 8;
|
||||||
|
dataSizeOut = fileSize - sizeof(WavHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- DataLogger ---------------------------------------------------------------
|
||||||
|
|
||||||
|
DataLogger::DataLogger()
|
||||||
|
:
|
||||||
|
files(nullptr),
|
||||||
|
poolData(nullptr),
|
||||||
|
freeQ(nullptr),
|
||||||
|
filledQ(nullptr),
|
||||||
|
sessionSeq(0),
|
||||||
|
bytesThisFile(0),
|
||||||
|
totalBytes(0),
|
||||||
|
totalChunks(0),
|
||||||
|
totalDropped(0),
|
||||||
|
lastDropPrintMs(0),
|
||||||
|
windowBytes(0),
|
||||||
|
windowStartMs(0),
|
||||||
|
bps(0),
|
||||||
|
active(false),
|
||||||
|
warning(false)
|
||||||
|
{
|
||||||
|
memset(chunks, 0, sizeof(chunks));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool DataLogger::begin(fs::FS& files)
|
||||||
|
{
|
||||||
|
this->files = &files;
|
||||||
|
|
||||||
|
// One contiguous DMA-capable block, sliced into STORAGE_LOG_POOL_SIZE
|
||||||
|
// chunks so the SDMMC IDMA engine can read each chunk directly.
|
||||||
|
poolData = (uint8_t*)heap_caps_malloc(
|
||||||
|
STORAGE_LOG_POOL_SIZE * STORAGE_LOG_CHUNK_SIZE,
|
||||||
|
MALLOC_CAP_DMA);
|
||||||
|
|
||||||
|
Serial.print("Allocating: ");
|
||||||
|
Serial.print(STORAGE_LOG_POOL_SIZE * STORAGE_LOG_CHUNK_SIZE);
|
||||||
|
Serial.println(" bytes of data");
|
||||||
|
if (poolData == nullptr)
|
||||||
|
{
|
||||||
|
Serial.println("[Logger] Failed to allocate DMA chunk pool");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < STORAGE_LOG_POOL_SIZE; i++)
|
||||||
|
{
|
||||||
|
chunks[i].data = poolData + (uint32_t)i * STORAGE_LOG_CHUNK_SIZE;
|
||||||
|
chunks[i].capacity = STORAGE_LOG_CHUNK_SIZE;
|
||||||
|
chunks[i].length = 0;
|
||||||
|
chunks[i].sequence = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
freeQ = xQueueCreate(STORAGE_LOG_POOL_SIZE, sizeof(AudioChunk*));
|
||||||
|
filledQ = xQueueCreate(STORAGE_LOG_POOL_SIZE, sizeof(AudioChunk*));
|
||||||
|
|
||||||
|
if (freeQ == nullptr || filledQ == nullptr)
|
||||||
|
{
|
||||||
|
Serial.println("[Logger] Failed to create chunk queues");
|
||||||
|
heap_caps_free(poolData);
|
||||||
|
poolData = nullptr;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < STORAGE_LOG_POOL_SIZE; i++)
|
||||||
|
{
|
||||||
|
AudioChunk* c = &chunks[i];
|
||||||
|
xQueueSend(freeQ, &c, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
files.mkdir(STORAGE_LOG_DIR);
|
||||||
|
|
||||||
|
pinMode(STORAGE_WARN_LED_GPIO, OUTPUT);
|
||||||
|
digitalWrite(STORAGE_WARN_LED_GPIO,
|
||||||
|
STORAGE_WARN_LED_ACTIVE_HIGH ? LOW : HIGH);
|
||||||
|
|
||||||
|
Serial.printf("[Logger] Pool ready: %d chunks x %u bytes\n",
|
||||||
|
STORAGE_LOG_POOL_SIZE, STORAGE_LOG_CHUNK_SIZE);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void DataLogger::end()
|
||||||
|
{
|
||||||
|
closeSession();
|
||||||
|
|
||||||
|
if (freeQ) { vQueueDelete(freeQ); freeQ = nullptr; }
|
||||||
|
if (filledQ) { vQueueDelete(filledQ); filledQ = nullptr; }
|
||||||
|
|
||||||
|
if (poolData)
|
||||||
|
{
|
||||||
|
heap_caps_free(poolData);
|
||||||
|
poolData = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
active = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool DataLogger::openSession()
|
||||||
|
{
|
||||||
|
if (files == nullptr)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a fresh name, skipping any that already exist so we never
|
||||||
|
// truncate an earlier recording from a same-second boot.
|
||||||
|
char path[sizeof(filePath)];
|
||||||
|
do
|
||||||
|
{
|
||||||
|
snprintf(path, sizeof(path), "%s/rec_%lu_%lu.wav",
|
||||||
|
STORAGE_LOG_DIR,
|
||||||
|
(unsigned long)(millis() / 1000),
|
||||||
|
(unsigned long)sessionSeq++);
|
||||||
|
}
|
||||||
|
while (files->exists(path));
|
||||||
|
|
||||||
|
File f = files->open(path, FILE_WRITE);
|
||||||
|
if (!f)
|
||||||
|
{
|
||||||
|
Serial.println("[Logger] Failed to open session file (card full?)");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
WavHeader header;
|
||||||
|
buildWavHeader(header,
|
||||||
|
STORAGE_AUDIO_CHANNELS,
|
||||||
|
STORAGE_AUDIO_SAMPLE_RATE_HZ,
|
||||||
|
STORAGE_AUDIO_BITS,
|
||||||
|
0);
|
||||||
|
|
||||||
|
if (f.write((const uint8_t*)&header, sizeof(header)) != sizeof(header))
|
||||||
|
{
|
||||||
|
f.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
strncpy(filePath, path, sizeof(filePath));
|
||||||
|
filePath[sizeof(filePath) - 1] = '\0';
|
||||||
|
|
||||||
|
file = f;
|
||||||
|
active = true;
|
||||||
|
bytesThisFile = sizeof(header);
|
||||||
|
|
||||||
|
Serial.printf("[Logger] Session open: %s\n", filePath);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool DataLogger::closeSession()
|
||||||
|
{
|
||||||
|
if (!active || !file)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch the RIFF/data sizes so the file is valid even if the run was
|
||||||
|
// short. On unclean power-off the stale sizes are ignored by EOF-reading
|
||||||
|
// decoders (see docs/audio_logging.md).
|
||||||
|
uint32_t riffSize, dataSize;
|
||||||
|
finalizeWavHeader(file.size(), riffSize, dataSize);
|
||||||
|
|
||||||
|
file.seek(4);
|
||||||
|
file.write((const uint8_t*)&riffSize, 4);
|
||||||
|
file.seek(40);
|
||||||
|
file.write((const uint8_t*)&dataSize, 4);
|
||||||
|
|
||||||
|
file.flush();
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
active = false;
|
||||||
|
|
||||||
|
Serial.printf("[Logger] Session closed: %s (%llu bytes)\n",
|
||||||
|
filePath, (unsigned long long)bytesThisFile);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool DataLogger::rotateIfNeeded()
|
||||||
|
{
|
||||||
|
if (active && bytesThisFile >= STORAGE_LOG_ROTATE_BYTES)
|
||||||
|
{
|
||||||
|
closeSession();
|
||||||
|
return openSession();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool DataLogger::hasSession() const
|
||||||
|
{
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool DataLogger::isEmpty() const
|
||||||
|
{
|
||||||
|
return filledQ == nullptr || uxQueueMessagesWaiting(filledQ) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
AudioChunk* DataLogger::acquireChunk(TickType_t timeout)
|
||||||
|
{
|
||||||
|
if (freeQ == nullptr)
|
||||||
|
{
|
||||||
|
// Logger pool not created yet (recording pressed during boot).
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioChunk* c = nullptr;
|
||||||
|
|
||||||
|
if (xQueueReceive(freeQ, &c, timeout) != pdTRUE)
|
||||||
|
{
|
||||||
|
// Pool exhausted: drop the oldest queued chunk and hand its buffer to
|
||||||
|
// the producer so recording never stalls.
|
||||||
|
c = dropOldestChunk();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c != nullptr)
|
||||||
|
{
|
||||||
|
c->length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void DataLogger::submitChunk(AudioChunk* chunk)
|
||||||
|
{
|
||||||
|
if (chunk == nullptr || filledQ == nullptr) return;
|
||||||
|
xQueueSend(filledQ, &chunk, portMAX_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
AudioChunk* DataLogger::nextChunk(TickType_t timeout)
|
||||||
|
{
|
||||||
|
if (filledQ == nullptr)
|
||||||
|
{
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioChunk* c = nullptr;
|
||||||
|
xQueueReceive(filledQ, &c, timeout);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool DataLogger::writeChunk(AudioChunk* chunk)
|
||||||
|
{
|
||||||
|
if (chunk == nullptr || chunk->length == 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!active && !openSession())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t n = file.write(chunk->data, chunk->length);
|
||||||
|
|
||||||
|
if (n != chunk->length)
|
||||||
|
{
|
||||||
|
Serial.println("[Logger] Card write FAILED (partial write)");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bytesThisFile += n;
|
||||||
|
totalBytes += n;
|
||||||
|
totalChunks++;
|
||||||
|
|
||||||
|
updateBpsWindow(n, millis());
|
||||||
|
|
||||||
|
// Periodic f_sync so an unclean power-off loses at most this window.
|
||||||
|
if (bytesThisFile - sizeof(WavHeader) >= STORAGE_LOG_FLUSH_BYTES &&
|
||||||
|
(bytesThisFile - sizeof(WavHeader)) % STORAGE_LOG_FLUSH_BYTES < n)
|
||||||
|
{
|
||||||
|
file.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void DataLogger::releaseChunk(AudioChunk* chunk)
|
||||||
|
{
|
||||||
|
if (chunk == nullptr || freeQ == nullptr) return;
|
||||||
|
|
||||||
|
chunk->length = 0;
|
||||||
|
|
||||||
|
if (warning && uxQueueSpacesAvailable(freeQ) >= STORAGE_LOG_POOL_SIZE / 2)
|
||||||
|
{
|
||||||
|
setOverflowWarning(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
xQueueSend(freeQ, &chunk, portMAX_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
AudioChunk* DataLogger::dropOldestChunk()
|
||||||
|
{
|
||||||
|
AudioChunk* c = nullptr;
|
||||||
|
|
||||||
|
if (filledQ == nullptr)
|
||||||
|
{
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (xQueueReceive(filledQ, &c, 0) == pdTRUE)
|
||||||
|
{
|
||||||
|
totalDropped++;
|
||||||
|
|
||||||
|
uint32_t now = millis();
|
||||||
|
if (now - lastDropPrintMs >= 5000)
|
||||||
|
{
|
||||||
|
lastDropPrintMs = now;
|
||||||
|
Serial.printf("[Logger] WARNING: dropping oldest chunk "
|
||||||
|
"(SD behind), %lu dropped total\n",
|
||||||
|
(unsigned long)totalDropped);
|
||||||
|
}
|
||||||
|
|
||||||
|
setOverflowWarning(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void DataLogger::setOverflowWarning(bool overflowing)
|
||||||
|
{
|
||||||
|
if (overflowing == warning)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
warning = overflowing;
|
||||||
|
|
||||||
|
int level = overflowing
|
||||||
|
? (STORAGE_WARN_LED_ACTIVE_HIGH ? HIGH : LOW)
|
||||||
|
: (STORAGE_WARN_LED_ACTIVE_HIGH ? LOW : HIGH);
|
||||||
|
|
||||||
|
digitalWrite(STORAGE_WARN_LED_GPIO, level);
|
||||||
|
|
||||||
|
if (overflowing)
|
||||||
|
{
|
||||||
|
Serial.println("[Logger] Overflow: SD cannot keep up");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void DataLogger::updateBpsWindow(uint32_t bytes, uint32_t nowMs)
|
||||||
|
{
|
||||||
|
windowBytes += bytes;
|
||||||
|
|
||||||
|
uint32_t elapsed = nowMs - windowStartMs;
|
||||||
|
|
||||||
|
if (elapsed >= 1000)
|
||||||
|
{
|
||||||
|
bps = (uint32_t)(((uint64_t)windowBytes * 1000ULL) / elapsed);
|
||||||
|
windowBytes = 0;
|
||||||
|
windowStartMs = nowMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
uint32_t DataLogger::bytesWritten() const { return (uint32_t)totalBytes; }
|
||||||
|
uint32_t DataLogger::chunksWritten() const { return totalChunks; }
|
||||||
|
uint32_t DataLogger::droppedChunks() const { return totalDropped; }
|
||||||
|
uint32_t DataLogger::writeSpeedBps() const { return bps; }
|
||||||
|
bool DataLogger::overflowing() const { return warning; }
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Audio logging (see docs/audio_logging.md for the full design).
|
||||||
|
//
|
||||||
|
// Producer/consumer bridge between the Node collection task (core 1) and the
|
||||||
|
// StorageTask (core 0). Owns the chunk pool, the two FreeRTOS queues, and the
|
||||||
|
// open WAV file. Only the consumer touches the card.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <FS.h>
|
||||||
|
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <freertos/queue.h>
|
||||||
|
|
||||||
|
#include "storage_config.h"
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// WAV (RIFF) metadata helpers.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// 44-byte PCM WAVE header. Layout and field meaning are documented in
|
||||||
|
// docs/audio_logging.md section 3.
|
||||||
|
struct WavHeader
|
||||||
|
{
|
||||||
|
uint8_t riff[4]; // "RIFF"
|
||||||
|
uint32_t riffSize; // file size - 8
|
||||||
|
uint8_t wave[4]; // "WAVE"
|
||||||
|
uint8_t fmt[4]; // "fmt "
|
||||||
|
uint32_t fmtChunkSize; // 16 (PCM)
|
||||||
|
uint16_t audioFormat; // 1 (PCM)
|
||||||
|
uint16_t numChannels; // STORAGE_AUDIO_CHANNELS
|
||||||
|
uint32_t sampleRate; // STORAGE_AUDIO_SAMPLE_RATE_HZ
|
||||||
|
uint32_t byteRate; // sampleRate * blockAlign
|
||||||
|
uint16_t blockAlign; // numChannels * (bitsPerSample / 8)
|
||||||
|
uint16_t bitsPerSample; // STORAGE_AUDIO_BITS
|
||||||
|
uint8_t data[4]; // "data"
|
||||||
|
uint32_t dataSize; // file size - 44
|
||||||
|
} __attribute__((packed));
|
||||||
|
|
||||||
|
static_assert(sizeof(WavHeader) == 44, "WAV header must be exactly 44 bytes");
|
||||||
|
|
||||||
|
// Fills the header for a PCM stream of the given channels/rate/depth.
|
||||||
|
// dataSize is typically 0 at file open and corrected on finalize.
|
||||||
|
void buildWavHeader(WavHeader& header,
|
||||||
|
uint16_t numChannels,
|
||||||
|
uint32_t sampleRate,
|
||||||
|
uint16_t bitsPerSample,
|
||||||
|
uint32_t dataSize);
|
||||||
|
|
||||||
|
// Returns the two little-endian values to patch at offsets 4 and 40 when a
|
||||||
|
// file is closed/rotated: riffSize = fileSize - 8, dataSize = fileSize - 44.
|
||||||
|
void finalizeWavHeader(uint32_t fileSize,
|
||||||
|
uint32_t& riffSizeOut,
|
||||||
|
uint32_t& dataSizeOut);
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Chunk pool
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// One slot of the write pool. Producers borrow a chunk, fill `data[0..length)`
|
||||||
|
// with interleaved PCM (40 channels, frame order, 80 bytes/frame), and submit
|
||||||
|
// it. Chunks must be submitted in strict stream order.
|
||||||
|
struct AudioChunk
|
||||||
|
{
|
||||||
|
uint8_t* data; // DMA-capable buffer owned by the pool
|
||||||
|
uint32_t capacity; // STORAGE_LOG_CHUNK_SIZE
|
||||||
|
uint32_t length; // valid bytes, multiple of the WAV frame size
|
||||||
|
uint32_t sequence; // monotonic order (diagnostics only)
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// DataLogger
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class DataLogger
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
DataLogger();
|
||||||
|
|
||||||
|
// Lifecycle --------------------------------------------------------------
|
||||||
|
|
||||||
|
// Allocates the pool (MALLOC_CAP_DMA), creates the queues, creates
|
||||||
|
// STORAGE_LOG_DIR. Does not open a session file.
|
||||||
|
bool begin(fs::FS& files);
|
||||||
|
|
||||||
|
// Finalizes and closes the current session file, frees the pool/queues.
|
||||||
|
void end();
|
||||||
|
|
||||||
|
// Session control --------------------------------------------------------
|
||||||
|
|
||||||
|
// Creates the next rec_<uptimeSeconds>_<n>.wav under STORAGE_LOG_DIR
|
||||||
|
// (skipping forward if the name exists), writes the 44-byte WAV header.
|
||||||
|
// false if the card is full or unwritable.
|
||||||
|
bool openSession();
|
||||||
|
|
||||||
|
// Finalizes (patches RIFF/data sizes) and closes the current file.
|
||||||
|
bool closeSession();
|
||||||
|
|
||||||
|
// Closes/opens when bytesThisFile >= STORAGE_LOG_ROTATE_BYTES. Called by
|
||||||
|
// the consumer on every loop; a no-op when idle or below the threshold.
|
||||||
|
bool rotateIfNeeded();
|
||||||
|
|
||||||
|
bool hasSession() const;
|
||||||
|
bool isEmpty() const; // no chunks waiting to be written
|
||||||
|
|
||||||
|
// Producer API (Node collection task, core 1) ----------------------------
|
||||||
|
|
||||||
|
// Pops a free chunk, or on timeout drops the oldest queued chunk and
|
||||||
|
// returns it so the producer can keep streaming. nullptr only if there is
|
||||||
|
// nothing to drop (empty pool + empty queue).
|
||||||
|
AudioChunk* acquireChunk(TickType_t timeout);
|
||||||
|
|
||||||
|
// Returns a filled chunk to the write queue. Must be called in order.
|
||||||
|
void submitChunk(AudioChunk* chunk);
|
||||||
|
|
||||||
|
// Consumer API (StorageTask, core 0) --------------------------------------
|
||||||
|
|
||||||
|
// Blocks up to `timeout` for the next filled chunk. nullptr on timeout.
|
||||||
|
AudioChunk* nextChunk(TickType_t timeout);
|
||||||
|
|
||||||
|
// Appends chunk->data[0..length) to the WAV data section with one
|
||||||
|
// file.write(), updating counters and the bps window. Opens a session
|
||||||
|
// lazily if none is open. false on card failure (fatal per design.md).
|
||||||
|
bool writeChunk(AudioChunk* chunk);
|
||||||
|
|
||||||
|
// Returns a chunk to the free pool after it has been written or dropped.
|
||||||
|
void releaseChunk(AudioChunk* chunk);
|
||||||
|
|
||||||
|
// Stats -------------------------------------------------------------------
|
||||||
|
|
||||||
|
uint32_t bytesWritten() const;
|
||||||
|
uint32_t chunksWritten() const;
|
||||||
|
uint32_t droppedChunks() const;
|
||||||
|
uint32_t writeSpeedBps() const;
|
||||||
|
bool overflowing() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Pops the oldest unwritten chunk off filledQ without writing it (FIFO
|
||||||
|
// front == oldest), increments droppedChunks, raises the warning.
|
||||||
|
AudioChunk* dropOldestChunk();
|
||||||
|
|
||||||
|
// Serial (rate-limited) + LED (STORAGE_WARN_LED_GPIO) overflow indication.
|
||||||
|
void setOverflowWarning(bool overflowing);
|
||||||
|
|
||||||
|
void updateBpsWindow(uint32_t bytes, uint32_t nowMs);
|
||||||
|
|
||||||
|
fs::FS* files; // SD backend, injected by begin()
|
||||||
|
AudioChunk chunks[STORAGE_LOG_POOL_SIZE];
|
||||||
|
uint8_t* poolData; // one DMA-capable block, sliced into chunks
|
||||||
|
QueueHandle_t freeQ; // empty chunks
|
||||||
|
QueueHandle_t filledQ; // chunks waiting to be written
|
||||||
|
File file; // open session file
|
||||||
|
char filePath[96];
|
||||||
|
uint32_t sessionSeq; // rotation counter within this boot
|
||||||
|
|
||||||
|
uint64_t bytesThisFile;
|
||||||
|
uint64_t totalBytes;
|
||||||
|
uint32_t totalChunks;
|
||||||
|
uint32_t totalDropped;
|
||||||
|
uint32_t lastDropPrintMs;
|
||||||
|
|
||||||
|
// bps metering window (writeChunk is the only writer)
|
||||||
|
uint32_t windowBytes;
|
||||||
|
uint32_t windowStartMs;
|
||||||
|
uint32_t bps;
|
||||||
|
|
||||||
|
bool active;
|
||||||
|
bool warning;
|
||||||
|
};
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
#include "sd_manager.h"
|
||||||
|
|
||||||
|
#include "storage_config.h"
|
||||||
|
|
||||||
|
#include <driver/gpio.h>
|
||||||
|
|
||||||
|
// Both backends are compiled into the image so that the SDIO path is
|
||||||
|
// compile-checked even while the system still runs SPI for bring-up.
|
||||||
|
#include <SD.h>
|
||||||
|
#include <SD_MMC.h>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
class SpiBackend : public StorageBackend
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
bool begin() override
|
||||||
|
{
|
||||||
|
// Explicit SPI instance so the pin wiring is driven from
|
||||||
|
// storage_config.h rather than the board defaults. The second
|
||||||
|
// spi.begin() call made inside SDFS::begin() is a no-op because the
|
||||||
|
// bus is already started with these pins.
|
||||||
|
static SPIClass spi;
|
||||||
|
|
||||||
|
spi.begin(
|
||||||
|
STORAGE_SPI_SCK,
|
||||||
|
STORAGE_SPI_MISO,
|
||||||
|
STORAGE_SPI_MOSI,
|
||||||
|
STORAGE_SPI_CS
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cheap breakout modules often omit the pull-up resistors the SD
|
||||||
|
// spec expects on the idle-high lines. The ESP32 SPI HAL clears the
|
||||||
|
// internal pull-ups when it attaches a pin (esp32-hal-spi.c), so a
|
||||||
|
// floating MISO/CS means the card never answers CMD0 during init
|
||||||
|
// ("Card Failed! cmd: 0x00"). Re-enable the pull-ups directly so we
|
||||||
|
// do not disturb the pin's peripheral function.
|
||||||
|
gpio_pullup_en((gpio_num_t)STORAGE_SPI_MISO);
|
||||||
|
gpio_pullup_en((gpio_num_t)STORAGE_SPI_CS);
|
||||||
|
|
||||||
|
// Give the card a moment to stabilize after power-on before the init
|
||||||
|
// handshake starts (the framework sends 74+ dummy clocks, but some
|
||||||
|
// cards need a little more settling time on a fresh mount attempt).
|
||||||
|
delay(20);
|
||||||
|
|
||||||
|
return SD.begin(
|
||||||
|
STORAGE_SPI_CS,
|
||||||
|
spi,
|
||||||
|
STORAGE_SPI_FREQ,
|
||||||
|
STORAGE_MOUNT_POINT,
|
||||||
|
STORAGE_MAX_OPEN_FILES,
|
||||||
|
false // format_if_empty: never auto-format
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void end() override
|
||||||
|
{
|
||||||
|
SD.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::FS& fs() override
|
||||||
|
{
|
||||||
|
return SD;
|
||||||
|
}
|
||||||
|
|
||||||
|
StorageCardType cardType() override
|
||||||
|
{
|
||||||
|
return mapType(SD.cardType());
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64_t totalBytes() override
|
||||||
|
{
|
||||||
|
return SD.totalBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64_t usedBytes() override
|
||||||
|
{
|
||||||
|
return SD.usedBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
static StorageCardType mapType(sdcard_type_t type)
|
||||||
|
{
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case CARD_MMC: return StorageCardType::MMC;
|
||||||
|
case CARD_SD: return StorageCardType::SD;
|
||||||
|
case CARD_SDHC: return StorageCardType::SDHC;
|
||||||
|
case CARD_NONE: return StorageCardType::None;
|
||||||
|
default: return StorageCardType::Unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class SdmmcBackend : public StorageBackend
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
bool begin() override
|
||||||
|
{
|
||||||
|
// The plain esp32dev variant does not pre-wire the SDMMC pins, so
|
||||||
|
// they must always be set explicitly. The classic ESP32 routes the
|
||||||
|
// SDMMC peripheral through the GPIO matrix, so the pins defined in
|
||||||
|
// storage_config.h are fully re-routable.
|
||||||
|
if (!SD_MMC.setPins(
|
||||||
|
STORAGE_SDMMC_CLK,
|
||||||
|
STORAGE_SDMMC_CMD,
|
||||||
|
STORAGE_SDMMC_D0,
|
||||||
|
STORAGE_SDMMC_D1,
|
||||||
|
STORAGE_SDMMC_D2,
|
||||||
|
STORAGE_SDMMC_D3))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SD_MMC.begin(
|
||||||
|
STORAGE_MOUNT_POINT,
|
||||||
|
STORAGE_SDMMC_MODE_1BIT, // false == 4-bit bus
|
||||||
|
STORAGE_SDMMC_FORMAT_IF_FAILED, // never auto-format
|
||||||
|
STORAGE_SDMMC_FREQ_HZ, // 40 MHz == SDMMC_FREQ_HIGHSPEED
|
||||||
|
STORAGE_MAX_OPEN_FILES
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void end() override
|
||||||
|
{
|
||||||
|
SD_MMC.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::FS& fs() override
|
||||||
|
{
|
||||||
|
return SD_MMC;
|
||||||
|
}
|
||||||
|
|
||||||
|
StorageCardType cardType() override
|
||||||
|
{
|
||||||
|
return mapType(SD_MMC.cardType());
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64_t totalBytes() override
|
||||||
|
{
|
||||||
|
return SD_MMC.totalBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64_t usedBytes() override
|
||||||
|
{
|
||||||
|
return SD_MMC.usedBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
static StorageCardType mapType(sdcard_type_t type)
|
||||||
|
{
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case CARD_MMC: return StorageCardType::MMC;
|
||||||
|
case CARD_SD: return StorageCardType::SD;
|
||||||
|
case CARD_SDHC: return StorageCardType::SDHC;
|
||||||
|
case CARD_NONE: return StorageCardType::None;
|
||||||
|
default: return StorageCardType::Unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
|
SDManager::SDManager()
|
||||||
|
:
|
||||||
|
backend(nullptr),
|
||||||
|
mounted(false)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool SDManager::begin()
|
||||||
|
{
|
||||||
|
if (mounted)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#if STORAGE_IFACE == STORAGE_IFACE_SDMMC
|
||||||
|
backend = new SdmmcBackend();
|
||||||
|
#else
|
||||||
|
backend = new SpiBackend();
|
||||||
|
#endif
|
||||||
|
|
||||||
|
if (backend == nullptr)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
mounted = backend->begin();
|
||||||
|
|
||||||
|
if (!mounted)
|
||||||
|
{
|
||||||
|
Serial.println("[Storage] SD card mount FAILED");
|
||||||
|
delete backend;
|
||||||
|
backend = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mounted;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void SDManager::end()
|
||||||
|
{
|
||||||
|
if (backend != nullptr)
|
||||||
|
{
|
||||||
|
backend->end();
|
||||||
|
delete backend;
|
||||||
|
backend = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
mounted = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool SDManager::isMounted() const
|
||||||
|
{
|
||||||
|
return mounted;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fs::FS& SDManager::fs()
|
||||||
|
{
|
||||||
|
return backend->fs();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
StorageCardType SDManager::cardType() const
|
||||||
|
{
|
||||||
|
if (mounted && backend != nullptr)
|
||||||
|
{
|
||||||
|
return backend->cardType();
|
||||||
|
}
|
||||||
|
return StorageCardType::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const char* SDManager::cardTypeName() const
|
||||||
|
{
|
||||||
|
return cardTypeName(cardType());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
uint64_t SDManager::totalBytes() const
|
||||||
|
{
|
||||||
|
if (mounted && backend != nullptr)
|
||||||
|
{
|
||||||
|
return backend->totalBytes();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
uint64_t SDManager::usedBytes() const
|
||||||
|
{
|
||||||
|
if (mounted && backend != nullptr)
|
||||||
|
{
|
||||||
|
return backend->usedBytes();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
uint64_t SDManager::freeBytes() const
|
||||||
|
{
|
||||||
|
uint64_t total = totalBytes();
|
||||||
|
uint64_t used = usedBytes();
|
||||||
|
return (total > used) ? (total - used) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
uint32_t SDManager::measureWriteSpeed()
|
||||||
|
{
|
||||||
|
if (!mounted || backend == nullptr)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::FS& files = backend->fs();
|
||||||
|
|
||||||
|
const char* scratchPath = STORAGE_SPEED_MEASURE_PATH;
|
||||||
|
const size_t bufferSize = 16 * 1024;
|
||||||
|
|
||||||
|
// Remove any leftover from a previous crashed run.
|
||||||
|
if (files.exists(scratchPath))
|
||||||
|
{
|
||||||
|
files.remove(scratchPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t* buffer = (uint8_t*)malloc(bufferSize);
|
||||||
|
if (buffer == nullptr)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
memset(buffer, 0xA5, bufferSize);
|
||||||
|
|
||||||
|
File file = files.open(scratchPath, FILE_WRITE);
|
||||||
|
if (!file)
|
||||||
|
{
|
||||||
|
free(buffer);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t remaining = STORAGE_SPEED_MEASURE_BYTES;
|
||||||
|
uint32_t startUs = micros();
|
||||||
|
|
||||||
|
while (remaining > 0)
|
||||||
|
{
|
||||||
|
size_t toWrite = (remaining < bufferSize) ? remaining : bufferSize;
|
||||||
|
size_t written = file.write(buffer, toWrite);
|
||||||
|
|
||||||
|
if (written == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining -= written;
|
||||||
|
}
|
||||||
|
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
uint32_t elapsedUs = micros() - startUs;
|
||||||
|
Serial.print("Elapsed microseconds: ");
|
||||||
|
Serial.println(elapsedUs);
|
||||||
|
|
||||||
|
if (files.exists(scratchPath))
|
||||||
|
{
|
||||||
|
files.remove(scratchPath);
|
||||||
|
}
|
||||||
|
free(buffer);
|
||||||
|
|
||||||
|
if (remaining != 0 || elapsedUs == 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64_t writtenBytes = (uint64_t)STORAGE_SPEED_MEASURE_BYTES - remaining;
|
||||||
|
return (uint32_t)((writtenBytes * 1000000ULL) / elapsedUs);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const char* SDManager::cardTypeName(StorageCardType type)
|
||||||
|
{
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case StorageCardType::MMC: return "MMC";
|
||||||
|
case StorageCardType::SD: return "SD";
|
||||||
|
case StorageCardType::SDHC: return "SDHC";
|
||||||
|
case StorageCardType::Unknown: return "Unknown";
|
||||||
|
case StorageCardType::None:
|
||||||
|
default: return "None";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool SDManager::writeTestFile()
|
||||||
|
{
|
||||||
|
if (!mounted || backend == nullptr)
|
||||||
|
{
|
||||||
|
Serial.println("[Storage] Test file: not mounted, skipping");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* path = STORAGE_TEST_FILE_PATH;
|
||||||
|
const char* text = "the dog ate the moon";
|
||||||
|
|
||||||
|
fs::FS& files = backend->fs();
|
||||||
|
|
||||||
|
if (files.exists(path))
|
||||||
|
{
|
||||||
|
files.remove(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
File file = files.open(path, FILE_WRITE);
|
||||||
|
if (!file)
|
||||||
|
{
|
||||||
|
Serial.println("[Storage] Test file: could not open for write");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t written = file.print(text);
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
if (written != strlen(text))
|
||||||
|
{
|
||||||
|
Serial.printf("[Storage] Test file: wrote %u of %u bytes\n",
|
||||||
|
(unsigned)written, (unsigned)strlen(text));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[Storage] Test file: wrote '%s' (%u bytes)\n",
|
||||||
|
text, (unsigned)written);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void SDManager::printCardInfo()
|
||||||
|
{
|
||||||
|
if (!mounted)
|
||||||
|
{
|
||||||
|
Serial.println("[Storage] Card info unavailable (not mounted)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64_t total = backend->totalBytes();
|
||||||
|
uint64_t used = backend->usedBytes();
|
||||||
|
uint64_t free = (total > used) ? (total - used) : 0;
|
||||||
|
|
||||||
|
Serial.println("----------------------------------------");
|
||||||
|
Serial.println("SD card information");
|
||||||
|
Serial.println("----------------------------------------");
|
||||||
|
Serial.printf(" Type : %s\n", cardTypeName(backend->cardType()));
|
||||||
|
Serial.printf(" Total : %llu bytes\n", (unsigned long long)total);
|
||||||
|
Serial.printf(" Used : %llu bytes\n", (unsigned long long)used);
|
||||||
|
Serial.printf(" Free : %llu bytes\n", (unsigned long long)free);
|
||||||
|
Serial.println("----------------------------------------");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void SDManager::listFiles()
|
||||||
|
{
|
||||||
|
if (!mounted)
|
||||||
|
{
|
||||||
|
Serial.println("[Storage] Cannot list files (not mounted)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.println("Files on SD card:");
|
||||||
|
Serial.println("----------------------------------------");
|
||||||
|
|
||||||
|
char path[256];
|
||||||
|
snprintf(path, sizeof(path), "/");
|
||||||
|
|
||||||
|
listFilesRecursive(backend->fs(), path, sizeof(path), 0);
|
||||||
|
|
||||||
|
Serial.println("----------------------------------------");
|
||||||
|
Serial.println("End of listing");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void SDManager::listFilesRecursive(fs::FS& files, char* path, size_t pathSize, uint8_t depth)
|
||||||
|
{
|
||||||
|
if (depth > STORAGE_LIST_MAX_DEPTH)
|
||||||
|
{
|
||||||
|
Serial.printf(" ... (max depth %u reached)\n", STORAGE_LIST_MAX_DEPTH);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
File dir = files.open(path);
|
||||||
|
|
||||||
|
if (!dir)
|
||||||
|
{
|
||||||
|
Serial.printf(" [error] cannot open: %s\n", path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dir.isDirectory())
|
||||||
|
{
|
||||||
|
Serial.printf(" %s (%llu bytes)\n", path, (unsigned long long)dir.size());
|
||||||
|
dir.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
File entry;
|
||||||
|
while ((entry = dir.openNextFile()))
|
||||||
|
{
|
||||||
|
size_t base = strlen(path);
|
||||||
|
|
||||||
|
if (entry.isDirectory())
|
||||||
|
{
|
||||||
|
snprintf(path + base, pathSize - base, "/%s", entry.name());
|
||||||
|
Serial.printf(" %s/\n", path);
|
||||||
|
listFilesRecursive(files, path, pathSize, depth + 1);
|
||||||
|
path[base] = '\0';
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
snprintf(path + base, pathSize - base, "/%s", entry.name());
|
||||||
|
Serial.printf(" %s (%llu bytes)\n", path, (unsigned long long)entry.size());
|
||||||
|
path[base] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
dir.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <FS.h>
|
||||||
|
|
||||||
|
// StorageCardType decouples the rest of the system from the SD/SD_MMC
|
||||||
|
// library enum so nothing outside sd_manager.cpp needs to know which
|
||||||
|
// hardware interface is in use.
|
||||||
|
enum class StorageCardType
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
MMC,
|
||||||
|
SD,
|
||||||
|
SDHC,
|
||||||
|
Unknown
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hardware abstraction for the SD card transport. The concrete backend
|
||||||
|
// (SPI today, SDIO later) is selected at build time in storage_config.h.
|
||||||
|
// Everything downstream (StorageTask, future DataLogger) talks only to
|
||||||
|
// the `fs::FS` reference, so swapping SPI for SDIO touches no other code.
|
||||||
|
class StorageBackend
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
virtual ~StorageBackend() {}
|
||||||
|
|
||||||
|
virtual bool begin() = 0;
|
||||||
|
virtual void end() = 0;
|
||||||
|
|
||||||
|
virtual fs::FS& fs() = 0;
|
||||||
|
|
||||||
|
virtual StorageCardType cardType() = 0;
|
||||||
|
virtual uint64_t totalBytes() = 0;
|
||||||
|
virtual uint64_t usedBytes() = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Owns the selected backend, mounts the card, and provides the boot-time
|
||||||
|
// info/listing report. The mount and any file access are blocking and are
|
||||||
|
// expected to be driven from the dedicated StorageTask on core 0.
|
||||||
|
class SDManager
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
SDManager();
|
||||||
|
|
||||||
|
bool begin();
|
||||||
|
void end();
|
||||||
|
|
||||||
|
bool isMounted() const;
|
||||||
|
|
||||||
|
fs::FS& fs();
|
||||||
|
|
||||||
|
StorageCardType cardType() const;
|
||||||
|
const char* cardTypeName() const;
|
||||||
|
uint64_t totalBytes() const;
|
||||||
|
uint64_t usedBytes() const;
|
||||||
|
uint64_t freeBytes() const;
|
||||||
|
|
||||||
|
// Writes a scratch file of STORAGE_SPEED_MEASURE_BYTES and returns the
|
||||||
|
// achieved write throughput in bytes/second (0 on failure). The scratch
|
||||||
|
// file is deleted before returning.
|
||||||
|
uint32_t measureWriteSpeed();
|
||||||
|
|
||||||
|
void printCardInfo();
|
||||||
|
void listFiles();
|
||||||
|
|
||||||
|
// Small sanity write used during bring-up: creates happy_file.txt with
|
||||||
|
// a fixed message and returns true when the file lands on the card.
|
||||||
|
bool writeTestFile();
|
||||||
|
|
||||||
|
private:
|
||||||
|
StorageBackend* backend;
|
||||||
|
bool mounted;
|
||||||
|
|
||||||
|
void listFilesRecursive(fs::FS& files, char* path, size_t pathSize, uint8_t depth);
|
||||||
|
static const char* cardTypeName(StorageCardType type);
|
||||||
|
};
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Storage configuration
|
||||||
|
// ============================================================================
|
||||||
|
//
|
||||||
|
// This header is the single place to configure how the Hub talks to the
|
||||||
|
// SD card. Two hardware interfaces are supported by the Arduino-ESP32
|
||||||
|
// framework, and both are implemented behind the StorageBackend interface
|
||||||
|
// in sd_manager.cpp:
|
||||||
|
//
|
||||||
|
// STORAGE_IFACE_SPI - Uses the `SD` library (SPI protocol). This is the
|
||||||
|
// bring-up path for the current breakout module,
|
||||||
|
// which only breaks out the 4 SPI lines.
|
||||||
|
// Max practical throughput: ~1-2 MB/s. NOT enough
|
||||||
|
// for the 6-8 MB/s audio logging target.
|
||||||
|
//
|
||||||
|
// STORAGE_IFACE_SDMMC - Uses the `SD_MMC` library (SDIO protocol, the
|
||||||
|
// SDMMC peripheral). This is the production path.
|
||||||
|
// 4-bit mode @ 40 MHz (SDMMC_FREQ_HIGHSPEED) gives
|
||||||
|
// roughly 8-12 MB/s, which comfortably meets the
|
||||||
|
// target. REQUIRED for the final design.
|
||||||
|
//
|
||||||
|
// TO MIGRATE TO SDIO (the new SDIO-capable board that is being shipped):
|
||||||
|
//
|
||||||
|
// 1. Change STORAGE_IFACE below from STORAGE_IFACE_SPI to
|
||||||
|
// STORAGE_IFACE_SDMMC.
|
||||||
|
//
|
||||||
|
// 2. Wire the SD card to the SDIO lines listed in the STORAGE_SDMMC_*
|
||||||
|
// defines below. On the classic ESP32 the SDMMC peripheral is routed
|
||||||
|
// through the GPIO matrix, so these pins can be changed to any free
|
||||||
|
// GPIO simply by editing the defines.
|
||||||
|
//
|
||||||
|
// 3. Keep STORAGE_SDMMC_MODE_1BIT as `false` (4-bit bus is required for
|
||||||
|
// the write throughput) and keep STORAGE_SDMMC_FORMAT_IF_FAILED as
|
||||||
|
// `false` (a foreign/unformatted card must never be auto-formatted).
|
||||||
|
//
|
||||||
|
// 4. Everything downstream (StorageTask, SDManager, and the future
|
||||||
|
// DataLogger) talks only through the `fs::FS` interface, so no other
|
||||||
|
// code changes are needed.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// --- Backend selection ------------------------------------------------------
|
||||||
|
|
||||||
|
#define STORAGE_IFACE_SPI 1
|
||||||
|
#define STORAGE_IFACE_SDMMC 2
|
||||||
|
|
||||||
|
// SPI until the SDIO-capable board arrives.
|
||||||
|
#ifndef STORAGE_IFACE
|
||||||
|
#define STORAGE_IFACE STORAGE_IFACE_SDMMC
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// --- Common -----------------------------------------------------------------
|
||||||
|
|
||||||
|
// Single canonical mount point so file paths never change when the backend
|
||||||
|
// is switched (the SD lib defaults to "/sd", SD_MMC to "/sdcard").
|
||||||
|
#define STORAGE_MOUNT_POINT "/sdcard"
|
||||||
|
|
||||||
|
#define STORAGE_MAX_OPEN_FILES 5
|
||||||
|
|
||||||
|
// --- SPI (bring-up only) ----------------------------------------------------
|
||||||
|
|
||||||
|
// Default VSPI pins on the classic ESP32 DevKitC (variant/pins_arduino.h).
|
||||||
|
#define STORAGE_SPI_CS 5
|
||||||
|
#define STORAGE_SPI_SCK 18
|
||||||
|
#define STORAGE_SPI_MOSI 23
|
||||||
|
#define STORAGE_SPI_MISO 19
|
||||||
|
|
||||||
|
// 10 MHz is a reliable default for breadboard/jumper-wire bring-up. The SD
|
||||||
|
// init handshake always runs at 400 kHz regardless (see sd_diskio.cpp), so a
|
||||||
|
// mount failure is NOT a frequency problem - check power, wiring, pull-ups
|
||||||
|
// and card seating first. Raise to 20 MHz once the wiring is proven.
|
||||||
|
#define STORAGE_SPI_FREQ 10000000UL
|
||||||
|
|
||||||
|
// --- SDIO / SD_MMC (production, 4-bit) --------------------------------------
|
||||||
|
|
||||||
|
// Default ESP32 SDMMC slot-1 pins (GPIO matrix, freely re-routable).
|
||||||
|
// Interface 1: #define STORAGE_SDMMC_CLK 6
|
||||||
|
// Interface 1: #define STORAGE_SDMMC_CMD 11
|
||||||
|
// Interface 1: #define STORAGE_SDMMC_D0 7
|
||||||
|
// Interface 1: #define STORAGE_SDMMC_D1 8
|
||||||
|
// Interface 1: #define STORAGE_SDMMC_D2 9
|
||||||
|
// Interface 1: #define STORAGE_SDMMC_D3 10
|
||||||
|
|
||||||
|
#define STORAGE_SDMMC_CLK 14
|
||||||
|
#define STORAGE_SDMMC_CMD 15
|
||||||
|
#define STORAGE_SDMMC_D0 2
|
||||||
|
#define STORAGE_SDMMC_D1 4
|
||||||
|
#define STORAGE_SDMMC_D2 12
|
||||||
|
#define STORAGE_SDMMC_D3 13
|
||||||
|
|
||||||
|
// false = 4-bit wide bus (required for >8 MB/s). Do NOT enable 1-bit mode.
|
||||||
|
#define STORAGE_SDMMC_MODE_1BIT false
|
||||||
|
|
||||||
|
// NEVER auto-format: an unformatted/foreign card must never be destroyed.
|
||||||
|
#define STORAGE_SDMMC_FORMAT_IF_FAILED false
|
||||||
|
|
||||||
|
// 40 MHz == SDMMC_FREQ_HIGHSPEED. Written as a literal to keep this header
|
||||||
|
// free of driver includes.
|
||||||
|
#define STORAGE_SDMMC_FREQ_HZ 40000000
|
||||||
|
|
||||||
|
// --- Boot-time file listing -------------------------------------------------
|
||||||
|
|
||||||
|
// Maximum directory depth printed during the recursive boot listing. Guards
|
||||||
|
// the StorageTask stack against pathological directory nesting.
|
||||||
|
#define STORAGE_LIST_MAX_DEPTH 10
|
||||||
|
|
||||||
|
// --- Write-speed estimation -------------------------------------------------
|
||||||
|
|
||||||
|
// Path of the temporary scratch file used to measure write throughput. It is
|
||||||
|
// created, written, measured, then deleted, so it never appears in listings.
|
||||||
|
#define STORAGE_SPEED_MEASURE_PATH STORAGE_MOUNT_POINT "/.writespeed.tmp"
|
||||||
|
|
||||||
|
// Size of the scratch file written per measurement (bytes).
|
||||||
|
#define STORAGE_SPEED_MEASURE_BYTES (0.5 * 1024 * 1024)
|
||||||
|
|
||||||
|
// How often to re-measure write speed (ms). This is a bring-up ESTIMATE only:
|
||||||
|
// it writes STORAGE_SPEED_MEASURE_BYTES to the card on every tick. Once real
|
||||||
|
// logging exists, throughput should be derived from actual logging writes and
|
||||||
|
// this benchmark disabled by setting the interval to 0 (measure once at boot).
|
||||||
|
#define STORAGE_SPEED_MEASURE_INTERVAL_MS 0
|
||||||
|
|
||||||
|
// --- Bring-up sanity test ----------------------------------------------------
|
||||||
|
|
||||||
|
// Small fixed-content file written at boot to prove the card's write path
|
||||||
|
// works end to end (create/open/write/close on the live mount).
|
||||||
|
#define STORAGE_TEST_FILE_PATH STORAGE_MOUNT_POINT "/happy_file.txt"
|
||||||
|
|
||||||
|
// --- Audio logging ----------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The DataLogger (docs/audio_logging.md) writes one multichannel WAV file
|
||||||
|
// per rotation. The Node collection task feeds it interleaved 40-channel PCM
|
||||||
|
// as chunks; the logger appends them and only the consumer touches the card.
|
||||||
|
//
|
||||||
|
// Stream rate: 40 ch x 48 kHz x 2 B = 3,840,000 B/s. The 64 KB pool holds
|
||||||
|
// ~17 ms of audio, so collection rounds must stay <= ~12 ms (<= 48 KB) or
|
||||||
|
// the pool must grow (see docs/audio_logging.md section 7).
|
||||||
|
|
||||||
|
// WAV parameters (must match what the Nodes produce).
|
||||||
|
#define STORAGE_AUDIO_SAMPLE_RATE_HZ 48000
|
||||||
|
#define STORAGE_AUDIO_CHANNELS 40 // 10 nodes x 4 mics
|
||||||
|
#define STORAGE_AUDIO_BITS 16
|
||||||
|
|
||||||
|
// Chunk pool: STORAGE_LOG_POOL_SIZE chunks of STORAGE_LOG_CHUNK_SIZE bytes,
|
||||||
|
// allocated with MALLOC_CAP_DMA. 64 KB total. Each interleave round (see
|
||||||
|
// NET_ROUND_FRAMES) must fit inside one chunk.
|
||||||
|
#define STORAGE_LOG_CHUNK_SIZE (8 * 1024)
|
||||||
|
#define STORAGE_LOG_POOL_SIZE 8
|
||||||
|
|
||||||
|
// Recording directory and rotation policy.
|
||||||
|
#define STORAGE_LOG_DIR STORAGE_MOUNT_POINT "/audio"
|
||||||
|
#define STORAGE_LOG_ROTATE_BYTES (1024LL * 1024 * 1024) // 1 GiB / file
|
||||||
|
#define STORAGE_LOG_FLUSH_BYTES (16LL * 1024 * 1024) // f_sync cadence
|
||||||
|
|
||||||
|
// Overflow warning LED: lights while chunks are being dropped because the SD
|
||||||
|
// card cannot keep up. GPIO 4 is unused by the SD card lines (SPI 5/18/19/23,
|
||||||
|
// SDMMC 6-11). Change both values if a different LED is wired.
|
||||||
|
#define STORAGE_WARN_LED_GPIO 4
|
||||||
|
#define STORAGE_WARN_LED_ACTIVE_HIGH true
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
#include "storage_task.h"
|
||||||
|
|
||||||
|
#include <esp_system.h>
|
||||||
|
|
||||||
|
#include "../storage/storage_config.h"
|
||||||
|
|
||||||
|
|
||||||
|
StorageTask::StorageTask(SDManager& manager, StorageState& state,
|
||||||
|
DataLogger& logger, RecordingController& recorder)
|
||||||
|
:
|
||||||
|
storage(manager),
|
||||||
|
storageState(state),
|
||||||
|
logger(logger),
|
||||||
|
recorder(recorder),
|
||||||
|
taskHandle(nullptr)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void StorageTask::start()
|
||||||
|
{
|
||||||
|
// Dedicated task on core 0 so SD card work never blocks the services
|
||||||
|
// running on core 1 (SystemTask / DiagnosticsTask). The 8 KB stack is
|
||||||
|
// sized for the recursive boot-time file listing and the logger loop.
|
||||||
|
xTaskCreatePinnedToCore(
|
||||||
|
taskEntry,
|
||||||
|
"StorageTask",
|
||||||
|
8192,
|
||||||
|
this,
|
||||||
|
3,
|
||||||
|
&taskHandle,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void StorageTask::taskEntry(void* parameter)
|
||||||
|
{
|
||||||
|
StorageTask* task =
|
||||||
|
static_cast<StorageTask*>(parameter);
|
||||||
|
|
||||||
|
task->run();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void StorageTask::run()
|
||||||
|
{
|
||||||
|
Serial.println("[Storage] Initializing SD card...");
|
||||||
|
|
||||||
|
storageState.begin();
|
||||||
|
|
||||||
|
// While the mount has not succeeded the dashboard must show "SD not found".
|
||||||
|
storageState.setMounted(false);
|
||||||
|
|
||||||
|
// Retry the mount until it succeeds. This keeps the card dead-pin-capable:
|
||||||
|
// reseating the card, fixing a wire, or powering the module will bring it
|
||||||
|
// up without rebooting the Hub.
|
||||||
|
while (!storage.begin())
|
||||||
|
{
|
||||||
|
Serial.println("[Storage] SD card mount FAILED.");
|
||||||
|
Serial.println("[Storage] Check: 3.3V power + common ground, CS/SCK/MOSI/MISO wiring,");
|
||||||
|
Serial.println("[Storage] card fully seated (click), and module pull-ups.");
|
||||||
|
Serial.println("[Storage] Retrying in 5 s...");
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(5000));
|
||||||
|
}
|
||||||
|
|
||||||
|
storageState.setMounted(true);
|
||||||
|
storageState.setCardType(storage.cardTypeName());
|
||||||
|
|
||||||
|
storage.printCardInfo();
|
||||||
|
storage.listFiles();
|
||||||
|
|
||||||
|
storage.writeTestFile();
|
||||||
|
|
||||||
|
// Initial write-speed estimate for the dashboard until real logging data
|
||||||
|
// is available.
|
||||||
|
storageState.setWriteSpeedBps(storage.measureWriteSpeed());
|
||||||
|
storageState.setCapacity(storage.totalBytes(), storage.usedBytes());
|
||||||
|
|
||||||
|
if (!logger.begin(storage.fs()))
|
||||||
|
{
|
||||||
|
Serial.println("[Storage] DataLogger failed to initialize, no recording");
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.println("[Storage] Waiting for recording sessions...");
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
// Open a WAV session as soon as recording starts; finalize and close
|
||||||
|
// it once recording has stopped and the queue has drained.
|
||||||
|
if (recorder.isRecording() && !logger.hasSession())
|
||||||
|
{
|
||||||
|
if (!logger.openSession())
|
||||||
|
{
|
||||||
|
Serial.println("[Storage] Could not open session (card full?)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recorder.isRecording() && logger.hasSession() && logger.isEmpty())
|
||||||
|
{
|
||||||
|
logger.closeSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block for the next filled chunk (100 ms so the session lifecycle and
|
||||||
|
// stats stay responsive even when idle).
|
||||||
|
AudioChunk* chunk = logger.nextChunk(pdMS_TO_TICKS(100));
|
||||||
|
|
||||||
|
if (chunk != nullptr)
|
||||||
|
{
|
||||||
|
if (!logger.writeChunk(chunk))
|
||||||
|
{
|
||||||
|
// design.md: SD failure is fatal - halt with a message. Reboot
|
||||||
|
// cleanly so the Hub comes back (a dead card simply loops in
|
||||||
|
// the mount retry at the top of this task).
|
||||||
|
Serial.println("[Storage] FATAL: card write failed, "
|
||||||
|
"halting storage.");
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(100));
|
||||||
|
esp_restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.releaseChunk(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.rotateIfNeeded();
|
||||||
|
|
||||||
|
// Publish real logging stats to the dashboard.
|
||||||
|
storageState.setWriteSpeedBps(logger.writeSpeedBps());
|
||||||
|
storageState.setLoggingStats(logger.droppedChunks(), logger.bytesWritten());
|
||||||
|
storageState.setCapacity(storage.totalBytes(), storage.usedBytes());
|
||||||
|
|
||||||
|
// Yield a tick even while saturated: when the SD card cannot keep up
|
||||||
|
// the filled queue never empties, so nextChunk() returns instantly and
|
||||||
|
// this task would otherwise starve the IDLE0 task, tripping the task
|
||||||
|
// watchdog and aborting the Hub. The 1 ms per chunk costs <2% of the
|
||||||
|
// write budget and the overflow path already drops chunks by design.
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
#include "../core/storage_state.h"
|
||||||
|
#include "../core/node_registry.h"
|
||||||
|
#include "../storage/sd_manager.h"
|
||||||
|
#include "../storage/data_logger.h"
|
||||||
|
|
||||||
|
|
||||||
|
class StorageTask
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
StorageTask(SDManager& manager, StorageState& state,
|
||||||
|
DataLogger& logger, RecordingController& recorder);
|
||||||
|
|
||||||
|
void start();
|
||||||
|
|
||||||
|
private:
|
||||||
|
static void taskEntry(void* parameter);
|
||||||
|
void run();
|
||||||
|
|
||||||
|
SDManager& storage;
|
||||||
|
StorageState& storageState;
|
||||||
|
DataLogger& logger;
|
||||||
|
RecordingController& recorder;
|
||||||
|
|
||||||
|
TaskHandle_t taskHandle = nullptr;
|
||||||
|
};
|
||||||
@@ -22,10 +22,12 @@ taskHandle(nullptr)
|
|||||||
// );
|
// );
|
||||||
void SystemTask::start()
|
void SystemTask::start()
|
||||||
{
|
{
|
||||||
|
// 8 KB stack: the services now include UDP handling (discovery/collection)
|
||||||
|
// which needs a few KB of stack for packet buffers.
|
||||||
xTaskCreatePinnedToCore(
|
xTaskCreatePinnedToCore(
|
||||||
taskEntry,
|
taskEntry,
|
||||||
"SystemTask",
|
"SystemTask",
|
||||||
4096,
|
8192,
|
||||||
this,
|
this,
|
||||||
2,
|
2,
|
||||||
&taskHandle,
|
&taskHandle,
|
||||||
|
|||||||
Reference in New Issue
Block a user