diff --git a/src/birdcage/protocol.py b/src/birdcage/protocol.py index 0a20d97..55be998 100644 --- a/src/birdcage/protocol.py +++ b/src/birdcage/protocol.py @@ -10,6 +10,7 @@ from __future__ import annotations import logging import re +import threading import time from abc import ABC, abstractmethod from collections.abc import Callable @@ -420,20 +421,55 @@ class CarryoutG2Protocol(FirmwareProtocol): raise ValueError(f"Could not parse RSSI from: {response!r}") - def send_with_timeout(self, cmd: str, timeout: float = 90) -> str: - """Send a command with a custom serial timeout. + def send_with_timeout( + self, + cmd: str, + timeout: float = 90, + cancel: threading.Event | None = None, + ) -> str: + """Send a command with a deadline-based timeout and optional cancellation. - Used for long-running firmware commands (azscanwxp, azscan) that stream - output over tens of seconds before the final prompt. + Uses a short per-byte read timeout (2s) so the cancel event can be + checked between reads. The overall deadline limits total wall-clock + time. Used for long-running firmware commands (azscanwxp, azscan) + that stream output before the final prompt. + + Args: + cmd: Command string to send. + timeout: Maximum wall-clock seconds to wait for the prompt. + cancel: Optional event; if set, the read aborts immediately. + + Raises: + InterruptedError: If *cancel* is set during the read. + TimeoutError: If the prompt is not received within *timeout*. """ if not self._serial: raise RuntimeError("Not connected") - original = self._serial.timeout - self._serial.timeout = timeout + + original_timeout = self._serial.timeout + self._serial.timeout = 2.0 # short per-byte timeout for cancel checks try: - return self._send(cmd) + self._serial.write(f"{cmd}\r".encode("ascii")) + + resp_data = bytearray() + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if cancel and cancel.is_set(): + raise InterruptedError(f"Cancelled during: {cmd!r}") + + byte = self._serial.read(1) + if len(byte) == 0: + continue # short timeout expired, loop and check again + resp_data.append(byte[0]) + if byte[0] == self.PROMPT_CHAR: + break + else: + raise TimeoutError(f"No prompt after {timeout}s for: {cmd!r}") + + time.sleep(0.001) + return resp_data.decode("utf-8", errors="ignore") finally: - self._serial.timeout = original + self._serial.timeout = original_timeout def send_raw(self, cmd: str) -> str: """Send arbitrary command, return raw prompt-terminated response.""" diff --git a/tui/src/birdcage_tui/bridge.py b/tui/src/birdcage_tui/bridge.py index 6ab4a4d..cb126b8 100644 --- a/tui/src/birdcage_tui/bridge.py +++ b/tui/src/birdcage_tui/bridge.py @@ -63,6 +63,7 @@ class SerialBridge: def __init__(self, protocol: CarryoutG2Protocol) -> None: self._proto = protocol self._lock = threading.Lock() + self._cancel = threading.Event() self._menu = Menu.UNKNOWN self._connected = False @@ -158,13 +159,20 @@ class SerialBridge: self._menu = self._detect_menu() def disconnect(self) -> None: - """Close the serial connection.""" + """Close the serial connection. + + Signals cancellation first to unblock any long-running serial + reads (e.g. firmware sweep), then acquires the lock to close + the port cleanly. + """ + self._cancel.set() with self._lock: with contextlib.suppress(Exception): self._go_to_root() self._proto.disconnect() self._connected = False self._menu = Menu.UNKNOWN + self._cancel.clear() # reset for potential reconnect @property def is_connected(self) -> bool: @@ -424,9 +432,11 @@ class SerialBridge: # Move to start position and wait for prompt. self._send(f"a 0 {start_az}") # Execute firmware sweep with extended timeout. + # Pass cancel event so disconnect() can interrupt the read. response = self._proto.send_with_timeout( f"azscanwxp 0 {span} {step_cdeg} {num_xponders}", timeout=timeout, + cancel=self._cancel, ) # Parse streaming output lines.