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.
Files changed (27) hide show
  1. {yrcom-0.3.1/src/yrcom.egg-info → yrcom-0.3.2}/PKG-INFO +1 -1
  2. {yrcom-0.3.1 → yrcom-0.3.2}/pyproject.toml +1 -1
  3. {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom/__init__.py +1 -1
  4. {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom/yaskawa_client.py +43 -21
  5. {yrcom-0.3.1 → yrcom-0.3.2/src/yrcom.egg-info}/PKG-INFO +1 -1
  6. {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom.egg-info/SOURCES.txt +1 -0
  7. yrcom-0.3.2/tests/test_tcp_framing.py +103 -0
  8. {yrcom-0.3.1 → yrcom-0.3.2}/LICENSE +0 -0
  9. {yrcom-0.3.1 → yrcom-0.3.2}/README.md +0 -0
  10. {yrcom-0.3.1 → yrcom-0.3.2}/setup.cfg +0 -0
  11. {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom/hses_client.py +0 -0
  12. {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom/hses_errors.py +0 -0
  13. {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom/hses_file.py +0 -0
  14. {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom/hses_server_codec.py +0 -0
  15. {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom/hses_sim.py +0 -0
  16. {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom/robot_connection.py +0 -0
  17. {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom.egg-info/dependency_links.txt +0 -0
  18. {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom.egg-info/entry_points.txt +0 -0
  19. {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom.egg-info/requires.txt +0 -0
  20. {yrcom-0.3.1 → yrcom-0.3.2}/src/yrcom.egg-info/top_level.txt +0 -0
  21. {yrcom-0.3.1 → yrcom-0.3.2}/tests/test_hses_client.py +0 -0
  22. {yrcom-0.3.1 → yrcom-0.3.2}/tests/test_hses_file.py +0 -0
  23. {yrcom-0.3.1 → yrcom-0.3.2}/tests/test_hses_server_codec.py +0 -0
  24. {yrcom-0.3.1 → yrcom-0.3.2}/tests/test_hses_sim.py +0 -0
  25. {yrcom-0.3.1 → yrcom-0.3.2}/tests/test_robot_connection.py +0 -0
  26. {yrcom-0.3.1 → yrcom-0.3.2}/tests/test_yaskawa_client.py +0 -0
  27. {yrcom-0.3.1 → yrcom-0.3.2}/tests/test_yaskawa_commands.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yrcom
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Yaskawa robot communication — Ethernet Server, HSES and HSES File Control (pure stdlib)
5
5
  Author: cmosbk
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "yrcom"
7
- version = "0.3.1"
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"
@@ -40,7 +40,7 @@ from .hses_server_codec import (
40
40
  encode_error,
41
41
  )
42
42
 
43
- __version__ = "0.3.1"
43
+ __version__ = "0.3.2"
44
44
 
45
45
  __all__ = [
46
46
  "YaskawaClient", "VarType", "EthernetServerClient",
@@ -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 _recv_until_ok(self) -> str:
170
- """Receive until OK or NG appears in the buffer."""
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
- break
178
- buf += chunk.decode("ascii")
179
- if "OK" in buf or "NG" in buf:
180
- break
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 _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 = ""
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
- 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
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: yrcom
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Yaskawa robot communication — Ethernet Server, HSES and HSES File Control (pure stdlib)
5
5
  Author: cmosbk
6
6
  License: MIT
@@ -20,5 +20,6 @@ tests/test_hses_file.py
20
20
  tests/test_hses_server_codec.py
21
21
  tests/test_hses_sim.py
22
22
  tests/test_robot_connection.py
23
+ tests/test_tcp_framing.py
23
24
  tests/test_yaskawa_client.py
24
25
  tests/test_yaskawa_commands.py
@@ -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