sylo-plc-comms 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/extensions/index.ts +326 -0
- package/package.json +29 -0
- package/scripts/__pycache__/_cip_client.cpython-312.pyc +0 -0
- package/scripts/__pycache__/_download_allowlist.cpython-312.pyc +0 -0
- package/scripts/__pycache__/_json_out.cpython-312.pyc +0 -0
- package/scripts/__pycache__/_opcua_client.cpython-312.pyc +0 -0
- package/scripts/__pycache__/cip_plc_info.cpython-312.pyc +0 -0
- package/scripts/__pycache__/cip_tag_list.cpython-312.pyc +0 -0
- package/scripts/__pycache__/cip_tag_read.cpython-312.pyc +0 -0
- package/scripts/__pycache__/cip_tag_write.cpython-312.pyc +0 -0
- package/scripts/__pycache__/opcua_browse.cpython-312.pyc +0 -0
- package/scripts/__pycache__/opcua_read.cpython-312.pyc +0 -0
- package/scripts/__pycache__/opcua_status.cpython-312.pyc +0 -0
- package/scripts/__pycache__/opcua_tag_list.cpython-312.pyc +0 -0
- package/scripts/__pycache__/opcua_write.cpython-312.pyc +0 -0
- package/scripts/_cip_client.py +139 -0
- package/scripts/_download_allowlist.py +111 -0
- package/scripts/_json_out.py +19 -0
- package/scripts/_opcua_client.py +142 -0
- package/scripts/cip_plc_info.py +57 -0
- package/scripts/cip_tag_list.py +99 -0
- package/scripts/cip_tag_read.py +130 -0
- package/scripts/cip_tag_write.py +169 -0
- package/scripts/opcua_browse.py +139 -0
- package/scripts/opcua_read.py +149 -0
- package/scripts/opcua_status.py +102 -0
- package/scripts/opcua_tag_list.py +160 -0
- package/scripts/opcua_write.py +212 -0
- package/scripts/requirements.txt +3 -0
- package/vendor/ciplogix/README.md +38 -0
- package/vendor/ciplogix/ciplogix-1.1.0-py3-none-any.whl +0 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Write one or many Logix tags to a PLC via ciplogix.
|
|
3
|
+
|
|
4
|
+
GATED by the operator-managed download allowlist: the target IP must be present
|
|
5
|
+
and enabled. This reuses the same allowlist as project downloads — it is the
|
|
6
|
+
operator's explicit "this PLC is okay to touch" list. The agent cannot write to
|
|
7
|
+
any IP not on it, even with operator permission, and never edits the allowlist.
|
|
8
|
+
|
|
9
|
+
Inputs: a JSON array of {"tag": "...", "value": <json>} objects via --stdin,
|
|
10
|
+
--writes-json, or a single --tag/--value pair.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
echo '[{"tag":"MyDint","value":42}]' | py -3.12 cip_tag_write.py --ip 10.1.200.45 --stdin
|
|
14
|
+
py -3.12 cip_tag_write.py --ip 10.1.200.45 --tag MyDint --value 42
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from _cip_client import ensure_ciplogix, open_driver
|
|
25
|
+
from _download_allowlist import load_allowlist
|
|
26
|
+
from _json_out import emit, emit_error
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _ip_allowed(ip: str) -> tuple[bool, str]:
|
|
30
|
+
al = load_allowlist()
|
|
31
|
+
for entry in al.get("ips", []):
|
|
32
|
+
if isinstance(entry, dict) and entry.get("ip") == ip:
|
|
33
|
+
if entry.get("enabled", True):
|
|
34
|
+
return True, "ok"
|
|
35
|
+
return False, f"IP {ip} is in the allowlist but disabled."
|
|
36
|
+
return False, f"IP {ip} is not in the allowlist. The agent cannot write tags to it."
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _coerce_in(v: Any) -> Any:
|
|
40
|
+
"""Best-effort JSON -> pycomm3 value coercion. pycomm3 accepts native types
|
|
41
|
+
directly; the main gotcha is that JSON bools arrive fine and ints/floats are
|
|
42
|
+
native. Strings for STRING tags must be str. We leave values as-is except
|
|
43
|
+
we keep bools as bool (JSON true/false) and pass lists through."""
|
|
44
|
+
return v
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main() -> None:
|
|
48
|
+
parser = argparse.ArgumentParser(description="Write Logix tags via ciplogix (allowlist-gated)")
|
|
49
|
+
parser.add_argument("--ip", required=True, help="Controller IPv4 address")
|
|
50
|
+
parser.add_argument("--tag", default="", help="Single tag name (paired with --value)")
|
|
51
|
+
parser.add_argument("--value", default="", help="Single tag value (parsed as JSON if possible)")
|
|
52
|
+
parser.add_argument("--writes-json", default="", help="JSON array of {tag,value} objects")
|
|
53
|
+
parser.add_argument("--stdin", action="store_true", help="Read JSON array of {tag,value} from stdin")
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--dry-run",
|
|
56
|
+
action="store_true",
|
|
57
|
+
help="Resolve and validate writes but do not send to the PLC",
|
|
58
|
+
)
|
|
59
|
+
args = parser.parse_args()
|
|
60
|
+
|
|
61
|
+
allowed, reason = _ip_allowed(args.ip)
|
|
62
|
+
if not allowed:
|
|
63
|
+
emit_error(reason)
|
|
64
|
+
|
|
65
|
+
writes: list[dict[str, Any]] = []
|
|
66
|
+
if args.stdin:
|
|
67
|
+
raw = sys.stdin.read().strip()
|
|
68
|
+
try:
|
|
69
|
+
parsed = json.loads(raw) if raw else []
|
|
70
|
+
except json.JSONDecodeError as exc:
|
|
71
|
+
emit_error(f"stdin is not valid JSON: {exc}")
|
|
72
|
+
return
|
|
73
|
+
if not isinstance(parsed, list):
|
|
74
|
+
emit_error("stdin JSON must be an array of {tag,value} objects.")
|
|
75
|
+
return
|
|
76
|
+
writes = parsed
|
|
77
|
+
if args.writes_json:
|
|
78
|
+
try:
|
|
79
|
+
parsed = json.loads(args.writes_json)
|
|
80
|
+
except json.JSONDecodeError as exc:
|
|
81
|
+
emit_error(f"--writes-json is not valid JSON: {exc}")
|
|
82
|
+
return
|
|
83
|
+
if isinstance(parsed, list):
|
|
84
|
+
writes.extend(parsed)
|
|
85
|
+
elif isinstance(parsed, dict):
|
|
86
|
+
writes.append(parsed)
|
|
87
|
+
if args.tag:
|
|
88
|
+
val: Any
|
|
89
|
+
vraw = args.value
|
|
90
|
+
try:
|
|
91
|
+
val = json.loads(vraw) if vraw != "" else None
|
|
92
|
+
except json.JSONDecodeError:
|
|
93
|
+
val = vraw # treat as plain string
|
|
94
|
+
writes.append({"tag": args.tag, "value": val})
|
|
95
|
+
|
|
96
|
+
# Normalize
|
|
97
|
+
clean: list[tuple[str, Any]] = []
|
|
98
|
+
for w in writes:
|
|
99
|
+
if not isinstance(w, dict) or "tag" not in w:
|
|
100
|
+
emit_error("Each write must be an object with a 'tag' field.")
|
|
101
|
+
return
|
|
102
|
+
name = str(w["tag"]).strip()
|
|
103
|
+
if not name:
|
|
104
|
+
emit_error("Empty tag name in write list.")
|
|
105
|
+
return
|
|
106
|
+
clean.append((name, _coerce_in(w.get("value"))))
|
|
107
|
+
|
|
108
|
+
if not clean:
|
|
109
|
+
emit_error("No writes supplied. Use --tag/--value, --writes-json, or --stdin.")
|
|
110
|
+
|
|
111
|
+
if args.dry_run:
|
|
112
|
+
emit({
|
|
113
|
+
"ok": True,
|
|
114
|
+
"ip": args.ip,
|
|
115
|
+
"dry_run": True,
|
|
116
|
+
"writes": [{"tag": n, "value": v} for n, v in clean],
|
|
117
|
+
})
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
ensure_ciplogix()
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
emit_error(f"ciplogix setup failed: {exc}")
|
|
124
|
+
|
|
125
|
+
plc = open_driver(args.ip, init_tags=True)
|
|
126
|
+
try:
|
|
127
|
+
results: list[dict] = []
|
|
128
|
+
# ciplogix supports plc.write((tag, value), (tag, value), ...) multi-write
|
|
129
|
+
try:
|
|
130
|
+
resp = plc.write(*[(n, v) for n, v in clean])
|
|
131
|
+
except Exception as exc:
|
|
132
|
+
for n, v in clean:
|
|
133
|
+
results.append({"tag": n, "value": v, "ok": False, "error": str(exc)})
|
|
134
|
+
emit({"ok": True, "ip": args.ip, "results": results, "all_failed": True})
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
resp_list = resp if isinstance(resp, list) else [resp]
|
|
138
|
+
for (name, value), tag in zip(clean, resp_list):
|
|
139
|
+
err = None
|
|
140
|
+
try:
|
|
141
|
+
err = getattr(tag, "error", None)
|
|
142
|
+
if not err and getattr(tag, "status", None) not in (None, "", "Success"):
|
|
143
|
+
err = str(getattr(tag, "status"))
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
results.append({
|
|
147
|
+
"tag": name,
|
|
148
|
+
"value": value,
|
|
149
|
+
"ok": not err,
|
|
150
|
+
"error": err,
|
|
151
|
+
})
|
|
152
|
+
ok_count = sum(1 for r in results if r["ok"])
|
|
153
|
+
emit({
|
|
154
|
+
"ok": True,
|
|
155
|
+
"ip": args.ip,
|
|
156
|
+
"requested": len(clean),
|
|
157
|
+
"succeeded": ok_count,
|
|
158
|
+
"failed": len(clean) - ok_count,
|
|
159
|
+
"results": results,
|
|
160
|
+
})
|
|
161
|
+
finally:
|
|
162
|
+
try:
|
|
163
|
+
plc.close()
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__":
|
|
169
|
+
main()
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Browse an OPC UA address space node and return its children.
|
|
3
|
+
|
|
4
|
+
Read-only. Default starting node is the root Objects folder. Pass a node-id
|
|
5
|
+
spec (ns=2;s=... or bare name) to browse a specific node.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
py -3.12 opcua_browse.py --ip 10.1.200.45
|
|
9
|
+
py -3.12 opcua_browse.py --ip 10.1.200.45 --node "ns=2;s=MyTag"
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import asyncio
|
|
16
|
+
|
|
17
|
+
from _json_out import emit, emit_error
|
|
18
|
+
from _opcua_client import (
|
|
19
|
+
connect,
|
|
20
|
+
default_endpoint,
|
|
21
|
+
ensure_asyncua,
|
|
22
|
+
jsonable,
|
|
23
|
+
parse_node_id,
|
|
24
|
+
tags_namespace_index,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def run(endpoint: str, node_spec: str, default_ns: int) -> dict:
|
|
29
|
+
from asyncua import ua
|
|
30
|
+
|
|
31
|
+
client = await connect(endpoint)
|
|
32
|
+
try:
|
|
33
|
+
if node_spec:
|
|
34
|
+
node = client.get_node(parse_node_id(node_spec, default_ns))
|
|
35
|
+
else:
|
|
36
|
+
node = client.get_node(ua.ObjectIds.ObjectsFolder)
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
children = await node.get_children()
|
|
40
|
+
except Exception as exc:
|
|
41
|
+
emit_error(f"browse failed for {node_spec or 'Objects'}: {exc}")
|
|
42
|
+
|
|
43
|
+
out_children = []
|
|
44
|
+
for ch in children:
|
|
45
|
+
entry = {
|
|
46
|
+
"node_id": str(ch.nodeid),
|
|
47
|
+
"browse_name": None,
|
|
48
|
+
"display_name": None,
|
|
49
|
+
"node_class": None,
|
|
50
|
+
"data_type": None,
|
|
51
|
+
"value": None,
|
|
52
|
+
}
|
|
53
|
+
try:
|
|
54
|
+
bn = await ch.read_browse_name()
|
|
55
|
+
entry["browse_name"] = str(bn)
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
try:
|
|
59
|
+
dn = await ch.read_display_name()
|
|
60
|
+
entry["display_name"] = str(dn)
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
try:
|
|
64
|
+
nc = await ch.read_node_class()
|
|
65
|
+
entry["node_class"] = str(nc)
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
# If it's a Variable, grab data type + value
|
|
69
|
+
if entry["node_class"] and "Variable" in str(entry["node_class"]):
|
|
70
|
+
try:
|
|
71
|
+
dt = await ch.read_data_type()
|
|
72
|
+
entry["data_type"] = str(dt)
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
try:
|
|
76
|
+
val = await ch.read_value()
|
|
77
|
+
entry["value"] = jsonable(val)
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
out_children.append(entry)
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
"ok": True,
|
|
84
|
+
"endpoint": endpoint,
|
|
85
|
+
"browsed_node": node_spec or "ObjectsFolder",
|
|
86
|
+
"child_count": len(out_children),
|
|
87
|
+
"children": out_children,
|
|
88
|
+
}
|
|
89
|
+
finally:
|
|
90
|
+
try:
|
|
91
|
+
await client.close()
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main() -> None:
|
|
97
|
+
parser = argparse.ArgumentParser(description="Browse OPC UA address space (read-only)")
|
|
98
|
+
parser.add_argument("--endpoint", default="", help="Full opc.tcp:// endpoint (overrides --ip)")
|
|
99
|
+
parser.add_argument("--ip", default="", help="Controller IP")
|
|
100
|
+
parser.add_argument("--port", type=int, default=4840, help="OPC UA port (default 4840)")
|
|
101
|
+
parser.add_argument("--node", default="", help="Node-id spec to browse (default Objects folder)")
|
|
102
|
+
parser.add_argument(
|
|
103
|
+
"--namespace",
|
|
104
|
+
type=int,
|
|
105
|
+
default=None,
|
|
106
|
+
help="Default namespace index for bare node specs (auto: Tags namespace, else 2)",
|
|
107
|
+
)
|
|
108
|
+
args = parser.parse_args()
|
|
109
|
+
|
|
110
|
+
endpoint = args.endpoint.strip() or default_endpoint(args.ip, args.port)
|
|
111
|
+
if not endpoint:
|
|
112
|
+
emit_error("Provide --endpoint or --ip.")
|
|
113
|
+
|
|
114
|
+
async def resolve_ns() -> int:
|
|
115
|
+
if args.namespace is not None:
|
|
116
|
+
return args.namespace
|
|
117
|
+
client = await connect(endpoint)
|
|
118
|
+
try:
|
|
119
|
+
return await tags_namespace_index(client) or 2
|
|
120
|
+
finally:
|
|
121
|
+
try:
|
|
122
|
+
await client.close()
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
ensure_asyncua()
|
|
128
|
+
default_ns = asyncio.run(resolve_ns())
|
|
129
|
+
result = asyncio.run(run(endpoint, args.node.strip(), default_ns))
|
|
130
|
+
except SystemExit:
|
|
131
|
+
raise
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
emit_error(f"opcua_browse failed: {exc}")
|
|
134
|
+
return
|
|
135
|
+
emit(result)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
main()
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read one or many OPC UA nodes by node-id spec or tag path.
|
|
3
|
+
|
|
4
|
+
Accepts:
|
|
5
|
+
--nodes "ns=2;s=MyTag,MyOtherTag" (bare names use Tags namespace, ns=2)
|
|
6
|
+
--nodes-json '["ns=2;s=MyTag","MyDint"]'
|
|
7
|
+
--stdin (JSON array of specs)
|
|
8
|
+
|
|
9
|
+
Returns per-node value + status. Uses asyncua read for each node.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
py -3.12 opcua_read.py --ip 10.1.200.45 --nodes "SafetyOneShot,IA000105"
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import asyncio
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from _json_out import emit, emit_error
|
|
22
|
+
from _opcua_client import (
|
|
23
|
+
connect,
|
|
24
|
+
default_endpoint,
|
|
25
|
+
ensure_asyncua,
|
|
26
|
+
jsonable,
|
|
27
|
+
parse_node_id,
|
|
28
|
+
tags_namespace_index,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def read_one(client, spec: str, default_ns: int) -> dict:
|
|
33
|
+
from asyncua import ua
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
node = client.get_node(parse_node_id(spec, default_ns))
|
|
37
|
+
except Exception as exc:
|
|
38
|
+
return {"node": spec, "ok": False, "value": None, "error": f"bad node id: {exc}"}
|
|
39
|
+
try:
|
|
40
|
+
val = await node.read_value()
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
return {"node": spec, "ok": False, "value": None, "error": str(exc)}
|
|
43
|
+
out: dict[str, Any] = {"node": spec, "ok": True, "value": jsonable(val), "error": None}
|
|
44
|
+
try:
|
|
45
|
+
dt = await node.read_data_type()
|
|
46
|
+
out["data_type"] = str(dt)
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
try:
|
|
50
|
+
# Source timestamp for diagnostics
|
|
51
|
+
ts = await node.read_attribute(ua.Attributes.SourceTimestamp)
|
|
52
|
+
out["source_timestamp"] = jsonable(ts.Value.Value if hasattr(ts, "Value") else None)
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def run(endpoint: str, specs: list[str], default_ns: int) -> dict:
|
|
59
|
+
client = await connect(endpoint)
|
|
60
|
+
try:
|
|
61
|
+
results = []
|
|
62
|
+
for s in specs:
|
|
63
|
+
results.append(await read_one(client, s, default_ns))
|
|
64
|
+
ok = sum(1 for r in results if r["ok"])
|
|
65
|
+
return {
|
|
66
|
+
"ok": True,
|
|
67
|
+
"endpoint": endpoint,
|
|
68
|
+
"namespace": default_ns,
|
|
69
|
+
"requested": len(specs),
|
|
70
|
+
"succeeded": ok,
|
|
71
|
+
"failed": len(specs) - ok,
|
|
72
|
+
"results": results,
|
|
73
|
+
}
|
|
74
|
+
finally:
|
|
75
|
+
try:
|
|
76
|
+
await client.close()
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main() -> None:
|
|
82
|
+
import json
|
|
83
|
+
import sys
|
|
84
|
+
|
|
85
|
+
parser = argparse.ArgumentParser(description="Read OPC UA nodes (read-only)")
|
|
86
|
+
parser.add_argument("--endpoint", default="", help="Full opc.tcp:// endpoint (overrides --ip)")
|
|
87
|
+
parser.add_argument("--ip", default="", help="Controller IP")
|
|
88
|
+
parser.add_argument("--port", type=int, default=4840, help="OPC UA port (default 4840)")
|
|
89
|
+
parser.add_argument("--nodes", default="", help="Comma-separated node-id specs / tag names")
|
|
90
|
+
parser.add_argument("--nodes-json", default="", help="JSON array of node-id specs")
|
|
91
|
+
parser.add_argument("--stdin", action="store_true", help="Read JSON array from stdin")
|
|
92
|
+
parser.add_argument("--namespace", type=int, default=None, help="Default ns for bare names (auto)")
|
|
93
|
+
args = parser.parse_args()
|
|
94
|
+
|
|
95
|
+
endpoint = args.endpoint.strip() or default_endpoint(args.ip, args.port)
|
|
96
|
+
if not endpoint:
|
|
97
|
+
emit_error("Provide --endpoint or --ip.")
|
|
98
|
+
|
|
99
|
+
specs: list[str] = []
|
|
100
|
+
if args.stdin:
|
|
101
|
+
raw = sys.stdin.read().strip()
|
|
102
|
+
try:
|
|
103
|
+
parsed = json.loads(raw) if raw else []
|
|
104
|
+
except json.JSONDecodeError as exc:
|
|
105
|
+
emit_error(f"stdin is not valid JSON: {exc}")
|
|
106
|
+
return
|
|
107
|
+
if not isinstance(parsed, list):
|
|
108
|
+
emit_error("stdin JSON must be an array of node specs.")
|
|
109
|
+
return
|
|
110
|
+
specs = [str(x).strip() for x in parsed if str(x).strip()]
|
|
111
|
+
if args.nodes_json:
|
|
112
|
+
try:
|
|
113
|
+
parsed = json.loads(args.nodes_json)
|
|
114
|
+
except json.JSONDecodeError as exc:
|
|
115
|
+
emit_error(f"--nodes-json is not valid JSON: {exc}")
|
|
116
|
+
return
|
|
117
|
+
specs.extend(str(x).strip() for x in parsed if str(x).strip())
|
|
118
|
+
if args.nodes:
|
|
119
|
+
specs.extend(x.strip() for x in args.nodes.split(",") if x.strip())
|
|
120
|
+
specs = [s for s in specs if s]
|
|
121
|
+
if not specs:
|
|
122
|
+
emit_error("No node specs supplied. Use --nodes, --nodes-json, or --stdin.")
|
|
123
|
+
|
|
124
|
+
async def resolve_ns() -> int:
|
|
125
|
+
if args.namespace is not None:
|
|
126
|
+
return args.namespace
|
|
127
|
+
client = await connect(endpoint)
|
|
128
|
+
try:
|
|
129
|
+
return await tags_namespace_index(client) or 2
|
|
130
|
+
finally:
|
|
131
|
+
try:
|
|
132
|
+
await client.close()
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
ensure_asyncua()
|
|
138
|
+
default_ns = asyncio.run(resolve_ns())
|
|
139
|
+
result = asyncio.run(run(endpoint, specs, default_ns))
|
|
140
|
+
except SystemExit:
|
|
141
|
+
raise
|
|
142
|
+
except Exception as exc:
|
|
143
|
+
emit_error(f"opcua_read failed: {exc}")
|
|
144
|
+
return
|
|
145
|
+
emit(result)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
main()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Probe an OPC UA server: endpoints, security policies, namespaces, server state.
|
|
3
|
+
|
|
4
|
+
Read-only. Use to confirm the server is reachable and discover the tags
|
|
5
|
+
namespace index before browse/read/write.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
py -3.12 opcua_status.py --endpoint opc.tcp://10.1.200.45:4840
|
|
9
|
+
py -3.12 opcua_status.py --ip 10.1.200.45
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import asyncio
|
|
16
|
+
|
|
17
|
+
from _json_out import emit, emit_error
|
|
18
|
+
from _opcua_client import (
|
|
19
|
+
connect,
|
|
20
|
+
default_endpoint,
|
|
21
|
+
ensure_asyncua,
|
|
22
|
+
namespaces,
|
|
23
|
+
tags_namespace_index,
|
|
24
|
+
jsonable,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def run(endpoint: str) -> dict:
|
|
29
|
+
client = await connect(endpoint)
|
|
30
|
+
try:
|
|
31
|
+
# Endpoints + security policies
|
|
32
|
+
endpoints = []
|
|
33
|
+
try:
|
|
34
|
+
eps = await client.get_endpoints()
|
|
35
|
+
for ep in eps:
|
|
36
|
+
endpoints.append({
|
|
37
|
+
"endpoint": str(ep.EndpointUrl),
|
|
38
|
+
"security_policy": str(ep.SecurityPolicyUri),
|
|
39
|
+
"security_mode": str(ep.SecurityMode),
|
|
40
|
+
"transport": str(ep.TransportPolicyUri) if ep.TransportPolicyUri else None,
|
|
41
|
+
"user_identity_tokens": [
|
|
42
|
+
str(t.PolicyId) for t in (ep.UserIdentityTokens or [])
|
|
43
|
+
],
|
|
44
|
+
})
|
|
45
|
+
except Exception as exc:
|
|
46
|
+
endpoints = [{"error": str(exc)}]
|
|
47
|
+
|
|
48
|
+
# Server state / status
|
|
49
|
+
server_state = None
|
|
50
|
+
server_status = None
|
|
51
|
+
try:
|
|
52
|
+
svr = client.get_server_node()
|
|
53
|
+
server_state = jsonable(await svr.get_child(["0:ServerStatus"]))
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
try:
|
|
57
|
+
state_node = client.get_node("i=2259") # Server_ServerStatus
|
|
58
|
+
server_status = jsonable(await state_node.read_value())
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
server_status = {"error": str(exc)}
|
|
61
|
+
|
|
62
|
+
ns = await namespaces(client)
|
|
63
|
+
tags_ns = await tags_namespace_index(client)
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
"ok": True,
|
|
67
|
+
"endpoint": endpoint,
|
|
68
|
+
"connected": True,
|
|
69
|
+
"endpoints": endpoints,
|
|
70
|
+
"namespaces": ns,
|
|
71
|
+
"tags_namespace_index": tags_ns,
|
|
72
|
+
"server_status": server_status,
|
|
73
|
+
"server_state": server_state,
|
|
74
|
+
}
|
|
75
|
+
finally:
|
|
76
|
+
try:
|
|
77
|
+
await client.close()
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def main() -> None:
|
|
83
|
+
parser = argparse.ArgumentParser(description="Probe an OPC UA server (read-only)")
|
|
84
|
+
parser.add_argument("--endpoint", default="", help="Full opc.tcp:// endpoint (overrides --ip)")
|
|
85
|
+
parser.add_argument("--ip", default="", help="Controller IP (default endpoint opc.tcp://IP:4840)")
|
|
86
|
+
parser.add_argument("--port", type=int, default=4840, help="OPC UA port (default 4840)")
|
|
87
|
+
args = parser.parse_args()
|
|
88
|
+
|
|
89
|
+
endpoint = args.endpoint.strip() or default_endpoint(args.ip, args.port)
|
|
90
|
+
if not endpoint:
|
|
91
|
+
emit_error("Provide --endpoint or --ip.")
|
|
92
|
+
try:
|
|
93
|
+
ensure_asyncua()
|
|
94
|
+
result = asyncio.run(run(endpoint))
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
emit_error(f"opcua_status failed: {exc}")
|
|
97
|
+
return
|
|
98
|
+
emit(result)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
main()
|