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_client.py ADDED
@@ -0,0 +1,1659 @@
1
+ """
2
+ Yaskawa YRC1000 — High-Speed Ethernet Server (HSES) client.
3
+ UDP port 10040, YERC binary protocol. No external dependencies.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import socket
9
+ import struct
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from typing import Optional, Self
13
+
14
+ from .hses_errors import status_text, added_status_text
15
+
16
+ # Diagnostic log of the HSES layer (requests/responses, control commands, errors).
17
+ # This is a *software* trace for field debugging — not the telemetry CSV. The
18
+ # library only emits records; the application decides handlers/levels. Requests
19
+ # and responses go to DEBUG, submitted control commands to INFO, and any robot
20
+ # error / malformed reply to ERROR.
21
+ log = logging.getLogger(__name__)
22
+
23
+ HSES_PORT = 10040
24
+ _MAGIC = b"YERC"
25
+ _HDR_SIZE = 0x0020 # 32 bytes, always
26
+ _PROC_ROBOT = 1 # processing division: robot control
27
+
28
+ # Command numbers per HW1483358 Section 3.3.3.x
29
+ _CMD_ALARM = 0x70 # Alarm Data Reading
30
+ _CMD_ALARM_HIST = 0x71 # Alarm History Reading
31
+ _CMD_STATUS = 0x72 # Status Information Reading
32
+ _CMD_JOB = 0x73 # Executing Job Information Reading
33
+ _CMD_AXIS_CFG = 0x74 # Axis Configuration Information Reading
34
+ _CMD_POSITION = 0x75 # Robot Position Data Reading
35
+ _CMD_POSERR = 0x76 # Position Error (following error) Reading
36
+ _CMD_TORQUE = 0x77 # Torque Data Reading
37
+ _CMD_IO = 0x78 # I/O Data Reading
38
+ _CMD_REGISTER = 0x79 # Register Data Reading / Writing
39
+ _CMD_B_VAR = 0x7A # Byte Variable (B) Reading
40
+ _CMD_I_VAR = 0x7B # Integer Variable (I) Reading
41
+ _CMD_D_VAR = 0x7C # Double Variable (D) Reading / Writing
42
+ _CMD_R_VAR = 0x7D # Real Variable (R) Reading / Writing
43
+ _CMD_S_VAR = 0x8C # 32-Byte Character Variable (S) Reading / Writing (DX200/YRC1000)
44
+ _CMD_S16_VAR = 0x7E # 16-Byte Character Variable (S) Reading / Writing (DX100)
45
+ _CMD_P_VAR = 0x7F # Robot Position Variable (P) Reading / Writing
46
+ _CMD_BP_VAR = 0x80 # Base Position Variable (BP) Reading / Writing
47
+ _CMD_EX_VAR = 0x81 # Station/External Axis Variable (EX) Reading / Writing
48
+ _CMD_RESET = 0x82 # Alarm Reset / Error Cancel
49
+ _CMD_HOLD_SERVO = 0x83 # HOLD / Servo ON-OFF / HLOCK
50
+ _CMD_CYCLE = 0x84 # Step / Cycle / Auto switching
51
+ _CMD_MSG = 0x85 # Character string display to pendant
52
+ _CMD_JOB_START = 0x86 # Start-up (Job START)
53
+ _CMD_JOB_SELECT = 0x87 # Job select
54
+ _CMD_MGMT_TIME = 0x88 # Management Time Acquiring
55
+ _CMD_SYS_INFO = 0x89 # System Information Acquiring
56
+ _CMD_IO_PLURAL = 0x300 # Plural I/O Data Reading / Writing
57
+ _CMD_REG_PLURAL = 0x301 # Plural Register Reading / Writing
58
+ _CMD_B_PLURAL = 0x302 # Plural Byte (B) Reading / Writing
59
+ _CMD_I_PLURAL = 0x303 # Plural Integer (I) Reading / Writing
60
+ _CMD_D_PLURAL = 0x304 # Plural Double (D) Reading / Writing
61
+ _CMD_R_PLURAL = 0x305 # Plural Real (R) Reading / Writing
62
+ _CMD_P_PLURAL = 0x307 # Plural Robot Position (P) Reading / Writing
63
+ _CMD_BP_PLURAL = 0x308 # Plural Base Position (BP) Reading / Writing
64
+ _CMD_EX_PLURAL = 0x309 # Plural Station/External Axis (EX) Reading / Writing
65
+ _CMD_S_PLURAL = 0x30C # Plural 32-Byte Character (S) Reading / Writing (DX200/YRC1000)
66
+ _CMD_S16_PLURAL = 0x306 # Plural 16-Byte Character (S) Reading / Writing (DX100)
67
+ _CMD_ALARM_SUB = 0x30A # Alarm Data Reading (Sub-Code strings)
68
+ _CMD_ALARM_HIST_SUB = 0x30B # Alarm History Reading (Sub-Code strings)
69
+ _CMD_ENC_TEMP = 0x0411 # Encoder Temperature Reading (SW YAS4.81+)
70
+ _CMD_REGEN_PWR = 0x0412 # Regenerative Power Reading (SW YAS4.90+)
71
+ _CMD_CONV_TEMP = 0x0413 # Converter Temperature Reading (SW YAS5.20+)
72
+
73
+ # Controller generations. They share almost every data layout; the differences are
74
+ # the S-variable command/width (DX100 = 16-byte 0x7E/0x306, newer = 32-byte
75
+ # 0x8C/0x30C), the meaning of status 0x72 Data1 bit4, and the YRC1000-only
76
+ # temperature commands (0x0411-0x0413). See manual HW1483358.
77
+ MODEL_DX100 = "dx100"
78
+ MODEL_DX200 = "dx200"
79
+ MODEL_YRC1000 = "yrc1000"
80
+ _VALID_MODELS = (MODEL_DX100, MODEL_DX200, MODEL_YRC1000)
81
+
82
+ _STATUS_CMD_UNDEFINED = 0x08 # "Requested command is undefined" (§4.1)
83
+ _CMD_MOVE_CART = 0x8A # Move Instruction (Cartesian)
84
+ _CMD_MOVE_PULSE = 0x8B # Move Instruction (Pulse)
85
+
86
+ # Service codes
87
+ _SVC_GET_ALL = 0x01
88
+ _SVC_SET_ALL = 0x02
89
+ _SVC_GET_SINGLE = 0x0E
90
+ _SVC_SET_SINGLE = 0x10
91
+ _SVC_READ_ALL = 0x33 # plural read (cmd 0x30x)
92
+ _SVC_WRITE_ALL = 0x34 # plural write (cmd 0x30x)
93
+
94
+ # Instance selectors for HOLD / Servo command (0x83)
95
+ _HS_HOLD = 1
96
+ _HS_SERVO = 2
97
+ _HS_HLOCK = 3
98
+
99
+ # ON/OFF data values shared by control commands
100
+ _ON = 1
101
+ _OFF = 2
102
+
103
+ # Interpolation modes = instance selector for the move commands (0x8A/0x8B)
104
+ _MOVE_LINK_ABS = 1 # joint interpolation to an absolute pose
105
+ _MOVE_STRAIGHT_ABS = 2 # linear interpolation to an absolute pose
106
+ _MOVE_STRAIGHT_INC = 3 # linear interpolation by an increment (0x8A only)
107
+
108
+ # Speed classification for the move commands (value 2 = VR 0.1 °/s is passed as a
109
+ # raw int on the speed_class field; see CartesianMove/PulseMove)
110
+ _SPEED_PERCENT = 0 # 0.01 % of max joint speed (link moves)
111
+ _SPEED_V = 1 # 0.1 mm/s (straight moves)
112
+
113
+
114
+ @dataclass
115
+ class _RawResp:
116
+ cmd: int
117
+ status: int # 0x00 = normal
118
+ added: int # added-status word (0 if none)
119
+ data: bytes # raw payload
120
+
121
+
122
+ @dataclass
123
+ class PositionVar:
124
+ """
125
+ Robot position variable P[number] (cmd 0x7F) — a 52-byte record of 13 int32.
126
+
127
+ `axes` holds the eight raw protocol coordinate values (unused axes are 0).
128
+ For a cartesian data_type (16 Base / 17 Robot / 18 Tool / 19 User) the first
129
+ six are X/Y/Z in µm and Rx/Ry/Rz in 0.0001°; for pulse (data_type 0) each is
130
+ an encoder pulse count. Values are kept raw so a read → write round-trip is
131
+ lossless.
132
+ """
133
+ data_type: int # 0=Pulse, 16=Base, 17=Robot, 18=Tool, 19=User
134
+ figure: int = 0 # configuration bits (arm flip/no-flip etc.)
135
+ tool_no: int = 0
136
+ user_coord_no: int = 0
137
+ extended_figure: int = 0
138
+ axes: list = field(default_factory=lambda: [0] * 8)
139
+
140
+ @classmethod
141
+ def from_bytes(cls, data: bytes) -> "PositionVar":
142
+ """Decode a 52-byte P record; short data is zero-padded to 13 int32."""
143
+ n = len(data) // 4
144
+ vals = list(struct.unpack_from(f"<{n}i", data)) if n else []
145
+ vals += [0] * (13 - len(vals))
146
+ return cls(
147
+ data_type=vals[0], figure=vals[1], tool_no=vals[2],
148
+ user_coord_no=vals[3], extended_figure=vals[4], axes=vals[5:13],
149
+ )
150
+
151
+ def to_bytes(self) -> bytes:
152
+ """Encode to the 52-byte request payload (axes padded/validated to 8)."""
153
+ axes = list(self.axes)
154
+ if len(axes) > 8:
155
+ raise ValueError("axes must have at most 8 elements")
156
+ axes += [0] * (8 - len(axes))
157
+ return struct.pack(
158
+ "<13i", self.data_type, self.figure, self.tool_no,
159
+ self.user_coord_no, self.extended_figure, *axes,
160
+ )
161
+
162
+
163
+ @dataclass
164
+ class AxisPositionVar:
165
+ """
166
+ Base position (BP, cmd 0x80) or station/external axis (EX, cmd 0x81)
167
+ variable — a 36-byte record of 9 int32: a data type plus eight axis values.
168
+
169
+ For BP with a cartesian data_type (16) the first three axes are X/Y/Z in µm
170
+ (base/positioner axes carry no orientation); data_type 0 (pulse) and every EX
171
+ record carry raw encoder pulse counts. Values are kept raw so a read → write
172
+ round-trip is lossless.
173
+ """
174
+ data_type: int # BP: 0=Pulse, 16=Base; EX: 0=Pulse only
175
+ axes: list = field(default_factory=lambda: [0] * 8)
176
+
177
+ @classmethod
178
+ def from_bytes(cls, data: bytes) -> "AxisPositionVar":
179
+ """Decode a 36-byte BP/EX record; short data is zero-padded to 9 int32."""
180
+ n = len(data) // 4
181
+ vals = list(struct.unpack_from(f"<{n}i", data)) if n else []
182
+ vals += [0] * (9 - len(vals))
183
+ return cls(data_type=vals[0], axes=vals[1:9])
184
+
185
+ def to_bytes(self) -> bytes:
186
+ """Encode to the 36-byte request payload (axes padded/validated to 8)."""
187
+ axes = list(self.axes)
188
+ if len(axes) > 8:
189
+ raise ValueError("axes must have at most 8 elements")
190
+ axes += [0] * (8 - len(axes))
191
+ return struct.pack("<9i", self.data_type, *axes)
192
+
193
+
194
+ def _pad(values, n: int, name: str) -> list:
195
+ """Right-pad a raw-int list with zeros to exactly `n` (over-length is an error)."""
196
+ vals = list(values)
197
+ if len(vals) > n:
198
+ raise ValueError(f"{name} must have at most {n} elements")
199
+ return vals + [0] * (n - len(vals))
200
+
201
+
202
+ @dataclass
203
+ class CartesianMove:
204
+ """
205
+ Request record for a Cartesian move (cmd 0x8A) — 104 bytes = 26 int32.
206
+
207
+ Coordinates are raw protocol units: X/Y/Z in µm, Rx/Ry/Rz in 0.0001°. A move
208
+ targets EITHER the robot group OR a station group, never both (the controller
209
+ rejects robot+station together with added-status 0xB008). Servo ON and Command
210
+ Remote are required — this MOVES the robot.
211
+ """
212
+ x: int = 0
213
+ y: int = 0
214
+ z: int = 0
215
+ rx: int = 0
216
+ ry: int = 0
217
+ rz: int = 0
218
+ speed_class: int = _SPEED_V # 0=% (link) · 1=V mm/s · 2=VR °/s
219
+ speed: int = 0 # link 0.01% · V 0.1 mm/s · VR 0.1°/s
220
+ coord: int = 17 # 16=Base 17=Robot 18=User 19=Tool
221
+ robot_group: int = 1 # 1-8 (0 = none, use station instead)
222
+ station_group: int = 0 # 1-24 (0 = none)
223
+ type: int = 0 # pose configuration bitfield (like 0x75)
224
+ expanded_type: int = 0
225
+ tool_no: int = 0 # 0-63
226
+ user_coord_no: int = 0 # 1-63 (only when coord=18 User)
227
+ base_axes: list = field(default_factory=lambda: [0, 0, 0]) # µm, 3 axes
228
+ station_axes: list = field(default_factory=lambda: [0] * 6) # pulse, 6 axes
229
+
230
+ def to_bytes(self) -> bytes:
231
+ """Encode to the 104-byte request payload in protocol field order."""
232
+ base = _pad(self.base_axes, 3, "base_axes")
233
+ station = _pad(self.station_axes, 6, "station_axes")
234
+ return struct.pack(
235
+ "<26i",
236
+ self.robot_group, self.station_group, self.speed_class, self.speed,
237
+ self.coord, self.x, self.y, self.z, self.rx, self.ry, self.rz,
238
+ 0, 0, # reserve (offsets 44/48)
239
+ self.type, self.expanded_type, self.tool_no, self.user_coord_no,
240
+ *base, *station,
241
+ )
242
+
243
+
244
+ @dataclass
245
+ class PulseMove:
246
+ """
247
+ Request record for a Pulse move (cmd 0x8B) — 88 bytes = 22 int32.
248
+
249
+ `robot_axes` are raw encoder pulse counts (up to 8). Like the Cartesian move
250
+ this targets EITHER the robot OR a station group (robot+station → 0xB008) and
251
+ requires Servo ON + Command Remote. This MOVES the robot.
252
+ """
253
+ robot_axes: list = field(default_factory=lambda: [0] * 8) # pulse, 8 axes
254
+ speed_class: int = _SPEED_PERCENT # 0=% · 1=V · 2=VR
255
+ speed: int = 0
256
+ robot_group: int = 1 # 1-8 (0 = none)
257
+ station_group: int = 0 # 1-24 (0 = none)
258
+ tool_no: int = 0 # 0-63
259
+ base_axes: list = field(default_factory=lambda: [0, 0, 0]) # pulse, 3 axes
260
+ station_axes: list = field(default_factory=lambda: [0] * 6) # pulse, 6 axes
261
+
262
+ def to_bytes(self) -> bytes:
263
+ """Encode to the 88-byte request payload in protocol field order."""
264
+ robot = _pad(self.robot_axes, 8, "robot_axes")
265
+ base = _pad(self.base_axes, 3, "base_axes")
266
+ station = _pad(self.station_axes, 6, "station_axes")
267
+ return struct.pack(
268
+ "<22i",
269
+ self.robot_group, self.station_group, self.speed_class, self.speed,
270
+ *robot, self.tool_no, *base, *station,
271
+ )
272
+
273
+
274
+ class HsesError(Exception):
275
+ """Raised when the robot returns a non-zero status code."""
276
+ def __init__(self, cmd: int, status: int, added: int) -> None:
277
+ msg = (f"HSES error cmd=0x{cmd:04X} "
278
+ f"status=0x{status:02X} ({status_text(status)})")
279
+ if added:
280
+ msg += f" added=0x{added:04X} ({added_status_text(added)})"
281
+ super().__init__(msg)
282
+ self.cmd = cmd
283
+ self.status = status
284
+ self.added = added
285
+
286
+
287
+ class _HsesSocket:
288
+ """Shared UDP-transport lifecycle for the HSES telemetry and file clients:
289
+ lazy socket open (optionally bound to a local adapter), idempotent close,
290
+ context-manager support, and the 1..0xFF request-id rollover."""
291
+
292
+ def __init__(self, host: str, port: int, timeout: float,
293
+ bind_host: Optional[str] = None) -> None:
294
+ self._host = host
295
+ self._port = port
296
+ self._timeout = timeout
297
+ self._bind_host = bind_host # local adapter IP to send from (optional)
298
+ self._sock: Optional[socket.socket] = None
299
+ self._req_id = 1
300
+
301
+ def open(self) -> None:
302
+ if self._sock is not None:
303
+ return
304
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
305
+ if self._bind_host:
306
+ s.bind((self._bind_host, 0)) # pin egress to the chosen adapter
307
+ s.settimeout(self._timeout)
308
+ self._sock = s
309
+
310
+ def close(self) -> None:
311
+ if self._sock is not None:
312
+ try:
313
+ self._sock.close()
314
+ except OSError:
315
+ pass
316
+ self._sock = None
317
+
318
+ def __enter__(self) -> Self:
319
+ self.open()
320
+ return self
321
+
322
+ def __exit__(self, *_: object) -> None:
323
+ self.close()
324
+
325
+ def _bump_req_id(self) -> int:
326
+ rid = self._req_id
327
+ self._req_id = (self._req_id % 0xFF) + 1
328
+ return rid
329
+
330
+
331
+ class HsesClient(_HsesSocket):
332
+ """
333
+ UDP client for the Yaskawa YRC1000 High-Speed Ethernet Server.
334
+
335
+ Usage:
336
+ with HsesClient("192.0.2.10") as c:
337
+ print(c.read_status())
338
+ """
339
+
340
+ def __init__(self, host: str, port: int = HSES_PORT, timeout: float = 2.0,
341
+ bind_host: Optional[str] = None, model: str = MODEL_YRC1000) -> None:
342
+ super().__init__(host, port, timeout, bind_host)
343
+ self._model = model
344
+ self._axis_names: Optional[tuple] = None # real 0x74 names, once loaded
345
+ self._apply_model() # derives S command/width from the model
346
+
347
+ # ── controller model ───────────────────────────────────────────────────────
348
+
349
+ def _apply_model(self) -> None:
350
+ """Derive the model-dependent S-variable command and field width. DX100
351
+ uses the 16-byte S variant (0x7E / 0x306); DX200 and YRC1000 the 32-byte
352
+ one (0x8C / 0x30C)."""
353
+ if self._model not in _VALID_MODELS:
354
+ raise ValueError(f"unknown controller model: {self._model!r} "
355
+ f"(expected one of {_VALID_MODELS})")
356
+ s16 = self._model == MODEL_DX100
357
+ self._s_cmd = _CMD_S16_VAR if s16 else _CMD_S_VAR
358
+ self._s_plural_cmd = _CMD_S16_PLURAL if s16 else _CMD_S_PLURAL
359
+ self._s_width = 16 if s16 else 32
360
+
361
+ @property
362
+ def model(self) -> str:
363
+ return self._model
364
+
365
+ @model.setter
366
+ def model(self, value: str) -> None:
367
+ self._model = value
368
+ self._apply_model()
369
+
370
+ def detect_model(self) -> str:
371
+ """Detect the controller generation with a capability probe (no version
372
+ strings — those are not documented). Sends a 32-byte S read (0x8C): a
373
+ DX100 rejects the command with status 0x08 ("undefined"), while DX200 /
374
+ YRC1000 accept it. Sets self.model to the detected value and returns it.
375
+
376
+ Cannot distinguish DX200 from YRC1000 (identical S behaviour) — it reports
377
+ "yrc1000" for both; use an explicit model to get DX200's temperature-command
378
+ guard. The status-0x08 assumption must be confirmed live against a DX100
379
+ (docs/online_tests.md)."""
380
+ try:
381
+ self._tx(_CMD_S_VAR, instance=0, attribute=1, service=_SVC_GET_SINGLE)
382
+ detected = MODEL_YRC1000
383
+ except HsesError as e:
384
+ detected = MODEL_DX100 if e.status == _STATUS_CMD_UNDEFINED else MODEL_YRC1000
385
+ self.model = detected
386
+ log.info("detect_model: probed 0x8C -> %s", detected)
387
+ return detected
388
+
389
+ def _require_yrc1000(self, what: str) -> None:
390
+ """Guard for the YRC1000-only telemetry commands (0x0411-0x0413), which
391
+ DX100/DX200 do not implement."""
392
+ if self._model != MODEL_YRC1000:
393
+ raise RuntimeError(
394
+ f"{what} is only available on YRC1000 "
395
+ f"(controller model is {self._model})")
396
+
397
+ # ── axis names ─────────────────────────────────────────────────────────────
398
+ @property
399
+ def axis_names(self) -> tuple:
400
+ """Per-axis labels used by the pulse-position / following-error / torque /
401
+ encoder-temperature decoders. Returns the controller's real names once
402
+ `load_axis_names()` has fetched them (e.g. S,L,U,T on a 4-axis palletiser),
403
+ otherwise the S,L,U,R,B,T,E,8 default."""
404
+ return self._axis_names or _AXIS_NAMES
405
+
406
+ def load_axis_names(self, group: int = 1) -> tuple:
407
+ """Fetch and cache the real axis names from Axis Configuration (0x74) so the
408
+ per-axis reads are labelled with the controller's actual axis names instead
409
+ of the S,L,U,R,B,T default (the 4th axis is R on a wrist robot but T on a
410
+ palletiser). Empty slots keep a positional `axisN` label. Best-effort: on any
411
+ error the cache is left unset and the default names apply. Call once after
412
+ the connection is verified."""
413
+ try:
414
+ cfg = self.read_axis_config(group)
415
+ names = tuple((cfg.get(f"axis_{i + 1}") or f"axis{i + 1}") for i in range(8))
416
+ self._axis_names = names
417
+ log.info("axis names from 0x74: %s", cfg.get("axes"))
418
+ except Exception as e: # noqa: BLE001 — best-effort, fall back to defaults
419
+ log.warning("load_axis_names failed, using default names: %s", e)
420
+ return self.axis_names
421
+
422
+ # ── packet building ────────────────────────────────────────────────────────
423
+
424
+ @staticmethod
425
+ def _build(
426
+ cmd: int, instance: int, attribute: int, service: int,
427
+ req_id: int, payload: bytes = b"",
428
+ ) -> bytes:
429
+ hdr = bytearray(_HDR_SIZE)
430
+ hdr[0:4] = _MAGIC
431
+ struct.pack_into("<H", hdr, 4, _HDR_SIZE) # header size (fixed 32)
432
+ struct.pack_into("<H", hdr, 6, len(payload)) # data size
433
+ hdr[8] = 0x03 # Reserve1 FIXED
434
+ hdr[9] = _PROC_ROBOT
435
+ hdr[10] = 0x00 # ACK = request
436
+ hdr[11] = req_id
437
+ struct.pack_into("<I", hdr, 12, 0) # Block No.
438
+ hdr[16:24] = b"99999999" # Reserve2 FIXED
439
+ struct.pack_into("<H", hdr, 24, cmd)
440
+ struct.pack_into("<H", hdr, 26, instance)
441
+ hdr[28] = attribute
442
+ hdr[29] = service
443
+ struct.pack_into("<H", hdr, 30, 0) # Padding
444
+ return bytes(hdr) + payload
445
+
446
+ # ── low-level exchange ────────────────────────────────────────────────────
447
+
448
+ @staticmethod
449
+ def _parse_resp(raw: bytes) -> _RawResp:
450
+ """Decode a validated response header (magic/length already checked) into
451
+ a _RawResp. In the answer header the Instance/Attribute/Padding fields are
452
+ repurposed: bytes 24-25 → echoed command, 26 → status, 28 → added-status
453
+ size, 30-31 → added-status word 1. Shared by _tx and _tx_multi."""
454
+ dat_size = struct.unpack_from("<H", raw, 6)[0]
455
+ resp_status = raw[26]
456
+ added_size = raw[28]
457
+ added = struct.unpack_from("<H", raw, 30)[0] if added_size >= 1 else 0
458
+ cmd_echo = struct.unpack_from("<H", raw, 24)[0]
459
+ data = raw[_HDR_SIZE: _HDR_SIZE + dat_size]
460
+ return _RawResp(cmd=cmd_echo, status=resp_status, added=added, data=data)
461
+
462
+ def _tx(
463
+ self,
464
+ cmd: int,
465
+ instance: int = 1,
466
+ attribute: int = 0,
467
+ service: int = _SVC_GET_ALL,
468
+ payload: bytes = b"",
469
+ ) -> _RawResp:
470
+ if self._sock is None:
471
+ raise RuntimeError("Call open() first")
472
+
473
+ rid = self._bump_req_id()
474
+ pkt = self._build(cmd, instance, attribute, service, rid, payload)
475
+ log.debug("tx cmd=0x%04X inst=%d attr=%d svc=0x%02X id=%d payload=%dB",
476
+ cmd, instance, attribute, service, rid, len(payload))
477
+ self._sock.sendto(pkt, (self._host, self._port))
478
+
479
+ # Match the response by request ID (like _tx_multi). Without this
480
+ # check, the late response of a previously timed-out request is read
481
+ # as the response to this one — the offset then persists and silently
482
+ # yields foreign payload data (e.g. bytes of a job name as the value
483
+ # of a B variable).
484
+ deadline = time.monotonic() + self._timeout
485
+ try:
486
+ while True:
487
+ remaining = deadline - time.monotonic()
488
+ if remaining <= 0:
489
+ log.error("timeout cmd=0x%04X inst=%d id=%d after %.1fs",
490
+ cmd, instance, rid, self._timeout)
491
+ raise socket.timeout(
492
+ f"no response for cmd=0x{cmd:04X} id={rid}")
493
+ self._sock.settimeout(remaining)
494
+ try:
495
+ raw = self._sock.recv(4096)
496
+ except socket.timeout:
497
+ log.error("timeout cmd=0x%04X inst=%d id=%d after %.1fs",
498
+ cmd, instance, rid, self._timeout)
499
+ raise
500
+ if len(raw) < _HDR_SIZE:
501
+ log.error("malformed response cmd=0x%04X id=%d: too short (%d B)",
502
+ cmd, rid, len(raw))
503
+ raise IOError(f"Response too short: {len(raw)} B")
504
+ if raw[0:4] != _MAGIC:
505
+ log.error("malformed response cmd=0x%04X id=%d: bad magic %r",
506
+ cmd, rid, raw[0:4])
507
+ raise IOError(f"Bad magic: {raw[0:4]!r}")
508
+ if raw[11] != rid:
509
+ log.warning("verwerfe verspaetete Antwort id=%d (erwartet %d, "
510
+ "cmd=0x%04X)", raw[11], rid, cmd)
511
+ continue
512
+ break
513
+ finally:
514
+ self._sock.settimeout(self._timeout)
515
+
516
+ resp = self._parse_resp(raw)
517
+
518
+ if resp.status != 0x00:
519
+ log.error("rx cmd=0x%04X id=%d status=0x%02X (%s) added=0x%04X (%s)",
520
+ cmd, rid, resp.status, status_text(resp.status),
521
+ resp.added, added_status_text(resp.added) if resp.added else "-")
522
+ raise HsesError(cmd, resp.status, resp.added)
523
+
524
+ log.debug("rx cmd=0x%04X id=%d status=0 data=%dB", cmd, rid, len(resp.data))
525
+ return resp
526
+
527
+ # ── parallel burst ────────────────────────────────────────────────────────
528
+
529
+ def _tx_multi(
530
+ self,
531
+ reqs: list[tuple],
532
+ ) -> "list[Optional[_RawResp]]":
533
+ """
534
+ Send all requests simultaneously, collect responses matched by req_id.
535
+ reqs: list of (cmd, instance, attribute, service, payload)
536
+ Returns list of _RawResp|None in the same order as reqs.
537
+ """
538
+ if self._sock is None:
539
+ raise RuntimeError("Call open() first")
540
+
541
+ log.debug("tx_multi %d requests", len(reqs))
542
+ req_ids: list[int] = []
543
+ for cmd, instance, attribute, service, payload in reqs:
544
+ rid = self._bump_req_id()
545
+ req_ids.append(rid)
546
+ pkt = self._build(cmd, instance, attribute, service, rid, payload)
547
+ self._sock.sendto(pkt, (self._host, self._port))
548
+
549
+ id_to_resp: dict[int, _RawResp] = {}
550
+ pending = set(req_ids)
551
+ deadline = time.monotonic() + self._timeout
552
+
553
+ while pending:
554
+ remaining = deadline - time.monotonic()
555
+ if remaining <= 0:
556
+ break
557
+ self._sock.settimeout(remaining)
558
+ try:
559
+ raw = self._sock.recv(4096)
560
+ except OSError:
561
+ break
562
+ if len(raw) < _HDR_SIZE or raw[0:4] != _MAGIC:
563
+ continue
564
+ rid = raw[11]
565
+ if rid not in pending:
566
+ continue
567
+ id_to_resp[rid] = self._parse_resp(raw)
568
+ pending.discard(rid)
569
+
570
+ self._sock.settimeout(self._timeout)
571
+ if pending:
572
+ log.warning("tx_multi: %d/%d responses missing (timeout)",
573
+ len(pending), len(req_ids))
574
+ return [id_to_resp.get(rid) for rid in req_ids]
575
+
576
+ def poll_all(
577
+ self,
578
+ *,
579
+ alarm_slots: int = 0,
580
+ include_job: bool = True,
581
+ position_group: int = 0,
582
+ b_vars: "list[dict]" = [],
583
+ i_vars: "list[dict]" = [],
584
+ io_signals: "list[dict]" = [],
585
+ read_cio: bool = False,
586
+ ) -> dict:
587
+ """
588
+ Fetch all configured data in one parallel UDP burst.
589
+
590
+ alarm_slots: number of alarm history slots (0=skip, max 4)
591
+ include_job: read executing job info
592
+ position_group: 0=skip, 1-8=pulse, 101-108=cartesian
593
+ b_vars/i_vars: list of {"label": str, "no": int}
594
+ io_signals: list of {"label": str, "logical": int}
595
+
596
+ Returns flat dict of all decoded fields.
597
+ Raises HsesError if the STATUS command returns an error.
598
+ """
599
+ reqs: list[tuple] = []
600
+ tags: list[str] = []
601
+
602
+ reqs.append((_CMD_STATUS, 1, 0, _SVC_GET_ALL, b""))
603
+ tags.append("status")
604
+
605
+ for slot in range(1, alarm_slots + 1):
606
+ reqs.append((_CMD_ALARM, slot, 0, _SVC_GET_ALL, b""))
607
+ tags.append(f"alarm{slot}")
608
+
609
+ if include_job:
610
+ reqs.append((_CMD_JOB, 1, 0, _SVC_GET_ALL, b""))
611
+ tags.append("job")
612
+
613
+ if position_group:
614
+ reqs.append((_CMD_POSITION, position_group, 0, _SVC_GET_ALL, b""))
615
+ tags.append("position")
616
+
617
+ for v in b_vars:
618
+ reqs.append((_CMD_B_VAR, v["no"], 1, _SVC_GET_SINGLE, b""))
619
+ tags.append(f"b_var:{v['label']}")
620
+
621
+ for v in i_vars:
622
+ reqs.append((_CMD_I_VAR, v["no"], 1, _SVC_GET_SINGLE, b""))
623
+ tags.append(f"i_var:{v['label']}")
624
+
625
+ # CIO counter: low byte at logical 72000 (inst 7200), high byte at 72010 (inst 7201)
626
+ if read_cio:
627
+ reqs.append((_CMD_IO_PLURAL, 7200, 0, _SVC_READ_ALL, struct.pack("<I", 2)))
628
+ tags.append("cio")
629
+
630
+ # One plural read covers all io_signals regardless of count
631
+ io_min_inst = 0
632
+ if io_signals:
633
+ io_min_inst = min(sig["logical"] // 10 for sig in io_signals)
634
+ io_max_inst = max(sig["logical"] // 10 for sig in io_signals)
635
+ n_bytes = io_max_inst - io_min_inst + 1
636
+ if n_bytes % 2 != 0:
637
+ n_bytes += 1
638
+ reqs.append((_CMD_IO_PLURAL, io_min_inst, 0, _SVC_READ_ALL,
639
+ struct.pack("<I", n_bytes)))
640
+ tags.append("io_plural")
641
+
642
+ responses = self._tx_multi(reqs)
643
+ out: dict = {}
644
+
645
+ for tag, resp in zip(tags, responses):
646
+ if resp is None:
647
+ continue
648
+ if tag == "status":
649
+ if resp.status != 0:
650
+ raise HsesError(resp.cmd, resp.status, resp.added)
651
+ out.update(_decode_status(resp.data, self._model))
652
+ elif tag.startswith("alarm"):
653
+ if resp.status == 0:
654
+ slot = int(tag[5:])
655
+ for k, v in _decode_alarm(resp.data).items():
656
+ out[f"alarm{slot}_{k[len('alarm_'):]}"] = v
657
+ elif tag == "job":
658
+ if resp.status == 0:
659
+ out.update(_decode_job(resp.data))
660
+ elif tag == "position":
661
+ if resp.status == 0:
662
+ out.update(_decode_position(resp.data))
663
+ elif tag.startswith("b_var:"):
664
+ if resp.status == 0 and len(resp.data) >= 1:
665
+ out[f"var_{tag[6:]}"] = resp.data[0]
666
+ elif tag.startswith("i_var:"):
667
+ if resp.status == 0 and len(resp.data) >= 2:
668
+ out[f"var_{tag[6:]}"] = struct.unpack_from("<h", resp.data, 0)[0]
669
+ elif tag == "cio":
670
+ if resp.status == 0 and len(resp.data) >= 6:
671
+ # response: 4-byte count + 2 data bytes
672
+ lo = resp.data[4]
673
+ hi = resp.data[5]
674
+ out["cio_counter"] = (hi << 8) | lo
675
+ elif tag == "io_plural":
676
+ if resp.status == 0 and len(resp.data) >= 4:
677
+ n = struct.unpack_from("<I", resp.data, 0)[0]
678
+ io_bytes = resp.data[4: 4 + n]
679
+ for sig in io_signals:
680
+ offset = sig["logical"] // 10 - io_min_inst
681
+ if offset < len(io_bytes):
682
+ out[f"io_{sig['label']}"] = io_bytes[offset]
683
+
684
+ return out
685
+
686
+ # ── public read methods ────────────────────────────────────────────────────
687
+
688
+ def read_status(self) -> dict:
689
+ """
690
+ Status Information Reading (cmd 0x72).
691
+ Returns dict with boolean flags:
692
+ step, one_cycle, auto, running, speed_limit, teach, play, cmd_remote,
693
+ hold_pendant, hold_external, hold_cmd, alarm, error, servo_on
694
+ On DX100 the bit4 flag is keyed `in_guard_safe` instead of `speed_limit`.
695
+ """
696
+ r = self._tx(_CMD_STATUS, instance=1, attribute=0, service=_SVC_GET_ALL)
697
+ if len(r.data) < 8:
698
+ raise IOError(f"Status data too short: {len(r.data)} B")
699
+ return _decode_status(r.data, self._model)
700
+
701
+ def read_alarm(self, slot: int = 1) -> dict:
702
+ """
703
+ Alarm Data Reading (cmd 0x70).
704
+ slot: 1=latest, 2=second from latest, 3=third, 4=fourth.
705
+ Returns dict: alarm_code, alarm_data, alarm_type, alarm_time, alarm_name.
706
+ alarm_type=0 means no alarm in that slot.
707
+ """
708
+ r = self._tx(_CMD_ALARM, instance=slot, attribute=0, service=_SVC_GET_ALL)
709
+ return _decode_alarm(r.data)
710
+
711
+ def read_alarm_history(self, number: int = 1) -> dict:
712
+ """
713
+ Alarm History Reading (cmd 0x71). Same 60-byte layout as read_alarm().
714
+ `number` is the history slot per class:
715
+ 1-100 major alarms
716
+ 1001-1100 minor alarms
717
+ 2001-2100 user alarms (system)
718
+ 3001-3100 user alarms (user)
719
+ 4001-4100 off-line alarms
720
+ Returns dict: alarm_code, alarm_data, alarm_type, alarm_time, alarm_name.
721
+ alarm_type=0 (and code=0) means the slot is empty.
722
+ """
723
+ r = self._tx(_CMD_ALARM_HIST, instance=number, attribute=0,
724
+ service=_SVC_GET_ALL)
725
+ return _decode_alarm(r.data)
726
+
727
+ def read_alarm_sub(self, slot: int = 1) -> dict:
728
+ """
729
+ Alarm Data Reading with sub-code strings (cmd 0x30A). Extends
730
+ read_alarm() with the diagnostic strings that appear next to the
731
+ sub-code on the pendant.
732
+ slot: 1=latest, 2=second from latest, 3=third, 4=fourth.
733
+ Returns read_alarm()'s dict plus sub_code_info, sub_code_str and
734
+ reverse_display. alarm_type=0 means no alarm in that slot.
735
+ """
736
+ r = self._tx(_CMD_ALARM_SUB, instance=slot, attribute=0,
737
+ service=_SVC_GET_ALL)
738
+ return _decode_alarm_sub(r.data)
739
+
740
+ def read_alarm_history_sub(self, number: int = 1) -> dict:
741
+ """
742
+ Alarm History Reading with sub-code strings (cmd 0x30B). Same extended
743
+ 268-byte layout as read_alarm_sub(); `number` is the history slot per
744
+ class exactly as in read_alarm_history() (1-100 major, 1001+ minor,
745
+ 2001+/3001+ user, 4001+ off-line). Read-only.
746
+ Returns the same dict as read_alarm_sub(); an empty slot has
747
+ alarm_type=0 and alarm_code=0.
748
+ """
749
+ r = self._tx(_CMD_ALARM_HIST_SUB, instance=number, attribute=0,
750
+ service=_SVC_GET_ALL)
751
+ return _decode_alarm_sub(r.data)
752
+
753
+ def read_job(self, task: int = 1) -> dict:
754
+ """
755
+ Executing Job Information Reading (cmd 0x73).
756
+ task: 1=master task, 2-16=sub tasks.
757
+ Returns dict: job_name, job_line, job_step, job_speed.
758
+ """
759
+ r = self._tx(_CMD_JOB, instance=task, attribute=0, service=_SVC_GET_ALL)
760
+ return _decode_job(r.data)
761
+
762
+ def read_axis_config(self, group: int = 1) -> dict:
763
+ """
764
+ Axis Configuration Information Reading (cmd 0x74).
765
+ group 1-8 = R1-R8 (pulse), 101-108 = R1-R8 (cartesian).
766
+ Defines the axis order used by 0x75/0x76/0x77 (non-present axes are
767
+ skipped, no gaps). Returns axis_1..axis_8 (name string per axis, empty
768
+ if absent) plus 'axes' = list of present names. This robot: S,L,U,R.
769
+ """
770
+ r = self._tx(_CMD_AXIS_CFG, instance=group, attribute=0, service=_SVC_GET_ALL)
771
+ return _decode_axis_config(r.data)
772
+
773
+ def read_position(self, group: int = 1) -> dict:
774
+ """
775
+ Robot Position Data Reading (cmd 0x75).
776
+ group 1-8 = R1-R8 pulse (joint angles in pulse units).
777
+ group 101-108 = R1-R8 cartesian (X/Y/Z in mm, Rx/Ry/Rz in degrees).
778
+ """
779
+ r = self._tx(_CMD_POSITION, instance=group, attribute=0, service=_SVC_GET_ALL)
780
+ return _decode_position(r.data, self.axis_names)
781
+
782
+ def read_position_error(self, group: int = 1) -> dict:
783
+ """
784
+ Position Error (following error) Reading (cmd 0x76).
785
+ group 1-8 = R1-R8. Returns the following error per axis in pulse units,
786
+ keyed pe_S/pe_L/pe_U/pe_R/... in the axis order defined by 0x74.
787
+ This 4-axis robot (S,L,U,R) leaves the remaining words at 0.
788
+ """
789
+ r = self._tx(_CMD_POSERR, instance=group, attribute=0, service=_SVC_GET_ALL)
790
+ return _decode_axis_int32(r.data, "pe", self.axis_names)
791
+
792
+ def read_torque(self, group: int = 1) -> dict:
793
+ """
794
+ Torque Data Reading (cmd 0x77).
795
+ group 1-8 = R1-R8. Returns torque per axis (typ. % of rated torque,
796
+ sign = direction), keyed tq_S/tq_L/tq_U/tq_R/... in the axis order
797
+ defined by 0x74. This 4-axis robot leaves the remaining words at 0.
798
+ """
799
+ r = self._tx(_CMD_TORQUE, instance=group, attribute=0, service=_SVC_GET_ALL)
800
+ return _decode_axis_int32(r.data, "tq", self.axis_names)
801
+
802
+ def read_io(self, logical: int) -> int:
803
+ """
804
+ I/O Data Reading (cmd 0x78).
805
+ logical: signal logical number as shown on the PP (e.g. 10010 = first output).
806
+ instance = logical // 10. Returns raw DWORD (0=OFF, non-zero=ON).
807
+ """
808
+ r = self._tx(_CMD_IO, instance=logical // 10, attribute=1, service=_SVC_GET_SINGLE)
809
+ if not r.data:
810
+ return 0
811
+ return r.data[0]
812
+
813
+ def read_io_plural(self, start_logical: int, count: int) -> bytes:
814
+ """
815
+ Plural I/O Data Reading (cmd 0x300, service 0x33).
816
+ Reads `count` consecutive I/O bytes starting at start_logical in one request.
817
+ count must be even, max 474.
818
+ Returns raw bytes of length count.
819
+ instance = start_logical // 10
820
+ """
821
+ if count % 2 != 0:
822
+ count += 1 # must be even per spec
823
+ payload = struct.pack("<I", count)
824
+ r = self._tx(_CMD_IO_PLURAL, instance=start_logical // 10,
825
+ attribute=0, service=_SVC_READ_ALL, payload=payload)
826
+ # Response data: DWORD 1 = count returned, then count bytes of I/O data
827
+ if len(r.data) < 4:
828
+ return b""
829
+ n_returned = struct.unpack_from("<I", r.data, 0)[0]
830
+ return r.data[4: 4 + n_returned]
831
+
832
+ def write_io_plural(self, start_logical: int, data: bytes) -> None:
833
+ """
834
+ Plural I/O Data Writing (cmd 0x300, service 0x34).
835
+ Writes len(data) consecutive I/O bytes starting at start_logical.
836
+ len(data) must be even, max 474.
837
+ instance = start_logical // 10
838
+ Writable areas: network inputs (27010–29560), pseudo inputs (87010–87200).
839
+ """
840
+ count = len(data)
841
+ if count % 2 != 0:
842
+ data = data + b"\x00"
843
+ count += 1
844
+ payload = struct.pack("<I", count) + data
845
+ self._tx(_CMD_IO_PLURAL, instance=start_logical // 10,
846
+ attribute=0, service=_SVC_WRITE_ALL, payload=payload)
847
+
848
+ def write_io(self, logical: int, value: int) -> None:
849
+ """
850
+ Write a single I/O byte by logical address.
851
+ value: 0–255 (one byte, 8 IO bits packed).
852
+ Uses write_io_plural with 2 bytes (minimum even payload).
853
+ """
854
+ byte_val = value & 0xFF
855
+ self.write_io_plural(logical, bytes([byte_val, 0x00]))
856
+
857
+ # ── control commands ────────────────────────────────────────────────────────
858
+ #
859
+ # All of these need Command Remote enabled (RS 005 = 1) and, depending on the
860
+ # action, the right mode / servo state. _tx() raises HsesError on a non-zero
861
+ # status, so a failed command (e.g. 0x2080 incorrect mode, 0x2100 command
862
+ # remote not set) surfaces as an exception carrying the added-status code.
863
+
864
+ def _control(self, cmd: int, instance: int, value: int) -> None:
865
+ """Single-element control write (service 0x10) with a 4-byte int payload."""
866
+ self._tx(cmd, instance=instance, attribute=1, service=_SVC_SET_SINGLE,
867
+ payload=struct.pack("<I", value))
868
+
869
+ def reset_alarm(self) -> None:
870
+ """Alarm Reset (cmd 0x82, instance 1). Clears the active alarm."""
871
+ log.info("control: reset_alarm")
872
+ self._control(_CMD_RESET, 1, 1)
873
+
874
+ def cancel_error(self) -> None:
875
+ """Error Cancel (cmd 0x82, instance 2). Cancels the active error."""
876
+ log.info("control: cancel_error")
877
+ self._control(_CMD_RESET, 2, 1)
878
+
879
+ def hold(self, on: bool = True) -> None:
880
+ """HOLD (cmd 0x83, instance 1). on=True → hold, on=False → release."""
881
+ log.info("control: hold %s", "on" if on else "off")
882
+ self._control(_CMD_HOLD_SERVO, _HS_HOLD, _ON if on else _OFF)
883
+
884
+ def servo(self, on: bool = True) -> None:
885
+ """Servo power (cmd 0x83, instance 2). on=True → ON, on=False → OFF."""
886
+ log.info("control: servo %s", "on" if on else "off")
887
+ self._control(_CMD_HOLD_SERVO, _HS_SERVO, _ON if on else _OFF)
888
+
889
+ def hlock(self, on: bool = True) -> None:
890
+ """
891
+ HLOCK (cmd 0x83, instance 3). Interlocks the programming pendant and I/O
892
+ operation-system signals. on=True → lock, on=False → unlock.
893
+ Invalid while the pendant is in edit mode or a file is being accessed.
894
+ """
895
+ log.info("control: hlock %s", "on" if on else "off")
896
+ self._control(_CMD_HOLD_SERVO, _HS_HLOCK, _ON if on else _OFF)
897
+
898
+ def set_cycle(self, mode: int) -> None:
899
+ """
900
+ Step / Cycle / Auto switching (cmd 0x84, instance 2).
901
+ mode: 1=STEP, 2=1 CYCLE, 3=AUTO (continuous).
902
+ """
903
+ if mode not in (1, 2, 3):
904
+ raise ValueError("cycle mode must be 1 (STEP), 2 (1 CYCLE) or 3 (AUTO)")
905
+ log.info("control: set_cycle mode=%d", mode)
906
+ self._control(_CMD_CYCLE, 2, mode)
907
+
908
+ def display_message(self, text: str) -> None:
909
+ """
910
+ Character string display to the programming pendant (cmd 0x85).
911
+ Up to 30 half-width characters (32-byte field, encoded in the pendant's
912
+ language code — use latin-1 to match the YRC1000).
913
+ """
914
+ log.info("control: display_message %r", text)
915
+ raw = text.encode("latin-1", errors="replace")[:32]
916
+ payload = raw.ljust(32, b"\x00")
917
+ self._tx(_CMD_MSG, instance=1, attribute=1, service=_SVC_SET_SINGLE,
918
+ payload=payload)
919
+
920
+ def start_job(self) -> None:
921
+ """
922
+ Start-up / Job START (cmd 0x86, instance 1). Plays back the currently
923
+ selected job. Requires servo ON and play mode.
924
+ """
925
+ log.info("control: start_job")
926
+ self._control(_CMD_JOB_START, 1, 1)
927
+
928
+ def select_job(self, name: str, line: int = 0, *,
929
+ master_task: Optional[int] = None) -> None:
930
+ """
931
+ Job Select (cmd 0x87, service 0x02 Set_All).
932
+ name: job name WITHOUT the .JBI extension (max 32 half-width chars).
933
+ line: start line number, 0-9999.
934
+ master_task: None → set the executing job (instance 1).
935
+ 0-15 → set the master job for that task (instance 10-25).
936
+
937
+ Attribute is 0 (Set_All targets element 0 = the whole record). The robot
938
+ rejects a ".JBI" suffix and a non-zero attribute with status 0x01
939
+ (verified live 2026-07-04).
940
+ """
941
+ if master_task is None:
942
+ instance = 1
943
+ elif 0 <= master_task <= 15:
944
+ instance = 10 + master_task
945
+ else:
946
+ raise ValueError("master_task must be 0-15 or None")
947
+ if not 0 <= line <= 9999:
948
+ raise ValueError("line must be 0-9999")
949
+ log.info("control: select_job name=%r line=%d task=%s",
950
+ name, line, master_task)
951
+ raw = name.encode("latin-1", errors="replace")[:32].ljust(32, b"\x00")
952
+ payload = raw + struct.pack("<I", line)
953
+ self._tx(_CMD_JOB_SELECT, instance=instance, attribute=0,
954
+ service=_SVC_SET_ALL, payload=payload)
955
+
956
+ # ── motion commands (⚠️ move the robot) ──────────────────────────────────────
957
+ #
958
+ # These order a real motion: Servo ON, Command Remote (RS 005 = 1) and PLAY
959
+ # mode are required, and the controller executes the move immediately. Only
960
+ # use them with a clear work envelope and the emergency stop within reach.
961
+
962
+ def move_cartesian(self, move: CartesianMove,
963
+ mode: int = _MOVE_STRAIGHT_ABS) -> None:
964
+ """
965
+ Cartesian move (cmd 0x8A). ⚠️ MOVES the robot to the pose in `move`.
966
+
967
+ mode: 1=link-absolute (joint interp.), 2=straight-absolute (linear),
968
+ 3=straight-increment (linear, relative to the current pose).
969
+ Attribute is fixed 1, service is Set_All (0x02, execute).
970
+ """
971
+ if mode not in (_MOVE_LINK_ABS, _MOVE_STRAIGHT_ABS, _MOVE_STRAIGHT_INC):
972
+ raise ValueError("mode must be 1 (link), 2 (straight) or 3 (increment)")
973
+ log.info("control: move_cartesian mode=%d coord=%d group=r%d/s%d",
974
+ mode, move.coord, move.robot_group, move.station_group)
975
+ self._tx(_CMD_MOVE_CART, instance=mode, attribute=1,
976
+ service=_SVC_SET_ALL, payload=move.to_bytes())
977
+
978
+ def move_pulse(self, move: PulseMove,
979
+ mode: int = _MOVE_LINK_ABS) -> None:
980
+ """
981
+ Pulse move (cmd 0x8B). ⚠️ MOVES the robot to the joint pulses in `move`.
982
+
983
+ mode: 1=link-absolute (joint interp.), 2=straight-absolute (linear).
984
+ Attribute is fixed 1, service is Set_All (0x02, execute).
985
+ """
986
+ if mode not in (_MOVE_LINK_ABS, _MOVE_STRAIGHT_ABS):
987
+ raise ValueError("mode must be 1 (link) or 2 (straight)")
988
+ log.info("control: move_pulse mode=%d group=r%d/s%d",
989
+ mode, move.robot_group, move.station_group)
990
+ self._tx(_CMD_MOVE_PULSE, instance=mode, attribute=1,
991
+ service=_SVC_SET_ALL, payload=move.to_bytes())
992
+
993
+ # ── variable reads ──────────────────────────────────────────────────────────
994
+
995
+ def read_register(self, number: int) -> int:
996
+ """
997
+ Register M[number] (cmd 0x79). Registers are 16-bit values carried in a
998
+ 4-byte field; returns 0-65535. Instance is the register number 0-999.
999
+ """
1000
+ r = self._tx(_CMD_REGISTER, instance=number, attribute=1, service=_SVC_GET_SINGLE)
1001
+ if len(r.data) < 2:
1002
+ return 0
1003
+ return struct.unpack_from("<H", r.data, 0)[0]
1004
+
1005
+ def read_b_var(self, number: int) -> int:
1006
+ """Byte Variable B[number] (cmd 0x7A). Returns 0-255."""
1007
+ r = self._tx(_CMD_B_VAR, instance=number, attribute=1, service=_SVC_GET_SINGLE)
1008
+ if len(r.data) < 1:
1009
+ return 0
1010
+ return r.data[0]
1011
+
1012
+ def read_i_var(self, number: int) -> int:
1013
+ """Integer Variable I[number] (cmd 0x7B). Returns the signed 16-bit value
1014
+ carried in the low word of the 4-byte slot (-32768..32767); use D (0x7C)
1015
+ for 32-bit. Matches write_i_var / the plural 0x303 path."""
1016
+ r = self._tx(_CMD_I_VAR, instance=number, attribute=1, service=_SVC_GET_SINGLE)
1017
+ if len(r.data) < 2:
1018
+ return 0
1019
+ return struct.unpack_from("<h", r.data, 0)[0]
1020
+
1021
+ def read_d_var(self, number: int) -> int:
1022
+ """Double Variable D[number] (cmd 0x7C). Returns signed 32-bit int."""
1023
+ r = self._tx(_CMD_D_VAR, instance=number, attribute=1, service=_SVC_GET_SINGLE)
1024
+ if len(r.data) < 4:
1025
+ return 0
1026
+ return struct.unpack_from("<i", r.data, 0)[0]
1027
+
1028
+ def read_r_var(self, number: int) -> float:
1029
+ """Real Variable R[number] (cmd 0x7D). Returns IEEE-754 float32."""
1030
+ r = self._tx(_CMD_R_VAR, instance=number, attribute=1, service=_SVC_GET_SINGLE)
1031
+ if len(r.data) < 4:
1032
+ return 0.0
1033
+ return struct.unpack_from("<f", r.data, 0)[0]
1034
+
1035
+ def read_s_var(self, number: int) -> str:
1036
+ """
1037
+ Character Variable S[number]. 32-byte cmd 0x8C on DX200/YRC1000, 16-byte
1038
+ cmd 0x7E on DX100 (selected by the client model). Returns the string with
1039
+ trailing NULs stripped, decoded as latin-1 to match the pendant's language.
1040
+ """
1041
+ r = self._tx(self._s_cmd, instance=number, attribute=1, service=_SVC_GET_SINGLE)
1042
+ return r.data[:self._s_width].split(b"\x00", 1)[0].decode("latin-1")
1043
+
1044
+ # ── variable writes ─────────────────────────────────────────────────────────
1045
+
1046
+ def write_register(self, number: int, value: int) -> None:
1047
+ """
1048
+ Write Register M[number] (cmd 0x79, service 0x10 Set_Single).
1049
+ value: 0-65535, carried in the low word of a 4-byte slot. Only registers
1050
+ below the writable limit (0-559, controller-dependent) accept a write;
1051
+ higher numbers are read-only and reject with an HsesError.
1052
+ """
1053
+ if not 0 <= value <= 65535:
1054
+ raise ValueError("register value must be 0-65535")
1055
+ self._tx(_CMD_REGISTER, instance=number, attribute=1, service=_SVC_SET_SINGLE,
1056
+ payload=struct.pack("<I", value))
1057
+
1058
+ def write_b_var(self, number: int, value: int) -> None:
1059
+ """
1060
+ Write Byte Variable B[number] (cmd 0x7A, service 0x10 Set_Single).
1061
+ value: 0-255. Payload is a 4-byte slot with the value in the low byte.
1062
+ """
1063
+ if not 0 <= value <= 255:
1064
+ raise ValueError("B variable value must be 0-255")
1065
+ self._tx(_CMD_B_VAR, instance=number, attribute=1, service=_SVC_SET_SINGLE,
1066
+ payload=struct.pack("<I", value))
1067
+
1068
+ def write_i_var(self, number: int, value: int) -> None:
1069
+ """
1070
+ Write Integer Variable I[number] (cmd 0x7B, service 0x10 Set_Single).
1071
+ value: signed 16-bit (-32768..32767), carried in the low word of a
1072
+ 4-byte slot.
1073
+ """
1074
+ if not -32768 <= value <= 32767:
1075
+ raise ValueError("I variable value must be a signed 16-bit int")
1076
+ self._tx(_CMD_I_VAR, instance=number, attribute=1, service=_SVC_SET_SINGLE,
1077
+ payload=struct.pack("<i", value))
1078
+
1079
+ def write_d_var(self, number: int, value: int) -> None:
1080
+ """
1081
+ Write Double Variable D[number] (cmd 0x7C, service 0x10 Set_Single).
1082
+ value: signed 32-bit int (-2147483648..2147483647).
1083
+ """
1084
+ if not -2_147_483_648 <= value <= 2_147_483_647:
1085
+ raise ValueError("D variable value must be a signed 32-bit int")
1086
+ self._tx(_CMD_D_VAR, instance=number, attribute=1, service=_SVC_SET_SINGLE,
1087
+ payload=struct.pack("<i", value))
1088
+
1089
+ def write_r_var(self, number: int, value: float) -> None:
1090
+ """
1091
+ Write Real Variable R[number] (cmd 0x7D, service 0x10 Set_Single).
1092
+ value: IEEE-754 float32.
1093
+ """
1094
+ self._tx(_CMD_R_VAR, instance=number, attribute=1, service=_SVC_SET_SINGLE,
1095
+ payload=struct.pack("<f", value))
1096
+
1097
+ def write_s_var(self, number: int, value: str) -> None:
1098
+ """
1099
+ Write Character Variable S[number] (service 0x10). 32-byte cmd 0x8C on
1100
+ DX200/YRC1000, 16-byte cmd 0x7E on DX100. value is encoded as latin-1
1101
+ (pendant language code), truncated and NUL-padded to the field width.
1102
+ """
1103
+ raw = value.encode("latin-1", errors="replace")[:self._s_width]
1104
+ self._tx(self._s_cmd, instance=number, attribute=1, service=_SVC_SET_SINGLE,
1105
+ payload=raw.ljust(self._s_width, b"\x00"))
1106
+
1107
+ def read_position_var(self, number: int) -> PositionVar:
1108
+ """
1109
+ Robot Position Variable P[number] (cmd 0x7F, service 0x01 Get_All).
1110
+ Instance is the variable number 0-127. P only supports "All" access, so
1111
+ the whole 52-byte record is returned as a PositionVar.
1112
+ """
1113
+ r = self._tx(_CMD_P_VAR, instance=number, attribute=0, service=_SVC_GET_ALL)
1114
+ return PositionVar.from_bytes(r.data)
1115
+
1116
+ def write_position_var(self, number: int, pos: PositionVar) -> None:
1117
+ """
1118
+ Write Robot Position Variable P[number] (cmd 0x7F, service 0x02 Set_All).
1119
+ P only supports "All" access, so the whole 52-byte record is written at
1120
+ once from the given PositionVar.
1121
+ """
1122
+ self._tx(_CMD_P_VAR, instance=number, attribute=0, service=_SVC_SET_ALL,
1123
+ payload=pos.to_bytes())
1124
+
1125
+ def read_bp_var(self, number: int) -> AxisPositionVar:
1126
+ """
1127
+ Base Position Variable BP[number] (cmd 0x80, service 0x01 Get_All).
1128
+ Instance is the variable number 0-127. Returns the whole 36-byte record
1129
+ as an AxisPositionVar (data_type 0=Pulse or 16=Base cartesian).
1130
+ """
1131
+ r = self._tx(_CMD_BP_VAR, instance=number, attribute=0, service=_SVC_GET_ALL)
1132
+ return AxisPositionVar.from_bytes(r.data)
1133
+
1134
+ def write_bp_var(self, number: int, pos: AxisPositionVar) -> None:
1135
+ """
1136
+ Write Base Position Variable BP[number] (cmd 0x80, service 0x02 Set_All).
1137
+ Only data_type 0 (Pulse) and 16 (Base cartesian) are valid.
1138
+ """
1139
+ self._tx(_CMD_BP_VAR, instance=number, attribute=0, service=_SVC_SET_ALL,
1140
+ payload=pos.to_bytes())
1141
+
1142
+ def read_ex_var(self, number: int) -> AxisPositionVar:
1143
+ """
1144
+ Station/External Axis Variable EX[number] (cmd 0x81, service 0x01 Get_All).
1145
+ Instance is the variable number 0-127. EX only carries pulse counts
1146
+ (data_type 0); returns the 36-byte record as an AxisPositionVar.
1147
+ """
1148
+ r = self._tx(_CMD_EX_VAR, instance=number, attribute=0, service=_SVC_GET_ALL)
1149
+ return AxisPositionVar.from_bytes(r.data)
1150
+
1151
+ def write_ex_var(self, number: int, pos: AxisPositionVar) -> None:
1152
+ """
1153
+ Write Station/External Axis Variable EX[number] (cmd 0x81, service 0x02
1154
+ Set_All). EX is pulse-only (data_type 0).
1155
+ """
1156
+ self._tx(_CMD_EX_VAR, instance=number, attribute=0, service=_SVC_SET_ALL,
1157
+ payload=pos.to_bytes())
1158
+
1159
+ # ── plural variable reads / writes ────────────────────────────────────────────
1160
+ #
1161
+ # Plural commands (0x301–0x309, 0x30C) read/write a run of consecutive
1162
+ # variables in a single request. Attribute is fixed 0, service 0x33 reads /
1163
+ # 0x34 writes. The request carries a uint32 element count; a read response
1164
+ # echoes the count returned, followed by the packed elements. Instance is the
1165
+ # start variable number. Per-command element sizes and max counts are in
1166
+ # HW1483358 §3.3.3.28–36/42. The 16-byte S variant (0x306) is
1167
+ # skipped in favour of the 32-byte 0x30C, mirroring the scalar 0x8C choice.
1168
+
1169
+ def _read_plural(self, cmd: int, start: int, count: int) -> bytes:
1170
+ """Send a plural read (service 0x33) and return the raw element bytes,
1171
+ trimmed to the element count the controller reports it returned."""
1172
+ r = self._tx(cmd, instance=start, attribute=0, service=_SVC_READ_ALL,
1173
+ payload=struct.pack("<I", count))
1174
+ if len(r.data) < 4:
1175
+ return b""
1176
+ return r.data[4:]
1177
+
1178
+ def _write_plural(self, cmd: int, start: int, count: int, data: bytes) -> None:
1179
+ """Send a plural write (service 0x34): a uint32 element count then the
1180
+ packed element bytes, starting at variable number `start`."""
1181
+ self._tx(cmd, instance=start, attribute=0, service=_SVC_WRITE_ALL,
1182
+ payload=struct.pack("<I", count) + data)
1183
+
1184
+ def _read_plural_scalar(self, cmd: int, start: int, count: int,
1185
+ fmt: str, size: int) -> list:
1186
+ """Plural read of fixed-width scalar elements (`fmt`/`size` per struct),
1187
+ empty-safe. Shared by the register/I/D/R variable reads."""
1188
+ buf = self._read_plural(cmd, start, count)
1189
+ n = len(buf) // size
1190
+ return list(struct.unpack_from(f"<{n}{fmt}", buf)) if n else []
1191
+
1192
+ def read_registers(self, start: int, count: int) -> "list[int]":
1193
+ """
1194
+ Plural Register read (cmd 0x301). Reads `count` consecutive registers
1195
+ starting at register number `start`. Each register is an unsigned 16-bit
1196
+ value (2-byte element); returns a list of 0-65535 ints (max 237).
1197
+ """
1198
+ return self._read_plural_scalar(_CMD_REG_PLURAL, start, count, "H", 2)
1199
+
1200
+ def write_registers(self, start: int, values: "list[int]") -> None:
1201
+ """
1202
+ Plural Register write (cmd 0x301). Writes `values` to consecutive
1203
+ registers from `start`. Each value 0-65535 (2-byte element). Only
1204
+ registers below the writable limit (0-559) accept a write.
1205
+ """
1206
+ for v in values:
1207
+ if not 0 <= v <= 65535:
1208
+ raise ValueError("register value must be 0-65535")
1209
+ self._write_plural(_CMD_REG_PLURAL, start, len(values),
1210
+ struct.pack(f"<{len(values)}H", *values))
1211
+
1212
+ def read_b_vars(self, start: int, count: int) -> "list[int]":
1213
+ """
1214
+ Plural Byte Variable read (cmd 0x302). Returns `count` consecutive B
1215
+ variables from `start` as a list of 0-255 ints (max 474). The wire count
1216
+ must be even, so an odd `count` reads one extra element and trims it.
1217
+ """
1218
+ buf = self._read_plural(_CMD_B_PLURAL, start, count + (count & 1))
1219
+ return list(buf[:count])
1220
+
1221
+ def write_b_vars(self, start: int, values: "list[int]") -> None:
1222
+ """
1223
+ Plural Byte Variable write (cmd 0x302). Writes `values` (each 0-255) to
1224
+ consecutive B variables from `start`. The wire count must be even, so an
1225
+ odd number of values is rejected — split or pad the run explicitly.
1226
+ """
1227
+ if len(values) % 2 != 0:
1228
+ raise ValueError("B plural write needs an even number of values")
1229
+ for v in values:
1230
+ if not 0 <= v <= 255:
1231
+ raise ValueError("B variable value must be 0-255")
1232
+ self._write_plural(_CMD_B_PLURAL, start, len(values), bytes(values))
1233
+
1234
+ def read_i_vars(self, start: int, count: int) -> "list[int]":
1235
+ """
1236
+ Plural Integer Variable read (cmd 0x303). Returns `count` consecutive I
1237
+ variables from `start` as signed 16-bit ints (2-byte element, max 237).
1238
+ """
1239
+ return self._read_plural_scalar(_CMD_I_PLURAL, start, count, "h", 2)
1240
+
1241
+ def write_i_vars(self, start: int, values: "list[int]") -> None:
1242
+ """
1243
+ Plural Integer Variable write (cmd 0x303). Writes `values` (each a signed
1244
+ 16-bit int) to consecutive I variables from `start`.
1245
+ """
1246
+ for v in values:
1247
+ if not -32768 <= v <= 32767:
1248
+ raise ValueError("I variable value must be a signed 16-bit int")
1249
+ self._write_plural(_CMD_I_PLURAL, start, len(values),
1250
+ struct.pack(f"<{len(values)}h", *values))
1251
+
1252
+ def read_d_vars(self, start: int, count: int) -> "list[int]":
1253
+ """
1254
+ Plural Double Variable read (cmd 0x304). Returns `count` consecutive D
1255
+ variables from `start` as signed 32-bit ints (4-byte element, max 118).
1256
+ """
1257
+ return self._read_plural_scalar(_CMD_D_PLURAL, start, count, "i", 4)
1258
+
1259
+ def write_d_vars(self, start: int, values: "list[int]") -> None:
1260
+ """
1261
+ Plural Double Variable write (cmd 0x304). Writes `values` (each a signed
1262
+ 32-bit int) to consecutive D variables from `start`.
1263
+ """
1264
+ for v in values:
1265
+ if not -2_147_483_648 <= v <= 2_147_483_647:
1266
+ raise ValueError("D variable value must be a signed 32-bit int")
1267
+ self._write_plural(_CMD_D_PLURAL, start, len(values),
1268
+ struct.pack(f"<{len(values)}i", *values))
1269
+
1270
+ def read_r_vars(self, start: int, count: int) -> "list[float]":
1271
+ """
1272
+ Plural Real Variable read (cmd 0x305). Returns `count` consecutive R
1273
+ variables from `start` as IEEE-754 float32 (4-byte element, max 118).
1274
+ """
1275
+ return self._read_plural_scalar(_CMD_R_PLURAL, start, count, "f", 4)
1276
+
1277
+ def write_r_vars(self, start: int, values: "list[float]") -> None:
1278
+ """
1279
+ Plural Real Variable write (cmd 0x305). Writes `values` as IEEE-754
1280
+ float32 to consecutive R variables from `start`.
1281
+ """
1282
+ self._write_plural(_CMD_R_PLURAL, start, len(values),
1283
+ struct.pack(f"<{len(values)}f", *values))
1284
+
1285
+ def read_s_vars(self, start: int, count: int) -> "list[str]":
1286
+ """
1287
+ Plural Character Variable read. 32-byte cmd 0x30C on DX200/YRC1000
1288
+ (max 14), 16-byte cmd 0x306 on DX100 (max 29). Returns `count` consecutive
1289
+ S variables from `start` as strings (trailing NULs stripped, latin-1).
1290
+ """
1291
+ w = self._s_width
1292
+ buf = self._read_plural(self._s_plural_cmd, start, count)
1293
+ n = len(buf) // w
1294
+ return [buf[i * w:i * w + w].split(b"\x00", 1)[0].decode("latin-1")
1295
+ for i in range(n)]
1296
+
1297
+ def write_s_vars(self, start: int, values: "list[str]") -> None:
1298
+ """
1299
+ Plural Character Variable write. 32-byte cmd 0x30C on DX200/YRC1000,
1300
+ 16-byte cmd 0x306 on DX100. Writes `values` to consecutive S variables
1301
+ from `start`; each is latin-1 encoded, truncated and NUL-padded to width.
1302
+ """
1303
+ w = self._s_width
1304
+ data = b"".join(
1305
+ v.encode("latin-1", errors="replace")[:w].ljust(w, b"\x00")
1306
+ for v in values
1307
+ )
1308
+ self._write_plural(self._s_plural_cmd, start, len(values), data)
1309
+
1310
+ def read_position_vars(self, start: int, count: int) -> "list[PositionVar]":
1311
+ """
1312
+ Plural Robot Position Variable read (cmd 0x307). Returns `count`
1313
+ consecutive P variables from `start` as PositionVar records (52-byte
1314
+ element, max 9).
1315
+ """
1316
+ buf = self._read_plural(_CMD_P_PLURAL, start, count)
1317
+ n = len(buf) // 52
1318
+ return [PositionVar.from_bytes(buf[i * 52:i * 52 + 52]) for i in range(n)]
1319
+
1320
+ def write_position_vars(self, start: int, values: "list[PositionVar]") -> None:
1321
+ """
1322
+ Plural Robot Position Variable write (cmd 0x307). Writes `values` to
1323
+ consecutive P variables from `start`.
1324
+ """
1325
+ self._write_plural(_CMD_P_PLURAL, start, len(values),
1326
+ b"".join(p.to_bytes() for p in values))
1327
+
1328
+ def _read_axispos_plural(self, cmd: int, start: int, count: int) -> "list[AxisPositionVar]":
1329
+ """Plural read of 36-byte AxisPositionVar records. Shared by BP and EX."""
1330
+ buf = self._read_plural(cmd, start, count)
1331
+ n = len(buf) // 36
1332
+ return [AxisPositionVar.from_bytes(buf[i * 36:i * 36 + 36])
1333
+ for i in range(n)]
1334
+
1335
+ def read_bp_vars(self, start: int, count: int) -> "list[AxisPositionVar]":
1336
+ """
1337
+ Plural Base Position Variable read (cmd 0x308). Returns `count`
1338
+ consecutive BP variables from `start` as AxisPositionVar records
1339
+ (36-byte element, max 13).
1340
+ """
1341
+ return self._read_axispos_plural(_CMD_BP_PLURAL, start, count)
1342
+
1343
+ def write_bp_vars(self, start: int, values: "list[AxisPositionVar]") -> None:
1344
+ """
1345
+ Plural Base Position Variable write (cmd 0x308). Writes `values` to
1346
+ consecutive BP variables from `start`.
1347
+ """
1348
+ self._write_plural(_CMD_BP_PLURAL, start, len(values),
1349
+ b"".join(p.to_bytes() for p in values))
1350
+
1351
+ def read_ex_vars(self, start: int, count: int) -> "list[AxisPositionVar]":
1352
+ """
1353
+ Plural Station/External Axis Variable read (cmd 0x309). Returns `count`
1354
+ consecutive EX variables from `start` as AxisPositionVar records
1355
+ (36-byte element, max 13; pulse-only).
1356
+ """
1357
+ return self._read_axispos_plural(_CMD_EX_PLURAL, start, count)
1358
+
1359
+ def write_ex_vars(self, start: int, values: "list[AxisPositionVar]") -> None:
1360
+ """
1361
+ Plural Station/External Axis Variable write (cmd 0x309). Writes `values`
1362
+ to consecutive EX variables from `start` (pulse-only).
1363
+ """
1364
+ self._write_plural(_CMD_EX_PLURAL, start, len(values),
1365
+ b"".join(p.to_bytes() for p in values))
1366
+
1367
+ # ── system / management info ──────────────────────────────────────────────────
1368
+
1369
+ def read_management_time(self, kind: int = 1) -> dict:
1370
+ """
1371
+ Management Time Acquiring (cmd 0x88).
1372
+ kind (instance): 1=Control-Power-ON, 10=Servo-Power-ON total, 11-18=Servo
1373
+ R1-R8, 110=Playback total, 210=Moving total, 301-308=Operation app 1-8.
1374
+ Returns dict: start_time (e.g. "2011/10/10 15:49"), elapse_time
1375
+ (e.g. "000000:00'00").
1376
+ """
1377
+ r = self._tx(_CMD_MGMT_TIME, instance=kind, attribute=0, service=_SVC_GET_ALL)
1378
+ return _decode_management_time(r.data)
1379
+
1380
+ def read_system_info(self, kind: int = 11) -> dict:
1381
+ """
1382
+ System Information Acquiring (cmd 0x89).
1383
+ kind (instance): 11-18=Type R1-R8, 21-44=Type S1-S24, 101-108=Application.
1384
+ Returns dict: sw_version (e.g. "YAS1.00.00A. (JP/US) -00"), model,
1385
+ parameter_version.
1386
+ """
1387
+ r = self._tx(_CMD_SYS_INFO, instance=kind, attribute=0, service=_SVC_GET_ALL)
1388
+ return _decode_system_info(r.data)
1389
+
1390
+ def read_encoder_temp(self, group: int = 1) -> dict:
1391
+ """
1392
+ Encoder Temperature Reading (cmd 0x0411, controller SW YAS4.81+).
1393
+ group 1-8 = R1-R8. Returns encoder temperature per axis in °C, keyed
1394
+ enc_temp_S/enc_temp_L/... (axis order per 0x74).
1395
+ Raises HsesError if the firmware predates YAS4.81, or RuntimeError on a
1396
+ DX100/DX200 controller (command not implemented).
1397
+ """
1398
+ self._require_yrc1000("Encoder temperature (0x0411)")
1399
+ r = self._tx(_CMD_ENC_TEMP, instance=group, attribute=1, service=_SVC_GET_ALL)
1400
+ return _decode_axis_int32(r.data, "enc_temp", self.axis_names)
1401
+
1402
+ def read_regen_power(self) -> dict:
1403
+ """
1404
+ Regenerative Power Reading (cmd 0x0412, controller SW YAS4.90+).
1405
+ Returns dict: valid_boards (bitmask), status (0=servo-off, 1=consumption,
1406
+ 2=regeneration), unit (0=kWh, 1=0.01 kWh) and boards[] each with
1407
+ today/yesterday/cumulative energy.
1408
+ Raises HsesError if the firmware predates YAS4.90, or RuntimeError on a
1409
+ DX100/DX200 controller (command not implemented).
1410
+ """
1411
+ self._require_yrc1000("Regenerative power (0x0412)")
1412
+ r = self._tx(_CMD_REGEN_PWR, instance=1, attribute=1, service=_SVC_GET_ALL)
1413
+ return _decode_regen_power(r.data)
1414
+
1415
+ def read_converter_temp(self, board: int = 1) -> int:
1416
+ """
1417
+ Converter Temperature Reading (cmd 0x0413, controller SW YAS5.20+).
1418
+ board 1-8 = servo board number. Returns converter temperature in °C.
1419
+ Raises HsesError if the firmware predates YAS5.20, or RuntimeError on a
1420
+ DX100/DX200 controller (command not implemented).
1421
+ """
1422
+ self._require_yrc1000("Converter temperature (0x0413)")
1423
+ r = self._tx(_CMD_CONV_TEMP, instance=board, attribute=1, service=_SVC_GET_ALL)
1424
+ if len(r.data) < 4:
1425
+ return 0
1426
+ return struct.unpack_from("<i", r.data, 0)[0]
1427
+
1428
+
1429
+ # ── response decoders ─────────────────────────────────────────────────────────
1430
+
1431
+ def _decode_status(data: bytes, model: str = MODEL_YRC1000) -> dict:
1432
+ if len(data) < 8:
1433
+ return {}
1434
+ d1 = struct.unpack_from("<I", data, 0)[0]
1435
+ d2 = struct.unpack_from("<I", data, 4)[0]
1436
+ # Data1 bit4 differs by generation: DX100 = "in-guard safe operation",
1437
+ # DX200/YRC1000 = "speed limit". Emit the model-appropriate key.
1438
+ bit4_key = "in_guard_safe" if model == MODEL_DX100 else "speed_limit"
1439
+ return {
1440
+ "step": bool(d1 & 0x01),
1441
+ "one_cycle": bool(d1 & 0x02),
1442
+ "auto": bool(d1 & 0x04),
1443
+ "running": bool(d1 & 0x08),
1444
+ bit4_key: bool(d1 & 0x10),
1445
+ "teach": bool(d1 & 0x20),
1446
+ "play": bool(d1 & 0x40),
1447
+ "cmd_remote": bool(d1 & 0x80),
1448
+ "hold_pendant": bool(d2 & 0x02),
1449
+ "hold_external": bool(d2 & 0x04),
1450
+ "hold_cmd": bool(d2 & 0x08),
1451
+ "alarm": bool(d2 & 0x10),
1452
+ "error": bool(d2 & 0x20),
1453
+ "servo_on": bool(d2 & 0x40),
1454
+ }
1455
+
1456
+
1457
+ def _decode_alarm(data: bytes) -> dict:
1458
+ """Decode alarm data (up to 60 bytes = 15 × 32-bit LE ints, Section 3.3.3.1)."""
1459
+ out: dict = {
1460
+ "alarm_code": 0, "alarm_data": 0, "alarm_type": 0,
1461
+ "alarm_time": "", "alarm_name": "",
1462
+ }
1463
+ if len(data) < 12:
1464
+ return out
1465
+ out["alarm_code"] = struct.unpack_from("<I", data, 0)[0]
1466
+ out["alarm_data"] = struct.unpack_from("<I", data, 4)[0]
1467
+ out["alarm_type"] = struct.unpack_from("<I", data, 8)[0]
1468
+ if len(data) >= 28:
1469
+ # integers 4-7 = alarm time, 16 chars
1470
+ out["alarm_time"] = data[12:28].rstrip(b"\x00").decode("latin-1", errors="replace")
1471
+ if len(data) >= 60:
1472
+ # integers 8-15 = alarm name, 32 chars
1473
+ out["alarm_name"] = data[28:60].rstrip(b"\x00").decode("latin-1", errors="replace")
1474
+ return out
1475
+
1476
+
1477
+ def _decode_alarm_sub(data: bytes) -> dict:
1478
+ """
1479
+ Decode extended alarm data with sub-code strings (0x30A/0x30B, up to 268
1480
+ bytes, Section 3.3.3.37). First 60 bytes are identical to _decode_alarm();
1481
+ the three trailing char fields carry the diagnostic sub-code strings:
1482
+ [60] char[16] sub-code extra info (e.g. "[SV#1]")
1483
+ [76] char[96] sub-code data string
1484
+ [172] char[96] reverse-display info ("0"=normal, "1"=inverted)
1485
+ """
1486
+ out = _decode_alarm(data)
1487
+ out.update({"sub_code_info": "", "sub_code_str": "", "reverse_display": ""})
1488
+ if len(data) >= 76:
1489
+ out["sub_code_info"] = _cstr(data[60:76])
1490
+ if len(data) >= 172:
1491
+ out["sub_code_str"] = _cstr(data[76:172])
1492
+ if len(data) >= 268:
1493
+ out["reverse_display"] = _cstr(data[172:268])
1494
+ return out
1495
+
1496
+
1497
+ def _decode_job(data: bytes) -> dict:
1498
+ """Decode executing job data (44 bytes = 11 × 32-bit LE ints, Section 3.3.3.4)."""
1499
+ out: dict = {"job_name": "", "job_line": 0, "job_step": 0, "job_speed": 0}
1500
+ if len(data) < 32:
1501
+ return out
1502
+ out["job_name"] = data[0:32].rstrip(b"\x00").decode("latin-1", errors="replace")
1503
+ if len(data) >= 44:
1504
+ line, step, speed = struct.unpack_from("<3I", data, 32)
1505
+ out["job_line"] = line
1506
+ out["job_step"] = step
1507
+ out["job_speed"] = speed
1508
+ return out
1509
+
1510
+
1511
+ def _cstr(raw: bytes) -> str:
1512
+ """Decode a fixed-width, null/space-padded ASCII field to a stripped str."""
1513
+ return raw.decode("latin-1", errors="replace").split("\x00", 1)[0].strip()
1514
+
1515
+
1516
+ def _decode_regen_power(data: bytes) -> dict:
1517
+ """
1518
+ Decode regenerative power (0x0412, 104 bytes = 26 × int32): word0 valid-board
1519
+ bitmask, word1 byte-packed status/unit, then per board (1-8) three int32s
1520
+ today / yesterday / cumulative.
1521
+ """
1522
+ out: dict = {"valid_boards": 0, "status": 0, "unit": 0, "boards": []}
1523
+ if len(data) < 8:
1524
+ return out
1525
+ out["valid_boards"] = struct.unpack_from("<I", data, 0)[0]
1526
+ out["status"] = data[4] # 0=servo-off, 1=consumption, 2=regeneration
1527
+ out["unit"] = data[5] # 0=kWh, 1=0.01 kWh
1528
+ for b in range(8):
1529
+ off = 8 + b * 12
1530
+ if off + 12 > len(data):
1531
+ break
1532
+ today, yesterday, cumulative = struct.unpack_from("<3i", data, off)
1533
+ out["boards"].append({
1534
+ "board": b + 1,
1535
+ "valid": bool(out["valid_boards"] & (1 << b)),
1536
+ "today": today,
1537
+ "yesterday": yesterday,
1538
+ "cumulative": cumulative,
1539
+ })
1540
+ return out
1541
+
1542
+
1543
+ def _decode_management_time(data: bytes) -> dict:
1544
+ """Decode management time (0x88): char[16] start + char[12] elapse."""
1545
+ out = {"start_time": "", "elapse_time": ""}
1546
+ if len(data) >= 16:
1547
+ out["start_time"] = _cstr(data[0:16])
1548
+ if len(data) >= 28:
1549
+ out["elapse_time"] = _cstr(data[16:28])
1550
+ return out
1551
+
1552
+
1553
+ def _decode_system_info(data: bytes) -> dict:
1554
+ """Decode system info (0x89): char[24] sw + char[16] model + char[8] param."""
1555
+ out = {"sw_version": "", "model": "", "parameter_version": ""}
1556
+ if len(data) >= 24:
1557
+ out["sw_version"] = _cstr(data[0:24])
1558
+ if len(data) >= 40:
1559
+ out["model"] = _cstr(data[24:40])
1560
+ if len(data) >= 48:
1561
+ out["parameter_version"] = _cstr(data[40:48])
1562
+ return out
1563
+
1564
+
1565
+ def _decode_axis_config(data: bytes) -> dict:
1566
+ """
1567
+ Decode axis configuration (32 bytes = 8 × 4-byte words, 0x74). Each word
1568
+ holds an axis name as ASCII characters (LE, null-padded); 0 = axis absent.
1569
+ """
1570
+ out: dict = {"axes": []}
1571
+ n = min(len(data) // 4, 8)
1572
+ for i in range(n):
1573
+ name = data[i * 4:i * 4 + 4].rstrip(b"\x00").decode("latin-1", errors="replace")
1574
+ out[f"axis_{i + 1}"] = name
1575
+ if name:
1576
+ out["axes"].append(name)
1577
+ return out
1578
+
1579
+
1580
+ _AXIS_NAMES = ("S", "L", "U", "R", "B", "T", "E", "8")
1581
+
1582
+
1583
+ def _decode_axis_int32(data: bytes, prefix: str, names: tuple = _AXIS_NAMES) -> dict:
1584
+ """
1585
+ Decode 8 × 32-bit LE signed ints as per-axis values (0x76 position error,
1586
+ 0x77 torque, 0x0411 encoder temp). Keys are f"{prefix}_{axis}" in the axis
1587
+ order defined by 0x74 — pass the client's `axis_names` to label the 4th axis
1588
+ correctly (R on a wrist robot, T on a palletiser); defaults to S,L,U,R,B,T,E,8.
1589
+ """
1590
+ out: dict = {}
1591
+ n = min(len(data) // 4, len(names))
1592
+ if n == 0:
1593
+ return out
1594
+ vals = struct.unpack_from(f"<{n}i", data)
1595
+ for name, v in zip(names, vals):
1596
+ out[f"{prefix}_{name}"] = v
1597
+ return out
1598
+
1599
+
1600
+ def _decode_position(data: bytes, names: Optional[tuple] = None) -> dict:
1601
+ """Decode position data (52 bytes = 13 × 32-bit LE signed ints, Section 3.3.3.6).
1602
+
1603
+ For a pulse record the joint values are labelled with the axis order from 0x74
1604
+ (pass the client's `axis_names`); without it the S,L,U,R,B,T default applies."""
1605
+ out: dict = {}
1606
+ n = len(data) // 4
1607
+ if n < 6:
1608
+ return out
1609
+ vals = list(struct.unpack_from(f"<{n}i", data))
1610
+ data_type = vals[0] # 0=pulse, 16=cartesian (base coordinate)
1611
+ axes = vals[5:]
1612
+ if data_type == 16:
1613
+ # X/Y/Z in µm → mm, Rx/Ry/Rz in 0.0001° → °
1614
+ for i, name in enumerate(["X", "Y", "Z", "Rx", "Ry", "Rz"]):
1615
+ if i < len(axes):
1616
+ out[f"pos_{name}"] = (
1617
+ round(axes[i] / 1000.0, 3) if i < 3 else
1618
+ round(axes[i] / 10000.0, 4)
1619
+ )
1620
+ else:
1621
+ pulse_names = names or ("S", "L", "U", "R", "B", "T")
1622
+ for i, name in enumerate(pulse_names):
1623
+ if i < len(axes):
1624
+ out[f"pos_{name}"] = axes[i]
1625
+ return out
1626
+
1627
+
1628
+ # ── quick self-test ───────────────────────────────────────────────────────────
1629
+
1630
+ if __name__ == "__main__":
1631
+ import sys
1632
+
1633
+ host = sys.argv[1] if len(sys.argv) > 1 else "192.0.2.10"
1634
+ print(f"Testing HSES at {host}:{HSES_PORT}")
1635
+
1636
+ with HsesClient(host, timeout=3.0) as c:
1637
+ try:
1638
+ s = c.read_status()
1639
+ print(f"STATUS: {s}")
1640
+ except Exception as e:
1641
+ print(f"STATUS: ERROR — {e}")
1642
+
1643
+ try:
1644
+ a = c.read_alarm(1)
1645
+ print(f"ALARM 1: {a}")
1646
+ except Exception as e:
1647
+ print(f"ALARM 1: ERROR — {e}")
1648
+
1649
+ try:
1650
+ j = c.read_job(1)
1651
+ print(f"JOB: {j}")
1652
+ except Exception as e:
1653
+ print(f"JOB: ERROR — {e}")
1654
+
1655
+ try:
1656
+ p = c.read_position(1)
1657
+ print(f"POS: {p}")
1658
+ except Exception as e:
1659
+ print(f"POS: ERROR — {e}")