Skip to content

Commit c73227b

Browse files
committed
Fixes from testing
* Use Network.taskgroup to administer asyncio tasks * Network requires running in an async context to work * Fixup error messages * Fix disrepencies between sync and async versions * Documentation update
1 parent eb6ccbf commit c73227b

11 files changed

Lines changed: 173 additions & 84 deletions

File tree

‎canopen/emcy.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def on_emcy(self, can_id, data, timestamp):
4242
self.emcy_received.notify_all()
4343

4444
# Call all registered callbacks
45-
self.network.dispatch_callbacks(self.callbacks, entry)
45+
self.network.dispatch_callbacks(self.callbacks, entry, ignore_errors=True)
4646

4747
def add_callback(self, callback: Callable[[EmcyError], None]):
4848
"""Get notified on EMCY messages from this node.
@@ -89,7 +89,7 @@ def wait(
8989

9090
async def async_wait(
9191
self, emcy_code: Optional[int] = None, timeout: float = 10
92-
) -> EmcyError:
92+
) -> Optional[EmcyError]:
9393
"""Wait for a new EMCY to arrive.
9494
9595
:param emcy_code: EMCY code to wait for

‎canopen/network.py‎

Lines changed: 108 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
import asyncio
44
import logging
55
import threading
6-
from collections.abc import Iterator, MutableMapping
6+
from collections.abc import Coroutine, Iterator, MutableMapping
77
from typing import Callable, Final, Optional, Union
8+
import sys
89

910
import can
1011

@@ -17,6 +18,12 @@
1718
from canopen.sync import SyncProducer
1819
from canopen.timestamp import TimeProducer
1920

21+
# Use backported TaskGroup for Python < 3.13, as asyncio.TaskGroup was added in 3.11
22+
if sys.version_info < (3, 11):
23+
from taskgroup import TaskGroup
24+
else:
25+
from asyncio import TaskGroup
26+
2027

2128
logger = logging.getLogger(__name__)
2229

@@ -28,6 +35,7 @@ class Network(MutableMapping):
2835

2936
NOTIFIER_CYCLE: float = 1.0 #: Maximum waiting time for one notifier iteration.
3037
NOTIFIER_SHUTDOWN_TIMEOUT: float = 5.0 #: Maximum waiting time to stop notifiers.
38+
FILTER_ERRORS: bool = True #: If True, exceptions in callbacks will be logged only
3139

