OpenSMI-Core 0.3.1__tar.gz → 0.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {opensmi_core-0.3.1 → opensmi_core-0.4}/PKG-INFO +1 -1
  2. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/OpenSMI_Core.egg-info/PKG-INFO +1 -1
  3. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/OpenSMI_Core.egg-info/scm_version.json +2 -2
  4. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/opensmi/core/subscription_manager.py +154 -46
  5. {opensmi_core-0.3.1 → opensmi_core-0.4}/.github/workflows/api-docs.yml +0 -0
  6. {opensmi_core-0.3.1 → opensmi_core-0.4}/.github/workflows/python-publish.yml +0 -0
  7. {opensmi_core-0.3.1 → opensmi_core-0.4}/.gitignore +0 -0
  8. {opensmi_core-0.3.1 → opensmi_core-0.4}/.gitlab-ci.yml +0 -0
  9. {opensmi_core-0.3.1 → opensmi_core-0.4}/LICENSE +0 -0
  10. {opensmi_core-0.3.1 → opensmi_core-0.4}/LICENSES/MIT.txt +0 -0
  11. {opensmi_core-0.3.1 → opensmi_core-0.4}/README.md +0 -0
  12. {opensmi_core-0.3.1 → opensmi_core-0.4}/pyproject.toml +0 -0
  13. {opensmi_core-0.3.1 → opensmi_core-0.4}/setup.cfg +0 -0
  14. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/OpenSMI_Core.egg-info/SOURCES.txt +0 -0
  15. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/OpenSMI_Core.egg-info/dependency_links.txt +0 -0
  16. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/OpenSMI_Core.egg-info/requires.txt +0 -0
  17. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/OpenSMI_Core.egg-info/scm_file_list.json +0 -0
  18. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/OpenSMI_Core.egg-info/top_level.txt +0 -0
  19. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/opensmi/core/__init__.py +0 -0
  20. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/opensmi/core/async_task_mixin.py +0 -0
  21. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/opensmi/core/base_server.py +0 -0
  22. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/opensmi/core/base_ua_object.py +0 -0
  23. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/opensmi/core/config.py +0 -0
  24. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/opensmi/core/enums.py +0 -0
  25. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/opensmi/core/errors.py +0 -0
  26. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/opensmi/core/frozen.py +0 -0
  27. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/opensmi/core/hook.py +0 -0
  28. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/opensmi/core/lifecycle_mixin.py +0 -0
  29. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/opensmi/core/logging.py +0 -0
  30. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/opensmi/core/meta.py +0 -0
  31. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/opensmi/core/mixin_helpers.py +0 -0
  32. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/opensmi/core/protocols.py +0 -0
  33. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/opensmi/core/repr_mixin.py +0 -0
  34. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/opensmi/core/signal.py +0 -0
  35. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/opensmi/core/ua.py +0 -0
  36. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/opensmi/core/ua_enums.py +0 -0
  37. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/opensmi/core/ua_node_util.py +0 -0
  38. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/opensmi/core/unit_all.py +0 -0
  39. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/opensmi/core/unit_base.py +0 -0
  40. {opensmi_core-0.3.1 → opensmi_core-0.4}/src/opensmi/core/unset.py +0 -0
  41. {opensmi_core-0.3.1 → opensmi_core-0.4}/tests/test_signal.py +0 -0
  42. {opensmi_core-0.3.1 → opensmi_core-0.4}/tests/test_unit.py +0 -0
  43. {opensmi_core-0.3.1 → opensmi_core-0.4}/tools/auto_generate_units.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: OpenSMI-Core
3
- Version: 0.3.1
3
+ Version: 0.4
4
4
  Summary: Smart Machine Interface core functionality for server and client applications.
5
5
  Author-email: Carsten Harms <Carsten.Harms@dfki.de>, Simon Jungbluth <Simon.Jungbluth@smartfactory.de>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: OpenSMI-Core
