yrcom 0.2.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.
yrcom/hses_errors.py ADDED
@@ -0,0 +1,190 @@
1
+ """
2
+ HSES response-code plaintext lookup (HW1483358 Section 3.4).
3
+
4
+ The YRC1000 answers every request with a one-byte *status* code and, when the
5
+ status is non-zero, an *added-status* word carrying the real reason. Both are
6
+ otherwise opaque hex numbers; this module maps them to the descriptions from
7
+ the official manual so HsesError messages read like
8
+ ``... status=0x1F (Vendor-specific error) added=0x2080 (Incorrect mode)``.
9
+
10
+ Descriptions are kept in English to match the codebase standard and the
11
+ Yaskawa manual terminology.
12
+ """
13
+
14
+ # 4.1 Status-Code (3.4.1)
15
+ STATUS_CODES: dict[int, str] = {
16
+ 0x00: "Success",
17
+ # 0x01 is not in the 3.4.1 table but is what a live YRC1000 (YAS5.40) returns
18
+ # for almost every rejected/unavailable request — with added-status 0x0000
19
+ # instead of the documented specific codes. Observed for: undefined position
20
+ # variable (P/BP/EX not created), empty file-list filter, missing file,
21
+ # write_file/delete_file in PLAY mode, and move commands in the wrong state
22
+ # (TEACH / servo-off / robot+station together — no 0xB008). Treat as a generic
23
+ # "request not executable in the current state / resource does not exist".
24
+ 0x01: "Request not executable in current state / data not present",
25
+ 0x08: "Requested command is undefined",
26
+ 0x09: "Invalid element number detected",
27
+ 0x1F: "Vendor-specific error (see added status)",
28
+ 0x28: "Instance of the requested data does not exist in the command",
29
+ }
30
+
31
+ # 4.2 Added-Status-Code (3.4.2) — WORD values, hex.
32
+ ADDED_STATUS_CODES: dict[int, str] = {
33
+ 0x1010: "Command error",
34
+ 0x1011: "Error in number of command operands",
35
+ 0x1012: "Command operand value out of range",
36
+ 0x1013: "Command operand length error",
37
+ 0x1020: "Disk full",
38
+ 0x2010: "Manipulator is moving",
39
+ 0x2020: "Hold by programming pendant",
40
+ 0x2030: "Hold by playback panel",
41
+ 0x2040: "External hold",
42
+ 0x2050: "Command hold",
43
+ 0x2060: "Error/alarm active",
44
+ 0x2070: "Servo OFF",
45
+ 0x2080: "Incorrect mode",
46
+ 0x2090: "File is used by another function",
47
+ 0x2100: "Command remote not set",
48
+ 0x2110: "This data cannot be accessed",
49
+ 0x2120: "This data cannot be loaded",
50
+ 0x2130: "Editing",
51
+ 0x2150: "Coordinate conversion in progress",
52
+ 0x3010: "Turn on the servo",
53
+ 0x3040: "Perform home positioning",
54
+ 0x3050: "Confirm positions",
55
+ 0x3070: "Current value not created",
56
+ 0x3220: "Panel lock; mode/cycle prohibit signal ON",
57
+ 0x3230: "Panel lock; start prohibit signal ON",
58
+ 0x3350: "User coordinate not taught",
59
+ 0x3360: "User coordinate destroyed",
60
+ 0x3370: "Incorrect control group",
61
+ 0x3380: "Incorrect base axis data",
62
+ 0x3390: "Relative job conversion prohibited (CVTRJ)",
63
+ 0x3400: "Master job call prohibited (parameter)",
64
+ 0x3410: "Master job call prohibited (lamp ON during operation)",
65
+ 0x3420: "Master job call prohibited (teach lock)",
66
+ 0x3430: "Robot calibration data undefined",
67
+ 0x3450: "Servo cannot be turned on",
68
+ 0x3460: "Coordinate system cannot be set",
69
+ 0x4010: "Insufficient memory (job registration memory)",
70
+ 0x4012: "Insufficient memory (position data memory)",
71
+ 0x4020: "Job editing prohibited",
72
+ 0x4030: "Job with same name exists",
73
+ 0x4040: "No specified job",
74
+ 0x4060: "Set an execution job",
75
+ 0x4120: "Position data destroyed",
76
+ 0x4130: "Position data does not exist",
77
+ 0x4140: "Incorrect position variable type",
78
+ 0x4150: "END instruction for a non-master job",
79
+ 0x4170: "Instruction data destroyed",
80
+ 0x4190: "Invalid character in job name",
81
+ 0x4200: "Invalid character in label name",
82
+ 0x4230: "Invalid instruction in this system",
83
+ 0x4420: "No step in the job to convert",
84
+ 0x4430: "Already converted",
85
+ 0x4480: "Teach the user coordinate",
86
+ 0x4490: "Relative job / independent control function not allowed",
87
+ 0x5110: "Syntax error (instruction syntax)",
88
+ 0x5120: "Position data error",
89
+ 0x5130: "No NOP or END",
90
+ 0x5170: "Format error",
91
+ 0x5180: "Incorrect data count",
92
+ 0x5200: "Data range exceeded",
93
+ 0x5310: "Syntax error (other than instruction)",
94
+ 0x5340: "Error in pseudo-instruction specification",
95
+ 0x5370: "Error in condition-file record",
96
+ 0x5390: "Error in JOB record",
97
+ 0x5430: "System data not identical",
98
+ 0x5480: "Incorrect welding-function type",
99
+ 0x6010: "Robot/station in operation",
100
+ 0x6020: "Insufficient memory in the specified device",
101
+ 0x6030: "Cannot access the specified device",
102
+ 0x6040: "Unexpected auto-backup request",
103
+ 0x6050: "CMOS size exceeds RAM area",
104
+ 0x6060: "No memory allocation at power-on",
105
+ 0x6070: "Backup-file info access error",
106
+ 0x6080: "Backup-file sorting failed (remove)",
107
+ 0x6090: "Backup-file sorting failed (rename)",
108
+ 0x6100: "Drive name exceeds limit",
109
+ 0x6110: "Incorrect device",
110
+ 0x6120: "System error",
111
+ 0x6130: "Auto-backup not available",
112
+ 0x6140: "Cannot back up during auto-backup",
113
+ 0xA000: "Undefined command",
114
+ 0xA001: "Instance error",
115
+ 0xA002: "Attribute error",
116
+ 0xA100: "Response data-part size error (hardware limit)",
117
+ 0xA101: "Response data-part size error (software limit)",
118
+ 0xB001: "Undefined position variable",
119
+ 0xB002: "Data use prohibited",
120
+ 0xB003: "Requested data size incorrect",
121
+ 0xB004: "Out of data range",
122
+ 0xB005: "Data undefined",
123
+ 0xB006: "Specified application not registered",
124
+ 0xB007: "Specified type not registered",
125
+ 0xB008: "Control-group setting error",
126
+ 0xB009: "Speed setting error",
127
+ 0xB00A: "Operation speed not set",
128
+ 0xB00B: "Operation coordinate-system setting error",
129
+ 0xB00C: "Type setting error",
130
+ 0xB00D: "Tool-No setting error",
131
+ 0xB00E: "User-No setting error",
132
+ 0xC001: "System error (data-area setting)",
133
+ 0xC002: "System error (over response data area)",
134
+ 0xC003: "System error (data-element size mismatch)",
135
+ 0xC800: "System error (customize API)",
136
+ 0xCFFF: "Other error",
137
+ 0xD8FA: "Transmission exclusive error (busy or semaphore)",
138
+ 0xD8F1: "Processing another command (busy)",
139
+ 0xE24F: "Incorrect parameter setting for system backup",
140
+ 0xE250: "System-backup file creation error (check remote mode)",
141
+ 0xE289: "System error",
142
+ 0xE28A: "System error",
143
+ 0xE28B: "Communication disconnected due to receive timeout",
144
+ 0xE28C: "Target file cannot be overwritten",
145
+ 0xE29C: "Requested file does not exist or size is 0",
146
+ 0xE29D: "System error",
147
+ 0xE29E: "System error",
148
+ 0xE29F: "System error",
149
+ 0xE2A0: "Incorrect requested path",
150
+ 0xE2A7: "Relevant file not in the requested file list",
151
+ 0xE2AA: "System error",
152
+ 0xE2AF: "Delete request for a non-deletable file",
153
+ 0xE2B0: "System error",
154
+ 0xE2B1: "Directory cannot be deleted",
155
+ 0xE2B2: "Send/receive request while remote is OFF",
156
+ 0xE2B3: "File not found",
157
+ 0xE2B4: "Requested path too long",
158
+ 0xE444: "Processing another command (busy)",
159
+ 0xE49D: "Format error (data size 0)",
160
+ 0xE49E: "Format error (frame size over)",
161
+ 0xE49F: "Format error (frame size 0)",
162
+ 0xE4A1: "Format error (block-number error)",
163
+ 0xE4A2: "Format error (ACK error)",
164
+ 0xE4A3: "Format error (processing-category error)",
165
+ 0xE4A4: "Format error (access-level error)",
166
+ 0xE4A5: "Format error (header-size error)",
167
+ 0xE4A6: "Format error (identifier error)",
168
+ 0xE4A7: "Format error (command and frame size differ)",
169
+ 0xE4A8: "System error",
170
+ 0xE4A9: "System error",
171
+ 0xFFF0: "System error",
172
+ 0xFFF2: "System error",
173
+ 0xFFF3: "System error",
174
+ 0xFFF4: "System error",
175
+ 0xFFF5: "System error",
176
+ 0xFFF6: "Too many requests, cannot process (busy)",
177
+ 0xFFF7: "System error",
178
+ 0xFFF8: "System error",
179
+ 0xFFFE: "Remote mode detected, communication disconnected",
180
+ }
181
+
182
+
183
+ def status_text(status: int) -> str:
184
+ """Description for a status code, or 'Unknown status' if not listed."""
185
+ return STATUS_CODES.get(status, "Unknown status")
186
+
187
+
188
+ def added_status_text(added: int) -> str:
189
+ """Description for an added-status word, or 'Unknown added status'."""
190
+ return ADDED_STATUS_CODES.get(added, "Unknown added status")
yrcom/hses_file.py ADDED
@@ -0,0 +1,234 @@
1
+ """
2
+ Yaskawa YRC1000 — HSES File Control client.
3
+
4
+ A separate transport from the telemetry client (`hses_client.HsesClient`):
5
+ different UDP port (10041), Processing Division 2, and a multi-packet Data/ACK
6
+ handshake instead of a single request/response. The sub-header carries a fixed
7
+ Command/Instance/Attribute of 0 — the operation is chosen by the Service byte
8
+ alone (0x09 delete, 0x15 load, 0x16 save, 0x32 list). See manual HW1483358
9
+ (File Control Function).
10
+
11
+ File transfers are chunked into <=479-byte data parts. Each data packet's Block
12
+ No. increments; the final packet is flagged with bit 0x8000_0000. The receiving
13
+ side acknowledges every data packet with an empty ACK (echoing the block number)
14
+ before the next one is sent.
15
+
16
+ Command Remote (RS 005 = 1) is required for write/delete/transfer access.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import struct
22
+ from typing import Optional
23
+
24
+ from .hses_client import _MAGIC, _HDR_SIZE, HsesError, _HsesSocket
25
+
26
+ # Diagnostic trace of the file-control layer (Roadmap I convention); the library
27
+ # only emits, the app configures handlers. Requests/ACKs → DEBUG, file operations
28
+ # → INFO, robot errors / malformed replies → ERROR.
29
+ log = logging.getLogger(__name__)
30
+
31
+ HSES_FILE_PORT = 10041
32
+ _PROC_FILE = 2 # processing division: file control
33
+
34
+ # Service bytes (the sub-header Command/Instance/Attribute are all 0)
35
+ _SVC_DELETE = 0x09 # File Deleting (no transfer)
36
+ _SVC_LOAD = 0x15 # File Loading PC -> YRC (write a file)
37
+ _SVC_SAVE = 0x16 # File Saving YRC -> PC (read a file)
38
+ _SVC_LIST = 0x32 # File List YRC -> PC
39
+
40
+ _ACK_REQUEST = 0x00 # header offset 10: this packet is a request
41
+ _ACK_ANSWER = 0x01 # this packet is an answer / acknowledgement
42
+ _LAST_BLOCK = 0x8000_0000 # block-no bit marking the final packet
43
+ _MAX_DATA = 479 # max data-part size per packet
44
+
45
+ _CMOS_PATH = "/SPDRV/CMOSBK.BIN" # batch CMOS backup (Device = RAMDISK, ~10 min)
46
+
47
+
48
+ class HsesFileClient(_HsesSocket):
49
+ """
50
+ UDP client for the Yaskawa YRC1000 HSES File Control port (10041).
51
+
52
+ Usage:
53
+ with HsesFileClient("192.0.2.10") as f:
54
+ for name in f.list_files("*.JBI"):
55
+ print(name)
56
+ data = f.read_file("TESTJOB.JBI")
57
+ """
58
+
59
+ def __init__(self, host: str, port: int = HSES_FILE_PORT, timeout: float = 5.0,
60
+ bind_host: Optional[str] = None) -> None:
61
+ super().__init__(host, port, timeout, bind_host)
62
+
63
+ # ── packet building / parsing ─────────────────────────────────────────────
64
+
65
+ @staticmethod
66
+ def _build(service: int, req_id: int, block_no: int, ack: int,
67
+ payload: bytes = b"") -> bytes:
68
+ """Build a 32-byte File Control header (Command/Instance/Attribute = 0)."""
69
+ hdr = bytearray(_HDR_SIZE)
70
+ hdr[0:4] = _MAGIC
71
+ struct.pack_into("<H", hdr, 4, _HDR_SIZE)
72
+ struct.pack_into("<H", hdr, 6, len(payload))
73
+ hdr[8] = 0x03 # Reserve1 FIXED
74
+ hdr[9] = _PROC_FILE
75
+ hdr[10] = ack
76
+ hdr[11] = req_id
77
+ struct.pack_into("<I", hdr, 12, block_no & 0xFFFF_FFFF)
78
+ hdr[16:24] = b"99999999" # Reserve2 FIXED
79
+ struct.pack_into("<H", hdr, 24, 0) # Command No. = 0
80
+ struct.pack_into("<H", hdr, 26, 0) # Instance = 0
81
+ hdr[28] = 0 # Attribute = 0
82
+ hdr[29] = service
83
+ struct.pack_into("<H", hdr, 30, 0) # Padding
84
+ return bytes(hdr) + payload
85
+
86
+ def _recv(self, req_service: int) -> tuple:
87
+ """
88
+ Receive one packet and parse it. Returns (block_no, data, added).
89
+ Raises HsesError on a non-zero status, IOError on a malformed reply.
90
+ The answer's status/added fields sit at the same offsets as the robot
91
+ control response (status raw[26], added-size raw[28], added raw[30]).
92
+ """
93
+ raw = self._sock.recv(4096)
94
+ if len(raw) < _HDR_SIZE:
95
+ log.error("malformed file reply svc=0x%02X: too short (%d B)",
96
+ req_service, len(raw))
97
+ raise IOError(f"Response too short: {len(raw)} B")
98
+ if raw[0:4] != _MAGIC:
99
+ log.error("malformed file reply svc=0x%02X: bad magic %r",
100
+ req_service, raw[0:4])
101
+ raise IOError(f"Bad magic: {raw[0:4]!r}")
102
+
103
+ dat_size = struct.unpack_from("<H", raw, 6)[0]
104
+ block_no = struct.unpack_from("<I", raw, 12)[0]
105
+ resp_status = raw[26]
106
+ added_size = raw[28]
107
+ added = struct.unpack_from("<H", raw, 30)[0] if added_size >= 1 else 0
108
+ data = raw[_HDR_SIZE: _HDR_SIZE + dat_size]
109
+
110
+ if resp_status != 0x00:
111
+ log.error("file reply svc=0x%02X status=0x%02X added=0x%04X",
112
+ req_service, resp_status, added)
113
+ raise HsesError(_PROC_FILE, resp_status, added)
114
+
115
+ return block_no, data, added
116
+
117
+ # ── receive transfer (File List 0x32, File Saving 0x16) ────────────────────
118
+
119
+ def _read_transfer(self, service: int, request_payload: bytes) -> bytes:
120
+ """
121
+ Run a YRC → PC transfer: send the request, then receive data packets,
122
+ acknowledging each, until the packet flagged with _LAST_BLOCK arrives.
123
+ Returns the concatenated data parts.
124
+
125
+ A status-0 reply carrying an added status (e.g. 0xE2A7 file not found,
126
+ 0xE29C size 0) is treated as an empty result, not an error.
127
+ """
128
+ if self._sock is None:
129
+ raise RuntimeError("Call open() first")
130
+ rid = self._bump_req_id()
131
+ log.debug("file tx svc=0x%02X id=%d payload=%dB",
132
+ service, rid, len(request_payload))
133
+ self._sock.sendto(self._build(service, rid, 0, _ACK_REQUEST, request_payload),
134
+ (self._host, self._port))
135
+
136
+ chunks: list[bytes] = []
137
+ while True:
138
+ block_no, data, added = self._recv(service)
139
+ if added and not data:
140
+ log.warning("file svc=0x%02X id=%d empty (added=0x%04X)",
141
+ service, rid, added)
142
+ return b""
143
+ chunks.append(data)
144
+ # Acknowledge this packet (empty data part, echo the block number).
145
+ self._sock.sendto(self._build(service, rid, block_no, _ACK_ANSWER),
146
+ (self._host, self._port))
147
+ if block_no & _LAST_BLOCK:
148
+ break
149
+ out = b"".join(chunks)
150
+ log.debug("file rx svc=0x%02X id=%d total=%dB", service, rid, len(out))
151
+ return out
152
+
153
+ # ── send transfer (File Loading 0x15) ──────────────────────────────────────
154
+
155
+ def _write_transfer(self, service: int, request_payload: bytes,
156
+ content: bytes) -> None:
157
+ """
158
+ Run a PC → YRC transfer: send the request, wait for the robot's ACK, then
159
+ send `content` as _MAX_DATA-sized data packets, waiting for an ACK after
160
+ each. The final packet is flagged with _LAST_BLOCK.
161
+ """
162
+ if self._sock is None:
163
+ raise RuntimeError("Call open() first")
164
+ rid = self._bump_req_id()
165
+ log.debug("file tx svc=0x%02X id=%d payload=%dB content=%dB",
166
+ service, rid, len(request_payload), len(content))
167
+ self._sock.sendto(self._build(service, rid, 0, _ACK_REQUEST, request_payload),
168
+ (self._host, self._port))
169
+ self._recv(service) # robot acknowledges the request (raises on error)
170
+
171
+ parts = [content[i:i + _MAX_DATA] for i in range(0, len(content), _MAX_DATA)] or [b""]
172
+ for i, part in enumerate(parts):
173
+ last = i == len(parts) - 1
174
+ block_no = (i + 1) | (_LAST_BLOCK if last else 0)
175
+ self._sock.sendto(self._build(service, rid, block_no, _ACK_ANSWER, part),
176
+ (self._host, self._port))
177
+ self._recv(service) # robot acknowledges each data packet
178
+ log.debug("file tx svc=0x%02X id=%d done (%d packets)", service, rid, len(parts))
179
+
180
+ # ── public operations ──────────────────────────────────────────────────────
181
+
182
+ def list_files(self, pattern: str = "*.JBI") -> "list[str]":
183
+ """
184
+ File List (service 0x32). `pattern` filters by extension — e.g. *.JBI,
185
+ *.DAT, *.CND, *.PRM, *.SYS, *.LST; an empty/`*.*` filter lists jobs.
186
+ Returns the file names as a list (blank lines dropped).
187
+ """
188
+ log.info("file: list %r", pattern)
189
+ raw = self._read_transfer(_SVC_LIST, pattern.encode("ascii", errors="replace"))
190
+ text = raw.decode("latin-1", errors="replace")
191
+ return [line.strip() for line in text.split("\r\n") if line.strip()]
192
+
193
+ def read_file(self, name: str) -> bytes:
194
+ """
195
+ File Saving (service 0x16) — read a file from the controller (YRC → PC).
196
+ `name` is the file name as shown on the pendant (e.g. "TESTJOB.JBI").
197
+ Returns the raw file bytes (ASCII content with <CR><LF> line breaks).
198
+ """
199
+ log.info("file: read %r", name)
200
+ return self._read_transfer(_SVC_SAVE, name.encode("ascii", errors="replace"))
201
+
202
+ def write_file(self, name: str, content: bytes) -> None:
203
+ """
204
+ File Loading (service 0x15) — send a file to the controller (PC → YRC).
205
+ `name` is the target file name; `content` the raw file bytes (ASCII,
206
+ <CR><LF> line breaks). Requires Command Remote (RS 005 = 1).
207
+ """
208
+ log.info("file: write %r (%d B)", name, len(content))
209
+ self._write_transfer(_SVC_LOAD, name.encode("ascii", errors="replace"), content)
210
+
211
+ def delete_file(self, name: str) -> None:
212
+ """
213
+ File Deleting (service 0x09). Deletes `name` on the controller — a single
214
+ request/response, no transfer. Requires Command Remote (RS 005 = 1).
215
+ """
216
+ if self._sock is None:
217
+ raise RuntimeError("Call open() first")
218
+ log.info("file: delete %r", name)
219
+ rid = self._bump_req_id()
220
+ self._sock.sendto(
221
+ self._build(_SVC_DELETE, rid, 0, _ACK_REQUEST,
222
+ name.encode("ascii", errors="replace")),
223
+ (self._host, self._port),
224
+ )
225
+ self._recv(_SVC_DELETE) # raises HsesError on a non-zero status
226
+
227
+ def backup_cmos(self) -> bytes:
228
+ """
229
+ Batch CMOS backup — read the complete CMOSBK.BIN via File Saving (0x16).
230
+ The controller's Device must be RAMDISK and the transfer takes ~10 min,
231
+ so use a generous timeout. Returns the raw backup bytes.
232
+ """
233
+ log.info("file: CMOS batch backup %s", _CMOS_PATH)
234
+ return self._read_transfer(_SVC_SAVE, _CMOS_PATH.encode("ascii"))
yrcom/hses_sim.py ADDED
@@ -0,0 +1,173 @@
1
+ """
2
+ HSES simulator — replays a recorded robot session (tx/rx wire pairs) as a
3
+ UDP server so the **real** HsesClient can work against it offline.
4
+
5
+ The server indexes the recorded request→response byte pairs. It ignores the
6
+ request ID (header byte 11), which increments per call, and patches it back
7
+ into the response — so the response matches any request regardless of the
8
+ order in which the client asks. The exact wire bytes of the real controller
9
+ are sent back, so the real parser in HsesClient runs unchanged.
10
+
11
+ Usage:
12
+
13
+ # as a context manager (thread, ephemeral or fixed port)
14
+ from yrcom import HsesReplayServer, HsesClient
15
+ with HsesReplayServer.from_capture("captures/latest.json") as srv:
16
+ c = HsesClient("127.0.0.1", srv.port, 2.0); c.open()
17
+ print(c.read_status()) # real recorded response, offline
18
+
19
+ # or standalone on the default port 10040:
20
+ # python -m yrcom.hses_sim captures/latest.json --port 10040
21
+
22
+ Limits: this is a replay, not a state machine. A request not present in the
23
+ capture is answered with HSES status 0x01 ("not executable") — the same reply
24
+ the real controller gives for unknown/invalid reads. Writes change nothing; a
25
+ subsequent read still returns the recorded value.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import socket
31
+ import struct
32
+ import threading
33
+ from pathlib import Path
34
+ from typing import Optional
35
+
36
+ _HDR_SIZE = 0x0020
37
+ _REQ_ID_OFF = 11 # header byte that increments per request
38
+ _MAGIC = b"YERC"
39
+
40
+
41
+ def _req_key(pkt: bytes) -> bytes:
42
+ """Canonical key of a request: request ID masked out."""
43
+ b = bytearray(pkt)
44
+ if len(b) > _REQ_ID_OFF:
45
+ b[_REQ_ID_OFF] = 0
46
+ return bytes(b)
47
+
48
+
49
+ def _error_response(req: bytes, status: int = 0x01) -> bytes:
50
+ """Build a valid HSES error response (status != 0) to a request.
51
+
52
+ Mirrors the header, sets the ACK flag, status and empty data length —
53
+ exactly the shape the real controller sends for a non-executable read.
54
+ """
55
+ resp = bytearray(_HDR_SIZE)
56
+ if len(req) >= _HDR_SIZE:
57
+ resp[:] = req[:_HDR_SIZE]
58
+ else:
59
+ resp[0:4] = _MAGIC
60
+ struct.pack_into("<H", resp, 4, _HDR_SIZE)
61
+ struct.pack_into("<H", resp, 6, 0) # data size = 0
62
+ resp[10] = 0x01 # ACK = answer
63
+ resp[26] = status # status byte
64
+ resp[27] = 0
65
+ resp[28] = 0 # added-status size
66
+ struct.pack_into("<H", resp, 30, 0)
67
+ return bytes(resp)
68
+
69
+
70
+ class HsesReplayServer:
71
+ """UDP server that replays recorded HSES responses."""
72
+
73
+ def __init__(self, table: dict[bytes, bytes], host: str = "127.0.0.1",
74
+ port: int = 0) -> None:
75
+ self._table = table
76
+ self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
77
+ self._sock.bind((host, port))
78
+ self.host, self.port = self._sock.getsockname()
79
+ self._thread: Optional[threading.Thread] = None
80
+ self._running = False
81
+ self.hits = 0
82
+ self.misses = 0
83
+
84
+ # ── construction from a capture file ──────────────────────────────────────
85
+ @classmethod
86
+ def from_capture(cls, path: str | Path, host: str = "127.0.0.1",
87
+ port: int = 0) -> "HsesReplayServer":
88
+ data = json.loads(Path(path).read_text())
89
+ table: dict[bytes, bytes] = {}
90
+ hses = data.get("hses", {})
91
+ for op in hses.get("ops", []):
92
+ for ex in op.get("wire", []):
93
+ tx, rx = ex.get("tx"), ex.get("rx")
94
+ if not tx or not rx:
95
+ continue
96
+ table[_req_key(bytes.fromhex(tx))] = bytes.fromhex(rx)
97
+ if not table:
98
+ raise ValueError(f"no HSES wire data in {path}")
99
+ return cls(table, host, port)
100
+
101
+ # ── server loop ───────────────────────────────────────────────────────────
102
+ def _serve(self) -> None:
103
+ self._sock.settimeout(0.25)
104
+ while self._running:
105
+ try:
106
+ req, addr = self._sock.recvfrom(4096)
107
+ except socket.timeout:
108
+ continue
109
+ except OSError:
110
+ break
111
+ resp = self._table.get(_req_key(req))
112
+ if resp is None:
113
+ self.misses += 1
114
+ out = _error_response(req)
115
+ else:
116
+ self.hits += 1
117
+ out = bytearray(resp)
118
+ # patch the caller's request ID into the response
119
+ if len(req) > _REQ_ID_OFF and len(out) > _REQ_ID_OFF:
120
+ out[_REQ_ID_OFF] = req[_REQ_ID_OFF]
121
+ out = bytes(out)
122
+ try:
123
+ self._sock.sendto(out, addr)
124
+ except OSError:
125
+ break
126
+
127
+ def start(self) -> "HsesReplayServer":
128
+ if self._running:
129
+ return self
130
+ self._running = True
131
+ self._thread = threading.Thread(target=self._serve, daemon=True)
132
+ self._thread.start()
133
+ return self
134
+
135
+ def stop(self) -> None:
136
+ self._running = False
137
+ if self._thread:
138
+ self._thread.join(timeout=1.0)
139
+ try:
140
+ self._sock.close()
141
+ except OSError:
142
+ pass
143
+
144
+ def __enter__(self) -> "HsesReplayServer":
145
+ return self.start()
146
+
147
+ def __exit__(self, *_exc) -> None:
148
+ self.stop()
149
+
150
+
151
+ def _main() -> None:
152
+ import argparse
153
+
154
+ ap = argparse.ArgumentParser(description="HSES replay server from a capture file")
155
+ ap.add_argument("capture", nargs="?", default="captures/latest.json")
156
+ ap.add_argument("--host", default="127.0.0.1")
157
+ ap.add_argument("--port", type=int, default=10040)
158
+ args = ap.parse_args()
159
+
160
+ srv = HsesReplayServer.from_capture(args.capture, args.host, args.port).start()
161
+ print(f"HSES simulator listening on {srv.host}:{srv.port} "
162
+ f"({len(srv._table)} responses from {args.capture})")
163
+ print("Stop with Ctrl-C")
164
+ try:
165
+ while True:
166
+ srv._thread.join(1.0)
167
+ except KeyboardInterrupt:
168
+ print(f"\nhits={srv.hits} misses={srv.misses}")
169
+ srv.stop()
170
+
171
+
172
+ if __name__ == "__main__":
173
+ _main()