From 04dea8eeab7cd83513699bf6d31872c4e2fd6973 Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Sun, 5 Apr 2026 14:20:08 +0200 Subject: [PATCH 01/13] WiP: improv --- esp_flasher/gui.py | 273 +++++++++++++++++++++++++- esp_flasher/improv.py | 353 ++++++++++++++++++++++++++++++++++ esp_flasher/serial_console.py | 1 + 3 files changed, 625 insertions(+), 2 deletions(-) create mode 100644 esp_flasher/improv.py diff --git a/esp_flasher/gui.py b/esp_flasher/gui.py index b2626ef..20549aa 100644 --- a/esp_flasher/gui.py +++ b/esp_flasher/gui.py @@ -7,9 +7,10 @@ from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QComboBox, QFileDialog, QTextEdit, QGroupBox, QGridLayout, - QLineEdit) + QLineEdit, QDialog, QListWidget, QListWidgetItem, + QProgressBar, QMessageBox) from PyQt6.QtGui import QColor, QPalette -from PyQt6.QtCore import pyqtSignal, QObject, Qt, QSettings +from PyQt6.QtCore import pyqtSignal, QObject, Qt, QSettings, QTimer from esp_flasher.own_esptool import get_port_list, colorize, COLOR_RED, COLOR_GREEN, COLOR_CYAN, COLOR_YELLOW from esp_flasher.const import (__version__, DEFAULT_WINDOW_WIDTH, @@ -17,6 +18,233 @@ DEFAULT_WINDOW_Y) from esp_flasher.console_color import ColoredConsole +class ImprovDialog(QDialog): + """Dialog for Improv WiFi provisioning.""" + + def __init__(self, serial_port, parent=None): + super().__init__(parent) + self.setWindowTitle("Improv WiFi Provisioning") + self.setMinimumWidth(420) + self._serial_port = serial_port # reuse already-open port (never close it) + self._improv = None + self._is_provisioning = False # True only after we send credentials + self._init_ui() + self._start_improv() + + def _init_ui(self): + layout = QVBoxLayout() + + # Device info + self.info_label = QLabel("Detecting Improv device...") + layout.addWidget(self.info_label) + + # WiFi network list + net_group = QGroupBox("WiFi Networks") + net_layout = QVBoxLayout() + self.network_list = QListWidget() + self.network_list.itemDoubleClicked.connect(self._on_network_selected) + net_layout.addWidget(self.network_list) + + scan_btn = QPushButton("Scan Networks") + scan_btn.clicked.connect(self._scan_networks) + net_layout.addWidget(scan_btn) + net_group.setLayout(net_layout) + layout.addWidget(net_group) + + # Credentials + cred_group = QGroupBox("WiFi Credentials") + cred_layout = QGridLayout() + cred_layout.addWidget(QLabel("SSID:"), 0, 0) + self.ssid_input = QLineEdit() + cred_layout.addWidget(self.ssid_input, 0, 1) + cred_layout.addWidget(QLabel("Password:"), 1, 0) + self.password_input = QLineEdit() + self.password_input.setEchoMode(QLineEdit.EchoMode.Password) + cred_layout.addWidget(self.password_input, 1, 1) + cred_group.setLayout(cred_layout) + layout.addWidget(cred_group) + + # Status / progress + self.status_label = QLabel("") + layout.addWidget(self.status_label) + self.progress = QProgressBar() + self.progress.setRange(0, 0) # indeterminate + self.progress.setVisible(False) + layout.addWidget(self.progress) + + # Buttons + btn_layout = QHBoxLayout() + self.provision_btn = QPushButton("Provision") + self.provision_btn.clicked.connect(self._provision) + self.provision_btn.setEnabled(False) + btn_layout.addWidget(self.provision_btn) + + close_btn = QPushButton("Close") + close_btn.clicked.connect(self.close) + btn_layout.addWidget(close_btn) + layout.addLayout(btn_layout) + + self.setLayout(layout) + + def _start_improv(self): + """Start Improv on the already-open serial port (same as JS: port stays open).""" + # Flush stale console data from buffer + self._serial_port.reset_input_buffer() + + from esp_flasher.improv import ImprovManager + self._improv = ImprovManager(self._serial_port) + self._improv.state_changed.connect(self._on_state_changed) + self._improv.error_received.connect(self._on_error) + self._improv.device_info_received.connect(self._on_device_info) + self._improv.log_message.connect(self._on_log) + self._improv.provisioned.connect(self._on_provisioned) + self._improv.start() + + # Periodically poll for device until it responds + self._detect_attempts = 0 + self._detect_timer = QTimer(self) + self._detect_timer.timeout.connect(self._poll_device_state) + self._detect_timer.start(1000) + # Also send first request immediately + QTimer.singleShot(100, self._poll_device_state) + + def _poll_device_state(self): + """Send request_current_state until device responds or we give up.""" + if not self._improv: + self._detect_timer.stop() + return + if self._improv.device_state is not None: + # Device has responded, stop polling + self._detect_timer.stop() + return + self._detect_attempts += 1 + if self._detect_attempts > 15: + self._detect_timer.stop() + self.status_label.setText("No Improv device detected (timeout)") + return + self.status_label.setText( + f"Detecting Improv device... (attempt {self._detect_attempts}/15)" + ) + self._improv.request_current_state() + + def _on_state_changed(self, state): + from esp_flasher.improv import STATE_READY, STATE_PROVISIONING, STATE_PROVISIONED, STATE_NAMES + name = STATE_NAMES.get(state, f"Unknown ({state})") + self.progress.setVisible(False) + if state == STATE_READY: + self.status_label.setText(f"State: {name}") + self.provision_btn.setEnabled(True) + # Auto-request device info + threading.Thread(target=self._request_info_bg, daemon=True).start() + elif state == STATE_PROVISIONING: + self.status_label.setText("Connecting to WiFi...") + self.provision_btn.setEnabled(False) + self.progress.setVisible(True) + elif state == STATE_PROVISIONED: + if self._is_provisioning: + self.status_label.setText("✓ WiFi provisioned successfully!") + self.provision_btn.setEnabled(False) + else: + self.status_label.setText("Device already connected to WiFi") + self.provision_btn.setEnabled(True) + # Request device info in both cases + threading.Thread(target=self._request_info_bg, daemon=True).start() + + def _on_error(self, error): + from esp_flasher.improv import ERROR_NAMES, ERROR_NONE + if error != ERROR_NONE: + self.status_label.setText(f"Error: {ERROR_NAMES.get(error, 'Unknown')}") + self.progress.setVisible(False) + self.provision_btn.setEnabled(True) + + def _on_device_info(self, info): + parts = [] + labels = ["Firmware", "Version", "Chip", "Name"] + for i, val in enumerate(info): + if val: + label = labels[i] if i < len(labels) else f"Field{i}" + parts.append(f"{label}: {val}") + self.info_label.setText(" | ".join(parts) if parts else "Device detected") + + def _on_log(self, msg): + self.status_label.setText(msg) + + def _on_provisioned(self, result): + self.progress.setVisible(False) + self.provision_btn.setEnabled(False) + msg = "✓ WiFi provisioned successfully!" + if result: + msg += f"\n{', '.join(result)}" + self.status_label.setText(msg) + + def _on_network_selected(self, item): + ssid = item.data(Qt.ItemDataRole.UserRole) + if ssid: + self.ssid_input.setText(ssid) + self.password_input.setFocus() + + def _scan_networks(self): + self.network_list.clear() + self.status_label.setText("Scanning WiFi networks...") + self.progress.setVisible(True) + threading.Thread(target=self._scan_bg, daemon=True).start() + + def _scan_bg(self): + networks = self._improv.request_wifi_networks() + # Sort by RSSI descending + networks.sort(key=lambda n: n[1], reverse=True) + # Update UI from main thread via signal + QTimer.singleShot(0, lambda: self._update_network_list(networks)) + + def _update_network_list(self, networks): + self.network_list.clear() + self.progress.setVisible(False) + if not networks: + self.status_label.setText("No networks found") + return + for ssid, rssi, secured in networks: + lock = "🔒 " if secured else " " + item = QListWidgetItem(f"{lock}{ssid} ({rssi} dBm)") + item.setData(Qt.ItemDataRole.UserRole, ssid) + self.network_list.addItem(item) + self.status_label.setText(f"Found {len(networks)} networks") + + def _request_info_bg(self): + self._improv.request_device_info() + + def _provision(self): + ssid = self.ssid_input.text().strip() + password = self.password_input.text() + if not ssid: + self.status_label.setText("Please enter an SSID") + return + self._is_provisioning = True + self.provision_btn.setEnabled(False) + self.progress.setVisible(True) + self.status_label.setText(f"Provisioning WiFi: {ssid}...") + threading.Thread(target=self._provision_bg, args=(ssid, password), daemon=True).start() + + def _provision_bg(self, ssid, password): + result = self._improv.send_wifi_settings(ssid, password) + if result is None: + QTimer.singleShot(0, lambda: self._provision_failed()) + + def _provision_failed(self): + self._is_provisioning = False + self.progress.setVisible(False) + self.provision_btn.setEnabled(True) + self.status_label.setText("WiFi provisioning failed") + + def closeEvent(self, event): + if hasattr(self, '_detect_timer') and self._detect_timer.isActive(): + self._detect_timer.stop() + if self._improv: + self._improv.stop() + self._improv = None + # Do NOT close the serial port — caller owns it and will restart console reader + super().closeEvent(event) + + class FlashingThread(threading.Thread): def __init__(self, firmware, port, finished=None, failed=None): threading.Thread.__init__(self) @@ -118,6 +346,9 @@ def init_ui(self): self.flash_button = QPushButton("Flash ESP") self.flash_button.clicked.connect(self.flash_esp) actions_layout.addWidget(self.flash_button) + self.improv_button = QPushButton("Improv WiFi") + self.improv_button.clicked.connect(self.open_improv) + actions_layout.addWidget(self.improv_button) actions_group_box.setLayout(actions_layout) # Console with input field @@ -321,6 +552,44 @@ def _start_flash_worker(self): ) self._flash_worker.start() + def open_improv(self): + """Open Improv WiFi provisioning dialog. + Matches JS: stop console reader, pass open port to Improv, restart reader on close.""" + if self._is_flashing: + self.show_log_error("Cannot use Improv while flashing") + return + if not self._serial_port or not self._serial_port.is_open: + self.show_log_error("Connect to a serial port first") + return + + # Stop console reader — disconnect signals first, then stop thread + if self._serial_reader: + self._serial_reader.line_received.disconnect(self.append_log_line) + self._serial_reader.error_occurred.disconnect(self.handle_serial_error) + self._serial_reader.stop() + # Wait until thread is truly dead + if self._serial_reader.thread and self._serial_reader.thread.is_alive(): + self._serial_reader.thread.join(timeout=3.0) + self._serial_reader = None + + # Disable console input while in Improv mode + self.input_field.setEnabled(False) + self.send_button.setEnabled(False) + + # Open Improv dialog with the same open port (port stays open, no ESP reset) + dlg = ImprovDialog(self._serial_port, parent=self) + dlg.exec() + + # Restart console reader on the same open port (like JS reconnectConsole) + if self._serial_port and self._serial_port.is_open: + from esp_flasher.serial_console import SerialReader + self._serial_reader = SerialReader(self._serial_port) + self._serial_reader.line_received.connect(self.append_log_line) + self._serial_reader.error_occurred.connect(self.handle_serial_error) + self._serial_reader.start() + self.input_field.setEnabled(True) + self.send_button.setEnabled(True) + def on_flash_finished(self): """Called when flashing is complete""" print(colorize("\nFlashing complete!", COLOR_GREEN)) diff --git a/esp_flasher/improv.py b/esp_flasher/improv.py new file mode 100644 index 0000000..1891bbd --- /dev/null +++ b/esp_flasher/improv.py @@ -0,0 +1,353 @@ +""" +Improv Wi-Fi Serial Protocol implementation for ESP-Flasher. +Based on the Improv Serial specification and esp32tool js/improv.js reference. +""" + +import time +import threading +import logging + +from PyQt6.QtCore import pyqtSignal, QObject + +logger = logging.getLogger(__name__) + +# Improv packet header +IMPROV_HEADER = b"IMPROV" +IMPROV_VERSION = 0x01 + +# Message types +TYPE_CURRENT_STATE = 0x01 +TYPE_ERROR_STATE = 0x02 +TYPE_RPC = 0x03 +TYPE_RPC_RESULT = 0x04 + +# Device states +STATE_READY = 0x02 +STATE_PROVISIONING = 0x03 +STATE_PROVISIONED = 0x04 + +STATE_NAMES = { + STATE_READY: "Ready", + STATE_PROVISIONING: "Provisioning", + STATE_PROVISIONED: "Provisioned", +} + +# Error codes +ERROR_NONE = 0x00 +ERROR_INVALID_RPC = 0x01 +ERROR_UNKNOWN_RPC = 0x02 +ERROR_UNABLE_TO_CONNECT = 0x03 +ERROR_TIMEOUT = 0xFE +ERROR_UNKNOWN = 0xFF + +ERROR_NAMES = { + ERROR_NONE: "No error", + ERROR_INVALID_RPC: "Invalid RPC packet", + ERROR_UNKNOWN_RPC: "Unknown RPC command", + ERROR_UNABLE_TO_CONNECT: "Unable to connect", + ERROR_TIMEOUT: "Timeout", + ERROR_UNKNOWN: "Unknown error", +} + +# RPC commands +CMD_SEND_WIFI_SETTINGS = 0x01 +CMD_REQUEST_CURRENT_STATE = 0x02 +CMD_REQUEST_INFO = 0x03 +CMD_REQUEST_WIFI_NETWORKS = 0x04 + +# Timeouts (match esp32tool js/improv.js) +PROVISION_TIMEOUT = 30.0 + + +def _build_packet(msg_type, data): + """Build an Improv serial packet.""" + payload = bytearray(IMPROV_HEADER) + payload.append(IMPROV_VERSION) + payload.append(msg_type) + payload.append(len(data)) + payload.extend(data) + checksum = sum(payload) & 0xFF + payload.append(checksum) + payload.append(0x0A) + return bytes(payload) + + +def _build_rpc(command, payload=b""): + """Build an RPC packet with the given command and payload.""" + data = bytearray() + data.append(command) + data.append(len(payload)) + data.extend(payload) + return _build_packet(TYPE_RPC, data) + + +def _build_wifi_payload(ssid, password): + """Build the WiFi settings payload (TLV encoded SSID + password).""" + ssid_bytes = ssid.encode("utf-8") + pw_bytes = password.encode("utf-8") + payload = bytearray() + payload.append(len(ssid_bytes)) + payload.extend(ssid_bytes) + payload.append(len(pw_bytes)) + payload.extend(pw_bytes) + return bytes(payload) + + +def _parse_tlv_strings(data): + """Parse TLV-encoded strings from RPC result data.""" + if len(data) < 2: + return [] + total_length = data[1] + strings = [] + idx = 2 + while idx < 2 + total_length and idx < len(data): + str_len = data[idx] + idx += 1 + if idx + str_len > len(data): + break + s = data[idx:idx + str_len].decode("utf-8", errors="replace") + strings.append(s) + idx += str_len + return strings + + +class ImprovManager(QObject): + """Manages Improv serial communication with an ESP device. + + Receiver state machine mirrors the JS reference (esp32tool js/improv.js): + - is_improv=None (scanning: accumulate bytes, check at 9 bytes) + - is_improv=True (reading improv packet body + checksum) + - is_improv=False (skip non-improv line until newline) + """ + + # Signals + state_changed = pyqtSignal(int) # device state + error_received = pyqtSignal(int) # error code + device_info_received = pyqtSignal(list) # [firmware, version, chip, name] + wifi_networks_received = pyqtSignal(list) # list of (ssid, rssi, secured) + provisioned = pyqtSignal(list) # result strings (e.g. redirect URL) + log_message = pyqtSignal(str) # log/status messages + + def __init__(self, serial_port): + super().__init__() + self._port = serial_port + self._running = False + self._thread = None + + # Receiver state machine (matches JS: undefined/true/false) + self._line = [] + self._is_improv = None # None=scanning, True=reading packet, False=skip line + self._improv_length = 0 + + # RPC response synchronization + self._rpc_event = threading.Event() + self._rpc_result = None + self._rpc_error = None + self._rpc_command = None + + # WiFi network scan accumulator + self._wifi_networks = [] + self._wifi_scan_done = threading.Event() + + # Current device state + self.device_state = None + + def start(self): + """Start the Improv receiver thread.""" + self._running = True + self._thread = threading.Thread(target=self._read_loop, daemon=True) + self._thread.start() + + def stop(self): + """Stop the Improv receiver thread.""" + self._running = False + if self._thread: + self._thread.join(timeout=2.0) + self._thread = None + + # --- Public API --- + + def request_current_state(self): + """Request the current state from the device.""" + pkt = _build_rpc(CMD_REQUEST_CURRENT_STATE) + self._write(pkt) + + def request_device_info(self): + """Request device info. Returns [firmware, version, chip, name] or None. + No timeout — matches JS where requestInfo() waits indefinitely.""" + return self._send_rpc(CMD_REQUEST_INFO) + + def request_wifi_networks(self): + """Scan for WiFi networks. Returns list of (ssid, rssi, secured). + No timeout — matches JS where scan() waits indefinitely.""" + self._wifi_networks = [] + self._wifi_scan_done.clear() + pkt = _build_rpc(CMD_REQUEST_WIFI_NETWORKS) + self._write(pkt) + self._wifi_scan_done.wait() + return list(self._wifi_networks) + + def send_wifi_settings(self, ssid, password, timeout=PROVISION_TIMEOUT): + """Send WiFi credentials. Returns result strings or None on error. + 30s timeout — matches JS provision(ssid, password, 30000).""" + payload = _build_wifi_payload(ssid, password) + return self._send_rpc(CMD_SEND_WIFI_SETTINGS, payload, timeout=timeout) + + # --- Internal --- + + def _send_rpc(self, command, payload=b"", timeout=None): + """Send an RPC command and wait for the result. + timeout=None means wait indefinitely (matches JS behavior for info/scan).""" + self._rpc_event.clear() + self._rpc_result = None + self._rpc_error = None + self._rpc_command = command + + pkt = _build_rpc(command, payload) + self._write(pkt) + + if self._rpc_event.wait(timeout=timeout): + if self._rpc_error is not None and self._rpc_error != ERROR_NONE: + return None + return self._rpc_result + self.log_message.emit("RPC timeout") + self.error_received.emit(ERROR_TIMEOUT) + return None + + def _write(self, data): + """Write data to the serial port.""" + try: + if self._port and self._port.is_open: + self._port.write(data) + self._port.flush() + except Exception as e: + logger.error("Improv write error: %s", e) + self.log_message.emit(f"Write error: {e}") + + def _read_loop(self): + """Background thread: read bytes and detect Improv packets.""" + while self._running: + try: + if not self._port or not self._port.is_open: + break + if self._port.in_waiting > 0: + raw = self._port.read(self._port.in_waiting) + for b in raw: + self._process_byte(b) + else: + time.sleep(0.01) + except Exception as e: + if self._running: + logger.error("Improv read error: %s", e) + break + + def _process_byte(self, byte): + """Process a single byte — exact port of JS _processInput state machine.""" + + # State: is_improv=False → skip non-improv line until newline + if self._is_improv is False: + if byte == 0x0A: + self._is_improv = None + return + + # State: is_improv=True → collecting improv packet body + if self._is_improv is True: + self._line.append(byte) + if len(self._line) == self._improv_length: + self._handle_packet(self._line) + self._is_improv = None + self._line = [] + return + + # State: is_improv=None → scanning for header + + # Newline resets accumulation + if byte == 0x0A: + self._line = [] + return + + self._line.append(byte) + + # Only check once we have exactly 9 bytes + if len(self._line) != 9: + return + + # Check if first 6 bytes match "IMPROV" + if bytes(self._line[:6]) == IMPROV_HEADER: + # Header matched — calculate total packet length + data_len = self._line[8] + self._improv_length = 9 + data_len + 1 # 9 header + data + checksum + self._is_improv = True + else: + # Not an Improv header — discard and go back to scanning + # (JS does: isImprov = false, line = []) + self._line = [] + self._is_improv = None + + def _handle_packet(self, line): + """Handle a complete Improv packet (including header + checksum).""" + # Checksum: sum of all bytes except the last one (the checksum itself) + calculated = sum(line[:-1]) & 0xFF + received = line[-1] + if calculated != received: + logger.warning("Improv checksum mismatch: expected %02X, got %02X", + calculated, received) + return + + # Parse fields after the 6-byte "IMPROV" prefix + version = line[6] + if version != IMPROV_VERSION: + return + + msg_type = line[7] + data_len = line[8] + data = line[9:9 + data_len] + + if msg_type == TYPE_CURRENT_STATE: + if data: + self.device_state = data[0] + state_name = STATE_NAMES.get(data[0], f"Unknown ({data[0]:#x})") + self.log_message.emit(f"Device state: {state_name}") + self.state_changed.emit(data[0]) + + elif msg_type == TYPE_ERROR_STATE: + if data: + error = data[0] + error_name = ERROR_NAMES.get(error, f"Unknown ({error:#x})") + if error != ERROR_NONE: + self.log_message.emit(f"Device error: {error_name}") + self.error_received.emit(error) + if error != ERROR_NONE: + self._rpc_error = error + self._rpc_event.set() + + elif msg_type == TYPE_RPC_RESULT: + if not data: + return + command = data[0] + strings = _parse_tlv_strings(data) + + if command == CMD_REQUEST_INFO: + self.log_message.emit(f"Device info: {strings}") + self.device_info_received.emit(strings) + self._rpc_result = strings + self._rpc_event.set() + + elif command == CMD_REQUEST_WIFI_NETWORKS: + if not strings: + self._wifi_scan_done.set() + else: + if len(strings) >= 3: + self._wifi_networks.append( + (strings[0], int(strings[1]), strings[2] == "YES") + ) + + elif command == CMD_SEND_WIFI_SETTINGS: + self.log_message.emit(f"Provisioned: {strings}") + self.provisioned.emit(strings) + self._rpc_result = strings + self._rpc_event.set() + + else: + self._rpc_result = strings + self._rpc_event.set() diff --git a/esp_flasher/serial_console.py b/esp_flasher/serial_console.py index 6d302a9..0c20fe4 100644 --- a/esp_flasher/serial_console.py +++ b/esp_flasher/serial_console.py @@ -44,6 +44,7 @@ def stop(self): self.running = False if self.thread: self.thread.join(timeout=1.0) + self.thread = None def _read_loop(self): """Read loop running in background thread""" From 607360c3b837639a88c6976ace5342a7635f629e Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Sun, 5 Apr 2026 14:47:41 +0200 Subject: [PATCH 02/13] Response: Improv device info --- esp_flasher/gui.py | 29 +++++++++++++----- esp_flasher/improv.py | 55 +++++++++++++++++++++++++---------- esp_flasher/serial_console.py | 12 +++++++- 3 files changed, 72 insertions(+), 24 deletions(-) diff --git a/esp_flasher/gui.py b/esp_flasher/gui.py index 20549aa..31c1f8d 100644 --- a/esp_flasher/gui.py +++ b/esp_flasher/gui.py @@ -88,8 +88,18 @@ def _init_ui(self): def _start_improv(self): """Start Improv on the already-open serial port (same as JS: port stays open).""" - # Flush stale console data from buffer - self._serial_port.reset_input_buffer() + if not self._serial_port or not self._serial_port.is_open: + self.status_label.setText("Serial port not open") + return + + # Drain & discard stale console data so the Improv state-machine + # starts clean. The read-loop also handles stale bytes via its + # newline-reset logic, so this is belt-and-suspenders. + try: + self._serial_port.reset_input_buffer() + except Exception as e: + self.status_label.setText(f"Port error: {e}") + return from esp_flasher.improv import ImprovManager self._improv = ImprovManager(self._serial_port) @@ -562,14 +572,19 @@ def open_improv(self): self.show_log_error("Connect to a serial port first") return - # Stop console reader — disconnect signals first, then stop thread + # Stop console reader — mute signals first to prevent cross-thread + # queued events from being delivered, then stop thread, then disconnect + # and flush the Qt event queue so no stale events remain. if self._serial_reader: + self._serial_reader.stop() # sets _muted=True and running=False, joins thread + # Verify the thread is truly dead before handing the port to Improv + if self._serial_reader.thread and self._serial_reader.thread.is_alive(): + self._serial_reader.thread.join(timeout=2.0) self._serial_reader.line_received.disconnect(self.append_log_line) self._serial_reader.error_occurred.disconnect(self.handle_serial_error) - self._serial_reader.stop() - # Wait until thread is truly dead - if self._serial_reader.thread and self._serial_reader.thread.is_alive(): - self._serial_reader.thread.join(timeout=3.0) + # Flush any already-queued cross-thread events so they are discarded + from PyQt6.QtWidgets import QApplication + QApplication.processEvents() self._serial_reader = None # Disable console input while in Improv mode diff --git a/esp_flasher/improv.py b/esp_flasher/improv.py index 1891bbd..3bc730d 100644 --- a/esp_flasher/improv.py +++ b/esp_flasher/improv.py @@ -105,7 +105,7 @@ def _parse_tlv_strings(data): idx += 1 if idx + str_len > len(data): break - s = data[idx:idx + str_len].decode("utf-8", errors="replace") + s = bytes(data[idx:idx + str_len]).decode("utf-8", errors="replace") strings.append(s) idx += str_len return strings @@ -162,7 +162,7 @@ def stop(self): """Stop the Improv receiver thread.""" self._running = False if self._thread: - self._thread.join(timeout=2.0) + self._thread.join(timeout=3.0) self._thread = None # --- Public API --- @@ -225,21 +225,44 @@ def _write(self, data): self.log_message.emit(f"Write error: {e}") def _read_loop(self): - """Background thread: read bytes and detect Improv packets.""" - while self._running: - try: - if not self._port or not self._port.is_open: + """Background thread: read bytes and detect Improv packets. + Uses blocking read(1) instead of in_waiting polling — mirrors + the JS 'await reader.read()' approach and avoids macOS issues + where in_waiting may return 0 after tcflush/reset_input_buffer.""" + # Use short timeout so stop() isn't blocked for too long + old_timeout = self._port.timeout + try: + self._port.timeout = 0.1 + except Exception: + pass + try: + while self._running: + try: + if not self._port or not self._port.is_open: + self.log_message.emit("Serial port closed") + break + # Blocking read — returns 1 byte or b'' on timeout + raw = self._port.read(1) + if raw: + # Got one byte; also grab anything else already buffered + try: + remaining = self._port.in_waiting + if remaining > 0: + raw += self._port.read(remaining) + except OSError: + pass + for b in raw: + self._process_byte(b) + except Exception as e: + if self._running: + logger.error("Improv read error: %s", e) + self.log_message.emit(f"Read error: {e}") break - if self._port.in_waiting > 0: - raw = self._port.read(self._port.in_waiting) - for b in raw: - self._process_byte(b) - else: - time.sleep(0.01) - except Exception as e: - if self._running: - logger.error("Improv read error: %s", e) - break + finally: + try: + self._port.timeout = old_timeout + except Exception: + pass def _process_byte(self, byte): """Process a single byte — exact port of JS _processInput state machine.""" diff --git a/esp_flasher/serial_console.py b/esp_flasher/serial_console.py index 0c20fe4..6f22aaf 100644 --- a/esp_flasher/serial_console.py +++ b/esp_flasher/serial_console.py @@ -28,6 +28,7 @@ def __init__(self, serial_port): super().__init__() self.serial_port = serial_port self.running = False + self._muted = False # When True, suppress all signal emissions self.thread = None # Use incremental decoder for proper UTF-8 handling import codecs @@ -41,6 +42,7 @@ def start(self): def stop(self): """Stop reading from serial port""" + self._muted = True # Suppress signals immediately, before thread winds down self.running = False if self.thread: self.thread.join(timeout=1.0) @@ -61,7 +63,7 @@ def _read_loop(self): buffer += text # Process complete lines (ending with \n or \r) - while '\n' in buffer or '\r' in buffer: + while self.running and ('\n' in buffer or '\r' in buffer): # Find the first line ending idx_n = buffer.find('\n') idx_r = buffer.find('\r') @@ -102,6 +104,8 @@ def _read_loop(self): else: break except serial.SerialException as e: + if self._muted: + break root = e.__cause__ or e.__context__ err_str = str(e).lower() if ( @@ -113,6 +117,8 @@ def _read_loop(self): self.error_occurred.emit(f"Serial port error: {e}") break except OSError as e: + if self._muted: + break if e.errno in ( 5, # EIO - Input/output error (Linux) 6, # ENXIO - No such device or address (macOS/Linux) @@ -125,11 +131,15 @@ def _read_loop(self): self.error_occurred.emit(f"Unexpected error: {e}") break except Exception as e: + if self._muted: + break self.error_occurred.emit(f"Unexpected error: {e}") break def _emit_line(self, line): """Emit a line with optional timestamp""" + if self._muted: + return # Remove ANSI codes for timestamp detection import re ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') From 56a71723da5494c58b642e110115197f7e9432a0 Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Sun, 5 Apr 2026 15:07:07 +0200 Subject: [PATCH 03/13] working --- esp_flasher/gui.py | 12 +++++++++--- esp_flasher/improv.py | 17 +++++++++++------ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/esp_flasher/gui.py b/esp_flasher/gui.py index 31c1f8d..6339201 100644 --- a/esp_flasher/gui.py +++ b/esp_flasher/gui.py @@ -20,6 +20,8 @@ class ImprovDialog(QDialog): """Dialog for Improv WiFi provisioning.""" + _scan_finished = pyqtSignal(list) # thread-safe signal for scan results + _provision_failed_signal = pyqtSignal() # thread-safe signal for provision failure def __init__(self, serial_port, parent=None): super().__init__(parent) @@ -28,6 +30,8 @@ def __init__(self, serial_port, parent=None): self._serial_port = serial_port # reuse already-open port (never close it) self._improv = None self._is_provisioning = False # True only after we send credentials + self._scan_finished.connect(self._update_network_list) + self._provision_failed_signal.connect(self._provision_failed) self._init_ui() self._start_improv() @@ -36,6 +40,7 @@ def _init_ui(self): # Device info self.info_label = QLabel("Detecting Improv device...") + self.info_label.setWordWrap(True) layout.addWidget(self.info_label) # WiFi network list @@ -177,6 +182,7 @@ def _on_device_info(self, info): self.info_label.setText(" | ".join(parts) if parts else "Device detected") def _on_log(self, msg): + """Show Improv status messages in the dialog's status label.""" self.status_label.setText(msg) def _on_provisioned(self, result): @@ -203,8 +209,8 @@ def _scan_bg(self): networks = self._improv.request_wifi_networks() # Sort by RSSI descending networks.sort(key=lambda n: n[1], reverse=True) - # Update UI from main thread via signal - QTimer.singleShot(0, lambda: self._update_network_list(networks)) + # Thread-safe: emit signal to update UI on main thread + self._scan_finished.emit(networks) def _update_network_list(self, networks): self.network_list.clear() @@ -237,7 +243,7 @@ def _provision(self): def _provision_bg(self, ssid, password): result = self._improv.send_wifi_settings(ssid, password) if result is None: - QTimer.singleShot(0, lambda: self._provision_failed()) + self._provision_failed_signal.emit() def _provision_failed(self): self._is_provisioning = False diff --git a/esp_flasher/improv.py b/esp_flasher/improv.py index 3bc730d..bdf105c 100644 --- a/esp_flasher/improv.py +++ b/esp_flasher/improv.py @@ -177,14 +177,14 @@ def request_device_info(self): No timeout — matches JS where requestInfo() waits indefinitely.""" return self._send_rpc(CMD_REQUEST_INFO) - def request_wifi_networks(self): - """Scan for WiFi networks. Returns list of (ssid, rssi, secured). - No timeout — matches JS where scan() waits indefinitely.""" + def request_wifi_networks(self, timeout=30.0): + """Scan for WiFi networks. Returns list of (ssid, rssi, secured).""" self._wifi_networks = [] self._wifi_scan_done.clear() pkt = _build_rpc(CMD_REQUEST_WIFI_NETWORKS) self._write(pkt) - self._wifi_scan_done.wait() + if not self._wifi_scan_done.wait(timeout=timeout): + self.log_message.emit("WiFi scan timeout") return list(self._wifi_networks) def send_wifi_settings(self, ssid, password, timeout=PROVISION_TIMEOUT): @@ -351,18 +351,23 @@ def _handle_packet(self, line): strings = _parse_tlv_strings(data) if command == CMD_REQUEST_INFO: - self.log_message.emit(f"Device info: {strings}") self.device_info_received.emit(strings) self._rpc_result = strings self._rpc_event.set() elif command == CMD_REQUEST_WIFI_NETWORKS: if not strings: + # Empty result = scan complete (matches JS: receivedData done) + self.log_message.emit(f"WiFi scan complete: {len(self._wifi_networks)} networks") self._wifi_scan_done.set() else: if len(strings) >= 3: + try: + rssi = int(strings[1]) + except (ValueError, IndexError): + rssi = 0 self._wifi_networks.append( - (strings[0], int(strings[1]), strings[2] == "YES") + (strings[0], rssi, strings[2] == "YES") ) elif command == CMD_SEND_WIFI_SETTINGS: From 1dd831186807e653465891e3758cd09c9cc96fc3 Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Sun, 5 Apr 2026 15:14:40 +0200 Subject: [PATCH 04/13] fix: Blocking calls --- esp_flasher/gui.py | 12 +++++++++--- esp_flasher/improv.py | 16 +++++++++++++--- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/esp_flasher/gui.py b/esp_flasher/gui.py index 6339201..de83538 100644 --- a/esp_flasher/gui.py +++ b/esp_flasher/gui.py @@ -50,9 +50,9 @@ def _init_ui(self): self.network_list.itemDoubleClicked.connect(self._on_network_selected) net_layout.addWidget(self.network_list) - scan_btn = QPushButton("Scan Networks") - scan_btn.clicked.connect(self._scan_networks) - net_layout.addWidget(scan_btn) + self.scan_btn = QPushButton("Scan Networks") + self.scan_btn.clicked.connect(self._scan_networks) + net_layout.addWidget(self.scan_btn) net_group.setLayout(net_layout) layout.addWidget(net_group) @@ -200,6 +200,10 @@ def _on_network_selected(self, item): self.password_input.setFocus() def _scan_networks(self): + if getattr(self, '_scan_in_progress', False): + return + self._scan_in_progress = True + self.scan_btn.setEnabled(False) self.network_list.clear() self.status_label.setText("Scanning WiFi networks...") self.progress.setVisible(True) @@ -213,6 +217,8 @@ def _scan_bg(self): self._scan_finished.emit(networks) def _update_network_list(self, networks): + self._scan_in_progress = False + self.scan_btn.setEnabled(True) self.network_list.clear() self.progress.setVisible(False) if not networks: diff --git a/esp_flasher/improv.py b/esp_flasher/improv.py index bdf105c..b8210c2 100644 --- a/esp_flasher/improv.py +++ b/esp_flasher/improv.py @@ -161,6 +161,9 @@ def start(self): def stop(self): """Stop the Improv receiver thread.""" self._running = False + # Unblock any threads waiting on RPC responses or WiFi scan + self._rpc_event.set() + self._wifi_scan_done.set() if self._thread: self._thread.join(timeout=3.0) self._thread = None @@ -220,9 +223,16 @@ def _write(self, data): if self._port and self._port.is_open: self._port.write(data) self._port.flush() + else: + # Port not available — unblock any waiters so they don't hang + self._rpc_event.set() + self._wifi_scan_done.set() except Exception as e: logger.error("Improv write error: %s", e) self.log_message.emit(f"Write error: {e}") + # Unblock any threads waiting for a response that will never come + self._rpc_event.set() + self._wifi_scan_done.set() def _read_loop(self): """Background thread: read bytes and detect Improv packets. @@ -302,10 +312,10 @@ def _process_byte(self, byte): self._improv_length = 9 + data_len + 1 # 9 header + data + checksum self._is_improv = True else: - # Not an Improv header — discard and go back to scanning - # (JS does: isImprov = false, line = []) + # Not an Improv header — discard buffer and skip rest of line + # (JS: isImprov = false, line = [] — skip until next newline) self._line = [] - self._is_improv = None + self._is_improv = False def _handle_packet(self, line): """Handle a complete Improv packet (including header + checksum).""" From 0f7d7be3cf7a153209c3f4b1b014a02bb0b49640 Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Sun, 5 Apr 2026 15:29:14 +0200 Subject: [PATCH 05/13] nitpick fixes --- esp_flasher/gui.py | 5 +---- esp_flasher/improv.py | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/esp_flasher/gui.py b/esp_flasher/gui.py index de83538..00559d9 100644 --- a/esp_flasher/gui.py +++ b/esp_flasher/gui.py @@ -102,7 +102,7 @@ def _start_improv(self): # newline-reset logic, so this is belt-and-suspenders. try: self._serial_port.reset_input_buffer() - except Exception as e: + except (OSError, serial.SerialException) as e: self.status_label.setText(f"Port error: {e}") return @@ -589,9 +589,6 @@ def open_improv(self): # and flush the Qt event queue so no stale events remain. if self._serial_reader: self._serial_reader.stop() # sets _muted=True and running=False, joins thread - # Verify the thread is truly dead before handing the port to Improv - if self._serial_reader.thread and self._serial_reader.thread.is_alive(): - self._serial_reader.thread.join(timeout=2.0) self._serial_reader.line_received.disconnect(self.append_log_line) self._serial_reader.error_occurred.disconnect(self.handle_serial_error) # Flush any already-queued cross-thread events so they are discarded diff --git a/esp_flasher/improv.py b/esp_flasher/improv.py index b8210c2..55f7d13 100644 --- a/esp_flasher/improv.py +++ b/esp_flasher/improv.py @@ -243,8 +243,8 @@ def _read_loop(self): old_timeout = self._port.timeout try: self._port.timeout = 0.1 - except Exception: - pass + except Exception as e: + logger.debug("Could not set serial timeout: %s", e) try: while self._running: try: From 1c044362efb6734a68b5a935e0ecb50f5a7283d1 Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Sun, 5 Apr 2026 15:35:00 +0200 Subject: [PATCH 06/13] fix: Correlate TYPE_RPC_RESULT packets with the pending command --- esp_flasher/improv.py | 65 ++++++++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 23 deletions(-) diff --git a/esp_flasher/improv.py b/esp_flasher/improv.py index 55f7d13..34dcff0 100644 --- a/esp_flasher/improv.py +++ b/esp_flasher/improv.py @@ -140,6 +140,7 @@ def __init__(self, serial_port): self._improv_length = 0 # RPC response synchronization + self._rpc_lock = threading.Lock() # Serialize RPC calls self._rpc_event = threading.Event() self._rpc_result = None self._rpc_error = None @@ -200,22 +201,28 @@ def send_wifi_settings(self, ssid, password, timeout=PROVISION_TIMEOUT): def _send_rpc(self, command, payload=b"", timeout=None): """Send an RPC command and wait for the result. - timeout=None means wait indefinitely (matches JS behavior for info/scan).""" - self._rpc_event.clear() - self._rpc_result = None - self._rpc_error = None - self._rpc_command = command - - pkt = _build_rpc(command, payload) - self._write(pkt) - - if self._rpc_event.wait(timeout=timeout): - if self._rpc_error is not None and self._rpc_error != ERROR_NONE: - return None - return self._rpc_result - self.log_message.emit("RPC timeout") - self.error_received.emit(ERROR_TIMEOUT) - return None + timeout=None means wait indefinitely (matches JS behavior for info/scan). + Serializes RPC calls to prevent concurrent RPCs from racing.""" + with self._rpc_lock: + self._rpc_event.clear() + self._rpc_result = None + self._rpc_error = None + self._rpc_command = command + + pkt = _build_rpc(command, payload) + self._write(pkt) + + if self._rpc_event.wait(timeout=timeout): + if self._rpc_error is not None and self._rpc_error != ERROR_NONE: + self._rpc_command = None + return None + result = self._rpc_result + self._rpc_command = None + return result + self.log_message.emit("RPC timeout") + self.error_received.emit(ERROR_TIMEOUT) + self._rpc_command = None + return None def _write(self, data): """Write data to the serial port.""" @@ -350,7 +357,8 @@ def _handle_packet(self, line): if error != ERROR_NONE: self.log_message.emit(f"Device error: {error_name}") self.error_received.emit(error) - if error != ERROR_NONE: + # Only wake the waiter if there's a pending RPC command + if error != ERROR_NONE and self._rpc_command is not None: self._rpc_error = error self._rpc_event.set() @@ -360,10 +368,19 @@ def _handle_packet(self, line): command = data[0] strings = _parse_tlv_strings(data) + # Verify result matches pending RPC command to prevent race conditions + if self._rpc_command is not None and command != self._rpc_command: + logger.warning( + "Ignoring RPC result for command %d (expected %d)", + command, self._rpc_command + ) + return + if command == CMD_REQUEST_INFO: self.device_info_received.emit(strings) - self._rpc_result = strings - self._rpc_event.set() + if self._rpc_command == command: + self._rpc_result = strings + self._rpc_event.set() elif command == CMD_REQUEST_WIFI_NETWORKS: if not strings: @@ -383,9 +400,11 @@ def _handle_packet(self, line): elif command == CMD_SEND_WIFI_SETTINGS: self.log_message.emit(f"Provisioned: {strings}") self.provisioned.emit(strings) - self._rpc_result = strings - self._rpc_event.set() + if self._rpc_command == command: + self._rpc_result = strings + self._rpc_event.set() else: - self._rpc_result = strings - self._rpc_event.set() + if self._rpc_command == command: + self._rpc_result = strings + self._rpc_event.set() From abd41a117af10997bfa7e08270252a1cc9811ee3 Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Sun, 5 Apr 2026 15:48:05 +0200 Subject: [PATCH 07/13] enhanced GUI improv device infos --- esp_flasher/gui.py | 141 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 132 insertions(+), 9 deletions(-) diff --git a/esp_flasher/gui.py b/esp_flasher/gui.py index 00559d9..7093561 100644 --- a/esp_flasher/gui.py +++ b/esp_flasher/gui.py @@ -18,6 +18,97 @@ DEFAULT_WINDOW_Y) from esp_flasher.console_color import ColoredConsole + +class DeviceInfoDialog(QDialog): + """Attractive dialog to display device information.""" + + def __init__(self, device_info, parent=None): + super().__init__(parent) + self.setWindowTitle("Device Information") + self.setMinimumWidth(400) + self.setMinimumHeight(250) + self._device_info = device_info + self._init_ui() + + def _init_ui(self): + layout = QVBoxLayout() + layout.setSpacing(15) + layout.setContentsMargins(20, 20, 20, 20) + + # Title + title = QLabel("📱 Device Information") + title.setStyleSheet(""" + QLabel { + font-size: 18px; + font-weight: bold; + color: #2196F3; + padding: 10px; + } + """) + layout.addWidget(title) + + # Info card container + card = QWidget() + card.setStyleSheet(""" + QWidget { + background-color: #f5f5f5; + border-radius: 8px; + padding: 15px; + } + """) + card_layout = QVBoxLayout() + card_layout.setSpacing(12) + + labels = ["Firmware", "Version", "Chip", "Name"] + icons = ["🔧", "📋", "💾", "đŸˇī¸"] + + for i, val in enumerate(self._device_info): + if val and i < len(labels): + info_row = QLabel(f"{icons[i]} {labels[i]}: {val}") + info_row.setStyleSheet(""" + QLabel { + font-size: 13px; + padding: 8px; + background-color: white; + color: #333333; + border-radius: 4px; + border-left: 3px solid #2196F3; + } + """) + info_row.setWordWrap(True) + info_row.setTextFormat(Qt.TextFormat.RichText) + card_layout.addWidget(info_row) + + card.setLayout(card_layout) + layout.addWidget(card) + + layout.addStretch() + + # Close button + close_btn = QPushButton("Close") + close_btn.setStyleSheet(""" + QPushButton { + background-color: #2196F3; + color: white; + border: none; + padding: 10px 20px; + font-size: 13px; + border-radius: 4px; + font-weight: bold; + } + QPushButton:hover { + background-color: #1976D2; + } + QPushButton:pressed { + background-color: #0D47A1; + } + """) + close_btn.clicked.connect(self.accept) + layout.addWidget(close_btn) + + self.setLayout(layout) + + class ImprovDialog(QDialog): """Dialog for Improv WiFi provisioning.""" _scan_finished = pyqtSignal(list) # thread-safe signal for scan results @@ -38,10 +129,31 @@ def __init__(self, serial_port, parent=None): def _init_ui(self): layout = QVBoxLayout() - # Device info + # Device info - now with a button to show details + info_container = QHBoxLayout() self.info_label = QLabel("Detecting Improv device...") self.info_label.setWordWrap(True) - layout.addWidget(self.info_label) + info_container.addWidget(self.info_label) + + self.info_btn = QPushButton("â„šī¸ Details") + self.info_btn.setVisible(False) + self.info_btn.setMaximumWidth(100) + self.info_btn.setStyleSheet(""" + QPushButton { + background-color: #2196F3; + color: white; + border: none; + padding: 5px 10px; + border-radius: 4px; + font-weight: bold; + } + QPushButton:hover { + background-color: #1976D2; + } + """) + self.info_btn.clicked.connect(self._show_device_info_dialog) + info_container.addWidget(self.info_btn) + layout.addLayout(info_container) # WiFi network list net_group = QGroupBox("WiFi Networks") @@ -173,13 +285,24 @@ def _on_error(self, error): self.provision_btn.setEnabled(True) def _on_device_info(self, info): - parts = [] - labels = ["Firmware", "Version", "Chip", "Name"] - for i, val in enumerate(info): - if val: - label = labels[i] if i < len(labels) else f"Field{i}" - parts.append(f"{label}: {val}") - self.info_label.setText(" | ".join(parts) if parts else "Device detected") + self._device_info = info # Store for later display + + if not info or not any(info): + self.info_label.setText("Device detected") + self.info_btn.setVisible(False) + return + + # Show compact summary + name = info[3] if len(info) > 3 and info[3] else "Unknown" + chip = info[2] if len(info) > 2 and info[2] else "Unknown" + self.info_label.setText(f"📱 Device: {name} ({chip})") + self.info_btn.setVisible(True) + + def _show_device_info_dialog(self): + """Open the detailed device info dialog.""" + if hasattr(self, '_device_info') and self._device_info: + dialog = DeviceInfoDialog(self._device_info, self) + dialog.exec() def _on_log(self, msg): """Show Improv status messages in the dialog's status label.""" From b342856af613cdd2366271f1ff5e5fb0a0c8069b Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Sun, 5 Apr 2026 15:51:14 +0200 Subject: [PATCH 08/13] better symbols --- esp_flasher/gui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/esp_flasher/gui.py b/esp_flasher/gui.py index 7093561..f7227c3 100644 --- a/esp_flasher/gui.py +++ b/esp_flasher/gui.py @@ -60,7 +60,7 @@ def _init_ui(self): card_layout.setSpacing(12) labels = ["Firmware", "Version", "Chip", "Name"] - icons = ["🔧", "📋", "💾", "đŸˇī¸"] + icons = ["âš™ī¸", "đŸ”ĸ", "🔌", "📛"] for i, val in enumerate(self._device_info): if val and i < len(labels): From f44598e81c2684fc51c37a90f30e480fb01bba87 Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Sun, 5 Apr 2026 16:12:00 +0200 Subject: [PATCH 09/13] guard thread died / symbols changed --- esp_flasher/gui.py | 32 +++++++++++++++++--------------- esp_flasher/improv.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/esp_flasher/gui.py b/esp_flasher/gui.py index f7227c3..e13c08a 100644 --- a/esp_flasher/gui.py +++ b/esp_flasher/gui.py @@ -3,6 +3,7 @@ import threading import os import platform +import serial from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QComboBox, @@ -17,6 +18,7 @@ DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_X, DEFAULT_WINDOW_Y) from esp_flasher.console_color import ColoredConsole +from esp_flasher.serial_console import SerialReader class DeviceInfoDialog(QDialog): @@ -60,7 +62,7 @@ def _init_ui(self): card_layout.setSpacing(12) labels = ["Firmware", "Version", "Chip", "Name"] - icons = ["âš™ī¸", "đŸ”ĸ", "🔌", "📛"] + icons = ["âš™ī¸", "đŸ”ĸ", "🧠", "đŸˇī¸"] for i, val in enumerate(self._device_info): if val and i < len(labels): @@ -262,7 +264,8 @@ def _on_state_changed(self, state): self.status_label.setText(f"State: {name}") self.provision_btn.setEnabled(True) # Auto-request device info - threading.Thread(target=self._request_info_bg, daemon=True).start() + improv = self._improv + threading.Thread(target=lambda: self._request_info_bg(improv), daemon=True).start() elif state == STATE_PROVISIONING: self.status_label.setText("Connecting to WiFi...") self.provision_btn.setEnabled(False) @@ -275,7 +278,8 @@ def _on_state_changed(self, state): self.status_label.setText("Device already connected to WiFi") self.provision_btn.setEnabled(True) # Request device info in both cases - threading.Thread(target=self._request_info_bg, daemon=True).start() + improv = self._improv + threading.Thread(target=lambda: self._request_info_bg(improv), daemon=True).start() def _on_error(self, error): from esp_flasher.improv import ERROR_NAMES, ERROR_NONE @@ -330,10 +334,11 @@ def _scan_networks(self): self.network_list.clear() self.status_label.setText("Scanning WiFi networks...") self.progress.setVisible(True) - threading.Thread(target=self._scan_bg, daemon=True).start() + improv = self._improv + threading.Thread(target=lambda: self._scan_bg(improv), daemon=True).start() - def _scan_bg(self): - networks = self._improv.request_wifi_networks() + def _scan_bg(self, improv): + networks = improv.request_wifi_networks() # Sort by RSSI descending networks.sort(key=lambda n: n[1], reverse=True) # Thread-safe: emit signal to update UI on main thread @@ -354,8 +359,8 @@ def _update_network_list(self, networks): self.network_list.addItem(item) self.status_label.setText(f"Found {len(networks)} networks") - def _request_info_bg(self): - self._improv.request_device_info() + def _request_info_bg(self, improv): + improv.request_device_info() def _provision(self): ssid = self.ssid_input.text().strip() @@ -367,10 +372,11 @@ def _provision(self): self.provision_btn.setEnabled(False) self.progress.setVisible(True) self.status_label.setText(f"Provisioning WiFi: {ssid}...") - threading.Thread(target=self._provision_bg, args=(ssid, password), daemon=True).start() + improv = self._improv + threading.Thread(target=lambda: self._provision_bg(improv, ssid, password), daemon=True).start() - def _provision_bg(self, ssid, password): - result = self._improv.send_wifi_settings(ssid, password) + def _provision_bg(self, improv, ssid, password): + result = improv.send_wifi_settings(ssid, password) if result is None: self._provision_failed_signal.emit() @@ -613,9 +619,6 @@ def connect_to_port(self): # Start serial communication try: - import serial - from esp_flasher.serial_console import SerialReader - self._serial_port = serial.Serial(self._port, baudrate=115200, timeout=1) # Start reader thread @@ -729,7 +732,6 @@ def open_improv(self): # Restart console reader on the same open port (like JS reconnectConsole) if self._serial_port and self._serial_port.is_open: - from esp_flasher.serial_console import SerialReader self._serial_reader = SerialReader(self._serial_port) self._serial_reader.line_received.connect(self.append_log_line) self._serial_reader.error_occurred.connect(self.handle_serial_error) diff --git a/esp_flasher/improv.py b/esp_flasher/improv.py index 34dcff0..3a82a5d 100644 --- a/esp_flasher/improv.py +++ b/esp_flasher/improv.py @@ -133,6 +133,7 @@ def __init__(self, serial_port): self._port = serial_port self._running = False self._thread = None + self._receiver_dead = False # Flag to indicate receiver thread has exited # Receiver state machine (matches JS: undefined/true/false) self._line = [] @@ -156,12 +157,14 @@ def __init__(self, serial_port): def start(self): """Start the Improv receiver thread.""" self._running = True + self._receiver_dead = False self._thread = threading.Thread(target=self._read_loop, daemon=True) self._thread.start() def stop(self): """Stop the Improv receiver thread.""" self._running = False + self._receiver_dead = True # Unblock any threads waiting on RPC responses or WiFi scan self._rpc_event.set() self._wifi_scan_done.set() @@ -183,11 +186,17 @@ def request_device_info(self): def request_wifi_networks(self, timeout=30.0): """Scan for WiFi networks. Returns list of (ssid, rssi, secured).""" + if not self._running or not self._port or not self._port.is_open: + return [] + if self._receiver_dead: + raise RuntimeError("Improv receiver thread has died") self._wifi_networks = [] self._wifi_scan_done.clear() pkt = _build_rpc(CMD_REQUEST_WIFI_NETWORKS) self._write(pkt) if not self._wifi_scan_done.wait(timeout=timeout): + if self._receiver_dead: + raise RuntimeError("Improv receiver thread died during WiFi scan") self.log_message.emit("WiFi scan timeout") return list(self._wifi_networks) @@ -203,7 +212,16 @@ def _send_rpc(self, command, payload=b"", timeout=None): """Send an RPC command and wait for the result. timeout=None means wait indefinitely (matches JS behavior for info/scan). Serializes RPC calls to prevent concurrent RPCs from racing.""" + if not self._running or not self._port or not self._port.is_open: + return None + if self._receiver_dead: + raise RuntimeError("Improv receiver thread has died") + with self._rpc_lock: + # Check again after acquiring lock + if self._receiver_dead: + raise RuntimeError("Improv receiver thread has died") + self._rpc_event.clear() self._rpc_result = None self._rpc_error = None @@ -213,12 +231,16 @@ def _send_rpc(self, command, payload=b"", timeout=None): self._write(pkt) if self._rpc_event.wait(timeout=timeout): + if self._receiver_dead: + raise RuntimeError("Improv receiver thread died during RPC") if self._rpc_error is not None and self._rpc_error != ERROR_NONE: self._rpc_command = None return None result = self._rpc_result self._rpc_command = None return result + if self._receiver_dead: + raise RuntimeError("Improv receiver thread died during RPC") self.log_message.emit("RPC timeout") self.error_received.emit(ERROR_TIMEOUT) self._rpc_command = None @@ -257,6 +279,9 @@ def _read_loop(self): try: if not self._port or not self._port.is_open: self.log_message.emit("Serial port closed") + self._running = False + self._rpc_event.set() + self._wifi_scan_done.set() break # Blocking read — returns 1 byte or b'' on timeout raw = self._port.read(1) @@ -274,8 +299,15 @@ def _read_loop(self): if self._running: logger.error("Improv read error: %s", e) self.log_message.emit(f"Read error: {e}") + self._running = False + self._rpc_event.set() + self._wifi_scan_done.set() break finally: + # Mark receiver as dead and wake all waiters + self._receiver_dead = True + self._rpc_event.set() + self._wifi_scan_done.set() try: self._port.timeout = old_timeout except Exception: From 4e5601697ae827dfe54bb1eefcc315f7c914aebe Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Sun, 5 Apr 2026 16:27:38 +0200 Subject: [PATCH 10/13] improv guards to prevent AttributeError --- esp_flasher/gui.py | 6 ++++++ esp_flasher/improv.py | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/esp_flasher/gui.py b/esp_flasher/gui.py index e13c08a..9dd2e62 100644 --- a/esp_flasher/gui.py +++ b/esp_flasher/gui.py @@ -329,6 +329,9 @@ def _on_network_selected(self, item): def _scan_networks(self): if getattr(self, '_scan_in_progress', False): return + if not self._improv: + self.status_label.setText("Improv not initialized") + return self._scan_in_progress = True self.scan_btn.setEnabled(False) self.network_list.clear() @@ -368,6 +371,9 @@ def _provision(self): if not ssid: self.status_label.setText("Please enter an SSID") return + if not self._improv: + self.status_label.setText("Improv not initialized") + return self._is_provisioning = True self.provision_btn.setEnabled(False) self.progress.setVisible(True) diff --git a/esp_flasher/improv.py b/esp_flasher/improv.py index 3a82a5d..ed4f3da 100644 --- a/esp_flasher/improv.py +++ b/esp_flasher/improv.py @@ -310,8 +310,8 @@ def _read_loop(self): self._wifi_scan_done.set() try: self._port.timeout = old_timeout - except Exception: - pass + except Exception as e: + logger.debug("Could not restore serial timeout: %s", e) def _process_byte(self, byte): """Process a single byte — exact port of JS _processInput state machine.""" From ef306c14117ff87a58a00e3f72a58acec6d4caca Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Sun, 5 Apr 2026 17:30:08 +0200 Subject: [PATCH 11/13] add timeout for improv device info RPC --- esp_flasher/gui.py | 28 ++++++++++++++------ esp_flasher/improv.py | 59 ++++++++++++++++++++++++++++--------------- 2 files changed, 58 insertions(+), 29 deletions(-) diff --git a/esp_flasher/gui.py b/esp_flasher/gui.py index 9dd2e62..59da484 100644 --- a/esp_flasher/gui.py +++ b/esp_flasher/gui.py @@ -341,11 +341,16 @@ def _scan_networks(self): threading.Thread(target=lambda: self._scan_bg(improv), daemon=True).start() def _scan_bg(self, improv): - networks = improv.request_wifi_networks() - # Sort by RSSI descending - networks.sort(key=lambda n: n[1], reverse=True) - # Thread-safe: emit signal to update UI on main thread - self._scan_finished.emit(networks) + try: + networks = improv.request_wifi_networks() + # Sort by RSSI descending + networks.sort(key=lambda n: n[1], reverse=True) + # Thread-safe: emit signal to update UI on main thread + self._scan_finished.emit(networks) + except Exception as e: + logger.error("WiFi scan error: %s", e) + # Emit empty list to reset UI + self._scan_finished.emit([]) def _update_network_list(self, networks): self._scan_in_progress = False @@ -363,7 +368,10 @@ def _update_network_list(self, networks): self.status_label.setText(f"Found {len(networks)} networks") def _request_info_bg(self, improv): - improv.request_device_info() + try: + improv.request_device_info() + except Exception as e: + logger.error("Device info request error: %s", e) def _provision(self): ssid = self.ssid_input.text().strip() @@ -382,8 +390,12 @@ def _provision(self): threading.Thread(target=lambda: self._provision_bg(improv, ssid, password), daemon=True).start() def _provision_bg(self, improv, ssid, password): - result = improv.send_wifi_settings(ssid, password) - if result is None: + try: + result = improv.send_wifi_settings(ssid, password) + if result is None: + self._provision_failed_signal.emit() + except Exception as e: + logger.error("Provisioning error: %s", e) self._provision_failed_signal.emit() def _provision_failed(self): diff --git a/esp_flasher/improv.py b/esp_flasher/improv.py index ed4f3da..51dee9a 100644 --- a/esp_flasher/improv.py +++ b/esp_flasher/improv.py @@ -179,10 +179,10 @@ def request_current_state(self): pkt = _build_rpc(CMD_REQUEST_CURRENT_STATE) self._write(pkt) - def request_device_info(self): + def request_device_info(self, timeout=10.0): """Request device info. Returns [firmware, version, chip, name] or None. - No timeout — matches JS where requestInfo() waits indefinitely.""" - return self._send_rpc(CMD_REQUEST_INFO) + Uses a 10s timeout to prevent indefinite blocking.""" + return self._send_rpc(CMD_REQUEST_INFO, timeout=timeout) def request_wifi_networks(self, timeout=30.0): """Scan for WiFi networks. Returns list of (ssid, rssi, secured).""" @@ -190,14 +190,25 @@ def request_wifi_networks(self, timeout=30.0): return [] if self._receiver_dead: raise RuntimeError("Improv receiver thread has died") - self._wifi_networks = [] - self._wifi_scan_done.clear() - pkt = _build_rpc(CMD_REQUEST_WIFI_NETWORKS) - self._write(pkt) + + # Acquire lock to reserve RPC slot + self._rpc_lock.acquire() + try: + self._wifi_networks = [] + self._wifi_scan_done.clear() + self._rpc_command = CMD_REQUEST_WIFI_NETWORKS + pkt = _build_rpc(CMD_REQUEST_WIFI_NETWORKS) + self._write(pkt) + finally: + self._rpc_lock.release() + + # Wait for scan completion without holding lock if not self._wifi_scan_done.wait(timeout=timeout): if self._receiver_dead: raise RuntimeError("Improv receiver thread died during WiFi scan") self.log_message.emit("WiFi scan timeout") + self._rpc_command = None + return list(self._wifi_networks) return list(self._wifi_networks) def send_wifi_settings(self, ssid, password, timeout=PROVISION_TIMEOUT): @@ -217,7 +228,9 @@ def _send_rpc(self, command, payload=b"", timeout=None): if self._receiver_dead: raise RuntimeError("Improv receiver thread has died") - with self._rpc_lock: + # Acquire lock only to send request and register waiter + self._rpc_lock.acquire() + try: # Check again after acquiring lock if self._receiver_dead: raise RuntimeError("Improv receiver thread has died") @@ -229,22 +242,26 @@ def _send_rpc(self, command, payload=b"", timeout=None): pkt = _build_rpc(command, payload) self._write(pkt) - - if self._rpc_event.wait(timeout=timeout): - if self._receiver_dead: - raise RuntimeError("Improv receiver thread died during RPC") - if self._rpc_error is not None and self._rpc_error != ERROR_NONE: - self._rpc_command = None - return None - result = self._rpc_result - self._rpc_command = None - return result + finally: + # Release lock before waiting so other RPCs can queue + self._rpc_lock.release() + + # Wait for response without holding the lock + if self._rpc_event.wait(timeout=timeout): if self._receiver_dead: raise RuntimeError("Improv receiver thread died during RPC") - self.log_message.emit("RPC timeout") - self.error_received.emit(ERROR_TIMEOUT) + if self._rpc_error is not None and self._rpc_error != ERROR_NONE: + self._rpc_command = None + return None + result = self._rpc_result self._rpc_command = None - return None + return result + if self._receiver_dead: + raise RuntimeError("Improv receiver thread died during RPC") + self.log_message.emit("RPC timeout") + self.error_received.emit(ERROR_TIMEOUT) + self._rpc_command = None + return None def _write(self, data): """Write data to the serial port.""" From dd6d1933ffa0ed9daecec8ad7f107e33a3b648ea Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Sun, 5 Apr 2026 19:33:16 +0200 Subject: [PATCH 12/13] align scheme colors --- esp_flasher/gui.py | 52 ++++++++++++++++++++++++++----------------- esp_flasher/improv.py | 1 - 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/esp_flasher/gui.py b/esp_flasher/gui.py index 59da484..b852953 100644 --- a/esp_flasher/gui.py +++ b/esp_flasher/gui.py @@ -2,6 +2,7 @@ import sys import threading import os +import logging import platform import serial @@ -20,6 +21,7 @@ from esp_flasher.console_color import ColoredConsole from esp_flasher.serial_console import SerialReader +logger = logging.getLogger(__name__) class DeviceInfoDialog(QDialog): """Attractive dialog to display device information.""" @@ -38,12 +40,12 @@ def _init_ui(self): layout.setContentsMargins(20, 20, 20, 20) # Title - title = QLabel("📱 Device Information") + title = QLabel("Device Information") title.setStyleSheet(""" QLabel { font-size: 18px; font-weight: bold; - color: #2196F3; + color: white; padding: 10px; } """) @@ -53,7 +55,6 @@ def _init_ui(self): card = QWidget() card.setStyleSheet(""" QWidget { - background-color: #f5f5f5; border-radius: 8px; padding: 15px; } @@ -62,19 +63,16 @@ def _init_ui(self): card_layout.setSpacing(12) labels = ["Firmware", "Version", "Chip", "Name"] - icons = ["âš™ī¸", "đŸ”ĸ", "🧠", "đŸˇī¸"] for i, val in enumerate(self._device_info): if val and i < len(labels): - info_row = QLabel(f"{icons[i]} {labels[i]}: {val}") + info_row = QLabel(f"{labels[i]}: {val}") info_row.setStyleSheet(""" QLabel { font-size: 13px; padding: 8px; - background-color: white; - color: #333333; border-radius: 4px; - border-left: 3px solid #2196F3; + border-left: 3px; } """) info_row.setWordWrap(True) @@ -90,19 +88,21 @@ def _init_ui(self): close_btn = QPushButton("Close") close_btn.setStyleSheet(""" QPushButton { - background-color: #2196F3; - color: white; - border: none; padding: 10px 20px; font-size: 13px; border-radius: 4px; font-weight: bold; + border: 1px solid #cccccc; } QPushButton:hover { - background-color: #1976D2; + background-color: #2d5016; + color: white; + border: 1px solid #2d5016; } QPushButton:pressed { - background-color: #0D47A1; + background-color: #1f3a0f; + color: white; + border: 1px solid #1f3a0f; } """) close_btn.clicked.connect(self.accept) @@ -136,21 +136,25 @@ def _init_ui(self): self.info_label = QLabel("Detecting Improv device...") self.info_label.setWordWrap(True) info_container.addWidget(self.info_label) - - self.info_btn = QPushButton("â„šī¸ Details") + self.info_btn = QPushButton("Details") self.info_btn.setVisible(False) self.info_btn.setMaximumWidth(100) self.info_btn.setStyleSheet(""" QPushButton { - background-color: #2196F3; - color: white; - border: none; padding: 5px 10px; border-radius: 4px; font-weight: bold; + border: 1px solid #cccccc; } QPushButton:hover { - background-color: #1976D2; + background-color: #2d5016; + color: white; + border: 1px solid #2d5016; + } + QPushButton:pressed { + background-color: #1f3a0f; + color: white; + border: 1px solid #1f3a0f; } """) self.info_btn.clicked.connect(self._show_device_info_dialog) @@ -299,7 +303,15 @@ def _on_device_info(self, info): # Show compact summary name = info[3] if len(info) > 3 and info[3] else "Unknown" chip = info[2] if len(info) > 2 and info[2] else "Unknown" - self.info_label.setText(f"📱 Device: {name} ({chip})") + self.info_label.setText(f"{name} ({chip})") + self.info_label.setStyleSheet(""" + QLabel { + font-size: 15px; + font-weight: bold; + color: white; + padding: 0px; + } + """) self.info_btn.setVisible(True) def _show_device_info_dialog(self): diff --git a/esp_flasher/improv.py b/esp_flasher/improv.py index 51dee9a..cb1a806 100644 --- a/esp_flasher/improv.py +++ b/esp_flasher/improv.py @@ -209,7 +209,6 @@ def request_wifi_networks(self, timeout=30.0): self.log_message.emit("WiFi scan timeout") self._rpc_command = None return list(self._wifi_networks) - return list(self._wifi_networks) def send_wifi_settings(self, ssid, password, timeout=PROVISION_TIMEOUT): """Send WiFi credentials. Returns result strings or None on error. From 6de5507ffe5982330e9f7eaaf7d8b99a5013bb7c Mon Sep 17 00:00:00 2001 From: Jason2866 Date: Sun, 5 Apr 2026 19:57:29 +0200 Subject: [PATCH 13/13] fix: possible race condition in improv --- esp_flasher/gui.py | 9 +++--- esp_flasher/improv.py | 73 +++++++++++++++++++++---------------------- 2 files changed, 40 insertions(+), 42 deletions(-) diff --git a/esp_flasher/gui.py b/esp_flasher/gui.py index b852953..b0a77a9 100644 --- a/esp_flasher/gui.py +++ b/esp_flasher/gui.py @@ -5,6 +5,7 @@ import logging import platform import serial +import html from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QComboBox, @@ -66,7 +67,7 @@ def _init_ui(self): for i, val in enumerate(self._device_info): if val and i < len(labels): - info_row = QLabel(f"{labels[i]}: {val}") + info_row = QLabel(f"{labels[i]}: {html.escape(val)}") info_row.setStyleSheet(""" QLabel { font-size: 13px; @@ -131,7 +132,7 @@ def __init__(self, serial_port, parent=None): def _init_ui(self): layout = QVBoxLayout() - # Device info - now with a button to show details + # Device info info_container = QHBoxLayout() self.info_label = QLabel("Detecting Improv device...") self.info_label.setWordWrap(True) @@ -210,7 +211,7 @@ def _init_ui(self): self.setLayout(layout) def _start_improv(self): - """Start Improv on the already-open serial port (same as JS: port stays open).""" + """Start Improv on the already-open serial port.""" if not self._serial_port or not self._serial_port.is_open: self.status_label.setText("Serial port not open") return @@ -732,7 +733,7 @@ def _start_flash_worker(self): def open_improv(self): """Open Improv WiFi provisioning dialog. - Matches JS: stop console reader, pass open port to Improv, restart reader on close.""" + stop console reader, pass open port to Improv, restart reader on close.""" if self._is_flashing: self.show_log_error("Cannot use Improv while flashing") return diff --git a/esp_flasher/improv.py b/esp_flasher/improv.py index cb1a806..31cbbb9 100644 --- a/esp_flasher/improv.py +++ b/esp_flasher/improv.py @@ -1,6 +1,5 @@ """ Improv Wi-Fi Serial Protocol implementation for ESP-Flasher. -Based on the Improv Serial specification and esp32tool js/improv.js reference. """ import time @@ -55,7 +54,6 @@ CMD_REQUEST_INFO = 0x03 CMD_REQUEST_WIFI_NETWORKS = 0x04 -# Timeouts (match esp32tool js/improv.js) PROVISION_TIMEOUT = 30.0 @@ -114,7 +112,7 @@ def _parse_tlv_strings(data): class ImprovManager(QObject): """Manages Improv serial communication with an ESP device. - Receiver state machine mirrors the JS reference (esp32tool js/improv.js): + Receiver state machine: - is_improv=None (scanning: accumulate bytes, check at 9 bytes) - is_improv=True (reading improv packet body + checksum) - is_improv=False (skip non-improv line until newline) @@ -135,7 +133,7 @@ def __init__(self, serial_port): self._thread = None self._receiver_dead = False # Flag to indicate receiver thread has exited - # Receiver state machine (matches JS: undefined/true/false) + # Receiver state machine self._line = [] self._is_improv = None # None=scanning, True=reading packet, False=skip line self._improv_length = 0 @@ -191,7 +189,7 @@ def request_wifi_networks(self, timeout=30.0): if self._receiver_dead: raise RuntimeError("Improv receiver thread has died") - # Acquire lock to reserve RPC slot + # Hold lock across entire send-and-wait sequence to prevent race conditions self._rpc_lock.acquire() try: self._wifi_networks = [] @@ -199,20 +197,21 @@ def request_wifi_networks(self, timeout=30.0): self._rpc_command = CMD_REQUEST_WIFI_NETWORKS pkt = _build_rpc(CMD_REQUEST_WIFI_NETWORKS) self._write(pkt) + + # Wait for scan completion while holding lock + if not self._wifi_scan_done.wait(timeout=timeout): + if self._receiver_dead: + raise RuntimeError("Improv receiver thread died during WiFi scan") + self.log_message.emit("WiFi scan timeout") + self._rpc_command = None + return list(self._wifi_networks) finally: + # Release lock after wait completes or times out self._rpc_lock.release() - - # Wait for scan completion without holding lock - if not self._wifi_scan_done.wait(timeout=timeout): - if self._receiver_dead: - raise RuntimeError("Improv receiver thread died during WiFi scan") - self.log_message.emit("WiFi scan timeout") - self._rpc_command = None - return list(self._wifi_networks) def send_wifi_settings(self, ssid, password, timeout=PROVISION_TIMEOUT): """Send WiFi credentials. Returns result strings or None on error. - 30s timeout — matches JS provision(ssid, password, 30000).""" + 30s timeout — (ssid, password, 30000).""" payload = _build_wifi_payload(ssid, password) return self._send_rpc(CMD_SEND_WIFI_SETTINGS, payload, timeout=timeout) @@ -220,14 +219,14 @@ def send_wifi_settings(self, ssid, password, timeout=PROVISION_TIMEOUT): def _send_rpc(self, command, payload=b"", timeout=None): """Send an RPC command and wait for the result. - timeout=None means wait indefinitely (matches JS behavior for info/scan). + timeout=None means wait indefinitely. Serializes RPC calls to prevent concurrent RPCs from racing.""" if not self._running or not self._port or not self._port.is_open: return None if self._receiver_dead: raise RuntimeError("Improv receiver thread has died") - # Acquire lock only to send request and register waiter + # Hold lock across entire send-and-wait sequence to prevent race conditions self._rpc_lock.acquire() try: # Check again after acquiring lock @@ -241,26 +240,26 @@ def _send_rpc(self, command, payload=b"", timeout=None): pkt = _build_rpc(command, payload) self._write(pkt) - finally: - # Release lock before waiting so other RPCs can queue - self._rpc_lock.release() - - # Wait for response without holding the lock - if self._rpc_event.wait(timeout=timeout): + + # Wait for response while holding the lock + if self._rpc_event.wait(timeout=timeout): + if self._receiver_dead: + raise RuntimeError("Improv receiver thread died during RPC") + if self._rpc_error is not None and self._rpc_error != ERROR_NONE: + self._rpc_command = None + return None + result = self._rpc_result + self._rpc_command = None + return result if self._receiver_dead: raise RuntimeError("Improv receiver thread died during RPC") - if self._rpc_error is not None and self._rpc_error != ERROR_NONE: - self._rpc_command = None - return None - result = self._rpc_result + self.log_message.emit("RPC timeout") + self.error_received.emit(ERROR_TIMEOUT) self._rpc_command = None - return result - if self._receiver_dead: - raise RuntimeError("Improv receiver thread died during RPC") - self.log_message.emit("RPC timeout") - self.error_received.emit(ERROR_TIMEOUT) - self._rpc_command = None - return None + return None + finally: + # Release lock after wait completes or times out + self._rpc_lock.release() def _write(self, data): """Write data to the serial port.""" @@ -281,8 +280,7 @@ def _write(self, data): def _read_loop(self): """Background thread: read bytes and detect Improv packets. - Uses blocking read(1) instead of in_waiting polling — mirrors - the JS 'await reader.read()' approach and avoids macOS issues + Uses blocking read(1) instead of in_waiting polling — avoids macOS issues where in_waiting may return 0 after tcflush/reset_input_buffer.""" # Use short timeout so stop() isn't blocked for too long old_timeout = self._port.timeout @@ -330,7 +328,7 @@ def _read_loop(self): logger.debug("Could not restore serial timeout: %s", e) def _process_byte(self, byte): - """Process a single byte — exact port of JS _processInput state machine.""" + """Process a single byte.""" # State: is_improv=False → skip non-improv line until newline if self._is_improv is False: @@ -368,7 +366,6 @@ def _process_byte(self, byte): self._is_improv = True else: # Not an Improv header — discard buffer and skip rest of line - # (JS: isImprov = false, line = [] — skip until next newline) self._line = [] self._is_improv = False @@ -432,7 +429,7 @@ def _handle_packet(self, line): elif command == CMD_REQUEST_WIFI_NETWORKS: if not strings: - # Empty result = scan complete (matches JS: receivedData done) + # Empty result = scan complete self.log_message.emit(f"WiFi scan complete: {len(self._wifi_networks)} networks") self._wifi_scan_done.set() else: