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,111 @@
1
+ #!/usr/bin/env python3
2
+ """Shared download-allowlist loader / membership gate.
3
+
4
+ The canonical allowlist lives at packages/sylo-logicforge/assets/download-allowlist.json
5
+ and is operator-managed via the LogicForge Settings tab. The agent never edits
6
+ it — the download script reads it and refuses any IP not present and enabled.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+
18
+ def package_root() -> Path:
19
+ return Path(__file__).resolve().parent.parent
20
+
21
+
22
+ def allowlist_path() -> Path:
23
+ """Allowlist JSON path (env override for project-local testing)."""
24
+ env = os.environ.get("LOGICFORGE_DOWNLOAD_ALLOWLIST", "").strip()
25
+ if env:
26
+ return Path(env).expanduser().resolve()
27
+ # Canonical file lives with sylo-logicforge (the LogicForge Settings tab
28
+ # reads/writes it there); resolve across the monorepo so every package
29
+ # enforces the same operator list.
30
+ sibling = (
31
+ Path(__file__).resolve().parents[2]
32
+ / "sylo-logicforge"
33
+ / "assets"
34
+ / "download-allowlist.json"
35
+ )
36
+ if sibling.is_file():
37
+ return sibling
38
+ return package_root() / "assets" / "download-allowlist.json"
39
+
40
+
41
+ def default_allowlist() -> dict[str, Any]:
42
+ return {
43
+ "allow_downloads": False,
44
+ "post_download_mode": "program",
45
+ "ips": [],
46
+ "updated_at": None,
47
+ "notes": "Operator-managed via LogicForge Settings tab.",
48
+ }
49
+
50
+
51
+ def load_allowlist() -> dict[str, Any]:
52
+ path = allowlist_path()
53
+ if not path.is_file():
54
+ return default_allowlist()
55
+ try:
56
+ data = json.loads(path.read_text(encoding="utf-8"))
57
+ except (json.JSONDecodeError, OSError):
58
+ return default_allowlist()
59
+ if not isinstance(data, dict):
60
+ return default_allowlist()
61
+ # Normalize / fill missing keys
62
+ base = default_allowlist()
63
+ base.update(data)
64
+ if not isinstance(base.get("ips"), list):
65
+ base["ips"] = []
66
+ if base.get("post_download_mode") not in ("program", "run"):
67
+ base["post_download_mode"] = "program"
68
+ return base
69
+
70
+
71
+ def save_allowlist(data: dict[str, Any]) -> dict[str, Any]:
72
+ path = allowlist_path()
73
+ path.parent.mkdir(parents=True, exist_ok=True)
74
+ data = dict(data)
75
+ data["updated_at"] = datetime.now(timezone.utc).isoformat()
76
+ if data.get("post_download_mode") not in ("program", "run"):
77
+ data["post_download_mode"] = "program"
78
+ if not isinstance(data.get("ips"), list):
79
+ data["ips"] = []
80
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
81
+ return data
82
+
83
+
84
+ def _ip_from_comm_path(comm_path: str) -> str | None:
85
+ """Extract a bare IPv4 from a Rockwell comm path, else None."""
86
+ import re
87
+
88
+ raw = comm_path.strip()
89
+ # Look for an IPv4 anywhere in the path
90
+ m = re.search(r"\b\d{1,3}(?:\.\d{1,3}){3}\b", raw)
91
+ return m.group(0) if m else None
92
+
93
+
94
+ def check_ip_allowed(ip_or_comm: str, allowlist: dict[str, Any] | None = None) -> tuple[bool, str]:
95
+ """Return (allowed, reason). Resolves bare IP from a comm path if needed."""
96
+ al = allowlist if allowlist is not None else load_allowlist()
97
+ if not al.get("allow_downloads", False):
98
+ return False, "Downloads are disabled in the allowlist (allow_downloads=false)."
99
+
100
+ ip = ip_or_comm.strip()
101
+ if "\\" in ip or "/" in ip:
102
+ ip = _ip_from_comm_path(ip) or ip
103
+
104
+ for entry in al.get("ips", []):
105
+ if not isinstance(entry, dict):
106
+ continue
107
+ if entry.get("ip") == ip:
108
+ if entry.get("enabled", True):
109
+ return True, ip
110
+ return False, f"IP {ip} is in the allowlist but disabled."
111
+ return False, f"IP {ip} is not in the download allowlist. The agent cannot download to it."
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env python3
2
+ """Shared JSON stdout helpers for sylo-plc-comms scripts."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import sys
8
+ from typing import Any
9
+
10
+
11
+ def emit(payload: dict[str, Any]) -> None:
12
+ """Print JSON to stdout and exit with code 0 or 1."""
13
+ print(json.dumps(payload, indent=2))
14
+ if payload.get("ok") is False:
15
+ sys.exit(1)
16
+
17
+
18
+ def emit_error(message: str, **extra: Any) -> None:
19
+ emit({"ok": False, "error": message, **extra})
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env python3
2
+ """Shared asyncua (OPC UA client) helpers for sylo-plc-comms scripts.
3
+
4
+ asyncua is async, so each script wraps a coroutine in asyncio.run(). This
5
+ helper centralizes endpoint connect, namespace resolution, node-id parsing,
6
+ and JSON coercion of OPC UA variant values.
7
+
8
+ Rockwell Logix OPC UA server conventions:
9
+ - Default endpoint: opc.tcp://<ip>:4840
10
+ - The user tags live in the namespace named "Tags" (namespace index 2 on
11
+ most 5380/5580 controllers). Tags are String NodeIds whose identifier is
12
+ the full tag path, e.g. ns=2;s=MyTag or ns=2;s=Program:MainProgram.MyTag
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ from typing import Any
19
+
20
+ from _json_out import emit_error
21
+
22
+
23
+ def ensure_asyncua() -> str:
24
+ """pip-install asyncua if not importable. Returns a status string."""
25
+ try:
26
+ import asyncua # noqa: F401
27
+
28
+ return "already_importable"
29
+ except ImportError:
30
+ pass
31
+
32
+ import subprocess
33
+ import sys
34
+
35
+ subprocess.check_call(
36
+ [sys.executable, "-m", "pip", "install", "asyncua", "--quiet"],
37
+ stdout=subprocess.DEVNULL,
38
+ stderr=subprocess.DEVNULL,
39
+ )
40
+ return "installed"
41
+
42
+
43
+ def default_endpoint(ip: str, port: int = 4840, path: str = "") -> str:
44
+ base = f"opc.tcp://{ip}:{port}"
45
+ return f"{base}{path}" if path else base
46
+
47
+
48
+ async def connect(endpoint: str, timeout: float = 10.0):
49
+ """Open an asyncua Client and return it. Caller must `await client.close()`
50
+ (prefer `async with`). On failure, emit_error + exit."""
51
+ from asyncua import Client
52
+
53
+ client = Client(url=endpoint, timeout=timeout)
54
+ try:
55
+ await client.connect()
56
+ except Exception as exc:
57
+ msg = str(exc).strip() or type(exc).__name__
58
+ emit_error(f"OPC UA connect failed to {endpoint}: {msg}")
59
+ return client
60
+
61
+
62
+ async def namespaces(client) -> list[dict[str, Any]]:
63
+ """Return [{index, uri}] for the server."""
64
+ try:
65
+ uris = await client.get_namespace_array()
66
+ except Exception as exc:
67
+ emit_error(f"get_namespace_array failed: {exc}")
68
+ return []
69
+ return [{"index": i, "uri": u} for i, u in enumerate(uris)]
70
+
71
+
72
+ async def tags_namespace_index(client) -> int | None:
73
+ """Find the namespace index whose URI ends with 'Tags' (Rockwell convention).
74
+ Falls back to index 2 if no match."""
75
+ ns = await namespaces(client)
76
+ for entry in ns:
77
+ uri = str(entry["uri"])
78
+ if uri.rstrip("/").endswith("Tags"):
79
+ return entry["index"]
80
+ return 2 if len(ns) > 2 else None
81
+
82
+
83
+ def parse_node_id(spec: str, default_ns: int = 2):
84
+ """Parse a node id spec into an asyncua ua.NodeId.
85
+
86
+ Accepted forms:
87
+ "ns=2;s=MyTag" -> NodeId("MyTag", 2, StringNodeId)
88
+ "MyTag" -> NodeId("MyTag", default_ns)
89
+ "ns=3;i=1001" -> NodeId(1001, 3, NumericNodeId)
90
+ """
91
+ from asyncua import ua
92
+
93
+ spec = spec.strip()
94
+ ns = default_ns
95
+ if "ns=" in spec:
96
+ parts = {p.split("=", 1)[0]: p.split("=", 1)[1] for p in spec.split(";") if "=" in p}
97
+ ns = int(parts.get("ns", default_ns))
98
+ if "s" in parts:
99
+ return ua.NodeId(parts["s"], ns)
100
+ if "i" in parts:
101
+ return ua.NodeId(int(parts["i"]), ns, ua.NodeIdType.Numeric)
102
+ if "b" in parts:
103
+ return ua.NodeId(bytes.fromhex(parts["b"]), ns, ua.NodeIdType.ByteString)
104
+ if "g" in parts:
105
+ return ua.NodeId(parts["g"], ns, ua.NodeIdType.Guid)
106
+ # Bare identifier -> string node id in default namespace
107
+ if spec.isdigit():
108
+ return ua.NodeId(int(spec), ns, ua.NodeIdType.Numeric)
109
+ return ua.NodeId(spec, ns)
110
+
111
+
112
+ def jsonable(v: Any) -> Any:
113
+ """Coerce OPC UA variant values to JSON-friendly Python types."""
114
+ if v is None:
115
+ return None
116
+ if isinstance(v, (str, int, float, bool)):
117
+ return v
118
+ if isinstance(v, bytes):
119
+ try:
120
+ return v.decode("utf-8", errors="replace")
121
+ except Exception:
122
+ return v.hex()
123
+ if isinstance(v, (list, tuple)):
124
+ return [jsonable(x) for x in v]
125
+ if isinstance(v, dict):
126
+ return {str(k): jsonable(val) for k, val in v.items()}
127
+ # Extension objects / datetimes / enums -> string
128
+ try:
129
+ import datetime
130
+ if isinstance(v, datetime.datetime):
131
+ return v.isoformat()
132
+ except Exception:
133
+ pass
134
+ return str(v)
135
+
136
+
137
+ # Map common OPC UA browse names / NodeId types to a short type label
138
+ def variant_type_label(vt) -> str:
139
+ try:
140
+ return str(vt.name if hasattr(vt, "name") else vt)
141
+ except Exception:
142
+ return str(vt)
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env python3
2
+ """Full PLC controller attributes via ciplogix (read-only CIP Identity + attrs).
3
+
4
+ Richer than plc_status.py (which is the pre-download keyswitch check). This is
5
+ the agent-facing "tell me about this controller" tool.
6
+
7
+ Usage:
8
+ py -3.12 cip_plc_info.py --ip 10.1.200.45
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+
15
+ from _cip_client import ensure_ciplogix, open_driver, plc_info_dict
16
+ from _download_allowlist import load_allowlist
17
+ from _json_out import emit
18
+
19
+
20
+ def main() -> None:
21
+ parser = argparse.ArgumentParser(description="Full PLC controller info via ciplogix")
22
+ parser.add_argument("--ip", required=True, help="Controller IPv4 address")
23
+ args = parser.parse_args()
24
+
25
+ try:
26
+ ensure_ciplogix()
27
+ except Exception as exc:
28
+ from _json_out import emit_error
29
+ emit_error(f"ciplogix setup failed: {exc}")
30
+
31
+ plc = open_driver(args.ip, init_tags=False)
32
+ try:
33
+ info = plc_info_dict(plc)
34
+ info["ip"] = args.ip
35
+ info["reachable"] = True
36
+ info["error"] = None
37
+ except Exception as exc:
38
+ plc.close()
39
+ from _json_out import emit_error
40
+ emit_error(f"get_plc_info failed: {exc}")
41
+ return
42
+ finally:
43
+ try:
44
+ plc.close()
45
+ except Exception:
46
+ pass
47
+
48
+ allowlist = load_allowlist()
49
+ info["in_allowlist"] = any(
50
+ isinstance(e, dict) and e.get("ip") == args.ip and e.get("enabled", True)
51
+ for e in allowlist.get("ips", [])
52
+ )
53
+ emit({"ok": True, **info})
54
+
55
+
56
+ if __name__ == "__main__":
57
+ main()
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env python3
2
+ """List controller (and program) tags from a Logix PLC via ciplogix.
3
+
4
+ Read-only. init_tags=True pulls the full tag list including UDT/struct layout;
5
+ this is the same call ciplogix makes on connect when init_tags=True, just
6
+ exposed explicitly. Optionally filter by substring and/or include program tags.
7
+
8
+ Usage:
9
+ py -3.12 cip_tag_list.py --ip 10.1.200.45
10
+ py -3.12 cip_tag_list.py --ip 10.1.200.45 --filter IA00 --include-programs
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+
17
+ from _cip_client import ensure_ciplogix, normalize_tag_entry, open_driver
18
+ from _json_out import emit, emit_error
19
+
20
+
21
+ def main() -> None:
22
+ parser = argparse.ArgumentParser(description="List Logix controller tags via ciplogix")
23
+ parser.add_argument("--ip", required=True, help="Controller IPv4 address")
24
+ parser.add_argument("--filter", default="", help="Optional case-insensitive tag-name substring filter")
25
+ parser.add_argument(
26
+ "--include-programs",
27
+ action="store_true",
28
+ help="Also list program-scoped tags (one extra round trip per program)",
29
+ )
30
+ parser.add_argument(
31
+ "--limit",
32
+ type=int,
33
+ default=0,
34
+ help="Cap number of tags returned (0 = no cap). Apply after filtering.",
35
+ )
36
+ args = parser.parse_args()
37
+
38
+ try:
39
+ ensure_ciplogix()
40
+ except Exception as exc:
41
+ emit_error(f"ciplogix setup failed: {exc}")
42
+
43
+ plc = open_driver(args.ip, init_tags=True)
44
+ try:
45
+ flt = (args.filter or "").strip().lower()
46
+ try:
47
+ raw_tags = plc.get_tag_list()
48
+ except Exception as exc:
49
+ emit_error(f"get_tag_list failed: {exc}")
50
+ return
51
+
52
+ tags = [normalize_tag_entry(t) for t in raw_tags]
53
+
54
+ program_tags: list[dict] = []
55
+ if args.include_programs:
56
+ try:
57
+ programs = plc.get_programs() # type: ignore[attr-defined]
58
+ except Exception:
59
+ programs = []
60
+ for prog in programs:
61
+ prog_name = prog if isinstance(prog, str) else getattr(prog, "name", str(prog))
62
+ try:
63
+ p_raw = plc.get_tag_list(program=prog_name)
64
+ except Exception:
65
+ p_raw = []
66
+ for t in p_raw:
67
+ t["program"] = prog_name
68
+ program_tags.append(normalize_tag_entry(t))
69
+
70
+ all_tags = tags + program_tags
71
+ if flt:
72
+ all_tags = [
73
+ t for t in all_tags
74
+ if flt in (str(t.get("tag_name") or "").lower())
75
+ or flt in (str(t.get("program") or "").lower())
76
+ ]
77
+ total = len(all_tags)
78
+ if args.limit and args.limit > 0:
79
+ all_tags = all_tags[: args.limit]
80
+
81
+ emit({
82
+ "ok": True,
83
+ "ip": args.ip,
84
+ "controller_tag_count": len(tags),
85
+ "program_tag_count": len(program_tags),
86
+ "total_matching": total,
87
+ "returned": len(all_tags),
88
+ "truncated": total > len(all_tags),
89
+ "tags": all_tags,
90
+ })
91
+ finally:
92
+ try:
93
+ plc.close()
94
+ except Exception:
95
+ pass
96
+
97
+
98
+ if __name__ == "__main__":
99
+ main()
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+ """Read one or many Logix tags from a PLC via ciplogix (read-only).
3
+
4
+ Accepts a JSON list of tag names on stdin (--tags-json also accepted). Uses
5
+ ciplogix multi-service read when more than one tag is given. Returns per-tag
6
+ value + error. Bit/array element syntax follows pycomm3 conventions:
7
+ MyTag, MyTag.0, MyArray[3], MyStruct.Member, Program:MainProgram.MyTag
8
+
9
+ Usage:
10
+ py -3.12 cip_tag_read.py --ip 10.1.200.45 --tags "MyDint,MyBool.0"
11
+ echo '["MyDint","MyReal"]' | py -3.12 cip_tag_read.py --ip 10.1.200.45 --stdin
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import sys
19
+
20
+ from _cip_client import ensure_ciplogix, open_driver
21
+ from _json_out import emit, emit_error
22
+
23
+
24
+ def _coerce_value(v):
25
+ """Make ciplogix tag values JSON-serializable (bytes, structs, datetimes)."""
26
+ if v is None:
27
+ return None
28
+ if isinstance(v, (str, int, float, bool)):
29
+ return v
30
+ if isinstance(v, bytes):
31
+ try:
32
+ return v.decode("utf-8", errors="replace")
33
+ except Exception:
34
+ return list(v)
35
+ if isinstance(v, (list, tuple)):
36
+ return [_coerce_value(x) for x in v]
37
+ if isinstance(v, dict):
38
+ return {str(k): _coerce_value(val) for k, val in v.items()}
39
+ # pycomm3 struct Tag objects / datetime / enum -> string-ish
40
+ return str(v)
41
+
42
+
43
+ def main() -> None:
44
+ parser = argparse.ArgumentParser(description="Read Logix tags via ciplogix")
45
+ parser.add_argument("--ip", required=True, help="Controller IPv4 address")
46
+ parser.add_argument("--tags", default="", help="Comma-separated tag names")
47
+ parser.add_argument("--tags-json", default="", help="JSON array of tag names")
48
+ parser.add_argument("--stdin", action="store_true", help="Read JSON array of tags from stdin")
49
+ args = parser.parse_args()
50
+
51
+ # Resolve tag list
52
+ names: list[str] = []
53
+ if args.stdin:
54
+ raw = sys.stdin.read().strip()
55
+ try:
56
+ parsed = json.loads(raw) if raw else []
57
+ except json.JSONDecodeError as exc:
58
+ emit_error(f"stdin is not valid JSON: {exc}")
59
+ return
60
+ if not isinstance(parsed, list):
61
+ emit_error("stdin JSON must be an array of tag name strings.")
62
+ return
63
+ names = [str(x).strip() for x in parsed if str(x).strip()]
64
+ if args.tags_json:
65
+ try:
66
+ parsed = json.loads(args.tags_json)
67
+ except json.JSONDecodeError as exc:
68
+ emit_error(f"--tags-json is not valid JSON: {exc}")
69
+ return
70
+ names.extend(str(x).strip() for x in parsed if str(x).strip())
71
+ if args.tags:
72
+ names.extend(x.strip() for x in args.tags.split(",") if x.strip())
73
+
74
+ names = [n for n in names if n]
75
+ if not names:
76
+ emit_error("No tag names supplied. Use --tags, --tags-json, or --stdin.")
77
+
78
+ try:
79
+ ensure_ciplogix()
80
+ except Exception as exc:
81
+ emit_error(f"ciplogix setup failed: {exc}")
82
+
83
+ plc = open_driver(args.ip, init_tags=True)
84
+ try:
85
+ results: list[dict] = []
86
+ try:
87
+ resp = plc.read(*names)
88
+ except Exception as exc:
89
+ # All-failed path — still emit one error row per tag so the caller
90
+ # can see which tags were attempted.
91
+ for n in names:
92
+ results.append({"tag": n, "ok": False, "value": None, "error": str(exc)})
93
+ emit({"ok": True, "ip": args.ip, "results": results, "all_failed": True})
94
+ return
95
+
96
+ # plc.read returns a single Tag when one name, or a list of Tags when many.
97
+ resp_list = resp if isinstance(resp, list) else [resp]
98
+ for name, tag in zip(names, resp_list):
99
+ err = None
100
+ try:
101
+ err = getattr(tag, "error", None)
102
+ if not err and getattr(tag, "status", None) not in (None, "", "Success"):
103
+ err = str(getattr(tag, "status"))
104
+ except Exception:
105
+ pass
106
+ results.append({
107
+ "tag": name,
108
+ "ok": not err,
109
+ "value": _coerce_value(getattr(tag, "value", None)),
110
+ "error": err,
111
+ "type": getattr(tag, "tag_type", None) or getattr(tag, "data_type_name", None),
112
+ })
113
+ ok_count = sum(1 for r in results if r["ok"])
114
+ emit({
115
+ "ok": True,
116
+ "ip": args.ip,
117
+ "requested": len(names),
118
+ "succeeded": ok_count,
119
+ "failed": len(names) - ok_count,
120
+ "results": results,
121
+ })
122
+ finally:
123
+ try:
124
+ plc.close()
125
+ except Exception:
126
+ pass
127
+
128
+
129
+ if __name__ == "__main__":
130
+ main()