Skip to content

Commit a31f44e

Browse files
authored
Merge pull request #1 from SebRoLENS/fix/tcp-scpi-terminator
Fix Ethernet connection regression in v1.0.0
2 parents e366dfb + b1e5827 commit a31f44e

9 files changed

Lines changed: 99 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
All notable changes are documented here. The project follows semantic versioning.
44

5+
## [1.0.1] - 2026-08-29
6+
7+
- Fixed the Ethernet connection regression introduced in v1.0.0: TCP/SCPI
8+
commands now end in CRLF, preserving the validated v0.3.1 behaviour and the
9+
LF terminator required by the Druck K0472 manual.
10+
- Ignored an empty line-feed fragment when a CRLF instrument reply is split
11+
across TCP packets.
12+
- Added a loopback TCP regression test for the complete connection handshake.
13+
514
## [1.0.0] - 2026-08-28
615

716
- Added a separate Python/PySide6 implementation for Windows and Linux.

CITATION.cff

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ authors:
77
given-names: S.
88
orcid: "https://orcid.org/0000-0002-9553-7788"
99
repository-code: "https://github.com/SebRoLENS/pace-controller"
10-
url: "https://github.com/SebRoLENS/pace-controller/releases/tag/v1.0.0"
11-
version: "1.0.0"
12-
date-released: 2026-08-28
10+
url: "https://github.com/SebRoLENS/pace-controller/releases/tag/v1.0.1"
11+
version: "1.0.1"
12+
date-released: 2026-08-29
1313
license: MIT
1414
keywords:
1515
- Druck PACE

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
Cross-platform graphical controller for classic Druck **PACE 5000** and **PACE 6000** instruments using Ethernet or RS-232.
99

10-
Current public version: **1.0.0**
10+
Current public version: **1.0.1**
1111

1212
> [!CAUTION]
1313
> This application sends real pressure-control and vent commands. It is not a certified safety system and does not replace pressure-relief devices, hardware interlocks, instrument limits, laboratory procedures, or direct operator supervision.
@@ -59,6 +59,10 @@ No NI-VISA, Druck USB driver, Python, LabVIEW, or Internet connection is require
5959

6060
Use static address `192.168.10.2`, mask `255.255.255.0`, empty gateway/DNS, Access Control **Open**, and TCP SCPI port `5025`.
6161

62+
Ethernet commands are sent with CRLF line endings: the final LF is the SCPI
63+
message terminator required by the Druck K0472 manual and matches the validated
64+
legacy Windows implementation.
65+
6266
The application first tries the configured address. If enabled, it temporarily adds `192.168.10.1/24` only to exactly one safe dedicated adapter with no gateway or unrelated network. Ambiguous adapters are never modified.
6367

6468
### RS-232

cross_platform/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Cross-platform graphical controller for classic Druck **PACE 5000** and **PACE 6000** instruments connected through Ethernet or RS-232.
44

5-
Current version: **1.0.0**
5+
Current version: **1.0.1**
66

77
> [!CAUTION]
88
> This application sends real pressure-control and vent commands. It is not a certified safety system and does not replace pressure-relief devices, hardware interlocks, instrument limits, laboratory procedures, or direct operator supervision.
@@ -80,6 +80,10 @@ Configure the PACE with:
8080
| Access control | Open |
8181
| SCPI socket | TCP `5025` |
8282

83+
Ethernet commands are sent with CRLF line endings. The final LF is the SCPI
84+
message terminator specified by Druck K0472 and the complete sequence matches
85+
the validated legacy Windows controller.
86+
8387
The program first tries the configured address. If requested, it then adds `192.168.10.1/24` only to a single safe dedicated Ethernet adapter that has no gateway and no unrelated IPv4 network. No adapter is changed if selection is ambiguous. The temporary address is removed at shutdown.
8488

8589
### RS-232

cross_platform/docs/PACE_Controller_Manual.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
---
22
title: "PACE Controller - Cross-Platform User and Technical Manual"
33
author: "S. Romi"
4-
date: "Version 1.0.0 - 2026"
4+
date: "Version 1.0.1 - 2026"
55
geometry: margin=2.2cm
66
colorlinks: true
77
lang: en
88
---
99

1010
# PACE Controller
1111

12-
User and technical manual for version **1.0.0**.
12+
User and technical manual for version **1.0.1**.
1313

1414
## 1. Scope
1515

@@ -66,6 +66,10 @@ Configure the PACE Ethernet parameters as follows:
6666
| Access control | Open |
6767
| SCPI port | TCP `5025` |
6868

69+
The Ethernet transport sends every command with a CRLF line ending. Its final
70+
LF byte (ASCII 10) is the SCPI message terminator required by K0472, while the
71+
complete sequence preserves the validated legacy Windows implementation.
72+
6973
Connect the computer and PACE directly with an Ethernet cable. Modern interfaces normally support automatic crossover.
7074

7175
The application first attempts the configured address without changing the computer. If the PACE is unreachable and automatic configuration is enabled, it looks for exactly one safe dedicated wired adapter satisfying all of these conditions:

