in sev state

This commit is contained in:
2026-08-14 08:24:33 -06:00
parent e06b429db9
commit 3d26b66490
27 changed files with 2578 additions and 108 deletions
Binary file not shown.
+309
View File
@@ -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())
+268
View File
@@ -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;
}
+97
View File
@@ -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];
};
+11
View File
@@ -9,6 +9,8 @@ void StorageState::begin()
current.usedMB = 0;
current.freeMB = 0;
current.writeSpeedBps = 0;
current.droppedChunks = 0;
current.bytesWritten = 0;
current.cardType[0] = '\0';
}
@@ -47,6 +49,13 @@ void StorageState::setWriteSpeedBps(uint32_t bytesPerSecond)
}
void StorageState::setLoggingStats(uint32_t droppedChunks, uint32_t bytesWritten)
{
current.droppedChunks = droppedChunks;
current.bytesWritten = bytesWritten;
}
StorageSnapshot StorageState::getCurrent()
{
StorageSnapshot snapshot;
@@ -57,6 +66,8 @@ StorageSnapshot StorageState::getCurrent()
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++)
{
+3
View File
@@ -14,6 +14,8 @@ struct StorageSnapshot
uint32_t usedMB;
uint32_t freeMB;
uint32_t writeSpeedBps;
uint32_t droppedChunks;
uint32_t bytesWritten;
char cardType[16];
};
@@ -27,6 +29,7 @@ public:
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();
+35 -2
View File
@@ -6,13 +6,24 @@
#include "core/dashboard_state.h"
#include "core/diagnostics_state.h"
#include "core/storage_state.h"
#include "core/node_registry.h"
#include "services/service_manager.h"
#include "services/wifi_service.h"
#include "services/ota_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/diagnostics_task.h"
@@ -22,14 +33,26 @@
DashboardState dashboardState;
DiagnosticsState diagnosticsState;
StorageState storageState;
NodeRegistry nodeRegistry;
RecordingController recordingController;
// Simple service scheduler nice for grouping tasks
ServiceManager services;
WiFiService wifi;
OTAService ota;
WebService web(dashboardState, diagnosticsState, storageState);
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
SystemTask systemTask(services);
@@ -37,16 +60,26 @@ SystemTask systemTask(services);
DiagnosticsTask diagnosticsTask(diagnosticsState);
SDManager sdManager;
StorageTask storageTask(sdManager, storageState);
StorageTask storageTask(sdManager, storageState, dataLogger, recordingController);
void setup()
{
Serial.begin(115200);
nodeRegistry.begin();
recordingController.begin();
services.add(&wifi);
services.add(&ota);
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();
diagnosticsTask.start();
+360
View File
@@ -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;
}
+80
View File
@@ -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;
};
+196
View File
@@ -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);
}
+42
View File
@@ -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;
};
+82
View File
@@ -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
+237
View File
@@ -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
+139
View File
@@ -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;
}
+40
View File
@@ -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];
};
+95
View File
@@ -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");
}
}
}
}
+38
View File
@@ -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;
};
+81 -1
View File
@@ -1,11 +1,14 @@
#include "web_service.h"
WebService::WebService(DashboardState& state, DiagnosticsState& diag_state, StorageState& storage_state)
WebService::WebService(DashboardState& state, DiagnosticsState& diag_state, StorageState& storage_state,
NodeRegistry& nodes, RecordingController& recorder)
:
Service("Web", 10),
dashboardState(state),
diagnosticsState(diag_state),
storageState(storage_state),
nodes(nodes),
recorder(recorder),
server(80),
webSocket(81)
{
@@ -95,6 +98,7 @@ function connectWebSocket() {
document.getElementById("cpu_frequency").innerHTML = "CPU Frequency: " + data.diagnostics.cpu_frequency + "MHz";
updateStorage(data.storage);
updateNetwork(data.network);
};
socket.onclose = function() {
@@ -136,6 +140,27 @@ function updateStorage(storage) {
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>
</head>
@@ -161,6 +186,13 @@ window.onload = connectWebSocket;
<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>
</body>
<footer>
<div id="firmware_version">Firmware Version: Loading...</div>
@@ -234,6 +266,54 @@ void WebService::broadcastState()
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
json += "}";
+5 -1
View File
@@ -8,12 +8,14 @@
#include "../core/dashboard_state.h"
#include "../core/diagnostics_state.h"
#include "../core/storage_state.h"
#include "../core/node_registry.h"
class WebService : public Service {
public:
WebService(DashboardState& state, DiagnosticsState& diag_state, StorageState& storage_state);
WebService(DashboardState& state, DiagnosticsState& diag_state, StorageState& storage_state,
NodeRegistry& nodes, RecordingController& recorder);
void begin() override;
void update() override;
@@ -26,6 +28,8 @@ private:
DashboardState& dashboardState;
DiagnosticsState& diagnosticsState;
StorageState& storageState;
NodeRegistry& nodes;
RecordingController& recorder;
void handleWebSocketMessage(uint8_t clientNum, uint8_t *payload, size_t length);
void broadcastState();
+279 -36
View File
@@ -2,14 +2,10 @@
#include <string.h>
// ============================================================================
// Skeleton implementation. The WAV metadata helpers below are complete;
// everything else is stubbed with a TODO and the work is tracked in
// docs/audio_logging.md section 14.
// ============================================================================
#include <esp_heap_caps.h>
// --- WAV metadata helpers (complete) -----------------------------------------
// --- WAV metadata helpers ----------------------------------------------------
void buildWavHeader(WavHeader& header,
uint16_t numChannels,
@@ -53,7 +49,7 @@ void finalizeWavHeader(uint32_t fileSize,
DataLogger::DataLogger()
:
files(nullptr),
pool(nullptr),
poolData(nullptr),
freeQ(nullptr),
filledQ(nullptr),
sessionSeq(0),
@@ -68,103 +64,350 @@ bps(0),
active(false),
warning(false)
{
memset(chunks, 0, sizeof(chunks));
}
bool DataLogger::begin(fs::FS& files)
{
// TODO: allocate the chunk pool with heap_caps_malloc(MALLOC_CAP_DMA)
// (STORAGE_LOG_POOL_SIZE x STORAGE_LOG_CHUNK_SIZE), create freeQ with all
// chunks and filledQ empty (xQueueCreate), mkdir STORAGE_LOG_DIR.
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()
{
// TODO: closeSession(), free the pool (heap_caps_free), delete the queues.
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()
{
// TODO: build "rec_<uptimeSeconds>_<sessionSeq>.wav" under
// STORAGE_LOG_DIR (skip forward if the name exists), open FILE_WRITE,
// write the 44-byte header with buildWavHeader(dataSize=0).
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()
{
// TODO: finalizeWavHeader(file.size(), ...) -> patch offsets 4 and 40,
// file.flush(), file.close().
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()
{
// TODO: if active && bytesThisFile >= STORAGE_LOG_ROTATE_BYTES:
// closeSession(); sessionSeq++; openSession().
return false;
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)
{
// TODO: xQueueReceive(freeQ, &chunk, timeout). On timeout, call
// dropOldestChunk() and return its chunk so the producer keeps streaming.
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)
{
// TODO: xQueueSend(filledQ, &chunk, ...). Must preserve stream order.
if (chunk == nullptr || filledQ == nullptr) return;
xQueueSend(filledQ, &chunk, portMAX_DELAY);
}
AudioChunk* DataLogger::nextChunk(TickType_t timeout)
{
// TODO: xQueueReceive(filledQ, &chunk, timeout).
if (filledQ == nullptr)
{
return nullptr;
}
AudioChunk* c = nullptr;
xQueueReceive(filledQ, &c, timeout);
return c;
}
bool DataLogger::writeChunk(AudioChunk* chunk)
{
// TODO: if (!active) return false;
// n = file.write(chunk->data, chunk->length);
// bytesThisFile += n; totalBytes += n; totalChunks++;
// feed the writeSpeedBps window (windowBytes/windowStartMs);
// file.flush() every STORAGE_LOG_FLUSH_BYTES;
// return n == chunk->length; (card failure -> fatal)
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)
{
// TODO: chunk->length = 0; xQueueSend(freeQ, &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()
{
// TODO: xQueueReceive from the BACK of filledQ without writing, count it,
// setOverflowWarning(true). Called by acquireChunk on timeout.
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)
{
// TODO: rate-limited Serial line with totalDropped + bytes behind;
// digitalWrite(STORAGE_WARN_LED_GPIO, ...) using
// STORAGE_WARN_LED_ACTIVE_HIGH.
(void)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;
}
}
+24 -24
View File
@@ -1,13 +1,11 @@
#pragma once
// ============================================================================
// Audio logging interface (see docs/audio_logging.md for the full design).
// Audio logging (see docs/audio_logging.md for the full design).
//
// THIS FILE IS A SKELETON. The chunk-pool / queue / file logic in
// data_logger.cpp is stubbed with TODOs; only the WAV metadata helpers are
// implemented. The interface below is the contract the future Node
// collection task (producer) and the StorageTask (consumer) are written
// against.
// 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>
@@ -20,7 +18,7 @@
// ----------------------------------------------------------------------------
// WAV (RIFF) metadata helpers - fully implemented in data_logger.cpp.
// WAV (RIFF) metadata helpers.
// ----------------------------------------------------------------------------
// 44-byte PCM WAVE header. Layout and field meaning are documented in
@@ -79,9 +77,6 @@ struct AudioChunk
// DataLogger
// ----------------------------------------------------------------------------
// 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.
class DataLogger
{
public:
@@ -98,22 +93,26 @@ public:
// Session control --------------------------------------------------------
// Creates the next rec_<uptimeSeconds>_<n>.wav under STORAGE_LOG_DIR,
// writes the 44-byte WAV header. false if the card is full or unwritable.
// 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; must be a no-op when idle.
// 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 (Section
// 8) and returns it so the producer can keep streaming. nullptr only if
// there is nothing to drop (empty pool + empty queue).
// 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.
@@ -121,13 +120,12 @@ public:
// Consumer API (StorageTask, core 0) --------------------------------------
// Blocks up to `timeout` for the next filled chunk. nullptr on timeout
// (lets the consumer pulse for stats even when idle).
// 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(), updates the byte/chunk counters and the bps window.
// false on card failure (fatal per design.md).
// 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.
@@ -142,16 +140,18 @@ public:
bool overflowing() const;
private:
// Pops the oldest unwritten chunk off filledQ without writing it,
// increments droppedChunks, raises the warning. Returns it to the caller
// (used by acquireChunk on timeout).
// 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* pool; // STORAGE_LOG_POOL_SIZE chunks
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
+48
View File
@@ -285,7 +285,10 @@ uint32_t SDManager::measureWriteSpeed()
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)
@@ -324,7 +327,10 @@ uint32_t SDManager::measureWriteSpeed()
Serial.print("Elapsed microseconds: ");
Serial.println(elapsedUs);
if (files.exists(scratchPath))
{
files.remove(scratchPath);
}
free(buffer);
if (remaining != 0 || elapsedUs == 0)
@@ -351,6 +357,47 @@ const char* SDManager::cardTypeName(StorageCardType type)
}
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)
@@ -434,6 +481,7 @@ void SDManager::listFilesRecursive(fs::FS& files, char* path, size_t pathSize, u
{
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();
+4
View File
@@ -63,6 +63,10 @@ public:
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;
+25 -11
View File
@@ -47,7 +47,7 @@
// SPI until the SDIO-capable board arrives.
#ifndef STORAGE_IFACE
#define STORAGE_IFACE STORAGE_IFACE_SPI
#define STORAGE_IFACE STORAGE_IFACE_SDMMC
#endif
// --- Common -----------------------------------------------------------------
@@ -75,12 +75,19 @@
// --- SDIO / SD_MMC (production, 4-bit) --------------------------------------
// Default ESP32 SDMMC slot-1 pins (GPIO matrix, freely re-routable).
#define STORAGE_SDMMC_CLK 6
#define STORAGE_SDMMC_CMD 11
#define STORAGE_SDMMC_D0 7
#define STORAGE_SDMMC_D1 8
#define STORAGE_SDMMC_D2 9
#define STORAGE_SDMMC_D3 10
// 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
@@ -113,14 +120,20 @@
// 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 128 KB pool holds
// ~34 ms of audio, so collection rounds must stay <= ~25 ms (<= 96 KB) or
// 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).
@@ -129,8 +142,9 @@
#define STORAGE_AUDIO_BITS 16
// Chunk pool: STORAGE_LOG_POOL_SIZE chunks of STORAGE_LOG_CHUNK_SIZE bytes,
// allocated with MALLOC_CAP_DMA. 128 KB total.
#define STORAGE_LOG_CHUNK_SIZE (16 * 1024)
// 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.
+62 -22
View File
@@ -1,12 +1,17 @@
#include "storage_task.h"
#include <esp_system.h>
#include "../storage/storage_config.h"
StorageTask::StorageTask(SDManager& manager, StorageState& state)
StorageTask::StorageTask(SDManager& manager, StorageState& state,
DataLogger& logger, RecordingController& recorder)
:
storage(manager),
storageState(state),
logger(logger),
recorder(recorder),
taskHandle(nullptr)
{
}
@@ -16,8 +21,7 @@ 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; the eventual
// producer/consumer logging loop will also live here.
// sized for the recursive boot-time file listing and the logger loop.
xTaskCreatePinnedToCore(
taskEntry,
"StorageTask",
@@ -50,8 +54,7 @@ void StorageTask::run()
// 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. (The SD init handshake runs at 400 kHz, so
// a failure here is wiring/power/seating/pull-up related, not speed.)
// up without rebooting the Hub.
while (!storage.begin())
{
Serial.println("[Storage] SD card mount FAILED.");
@@ -67,32 +70,69 @@ void StorageTask::run()
storage.printCardInfo();
storage.listFiles();
// Initial write-speed estimate for the dashboard.
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());
TickType_t lastWake =
xTaskGetTickCount();
if (!logger.begin(storage.fs()))
{
Serial.println("[Storage] DataLogger failed to initialize, no recording");
}
uint32_t lastSpeedMeasure = millis();
Serial.println("[Storage] Waiting for recording sessions...");
while (true)
{
// Periodically re-estimate write speed and refresh capacity so the
// dashboard stays current. An interval of 0 disables re-measuring.
if (STORAGE_SPEED_MEASURE_INTERVAL_MS != 0 &&
millis() - lastSpeedMeasure >= STORAGE_SPEED_MEASURE_INTERVAL_MS)
// 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())
{
lastSpeedMeasure = millis();
storageState.setWriteSpeedBps(storage.measureWriteSpeed());
storageState.setCapacity(storage.totalBytes(), storage.usedBytes());
if (!logger.openSession())
{
Serial.println("[Storage] Could not open session (card full?)");
}
}
// Future integration point: a producer/consumer queue will feed
// audio data here to be flushed to the card.
vTaskDelayUntil(
&lastWake,
pdMS_TO_TICKS(1000)
);
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));
}
}
+6 -1
View File
@@ -3,13 +3,16 @@
#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);
StorageTask(SDManager& manager, StorageState& state,
DataLogger& logger, RecordingController& recorder);
void start();
@@ -19,6 +22,8 @@ private:
SDManager& storage;
StorageState& storageState;
DataLogger& logger;
RecordingController& recorder;
TaskHandle_t taskHandle = nullptr;
};
+3 -1
View File
@@ -22,10 +22,12 @@ taskHandle(nullptr)
// );
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(
taskEntry,
"SystemTask",
4096,
8192,
this,
2,
&taskHandle,