yrcom 0.3.1__tar.gz → 0.3.2__tar.gz
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-0.3.1/src/yrcom.egg-info → yrcom-0.3.2}/PKG-INFO +1 -1
- {yrcom-0.3.1 → yrcom-0.3.2}/pyproject.toml +1 -1
- {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom/__init__.py +1 -1
- {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom/yaskawa_client.py +43 -21
- {yrcom-0.3.1 → yrcom-0.3.2/src/yrcom.egg-info}/PKG-INFO +1 -1
- {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom.egg-info/SOURCES.txt +1 -0
- yrcom-0.3.2/tests/test_tcp_framing.py +103 -0
- {yrcom-0.3.1 → yrcom-0.3.2}/LICENSE +0 -0
- {yrcom-0.3.1 → yrcom-0.3.2}/README.md +0 -0
- {yrcom-0.3.1 → yrcom-0.3.2}/setup.cfg +0 -0
- {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom/hses_client.py +0 -0
- {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom/hses_errors.py +0 -0
- {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom/hses_file.py +0 -0
- {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom/hses_server_codec.py +0 -0
- {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom/hses_sim.py +0 -0
- {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom/robot_connection.py +0 -0
- {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom.egg-info/dependency_links.txt +0 -0
- {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom.egg-info/entry_points.txt +0 -0
- {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom.egg-info/requires.txt +0 -0
- {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom.egg-info/top_level.txt +0 -0
- {yrcom-0.3.1 → yrcom-0.3.2}/tests/test_hses_client.py +0 -0
- {yrcom-0.3.1 → yrcom-0.3.2}/tests/test_hses_file.py +0 -0
- {yrcom-0.3.1 → yrcom-0.3.2}/tests/test_hses_server_codec.py +0 -0
- {yrcom-0.3.1 → yrcom-0.3.2}/tests/test_hses_sim.py +0 -0
- {yrcom-0.3.1 → yrcom-0.3.2}/tests/test_robot_connection.py +0 -0
- {yrcom-0.3.1 → yrcom-0.3.2}/tests/test_yaskawa_client.py +0 -0
- {yrcom-0.3.1 → yrcom-0.3.2}/tests/test_yaskawa_commands.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "yrcom"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.2"
|
|
8
8
|
description = "Yaskawa robot communication — Ethernet Server, HSES and HSES File Control (pure stdlib)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -104,6 +104,9 @@ class YaskawaClient:
|
|
|
104
104
|
self.port = port
|
|
105
105
|
self.timeout = timeout
|
|
106
106
|
self.sock: socket.socket | None = None
|
|
107
|
+
# Bytes already received but not yet consumed. TCP is a byte stream: one recv() may
|
|
108
|
+
# hold a header and its answer together, or only part of a line.
|
|
109
|
+
self._rx = b""
|
|
107
110
|
|
|
108
111
|
# ── Context manager ───────────────────────────────────────────────────────
|
|
109
112
|
|
|
@@ -120,6 +123,7 @@ class YaskawaClient:
|
|
|
120
123
|
"""Open TCP socket to the controller. No-op if already connected."""
|
|
121
124
|
if self.sock is not None:
|
|
122
125
|
return
|
|
126
|
+
self._rx = b""
|
|
123
127
|
try:
|
|
124
128
|
self.sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
|
|
125
129
|
except Exception:
|
|
@@ -131,6 +135,7 @@ class YaskawaClient:
|
|
|
131
135
|
if self.sock:
|
|
132
136
|
self.sock.close()
|
|
133
137
|
self.sock = None
|
|
138
|
+
self._rx = b""
|
|
134
139
|
|
|
135
140
|
# ── Protocol primitives ───────────────────────────────────────────────────
|
|
136
141
|
|
|
@@ -160,39 +165,56 @@ class YaskawaClient:
|
|
|
160
165
|
raise RuntimeError("Socket not connected")
|
|
161
166
|
header = f"HOSTCTRL_REQUEST {command} {size}\r\n"
|
|
162
167
|
self.sock.sendall(header.encode("ascii"))
|
|
163
|
-
self._recv_until_ok()
|
|
168
|
+
reply = self._recv_until_ok()
|
|
169
|
+
if reply.startswith("NG"):
|
|
170
|
+
# Request refused: no answer line follows, so waiting for one would only time out.
|
|
171
|
+
return reply
|
|
164
172
|
if size > 0 and data:
|
|
165
173
|
payload = data if data.endswith("\r") else data + "\r"
|
|
166
174
|
self.sock.sendall(payload.encode("ascii"))
|
|
167
175
|
return self._recv_answer()
|
|
168
176
|
|
|
169
|
-
def
|
|
170
|
-
"""
|
|
177
|
+
def _recv_line(self) -> str | None:
|
|
178
|
+
"""Return the next line from the stream without its terminator, or None
|
|
179
|
+
if the peer closed the connection with nothing left to read.
|
|
180
|
+
|
|
181
|
+
Lines end in ``\r\n`` (handshake and header replies) or a bare ``\r``
|
|
182
|
+
(answer data). Reads until a terminator is buffered, so a reply split
|
|
183
|
+
over several segments, or several replies in one segment, are handled
|
|
184
|
+
the same way. A ``\n`` left over from ``\r\n`` split across segments is
|
|
185
|
+
skipped at the start of the next line.
|
|
186
|
+
"""
|
|
171
187
|
if self.sock is None:
|
|
172
188
|
raise RuntimeError("Socket not connected")
|
|
173
|
-
buf = ""
|
|
174
189
|
while True:
|
|
190
|
+
self._rx = self._rx.lstrip(b"\n")
|
|
191
|
+
end = self._rx.find(b"\r")
|
|
192
|
+
if end >= 0:
|
|
193
|
+
line, self._rx = self._rx[:end], self._rx[end + 1:]
|
|
194
|
+
if self._rx.startswith(b"\n"):
|
|
195
|
+
self._rx = self._rx[1:]
|
|
196
|
+
return line.decode("ascii")
|
|
175
197
|
chunk = self.sock.recv(_RECV_SIZE)
|
|
176
198
|
if not chunk:
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
return buf
|
|
199
|
+
# Peer closed: hand out an unterminated rest once, then report the end.
|
|
200
|
+
line, self._rx = self._rx, b""
|
|
201
|
+
return line.decode("ascii") if line else None
|
|
202
|
+
self._rx += chunk
|
|
182
203
|
|
|
183
|
-
def
|
|
184
|
-
"""Receive
|
|
185
|
-
if
|
|
186
|
-
raise RuntimeError("Socket not connected")
|
|
187
|
-
buf = ""
|
|
204
|
+
def _recv_until_ok(self) -> str:
|
|
205
|
+
"""Receive lines until one starts with OK or NG and return it (with ``\r\n``);
|
|
206
|
+
"" if the peer closed first."""
|
|
188
207
|
while True:
|
|
189
|
-
|
|
190
|
-
if
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
208
|
+
line = self._recv_line()
|
|
209
|
+
if line is None:
|
|
210
|
+
return ""
|
|
211
|
+
if line.startswith(("OK", "NG")):
|
|
212
|
+
return line + "\r\n"
|
|
213
|
+
|
|
214
|
+
def _recv_answer(self) -> str:
|
|
215
|
+
"""Receive one answer line (data ends with ``\r`` or ``\r\n``); "" if the peer closed."""
|
|
216
|
+
line = self._recv_line()
|
|
217
|
+
return "" if line is None else line + "\r"
|
|
196
218
|
|
|
197
219
|
# ── Transaction patterns ──────────────────────────────────────────────────
|
|
198
220
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Ethernet Server replies arrive as a TCP byte stream: a header and its answer may come
|
|
2
|
+
in one segment, a line may be split over several, and ``\\r\\n`` may be cut between
|
|
3
|
+
``\\r`` and ``\\n``. The client must read lines, not whatever one recv() returns."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import socket
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from yrcom.robot_connection import PersistentTcpClient
|
|
13
|
+
from yrcom.yaskawa_client import YaskawaClient
|
|
14
|
+
|
|
15
|
+
GREETING = b"OK: NX Information Server(Ver. W4.00)\r\n"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _serve(chunks_for_request, keep=0.3):
|
|
19
|
+
"""One-connection server: answers CONNECT with GREETING, then every request header
|
|
20
|
+
with the chunks `chunks_for_request(n)` returns (n = request number, from 0)."""
|
|
21
|
+
srv = socket.socket()
|
|
22
|
+
srv.bind(("127.0.0.1", 0))
|
|
23
|
+
srv.listen(1)
|
|
24
|
+
|
|
25
|
+
def run():
|
|
26
|
+
conn, _ = srv.accept()
|
|
27
|
+
f = conn.makefile("rb")
|
|
28
|
+
f.readline() # CONNECT Robot_access ...
|
|
29
|
+
conn.sendall(GREETING)
|
|
30
|
+
n = 0
|
|
31
|
+
while True:
|
|
32
|
+
if not f.readline(): # HOSTCTRL_REQUEST ...
|
|
33
|
+
break
|
|
34
|
+
for c in chunks_for_request(n):
|
|
35
|
+
conn.sendall(c)
|
|
36
|
+
time.sleep(0.005) # separate segments where intended
|
|
37
|
+
n += 1
|
|
38
|
+
time.sleep(keep)
|
|
39
|
+
conn.close()
|
|
40
|
+
|
|
41
|
+
threading.Thread(target=run, daemon=True).start()
|
|
42
|
+
return srv, srv.getsockname()[1]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
REPLY = b"OK: RSTATS\r\n" + b"194,64\r"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _bytewise(data: bytes) -> list[bytes]:
|
|
49
|
+
return [bytes([b]) for b in data]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.mark.parametrize("chunks", [
|
|
53
|
+
[b"OK: RSTATS\r\n", b"194,64\r"], # header and answer apart
|
|
54
|
+
[REPLY], # header and answer in one segment
|
|
55
|
+
_bytewise(REPLY), # one byte per segment
|
|
56
|
+
[b"OK: RSTATS\r", b"\n194,", b"64\r"], # \r\n cut between \r and \n
|
|
57
|
+
[b"OK: RSTATS\r\n194,64\r\n"], # answer terminated with \r\n
|
|
58
|
+
], ids=["apart", "together", "bytewise", "crlf-split", "answer-crlf"])
|
|
59
|
+
def test_rstats_survives_any_segmentation(chunks):
|
|
60
|
+
srv, port = _serve(lambda n: chunks)
|
|
61
|
+
try:
|
|
62
|
+
with YaskawaClient("127.0.0.1", port, timeout=2.0) as c:
|
|
63
|
+
c.send_start(keep_alive=-1)
|
|
64
|
+
assert c.read_rstats(decode=False, persistent=True) == [194, 64]
|
|
65
|
+
finally:
|
|
66
|
+
srv.close()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_two_replies_in_one_segment_stay_apart():
|
|
70
|
+
# first request: both replies of the session arrive at once; the second request must
|
|
71
|
+
# get its own answer from the buffer, not wait for the network
|
|
72
|
+
both = b"OK: RSTATS\r\n1,2\r" + b"OK: RSTATS\r\n3,4\r"
|
|
73
|
+
srv, port = _serve(lambda n: [both] if n == 0 else [])
|
|
74
|
+
try:
|
|
75
|
+
with YaskawaClient("127.0.0.1", port, timeout=2.0) as c:
|
|
76
|
+
c.send_start(keep_alive=-1)
|
|
77
|
+
assert c.read_rstats(decode=False, persistent=True) == [1, 2]
|
|
78
|
+
assert c.read_rstats(decode=False, persistent=True) == [3, 4]
|
|
79
|
+
finally:
|
|
80
|
+
srv.close()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_ng_header_returns_without_waiting():
|
|
84
|
+
srv, port = _serve(lambda n: [b"NG: HOSTCTRL_REQUEST\r\n"])
|
|
85
|
+
try:
|
|
86
|
+
with YaskawaClient("127.0.0.1", port, timeout=2.0) as c:
|
|
87
|
+
c.send_start(keep_alive=-1)
|
|
88
|
+
t0 = time.monotonic()
|
|
89
|
+
assert c.send_command("RSTATS").startswith("NG")
|
|
90
|
+
assert time.monotonic() - t0 < 1.0 # no timeout wait for an answer line
|
|
91
|
+
finally:
|
|
92
|
+
srv.close()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_persistent_connection_together_and_bytewise():
|
|
96
|
+
for chunks in ([REPLY], _bytewise(REPLY)):
|
|
97
|
+
srv, port = _serve(lambda n, ch=chunks: ch)
|
|
98
|
+
try:
|
|
99
|
+
tcp = PersistentTcpClient("127.0.0.1", port, timeout=2.0)
|
|
100
|
+
assert tcp.read_rstats(decode=False) == [194, 64]
|
|
101
|
+
tcp.close()
|
|
102
|
+
finally:
|
|
103
|
+
srv.close()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|