diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dcd436..c01f847 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes are documented here. The project follows semantic versioning. +## [1.0.1] - 2026-08-29 + +- Fixed the Ethernet connection regression introduced in v1.0.0: TCP/SCPI + commands now end in CRLF, preserving the validated v0.3.1 behaviour and the + LF terminator required by the Druck K0472 manual. +- Ignored an empty line-feed fragment when a CRLF instrument reply is split + across TCP packets. +- Added a loopback TCP regression test for the complete connection handshake. + ## [1.0.0] - 2026-08-28 - Added a separate Python/PySide6 implementation for Windows and Linux. diff --git a/CITATION.cff b/CITATION.cff index 8b616d5..6c132e6 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -7,9 +7,9 @@ authors: given-names: S. orcid: "https://orcid.org/0000-0002-9553-7788" repository-code: "https://github.com/SebRoLENS/pace-controller" -url: "https://github.com/SebRoLENS/pace-controller/releases/tag/v1.0.0" -version: "1.0.0" -date-released: 2026-08-28 +url: "https://github.com/SebRoLENS/pace-controller/releases/tag/v1.0.1" +version: "1.0.1" +date-released: 2026-08-29 license: MIT keywords: - Druck PACE diff --git a/README.md b/README.md index 255b118..6d09aae 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Cross-platform graphical controller for classic Druck **PACE 5000** and **PACE 6000** instruments using Ethernet or RS-232. -Current public version: **1.0.0** +Current public version: **1.0.1** > [!CAUTION] > 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 Use static address `192.168.10.2`, mask `255.255.255.0`, empty gateway/DNS, Access Control **Open**, and TCP SCPI port `5025`. +Ethernet commands are sent with CRLF line endings: the final LF is the SCPI +message terminator required by the Druck K0472 manual and matches the validated +legacy Windows implementation. + 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. ### RS-232 diff --git a/cross_platform/README.md b/cross_platform/README.md index e55bea0..6028b72 100644 --- a/cross_platform/README.md +++ b/cross_platform/README.md @@ -2,7 +2,7 @@ Cross-platform graphical controller for classic Druck **PACE 5000** and **PACE 6000** instruments connected through Ethernet or RS-232. -Current version: **1.0.0** +Current version: **1.0.1** > [!CAUTION] > 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: | Access control | Open | | SCPI socket | TCP `5025` | +Ethernet commands are sent with CRLF line endings. The final LF is the SCPI +message terminator specified by Druck K0472 and the complete sequence matches +the validated legacy Windows controller. + 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. ### RS-232 diff --git a/cross_platform/docs/PACE_Controller_Manual.md b/cross_platform/docs/PACE_Controller_Manual.md index 21170c1..c47e830 100644 --- a/cross_platform/docs/PACE_Controller_Manual.md +++ b/cross_platform/docs/PACE_Controller_Manual.md @@ -1,7 +1,7 @@ --- title: "PACE Controller - Cross-Platform User and Technical Manual" author: "S. Romi" -date: "Version 1.0.0 - 2026" +date: "Version 1.0.1 - 2026" geometry: margin=2.2cm colorlinks: true lang: en @@ -9,7 +9,7 @@ lang: en # PACE Controller -User and technical manual for version **1.0.0**. +User and technical manual for version **1.0.1**. ## 1. Scope @@ -66,6 +66,10 @@ Configure the PACE Ethernet parameters as follows: | Access control | Open | | SCPI port | TCP `5025` | +The Ethernet transport sends every command with a CRLF line ending. Its final +LF byte (ASCII 10) is the SCPI message terminator required by K0472, while the +complete sequence preserves the validated legacy Windows implementation. + Connect the computer and PACE directly with an Ethernet cable. Modern interfaces normally support automatic crossover. 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: diff --git a/cross_platform/pyproject.toml b/cross_platform/pyproject.toml index 0101a7a..2ffc9c4 100644 --- a/cross_platform/pyproject.toml +++ b/cross_platform/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pace-pressure-controller" -version = "1.0.0" +version = "1.0.1" description = "Cross-platform Ethernet and RS-232 controller for classic Druck PACE 5000/6000" readme = "README.md" requires-python = ">=3.10" diff --git a/cross_platform/src/pace_controller/__init__.py b/cross_platform/src/pace_controller/__init__.py index 1ce5669..474ed40 100644 --- a/cross_platform/src/pace_controller/__init__.py +++ b/cross_platform/src/pace_controller/__init__.py @@ -1,4 +1,4 @@ """PACE Controller cross-platform application.""" -__version__ = "1.0.0" +__version__ = "1.0.1" diff --git a/cross_platform/src/pace_controller/transports.py b/cross_platform/src/pace_controller/transports.py index b905d1f..ba50771 100644 --- a/cross_platform/src/pace_controller/transports.py +++ b/cross_platform/src/pace_controller/transports.py @@ -13,6 +13,9 @@ from .models import ConnectionConfig, ConnectionKind +PACE_TCP_COMMAND_TERMINATOR = b"\r\n" + + class TransportError(RuntimeError): pass @@ -71,7 +74,13 @@ def _send(self, command: str) -> None: if self._socket is None: raise TransportError("TCP transport is not connected") try: - self._socket.sendall(command.rstrip("\r\n").encode("ascii") + b"\r") + # K0472 requires LF (ASCII 10) to terminate SCPI commands. CRLF + # also preserves the byte sequence used by the validated legacy + # Windows controller. + self._socket.sendall( + command.rstrip("\r\n").encode("ascii") + + PACE_TCP_COMMAND_TERMINATOR + ) except OSError as exc: raise TransportError(f"TCP write failed: {exc}") from exc @@ -87,6 +96,11 @@ def _read_line(self) -> str: del self._buffer[: position + 1] while self._buffer[:1] in (b"\r", b"\n"): del self._buffer[:1] + if not raw: + # A CRLF reply can be split across TCP packets. If + # CR completed the previous response, ignore the + # delayed LF instead of returning an empty response. + continue return raw.decode("ascii", errors="replace").strip() if time.monotonic() >= deadline: raise TransportError("Timed out waiting for the PACE response") @@ -338,4 +352,3 @@ def create_transport(config: ConnectionConfig) -> ScpiTransport: timeout=config.timeout, ) return SimulatorTransport() - diff --git a/cross_platform/tests/test_core.py b/cross_platform/tests/test_core.py index fd9538b..82f495d 100644 --- a/cross_platform/tests/test_core.py +++ b/cross_platform/tests/test_core.py @@ -1,6 +1,8 @@ from __future__ import annotations import hashlib +import socket +import threading import time from pathlib import Path @@ -11,7 +13,7 @@ from pace_controller.leak import LeakMonitor from pace_controller.models import LeakThresholds from pace_controller.service import scpi_float, scpi_number, scpi_numbers, scpi_payload -from pace_controller.transports import SimulatorTransport +from pace_controller.transports import SimulatorTransport, TcpTransport ROOT = Path(__file__).resolve().parents[2] @@ -61,6 +63,57 @@ def test_simulator_accepts_same_scpi_as_real_transport() -> None: device.close() +def test_tcp_transport_matches_validated_pace_line_endings() -> None: + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + host, port = listener.getsockname() + received: list[bytes] = [] + server_errors: list[BaseException] = [] + + def receive_command(connection: socket.socket) -> bytes: + payload = bytearray() + while not payload.endswith(b"\n"): + chunk = connection.recv(1024) + if not chunk: + break + payload.extend(chunk) + return bytes(payload) + + def serve() -> None: + try: + connection, _ = listener.accept() + with connection: + connection.settimeout(2.0) + received.append(receive_command(connection)) + connection.sendall(b"DRUCK,PACE5000,TEST,1.0\r") + time.sleep(0.05) + connection.sendall(b"\n") + received.append(receive_command(connection)) + time.sleep(0.05) + connection.sendall(b"BAR\r\n") + except BaseException as exc: # surfaced in the test thread + server_errors.append(exc) + finally: + listener.close() + + server = threading.Thread(target=serve, daemon=True) + server.start() + transport = TcpTransport(host, port, timeout=1.0) + try: + transport.connect() + assert transport.query("*IDN?") == "DRUCK,PACE5000,TEST,1.0" + assert transport.query(":UNIT1:PRES?") == "BAR" + finally: + transport.close() + server.join(2.0) + + assert not server.is_alive() + assert not server_errors + assert received == [b"*IDN?\r\n", b":UNIT1:PRES?\r\n"] + + @pytest.mark.parametrize( ("elapsed", "drop", "expected"), [