opcua-tagmap 0.2.0__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.
File without changes
opcua_tagmap/browse.py ADDED
@@ -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))
opcua_tagmap/cli.py ADDED
@@ -0,0 +1,150 @@
1
+ """Command line entry point.
2
+
3
+ opcua-tagmap browse opc.tcp://plc:4840
4
+ opcua-tagmap gen telegraf opc.tcp://plc:4840 --include 'Objects/Plant1/*/MV*'
5
+ """
6
+
7
+ import argparse
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from .browse import DEFAULT_MAX_DEPTH, DEFAULT_MAX_NODES, browse_sync
12
+ from .emit import table, telegraf, testcases
13
+ from .globmatch import filter_tags
14
+
15
+
16
+ def _add_common(parser: argparse.ArgumentParser, with_out: bool = True) -> None:
17
+ parser.add_argument("endpoint", help="opc.tcp://host:port/path")
18
+ parser.add_argument("--root", default=None,
19
+ help='start below this NodeId, e.g. "ns=2;s=Plant1"')
20
+ parser.add_argument("--include", action="append", default=[], metavar="GLOB",
21
+ help="keep browse paths matching this pattern (repeatable)")
22
+ parser.add_argument("--exclude", action="append", default=[], metavar="GLOB",
23
+ help="drop browse paths matching this pattern (repeatable)")
24
+ parser.add_argument("--max-depth", type=int, default=DEFAULT_MAX_DEPTH)
25
+ parser.add_argument("--max-nodes", type=int, default=DEFAULT_MAX_NODES)
26
+ parser.add_argument("--timeout", type=float, default=10.0)
27
+ parser.add_argument("--include-standard", action="store_true",
28
+ help="also include the OPC UA standard namespace "
29
+ "(ns=0) — the server's own diagnostic tree, "
30
+ "roughly 250 nodes, excluded by default")
31
+ if with_out:
32
+ parser.add_argument("--out", default=None,
33
+ help="write here instead of stdout")
34
+
35
+
36
+ def _collect(args):
37
+ """Browse, filter, and fail loudly if nothing survived."""
38
+ result = browse_sync(args.endpoint, root=args.root,
39
+ max_depth=args.max_depth, max_nodes=args.max_nodes,
40
+ timeout=args.timeout,
41
+ skip_standard=not args.include_standard)
42
+
43
+ for err in result.errors:
44
+ print(f"warning: could not browse {err}", file=sys.stderr)
45
+ if result.truncated:
46
+ print(f"warning: stopped at --max-nodes {args.max_nodes}; "
47
+ "output is incomplete", file=sys.stderr)
48
+
49
+ tags = filter_tags(result.tags, args.include, args.exclude)
50
+
51
+ if not tags:
52
+ # Emitting an empty config would look like success and silently
53
+ # collect nothing. Say so and exit non-zero.
54
+ if result.tags and (args.include or args.exclude):
55
+ print(f"error: no nodes matched. {len(result.tags)} variable(s) "
56
+ "found on the server but every one was filtered out.",
57
+ file=sys.stderr)
58
+ else:
59
+ print("error: no variables found on the server.", file=sys.stderr)
60
+ sys.exit(1)
61
+
62
+ return tags
63
+
64
+
65
+ def _write(text: str, out: str | None) -> None:
66
+ if out:
67
+ Path(out).write_text(text, encoding="utf-8")
68
+ print(f"wrote {out}", file=sys.stderr)
69
+ else:
70
+ sys.stdout.write(text)
71
+
72
+
73
+ def _write_testcases(tags, args) -> int:
74
+ """Write one stub per numeric tag, never clobbering filled-in work."""
75
+ stubs, skipped = testcases.plan(tags, args.endpoint)
76
+
77
+ for tag, reason in skipped:
78
+ print(f"skipped {tag.display_name}: {reason}", file=sys.stderr)
79
+
80
+ if not stubs:
81
+ print("error: no numeric variables among the matched tags, so there "
82
+ "is nothing a min/max spec could check.", file=sys.stderr)
83
+ return 1
84
+
85
+ out_dir = Path(args.out_dir)
86
+ out_dir.mkdir(parents=True, exist_ok=True)
87
+
88
+ written = kept = 0
89
+ for filename, text in stubs:
90
+ path = out_dir / filename
91
+ if path.exists() and not args.overwrite:
92
+ kept += 1
93
+ continue
94
+ path.write_text(text, encoding="utf-8")
95
+ written += 1
96
+
97
+ print(f"wrote {written} stub(s) to {out_dir}/", file=sys.stderr)
98
+ if kept:
99
+ print(f"left {kept} existing file(s) alone — pass --overwrite to "
100
+ "replace them", file=sys.stderr)
101
+ if written:
102
+ print("every stub needs a limit before it means anything: "
103
+ "uncomment min/max and fill in the real number.", file=sys.stderr)
104
+ return 0
105
+
106
+
107
+ def main(argv=None) -> int:
108
+ parser = argparse.ArgumentParser(prog="opcua-tagmap", description=__doc__)
109
+ sub = parser.add_subparsers(dest="command", required=True)
110
+
111
+ p_browse = sub.add_parser("browse", help="list the variables on a server")
112
+ p_browse.add_argument("--format", choices=["text", "csv", "json"],
113
+ default="text")
114
+ _add_common(p_browse)
115
+
116
+ p_gen = sub.add_parser("gen", help="generate collector configuration")
117
+ gen_sub = p_gen.add_subparsers(dest="target", required=True)
118
+ p_telegraf = gen_sub.add_parser("telegraf", help="Telegraf [[inputs.opcua]]")
119
+ p_telegraf.add_argument("--input-name", default="opcua",
120
+ help="value for the Telegraf input `name` field")
121
+ _add_common(p_telegraf)
122
+
123
+ p_tc = gen_sub.add_parser(
124
+ "testcases",
125
+ help="test-case stubs, one YAML file per numeric tag")
126
+ p_tc.add_argument("--out-dir", default="test_cases", metavar="DIR",
127
+ help="directory to write stubs into (default: test_cases)")
128
+ p_tc.add_argument("--overwrite", action="store_true",
129
+ help="replace stubs that already exist — this discards "
130
+ "limits you have filled in, so it is off by default")
131
+ _add_common(p_tc, with_out=False)
132
+
133
+ args = parser.parse_args(argv)
134
+ tags = _collect(args)
135
+
136
+ if args.command == "browse":
137
+ renderer = {"text": table.to_text, "csv": table.to_csv,
138
+ "json": table.to_json}[args.format]
139
+ _write(renderer(tags), args.out)
140
+ elif args.target == "testcases":
141
+ return _write_testcases(tags, args)
142
+ else:
143
+ _write(telegraf.render(tags, args.endpoint, args.input_name), args.out)
144
+
145
+ print(f"{len(tags)} node(s)", file=sys.stderr)
146
+ return 0
147
+
148
+
149
+ if __name__ == "__main__":
150
+ sys.exit(main())
File without changes
@@ -0,0 +1,35 @@
1
+ """Plain tag inventory: CSV or JSON.
2
+
3
+ Useful on its own — a commissioning engineer wants the list of what is on the
4
+ server regardless of which platform, if any, will collect it.
5
+ """
6
+
7
+ import csv
8
+ import io
9
+ import json
10
+
11
+ from ..model import FIELDS, TagNode
12
+
13
+
14
+ def to_csv(tags: list[TagNode]) -> str:
15
+ buf = io.StringIO()
16
+ writer = csv.DictWriter(buf, fieldnames=FIELDS, lineterminator="\n")
17
+ writer.writeheader()
18
+ for tag in tags:
19
+ writer.writerow(tag.as_dict())
20
+ return buf.getvalue()
21
+
22
+
23
+ def to_json(tags: list[TagNode]) -> str:
24
+ return json.dumps([t.as_dict() for t in tags], indent=2) + "\n"
25
+
26
+
27
+ def to_text(tags: list[TagNode]) -> str:
28
+ """Aligned columns for reading in a terminal."""
29
+ if not tags:
30
+ return ""
31
+ width = max(len(t.browse_path) for t in tags)
32
+ return "".join(
33
+ f"{t.browse_path:<{width}} {t.node_id:<28} {t.datatype}\n"
34
+ for t in tags
35
+ )
@@ -0,0 +1,68 @@
1
+ """Render discovered tags as a Telegraf `[[inputs.opcua]]` block.
2
+
3
+ Pure: takes a list of TagNode, returns text. No network, no files.
4
+ """
5
+
6
+ from ..model import TagNode
7
+
8
+ # Telegraf reads these as strings even though they are numbers/enums.
9
+ _HEADER = """\
10
+ # Generated by opcua-tagmap. {count} node(s) from {endpoint}
11
+ [[inputs.opcua]]
12
+ name = "{name}"
13
+ endpoint = "{endpoint}"
14
+ connect_timeout = "10s"
15
+ request_timeout = "5s"
16
+ security_policy = "None"
17
+ security_mode = "None"
18
+ auth_method = "Anonymous"
19
+ """
20
+
21
+ _NODE = """
22
+ [[inputs.opcua.nodes]]
23
+ name = "{name}"
24
+ namespace = "{namespace}"
25
+ identifier_type = "{identifier_type}"
26
+ identifier = "{identifier}"
27
+ """
28
+
29
+
30
+ def _toml_escape(value: str) -> str:
31
+ return value.replace("\\", "\\\\").replace('"', '\\"')
32
+
33
+
34
+ def field_names(tags: list[TagNode]) -> list[str]:
35
+ """One Telegraf field name per tag, unique within the block.
36
+
37
+ Telegraf refuses a config in which two nodes share a name. Plants reuse names all the time
38
+ (every control loop has a PV), so a name that occurs more than once gets its parents in front:
39
+ PV -> TIC101_PV, and the whole path below Objects if that is still not unique.
40
+ """
41
+ def tail(tag: TagNode, n: int) -> str:
42
+ parts = tag.browse_path.split("/")
43
+ parts = parts[1:] if len(parts) > 1 else parts # drop the leading "Objects"
44
+ return "_".join(parts[-n:] if n else parts)
45
+
46
+ names = [t.display_name for t in tags]
47
+ for n in (2, 3, 0):
48
+ counts = {x: names.count(x) for x in set(names)}
49
+ if all(c == 1 for c in counts.values()):
50
+ break
51
+ names = [tail(t, n) if counts[name] > 1 else name for t, name in zip(tags, names)]
52
+ return names
53
+
54
+
55
+ def render(tags: list[TagNode], endpoint: str, name: str = "opcua") -> str:
56
+ """Build a complete, paste-able Telegraf input block."""
57
+ out = [_HEADER.format(count=len(tags), endpoint=_toml_escape(endpoint),
58
+ name=_toml_escape(name))]
59
+
60
+ for tag, field in zip(tags, field_names(tags)):
61
+ out.append(_NODE.format(
62
+ name=_toml_escape(field),
63
+ namespace=tag.namespace,
64
+ identifier_type=tag.identifier_type,
65
+ identifier=_toml_escape(tag.identifier),
66
+ ))
67
+
68
+ return "".join(out)
@@ -0,0 +1,125 @@
1
+ """Render discovered tags as test-case stubs.
2
+
3
+ The output is the YAML shape read by opc-ua-test-automation-pipeline: one file
4
+ per numeric tag, with the structure filled in and every limit left blank.
5
+
6
+ The blanks are the point. This module must never guess a limit — a plausible
7
+ invented number produces a test that passes for no reason, which is worse than
8
+ having no test at all. Limits come from a datasheet and a person.
9
+
10
+ Pure: takes TagNode objects, returns text. No network, no files.
11
+ """
12
+
13
+ from ..model import TagNode
14
+
15
+ # OPC UA variant type names that a min/max spec can actually be applied to.
16
+ NUMERIC_TYPES = frozenset({
17
+ "Double", "Float",
18
+ "SByte", "Byte",
19
+ "Int16", "UInt16", "Int32", "UInt32", "Int64", "UInt64",
20
+ })
21
+
22
+ # Industrial tag names usually carry their unit as a suffix. This is a
23
+ # convenience only, and the generated file says so.
24
+ _UNIT_SUFFIXES = [
25
+ ("_mbar", "mbar"), ("_bar", "bar"), ("_kPa", "kPa"), ("_Pa", "Pa"),
26
+ ("_mV", "mV"), ("_V", "V"), ("_mA", "mA"), ("_A", "A"),
27
+ ("_kW", "kW"), ("_W", "W"), ("_kWh", "kWh"),
28
+ ("_ms", "ms"), ("_s", "s"), ("_Hz", "Hz"),
29
+ ("_rpm", "rpm"), ("_C", "C"), ("_K", "K"),
30
+ ("_pct", "%"), ("_mm", "mm"), ("_m", "m"), ("_l", "l"),
31
+ ]
32
+
33
+ _TEMPLATE = """\
34
+ # Generated by opcua-tagmap from {endpoint}
35
+ #
36
+ # This is a STUB. It will not judge anything until you give it a limit:
37
+ # uncomment min and/or max under `spec` and put the real number there,
38
+ # taken from the datasheet or the machine specification.
39
+ #
40
+ # If this variable does not need a test, delete this file.
41
+
42
+ test_id: {test_id}
43
+ title: TODO - what is being proven about {display_name}?
44
+ author: TODO
45
+ hypothesis: >
46
+ TODO - what do you believe is true, and why does it matter?
47
+ procedure:
48
+ - TODO - how is the machine driven while this is measured?
49
+ - Sample {display_name} for the duration of that event.
50
+
51
+ variable: {display_name}
52
+ node_id: {node_id} # {browse_path}
53
+
54
+ # Only check while something else is happening - e.g. only during an inrush,
55
+ # or only while the motor runs. Delete both lines to check the whole run.
56
+ # condition_variable: TODO
57
+ # condition_min: 0.0
58
+
59
+ spec:
60
+ # min:{units_min_pad} # lower limit, if there is one
61
+ # max:{units_min_pad} # upper limit, if there is one
62
+ units: {units}{units_comment}
63
+
64
+ expected_result: >
65
+ TODO - state the pass condition in one sentence.
66
+ on_failure: >
67
+ TODO - if this fails, what should someone go and look at?
68
+ """
69
+
70
+
71
+ def is_numeric(tag: TagNode) -> bool:
72
+ """True when a min/max spec can be applied to this tag."""
73
+ return tag.datatype in NUMERIC_TYPES
74
+
75
+
76
+ def guess_units(display_name: str) -> tuple[str, bool]:
77
+ """Best-effort unit from a tag-name suffix.
78
+
79
+ Returns (units, guessed). `guessed` is False when nothing matched, so the
80
+ caller can mark the field as needing attention rather than silently
81
+ asserting a unit that is not there.
82
+ """
83
+ for suffix, unit in _UNIT_SUFFIXES:
84
+ if display_name.endswith(suffix):
85
+ return unit, True
86
+ return "TODO", False
87
+
88
+
89
+ def stub_filename(tag: TagNode) -> str:
90
+ """File name for a tag's stub, safe on every platform."""
91
+ safe = "".join(c if (c.isalnum() or c in "-_") else "_"
92
+ for c in tag.display_name)
93
+ return f"TC-{safe}.yaml"
94
+
95
+
96
+ def render(tag: TagNode, endpoint: str) -> str:
97
+ """Build one test-case stub. Limits are left blank deliberately."""
98
+ units, guessed = guess_units(tag.display_name)
99
+ return _TEMPLATE.format(
100
+ endpoint=endpoint,
101
+ test_id=f"TC-{tag.display_name}",
102
+ display_name=tag.display_name,
103
+ node_id=tag.node_id,
104
+ browse_path=tag.browse_path,
105
+ units=units,
106
+ units_comment="" if guessed else " # could not infer from the name",
107
+ units_min_pad="",
108
+ )
109
+
110
+
111
+ def plan(tags: list[TagNode], endpoint: str) -> tuple[list, list]:
112
+ """Split tags into stubs to write and tags that cannot have one.
113
+
114
+ Returns (stubs, skipped) where stubs is a list of (filename, text) and
115
+ skipped is a list of (tag, reason).
116
+ """
117
+ stubs, skipped = [], []
118
+ for tag in tags:
119
+ if is_numeric(tag):
120
+ stubs.append((stub_filename(tag), render(tag, endpoint)))
121
+ else:
122
+ skipped.append(
123
+ (tag, f"{tag.datatype} - a min/max spec cannot express it")
124
+ )
125
+ return stubs, skipped
@@ -0,0 +1,98 @@
1
+ """Glob matching over browse paths.
2
+
3
+ This is the piece Telegraf users keep asking for (influxdata/telegraf#18529):
4
+ say `Objects/Plant1/*/MV*` instead of listing four hundred nodes by hand.
5
+
6
+ Segment-aware, so `*` stops at a `/`:
7
+
8
+ * any run of characters within one segment
9
+ ? one character within one segment
10
+ [abc] character class; [!abc] negates
11
+ ** any number of whole segments, including none
12
+
13
+ Pure functions — no OPC UA, no I/O, no network.
14
+ """
15
+
16
+ import re
17
+ from typing import Iterable
18
+
19
+ from .model import TagNode
20
+
21
+
22
+ def translate(pattern: str) -> str:
23
+ """Glob pattern -> anchored regex source."""
24
+ out: list[str] = []
25
+ i, n = 0, len(pattern)
26
+
27
+ while i < n:
28
+ c = pattern[i]
29
+
30
+ if c == "*":
31
+ if pattern[i:i + 2] == "**":
32
+ i += 2
33
+ # `**/` and a trailing `**` both mean "zero or more segments"
34
+ if pattern[i:i + 1] == "/":
35
+ i += 1
36
+ out.append("(?:[^/]*/)*")
37
+ else:
38
+ out.append(".*")
39
+ else:
40
+ out.append("[^/]*")
41
+ i += 1
42
+
43
+ elif c == "?":
44
+ out.append("[^/]")
45
+ i += 1
46
+
47
+ elif c == "[":
48
+ j = i + 1
49
+ if j < n and pattern[j] in "!^":
50
+ j += 1
51
+ if j < n and pattern[j] == "]": # a ] right after [ is a literal
52
+ j += 1
53
+ while j < n and pattern[j] != "]":
54
+ j += 1
55
+ if j >= n: # unclosed bracket: treat as a literal [
56
+ out.append(re.escape(c))
57
+ i += 1
58
+ else:
59
+ inner = pattern[i + 1:j]
60
+ if inner[:1] in ("!", "^"):
61
+ inner = "^" + inner[1:]
62
+ out.append("[" + inner.replace("\\", "\\\\") + "]")
63
+ i = j + 1
64
+
65
+ else:
66
+ out.append(re.escape(c))
67
+ i += 1
68
+
69
+ return "^" + "".join(out) + "$"
70
+
71
+
72
+ def compile_patterns(patterns: Iterable[str]) -> list[re.Pattern]:
73
+ return [re.compile(translate(p)) for p in patterns]
74
+
75
+
76
+ def matches_any(path: str, compiled: list[re.Pattern]) -> bool:
77
+ return any(rx.match(path) for rx in compiled)
78
+
79
+
80
+ def filter_tags(tags: Iterable[TagNode],
81
+ include: Iterable[str] | None = None,
82
+ exclude: Iterable[str] | None = None) -> list[TagNode]:
83
+ """Keep tags matching any include pattern, then drop any matching exclude.
84
+
85
+ No include patterns means keep everything — the common case of wanting the
86
+ whole server.
87
+ """
88
+ inc = compile_patterns(include or [])
89
+ exc = compile_patterns(exclude or [])
90
+
91
+ kept = []
92
+ for tag in tags:
93
+ if inc and not matches_any(tag.browse_path, inc):
94
+ continue
95
+ if exc and matches_any(tag.browse_path, exc):
96
+ continue
97
+ kept.append(tag)
98
+ return kept
opcua_tagmap/model.py ADDED
@@ -0,0 +1,37 @@
1
+ """The one data structure everything else passes around."""
2
+
3
+ from dataclasses import dataclass, asdict
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class TagNode:
8
+ """A single readable variable found on an OPC UA server.
9
+
10
+ `browse_path` is the human-readable route to the node, slash separated and
11
+ starting at Objects — e.g. "Objects/ProtectionPanel/CoilVoltage_V". It is
12
+ what glob patterns match against, and what a person recognises.
13
+
14
+ The NodeId trio (`namespace`, `identifier_type`, `identifier`) is what
15
+ every downstream collector actually needs in its config.
16
+ """
17
+
18
+ browse_path: str
19
+ display_name: str
20
+ namespace: int
21
+ identifier_type: str # "s" string, "i" numeric, "g" guid, "b" bytestring
22
+ identifier: str
23
+ datatype: str # variant type name: Double, String, Boolean, ...
24
+
25
+ @property
26
+ def node_id(self) -> str:
27
+ """Canonical OPC UA NodeId string, e.g. ns=2;s=CoilVoltage_V."""
28
+ return f"ns={self.namespace};{self.identifier_type}={self.identifier}"
29
+
30
+ def as_dict(self) -> dict:
31
+ d = asdict(self)
32
+ d["node_id"] = self.node_id
33
+ return d
34
+
35
+
36
+ FIELDS = ["browse_path", "display_name", "namespace", "identifier_type",
37
+ "identifier", "datatype", "node_id"]
@@ -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,15 @@
1
+ opcua_tagmap/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ opcua_tagmap/browse.py,sha256=51m2TtjlS5yEf7V0Oheqa6vw_as4Z2rWDIaKjktKay4,4696
3
+ opcua_tagmap/cli.py,sha256=28g11TNlQzx24RUxP1KR5XUMrysmg9ID7iM6nR0K2SY,6013
4
+ opcua_tagmap/globmatch.py,sha256=sPvthiPRup2BuqUOHBxkiIakd2YPWfY-0hpAhMF6jKQ,2911
5
+ opcua_tagmap/model.py,sha256=HADQe29EmT0hE4kDFgRdpCzKIKD23Xoe0zB0xTmSZ7U,1237
6
+ opcua_tagmap/emit/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ opcua_tagmap/emit/table.py,sha256=BgB3pWVWlRObQBWmwYP1bs4K-wAvMsfbZN5NbKHVvFg,914
8
+ opcua_tagmap/emit/telegraf.py,sha256=qdpovLTKFXZ2gNgfkhIFv_UcsKtrGgIHa76uLn-6_TI,2275
9
+ opcua_tagmap/emit/testcases.py,sha256=ms5qumlfd0LH5FSx0LsskN9rfPrYu_5ufY7Uf_EFLSI,4314
10
+ opcua_tagmap-0.2.0.dist-info/licenses/LICENSE,sha256=icYzgdETaqO4KDzmh6wTqZ6M9i2omUy6Wyx8DX5YZbw,1068
11
+ opcua_tagmap-0.2.0.dist-info/METADATA,sha256=aPvZ7ZA7FVBX2lYBpyGD8IvqkxprTVos_yyiA-1cxpg,9189
12
+ opcua_tagmap-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
13
+ opcua_tagmap-0.2.0.dist-info/entry_points.txt,sha256=j8f0p9YWUDEKpXDSJNvVwHKq1BArlL8Oid_zBqYhnQM,55
14
+ opcua_tagmap-0.2.0.dist-info/top_level.txt,sha256=sqgLxTWMKvtzP77P06m2COwOILvHEn3JQ8EIkpJma1Q,13
15
+ opcua_tagmap-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ opcua-tagmap = opcua_tagmap.cli:main
@@ -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 @@
1
+ opcua_tagmap