-
Notifications
You must be signed in to change notification settings - Fork 358
Add asyncio support #359
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Add asyncio support #359
Changes from all commits
5900056
b48c472
1be9102
5631a03
6c6367c
c9a69e9
09fe0a3
6aac78c
dbaeb87
215d585
3b5f869
afd9f5c
0a0157d
db01e4c
6715f33
ea7dbe5
95daae2
2616f12
4061f71
e6ce8f6
e664747
8c74fdc
56ed224
30d695d
abbc2dc
41e028d
67420a1
1f2a3f4
9dd782e
d0160a5
fe08d89
aa292a6
fd3be01
6dca2e1
59a7643
bd749cd
dba463a
46f9b4a
8260d7b
a5de223
fdb6414
edc0444
2204ef3
535f975
c1e3659
e3c84eb
3138176
751f854
e9ef593
34d110b
6c2e0b1
b483268
8b7465f
b420035
ee16bd4
caf6db5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| CANopen for Python | ||
| ================== | ||
| CANopen for Python, asyncio port | ||
| ================================ | ||
|
|
||
| A Python implementation of the CANopen_ standard. | ||
| The aim of the project is to support the most common parts of the CiA 301 | ||
|
|
@@ -8,6 +8,84 @@ automation tasks rather than a standard compliant master implementation. | |
|
|
||
| The library supports Python 3.8 or newer. | ||
|
|
||
| This library is the asyncio port of CANopen. See below for code example. | ||
|
|
||
|
|
||
| Asyncio port | ||
| ------------ | ||
|
|
||
| The objective of the library is to provide a canopen implementation in | ||
| either async or non-async environment, with suitable API for both. | ||
|
|
||
| To minimize the impact of the async changes, this port is designed to use the | ||
| existing synchronous backend of the library. This means that the library | ||
| uses :code:`asyncio.to_thread()` for many asynchronous operations. | ||
|
|
||
| This port remains compatible with using it in a regular non-asyncio | ||
| environment. This is selected with the `loop` parameter in the | ||
| :code:`Network` constructor. If you pass a valid asyncio event loop, the | ||
| library will run in async mode. If you pass `loop=None`, it will run in | ||
| regular blocking mode. It cannot be used in both modes at the same time. | ||
|
|
||
|
|
||
| Difference between async and non-async version | ||
| ---------------------------------------------- | ||
|
|
||
| This port have some differences with the upstream non-async version of canopen. | ||
|
|
||
| * Minimum python version is 3.9, while the upstream version supports 3.8. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No longer - we will even bump to 3.10 soonish. Which is good, for less difference between fork and upstream. |
||
|
|
||
| * The :code:`Network` accepts additional parameters than upstream. It accepts | ||
| :code:`loop` which selects the mode of operation. If :code:`None` it will | ||
| run in blocking mode, otherwise it will run in async mode. It supports | ||
| providing a custom CAN :code:`notifier` if the CAN bus will be shared by | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also available in upstream by now, see #621. |
||
| multiple protocols. | ||
|
|
||
| * The :code:`Network` class can be (and should be) used in an async context | ||
| manager. This will ensure the network will be automatically disconnected when | ||
| exiting the context. See the example below. | ||
|
Comment on lines
+44
to
+46
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wouldn't this be a good chance to distinguish whether we are supposed to be running in an asyncio based execution model? I'd like that a lot more than passing an explicit loop parameter. Basic idea: Get a reference to the current asyncio loop in |
||
|
|
||
| * Most async functions follow an "a" prefix naming scheme. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I find that difficult in some places. |
||
| E.g. the async variant for :code:`SdoClient.download()` is available | ||
| as :code:`SdoClient.adownload()`. | ||
|
|
||
| * Variables in the regular canopen library uses properties for getting and | ||
| setting. This is replaced with awaitable methods in the async version. | ||
|
|
||
| var = sdo['Variable'].raw # synchronous | ||
| sdo['Variable'].raw = 12 # synchronous | ||
|
|
||
| var = await sdo['Variable'].get_raw() # async | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To minimize API duplication, this could be as simple as: |
||
| await sdo['Variable'].set_raw(12) # async | ||
|
|
||
| * Installed :code:`ensure_not_async()` sentinel guard in functions which | ||
| prevents calling blocking functions in async context. It will raise the | ||
| exception :code:`RuntimeError` "Calling a blocking function" when this | ||
| happen. If this is encountered, it is likely that the code is not using the | ||
| async variants of the library. | ||
|
|
||
| * The mechanism for CAN bus callbacks have been changed. Callbacks might be | ||
| async, which means they cannot be called immediately. This affects how | ||
| error handling is done in the library. | ||
|
Comment on lines
+67
to
+69
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This sounds a lot like duplicating what python-can already provides. Can we embrace that lower-level concept more instead of building our own dispatcher? |
||
|
|
||
| * The callbacks to the message handlers have been changed to be handled by | ||
| :code:`Network.dispatch_callbacks()`. They are no longer called with any | ||
| locks held, as this would not work with async. This affects: | ||
| * :code:`PdoMaps.on_message` | ||
| * :code:`EmcyConsumer.on_emcy` | ||
| * :code:`NtmMaster.on_heartbaet` | ||
|
|
||
| * SDO block upload and download is not yet supported in async mode. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Which would be a really good fit for a coroutine I suppose, if done right :-) |
||
|
|
||
| * :code:`ODVariable.__len__()` returns 64 bits instead of 8 bits to support | ||
| truncated 24-bits integers, see #436 | ||
|
|
||
| * :code:`BaseNode402` does not work with async | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can imagine how a homing procedure for example would benefit from being called with |
||
|
|
||
| * :code:`LssMaster` does not work with async, except :code:`LssMaster.fast_scan()` | ||
|
|
||
| * :code:`Bits` is not working in async | ||
|
|
||
|
|
||
| Features | ||
| -------- | ||
|
|
@@ -156,6 +234,70 @@ The :code:`n` is the PDO index (normally 1 to 4). The second form of access is f | |
| network.disconnect() | ||
|
|
||
|
|
||
| Asyncio | ||
| ------- | ||
|
|
||
| This is the same example as above, but using asyncio | ||
|
|
||
| .. code-block:: python | ||
|
|
||
| import asyncio | ||
| import canopen | ||
| import can | ||
|
|
||
| async def my_node(network, nodeid, od): | ||
|
|
||
| # Create the node object and load the OD | ||
| node = network.add_node(nodeid, od) | ||
|
|
||
| # Read the PDOs from the remote | ||
| await node.tpdo.aread() | ||
| await node.rpdo.aread() | ||
|
|
||
| # Set the module state | ||
| node.nmt.set_state('OPERATIONAL') | ||
|
|
||
| # Set motor speed via SDO | ||
| await node.sdo['MotorSpeed'].aset_raw(2) | ||
|
|
||
| while True: | ||
|
|
||
| # Wait for TPDO 1 | ||
| t = await node.tpdo[1].await_for_reception(1) | ||
| if not t: | ||
| continue | ||
|
|
||
| # Get the TPDO 1 value | ||
| rpm = node.tpdo[1]['MotorSpeed Actual'].get_raw() | ||
| print(f'SPEED on motor {nodeid}:', rpm) | ||
|
|
||
| # Sleep a little | ||
| await asyncio.sleep(0.2) | ||
|
|
||
| # Send RPDO 1 with some data | ||
| node.rpdo[1]['Some variable'].set_phys(42) | ||
| node.rpdo[1].transmit() | ||
|
|
||
| async def main(): | ||
|
|
||
| # Connect to the CAN bus | ||
| # Arguments are passed to python-can's can.Bus() constructor | ||
| # (see https://python-can.readthedocs.io/en/latest/bus.html). | ||
| # Note the loop parameter to enable asyncio operation | ||
| loop = asyncio.get_running_loop() | ||
| async with canopen.Network(loop=loop).connect( | ||
| interface='pcan', bitrate=1000000) as network: | ||
|
|
||
| # Create two independent tasks for two nodes 51 and 52 which will run concurrently | ||
| task1 = asyncio.create_task(my_node(network, 51, '/path/to/object_dictionary.eds')) | ||
| task2 = asyncio.create_task(my_node(network, 52, '/path/to/object_dictionary.eds')) | ||
|
|
||
| # Wait for both to complete (which will never happen) | ||
| await asyncio.gather((task1, task2)) | ||
|
|
||
| asyncio.run(main()) | ||
|
|
||
|
|
||
| Debugging | ||
| --------- | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| """ Utils for async """ | ||
| import functools | ||
| import logging | ||
| import threading | ||
| import traceback | ||
|
|
||
| # NOTE: Global, but needed to be able to use ensure_not_async() in | ||
| # decorator context. | ||
| _ASYNC_SENTINELS: dict[int, bool] = {} | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def set_async_sentinel(enable: bool): | ||
| """ Register a function to validate if async is running """ | ||
| _ASYNC_SENTINELS[threading.get_ident()] = enable | ||
|
|
||
|
|
||
| def ensure_not_async(fn): | ||
| """ Decorator that will ensure that the function is not called if async | ||
| is running. | ||
| """ | ||
| @functools.wraps(fn) | ||
| def async_guard_wrap(*args, **kwargs): | ||
| if _ASYNC_SENTINELS.get(threading.get_ident(), False): | ||
| st = "".join(traceback.format_stack()) | ||
| logger.debug("Traceback:\n%s", st.rstrip()) | ||
| raise RuntimeError(f"Calling a blocking function, {fn.__qualname__}() in {fn.__code__.co_filename}:{fn.__code__.co_firstlineno}, while running async") | ||
| return fn(*args, **kwargs) | ||
| return async_guard_wrap | ||
|
|
||
|
|
||
| class AllowBlocking: | ||
| """ Context manager to pause async guard """ | ||
| def __init__(self): | ||
| self._enabled = _ASYNC_SENTINELS.get(threading.get_ident(), False) | ||
|
|
||
| def __enter__(self): | ||
| set_async_sentinel(False) | ||
| return self | ||
|
|
||
| def __exit__(self, exc_type, exc_value, traceback): | ||
| set_async_sentinel(self._enabled) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking at the python-can code, passing a loop is mainly interesting for the
Notifierobject. Which seems to be an elegant way of making sure callbacks triggered by message reception are run as tasks, instead of rolling our own dispatcher solution. Especially since an arbitrary notifier can be passed in now in latest upstream. On theListenerside, theAsyncBufferedReaderimplementation actually warns about a loop parameter being passed. This just makes me wonder whether the API of passing a loop to theNetworkconstructor is going in the right direction.