Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions src/glider/hal/boards/telemetrix_board.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,23 @@ def telemetrix(self):

@property
def is_running(self) -> bool:
return self._thread is not None and self._thread.is_alive()
return self._thread is not None and self._thread.is_alive() and self.link_error is None

@property
def link_error(self) -> str | None:
"""Why the serial link died under a live thread, or None if it hasn't.

Once the port drops, telemetrix-aio's serial write swallows the
SerialException and returns None, so the thread outlives its board and
every later write "succeeds" in silence. What does die with the port is
its report reader task. A deliberate shutdown() ends the reader too, so
that is not counted.
"""
tmx = self._telemetrix
task = getattr(tmx, "the_task", None)
if task is None or not task.done() or getattr(tmx, "shutdown_flag", False):
return None
return "serial write failed" if task.cancelled() else repr(task.exception())


# Standard Arduino Uno pin capabilities
Expand Down Expand Up @@ -317,10 +333,11 @@ def is_connected(self) -> bool:
return False

if not self._telemetrix_thread.is_running:
# Thread died but state wasn't updated - fix it now
# Thread or its serial link died but state wasn't updated - fix it now
# Only log once by checking if we're transitioning state
if self._state == BoardConnectionState.CONNECTED:
logger.warning("Telemetrix thread died unexpectedly - marking as disconnected")
reason = self._telemetrix_thread.link_error or "worker thread died"
logger.warning(f"Telemetrix link lost ({reason}) - marking as disconnected")
self._set_state(BoardConnectionState.DISCONNECTED)
# The cached pin state is stale the moment the link drops
# (the Arduino resets on the next serial open); clear it so
Expand Down
29 changes: 28 additions & 1 deletion tests/unit/hal/test_telemetrix_board.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import pytest

from glider.hal.base_board import BoardConnectionState, PinMode, PinType
from glider.hal.boards.telemetrix_board import TelemetrixBoard
from glider.hal.boards.telemetrix_board import TelemetrixBoard, TelemetrixThread


@pytest.fixture
Expand Down Expand Up @@ -160,6 +160,8 @@ async def test_dead_thread_triggers_auto_reconnect():
board = TelemetrixBoard(port="COM3", auto_reconnect=True)

class DeadThread:
link_error = None

@property
def is_running(self):
return False
Expand Down Expand Up @@ -195,6 +197,31 @@ def telemetrix(self):
pass


@pytest.mark.asyncio
async def test_dropped_serial_link_reads_as_disconnected():
"""A live worker thread is not a live board.

Once the USB link drops, telemetrix-aio's serial write swallows the
SerialException and returns None, so every later write "succeeds" and the
rig just stops. The one thing that does die is its report reader task.
"""
reader = asyncio.get_running_loop().create_future()
thread = TelemetrixThread()
thread._thread = types.SimpleNamespace(is_alive=lambda: True)
thread._telemetrix = types.SimpleNamespace(the_task=reader, shutdown_flag=False)
board = TelemetrixBoard(port="COM3")
board._telemetrix_thread = thread
board._state = BoardConnectionState.CONNECTED

assert board.is_connected is True

reader.set_exception(OSError(6, "Device not configured"))

assert board.is_connected is False
with pytest.raises(RuntimeError, match="not connected"):
await board.write_digital(13, True)


@pytest.mark.asyncio
async def test_disconnect_clears_pin_state_caches():
board = TelemetrixBoard(port="COM3")
Expand Down
Loading