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.
- opensmi/core/__init__.py +21 -0
- opensmi/core/async_task_mixin.py +55 -0
- opensmi/core/base_server.py +75 -0
- opensmi/core/base_ua_object.py +120 -0
- opensmi/core/config.py +92 -0
- opensmi/core/enums.py +24 -0
- opensmi/core/errors.py +67 -0
- opensmi/core/frozen.py +26 -0
- opensmi/core/hook.py +22 -0
- opensmi/core/lifecycle_mixin.py +275 -0
- opensmi/core/logging.py +161 -0
- opensmi/core/meta.py +51 -0
- opensmi/core/mixin_helpers.py +19 -0
- opensmi/core/protocols.py +32 -0
- opensmi/core/repr_mixin.py +33 -0
- opensmi/core/signal.py +36 -0
- opensmi/core/subscription_manager.py +190 -0
- opensmi/core/ua.py +17 -0
- opensmi/core/ua_enums.py +93 -0
- opensmi/core/ua_node_util.py +239 -0
- opensmi/core/unit_all.py +2187 -0
- opensmi/core/unit_base.py +71 -0
- opensmi/core/unset.py +22 -0
- opensmi_core-0.1.dist-info/METADATA +32 -0
- opensmi_core-0.1.dist-info/RECORD +28 -0
- opensmi_core-0.1.dist-info/WHEEL +5 -0
- opensmi_core-0.1.dist-info/licenses/LICENSE +18 -0
- opensmi_core-0.1.dist-info/top_level.txt +1 -0
|
@@ -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
|
opensmi/core/ua_enums.py
ADDED
|
@@ -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
|