sylo-plc-comms 0.1.0 → 0.1.1
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/.github/workflows/publish.yml +30 -0
- package/package.json +27 -9
- package/scripts/cip_plc_info.py +56 -56
- package/scripts/cip_tag_list.py +98 -98
- package/scripts/cip_tag_read.py +129 -129
- package/scripts/cip_tag_write.py +168 -168
- package/scripts/opcua_browse.py +138 -138
- package/scripts/opcua_read.py +148 -148
- package/scripts/opcua_status.py +101 -101
- package/scripts/opcua_tag_list.py +159 -159
- package/scripts/opcua_write.py +211 -211
- package/vendor/ciplogix/README.md +37 -37
- 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
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Publishes to npm via Trusted Publishing (OIDC — no tokens).
|
|
2
|
+
# npm side: package Settings -> Trusted publishing -> GitHub Actions ->
|
|
3
|
+
# user Yeti-Trix, this repo, workflow filename publish.yml.
|
|
4
|
+
# Release flow: bump package.json version -> commit -> tag vX.Y.Z -> push tag.
|
|
5
|
+
# Provenance attestations are generated automatically (public repo + package).
|
|
6
|
+
name: Publish Package
|
|
7
|
+
|
|
8
|
+
on:
|
|
9
|
+
push:
|
|
10
|
+
tags:
|
|
11
|
+
- 'v*'
|
|
12
|
+
workflow_dispatch:
|
|
13
|
+
|
|
14
|
+
permissions:
|
|
15
|
+
id-token: write
|
|
16
|
+
contents: read
|
|
17
|
+
|
|
18
|
+
jobs:
|
|
19
|
+
publish:
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v6
|
|
23
|
+
- uses: actions/setup-node@v6
|
|
24
|
+
with:
|
|
25
|
+
node-version: '24'
|
|
26
|
+
registry-url: 'https://registry.npmjs.org'
|
|
27
|
+
package-manager-cache: false
|
|
28
|
+
- run: npm install
|
|
29
|
+
- run: npm run build --if-present
|
|
30
|
+
- run: npm publish
|
package/package.json
CHANGED
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sylo-plc-comms",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "PLC comms layer for Studio 5000/Logix — CIP (EtherNet/IP via ciplogix, vendored wheel) controller info + tag reads/writes, and OPC UA (asyncua) browse/read/write. No Logix Designer SDK required.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"pi-package"
|
|
8
8
|
],
|
|
9
|
-
"files": [
|
|
10
|
-
"extensions",
|
|
11
|
-
"scripts",
|
|
12
|
-
"vendor",
|
|
13
|
-
"README.md",
|
|
14
|
-
"LICENSE"
|
|
15
|
-
],
|
|
16
9
|
"scripts": {},
|
|
17
10
|
"pi": {
|
|
18
11
|
"extensions": [
|
|
@@ -25,5 +18,30 @@
|
|
|
25
18
|
"dependencies": {
|
|
26
19
|
"typebox": "^1.1.24"
|
|
27
20
|
},
|
|
28
|
-
"license": "MIT"
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"gitHead": "f05700bce9bf8ef8574894f24a8a3fa71da11d61",
|
|
23
|
+
"dist": {
|
|
24
|
+
"integrity": "sha512-si9tNTSgo3t/duu+NPOb/8FU5G1W/KIQy+h8ezECT1ZVygbWaRMU0QbXuM/pDYTkw68CaLa1FD1bePM40auSZw==",
|
|
25
|
+
"shasum": "228315947b4eafd4a30fdfd18574ba4431222fa1",
|
|
26
|
+
"tarball": "https://registry.npmjs.org/sylo-plc-comms/-/sylo-plc-comms-0.1.0.tgz",
|
|
27
|
+
"fileCount": 33,
|
|
28
|
+
"unpackedSize": 198528,
|
|
29
|
+
"signatures": [
|
|
30
|
+
{
|
|
31
|
+
"keyid": "SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U",
|
|
32
|
+
"sig": "MEYCIQDab2Ulido+eQYehFyzwIKHtqKLVROZc38XxWkLcD6naAIhAMGzRvKAGq/+jfbyvMc1nolb3bhqM4p6taY3Ri2PCb/d"
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
"directories": {},
|
|
37
|
+
"maintainers": [
|
|
38
|
+
{
|
|
39
|
+
"name": "yeti-trix",
|
|
40
|
+
"email": "spencer.current@gmail.com"
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/Yeti-Trix/sylo-plc-comms.git"
|
|
46
|
+
}
|
|
29
47
|
}
|
package/scripts/cip_plc_info.py
CHANGED
|
@@ -1,57 +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__":
|
|
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
57
|
main()
|
package/scripts/cip_tag_list.py
CHANGED
|
@@ -1,99 +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__":
|
|
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
99
|
main()
|
package/scripts/cip_tag_read.py
CHANGED
|
@@ -1,130 +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__":
|
|
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
130
|
main()
|