OpenSMI-Core 0.1__py3-none-any.whl

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.
@@ -0,0 +1,190 @@
1
+ # SPDX-FileCopyrightText: 2026 OpenSMI Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ from __future__ import annotations
6
+
7
+ import contextlib
8
+ from abc import ABC, abstractmethod
9
+ from collections.abc import Iterable
10
+ from typing import Any
11
+
12
+ import structlog
13
+ from asyncua import ua
14
+ from asyncua.client.client import Client
15
+ from asyncua.common.node import Node
16
+ from asyncua.common.subscription import DataChangeNotif, Subscription
17
+ from asyncua.ua import NodeId, UaStatusCodeError
18
+ from asyncua.ua.uaerrors import BadAttributeIdInvalid
19
+
20
+ from opensmi.core.protocols import UaEvent
21
+
22
+
23
+ class UaDataChangeSubscriber(ABC):
24
+ """Interface for OPC UA data change subscribers."""
25
+
26
+ @abstractmethod
27
+ async def ua_on_data_change(self, node: Node, val: Any, data: DataChangeNotif) -> None:
28
+ """Handle OPC UA node data change notification."""
29
+
30
+
31
+ class UaEventSubscriber(ABC):
32
+ """Interface for OPC UA event subscribers."""
33
+
34
+ @abstractmethod
35
+ async def ua_on_event(self, ua_event: UaEvent) -> None:
36
+ """Handle OPC UA event notification."""
37
+
38
+
39
+ class SubscriptionManager:
40
+ """Centrally manages all OPC UA subscription needs.
41
+
42
+ It bundles subscriptions using only one subscription to an OPC UA server
43
+ to not stay within max. subscription limits of certain OPC UA servers.
44
+ """
45
+
46
+ _ua_client: Client
47
+ _ua_subscription: Subscription
48
+
49
+ def __init__(self) -> None:
50
+ """Create a new instance."""
51
+ self._data_subscription_map: dict[NodeId, list[UaDataChangeSubscriber]] = {}
52
+ """ Maps a OPC UA node to a list of handlers for data change notifications. """
53
+ self._event_subscription_map: dict[NodeId, list[UaEventSubscriber]] = {}
54
+ """ Maps a OPC UA node to a list of handlers for event notifications. """
55
+
56
+ self.period: float = 100
57
+ """Publishing interval in milliseconds for OPC UA subscription."""
58
+
59
+ self.logger = structlog.getLogger("opensmi.SubscriptionManager")
60
+
61
+ async def _create_subscription(self, ua_client: Client):
62
+ self._ua_subscription = await ua_client.create_subscription(period=self.period, handler=self)
63
+ self.logger.info("Created subscription!", parameters=self._ua_subscription.parameters)
64
+
65
+ async def init(self, ua_client: Client, period: float = 100) -> None:
66
+ self.period = period
67
+ self._ua_client = ua_client
68
+ await self._create_subscription(ua_client)
69
+
70
+ # subscribe to a node that always changes reliably, for example the current time of the server
71
+ # this is done to prevent the subscription from timing out
72
+ current_time_node = ua_client.get_node(ua.object_ids.ObjectIds.Server_ServerStatus_CurrentTime)
73
+ await self.subscribe_data_change(self, current_time_node) # pyright: ignore[reportArgumentType]
74
+
75
+ # async def renew_subscriptions(self, ua_client: Client) -> None:
76
+ # self.logger.info(f"Renewing {self.no_of_monitored_items} subscriptions...")
77
+ # old_data_subscription_map = self._data_subscription_map.copy()
78
+ # old_event_subscription_map = self._event_subscription_map.copy()
79
+ #
80
+ # await self.clear_subscriptions()
81
+ # await self._create_subscription(ua_client)
82
+ #
83
+ # # add all old data change subscriptions back
84
+ # for node_id, handlers in old_data_subscription_map.items():
85
+ # for handler in handlers:
86
+ # await self.subscribe_data_change(handler=handler, nodes=[ua_client.get_node(node_id)])
87
+ #
88
+ # # add all old event subscriptions back
89
+ # for node_id, handlers in old_event_subscription_map.items():
90
+ # for handler in handlers:
91
+ # await self.subscribe_event(handler=handler, source_node=node_id)
92
+
93
+ async def clear_subscriptions(self) -> None:
94
+ self.logger.info(f"Clearing {self.no_of_monitored_items} subscriptions...")
95
+ try:
96
+ await self._ua_subscription.delete()
97
+ self.logger.info("Deleted main OPC UA subscription!")
98
+ except ConnectionError:
99
+ pass # we can safely ignore it since is no longer relevant
100
+ except AttributeError:
101
+ pass # we can safely ignore it since there is no subscription to delete
102
+ except Exception as err:
103
+ self.logger.exception("Could not delete main OPC UA subscription!", reason=err)
104
+
105
+ with contextlib.suppress(NameError, AttributeError):
106
+ del self._ua_subscription
107
+
108
+ self._data_subscription_map.clear()
109
+ self._event_subscription_map.clear()
110
+
111
+ @property
112
+ def no_of_monitored_items(self) -> int:
113
+ """Return the number of monitored nodes."""
114
+ return len(self._data_subscription_map) + len(self._event_subscription_map)
115
+
116
+ async def subscribe_data_change(
117
+ self, handler: UaDataChangeSubscriber, nodes: Node | NodeId | Iterable[Node | NodeId]
118
+ ) -> None:
119
+ """Add given handler to receive data change notifications for given nodes."""
120
+ if not isinstance(nodes, Iterable):
121
+ nodes = [nodes] # create a list out of the given single node or node id
122
+
123
+ for node in nodes:
124
+ if isinstance(node, NodeId):
125
+ node = self._ua_client.get_node(node)
126
+
127
+ if node.nodeid not in self._data_subscription_map:
128
+ self._data_subscription_map[node.nodeid] = []
129
+ try:
130
+ await self._ua_subscription.subscribe_data_change(node)
131
+ self._data_subscription_map[node.nodeid].append(handler)
132
+ self.logger.debug("Added data change handler", handler=handler, node=node.nodeid.to_string())
133
+ except BadAttributeIdInvalid:
134
+ self.logger.warning("Node does not support subscription!", node=node.nodeid.to_string())
135
+ except UaStatusCodeError as err:
136
+ self.logger.exception("Unexpected UA error!", err=err)
137
+ else:
138
+ # We only need 1 subscription for n handlers.
139
+ self.logger.debug(
140
+ "Added data change handler, reusing existing subscription.",
141
+ handler=handler,
142
+ node=node.nodeid.to_string(),
143
+ )
144
+ self._data_subscription_map[node.nodeid].append(handler)
145
+
146
+ async def subscribe_event(self, handler: UaEventSubscriber, source_node: Node | NodeId) -> None:
147
+ """Add given handler to receive event notifications for given node."""
148
+ # we only get the node ID in the notification
149
+ if isinstance(source_node, Node):
150
+ node_id: NodeId = source_node.nodeid
151
+ elif isinstance(source_node, NodeId):
152
+ node_id = source_node
153
+ else:
154
+ msg = f"Given source_node {source_node} is not supported!"
155
+ raise TypeError(msg)
156
+
157
+ if source_node not in self._event_subscription_map:
158
+ self._event_subscription_map[node_id] = []
159
+
160
+ await self._ua_subscription.subscribe_events(sourcenode=source_node)
161
+ self._event_subscription_map[node_id].append(handler)
162
+
163
+ ###########################################
164
+ # asyncua callbacks
165
+
166
+ async def datachange_notification(self, node: Node, val: Any, data: DataChangeNotif) -> None:
167
+ """Handle OPC UA data change notifications callback."""
168
+ try:
169
+ for handler in self._data_subscription_map[node.nodeid]: # list of handlers
170
+ if handler == self:
171
+ continue # current time, not required for anything specific
172
+ await handler.ua_on_data_change(node, val, data)
173
+ except KeyError:
174
+ self.logger.warning(
175
+ "Received DataChange Notification with no handlers", key=node.nodeid.to_string(), value=val
176
+ )
177
+
178
+ async def status_change_notification(self, status: ua.StatusChangeNotification) -> None:
179
+ """Handle OPC UA status notifications callback."""
180
+ self.logger.info("Received status change notification", status=status)
181
+
182
+ async def event_notification(self, ua_event: UaEvent) -> None:
183
+ """Handle OPC UA event notifications callback."""
184
+ # TODO(CaHa): ua_event.emitting_node is always i=2253 (=Server)
185
+ # might be an asyncua problem
186
+ try:
187
+ for handler in self._event_subscription_map[ua_event.SourceNode]:
188
+ await handler.ua_on_event(ua_event)
189
+ except KeyError:
190
+ self.logger.warning("Received Event Notification with no handlers", ua_event=ua_event)
opensmi/core/ua.py ADDED
@@ -0,0 +1,17 @@
1
+ # SPDX-FileCopyrightText: 2026 OpenSMI Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """OPC UA helper stuff."""
6
+
7
+ from dataclasses import dataclass
8
+
9
+ from asyncua import ua
10
+
11
+
12
+ @dataclass(frozen=True, slots=True, eq=True)
13
+ class NodeIdDefinition:
14
+ """Definition of an unresolved OPC UA node."""
15
+
16
+ ns_uri: str
17
+ id: ua.Int32
@@ -0,0 +1,93 @@
1
+ # SPDX-FileCopyrightText: 2026 OpenSMI Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """Common enumerations with OPC UA LocalizedText representation."""
6
+
7
+ from enum import Enum
8
+
9
+ from asyncua import ua
10
+
11
+
12
+ class LocalizedTextEnum(Enum):
13
+ """Enum that return their str values in form of an OPC UA LocalizedText."""
14
+
15
+ @property
16
+ def localized_text(self) -> ua.LocalizedText:
17
+ """Return the OPC UA localized text of the enum member."""
18
+ return ua.LocalizedText(Text=self.value, Locale="en-US")
19
+
20
+
21
+ class SkillState(Enum):
22
+ """Represents the state of a skill (`BaseSkill`).
23
+
24
+ State values according to the skill node-set specification.
25
+ """
26
+
27
+ HALTED = (11, "Halted")
28
+ """State indicating that the skill is halted (=stopped). To leave this state, the skill needs to be reset.
29
+ **Note**: Skills can halt for various reasons, e.g. internal failures, external safety trigger, etc."""
30
+ READY = (12, "Ready")
31
+ """State indicating that the skill is ready to be started."""
32
+ RUNNING = (13, "Running")
33
+ """State indicating that the skill is running."""
34
+ SUSPENDED = (14, "Suspended")
35
+ """State indicating that the skill is suspended."""
36
+ COMPLETED = (15, "Completed")
37
+ """State indicating that the skill is completed.
38
+ Only used for finite skills, because continuous skills do not complete."""
39
+
40
+ STARTING = (16, "Starting")
41
+ """Intermediate state leading to `SkillState.RUNNING` on success or `SkillState.HALTING` on failure."""
42
+ HALTING = (17, "Halting")
43
+ """Intermediate state always leading to `SkillState.HALTED`."""
44
+ COMPLETING = (18, "Completing")
45
+ """Intermediate state leading to `SkillState.COMPLETED` on success or `SkillState.HALTING` on failure.
46
+ Only used for finite skills, because continuous skills do not complete."""
47
+ RESETTING = (19, "Resetting")
48
+ """Intermediate state leading to `SkillState.READY` on success or `SkillState.HALTING` on failure."""
49
+ SUSPENDING = (20, "Suspending")
50
+ """Intermediate state leading to `SkillState.SUSPENDING` on success or `SkillState.HALTING` on failure."""
51
+
52
+ @property
53
+ def localized_text(self) -> ua.LocalizedText:
54
+ """Return the OPC UA localized text of the enum member."""
55
+ return ua.LocalizedText(Text=self.value[1], Locale="en-US")
56
+
57
+ @property
58
+ def id(self) -> int:
59
+ """Return the state ID number of the enum member."""
60
+ return self.value[0]
61
+
62
+
63
+ class MachineryItemState(LocalizedTextEnum):
64
+ """Represents the state of a machinery item (`BaseMachineryItem`).
65
+
66
+ Documentation from https://reference.opcfoundation.org/Machinery/v103/docs/12.2
67
+ """
68
+
69
+ NOT_AVAILABLE = "NotAvailable"
70
+ """The machine is not available and does not perform any activity (e.g., switched off, in energy saving mode)"""
71
+ OUT_OF_SERVICE = "OutOfService"
72
+ """The machine is not functional and does not perform any activity (e.g., error, blocked)"""
73
+ NOT_EXECUTING = "NotExecuting"
74
+ """The machine is available & functional and does not perform any activity.
75
+ It waits for an action from outside to start or restart an activity."""
76
+ EXECUTING = "Executing"
77
+ """The machine is available & functional and is actively performing an activity (pursues a purpose)."""
78
+
79
+
80
+ class MachineryOperationMode(LocalizedTextEnum):
81
+ """Represents the operation mode of a machinery item (`BaseMachineryItem`).
82
+
83
+ Documentation from https://reference.opcfoundation.org/Machinery/v103/docs/13.2
84
+ """
85
+
86
+ NONE = "None"
87
+ """No machinery operation mode available."""
88
+ MAINTENANCE = "Maintenance"
89
+ """Mode with the intention to carry out maintenance or servicing activities."""
90
+ SETUP = "Setup"
91
+ """Mode with the intention to carry out setup, preparation or postprocessing activities of a production process."""
92
+ PROCESSING = "Processing"
93
+ """Mode with the intention to carry out the value adding activities."""
@@ -0,0 +1,239 @@
1
+ # SPDX-FileCopyrightText: 2026 OpenSMI Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """Utility functions for ``ua.Node``."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ import structlog.stdlib
14
+ from asyncua import ua
15
+ from asyncua.common.node import Node
16
+ from asyncua.ua import QualifiedName
17
+ from asyncua.ua.uaerrors import BadTypeMismatch
18
+
19
+ from opensmi.core import Unit
20
+
21
+ NULL_NODE_ID = ua.NodeId()
22
+ """ This node id indicates an invalid Node ID and should be treated as 'None'. """
23
+
24
+
25
+ async def read_value(node: Node) -> Any:
26
+ """Read the value of the given node and transforms the NULL_NODE_ID into None."""
27
+ value = await node.read_value()
28
+ if isinstance(value, ua.NodeId) and value.is_null():
29
+ return None
30
+ return value
31
+
32
+
33
+ # async def read_all_variables(location: Node) -> dict[str, Any]:
34
+ # """Return all variables of the given node in dictionary form."""
35
+ # assert location is not None
36
+ #
37
+ # ret = {}
38
+ # for node in await location.get_children():
39
+ # name, value = await asyncio.gather(node.read_browse_name(), node.read_value())
40
+ # ret[str(name.Name)] = value
41
+ # return ret
42
+
43
+
44
+ async def _write_value(node: Node, value: Any, *, variant_type: ua.VariantType) -> None:
45
+ # convert node id string to NodeId, this is not done automatically by asyncua
46
+ if variant_type == ua.VariantType.NodeId and isinstance(value, str):
47
+ if value == "":
48
+ await node.write_value(NULL_NODE_ID)
49
+ else:
50
+ await node.write_value(ua.NodeId.from_string(value))
51
+ elif variant_type == ua.VariantType.NodeId and value is None:
52
+ await node.write_value(NULL_NODE_ID) # we cannot write None here, it needs to be an (invalid) node id instead!
53
+ elif variant_type == ua.VariantType.LocalizedText and isinstance(value, str):
54
+ # just writing the raw string does not fail immediately in asyncua, but later when a client tries to read it.
55
+ await node.write_value(ua.LocalizedText(value, "en-US"))
56
+ else:
57
+ await node.write_value(value, variant_type)
58
+
59
+
60
+ async def write_value(node: Node, value: Any, *, variant_type: ua.VariantType | None = None) -> None:
61
+ """Write the given value to the given node. Will try to use existing variant type of the node."""
62
+ # Note: asyncua is very strict, e.g. float is not automatically cast to double and trying to write that will fail!
63
+ try:
64
+ if isinstance(value, ua.Variant):
65
+ await _write_value(node, value, variant_type=value.VariantType)
66
+ if variant_type is not None:
67
+ await _write_value(node, value, variant_type=variant_type)
68
+ else:
69
+ # we need to figure out the correct variant type to use ourselves
70
+ data_value_value = (await node.read_data_value()).Value
71
+ if data_value_value is None:
72
+ await node.write_value(value) # try without variant type, might fail
73
+ else:
74
+ await _write_value(node, value, variant_type=data_value_value.VariantType)
75
+ except BadTypeMismatch as err: # the exception has 0 useful information on its own
76
+ expected_variant_type = await node.read_data_type_as_variant_type()
77
+ err.add_note(f"{node=}")
78
+ err.add_note(f"Expected variant_type={expected_variant_type!r}")
79
+ err.add_note(f"Given {value=!r} ; {variant_type=!r}")
80
+ raise
81
+
82
+
83
+ # async def get_all_variables_with_browse_name(node: Node) -> dict[str, Node]:
84
+ # """Gather all child variables for given node and store them in a dict."""
85
+ # node_dict = {}
86
+ # for child in await node.get_variables():
87
+ # node_dict[str((await child.read_browse_name()).Name)] = child
88
+ # return node_dict
89
+
90
+
91
+ @dataclass(slots=True)
92
+ class PropertiesResult:
93
+ """Results of ``get_unit_and_range()``."""
94
+
95
+ unit: ua.EUInformation | None = None
96
+ range: ua.Range | None = None
97
+
98
+
99
+ async def get_properties(node: Node, *, logger: structlog.stdlib.BoundLogger | None = None) -> PropertiesResult:
100
+ """Retrieve the supported OPC UA properties of the given node."""
101
+ assert node is not None, "No node given!"
102
+ result = PropertiesResult()
103
+
104
+ for property_node in await node.get_properties():
105
+ property_browse_name_data_value, property_value_data_value = await property_node.read_attributes(
106
+ [ua.attribute_ids.AttributeIds.BrowseName, ua.attribute_ids.AttributeIds.Value]
107
+ )
108
+ property_name: str = property_browse_name_data_value.Value.Value.Name # type: ignore
109
+ property_value = property_value_data_value.Value.Value # type: ignore
110
+
111
+ if property_name == "EngineeringUnits" and isinstance(property_value, ua.EUInformation):
112
+ if logger is not None:
113
+ logger.debug("Found EngineeringUnits", unit=property_value)
114
+ result.unit = property_value
115
+ elif property_name == "EURange" and isinstance(property_value, ua.Range):
116
+ if logger is not None:
117
+ logger.debug("Found EURange", range=property_value)
118
+ result.range = property_value
119
+ elif logger is not None:
120
+ logger.warning(
121
+ "Found unsupported property, ignoring!",
122
+ ua_property_node_id=property_node.nodeid.to_string(),
123
+ ua_node_id=node.nodeid.to_string(),
124
+ )
125
+
126
+ return result
127
+
128
+
129
+ async def get_type_definition(ua_node: Node) -> str:
130
+ """Return the browse name of the OPC UA type definition of the given node."""
131
+ try:
132
+ type_definition_list = await ua_node.get_references(
133
+ ua.object_ids.ObjectIds.HasTypeDefinition,
134
+ ua.BrowseDirection.Forward,
135
+ )
136
+ return type_definition_list[0].BrowseName.Name
137
+ except Exception as ex:
138
+ msg = f"Could not find type definition for node {ua_node.nodeid.to_string()}!"
139
+ raise RuntimeError(msg) from ex
140
+
141
+
142
+ def get_node_id(
143
+ ua_location: Node | ua.NodeId,
144
+ *,
145
+ name: QualifiedName | str,
146
+ ns_idx: int | None = 1,
147
+ ) -> ua.NodeId:
148
+ """Return a (new) Node ID in the standard hierarchical format based on given parameters.
149
+
150
+ :param ua_location: OPC UA parent node in the hierarchy
151
+ :param name: (Browse)Name of the new Node ID.
152
+ :param ns_idx: (optional) OPC UA namespace index, if ``None``, the namespace index of the location node
153
+ id will be used.
154
+ """
155
+ assert ua_location is not None, "No location node given!"
156
+ if isinstance(ua_location, Node):
157
+ ua_location = ua_location.nodeid
158
+ assert isinstance(ua_location, ua.NodeId)
159
+
160
+ if ns_idx is None:
161
+ ns_idx = ua_location.NamespaceIndex
162
+
163
+ if isinstance(name, QualifiedName):
164
+ name = name.Name
165
+
166
+ if isinstance(ua_location.Identifier, str):
167
+ identifier = ua.String(f"{ua_location.Identifier!s}.{name}")
168
+ else:
169
+ identifier = ua.String(name)
170
+
171
+ return ua.NodeId(Identifier=identifier, NamespaceIndex=ua.Int16(ns_idx))
172
+
173
+
174
+ async def get_child_without_ns(parent: Node, *, display_name: str) -> Node:
175
+ """Return the first child node with given display name while ignoring the namespace index.
176
+
177
+ :raises BadNoMatch: If no child with given display name exists.
178
+ """
179
+ for child in await parent.get_children():
180
+ child_display_name = (await child.read_display_name()).Text
181
+ # print(parent.nodeid.to_string(), display_name, child_display_name)
182
+ if child_display_name == display_name:
183
+ return child
184
+ raise ua.uaerrors.BadNoMatch
185
+
186
+
187
+ async def write_range(node: Node, range_: tuple[float, float] | ua.Range) -> None:
188
+ """Write given ``range`` to given ``node``."""
189
+ assert node is not None, "No node given!"
190
+ assert range_ is not None, "No range given!"
191
+
192
+ if not isinstance(range_, ua.Range):
193
+ range_ = ua.Range(*range_)
194
+
195
+ ua_range_node = await node.get_child("0:EURange")
196
+ await ua_range_node.write_value(range_)
197
+
198
+
199
+ async def write_unit(
200
+ ua_node: Node, unit: Unit | str | int | ua.EUInformation, *, engineering_unit: str = "EngineeringUnits"
201
+ ) -> None:
202
+ """Write given ``unit`` to given ``node``."""
203
+ assert ua_node is not None, "No node given!"
204
+ assert unit is not None, "No unit given!"
205
+
206
+ if isinstance(unit, Unit):
207
+ unit = unit.ua_eu_information
208
+ elif isinstance(unit, str):
209
+ if unit == "%": # backwards compatibility
210
+ unit = "pct"
211
+ unit = Unit.from_symbol(unit).ua_eu_information
212
+ elif isinstance(unit, int):
213
+ unit = Unit.from_ua_unit_id(unit).ua_eu_information
214
+
215
+ assert isinstance(unit, ua.EUInformation)
216
+ ua_engineering_unit_node = await ua_node.get_child(f"0:{engineering_unit}")
217
+ await ua_engineering_unit_node.write_value(unit)
218
+
219
+
220
+ async def read_unit(ua_node: Node, *, engineering_unit: str = "EngineeringUnits") -> ua.EUInformation:
221
+ """Read the engineering unit from the given node."""
222
+ assert ua_node is not None, "No node given!"
223
+
224
+ ua_engineering_unit_node = await ua_node.get_child(f"0:{engineering_unit}")
225
+ unit = await ua_engineering_unit_node.read_value()
226
+
227
+ assert isinstance(unit, ua.EUInformation), f"Expected EUInformation, got {type(unit).__name__}"
228
+
229
+ return unit
230
+
231
+
232
+ async def get_children_browse_names(ua_node: Node) -> Mapping[Node, ua.QualifiedName]:
233
+ """Return a mapping from child nodes to qualified names."""
234
+ result = {}
235
+ for ref in await ua_node.get_children_descriptions():
236
+ node = Node(session=ua_node.session, nodeid=ref.NodeId)
237
+ result[node] = ref.BrowseName
238
+
239
+ return result