3
- Version: 0.3.1
3
+ Version: 0.4
4
4
  Summary: Smart Machine Interface core functionality for server and client applications.
5
5
  Author-email: Carsten Harms <Carsten.Harms@dfki.de>, Simon Jungbluth <Simon.Jungbluth@smartfactory.de>
6
6
  License-Expression: MIT
@@ -1,7 +1,7 @@
1
1
  {
2
- "tag": "0.3.1",
2
+ "tag": "0.4",
3
3
  "distance": 0,
4
- "node": "g672f1787bfda4d814e6bfae7730db36dc7cd0999",
4
+ "node": "gf395f24046a13a5a9c4b6536f16e250aada34582",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-10-12"
@@ -10,7 +10,8 @@ import time
10
10
  from abc import ABC, abstractmethod
11
11
  from asyncio import Task
12
12
  from collections.abc import Iterable
13
- from typing import Any, Never
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Generic, Never, TypeVar
14
15
 
15
16
  import structlog
16
17
  from asyncua import ua
@@ -39,11 +40,21 @@ class UaEventSubscriber(ABC):
39
40
  """Handle OPC UA event notification."""
40
41
 
41
42
 
43
+ _HandlerType = TypeVar("_HandlerType")
44
+
45
+
46
+ @dataclass
47
+ class _MonitoredItem(Generic[_HandlerType]):
48
+ handle: int | None
49
+ """Handle as returned by ``asyncua``. Is ``None`` when renew failed."""
50
+ handlers: list[_HandlerType] = field(default_factory=list)
51
+
52
+
42
53
  class SubscriptionManager:
43
54
  """Centrally manages all OPC UA subscription needs.
44
55
 
45
56
  It bundles subscriptions using only one subscription to an OPC UA server
46
- to not stay within max. subscription limits of certain OPC UA servers.
57
+ to stay within max. subscription limits of certain OPC UA servers.
47
58
  """
48
59
 
49
60
  _ua_client: Client
@@ -65,10 +76,10 @@ class SubscriptionManager:
65
76
  self._publishing_interval_ms = publishing_interval_ms
66
77
  self._ua_client = ua_client
67
78
 
68
- self._data_subscription_map: dict[NodeId, list[UaDataChangeSubscriber]] = {}
69
- """ Maps a OPC UA node to a list of handlers for data change notifications. """
70
- self._event_subscription_map: dict[NodeId, list[UaEventSubscriber]] = {}
71
- """ Maps a OPC UA node to a list of handlers for event notifications. """
79
+ self._data_subscription_map: dict[NodeId, _MonitoredItem[UaDataChangeSubscriber]] = {}
80
+ """ Maps a OPC UA node to a handle and a list of handlers for data change notifications. """
81
+ self._event_subscription_map: dict[NodeId, _MonitoredItem[UaEventSubscriber]] = {}
82
+ """ Maps a OPC UA node to a handle and a list of handlers for event notifications. """
72
83
 
73
84
  self._heartbeat_task: Task[Never] | None = None
74
85
  self._last_heartbeat = time.monotonic()
@@ -83,6 +94,13 @@ class SubscriptionManager:
83
94
  self.logger.info("Created subscription!", parameters=self._ua_subscription.parameters)
84
95
 
85
96
  async def init(self) -> None:
97
+ """Create the subscription and start heartbeat monitoring.
98
+
99
+ Must be called once before any ``subscribe_*`` method.
100
+ """
101
+ if self._heartbeat_task is not None:
102
+ return
103
+
86
104
  await self._create_subscription()
87
105
 
88
106
  # subscribe to a node that always changes reliably, for example the current time of the server
@@ -92,47 +110,80 @@ class SubscriptionManager:
92
110
 
93
111
  self._last_heartbeat = time.monotonic()
94
112
  self._heartbeat_task = asyncio.create_task(self._heartbeat_loop(), name="heartbeat_task")
113
+ self.logger.info("Subscription manager initialized")
95
114
 
96
- async def _renew_subscriptions(self) -> None:
97
- self.logger.info(f"Trying to renew {self.no_of_monitored_items} subscriptions...")
98
- old_data_subscription_map = self._data_subscription_map.copy()
99
- old_event_subscription_map = self._event_subscription_map.copy()
115
+ async def shutdown(self) -> None:
116
+ """Stop heartbeat monitoring and delete the OPC UA subscription."""
117
+ # Stop the heartbeat first, so it can't start renewing subscriptions while shutting down
118
+ if self._heartbeat_task is not None:
119
+ self._heartbeat_task.cancel()
120
+ with contextlib.suppress(asyncio.CancelledError):
121
+ await self._heartbeat_task
122
+ self._heartbeat_task = None
100
123
 
101
124
  await self.clear_subscriptions()
102
- while True:
125
+ self.logger.info("Subscription manager shut down")
126
+
127
+ # TODO(CaHa): What about the subscriptions that could not be renewed?
128
+
129
+ async def _renew_data_change_subscriptions(self) -> None:
130
+ for node_id, monitored_item in list(self._data_subscription_map.items()):
103
131
  try:
104
- await self._create_subscription()
105
- # add all old data change subscriptions back
106
- for node_id, handlers in old_data_subscription_map.items():
107
- for handler in handlers:
108
- await self.subscribe_data_change(handler=handler, nodes=[self._ua_client.get_node(node_id)])
132
+ handle = await self._ua_subscription.subscribe_data_change(self._ua_client.get_node(node_id))
133
+ monitored_item.handle = handle
134
+ except UaStatusCodeError as err:
135
+ monitored_item.handle = None
136
+ self.logger.warning("Could not renew data change subscription", node=node_id.to_string(), reason=err)
137
+
138
+ async def _renew_event_subscriptions(self) -> None:
139
+ for node_id, monitored_item in list(self._event_subscription_map.items()):
140
+ try:
141
+ handle = await self._ua_subscription.subscribe_events(node_id)
142
+ monitored_item.handle = handle
143
+ except UaStatusCodeError as err:
144
+ monitored_item.handle = None
145
+ self.logger.warning("Could not renew event subscription", node=node_id.to_string(), reason=err)
109
146
 
110
- # add all old event subscriptions back
111
- for node_id, handlers in old_event_subscription_map.items():
112
- for handler in handlers:
113
- await self.subscribe_event(handler=handler, source_node=node_id)
147
+ async def _renew_subscriptions(self) -> None:
148
+ self.logger.info("Trying to renew subscriptions...", count=self.no_of_monitored_items)
149
+ await self._delete_subscription()
114
150
 
151
+ while True:
152
+ try:
153
+ await self._create_subscription()
154
+ await self._renew_data_change_subscriptions()
155
+ await self._renew_event_subscriptions()
156
+ self._last_heartbeat = time.monotonic()
115
157
  self.logger.info("Successfully renewed subscriptions")
116
158
  break
117
159
  except Exception as err:
118
160
  self.logger.warning("Could not renew subscriptions!", reason=err)
161
+ await self._delete_subscription()
119
162
  await asyncio.sleep(1)
120
163
 
121
- async def clear_subscriptions(self) -> None:
122
- self.logger.info(f"Clearing {self.no_of_monitored_items} subscriptions...")
164
+ def _invalidate_subscription_handles(self) -> None:
165
+ for item in self._data_subscription_map.values():
166
+ item.handle = None
167
+ for item in self._event_subscription_map.values():
168
+ item.handle = None
169
+
170
+ async def _delete_subscription(self) -> None:
171
+ """Best-effort deletion of the server-side subscription; the maps are left untouched."""
172
+ self._invalidate_subscription_handles()
123
173
  try:
124
174
  await self._ua_subscription.delete()
125
175
  self.logger.info("Deleted main OPC UA subscription!")
126
- except ConnectionError:
127
- pass # we can safely ignore it since is no longer relevant
128
- except AttributeError:
129
- pass # we can safely ignore it since there is no subscription to delete
176
+ except (ConnectionError, AttributeError):
177
+ pass # connection is gone or there is no subscription to delete
130
178
  except Exception as err:
131
179
  self.logger.exception("Could not delete main OPC UA subscription!", reason=err)
132
-
133
180
  with contextlib.suppress(NameError, AttributeError):
134
181
  del self._ua_subscription
135
182
 
183
+ async def clear_subscriptions(self) -> None:
184
+ """Delete the OPC UA subscription and forget all registered handlers."""
185
+ self.logger.info("Clearing subscriptions...", count=self.no_of_monitored_items)
186
+ await self._delete_subscription()
136
187
  self._data_subscription_map.clear()
137
188
  self._event_subscription_map.clear()
138
189
 
@@ -153,11 +204,12 @@ class SubscriptionManager:
153
204
  node = self._ua_client.get_node(node)
154
205
 
155
206
  if node.nodeid not in self._data_subscription_map:
156
- self._data_subscription_map[node.nodeid] = []
157
207
  try:
158
- await self._ua_subscription.subscribe_data_change(node)
159
- self._data_subscription_map[node.nodeid].append(handler)
160
- self.logger.debug("Added data change handler", handler=handler, node=node.nodeid.to_string())
208
+ handle = await self._ua_subscription.subscribe_data_change(node)
209
+ self._data_subscription_map[node.nodeid] = _MonitoredItem(handle=handle, handlers=[handler])
210
+ self.logger.debug(
211
+ "Added data change handler", handler=handler, node=node.nodeid.to_string(), handle=handle
212
+ )
161
213
  except BadAttributeIdInvalid:
162
214
  self.logger.warning("Node does not support subscription!", node=node.nodeid.to_string())
163
215
  except UaStatusCodeError as err:
@@ -169,7 +221,7 @@ class SubscriptionManager:
169
221
  handler=handler,
170
222
  node=node.nodeid.to_string(),
171
223
  )
