Repository: canopen-python/canopen
Pull request #573: Fix multiple associations and removals of networks
Author: login: sveinse
Created: 2025-04-26T11:44:09Z | Merged: 2025-04-28T20:36:54Z

This PR fixes the issue of not being able to call `associate_network()` and `remove_network()` multiple times. See #562.

Fixes #562

Linked issue #562: Behavior and failure for uninitialized network

https://github.com/christiansandberg/canopen/blob/5382f245d8ede61b113c6a648cb99b199667f0cd/canopen/node/local.py#L51-L59

Calling `LocalNode.remove_network()` or `RemoteNode.remove_network()` on an already closed network will fail, due to `self.network` no longer have the `.unsubscribe()` method (which is expected). Depending on what behavior is wanted, there lacks some functionality with regards to the logic of uninitialized `LocalNode`. Related to #525

### Option 1 - multiple calls to `.remove_network()` should be ok
If it should be acceptable to call `.remove_network()` multiple times without failing -> then these methods should support that the network is uninitialized

```py
    def remove_network(self) -> None:
        if self.network is canopen.network._UNINITIALIZED_NETWORK:
            return
        # Rest of the function....
```

### Option 2 - multiple calls to `.remove_network()` should fail
If a repeated call to `.remove_network()` should fail or fail on uninitialized network, I think we're missing a function to test if the `LocalNode` object is uninitialized. 

`LocalNode` and friends it sets the network uninitialized on its `__init__()` and then later initialize it by `.associate_network()`. This means that there are code paths through the usage of `LocalNode` where a cleanup code might need to call `.remove_network()` conditionally depending on the state of its association with a network.

There is no current way to test if a `LocalNode` object is associated without using the private `canopen.network._UNINITIALIZED_NETWORK` object. Moreso, `_UNINITIALIZED_NETWORK` is inherited from `Network` so a negative `isinstance(node.network, Network)` does not help to determine if its initialized.

To prevent access to the private object, I suggest that the best approach would be to add a new `.has_network()` method to the `LocalNode` and `RemoteNode` implementation.

```py
    def has_network(self):
       """Return True if the node has an associated network."""
       return self.network is not canopen.network._UNINITIALIZED_NETWORK
```




Linked issue comments: user: login: acolomb
created_at: 2025-02-25T07:35:30Z
body: Thanks for pointing this out.  I prefer the first option, it does not make much sense to fail on that call.
author_association: MEMBER
user: login: sveinse
created_at: 2025-04-26T11:44:23Z
body: While working on this, it was uncovered that calling `associate_network()` multiple times is not safe either. The `Network.subscribe()` method happily appends multiple callbacks to its lists. This is addressed in the PR.


author_association: COLLABORATOR
user: login: sveinse
created_at: 2025-04-26T13:07:51Z
body: FYI I just discovered that we already have the a method for testing that a node is associated with a network or not: https://github.com/canopen-python/canopen/blob/48cca7c655f8c932b1f761a8edc11d0702397fab/canopen/node/base.py#L30-L32


author_association: COLLABORATOR

Diff:
diff --git a/canopen/network.py b/canopen/network.py
index 02bec899..6f2777c8 100644
--- a/canopen/network.py
+++ b/canopen/network.py
@@ -75,10 +75,10 @@ def unsubscribe(self, can_id, callback=None) -> None:
             If given, remove only this callback.  Otherwise all callbacks for
             the CAN ID.
         """
-        if callback is None:
-            del self.subscribers[can_id]
-        else:
+        if callback is not None:
             self.subscribers[can_id].remove(callback)
+        if not self.subscribers[can_id] or callback is None:
+            del self.subscribers[can_id]
 
     def connect(self, *args, **kwargs) -> Network:
         """Connect to CAN bus using python-can.
