OpenSMI-Core 0.5__tar.gz → 0.7__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.
- opensmi_core-0.7/.github/workflows/checks.yml +72 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/.github/workflows/python-publish.yml +6 -1
- {opensmi_core-0.5 → opensmi_core-0.7}/.gitignore +2 -1
- {opensmi_core-0.5 → opensmi_core-0.7}/PKG-INFO +1 -1
- {opensmi_core-0.5 → opensmi_core-0.7}/pyproject.toml +14 -5
- {opensmi_core-0.5 → opensmi_core-0.7}/src/OpenSMI_Core.egg-info/PKG-INFO +1 -1
- {opensmi_core-0.5 → opensmi_core-0.7}/src/OpenSMI_Core.egg-info/SOURCES.txt +23 -2
- {opensmi_core-0.5 → opensmi_core-0.7}/src/OpenSMI_Core.egg-info/scm_file_list.json +23 -2
- opensmi_core-0.7/src/OpenSMI_Core.egg-info/scm_version.json +8 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/src/opensmi/core/__init__.py +5 -0
- opensmi_core-0.7/src/opensmi/core/base_server.py +181 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/src/opensmi/core/base_ua_object.py +79 -12
- opensmi_core-0.7/src/opensmi/core/nodesets/Opc.Ua.Di.NodeSet2.xml +5719 -0
- opensmi_core-0.7/src/opensmi/core/nodesets/Opc.Ua.Machinery.NodeSet2.xml +1713 -0
- opensmi_core-0.7/src/opensmi/core/nodesets/Opc.Ua.RSL.NodeSet2.xml +601 -0
- opensmi_core-0.7/src/opensmi/core/nodesets/Opc.Ua.SmartFactory.MachineSet.NodeSet2.xml +2886 -0
- opensmi_core-0.7/src/opensmi/core/nodesets/Opc.Ua.SmartFactory.SkillSet.NodeSet2.xml +856 -0
- opensmi_core-0.7/src/opensmi/core/nodesets/__init__.py +26 -0
- opensmi_core-0.7/src/opensmi/core/nodesets/di.py +73 -0
- opensmi_core-0.7/src/opensmi/core/nodesets/machinery.py +35 -0
- opensmi_core-0.7/src/opensmi/core/nodesets/rsl.py +30 -0
- opensmi_core-0.7/src/opensmi/core/nodesets/smart_factory_machine_set.py +51 -0
- opensmi_core-0.7/src/opensmi/core/nodesets/smart_factory_skill_set.py +31 -0
- opensmi_core-0.7/src/opensmi/core/paths.py +216 -0
- opensmi_core-0.7/src/opensmi/core/rsl.py +271 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/src/opensmi/core/subscription_manager.py +43 -8
- opensmi_core-0.7/src/opensmi/core/ua.py +59 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/src/opensmi/core/ua_enums.py +1 -1
- {opensmi_core-0.5 → opensmi_core-0.7}/src/opensmi/core/ua_node_util.py +4 -2
- opensmi_core-0.7/tests/test_nodesets.py +48 -0
- opensmi_core-0.7/tests/test_object_path.py +268 -0
- opensmi_core-0.7/tests/test_paths.py +110 -0
- opensmi_core-0.7/tests/test_resolve.py +296 -0
- opensmi_core-0.7/tests/test_rsl.py +495 -0
- opensmi_core-0.7/tests/test_ua_values.py +268 -0
- opensmi_core-0.7/tools/auto_generate_nodesets.py +220 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/tools/auto_generate_units.py +3 -0
- opensmi_core-0.7/tools/nodeset.py.template +25 -0
- opensmi_core-0.5/.gitlab-ci.yml +0 -49
- opensmi_core-0.5/src/OpenSMI_Core.egg-info/scm_version.json +0 -8
- opensmi_core-0.5/src/opensmi/core/base_server.py +0 -75
- opensmi_core-0.5/src/opensmi/core/ua.py +0 -17
- {opensmi_core-0.5 → opensmi_core-0.7}/.github/workflows/api-docs.yml +0 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/LICENSE +0 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/LICENSES/MIT.txt +0 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/README.md +0 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/setup.cfg +0 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/src/OpenSMI_Core.egg-info/dependency_links.txt +0 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/src/OpenSMI_Core.egg-info/requires.txt +0 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/src/OpenSMI_Core.egg-info/top_level.txt +0 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/src/opensmi/core/async_task_mixin.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/src/opensmi/core/config.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/src/opensmi/core/enums.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/src/opensmi/core/errors.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/src/opensmi/core/frozen.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/src/opensmi/core/hook.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/src/opensmi/core/lifecycle_mixin.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/src/opensmi/core/logging.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/src/opensmi/core/meta.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/src/opensmi/core/mixin_helpers.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/src/opensmi/core/protocols.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/src/opensmi/core/repr_mixin.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/src/opensmi/core/signal.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/src/opensmi/core/unit_all.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/src/opensmi/core/unit_base.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/src/opensmi/core/unset.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/tests/test_signal.py +0 -0
- {opensmi_core-0.5 → opensmi_core-0.7}/tests/test_unit.py +0 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
name: Checks
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ main ]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_call: # lets the publishing workflow reuse this one
|
|
8
|
+
secrets:
|
|
9
|
+
CODECOV_TOKEN:
|
|
10
|
+
required: false
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
lint:
|
|
17
|
+
name: Ruff and basedpyright
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
|
+
|
|
23
|
+
- uses: astral-sh/setup-uv@v7
|
|
24
|
+
with:
|
|
25
|
+
python-version: "3.12"
|
|
26
|
+
enable-cache: true
|
|
27
|
+
|
|
28
|
+
- name: Install dependencies
|
|
29
|
+
run: uv sync --group dev --all-extras
|
|
30
|
+
|
|
31
|
+
# - name: Ruff lint
|
|
32
|
+
# run: uv run ruff check .
|
|
33
|
+
|
|
34
|
+
- name: Ruff format check
|
|
35
|
+
run: uv run ruff format --check .
|
|
36
|
+
|
|
37
|
+
- name: basedpyright
|
|
38
|
+
run: uv run basedpyright
|
|
39
|
+
|
|
40
|
+
test:
|
|
41
|
+
runs-on: ubuntu-latest
|
|
42
|
+
strategy:
|
|
43
|
+
fail-fast: false
|
|
44
|
+
matrix:
|
|
45
|
+
python-version: [ "3.11", "3.12", "3.13", "3.14" ]
|
|
46
|
+
|
|
47
|
+
steps:
|
|
48
|
+
- uses: actions/checkout@v4
|
|
49
|
+
|
|
50
|
+
- uses: astral-sh/setup-uv@v7
|
|
51
|
+
with:
|
|
52
|
+
python-version: ${{ matrix.python-version }}
|
|
53
|
+
enable-cache: true
|
|
54
|
+
|
|
55
|
+
- name: Install dependencies
|
|
56
|
+
run: uv sync --group dev
|
|
57
|
+
|
|
58
|
+
- name: Run tests with coverage
|
|
59
|
+
run: >
|
|
60
|
+
uv run pytest
|
|
61
|
+
-n auto
|
|
62
|
+
--cov
|
|
63
|
+
--cov-report=term-missing
|
|
64
|
+
--cov-report=xml
|
|
65
|
+
|
|
66
|
+
- name: Upload coverage to Codecov
|
|
67
|
+
uses: codecov/codecov-action@v5
|
|
68
|
+
with:
|
|
69
|
+
token: ${{ secrets.CODECOV_TOKEN }}
|
|
70
|
+
files: coverage.xml
|
|
71
|
+
flags: py${{ matrix.python-version }}
|
|
72
|
+
fail_ci_if_error: false # don't block on Codecov outages or fork PRs without the token
|
|
@@ -10,13 +10,18 @@ name: Upload Python Package
|
|
|
10
10
|
|
|
11
11
|
on:
|
|
12
12
|
release:
|
|
13
|
-
types: [published]
|
|
13
|
+
types: [ published ]
|
|
14
14
|
|
|
15
15
|
permissions:
|
|
16
16
|
contents: read
|
|
17
17
|
|
|
18
18
|
jobs:
|
|
19
|
+
checks:
|
|
20
|
+
uses: ./.github/workflows/checks.yml
|
|
21
|
+
secrets: inherit # passes CODECOV_TOKEN through
|
|
22
|
+
|
|
19
23
|
release-build:
|
|
24
|
+
needs: [ checks ]
|
|
20
25
|
runs-on: ubuntu-latest
|
|
21
26
|
|
|
22
27
|
steps:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: OpenSMI-Core
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7
|
|
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
|
|
@@ -61,7 +61,9 @@ dev = [
|
|
|
61
61
|
"pytest-xdist>=3",
|
|
62
62
|
"pytest-html>=4",
|
|
63
63
|
"pytest-timeout>=2",
|
|
64
|
-
"openpyxl"
|
|
64
|
+
"openpyxl", # to load UNECE Excel to autogenerate units
|
|
65
|
+
"scipy~=1.17.1", # for testing, serves as reference implementation. newer versions dropped Python 3.11
|
|
66
|
+
"numpy~=2.4.6" # for scipy, newer versions dropped Python 3.11
|
|
65
67
|
]
|
|
66
68
|
|
|
67
69
|
[tool.setuptools]
|
|
@@ -72,6 +74,9 @@ where = ["src"]
|
|
|
72
74
|
namespaces = true
|
|
73
75
|
# exclude = ["tests", "doc*", "examples"]
|
|
74
76
|
|
|
77
|
+
[tool.setuptools.package-data]
|
|
78
|
+
"opensmi.core.nodesets" = ["*.NodeSet2.xml"]
|
|
79
|
+
|
|
75
80
|
[tool.ruff]
|
|
76
81
|
# Exclude a variety of commonly ignored directories.
|
|
77
82
|
exclude = [
|
|
@@ -133,10 +138,10 @@ author = "OpenSMI Contributors"
|
|
|
133
138
|
|
|
134
139
|
[tool.ruff.lint.per-file-ignores]
|
|
135
140
|
"tests/**" = ["ANN", "D", "T", "SLF001"]
|
|
136
|
-
"src/opensmi/core/unit_all.py" = [
|
|
137
|
-
"E501",
|
|
138
|
-
"RUF001",
|
|
139
|
-
"CPY",
|
|
141
|
+
"src/opensmi/core/unit_all.py" = [# auto-generated
|
|
142
|
+
"E501", # line too long
|
|
143
|
+
"RUF001", # String contains ambiguous ~
|
|
144
|
+
"CPY", # copyright
|
|
140
145
|
]
|
|
141
146
|
|
|
142
147
|
[tool.ruff.format]
|
|
@@ -180,3 +185,7 @@ theme = "readthedocs"
|
|
|
180
185
|
# verbose = 1
|
|
181
186
|
# warnings-as-errors = true
|
|
182
187
|
intersphinx = ["https://opcua-asyncio.readthedocs.io/en/latest/objects.inv"] # TODO(CaHa): does not work
|
|
188
|
+
|
|
189
|
+
[tool.coverage.run]
|
|
190
|
+
source = ["src/opensmi/core"]
|
|
191
|
+
branch = true
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: OpenSMI-Core
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7
|
|
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,9 +1,9 @@
|
|
|
1
1
|
.gitignore
|
|
2
|
-
.gitlab-ci.yml
|
|
3
2
|
LICENSE
|
|
4
3
|
README.md
|
|
5
4
|
pyproject.toml
|
|
6
5
|
.github/workflows/api-docs.yml
|
|
6
|
+
.github/workflows/checks.yml
|
|
7
7
|
.github/workflows/python-publish.yml
|
|
8
8
|
LICENSES/MIT.txt
|
|
9
9
|
src/OpenSMI_Core.egg-info/PKG-INFO
|
|
@@ -26,8 +26,10 @@ src/opensmi/core/lifecycle_mixin.py
|
|
|
26
26
|
src/opensmi/core/logging.py
|
|
27
27
|
src/opensmi/core/meta.py
|
|
28
28
|
src/opensmi/core/mixin_helpers.py
|
|
29
|
+
src/opensmi/core/paths.py
|
|
29
30
|
src/opensmi/core/protocols.py
|
|
30
31
|
src/opensmi/core/repr_mixin.py
|
|
32
|
+
src/opensmi/core/rsl.py
|
|
31
33
|
src/opensmi/core/signal.py
|
|
32
34
|
src/opensmi/core/subscription_manager.py
|
|
33
35
|
src/opensmi/core/ua.py
|
|
@@ -36,6 +38,25 @@ src/opensmi/core/ua_node_util.py
|
|
|
36
38
|
src/opensmi/core/unit_all.py
|
|
37
39
|
src/opensmi/core/unit_base.py
|
|
38
40
|
src/opensmi/core/unset.py
|
|
41
|
+
src/opensmi/core/nodesets/Opc.Ua.Di.NodeSet2.xml
|
|
42
|
+
src/opensmi/core/nodesets/Opc.Ua.Machinery.NodeSet2.xml
|
|
43
|
+
src/opensmi/core/nodesets/Opc.Ua.RSL.NodeSet2.xml
|
|
44
|
+
src/opensmi/core/nodesets/Opc.Ua.SmartFactory.MachineSet.NodeSet2.xml
|
|
45
|
+
src/opensmi/core/nodesets/Opc.Ua.SmartFactory.SkillSet.NodeSet2.xml
|
|
46
|
+
src/opensmi/core/nodesets/__init__.py
|
|
47
|
+
src/opensmi/core/nodesets/di.py
|
|
48
|
+
src/opensmi/core/nodesets/machinery.py
|
|
49
|
+
src/opensmi/core/nodesets/rsl.py
|
|
50
|
+
src/opensmi/core/nodesets/smart_factory_machine_set.py
|
|
51
|
+
src/opensmi/core/nodesets/smart_factory_skill_set.py
|
|
52
|
+
tests/test_nodesets.py
|
|
53
|
+
tests/test_object_path.py
|
|
54
|
+
tests/test_paths.py
|
|
55
|
+
tests/test_resolve.py
|
|
56
|
+
tests/test_rsl.py
|
|
39
57
|
tests/test_signal.py
|
|
58
|
+
tests/test_ua_values.py
|
|
40
59
|
tests/test_unit.py
|
|
41
|
-
tools/
|
|
60
|
+
tools/auto_generate_nodesets.py
|
|
61
|
+
tools/auto_generate_units.py
|
|
62
|
+
tools/nodeset.py.template
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"files": [
|
|
3
3
|
".github/workflows/api-docs.yml",
|
|
4
|
+
".github/workflows/checks.yml",
|
|
4
5
|
".github/workflows/python-publish.yml",
|
|
5
6
|
".gitignore",
|
|
6
|
-
".gitlab-ci.yml",
|
|
7
7
|
"LICENSE",
|
|
8
8
|
"LICENSES/MIT.txt",
|
|
9
9
|
"README.md",
|
|
@@ -21,8 +21,21 @@
|
|
|
21
21
|
"src/opensmi/core/logging.py",
|
|
22
22
|
"src/opensmi/core/meta.py",
|
|
23
23
|
"src/opensmi/core/mixin_helpers.py",
|
|
24
|
+
"src/opensmi/core/nodesets/Opc.Ua.Di.NodeSet2.xml",
|
|
25
|
+
"src/opensmi/core/nodesets/Opc.Ua.Machinery.NodeSet2.xml",
|
|
26
|
+
"src/opensmi/core/nodesets/Opc.Ua.RSL.NodeSet2.xml",
|
|
27
|
+
"src/opensmi/core/nodesets/Opc.Ua.SmartFactory.MachineSet.NodeSet2.xml",
|
|
28
|
+
"src/opensmi/core/nodesets/Opc.Ua.SmartFactory.SkillSet.NodeSet2.xml",
|
|
29
|
+
"src/opensmi/core/nodesets/__init__.py",
|
|
30
|
+
"src/opensmi/core/nodesets/di.py",
|
|
31
|
+
"src/opensmi/core/nodesets/machinery.py",
|
|
32
|
+
"src/opensmi/core/nodesets/rsl.py",
|
|
33
|
+
"src/opensmi/core/nodesets/smart_factory_machine_set.py",
|
|
34
|
+
"src/opensmi/core/nodesets/smart_factory_skill_set.py",
|
|
35
|
+
"src/opensmi/core/paths.py",
|
|
24
36
|
"src/opensmi/core/protocols.py",
|
|
25
37
|
"src/opensmi/core/repr_mixin.py",
|
|
38
|
+
"src/opensmi/core/rsl.py",
|
|
26
39
|
"src/opensmi/core/signal.py",
|
|
27
40
|
"src/opensmi/core/subscription_manager.py",
|
|
28
41
|
"src/opensmi/core/ua.py",
|
|
@@ -31,8 +44,16 @@
|
|
|
31
44
|
"src/opensmi/core/unit_all.py",
|
|
32
45
|
"src/opensmi/core/unit_base.py",
|
|
33
46
|
"src/opensmi/core/unset.py",
|
|
47
|
+
"tests/test_nodesets.py",
|
|
48
|
+
"tests/test_object_path.py",
|
|
49
|
+
"tests/test_paths.py",
|
|
50
|
+
"tests/test_resolve.py",
|
|
51
|
+
"tests/test_rsl.py",
|
|
34
52
|
"tests/test_signal.py",
|
|
53
|
+
"tests/test_ua_values.py",
|
|
35
54
|
"tests/test_unit.py",
|
|
36
|
-
"tools/
|
|
55
|
+
"tools/auto_generate_nodesets.py",
|
|
56
|
+
"tools/auto_generate_units.py",
|
|
57
|
+
"tools/nodeset.py.template"
|
|
37
58
|
]
|
|
38
59
|
}
|
|
@@ -12,7 +12,12 @@ from .lifecycle_mixin import LifecycleMixin as LifecycleMixin
|
|
|
12
12
|
from .lifecycle_mixin import lifecycle as lifecycle
|
|
13
13
|
from .logging import LogFormat as LogFormat
|
|
14
14
|
from .logging import setup_logging as setup_logging
|
|
15
|
+
from .paths import ObjectPath as ObjectPath
|
|
15
16
|
from .signal import Signal as Signal
|
|
17
|
+
from .ua import OPC_UA_NS as OPC_UA_NS
|
|
18
|
+
from .ua import LocalizedText as LocalizedText
|
|
19
|
+
from .ua import NodeIdDefinition as NodeIdDefinition
|
|
20
|
+
from .ua import QualifiedName as QualifiedName
|
|
16
21
|
from .ua_enums import MachineryItemState as MachineryItemState
|
|
17
22
|
from .ua_enums import MachineryOperationMode as MachineryOperationMode
|
|
18
23
|
from .ua_enums import SkillState as SkillState
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 OpenSMI Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""Base class for both client `RemoteServer` and server `Server`."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Generic, TypeVar
|
|
10
|
+
from weakref import WeakValueDictionary
|
|
11
|
+
|
|
12
|
+
import structlog
|
|
13
|
+
from asyncua import ua
|
|
14
|
+
|
|
15
|
+
from opensmi.core.base_ua_object import BaseUaObject
|
|
16
|
+
from opensmi.core.paths import ObjectPath
|
|
17
|
+
from opensmi.core.protocols import NamespaceProvider
|
|
18
|
+
from opensmi.core.ua import NodeIdDefinition, QualifiedName
|
|
19
|
+
|
|
20
|
+
_LOGGER = structlog.get_logger("opensmi." + __name__)
|
|
21
|
+
|
|
22
|
+
_UaObjectType = TypeVar("_UaObjectType", bound=BaseUaObject)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BaseServer(Generic[_UaObjectType]):
|
|
26
|
+
"""Base class for both client `RemoteServer` and server `Server`."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, **kwargs) -> None:
|
|
29
|
+
"""*Cooperative* constructor."""
|
|
30
|
+
super().__init__(**kwargs)
|
|
31
|
+
|
|
32
|
+
self._ua_objects: WeakValueDictionary[ua.NodeId, _UaObjectType] = WeakValueDictionary()
|
|
33
|
+
"""Maps OPC UA nodes to UaObjects with weak references."""
|
|
34
|
+
self._ua_objects_by_path: WeakValueDictionary[ObjectPath, _UaObjectType] = WeakValueDictionary()
|
|
35
|
+
"""Maps canonical paths and aliases to UaObjects with weak references."""
|
|
36
|
+
self._namespace_map: dict[str, int] = {}
|
|
37
|
+
|
|
38
|
+
def resolve(self, path: ObjectPath | str) -> _UaObjectType:
|
|
39
|
+
"""Return the object at ``path``, its canonical path or one of its aliases.
|
|
40
|
+
|
|
41
|
+
:raises KeyError: If no object has this path.
|
|
42
|
+
:raises ValueError: If ``path`` is a ``str`` that is no valid path.
|
|
43
|
+
"""
|
|
44
|
+
return self._ua_objects_by_path[ObjectPath.of(path)]
|
|
45
|
+
|
|
46
|
+
def resolve_optional(self, path: ObjectPath | str) -> _UaObjectType | None:
|
|
47
|
+
"""Return the object at ``path``, its canonical path or one of its aliases, or ``None``.
|
|
48
|
+
|
|
49
|
+
:raises ValueError: If ``path`` is a ``str`` that is no valid path.
|
|
50
|
+
"""
|
|
51
|
+
return self._ua_objects_by_path.get(ObjectPath.of(path))
|
|
52
|
+
|
|
53
|
+
def find(self, pattern: str) -> list[_UaObjectType]:
|
|
54
|
+
"""Return all objects whose canonical path matches the glob ``pattern`` (see `ObjectPath.match`), in tree order.
|
|
55
|
+
|
|
56
|
+
:raises ValueError: If ``pattern`` is no valid pattern.
|
|
57
|
+
"""
|
|
58
|
+
found = [
|
|
59
|
+
(path, ua_object)
|
|
60
|
+
for path, ua_object in list(self._ua_objects_by_path.items())
|
|
61
|
+
if path == ua_object.path and path.match(pattern)
|
|
62
|
+
]
|
|
63
|
+
return [ua_object for _, ua_object in sorted(found, key=lambda item: item[0])]
|
|
64
|
+
|
|
65
|
+
def get_ua_object(self, ua_node_id: ua.NodeId) -> _UaObjectType:
|
|
66
|
+
"""Return the ``UaObject`` which corresponds to the given ``ua_node``.
|
|
67
|
+
|
|
68
|
+
:raises KeyError: If no ``UaObject`` is found.
|
|
69
|
+
"""
|
|
70
|
+
return self._ua_objects[ua_node_id]
|
|
71
|
+
|
|
72
|
+
def get_ua_object_optional(self, ua_node_id: ua.NodeId) -> _UaObjectType | None:
|
|
73
|
+
"""Return the ``UaObject`` which corresponds to the given ``ua_node`` or ``None``."""
|
|
74
|
+
return self._ua_objects.get(ua_node_id, default=None)
|
|
75
|
+
|
|
76
|
+
def register_ua_object(self, ua_object: _UaObjectType) -> None:
|
|
77
|
+
"""Register the given ``UaObject`` to the server, by node and by its canonical path and aliases.
|
|
78
|
+
|
|
79
|
+
Objects register top-down: the path is taken now, so the parent must already be registered and can't change
|
|
80
|
+
anymore (`BaseUaObject._set_parent`).
|
|
81
|
+
|
|
82
|
+
:raises RuntimeError: If the object has a parent that isn't registered.
|
|
83
|
+
"""
|
|
84
|
+
assert ua_object is not None
|
|
85
|
+
assert isinstance(ua_object.ua_node.nodeid, ua.NodeId)
|
|
86
|
+
|
|
87
|
+
parent = ua_object._parent # noqa: SLF001 the public getter may refuse an unset parent
|
|
88
|
+
if parent is not None and (
|
|
89
|
+
parent._ua_node is None # noqa: SLF001
|
|
90
|
+
or self._ua_objects.get(parent._ua_node.nodeid) is not parent # noqa: SLF001
|
|
91
|
+
):
|
|
92
|
+
msg = f"Cannot register {ua_object!r} at {ua_object.path}: its parent {parent!r} isn't registered yet"
|
|
93
|
+
raise RuntimeError(msg)
|
|
94
|
+
|
|
95
|
+
self._ua_objects[ua_object.ua_node.nodeid] = ua_object
|
|
96
|
+
for path in (ua_object.path, *ua_object.aliases):
|
|
97
|
+
self._register_path(path, ua_object)
|
|
98
|
+
|
|
99
|
+
def unregister_ua_object(self, ua_object: _UaObjectType) -> None:
|
|
100
|
+
"""Unregister the given ``UaObject`` from the server, by node and by its canonical path and aliases."""
|
|
101
|
+
assert ua_object is not None
|
|
102
|
+
assert isinstance(ua_object.ua_node.nodeid, ua.NodeId)
|
|
103
|
+
|
|
104
|
+
_ = self._ua_objects.pop(ua_object.ua_node.nodeid)
|
|
105
|
+
for path in (ua_object.path, *ua_object.aliases):
|
|
106
|
+
if self._ua_objects_by_path.get(path) is ua_object:
|
|
107
|
+
del self._ua_objects_by_path[path]
|
|
108
|
+
|
|
109
|
+
def _register_path(self, path: ObjectPath, ua_object: _UaObjectType) -> None:
|
|
110
|
+
"""Map ``path`` to ``ua_object``; a path already taken by another object keeps that object."""
|
|
111
|
+
existing = self._ua_objects_by_path.get(path)
|
|
112
|
+
if existing is not None and existing is not ua_object:
|
|
113
|
+
_LOGGER.error("Path already registered, keeping the first object", path=str(path), existing=repr(existing))
|
|
114
|
+
return
|
|
115
|
+
self._ua_objects_by_path[path] = ua_object
|
|
116
|
+
|
|
117
|
+
def _clear_ua_objects(self) -> None:
|
|
118
|
+
"""Forget all registered objects, by node and by path (e.g. before a client browses again)."""
|
|
119
|
+
self._ua_objects.clear()
|
|
120
|
+
self._ua_objects_by_path.clear()
|
|
121
|
+
|
|
122
|
+
def ua_get_namespace_index(self, uri: str | type[NamespaceProvider]) -> ua.Int16:
|
|
123
|
+
"""Return the namespace index for the given namespace ``uri``.
|
|
124
|
+
|
|
125
|
+
:raises KeyError: If the namespace URI is unknown.
|
|
126
|
+
"""
|
|
127
|
+
if not isinstance(uri, str):
|
|
128
|
+
uri = uri.URI
|
|
129
|
+
|
|
130
|
+
return ua.Int16(self._namespace_map[uri])
|
|
131
|
+
|
|
132
|
+
def ua_get_namespace_uri(self, index: int) -> str:
|
|
133
|
+
"""Return the namespace URI for the given namespace ``index``.
|
|
134
|
+
|
|
135
|
+
:raises KeyError: If the server has no namespace at ``index``.
|
|
136
|
+
"""
|
|
137
|
+
for uri, ns_index in self._namespace_map.items():
|
|
138
|
+
if ns_index == index:
|
|
139
|
+
return uri
|
|
140
|
+
raise KeyError(index)
|
|
141
|
+
|
|
142
|
+
def ua_get_node_id(self, definition: NodeIdDefinition) -> ua.NodeId:
|
|
143
|
+
"""Return the complete OPC UA node ID for the given ``definition``.
|
|
144
|
+
|
|
145
|
+
:raises KeyError: If the namespace URI of the definition is unknown.
|
|
146
|
+
"""
|
|
147
|
+
return ua.NodeId(
|
|
148
|
+
Identifier=definition.identifier, # pyright: ignore[reportArgumentType]
|
|
149
|
+
NamespaceIndex=self.ua_get_namespace_index(definition.namespace_uri),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def ua_to_node_id_definition(self, node_id: ua.NodeId) -> NodeIdDefinition:
|
|
153
|
+
"""Convert the given node ID to a node ID definition.
|
|
154
|
+
|
|
155
|
+
An ``ua.ExpandedNodeId`` uses its ``NamespaceUri`` if set.
|
|
156
|
+
|
|
157
|
+
:raises KeyError: If the namespace index is unknown.
|
|
158
|
+
:raises ValueError: If an ``ua.ExpandedNodeId`` points to another server.
|
|
159
|
+
"""
|
|
160
|
+
namespace_uri = None
|
|
161
|
+
if isinstance(node_id, ua.ExpandedNodeId):
|
|
162
|
+
if node_id.ServerIndex:
|
|
163
|
+
msg = f"{node_id.to_string()} is on another server (ServerIndex {node_id.ServerIndex})"
|
|
164
|
+
raise ValueError(msg)
|
|
165
|
+
namespace_uri = node_id.NamespaceUri
|
|
166
|
+
return NodeIdDefinition(
|
|
167
|
+
identifier=node_id.Identifier,
|
|
168
|
+
namespace_uri=namespace_uri or self.ua_get_namespace_uri(node_id.NamespaceIndex),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def ua_to_qualified_name(self, qualified_name: ua.QualifiedName) -> QualifiedName:
|
|
172
|
+
"""Convert `ua.QualifiedName` to our `QualifiedName`."""
|
|
173
|
+
return QualifiedName(
|
|
174
|
+
namespace_uri=self.ua_get_namespace_uri(qualified_name.NamespaceIndex), name=qualified_name.Name
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def ua_from_qualified_name(self, qualified_name: QualifiedName) -> ua.QualifiedName:
|
|
178
|
+
"""Convert from our `QualifiedName` to `ua.QualifiedName`."""
|
|
179
|
+
return ua.QualifiedName(
|
|
180
|
+
Name=qualified_name.name, NamespaceIndex=self.ua_get_namespace_index(qualified_name.namespace_uri)
|
|
181
|
+
)
|
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
"""Base class for all UA objects, both client- and server-side."""
|
|
6
6
|
|
|
7
|
-
from
|
|
8
|
-
from typing import TYPE_CHECKING, Generic, TypeVar
|
|
7
|
+
from typing import TYPE_CHECKING, ClassVar, Generic, TypeVar
|
|
9
8
|
|
|
10
9
|
import structlog
|
|
11
10
|
from asyncua.common.node import Node
|
|
12
11
|
from structlog.stdlib import BoundLogger
|
|
13
12
|
from typing_extensions import deprecated
|
|
14
13
|
|
|
14
|
+
from opensmi.core.paths import ObjectPath, sanitize_segment
|
|
15
|
+
|
|
15
16
|
if TYPE_CHECKING:
|
|
16
17
|
from opensmi.core.base_server import BaseServer
|
|
17
18
|
|
|
@@ -23,17 +24,24 @@ class BaseUaObject(Generic[_ServerType]):
|
|
|
23
24
|
|
|
24
25
|
_parent: "BaseUaObject[_ServerType] | None" = None
|
|
25
26
|
|
|
27
|
+
_folder: ClassVar[str | None] = None
|
|
28
|
+
"""OpenSMI folder between the parent and this object in the path, e.g. ``"SkillSet"``; ``None`` for none."""
|
|
29
|
+
|
|
26
30
|
def __init__(
|
|
27
31
|
self,
|
|
28
32
|
*,
|
|
29
33
|
name: str | None = None,
|
|
34
|
+
segment: str | None = None,
|
|
30
35
|
server: _ServerType | None = None,
|
|
31
36
|
parent: "BaseUaObject[_ServerType] | None" = None,
|
|
32
37
|
**kwargs,
|
|
33
38
|
) -> None:
|
|
34
39
|
"""*Cooperative* constructor.
|
|
35
40
|
|
|
36
|
-
:param name: The name of the UA object
|
|
41
|
+
:param name: The name of the UA object, as on the server (may be empty on servers we don't control; the
|
|
42
|
+
segment never is). Defaults to the class name.
|
|
43
|
+
:param segment: The last segment of the object's path. Defaults to the sanitized ``name``; clients pass the
|
|
44
|
+
unique segment from ``opensmi.core.paths.unique_segments()``.
|
|
37
45
|
:param server: The associated server of the UA object. Note that there must be at least one non ``None`` in
|
|
38
46
|
the hierarchy.
|
|
39
47
|
:param parent: The parent of the UA object. Only Machines are allowed to have no parent. Can be set later before
|
|
@@ -41,18 +49,19 @@ class BaseUaObject(Generic[_ServerType]):
|
|
|
41
49
|
"""
|
|
42
50
|
if name is None:
|
|
43
51
|
name = self.__class__.__name__
|
|
44
|
-
elif not isinstance(name, str)
|
|
52
|
+
elif not isinstance(name, str):
|
|
45
53
|
msg = f"Given name '{name}' is not valid!"
|
|
46
54
|
raise TypeError(msg)
|
|
47
55
|
|
|
48
56
|
self._name: str = name
|
|
57
|
+
self._segment: str = sanitize_segment(name) if segment is None else segment
|
|
58
|
+
_ = ObjectPath((self._segment,)) # check the segment early
|
|
59
|
+
self._aliases: set[ObjectPath] = set()
|
|
49
60
|
self._server: _ServerType | None = server
|
|
61
|
+
self._ua_node: Node | None = None
|
|
50
62
|
if parent is not None:
|
|
51
63
|
self.parent = parent # via setter to allow optional runtime type checking
|
|
52
|
-
self.
|
|
53
|
-
self._logger: structlog.BoundLogger = structlog.get_logger(
|
|
54
|
-
f"opensmi.{self.__class__.__name__}", name=self.name
|
|
55
|
-
)
|
|
64
|
+
self._logger: structlog.BoundLogger = structlog.get_logger(f"opensmi.{self.__class__.__name__}", name=self.name)
|
|
56
65
|
|
|
57
66
|
super().__init__(**kwargs) # cooperative __init__() after we are done
|
|
58
67
|
|
|
@@ -89,12 +98,60 @@ class BaseUaObject(Generic[_ServerType]):
|
|
|
89
98
|
return f"/{self.name}"
|
|
90
99
|
|
|
91
100
|
@property
|
|
92
|
-
def path(self) ->
|
|
93
|
-
"""Return the full path of the object."""
|
|
101
|
+
def path(self) -> ObjectPath:
|
|
102
|
+
"""Return the full path of the object: the parent's path, the OpenSMI folder (if any) and the segment."""
|
|
103
|
+
base = ObjectPath()
|
|
94
104
|
if self.parent is not None:
|
|
95
105
|
assert isinstance(self.parent, BaseUaObject)
|
|
96
|
-
|
|
97
|
-
|
|
106
|
+
base = self.parent.path
|
|
107
|
+
if self._folder is not None:
|
|
108
|
+
base /= self._folder
|
|
109
|
+
return base / self._segment
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def aliases(self) -> frozenset[ObjectPath]:
|
|
113
|
+
"""Further paths of this object, e.g. of a variable also listed in other containers; ``path`` is canonical."""
|
|
114
|
+
return frozenset(self._aliases)
|
|
115
|
+
|
|
116
|
+
def _add_alias(self, path: ObjectPath) -> None:
|
|
117
|
+
"""Add ``path`` as a further path of this object; ``server.resolve()`` finds it once the object has a node."""
|
|
118
|
+
self._aliases.add(path)
|
|
119
|
+
if self._ua_node is not None:
|
|
120
|
+
self.server._register_path(path, self) # noqa: SLF001
|
|
121
|
+
|
|
122
|
+
def resolve(self, relative: str) -> "BaseUaObject[_ServerType]":
|
|
123
|
+
"""Return the object at ``relative`` below this one, e.g. ``"Components/Gripper"``.
|
|
124
|
+
|
|
125
|
+
:raises KeyError: If no object has this path.
|
|
126
|
+
:raises ValueError: If ``relative`` starts with ``/`` or is no valid relative path.
|
|
127
|
+
"""
|
|
128
|
+
return self.server.resolve(self._absolute(relative))
|
|
129
|
+
|
|
130
|
+
def resolve_optional(self, relative: str) -> "BaseUaObject[_ServerType] | None":
|
|
131
|
+
"""Return the object at ``relative`` below this one, or ``None``.
|
|
132
|
+
|
|
133
|
+
:raises ValueError: If ``relative`` starts with ``/`` or is no valid relative path.
|
|
134
|
+
"""
|
|
135
|
+
return self.server.resolve_optional(self._absolute(relative))
|
|
136
|
+
|
|
137
|
+
def find(self, pattern: str) -> "list[BaseUaObject[_ServerType]]":
|
|
138
|
+
"""Return all objects below this one whose path matches the relative glob ``pattern``, e.g. ``"**/SkillSet/*"``.
|
|
139
|
+
|
|
140
|
+
:raises ValueError: If ``pattern`` starts with ``/`` or is no valid pattern.
|
|
141
|
+
"""
|
|
142
|
+
self._check_relative(pattern)
|
|
143
|
+
prefix = "".join(f"/{ObjectPath.escape(segment)}" for segment in self.path.segments)
|
|
144
|
+
return [found for found in self.server.find(f"{prefix}/{pattern}") if found is not self]
|
|
145
|
+
|
|
146
|
+
def _absolute(self, relative: str) -> ObjectPath:
|
|
147
|
+
self._check_relative(relative)
|
|
148
|
+
return ObjectPath((*self.path.segments, *relative.split("/")))
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _check_relative(relative: str) -> None:
|
|
152
|
+
if relative.startswith("/"):
|
|
153
|
+
msg = f"Expected a path relative to this object, got an absolute one: {relative!r}"
|
|
154
|
+
raise ValueError(msg)
|
|
98
155
|
|
|
99
156
|
@property
|
|
100
157
|
def logger(self) -> BoundLogger:
|
|
@@ -110,6 +167,16 @@ class BaseUaObject(Generic[_ServerType]):
|
|
|
110
167
|
def parent(self, parent: "BaseUaObject[_ServerType]") -> None:
|
|
111
168
|
"""Set the parent of the UA object. Given ``parent`` must be an instance of `BaseUaObject`."""
|
|
112
169
|
assert isinstance(parent, BaseUaObject)
|
|
170
|
+
self._set_parent(parent)
|
|
171
|
+
|
|
172
|
+
def _set_parent(self, parent: "BaseUaObject[_ServerType]") -> None:
|
|
173
|
+
"""Set the parent; subclasses overriding the ``parent`` setter call this instead of setting ``_parent``.
|
|
174
|
+
|
|
175
|
+
:raises RuntimeError: If the object already has a node: its path is registered and must not change.
|
|
176
|
+
"""
|
|
177
|
+
if self._ua_node is not None:
|
|
178
|
+
msg = f"Cannot change the parent of {self!r}: its path {self.path} is already registered"
|
|
179
|
+
raise RuntimeError(msg)
|
|
113
180
|
self._parent = parent
|
|
114
181
|
|
|
115
182
|
# TODO
|