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.
Files changed (33) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/extensions/index.ts +326 -0
  4. package/package.json +29 -0
  5. package/scripts/__pycache__/_cip_client.cpython-312.pyc +0 -0
  6. package/scripts/__pycache__/_download_allowlist.cpython-312.pyc +0 -0
  7. package/scripts/__pycache__/_json_out.cpython-312.pyc +0 -0
  8. package/scripts/__pycache__/_opcua_client.cpython-312.pyc +0 -0
  9. package/scripts/__pycache__/cip_plc_info.cpython-312.pyc +0 -0
  10. package/scripts/__pycache__/cip_tag_list.cpython-312.pyc +0 -0
  11. package/scripts/__pycache__/cip_tag_read.cpython-312.pyc +0 -0
  12. package/scripts/__pycache__/cip_tag_write.cpython-312.pyc +0 -0
  13. package/scripts/__pycache__/opcua_browse.cpython-312.pyc +0 -0
  14. package/scripts/__pycache__/opcua_read.cpython-312.pyc +0 -0
  15. package/scripts/__pycache__/opcua_status.cpython-312.pyc +0 -0
  16. package/scripts/__pycache__/opcua_tag_list.cpython-312.pyc +0 -0
  17. package/scripts/__pycache__/opcua_write.cpython-312.pyc +0 -0
  18. package/scripts/_cip_client.py +139 -0
  19. package/scripts/_download_allowlist.py +111 -0
  20. package/scripts/_json_out.py +19 -0
  21. package/scripts/_opcua_client.py +142 -0
  22. package/scripts/cip_plc_info.py +57 -0
  23. package/scripts/cip_tag_list.py +99 -0
  24. package/scripts/cip_tag_read.py +130 -0
  25. package/scripts/cip_tag_write.py +169 -0
  26. package/scripts/opcua_browse.py +139 -0
  27. package/scripts/opcua_read.py +149 -0
  28. package/scripts/opcua_status.py +102 -0
  29. package/scripts/opcua_tag_list.py +160 -0
  30. package/scripts/opcua_write.py +212 -0
  31. package/scripts/requirements.txt +3 -0
  32. package/vendor/ciplogix/README.md +38 -0
  33. 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()