Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion cross_platform/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions cross_platform/docs/PACE_Controller_Manual.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
---
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
---

# PACE Controller

User and technical manual for version **1.0.0**.
User and technical manual for version **1.0.1**.

## 1. Scope

Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion cross_platform/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion cross_platform/src/pace_controller/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""PACE Controller cross-platform application."""

__version__ = "1.0.0"
__version__ = "1.0.1"

17 changes: 15 additions & 2 deletions cross_platform/src/pace_controller/transports.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
from .models import ConnectionConfig, ConnectionKind


PACE_TCP_COMMAND_TERMINATOR = b"\r\n"


class TransportError(RuntimeError):
pass

Expand Down Expand Up @@ -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

Expand All @@ -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")
Expand Down Expand Up @@ -338,4 +352,3 @@ def create_transport(config: ConnectionConfig) -> ScpiTransport:
timeout=config.timeout,
)
return SimulatorTransport()

55 changes: 54 additions & 1 deletion cross_platform/tests/test_core.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

import hashlib
import socket
import threading
import time
from pathlib import Path

Expand All @@ -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]
Expand Down Expand Up @@ -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"),
[
Expand Down