172
- self._data_subscription_map[node.nodeid].append(handler)
224
+ self._data_subscription_map[node.nodeid].handlers.append(handler)
173
225
 
174
226
  async def subscribe_event(self, handler: UaEventSubscriber, source_node: Node | NodeId) -> None:
175
227
  """Add given handler to receive event notifications for given node."""
@@ -182,11 +234,57 @@ class SubscriptionManager:
182
234
  msg = f"Given source_node {source_node} is not supported!"
183
235
  raise TypeError(msg)
184
236
 
185
- if source_node not in self._event_subscription_map:
186
- self._event_subscription_map[node_id] = []
237
+ if node_id not in self._event_subscription_map:
238
+ handle = await self._ua_subscription.subscribe_events(sourcenode=source_node)
239
+ self._event_subscription_map[node_id] = _MonitoredItem(handle=handle, handlers=[handler])
240
+ self.logger.debug("Added event handler", handler=handler, node=node_id.to_string(), handle=handle)
241
+ else:
242
+ # We only need 1 subscription for n handlers.
243
+ self.logger.debug(
244
+ "Added event handler, reusing existing subscription.", handler=handler, node=node_id.to_string()
245
+ )
246
+ self._event_subscription_map[node_id].handlers.append(handler)
247
+
248
+ async def unsubscribe_data_change(
249
+ self, handler: UaDataChangeSubscriber, nodes: Node | NodeId | Iterable[Node | NodeId]
250
+ ) -> None:
251
+ """Remove handler for given nodes; removes server subscription once it has no handlers left."""
252
+ if not isinstance(nodes, Iterable):
253
+ nodes = [nodes]
254
+
255
+ for node in nodes:
256
+ node_id = node.nodeid if isinstance(node, Node) else node
257
+ await self._unsubscribe(handler=handler, node_id=node_id, subscription_map=self._data_subscription_map)
187
258
 
