yrcom 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- yrcom/__init__.py +48 -0
- yrcom/hses_client.py +1659 -0
- yrcom/hses_errors.py +190 -0
- yrcom/hses_file.py +234 -0
- yrcom/hses_sim.py +173 -0
- yrcom/robot_connection.py +434 -0
- yrcom/yaskawa_client.py +615 -0
- yrcom-0.2.0.dist-info/METADATA +112 -0
- yrcom-0.2.0.dist-info/RECORD +13 -0
- yrcom-0.2.0.dist-info/WHEEL +5 -0
- yrcom-0.2.0.dist-info/entry_points.txt +2 -0
- yrcom-0.2.0.dist-info/licenses/LICENSE +21 -0
- yrcom-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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()}
|