#!/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('= 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())