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.
- opcua_tagmap-0.2.0/LICENSE +21 -0
- opcua_tagmap-0.2.0/PKG-INFO +234 -0
- opcua_tagmap-0.2.0/README.md +208 -0
- opcua_tagmap-0.2.0/pyproject.toml +38 -0
- opcua_tagmap-0.2.0/setup.cfg +4 -0
- opcua_tagmap-0.2.0/src/opcua_tagmap/__init__.py +0 -0
- opcua_tagmap-0.2.0/src/opcua_tagmap/browse.py +128 -0
- opcua_tagmap-0.2.0/src/opcua_tagmap/cli.py +150 -0
- opcua_tagmap-0.2.0/src/opcua_tagmap/emit/__init__.py +0 -0
- opcua_tagmap-0.2.0/src/opcua_tagmap/emit/table.py +35 -0
- opcua_tagmap-0.2.0/src/opcua_tagmap/emit/telegraf.py +68 -0
- opcua_tagmap-0.2.0/src/opcua_tagmap/emit/testcases.py +125 -0
- opcua_tagmap-0.2.0/src/opcua_tagmap/globmatch.py +98 -0
- opcua_tagmap-0.2.0/src/opcua_tagmap/model.py +37 -0
- opcua_tagmap-0.2.0/src/opcua_tagmap.egg-info/PKG-INFO +234 -0
- opcua_tagmap-0.2.0/src/opcua_tagmap.egg-info/SOURCES.txt +22 -0
- opcua_tagmap-0.2.0/src/opcua_tagmap.egg-info/dependency_links.txt +1 -0
- opcua_tagmap-0.2.0/src/opcua_tagmap.egg-info/entry_points.txt +2 -0
- opcua_tagmap-0.2.0/src/opcua_tagmap.egg-info/requires.txt +5 -0
- opcua_tagmap-0.2.0/src/opcua_tagmap.egg-info/top_level.txt +1 -0
- opcua_tagmap-0.2.0/tests/test_browse_live.py +59 -0
- opcua_tagmap-0.2.0/tests/test_emit.py +86 -0
- opcua_tagmap-0.2.0/tests/test_globmatch.py +98 -0
- opcua_tagmap-0.2.0/tests/test_testcases.py +107 -0
|
@@ -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
|
+
[](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
|
+
[](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"]
|
|
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))
|