cross_platform/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "pace-pressure-controller"
7-
version = "1.0.0"
7+
version = "1.0.1"
88
description = "Cross-platform Ethernet and RS-232 controller for classic Druck PACE 5000/6000"
99
readme = "README.md"
1010
requires-python = ">=3.10"
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
"""PACE Controller cross-platform application."""
22

3-
__version__ = "1.0.0"
3+
__version__ = "1.0.1"
44

cross_platform/src/pace_controller/transports.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
from .models import ConnectionConfig, ConnectionKind
1414

1515

16+
PACE_TCP_COMMAND_TERMINATOR = b"\r\n"
17+
18+
1619
class TransportError(RuntimeError):
1720
pass
1821

@@ -71,7 +74,13 @@ def _send(self, command: str) -> None:
7174
if self._socket is None:
7275
raise TransportError("TCP transport is not connected")
7376
try:
74-
self._socket.sendall(command.rstrip("\r\n").encode("ascii") + b"\r")
77+
# K0472 requires LF (ASCII 10) to terminate SCPI commands. CRLF
78+
# also preserves the byte sequence used by the validated legacy
79+
# Windows controller.
80+
self._socket.sendall(
81+
command.rstrip("\r\n").encode("ascii")
82+
+ PACE_TCP_COMMAND_TERMINATOR
83+
)
7584
except OSError as exc:
7685
raise TransportError(f"TCP write failed: {exc}") from exc
7786

@@ -87,6 +96,11 @@ def _read_line(self) -> str:
8796
del self._buffer[: position + 1]
8897
while self._buffer[:1] in (b"\r", b"\n"):
8998
del self._buffer[:1]
99+
if not raw:
100+
# A CRLF reply can be split across TCP packets. If
101+
# CR completed the previous response, ignore the
102+
# delayed LF instead of returning an empty response.
103+
continue
90104
return raw.decode("ascii", errors="replace").strip()
91105
if time.monotonic() >= deadline:
92106
raise TransportError("Timed out waiting for the PACE response")
@@ -338,4 +352,3 @@ def create_transport(config: ConnectionConfig) -> ScpiTransport:
338352
timeout=config.timeout,
339353
)
340354
return SimulatorTransport()
341-

cross_platform/tests/test_core.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

33
import hashlib
4+
import socket
5+
import threading
46
import time
57
from pathlib import Path
68

@@ -11,7 +13,7 @@
1113
from pace_controller.leak import LeakMonitor
1214
from pace_controller.models import LeakThresholds
1315
from pace_controller.service import scpi_float, scpi_number, scpi_numbers, scpi_payload
14-
from pace_controller.transports import SimulatorTransport
16+
from pace_controller.transports import SimulatorTransport, TcpTransport
1517

1618

1719
ROOT = Path(__file__).resolve().parents[2]
@@ -61,6 +63,57 @@ def test_simulator_accepts_same_scpi_as_real_transport() -> None:
6163
device.close()
6264

6365

66+
def test_tcp_transport_matches_validated_pace_line_endings() -> None:
67+
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
68+
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
69+
listener.bind(("127.0.0.1", 0))
70+
listener.listen(1)
71+
host, port = listener.getsockname()
72+
received: list[bytes] = []
73+
server_errors: list[BaseException] = []
74+
75+
def receive_command(connection: socket.socket) -> bytes:
76+
payload = bytearray()
77+
while not payload.endswith(b"\n"):
78+
chunk = connection.recv(1024)
79+
if not chunk:
80+
break
81+
payload.extend(chunk)
82+
return bytes(payload)
83+
84+
def serve() -> None:
85+
try:
86+
connection, _ = listener.accept()
87+
with connection:
88+
connection.settimeout(2.0)
89+
received.append(receive_command(connection))
90+
connection.sendall(b"DRUCK,PACE5000,TEST,1.0\r")
91+
time.sleep(0.05)
92+
connection.sendall(b"\n")
93+
received.append(receive_command(connection))
94+
time.sleep(0.05)
95+
connection.sendall(b"BAR\r\n")
96+
except BaseException as exc: # surfaced in the test thread
97+
server_errors.append(exc)
98+
finally:
99+
listener.close()
100+
101+
server = threading.Thread(target=serve, daemon=True)
102+
server.start()
103+
transport = TcpTransport(host, port, timeout=1.0)
104+
try:
105+
transport.connect()
106+
assert transport.query("*IDN?") == "DRUCK,PACE5000,TEST,1.0"
107+
assert transport.query(":UNIT1:PRES?") == "BAR"
108+
finally:
109+
transport.close()
110+
server.join(2.0)
111+
112+
assert not server.is_alive()
113+
assert not server_errors
114+
assert received == [b"*IDN?\r\n", b":UNIT1:PRES?\r\n"]
115+
116+
64117
@pytest.mark.parametrize(
65118
("elapsed", "drop", "expected"),
66119
[

0 commit comments

Comments
 (0)