opcua-tagmap 0.2.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Suleman Ali
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,234 @@
1
+ Metadata-Version: 2.4
2
+ Name: opcua-tagmap
3
+ Version: 0.2.0
4
+ Summary: Browse an OPC UA server and generate a tag inventory, a static Telegraf config or test-case stubs
5
+ Author: Suleman Ali
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/sulemanali5760/opcua-tagmap
8
+ Project-URL: Issues, https://github.com/sulemanali5760/opcua-tagmap/issues
9
+ Keywords: opcua,opc-ua,telegraf,iiot,plc,industrial,automation,tags,discovery
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Manufacturing
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: System :: Monitoring
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: asyncua>=1.1.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7; extra == "dev"
24
+ Requires-Dist: pyyaml>=6; extra == "dev"
25
+ Dynamic: license-file
26
+
27
+ # opcua-tagmap
28
+
29
+ [![CI](https://github.com/sulemanali5760/opcua-tagmap/actions/workflows/ci.yml/badge.svg)](https://github.com/sulemanali5760/opcua-tagmap/actions/workflows/ci.yml)
30
+
31
+ Browse an OPC UA server, filter the tags you want with glob patterns, and get
32
+ a tag inventory (CSV/JSON), a static Telegraf config, or test-case stubs.
33
+
34
+ It does not collect, store or display anything — Telegraf, InfluxDB, Grafana,
35
+ ThingsBoard and Node-RED already do that well. This works *before* them:
36
+ turning a PLC's address space into files you can read, review and version.
37
+
38
+ ## The problem
39
+
40
+ Pointing any IIoT collector at a real machine means enumerating every node:
41
+
42
+ ```toml
43
+ [[inputs.opcua.nodes]]
44
+ name = "host_MV01"
45
+ namespace = "2"
46
+ identifier_type = "s"
47
+ identifier = "Plant.Device1.MV01"
48
+
49
+ # ... repeated for MV02 ... MV99, by hand
50
+ ```
51
+
52
+ Telegraf's own issue [#18529](https://github.com/influxdata/telegraf/issues/18529)
53
+ called browse-based discovery *"one of the most requested features for
54
+ large-scale OPC-UA deployments"*. ThingsBoard users hit the same wall
55
+ ([#9594](https://github.com/thingsboard/thingsboard/issues/9594)).
56
+
57
+ ### Telegraf has this built in now
58
+
59
+ That issue was closed in May 2026: Telegraf merged browse-based discovery with
60
+ glob patterns (`[inputs.opcua.browse]`, pull request
61
+ [#18831](https://github.com/influxdata/telegraf/pull/18831)); releases from
62
+ 1.39.0 (June 2026) on were published after that merge. **If you run a current
63
+ Telegraf and want it to discover nodes by itself at start-up, use that, not this tool.**
64
+
65
+ What `opcua-tagmap` is still for:
66
+
67
+ - **A static config you can review.** The generated file lists every node, so a
68
+ change on the PLC shows up as a diff in version control instead of silently
69
+ changing what is collected.
70
+ - **Older Telegraf versions** that do not have the browse section.
71
+ - **A tag inventory** as CSV or JSON for commissioning documents, independent of any collector.
72
+ - **Test-case stubs** for acceptance tests (below).
73
+
74
+ ## Install
75
+
76
+ With internet:
77
+
78
+ ```bash
79
+ pip install https://github.com/sulemanali5760/opcua-tagmap/releases/download/v0.2.0/opcua_tagmap-0.2.0-py3-none-any.whl
80
+ ```
81
+
82
+ Without internet (a plant PC, a commissioning laptop on the machine network): download the offline bundle
83
+ for your system from the [latest release](https://github.com/sulemanali5760/opcua-tagmap/releases/latest), carry
84
+ it over on a USB stick, unzip it and run inside the folder:
85
+
86
+ ```bash
87
+ python -m pip install --no-index --find-links . opcua-tagmap
88
+ ```
89
+
90
+ The bundle holds this tool and every package it needs; nothing is downloaded. Each bundle is installed in a
91
+ clean environment and started once before it is published. Python 3.11 or newer. Not on PyPI yet.
92
+
93
+ ## Use
94
+
95
+ See what is on a server:
96
+
97
+ ```bash
98
+ opcua-tagmap browse opc.tcp://192.168.0.10:4840
99
+ ```
100
+
101
+ ```
102
+ Objects/ProtectionPanel/CoilVoltage_V ns=2;s=CoilVoltage_V Double
103
+ Objects/ProtectionPanel/ContactorState ns=2;s=ContactorState String
104
+ Objects/ProtectionPanel/CurrentDraw_A ns=2;s=CurrentDraw_A Double
105
+ ...
106
+ ```
107
+
108
+ Generate a Telegraf input for a subset:
109
+
110
+ ```bash
111
+ opcua-tagmap gen telegraf opc.tcp://192.168.0.10:4840 \
112
+ --include 'Objects/Plant1/*/MV*' \
113
+ --out telegraf.conf
114
+ ```
115
+
116
+ Scaffold acceptance tests for a whole panel:
117
+
118
+ ```bash
119
+ opcua-tagmap gen testcases opc.tcp://192.168.0.10:4840 \
120
+ --include 'Objects/ProtectionPanel/*' \
121
+ --out-dir test_cases/
122
+ ```
123
+
124
+ ```
125
+ skipped ContactorState: String - a min/max spec cannot express it
126
+ skipped FaultFlag: Boolean - a min/max spec cannot express it
127
+ wrote 5 stub(s) to test_cases/
128
+ every stub needs a limit before it means anything: uncomment min/max and fill in the real number.
129
+ ```
130
+
131
+ One YAML file per numeric tag, in the format
132
+ [opc-ua-test-automation-pipeline](https://github.com/sulemanali5760/opc-ua-test-automation-pipeline)
133
+ reads. On a machine with a hundred tags, this turns *write a hundred files*
134
+ into *delete the eighty-five you do not care about and fill in fifteen numbers*.
135
+
136
+ **It never guesses a limit.** A generated stub has `min` and `max` commented
137
+ out, so it cannot pass or fail until someone opens a datasheet and types the
138
+ real number. An invented limit produces a test that passes for no reason,
139
+ which is worse than having no test.
140
+
141
+ Re-running is safe: files that already exist are left alone, so adding a tag
142
+ next month gives you only the new stubs. `--overwrite` forces the issue.
143
+
144
+ Export the inventory for a commissioning document:
145
+
146
+ ```bash
147
+ opcua-tagmap browse opc.tcp://192.168.0.10:4840 --format csv --out tags.csv
148
+ ```
149
+
150
+ ## Names in the Telegraf output
151
+
152
+ Telegraf rejects a config in which two nodes share a name, and plants reuse
153
+ names all the time (every control loop has a `PV`). A name that occurs more
154
+ than once gets its parent in front: `PV` becomes `TIC101_PV` and `TI102_PV`;
155
+ names that are already unique are left alone.
156
+
157
+ ## Structured variables
158
+
159
+ Members of a structured variable are listed too (`Recipe/Setpoint`,
160
+ `Recipe/Duration`): this is how controllers expose a STRUCT. Properties that
161
+ only describe a variable, such as `EURange` or `EngineeringUnits`, are not tags
162
+ and are left out.
163
+
164
+ ## Glob patterns
165
+
166
+ Matched against the browse path, and segment-aware — `*` stops at a `/`:
167
+
168
+ | Pattern | Matches |
169
+ |---|---|
170
+ | `Objects/Plant1/*/MV*` | any `MV…` one level under any child of `Plant1` |
171
+ | `Objects/**/Temperature` | `Temperature` at any depth |
172
+ | `Objects/Panel/MV0?` | `MV01`, `MV02`, … but not `MV012` |
173
+ | `Objects/Panel/MV[01]1` | `MV01` and `MV11` |
174
+
175
+ `--include` is a union — repeat it. `--exclude` is applied afterwards.
176
+ No `--include` means every variable on the server.
177
+
178
+ ## Options
179
+
180
+ | Flag | Purpose |
181
+ |---|---|
182
+ | `--root ns=2;s=Plant1` | start part-way down instead of at Objects |
183
+ | `--max-depth`, `--max-nodes` | stop a huge server from running away |
184
+ | `--include-standard` | also return the OPC UA standard namespace (see below) |
185
+ | `--format text\|csv\|json` | `browse` output shape |
186
+ | `--out FILE` | write to a file instead of stdout |
187
+
188
+ **Namespace 0 is excluded by default.** Every OPC UA server exposes a standard
189
+ `Server` object — roughly 250 diagnostic nodes like `MaxSessions`,
190
+ `ServerCapabilities` and `RedundancySupport`. Browsing a small demo server
191
+ raw returns 255 nodes, of which 7 are the machine. Since the standard
192
+ namespace is always ns=0 and machine tags always live in ns≥1, ns=0 is dropped
193
+ unless you ask for it with `--include-standard`.
194
+
195
+ If a pattern matches nothing, the tool **exits non-zero and writes no file**,
196
+ rather than handing you a valid-looking config that collects nothing.
197
+
198
+ Nodes that cannot be browsed — permissions, for instance — are reported as
199
+ warnings on stderr and the rest of the tree is still walked.
200
+
201
+ ## Roadmap
202
+
203
+ - `gen thingsboard` — IoT Gateway `opcua.json`
204
+ - `gen nodered` — flow JSON
205
+ - `gen ditto` — Eclipse Ditto Thing JSON
206
+ - diff two browses: which tags appeared or vanished between machine revisions
207
+
208
+ ## What is tested
209
+
210
+ CI runs the unit tests, then the whole tool against a simulated OPC UA server
211
+ with a known address space, asserting that a browse finds exactly its seven
212
+ variables, that the generated TOML parses and carries all seven NodeIds, that
213
+ `Objects/ProtectionPanel/C*` selects exactly four, and that a pattern matching
214
+ nothing fails instead of writing an empty config.
215
+
216
+ A second test server, started inside `pytest`, covers what the panel simulator
217
+ does not have: two objects with a variable of the same name, and a structured
218
+ variable with member variables and a property.
219
+
220
+ **No security yet.** The tool connects anonymously without encryption, and the
221
+ generated Telegraf block says so (`security_policy = "None"`). A server that
222
+ only accepts encrypted or logged-in sessions cannot be browsed with it; to find
223
+ out what such a server wants, see
224
+ [opcua-doctor](https://github.com/sulemanali5760/opcua-doctor).
225
+
226
+ **Not yet verified against a real PLC.** The simulator is
227
+ [`asyncua`](https://github.com/FreeOpcUa/opcua-asyncio)-based and well behaved;
228
+ real servers have deeper trees, permission boundaries and vendor quirks in
229
+ NodeId encoding. If you run it against real hardware, an issue describing what
230
+ happened is genuinely useful.
231
+
232
+ ## Licence
233
+
234
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,208 @@
1
+ # opcua-tagmap
2
+
3
+ [![CI](https://github.com/sulemanali5760/opcua-tagmap/actions/workflows/ci.yml/badge.svg)](https://github.com/sulemanali5760/opcua-tagmap/actions/workflows/ci.yml)
4
+
5
+ Browse an OPC UA server, filter the tags you want with glob patterns, and get
6
+ a tag inventory (CSV/JSON), a static Telegraf config, or test-case stubs.
7
+
8
+ It does not collect, store or display anything — Telegraf, InfluxDB, Grafana,
9
+ ThingsBoard and Node-RED already do that well. This works *before* them:
10
+ turning a PLC's address space into files you can read, review and version.
11
+
12
+ ## The problem
13
+
14
+ Pointing any IIoT collector at a real machine means enumerating every node:
15
+
16
+ ```toml
17
+ [[inputs.opcua.nodes]]
18
+ name = "host_MV01"
19
+ namespace = "2"
20
+ identifier_type = "s"
21
+ identifier = "Plant.Device1.MV01"
22
+
23
+ # ... repeated for MV02 ... MV99, by hand
24
+ ```
25
+
26
+ Telegraf's own issue [#18529](https://github.com/influxdata/telegraf/issues/18529)
27
+ called browse-based discovery *"one of the most requested features for
28
+ large-scale OPC-UA deployments"*. ThingsBoard users hit the same wall
29
+ ([#9594](https://github.com/thingsboard/thingsboard/issues/9594)).
30
+
31
+ ### Telegraf has this built in now
32
+
33
+ That issue was closed in May 2026: Telegraf merged browse-based discovery with
34
+ glob patterns (`[inputs.opcua.browse]`, pull request
35
+ [#18831](https://github.com/influxdata/telegraf/pull/18831)); releases from
36
+ 1.39.0 (June 2026) on were published after that merge. **If you run a current
37
+ Telegraf and want it to discover nodes by itself at start-up, use that, not this tool.**
38
+
39
+ What `opcua-tagmap` is still for:
40
+
41
+ - **A static config you can review.** The generated file lists every node, so a
42
+ change on the PLC shows up as a diff in version control instead of silently
43
+ changing what is collected.
44
+ - **Older Telegraf versions** that do not have the browse section.
45
+ - **A tag inventory** as CSV or JSON for commissioning documents, independent of any collector.
46
+ - **Test-case stubs** for acceptance tests (below).
47
+
48
+ ## Install
49
+
50
+ With internet:
51
+
52
+ ```bash
53
+ pip install https://github.com/sulemanali5760/opcua-tagmap/releases/download/v0.2.0/opcua_tagmap-0.2.0-py3-none-any.whl
54
+ ```
55
+
56
+ Without internet (a plant PC, a commissioning laptop on the machine network): download the offline bundle
57
+ for your system from the [latest release](https://github.com/sulemanali5760/opcua-tagmap/releases/latest), carry
58
+ it over on a USB stick, unzip it and run inside the folder:
59
+
60
+ ```bash
61
+ python -m pip install --no-index --find-links . opcua-tagmap
62
+ ```
63
+
64
+ The bundle holds this tool and every package it needs; nothing is downloaded. Each bundle is installed in a
65
+ clean environment and started once before it is published. Python 3.11 or newer. Not on PyPI yet.
66
+
67
+ ## Use
68
+
69
+ See what is on a server:
70
+
71
+ ```bash
72
+ opcua-tagmap browse opc.tcp://192.168.0.10:4840
73
+ ```
74
+
75
+ ```
76
+ Objects/ProtectionPanel/CoilVoltage_V ns=2;s=CoilVoltage_V Double
77
+ Objects/ProtectionPanel/ContactorState ns=2;s=ContactorState String
78
+ Objects/ProtectionPanel/CurrentDraw_A ns=2;s=CurrentDraw_A Double
79
+ ...
80
+ ```
81
+
82
+ Generate a Telegraf input for a subset:
83
+
84
+ ```bash
85
+ opcua-tagmap gen telegraf opc.tcp://192.168.0.10:4840 \
86
+ --include 'Objects/Plant1/*/MV*' \
87
+ --out telegraf.conf
88
+ ```
89
+
90
+ Scaffold acceptance tests for a whole panel:
91
+
92
+ ```bash
93
+ opcua-tagmap gen testcases opc.tcp://192.168.0.10:4840 \
94
+ --include 'Objects/ProtectionPanel/*' \
95
+ --out-dir test_cases/
96
+ ```
97
+
98
+ ```
99
+ skipped ContactorState: String - a min/max spec cannot express it
100
+ skipped FaultFlag: Boolean - a min/max spec cannot express it
101
+ wrote 5 stub(s) to test_cases/
102
+ every stub needs a limit before it means anything: uncomment min/max and fill in the real number.
103
+ ```
104
+
105
+ One YAML file per numeric tag, in the format
106
+ [opc-ua-test-automation-pipeline](https://github.com/sulemanali5760/opc-ua-test-automation-pipeline)
107
+ reads. On a machine with a hundred tags, this turns *write a hundred files*
108
+ into *delete the eighty-five you do not care about and fill in fifteen numbers*.
109
+
110
+ **It never guesses a limit.** A generated stub has `min` and `max` commented
111
+ out, so it cannot pass or fail until someone opens a datasheet and types the
112
+ real number. An invented limit produces a test that passes for no reason,
113
+ which is worse than having no test.
114
+
115
+ Re-running is safe: files that already exist are left alone, so adding a tag
116
+ next month gives you only the new stubs. `--overwrite` forces the issue.
117
+
118
+ Export the inventory for a commissioning document:
119
+
120
+ ```bash
121
+ opcua-tagmap browse opc.tcp://192.168.0.10:4840 --format csv --out tags.csv
122
+ ```
123
+
124
+ ## Names in the Telegraf output
125
+
126
+ Telegraf rejects a config in which two nodes share a name, and plants reuse
127
+ names all the time (every control loop has a `PV`). A name that occurs more
128
+ than once gets its parent in front: `PV` becomes `TIC101_PV` and `TI102_PV`;
129
+ names that are already unique are left alone.
130
+
131
+ ## Structured variables
132
+
133
+ Members of a structured variable are listed too (`Recipe/Setpoint`,
134
+ `Recipe/Duration`): this is how controllers expose a STRUCT. Properties that
135
+ only describe a variable, such as `EURange` or `EngineeringUnits`, are not tags
136
+ and are left out.
137
+
138
+ ## Glob patterns
139
+
140
+ Matched against the browse path, and segment-aware — `*` stops at a `/`:
141
+
142
+ | Pattern | Matches |
143
+ |---|---|
144
+ | `Objects/Plant1/*/MV*` | any `MV…` one level under any child of `Plant1` |
145
+ | `Objects/**/Temperature` | `Temperature` at any depth |
146
+ | `Objects/Panel/MV0?` | `MV01`, `MV02`, … but not `MV012` |
147
+ | `Objects/Panel/MV[01]1` | `MV01` and `MV11` |
148
+
149
+ `--include` is a union — repeat it. `--exclude` is applied afterwards.
150
+ No `--include` means every variable on the server.
151
+
152
+ ## Options
153
+
154
+ | Flag | Purpose |
155
+ |---|---|
156
+ | `--root ns=2;s=Plant1` | start part-way down instead of at Objects |
157
+ | `--max-depth`, `--max-nodes` | stop a huge server from running away |
158
+ | `--include-standard` | also return the OPC UA standard namespace (see below) |
159
+ | `--format text\|csv\|json` | `browse` output shape |
160
+ | `--out FILE` | write to a file instead of stdout |
161
+
162
+ **Namespace 0 is excluded by default.** Every OPC UA server exposes a standard
163
+ `Server` object — roughly 250 diagnostic nodes like `MaxSessions`,
164
+ `ServerCapabilities` and `RedundancySupport`. Browsing a small demo server
165
+ raw returns 255 nodes, of which 7 are the machine. Since the standard
166
+ namespace is always ns=0 and machine tags always live in ns≥1, ns=0 is dropped
167
+ unless you ask for it with `--include-standard`.
168
+
169
+ If a pattern matches nothing, the tool **exits non-zero and writes no file**,
170
+ rather than handing you a valid-looking config that collects nothing.
171
+
172
+ Nodes that cannot be browsed — permissions, for instance — are reported as
173
+ warnings on stderr and the rest of the tree is still walked.
174
+
175
+ ## Roadmap
176
+
177
+ - `gen thingsboard` — IoT Gateway `opcua.json`
178
+ - `gen nodered` — flow JSON
179
+ - `gen ditto` — Eclipse Ditto Thing JSON
180
+ - diff two browses: which tags appeared or vanished between machine revisions
181
+
182
+ ## What is tested
183
+
184
+ CI runs the unit tests, then the whole tool against a simulated OPC UA server
185
+ with a known address space, asserting that a browse finds exactly its seven
186
+ variables, that the generated TOML parses and carries all seven NodeIds, that
187
+ `Objects/ProtectionPanel/C*` selects exactly four, and that a pattern matching
188
+ nothing fails instead of writing an empty config.
189
+
190
+ A second test server, started inside `pytest`, covers what the panel simulator
191
+ does not have: two objects with a variable of the same name, and a structured
192
+ variable with member variables and a property.
193
+
194
+ **No security yet.** The tool connects anonymously without encryption, and the
195
+ generated Telegraf block says so (`security_policy = "None"`). A server that
196
+ only accepts encrypted or logged-in sessions cannot be browsed with it; to find
197
+ out what such a server wants, see
198
+ [opcua-doctor](https://github.com/sulemanali5760/opcua-doctor).
199
+
200
+ **Not yet verified against a real PLC.** The simulator is
201
+ [`asyncua`](https://github.com/FreeOpcUa/opcua-asyncio)-based and well behaved;
202
+ real servers have deeper trees, permission boundaries and vendor quirks in
203
+ NodeId encoding. If you run it against real hardware, an issue describing what
204
+ happened is genuinely useful.
205
+
206
+ ## Licence
207
+
208
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "opcua-tagmap"
7
+ version = "0.2.0"
8
+ description = "Browse an OPC UA server and generate a tag inventory, a static Telegraf config or test-case stubs"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Suleman Ali" }]
13
+ keywords = ["opcua", "opc-ua", "telegraf", "iiot", "plc", "industrial",
14
+ "automation", "tags", "discovery"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Manufacturing",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3",
23
+ "Topic :: System :: Monitoring",
24
+ ]
25
+ dependencies = ["asyncua>=1.1.0"]
26
+
27
+ [project.optional-dependencies]
28
+ dev = ["pytest>=7", "pyyaml>=6"]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/sulemanali5760/opcua-tagmap"
32
+ Issues = "https://github.com/sulemanali5760/opcua-tagmap/issues"
33
+
34
+ [project.scripts]
35
+ opcua-tagmap = "opcua_tagmap.cli:main"
36
+
37
+ [tool.setuptools.packages.find]
38
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,128 @@
1
+ """Walk an OPC UA server's address space and collect its variables.
2
+
3
+ All protocol work is asyncua's. This module only decides where to descend,
4
+ when to stop, and what to record.
5
+ """
6
+
7
+ import asyncio
8
+ from typing import Iterable
9
+
10
+ from asyncua import Client, ua
11
+
12
+ from .model import TagNode
13
+
14
+ DEFAULT_MAX_DEPTH = 12
15
+ DEFAULT_MAX_NODES = 10_000
16
+
17
+ # NodeId encodings, as the rest of the world writes them in config files.
18
+ _ID_TYPE = {
19
+ ua.NodeIdType.Numeric: "i",
20
+ ua.NodeIdType.String: "s",
21
+ ua.NodeIdType.Guid: "g",
22
+ ua.NodeIdType.ByteString: "b",
23
+ ua.NodeIdType.TwoByte: "i",
24
+ ua.NodeIdType.FourByte: "i",
25
+ }
26
+
27
+
28
+ class BrowseResult:
29
+ def __init__(self, tags: list[TagNode], errors: list[str], truncated: bool):
30
+ self.tags = tags
31
+ self.errors = errors
32
+ self.truncated = truncated
33
+
34
+
35
+ async def _read_datatype(node) -> str:
36
+ try:
37
+ variant = await node.read_data_type_as_variant_type()
38
+ return variant.name
39
+ except Exception:
40
+ return "Unknown"
41
+
42
+
43
+ async def _walk(node, path: str, depth: int, state: dict, refs=ua.ObjectIds.HierarchicalReferences) -> None:
44
+ if depth > state["max_depth"] or len(state["tags"]) >= state["max_nodes"]:
45
+ if len(state["tags"]) >= state["max_nodes"]:
46
+ state["truncated"] = True
47
+ return
48
+
49
+ try:
50
+ children = await node.get_children(refs=refs)
51
+ except Exception as e:
52
+ # A permission error part-way down a real server must not abort the
53
+ # whole browse — record it and carry on with the rest of the tree.
54
+ state["errors"].append(f"{path}: {e}")
55
+ return
56
+
57
+ for child in children:
58
+ # Namespace 0 is reserved for the OPC UA standard: the Server object
59
+ # alone carries ~250 diagnostic nodes (MaxSessions, ServerCapabilities,
60
+ # RedundancySupport...). Machine tags always live in ns>=1, so without
61
+ # this the real tags are buried. --include-standard opts back in.
62
+ if state["skip_standard"] and child.nodeid.NamespaceIndex == 0:
63
+ continue
64
+
65
+ try:
66
+ name = (await child.read_browse_name()).Name
67
+ node_class = await child.read_node_class()
68
+ except Exception as e:
69
+ state["errors"].append(f"{path}: {e}")
70
+ continue
71
+
72
+ child_path = f"{path}/{name}"
73
+
74
+ if node_class == ua.NodeClass.Variable:
75
+ if len(state["tags"]) >= state["max_nodes"]:
76
+ state["truncated"] = True
77
+ return
78
+ nid = child.nodeid
79
+ state["tags"].append(TagNode(
80
+ browse_path=child_path,
81
+ display_name=name,
82
+ namespace=nid.NamespaceIndex,
83
+ identifier_type=_ID_TYPE.get(nid.NodeIdType, "s"),
84
+ identifier=str(nid.Identifier),
85
+ datatype=await _read_datatype(child),
86
+ ))
87
+ # A structured variable carries its members as component variables (this is how an
88
+ # S7-1500 or a CODESYS controller exposes a STRUCT or an array of STRUCT). Follow
89
+ # HasComponent only: properties such as EURange describe the variable, they are not tags.
90
+ await _walk(child, child_path, depth + 1, state, refs=ua.ObjectIds.HasComponent)
91
+ elif node_class == ua.NodeClass.Object:
92
+ await _walk(child, child_path, depth + 1, state)
93
+
94
+
95
+ async def browse(endpoint: str,
96
+ root: str | None = None,
97
+ max_depth: int = DEFAULT_MAX_DEPTH,
98
+ max_nodes: int = DEFAULT_MAX_NODES,
99
+ timeout: float = 10.0,
100
+ skip_standard: bool = True) -> BrowseResult:
101
+ """Collect every Variable under `root` (default: the Objects folder).
102
+
103
+ `root` may be a NodeId string such as "ns=2;s=ProtectionPanel" to start
104
+ part-way down a large server.
105
+
106
+ `skip_standard` drops the OPC UA standard namespace (ns=0), which is what
107
+ makes the output about the machine rather than about the server.
108
+ """
109
+ state = {"tags": [], "errors": [], "truncated": False,
110
+ "max_depth": max_depth, "max_nodes": max_nodes,
111
+ "skip_standard": skip_standard}
112
+
113
+ client = Client(url=endpoint, timeout=timeout)
114
+ async with client:
115
+ if root:
116
+ start = client.get_node(root)
117
+ start_path = (await start.read_browse_name()).Name
118
+ else:
119
+ start = client.nodes.objects
120
+ start_path = "Objects"
121
+ await _walk(start, start_path, 0, state)
122
+
123
+ state["tags"].sort(key=lambda t: t.browse_path)
124
+ return BrowseResult(state["tags"], state["errors"], state["truncated"])
125
+
126
+
127
+ def browse_sync(*args, **kwargs) -> BrowseResult:
128
+ return asyncio.run(browse(*args, **kwargs))