-
Notifications
You must be signed in to change notification settings - Fork 357
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
56e21a8
9e7d0b1
3c8da6c
5d2eb78
f71de73
eb6ccbf
c73227b
29ef4bc
a60b75c
3b4a6aa
57c0605
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 |
|---|---|---|
|
|
@@ -8,6 +8,80 @@ automation tasks rather than a standard compliant master implementation. | |
|
|
||
| The library supports Python 3.9 or newer. | ||
|
|
||
| The library can be run in two modes: regular mode or in async mode. In regular | ||
| mode, calls to the library may block until a response is ready. In async mode | ||
| it is possible to read and write to more than one node at the same time | ||
| without the need of multiple threads. | ||
|
|
||
|
|
||
| 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. | ||
|
|
||
| * The async use of :code:`Network` must be used in an async context. This is | ||
| required to setup the async tasks and handles proper cleanup and exception | ||
| handling. | ||
|
|
||
| async with canopen.Network().connect() as network: | ||
| # do async stuff with network | ||
|
|
||
| * 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.
Collaborator
Author
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. Yes, let's find good names. |
||
| 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'] # async | ||
| var = await sdo['Variable'].aread() # async (equivalent) | ||
| await sdo['Variable'].awrite(12) # async | ||
|
|
||
| * Opt-in :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, the code is not using the async variants of the library when | ||
| it shouldn't. | ||
|
|
||
| To enable it call :code:`canopen.async_guard.enable_async_guard(True)` in | ||
| your main thread/main loop. | ||
|
|
||
| * 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. | ||
|
sveinse marked this conversation as resolved.
|
||
|
|
||
| * :code:`BaseNode402` does not work with async | ||
|
sveinse marked this conversation as resolved.
|
||
|
|
||
| * :code:`Bits` is not working differently in async mode. In non-async mode, | ||
| the raw value is read from the node when the :code:`Bits` object is created. | ||
| In async mode, the raw value must be manually read by calling | ||
| :code:`await bits.aread()` before accessing the bits. | ||
|
|
||
|
|
||
| Features | ||
| -------- | ||
|
|
@@ -156,6 +230,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'].awrite(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'].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'].awrite(42, "phys") | ||
| 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,68 @@ | ||
| """ Utils for async """ | ||
| import asyncio | ||
| import functools | ||
| import logging | ||
| import threading | ||
| import traceback | ||
|
|
||
|
|
||
| _ASYNC_GUARDS: dict[int, bool] = {} | ||
| """Per-thread boolean indicating allowance of running blocking functions. | ||
|
|
||
| :code:`True` indicates that blocking functions are not allowed to be called | ||
| from the current thread. | ||
| """ | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def enable_async_guard(enable: bool): | ||
| """Enable or disable the async guard for the current thread. | ||
|
|
||
| :param enable: True to enable the async guard, False to disable it. | ||
| """ | ||
| _ASYNC_GUARDS[threading.get_ident()] = enable | ||
|
|
||
|
|
||
| def is_async_guarded() -> bool: | ||
| """Check if async guard is enabled for this thread. | ||
|
|
||
| :return: True if async guard is enabled, False otherwise. | ||
| """ | ||
| return _ASYNC_GUARDS.get(threading.get_ident(), False) | ||
|
|
||
|
|
||
| def ensure_not_async(fn=None, error_message=None): | ||
| """Guard a function from being called from the async main thread. | ||
|
|
||
| This function is used to guard functions that are blocking and should not | ||
| be called from async code. If the function is called while async is | ||
| running, a RuntimeError will be raised. | ||
|
|
||
| Can be used either as a plain decorator, :code:`@ensure_not_async`, or | ||
| called with an extra error message, :code:`@ensure_not_async("message")`. | ||
|
|
||
| :param error_message: Optional message appended to the RuntimeError raised | ||
| when the guard trips. | ||
| """ | ||
| if isinstance(fn, str): | ||
| fn, error_message = None, fn | ||
|
|
||
| def decorator(fn): | ||
| @functools.wraps(fn) | ||
| def async_guard_wrap(*args, **kwargs): | ||
| if is_async_guarded(): | ||
| st = "".join(traceback.format_stack()) | ||
| logger.debug("Traceback:\n%s", st.rstrip()) | ||
| msg = ("Calling a blocking function while running async. " | ||
| f"Function {fn.__qualname__}() " | ||
| f"in {fn.__code__.co_filename}:{fn.__code__.co_firstlineno}") | ||
| if error_message: | ||
| msg += f". {error_message}" | ||
| raise RuntimeError(msg) | ||
| return fn(*args, **kwargs) | ||
| return async_guard_wrap | ||
|
|
||
| if fn is not None: | ||
| return decorator(fn) | ||
| return decorator |
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.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.
We are not passing the loop parameter to the
can.Notifier. The reason is that we want to keep the current notifier / listener back-end callback system. We could use async callbacks if we wanted to. but that would require making a second parallel async variant of the back-end callback system, which is what I've avoided.The
loopparameter serves two purposes inNetwork: To set a boolean-like flag if the network is running in async mode vs. non-async mode via theis_asyncand theNetwork.dispatch_callbacks().