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
14 changes: 14 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# History

## 0.15.0 (2026-10-02)

* Keep replies in step with their commands when the reading thread is held up,
for example by another thread holding the GIL. pyserial's ``read_until``
checks its deadline after every byte, so a late reader used to take one byte
of an on-time reply and leave the rest for the next command, after which
every command read the reply meant for another one.
- Replies are now read with a timeout that restarts with every byte, so a
late reader still gets the rest of a reply that has already arrived.
- A missing, incomplete or unparseable reply now flushes the input buffer
and checks that a ``version`` round trip succeeds before raising
``TeensyToAnyReplyError``, a subclass of ``RuntimeError``. A reply carrying
an error code from the device is raised as before, without flushing.

## 0.14.0 (2025-09-05)

* Provide support for teensytoany firmware 0.18.0 for ``spi_transfer16`` command.
Expand Down
3 changes: 2 additions & 1 deletion teensytoany/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
__email__ = 'info@ramonaoptics.com'
from ._version import __version__ # noqa
from .teensypower import TeensyPower
from .teensytoany import TeensyToAny
from .teensytoany import TeensyToAny, TeensyToAnyReplyError

__all__ = [
'TeensyToAny',
'TeensyToAnyReplyError',
'TeensyPower',
]
69 changes: 62 additions & 7 deletions teensytoany/teensytoany.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,32 @@
from serial import LF, Serial
from serial.tools.list_ports import comports

__all__ = ['TeensyToAny']
__all__ = ['TeensyToAny', 'TeensyToAnyReplyError']


class TeensyToAnyReplyError(RuntimeError):
"""A reply was missing or could not be parsed.

Before this is raised, the input buffer is flushed and a ``version`` round
trip checks that the next command will get its own reply.
"""


def read_until(serial, expected, size):
"""Read from ``serial`` until ``expected`` arrives or ``size`` bytes are read.

Unlike pyserial's ``read_until``, which gives the whole line one deadline,
the timeout restarts with every byte: a reader held up past the deadline
still gets the rest of a reply that has already arrived. Stops early if no
byte arrives within the port's timeout.
"""
data = bytearray()
while len(data) < size:
byte = serial.read(1)
data += byte
if not byte or byte == expected:
break
return bytes(data)


class TeensyToAny:
Expand Down Expand Up @@ -471,7 +496,7 @@ def _read(self, *, size=1024, decode=True) -> str:
if self._serial is None:
raise RuntimeError("Device must be opened first")

data = self._serial.read_until(LF, size=size)
data = read_until(self._serial, LF, size=size)

if decode:
data = data.decode()
Expand All @@ -481,13 +506,26 @@ def _read(self, *, size=1024, decode=True) -> str:
def _ask(self, data, *, size=1024, decode=True) -> str:
self._write(data)
returned = self._read(size=size, decode=decode)
if len(returned) == 0:
raise RuntimeError(f"Failed to read a response for command: {data}")

returned_list = returned.split(' ', 1)
error = returned_list[0]
message = None if len(returned_list) == 1 else returned_list[1]
error = int(error)
error = None
if returned.endswith('\n'):
try:
error = int(returned_list[0])
except ValueError as e:
warn(
f"Received erroneous response code({e}), "
"ignoring the error and will resynchronize the communication.",
stacklevel=2
)
if error is None:
Comment on lines +512 to +521

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if returned.endswith('\n'):
try:
error = int(returned_list[0])
except ValueError:
pass
if error is None:
if returned.endswith('\n'):
error = int(returned_list[0])
else:

I'm just not convinced any of these "else" paths are actually triggered.

@matthewc0llins matthewc0llins Oct 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It never happened when testing real teensys, but it can be recreated artificially. It would be rare, but it could happen where the teensy could get held up by an I2C or SPI transaction that times out (200ms). This only gives 5ms for the teensy to respond to the serial command. The branch where the resync fails would be even rarer, requiring the teensy to still be connected but stop responding.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a hung bus on an i2c transaction is a real concern thx for flagging that.

Can we be defensive about this in firmware? As in, add a i2c timeout in firmware of like 5-20 ms?

I would rather "fix" this concern in the firmware itself (which we control) rather than hack around this at this level.

# Nothing arrived in time, or not a whole reply: anything still on its way is
# dropped before the next command.
recovery = ("" if self._resynchronize() else
"; replies are out of step, power cycle the device")
raise TeensyToAnyReplyError(
f"Failed to read a response for command: {data} "
f"(got {returned!r}){recovery}")
if error != 0:
if message is None:
message = os.strerror(error)
Expand All @@ -497,6 +535,23 @@ def _ask(self, data, *, size=1024, decode=True) -> str:
message = message.strip()
return message

def _resynchronize(self, *, attempts=5):
# Commands the device has queued keep replying after a flush, so wait
# for the port to go quiet before checking a version round trip.
for _ in range(attempts):
self._serial.reset_input_buffer()
sleep(max(0.5, 2 * self._timeout))
if self._serial.in_waiting:
continue
# The reply to version is known, so getting it back proves replies are in step.
self._write("version")
reply = self._read()
# While the device is being opened, its version isn't known yet.
if (reply.startswith('0 ') and reply.endswith('\n')
and self._version in (None, reply[2:].strip())):
return True
return False

def i2c_init(self, baud_rate: int=100_100, timeout=200_000, register_space=1):
cmd = f"i2c_init {baud_rate:d} {timeout:d} {register_space:d}"
self._ask(cmd)
Expand Down
Loading