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.
- opcua_tagmap/__init__.py +0 -0
- opcua_tagmap/browse.py +128 -0
- opcua_tagmap/cli.py +150 -0
- opcua_tagmap/emit/__init__.py +0 -0
- opcua_tagmap/emit/table.py +35 -0
- opcua_tagmap/emit/telegraf.py +68 -0
- opcua_tagmap/emit/testcases.py +125 -0
- opcua_tagmap/globmatch.py +98 -0
- opcua_tagmap/model.py +37 -0
- opcua_tagmap-0.2.0.dist-info/METADATA +234 -0
- opcua_tagmap-0.2.0.dist-info/RECORD +15 -0
- opcua_tagmap-0.2.0.dist-info/WHEEL +5 -0
- opcua_tagmap-0.2.0.dist-info/entry_points.txt +2 -0
- opcua_tagmap-0.2.0.dist-info/licenses/LICENSE +21 -0
- opcua_tagmap-0.2.0.dist-info/top_level.txt +1 -0
opcua_tagmap/__init__.py
ADDED
|
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
|
+
[](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,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
|