Fixed loophole where device left in streaming state on app closure

This commit is contained in:
2026-01-20 00:52:07 -06:00
parent c8c15df889
commit 5bab86c703
2 changed files with 151 additions and 51 deletions

View File

@@ -150,15 +150,18 @@ static void serial_input_task(void* pvParameters)
command_t cmd = parse_command(line_buffer); command_t cmd = parse_command(line_buffer);
if (cmd != CMD_NONE) { if (cmd != CMD_NONE) {
/* Handle state transitions directly */ /* Handle CONNECT command from ANY state (for reconnection/recovery) */
/* This allows streaming loop to see state changes immediately */
switch (g_device_state) {
case STATE_IDLE:
if (cmd == CMD_CONNECT) { if (cmd == CMD_CONNECT) {
/* Stop streaming if active, reset to CONNECTED state */
g_device_state = STATE_CONNECTED; g_device_state = STATE_CONNECTED;
send_ack_connect(); send_ack_connect();
printf("[STATE] IDLE -> CONNECTED\n"); printf("[STATE] ANY -> CONNECTED (reconnect)\n");
} }
/* Handle other state transitions */
else {
switch (g_device_state) {
case STATE_IDLE:
/* Only CONNECT allowed from IDLE (handled above) */
break; break;
case STATE_CONNECTED: case STATE_CONNECTED:
@@ -186,6 +189,7 @@ static void serial_input_task(void* pvParameters)
break; break;
} }
} }
}
line_idx = 0; line_idx = 0;
} }

View File

@@ -70,6 +70,7 @@ import serial
import serial.tools.list_ports import serial.tools.list_ports
import json import json
import time import time
import threading
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from enum import Enum from enum import Enum
@@ -113,6 +114,7 @@ class RealSerialStream:
self.serial: Optional[serial.Serial] = None self.serial: Optional[serial.Serial] = None
self.state = ConnectionState.DISCONNECTED self.state = ConnectionState.DISCONNECTED
self.device_info: Optional[Dict[str, Any]] = None self.device_info: Optional[Dict[str, Any]] = None
self._auto_detect_result: Optional[Dict[str, Any]] = None # Cache for concurrent auto-detect
def connect(self, timeout: float = 5.0) -> Dict[str, Any]: def connect(self, timeout: float = 5.0) -> Dict[str, Any]:
""" """
@@ -167,7 +169,7 @@ class RealSerialStream:
# Perform handshake # Perform handshake
try: try:
# Send connect command # Send connect command (works from any device state - handles reconnection)
connect_cmd = {"cmd": "connect"} connect_cmd = {"cmd": "connect"}
self._send_json(connect_cmd) self._send_json(connect_cmd)
print(f"[SERIAL] Sent: {connect_cmd}") print(f"[SERIAL] Sent: {connect_cmd}")
@@ -185,7 +187,13 @@ class RealSerialStream:
print(f"[SERIAL] Handshake complete: {response}") print(f"[SERIAL] Handshake complete: {response}")
return response return response
except json.JSONDecodeError: except json.JSONDecodeError:
# Ignore non-JSON lines (might be startup messages) # Ignore non-JSON lines (startup messages, residual CSV data from reconnection)
# This allows reconnection even if device was streaming when app crashed
if line and line[0].isdigit() and ',' in line:
# Residual CSV data - ignore silently
pass
else:
# Other non-JSON - show for debugging
print(f"[SERIAL] Ignoring: {line.strip()}") print(f"[SERIAL] Ignoring: {line.strip()}")
continue continue
@@ -331,31 +339,119 @@ class RealSerialStream:
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 using concurrent handshake.
Looks for common USB-UART bridge chips used on ESP32 dev boards: Sends connect command to all available ports simultaneously and selects
- CP210x (Silicon Labs) the first one that responds with valid handshake acknowledgment.
- CH340 (WCH)
- FTDI This is more reliable than USB chip detection since it verifies the
device is actually running the expected firmware.
@return Port name if found, None otherwise. @return Port name if found, None otherwise.
@note Stores device_info in self._auto_detect_result for reuse by connect()
""" """
ports = serial.tools.list_ports.comports() ports = serial.tools.list_ports.comports()
# Known USB-UART chip identifiers if not ports:
known_chips = ['cp210', 'ch340', 'ftdi', 'usb-serial', 'usb serial'] print("[SERIAL] No serial ports found")
return None
for port in ports: if len(ports) == 1:
description_lower = port.description.lower() # Only one port, no need for concurrent detection
if any(chip in description_lower for chip in known_chips): print(f"[SERIAL] Only one port available: {ports[0].device}")
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 ports[0].device
print(f"[SERIAL] Auto-detecting ESP32 across {len(ports)} ports...")
# Thread-safe result container
result_lock = threading.Lock()
result = {'port': None, 'device_info': None}
def try_handshake(port_name: str):
"""Attempt handshake on a single port (runs in thread)."""
try:
# Open serial connection with short timeout
ser = serial.Serial(
port=port_name,
baudrate=self.baud_rate,
timeout=0.5
)
# Clear buffer and settle
ser.reset_input_buffer()
time.sleep(0.05)
# Send connect command (resets device state if needed)
connect_cmd = {"cmd": "connect"}
json_str = json.dumps(connect_cmd) + "\n"
ser.write(json_str.encode('utf-8'))
ser.flush()
# Wait for acknowledgment (max 2 seconds)
start_time = time.time()
while (time.time() - start_time) < 2.0:
line_bytes = ser.readline()
if line_bytes:
try:
line = line_bytes.decode('utf-8', errors='ignore').strip()
response = json.loads(line)
if response.get("status") == "ack_connect":
# Found it! Store result if we're first
with result_lock:
if result['port'] is None:
result['port'] = port_name
result['device_info'] = response
print(f"[SERIAL] ✓ ESP32 found on {port_name}: {response.get('device', 'Unknown')}")
# Send disconnect to return device to IDLE
disconnect_cmd = {"cmd": "disconnect"}
json_str = json.dumps(disconnect_cmd) + "\n"
ser.write(json_str.encode('utf-8'))
ser.flush()
time.sleep(0.05)
ser.close()
return
except json.JSONDecodeError:
# Ignore non-JSON (residual CSV data, startup messages)
continue
# No valid response
ser.close()
except (serial.SerialException, OSError):
# Port unavailable or busy - skip silently
pass
# Launch concurrent handshake attempts
threads = []
for port in ports:
thread = threading.Thread(target=try_handshake, args=(port.device,), daemon=True)
thread.start()
threads.append(thread)
# Poll for first success or timeout (instead of sequential joins)
start_time = time.time()
max_wait = 2.5
while (time.time() - start_time) < max_wait:
# Check if we found a device
with result_lock:
if result['port'] is not None:
# Success! Return immediately
self._auto_detect_result = result
elapsed = time.time() - start_time
print(f"[SERIAL] Auto-detect complete in {elapsed:.2f}s")
return result['port']
# Check if all threads finished (no device found)
if not any(thread.is_alive() for thread in threads):
break
# Brief sleep to avoid busy-waiting CPU
time.sleep(0.05)
# Timeout or all threads finished without success
print("[SERIAL] No ESP32 responded to handshake on any port")
return None return None
@staticmethod @staticmethod