Skip to content

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

Open
wants to merge 55 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
5900056
Add central typings to ease project navigation
sveinse Oct 11, 2021
b48c472
First working concept
sveinse Oct 12, 2021
1be9102
Migrate to vars access methods and deprecate attr
sveinse Oct 25, 2021
5631a03
Adding support for reading PDO map from OD
sveinse Oct 25, 2021
6c6367c
Updated README.rst
sveinse Nov 14, 2021
c9a69e9
Fix bugs
sveinse Nov 14, 2021
09fe0a3
Merge branch 'christiansandberg:master' into feature-asyncio
sveinse Nov 14, 2021
6aac78c
Added loop to connect()
sveinse Nov 14, 2021
dbaeb87
Refactor on async callbacks
sveinse Nov 29, 2021
215d585
Merge branch 'christiansandberg:master' into feature-asyncio
sveinse Nov 29, 2021
3b5f869
Merge pull request #1 from christiansandberg/master
sveinse Feb 5, 2022
afd9f5c
Added more support for async
sveinse Sep 12, 2022
0a0157d
Merge pull request #2 from christiansandberg/master
sveinse Sep 12, 2022
db01e4c
Implement async guarding to prevent accidental blocking IO
sveinse Nov 26, 2022
6715f33
Merge upstream 'master' into feature-asyncio
sveinse Nov 26, 2022
ea7dbe5
Minor formatting updates
sveinse Nov 26, 2022
95daae2
Merge branch 'christiansandberg:master' into feature-asyncio
sveinse Jan 10, 2023
2616f12
fix typo
Mar 8, 2023
4061f71
Handle timeout in aread_response
Mar 13, 2023
e6ce8f6
Merge pull request #3 from mrk-its/fix-typo
sveinse Mar 25, 2023
e664747
Merge pull request #4 from mrk-its/aread_response_timeout
sveinse Mar 25, 2023
8c74fdc
Merge pull request #5 from christiansandberg/master
sveinse Mar 25, 2023
56ed224
Annotation and fixes
sveinse Mar 27, 2023
30d695d
Merge 'master' into feature-asyncio
sveinse Apr 26, 2024
abbc2dc
Updated after merging in master
sveinse Apr 26, 2024
41e028d
Minor improvements
sveinse May 12, 2024
67420a1
Improvements
sveinse May 14, 2024
1f2a3f4
Minor housekeeping updates
sveinse May 17, 2024
9dd782e
Merge branch 'master' into feature-asyncio
sveinse May 17, 2024
d0160a5
Merge branch 'master' into feature-asyncio
sveinse May 18, 2024
fe08d89
Merge branch 'master' into feature-asyncio
sveinse May 18, 2024
aa292a6
Migrate SDO client to another thread which allow reuse of existing co…
sveinse May 18, 2024
fd3be01
Merge branch 'master' into feature-asyncio
sveinse Jun 14, 2024
6dca2e1
Merge master into feature-asyncio
sveinse Aug 31, 2024
59a7643
Minor fixes
sveinse Aug 31, 2024
bd749cd
Fixup the canopen connect mechanism for async
sveinse Feb 2, 2025
dba463a
Merge master into feature-asyncio
sveinse Feb 2, 2025
46f9b4a
Workaround for NMT Slave to avoid blocking IO
sveinse Feb 2, 2025
8260d7b
Merge branch 'canopen-python:master' into feature-asyncio
sveinse Apr 12, 2025
a5de223
Comments and notes update
sveinse May 2, 2025
fdb6414
Unittests for running async and non-async
sveinse May 2, 2025
edc0444
Refurbish async guard system
sveinse May 2, 2025
2204ef3
Merge branch 'master' into feature-asyncio
sveinse May 2, 2025
535f975
Update tests for async
sveinse May 2, 2025
c1e3659
Minor comment and typing updates
sveinse May 4, 2025
e3c84eb
Implement the framwork for sync back-end
sveinse May 4, 2025
3138176
Minor improvements
sveinse May 8, 2025
751f854
Added callback dispatcher
sveinse May 15, 2025
e9ef593
Merge 'master' into feature-asyncio
sveinse Jun 11, 2025
34d110b
Cleanup of the async code
sveinse Jun 14, 2025
6c2e0b1
Fix test cases use of add_node into aadd_node
sveinse Jun 14, 2025
b483268
Various fixes for issues
sveinse Jun 15, 2025
8b7465f
Append name to maintainers and copyright
sveinse Jun 15, 2025
b420035
Fixes after type checking
sveinse Jun 15, 2025
ee16bd4
Merge 'master' into feature-asyncio
sveinse Jun 20, 2025
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
1 change: 1 addition & 0 deletions LICENSE.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
MIT License