diff --git a/canopen/node/local.py b/canopen/node/local.py
index 8f2493d9..34bba899 100644
--- a/canopen/node/local.py
+++ b/canopen/node/local.py
@@ -39,6 +39,8 @@ def __init__(
         self.emcy = EmcyProducer(0x80 + self.id)
 
     def associate_network(self, network: canopen.network.Network):
+        if self.has_network():
+            raise RuntimeError("Node is already associated with a network")
         self.network = network
         self.sdo.network = network
         self.tpdo.network = network
@@ -49,6 +51,8 @@ def associate_network(self, network: canopen.network.Network):
         network.subscribe(0, self.nmt.on_command)
 
     def remove_network(self) -> None:
+        if not self.has_network():
+            return
         self.network.unsubscribe(self.sdo.rx_cobid, self.sdo.on_request)
         self.network.unsubscribe(0, self.nmt.on_command)
         self.network = canopen.network._UNINITIALIZED_NETWORK
diff --git a/canopen/node/remote.py b/canopen/node/remote.py
index 226c0c0f..371f784c 100644
--- a/canopen/node/remote.py
+++ b/canopen/node/remote.py
@@ -51,6 +51,8 @@ def __init__(
             self.load_configuration()
 
     def associate_network(self, network: canopen.network.Network):
+        if self.has_network():
+            raise RuntimeError("Node is already associated with a network")
         self.network = network
         self.sdo.network = network
         self.pdo.network = network
@@ -64,6 +66,8 @@ def associate_network(self, network: canopen.network.Network):
         network.subscribe(0, self.nmt.on_command)
 
     def remove_network(self) -> None:
+        if not self.has_network():
+            return
         for sdo in self.sdo_channels:
             self.network.unsubscribe(sdo.tx_cobid, sdo.on_response)
         self.network.unsubscribe(0x700 + self.id, self.nmt.on_heartbeat)
diff --git a/test/test_node.py b/test/test_node.py
new file mode 100644
index 00000000..43373a2a
--- /dev/null
+++ b/test/test_node.py
@@ -0,0 +1,104 @@
+import unittest
+
+import canopen
+
+
+def count_subscribers(network: canopen.Network) -> int:
+    """Count the number of subscribers in the network."""
+    return sum(len(n) for n in network.subscribers.values())
+
+
+class TestLocalNode(unittest.TestCase):
+
+    @classmethod
+    def setUpClass(cls):
+        cls.network = canopen.Network()
+        cls.network.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0
+        cls.network.connect(interface="virtual")
+
+        cls.node = canopen.LocalNode(2, canopen.objectdictionary.ObjectDictionary())
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.network.disconnect()
+
+    def test_associate_network(self):
+        # Need to store the number of subscribers before associating because the
+        # network implementation automatically adds subscribers to the list
+        n_subscribers = count_subscribers(self.network)
+
+        # Associating the network with the local node
+        self.node.associate_network(self.network)
+        self.assertIs(self.node.network, self.network)
+        self.assertIs(self.node.sdo.network, self.network)
+        self.assertIs(self.node.tpdo.network, self.network)
+        self.assertIs(self.node.rpdo.network, self.network)
+        self.assertIs(self.node.nmt.network, self.network)
+        self.assertIs(self.node.emcy.network, self.network)
+
+        # Test that its not possible to associate the network multiple times
+        with self.assertRaises(RuntimeError) as cm:
+            self.node.associate_network(self.network)
+        self.assertIn("already associated with a network", str(cm.exception))
+
+        # Test removal of the network. The count of subscribers should
+        # be the same as before the association
+        self.node.remove_network()
+        uninitalized = canopen.network._UNINITIALIZED_NETWORK
+        self.assertIs(self.node.network, uninitalized)
+        self.assertIs(self.node.sdo.network, uninitalized)
+        self.assertIs(self.node.tpdo.network, uninitalized)
+        self.assertIs(self.node.rpdo.network, uninitalized)
+        self.assertIs(self.node.nmt.network, uninitalized)
+        self.assertIs(self.node.emcy.network, uninitalized)
+        self.assertEqual(count_subscribers(self.network), n_subscribers)
+
+        # Test that its possible to deassociate the network multiple times
+        self.node.remove_network()
+
+
+class TestRemoteNode(unittest.TestCase):
+
+    @classmethod
+    def setUpClass(cls):
+        cls.network = canopen.Network()
+        cls.network.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0
+        cls.network.connect(interface="virtual")
+
+        cls.node = canopen.RemoteNode(2, canopen.objectdictionary.ObjectDictionary())
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.network.disconnect()
+
+    def test_associate_network(self):
+        # Need to store the number of subscribers before associating because the
+        # network implementation automatically adds subscribers to the list
+        n_subscribers = count_subscribers(self.network)
+
+        # Associating the network with the local node
+        self.node.associate_network(self.network)
+        self.assertIs(self.node.network, self.network)
+        self.assertIs(self.node.sdo.network, self.network)
+        self.assertIs(self.node.tpdo.network, self.network)
+        self.assertIs(self.node.rpdo.network, self.network)
+        self.assertIs(self.node.nmt.network, self.network)
+
+        # Test that its not possible to associate the network multiple times
+        with self.assertRaises(RuntimeError) as cm:
+            self.node.associate_network(self.network)
+        self.assertIn("already associated with a network", str(cm.exception))
+
+        # Test removal of the network. The count of subscribers should
+        # be the same as before the association
+        self.node.remove_network()
+        uninitalized = canopen.network._UNINITIALIZED_NETWORK
+        self.assertIs(self.node.network, uninitalized)
+        self.assertIs(self.node.sdo.network, uninitalized)
+        self.assertIs(self.node.tpdo.network, uninitalized)
+        self.assertIs(self.node.rpdo.network, uninitalized)
+        self.assertIs(self.node.nmt.network, uninitalized)
+        self.assertEqual(count_subscribers(self.network), n_subscribers)
+
+        # Test that its possible to deassociate the network multiple times
+        self.node.remove_network()


Reviews:
user: login: sveinse
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-26T11:47:58Z
user: login: acolomb
body: Nice.  Mainly nit-picks, and I do welcome to use the existing `.has_network()` method.  I don't quite think automatically re-associating to a different network is appropriate.  Associating twice without removing from the first network will more likely point toward a programming error rather than a conscious, common workflow.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-27T19:27:25Z
user: login: sveinse
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T15:14:52Z
user: login: sveinse
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T15:15:05Z
user: login: sveinse
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T15:15:15Z
user: login: sveinse
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T15:16:42Z
user: login: sveinse
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T15:17:43Z
user: login: sveinse
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T15:18:45Z
user: login: sveinse
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T16:03:02Z
user: login: acolomb
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-28T19:33:20Z
user: login: acolomb
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-28T19:38:39Z
user: login: sveinse
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T19:49:10Z
user: login: sveinse
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T19:53:07Z
user: login: sveinse
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T19:54:49Z
user: login: acolomb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T19:59:46Z
user: login: acolomb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-28T20:00:30Z

Review comments:
diff_hunk: @@ -0,0 +1,121 @@
+"""Unit tests for the RemoteNode and LocalNode classes."""
+import unittest
+
+import canopen
+
+from .util import SAMPLE_EDS
+
+
+def count_subscribers(network: canopen.Network) -> int:
+    """Count the number of subscribers in the network."""
+    return sum(
+        len(n) for n in network.subscribers.values()
+    )
path: test/test_node.py
user: login: sveinse
body: This function exists because `Network.unsubscribe()` doesn't properly clean up. It leaves a dict of zero-length lists per `can_id` on unsubscription. Does this warrant a separate PR to fix the `Network.subsubscribe()` code?

https://github.com/canopen-python/canopen/blob/48cca7c655f8c932b1f761a8edc11d0702397fab/canopen/network.py#L69-L81
created_at: 2025-04-26T11:47:57Z
author_association: COLLABORATOR
diff_hunk: @@ -39,6 +39,9 @@ def __init__(
         self.emcy = EmcyProducer(0x80 + self.id)
 
     def associate_network(self, network: canopen.network.Network):
+        if self.network is not canopen.network._UNINITIALIZED_NETWORK:
path: canopen/node/local.py
user: login: acolomb
body: ```suggestion
        if self.has_network():
```
created_at: 2025-04-27T18:53:52Z
author_association: MEMBER
diff_hunk: @@ -49,6 +52,9 @@ def associate_network(self, network: canopen.network.Network):
         network.subscribe(0, self.nmt.on_command)
 
     def remove_network(self) -> None:
+        # Make it safe to call this method multiple times
+        if self.network is canopen.network._UNINITIALIZED_NETWORK:
path: canopen/node/local.py
user: login: acolomb
body: ```suggestion
        if not self.has_network():
```

Removed the comment as this version should be rather self-explanatory.
created_at: 2025-04-27T18:55:07Z
author_association: MEMBER
diff_hunk: @@ -51,6 +51,9 @@ def __init__(
             self.load_configuration()
 
     def associate_network(self, network: canopen.network.Network):
+        if self.network is not canopen.network._UNINITIALIZED_NETWORK:
path: canopen/node/remote.py
user: login: acolomb
body: ```suggestion
        if self.has_network():
```
created_at: 2025-04-27T18:55:26Z
author_association: MEMBER
diff_hunk: @@ -64,6 +67,9 @@ def associate_network(self, network: canopen.network.Network):
         network.subscribe(0, self.nmt.on_command)
 
     def remove_network(self) -> None:
+        # Make it safe to call this method multiple times
+        if self.network is canopen.network._UNINITIALIZED_NETWORK:
path: canopen/node/remote.py
user: login: acolomb
body: ```suggestion
        if not self.has_network():
```
created_at: 2025-04-27T18:56:22Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+"""Unit tests for the RemoteNode and LocalNode classes."""
+import unittest
+
+import canopen
+
+from .util import SAMPLE_EDS
+
+
+def count_subscribers(network: canopen.Network) -> int:
+    """Count the number of subscribers in the network."""
+    return sum(
+        len(n) for n in network.subscribers.values()
+    )
+
+
+class TestLocalNode(unittest.TestCase):
+    """
+    Test local node.
path: test/test_node.py
user: login: acolomb
body: This comment is pretty useless given the name of the class, no?  And such a short docstring (summary only) should be on one line.
created_at: 2025-04-27T18:59:10Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+"""Unit tests for the RemoteNode and LocalNode classes."""
+import unittest
+
+import canopen
+
+from .util import SAMPLE_EDS
+
+
+def count_subscribers(network: canopen.Network) -> int:
+    """Count the number of subscribers in the network."""
+    return sum(
+        len(n) for n in network.subscribers.values()
+    )
+
+
+class TestLocalNode(unittest.TestCase):
+    """
+    Test local node.
+    """
+
+    @classmethod
+    def setUpClass(cls):
+        cls.network = canopen.Network()
+        cls.network.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0
+        cls.network.connect("test", interface="virtual")
path: test/test_node.py
user: login: acolomb
body: ```suggestion
        cls.network.connect(interface="virtual")
```
created_at: 2025-04-27T19:03:30Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+"""Unit tests for the RemoteNode and LocalNode classes."""
+import unittest
+
+import canopen
+
+from .util import SAMPLE_EDS
+
+
+def count_subscribers(network: canopen.Network) -> int:
+    """Count the number of subscribers in the network."""
+    return sum(
+        len(n) for n in network.subscribers.values()
+    )
+
+
+class TestLocalNode(unittest.TestCase):
+    """
+    Test local node.
+    """
+
+    @classmethod
+    def setUpClass(cls):
+        cls.network = canopen.Network()
+        cls.network.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0
+        cls.network.connect("test", interface="virtual")
+
+        cls.node = canopen.LocalNode(2, SAMPLE_EDS)
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.network.disconnect()
+
+    def test_associate_network(self):
+
+        # Need to store the number of subscribers before associating because
+        # the current network implementation automatically adds subscribers
+        # to the list
path: test/test_node.py
user: login: acolomb
body: ```suggestion
        # Need to store the number of subscribers before associating because the
        # network implementation automatically adds subscribers to the list
```
created_at: 2025-04-27T19:09:59Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+"""Unit tests for the RemoteNode and LocalNode classes."""
+import unittest
+
+import canopen
+
+from .util import SAMPLE_EDS
+
+
+def count_subscribers(network: canopen.Network) -> int:
+    """Count the number of subscribers in the network."""
+    return sum(
+        len(n) for n in network.subscribers.values()
+    )
+
+
+class TestLocalNode(unittest.TestCase):
+    """
+    Test local node.
+    """
+
+    @classmethod
+    def setUpClass(cls):
+        cls.network = canopen.Network()
+        cls.network.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0
+        cls.network.connect("test", interface="virtual")
+
+        cls.node = canopen.LocalNode(2, SAMPLE_EDS)
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.network.disconnect()
+
+    def test_associate_network(self):
+
+        # Need to store the number of subscribers before associating because
+        # the current network implementation automatically adds subscribers
+        # to the list
+        n_subscribers = count_subscribers(self.network)
+
+        # Associating the network with the local node
+        self.node.associate_network(self.network)
+        self.assertIs(self.node.network, self.network)
+        self.assertIs(self.node.sdo.network, self.network)
+        self.assertIs(self.node.tpdo.network, self.network)
+        self.assertIs(self.node.rpdo.network, self.network)
+        self.assertIs(self.node.nmt.network, self.network)
+        self.assertIs(self.node.emcy.network, self.network)
+
+        # Test that its possible to associate the network multiple times
+        # by checking that the number of subscribers remains the same
+        count = count_subscribers(self.network)
+        self.node.associate_network(self.network)
+        self.assertEqual(count_subscribers(self.network), count)
+
+        # Test removal of the network. The count of subscribers should
+        # be the same as before the association
+        self.node.remove_network()
+        uninitalized = canopen.network._UNINITIALIZED_NETWORK
+        self.assertIs(self.node.network, uninitalized)
+        self.assertIs(self.node.sdo.network, uninitalized)
+        self.assertIs(self.node.tpdo.network, uninitalized)
+        self.assertIs(self.node.rpdo.network, uninitalized)
+        self.assertIs(self.node.nmt.network, uninitalized)
+        self.assertIs(self.node.emcy.network, uninitalized)
+        self.assertEqual(count_subscribers(self.network), n_subscribers)
+
+        # Test that its possible to deassociate the network multiple times
+        self.node.remove_network()
+
+
+class TestRemoteNode(unittest.TestCase):
+    """
+    Test remote node.
+    """
+
+    @classmethod
+    def setUpClass(cls):
+        cls.network = canopen.Network()
+        cls.network.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0
+        cls.network.connect("test", interface="virtual")
+
+        cls.node = canopen.RemoteNode(2, SAMPLE_EDS)
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.network.disconnect()
+
+    def test_associate_network(self):
+
+        # Need to store the number of subscribers before associating because
+        # the current network implementation automatically adds subscribers
+        # to the list
path: test/test_node.py
user: login: acolomb
body: ```suggestion
        # Need to store the number of subscribers before associating because the
        # network implementation automatically adds subscribers to the list
```
created_at: 2025-04-27T19:11:02Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+"""Unit tests for the RemoteNode and LocalNode classes."""
+import unittest
+
+import canopen
+
+from .util import SAMPLE_EDS
+
+
+def count_subscribers(network: canopen.Network) -> int:
+    """Count the number of subscribers in the network."""
+    return sum(
+        len(n) for n in network.subscribers.values()
+    )
path: test/test_node.py
user: login: acolomb
body: Yes I think it would be good measure to add something like this here:

    if not len(self.subscribers[can_id]):
        del self.subscribers[can_id]

And probably refactor to have that deletion only once, combining the two conditionals.
created_at: 2025-04-27T19:17:04Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+"""Unit tests for the RemoteNode and LocalNode classes."""
+import unittest
+
+import canopen
+
+from .util import SAMPLE_EDS
+
+
+def count_subscribers(network: canopen.Network) -> int:
+    """Count the number of subscribers in the network."""
+    return sum(
+        len(n) for n in network.subscribers.values()
+    )
+
+
+class TestLocalNode(unittest.TestCase):
+    """
+    Test local node.
+    """
+
+    @classmethod
+    def setUpClass(cls):
+        cls.network = canopen.Network()
+        cls.network.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0
+        cls.network.connect("test", interface="virtual")
+
+        cls.node = canopen.LocalNode(2, SAMPLE_EDS)
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.network.disconnect()
+
+    def test_associate_network(self):
+
+        # Need to store the number of subscribers before associating because
+        # the current network implementation automatically adds subscribers
+        # to the list
+        n_subscribers = count_subscribers(self.network)
+
+        # Associating the network with the local node
+        self.node.associate_network(self.network)
+        self.assertIs(self.node.network, self.network)
+        self.assertIs(self.node.sdo.network, self.network)
+        self.assertIs(self.node.tpdo.network, self.network)
+        self.assertIs(self.node.rpdo.network, self.network)
+        self.assertIs(self.node.nmt.network, self.network)
+        self.assertIs(self.node.emcy.network, self.network)
+
+        # Test that its possible to associate the network multiple times
+        # by checking that the number of subscribers remains the same
+        count = count_subscribers(self.network)
+        self.node.associate_network(self.network)
+        self.assertEqual(count_subscribers(self.network), count)
+
+        # Test removal of the network. The count of subscribers should
+        # be the same as before the association
+        self.node.remove_network()
+        uninitalized = canopen.network._UNINITIALIZED_NETWORK
+        self.assertIs(self.node.network, uninitalized)
+        self.assertIs(self.node.sdo.network, uninitalized)
+        self.assertIs(self.node.tpdo.network, uninitalized)
+        self.assertIs(self.node.rpdo.network, uninitalized)
+        self.assertIs(self.node.nmt.network, uninitalized)
+        self.assertIs(self.node.emcy.network, uninitalized)
+        self.assertEqual(count_subscribers(self.network), n_subscribers)
+
+        # Test that its possible to deassociate the network multiple times
+        self.node.remove_network()
+
+
+class TestRemoteNode(unittest.TestCase):
+    """
+    Test remote node.
+    """
path: test/test_node.py
user: login: acolomb
body: Better comment.
created_at: 2025-04-27T19:17:30Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+"""Unit tests for the RemoteNode and LocalNode classes."""
+import unittest
+
+import canopen
+
+from .util import SAMPLE_EDS
+
+
+def count_subscribers(network: canopen.Network) -> int:
+    """Count the number of subscribers in the network."""
+    return sum(
+        len(n) for n in network.subscribers.values()
+    )
+
+
+class TestLocalNode(unittest.TestCase):
+    """
+    Test local node.
+    """
+
+    @classmethod
+    def setUpClass(cls):
+        cls.network = canopen.Network()
+        cls.network.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0
+        cls.network.connect("test", interface="virtual")
+
+        cls.node = canopen.LocalNode(2, SAMPLE_EDS)
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.network.disconnect()
+
+    def test_associate_network(self):
+
+        # Need to store the number of subscribers before associating because
+        # the current network implementation automatically adds subscribers
+        # to the list
+        n_subscribers = count_subscribers(self.network)
+
+        # Associating the network with the local node
+        self.node.associate_network(self.network)
+        self.assertIs(self.node.network, self.network)
+        self.assertIs(self.node.sdo.network, self.network)
+        self.assertIs(self.node.tpdo.network, self.network)
+        self.assertIs(self.node.rpdo.network, self.network)
+        self.assertIs(self.node.nmt.network, self.network)
+        self.assertIs(self.node.emcy.network, self.network)
+
+        # Test that its possible to associate the network multiple times
+        # by checking that the number of subscribers remains the same
+        count = count_subscribers(self.network)
+        self.node.associate_network(self.network)
+        self.assertEqual(count_subscribers(self.network), count)
+
+        # Test removal of the network. The count of subscribers should
+        # be the same as before the association
+        self.node.remove_network()
+        uninitalized = canopen.network._UNINITIALIZED_NETWORK
+        self.assertIs(self.node.network, uninitalized)
+        self.assertIs(self.node.sdo.network, uninitalized)
+        self.assertIs(self.node.tpdo.network, uninitalized)
+        self.assertIs(self.node.rpdo.network, uninitalized)
+        self.assertIs(self.node.nmt.network, uninitalized)
+        self.assertIs(self.node.emcy.network, uninitalized)
+        self.assertEqual(count_subscribers(self.network), n_subscribers)
+
+        # Test that its possible to deassociate the network multiple times
+        self.node.remove_network()
+
+
+class TestRemoteNode(unittest.TestCase):
+    """
+    Test remote node.
+    """
+
+    @classmethod
+    def setUpClass(cls):
+        cls.network = canopen.Network()
+        cls.network.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0
+        cls.network.connect("test", interface="virtual")
path: test/test_node.py
user: login: acolomb
body: ```suggestion
        cls.network.connect(interface="virtual")
```
created_at: 2025-04-27T19:17:40Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+"""Unit tests for the RemoteNode and LocalNode classes."""
+import unittest
+
+import canopen
+
+from .util import SAMPLE_EDS
path: test/test_node.py
user: login: acolomb
body: I think we can get away without an EDS here at all.  Just initialize the nodes with an empty `canopen.objectdictionary.ObjectDictionary()`.
created_at: 2025-04-27T19:21:54Z
author_association: MEMBER
diff_hunk: @@ -39,6 +39,9 @@ def __init__(
         self.emcy = EmcyProducer(0x80 + self.id)
 
     def associate_network(self, network: canopen.network.Network):
+        if self.network is not canopen.network._UNINITIALIZED_NETWORK:
+            # Unsubscribe from old network (to prevent double subscription)
+            self.remove_network()
path: canopen/node/local.py
user: login: acolomb
body: Is it really common to re-associate a node to a different network?  I would think raising a `RuntimeError` exception here is more appropriate.
created_at: 2025-04-27T19:25:05Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+"""Unit tests for the RemoteNode and LocalNode classes."""
+import unittest
+
+import canopen
+
+from .util import SAMPLE_EDS
+
+
+def count_subscribers(network: canopen.Network) -> int:
+    """Count the number of subscribers in the network."""
+    return sum(
+        len(n) for n in network.subscribers.values()
+    )
path: test/test_node.py
user: login: sveinse
body: Agreed. Fix implemented.
created_at: 2025-04-28T15:14:52Z
author_association: COLLABORATOR
diff_hunk: @@ -39,6 +39,9 @@ def __init__(
         self.emcy = EmcyProducer(0x80 + self.id)
 
     def associate_network(self, network: canopen.network.Network):
+        if self.network is not canopen.network._UNINITIALIZED_NETWORK:
path: canopen/node/local.py
user: login: sveinse
body: Done.
created_at: 2025-04-28T15:15:05Z
author_association: COLLABORATOR
diff_hunk: @@ -51,6 +51,9 @@ def __init__(
             self.load_configuration()
 
     def associate_network(self, network: canopen.network.Network):
+        if self.network is not canopen.network._UNINITIALIZED_NETWORK:
path: canopen/node/remote.py
user: login: sveinse
body: Done.
created_at: 2025-04-28T15:15:15Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,121 @@
+"""Unit tests for the RemoteNode and LocalNode classes."""
+import unittest
+
+import canopen
+
+from .util import SAMPLE_EDS
+
+
+def count_subscribers(network: canopen.Network) -> int:
+    """Count the number of subscribers in the network."""
+    return sum(
+        len(n) for n in network.subscribers.values()
+    )
+
+
+class TestLocalNode(unittest.TestCase):
+    """
+    Test local node.
path: test/test_node.py
user: login: sveinse
body: Full sentence added on one line. The intention was that these two classes contain other unittests for these two classes, not just this particular test.
created_at: 2025-04-28T15:16:42Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,121 @@
+"""Unit tests for the RemoteNode and LocalNode classes."""
+import unittest
+
+import canopen
+
+from .util import SAMPLE_EDS
+
+
+def count_subscribers(network: canopen.Network) -> int:
+    """Count the number of subscribers in the network."""
+    return sum(
+        len(n) for n in network.subscribers.values()
+    )
+
+
+class TestLocalNode(unittest.TestCase):
+    """
+    Test local node.
+    """
+
+    @classmethod
+    def setUpClass(cls):
+        cls.network = canopen.Network()
+        cls.network.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0
+        cls.network.connect("test", interface="virtual")
path: test/test_node.py
user: login: sveinse
body: This was a copy-paste from other uses of connect in the lib. Fixed.
created_at: 2025-04-28T15:17:43Z
author_association: COLLABORATOR
diff_hunk: @@ -39,6 +39,9 @@ def __init__(
         self.emcy = EmcyProducer(0x80 + self.id)
 
     def associate_network(self, network: canopen.network.Network):
+        if self.network is not canopen.network._UNINITIALIZED_NETWORK:
+            # Unsubscribe from old network (to prevent double subscription)
+            self.remove_network()
path: canopen/node/local.py
user: login: sveinse
body: New behavior where `RuntimeError()` is implemented.
created_at: 2025-04-28T15:18:45Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,121 @@
+"""Unit tests for the RemoteNode and LocalNode classes."""
+import unittest
+
+import canopen
+
+from .util import SAMPLE_EDS
path: test/test_node.py
user: login: sveinse
body: Changed it to using `canopen.objectdictionary.ObjectDictionary()`
created_at: 2025-04-28T16:03:02Z
author_association: COLLABORATOR
diff_hunk: @@ -79,6 +79,9 @@ def unsubscribe(self, can_id, callback=None) -> None:
             del self.subscribers[can_id]
         else:
             self.subscribers[can_id].remove(callback)
+            # Remove empty list entry
+            if not self.subscribers[can_id]:
+                del self.subscribers[can_id]
path: canopen/network.py
user: login: acolomb
body: I meant something like this instead, with a single `del` code-path.  I find that easier to reason about:

```python3
        if callback is not None:
            self.subscribers[can_id].remove(callback)
        if not self.subscribers[can_id] or callback is None:
            del self.subscribers[can_id]
```
created_at: 2025-04-28T19:20:33Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,113 @@
+"""Unit tests for the RemoteNode and LocalNode classes."""
+import unittest
+
+import canopen
+import canopen.network
+import canopen.objectdictionary
path: test/test_node.py
user: login: acolomb
body: These seem to be not needed.
```suggestion
```
created_at: 2025-04-28T19:21:32Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,113 @@
+"""Unit tests for the RemoteNode and LocalNode classes."""
+import unittest
+
+import canopen
+import canopen.network
+import canopen.objectdictionary
+
+
+def count_subscribers(network: canopen.Network) -> int:
+    """Count the number of subscribers in the network."""
+    return sum(
+        len(n) for n in network.subscribers.values()
+    )
path: test/test_node.py
user: login: acolomb
body: ```suggestion
    return sum(len(n) for n in network.subscribers.values())
```

(black)
created_at: 2025-04-28T19:21:58Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,113 @@
+"""Unit tests for the RemoteNode and LocalNode classes."""
path: test/test_node.py
user: login: acolomb
body: I'm not really fond of having comments just for the sake of having a comment.  None of our test files so far have a module-level docstring.  There is no generated API documentation for them.  And `import unittest` says just as much as this comment, while the enumeration of classes is redundant and bound to get out of sync sometime.

In essence, don't feel obliged to write docstrings that add no value.  Or try to come up with a really good one that does so.

What *IS* the common denominator for stuff that will be grouped in this test file?  So far it is only network association, what else do you have in mind?

```suggestion
```
created_at: 2025-04-28T19:27:47Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,113 @@
+"""Unit tests for the RemoteNode and LocalNode classes."""
+import unittest
+
+import canopen
+import canopen.network
+import canopen.objectdictionary
+
+
+def count_subscribers(network: canopen.Network) -> int:
+    """Count the number of subscribers in the network."""
+    return sum(
+        len(n) for n in network.subscribers.values()
+    )
+
+
+class TestLocalNode(unittest.TestCase):
+    """Unit tests for the LocalNode class."""
path: test/test_node.py
user: login: acolomb
body: Not better :wink:

```suggestion
```
created_at: 2025-04-28T19:29:13Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,113 @@
+"""Unit tests for the RemoteNode and LocalNode classes."""
+import unittest
+
+import canopen
+import canopen.network
+import canopen.objectdictionary
+
+
+def count_subscribers(network: canopen.Network) -> int:
+    """Count the number of subscribers in the network."""
+    return sum(
+        len(n) for n in network.subscribers.values()
+    )
+
+
+class TestLocalNode(unittest.TestCase):
+    """Unit tests for the LocalNode class."""
+
+    @classmethod
+    def setUpClass(cls):
+        cls.network = canopen.Network()
+        cls.network.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0
+        cls.network.connect(interface="virtual")
+
+        cls.node = canopen.LocalNode(2, canopen.objectdictionary.ObjectDictionary())
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.network.disconnect()
+
+    def test_associate_network(self):
+
+        # Need to store the number of subscribers before associating because the
+        # network implementation automatically adds subscribers to the list
+        n_subscribers = count_subscribers(self.network)
+
+        # Associating the network with the local node
+        self.node.associate_network(self.network)
+        self.assertIs(self.node.network, self.network)
+        self.assertIs(self.node.sdo.network, self.network)
+        self.assertIs(self.node.tpdo.network, self.network)
+        self.assertIs(self.node.rpdo.network, self.network)
+        self.assertIs(self.node.nmt.network, self.network)
+        self.assertIs(self.node.emcy.network, self.network)
+
+        # Test that its not possible to associate the network multiple times
+        with self.assertRaises(RuntimeError) as cm:
+            self.node.associate_network(self.network)
+        self.assertIn("already associated with a network", str(cm.exception))
+
+        # Test removal of the network. The count of subscribers should
+        # be the same as before the association
+        self.node.remove_network()
+        uninitalized = canopen.network._UNINITIALIZED_NETWORK
+        self.assertIs(self.node.network, uninitalized)
+        self.assertIs(self.node.sdo.network, uninitalized)
+        self.assertIs(self.node.tpdo.network, uninitalized)
+        self.assertIs(self.node.rpdo.network, uninitalized)
+        self.assertIs(self.node.nmt.network, uninitalized)
+        self.assertIs(self.node.emcy.network, uninitalized)
+        self.assertEqual(count_subscribers(self.network), n_subscribers)
+
+        # Test that its possible to deassociate the network multiple times
+        self.node.remove_network()
+
+
+class TestRemoteNode(unittest.TestCase):
+    """Unittests for the RemoteNode class."""
path: test/test_node.py
user: login: acolomb
body: ```suggestion
```
created_at: 2025-04-28T19:29:25Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,113 @@
+"""Unit tests for the RemoteNode and LocalNode classes."""
+import unittest
+
+import canopen
+import canopen.network
+import canopen.objectdictionary
+
+
+def count_subscribers(network: canopen.Network) -> int:
+    """Count the number of subscribers in the network."""
+    return sum(
+        len(n) for n in network.subscribers.values()
+    )
+
+
+class TestLocalNode(unittest.TestCase):
+    """Unit tests for the LocalNode class."""
+
+    @classmethod
+    def setUpClass(cls):
+        cls.network = canopen.Network()
+        cls.network.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0
+        cls.network.connect(interface="virtual")
+
+        cls.node = canopen.LocalNode(2, canopen.objectdictionary.ObjectDictionary())
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.network.disconnect()
+
+    def test_associate_network(self):
+
+        # Need to store the number of subscribers before associating because the
+        # network implementation automatically adds subscribers to the list
+        n_subscribers = count_subscribers(self.network)
+
+        # Associating the network with the local node
+        self.node.associate_network(self.network)
+        self.assertIs(self.node.network, self.network)
+        self.assertIs(self.node.sdo.network, self.network)
+        self.assertIs(self.node.tpdo.network, self.network)
+        self.assertIs(self.node.rpdo.network, self.network)
+        self.assertIs(self.node.nmt.network, self.network)
+        self.assertIs(self.node.emcy.network, self.network)
+
+        # Test that its not possible to associate the network multiple times
+        with self.assertRaises(RuntimeError) as cm:
+            self.node.associate_network(self.network)
+        self.assertIn("already associated with a network", str(cm.exception))
+
+        # Test removal of the network. The count of subscribers should
+        # be the same as before the association
+        self.node.remove_network()
+        uninitalized = canopen.network._UNINITIALIZED_NETWORK
+        self.assertIs(self.node.network, uninitalized)
+        self.assertIs(self.node.sdo.network, uninitalized)
+        self.assertIs(self.node.tpdo.network, uninitalized)
+        self.assertIs(self.node.rpdo.network, uninitalized)
+        self.assertIs(self.node.nmt.network, uninitalized)
+        self.assertIs(self.node.emcy.network, uninitalized)
+        self.assertEqual(count_subscribers(self.network), n_subscribers)
+
+        # Test that its possible to deassociate the network multiple times
+        self.node.remove_network()
+
+
+class TestRemoteNode(unittest.TestCase):
+    """Unittests for the RemoteNode class."""
+
+    @classmethod
+    def setUpClass(cls):
+        cls.network = canopen.Network()
+        cls.network.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0
+        cls.network.connect(interface="virtual")
+
+        cls.node = canopen.RemoteNode(2, canopen.objectdictionary.ObjectDictionary())
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.network.disconnect()
+
+    def test_associate_network(self):
+
path: test/test_node.py
user: login: acolomb
body: ```suggestion
```
created_at: 2025-04-28T19:29:43Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,113 @@
+"""Unit tests for the RemoteNode and LocalNode classes."""
+import unittest
+
+import canopen
+import canopen.network
+import canopen.objectdictionary
+
+
+def count_subscribers(network: canopen.Network) -> int:
+    """Count the number of subscribers in the network."""
+    return sum(
+        len(n) for n in network.subscribers.values()
+    )
+
+
+class TestLocalNode(unittest.TestCase):
+    """Unit tests for the LocalNode class."""
+
+    @classmethod
+    def setUpClass(cls):
+        cls.network = canopen.Network()
+        cls.network.NOTIFIER_SHUTDOWN_TIMEOUT = 0.0
+        cls.network.connect(interface="virtual")
+
+        cls.node = canopen.LocalNode(2, canopen.objectdictionary.ObjectDictionary())
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.network.disconnect()
+
+    def test_associate_network(self):
+
path: test/test_node.py
user: login: acolomb
body: ```suggestion
```
created_at: 2025-04-28T19:33:16Z
author_association: MEMBER
diff_hunk: @@ -79,6 +79,9 @@ def unsubscribe(self, can_id, callback=None) -> None:
             del self.subscribers[can_id]
         else:
             self.subscribers[can_id].remove(callback)
+            # Remove empty list entry
+            if not self.subscribers[can_id]:
+                del self.subscribers[can_id]
path: canopen/network.py
user: login: sveinse
body: Yes, two lines shorter.
created_at: 2025-04-28T19:49:10Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,113 @@
+"""Unit tests for the RemoteNode and LocalNode classes."""
+import unittest
+
+import canopen
+import canopen.network
+import canopen.objectdictionary
path: test/test_node.py
user: login: sveinse
body: They are. Some linters, like pyright, is complaining when they are lacking. The reason is that they are not referenced in the `__init__.py` exports, so they need to be explicitly imported.

<img width="1101" alt="image" src="https://github.com/user-attachments/assets/80ff0571-3c79-4c45-b4b9-af79bc4aad51" />

created_at: 2025-04-28T19:53:07Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,113 @@
+"""Unit tests for the RemoteNode and LocalNode classes."""
path: test/test_node.py
user: login: sveinse
body: 
> What _IS_ the common denominator for stuff that will be grouped in this test file? So far it is only network association, what else do you have in mind?

Eventually we'll have more unit tests of the methods in these two classes, no?
created_at: 2025-04-28T19:54:49Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,113 @@
+"""Unit tests for the RemoteNode and LocalNode classes."""
+import unittest
+
+import canopen
+import canopen.network
+import canopen.objectdictionary
path: test/test_node.py
user: login: acolomb
body: I understand.  How come the code works without them though?
created_at: 2025-04-28T19:59:46Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,113 @@
+"""Unit tests for the RemoteNode and LocalNode classes."""
path: test/test_node.py
user: login: acolomb
body: Maybe, that's why I'm asking if you have any concrete ideas what will be added?
created_at: 2025-04-28T20:00:30Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-04-28T15:55:47Z
body: ## Welcome to [Codecov](https://codecov.io?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=canopen-python) :tada:

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

:information_source: You can also turn on [project coverage checks](https://docs.codecov.com/docs/common-recipe-list#set-project-coverage-checks-on-a-pull-request) and [project coverage reporting on Pull Request comment](https://docs.codecov.com/docs/common-recipe-list#show-project-coverage-changes-on-the-pull-request-comment)

Thanks for integrating Codecov - We've got you covered :open_umbrella:
author_association: NONE
user: login: acolomb
created_at: 2025-04-28T19:37:51Z
body: I took the liberty to apply my suggested changes.  Should be uncontroversial I hope.  Please indicate whether you are happy with the last commit's logic "simplification", it might seem simpler only to me :-)
author_association: MEMBER
user: login: sveinse
created_at: 2025-04-28T20:02:49Z
body: Comments updated. No objections.

You and I have very different coding styles my friend 😎 While in many projects the commit is rejected for not having docstrings,  here its removed because its not informative enough 🎉 It's fine, I just find it amusing. One certainly needs to learn every project's individual styles, that's for sure. Even the undocumented ones :D

PS! I don't know, but I don't have push authorization no more since the project move. Was that deliberate?
author_association: COLLABORATOR
user: login: acolomb
created_at: 2025-04-28T20:08:10Z
body: Yes, I know my stance on coding style is not average.  And I'm happy that you're open to adjusting.  It's mostly stuff where my experience tells me that exceptions from any rule might be better than following it blindly.  There should always be room for well-reasoned deviation.

To the question at hand: I really *hate* comments stating the obvious that give no further information than what the (well-written) code already tells.  It's just twice the reading effort, and only makes sense if the docstrings are needed for auto-generated docs.  This is different in the context of unit tests, not the actual library classes.  And if we wanted to be perfect, there would be many many test functions with "Missing docstring in public method" messages to tackle.

> PS! I don't know, but I don't have push authorization no more since the project move. Was that deliberate?

Not that I know of.  The move was done by Christian, I haven't adjusted any permissions since.  What exactly can you no longer do?

Actually, I did change one thing: Activate branch protections for the `master` branch.  That was needed to differentiate the required / optional checks.  And in the process I blocked direct pushes to master, they need to go through a PR with approval (except for organization admins who may bypass that).
author_association: MEMBER
user: login: sveinse
created_at: 2025-04-28T20:15:55Z
body: > Yes, I know my stance on coding style is not average. And I'm happy that you're open to adjusting. It's mostly stuff where my experience tells me that exceptions from any rule might be better than following it blindly. There should always be room for well-reasoned deviation.

Well, in practice I don't have much choice, do I? Going into a PR where someone adamantly disagrees with the style is not a good way to create a cooperative environment, right. We have far harder challenges ahead of us with respect to asyncio than to squabble over formatting styles.

> > PS! I don't know, but I don't have push authorization no more since the project move. Was that deliberate?
> 
> Not that I know of. The move was done by Christian, I haven't adjusted any permissions since. What exactly can you no longer do?

I can't merge this branch, even when it's approved.

<img width="647" alt="image" src="https://github.com/user-attachments/assets/48b78b7f-ad1a-46a3-a9e5-712db7dbe8fa" />

author_association: COLLABORATOR
user: login: acolomb
created_at: 2025-04-28T20:29:53Z
body: > I can't merge this branch, even when it's approved.

Hope it works now.  My intention was to disallow direct pushes, but merging pull requests should still be allowed for people with write access (which you do have).  I disabled the "Restrict who can push to matching branches" setting again, hope that has the desired effect.
author_association: MEMBER
user: login: sveinse
created_at: 2025-04-28T20:32:36Z
body: I have access now. Thanks!

Since we're into discussions about merging: What is your preferred approach to merge? Is squash and merge the way to go? -- I personally tend to prefer that with public PRs, since there often will be a bit back and forth, like this one, where there will be many minor tweak commits.
author_association: COLLABORATOR
user: login: acolomb
created_at: 2025-04-28T20:34:13Z
body: Yes, squash and merge should do it.  Unless we have special circumstances with a perfectly hand-crafted commit history.  Which is pretty rare.
author_association: MEMBER
user: login: sveinse
created_at: 2025-04-28T20:37:51Z
body: Merged! Thank you for the review @acolomb 
author_association: COLLABORATOR
user: login: acolomb
created_at: 2025-04-28T20:38:23Z
body: Great! Thank you very much for your continued involvement and you patience with reviews :-)
author_association: MEMBER
user: login: acolomb
created_at: 2025-04-28T20:41:23Z
body: One more thing about the removed imports, forgot to write it down:  The `canopen.objectdictionary.ObjectDictionary` is in fact available as just `canopen.ObjectDictionary`.  Which we should have used.

The same doesn't apply to the `_UNINITIALIZED_NETWORK` thing.  Which should be non-public API anyway.

As long as the tests work, I don't mind much about it though.  It's only unit test code :wink:
author_association: MEMBER