188
- await self._ua_subscription.subscribe_events(sourcenode=source_node)
189
- self._event_subscription_map[node_id].append(handler)
259
+ async def unsubscribe_event(self, handler: UaEventSubscriber, source_node: Node | NodeId) -> None:
260
+ """Remove handler for events of given node; removes server subscription once it has no handlers left."""
261
+ node_id = source_node.nodeid if isinstance(source_node, Node) else source_node
262
+ await self._unsubscribe(handler=handler, node_id=node_id, subscription_map=self._event_subscription_map)
263
+
264
+ async def _unsubscribe(
265
+ self,
266
+ *,
267
+ handler: _HandlerType,
268
+ node_id: NodeId,
269
+ subscription_map: dict[NodeId, _MonitoredItem[_HandlerType]],
270
+ ) -> None:
271
+ item = subscription_map.get(node_id)
272
+ if item is None or handler not in item.handlers:
273
+ return # nothing to do
274
+
275
+ item.handlers.remove(handler)
276
+
277
+ if len(item.handlers) > 0:
278
+ return # other handlers still need the monitored item
279
+
280
+ del subscription_map[node_id]
281
+ if item.handle is None:
282
+ return # nothing to unsubscribe
283
+
284
+ try:
285
+ await self._ua_subscription.unsubscribe(item.handle)
286
+ except (UaStatusCodeError, ConnectionError) as err:
287
+ self.logger.warning("Could not delete monitored item", node=node_id.to_string(), reason=err)
190
288
 