3240
def __init__(self, bus: Optional[can.BusABC] = None,
3341
notifier: Optional[can.Notifier] = None,
@@ -40,7 +48,6 @@ def __init__(self, bus: Optional[can.BusABC] = None,
4048
#: :meth:`canopen.Network.connect` is called
4149
self.bus = bus
4250
self.loop = loop
43-
self._futures: set[asyncio.Future] = set()
4451
#: A :class:`~canopen.network.NodeScanner` for detecting nodes
4552
self.scanner = NodeScanner(self)
4653
#: List of :class:`can.Listener` objects.
@@ -50,6 +57,8 @@ def __init__(self, bus: Optional[can.BusABC] = None,
5057
self.nodes: dict[int, Union[RemoteNode, LocalNode]] = {}
5158
self.subscribers: dict[int, list[Callback]] = {}
5259
self.send_lock = threading.Lock()
60+
self.taskgroup: TaskGroup = TaskGroup()
61+
self.thread_id: int = threading.get_ident()
5362
self.sync = SyncProducer(self)
5463
self.time = TimeProducer(self)
5564
self.nmt = NmtMaster(0)
@@ -114,9 +123,14 @@ def connect(self, *args, **kwargs) -> Network:
114123
self.bus = can.Bus(*args, **kwargs)
115124
logger.info("Connected to '%s'", self.bus.channel_info)
116125
if self.notifier is None:
117-
self.notifier = can.Notifier(self.bus, [], self.NOTIFIER_CYCLE)
118-
for listener in self.listeners:
119-
self.notifier.add_listener(listener)
126+
# The notifier is started without setting the loop paramter, even
127+
# when running in async mode. The notifier changes in sublte ways
128+
# when the loop parameter is set. All callbacks via the Listener
129+
# interface will be called from the separate rx thread, which is
130+
# what canopen is designed for. The async mode of the notifier will
131+
# send all callbacks to the event loop thread, which is not
132+
# compatible with the blocking locks and queues used in canopen.
133+
self.notifier = can.Notifier(self.bus, self.listeners, self.NOTIFIER_CYCLE)
120134
return self
121135

122136
def disconnect(self) -> None:
@@ -145,15 +159,49 @@ def __exit__(self, type, value, traceback):
145159
self.disconnect()
146160

147161
async def __aenter__(self):
148-
# FIXME: When TaskGroup are available, we should use them to manage the
149-
# tasks. The user must use the `async with` statement with the Network
150-
# to ensure its created.
162+
if self.loop is None:
163+
self.loop = asyncio.get_running_loop()
164+
self.thread_id = threading.get_ident()
165+
await self.taskgroup.__aenter__()
151166
return self
152167

153168
async def __aexit__(self, type, value, traceback):
169+
await self.taskgroup.__aexit__(None, None, None)
154170
self.disconnect()
155171

156-
async def enable_async_guard(self, enable: bool = True) -> None:
172+
@property
173+
def is_async(self) -> bool:
174+
"""Check if canopen has been connected with async"""
175+
return self.loop is not None
176+
177+
def create_task(self, coro: Coroutine, *args, **kwarge) -> asyncio.Task:
178+
"""Create an async task.
179+
180+
This function is thread-safe and can be called from any thread. If
181+
called from the same thread as the event loop, it will use
182+
:code:`asyncio.create_task()` directly. If called from a different
183+
thread, it will use :code:`asyncio.run_coroutine_threadsafe()` to
184+
schedule the task in the event loop.
185+
186+
:param coro:
187+
The coroutine to run in the event loop.
188+
"""
189+
190+
if threading.get_ident() == self.thread_id:
191+
# If we are running in the same thread as the event loop
192+
# asyncio.create_task() can be used directly.
193+
return self.taskgroup.create_task(coro, *args, **kwarge)
194+
195+
else:
196+
async def _create_task():
197+
return self.taskgroup.create_task(coro, *args, **kwarge)
198+
# The .result() will block until the task is created in
199+
# the event loop and return the task object.
200+
future = asyncio.run_coroutine_threadsafe(_create_task(), self.loop)
201+
return future.result()
202+
203+
@staticmethod
204+
async def enable_async_guard(enable: bool = True) -> None:
157205
"""Enable or disable the async guard for this network.
158206
159207
This makes sure that all functions that are decorated with
@@ -186,6 +234,19 @@ def add_node(
186234
:param upload_eds:
187235
Set ``True`` if EDS file should be uploaded from 0x1021.
188236
237+
.. note::
238+
Using this option will fail in async mode, since uploading the
239+
EDS requires blocking SDO transfers during node setup. Use a
240+
pre-fetched ``object_dictionary`` instead when running under
241+
asyncio.
242+
243+
Example of pre-fetching the object dictionary with async:
244+
245+
.. code-block:: python
246+
247+
od = await aimport_from_node(node_id, network)
248+
node = network.add_node(node_id, od)
249+
189250
:return:
190251
The Node object that was added.
191252
"""
@@ -197,20 +258,6 @@ def add_node(
197258
self[node.id] = node
198259
return node
199260

200-
async def aadd_node(
201-
self,
202-
node: Union[int, RemoteNode, LocalNode],
203-
object_dictionary: Union[str, ObjectDictionary, None] = None,
204-
upload_eds: bool = False,
205-
) -> RemoteNode:
206-
"""Add a remote node to the network, async variant.
207-
208-
See add_node() for description
209-
"""
210-
# The async variant exists because import_from_node might block
211-
return await asyncio.to_thread(self.add_node, node,
212-
object_dictionary, upload_eds)
213-
214261
def create_node(
215262
self,
216263
node: Union[int, LocalNode],
@@ -296,40 +343,54 @@ def notify(self, can_id: int, data: bytearray, timestamp: float) -> None:
296343
self.dispatch_callbacks(self.subscribers[can_id], can_id, data, timestamp)
297344
self.scanner.on_message_received(can_id)
298345

299-
def on_error(self, exc: BaseException) -> None:
300-
"""This method is called to handle any exception in the callbacks."""
301-
302-
# Exceptions in any callbaks should not affect CAN processing
346+
def on_error(self, exc: BaseException, ignore_errors: Optional[bool] = None) -> None:
347+
"""Handle any exception in the callbacks.
348+
349+
With self.FILTER_ERRORS set to True, exceptions in callbacks will be logged
350+
only, and the program will continue running. This is useful for
351+
production systems where you want to log errors but not crash the
352+
entire application due to a single callback failure.
353+
354+
With self.FILTER_ERRORS set to False, exceptions in callbacks will be raised,
355+
which will stop the program. This is useful for development and debugging,
356+
where you want to catch errors early and fix them. This is also
357+
important for unit tests, as only logging errors may hide problems.
358+
359+
:param exc:
360+
The exception that was raised.
361+
:param ignore_errors:
362+
If True, exceptions in callbacks will be logged only, and the program
363+
will continue running. If False, exceptions in callbacks will be raised,
364+
which will stop the program. If None, the value of self.FILTER_ERRORS
365+
"""
303366
logger.exception("Exception in callback: %s", exc_info=exc)
304367

305-
def dispatch_callbacks(self, callbacks: list[Callback], *args) -> None:
368+
if ignore_errors is None:
369+
ignore_errors = self.FILTER_ERRORS
370+
if not ignore_errors:
371+
raise exc
372+
373+
def dispatch_callbacks(self, callbacks: list[Callable], *args, **kwargs) -> None:
306374
"""Dispatch a list of callbacks with the given arguments.
307375
308376
:param callbacks:
309377
List of callbacks to call
310378
:param args:
311379
Arguments to pass to the callbacks
380+
:param kwargs:
381+
Keyword arguments to pass to the callbacks. The "ignore_errors"
382+
keyword argument can be used to override the default error handling
383+
behavior for this specific call. See :meth:`canopen.Network.on_error`
384+
for details.
312385
"""
313-
def task_done(future: asyncio.Future) -> None:
314-
"""Callback to be called when a task is done."""
315-
self._futures.discard(future)
316-
try:
317-
if (exc := future.exception()) is not None:
318-
self.on_error(exc)
319-
except (asyncio.CancelledError, asyncio.InvalidStateError) as exc:
320-
# Handle cancelled tasks and unfinished tasks gracefully
321-
self.on_error(exc)
322-
323-
# Run the callbacks
386+
ignore_errors = kwargs.pop("ignore_errors", None)
324387
for callback in callbacks:
325-
result = callback(*args)
326-
if result is not None and self.loop is not None and asyncio.iscoroutine(result):
327-
# This function may be called from the rx thread, so it must
328-
# be thread-safe. We cannot use asyncio.create_task() here, since
329-
# it is not thread-safe.
330-
task = asyncio.run_coroutine_threadsafe(result, self.loop)
331-
self._futures.add(task)
332-
task.add_done_callback(task_done)
388+
try:
389+
result = callback(*args, **kwargs)
390+
if result is not None and asyncio.iscoroutine(result):
391+
self.create_task(result)
392+
except Exception as e:
393+
self.on_error(e, ignore_errors)
333394

334395
def check(self) -> None:
335396
"""Check that no fatal error has occurred in the receiving thread.
@@ -343,11 +404,6 @@ def check(self) -> None:
343404
logger.error("An error has caused receiving of messages to stop")
344405
raise exc
345406

346-
@property
347-
def is_async(self) -> bool:
348-
"""Check if canopen has been connected with async"""
349-
return self.loop is not None
350-
351407
def __getitem__(self, node_id: int) -> Union[RemoteNode, LocalNode]:
352408
return self.nodes[node_id]
353409

‎canopen/nmt.py‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -242,12 +242,23 @@ def send_command(self, code: int) -> None:
242242
# The heartbeat service should start on the transition
243243
# between INITIALIZING and PRE-OPERATIONAL state
244244
if old_state == 0 and self._state == 127:
245-
# FIXME: Document why this was fixed
246-
if self._heartbeat_time_ms == 0:
247-
heartbeat_time_ms = self._local_node.sdo[0x1017].raw
245+
if self.network.is_async:
246+
# In async mode we cannot read the heartbeat directly, so we
247+
# create a task to read it asynchronously and start the heartbeat
248+
# service when the read is complete.
249+
async def start_heartbeat_async():
250+
try:
251+
heartbeat_time_ms = await self._local_node.sdo[0x1017].aread()
252+
self.start_heartbeat(heartbeat_time_ms)
253+
except KeyError:
254+
pass
255+
self.network.create_task(start_heartbeat_async())
248256
else:
249-
heartbeat_time_ms = self._heartbeat_time_ms
250-
self.start_heartbeat(heartbeat_time_ms)
257+
try:
258+
heartbeat_time_ms = self._local_node.sdo[0x1017].raw
259+
self.start_heartbeat(heartbeat_time_ms)
260+
except KeyError:
261+
pass
251262
else:
252263
self.update_heartbeat()
253264

‎canopen/objectdictionary/eds.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ def import_from_node(node_id: int, network: canopen.network.Network):
205205
network.subscribe(0x580 + node_id, sdo_client.on_response)
206206
# Create file like object for Store EDS variable
207207
try:
208+
# Opening an SDO channel with file-like object is not supported in async
208209
with sdo_client.open(0x1021, 0, "rt") as eds_fp:
209210
od = import_eds(eds_fp, node_id)
210211
except Exception as e:

‎canopen/pdo/base.py‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -738,11 +738,6 @@ def get_data(self) -> bytes:
738738

739739
return data
740740

741-
async def aget_data(self) -> bytes:
742-
# Since get_data() is not making any IO, it can be called
743-
# directly with no special async variant
744-
return self.get_data()
745-
746741
def set_data(self, data: bytes):
747742
"""Set for the given variable the PDO data.
748743
@@ -776,10 +771,11 @@ def set_data(self, data: bytes):
776771

777772
self.pdo_parent.update()
778773

774+
async def aget_data(self) -> bytes:
775+
raise RuntimeError("Read of PDO data asynchronously is not supported, use regular access")
776+
779777
async def aset_data(self, data: bytes):
780-
# Since get_data() is not making any IO, it can be called
781-
# directly with no special async variant
782-
return self.set_data(data)
778+
raise RuntimeError("Writing PDO data asynchronously is not supported, use regular access")
783779

784780

785781
# For compatibility

‎canopen/sdo/base.py‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ async def alen(self) -> int:
171171
return await self[0].aread() # type: ignore[return-value]
172172

173173
def __contains__(self, subindex: object) -> bool:
174+
if not isinstance(subindex, int):
175+
return False
174176
return 0 <= subindex <= len(self)
175177

176178

@@ -181,9 +183,8 @@ def __init__(self, sdo_node: SdoBase, od: objectdictionary.ODVariable):
181183
self.sdo_node = sdo_node
182184
variable.Variable.__init__(self, od)
183185

184-
@ensure_not_async("Use aget_data() instead")
185-
def get_data(self) -> bytes:
186-
data = self.sdo_node.upload(self.od.index, self.od.subindex)
186+
def _truncate_data(self, data: bytes) -> bytes:
187+
"""Truncate data to the size specified in the object dictionary."""
187188
response_size = len(data)
188189

189190
# If size is available through variable in OD, then use the smaller of the two sizes.
@@ -196,8 +197,14 @@ def get_data(self) -> bytes:
196197
data = data[:var_size]
197198
return data
198199

200+
@ensure_not_async("Use aget_data() instead")
201+
def get_data(self) -> bytes:
202+
data = self.sdo_node.upload(self.od.index, self.od.subindex)
203+
return self._truncate_data(data)
204+
199205
async def aget_data(self) -> bytes:
200-
return await self.sdo_node.aupload(self.od.index, self.od.subindex)
206+
data = await self.sdo_node.aupload(self.od.index, self.od.subindex)
207+
return self._truncate_data(data)
201208

202209
@ensure_not_async("Use aset_data() instead")
203210
def set_data(self, data: bytes):

‎canopen/sdo/server.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ async def adownload(
250250
data: bytes,
251251
force_segment: bool = False,
252252
):
253-
"""May be called to make a write operation without an Object Dictionary.
253+
"""May be called to make a write operation without an Object Dictionary.
254254
255255
:param index:
256256
Index of object to write.

0 commit comments

Comments
 (0)