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,160 @@
1
+ #!/usr/bin/env python3
2
+ """List OPC UA tags by recursively browsing the controller's Tags namespace.
3
+
4
+ Read-only. Starts from the Objects folder and collects every Variable node
5
+ whose NodeId is in the Tags namespace (Rockwell ns=2). Struct members and
6
+ array elements appear as nested children and are flattened into a 'path'.
7
+
8
+ Limits: --max-depth (default 4) and --max-nodes (default 2000) protect against
9
+ runaway browse on large controllers.
10
+
11
+ Usage:
12
+ py -3.12 opcua_tag_list.py --ip 10.1.200.45
13
+ py -3.12 opcua_tag_list.py --ip 10.1.200.45 --filter IA00 --max-nodes 500
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import asyncio
20
+ from typing import Any
21
+
22
+ from _json_out import emit, emit_error
23
+ from _opcua_client import (
24
+ connect,
25
+ default_endpoint,
26
+ ensure_asyncua,
27
+ jsonable,
28
+ tags_namespace_index,
29
+ )
30
+
31
+
32
+ async def collect(
33
+ node,
34
+ tags_ns: int,
35
+ path: list[str],
36
+ depth: int,
37
+ max_depth: int,
38
+ max_nodes: int,
39
+ flt: str,
40
+ out: list[dict],
41
+ ) -> None:
42
+ if len(out) >= max_nodes:
43
+ return
44
+ from asyncua import ua
45
+
46
+ node_id = node.nodeid
47
+ ns_idx = getattr(node_id, "NamespaceIndex", None)
48
+ try:
49
+ nc = await node.read_node_class()
50
+ except Exception:
51
+ nc = None
52
+ try:
53
+ bn = await node.read_browse_name()
54
+ bn_str = str(bn)
55
+ except Exception:
56
+ bn_str = str(node_id)
57
+ try:
58
+ dn = await node.read_display_name()
59
+ dn_str = str(dn)
60
+ except Exception:
61
+ dn_str = bn_str
62
+
63
+ cur_path = path + [bn_str]
64
+
65
+ # Only collect Variable nodes in the tags namespace
66
+ is_var = nc is not None and "Variable" in str(nc)
67
+ if is_var and ns_idx == tags_ns:
68
+ entry: dict[str, Any] = {
69
+ "node_id": str(node_id),
70
+ "browse_path": ".".join(cur_path),
71
+ "display_name": dn_str,
72
+ "node_class": str(nc),
73
+ }
74
+ try:
75
+ dt = await node.read_data_type()
76
+ entry["data_type"] = str(dt)
77
+ except Exception:
78
+ entry["data_type"] = None
79
+ try:
80
+ entry["value_rank"] = await node.read_value_rank()
81
+ except Exception:
82
+ pass
83
+ try:
84
+ # array dimensions
85
+ dims = await node.read_attribute(ua.Attributes.ArrayDimensions)
86
+ entry["array_dimensions"] = jsonable(dims.Value.Value if hasattr(dims, "Value") else None)
87
+ except Exception:
88
+ pass
89
+ # Identifier string (the tag path) for ns=2 string node ids
90
+ ident = getattr(node_id, "Identifier", None)
91
+ entry["tag"] = str(ident) if isinstance(ident, str) else None
92
+ if (not flt) or flt in (entry["tag"] or "").lower() or flt in bn_str.lower():
93
+ out.append(entry)
94
+ if len(out) >= max_nodes:
95
+ return
96
+
97
+ if depth >= max_depth:
98
+ return
99
+ try:
100
+ children = await node.get_children()
101
+ except Exception:
102
+ return
103
+ for ch in children:
104
+ await collect(ch, tags_ns, cur_path, depth + 1, max_depth, max_nodes, flt, out)
105
+ if len(out) >= max_nodes:
106
+ return
107
+
108
+
109
+ async def run(endpoint: str, flt: str, max_depth: int, max_nodes: int) -> dict:
110
+ from asyncua import ua
111
+
112
+ client = await connect(endpoint)
113
+ try:
114
+ tags_ns = await tags_namespace_index(client)
115
+ if tags_ns is None:
116
+ emit_error("Could not determine the Tags namespace index on this server.")
117
+ root = client.get_node(ua.ObjectIds.ObjectsFolder)
118
+ out: list[dict] = []
119
+ await collect(root, tags_ns, [], 0, max_depth, max_nodes, flt, out)
120
+ return {
121
+ "ok": True,
122
+ "endpoint": endpoint,
123
+ "tags_namespace_index": tags_ns,
124
+ "tag_count": len(out),
125
+ "truncated": len(out) >= max_nodes,
126
+ "tags": out,
127
+ }
128
+ finally:
129
+ try:
130
+ await client.close()
131
+ except Exception:
132
+ pass
133
+
134
+
135
+ def main() -> None:
136
+ parser = argparse.ArgumentParser(description="List OPC UA tags (browse Tags namespace)")
137
+ parser.add_argument("--endpoint", default="", help="Full opc.tcp:// endpoint (overrides --ip)")
138
+ parser.add_argument("--ip", default="", help="Controller IP")
139
+ parser.add_argument("--port", type=int, default=4840, help="OPC UA port (default 4840)")
140
+ parser.add_argument("--filter", default="", help="Case-insensitive substring filter on tag/browse name")
141
+ parser.add_argument("--max-depth", type=int, default=4, help="Max browse depth (default 4)")
142
+ parser.add_argument("--max-nodes", type=int, default=2000, help="Cap on collected tag nodes (default 2000)")
143
+ args = parser.parse_args()
144
+
145
+ endpoint = args.endpoint.strip() or default_endpoint(args.ip, args.port)
146
+ if not endpoint:
147
+ emit_error("Provide --endpoint or --ip.")
148
+ try:
149
+ ensure_asyncua()
150
+ result = asyncio.run(run(endpoint, args.filter.strip().lower(), args.max_depth, args.max_nodes))
151
+ except SystemExit:
152
+ raise
153
+ except Exception as exc:
154
+ emit_error(f"opcua_tag_list failed: {exc}")
155
+ return
156
+ emit(result)
157
+
158
+
159
+ if __name__ == "__main__":
160
+ main()
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env python3
2
+ """Write one or many OPC UA nodes by node-id spec or tag path.
3
+
4
+ GATED by the operator-managed download allowlist: the endpoint host IP must be
5
+ present and enabled. Reuses the same allowlist as CIP project downloads / tag
6
+ writes. The agent cannot write to a PLC not on the list, even with permission.
7
+
8
+ Inputs: a JSON array of objects on stdin / --writes-json, each:
9
+ {"node": "ns=2;s=MyTag", "value": 42, "variant_type": "Int32"}
10
+ {"node": "MyTag", "value": true} # bare name -> Tags ns; type inferred
11
+ variant_type is optional; when omitted asyncua infers from the Python value
12
+ (works for bool/int/float/str scalars). For arrays pass a list value and the
13
+ node's existing array type is used.
14
+
15
+ Usage:
16
+ echo '[{"node":"SafetyOneShot","value":7,"variant_type":"Int32"}]' | \
17
+ py -3.12 opcua_write.py --ip 10.1.200.45 --stdin
18
+ py -3.12 opcua_write.py --ip 10.1.200.45 --node SafetyOneShot --value 7 --variant-type Int32
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import asyncio
25
+ import json
26
+ import sys
27
+ from typing import Any
28
+ from urllib.parse import urlparse
29
+
30
+ from _download_allowlist import load_allowlist
31
+ from _json_out import emit, emit_error
32
+ from _opcua_client import connect, default_endpoint, ensure_asyncua, jsonable, parse_node_id, tags_namespace_index
33
+
34
+
35
+ def _host_ip(endpoint: str) -> str:
36
+ try:
37
+ return urlparse(endpoint).hostname or ""
38
+ except Exception:
39
+ return ""
40
+
41
+
42
+ def _ip_allowed(ip: str) -> tuple[bool, str]:
43
+ if not ip:
44
+ return False, "Could not determine host IP from endpoint for allowlist check."
45
+ al = load_allowlist()
46
+ for entry in al.get("ips", []):
47
+ if isinstance(entry, dict) and entry.get("ip") == ip:
48
+ if entry.get("enabled", True):
49
+ return True, "ok"
50
+ return False, f"IP {ip} is in the allowlist but disabled."
51
+ return False, f"IP {ip} is not in the allowlist. The agent cannot write OPC UA tags to it."
52
+
53
+
54
+ def _variant(value: Any, variant_type: str | None):
55
+ """Build an asyncua ua.Variant if variant_type is given, else return raw value."""
56
+ from asyncua import ua
57
+
58
+ if not variant_type:
59
+ return value
60
+ vt = getattr(ua.VariantType, variant_type, None)
61
+ if vt is None:
62
+ raise ValueError(f"Unknown VariantType: {variant_type}")
63
+ return ua.Variant(value, vt)
64
+
65
+
66
+ async def write_one(client, spec: str, value: Any, variant_type: str | None, default_ns: int) -> dict:
67
+ try:
68
+ node = client.get_node(parse_node_id(spec, default_ns))
69
+ except Exception as exc:
70
+ return {"node": spec, "ok": False, "error": f"bad node id: {exc}"}
71
+ try:
72
+ var = _variant(value, variant_type)
73
+ except Exception as exc:
74
+ return {"node": spec, "ok": False, "error": str(exc)}
75
+ try:
76
+ await node.write_value(var)
77
+ except Exception as exc:
78
+ return {"node": spec, "ok": False, "error": str(exc)}
79
+ # Read-back for confirmation
80
+ readback = None
81
+ try:
82
+ readback = jsonable(await node.read_value())
83
+ except Exception:
84
+ pass
85
+ return {"node": spec, "ok": True, "error": None, "written": jsonable(value), "readback": readback}
86
+
87
+
88
+ async def run(endpoint: str, writes: list[dict], default_ns: int) -> dict:
89
+ client = await connect(endpoint)
90
+ try:
91
+ results = []
92
+ for w in writes:
93
+ spec = str(w.get("node", "")).strip()
94
+ value = w.get("value")
95
+ vt = w.get("variant_type")
96
+ results.append(await write_one(client, spec, value, vt, default_ns))
97
+ ok = sum(1 for r in results if r["ok"])
98
+ return {
99
+ "ok": True,
100
+ "endpoint": endpoint,
101
+ "namespace": default_ns,
102
+ "requested": len(writes),
103
+ "succeeded": ok,
104
+ "failed": len(writes) - ok,
105
+ "results": results,
106
+ }
107
+ finally:
108
+ try:
109
+ await client.close()
110
+ except Exception:
111
+ pass
112
+
113
+
114
+ def main() -> None:
115
+ parser = argparse.ArgumentParser(description="Write OPC UA nodes (allowlist-gated)")
116
+ parser.add_argument("--endpoint", default="", help="Full opc.tcp:// endpoint (overrides --ip)")
117
+ parser.add_argument("--ip", default="", help="Controller IP")
118
+ parser.add_argument("--port", type=int, default=4840, help="OPC UA port (default 4840)")
119
+ parser.add_argument("--node", default="", help="Single node spec (paired with --value)")
120
+ parser.add_argument("--value", default="", help="Single value (JSON-parsed if possible)")
121
+ parser.add_argument("--variant-type", default="", help="asyncua VariantType name (e.g. Int32, Boolean)")
122
+ parser.add_argument("--writes-json", default="", help="JSON array of {node,value,variant_type?}")
123
+ parser.add_argument("--stdin", action="store_true", help="Read JSON array from stdin")
124
+ parser.add_argument("--namespace", type=int, default=None, help="Default ns for bare names (auto)")
125
+ parser.add_argument("--dry-run", action="store_true", help="Validate writes but do not send")
126
+ args = parser.parse_args()
127
+
128
+ endpoint = args.endpoint.strip() or default_endpoint(args.ip, args.port)
129
+ if not endpoint:
130
+ emit_error("Provide --endpoint or --ip.")
131
+
132
+ allowed, reason = _ip_allowed(_host_ip(endpoint))
133
+ if not allowed:
134
+ emit_error(reason)
135
+
136
+ writes: list[dict] = []
137
+ if args.stdin:
138
+ raw = sys.stdin.read().strip()
139
+ try:
140
+ parsed = json.loads(raw) if raw else []
141
+ except json.JSONDecodeError as exc:
142
+ emit_error(f"stdin is not valid JSON: {exc}")
143
+ return
144
+ if not isinstance(parsed, list):
145
+ emit_error("stdin JSON must be an array of {node,value} objects.")
146
+ return
147
+ writes = parsed
148
+ if args.writes_json:
149
+ try:
150
+ parsed = json.loads(args.writes_json)
151
+ except json.JSONDecodeError as exc:
152
+ emit_error(f"--writes-json is not valid JSON: {exc}")
153
+ return
154
+ if isinstance(parsed, list):
155
+ writes.extend(parsed)
156
+ elif isinstance(parsed, dict):
157
+ writes.append(parsed)
158
+ if args.node:
159
+ vraw = args.value
160
+ try:
161
+ val = json.loads(vraw) if vraw != "" else None
162
+ except json.JSONDecodeError:
163
+ val = vraw
164
+ writes.append({
165
+ "node": args.node,
166
+ "value": val,
167
+ "variant_type": (args.variant_type.strip() or None),
168
+ })
169
+
170
+ clean: list[dict] = []
171
+ for w in writes:
172
+ if not isinstance(w, dict) or not w.get("node"):
173
+ emit_error("Each write must be an object with a 'node' field.")
174
+ return
175
+ clean.append({
176
+ "node": str(w["node"]).strip(),
177
+ "value": w.get("value"),
178
+ "variant_type": (str(w.get("variant_type", "")).strip() or None) if w.get("variant_type") else None,
179
+ })
180
+ if not clean:
181
+ emit_error("No writes supplied. Use --node/--value, --writes-json, or --stdin.")
182
+
183
+ if args.dry_run:
184
+ emit({"ok": True, "endpoint": endpoint, "dry_run": True, "writes": clean})
185
+ return
186
+
187
+ async def resolve_ns() -> int:
188
+ if args.namespace is not None:
189
+ return args.namespace
190
+ client = await connect(endpoint)
191
+ try:
192
+ return await tags_namespace_index(client) or 2
193
+ finally:
194
+ try:
195
+ await client.close()
196
+ except Exception:
197
+ pass
198
+
199
+ try:
200
+ ensure_asyncua()
201
+ default_ns = asyncio.run(resolve_ns())
202
+ result = asyncio.run(run(endpoint, clean, default_ns))
203
+ except SystemExit:
204
+ raise
205
+ except Exception as exc:
206
+ emit_error(f"opcua_write failed: {exc}")
207
+ return
208
+ emit(result)
209
+
210
+
211
+ if __name__ == "__main__":
212
+ main()
@@ -0,0 +1,3 @@
1
+ # sylo-plc-comms — CIP (ciplogix vendored wheel) + OPC UA (asyncua)
2
+ # ciplogix installs from vendor/ciplogix/*.whl on demand; asyncua pip-installs on demand.
3
+ asyncua>=2.0.0
@@ -0,0 +1,38 @@
1
+ # ciplogix (vendored)
2
+
3
+ Lightweight Allen-Bradley PLC communication over Ethernet/IP — a hardened fork of
4
+ **pycomm3** used by LogicForge for **controller status / keyswitch reads** in the
5
+ Download settings UI and the pre-download state check in `sdk_download_to_plc.py`.
6
+
7
+ - **Source:** https://github.com/Yeti-Trix/ciplogix
8
+ - **Wheel:** `ciplogix-1.1.0-py3-none-any.whl`
9
+ - **License:** MIT (see upstream repo)
10
+ - **Author:** Spencer Current (Yeti-Trix), built on pycomm3 by Ian Ottoway
11
+
12
+ ## Why it's here
13
+
14
+ The Logix Designer SDK is heavy and requires opening a full `.acd` project just to
15
+ ask a controller "are you there and what mode are you in." ciplogix answers that in
16
+ one CIP Identity request (`get_plc_info()` → `info["keyswitch"]`), which is what
17
+ the Download settings status column and the download pre-check use.
18
+
19
+ The SDK is still the engine for the actual **download** (project push to
20
+ controller). ciplogix is only used for **read-only status/mode**.
21
+
22
+ ## Install (handled by scripts)
23
+
24
+ `plc_status.py` and `sdk_download_to_plc.py` pip-install this wheel into the
25
+ SDK Python (3.12) on first run if `ciplogix` isn't importable. Its dependency
26
+ `pycomm3` is pulled from PyPI automatically.
27
+
28
+ ## Keyswitch values
29
+
30
+ `get_plc_info()["keyswitch"]` returns one of (Rockwell KB #28917):
31
+
32
+ | Value | Key position | Mode |
33
+ |-----------------|--------------|--------|
34
+ | `REMOTE RUN` | REM | Run |
35
+ | `REMOTE PROG` | REM | Program|
36
+ | `RUN` | RUN (hard) | Run |
37
+ | `PROG` | PROG (hard) | Program|
38
+ | `UNKNOWN` | — | — |