191
289
  async def _heartbeat_loop(self) -> Never:
192
290
  while True:
@@ -206,15 +304,20 @@ class SubscriptionManager:
206
304
  """Handle OPC UA data change notifications callback."""
207
305
  self._last_heartbeat = time.monotonic()
208
306
 
209
- try:
210
- for handler in self._data_subscription_map[node.nodeid]: # list of handlers
211
- if handler == self:
212
- continue # current time, not required for anything specific
213
- await handler.ua_on_data_change(node, val, data)
214
- except KeyError:
307
+ item = self._data_subscription_map.get(node.nodeid)
308
+ if item is None:
215
309
  self.logger.warning(
216
310
  "Received DataChange Notification with no handlers", key=node.nodeid.to_string(), value=val
217
311
  )
312
+ return
313
+
314
+ for handler in list(item.handlers): # copy, to guard against unsubscription during iteration
315
+ if handler is self:
316
+ continue # current time, not required for anything specific
317
+ try:
318
+ await handler.ua_on_data_change(node, val, data)
319
+ except Exception as err:
320
+ self.logger.warning("Data change handler failed", handler=handler, reason=err)
218
321
 
219
322
  async def status_change_notification(self, status: ua.StatusChangeNotification) -> None:
220
323
  """Handle OPC UA status notifications callback."""
@@ -226,8 +329,13 @@ class SubscriptionManager:
226
329
 
227
330
  # TODO(CaHa): ua_event.emitting_node is always i=2253 (=Server)
228
331
  # might be an asyncua problem
229
- try:
230
- for handler in self._event_subscription_map[ua_event.SourceNode]:
231
- await handler.ua_on_event(ua_event)
232
- except KeyError:
332
+ item = self._event_subscription_map.get(ua_event.SourceNode)
333
+ if item is None:
233
334
  self.logger.warning("Received Event Notification with no handlers", ua_event=ua_event)
335
+ return
336
+
337
+ for handler in list(item.handlers): # copy, to guard against unsubscription during iteration
338
+ try:
339
+ await handler.ua_on_event(ua_event)
340
+ except Exception as err:
341
+ self.logger.warning("Event handler failed", handler=handler, reason=err)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes