33import asyncio
44import logging
55import threading
6- from collections .abc import Iterator , MutableMapping
6+ from collections .abc import Coroutine , Iterator , MutableMapping
77from typing import Callable , Final , Optional , Union
8+ import sys
89
910import can
1011
1718from canopen .sync import SyncProducer
1819from 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
2128logger = 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
0 commit comments