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.
@@ -0,0 +1,615 @@
1
+ """
2
+ Yaskawa Ethernet Server client — standalone, zero external dependencies.
3
+
4
+ Supports NX100 and YRC1000 controllers via the HOSTCTRL_REQUEST protocol
5
+ (Ethernet Server Function, TCP port 80).
6
+
7
+ Protocol references:
8
+ NX100: HW0483202 (NX100 OPTIONS INSTRUCTIONS FOR ETHERNET SERVER FUNCTION)
9
+ YRC1000: HW1483358 Chapter 8 (Ethernet Server Function — backward compatible)
10
+
11
+ Typical usage
12
+ ─────────────
13
+ # Single transaction (auto connect/close):
14
+ client = YaskawaClient("192.168.1.24")
15
+ status = client.read_rstats()
16
+
17
+ # Persistent connection (context manager):
18
+ with YaskawaClient("192.168.1.24", timeout=2) as client:
19
+ client.send_start(keep_alive=3)
20
+ v = client.read_variable(VarType.BYTE, 10, persistent=True)
21
+ client.write_variable(VarType.BYTE, 10, 5, persistent=True)
22
+
23
+ # Block write (one TCP session, N commands):
24
+ with YaskawaClient(ip) as client:
25
+ ok = client.write_variable_block([
26
+ (VarType.BYTE, 10, 5),
27
+ (VarType.DOUBLE, 1, 1000),
28
+ ])
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import socket
34
+ from typing import Any, Literal, overload
35
+
36
+ from enum import IntEnum
37
+
38
+
39
+ class VarType(IntEnum):
40
+ """Yaskawa variable types (SAVEV / LOADV Data-1 parameter).
41
+
42
+ Single source of truth for yrcom.
43
+ NX100 manual §3.2 / YRC1000 manual Ch.8 §8.3.2.
44
+ """
45
+
46
+ BYTE = 0 # B-variable (8-bit)
47
+ INTEGER = 1 # I-variable (signed 16-bit)
48
+ DOUBLE = 2 # D-variable (double-precision INTEGER, signed 32-bit — not a float!)
49
+ FLOAT = 3 # R-variable (real / IEEE-754 float)
50
+ ROBOT_POS = 4 # P-variable (robot-axis position)
51
+ BASE_POS = 5 # BP-variable (base-axis position)
52
+ STATION_POS = 6 # EX-variable / station-axis position (pulse type only)
53
+ STRING = 7 # S-variable (character string)
54
+
55
+ # ── Public constants ──────────────────────────────────────────────────────────
56
+
57
+ DEFAULT_PORT: int = 80
58
+ DEFAULT_TIMEOUT: float = 5.0
59
+ _RECV_SIZE: int = 4096
60
+
61
+
62
+ # ── Type aliases ──────────────────────────────────────────────────────────────
63
+
64
+ ParsedData = list[int] | list[str] | dict[str, Any]
65
+
66
+
67
+ # ── Client ────────────────────────────────────────────────────────────────────
68
+
69
+ class YaskawaClient:
70
+ """
71
+ Yaskawa Ethernet Server protocol client (NX100 / YRC1000).
72
+
73
+ Protocol flow
74
+ ─────────────
75
+ 1. TCP connect to robot_ip:80
76
+ 2. Send: CONNECT Robot_access\\r\\n
77
+ (or CONNECT Robot_access Keep-Alive:N\\r\\n for N commands)
78
+ 3. Recv: OK: NX Information Server ...\\r\\n
79
+ 4. Send: HOSTCTRL_REQUEST {CMD} {len(data)}\\r\\n
80
+ 5. Recv: OK: {CMD}\\r\\n
81
+ 6. Send: {data}\\r (if len(data) > 0)
82
+ 7. Recv: {answer}\\r (or {answer}\\r\\n for "0000" acks)
83
+ 8. Repeat steps 4-7 if Keep-Alive; socket auto-closes after last command.
84
+
85
+ Notes
86
+ ─────
87
+ - Data size in the HOSTCTRL_REQUEST header is the byte length of the data
88
+ string *including* the trailing \\r.
89
+ - IO data is always in multiples of 8 contact points (one byte per 8 bits).
90
+ - IOWRITE can only target network inputs #22010–#23287 on NX100.
91
+ - START: pass job name without the .JBI extension.
92
+ - SETMJ/GETMJ require Remote/Play mode on the controller.
93
+ - write + read-back: always use separate send_start() sessions to avoid
94
+ double send_start() on the same connection returning empty responses.
95
+ """
96
+
97
+ def __init__(
98
+ self,
99
+ host: str,
100
+ port: int = DEFAULT_PORT,
101
+ timeout: float = DEFAULT_TIMEOUT,
102
+ ) -> None:
103
+ self.host = host
104
+ self.port = port
105
+ self.timeout = timeout
106
+ self.sock: socket.socket | None = None
107
+
108
+ # ── Context manager ───────────────────────────────────────────────────────
109
+
110
+ def __enter__(self) -> "YaskawaClient":
111
+ self.connect()
112
+ return self
113
+
114
+ def __exit__(self, *_: object) -> None:
115
+ self.close()
116
+
117
+ # ── Connection ────────────────────────────────────────────────────────────
118
+
119
+ def connect(self) -> None:
120
+ """Open TCP socket to the controller. No-op if already connected."""
121
+ if self.sock is not None:
122
+ return
123
+ try:
124
+ self.sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
125
+ except Exception:
126
+ self.sock = None
127
+ raise
128
+
129
+ def close(self) -> None:
130
+ """Close TCP socket."""
131
+ if self.sock:
132
+ self.sock.close()
133
+ self.sock = None
134
+
135
+ # ── Protocol primitives ───────────────────────────────────────────────────
136
+
137
+ def send_start(self, keep_alive: int | None = None) -> str:
138
+ """
139
+ Send CONNECT Robot_access handshake.
140
+
141
+ keep_alive=None → single-command session (socket closes after 1 command)
142
+ keep_alive=N → multi-command session (N commands before close)
143
+ keep_alive=-1 → infinite keep-alive (close manually or via context mgr)
144
+ """
145
+ if self.sock is None:
146
+ raise RuntimeError("Socket not connected — call connect() first")
147
+ if keep_alive is None:
148
+ line = "CONNECT Robot_access\r\n"
149
+ else:
150
+ line = f"CONNECT Robot_access Keep-Alive:{keep_alive}\r\n"
151
+ self.sock.sendall(line.encode("ascii"))
152
+ return self._recv_until_ok()
153
+
154
+ def send_command(self, command: str, size: int = 0, data: str = "") -> str:
155
+ """
156
+ Send HOSTCTRL_REQUEST header and optional data, return raw answer string.
157
+ Expects send_start() to have been called on this session already.
158
+ """
159
+ if self.sock is None:
160
+ raise RuntimeError("Socket not connected")
161
+ header = f"HOSTCTRL_REQUEST {command} {size}\r\n"
162
+ self.sock.sendall(header.encode("ascii"))
163
+ self._recv_until_ok()
164
+ if size > 0 and data:
165
+ payload = data if data.endswith("\r") else data + "\r"
166
+ self.sock.sendall(payload.encode("ascii"))
167
+ return self._recv_answer()
168
+
169
+ def _recv_until_ok(self) -> str:
170
+ """Receive until OK or NG appears in the buffer."""
171
+ if self.sock is None:
172
+ raise RuntimeError("Socket not connected")
173
+ buf = ""
174
+ while True:
175
+ chunk = self.sock.recv(_RECV_SIZE)
176
+ if not chunk:
177
+ break
178
+ buf += chunk.decode("ascii")
179
+ if "OK" in buf or "NG" in buf:
180
+ break
181
+ return buf
182
+
183
+ def _recv_answer(self) -> str:
184
+ """Receive the answer data (ends with \\r or \\r\\n)."""
185
+ if self.sock is None:
186
+ raise RuntimeError("Socket not connected")
187
+ buf = ""
188
+ while True:
189
+ chunk = self.sock.recv(_RECV_SIZE)
190
+ if not chunk:
191
+ break
192
+ buf += chunk.decode("ascii")
193
+ if "\r\n" in buf or "\r" in buf:
194
+ break
195
+ return buf
196
+
197
+ # ── Transaction patterns ──────────────────────────────────────────────────
198
+
199
+ @overload
200
+ def transaction(self, command: str, size: int = ..., data: str = ...,
201
+ keep_alive: int | None = ..., parse: Literal[True] = ...) -> ParsedData: ...
202
+ @overload
203
+ def transaction(self, command: str, size: int = ..., data: str = ...,
204
+ keep_alive: int | None = ..., parse: Literal[False] = ...) -> str: ...
205
+
206
+ def transaction(
207
+ self,
208
+ command: str,
209
+ size: int = 0,
210
+ data: str = "",
211
+ keep_alive: int | None = None,
212
+ parse: bool = True,
213
+ ) -> ParsedData | str:
214
+ """
215
+ Self-contained transaction: auto-connect if needed, auto-close if we opened.
216
+ When called inside an open with-block, reuses the existing socket.
217
+ """
218
+ was_connected = self.sock is not None
219
+ self.connect()
220
+ try:
221
+ self.send_start(keep_alive)
222
+ raw = self.send_command(command, size, data)
223
+ if parse:
224
+ part = raw.split("OK")[-1] if "OK" in raw else raw
225
+ return self._parse(command, part.strip())
226
+ return raw
227
+ finally:
228
+ if not was_connected:
229
+ self.close()
230
+
231
+ @overload
232
+ def send_transaction(self, command: str, size: int = ..., data: str = ...,
233
+ parse: Literal[True] = ...) -> ParsedData: ...
234
+ @overload
235
+ def send_transaction(self, command: str, size: int = ..., data: str = ...,
236
+ parse: Literal[False] = ...) -> str: ...
237
+
238
+ def send_transaction(
239
+ self,
240
+ command: str,
241
+ size: int = 0,
242
+ data: str = "",
243
+ parse: bool = True,
244
+ ) -> ParsedData | str:
245
+ """
246
+ Execute one command on an *already-open* session (after send_start()).
247
+ Use this inside a with-block for multi-command sessions.
248
+ """
249
+ raw = self.send_command(command, size, data)
250
+ if parse:
251
+ part = raw.split("OK")[-1] if "OK" in raw else raw
252
+ return self._parse(command, part.strip())
253
+ return raw
254
+
255
+ def _exec(self, persistent: bool, command: str, size: int = 0,
256
+ data: str = "", parse: bool = True) -> ParsedData | str:
257
+ """Route one command to the persistent (send_transaction) or one-shot
258
+ (transaction) path — both share the command/size/data/parse signature."""
259
+ if persistent:
260
+ return self.send_transaction(command, size=size, data=data, parse=parse)
261
+ return self.transaction(command, size=size, data=data, parse=parse)
262
+
263
+ # ── Parsing ───────────────────────────────────────────────────────────────
264
+
265
+ @staticmethod
266
+ def _clean(text: str) -> str:
267
+ return text.replace("\r\n", "").replace("\r", "").strip()
268
+
269
+ def _parse(self, command: str, answer: str) -> ParsedData:
270
+ answer = self._clean(answer)
271
+ parts = [p for p in answer.split(",") if p]
272
+
273
+ if command == "RSTATS":
274
+ return [int(p) for p in parts]
275
+ if command == "RPOSJ":
276
+ return [int(p) for p in parts]
277
+ if command == "RPOSC":
278
+ return {
279
+ "X": float(parts[0]), "Y": float(parts[1]), "Z": float(parts[2]),
280
+ "Rx": float(parts[3]), "Ry": float(parts[4]), "Rz": float(parts[5]),
281
+ "Type": int(parts[6]), "ToolNo": int(parts[7]),
282
+ }
283
+ if command == "RJSEQ":
284
+ return {"JobName": parts[0], "Line": int(parts[1]), "Step": int(parts[2])}
285
+ if command == "RALARM":
286
+ return [int(p) for p in parts]
287
+ if command == "IOREAD":
288
+ return [int(p) for p in parts]
289
+ if command == "RJDIR":
290
+ return parts
291
+ return parts
292
+
293
+ # ── Variable payload helpers ──────────────────────────────────────────────
294
+
295
+ @staticmethod
296
+ def _var_payload(var_type: VarType, var_no: int, value: Any = None) -> str:
297
+ vt = int(var_type)
298
+ if value is None:
299
+ return f"{vt},{var_no}\r"
300
+ if var_type in (VarType.BYTE, VarType.INTEGER, VarType.DOUBLE, VarType.FLOAT, VarType.STRING):
301
+ return f"{vt},{var_no},{value}\r"
302
+ if var_type in (VarType.ROBOT_POS, VarType.BASE_POS, VarType.STATION_POS):
303
+ if isinstance(value, (list, tuple)):
304
+ return f"{vt},{var_no}," + ",".join(str(v) for v in value) + "\r"
305
+ raise ValueError("Position variable types require a list/tuple value")
306
+ raise ValueError(f"Unknown VarType: {var_type}")
307
+
308
+ @staticmethod
309
+ def _parse_var(var_type: VarType, raw: str) -> Any:
310
+ if var_type in (VarType.BYTE, VarType.INTEGER, VarType.DOUBLE):
311
+ try:
312
+ return int(raw)
313
+ except (ValueError, TypeError):
314
+ return None
315
+ if var_type == VarType.FLOAT:
316
+ try:
317
+ return float(raw)
318
+ except (ValueError, TypeError):
319
+ return None
320
+ if var_type in (VarType.ROBOT_POS, VarType.BASE_POS, VarType.STATION_POS):
321
+ return [float(p) if "." in p else int(p) for p in raw.split(",") if p]
322
+ return raw # STRING
323
+
324
+ # ── Variable read / write ─────────────────────────────────────────────────
325
+
326
+ def read_variable(self, var_type: VarType, var_no: int, persistent: bool = False) -> Any:
327
+ """Read a single variable (SAVEV). persistent=True: call inside existing session."""
328
+ data = self._var_payload(var_type, var_no)
329
+ raw = self._exec(persistent, "SAVEV", size=len(data), data=data, parse=False)
330
+ return self._parse_var(var_type, self._clean(raw))
331
+
332
+ def write_variable(self, var_type: VarType, var_no: int, value: Any, persistent: bool = False) -> bool:
333
+ """Write a single variable (LOADV). persistent=True: call inside existing session."""
334
+ data = self._var_payload(var_type, var_no, value)
335
+ raw = self._exec(persistent, "LOADV", size=len(data), data=data, parse=False)
336
+ return self._clean(raw) == "0000"
337
+
338
+ def write_variable_block(self, var_tuples: list[tuple]) -> bool:
339
+ """
340
+ Write N variables in one TCP session (one send_start, N LOADV commands).
341
+ var_tuples: [(VarType, var_no, value), ...]
342
+ Call inside a with-block (socket must already be open).
343
+ """
344
+ self.send_start(keep_alive=len(var_tuples))
345
+ for var_type, var_no, value in var_tuples:
346
+ data = self._var_payload(var_type, var_no, value)
347
+ raw = self.send_command("LOADV", size=len(data), data=data)
348
+ if self._clean(raw) != "0000":
349
+ return False
350
+ return True
351
+
352
+ def read_variable_block(self, var_tuples: list[tuple]) -> dict:
353
+ """
354
+ Read N variables in one TCP session.
355
+ var_tuples: [(VarType, var_no), ...]
356
+ Call inside a with-block (socket must already be open).
357
+ Returns: {(VarType, var_no): value}
358
+ """
359
+ results: dict = {}
360
+ self.send_start(keep_alive=len(var_tuples))
361
+ for var_type, var_no in var_tuples:
362
+ data = self._var_payload(var_type, var_no)
363
+ raw = self.send_command("SAVEV", size=len(data), data=data)
364
+ results[(var_type, var_no)] = self._parse_var(var_type, self._clean(raw))
365
+ return results
366
+
367
+ # ── IO ────────────────────────────────────────────────────────────────────
368
+
369
+ def io_read(self, start_no: int, num_points: int, persistent: bool = False) -> list[int]:
370
+ """
371
+ Read IO bits (IOREAD). Returns one integer per 8-bit group.
372
+
373
+ start_no: Yaskawa robot IO address (e.g. 10010 for IN#1, 50010 for OUT#1).
374
+ num_points: Number of bits to read; must be a multiple of 8.
375
+ """
376
+ if num_points % 8:
377
+ raise ValueError("num_points must be a multiple of 8")
378
+ data = f"{start_no},{num_points}\r"
379
+ raw = self._exec(persistent, "IOREAD", size=len(data), data=data, parse=False)
380
+ return [int(v) for v in self._clean(raw).split(",") if v]
381
+
382
+ def io_write(self, start_no: int, values: list[int], persistent: bool = False) -> bool:
383
+ """
384
+ Write IO bits (IOWRITE). values: one byte value per 8 contacts.
385
+ NX100: only network inputs #22010–#23287 can be written.
386
+ """
387
+ num_points = len(values) * 8
388
+ data = f"{start_no},{num_points}," + ",".join(str(int(v)) for v in values) + "\r"
389
+ raw = self._exec(persistent, "IOWRITE", size=len(data), data=data, parse=False)
390
+ return self._clean(raw) == "0000"
391
+
392
+ def read_io_bit(self, address: int, persistent: bool = False) -> bool:
393
+ """
394
+ Read a single IO bit by Yaskawa robot address (e.g. 50023, 80023).
395
+ Uses IOREAD on the 8-bit block containing the address.
396
+ Yaskawa address format: last digit = bit index (0-7), rest = byte base.
397
+ """
398
+ bit = address % 10
399
+ base = address - bit
400
+ values = self.io_read(base, 8, persistent=persistent)
401
+ return bool((values[0] >> bit) & 1)
402
+
403
+ # ── Status ────────────────────────────────────────────────────────────────
404
+
405
+ def read_rstats(self, decode: bool = True, persistent: bool = False) -> dict[str, bool] | list[int]:
406
+ """
407
+ Read robot status (RSTATS).
408
+
409
+ decode=True → dict with named booleans (recommended)
410
+ decode=False → raw [data1, data2] integers
411
+
412
+ data1 bits D0=step D1=1cycle D2=auto D3=running D4=speed_limit
413
+ D5=teach D6=play D7=cmd_remote
414
+ data2 bits D1=hold_pendant D2=hold_external D3=hold_cmd
415
+ D4=alarm D5=error D6=servo_on
416
+ """
417
+ raw = self._exec(persistent, "RSTATS")
418
+ if not decode:
419
+ return raw # type: ignore[return-value]
420
+ if isinstance(raw, str):
421
+ raw = [int(p) for p in self._clean(raw).split(",") if p]
422
+ return self._decode_rstats(raw) # type: ignore[arg-type]
423
+
424
+ @staticmethod
425
+ def _decode_rstats(data: list[int]) -> dict[str, bool]:
426
+ d1, d2 = data[0], data[1]
427
+ return {
428
+ "step": bool(d1 & (1 << 0)),
429
+ "one_cycle": bool(d1 & (1 << 1)),
430
+ "auto": bool(d1 & (1 << 2)),
431
+ "running": bool(d1 & (1 << 3)),
432
+ "speed_limit": bool(d1 & (1 << 4)),
433
+ "teach": bool(d1 & (1 << 5)),
434
+ "play": bool(d1 & (1 << 6)),
435
+ "cmd_remote": bool(d1 & (1 << 7)),
436
+ "hold_pendant": bool(d2 & (1 << 1)),
437
+ "hold_external": bool(d2 & (1 << 2)),
438
+ "hold_cmd": bool(d2 & (1 << 3)),
439
+ "alarm": bool(d2 & (1 << 4)),
440
+ "error": bool(d2 & (1 << 5)),
441
+ "servo_on": bool(d2 & (1 << 6)),
442
+ }
443
+
444
+ def read_alarm(self, persistent: bool = False) -> list[int]:
445
+ """
446
+ Read error/alarm codes (RALARM).
447
+ Returns [error_code, error_data, alarm1_code, alarm1_data, ...alarm4_code, alarm4_data].
448
+ """
449
+ raw = self._exec(persistent, "RALARM")
450
+ return raw if isinstance(raw, list) else [] # type: ignore[return-value]
451
+
452
+ def read_position_joint(self, persistent: bool = False) -> list[int]:
453
+ """Read current joint position in pulses (RPOSJ). Returns [S, L, U, R, B, T, ...]."""
454
+ raw = self._exec(persistent, "RPOSJ")
455
+ return raw if isinstance(raw, list) else [] # type: ignore[return-value]
456
+
457
+ def read_position_cartesian(
458
+ self,
459
+ coord: int = 0,
460
+ external_axis: bool = False,
461
+ persistent: bool = False,
462
+ ) -> dict:
463
+ """
464
+ Read current Cartesian position (RPOSC).
465
+ coord: 0=base, 1=robot, 2-25=user coordinate 1-24.
466
+ Returns: {X, Y, Z, Rx, Ry, Rz, Type, ToolNo}
467
+ """
468
+ data = f"{coord},{1 if external_axis else 0}\r"
469
+ raw = self._exec(persistent, "RPOSC", size=len(data), data=data)
470
+ return raw if isinstance(raw, dict) else {}
471
+
472
+ def read_job_seq(self, persistent: bool = False) -> dict:
473
+ """Read current job name, line No., step No. (RJSEQ). Returns {JobName, Line, Step}."""
474
+ raw = self._exec(persistent, "RJSEQ")
475
+ return raw if isinstance(raw, dict) else {}
476
+
477
+ def list_jobs(self, parent_job: str = "*", persistent: bool = False) -> list[str]:
478
+ """
479
+ List registered job names (RJDIR).
480
+ parent_job='*' → all jobs; pass a job name to list its child jobs.
481
+ """
482
+ data = f"{parent_job}\r"
483
+ raw = self._exec(persistent, "RJDIR", size=len(data), data=data, parse=False)
484
+ return [n for n in self._clean(raw).split(",") if n]
485
+
486
+ # ── Control commands ──────────────────────────────────────────────────────
487
+
488
+ def servo_on(self, on: bool = True, persistent: bool = False) -> bool:
489
+ """Set servo power (SVON). on=True → ON, on=False → OFF."""
490
+ data = f"{1 if on else 0}\r"
491
+ raw = self._exec(persistent, "SVON", size=len(data), data=data, parse=False)
492
+ return self._clean(raw).startswith("0000")
493
+
494
+ def hold(self, on: bool = True, persistent: bool = False) -> bool:
495
+ """Set hold (HOLD). on=True → hold on, on=False → release hold."""
496
+ data = f"{1 if on else 0}\r"
497
+ raw = self._exec(persistent, "HOLD", size=len(data), data=data, parse=False)
498
+ return self._clean(raw).startswith("0000")
499
+
500
+ def reset(self, persistent: bool = False) -> bool:
501
+ """Reset alarm (RESET). Transmission alarms can only be reset from the programming pendant."""
502
+ raw = self._exec(persistent, "RESET", parse=False)
503
+ return self._clean(raw).startswith("0000")
504
+
505
+ def cancel(self, persistent: bool = False) -> bool:
506
+ """Cancel error (CANCEL)."""
507
+ raw = self._exec(persistent, "CANCEL", parse=False)
508
+ return self._clean(raw).startswith("0000")
509
+
510
+ def set_mode(self, mode: int, persistent: bool = False) -> bool:
511
+ """
512
+ Set operation mode (MODE). Requires external mode switch enabled on controller.
513
+ mode: 1=Teach, 2=Play
514
+ """
515
+ data = f"{mode}\r"
516
+ raw = self._exec(persistent, "MODE", size=len(data), data=data, parse=False)
517
+ return self._clean(raw).startswith("0000")
518
+
519
+ def set_cycle(self, cycle: int, persistent: bool = False) -> bool:
520
+ """
521
+ Set cycle mode (CYCLE).
522
+ cycle: 1=Step, 2=1cycle, 3=Auto
523
+ """
524
+ data = f"{cycle}\r"
525
+ raw = self._exec(persistent, "CYCLE", size=len(data), data=data, parse=False)
526
+ return self._clean(raw).startswith("0000")
527
+
528
+ def hlock(self, on: bool = True, persistent: bool = False) -> bool:
529
+ """
530
+ Set programming-pendant interlock (HLOCK).
531
+ on=True: locks out pendant and I/O except emergency stop.
532
+ """
533
+ data = f"{1 if on else 0}\r"
534
+ raw = self._exec(persistent, "HLOCK", size=len(data), data=data, parse=False)
535
+ return self._clean(raw).startswith("0000")
536
+
537
+ def start_job(self, jobname: str | None = None, persistent: bool = False) -> bool:
538
+ """
539
+ Start a job (START). Servo must be ON before calling with a job name.
540
+ jobname=None → start from current program pointer.
541
+ jobname='FOO' → start FOO from the beginning (no .JBI extension).
542
+ """
543
+ data = f"{jobname}\r" if jobname else "\r"
544
+ raw = self._exec(persistent, "START", size=len(data), data=data, parse=False)
545
+ return self._clean(raw).startswith("0000")
546
+
547
+ def set_master_job(self, jobname: str, persistent: bool = False) -> bool:
548
+ """
549
+ Set master job permanently in the controller (SETMJ).
550
+ Also sets it as the current execution job.
551
+ Requires Remote/Play mode; error 2100 otherwise.
552
+ """
553
+ data = f"{jobname}\r"
554
+ raw = self._exec(persistent, "SETMJ", size=len(data), data=data, parse=False)
555
+ return self._clean(raw) == "0000"
556
+
557
+ def get_master_job(self, persistent: bool = False) -> str | None:
558
+ """Read the currently set master job name (GETMJ). Returns None on error."""
559
+ raw = self._exec(persistent, "GETMJ", parse=False)
560
+ result = self._clean(raw)
561
+ return result if result else None
562
+
563
+ def set_job_seq(self, jobname: str, line: int, persistent: bool = False) -> bool:
564
+ """Set job name and execution line number (JSEQ)."""
565
+ data = f"{jobname},{line}\r"
566
+ raw = self._exec(persistent, "JSEQ", size=len(data), data=data, parse=False)
567
+ return self._clean(raw) == "0000"
568
+
569
+ def display_message(self, message: str, persistent: bool = False) -> bool:
570
+ """
571
+ Display a message on the programming-pendant remote display (MDSP).
572
+ Maximum 30 bytes; truncated silently if longer.
573
+ """
574
+ data = f"{message[:30]}\r"
575
+ raw = self._exec(persistent, "MDSP", size=len(data), data=data, parse=False)
576
+ return self._clean(raw) == "0000"
577
+
578
+ def set_control_group(self, robot_bits: int, station_bits: int, persistent: bool = False) -> bool:
579
+ """
580
+ Change the target control group (CGROUP).
581
+ robot_bits: bitmask of robots (D0=R1, D1=R2, D2=R3, D3=R4).
582
+ station_bits: bitmask of stations (D0=S1 … D11=S12).
583
+ """
584
+ data = f"{robot_bits},{station_bits}\r"
585
+ raw = self._exec(persistent, "CGROUP", size=len(data), data=data, parse=False)
586
+ return self._clean(raw) == "0000"
587
+
588
+ def delete_job(self, jobname: str = "*", persistent: bool = False) -> bool:
589
+ """
590
+ Delete a job (DELETE). jobname='*' deletes ALL registered jobs.
591
+ """
592
+ data = f"{jobname}\r"
593
+ raw = self._exec(persistent, "DELETE", size=len(data), data=data, parse=False)
594
+ return self._clean(raw) == "0000"
595
+
596
+ # ── IO address helpers ────────────────────────────────────────────────────
597
+
598
+ @staticmethod
599
+ def io_addr_to_byte_bit(rob_addr: int) -> tuple[int, int]:
600
+ """
601
+ Split a Yaskawa robot IO address into (byte_base, bit_index).
602
+ Each Yaskawa IO byte spans 10 address units; the last digit is the bit.
603
+
604
+ Examples:
605
+ 50053 → (50050, 3) bit 3 of byte at 50050
606
+ 10015 → (10010, 5) bit 5 of byte at 10010
607
+ 50020 → (50020, 0) bit 0 of byte at 50020
608
+ """
609
+ bit = rob_addr % 10
610
+ return rob_addr - bit, bit
611
+
612
+
613
+ # ── Backwards-compatible alias ────────────────────────────────────────────────
614
+
615
+ EthernetServerClient = YaskawaClient