App-Device handshake complete
This commit is contained in:
@@ -2,16 +2,21 @@
|
|||||||
* @file main.c
|
* @file main.c
|
||||||
* @brief Application entry point for the EMG-controlled robotic hand.
|
* @brief Application entry point for the EMG-controlled robotic hand.
|
||||||
*
|
*
|
||||||
* This is the top-level application that initializes all subsystems
|
* Implements a robust handshake protocol with the host computer:
|
||||||
* and runs the main loop. Currently configured to stream EMG data
|
* 1. Wait for "connect" command
|
||||||
* over USB serial for Python to receive.
|
* 2. Acknowledge connection
|
||||||
|
* 3. Wait for "start" command
|
||||||
|
* 4. Stream EMG data
|
||||||
|
* 5. Handle "stop" and "disconnect" commands
|
||||||
*
|
*
|
||||||
* @note This is Layer 4 (Application).
|
* @note This is Layer 4 (Application).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
#include <freertos/FreeRTOS.h>
|
#include <freertos/FreeRTOS.h>
|
||||||
#include <freertos/task.h>
|
#include <freertos/task.h>
|
||||||
|
#include <freertos/queue.h>
|
||||||
#include "esp_timer.h"
|
#include "esp_timer.h"
|
||||||
|
|
||||||
#include "config/config.h"
|
#include "config/config.h"
|
||||||
@@ -20,29 +25,206 @@
|
|||||||
#include "core/gestures.h"
|
#include "core/gestures.h"
|
||||||
|
|
||||||
/*******************************************************************************
|
/*******************************************************************************
|
||||||
* Private Functions
|
* Constants
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
#define CMD_BUFFER_SIZE 128
|
||||||
|
#define JSON_RESPONSE_SIZE 128
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
* Types
|
||||||
******************************************************************************/
|
******************************************************************************/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Stream EMG data over USB serial.
|
* @brief Device state machine.
|
||||||
|
*/
|
||||||
|
typedef enum {
|
||||||
|
STATE_IDLE = 0, /**< Waiting for connect command */
|
||||||
|
STATE_CONNECTED, /**< Connected, waiting for start command */
|
||||||
|
STATE_STREAMING, /**< Actively streaming EMG data */
|
||||||
|
} device_state_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Commands from host.
|
||||||
|
*/
|
||||||
|
typedef enum {
|
||||||
|
CMD_NONE = 0,
|
||||||
|
CMD_CONNECT,
|
||||||
|
CMD_START,
|
||||||
|
CMD_STOP,
|
||||||
|
CMD_DISCONNECT,
|
||||||
|
} command_t;
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
* Global State
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
static volatile device_state_t g_device_state = STATE_IDLE;
|
||||||
|
static QueueHandle_t g_cmd_queue = NULL;
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
* Forward Declarations
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
static void send_ack_connect(void);
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
* Command Parsing
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Parse incoming command from JSON.
|
||||||
*
|
*
|
||||||
* Outputs data in format: "timestamp_ms,ch0,ch1,ch2,ch3\n"
|
* Expected format: {"cmd": "connect"}
|
||||||
* This matches what Python's SimulatedEMGStream produces.
|
*
|
||||||
|
* @param line Input line from serial
|
||||||
|
* @return Parsed command
|
||||||
|
*/
|
||||||
|
static command_t parse_command(const char* line)
|
||||||
|
{
|
||||||
|
/* Simple JSON parsing - look for "cmd" field */
|
||||||
|
const char* cmd_start = strstr(line, "\"cmd\"");
|
||||||
|
if (!cmd_start) {
|
||||||
|
return CMD_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Find the value after "cmd": */
|
||||||
|
const char* value_start = strchr(cmd_start, ':');
|
||||||
|
if (!value_start) {
|
||||||
|
return CMD_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skip whitespace and opening quote */
|
||||||
|
value_start++;
|
||||||
|
while (*value_start == ' ' || *value_start == '"') {
|
||||||
|
value_start++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Match command strings */
|
||||||
|
if (strncmp(value_start, "connect", 7) == 0) {
|
||||||
|
return CMD_CONNECT;
|
||||||
|
} else if (strncmp(value_start, "start", 5) == 0) {
|
||||||
|
return CMD_START;
|
||||||
|
} else if (strncmp(value_start, "stop", 4) == 0) {
|
||||||
|
return CMD_STOP;
|
||||||
|
} else if (strncmp(value_start, "disconnect", 10) == 0) {
|
||||||
|
return CMD_DISCONNECT;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CMD_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
* Serial Input Task
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief FreeRTOS task to read serial input and parse commands.
|
||||||
|
*
|
||||||
|
* This task runs continuously, reading lines from stdin (USB serial)
|
||||||
|
* and updating device state directly. This allows commands to interrupt
|
||||||
|
* streaming immediately via the volatile state variable.
|
||||||
|
*
|
||||||
|
* @param pvParameters Unused
|
||||||
|
*/
|
||||||
|
static void serial_input_task(void* pvParameters)
|
||||||
|
{
|
||||||
|
char line_buffer[CMD_BUFFER_SIZE];
|
||||||
|
int line_idx = 0;
|
||||||
|
|
||||||
|
while (1) {
|
||||||
|
/* Read one character at a time */
|
||||||
|
int c = getchar();
|
||||||
|
|
||||||
|
if (c == EOF || c == 0xFF) {
|
||||||
|
/* No data available, yield to other tasks */
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(10));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c == '\n' || c == '\r') {
|
||||||
|
/* End of line - process command */
|
||||||
|
if (line_idx > 0) {
|
||||||
|
line_buffer[line_idx] = '\0';
|
||||||
|
|
||||||
|
command_t cmd = parse_command(line_buffer);
|
||||||
|
|
||||||
|
if (cmd != CMD_NONE) {
|
||||||
|
/* Handle state transitions directly */
|
||||||
|
/* This allows streaming loop to see state changes immediately */
|
||||||
|
switch (g_device_state) {
|
||||||
|
case STATE_IDLE:
|
||||||
|
if (cmd == CMD_CONNECT) {
|
||||||
|
g_device_state = STATE_CONNECTED;
|
||||||
|
send_ack_connect();
|
||||||
|
printf("[STATE] IDLE -> CONNECTED\n");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case STATE_CONNECTED:
|
||||||
|
if (cmd == CMD_START) {
|
||||||
|
g_device_state = STATE_STREAMING;
|
||||||
|
printf("[STATE] CONNECTED -> STREAMING\n");
|
||||||
|
/* Signal state machine to start streaming */
|
||||||
|
xQueueSend(g_cmd_queue, &cmd, 0);
|
||||||
|
} else if (cmd == CMD_DISCONNECT) {
|
||||||
|
g_device_state = STATE_IDLE;
|
||||||
|
printf("[STATE] CONNECTED -> IDLE\n");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case STATE_STREAMING:
|
||||||
|
if (cmd == CMD_STOP) {
|
||||||
|
g_device_state = STATE_CONNECTED;
|
||||||
|
printf("[STATE] STREAMING -> CONNECTED\n");
|
||||||
|
/* Streaming loop will exit when it sees state change */
|
||||||
|
} else if (cmd == CMD_DISCONNECT) {
|
||||||
|
g_device_state = STATE_IDLE;
|
||||||
|
printf("[STATE] STREAMING -> IDLE\n");
|
||||||
|
/* Streaming loop will exit when it sees state change */
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
line_idx = 0;
|
||||||
|
}
|
||||||
|
} else if (line_idx < CMD_BUFFER_SIZE - 1) {
|
||||||
|
/* Add character to buffer */
|
||||||
|
line_buffer[line_idx++] = (char)c;
|
||||||
|
} else {
|
||||||
|
/* Buffer overflow - reset */
|
||||||
|
line_idx = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
* State Machine
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Send JSON acknowledgment for connection.
|
||||||
|
*/
|
||||||
|
static void send_ack_connect(void)
|
||||||
|
{
|
||||||
|
printf("{\"status\":\"ack_connect\",\"device\":\"ESP32-EMG\",\"channels\":%d}\n",
|
||||||
|
EMG_NUM_CHANNELS);
|
||||||
|
fflush(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Stream EMG data continuously until stopped.
|
||||||
|
*
|
||||||
|
* This function blocks and streams data at the configured sample rate.
|
||||||
|
* Returns when state changes from STREAMING.
|
||||||
*/
|
*/
|
||||||
static void stream_emg_data(void)
|
static void stream_emg_data(void)
|
||||||
{
|
{
|
||||||
emg_sample_t sample;
|
emg_sample_t sample;
|
||||||
|
|
||||||
printf("\n[EMG] Starting data stream at %d Hz...\n", EMG_SAMPLE_RATE_HZ);
|
|
||||||
printf("[EMG] Format: timestamp_ms,ch0,ch1,ch2,ch3\n\n");
|
|
||||||
|
|
||||||
/*
|
|
||||||
* FreeRTOS tick rate is set to 1000 Hz in sdkconfig.defaults (1ms per tick).
|
|
||||||
* Delay of 1 tick = 1ms, giving us the full 1000 Hz sample rate.
|
|
||||||
*/
|
|
||||||
const TickType_t delay_ticks = 1; /* 1 tick = 1ms at 1000 Hz tick rate */
|
const TickType_t delay_ticks = 1; /* 1 tick = 1ms at 1000 Hz tick rate */
|
||||||
|
|
||||||
while (1) {
|
while (g_device_state == STATE_STREAMING) {
|
||||||
/* Read EMG (fake or real depending on FEATURE_FAKE_EMG) */
|
/* Read EMG (fake or real depending on FEATURE_FAKE_EMG) */
|
||||||
emg_sensor_read(&sample);
|
emg_sensor_read(&sample);
|
||||||
|
|
||||||
@@ -60,14 +242,30 @@ static void stream_emg_data(void)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Run demo mode - cycle through gestures.
|
* @brief Main state machine loop.
|
||||||
|
*
|
||||||
|
* Monitors device state and starts streaming when requested.
|
||||||
|
* Serial input task handles all state transitions directly.
|
||||||
*/
|
*/
|
||||||
static void run_demo(void)
|
static void state_machine_loop(void)
|
||||||
{
|
{
|
||||||
printf("\n[DEMO] Running gesture demo...\n");
|
command_t cmd;
|
||||||
|
const TickType_t poll_interval = pdMS_TO_TICKS(50);
|
||||||
|
|
||||||
while (1) {
|
while (1) {
|
||||||
gestures_demo_fist(1000);
|
/* Check if we should start streaming */
|
||||||
|
if (g_device_state == STATE_STREAMING) {
|
||||||
|
/* Stream until state changes (via serial input task) */
|
||||||
|
stream_emg_data();
|
||||||
|
/* Returns when state is no longer STREAMING */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wait for start command or just poll state */
|
||||||
|
/* Timeout allows checking state even if queue is empty */
|
||||||
|
xQueueReceive(g_cmd_queue, &cmd, poll_interval);
|
||||||
|
|
||||||
|
/* Note: State transitions are handled by serial_input_task */
|
||||||
|
/* This loop only triggers streaming when state becomes STREAMING */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +278,7 @@ void app_main(void)
|
|||||||
printf("\n");
|
printf("\n");
|
||||||
printf("========================================\n");
|
printf("========================================\n");
|
||||||
printf(" Bucky Arm - EMG Robotic Hand\n");
|
printf(" Bucky Arm - EMG Robotic Hand\n");
|
||||||
printf(" Firmware v1.0.0\n");
|
printf(" Firmware v2.0.0 (Handshake Protocol)\n");
|
||||||
printf("========================================\n\n");
|
printf("========================================\n\n");
|
||||||
|
|
||||||
/* Initialize subsystems */
|
/* Initialize subsystems */
|
||||||
@@ -98,16 +296,26 @@ void app_main(void)
|
|||||||
|
|
||||||
printf("[INIT] Done!\n\n");
|
printf("[INIT] Done!\n\n");
|
||||||
|
|
||||||
/*
|
/* Create command queue */
|
||||||
* Choose what to run:
|
g_cmd_queue = xQueueCreate(10, sizeof(command_t));
|
||||||
* - stream_emg_data(): Send EMG data to laptop (Phase 1)
|
if (g_cmd_queue == NULL) {
|
||||||
* - run_demo(): Test servo movement
|
printf("[ERROR] Failed to create command queue!\n");
|
||||||
*
|
return;
|
||||||
* For now, we stream EMG data.
|
}
|
||||||
* Comment out and use run_demo() to test servos.
|
|
||||||
*/
|
|
||||||
stream_emg_data();
|
|
||||||
|
|
||||||
/* Alternative: run servo demo */
|
/* Launch serial input task */
|
||||||
// run_demo();
|
xTaskCreate(
|
||||||
|
serial_input_task,
|
||||||
|
"serial_input",
|
||||||
|
4096, /* Stack size */
|
||||||
|
NULL, /* Parameters */
|
||||||
|
5, /* Priority */
|
||||||
|
NULL /* Task handle */
|
||||||
|
);
|
||||||
|
|
||||||
|
printf("[PROTOCOL] Waiting for host to connect...\n");
|
||||||
|
printf("[PROTOCOL] Send: {\"cmd\": \"connect\"}\n\n");
|
||||||
|
|
||||||
|
/* Run main state machine */
|
||||||
|
state_machine_loop();
|
||||||
}
|
}
|
||||||
|
|||||||
330
emg_gui.py
330
emg_gui.py
@@ -310,6 +310,7 @@ class CollectionPage(BasePage):
|
|||||||
|
|
||||||
# Collection state
|
# Collection state
|
||||||
self.is_collecting = False
|
self.is_collecting = False
|
||||||
|
self.is_connected = False
|
||||||
self.using_real_hardware = False
|
self.using_real_hardware = False
|
||||||
self.stream = None
|
self.stream = None
|
||||||
self.parser = None
|
self.parser = None
|
||||||
@@ -377,12 +378,23 @@ class CollectionPage(BasePage):
|
|||||||
)
|
)
|
||||||
self.refresh_ports_btn.pack(side="left")
|
self.refresh_ports_btn.pack(side="left")
|
||||||
|
|
||||||
# Connection status indicator
|
# Connection status and button
|
||||||
|
connect_frame = ctk.CTkFrame(self.port_frame, fg_color="transparent")
|
||||||
|
connect_frame.pack(fill="x", pady=(5, 0))
|
||||||
|
|
||||||
|
self.connect_button = ctk.CTkButton(
|
||||||
|
connect_frame, text="Connect",
|
||||||
|
width=100, height=28,
|
||||||
|
command=self._toggle_connection,
|
||||||
|
state="disabled" # Disabled until "Real ESP32" selected
|
||||||
|
)
|
||||||
|
self.connect_button.pack(side="left", padx=(0, 10))
|
||||||
|
|
||||||
self.connection_status = ctk.CTkLabel(
|
self.connection_status = ctk.CTkLabel(
|
||||||
self.port_frame, text="● Not connected",
|
connect_frame, text="● Disconnected",
|
||||||
font=ctk.CTkFont(size=11), text_color="gray"
|
font=ctk.CTkFont(size=11), text_color="gray"
|
||||||
)
|
)
|
||||||
self.connection_status.pack(anchor="w", pady=(5, 0))
|
self.connection_status.pack(side="left")
|
||||||
|
|
||||||
# Gesture selection
|
# Gesture selection
|
||||||
gesture_frame = ctk.CTkFrame(self.controls_panel, fg_color="transparent")
|
gesture_frame = ctk.CTkFrame(self.controls_panel, fg_color="transparent")
|
||||||
@@ -529,7 +541,6 @@ class CollectionPage(BasePage):
|
|||||||
|
|
||||||
def start_collection(self):
|
def start_collection(self):
|
||||||
"""Start data collection."""
|
"""Start data collection."""
|
||||||
self.is_collecting = True
|
|
||||||
# Get selected gestures
|
# Get selected gestures
|
||||||
gestures = [g for g, var in self.gesture_vars.items() if var.get()]
|
gestures = [g for g, var in self.gesture_vars.items() if var.get()]
|
||||||
if not gestures:
|
if not gestures:
|
||||||
@@ -540,18 +551,16 @@ class CollectionPage(BasePage):
|
|||||||
self.using_real_hardware = (self.source_var.get() == "real")
|
self.using_real_hardware = (self.source_var.get() == "real")
|
||||||
|
|
||||||
if self.using_real_hardware:
|
if self.using_real_hardware:
|
||||||
# Real ESP32 serial stream
|
# Must be connected for real hardware
|
||||||
port = self._get_serial_port()
|
if not self.is_connected or not self.stream:
|
||||||
|
messagebox.showerror("Not Connected", "Please connect to the ESP32 first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Send start command to begin streaming
|
||||||
try:
|
try:
|
||||||
self.stream = RealSerialStream(port=port)
|
self.stream.start()
|
||||||
self._update_connection_status("orange", "Connecting...")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Failed to create serial stream:\n{e}"
|
messagebox.showerror("Start Error", f"Failed to start streaming:\n{e}")
|
||||||
if "PermissionError" in str(type(e).__name__) or "Permission denied" in str(e):
|
|
||||||
error_msg += "\n\nThe port may still be in use. Wait a few seconds and try again."
|
|
||||||
elif "FileNotFoundError" in str(type(e).__name__):
|
|
||||||
error_msg += f"\n\nPort '{port}' not found. Try refreshing the port list."
|
|
||||||
messagebox.showerror("Connection Error", error_msg)
|
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
# Simulated stream (gesture-aware for realistic testing)
|
# Simulated stream (gesture-aware for realistic testing)
|
||||||
@@ -577,14 +586,19 @@ class CollectionPage(BasePage):
|
|||||||
self.collected_labels = []
|
self.collected_labels = []
|
||||||
self.sample_buffer = []
|
self.sample_buffer = []
|
||||||
|
|
||||||
|
# Mark as collecting
|
||||||
|
self.is_collecting = True
|
||||||
|
|
||||||
# Update UI
|
# Update UI
|
||||||
self.start_button.configure(text="Stop Collection", fg_color="red")
|
self.start_button.configure(text="Stop Collection", fg_color="red")
|
||||||
self.save_button.configure(state="disabled")
|
self.save_button.configure(state="disabled")
|
||||||
self.status_label.configure(text="Starting...")
|
self.status_label.configure(text="Starting...")
|
||||||
|
|
||||||
# Disable source selection during collection
|
# Disable source selection and connection during collection
|
||||||
self.sim_radio.configure(state="disabled")
|
self.sim_radio.configure(state="disabled")
|
||||||
self.real_radio.configure(state="disabled")
|
self.real_radio.configure(state="disabled")
|
||||||
|
if self.using_real_hardware:
|
||||||
|
self.connect_button.configure(state="disabled")
|
||||||
|
|
||||||
# Start collection thread
|
# Start collection thread
|
||||||
self.collection_thread = threading.Thread(target=self.collection_loop, daemon=True)
|
self.collection_thread = threading.Thread(target=self.collection_loop, daemon=True)
|
||||||
@@ -600,16 +614,16 @@ class CollectionPage(BasePage):
|
|||||||
# Safe cleanup - stream might already be in error state
|
# Safe cleanup - stream might already be in error state
|
||||||
try:
|
try:
|
||||||
if self.stream:
|
if self.stream:
|
||||||
self.stream.stop()
|
|
||||||
# Give OS time to release the port (important for macOS)
|
|
||||||
if self.using_real_hardware:
|
if self.using_real_hardware:
|
||||||
time.sleep(0.5)
|
# Send stop command (returns to CONNECTED state)
|
||||||
|
self.stream.stop()
|
||||||
|
else:
|
||||||
|
# For simulated stream, just stop it
|
||||||
|
self.stream.stop()
|
||||||
|
self.stream = None
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Ignore cleanup errors
|
pass # Ignore cleanup errors
|
||||||
|
|
||||||
# Clear stream reference
|
|
||||||
self.stream = None
|
|
||||||
|
|
||||||
# Drain any pending messages from queue to prevent stale data
|
# Drain any pending messages from queue to prevent stale data
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -622,27 +636,25 @@ class CollectionPage(BasePage):
|
|||||||
self.prompt_label.configure(text="DONE", text_color="green")
|
self.prompt_label.configure(text="DONE", text_color="green")
|
||||||
self.countdown_label.configure(text="")
|
self.countdown_label.configure(text="")
|
||||||
|
|
||||||
# Re-enable source selection
|
# Re-enable source selection and connection button
|
||||||
self.sim_radio.configure(state="normal")
|
self.sim_radio.configure(state="normal")
|
||||||
self.real_radio.configure(state="normal")
|
self.real_radio.configure(state="normal")
|
||||||
|
|
||||||
# Update connection status
|
|
||||||
if self.using_real_hardware:
|
if self.using_real_hardware:
|
||||||
self._update_connection_status("gray", "Disconnected")
|
self.connect_button.configure(state="normal")
|
||||||
|
# Still connected, just not streaming
|
||||||
|
if self.is_connected:
|
||||||
|
device_name = self.stream.device_info.get('device', 'ESP32') if self.stream and self.stream.device_info else 'ESP32'
|
||||||
|
self._update_connection_status("green", f"Connected ({device_name})")
|
||||||
|
|
||||||
if self.collected_windows:
|
if self.collected_windows:
|
||||||
self.save_button.configure(state="normal")
|
self.save_button.configure(state="normal")
|
||||||
|
|
||||||
def collection_loop(self):
|
def collection_loop(self):
|
||||||
"""Background collection loop."""
|
"""Background collection loop."""
|
||||||
# Try to start the stream (may fail for real hardware)
|
# Stream is already started (either via handshake for real HW or created for simulated)
|
||||||
try:
|
# Just mark as ready
|
||||||
self.stream.start()
|
|
||||||
if self.using_real_hardware:
|
if self.using_real_hardware:
|
||||||
self.data_queue.put(('connection_status', ('green', 'Connected')))
|
self.data_queue.put(('connection_status', ('green', 'Streaming')))
|
||||||
except Exception as e:
|
|
||||||
self.data_queue.put(('error', f"Failed to connect: {e}"))
|
|
||||||
return
|
|
||||||
|
|
||||||
self.scheduler.start_session()
|
self.scheduler.start_session()
|
||||||
|
|
||||||
@@ -861,9 +873,13 @@ class CollectionPage(BasePage):
|
|||||||
if self.source_var.get() == "real":
|
if self.source_var.get() == "real":
|
||||||
self.port_frame.pack(fill="x", pady=(5, 0))
|
self.port_frame.pack(fill="x", pady=(5, 0))
|
||||||
self._refresh_ports()
|
self._refresh_ports()
|
||||||
|
self.connect_button.configure(state="normal")
|
||||||
|
self.start_button.configure(state="disabled") # Must connect first
|
||||||
else:
|
else:
|
||||||
self.port_frame.pack_forget()
|
self.port_frame.pack_forget()
|
||||||
self._update_connection_status("gray", "Not using hardware")
|
self._update_connection_status("gray", "Not using hardware")
|
||||||
|
self.connect_button.configure(state="disabled")
|
||||||
|
self.start_button.configure(state="normal") # Simulated mode doesn't need connect
|
||||||
|
|
||||||
def _refresh_ports(self):
|
def _refresh_ports(self):
|
||||||
"""Scan and populate available serial ports."""
|
"""Scan and populate available serial ports."""
|
||||||
@@ -888,6 +904,91 @@ class CollectionPage(BasePage):
|
|||||||
"""Update the connection status indicator."""
|
"""Update the connection status indicator."""
|
||||||
self.connection_status.configure(text=f"● {text}", text_color=color)
|
self.connection_status.configure(text=f"● {text}", text_color=color)
|
||||||
|
|
||||||
|
def _toggle_connection(self):
|
||||||
|
"""Connect or disconnect from ESP32."""
|
||||||
|
if self.is_connected:
|
||||||
|
self._disconnect_device()
|
||||||
|
else:
|
||||||
|
self._connect_device()
|
||||||
|
|
||||||
|
def _connect_device(self):
|
||||||
|
"""Connect to ESP32 with handshake."""
|
||||||
|
port = self._get_serial_port()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Update UI to show connecting
|
||||||
|
self._update_connection_status("orange", "Connecting...")
|
||||||
|
self.connect_button.configure(state="disabled")
|
||||||
|
self.update() # Force UI update
|
||||||
|
|
||||||
|
# Create stream and connect
|
||||||
|
self.stream = RealSerialStream(port=port)
|
||||||
|
device_info = self.stream.connect(timeout=5.0)
|
||||||
|
|
||||||
|
# Success!
|
||||||
|
self.is_connected = True
|
||||||
|
self._update_connection_status("green", f"Connected ({device_info.get('device', 'ESP32')})")
|
||||||
|
self.connect_button.configure(text="Disconnect", state="normal")
|
||||||
|
self.start_button.configure(state="normal")
|
||||||
|
|
||||||
|
except TimeoutError as e:
|
||||||
|
messagebox.showerror(
|
||||||
|
"Connection Timeout",
|
||||||
|
f"Device did not respond within 5 seconds.\n\n"
|
||||||
|
f"Check that:\n"
|
||||||
|
f"• ESP32 is powered on\n"
|
||||||
|
f"• Correct firmware is flashed\n"
|
||||||
|
f"• USB cable is properly connected"
|
||||||
|
)
|
||||||
|
self._update_connection_status("red", "Timeout")
|
||||||
|
self.connect_button.configure(state="normal")
|
||||||
|
if self.stream:
|
||||||
|
try:
|
||||||
|
self.stream.disconnect()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
self.stream = None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Failed to connect:\n{e}"
|
||||||
|
if "Permission denied" in str(e) or "Resource busy" in str(e):
|
||||||
|
error_msg += "\n\nThe port may still be in use. Wait a few seconds and try again."
|
||||||
|
elif "FileNotFoundError" in str(type(e).__name__):
|
||||||
|
error_msg += f"\n\nPort not found. Try refreshing the port list."
|
||||||
|
|
||||||
|
messagebox.showerror("Connection Error", error_msg)
|
||||||
|
self._update_connection_status("red", "Failed")
|
||||||
|
self.connect_button.configure(state="normal")
|
||||||
|
if self.stream:
|
||||||
|
try:
|
||||||
|
self.stream.disconnect()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
self.stream = None
|
||||||
|
|
||||||
|
def _disconnect_device(self):
|
||||||
|
"""Disconnect from ESP32."""
|
||||||
|
try:
|
||||||
|
if self.stream:
|
||||||
|
self.stream.disconnect()
|
||||||
|
# Give OS time to release the port
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
self.is_connected = False
|
||||||
|
self.stream = None
|
||||||
|
self._update_connection_status("gray", "Disconnected")
|
||||||
|
self.connect_button.configure(text="Connect")
|
||||||
|
self.start_button.configure(state="disabled")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showwarning("Disconnect Warning", f"Error during disconnect: {e}")
|
||||||
|
# Still mark as disconnected even if there was an error
|
||||||
|
self.is_connected = False
|
||||||
|
self.stream = None
|
||||||
|
self._update_connection_status("gray", "Disconnected")
|
||||||
|
self.connect_button.configure(text="Connect")
|
||||||
|
self.start_button.configure(state="disabled")
|
||||||
|
|
||||||
def on_hide(self):
|
def on_hide(self):
|
||||||
"""Stop collection when leaving page."""
|
"""Stop collection when leaving page."""
|
||||||
if self.is_collecting:
|
if self.is_collecting:
|
||||||
@@ -1280,11 +1381,23 @@ class PredictionPage(BasePage):
|
|||||||
)
|
)
|
||||||
self.refresh_ports_btn.pack(side="left")
|
self.refresh_ports_btn.pack(side="left")
|
||||||
|
|
||||||
|
# Connection status and button
|
||||||
|
connect_frame = ctk.CTkFrame(self.port_frame, fg_color="transparent")
|
||||||
|
connect_frame.pack(fill="x", pady=(5, 0))
|
||||||
|
|
||||||
|
self.connect_button = ctk.CTkButton(
|
||||||
|
connect_frame, text="Connect",
|
||||||
|
width=100, height=28,
|
||||||
|
command=self._toggle_connection,
|
||||||
|
state="disabled" # Disabled until "Real ESP32" selected
|
||||||
|
)
|
||||||
|
self.connect_button.pack(side="left", padx=(0, 10))
|
||||||
|
|
||||||
self.connection_status = ctk.CTkLabel(
|
self.connection_status = ctk.CTkLabel(
|
||||||
self.port_frame, text="● Not connected",
|
connect_frame, text="● Disconnected",
|
||||||
font=ctk.CTkFont(size=11), text_color="gray"
|
font=ctk.CTkFont(size=11), text_color="gray"
|
||||||
)
|
)
|
||||||
self.connection_status.pack(anchor="w", pady=(5, 0))
|
self.connection_status.pack(side="left")
|
||||||
|
|
||||||
# Start button
|
# Start button
|
||||||
self.start_button = ctk.CTkButton(
|
self.start_button = ctk.CTkButton(
|
||||||
@@ -1346,6 +1459,7 @@ class PredictionPage(BasePage):
|
|||||||
|
|
||||||
# State
|
# State
|
||||||
self.is_predicting = False
|
self.is_predicting = False
|
||||||
|
self.is_connected = False
|
||||||
self.using_real_hardware = False
|
self.using_real_hardware = False
|
||||||
self.classifier = None
|
self.classifier = None
|
||||||
self.smoother = None
|
self.smoother = None
|
||||||
@@ -1392,6 +1506,19 @@ class PredictionPage(BasePage):
|
|||||||
# Determine data source
|
# Determine data source
|
||||||
self.using_real_hardware = (self.source_var.get() == "real")
|
self.using_real_hardware = (self.source_var.get() == "real")
|
||||||
|
|
||||||
|
# For real hardware, must be connected
|
||||||
|
if self.using_real_hardware:
|
||||||
|
if not self.is_connected or not self.stream:
|
||||||
|
messagebox.showerror("Not Connected", "Please connect to the ESP32 first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Send start command to begin streaming
|
||||||
|
try:
|
||||||
|
self.stream.start()
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showerror("Start Error", f"Failed to start streaming:\n{e}")
|
||||||
|
return
|
||||||
|
|
||||||
# Create prediction smoother
|
# Create prediction smoother
|
||||||
self.smoother = PredictionSmoother(
|
self.smoother = PredictionSmoother(
|
||||||
label_names=self.classifier.label_names,
|
label_names=self.classifier.label_names,
|
||||||
@@ -1403,9 +1530,11 @@ class PredictionPage(BasePage):
|
|||||||
self.is_predicting = True
|
self.is_predicting = True
|
||||||
self.start_button.configure(text="Stop", fg_color="red")
|
self.start_button.configure(text="Stop", fg_color="red")
|
||||||
|
|
||||||
# Disable source selection during prediction
|
# Disable source selection and connection during prediction
|
||||||
self.sim_radio.configure(state="disabled")
|
self.sim_radio.configure(state="disabled")
|
||||||
self.real_radio.configure(state="disabled")
|
self.real_radio.configure(state="disabled")
|
||||||
|
if self.using_real_hardware:
|
||||||
|
self.connect_button.configure(state="disabled")
|
||||||
|
|
||||||
# Start prediction thread
|
# Start prediction thread
|
||||||
thread = threading.Thread(target=self._prediction_thread, daemon=True)
|
thread = threading.Thread(target=self._prediction_thread, daemon=True)
|
||||||
@@ -1421,10 +1550,13 @@ class PredictionPage(BasePage):
|
|||||||
# Safe cleanup - stream might already be in error state
|
# Safe cleanup - stream might already be in error state
|
||||||
try:
|
try:
|
||||||
if self.stream:
|
if self.stream:
|
||||||
self.stream.stop()
|
|
||||||
# Give OS time to release the port (important for macOS)
|
|
||||||
if self.using_real_hardware:
|
if self.using_real_hardware:
|
||||||
time.sleep(0.5)
|
# Send stop command (returns to CONNECTED state)
|
||||||
|
self.stream.stop()
|
||||||
|
else:
|
||||||
|
# For simulated stream, just stop it
|
||||||
|
self.stream.stop()
|
||||||
|
self.stream = None
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Ignore cleanup errors
|
pass # Ignore cleanup errors
|
||||||
|
|
||||||
@@ -1435,22 +1567,27 @@ class PredictionPage(BasePage):
|
|||||||
self.sim_label.configure(text="")
|
self.sim_label.configure(text="")
|
||||||
self.raw_label.configure(text="", text_color="gray")
|
self.raw_label.configure(text="", text_color="gray")
|
||||||
|
|
||||||
# Re-enable source selection
|
# Re-enable source selection and connection button
|
||||||
self.sim_radio.configure(state="normal")
|
self.sim_radio.configure(state="normal")
|
||||||
self.real_radio.configure(state="normal")
|
self.real_radio.configure(state="normal")
|
||||||
|
|
||||||
# Update connection status
|
|
||||||
if self.using_real_hardware:
|
if self.using_real_hardware:
|
||||||
self._update_connection_status("gray", "Disconnected")
|
self.connect_button.configure(state="normal")
|
||||||
|
# Still connected, just not streaming
|
||||||
|
if self.is_connected:
|
||||||
|
device_name = self.stream.device_info.get('device', 'ESP32') if self.stream and self.stream.device_info else 'ESP32'
|
||||||
|
self._update_connection_status("green", f"Connected ({device_name})")
|
||||||
|
|
||||||
def _on_source_change(self):
|
def _on_source_change(self):
|
||||||
"""Show/hide port selection based on data source."""
|
"""Show/hide port selection based on data source."""
|
||||||
if self.source_var.get() == "real":
|
if self.source_var.get() == "real":
|
||||||
self.port_frame.pack(fill="x", pady=(5, 0))
|
self.port_frame.pack(fill="x", pady=(5, 0))
|
||||||
self._refresh_ports()
|
self._refresh_ports()
|
||||||
|
self.connect_button.configure(state="normal")
|
||||||
|
# Start button will be enabled after connection
|
||||||
else:
|
else:
|
||||||
self.port_frame.pack_forget()
|
self.port_frame.pack_forget()
|
||||||
self._update_connection_status("gray", "Not using hardware")
|
self._update_connection_status("gray", "Not using hardware")
|
||||||
|
self.connect_button.configure(state="disabled")
|
||||||
|
|
||||||
def _refresh_ports(self):
|
def _refresh_ports(self):
|
||||||
"""Scan and populate available serial ports."""
|
"""Scan and populate available serial ports."""
|
||||||
@@ -1472,22 +1609,95 @@ class PredictionPage(BasePage):
|
|||||||
"""Update the connection status indicator."""
|
"""Update the connection status indicator."""
|
||||||
self.connection_status.configure(text=f"● {text}", text_color=color)
|
self.connection_status.configure(text=f"● {text}", text_color=color)
|
||||||
|
|
||||||
|
def _toggle_connection(self):
|
||||||
|
"""Connect or disconnect from ESP32."""
|
||||||
|
if self.is_connected:
|
||||||
|
self._disconnect_device()
|
||||||
|
else:
|
||||||
|
self._connect_device()
|
||||||
|
|
||||||
|
def _connect_device(self):
|
||||||
|
"""Connect to ESP32 with handshake."""
|
||||||
|
port = self._get_serial_port()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Update UI to show connecting
|
||||||
|
self._update_connection_status("orange", "Connecting...")
|
||||||
|
self.connect_button.configure(state="disabled")
|
||||||
|
self.update() # Force UI update
|
||||||
|
|
||||||
|
# Create stream and connect
|
||||||
|
self.stream = RealSerialStream(port=port)
|
||||||
|
device_info = self.stream.connect(timeout=5.0)
|
||||||
|
|
||||||
|
# Success!
|
||||||
|
self.is_connected = True
|
||||||
|
self._update_connection_status("green", f"Connected ({device_info.get('device', 'ESP32')})")
|
||||||
|
self.connect_button.configure(text="Disconnect", state="normal")
|
||||||
|
|
||||||
|
except TimeoutError as e:
|
||||||
|
messagebox.showerror(
|
||||||
|
"Connection Timeout",
|
||||||
|
f"Device did not respond within 5 seconds.\n\n"
|
||||||
|
f"Check that:\n"
|
||||||
|
f"• ESP32 is powered on\n"
|
||||||
|
f"• Correct firmware is flashed\n"
|
||||||
|
f"• USB cable is properly connected"
|
||||||
|
)
|
||||||
|
self._update_connection_status("red", "Timeout")
|
||||||
|
self.connect_button.configure(state="normal")
|
||||||
|
if self.stream:
|
||||||
|
try:
|
||||||
|
self.stream.disconnect()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
self.stream = None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Failed to connect:\n{e}"
|
||||||
|
if "Permission denied" in str(e) or "Resource busy" in str(e):
|
||||||
|
error_msg += "\n\nThe port may still be in use. Wait a few seconds and try again."
|
||||||
|
elif "FileNotFoundError" in str(type(e).__name__):
|
||||||
|
error_msg += f"\n\nPort not found. Try refreshing the port list."
|
||||||
|
|
||||||
|
messagebox.showerror("Connection Error", error_msg)
|
||||||
|
self._update_connection_status("red", "Failed")
|
||||||
|
self.connect_button.configure(state="normal")
|
||||||
|
if self.stream:
|
||||||
|
try:
|
||||||
|
self.stream.disconnect()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
self.stream = None
|
||||||
|
|
||||||
|
def _disconnect_device(self):
|
||||||
|
"""Disconnect from ESP32."""
|
||||||
|
try:
|
||||||
|
if self.stream:
|
||||||
|
self.stream.disconnect()
|
||||||
|
# Give OS time to release the port
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
self.is_connected = False
|
||||||
|
self.stream = None
|
||||||
|
self._update_connection_status("gray", "Disconnected")
|
||||||
|
self.connect_button.configure(text="Connect")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showwarning("Disconnect Warning", f"Error during disconnect: {e}")
|
||||||
|
# Still mark as disconnected even if there was an error
|
||||||
|
self.is_connected = False
|
||||||
|
self.stream = None
|
||||||
|
self._update_connection_status("gray", "Disconnected")
|
||||||
|
self.connect_button.configure(text="Connect")
|
||||||
|
|
||||||
def _prediction_thread(self):
|
def _prediction_thread(self):
|
||||||
"""Background prediction thread."""
|
"""Background prediction thread."""
|
||||||
# Create appropriate stream based on source selection
|
# For simulated mode, create new stream
|
||||||
if self.using_real_hardware:
|
if not self.using_real_hardware:
|
||||||
port = self._get_serial_port()
|
|
||||||
try:
|
|
||||||
self.stream = RealSerialStream(port=port)
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Failed to create serial stream: {e}"
|
|
||||||
if "Permission denied" in str(e) or "Resource busy" in str(e):
|
|
||||||
error_msg += "\n\nThe port may still be in use. Wait a moment and try again."
|
|
||||||
self.data_queue.put(('error', error_msg))
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
self.stream = GestureAwareEMGStream(num_channels=NUM_CHANNELS, sample_rate=SAMPLING_RATE_HZ)
|
self.stream = GestureAwareEMGStream(num_channels=NUM_CHANNELS, sample_rate=SAMPLING_RATE_HZ)
|
||||||
|
|
||||||
|
# Stream is already started (either via handshake for real HW or will be started for simulated)
|
||||||
parser = EMGParser(num_channels=NUM_CHANNELS)
|
parser = EMGParser(num_channels=NUM_CHANNELS)
|
||||||
windower = Windower(window_size_ms=WINDOW_SIZE_MS, sample_rate=SAMPLING_RATE_HZ, overlap=0.0)
|
windower = Windower(window_size_ms=WINDOW_SIZE_MS, sample_rate=SAMPLING_RATE_HZ, overlap=0.0)
|
||||||
|
|
||||||
@@ -1498,16 +1708,18 @@ class PredictionPage(BasePage):
|
|||||||
gesture_start = time.perf_counter()
|
gesture_start = time.perf_counter()
|
||||||
current_gesture = gesture_cycle[0]
|
current_gesture = gesture_cycle[0]
|
||||||
|
|
||||||
# Start the stream
|
# Start simulated stream if needed
|
||||||
|
if not self.using_real_hardware:
|
||||||
try:
|
try:
|
||||||
if hasattr(self.stream, 'set_gesture'):
|
if hasattr(self.stream, 'set_gesture'):
|
||||||
self.stream.set_gesture(current_gesture)
|
self.stream.set_gesture(current_gesture)
|
||||||
self.stream.start()
|
self.stream.start()
|
||||||
|
|
||||||
if self.using_real_hardware:
|
|
||||||
self.data_queue.put(('connection_status', ('green', 'Connected')))
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.data_queue.put(('error', f"Failed to connect: {e}"))
|
self.data_queue.put(('error', f"Failed to start simulated stream: {e}"))
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# Real hardware is already streaming
|
||||||
|
self.data_queue.put(('connection_status', ('green', 'Streaming')))
|
||||||
return
|
return
|
||||||
|
|
||||||
while self.is_predicting:
|
while self.is_predicting:
|
||||||
|
|||||||
261
serial_stream.py
261
serial_stream.py
@@ -1,10 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
@file serial_stream.py
|
@file serial_stream.py
|
||||||
@brief Real serial stream for reading EMG data from ESP32.
|
@brief Real serial stream for reading EMG data from ESP32 with handshake protocol.
|
||||||
|
|
||||||
This module provides a serial communication interface for receiving
|
This module provides a serial communication interface for receiving
|
||||||
EMG data from the ESP32 microcontroller over USB. It implements the
|
EMG data from the ESP32 microcontroller over USB. It implements a
|
||||||
same interface as SimulatedEMGStream, making it a drop-in replacement.
|
robust handshake protocol to ensure reliable connection before streaming.
|
||||||
|
|
||||||
@section usage Usage Example
|
@section usage Usage Example
|
||||||
@code{.py}
|
@code{.py}
|
||||||
@@ -12,17 +12,50 @@ same interface as SimulatedEMGStream, making it a drop-in replacement.
|
|||||||
|
|
||||||
# Create stream (auto-detects port, or specify manually)
|
# Create stream (auto-detects port, or specify manually)
|
||||||
stream = RealSerialStream(port='COM3')
|
stream = RealSerialStream(port='COM3')
|
||||||
|
|
||||||
|
# Connect with handshake (raises on timeout/failure)
|
||||||
|
stream.connect(timeout=5.0)
|
||||||
|
|
||||||
|
# Start streaming
|
||||||
stream.start()
|
stream.start()
|
||||||
|
|
||||||
# Read data (same interface as SimulatedEMGStream)
|
# Read data
|
||||||
while True:
|
while True:
|
||||||
line = stream.readline()
|
line = stream.readline()
|
||||||
if line:
|
if line:
|
||||||
print(line) # "1234,512,489,501,523"
|
print(line) # "1234,512,489,501,523"
|
||||||
|
|
||||||
|
# Stop streaming (device returns to CONNECTED state)
|
||||||
stream.stop()
|
stream.stop()
|
||||||
|
|
||||||
|
# Disconnect (device returns to IDLE state)
|
||||||
|
stream.disconnect()
|
||||||
@endcode
|
@endcode
|
||||||
|
|
||||||
|
@section protocol Handshake Protocol & State Machine
|
||||||
|
|
||||||
|
ESP32 States:
|
||||||
|
IDLE - Waiting for "connect" command
|
||||||
|
CONNECTED - Handshake complete, waiting for "start" command
|
||||||
|
STREAMING - Actively sending CSV data
|
||||||
|
|
||||||
|
State Transitions:
|
||||||
|
1. connect() : IDLE → CONNECTED
|
||||||
|
App sends: {"cmd": "connect"}
|
||||||
|
Device responds: {"status": "ack_connect", "device": "ESP32-EMG", "channels": 4}
|
||||||
|
|
||||||
|
2. start() : CONNECTED → STREAMING
|
||||||
|
App sends: {"cmd": "start"}
|
||||||
|
Device starts streaming CSV data
|
||||||
|
|
||||||
|
3. stop() : STREAMING → CONNECTED
|
||||||
|
App sends: {"cmd": "stop"}
|
||||||
|
Device stops streaming, ready for new start command
|
||||||
|
|
||||||
|
4. disconnect() : ANY → IDLE
|
||||||
|
App sends: {"cmd": "disconnect"}
|
||||||
|
Device returns to idle, waiting for new connection
|
||||||
|
|
||||||
@section format Data Format
|
@section format Data Format
|
||||||
The ESP32 sends data as CSV lines:
|
The ESP32 sends data as CSV lines:
|
||||||
"timestamp_ms,ch0,ch1,ch2,ch3\\n"
|
"timestamp_ms,ch0,ch1,ch2,ch3\\n"
|
||||||
@@ -35,20 +68,32 @@ same interface as SimulatedEMGStream, making it a drop-in replacement.
|
|||||||
|
|
||||||
import serial
|
import serial
|
||||||
import serial.tools.list_ports
|
import serial.tools.list_ports
|
||||||
from typing import Optional, List
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionState(Enum):
|
||||||
|
"""Connection states for the serial stream."""
|
||||||
|
DISCONNECTED = 0 # No serial connection
|
||||||
|
CONNECTING = 1 # Serial open, waiting for handshake
|
||||||
|
CONNECTED = 2 # Handshake complete, ready to stream
|
||||||
|
STREAMING = 3 # Actively streaming data
|
||||||
|
|
||||||
|
|
||||||
class RealSerialStream:
|
class RealSerialStream:
|
||||||
"""
|
"""
|
||||||
@brief Reads EMG data from ESP32 over USB serial.
|
@brief Reads EMG data from ESP32 over USB serial with handshake protocol.
|
||||||
|
|
||||||
This class provides the same interface as SimulatedEMGStream:
|
This class implements a robust connection protocol:
|
||||||
- start() : Open serial connection
|
- connect() : Open serial port and perform handshake
|
||||||
- stop() : Close serial connection
|
- start() : Begin streaming data
|
||||||
|
- stop() : Stop streaming (device stays connected)
|
||||||
|
- disconnect() : Close connection (device returns to idle)
|
||||||
- readline() : Read one line of data
|
- readline() : Read one line of data
|
||||||
|
|
||||||
This allows it to be used as a drop-in replacement for testing
|
State machine ensures reliable communication without timing dependencies.
|
||||||
with real hardware instead of simulated data.
|
|
||||||
|
|
||||||
@note Requires pyserial: pip install pyserial
|
@note Requires pyserial: pip install pyserial
|
||||||
"""
|
"""
|
||||||
@@ -60,24 +105,34 @@ class RealSerialStream:
|
|||||||
@param port Serial port name (e.g., 'COM3' on Windows, '/dev/ttyUSB0' on Linux).
|
@param port Serial port name (e.g., 'COM3' on Windows, '/dev/ttyUSB0' on Linux).
|
||||||
If None, will attempt to auto-detect the ESP32.
|
If None, will attempt to auto-detect the ESP32.
|
||||||
@param baud_rate Communication speed in bits per second. Default 115200 matches 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.
|
@param timeout Read timeout in seconds for readline().
|
||||||
"""
|
"""
|
||||||
self.port = port
|
self.port = port
|
||||||
self.baud_rate = baud_rate
|
self.baud_rate = baud_rate
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.serial: Optional[serial.Serial] = None
|
self.serial: Optional[serial.Serial] = None
|
||||||
self.running = False
|
self.state = ConnectionState.DISCONNECTED
|
||||||
|
self.device_info: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
def start(self) -> None:
|
def connect(self, timeout: float = 5.0) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
@brief Open the serial connection to the ESP32.
|
@brief Connect to the ESP32 and perform handshake.
|
||||||
|
|
||||||
If no port was specified in __init__, attempts to auto-detect
|
Opens the serial port, sends connect command, and waits for
|
||||||
the ESP32 by looking for common USB-UART chip identifiers.
|
acknowledgment from the device.
|
||||||
|
|
||||||
|
@param timeout Maximum time to wait for handshake response (seconds).
|
||||||
|
|
||||||
|
@return Device info dict from handshake response.
|
||||||
|
|
||||||
@throws RuntimeError If no port specified and auto-detect fails.
|
@throws RuntimeError If no port specified and auto-detect fails.
|
||||||
@throws RuntimeError If unable to open the serial port.
|
@throws RuntimeError If unable to open the serial port.
|
||||||
|
@throws TimeoutError If device doesn't respond within timeout.
|
||||||
|
@throws ValueError If device sends invalid handshake response.
|
||||||
"""
|
"""
|
||||||
|
if self.state != ConnectionState.DISCONNECTED:
|
||||||
|
raise RuntimeError(f"Already in state {self.state.name}, cannot connect")
|
||||||
|
|
||||||
# Auto-detect port if not specified
|
# Auto-detect port if not specified
|
||||||
if self.port is None:
|
if self.port is None:
|
||||||
self.port = self._auto_detect_port()
|
self.port = self._auto_detect_port()
|
||||||
@@ -95,61 +150,185 @@ class RealSerialStream:
|
|||||||
baudrate=self.baud_rate,
|
baudrate=self.baud_rate,
|
||||||
timeout=self.timeout
|
timeout=self.timeout
|
||||||
)
|
)
|
||||||
self.running = True
|
self.state = ConnectionState.CONNECTING
|
||||||
|
|
||||||
# Clear any stale data in the buffer
|
# Clear any stale data in the buffer
|
||||||
self.serial.reset_input_buffer()
|
self.serial.reset_input_buffer()
|
||||||
|
time.sleep(0.1) # Let device settle after port open
|
||||||
|
|
||||||
print(f"[SERIAL] Connected to {self.port} at {self.baud_rate} baud")
|
print(f"[SERIAL] Port opened: {self.port}")
|
||||||
|
|
||||||
except serial.SerialException as e:
|
except serial.SerialException as e:
|
||||||
|
self.state = ConnectionState.DISCONNECTED
|
||||||
error_msg = f"Failed to open {self.port}: {e}"
|
error_msg = f"Failed to open {self.port}: {e}"
|
||||||
# Add helpful context for common errors
|
|
||||||
if "Permission denied" in str(e) or "Resource busy" in str(e):
|
if "Permission denied" in str(e) or "Resource busy" in str(e):
|
||||||
error_msg += "\n\nThe port may still be in use from a previous connection. Wait a moment and try again."
|
error_msg += "\n\nThe port may still be in use. Wait a moment and try again."
|
||||||
raise RuntimeError(error_msg)
|
raise RuntimeError(error_msg)
|
||||||
|
|
||||||
|
# Perform handshake
|
||||||
|
try:
|
||||||
|
# Send connect command
|
||||||
|
connect_cmd = {"cmd": "connect"}
|
||||||
|
self._send_json(connect_cmd)
|
||||||
|
print(f"[SERIAL] Sent: {connect_cmd}")
|
||||||
|
|
||||||
|
# Wait for acknowledgment
|
||||||
|
start_time = time.time()
|
||||||
|
while (time.time() - start_time) < timeout:
|
||||||
|
line = self._readline_raw()
|
||||||
|
if line:
|
||||||
|
try:
|
||||||
|
response = json.loads(line)
|
||||||
|
if response.get("status") == "ack_connect":
|
||||||
|
self.device_info = response
|
||||||
|
self.state = ConnectionState.CONNECTED
|
||||||
|
print(f"[SERIAL] Handshake complete: {response}")
|
||||||
|
return response
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Ignore non-JSON lines (might be startup messages)
|
||||||
|
print(f"[SERIAL] Ignoring: {line.strip()}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Timeout reached
|
||||||
|
self.state = ConnectionState.DISCONNECTED
|
||||||
|
if self.serial:
|
||||||
|
self.serial.close()
|
||||||
|
self.serial = None
|
||||||
|
raise TimeoutError(
|
||||||
|
f"Device did not respond to connection request within {timeout}s.\n"
|
||||||
|
"Check that the correct firmware is flashed and device is powered on."
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Clean up on any error
|
||||||
|
self.state = ConnectionState.DISCONNECTED
|
||||||
|
if self.serial:
|
||||||
|
try:
|
||||||
|
self.serial.close()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
self.serial = None
|
||||||
|
raise
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""
|
||||||
|
@brief Start streaming EMG data.
|
||||||
|
|
||||||
|
Device must be in CONNECTED state. Sends start command to ESP32,
|
||||||
|
which begins streaming CSV data.
|
||||||
|
|
||||||
|
@throws RuntimeError If not connected.
|
||||||
|
"""
|
||||||
|
if self.state != ConnectionState.CONNECTED:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Cannot start streaming from state {self.state.name}. "
|
||||||
|
"Must call connect() first."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send start command
|
||||||
|
start_cmd = {"cmd": "start"}
|
||||||
|
self._send_json(start_cmd)
|
||||||
|
self.state = ConnectionState.STREAMING
|
||||||
|
print(f"[SERIAL] Started streaming")
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""
|
"""
|
||||||
@brief Close the serial connection.
|
@brief Stop streaming EMG data.
|
||||||
|
|
||||||
Safe to call even if not connected.
|
Sends stop command to ESP32, which stops streaming and returns
|
||||||
|
to CONNECTED state. Connection remains open for restart.
|
||||||
|
|
||||||
|
Safe to call even if not streaming.
|
||||||
"""
|
"""
|
||||||
self.running = False
|
if self.state == ConnectionState.STREAMING:
|
||||||
|
try:
|
||||||
|
stop_cmd = {"cmd": "stop"}
|
||||||
|
self._send_json(stop_cmd)
|
||||||
|
self.state = ConnectionState.CONNECTED
|
||||||
|
print(f"[SERIAL] Stopped streaming")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[SERIAL] Warning during stop: {e}")
|
||||||
|
|
||||||
|
def disconnect(self) -> None:
|
||||||
|
"""
|
||||||
|
@brief Disconnect from the ESP32.
|
||||||
|
|
||||||
|
Sends disconnect command (device returns to IDLE state),
|
||||||
|
then closes the serial port. Safe to call from any state.
|
||||||
|
"""
|
||||||
|
# Send disconnect command if connected
|
||||||
|
if self.state in (ConnectionState.CONNECTED, ConnectionState.STREAMING):
|
||||||
|
try:
|
||||||
|
disconnect_cmd = {"cmd": "disconnect"}
|
||||||
|
self._send_json(disconnect_cmd)
|
||||||
|
time.sleep(0.1) # Give device time to process
|
||||||
|
print(f"[SERIAL] Sent disconnect command")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[SERIAL] Warning sending disconnect: {e}")
|
||||||
|
|
||||||
|
# Close serial port
|
||||||
if self.serial:
|
if self.serial:
|
||||||
try:
|
try:
|
||||||
if self.serial.is_open:
|
if self.serial.is_open:
|
||||||
self.serial.close()
|
self.serial.close()
|
||||||
print(f"[SERIAL] Disconnected from {self.port}")
|
print(f"[SERIAL] Port closed: {self.port}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[SERIAL] Warning during disconnect: {e}")
|
print(f"[SERIAL] Warning during port close: {e}")
|
||||||
finally:
|
finally:
|
||||||
self.serial = None
|
self.serial = None
|
||||||
|
|
||||||
|
self.state = ConnectionState.DISCONNECTED
|
||||||
|
self.device_info = None
|
||||||
|
|
||||||
def readline(self) -> Optional[str]:
|
def readline(self) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
@brief Read one line of data from the ESP32.
|
@brief Read one line of CSV data from the ESP32.
|
||||||
|
|
||||||
|
Should only be called when in STREAMING state.
|
||||||
Blocks until a complete line is received or timeout occurs.
|
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.
|
@return Line string including newline, or None if timeout/error.
|
||||||
|
|
||||||
@note Lines from ESP32 are in format: "timestamp_ms,ch0,ch1,ch2,ch3\\n"
|
@note Lines from ESP32 are in format: "timestamp_ms,ch0,ch1,ch2,ch3\\n"
|
||||||
"""
|
"""
|
||||||
|
if self.state != ConnectionState.STREAMING:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self._readline_raw()
|
||||||
|
|
||||||
|
def _readline_raw(self) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
@brief Read one line from serial port (internal helper).
|
||||||
|
|
||||||
|
@return Decoded line string, or None if timeout/error.
|
||||||
|
"""
|
||||||
if not self.serial or not self.serial.is_open:
|
if not self.serial or not self.serial.is_open:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
line_bytes = self.serial.readline()
|
line_bytes = self.serial.readline()
|
||||||
if line_bytes:
|
if line_bytes:
|
||||||
return line_bytes.decode('utf-8', errors='ignore')
|
return line_bytes.decode('utf-8', errors='ignore').strip()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except serial.SerialException:
|
except serial.SerialException:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _send_json(self, data: Dict[str, Any]) -> None:
|
||||||
|
"""
|
||||||
|
@brief Send JSON command to the device (internal helper).
|
||||||
|
|
||||||
|
@param data Dictionary to send as JSON.
|
||||||
|
|
||||||
|
@throws RuntimeError If serial port is not open.
|
||||||
|
"""
|
||||||
|
if not self.serial or not self.serial.is_open:
|
||||||
|
raise RuntimeError("Serial port not open")
|
||||||
|
|
||||||
|
json_str = json.dumps(data) + "\n"
|
||||||
|
self.serial.write(json_str.encode('utf-8'))
|
||||||
|
self.serial.flush() # Ensure data is sent immediately
|
||||||
|
|
||||||
def _auto_detect_port(self) -> Optional[str]:
|
def _auto_detect_port(self) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
@brief Attempt to auto-detect the ESP32 serial port.
|
@brief Attempt to auto-detect the ESP32 serial port.
|
||||||
@@ -244,11 +423,21 @@ if __name__ == "__main__":
|
|||||||
print("Starting stream... (Ctrl+C to stop)")
|
print("Starting stream... (Ctrl+C to stop)")
|
||||||
print("-" * 50)
|
print("-" * 50)
|
||||||
|
|
||||||
# Create and start stream
|
# Create stream
|
||||||
stream = RealSerialStream(port=port)
|
stream = RealSerialStream(port=port)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Connect with handshake
|
||||||
|
print("\nConnecting to device...")
|
||||||
|
device_info = stream.connect(timeout=5.0)
|
||||||
|
print(f"Connected! Device: {device_info.get('device', 'Unknown')}, "
|
||||||
|
f"Channels: {device_info.get('channels', '?')}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Start streaming
|
||||||
|
print("Starting data stream...")
|
||||||
stream.start()
|
stream.start()
|
||||||
|
print()
|
||||||
|
|
||||||
sample_count = 0
|
sample_count = 0
|
||||||
|
|
||||||
@@ -256,8 +445,6 @@ if __name__ == "__main__":
|
|||||||
line = stream.readline()
|
line = stream.readline()
|
||||||
|
|
||||||
if line:
|
if line:
|
||||||
line = line.strip()
|
|
||||||
|
|
||||||
# Check if this is a data line (starts with digit = timestamp)
|
# Check if this is a data line (starts with digit = timestamp)
|
||||||
if line and line[0].isdigit():
|
if line and line[0].isdigit():
|
||||||
sample_count += 1
|
sample_count += 1
|
||||||
@@ -267,7 +454,7 @@ if __name__ == "__main__":
|
|||||||
print(f" [{sample_count:6d} samples] Latest: {line}")
|
print(f" [{sample_count:6d} samples] Latest: {line}")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Print startup/info messages from ESP32
|
# Print non-data messages
|
||||||
print(f" {line}")
|
print(f" {line}")
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
@@ -275,6 +462,14 @@ if __name__ == "__main__":
|
|||||||
print("-" * 50)
|
print("-" * 50)
|
||||||
print("Stopped by user (Ctrl+C)")
|
print("Stopped by user (Ctrl+C)")
|
||||||
|
|
||||||
|
except TimeoutError as e:
|
||||||
|
print(f"\nConnection timeout: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\nError: {e}")
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
print("\nCleaning up...")
|
||||||
stream.stop()
|
stream.stop()
|
||||||
|
stream.disconnect()
|
||||||
print(f"Total samples received: {sample_count}")
|
print(f"Total samples received: {sample_count}")
|
||||||
|
|||||||
Reference in New Issue
Block a user