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/faults.py ADDED
@@ -0,0 +1,139 @@
1
+ """Declarative, post-dispatch response faults; no effects until explicitly selected."""
2
+
3
+ from copy import deepcopy
4
+ import json
5
+ import math
6
+ import os
7
+ from pathlib import Path
8
+ import socket
9
+ import struct
10
+ import threading
11
+
12
+ from .codec import Layout
13
+
14
+
15
+ def _integer(value, minimum, maximum):
16
+ if type(value) is not int or not minimum <= value <= maximum:
17
+ raise ValueError(f"expected integer in {minimum}..{maximum}")
18
+
19
+
20
+ def _seconds(value):
21
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value < 0:
22
+ raise ValueError("seconds must be finite and nonnegative")
23
+
24
+
25
+ # Action type -> (required, optional) parameter names; also drives `virtual-plc guide faults`.
26
+ ACTION_FIELDS = {
27
+ "delay": ({"seconds"}, set()), "drop": (set(), set()),
28
+ "truncate": ({"n"}, set()), "bad_end_code": ({"code"}, set()),
29
+ "close_before_response": (set(), set()), "close_after_response": (set(), set()),
30
+ "rst": (set(), set()), "silent_hold": (set(), set()),
31
+ "corrupt_header": (set(), {"offset", "xor"}),
32
+ "partial_send": ({"chunks"}, {"pause"}),
33
+ }
34
+
35
+
36
+ def _validate(step):
37
+ if not isinstance(step, dict) or set(step) - {"match", "times", "action"} or "action" not in step:
38
+ raise ValueError("a fault step requires action and optional match/times")
39
+ match = step.setdefault("match", {})
40
+ if not isinstance(match, dict) or set(match) - {"command", "nth", "connection"}:
41
+ raise ValueError("unknown match fields")
42
+ for key, value in match.items():
43
+ if key == "command" and isinstance(value, str):
44
+ value = match[key] = int(value, 0)
45
+ _integer(value, 0 if key == "command" else 1, 0xFFFF if key == "command" else 2**63 - 1)
46
+ _integer(step.setdefault("times", 1), 1, 2**63 - 1)
47
+ action = step["action"]
48
+ if not isinstance(action, dict):
49
+ raise ValueError("action must be an object")
50
+ kind = action.get("type")
51
+ if not isinstance(kind, str) or kind not in ACTION_FIELDS:
52
+ raise ValueError("unknown fault action")
53
+ required, optional = ACTION_FIELDS[kind]
54
+ if not required <= set(action) or set(action) - required - optional - {"type"}:
55
+ raise ValueError(f"invalid fields for {kind}")
56
+ if kind == "delay":
57
+ _seconds(action["seconds"])
58
+ elif kind == "truncate":
59
+ _integer(action["n"], 0, 2**63 - 1)
60
+ elif kind == "bad_end_code":
61
+ _integer(action["code"], 0, 0xFFFF)
62
+ elif kind == "corrupt_header":
63
+ # Common to every response header, including the shortest 3E binary header.
64
+ _integer(action.setdefault("offset", 0), 0, 8)
65
+ _integer(action.setdefault("xor", 1), 1, 255)
66
+ elif kind == "partial_send":
67
+ if not isinstance(action["chunks"], list) or not action["chunks"]:
68
+ raise ValueError("chunks must be a nonempty list of positive byte counts")
69
+ for size in action["chunks"]:
70
+ _integer(size, 1, 2**63 - 1)
71
+ _seconds(action.setdefault("pause", 0))
72
+
73
+
74
+ class FaultPlan:
75
+ """First eligible step wins; counts are reserved atomically, never rolled back."""
76
+
77
+ def __init__(self, steps):
78
+ if not isinstance(steps, list):
79
+ raise ValueError("steps must be a list")
80
+ self._steps = deepcopy(steps)
81
+ for step in self._steps:
82
+ _validate(step)
83
+ self._lock = threading.Lock()
84
+
85
+ @classmethod
86
+ def from_file(cls, path):
87
+ document = json.loads(Path(path).read_text(encoding="utf-8"))
88
+ if not isinstance(document, dict) or set(document) != {"steps"}:
89
+ raise ValueError("fault file must contain only a steps array")
90
+ return cls(document["steps"])
91
+
92
+ def select(self, command, *, nth, connection):
93
+ context = {"command": command, "nth": nth, "connection": connection}
94
+ with self._lock:
95
+ for step in self._steps:
96
+ if step["times"] and all(context[key] == value for key, value in step["match"].items()):
97
+ step["times"] -= 1
98
+ return deepcopy(step["action"])
99
+ return None
100
+
101
+
102
+ def send_fault(connection, response, request, action, stopping):
103
+ """Apply one reserved action. Return False to close; waits cancel on server stop."""
104
+ kind = action["type"]
105
+ if kind == "drop":
106
+ return True
107
+ if kind == "silent_hold":
108
+ stopping.wait()
109
+ return False
110
+ if kind == "close_before_response":
111
+ return False
112
+ if kind == "rst":
113
+ connection.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("hh" if os.name == "nt" else "ii", 1, 0))
114
+ return False # The worker closes without shutdown, preserving abortive-close semantics.
115
+ if kind == "delay" and stopping.wait(action["seconds"]):
116
+ return False
117
+ if kind == "truncate":
118
+ response = response[:action["n"]]
119
+ elif kind == "bad_end_code":
120
+ layout = Layout(request.frame, request.wire)
121
+ offset = layout.header_size
122
+ response = response[:offset] + layout.number(action["code"], 2) + response[offset + 2 * layout.scale:]
123
+ elif kind == "corrupt_header":
124
+ changed = bytearray(response)
125
+ changed[action["offset"]] ^= action["xor"]
126
+ response = bytes(changed)
127
+ elif kind == "partial_send":
128
+ offset = 0
129
+ for size in action["chunks"] + [len(response)]:
130
+ chunk = response[offset:offset + size]
131
+ if not chunk:
132
+ break
133
+ connection.sendall(chunk)
134
+ offset += len(chunk)
135
+ if offset < len(response) and stopping.wait(action["pause"]):
136
+ return False
137
+ return True
138
+ connection.sendall(response)
139
+ return kind != "close_after_response"
virtual_plc/guide.py ADDED
@@ -0,0 +1,343 @@
1
+ """Token-efficient usage guide for humans and AI agents: `virtual-plc guide [TOPIC] [--json]`.
2
+
3
+ Hand-written prose lives here. The option, profile, command, device and fault
4
+ tables are generated from the argparse parser and from the profile, command and
5
+ fault tables, so they cannot drift. Output is ASCII-only for legacy consoles.
6
+ """
7
+
8
+ import argparse
9
+ import inspect
10
+ import json
11
+ import sys
12
+ import textwrap
13
+
14
+ from . import __version__
15
+ from .codec import FRAMES, WIRES
16
+ from .commands import COMMAND_NAMES, supported_commands
17
+ from .faults import ACTION_FIELDS
18
+ from .profiles import PROFILES
19
+ from .server import Server
20
+
21
+ TOPICS = ("run", "options", "client", "profiles", "embed", "faults", "memory", "troubleshoot", "divergences")
22
+
23
+ # Option -> topic named by error messages; every other option points to `options`.
24
+ OPTION_TOPICS = {"--profile": "profiles", "--fault-plan": "faults", "--init": "memory",
25
+ "--dump-on-exit": "memory", "--limit": "memory", "--latch-range": "memory"}
26
+
27
+ # Mirrored verbatim in the README quickstart (a test fails on drift).
28
+ RECIPES = (
29
+ "virtual-plc --port 5000 --text-pad 0x20",
30
+ "virtual-plc --profile iQ-R --port 5001 --initial-state STOP",
31
+ "virtual-plc --init seed.json --fault-plan faults.json --dump-on-exit saved.json",
32
+ )
33
+ RECIPE_LINES = "\n".join(" " + recipe for recipe in RECIPES)
34
+
35
+ EFFECTS = {
36
+ "delay": "wait, then send the reply normally",
37
+ "drop": "send nothing; the connection stays open",
38
+ "truncate": "send only the first n bytes",
39
+ "bad_end_code": "replace the end code (0..65535)",
40
+ "close_before_response": "close without replying",
41
+ "close_after_response": "reply, then close",
42
+ "rst": "abortive close (TCP reset)",
43
+ "silent_hold": "answer nothing on this connection until the server stops",
44
+ "corrupt_header": "XOR one header byte (offset 0..8, xor 1..255)",
45
+ "partial_send": "send in chunks of the given byte counts, pausing between",
46
+ }
47
+
48
+ RUN = f"""
49
+ Start the simulator (Python 3.10+, stdlib only). The banner and -v logs go to stderr; stdout stays empty.
50
+ python -m pip install virtual-plc (from a checkout: python -m pip install -e .)
51
+ {RECIPE_LINES}
52
+ Same program: python -m virtual_plc ... Every option: virtual-plc guide options.
53
+ Banner: "listening on HOST:PORT (...)". With --port 0 parse the real port from it.
54
+ Stop: Ctrl+C or SIGTERM exits 0 and writes --dump-on-exit; Windows SIGBREAK needs a shared console.
55
+ Exit codes: 0 clean stop, 1 cannot bind, 2 bad configuration (message ends "see: virtual-plc guide TOPIC").
56
+ Default bind is 127.0.0.1. Other hosts print a WARNING: there is no authentication, so never expose it.
57
+ -v logs request summaries (no payloads or passwords) and why each connection closed.
58
+ Idle clients stay connected unless --idle-timeout SECONDS is set.
59
+ """.strip("\n")
60
+
61
+ CLIENT = """
62
+ Client: pymcprotocol (python -m pip install pymcprotocol). Its plctype must match --profile.
63
+ from pymcprotocol import Type3E # Type4E for 4E frames
64
+ c = Type3E(plctype="Q") # Q, L, QnA, iQ-L, iQ-R
65
+ c.setaccessopt(commtype="ascii") # default is "binary"
66
+ c.connect("127.0.0.1", 5000)
67
+ c.batchwrite_wordunits("D0", [123, -45]); c.batchread_wordunits("D0", 2) # client signed, server unsigned
68
+ c.batchwrite_bitunits("M0", [1, 0, 1]); c.batchread_bitunits("M0", 3)
69
+ c.randomwrite([], [], ["D0"], [0x12345678]); c.randomread([], ["D0"]) # returns (words, dwords)
70
+ c.echo_test("ABC"); c.read_cputype() # CPU name has NUL fill unless --text-pad 0x20
71
+ c.remote_stop(); c.remote_run(0) # remote_reset() needs STOP; remote_unlock(pw) needs --password
72
+ c.close()
73
+ iQ-R: start virtual-plc --profile iQ-R --port 5001 --initial-state STOP, then:
74
+ c = Type3E(plctype="iQ-R"); c.connect("127.0.0.1", 5001); c.close()
75
+ Hex-numbered devices (X Y B W SB SW DX DY ZR) take hex digits in the client: "X1F". Names are case-sensitive.
76
+ ASCII frames carry device numbers as decimal text (pymcprotocol "compat" numbering); the client handles it.
77
+ setaccessopt(timer_sec=2) sets the PLC monitoring timer (ignored here) and a 3 s client socket timeout.
78
+ """.strip("\n")
79
+
80
+ TROUBLESHOOT = """
81
+ Symptom -> cause -> fix
82
+ "cannot listen on H:P" (exit 1): port busy or denied -> choose another --port, or --port 0 and read the banner.
83
+ Exit 2 and "see: virtual-plc guide TOPIC": bad option, JSON file or plan -> read that topic; nothing was bound.
84
+ Client times out: wrong host/port, a fault plan that holds or drops replies, or --idle-timeout closed the socket
85
+ (reconnect). A silent_hold fault ends only when the server stops.
86
+ Large read fails: pymcprotocol reads once with recv(4096); keep batches under ~1000 words (ASCII is 4 chars/word).
87
+ --max-points is a separate server-side limit (points per request).
88
+ CPU name has NUL bytes: the default fill is 0x00 and the client keeps NULs -> --text-pad 0x20.
89
+ Status 0 on a broken reply: the client never checks subheader or length, so a truncated or closed binary
90
+ reply can look like success. It is not proof of success.
91
+ Run/stop/pause/latch-clear/reset fail: locked (unlock with the --password text; length per profile in
92
+ guide profiles) or wrong state: latch clear and reset need STOP, pause needs RUN or PAUSE.
93
+ A normal reset keeps the connection open.
94
+ iQ-R vs Q mismatch: addressed commands use different subcommands -> set --profile and plctype together.
95
+ Echo rejected: data must be ASCII letters/digits, 1..960 chars (an error end code, no disconnect).
96
+ Client device-name error: names are case-sensitive; hex devices need a digit first ("X0FFF", not "XFFF").
97
+ """.strip("\n")
98
+
99
+ DIVERGENCES = """
100
+ This is a simulator, not a PLC. These behaviors are synthetic or unverified; do not treat them as vendor facts.
101
+ - CPU identities (VirtualPLC-<profile>), device ranges and the SD203 state codes are synthetic.
102
+ - CPU-name fill defaults to NUL (--text-pad); the fill of a physical PLC is unverified.
103
+ - ASCII frames send device numbers as decimal text even for hex devices (X Y B W SB SW DX DY ZR), as
104
+ pymcprotocol does ("compat"). The manual's hex policy is not implemented.
105
+ - ASCII dword word order: default swapped (low word first); --ascii-dword-order client keeps high first.
106
+ The real order is unverified; round trips are symmetric but batch word views differ.
107
+ - The lock is server-wide, survives disconnects and gates only run/stop/pause/latch clear/reset. It is
108
+ not authentication.
109
+ - iQ-R native dword devices (LTN LSTN LCN LZ) use one 32-bit point per address; word access streams the
110
+ low/high halves. The stride is unverified.
111
+ - Timers and counters hold plain values; no PLC logic executes. The monitoring timer is parsed and ignored.
112
+ Every network/PC/station route aliases one memory.
113
+ - Error codes are design values; C200 and C201 may be swapped. Z devices are unsupported.
114
+ - Faults act after execution: a faulted write or reset has already happened.
115
+ """.strip("\n")
116
+
117
+ PROSE = {"run": RUN, "client": CLIENT, "troubleshoot": TROUBLESHOOT, "divergences": DIVERGENCES}
118
+
119
+ CONNECT = """
120
+ Connect (python -m pip install pymcprotocol):
121
+ from pymcprotocol import Type3E
122
+ c = Type3E(plctype="Q"); c.connect("127.0.0.1", 5000)
123
+ c.batchwrite_wordunits("D0", [123, -45]); print(c.batchread_wordunits("D0", 2)); c.close()
124
+ """.strip("\n")
125
+
126
+ PITFALLS = """
127
+ Pitfalls (fix):
128
+ 1. "cannot listen" (exit 1): port busy -> choose another --port, or --port 0 and read the stderr banner.
129
+ 2. iQ-R errors: the client plctype and --profile must both be iQ-R (Q-class names share one wire format).
130
+ 3. CPU name contains NUL bytes: the default fill is 0x00 -> start with --text-pad 0x20.
131
+ 4. Big reads fail in pymcprotocol (one recv of 4096 bytes) -> keep batches under ~1000 words.
132
+ 5. Reset or latch clear returns an error: both need STOP -> start with --initial-state STOP.
133
+ """.strip("\n")
134
+
135
+ OPTIONS_HEAD = "Options: flag, default ('-' = none), meaning. Bad values exit 2 with an error that ends in 'see: virtual-plc guide TOPIC'."
136
+ OPTIONS_TAIL = ("DEV:LO-HI repeats (--limit D:0-99 --limit M:0-15); its endpoints and --text-pad accept 0x notation.\n"
137
+ "Same data for programs: virtual-plc guide options --json")
138
+
139
+ FAULTS_HEAD = """
140
+ Faults change how replies are delivered AFTER the command ran; writes are never rolled back. Opt in with
141
+ --fault-plan FILE or Server(fault_plan=FaultPlan(steps)); without a plan nothing is matched or delayed.
142
+ Schema: {"steps": [STEP, ...]} in a file (no other root keys); Python takes the list. A bad plan exits 2 before bind.
143
+ STEP = {"match": {...}, "times": N, "action": {"type": T, ...}}; match and times are optional (times defaults to 1).
144
+ match keys, ANDed: command (int or "0x0619"), connection (1 = first accepted), nth (1 = first request on it).
145
+ The first step that matches and has uses left wins; one step applies per request.
146
+ Actions (parameters; [optional]):
147
+ """.strip("\n")
148
+
149
+ FAULTS_TAIL = """
150
+ Examples (each line is a complete valid file):
151
+ {"steps": [{"match": {"command": "0x0619"}, "action": {"type": "delay", "seconds": 0.5}}]}
152
+ {"steps": [{"match": {"command": "0x0401"}, "times": 2, "action": {"type": "bad_end_code", "code": 49241}}]}
153
+ {"steps": [{"match": {"connection": 1, "nth": 1}, "action": {"type": "truncate", "n": 1}}]}
154
+ Reset test: --initial-state STOP plus {"steps": [{"match": {"command": "0x1006"}, "action": {"type": "rst"}}]}
155
+ makes the client reconnect after about 1 s. Give silent_hold its own connection.
156
+ """.strip("\n")
157
+
158
+ MEMORY_HEAD = """
159
+ Memory JSON: --init FILE loads it before listening; --dump-on-exit FILE saves it after a graceful stop.
160
+ Schema: {"DEVICE": {"HEAD": [values, ...]}}; HEAD is a decimal string and values fill consecutive points.
161
+ {"D": {"100": [1, 2, 3]}, "M": {"0": [1, 0, 1]}}
162
+ {"LTN": {"7": [305419896]}}
163
+ Values are unsigned native points: bit 0/1, word 0..65535, dword 0..4294967295 (never signed numbers).
164
+ The whole snapshot is validated before anything is applied; an invalid file exits 2.
165
+ Dumps use the same schema: contiguous runs, explicit zeros kept, plus SD203 (CPU state: STOP=0 RUN=1 PAUSE=2).
166
+ Device names by kind (Q-class profiles lack LT*/LST*/LC*/LZ/RD; see guide profiles):
167
+ """.strip("\n")
168
+
169
+ MEMORY_TAIL = """
170
+ --limit DEV:LO-HI (repeatable) sets the inclusive valid points of a device; requests outside get an error.
171
+ --latch-range DEV:LO-HI (repeatable) selects latch ranges; default: all of L. SM and SD are never cleared.
172
+ Latch ranges are cleared by latchclear and preserved by remote_run(clear_mode=1).
173
+ Example: virtual-plc --init seed.json --limit D:0-102 --latch-range D:100-101 --dump-on-exit saved.json
174
+ Word access to a bit device needs a head divisible by 16 (a word read of M16 returns M16..M31).
175
+ Bit access to a word device is rejected. Python: Memory.load(dict) and Memory.dump(); see guide embed.
176
+ """.strip("\n")
177
+
178
+
179
+ def option_topic(names):
180
+ """Topic for an argparse argument name such as '--limit' or '-v/--verbose'."""
181
+ return next((OPTION_TOPICS[name] for name in names.split("/") if name in OPTION_TOPICS), "options")
182
+
183
+
184
+ def _option_rows(parser):
185
+ return [{
186
+ "name": action.option_strings[-1],
187
+ "flags": action.option_strings,
188
+ "metavar": (action.metavar or action.dest.upper()) if action.nargs != 0 else None,
189
+ "default": None if action.default is argparse.SUPPRESS else action.default,
190
+ "choices": list(action.choices) if action.choices else None,
191
+ "help": action.help,
192
+ } for action in parser._actions]
193
+
194
+
195
+ def _default_text(row):
196
+ default = row["default"]
197
+ if default is None or default == []:
198
+ return "-"
199
+ return "off" if default is False else str(default)
200
+
201
+
202
+ def _option_line(row):
203
+ label = ", ".join(row["flags"]) + (" " + row["metavar"] if row["metavar"] else "")
204
+ choices = " [" + "|".join(row["choices"]) + "]" if row["choices"] else ""
205
+ return f" {label} | {_default_text(row)} | {row['help']}{choices}"
206
+
207
+
208
+ def _options(parser):
209
+ return "\n".join([OPTIONS_HEAD, *map(_option_line, _option_rows(parser)), OPTIONS_TAIL])
210
+
211
+
212
+ def _overview(parser):
213
+ return "\n".join([
214
+ f"virtual-plc: local Mitsubishi MC-protocol PLC simulator (3E/4E, binary/ASCII; profiles {', '.join(PROFILES)})"
215
+ " for client tests. Localhost only.",
216
+ "Run:", RECIPE_LINES,
217
+ "Options (flag | default | meaning; details: virtual-plc guide options):",
218
+ *map(_option_line, _option_rows(parser)), CONNECT,
219
+ f"Topics (virtual-plc guide TOPIC [--json]): {' '.join(TOPICS)}", PITFALLS,
220
+ ])
221
+
222
+
223
+ def _profile_commands():
224
+ return {profile.name: supported_commands(profile) for profile in PROFILES.values()}
225
+
226
+
227
+ def _profiles(parser):
228
+ commands = _profile_commands()
229
+ lines = [f"Frames {' '.join(FRAMES)} x encodings {' '.join(WIRES)}: every command works on all four pairs, "
230
+ "even mixed on one connection.", f"Code {'Command':<30}Profiles"]
231
+ for code in sorted({code for codes in commands.values() for code in codes}):
232
+ names = [name for name, codes in commands.items() if code in codes]
233
+ where = "all" if len(names) == len(PROFILES) else " ".join(names)
234
+ lines.append(f"{code:04X} {COMMAND_NAMES[code]:<30}{where}")
235
+ groups = {}
236
+ for profile in PROFILES.values():
237
+ groups.setdefault((profile.wire_class, tuple(profile.devices)), []).append(profile)
238
+ for (wire_class, devices), members in groups.items():
239
+ wire = members[0].wire
240
+ size = str(wire.password_min) if wire.password_min == wire.password_max else f"{wire.password_min}-{wire.password_max}"
241
+ lines.append(f"{', '.join(profile.name for profile in members)}: {wire_class} wire, password {size} chars, devices:")
242
+ lines.append(textwrap.fill(" ".join(devices), 100, initial_indent=" ", subsequent_indent=" "))
243
+ lines.append("Client plctype must equal --profile for iQ-R; Q-class profiles differ only in CPU identity and code.")
244
+ return "\n".join(lines)
245
+
246
+
247
+ def _profile_data():
248
+ commands = _profile_commands()
249
+ return {"frames": list(FRAMES), "encodings": list(WIRES), "profiles": [{
250
+ "name": profile.name, "wire_class": profile.wire_class, "cpu_name": profile.cpu_name,
251
+ "devices": [{"name": device.name, "kind": device.kind} for device in profile.devices.values()],
252
+ "commands": [f"{code:04X}" for code in commands[profile.name]],
253
+ } for profile in PROFILES.values()]}
254
+
255
+
256
+ def _embed(parser):
257
+ keywords = ", ".join(inspect.signature(Server).parameters)
258
+ return f"""
259
+ Embed in Python (stdlib only). Leaving the with-block stops the server and joins its workers.
260
+ from virtual_plc import Server, Memory, FaultPlan
261
+ memory = Memory() # Memory("iQ-R"); Memory(limits=dict(D=(0, 99)))
262
+ memory.write_words("D", 0, [123]) # seed BEFORE start
263
+ with Server(memory=memory, port=0) as server: # port 0 = free port
264
+ host, port = server.address # connect clients here
265
+ snapshot = memory.dump() # AFTER stop; same JSON schema as --init
266
+ Server keywords: {keywords}.
267
+ Bad values raise ValueError before binding; Server(profile=...) and Memory(profile) must match.
268
+ Memory API: read_words/write_words/read_bits/write_bits(device, head, ...), load(dict), dump().
269
+ Live state: server.state ("RUN"/"STOP"/"PAUSE"), server.set_state(s), server.reset_count, server.locked.
270
+ Memory calls are not synchronized with wire requests: never load, mutate or dump while serving; use a client.
271
+ FaultPlan(steps) or FaultPlan.from_file(path) -> Server(fault_plan=...); schema: virtual-plc guide faults.
272
+ response_hook(bytes) -> bytes transforms every complete reply before it is sent.
273
+ pytest fixture:
274
+ import pytest
275
+ from pymcprotocol import Type3E
276
+ from virtual_plc import Server, Memory
277
+
278
+ @pytest.fixture
279
+ def plc():
280
+ memory = Memory()
281
+ memory.write_words("D", 0, [123])
282
+ with Server(memory=memory, port=0) as server:
283
+ yield server
284
+
285
+ def test_seeded(plc):
286
+ client = Type3E(plctype="Q")
287
+ try:
288
+ client.connect(*plc.address)
289
+ assert client.batchread_wordunits("D0", 1) == [123]
290
+ finally:
291
+ client.close()
292
+ """.strip("\n")
293
+
294
+
295
+ def _faults(parser):
296
+ actions = []
297
+ for kind, (required, optional) in ACTION_FIELDS.items():
298
+ parts = sorted(required) + [f"[{name}]" for name in sorted(optional)]
299
+ signature = f"{kind}({', '.join(parts)})" if parts else kind
300
+ actions.append(f" {signature:<34}{EFFECTS[kind]}")
301
+ return "\n".join([FAULTS_HEAD, *actions, FAULTS_TAIL])
302
+
303
+
304
+ def _memory(parser):
305
+ largest = max(PROFILES.values(), key=lambda profile: len(profile.devices))
306
+ kinds = {}
307
+ for device in largest.devices.values():
308
+ kinds.setdefault(device.kind, []).append(device.name)
309
+ return "\n".join([MEMORY_HEAD, *(f" {kind}: {' '.join(names)}" for kind, names in kinds.items()), MEMORY_TAIL])
310
+
311
+
312
+ GENERATED = {None: _overview, "options": _options, "profiles": _profiles, "embed": _embed,
313
+ "faults": _faults, "memory": _memory}
314
+
315
+
316
+ def render(parser, topic=None):
317
+ """Text of one topic (None = overview); `parser` supplies the option table."""
318
+ return PROSE[topic] if topic in PROSE else GENERATED[topic](parser)
319
+
320
+
321
+ def data(parser, topic=None):
322
+ """JSON-ready document: text, or structured rows for the options and profiles topics."""
323
+ document = {"version": __version__, "topic": topic or "overview"}
324
+ if topic == "options":
325
+ document["options"] = _option_rows(parser)
326
+ elif topic == "profiles":
327
+ document.update(_profile_data())
328
+ else:
329
+ document["text"] = render(parser, topic)
330
+ return document
331
+
332
+
333
+ def main(argv, parser):
334
+ """Print one guide topic and return the exit status (2 for an unknown topic)."""
335
+ arguments = argparse.ArgumentParser(prog="virtual-plc guide", description="Usage guide for humans and AI agents.")
336
+ arguments.add_argument("topic", nargs="?", help="one of: " + " ".join(TOPICS))
337
+ arguments.add_argument("--json", action="store_true", help="print JSON instead of text")
338
+ args = arguments.parse_args(argv)
339
+ if args.topic is not None and args.topic not in TOPICS:
340
+ print(f"unknown topic {args.topic!r}; topics: {', '.join(TOPICS)}", file=sys.stderr)
341
+ return 2
342
+ print(json.dumps(data(parser, args.topic)) if args.json else render(parser, args.topic))
343
+ return 0
virtual_plc/memory.py ADDED
@@ -0,0 +1,145 @@
1
+ """Sparse device storage and atomic request operations (M1)."""
2
+
3
+ from copy import deepcopy
4
+
5
+ from .profiles import PROFILES
6
+
7
+
8
+ class OutOfRange(ValueError):
9
+ """Access outside the configured device range."""
10
+
11
+
12
+ class Memory:
13
+ """Unsigned point storage per device family; bit devices hold one bit per point."""
14
+
15
+ def __init__(self, profile="Q", limits=None):
16
+ self.profile = PROFILES[profile]
17
+ self._limits = {name: (spec.minimum, spec.maximum) for name, spec in self.profile.devices.items()}
18
+ for name, span in (limits or {}).items():
19
+ spec = self.profile.devices.get(name)
20
+ if (spec is None or not isinstance(span, (tuple, list)) or len(span) != 2 or any(type(value) is not int for value in span)
21
+ or not spec.minimum <= span[0] <= span[1] <= spec.maximum):
22
+ raise ValueError("invalid memory limit")
23
+ self._limits[name] = tuple(span)
24
+ self._cells = {name: {} for name in self.profile.devices}
25
+
26
+ def read_words(self, device, head, count):
27
+ spec = self.check(device, head, count, "word")
28
+ if spec.kind == "word":
29
+ return self._load(device, head, count)
30
+ if spec.kind == "dword":
31
+ cells = self._cells[device]
32
+ return [(cells.get(head + i // 2, 0) >> (16 * (i % 2))) & 0xFFFF for i in range(count)]
33
+ bits = self._load(device, head, count * 16)
34
+ return [sum(bit << i for i, bit in enumerate(bits[j:j + 16])) for j in range(0, len(bits), 16)]
35
+
36
+ def read_bits(self, device, head, count):
37
+ self.check(device, head, count, "bit")
38
+ return self._load(device, head, count)
39
+
40
+ def write_words(self, device, head, values):
41
+ values = list(values)
42
+ if not all(0 <= value <= 0xFFFF for value in values):
43
+ raise ValueError("word out of range")
44
+ spec = self.check(device, head, len(values), "word")
45
+ if spec.kind == "dword":
46
+ # Heads address native 32-bit points; word counts stream low/high halves.
47
+ cells = self._cells[device]
48
+ for i, value in enumerate(values):
49
+ point, shift = head + i // 2, 16 * (i % 2)
50
+ cells[point] = (cells.get(point, 0) & ~(0xFFFF << shift)) | (value << shift)
51
+ return
52
+ if spec.kind == "bit":
53
+ values = [value >> i & 1 for value in values for i in range(16)]
54
+ self._store(device, head, values)
55
+
56
+ def write_bits(self, device, head, values):
57
+ values = list(values)
58
+ if not all(value in (0, 1) for value in values):
59
+ raise ValueError("bit must be 0 or 1")
60
+ self.check(device, head, len(values), "bit")
61
+ self._store(device, head, values)
62
+
63
+ def check(self, device, head, count, unit):
64
+ """Validate one access; `count` is in `unit`s. Returns the device spec."""
65
+ spec = self.profile.devices.get(device)
66
+ if spec is None or count <= 0:
67
+ raise ValueError(f"invalid access to {device} x{count}")
68
+ points = count
69
+ if unit == "bit" and spec.kind != "bit":
70
+ raise ValueError(f"bit access to word device {device}")
71
+ if unit == "word" and spec.kind == "bit":
72
+ if head % 16:
73
+ raise ValueError("word view of a bit device needs a head that is a multiple of 16")
74
+ points = count * 16
75
+ if unit == "word" and spec.kind == "dword":
76
+ points = (count + 1) // 2
77
+ minimum, maximum = self._limits[device]
78
+ if head < minimum or head + points - 1 > maximum:
79
+ raise OutOfRange(f"{device}{head} x{points} outside {minimum}..{maximum}")
80
+ return spec
81
+
82
+ def _load(self, device, head, count):
83
+ cells = self._cells[device]
84
+ return [cells.get(head + i, 0) for i in range(count)]
85
+
86
+ def _store(self, device, head, values):
87
+ self._cells[device].update(enumerate(values, head))
88
+
89
+ def clear(self, latch_ranges, mode):
90
+ """Clear stored points only; the server holds its request lock.
91
+
92
+ mode is latch, non_latch, or all. Special devices are always preserved.
93
+ """
94
+ for device, cells in self._cells.items():
95
+ if device in {"SM", "SD"}:
96
+ continue
97
+ for head in list(cells):
98
+ latched = any(low <= head <= high for low, high in latch_ranges.get(device, ()))
99
+ if mode == "all" or (mode == "latch" and latched) or (mode == "non_latch" and not latched):
100
+ del cells[head]
101
+
102
+
103
+ def dump(self):
104
+ """Return stored points as JSON-compatible contiguous runs (native units).
105
+
106
+ Call before start or after stop; direct Memory access is not synchronized.
107
+ """
108
+ result = {}
109
+ for name, cells in self._cells.items():
110
+ runs = {}
111
+ previous = None
112
+ for head, value in sorted(cells.items()):
113
+ if previous is None or head != previous + 1:
114
+ values = []
115
+ runs[str(head)] = values
116
+ values.append(value)
117
+ previous = head
118
+ if runs:
119
+ result[name] = runs
120
+ return result
121
+
122
+ def load(self, data):
123
+ """Validate a complete snapshot before overlaying it; use before start.
124
+
125
+ Values are unsigned bits/words/native dwords, not client signed values.
126
+ """
127
+ if not isinstance(data, dict):
128
+ raise ValueError("memory snapshot must be an object")
129
+ staged = Memory(self.profile.name, self._limits)
130
+ staged._cells = deepcopy(self._cells)
131
+ for name, runs in data.items():
132
+ spec = self.profile.devices.get(name)
133
+ if spec is None or not isinstance(runs, dict):
134
+ raise ValueError("invalid snapshot device or runs")
135
+ for address, values in runs.items():
136
+ if not isinstance(address, str) or not isinstance(values, list) or not values:
137
+ raise ValueError("snapshot runs need decimal address keys and nonempty lists")
138
+ head = int(address, 10)
139
+ maximum = {"bit": 1, "word": 65535, "dword": 0xFFFFFFFF}[spec.kind]
140
+ if any(type(value) is not int or not 0 <= value <= maximum for value in values):
141
+ raise ValueError("snapshot value out of range")
142
+ if spec.kind == "dword":
143
+ values = [half for value in values for half in (value & 0xFFFF, value >> 16)]
144
+ (staged.write_bits if spec.kind == "bit" else staged.write_words)(name, head, values)
145
+ self._cells = staged._cells