virtual-plc 0.3.0__py3-none-any.whl
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.
- virtual_plc/__init__.py +14 -0
- virtual_plc/__main__.py +161 -0
- virtual_plc/codec.py +235 -0
- virtual_plc/commands.py +221 -0
- virtual_plc/faults.py +139 -0
- virtual_plc/guide.py +343 -0
- virtual_plc/memory.py +145 -0
- virtual_plc/profiles.py +111 -0
- virtual_plc/server.py +191 -0
- virtual_plc-0.3.0.dist-info/METADATA +249 -0
- virtual_plc-0.3.0.dist-info/RECORD +15 -0
- virtual_plc-0.3.0.dist-info/WHEEL +5 -0
- virtual_plc-0.3.0.dist-info/entry_points.txt +2 -0
- virtual_plc-0.3.0.dist-info/licenses/LICENSE +21 -0
- virtual_plc-0.3.0.dist-info/top_level.txt +1 -0
virtual_plc/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Local MC protocol simulator with optional response faults."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("virtual_plc")
|
|
7
|
+
except PackageNotFoundError:
|
|
8
|
+
__version__ = "0.3.0" # Source-tree use without an installed distribution.
|
|
9
|
+
|
|
10
|
+
from .faults import FaultPlan
|
|
11
|
+
from .memory import Memory
|
|
12
|
+
from .server import Server
|
|
13
|
+
|
|
14
|
+
__all__ = ["Server", "FaultPlan", "Memory", "__version__"]
|
virtual_plc/__main__.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Local simulator CLI: python -m virtual_plc."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import ipaddress
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
import signal
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
|
|
13
|
+
from . import __version__, guide
|
|
14
|
+
from .server import Server
|
|
15
|
+
from .faults import FaultPlan
|
|
16
|
+
from .memory import Memory
|
|
17
|
+
from .profiles import PROFILES
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def text_pad(value):
|
|
21
|
+
try:
|
|
22
|
+
number = int(value, 0)
|
|
23
|
+
if 0 <= number <= 255:
|
|
24
|
+
return number
|
|
25
|
+
except ValueError:
|
|
26
|
+
pass
|
|
27
|
+
raise argparse.ArgumentTypeError("text pad must be an integer 0..255 (e.g. 0x00 or 0x20)")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def device_range(value):
|
|
31
|
+
try:
|
|
32
|
+
device, span = value.split(":")
|
|
33
|
+
low, high = (int(part, 0) for part in span.split("-"))
|
|
34
|
+
if device and 0 <= low <= high:
|
|
35
|
+
return device, (low, high)
|
|
36
|
+
except ValueError:
|
|
37
|
+
pass
|
|
38
|
+
raise argparse.ArgumentTypeError("range must be DEV:LO-HI with 0 <= LO <= HI")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def local_host(host):
|
|
42
|
+
if host.lower() == "localhost":
|
|
43
|
+
return True
|
|
44
|
+
try:
|
|
45
|
+
return ipaddress.ip_address(host).is_loopback
|
|
46
|
+
except ValueError:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class GuideParser(argparse.ArgumentParser):
|
|
51
|
+
def error(self, message, topic=None):
|
|
52
|
+
if topic is None:
|
|
53
|
+
flags = re.findall(r"--[a-z][a-z-]*", message)
|
|
54
|
+
topic = guide.option_topic("/".join(flags))
|
|
55
|
+
# JSON errors and filenames can contain newlines; diagnostics stay one line.
|
|
56
|
+
self.exit(2, f"{self.prog}: error: {' '.join(message.split())}; see: virtual-plc guide {topic}\n")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_parser():
|
|
60
|
+
parser = GuideParser(prog="virtual-plc", description="Virtual Mitsubishi MC protocol PLC (not for industrial use)")
|
|
61
|
+
parser.add_argument("--version", action="version", version=__version__, help="show installed version")
|
|
62
|
+
parser.add_argument("--host", default="127.0.0.1", help="bind address (local use only)")
|
|
63
|
+
parser.add_argument("--port", type=int, default=5000, help="TCP port 0..65535; 0 chooses a free port")
|
|
64
|
+
parser.add_argument("--profile", choices=PROFILES, default="Q", help="simulated CPU profile")
|
|
65
|
+
parser.add_argument("--password", help="simulated lock password (not authentication)")
|
|
66
|
+
parser.add_argument("--initial-state", choices=("RUN", "STOP", "PAUSE"), default="RUN", help="initial CPU state")
|
|
67
|
+
parser.add_argument("--max-points", type=int, default=4096, help="positive batch point limit")
|
|
68
|
+
parser.add_argument("--idle-timeout", type=float, default=None, help="seconds; default: no timeout")
|
|
69
|
+
parser.add_argument("--ascii-dword-order", choices=("swapped", "client"), default="swapped",
|
|
70
|
+
help="ASCII dwords: low word first / client high word first")
|
|
71
|
+
parser.add_argument("--text-pad", type=text_pad, default=0x00, help="CPU name fill byte 0..255; 0x20 = space")
|
|
72
|
+
parser.add_argument("--fault-plan", metavar="FILE", help="response fault JSON")
|
|
73
|
+
parser.add_argument("--init", metavar="FILE", help="load memory JSON before listening")
|
|
74
|
+
parser.add_argument("--dump-on-exit", metavar="FILE", help="save memory JSON after shutdown")
|
|
75
|
+
parser.add_argument("--latch-range", action="append", type=device_range, default=[], metavar="DEV:LO-HI",
|
|
76
|
+
help="retained span; repeatable; default all L")
|
|
77
|
+
parser.add_argument("--limit", action="append", type=device_range, default=[], metavar="DEV:LO-HI",
|
|
78
|
+
help="inclusive device limit; repeatable; default profile ceiling")
|
|
79
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="log requests and connection close reasons")
|
|
80
|
+
return parser
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def main(argv=None):
|
|
84
|
+
argv = sys.argv[1:] if argv is None else list(argv)
|
|
85
|
+
parser = build_parser()
|
|
86
|
+
if argv and argv[0] == "guide":
|
|
87
|
+
return guide.main(argv[1:], parser)
|
|
88
|
+
args = parser.parse_args(argv)
|
|
89
|
+
topic = "faults"
|
|
90
|
+
try:
|
|
91
|
+
plan = FaultPlan.from_file(args.fault_plan) if args.fault_plan else None
|
|
92
|
+
topic = "memory"
|
|
93
|
+
options = dict(profile=args.profile, password=args.password, initial_state=args.initial_state,
|
|
94
|
+
max_points=args.max_points, idle_timeout=args.idle_timeout, fault_plan=plan,
|
|
95
|
+
ascii_dword_order=args.ascii_dword_order, text_pad=args.text_pad, verbose=args.verbose)
|
|
96
|
+
if args.init or args.limit:
|
|
97
|
+
options["memory"] = Memory(args.profile, dict(args.limit))
|
|
98
|
+
if args.latch_range:
|
|
99
|
+
ranges = {}
|
|
100
|
+
for device, span in args.latch_range:
|
|
101
|
+
ranges.setdefault(device, []).append(span)
|
|
102
|
+
options["latch_ranges"] = ranges
|
|
103
|
+
topic = "options"
|
|
104
|
+
try:
|
|
105
|
+
server = Server(args.host, args.port, **options)
|
|
106
|
+
except ValueError as error:
|
|
107
|
+
parser.error(str(error), "memory" if "latch" in str(error) else topic)
|
|
108
|
+
topic = "memory"
|
|
109
|
+
if args.init:
|
|
110
|
+
options["memory"].load(json.loads(Path(args.init).read_text(encoding="utf-8")))
|
|
111
|
+
except (OSError, ValueError) as error:
|
|
112
|
+
parser.error(str(error), topic)
|
|
113
|
+
try:
|
|
114
|
+
server.start()
|
|
115
|
+
except (OSError, OverflowError) as error:
|
|
116
|
+
server.stop()
|
|
117
|
+
print(f"cannot listen on {args.host}:{args.port}: {error}", file=sys.stderr)
|
|
118
|
+
return 1
|
|
119
|
+
warning = "; WARNING: localhost-only simulator; do not expose to production networks" if not local_host(args.host) else ""
|
|
120
|
+
exit_code = 0
|
|
121
|
+
previous = {}
|
|
122
|
+
def terminate(signum, frame):
|
|
123
|
+
raise KeyboardInterrupt # finally stops workers and writes the snapshot, then exits 0
|
|
124
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
125
|
+
logger = logging.getLogger("virtual_plc")
|
|
126
|
+
old_level = logger.level
|
|
127
|
+
if args.verbose:
|
|
128
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
129
|
+
logger.addHandler(handler)
|
|
130
|
+
logger.setLevel(logging.INFO)
|
|
131
|
+
try:
|
|
132
|
+
for name in ("SIGTERM", "SIGBREAK"):
|
|
133
|
+
if hasattr(signal, name):
|
|
134
|
+
number = getattr(signal, name)
|
|
135
|
+
previous[number] = signal.signal(number, terminate)
|
|
136
|
+
print("listening on %s:%d (%s, %s) idle_timeout=%s text_pad=0x%02X ascii_dword_order=%s fault_plan=%s%s" %
|
|
137
|
+
(*server.address, args.profile, args.initial_state, args.idle_timeout, args.text_pad,
|
|
138
|
+
args.ascii_dword_order, args.fault_plan, warning), file=sys.stderr, flush=True)
|
|
139
|
+
event = threading.Event()
|
|
140
|
+
while not event.wait(0.5):
|
|
141
|
+
pass
|
|
142
|
+
except KeyboardInterrupt:
|
|
143
|
+
pass
|
|
144
|
+
finally:
|
|
145
|
+
server.stop()
|
|
146
|
+
for number, old in previous.items():
|
|
147
|
+
signal.signal(number, old)
|
|
148
|
+
if args.verbose:
|
|
149
|
+
logger.removeHandler(handler)
|
|
150
|
+
logger.setLevel(old_level)
|
|
151
|
+
if args.dump_on_exit:
|
|
152
|
+
try:
|
|
153
|
+
Path(args.dump_on_exit).write_text(json.dumps(server.memory.dump(), indent=2) + "\n", encoding="utf-8")
|
|
154
|
+
except OSError as error:
|
|
155
|
+
print(f"cannot dump memory: {error}", file=sys.stderr)
|
|
156
|
+
exit_code = 1
|
|
157
|
+
return exit_code
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
raise SystemExit(main())
|
virtual_plc/codec.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""MC 3E/4E framing and binary/ASCII conversion for both wire classes.
|
|
2
|
+
|
|
3
|
+
ASCII uses the explicit compat numbering policy: fixed-width decimal addresses.
|
|
4
|
+
Command handlers operate on canonical binary payloads for every transport.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
import struct
|
|
9
|
+
|
|
10
|
+
HEADER_SIZE = 9 # subheader 2 + route 5 + length 2
|
|
11
|
+
MIN_BODY = 6 # timer 2 + command 2 + subcommand 2 (binary bytes)
|
|
12
|
+
FRAMES = ("3E", "4E")
|
|
13
|
+
WIRES = ("binary", "ascii")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Layout:
|
|
18
|
+
frame: str
|
|
19
|
+
wire: str
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def scale(self):
|
|
23
|
+
return 2 if self.wire == "ascii" else 1
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def header_size(self):
|
|
27
|
+
return (HEADER_SIZE + (4 if self.frame == "4E" else 0)) * self.scale
|
|
28
|
+
|
|
29
|
+
def number(self, value, size):
|
|
30
|
+
binary = value.to_bytes(size, "little") # also checks field overflow
|
|
31
|
+
return f"{value:0{size * 2}X}".encode() if self.wire == "ascii" else binary
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LengthError(ValueError):
|
|
35
|
+
"""A fixed-width field is truncated or a payload has extra bytes."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Reader:
|
|
39
|
+
"""Exact-width numeric fields; ASCII hex permits either case, no whitespace."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, data, wire, offset=0):
|
|
42
|
+
self.data, self.wire, self.offset = data, wire, offset
|
|
43
|
+
|
|
44
|
+
def take(self, size):
|
|
45
|
+
result = self.data[self.offset:self.offset + size]
|
|
46
|
+
if len(result) != size:
|
|
47
|
+
raise LengthError("short field")
|
|
48
|
+
self.offset += size
|
|
49
|
+
return result
|
|
50
|
+
|
|
51
|
+
def number(self, size):
|
|
52
|
+
value = self.take(size * (2 if self.wire == "ascii" else 1))
|
|
53
|
+
if self.wire == "ascii":
|
|
54
|
+
if any(char not in b"0123456789abcdefABCDEF" for char in value):
|
|
55
|
+
raise ValueError("invalid hexadecimal field")
|
|
56
|
+
return int(value, 16)
|
|
57
|
+
return int.from_bytes(value, "little")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _layout(buffer):
|
|
61
|
+
if len(buffer) < 2:
|
|
62
|
+
return None
|
|
63
|
+
if buffer[:2] in (b"\x50\x00", b"\x54\x00"):
|
|
64
|
+
return Layout("4E" if buffer[0] == 0x54 else "3E", "binary")
|
|
65
|
+
if len(buffer) < 4 and buffer[:2] in (b"50", b"54"):
|
|
66
|
+
return None
|
|
67
|
+
if buffer[:4] in (b"5000", b"5400"):
|
|
68
|
+
return Layout("4E" if buffer[:4] == b"5400" else "3E", "ascii")
|
|
69
|
+
raise ValueError("unknown frame header")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class Request:
|
|
74
|
+
route: tuple # (network, pc, io, station)
|
|
75
|
+
timer: int
|
|
76
|
+
command: int
|
|
77
|
+
subcommand: int
|
|
78
|
+
data: bytes
|
|
79
|
+
frame: str = "3E"
|
|
80
|
+
wire: str = "binary"
|
|
81
|
+
serial: int = 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def frame_size(buffer):
|
|
85
|
+
"""Total size of the frame starting at buffer, or None until its header is complete."""
|
|
86
|
+
layout = _layout(buffer)
|
|
87
|
+
if layout is None or len(buffer) < layout.header_size:
|
|
88
|
+
return None
|
|
89
|
+
reader = Reader(buffer, layout.wire, layout.header_size - 2 * layout.scale)
|
|
90
|
+
return layout.header_size + reader.number(2)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def decode_request(frame):
|
|
94
|
+
layout = _layout(frame)
|
|
95
|
+
if layout is None or len(frame) < layout.header_size + MIN_BODY * layout.scale:
|
|
96
|
+
raise ValueError("short request")
|
|
97
|
+
if frame_size(frame) != len(frame):
|
|
98
|
+
raise ValueError("frame length mismatch")
|
|
99
|
+
reader = Reader(frame, layout.wire, 2 * layout.scale)
|
|
100
|
+
serial = 0
|
|
101
|
+
if layout.frame == "4E":
|
|
102
|
+
serial = reader.number(2)
|
|
103
|
+
if reader.number(2) != 0:
|
|
104
|
+
raise ValueError("nonzero 4E reserved field")
|
|
105
|
+
route = tuple(reader.number(size) for size in (1, 1, 2, 1))
|
|
106
|
+
reader.number(2) # length checked above
|
|
107
|
+
timer, command, subcommand = (reader.number(2) for _ in range(3))
|
|
108
|
+
return Request(route, timer, command, subcommand, frame[reader.offset:], layout.frame, layout.wire, serial)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def encode_response(request, end_code=0, data=b"", *, ascii_dword_order="swapped"):
|
|
112
|
+
layout = Layout(request.frame, request.wire)
|
|
113
|
+
if request.wire == "ascii" and data:
|
|
114
|
+
data = _ascii_response_data(request, data, ascii_dword_order)
|
|
115
|
+
body = layout.number(end_code, 2) + data
|
|
116
|
+
prefix = b"\xd4\x00" if request.frame == "4E" else b"\xd0\x00"
|
|
117
|
+
if request.wire == "ascii":
|
|
118
|
+
prefix = prefix.hex().upper().encode()
|
|
119
|
+
if request.frame == "4E":
|
|
120
|
+
prefix += layout.number(request.serial, 2) + layout.number(0, 2)
|
|
121
|
+
route = b"".join(layout.number(value, size) for value, size in zip(request.route, (1, 1, 2, 1)))
|
|
122
|
+
return prefix + route + layout.number(len(body), 2) + body
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def read_device(reader, profile, *, text_pad=0):
|
|
126
|
+
"""Read a descriptor using only the selected profile, never guess its class."""
|
|
127
|
+
wire = profile.wire
|
|
128
|
+
if reader.wire == "ascii":
|
|
129
|
+
device = profile.device_by_ascii(reader.take(wire.ascii_code_size).decode("latin-1"), text_pad=text_pad)
|
|
130
|
+
digits = reader.take(wire.ascii_address_size)
|
|
131
|
+
if any(char not in b"0123456789" for char in digits):
|
|
132
|
+
raise ValueError("invalid compat decimal address")
|
|
133
|
+
head = int(digits, 10)
|
|
134
|
+
else:
|
|
135
|
+
head = reader.number(wire.address_size)
|
|
136
|
+
device = profile.device_by_code(reader.number(wire.code_size))
|
|
137
|
+
if device is None:
|
|
138
|
+
raise ValueError("unknown device")
|
|
139
|
+
return device, head
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def normalize_request(request, profile, *, ascii_dword_order="swapped", text_pad=0):
|
|
143
|
+
"""Convert an ASCII command payload to the existing binary handler format.
|
|
144
|
+
|
|
145
|
+
A payload error is returned by dispatch as an end code, not a framing close.
|
|
146
|
+
Exact field widths reject overlong client addresses without truncating them.
|
|
147
|
+
"""
|
|
148
|
+
if request.wire == "binary":
|
|
149
|
+
return request
|
|
150
|
+
reader = Reader(request.data, "ascii")
|
|
151
|
+
wire = profile.wire
|
|
152
|
+
|
|
153
|
+
def descriptor():
|
|
154
|
+
device, head = read_device(reader, profile, text_pad=text_pad)
|
|
155
|
+
return head.to_bytes(wire.address_size, "little") + device.code.to_bytes(wire.code_size, "little")
|
|
156
|
+
|
|
157
|
+
command, subcommand = request.command, request.subcommand
|
|
158
|
+
data = bytearray()
|
|
159
|
+
if command in (0x0401, 0x1401):
|
|
160
|
+
data += descriptor()
|
|
161
|
+
points = reader.number(2)
|
|
162
|
+
data += points.to_bytes(2, "little")
|
|
163
|
+
if command == 0x1401:
|
|
164
|
+
if subcommand == wire.bit_subcommand:
|
|
165
|
+
data += pack_bits([char - 48 for char in reader.take(points)])
|
|
166
|
+
else:
|
|
167
|
+
data += pack_words([reader.number(2) for _ in range(points)])
|
|
168
|
+
elif command in (0x0403, 0x1402):
|
|
169
|
+
bit = subcommand == wire.bit_subcommand
|
|
170
|
+
counts = [reader.number(1) for _ in range(1 if bit else 2)]
|
|
171
|
+
data += bytes(counts)
|
|
172
|
+
for count, width in zip(counts, (wire.bit_value_size,) if bit else (2, 4)):
|
|
173
|
+
for _ in range(count):
|
|
174
|
+
data += descriptor()
|
|
175
|
+
if command == 0x1402:
|
|
176
|
+
value = _ascii_dword_value(reader.number(width), width, ascii_dword_order)
|
|
177
|
+
data += value.to_bytes(width, "little")
|
|
178
|
+
elif command == 0x0619:
|
|
179
|
+
size = reader.number(2)
|
|
180
|
+
data += size.to_bytes(2, "little") + reader.take(size)
|
|
181
|
+
if reader.offset != len(reader.data):
|
|
182
|
+
raise ValueError("payload length mismatch or overwidth address")
|
|
183
|
+
return replace(request, data=bytes(data))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _ascii_dword_value(value, width, order):
|
|
187
|
+
"""Exchange the two word halves only for an ASCII dword in swapped mode."""
|
|
188
|
+
return ((value & 0xFFFF) << 16) | (value >> 16) if width == 4 and order == "swapped" else value
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _ascii_response_data(request, data, ascii_dword_order):
|
|
192
|
+
reader = Reader(data, "binary")
|
|
193
|
+
layout = Layout(request.frame, "ascii")
|
|
194
|
+
if request.command == 0x0101:
|
|
195
|
+
return reader.take(16) + layout.number(reader.number(2), 2)
|
|
196
|
+
if request.command == 0x0619:
|
|
197
|
+
return layout.number(reader.number(2), 2) + data[2:]
|
|
198
|
+
if request.command == 0x0401 and request.subcommand in (1, 3):
|
|
199
|
+
points = Reader(request.data, "ascii", len(request.data) - 4).number(2)
|
|
200
|
+
return bytes(48 + bit for bit in unpack_bits(data, points))
|
|
201
|
+
counts = ((len(data) // 2, 2),)
|
|
202
|
+
if request.command == 0x0403:
|
|
203
|
+
sizes = Reader(request.data, "ascii")
|
|
204
|
+
counts = ((sizes.number(1), 2), (sizes.number(1), 4))
|
|
205
|
+
return b"".join(layout.number(_ascii_dword_value(reader.number(width), width, ascii_dword_order), width)
|
|
206
|
+
for count, width in counts for _ in range(count))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def pack_words(values):
|
|
210
|
+
if not all(0 <= value <= 0xFFFF for value in values):
|
|
211
|
+
raise ValueError("word out of range")
|
|
212
|
+
return struct.pack(f"<{len(values)}H", *values)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def unpack_words(data):
|
|
216
|
+
if len(data) % 2:
|
|
217
|
+
raise ValueError("odd word payload")
|
|
218
|
+
return list(struct.unpack(f"<{len(data) // 2}H", data))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def pack_bits(values):
|
|
222
|
+
if not all(value in (0, 1) for value in values):
|
|
223
|
+
raise ValueError("bit must be 0 or 1")
|
|
224
|
+
padded = list(values) + [0] * (len(values) % 2)
|
|
225
|
+
return bytes(high << 4 | low for high, low in zip(padded[::2], padded[1::2]))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def unpack_bits(data, count):
|
|
229
|
+
if len(data) != (count + 1) // 2:
|
|
230
|
+
raise ValueError("bit payload length mismatch")
|
|
231
|
+
nibbles = [nibble for byte in data for nibble in (byte >> 4, byte & 15)]
|
|
232
|
+
bits, padding = nibbles[:count], nibbles[count:]
|
|
233
|
+
if any(bit > 1 for bit in bits) or any(padding):
|
|
234
|
+
raise ValueError("bad bit nibble")
|
|
235
|
+
return bits
|
virtual_plc/commands.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Shared MC command handlers, parameterized by profile wire class."""
|
|
2
|
+
|
|
3
|
+
from enum import IntEnum
|
|
4
|
+
import struct
|
|
5
|
+
|
|
6
|
+
from .codec import LengthError, Reader, normalize_request, pack_bits, pack_words, read_device, unpack_bits, unpack_words
|
|
7
|
+
from .memory import OutOfRange
|
|
8
|
+
from .profiles import WIRE_CLASSES
|
|
9
|
+
|
|
10
|
+
ECHO_MAX = 960
|
|
11
|
+
LOCK_GATED = {0x1001, 0x1002, 0x1003, 0x1005, 0x1006}
|
|
12
|
+
CPU_STATES = {"STOP": 0, "RUN": 1, "PAUSE": 2} # SD203 layout: design, unverified.
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EndCode(IntEnum):
|
|
16
|
+
OK = 0x0000
|
|
17
|
+
POINTS_OUT_OF_RANGE = 0xC051
|
|
18
|
+
ADDRESS_OUT_OF_RANGE = 0xC056
|
|
19
|
+
UNSUPPORTED_COMMAND = 0xC059
|
|
20
|
+
REQUEST_DATA_ERROR = 0xC05C
|
|
21
|
+
DATA_LENGTH_MISMATCH = 0xC061
|
|
22
|
+
LOCKED = 0xC200
|
|
23
|
+
WRONG_PASSWORD = 0xC201
|
|
24
|
+
INVALID_STATE = 0xC202
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# DESIGN §7 rows: no manual cross-check yet.
|
|
28
|
+
ERROR_CODES = {
|
|
29
|
+
code.value: {"name": code.name, "source": "memory" if code in (EndCode.LOCKED, EndCode.WRONG_PASSWORD) else "design",
|
|
30
|
+
"status": "unverified"} for code in EndCode
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CommandError(Exception):
|
|
35
|
+
"""A request rejected with the end code in args[0]."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def batch(request, memory, max_points, control=None):
|
|
39
|
+
"""Batch read (0401) or write (1401); the subcommand selects bit or word units."""
|
|
40
|
+
wire = memory.profile.wire
|
|
41
|
+
if len(request.data) < wire.descriptor_size + 2:
|
|
42
|
+
raise CommandError(EndCode.REQUEST_DATA_ERROR)
|
|
43
|
+
reader = Reader(request.data, "binary")
|
|
44
|
+
device, head = read_device(reader, memory.profile)
|
|
45
|
+
points = reader.number(2)
|
|
46
|
+
payload = request.data[reader.offset:]
|
|
47
|
+
bit, write = request.subcommand == wire.bit_subcommand, request.command == 0x1401
|
|
48
|
+
if not 0 < points <= max_points:
|
|
49
|
+
raise CommandError(EndCode.POINTS_OUT_OF_RANGE)
|
|
50
|
+
expected = ((points + 1) // 2 if bit else 2 * points) if write else 0
|
|
51
|
+
if len(payload) != expected:
|
|
52
|
+
raise CommandError(EndCode.DATA_LENGTH_MISMATCH)
|
|
53
|
+
try:
|
|
54
|
+
if write:
|
|
55
|
+
values = unpack_bits(payload, points) if bit else unpack_words(payload)
|
|
56
|
+
(memory.write_bits if bit else memory.write_words)(device.name, head, values)
|
|
57
|
+
return b""
|
|
58
|
+
if bit:
|
|
59
|
+
return pack_bits(memory.read_bits(device.name, head, points))
|
|
60
|
+
return pack_words(memory.read_words(device.name, head, points))
|
|
61
|
+
except OutOfRange:
|
|
62
|
+
raise CommandError(EndCode.ADDRESS_OUT_OF_RANGE)
|
|
63
|
+
except ValueError:
|
|
64
|
+
raise CommandError(EndCode.REQUEST_DATA_ERROR)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def random_access(request, memory, max_points, control=None):
|
|
68
|
+
"""Random word/dword read/write or bit write; validate all entries first.
|
|
69
|
+
|
|
70
|
+
Counts are uint8, so a purported 256-entry list cannot match its payload.
|
|
71
|
+
Writes contain interleaved device/value entries, words before dwords.
|
|
72
|
+
The server holds its request lock through validation and application.
|
|
73
|
+
"""
|
|
74
|
+
data = request.data
|
|
75
|
+
wire = memory.profile.wire
|
|
76
|
+
bit = request.subcommand == wire.bit_subcommand
|
|
77
|
+
write = request.command == 0x1402
|
|
78
|
+
count_size = 1 if bit else 2
|
|
79
|
+
if len(data) < count_size:
|
|
80
|
+
raise CommandError(EndCode.DATA_LENGTH_MISMATCH)
|
|
81
|
+
groups = ((data[0], wire.bit_value_size),) if bit else ((data[0], 2), (data[1], 4))
|
|
82
|
+
expected = count_size + sum(count * (wire.descriptor_size + (width if write else 0)) for count, width in groups)
|
|
83
|
+
if len(data) != expected:
|
|
84
|
+
raise CommandError(EndCode.DATA_LENGTH_MISMATCH)
|
|
85
|
+
entries = []
|
|
86
|
+
offset = count_size
|
|
87
|
+
try:
|
|
88
|
+
for count, width in groups:
|
|
89
|
+
for _ in range(count):
|
|
90
|
+
reader = Reader(data, "binary", offset)
|
|
91
|
+
device, head = read_device(reader, memory.profile)
|
|
92
|
+
offset = reader.offset
|
|
93
|
+
memory.check(device.name, head, 1 if bit else width // 2, "bit" if bit else "word")
|
|
94
|
+
values = None
|
|
95
|
+
if write:
|
|
96
|
+
payload = data[offset:offset + width]
|
|
97
|
+
offset += width
|
|
98
|
+
if bit:
|
|
99
|
+
value = int.from_bytes(payload, "little")
|
|
100
|
+
if value not in (0, 1):
|
|
101
|
+
raise ValueError("bit must be 0 or 1")
|
|
102
|
+
values = [value]
|
|
103
|
+
else:
|
|
104
|
+
values = unpack_words(payload)
|
|
105
|
+
entries.append((device.name, head, width, values))
|
|
106
|
+
# Nothing above mutates storage. Overlaps are applied in wire order.
|
|
107
|
+
result = bytearray()
|
|
108
|
+
for device, head, width, values in entries:
|
|
109
|
+
if write:
|
|
110
|
+
(memory.write_bits if bit else memory.write_words)(device, head, values)
|
|
111
|
+
else:
|
|
112
|
+
result += pack_words(memory.read_words(device, head, width // 2))
|
|
113
|
+
return bytes(result)
|
|
114
|
+
except OutOfRange:
|
|
115
|
+
raise CommandError(EndCode.ADDRESS_OUT_OF_RANGE)
|
|
116
|
+
except ValueError:
|
|
117
|
+
raise CommandError(EndCode.REQUEST_DATA_ERROR)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def echo(request, memory, max_points, control=None):
|
|
121
|
+
length, text = int.from_bytes(request.data[:2], "little"), request.data[2:]
|
|
122
|
+
if len(request.data) < 2 or length != len(text):
|
|
123
|
+
raise CommandError(EndCode.DATA_LENGTH_MISMATCH)
|
|
124
|
+
if not 0 < length <= ECHO_MAX or not text.isalnum():
|
|
125
|
+
raise CommandError(EndCode.REQUEST_DATA_ERROR)
|
|
126
|
+
return request.data
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def cputype(request, memory, max_points, control=None):
|
|
130
|
+
profile = memory.profile
|
|
131
|
+
pad = 0 if control is None else control.text_pad
|
|
132
|
+
return profile.cpu_name.encode("ascii").ljust(16, bytes([pad])) + struct.pack("<H", profile.cpu_code)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def control_request(request, memory, max_points, control=None):
|
|
136
|
+
"""Agreed M5 policies, using the same field reader for every transport."""
|
|
137
|
+
command = request.command
|
|
138
|
+
auth = command in (0x1630, 0x1631)
|
|
139
|
+
if control is None or (auth and control.password is None):
|
|
140
|
+
raise CommandError(EndCode.UNSUPPORTED_COMMAND)
|
|
141
|
+
if control.locked and command in LOCK_GATED:
|
|
142
|
+
raise CommandError(EndCode.LOCKED)
|
|
143
|
+
reader = Reader(request.data, request.wire)
|
|
144
|
+
mode = reader.number(2) # password length for auth commands
|
|
145
|
+
clear, fixed = (reader.number(1), reader.number(1)) if command == 0x1001 else (0, 0)
|
|
146
|
+
password = reader.take(mode) if auth else b""
|
|
147
|
+
if reader.offset != len(reader.data):
|
|
148
|
+
raise LengthError("extra control data")
|
|
149
|
+
if auth:
|
|
150
|
+
wire = memory.profile.wire
|
|
151
|
+
if not wire.password_min <= len(password) <= wire.password_max or not password.isascii():
|
|
152
|
+
raise CommandError(EndCode.REQUEST_DATA_ERROR)
|
|
153
|
+
if password.decode("ascii") != control.password:
|
|
154
|
+
raise CommandError(EndCode.WRONG_PASSWORD)
|
|
155
|
+
control.locked = command == 0x1631
|
|
156
|
+
return b""
|
|
157
|
+
if mode not in ((1, 3) if command in (0x1001, 0x1003) else (1,)) or clear not in (0, 1, 2) or fixed:
|
|
158
|
+
raise CommandError(EndCode.REQUEST_DATA_ERROR)
|
|
159
|
+
if (command == 0x1003 and control.state == "STOP") or (command in (0x1005, 0x1006) and control.state != "STOP"):
|
|
160
|
+
raise CommandError(EndCode.INVALID_STATE)
|
|
161
|
+
if command == 0x1001 and clear:
|
|
162
|
+
memory.clear(control.latch_ranges, "non_latch" if clear == 1 else "all")
|
|
163
|
+
if command == 0x1005:
|
|
164
|
+
memory.clear(control.latch_ranges, "latch")
|
|
165
|
+
else:
|
|
166
|
+
if command == 0x1006:
|
|
167
|
+
control.reset_count += 1
|
|
168
|
+
control.set_state({0x1001: "RUN", 0x1003: "PAUSE"}.get(command, "STOP"))
|
|
169
|
+
return b""
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# Extension point: (command, subcommand) -> handler(request, memory, max_points, control).
|
|
173
|
+
HANDLERS = {
|
|
174
|
+
(0x0619, 0): echo, (0x0101, 0): cputype,
|
|
175
|
+
}
|
|
176
|
+
# Data subcommands are selected by wire class, so mismatches cannot dispatch.
|
|
177
|
+
DATA_HANDLERS = {}
|
|
178
|
+
for wire_class, wire in WIRE_CLASSES.items():
|
|
179
|
+
handlers = {}
|
|
180
|
+
for command in (0x0401, 0x1401):
|
|
181
|
+
handlers[command, wire.word_subcommand] = batch
|
|
182
|
+
handlers[command, wire.bit_subcommand] = batch
|
|
183
|
+
handlers[0x0403, wire.word_subcommand] = random_access
|
|
184
|
+
handlers[0x1402, wire.word_subcommand] = random_access
|
|
185
|
+
handlers[0x1402, wire.bit_subcommand] = random_access
|
|
186
|
+
DATA_HANDLERS[wire_class] = handlers
|
|
187
|
+
HANDLERS.update({(command, 0): control_request for command in LOCK_GATED | {0x1630, 0x1631}})
|
|
188
|
+
|
|
189
|
+
# Human-readable names for `virtual-plc guide profiles`; a test keeps this equal to the dispatch tables.
|
|
190
|
+
COMMAND_NAMES = {
|
|
191
|
+
0x0101: "cpu type", 0x0401: "batch read", 0x0403: "random read", 0x0619: "echo",
|
|
192
|
+
0x1001: "run", 0x1002: "stop", 0x1003: "pause", 0x1005: "latch clear", 0x1006: "reset",
|
|
193
|
+
0x1401: "batch write", 0x1402: "random write (word/dword/bit)",
|
|
194
|
+
0x1630: "unlock (needs --password)", 0x1631: "lock (needs --password)",
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def supported_commands(profile):
|
|
199
|
+
"""Command codes dispatch accepts on this profile, for any subcommand."""
|
|
200
|
+
return sorted({command for command, _ in HANDLERS} | {command for command, _ in DATA_HANDLERS[profile.wire_class]})
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def dispatch(request, memory, max_points, control=None):
|
|
204
|
+
"""Return (end_code, data); a rejected request leaves memory unchanged."""
|
|
205
|
+
key = (request.command, request.subcommand)
|
|
206
|
+
handler = HANDLERS.get(key) or DATA_HANDLERS[memory.profile.wire_class].get(key)
|
|
207
|
+
try:
|
|
208
|
+
if handler is None:
|
|
209
|
+
raise CommandError(EndCode.UNSUPPORTED_COMMAND)
|
|
210
|
+
# Control uses the shared Reader directly; data handlers consume canonical binary.
|
|
211
|
+
parsed = request if handler is control_request else normalize_request(
|
|
212
|
+
request, memory.profile, ascii_dword_order=control.ascii_dword_order if control is not None else "swapped",
|
|
213
|
+
text_pad=control.text_pad if control is not None else 0)
|
|
214
|
+
return EndCode.OK, handler(parsed, memory, max_points, control)
|
|
215
|
+
except CommandError as error:
|
|
216
|
+
return error.args[0], b""
|
|
217
|
+
except LengthError:
|
|
218
|
+
code = EndCode.DATA_LENGTH_MISMATCH if handler is control_request else EndCode.REQUEST_DATA_ERROR
|
|
219
|
+
return code, b""
|
|
220
|
+
except ValueError:
|
|
221
|
+
return EndCode.REQUEST_DATA_ERROR, b""
|