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.
@@ -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__"]
@@ -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
@@ -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""