yrcom 0.3.0__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 (28) hide show
  1. {yrcom-0.3.0/src/yrcom.egg-info → yrcom-0.3.2}/PKG-INFO +10 -1
  2. {yrcom-0.3.0 → yrcom-0.3.2}/README.md +4 -0
  3. {yrcom-0.3.0 → yrcom-0.3.2}/pyproject.toml +2 -1
  4. {yrcom-0.3.0 → yrcom-0.3.2}/src/yrcom/__init__.py +1 -1
  5. {yrcom-0.3.0 → yrcom-0.3.2}/src/yrcom/hses_client.py +21 -1
  6. {yrcom-0.3.0 → yrcom-0.3.2}/src/yrcom/yaskawa_client.py +43 -21
  7. {yrcom-0.3.0 → yrcom-0.3.2/src/yrcom.egg-info}/PKG-INFO +10 -1
  8. {yrcom-0.3.0 → yrcom-0.3.2}/src/yrcom.egg-info/SOURCES.txt +1 -0
  9. yrcom-0.3.2/src/yrcom.egg-info/requires.txt +9 -0
  10. {yrcom-0.3.0 → yrcom-0.3.2}/tests/test_hses_client.py +24 -0
  11. yrcom-0.3.2/tests/test_tcp_framing.py +103 -0
  12. yrcom-0.3.0/src/yrcom.egg-info/requires.txt +0 -3
  13. {yrcom-0.3.0 → yrcom-0.3.2}/LICENSE +0 -0
  14. {yrcom-0.3.0 → yrcom-0.3.2}/setup.cfg +0 -0
  15. {yrcom-0.3.0 → yrcom-0.3.2}/src/yrcom/hses_errors.py +0 -0
  16. {yrcom-0.3.0 → yrcom-0.3.2}/src/yrcom/hses_file.py +0 -0
  17. {yrcom-0.3.0 → yrcom-0.3.2}/src/yrcom/hses_server_codec.py +0 -0
  18. {yrcom-0.3.0 → yrcom-0.3.2}/src/yrcom/hses_sim.py +0 -0
  19. {yrcom-0.3.0 → yrcom-0.3.2}/src/yrcom/robot_connection.py +0 -0
  20. {yrcom-0.3.0 → yrcom-0.3.2}/src/yrcom.egg-info/dependency_links.txt +0 -0
  21. {yrcom-0.3.0 → yrcom-0.3.2}/src/yrcom.egg-info/entry_points.txt +0 -0
  22. {yrcom-0.3.0 → yrcom-0.3.2}/src/yrcom.egg-info/top_level.txt +0 -0
  23. {yrcom-0.3.0 → yrcom-0.3.2}/tests/test_hses_file.py +0 -0
  24. {yrcom-0.3.0 → yrcom-0.3.2}/tests/test_hses_server_codec.py +0 -0
  25. {yrcom-0.3.0 → yrcom-0.3.2}/tests/test_hses_sim.py +0 -0
  26. {yrcom-0.3.0 → yrcom-0.3.2}/tests/test_robot_connection.py +0 -0
  27. {yrcom-0.3.0 → yrcom-0.3.2}/tests/test_yaskawa_client.py +0 -0
  28. {yrcom-0.3.0 → 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.0
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
@@ -12,12 +12,21 @@ Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
13
  Provides-Extra: dev
14
14
  Requires-Dist: pytest>=8; extra == "dev"
15
+ Provides-Extra: docs
16
+ Requires-Dist: mkdocs>=1.6; extra == "docs"
17
+ Requires-Dist: mkdocs-material>=9.5; extra == "docs"
18
+ Requires-Dist: mkdocstrings[python]>=0.26; extra == "docs"
19
+ Requires-Dist: black>=24; extra == "docs"
15
20
  Dynamic: license-file
16
21
 
17
22
  # yrcom
18
23
 
19
24
  [![CI](https://github.com/CMOSBK/yrcom/actions/workflows/ci.yml/badge.svg)](https://github.com/CMOSBK/yrcom/actions/workflows/ci.yml)
20
25
 
26
+ 📖 **Documentation: <https://cmosbk.github.io/yrcom/>** — getting started, the
27
+ transport guide and the full API reference (every command, generated from the
28
+ source).
29
+
21
30
  Yaskawa robot communication as a standalone Python library — a single,
22
31
  hardware-verified communication core meant to be shared across projects
23
32
  instead of being duplicated.
@@ -2,6 +2,10 @@
2
2
 
3
3
  [![CI](https://github.com/CMOSBK/yrcom/actions/workflows/ci.yml/badge.svg)](https://github.com/CMOSBK/yrcom/actions/workflows/ci.yml)
4
4
 
5
+ 📖 **Documentation: <https://cmosbk.github.io/yrcom/>** — getting started, the
6
+ transport guide and the full API reference (every command, generated from the
7
+ source).
8
+
5
9
  Yaskawa robot communication as a standalone Python library — a single,
6
10
  hardware-verified communication core meant to be shared across projects
7
11
  instead of being duplicated.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "yrcom"
7
- version = "0.3.0"
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"
@@ -15,6 +15,7 @@ dependencies = []
15
15
 
16
16
  [project.optional-dependencies]
17
17
  dev = ["pytest>=8"]
18
+ docs = ["mkdocs>=1.6", "mkdocs-material>=9.5", "mkdocstrings[python]>=0.26", "black>=24"]
18
19
 
19
20
  [project.scripts]
20
21
  yrcom-sim = "yrcom.hses_sim:_main"
@@ -40,7 +40,7 @@ from .hses_server_codec import (
40
40
  encode_error,
41
41
  )
42
42
 
43
- __version__ = "0.3.0"
43
+ __version__ = "0.3.2"
44
44
 
45
45
  __all__ = [
46
46
  "YaskawaClient", "VarType", "EthernetServerClient",
@@ -581,6 +581,8 @@ class HsesClient(_HsesSocket):
581
581
  position_group: int = 0,
582
582
  b_vars: "list[dict]" = [],
583
583
  i_vars: "list[dict]" = [],
584
+ d_vars: "list[dict]" = [],
585
+ r_vars: "list[dict]" = [],
584
586
  io_signals: "list[dict]" = [],
585
587
  read_cio: bool = False,
586
588
  ) -> dict:
@@ -590,7 +592,11 @@ class HsesClient(_HsesSocket):
590
592
  alarm_slots: number of alarm history slots (0=skip, max 4)
591
593
  include_job: read executing job info
592
594
  position_group: 0=skip, 1-8=pulse, 101-108=cartesian
593
- b_vars/i_vars: list of {"label": str, "no": int}
595
+ b_vars/i_vars/d_vars/r_vars:
596
+ list of {"label": str, "no": int}. Each becomes one
597
+ request inside the burst, so its value shares the
598
+ instant of the rest of the row rather than arriving a
599
+ round trip later.
594
600
  io_signals: list of {"label": str, "logical": int}
595
601
 
596
602
  Returns flat dict of all decoded fields.
@@ -622,6 +628,14 @@ class HsesClient(_HsesSocket):
622
628
  reqs.append((_CMD_I_VAR, v["no"], 1, _SVC_GET_SINGLE, b""))
623
629
  tags.append(f"i_var:{v['label']}")
624
630
 
631
+ for v in d_vars:
632
+ reqs.append((_CMD_D_VAR, v["no"], 1, _SVC_GET_SINGLE, b""))
633
+ tags.append(f"d_var:{v['label']}")
634
+
635
+ for v in r_vars:
636
+ reqs.append((_CMD_R_VAR, v["no"], 1, _SVC_GET_SINGLE, b""))
637
+ tags.append(f"r_var:{v['label']}")
638
+
625
639
  # CIO counter: low byte at logical 72000 (inst 7200), high byte at 72010 (inst 7201)
626
640
  if read_cio:
627
641
  reqs.append((_CMD_IO_PLURAL, 7200, 0, _SVC_READ_ALL, struct.pack("<I", 2)))
@@ -666,6 +680,12 @@ class HsesClient(_HsesSocket):
666
680
  elif tag.startswith("i_var:"):
667
681
  if resp.status == 0 and len(resp.data) >= 2:
668
682
  out[f"var_{tag[6:]}"] = struct.unpack_from("<h", resp.data, 0)[0]
683
+ elif tag.startswith("d_var:"):
684
+ if resp.status == 0 and len(resp.data) >= 4:
685
+ out[f"var_{tag[6:]}"] = struct.unpack_from("<i", resp.data, 0)[0]
686
+ elif tag.startswith("r_var:"):
687
+ if resp.status == 0 and len(resp.data) >= 4:
688
+ out[f"var_{tag[6:]}"] = struct.unpack_from("<f", resp.data, 0)[0]
669
689
  elif tag == "cio":
670
690
  if resp.status == 0 and len(resp.data) >= 6:
671
691
  # response: 4-byte count + 2 data bytes
@@ -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.0
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
@@ -12,12 +12,21 @@ Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
13
  Provides-Extra: dev
14
14
  Requires-Dist: pytest>=8; extra == "dev"
15
+ Provides-Extra: docs
16
+ Requires-Dist: mkdocs>=1.6; extra == "docs"
17
+ Requires-Dist: mkdocs-material>=9.5; extra == "docs"
18
+ Requires-Dist: mkdocstrings[python]>=0.26; extra == "docs"
19
+ Requires-Dist: black>=24; extra == "docs"
15
20
  Dynamic: license-file
16
21
 
17
22
  # yrcom
18
23
 
19
24
  [![CI](https://github.com/CMOSBK/yrcom/actions/workflows/ci.yml/badge.svg)](https://github.com/CMOSBK/yrcom/actions/workflows/ci.yml)
20
25
 
26
+ 📖 **Documentation: <https://cmosbk.github.io/yrcom/>** — getting started, the
27
+ transport guide and the full API reference (every command, generated from the
28
+ source).
29
+
21
30
  Yaskawa robot communication as a standalone Python library — a single,
22
31
  hardware-verified communication core meant to be shared across projects
23
32
  instead of being duplicated.
@@ -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,9 @@
1
+
2
+ [dev]
3
+ pytest>=8
4
+
5
+ [docs]
6
+ mkdocs>=1.6
7
+ mkdocs-material>=9.5
8
+ mkdocstrings[python]>=0.26
9
+ black>=24
@@ -624,6 +624,30 @@ def test_poll_all_status_and_job():
624
624
  assert out["job_name"] == "MASTER"
625
625
 
626
626
 
627
+ def test_poll_all_reads_every_variable_kind_in_one_burst():
628
+ """D and R used to be reachable only as standalone reads, which put their
629
+ value a round trip away from the rest of the row."""
630
+ status_data = struct.pack("<II", 0x40, 0x40)
631
+ c, _ = _client([
632
+ _reply(hc._CMD_STATUS, status_data, req_id=1),
633
+ _reply(hc._CMD_B_VAR, struct.pack("<B", 7), req_id=2),
634
+ _reply(hc._CMD_I_VAR, struct.pack("<h", -3), req_id=3),
635
+ _reply(hc._CMD_D_VAR, struct.pack("<i", -70000), req_id=4),
636
+ _reply(hc._CMD_R_VAR, struct.pack("<f", 1.5), req_id=5),
637
+ ])
638
+ out = c.poll_all(
639
+ include_job=False,
640
+ b_vars=[{"label": "b", "no": 1}],
641
+ i_vars=[{"label": "i", "no": 2}],
642
+ d_vars=[{"label": "d", "no": 3}],
643
+ r_vars=[{"label": "r", "no": 4}],
644
+ )
645
+ assert out["var_b"] == 7
646
+ assert out["var_i"] == -3
647
+ assert out["var_d"] == -70000 # signed 32-bit, beyond an I variable
648
+ assert out["var_r"] == pytest.approx(1.5)
649
+
650
+
627
651
  def test_poll_all_raises_on_status_error():
628
652
  c, _ = _client([_reply(hc._CMD_STATUS, b"", req_id=1, status=0x08)])
629
653
  with pytest.raises(HsesError):
@@ -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()
@@ -1,3 +0,0 @@
1
-
2
- [dev]
3
- pytest>=8
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes