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/__init__.py +48 -0
- yrcom/hses_client.py +1659 -0
- yrcom/hses_errors.py +190 -0
- yrcom/hses_file.py +234 -0
- yrcom/hses_sim.py +173 -0
- yrcom/robot_connection.py +434 -0
- yrcom/yaskawa_client.py +615 -0
- yrcom-0.2.0.dist-info/METADATA +112 -0
- yrcom-0.2.0.dist-info/RECORD +13 -0
- yrcom-0.2.0.dist-info/WHEEL +5 -0
- yrcom-0.2.0.dist-info/entry_points.txt +2 -0
- yrcom-0.2.0.dist-info/licenses/LICENSE +21 -0
- yrcom-0.2.0.dist-info/top_level.txt +1 -0
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()
|