Restructure ESP32 firmware into layered architecture with serial streaming
Architecture changes: - config/: Centralized pin definitions and settings - hal/: Hardware abstraction layer (servo PWM control) - drivers/: Device drivers (hand, emg_sensor) - core/: Business logic (gestures) - app/: Application entry point New features: - Fake EMG data streaming over USB serial at ~100 Hz - FEATURE_FAKE_EMG flag to switch between fake/real sensors - Python RealSerialStream class for receiving serial data Data format: timestamp_ms,ch0,ch1,ch2,ch3
This commit is contained in:
270
serial_stream.py
Normal file
270
serial_stream.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
@file serial_stream.py
|
||||
@brief Real serial stream for reading EMG data from ESP32.
|
||||
|
||||
This module provides a serial communication interface for receiving
|
||||
EMG data from the ESP32 microcontroller over USB. It implements the
|
||||
same interface as SimulatedEMGStream, making it a drop-in replacement.
|
||||
|
||||
@section usage Usage Example
|
||||
@code{.py}
|
||||
from serial_stream import RealSerialStream
|
||||
|
||||
# Create stream (auto-detects port, or specify manually)
|
||||
stream = RealSerialStream(port='COM3')
|
||||
stream.start()
|
||||
|
||||
# Read data (same interface as SimulatedEMGStream)
|
||||
while True:
|
||||
line = stream.readline()
|
||||
if line:
|
||||
print(line) # "1234,512,489,501,523"
|
||||
|
||||
stream.stop()
|
||||
@endcode
|
||||
|
||||
@section format Data Format
|
||||
The ESP32 sends data as CSV lines:
|
||||
"timestamp_ms,ch0,ch1,ch2,ch3\\n"
|
||||
|
||||
Example:
|
||||
"12345,512,489,501,523\\n"
|
||||
|
||||
@author Bucky Arm Project
|
||||
"""
|
||||
|
||||
import serial
|
||||
import serial.tools.list_ports
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class RealSerialStream:
|
||||
"""
|
||||
@brief Reads EMG data from ESP32 over USB serial.
|
||||
|
||||
This class provides the same interface as SimulatedEMGStream:
|
||||
- start() : Open serial connection
|
||||
- stop() : Close serial connection
|
||||
- readline() : Read one line of data
|
||||
|
||||
This allows it to be used as a drop-in replacement for testing
|
||||
with real hardware instead of simulated data.
|
||||
|
||||
@note Requires pyserial: pip install pyserial
|
||||
"""
|
||||
|
||||
def __init__(self, port: str = None, baud_rate: int = 115200, timeout: float = 1.0):
|
||||
"""
|
||||
@brief Initialize the serial stream.
|
||||
|
||||
@param port Serial port name (e.g., 'COM3' on Windows, '/dev/ttyUSB0' on Linux).
|
||||
If None, will attempt to auto-detect the ESP32.
|
||||
@param baud_rate Communication speed in bits per second. Default 115200 matches ESP32.
|
||||
@param timeout Read timeout in seconds. Returns None if no data within this time.
|
||||
"""
|
||||
self.port = port
|
||||
self.baud_rate = baud_rate
|
||||
self.timeout = timeout
|
||||
self.serial: Optional[serial.Serial] = None
|
||||
self.running = False
|
||||
|
||||
def start(self) -> None:
|
||||
"""
|
||||
@brief Open the serial connection to the ESP32.
|
||||
|
||||
If no port was specified in __init__, attempts to auto-detect
|
||||
the ESP32 by looking for common USB-UART chip identifiers.
|
||||
|
||||
@throws RuntimeError If no port specified and auto-detect fails.
|
||||
@throws RuntimeError If unable to open the serial port.
|
||||
"""
|
||||
# Auto-detect port if not specified
|
||||
if self.port is None:
|
||||
self.port = self._auto_detect_port()
|
||||
|
||||
if self.port is None:
|
||||
raise RuntimeError(
|
||||
"No serial port specified and auto-detect failed.\n"
|
||||
"Use RealSerialStream.list_ports() to see available ports."
|
||||
)
|
||||
|
||||
# Open serial connection
|
||||
try:
|
||||
self.serial = serial.Serial(
|
||||
port=self.port,
|
||||
baudrate=self.baud_rate,
|
||||
timeout=self.timeout
|
||||
)
|
||||
self.running = True
|
||||
|
||||
# Clear any stale data in the buffer
|
||||
self.serial.reset_input_buffer()
|
||||
|
||||
print(f"[SERIAL] Connected to {self.port} at {self.baud_rate} baud")
|
||||
|
||||
except serial.SerialException as e:
|
||||
raise RuntimeError(f"Failed to open {self.port}: {e}")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""
|
||||
@brief Close the serial connection.
|
||||
|
||||
Safe to call even if not connected.
|
||||
"""
|
||||
self.running = False
|
||||
|
||||
if self.serial and self.serial.is_open:
|
||||
self.serial.close()
|
||||
print(f"[SERIAL] Disconnected from {self.port}")
|
||||
|
||||
def readline(self) -> Optional[str]:
|
||||
"""
|
||||
@brief Read one line of data from the ESP32.
|
||||
|
||||
Blocks until a complete line is received or timeout occurs.
|
||||
This matches the interface of SimulatedEMGStream.readline().
|
||||
|
||||
@return Line string including newline, or None if timeout/error.
|
||||
|
||||
@note Lines from ESP32 are in format: "timestamp_ms,ch0,ch1,ch2,ch3\\n"
|
||||
"""
|
||||
if not self.serial or not self.serial.is_open:
|
||||
return None
|
||||
|
||||
try:
|
||||
line_bytes = self.serial.readline()
|
||||
if line_bytes:
|
||||
return line_bytes.decode('utf-8', errors='ignore')
|
||||
return None
|
||||
|
||||
except serial.SerialException:
|
||||
return None
|
||||
|
||||
def _auto_detect_port(self) -> Optional[str]:
|
||||
"""
|
||||
@brief Attempt to auto-detect the ESP32 serial port.
|
||||
|
||||
Looks for common USB-UART bridge chips used on ESP32 dev boards:
|
||||
- CP210x (Silicon Labs)
|
||||
- CH340 (WCH)
|
||||
- FTDI
|
||||
|
||||
@return Port name if found, None otherwise.
|
||||
"""
|
||||
ports = serial.tools.list_ports.comports()
|
||||
|
||||
# Known USB-UART chip identifiers
|
||||
known_chips = ['cp210', 'ch340', 'ftdi', 'usb-serial', 'usb serial']
|
||||
|
||||
for port in ports:
|
||||
description_lower = port.description.lower()
|
||||
if any(chip in description_lower for chip in known_chips):
|
||||
print(f"[SERIAL] Auto-detected ESP32 on {port.device}")
|
||||
return port.device
|
||||
|
||||
# Fallback: use first available port
|
||||
if ports:
|
||||
print(f"[SERIAL] No ESP32 detected, using first port: {ports[0].device}")
|
||||
return ports[0].device
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def list_ports() -> List[str]:
|
||||
"""
|
||||
@brief List all available serial ports on the system.
|
||||
|
||||
Useful for finding the correct port name for your ESP32.
|
||||
|
||||
@return List of port names (e.g., ['COM3', 'COM4'] on Windows).
|
||||
"""
|
||||
ports = serial.tools.list_ports.comports()
|
||||
|
||||
if not ports:
|
||||
print("No serial ports found.")
|
||||
return []
|
||||
|
||||
print("\nAvailable serial ports:")
|
||||
print("-" * 50)
|
||||
|
||||
for port in ports:
|
||||
print(f" {port.device}")
|
||||
print(f" Description: {port.description}")
|
||||
print()
|
||||
|
||||
return [p.device for p in ports]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Standalone Test
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
"""
|
||||
@brief Quick test to verify ESP32 serial communication.
|
||||
|
||||
Run this file directly to test:
|
||||
python serial_stream.py [port]
|
||||
|
||||
If port is not specified, auto-detection is attempted.
|
||||
"""
|
||||
import sys
|
||||
|
||||
print("=" * 50)
|
||||
print(" ESP32 Serial Stream Test")
|
||||
print("=" * 50)
|
||||
print()
|
||||
|
||||
# Show available ports
|
||||
ports = RealSerialStream.list_ports()
|
||||
|
||||
if not ports:
|
||||
print("No ports found. Is the ESP32 plugged in?")
|
||||
sys.exit(1)
|
||||
|
||||
# Determine which port to use
|
||||
if len(sys.argv) > 1:
|
||||
port = sys.argv[1]
|
||||
print(f"Using specified port: {port}")
|
||||
else:
|
||||
port = None # Will auto-detect
|
||||
print("No port specified, will auto-detect.")
|
||||
|
||||
print()
|
||||
print("Starting stream... (Ctrl+C to stop)")
|
||||
print("-" * 50)
|
||||
|
||||
# Create and start stream
|
||||
stream = RealSerialStream(port=port)
|
||||
|
||||
try:
|
||||
stream.start()
|
||||
|
||||
sample_count = 0
|
||||
|
||||
while True:
|
||||
line = stream.readline()
|
||||
|
||||
if line:
|
||||
line = line.strip()
|
||||
|
||||
# Check if this is a data line (starts with digit = timestamp)
|
||||
if line and line[0].isdigit():
|
||||
sample_count += 1
|
||||
|
||||
# Print every 500th sample to avoid flooding terminal
|
||||
if sample_count % 500 == 0:
|
||||
print(f" [{sample_count:6d} samples] Latest: {line}")
|
||||
|
||||
else:
|
||||
# Print startup/info messages from ESP32
|
||||
print(f" {line}")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n")
|
||||
print("-" * 50)
|
||||
print("Stopped by user (Ctrl+C)")
|
||||
|
||||
finally:
|
||||
stream.stop()
|
||||
print(f"Total samples received: {sample_count}")
|
||||
Reference in New Issue
Block a user