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,434 @@
1
+ """
2
+ RobotConnection — facade bundling both robot transports behind one IP.
3
+
4
+ - HSES (UDP 10040) : High Speed Ethernet Server — fast polling,
5
+ status, plaintext alarms, IO, variables. The preferred path.
6
+ - Ethernet Server (TCP 80) : classic host-control path (servo, jobs, IO write,
7
+ SETMJ ...), wrapped as a thread-safe, self-reconnecting persistent session.
8
+
9
+ Which path is the *active* one is decided by `transport` ("hses" | "ethernet").
10
+ There is **no** automatic fallback: the chosen path is used. Both clients stay
11
+ reachable at all times (`.hses` / `.tcp`), so the few TCP-only commands still
12
+ work in HSES mode and, conversely, a controller without the Ethernet Server
13
+ option enabled remains usable for telemetry.
14
+
15
+ Deliberately slim: no FTP backup, no file control, no recording/poll loop and
16
+ no per-model axis names — specialised concerns that belong in applications,
17
+ not in the communication core.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import inspect
22
+ import logging
23
+ import threading
24
+ from concurrent.futures import ThreadPoolExecutor
25
+
26
+ from .hses_client import HsesClient, MODEL_YRC1000
27
+ from .yaskawa_client import YaskawaClient
28
+
29
+ log = logging.getLogger(__name__)
30
+
31
+ HSES_PORT = 10040
32
+ TCP_PORT = 80
33
+
34
+ # Accepted values for the transport setting.
35
+ TRANSPORT_HSES = "hses"
36
+ TRANSPORT_ETHERNET = "ethernet"
37
+ VALID_TRANSPORTS = (TRANSPORT_HSES, TRANSPORT_ETHERNET)
38
+
39
+ # VarType value (int) -> HSES client method
40
+ _VARTYPE_HSES_READ = {0: "read_b_var", 1: "read_i_var", 2: "read_d_var",
41
+ 3: "read_r_var", 7: "read_s_var"}
42
+ _VARTYPE_HSES_WRITE = {0: "write_b_var", 1: "write_i_var", 2: "write_d_var",
43
+ 3: "write_r_var", 7: "write_s_var"}
44
+
45
+ from .yaskawa_client import VarType as _VT
46
+ _POSVAR_ETH = {"P": _VT.ROBOT_POS, "BP": _VT.BASE_POS, "EX": _VT.STATION_POS}
47
+
48
+
49
+ class HsesWriteBlockedError(RuntimeError):
50
+ """Switching an IO output in HSES mode — deliberately blocked.
51
+
52
+ `HsesClient.write_io()` (yrcom/hses_client.py) always writes through
53
+ `write_io_plural()`, which requires an even byte count. A single byte is
54
+ padded with a hard `0x00` — so the byte following the target byte is
55
+ zeroed regardless of its actual state. Switching OT#4 (instance 1001)
56
+ would silently clear instance 1002 (OT#9–OT#16) — real actuators in the
57
+ robot cell. Since it is additionally unverified whether the controller
58
+ accepts output writes over this path at all, the write path stays blocked
59
+ instead of compensating the neighbour-byte effect on speculation.
60
+ `hses_client.py` is deliberately left unchanged. The read path
61
+ (`read_io_bytes`) is not affected by this defect and stays untouched.
62
+ """
63
+
64
+
65
+ class PersistentTcpClient:
66
+ """
67
+ Thread-safe, self-reconnecting TCP Ethernet Server session (keep-alive).
68
+
69
+ Forwards every attribute access to the underlying YaskawaClient; on a
70
+ broken socket it reconnects once and retries the call. Passes
71
+ `persistent=True` where the method supports it.
72
+ """
73
+
74
+ def __init__(self, ip: str, port: int = TCP_PORT, timeout: float = 5.0) -> None:
75
+ self._ip = ip
76
+ self._port = port
77
+ self._timeout = timeout
78
+ self._client: YaskawaClient | None = None
79
+ self._lock = threading.Lock()
80
+ self._connected = False
81
+
82
+ def _connect(self) -> None:
83
+ if self._client:
84
+ try:
85
+ self._client.close()
86
+ except Exception:
87
+ pass
88
+ client = YaskawaClient(self._ip, self._port, timeout=self._timeout)
89
+ client.connect()
90
+ client.send_start(keep_alive=-1) # unlimited keep-alive session
91
+ self._client = client
92
+ self._connected = True
93
+
94
+ def close(self) -> None:
95
+ self._connected = False
96
+ if self._client:
97
+ try:
98
+ self._client.close()
99
+ except Exception:
100
+ pass
101
+ self._client = None
102
+
103
+ def __getattr__(self, name: str):
104
+ def wrapper(*args, **kwargs):
105
+ with self._lock:
106
+ for attempt in range(2):
107
+ try:
108
+ if not self._connected or self._client is None:
109
+ self._connect()
110
+ method = getattr(self._client, name)
111
+ try:
112
+ sig = inspect.signature(method)
113
+ if "persistent" in sig.parameters and "persistent" not in kwargs:
114
+ kwargs["persistent"] = True
115
+ except (ValueError, TypeError):
116
+ pass
117
+ return method(*args, **kwargs)
118
+ except (OSError, ConnectionError, BrokenPipeError):
119
+ if attempt == 0:
120
+ self.close()
121
+ continue
122
+ raise
123
+
124
+ return wrapper
125
+
126
+
127
+ class RobotConnection:
128
+ """One logical connection to the robot, bundling HSES (UDP) + Ethernet Server (TCP)."""
129
+
130
+ def __init__(
131
+ self,
132
+ ip: str,
133
+ hses_port: int = HSES_PORT,
134
+ tcp_port: int = TCP_PORT,
135
+ timeout: float = 2.0,
136
+ bind_host: str | None = None,
137
+ transport: str = TRANSPORT_HSES,
138
+ ) -> None:
139
+ if transport not in VALID_TRANSPORTS:
140
+ raise ValueError(
141
+ f"Unknown transport {transport!r} — allowed: {VALID_TRANSPORTS}"
142
+ )
143
+ self.ip = ip
144
+ self.hses_port = hses_port
145
+ self.tcp_port = tcp_port
146
+ self.timeout = timeout
147
+ self.bind_host = bind_host # local adapter IP for the HSES socket (optional)
148
+ self.transport = transport # "hses" (UDP) or "ethernet" (TCP host control)
149
+ self._hses: HsesClient | None = None
150
+ self._tcp: PersistentTcpClient | None = None
151
+ self._connected = False
152
+ self._lock = threading.Lock()
153
+
154
+ @classmethod
155
+ def from_settings(cls, cfg: dict) -> "RobotConnection":
156
+ """Build a connection from a settings dict (robot_ip, robot_port, hses_port, transport)."""
157
+ return cls(
158
+ ip=cfg["robot_ip"],
159
+ tcp_port=cfg.get("robot_port", TCP_PORT),
160
+ hses_port=cfg.get("hses_port", HSES_PORT),
161
+ transport=cfg.get("transport", TRANSPORT_HSES),
162
+ )
163
+
164
+ # ── lifecycle ─────────────────────────────────────────────────────────────
165
+ def connect(self) -> dict:
166
+ """Open the chosen transport and verify reachability with a status read."""
167
+ with self._lock:
168
+ if self.transport == TRANSPORT_ETHERNET:
169
+ tcp = PersistentTcpClient(self.ip, self.tcp_port, max(self.timeout, 5.0))
170
+ status = tcp.read_rstats(decode=True) # raises when unreachable
171
+ self._tcp = tcp
172
+ else:
173
+ hses = HsesClient(self.ip, self.hses_port, self.timeout,
174
+ bind_host=self.bind_host, model=MODEL_YRC1000)
175
+ hses.open()
176
+ try:
177
+ status = hses.read_status() # raises when unreachable / bad reply
178
+ try:
179
+ hses.detect_model() # determine generation (status bit 4), best effort
180
+ except Exception:
181
+ pass
182
+ except Exception:
183
+ hses.close()
184
+ raise
185
+ self._hses = hses
186
+ self._connected = True
187
+ return status
188
+
189
+ def disconnect(self) -> None:
190
+ with self._lock:
191
+ if self._hses:
192
+ self._hses.close()
193
+ self._hses = None
194
+ if self._tcp:
195
+ self._tcp.close()
196
+ self._tcp = None
197
+ self._connected = False
198
+
199
+ @property
200
+ def connected(self) -> bool:
201
+ return self._connected
202
+
203
+ # ── high-speed path (HSES) ────────────────────────────────────────────────
204
+ @property
205
+ def hses(self) -> HsesClient:
206
+ """Lazily created HSES client (UDP), usable regardless of the active transport."""
207
+ if not self._hses:
208
+ self._hses = HsesClient(self.ip, self.hses_port, self.timeout,
209
+ bind_host=self.bind_host, model=MODEL_YRC1000)
210
+ self._hses.open()
211
+ return self._hses
212
+
213
+ # ── classic path (TCP Ethernet Server) ────────────────────────────────────
214
+ @property
215
+ def tcp(self) -> PersistentTcpClient:
216
+ """Lazily created persistent TCP host-control session."""
217
+ if not self._tcp:
218
+ self._tcp = PersistentTcpClient(self.ip, self.tcp_port, max(self.timeout, 5.0))
219
+ return self._tcp
220
+
221
+ # ── shared query ──────────────────────────────────────────────────────────
222
+ def status(self) -> dict:
223
+ """Fast live status read over the active transport.
224
+
225
+ Both paths return the same set of status keys (RSTATS ≡ read_status) —
226
+ callers cannot tell which path is currently active.
227
+ """
228
+ with self._lock:
229
+ if self.transport == TRANSPORT_ETHERNET:
230
+ return self.tcp.read_rstats(decode=True)
231
+ return self.hses.read_status()
232
+
233
+ # ── unified read interface (dispatch by transport) ────────────────────────
234
+ def _use_hses(self) -> bool:
235
+ return self.transport == TRANSPORT_HSES
236
+
237
+ def capabilities(self) -> set[str]:
238
+ """Which data sources the *active* transport can read.
239
+
240
+ Registers (M) can only be read over HSES; the text-based Ethernet
241
+ Server has no register read command. Derived purely from the
242
+ transport — no connection required.
243
+ """
244
+ if self._use_hses():
245
+ return {"var", "io", "status", "register"}
246
+ return {"var", "io", "status"}
247
+
248
+ def read_rstats(self) -> dict:
249
+ if self._use_hses():
250
+ return self.hses.read_status()
251
+ return self.tcp.read_rstats(decode=True)
252
+
253
+ def read_variable(self, var_type, var_no):
254
+ if self._use_hses():
255
+ name = _VARTYPE_HSES_READ.get(int(var_type))
256
+ if name is None:
257
+ raise ValueError(f"HSES read: VarType {var_type!r} not supported")
258
+ return getattr(self.hses, name)(var_no)
259
+ return self.tcp.read_variable(var_type, var_no)
260
+
261
+ def read_variable_block(self, var_tuples: list[tuple]) -> dict:
262
+ """Read several variables; returns {(VarType, var_no): value}.
263
+
264
+ Single reads in a loop over the active transport. The TCP path runs
265
+ them as persistent single commands on the open long-lived session
266
+ (`PersistentTcpClient` passes `persistent=True`); the batched block
267
+ commands of the YaskawaClient class open a session of their own and
268
+ do not get along with the persistent connection.
269
+ """
270
+ out: dict = {}
271
+ for var_type, var_no in var_tuples:
272
+ out[(var_type, var_no)] = self.read_variable(var_type, var_no)
273
+ return out
274
+
275
+ def read_register(self, number: int) -> int:
276
+ """Read an M register (HSES only). The ES has no register read command."""
277
+ if self._use_hses():
278
+ vals = self.hses.read_registers(number, 1)
279
+ return vals[0] if vals else 0
280
+ raise NotImplementedError("Reading registers is only possible over HSES")
281
+
282
+ def read_position_var(self, vartype: str, number: int) -> list:
283
+ """Read a position variable P/BP/EX; returns the axis values as a list."""
284
+ if self._use_hses():
285
+ if vartype == "P":
286
+ return list(self.hses.read_position_var(number).axes)
287
+ if vartype == "BP":
288
+ return list(self.hses.read_bp_var(number).axes)
289
+ if vartype == "EX":
290
+ return list(self.hses.read_ex_var(number).axes)
291
+ raise ValueError(f"Not a position type: {vartype!r}")
292
+ vt = _POSVAR_ETH.get(vartype)
293
+ if vt is None:
294
+ raise ValueError(f"Not a position type: {vartype!r}")
295
+ val = self.tcp.read_variable(vt, number)
296
+ return list(val) if isinstance(val, (list, tuple)) else [val]
297
+
298
+ def read_io_bit(self, address: int) -> bool:
299
+ if self._use_hses():
300
+ byte = self.hses.read_io(address) # read_io divides by 10 internally
301
+ return bool((byte >> (address % 10)) & 1)
302
+ return self.tcp.read_io_bit(address)
303
+
304
+ def read_io_bytes(self, start_logical: int, count: int) -> list[int]:
305
+ """Read several IO bytes in one go (for batched polling).
306
+
307
+ start_logical is the full Yaskawa address (e.g. 50050); one byte
308
+ spans 10 address units.
309
+ """
310
+ if self._use_hses():
311
+ # read_io_plural requires an even count — round up, then trim.
312
+ want = count + (count % 2)
313
+ data = self.hses.read_io_plural(start_logical, want)
314
+ return list(data)[:count]
315
+ # The ES counts in bits.
316
+ return self.tcp.io_read(start_logical, count * 8)
317
+
318
+ def write_io_byte(self, logical: int, value: int) -> bool:
319
+ """Write one IO byte — Ethernet Server only.
320
+
321
+ Blocked in HSES mode: see `HsesWriteBlockedError`. The Ethernet
322
+ branch passes `tcp.io_write()` through unchanged (including `False`)
323
+ instead of swallowing it — a swallowed failure would report a switch
324
+ operation to the operator that never happened.
325
+ """
326
+ if self._use_hses():
327
+ raise HsesWriteBlockedError(
328
+ "Switching outputs is blocked in HSES mode: "
329
+ "HsesClient.write_io() pads a single-byte write to an even "
330
+ "byte count internally, forcing the following byte to 0x00 — "
331
+ "a switch operation would silently clear up to eight "
332
+ "unrelated outputs (see yrcom/hses_client.py, write_io). "
333
+ "Remedy: use transport=\"ethernet\" to switch outputs."
334
+ )
335
+ return bool(self.tcp.io_write(logical, [value]))
336
+
337
+ def read_job_seq(self) -> dict:
338
+ if self._use_hses():
339
+ j = self.hses.read_job()
340
+ return {"JobName": j.get("job_name"),
341
+ "Line": j.get("job_line"),
342
+ "Step": j.get("job_step")}
343
+ return self.tcp.read_job_seq()
344
+
345
+ def read_alarm(self):
346
+ """HSES: plaintext dict (alarm_code/alarm_name/alarm_time/...).
347
+ ES: raw code list (RALARM). Callers handle both shapes."""
348
+ if self._use_hses():
349
+ return self.hses.read_alarm(1)
350
+ return self.tcp.read_alarm()
351
+
352
+ # ── unified control interface ─────────────────────────────────────────────
353
+ def write_variable(self, var_type, var_no, value) -> bool:
354
+ if self._use_hses():
355
+ name = _VARTYPE_HSES_WRITE.get(int(var_type))
356
+ if name is None:
357
+ raise ValueError(f"HSES write: VarType {var_type!r} not supported")
358
+ getattr(self.hses, name)(var_no, value)
359
+ return True
360
+ return self.tcp.write_variable(var_type, var_no, value)
361
+
362
+ def write_variable_block(self, write_tuples: list[tuple]) -> bool:
363
+ """Write several variables; tuples are (VarType, var_no, value).
364
+
365
+ Single writes in a loop over the active transport; True only if all
366
+ of them succeed. The TCP path runs as persistent single commands on
367
+ the long-lived session (not the batched block command, which would
368
+ open a session of its own). Unlike the IO byte write, the variable
369
+ write over HSES is not affected by the neighbour-byte defect
370
+ (dedicated per-type commands), so it stays allowed. Callers verify
371
+ by reading back anyway.
372
+ """
373
+ ok = True
374
+ for var_type, var_no, value in write_tuples:
375
+ ok = self.write_variable(var_type, var_no, value) and ok
376
+ return ok
377
+
378
+ def servo_on(self, on: bool = True) -> bool:
379
+ if self._use_hses():
380
+ self.hses.servo(on)
381
+ return True
382
+ return self.tcp.servo_on(on)
383
+
384
+ def hold(self, on: bool = True) -> bool:
385
+ if self._use_hses():
386
+ self.hses.hold(on)
387
+ return True
388
+ return self.tcp.hold(on)
389
+
390
+ def start_job(self, jobname=None) -> bool:
391
+ if self._use_hses():
392
+ self.hses.start_job() # HSES starts the selected job (no name)
393
+ return True
394
+ return self.tcp.start_job(jobname)
395
+
396
+ def set_job_seq(self, jobname, line) -> bool:
397
+ if self._use_hses():
398
+ self.hses.select_job(jobname, line)
399
+ return True
400
+ return self.tcp.set_job_seq(jobname, line)
401
+
402
+ # ── reachability probe ────────────────────────────────────────────────────
403
+ @staticmethod
404
+ def _probe(action) -> bool:
405
+ try:
406
+ action()
407
+ return True
408
+ except Exception:
409
+ return False
410
+
411
+ def _probe_hses(self) -> bool:
412
+ def attempt():
413
+ c = HsesClient(self.ip, self.hses_port, 2.0, bind_host=self.bind_host)
414
+ c.open()
415
+ try:
416
+ c.read_status()
417
+ finally:
418
+ c.close()
419
+
420
+ return self._probe(attempt)
421
+
422
+ def _probe_ethernet(self) -> bool:
423
+ return self._probe(lambda: YaskawaClient(self.ip, self.tcp_port, timeout=2.0).read_rstats())
424
+
425
+ def detect(self) -> dict:
426
+ """Probe both transports concurrently; report which one answers.
427
+
428
+ Useful during setup: whether a cell has only one, only the other or
429
+ both servers enabled is not known in advance.
430
+ """
431
+ with ThreadPoolExecutor(max_workers=2) as ex:
432
+ hses = ex.submit(self._probe_hses)
433
+ eth = ex.submit(self._probe_ethernet)
434
+ return {"hses": hses.result(), "ethernet": eth.result()}