Skip to content

Commit e0f8cda

Browse files
committed
python: Update to support UART based operation
- If using a serial port, default to 1Mbps operation, which is what moteus defaults to. - Implement optional checksums for fdcanusb transports. For UART based serial streams, require that checksums be enabled. - Provide for timeouts and retries at the fdcanusb protocol level. This is intended to make the link robust to corrupted characters that either cause a checksum failure, or result in the line terminator not being identified. - Use the new multiplex tunnel frame types to provide reliable diagonstic protocol support in the event of potential lost receive frames. Transmit frames will be retried until the "simulated fdcanusb" reports OK. However, it is possible the receive frames are lost entirely, thus the need for stream packet level acknowledgment and retry. - Prevent flashing over UART based transports, as currently devices do not support this.
1 parent 8e067f5 commit e0f8cda

File tree

14 files changed

+1176
-90
lines changed

14 files changed

+1176
-90
lines changed

lib/python/moteus/BUILD

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ filegroup(
3232
srcs = [
3333
"aioserial.py",
3434
"aiostream.py",
35+
"async_timeout.py",
3536
"calibrate_encoder.py",
3637
"command.py",
3738
"device_info.py",

lib/python/moteus/async_timeout.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Copyright 2025 mjbots Robotic Systems, LLC. info@mjbots.com
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Minimal asyncio.timeout replacement for nested timeout handling.
16+
17+
This module provides a timeout context manager that correctly handles
18+
nested timeouts, unlike asyncio.wait_for which has cancellation
19+
semantics that don't compose well.
20+
21+
The implementation uses Task.cancel() with a unique message to distinguish
22+
timeout cancellation from external cancellation, similar to Python 3.11+'s
23+
asyncio.timeout().
24+
"""
25+
26+
import asyncio
27+
import sys
28+
29+
30+
# Unique sentinel to identify our timeout cancellations
31+
_TIMEOUT_SENTINEL = "_moteus_timeout_expired"
32+
33+
34+
class Timeout:
35+
"""Async context manager for operation timeouts.
36+
37+
Usage:
38+
async with timeout(0.5):
39+
await some_operation()
40+
41+
Raises asyncio.TimeoutError if the deadline is exceeded.
42+
"""
43+
44+
def __init__(self, delay):
45+
"""Create a timeout context.
46+
47+
Args:
48+
delay: Timeout in seconds. None means no timeout.
49+
"""
50+
self._delay = delay
51+
self._task = None
52+
self._timeout_handle = None
53+
self._cancelled_by_timeout = False
54+
55+
async def __aenter__(self):
56+
if self._delay is None:
57+
return self
58+
59+
self._task = asyncio.current_task()
60+
if self._task is None:
61+
raise RuntimeError("timeout() must be used inside a task")
62+
63+
loop = asyncio.get_running_loop()
64+
self._timeout_handle = loop.call_later(
65+
self._delay, self._on_timeout)
66+
67+
return self
68+
69+
async def __aexit__(self, exc_type, exc_val, exc_tb):
70+
# Cancel the timeout callback if still pending
71+
if self._timeout_handle is not None:
72+
self._timeout_handle.cancel()
73+
self._timeout_handle = None
74+
75+
if exc_type is asyncio.CancelledError:
76+
# Check if this cancellation was from our timeout
77+
if sys.version_info >= (3, 9):
78+
# On 3.9+, check the cancel message for our sentinel
79+
# to avoid misattributing an external cancel as a timeout.
80+
if (getattr(exc_val, 'args', ()) and
81+
exc_val.args[0] == _TIMEOUT_SENTINEL):
82+
raise asyncio.TimeoutError() from exc_val
83+
elif self._cancelled_by_timeout:
84+
# Pre-3.9, best effort using the flag
85+
self._cancelled_by_timeout = False
86+
raise asyncio.TimeoutError() from exc_val
87+
88+
return False
89+
90+
def _on_timeout(self):
91+
"""Called when the timeout expires."""
92+
self._cancelled_by_timeout = True
93+
# Use cancel message if available (Python 3.9+)
94+
if sys.version_info >= (3, 9):
95+
self._task.cancel(_TIMEOUT_SENTINEL)
96+
else:
97+
self._task.cancel()
98+
99+
100+
def timeout(delay):
101+
"""Create a timeout context manager.
102+
103+
Args:
104+
delay: Timeout in seconds. None means no timeout.
105+
106+
Returns:
107+
Async context manager that raises asyncio.TimeoutError if
108+
the operation exceeds the timeout.
109+
110+
Example:
111+
async with timeout(1.0):
112+
await slow_operation()
113+
"""
114+
return Timeout(delay)

lib/python/moteus/export.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
'Stream',
2828
'Setpoint',
2929
'move_to',
30+
'timeout',
3031
'TRANSPORT_FACTORIES',
3132
'INT8', 'INT16', 'INT32', 'F32', 'IGNORE',
3233
'reader',
@@ -35,6 +36,7 @@
3536
'Subframe', 'parse_frame',
3637
'ParsedRegisters', 'parse_registers', 'scale_register',
3738
]
39+
from moteus.async_timeout import timeout
3840
from moteus.command import Command
3941
from moteus.device_info import DeviceAddress, DeviceInfo
4042
from moteus.fdcanusb import Fdcanusb

0 commit comments

Comments
 (0)