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,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,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` | — | — |
|
|
Binary file
|