Copyright (c) 2016 Christian Sandberg
Copyright (c) 2025 Svein Seldal

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
146 changes: 144 additions & 2 deletions README.rst
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
Expand All @@ -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.

* 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
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.

* Most async functions follow an "a" prefix naming scheme.
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
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.

* 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.

* :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

* :code:`LssMaster` does not work with async, except :code:`LssMaster.fast_scan()`

* :code:`Bits` is not working in async


Features
--------
Expand Down Expand Up @@ -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
---------

Expand Down
43 changes: 43 additions & 0 deletions canopen/async_guard.py
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)
26 changes: 24 additions & 2 deletions canopen/emcy.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
from __future__ import annotations
import asyncio
import logging
import struct
import threading
import time
from typing import Callable, List, Optional

from canopen.async_guard import ensure_not_async
import canopen.network


Expand All @@ -22,11 +25,15 @@
self.active: List["EmcyError"] = []
self.callbacks = []
self.emcy_received = threading.Condition()
self.network: canopen.network.Network = canopen.network._UNINITIALIZED_NETWORK

# @callback # NOTE: called from another thread
@ensure_not_async # NOTE: Safeguard for accidental async use
def on_emcy(self, can_id, data, timestamp):
code, register, data = EMCY_STRUCT.unpack(data)
entry = EmcyError(code, register, data, timestamp)

# NOTE: Blocking lock
with self.emcy_received:
if code & 0xFF00 == 0:
# Error reset
Expand All @@ -36,8 +43,8 @@
self.log.append(entry)
self.emcy_received.notify_all()

for callback in self.callbacks:
callback(entry)
# Call all registered callbacks
self.network.dispatch_callbacks(self.callbacks, entry)

def add_callback(self, callback: Callable[["EmcyError"], None]):
"""Get notified on EMCY messages from this node.
Expand All @@ -53,6 +60,7 @@
self.log = []
self.active = []

@ensure_not_async # NOTE: Safeguard for accidental async use
def wait(
self, emcy_code: Optional[int] = None, timeout: float = 10
) -> "EmcyError":
Expand All @@ -65,8 +73,10 @@
"""
end_time = time.time() + timeout
while True:
# NOTE: Blocking lock
with self.emcy_received:
prev_log_size = len(self.log)
# NOTE: Blocking call
self.emcy_received.wait(timeout)
if len(self.log) == prev_log_size:
# Resumed due to timeout
Expand All @@ -81,6 +91,18 @@
# This is the one we're interested in
return emcy

async def async_wait(
self, emcy_code: Optional[int] = None, timeout: float = 10
) -> EmcyError:
"""Wait for a new EMCY to arrive.

:param emcy_code: EMCY code to wait for
:param timeout: Max time in seconds to wait

:return: The EMCY exception object or None if timeout
"""
return await asyncio.to_thread(self.wait, emcy_code, timeout)

Check warning on line 104 in canopen/emcy.py

View check run for this annotation

Codecov / codecov/patch

canopen/emcy.py#L104

Added line #L104 was not covered by tests


class EmcyProducer:

Expand Down
Loading
Loading