From 11621ad6349171abc7640f99e0b37f001cbb43a8 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 12 Mar 2026 16:41:31 +0000 Subject: [PATCH 01/56] first crack at test --- src/sm_bluesky/common/server/__init__.py | 3 + .../server/abstract_instrument_server.py | 41 +++++++ tests/common/server/__init__.py | 0 .../server/test_abstract_instrument_server.py | 115 ++++++++++++++++++ 4 files changed, 159 insertions(+) create mode 100644 src/sm_bluesky/common/server/__init__.py create mode 100644 src/sm_bluesky/common/server/abstract_instrument_server.py create mode 100644 tests/common/server/__init__.py create mode 100644 tests/common/server/test_abstract_instrument_server.py diff --git a/src/sm_bluesky/common/server/__init__.py b/src/sm_bluesky/common/server/__init__.py new file mode 100644 index 00000000..fe40a135 --- /dev/null +++ b/src/sm_bluesky/common/server/__init__.py @@ -0,0 +1,3 @@ +from .abstract_instrument_server import AbstractInstrumentServer + +__all__ = ["AbstractInstrumentServer"] diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py new file mode 100644 index 00000000..0f132123 --- /dev/null +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -0,0 +1,41 @@ +from abc import abstractmethod +from socket import socket + + +class AbstractInstrumentServer: + def __init__(self, host: str, port: int): + self.host: str = host + self.port: int = port + self._is_running: bool = False + self._hardwarde_connected: bool = False + self._server_socket: socket + + def start(self) -> None: + pass + + def stop(self) -> None: + pass + + def _run_command_loop(self) -> None: + pass + + def _send_ack(self) -> None: + pass + + def _send_error(self, error_message: str) -> None: + pass + + def _send_response(self, response: str) -> None: + pass + + @abstractmethod + def connect_hardware(self) -> None: + raise NotImplementedError("Subclasses must implement connect_hardware") + + @abstractmethod + def disconnect_hardware(self) -> None: + raise NotImplementedError("Subclasses must implement disconnect_hardware") + + @abstractmethod + def _handle_command(self, cmd: bytes, arg: bytes) -> None: + raise NotImplementedError("Subclasses must implement handle_command") diff --git a/tests/common/server/__init__.py b/tests/common/server/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py new file mode 100644 index 00000000..fe274811 --- /dev/null +++ b/tests/common/server/test_abstract_instrument_server.py @@ -0,0 +1,115 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from sm_bluesky.common.server import AbstractInstrumentServer + + +class MockInstrument(AbstractInstrumentServer): + def connect_hardware(self) -> None: + self._hardwarde_connected = True + + def disconnect_hardware(self) -> None: + self._hardwarde_connected = False + + def _handle_command(self, cmd: bytes, arg: bytes) -> None: + if cmd == b"shutdown": + self._send_response("Shutting down server") + self.stop() + if cmd == b"ping": + self._send_ack() + if cmd == "disconnect": + self.disconnect_hardware() + + self._send_error("Unknown command") + + +@pytest.fixture +def mock_instrument(): + return MockInstrument(host="localhost", port=8888) + + +def test_connect_hardware(mock_instrument: AbstractInstrumentServer): + assert mock_instrument.connect_hardware() is True + + +def test_start_server_success( + mock_instrument: AbstractInstrumentServer, caplog: pytest.LogCaptureFixture +): + mock_instrument.start() + mock_instrument._run_command_loop = MagicMock() + assert mock_instrument._is_running is True + assert mock_instrument.connect_hardware() is True + assert "Hardware connected successfully" in caplog.text + assert ( + f"Server started on {mock_instrument.host}:{mock_instrument.port}" + in caplog.text + ) + assert mock_instrument._run_command_loop.assert_called_once() + + +def test_start_server_failure_hardware( + mock_instrument: AbstractInstrumentServer, caplog: pytest.LogCaptureFixture +): + # Simulate hardware connection failure by overriding the method + mock_instrument.connect_hardware = MagicMock( + side_effect=Exception("Simulated Hardware Failure") + ) + pytest.raises( + Exception, match="Simulated Hardware Failure", func=mock_instrument.start + ) + assert mock_instrument._is_running is False + + +@patch( + "sm_bluesky.common.server.abstract_instrument_server.AbstractInstrumentServer.socket.socket", + autospec=True, +) +def test_start_server_failure_socket( + mock_socket: MagicMock, + mock_instrument: AbstractInstrumentServer, + caplog: pytest.LogCaptureFixture, +): + + # Simulate socket failure by overriding the method + mock_socket.side_effect = Exception("Simulated Socket Failure") + mock_instrument.start() + assert mock_instrument._is_running is False + assert "Failed to start server" in caplog.text + assert "Simulated Socket Failure" in caplog.text + + +def test_stop_server( + mock_instrument: AbstractInstrumentServer, caplog: pytest.LogCaptureFixture +): + mock_instrument._server_socket = MagicMock() + mock_instrument.start() + assert mock_instrument._is_running is True + assert mock_instrument._server_socket is not None + assert mock_instrument._hardwarde_connected is True + mock_instrument._handle_command(b"shutdown", b"") + assert mock_instrument._hardwarde_connected is False + assert mock_instrument._is_running is False + assert "Server stopped" in caplog.text + + +def test_send_ack(mock_instrument: AbstractInstrumentServer): + mock_instrument._server_socket.sendall = MagicMock() + mock_instrument._handle_command(b"ping", b"") + assert mock_instrument._server_socket.sendall.assert_called_once_with(b"1\n") + + +def test_send_error(mock_instrument: AbstractInstrumentServer): + mock_instrument._server_socket.sendall = MagicMock() + mock_instrument._handle_command(b"unknown", b"") + assert mock_instrument._server_socket.sendall.assert_called_once_with( + b"0\tUnknown command\n" + ) + + +def test_send_response(mock_instrument: AbstractInstrumentServer): + mock_instrument._server_socket.sendall = MagicMock() + mock_instrument._handle_command(b"shutdown", b"") + assert mock_instrument._server_socket.sendall.assert_called_once_with( + b"1\tShutting down server\n" + ) From 6bcc51bb9e110d9f8bd7b057e571dfe7909bb9f0 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 12 Mar 2026 16:53:07 +0000 Subject: [PATCH 02/56] fixing typos --- .../common/server/abstract_instrument_server.py | 4 ++-- .../server/test_abstract_instrument_server.py | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 0f132123..f0d67337 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -7,7 +7,7 @@ def __init__(self, host: str, port: int): self.host: str = host self.port: int = port self._is_running: bool = False - self._hardwarde_connected: bool = False + self._hardware_connected: bool = False self._server_socket: socket def start(self) -> None: @@ -25,7 +25,7 @@ def _send_ack(self) -> None: def _send_error(self, error_message: str) -> None: pass - def _send_response(self, response: str) -> None: + def _send_response(self, response: str = "") -> None: pass @abstractmethod diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index fe274811..897aaea5 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -7,7 +7,7 @@ class MockInstrument(AbstractInstrumentServer): def connect_hardware(self) -> None: - self._hardwarde_connected = True + self._hardware_connected = True def disconnect_hardware(self) -> None: self._hardwarde_connected = False @@ -18,7 +18,7 @@ def _handle_command(self, cmd: bytes, arg: bytes) -> None: self.stop() if cmd == b"ping": self._send_ack() - if cmd == "disconnect": + if cmd == b"disconnect": self.disconnect_hardware() self._send_error("Unknown command") @@ -86,9 +86,9 @@ def test_stop_server( mock_instrument.start() assert mock_instrument._is_running is True assert mock_instrument._server_socket is not None - assert mock_instrument._hardwarde_connected is True + assert mock_instrument._hardware_connected is True mock_instrument._handle_command(b"shutdown", b"") - assert mock_instrument._hardwarde_connected is False + assert mock_instrument._hardware_connected is False assert mock_instrument._is_running is False assert "Server stopped" in caplog.text @@ -96,13 +96,13 @@ def test_stop_server( def test_send_ack(mock_instrument: AbstractInstrumentServer): mock_instrument._server_socket.sendall = MagicMock() mock_instrument._handle_command(b"ping", b"") - assert mock_instrument._server_socket.sendall.assert_called_once_with(b"1\n") + mock_instrument._server_socket.sendall.assert_called_once_with(b"1\n") def test_send_error(mock_instrument: AbstractInstrumentServer): mock_instrument._server_socket.sendall = MagicMock() mock_instrument._handle_command(b"unknown", b"") - assert mock_instrument._server_socket.sendall.assert_called_once_with( + mock_instrument._server_socket.sendall.assert_called_once_with( b"0\tUnknown command\n" ) @@ -110,6 +110,6 @@ def test_send_error(mock_instrument: AbstractInstrumentServer): def test_send_response(mock_instrument: AbstractInstrumentServer): mock_instrument._server_socket.sendall = MagicMock() mock_instrument._handle_command(b"shutdown", b"") - assert mock_instrument._server_socket.sendall.assert_called_once_with( + mock_instrument._server_socket.sendall.assert_called_once_with( b"1\tShutting down server\n" ) From 6d6d19db3c50a170576b14c164603a7ac22cd21c Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 13 Mar 2026 13:41:20 +0000 Subject: [PATCH 03/56] add start server --- .../server/abstract_instrument_server.py | 49 ++++++++++++-- .../server/test_abstract_instrument_server.py | 67 +++++++++++-------- 2 files changed, 84 insertions(+), 32 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index f0d67337..6e108c2e 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -1,5 +1,8 @@ +import socket from abc import abstractmethod -from socket import socket +from contextlib import contextmanager + +from sm_bluesky.log import LOGGER class AbstractInstrumentServer: @@ -8,10 +11,48 @@ def __init__(self, host: str, port: int): self.port: int = port self._is_running: bool = False self._hardware_connected: bool = False - self._server_socket: socket + self._server_socket: socket.socket + self._conn: socket.socket | None = None def start(self) -> None: - pass + self._is_running = True + + self._hardware_connected = self.connect_hardware() + if not self._hardware_connected: + self._is_running = False + LOGGER.error("Failed to connect hardware") + raise RuntimeError("Failed to connect hardware") + LOGGER.info("Hardware connected successfully") + self._server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._server_socket.bind((self.host, self.port)) + self._server_socket.listen() + self._server_socket.settimeout(1.0) + self._is_running = True + + LOGGER.info(f"Server started listening on {self.host}:{self.port}") + + while self._is_running: + try: + client_info = self._server_socket.accept() + LOGGER.info(f"Connection accepted from {client_info}") + with self._manage_connection(client_info): + self._run_command_loop() + except TimeoutError: + continue + except Exception as e: + LOGGER.error(f"Error in server loop: {e}") + self._is_running = False + + @contextmanager + def _manage_connection(self, client_info: tuple[socket.socket, str]): + self._conn, addr = client_info + LOGGER.info(f"Client {addr} connected. Server busy.") + try: + yield + finally: + self._conn.close() + self._conn = None + LOGGER.info(f"Client {addr} disconnected. Server idle.") def stop(self) -> None: pass @@ -29,7 +70,7 @@ def _send_response(self, response: str = "") -> None: pass @abstractmethod - def connect_hardware(self) -> None: + def connect_hardware(self) -> bool: raise NotImplementedError("Subclasses must implement connect_hardware") @abstractmethod diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 897aaea5..3eeb43a7 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -6,11 +6,12 @@ class MockInstrument(AbstractInstrumentServer): - def connect_hardware(self) -> None: + def connect_hardware(self) -> bool: self._hardware_connected = True + return True def disconnect_hardware(self) -> None: - self._hardwarde_connected = False + self._hardware_connected = False def _handle_command(self, cmd: bytes, arg: bytes) -> None: if cmd == b"shutdown": @@ -30,53 +31,63 @@ def mock_instrument(): def test_connect_hardware(mock_instrument: AbstractInstrumentServer): - assert mock_instrument.connect_hardware() is True + assert mock_instrument._hardware_connected is False + mock_instrument.connect_hardware() + assert mock_instrument._hardware_connected is True +@patch("socket.socket") def test_start_server_success( - mock_instrument: AbstractInstrumentServer, caplog: pytest.LogCaptureFixture + mock_socket_class: MagicMock, + mock_instrument: AbstractInstrumentServer, + caplog: pytest.LogCaptureFixture, ): - mock_instrument.start() - mock_instrument._run_command_loop = MagicMock() - assert mock_instrument._is_running is True - assert mock_instrument.connect_hardware() is True - assert "Hardware connected successfully" in caplog.text - assert ( - f"Server started on {mock_instrument.host}:{mock_instrument.port}" - in caplog.text + mock_server_socket = MagicMock() + mock_socket_class.return_value = mock_server_socket + mock_client_socket = MagicMock() + mock_server_socket.accept.return_value = (mock_client_socket, ("localhost", 8888)) + + mock_instrument._run_command_loop = lambda: setattr( + mock_instrument, "_is_running", False ) - assert mock_instrument._run_command_loop.assert_called_once() + mock_instrument.start() + + mock_server_socket.bind.assert_called_with(("localhost", 8888)) + assert "Server started listening on localhost:8888" in caplog.text + mock_server_socket.listen.assert_called_once() + mock_server_socket.accept.assert_called_once() + assert mock_instrument._is_running is False + assert "Connection accepted from" in caplog.text def test_start_server_failure_hardware( mock_instrument: AbstractInstrumentServer, caplog: pytest.LogCaptureFixture ): # Simulate hardware connection failure by overriding the method - mock_instrument.connect_hardware = MagicMock( - side_effect=Exception("Simulated Hardware Failure") - ) - pytest.raises( - Exception, match="Simulated Hardware Failure", func=mock_instrument.start - ) + mock_instrument.connect_hardware = MagicMock(side_effect=[False]) + with pytest.raises(RuntimeError, match="Failed to connect hardware"): + mock_instrument.start() + assert "Failed to connect hardware" in caplog.text + assert mock_instrument._is_running is False -@patch( - "sm_bluesky.common.server.abstract_instrument_server.AbstractInstrumentServer.socket.socket", - autospec=True, -) -def test_start_server_failure_socket( +@patch("socket.socket") +def test_start_server_failure_on_accept( mock_socket: MagicMock, mock_instrument: AbstractInstrumentServer, caplog: pytest.LogCaptureFixture, ): + error_message = "Simulated socket error" + mock_instance = MagicMock() + mock_socket.return_value = mock_instance - # Simulate socket failure by overriding the method - mock_socket.side_effect = Exception("Simulated Socket Failure") + mock_instance.accept.side_effect = Exception(error_message) mock_instrument.start() + assert mock_instrument._is_running is False - assert "Failed to start server" in caplog.text - assert "Simulated Socket Failure" in caplog.text + assert f"Error in server loop: {error_message}" in caplog.text + assert mock_instrument._conn is None def test_stop_server( From 4835373f76cc96165901fd098ae1250a9dc27375 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 13 Mar 2026 15:18:30 +0000 Subject: [PATCH 04/56] added stop --- .../server/abstract_instrument_server.py | 42 +++++++++++++++---- .../server/test_abstract_instrument_server.py | 22 ++++++---- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 6e108c2e..d9a07566 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -50,24 +50,50 @@ def _manage_connection(self, client_info: tuple[socket.socket, str]): try: yield finally: - self._conn.close() - self._conn = None - LOGGER.info(f"Client {addr} disconnected. Server idle.") + self._disconnect_client() + LOGGER.info(f"Client {addr} disconnected. Server ready.") def stop(self) -> None: - pass + + self._disconnect_client() + if hasattr(self, "_server_socket"): + self._server_socket.close() + if self._hardware_connected: + self.disconnect_hardware() + self._hardware_connected = False + self._is_running = False + LOGGER.info("Server stopped successfully") + + def _disconnect_client(self) -> None: + if self._conn: + self._conn.close() + self._conn = None + LOGGER.info("Client disconnected") def _run_command_loop(self) -> None: - pass + if self._conn is None: + LOGGER.error("No client connection available to run command loop") + return + queued_data = self._conn.recv(1024).splitlines() + if not queued_data: + queued_data = [b"disconnect"] + for line in queued_data: + if b"\t" in line: + cmd, arg = line.split(b"\t", 1) + else: + cmd, arg = line, b"" + self._handle_command(cmd, arg) def _send_ack(self) -> None: - pass + self._send_response() def _send_error(self, error_message: str) -> None: - pass + if self._conn: + self._conn.sendall(b"0\t" + error_message.encode() + b"\n") def _send_response(self, response: str = "") -> None: - pass + if self._conn: + self._conn.sendall(b"1\t" + response.encode() + b"\n") @abstractmethod def connect_hardware(self) -> bool: diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 3eeb43a7..ac6676f6 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -90,24 +90,30 @@ def test_start_server_failure_on_accept( assert mock_instrument._conn is None +@patch("socket.socket", autospec=True) def test_stop_server( - mock_instrument: AbstractInstrumentServer, caplog: pytest.LogCaptureFixture + mock_socket_class: MagicMock, + mock_instrument: AbstractInstrumentServer, + caplog: pytest.LogCaptureFixture, ): - mock_instrument._server_socket = MagicMock() + mock_server_socket = MagicMock() + mock_socket_class.return_value = mock_server_socket + mock_client_socket = MagicMock() + mock_instrument._conn = mock_client_socket + mock_server_socket.accept.return_value = (mock_client_socket, ("localhost", 8888)) + mock_instrument._conn.recv = MagicMock(return_value=b"shutdown\t") mock_instrument.start() - assert mock_instrument._is_running is True - assert mock_instrument._server_socket is not None - assert mock_instrument._hardware_connected is True - mock_instrument._handle_command(b"shutdown", b"") + # mock_instrument._handle_command(b"shutdown", b"") assert mock_instrument._hardware_connected is False assert mock_instrument._is_running is False assert "Server stopped" in caplog.text +@patch("socket.socket") def test_send_ack(mock_instrument: AbstractInstrumentServer): - mock_instrument._server_socket.sendall = MagicMock() + mock_instrument._conn.sendall = MagicMock() mock_instrument._handle_command(b"ping", b"") - mock_instrument._server_socket.sendall.assert_called_once_with(b"1\n") + mock_instrument._conn.sendall.assert_called_once_with(b"1\n") def test_send_error(mock_instrument: AbstractInstrumentServer): From 0a416534d7ffb3385183ec6f69dae7ed6a6ca3a2 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 13 Mar 2026 15:41:33 +0000 Subject: [PATCH 05/56] add responses --- .../server/test_abstract_instrument_server.py | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index ac6676f6..ffdf469d 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -17,12 +17,12 @@ def _handle_command(self, cmd: bytes, arg: bytes) -> None: if cmd == b"shutdown": self._send_response("Shutting down server") self.stop() - if cmd == b"ping": + elif cmd == b"ping": self._send_ack() - if cmd == b"disconnect": + elif cmd == b"disconnect": self.disconnect_hardware() - - self._send_error("Unknown command") + else: + self._send_error("Unknown command") @pytest.fixture @@ -103,30 +103,27 @@ def test_stop_server( mock_server_socket.accept.return_value = (mock_client_socket, ("localhost", 8888)) mock_instrument._conn.recv = MagicMock(return_value=b"shutdown\t") mock_instrument.start() - # mock_instrument._handle_command(b"shutdown", b"") assert mock_instrument._hardware_connected is False assert mock_instrument._is_running is False assert "Server stopped" in caplog.text -@patch("socket.socket") def test_send_ack(mock_instrument: AbstractInstrumentServer): + mock_instrument._conn = MagicMock() mock_instrument._conn.sendall = MagicMock() mock_instrument._handle_command(b"ping", b"") - mock_instrument._conn.sendall.assert_called_once_with(b"1\n") + mock_instrument._conn.sendall.assert_called_once_with(b"1\t\n") -def test_send_error(mock_instrument: AbstractInstrumentServer): - mock_instrument._server_socket.sendall = MagicMock() - mock_instrument._handle_command(b"unknown", b"") - mock_instrument._server_socket.sendall.assert_called_once_with( - b"0\tUnknown command\n" - ) +def test_send_unknow_command_error(mock_instrument: AbstractInstrumentServer): + mock_instrument._conn = MagicMock() + mock_instrument._conn.sendall = MagicMock() + mock_instrument._handle_command(b"sdljkfnsdouifn", b"") + mock_instrument._conn.sendall.assert_called_once_with(b"0\tUnknown command\n") def test_send_response(mock_instrument: AbstractInstrumentServer): - mock_instrument._server_socket.sendall = MagicMock() - mock_instrument._handle_command(b"shutdown", b"") - mock_instrument._server_socket.sendall.assert_called_once_with( - b"1\tShutting down server\n" - ) + mock_instrument._conn = MagicMock() + mock_instrument._conn.sendall = MagicMock() + mock_instrument._send_response("data data data") + mock_instrument._conn.sendall.assert_called_once_with(b"1\tdata data data\n") From c5701cc87e1086600b597f44e77b1780fac1bc99 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 13 Mar 2026 16:06:31 +0000 Subject: [PATCH 06/56] add test for timeout --- .../server/test_abstract_instrument_server.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index ffdf469d..a92bb017 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -1,3 +1,4 @@ +import socket from unittest.mock import MagicMock, patch import pytest @@ -60,6 +61,25 @@ def test_start_server_success( assert "Connection accepted from" in caplog.text +@patch("socket.socket") +def test_start_handles_timeout(mock_socket_class, mock_instrument): + mock_instance = MagicMock() + mock_socket_class.return_value = mock_instance + mock_instance.accept.side_effect = [ + socket.timeout, + (MagicMock(), ("127.0.0.1", 1234)), + ] + + with patch.object( + mock_instrument, + "_run_command_loop", + side_effect=lambda: setattr(mock_instrument, "_is_running", False), + ): + mock_instrument.start() + + assert mock_instance.accept.call_count == 2 + + def test_start_server_failure_hardware( mock_instrument: AbstractInstrumentServer, caplog: pytest.LogCaptureFixture ): From bbe2f058cb78b6dbb0603089baa2fd97b372e4f4 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 13 Mar 2026 16:22:45 +0000 Subject: [PATCH 07/56] add _run_command_loop --- .../server/abstract_instrument_server.py | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index d9a07566..4944de8f 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -74,15 +74,34 @@ def _run_command_loop(self) -> None: if self._conn is None: LOGGER.error("No client connection available to run command loop") return - queued_data = self._conn.recv(1024).splitlines() - if not queued_data: - queued_data = [b"disconnect"] - for line in queued_data: - if b"\t" in line: - cmd, arg = line.split(b"\t", 1) - else: - cmd, arg = line, b"" + buffer = b"" + while self._is_running: + try: + chunk = self._conn.recv(1024) + if not chunk: + break + buffer += chunk + + while b"\n" in buffer: + line, buffer = buffer.split(b"\n", 1) + if line: # Ignore empty lines + self._process_line(line) + + except (OSError, ConnectionResetError): + break + + def _process_line(self, line: bytes) -> None: + line = line.strip() + + if b"\t" in line: + cmd, arg = line.split(b"\t", 1) + else: + cmd, arg = line, b"" + + try: self._handle_command(cmd, arg) + except Exception as e: + self._send_error(str(e)) def _send_ack(self) -> None: self._send_response() @@ -96,13 +115,10 @@ def _send_response(self, response: str = "") -> None: self._conn.sendall(b"1\t" + response.encode() + b"\n") @abstractmethod - def connect_hardware(self) -> bool: - raise NotImplementedError("Subclasses must implement connect_hardware") + def connect_hardware(self) -> bool: ... @abstractmethod - def disconnect_hardware(self) -> None: - raise NotImplementedError("Subclasses must implement disconnect_hardware") + def disconnect_hardware(self) -> None: ... @abstractmethod - def _handle_command(self, cmd: bytes, arg: bytes) -> None: - raise NotImplementedError("Subclasses must implement handle_command") + def _handle_command(self, cmd: bytes, arg: bytes) -> None: ... From 429cd543874cf1f58da6a9fd655d240503140582 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 13 Mar 2026 16:24:53 +0000 Subject: [PATCH 08/56] rename command loop to serve_client --- src/sm_bluesky/common/server/abstract_instrument_server.py | 4 ++-- tests/common/server/test_abstract_instrument_server.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 4944de8f..914d4e0e 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -36,7 +36,7 @@ def start(self) -> None: client_info = self._server_socket.accept() LOGGER.info(f"Connection accepted from {client_info}") with self._manage_connection(client_info): - self._run_command_loop() + self._serve_client() except TimeoutError: continue except Exception as e: @@ -70,7 +70,7 @@ def _disconnect_client(self) -> None: self._conn = None LOGGER.info("Client disconnected") - def _run_command_loop(self) -> None: + def _serve_client(self) -> None: if self._conn is None: LOGGER.error("No client connection available to run command loop") return diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index a92bb017..1ee6bf7e 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -48,7 +48,7 @@ def test_start_server_success( mock_client_socket = MagicMock() mock_server_socket.accept.return_value = (mock_client_socket, ("localhost", 8888)) - mock_instrument._run_command_loop = lambda: setattr( + mock_instrument._serve_client = lambda: setattr( mock_instrument, "_is_running", False ) mock_instrument.start() @@ -72,7 +72,7 @@ def test_start_handles_timeout(mock_socket_class, mock_instrument): with patch.object( mock_instrument, - "_run_command_loop", + "_serve_client", side_effect=lambda: setattr(mock_instrument, "_is_running", False), ): mock_instrument.start() From 061b29dc3fb465c044cb99ef8ff043f214b9917d Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 13 Mar 2026 16:45:38 +0000 Subject: [PATCH 09/56] rename process lline --- src/sm_bluesky/common/server/abstract_instrument_server.py | 5 ++--- tests/common/server/test_abstract_instrument_server.py | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 914d4e0e..9477321f 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -85,13 +85,12 @@ def _serve_client(self) -> None: while b"\n" in buffer: line, buffer = buffer.split(b"\n", 1) if line: # Ignore empty lines - self._process_line(line) + self._dispatch_command(line.strip()) except (OSError, ConnectionResetError): break - def _process_line(self, line: bytes) -> None: - line = line.strip() + def _dispatch_command(self, line: bytes) -> None: if b"\t" in line: cmd, arg = line.split(b"\t", 1) diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 1ee6bf7e..18bfafb0 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -110,7 +110,7 @@ def test_start_server_failure_on_accept( assert mock_instrument._conn is None -@patch("socket.socket", autospec=True) +@patch("socket.socket") def test_stop_server( mock_socket_class: MagicMock, mock_instrument: AbstractInstrumentServer, @@ -121,7 +121,7 @@ def test_stop_server( mock_client_socket = MagicMock() mock_instrument._conn = mock_client_socket mock_server_socket.accept.return_value = (mock_client_socket, ("localhost", 8888)) - mock_instrument._conn.recv = MagicMock(return_value=b"shutdown\t") + mock_instrument._conn.recv = MagicMock(return_value=b"shutdown\t\n") mock_instrument.start() assert mock_instrument._hardware_connected is False assert mock_instrument._is_running is False From 1676c97896a9e7121bd3ecd42275b960c43a6728 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 16 Mar 2026 11:33:17 +0000 Subject: [PATCH 10/56] correct timeout and allow ipv6 --- src/sm_bluesky/common/server/abstract_instrument_server.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 9477321f..67bd1f8b 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -6,13 +6,14 @@ class AbstractInstrumentServer: - def __init__(self, host: str, port: int): + def __init__(self, host: str, port: int, ipv6: bool = False): self.host: str = host self.port: int = port self._is_running: bool = False self._hardware_connected: bool = False self._server_socket: socket.socket self._conn: socket.socket | None = None + self.address_type = socket.AF_INET6 if ipv6 else socket.AF_INET def start(self) -> None: self._is_running = True @@ -23,7 +24,7 @@ def start(self) -> None: LOGGER.error("Failed to connect hardware") raise RuntimeError("Failed to connect hardware") LOGGER.info("Hardware connected successfully") - self._server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._server_socket = socket.socket(self.address_type, socket.SOCK_STREAM) self._server_socket.bind((self.host, self.port)) self._server_socket.listen() self._server_socket.settimeout(1.0) @@ -37,7 +38,7 @@ def start(self) -> None: LOGGER.info(f"Connection accepted from {client_info}") with self._manage_connection(client_info): self._serve_client() - except TimeoutError: + except socket.timeout: # noqa: UP041 continue except Exception as e: LOGGER.error(f"Error in server loop: {e}") From 9821bca59526f2221c4ab7c787e61aa4daf5033b Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 16 Mar 2026 13:37:40 +0000 Subject: [PATCH 11/56] added error testing and full cycle test --- .../server/abstract_instrument_server.py | 3 +- .../server/test_abstract_instrument_server.py | 70 ++++++++++++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 67bd1f8b..3b80fe22 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -85,10 +85,11 @@ def _serve_client(self) -> None: while b"\n" in buffer: line, buffer = buffer.split(b"\n", 1) - if line: # Ignore empty lines + if line: self._dispatch_command(line.strip()) except (OSError, ConnectionResetError): + LOGGER.error("Client connection lost unexpectedly") break def _dispatch_command(self, line: bytes) -> None: diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 18bfafb0..097a2c0b 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -67,7 +67,7 @@ def test_start_handles_timeout(mock_socket_class, mock_instrument): mock_socket_class.return_value = mock_instance mock_instance.accept.side_effect = [ socket.timeout, - (MagicMock(), ("127.0.0.1", 1234)), + (MagicMock(), ("8.8.8.8", 1234)), ] with patch.object( @@ -147,3 +147,71 @@ def test_send_response(mock_instrument: AbstractInstrumentServer): mock_instrument._conn.sendall = MagicMock() mock_instrument._send_response("data data data") mock_instrument._conn.sendall.assert_called_once_with(b"1\tdata data data\n") + + +def test_serve_client_eof( + mock_instrument: AbstractInstrumentServer, +): + mock_conn = MagicMock() + mock_instrument._conn = mock_conn + mock_conn.recv.side_effect = [b"", b"shutdown\t\n"] + mock_instrument._is_running = True + mock_instrument._serve_client() + mock_instrument._conn.recv.assert_called_once() + + +def test_serve_client_no_client_connected( + mock_instrument: AbstractInstrumentServer, + caplog: pytest.LogCaptureFixture, +): + mock_instrument._conn = None + mock_instrument._serve_client() + assert "No client connection available to run command loop" in caplog.text + + +def test_serve_client_exception( + mock_instrument: AbstractInstrumentServer, + caplog: pytest.LogCaptureFixture, +): + mock_conn = MagicMock() + mock_instrument._conn = mock_conn + mock_conn.recv.side_effect = [ + OSError("Simulated connection error"), + b"shutdown\t\n", + ] + mock_instrument._is_running = True + mock_instrument._serve_client() + mock_instrument._conn.recv.assert_called_once() + assert "Client connection lost unexpectedly" in caplog.text + + +def test_full_connection_cycle_cleanup(mock_instrument, caplog): + mock_instance = MagicMock() + mock_instance.accept.return_value = (MagicMock(), ("8.8.8.8", 1234)) + mock_instrument._server_socket = mock_instance + + with patch.object(mock_instrument, "_serve_client", side_effect=None): + client_info = (MagicMock(), "8.8.8.8") + with mock_instrument._manage_connection(client_info): # + pass + assert mock_instrument._conn is None + assert mock_instance.close.called_once() + assert "Client disconnected" in caplog.text + + +def test_dispatch_command_exception_handling( + mock_instrument: AbstractInstrumentServer, caplog: pytest.LogCaptureFixture +): + mock_instrument._dispatch_command(b"test exception") + mock_instrument._handle_command = MagicMock(side_effect=Exception("Test exception")) + mock_instrument._send_error = MagicMock() + mock_instrument._dispatch_command(b"test exception") + mock_instrument._send_error.assert_called_once_with("Test exception") + + +def test_dispatch_command_with_arg(mock_instrument: AbstractInstrumentServer): + mock_instrument._handle_command = MagicMock() + mock_instrument._dispatch_command(b"command\targument\targument2") + mock_instrument._handle_command.assert_called_once_with( + b"command", b"argument\targument2" + ) From 51c2db4b871b609e5177c77a13848434b9ebec9c Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 16 Mar 2026 13:54:41 +0000 Subject: [PATCH 12/56] Add minimal docstring --- .../server/abstract_instrument_server.py | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 3b80fe22..9d342c70 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -6,6 +6,13 @@ class AbstractInstrumentServer: + """ + Base class for TCP instrument servers. + + Handles socket lifecycle, connection management, and buffered command + parsing. Subclasses must implement hardware-specific control logic. + """ + def __init__(self, host: str, port: int, ipv6: bool = False): self.host: str = host self.port: int = port @@ -16,6 +23,7 @@ def __init__(self, host: str, port: int, ipv6: bool = False): self.address_type = socket.AF_INET6 if ipv6 else socket.AF_INET def start(self) -> None: + """Initializes the server, connects hardware, and enters the listening loop.""" self._is_running = True self._hardware_connected = self.connect_hardware() @@ -46,6 +54,7 @@ def start(self) -> None: @contextmanager def _manage_connection(self, client_info: tuple[socket.socket, str]): + """Manages the lifecycle of a client connection with automatic cleanup.""" self._conn, addr = client_info LOGGER.info(f"Client {addr} connected. Server busy.") try: @@ -55,7 +64,7 @@ def _manage_connection(self, client_info: tuple[socket.socket, str]): LOGGER.info(f"Client {addr} disconnected. Server ready.") def stop(self) -> None: - + """Stops the server, closes sockets, and disconnects hardware.""" self._disconnect_client() if hasattr(self, "_server_socket"): self._server_socket.close() @@ -72,6 +81,7 @@ def _disconnect_client(self) -> None: LOGGER.info("Client disconnected") def _serve_client(self) -> None: + """Reads stream data from the client and handles command buffering.""" if self._conn is None: LOGGER.error("No client connection available to run command loop") return @@ -93,7 +103,7 @@ def _serve_client(self) -> None: break def _dispatch_command(self, line: bytes) -> None: - + """Parses raw input into command/argument pairs and executes the handler.""" if b"\t" in line: cmd, arg = line.split(b"\t", 1) else: @@ -116,10 +126,13 @@ def _send_response(self, response: str = "") -> None: self._conn.sendall(b"1\t" + response.encode() + b"\n") @abstractmethod - def connect_hardware(self) -> bool: ... + def connect_hardware(self) -> bool: + """Establishes connection to the specific hardware device.""" @abstractmethod - def disconnect_hardware(self) -> None: ... + def disconnect_hardware(self) -> None: + """Disconnect from the hardware device.""" @abstractmethod - def _handle_command(self, cmd: bytes, arg: bytes) -> None: ... + def _handle_command(self, cmd: bytes, arg: bytes) -> None: + """Executes logic for a specific instrument command.""" From 4fb19a05b7c76f3ec4c14fdb5ffd568d67bbc47e Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 16 Mar 2026 13:56:38 +0000 Subject: [PATCH 13/56] add abc to class --- src/sm_bluesky/common/server/abstract_instrument_server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 9d342c70..8f8d95b4 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -1,11 +1,11 @@ import socket -from abc import abstractmethod +from abc import ABC, abstractmethod from contextlib import contextmanager from sm_bluesky.log import LOGGER -class AbstractInstrumentServer: +class AbstractInstrumentServer(ABC): """ Base class for TCP instrument servers. From 1e582d7b7fd4e5f749b74fc1c93d05096cf70547 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 16 Mar 2026 14:11:57 +0000 Subject: [PATCH 14/56] correct test --- tests/common/server/test_abstract_instrument_server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 097a2c0b..edeb2da5 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -192,10 +192,10 @@ def test_full_connection_cycle_cleanup(mock_instrument, caplog): with patch.object(mock_instrument, "_serve_client", side_effect=None): client_info = (MagicMock(), "8.8.8.8") - with mock_instrument._manage_connection(client_info): # + with mock_instrument._manage_connection(client_info): pass + client_info[0].close.assert_called_once() assert mock_instrument._conn is None - assert mock_instance.close.called_once() assert "Client disconnected" in caplog.text From 4dfc68afbbdcfef29dad628ed00b6bd2117b6c5a Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 16 Mar 2026 15:32:24 +0000 Subject: [PATCH 15/56] remove redundancy --- tests/common/server/test_abstract_instrument_server.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index edeb2da5..4b89b187 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -202,7 +202,6 @@ def test_full_connection_cycle_cleanup(mock_instrument, caplog): def test_dispatch_command_exception_handling( mock_instrument: AbstractInstrumentServer, caplog: pytest.LogCaptureFixture ): - mock_instrument._dispatch_command(b"test exception") mock_instrument._handle_command = MagicMock(side_effect=Exception("Test exception")) mock_instrument._send_error = MagicMock() mock_instrument._dispatch_command(b"test exception") From 7fde1562cb66af0f5275096478d6fc3bb99336f3 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 16 Mar 2026 16:26:50 +0000 Subject: [PATCH 16/56] add the skeleton class for test --- .../server/pulse_generator_shanghai tech.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/sm_bluesky/common/server/pulse_generator_shanghai tech.py diff --git a/src/sm_bluesky/common/server/pulse_generator_shanghai tech.py b/src/sm_bluesky/common/server/pulse_generator_shanghai tech.py new file mode 100644 index 00000000..ce6299d3 --- /dev/null +++ b/src/sm_bluesky/common/server/pulse_generator_shanghai tech.py @@ -0,0 +1,48 @@ +from sm_bluesky.common.server import AbstractInstrumentServer + + +class GeneratorServerShanghaiTech(AbstractInstrumentServer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.device = None # Placeholder for your USB device connection + + def connect_hardware(self) -> bool: + """Initialize the USB connection protocol.""" + # TODO: Add your USB connection logic here + return True + + def disconnect_hardware(self): + """Safely release the USB resource.""" + # TODO: Add your USB disconnection logic here + pass + + def _handle_command(self, cmd: bytes, arg: bytes) -> None: + """ + Routes incoming commands to the pulse generator. + """ + if cmd == b"disconnect": + self.disconnect_hardware() + pass + + elif cmd == b"check_status": + # TODO: Logic to get hardware status + pass + + elif cmd == b"set_delay": + # TODO: Logic to set delay using args + pass + + elif cmd == b"get_delay": + # TODO: Return current delay + pass + + elif cmd == b"reset_output_buffer": + # TODO: Logic to clear buffer + pass + + elif cmd == b"pass_command": + # TODO: Forward raw command + pass + + else: + pass From 49ea8cf13c156bb9f946d384a058620e4f431b6a Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 16 Mar 2026 16:34:29 +0000 Subject: [PATCH 17/56] correct name and add connect_hardware test --- src/sm_bluesky/common/server/__init__.py | 3 ++- ..._shanghai tech.py => pulse_generator_shanghai_tech.py} | 2 +- tests/common/server/test_pulse_generator_shanghai_test.py | 8 ++++++++ 3 files changed, 11 insertions(+), 2 deletions(-) rename src/sm_bluesky/common/server/{pulse_generator_shanghai tech.py => pulse_generator_shanghai_tech.py} (98%) create mode 100644 tests/common/server/test_pulse_generator_shanghai_test.py diff --git a/src/sm_bluesky/common/server/__init__.py b/src/sm_bluesky/common/server/__init__.py index fe40a135..ee5e319c 100644 --- a/src/sm_bluesky/common/server/__init__.py +++ b/src/sm_bluesky/common/server/__init__.py @@ -1,3 +1,4 @@ from .abstract_instrument_server import AbstractInstrumentServer +from .pulse_generator_shanghai_tech import GeneratorServerShanghaiTech -__all__ = ["AbstractInstrumentServer"] +__all__ = ["AbstractInstrumentServer", "GeneratorServerShanghaiTech"] diff --git a/src/sm_bluesky/common/server/pulse_generator_shanghai tech.py b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py similarity index 98% rename from src/sm_bluesky/common/server/pulse_generator_shanghai tech.py rename to src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py index ce6299d3..dd01ad92 100644 --- a/src/sm_bluesky/common/server/pulse_generator_shanghai tech.py +++ b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py @@ -9,7 +9,7 @@ def __init__(self, *args, **kwargs): def connect_hardware(self) -> bool: """Initialize the USB connection protocol.""" # TODO: Add your USB connection logic here - return True + return False def disconnect_hardware(self): """Safely release the USB resource.""" diff --git a/tests/common/server/test_pulse_generator_shanghai_test.py b/tests/common/server/test_pulse_generator_shanghai_test.py new file mode 100644 index 00000000..6787e642 --- /dev/null +++ b/tests/common/server/test_pulse_generator_shanghai_test.py @@ -0,0 +1,8 @@ +from sm_bluesky.common.server import pulse_generator_shanghai_tech + + +def test_connect_hardware(): + server = pulse_generator_shanghai_tech.GeneratorServerShanghaiTech( + host="localhost", port=8888 + ) + assert server.connect_hardware() is True From c8d68d5c743947f270682f0f459b54e1fff1b9c3 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 16 Mar 2026 17:15:45 +0000 Subject: [PATCH 18/56] add usb and connect_hardware --- pyproject.toml | 2 ++ .../server/pulse_generator_shanghai_tech.py | 30 +++++++++++++--- .../test_pulse_generator_shanghai_test.py | 34 ++++++++++++++++--- uv.lock | 13 ++++++- 4 files changed, 68 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bc90c1d3..9ec413b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,8 @@ dependencies = [ "dls-dodal>=2.1.0", "ophyd-async[sim]", "scanspec", + "pyserial", + ] dynamic = ["version"] license.file = "LICENSE" diff --git a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py index dd01ad92..6cdd12f4 100644 --- a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py +++ b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py @@ -1,15 +1,35 @@ +from serial import Serial + from sm_bluesky.common.server import AbstractInstrumentServer +from sm_bluesky.log import LOGGER class GeneratorServerShanghaiTech(AbstractInstrumentServer): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.device = None # Placeholder for your USB device connection + def __init__( + self, + host: str, + port: int, + ipv6: bool = False, + usb_port: str = "COM4", + baud_rate: int = 9600, + timeout: float = 1.0, + ): + super().__init__(host, port, ipv6) + self.usb_port: str = usb_port + self.baud_rate: int = baud_rate + self.timeout: float = timeout + self.device: Serial | None = None def connect_hardware(self) -> bool: """Initialize the USB connection protocol.""" - # TODO: Add your USB connection logic here - return False + try: + self.device = Serial( + port=self.usb_port, baudrate=self.baud_rate, timeout=self.timeout + ) + return True + except Exception as e: + LOGGER.error(f"Failed to connect to hardware {e}") + return False def disconnect_hardware(self): """Safely release the USB resource.""" diff --git a/tests/common/server/test_pulse_generator_shanghai_test.py b/tests/common/server/test_pulse_generator_shanghai_test.py index 6787e642..25f02703 100644 --- a/tests/common/server/test_pulse_generator_shanghai_test.py +++ b/tests/common/server/test_pulse_generator_shanghai_test.py @@ -1,8 +1,32 @@ +from unittest.mock import MagicMock, patch + +import pytest +from serial import Serial + from sm_bluesky.common.server import pulse_generator_shanghai_tech -def test_connect_hardware(): - server = pulse_generator_shanghai_tech.GeneratorServerShanghaiTech( - host="localhost", port=8888 - ) - assert server.connect_hardware() is True +def test_connect_hardware_success(): + + with patch( + "sm_bluesky.common.server.pulse_generator_shanghai_tech.Serial" + ) as mock_serial_class: + mock_serial_class.return_value = MagicMock(spec=Serial) + server = pulse_generator_shanghai_tech.GeneratorServerShanghaiTech( + host="localhost", port=8888, usb_port="COM4", baud_rate=9600, timeout=1.0 + ) + assert server.connect_hardware() is True + + +def test_connect_hardware_failure(caplog: pytest.LogCaptureFixture): + with patch( + "sm_bluesky.common.server.pulse_generator_shanghai_tech.Serial" + ) as mock_serial_class: + error_message = "Connection failed" + mock_serial_class.side_effect = Exception(error_message) + server = pulse_generator_shanghai_tech.GeneratorServerShanghaiTech( + host="localhost", port=8888, usb_port="COM4", baud_rate=9600, timeout=1.0 + ) + + assert server.connect_hardware() is False + assert f"Failed to connect to hardware {error_message}" in caplog.text diff --git a/uv.lock b/uv.lock index 89a27f5f..9d628c61 100644 --- a/uv.lock +++ b/uv.lock @@ -4007,7 +4007,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -4695,6 +4695,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, ] +[[package]] +name = "pyserial" +version = "3.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -5443,6 +5452,7 @@ dependencies = [ { name = "bluesky" }, { name = "dls-dodal" }, { name = "ophyd-async", extra = ["sim"] }, + { name = "pyserial" }, { name = "scanspec" }, ] @@ -5477,6 +5487,7 @@ requires-dist = [ { name = "bluesky" }, { name = "dls-dodal", specifier = ">=2.1.0" }, { name = "ophyd-async", extras = ["sim"] }, + { name = "pyserial" }, { name = "scanspec" }, ] From 6e8b9c249594c154c88b33d5699374b77d8eba49 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 17 Mar 2026 11:01:11 +0000 Subject: [PATCH 19/56] add connect and disconnect --- .../server/pulse_generator_shanghai_tech.py | 20 +++++++-- .../test_pulse_generator_shanghai_test.py | 45 ++++++++++++++++++- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py index 6cdd12f4..71ac329c 100644 --- a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py +++ b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py @@ -26,15 +26,30 @@ def connect_hardware(self) -> bool: self.device = Serial( port=self.usb_port, baudrate=self.baud_rate, timeout=self.timeout ) + self._send_response("Hardware connected successfully") return True except Exception as e: LOGGER.error(f"Failed to connect to hardware {e}") + self._send_error(f"Failed to connect to hardware {e}") return False def disconnect_hardware(self): """Safely release the USB resource.""" - # TODO: Add your USB disconnection logic here - pass + if self.device and self.device.is_open: + try: + self.device.close() + except Exception as e: + LOGGER.error(f"Error occurred while closing hardware connection {e}") + self._send_error( + f"Error occurred while closing hardware connection {e}" + ) + self._hardware_connected = False + self.device = None + LOGGER.info("Hardware disconnected successfully") + self._send_response("Hardware disconnected") + else: + LOGGER.warning("Attempted to disconnect hardware that was not connected") + self._send_error("Attempted to disconnect hardware that was not connected") def _handle_command(self, cmd: bytes, arg: bytes) -> None: """ @@ -42,7 +57,6 @@ def _handle_command(self, cmd: bytes, arg: bytes) -> None: """ if cmd == b"disconnect": self.disconnect_hardware() - pass elif cmd == b"check_status": # TODO: Logic to get hardware status diff --git a/tests/common/server/test_pulse_generator_shanghai_test.py b/tests/common/server/test_pulse_generator_shanghai_test.py index 25f02703..008b5a0c 100644 --- a/tests/common/server/test_pulse_generator_shanghai_test.py +++ b/tests/common/server/test_pulse_generator_shanghai_test.py @@ -27,6 +27,49 @@ def test_connect_hardware_failure(caplog: pytest.LogCaptureFixture): server = pulse_generator_shanghai_tech.GeneratorServerShanghaiTech( host="localhost", port=8888, usb_port="COM4", baud_rate=9600, timeout=1.0 ) - + server._send_error = MagicMock() assert server.connect_hardware() is False assert f"Failed to connect to hardware {error_message}" in caplog.text + server._send_error.assert_called_with( + f"Failed to connect to hardware {error_message}" + ) + + +@patch("sm_bluesky.common.server.pulse_generator_shanghai_tech.Serial") +def test_disconnect_hardware( + mock_serial_class: MagicMock, caplog: pytest.LogCaptureFixture +): + mock_serial_class.side_effect = MagicMock(spec=Serial) + server = pulse_generator_shanghai_tech.GeneratorServerShanghaiTech( + host="localhost", port=8888, usb_port="COM4", baud_rate=9600, timeout=1.0 + ) + server.connect_hardware() + with patch.object(server, "device") as mock_device: + server._send_response = MagicMock() + server.disconnect_hardware() + mock_device.close.assert_called_once() + assert server._hardware_connected is False + assert server.device is None + assert "Hardware disconnected successfully" in caplog.text + server._send_response.assert_called_with("Hardware disconnected") + + +@patch("sm_bluesky.common.server.pulse_generator_shanghai_tech.Serial") +def test_disconnect_hardware_not_connected( + mock_serial_class: MagicMock, caplog: pytest.LogCaptureFixture +): + mock_serial_class.side_effect = MagicMock(spec=Serial) + server = pulse_generator_shanghai_tech.GeneratorServerShanghaiTech( + host="localhost", port=8888, usb_port="COM4", baud_rate=9600, timeout=1.0 + ) + server.connect_hardware() + with patch.object(server, "device") as mock_device: + mock_device.is_open = False + server._send_error = MagicMock() + server.disconnect_hardware() + mock_device.close.assert_not_called() + assert server._hardware_connected is False + assert "Attempted to disconnect hardware that was not connected" in caplog.text + server._send_error.assert_called_with( + "Attempted to disconnect hardware that was not connected" + ) From 51aaa8302c2cce7863a368ec41d0d07139bb6a80 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 17 Mar 2026 11:09:51 +0000 Subject: [PATCH 20/56] add exception test --- .../test_pulse_generator_shanghai_test.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/common/server/test_pulse_generator_shanghai_test.py b/tests/common/server/test_pulse_generator_shanghai_test.py index 008b5a0c..1dbf9b9c 100644 --- a/tests/common/server/test_pulse_generator_shanghai_test.py +++ b/tests/common/server/test_pulse_generator_shanghai_test.py @@ -73,3 +73,25 @@ def test_disconnect_hardware_not_connected( server._send_error.assert_called_with( "Attempted to disconnect hardware that was not connected" ) + + +@patch("sm_bluesky.common.server.pulse_generator_shanghai_tech.Serial") +def test_disconnect_hardware_exception_on_close( + mock_serial_class: MagicMock, caplog: pytest.LogCaptureFixture +): + mock_serial_class.side_effect = MagicMock(spec=Serial) + server = pulse_generator_shanghai_tech.GeneratorServerShanghaiTech( + host="localhost", port=8888, usb_port="COM4", baud_rate=9600, timeout=1.0 + ) + server.connect_hardware() + with patch.object(server, "device") as mock_device: + mock_device.close.side_effect = Exception("Close failed") + server._send_error = MagicMock() + server.disconnect_hardware() + mock_device.close.assert_called_once() + assert server._hardware_connected is False + assert server.device is None + assert "Error occurred while closing hardware connection" in caplog.text + server._send_error.assert_called_with( + "Error occurred while closing hardware connection Close failed" + ) From 3f18d4a3c9378ab6d8ac301918406d362c4c1add Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 17 Mar 2026 12:12:55 +0000 Subject: [PATCH 21/56] add command_registry for Abstract class commands. --- .../server/abstract_instrument_server.py | 25 +++++++++++++--- .../server/test_abstract_instrument_server.py | 29 +++++++++++-------- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 8f8d95b4..b9630dd9 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -1,5 +1,6 @@ import socket from abc import ABC, abstractmethod +from collections.abc import Callable from contextlib import contextmanager from sm_bluesky.log import LOGGER @@ -21,6 +22,12 @@ def __init__(self, host: str, port: int, ipv6: bool = False): self._server_socket: socket.socket self._conn: socket.socket | None = None self.address_type = socket.AF_INET6 if ipv6 else socket.AF_INET + self._command_registry: dict[bytes, Callable] = { + b"connect_hardware": self.connect_hardware, + b"disconnect_hardware": self.disconnect_hardware, + b"ping": self._send_ack, + b"shutdown": self.stop, + } def start(self) -> None: """Initializes the server, connects hardware, and enters the listening loop.""" @@ -125,6 +132,20 @@ def _send_response(self, response: str = "") -> None: if self._conn: self._conn.sendall(b"1\t" + response.encode() + b"\n") + def _handle_command(self, cmd: bytes, args: bytes) -> None: + """Executes logic for a specific instrument command.""" + handler = self._command_registry.get(cmd) + if not handler: + LOGGER.warning(f"Received unknown command: {cmd.decode()}") + self._send_error("Received unknown command") + else: + try: + handler(args) if args else handler() + + except Exception as e: + LOGGER.error(f"Error handling command '{cmd.decode()}': {e}") + self._send_error(f"Error handling command '{cmd.decode()}': {e}") + @abstractmethod def connect_hardware(self) -> bool: """Establishes connection to the specific hardware device.""" @@ -132,7 +153,3 @@ def connect_hardware(self) -> bool: @abstractmethod def disconnect_hardware(self) -> None: """Disconnect from the hardware device.""" - - @abstractmethod - def _handle_command(self, cmd: bytes, arg: bytes) -> None: - """Executes logic for a specific instrument command.""" diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 4b89b187..2cdf311f 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -14,17 +14,6 @@ def connect_hardware(self) -> bool: def disconnect_hardware(self) -> None: self._hardware_connected = False - def _handle_command(self, cmd: bytes, arg: bytes) -> None: - if cmd == b"shutdown": - self._send_response("Shutting down server") - self.stop() - elif cmd == b"ping": - self._send_ack() - elif cmd == b"disconnect": - self.disconnect_hardware() - else: - self._send_error("Unknown command") - @pytest.fixture def mock_instrument(): @@ -139,7 +128,23 @@ def test_send_unknow_command_error(mock_instrument: AbstractInstrumentServer): mock_instrument._conn = MagicMock() mock_instrument._conn.sendall = MagicMock() mock_instrument._handle_command(b"sdljkfnsdouifn", b"") - mock_instrument._conn.sendall.assert_called_once_with(b"0\tUnknown command\n") + mock_instrument._conn.sendall.assert_called_once_with( + b"0\tReceived unknown command\n" + ) + + +def test_send_command_handling_error(mock_instrument: AbstractInstrumentServer): + mock_instrument._conn = MagicMock() + mock_instrument._conn.sendall = MagicMock() + + def handling_exception(): + raise Exception(Exception("test_send_command_handling_error")) + + mock_instrument._command_registry.update({b"ping": handling_exception}) + mock_instrument._handle_command(b"ping", b"") + mock_instrument._conn.sendall.assert_called_once_with( + b"0\tError handling command 'ping': test_send_command_handling_error\n" + ) def test_send_response(mock_instrument: AbstractInstrumentServer): From 932999b64d69a46da9b84bb2dbbf56941b38c2cd Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 17 Mar 2026 12:18:49 +0000 Subject: [PATCH 22/56] merge abstract class update --- .../server/abstract_instrument_server.py | 25 +++++++++++++--- .../server/test_abstract_instrument_server.py | 29 +++++++++++-------- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 8f8d95b4..b9630dd9 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -1,5 +1,6 @@ import socket from abc import ABC, abstractmethod +from collections.abc import Callable from contextlib import contextmanager from sm_bluesky.log import LOGGER @@ -21,6 +22,12 @@ def __init__(self, host: str, port: int, ipv6: bool = False): self._server_socket: socket.socket self._conn: socket.socket | None = None self.address_type = socket.AF_INET6 if ipv6 else socket.AF_INET + self._command_registry: dict[bytes, Callable] = { + b"connect_hardware": self.connect_hardware, + b"disconnect_hardware": self.disconnect_hardware, + b"ping": self._send_ack, + b"shutdown": self.stop, + } def start(self) -> None: """Initializes the server, connects hardware, and enters the listening loop.""" @@ -125,6 +132,20 @@ def _send_response(self, response: str = "") -> None: if self._conn: self._conn.sendall(b"1\t" + response.encode() + b"\n") + def _handle_command(self, cmd: bytes, args: bytes) -> None: + """Executes logic for a specific instrument command.""" + handler = self._command_registry.get(cmd) + if not handler: + LOGGER.warning(f"Received unknown command: {cmd.decode()}") + self._send_error("Received unknown command") + else: + try: + handler(args) if args else handler() + + except Exception as e: + LOGGER.error(f"Error handling command '{cmd.decode()}': {e}") + self._send_error(f"Error handling command '{cmd.decode()}': {e}") + @abstractmethod def connect_hardware(self) -> bool: """Establishes connection to the specific hardware device.""" @@ -132,7 +153,3 @@ def connect_hardware(self) -> bool: @abstractmethod def disconnect_hardware(self) -> None: """Disconnect from the hardware device.""" - - @abstractmethod - def _handle_command(self, cmd: bytes, arg: bytes) -> None: - """Executes logic for a specific instrument command.""" diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 4b89b187..2cdf311f 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -14,17 +14,6 @@ def connect_hardware(self) -> bool: def disconnect_hardware(self) -> None: self._hardware_connected = False - def _handle_command(self, cmd: bytes, arg: bytes) -> None: - if cmd == b"shutdown": - self._send_response("Shutting down server") - self.stop() - elif cmd == b"ping": - self._send_ack() - elif cmd == b"disconnect": - self.disconnect_hardware() - else: - self._send_error("Unknown command") - @pytest.fixture def mock_instrument(): @@ -139,7 +128,23 @@ def test_send_unknow_command_error(mock_instrument: AbstractInstrumentServer): mock_instrument._conn = MagicMock() mock_instrument._conn.sendall = MagicMock() mock_instrument._handle_command(b"sdljkfnsdouifn", b"") - mock_instrument._conn.sendall.assert_called_once_with(b"0\tUnknown command\n") + mock_instrument._conn.sendall.assert_called_once_with( + b"0\tReceived unknown command\n" + ) + + +def test_send_command_handling_error(mock_instrument: AbstractInstrumentServer): + mock_instrument._conn = MagicMock() + mock_instrument._conn.sendall = MagicMock() + + def handling_exception(): + raise Exception(Exception("test_send_command_handling_error")) + + mock_instrument._command_registry.update({b"ping": handling_exception}) + mock_instrument._handle_command(b"ping", b"") + mock_instrument._conn.sendall.assert_called_once_with( + b"0\tError handling command 'ping': test_send_command_handling_error\n" + ) def test_send_response(mock_instrument: AbstractInstrumentServer): From c05c4192f7189fb835fabd55d24109c5c54d9e56 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 17 Mar 2026 13:21:02 +0000 Subject: [PATCH 23/56] added a _error_helper for logging and responds --- .../server/abstract_instrument_server.py | 26 ++++++--- .../server/pulse_generator_shanghai_tech.py | 53 ++++++++++--------- .../server/test_abstract_instrument_server.py | 4 +- 3 files changed, 50 insertions(+), 33 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index b9630dd9..6e60576c 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -3,7 +3,7 @@ from collections.abc import Callable from contextlib import contextmanager -from sm_bluesky.log import LOGGER +from sm_bluesky.log import LOGGER, logging class AbstractInstrumentServer(ABC): @@ -119,7 +119,7 @@ def _dispatch_command(self, line: bytes) -> None: try: self._handle_command(cmd, arg) except Exception as e: - self._send_error(str(e)) + self._error_helper(message="Handler Error", error=e) def _send_ack(self) -> None: self._send_response() @@ -136,15 +136,29 @@ def _handle_command(self, cmd: bytes, args: bytes) -> None: """Executes logic for a specific instrument command.""" handler = self._command_registry.get(cmd) if not handler: - LOGGER.warning(f"Received unknown command: {cmd.decode()}") - self._send_error("Received unknown command") + self._error_helper( + message=f"Received unknown command: '{cmd.decode()}'", + error=Exception("Unknow command"), + level=logging.WARNING, + ) else: try: handler(args) if args else handler() except Exception as e: - LOGGER.error(f"Error handling command '{cmd.decode()}': {e}") - self._send_error(f"Error handling command '{cmd.decode()}': {e}") + self._error_helper( + message=f"Error handling command '{cmd.decode()}'", error=e + ) + + def _error_helper( + self, + message: str, + error: Exception | None = None, + level: int = logging.ERROR, + ): + err_msg = f"{message}: {error}" if error else message + LOGGER.log(level=level, msg=err_msg) + self._send_error(err_msg) @abstractmethod def connect_hardware(self) -> bool: diff --git a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py index 71ac329c..2ea69ab7 100644 --- a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py +++ b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py @@ -13,13 +13,25 @@ def __init__( usb_port: str = "COM4", baud_rate: int = 9600, timeout: float = 1.0, + max_delay=1024, ): super().__init__(host, port, ipv6) self.usb_port: str = usb_port self.baud_rate: int = baud_rate self.timeout: float = timeout + self.max_pulse_delay: float = max_delay self.device: Serial | None = None + # Expand the registry with Pulse Generator specific commands + self._command_registry.update( + { + b"set_delay": self._set_delay, + b"get_delay": self._get_delay, + b"reset_output_buffer": self._reset_buffer, + b"pass_command": self._passthrough, + } + ) + def connect_hardware(self) -> bool: """Initialize the USB connection protocol.""" try: @@ -51,32 +63,23 @@ def disconnect_hardware(self): LOGGER.warning("Attempted to disconnect hardware that was not connected") self._send_error("Attempted to disconnect hardware that was not connected") - def _handle_command(self, cmd: bytes, arg: bytes) -> None: - """ - Routes incoming commands to the pulse generator. - """ - if cmd == b"disconnect": - self.disconnect_hardware() - - elif cmd == b"check_status": - # TODO: Logic to get hardware status - pass - - elif cmd == b"set_delay": - # TODO: Logic to set delay using args - pass - - elif cmd == b"get_delay": - # TODO: Return current delay - pass + def _set_delay(self, value: bytes) -> None: + delay = int(value.decode("utf-8")) + if 1024 > delay >= 0 and self.device is not None: + try: + self.device.write(b"AT+DLSET=" + value + b"\r\n") + self._send_response(self.device.readline().decode("utf-8").strip()) + except Exception as e: + self._error_helper(message="Set delay failed", error=e) - elif cmd == b"reset_output_buffer": - # TODO: Logic to clear buffer - pass + else: + self._send_error("Delay must be between 0 and 1023") + LOGGER.error("Delay must be between 0 and 1023") - elif cmd == b"pass_command": - # TODO: Forward raw command - pass + def _get_delay(self): + if self.device is not None: + self.device.write(b"AT+DLSET=?\r\n") + self._send_response(self.device.readline().decode("utf-8").strip()) else: - pass + self._send_error("Fail to read delay") diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 2cdf311f..03b350ce 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -129,7 +129,7 @@ def test_send_unknow_command_error(mock_instrument: AbstractInstrumentServer): mock_instrument._conn.sendall = MagicMock() mock_instrument._handle_command(b"sdljkfnsdouifn", b"") mock_instrument._conn.sendall.assert_called_once_with( - b"0\tReceived unknown command\n" + b"0\tReceived unknown command: 'sdljkfnsdouifn': Unknow command\n" ) @@ -210,7 +210,7 @@ def test_dispatch_command_exception_handling( mock_instrument._handle_command = MagicMock(side_effect=Exception("Test exception")) mock_instrument._send_error = MagicMock() mock_instrument._dispatch_command(b"test exception") - mock_instrument._send_error.assert_called_once_with("Test exception") + mock_instrument._send_error.assert_called_once_with("Handler Error: Test exception") def test_dispatch_command_with_arg(mock_instrument: AbstractInstrumentServer): From f94cfaf955c6a9de1b3fa8d6d391c04a9753a2e4 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 17 Mar 2026 13:25:39 +0000 Subject: [PATCH 24/56] add helper for error handling --- .../server/abstract_instrument_server.py | 26 ++++++++++++++----- .../server/test_abstract_instrument_server.py | 4 +-- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index b9630dd9..6e60576c 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -3,7 +3,7 @@ from collections.abc import Callable from contextlib import contextmanager -from sm_bluesky.log import LOGGER +from sm_bluesky.log import LOGGER, logging class AbstractInstrumentServer(ABC): @@ -119,7 +119,7 @@ def _dispatch_command(self, line: bytes) -> None: try: self._handle_command(cmd, arg) except Exception as e: - self._send_error(str(e)) + self._error_helper(message="Handler Error", error=e) def _send_ack(self) -> None: self._send_response() @@ -136,15 +136,29 @@ def _handle_command(self, cmd: bytes, args: bytes) -> None: """Executes logic for a specific instrument command.""" handler = self._command_registry.get(cmd) if not handler: - LOGGER.warning(f"Received unknown command: {cmd.decode()}") - self._send_error("Received unknown command") + self._error_helper( + message=f"Received unknown command: '{cmd.decode()}'", + error=Exception("Unknow command"), + level=logging.WARNING, + ) else: try: handler(args) if args else handler() except Exception as e: - LOGGER.error(f"Error handling command '{cmd.decode()}': {e}") - self._send_error(f"Error handling command '{cmd.decode()}': {e}") + self._error_helper( + message=f"Error handling command '{cmd.decode()}'", error=e + ) + + def _error_helper( + self, + message: str, + error: Exception | None = None, + level: int = logging.ERROR, + ): + err_msg = f"{message}: {error}" if error else message + LOGGER.log(level=level, msg=err_msg) + self._send_error(err_msg) @abstractmethod def connect_hardware(self) -> bool: diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 2cdf311f..03b350ce 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -129,7 +129,7 @@ def test_send_unknow_command_error(mock_instrument: AbstractInstrumentServer): mock_instrument._conn.sendall = MagicMock() mock_instrument._handle_command(b"sdljkfnsdouifn", b"") mock_instrument._conn.sendall.assert_called_once_with( - b"0\tReceived unknown command\n" + b"0\tReceived unknown command: 'sdljkfnsdouifn': Unknow command\n" ) @@ -210,7 +210,7 @@ def test_dispatch_command_exception_handling( mock_instrument._handle_command = MagicMock(side_effect=Exception("Test exception")) mock_instrument._send_error = MagicMock() mock_instrument._dispatch_command(b"test exception") - mock_instrument._send_error.assert_called_once_with("Test exception") + mock_instrument._send_error.assert_called_once_with("Handler Error: Test exception") def test_dispatch_command_with_arg(mock_instrument: AbstractInstrumentServer): From ac23daa4e59b38dd9bc42b8edad8639308a52fa1 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 17 Mar 2026 15:58:41 +0000 Subject: [PATCH 25/56] clean up test --- .../server/pulse_generator_shanghai_tech.py | 7 +- .../test_pulse_generator_shanghai_test.py | 120 ++++++++++-------- 2 files changed, 68 insertions(+), 59 deletions(-) diff --git a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py index 2ea69ab7..6c94a8f9 100644 --- a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py +++ b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py @@ -27,8 +27,8 @@ def __init__( { b"set_delay": self._set_delay, b"get_delay": self._get_delay, - b"reset_output_buffer": self._reset_buffer, - b"pass_command": self._passthrough, + # b"reset_output_buffer": self._reset_buffer, + # b"pass_command": self._passthrough, } ) @@ -68,7 +68,8 @@ def _set_delay(self, value: bytes) -> None: if 1024 > delay >= 0 and self.device is not None: try: self.device.write(b"AT+DLSET=" + value + b"\r\n") - self._send_response(self.device.readline().decode("utf-8").strip()) + device_respond = self.device.readline() + self._send_response(device_respond.decode("utf-8").strip()) except Exception as e: self._error_helper(message="Set delay failed", error=e) diff --git a/tests/common/server/test_pulse_generator_shanghai_test.py b/tests/common/server/test_pulse_generator_shanghai_test.py index 1dbf9b9c..2e890db1 100644 --- a/tests/common/server/test_pulse_generator_shanghai_test.py +++ b/tests/common/server/test_pulse_generator_shanghai_test.py @@ -1,97 +1,105 @@ from unittest.mock import MagicMock, patch import pytest -from serial import Serial -from sm_bluesky.common.server import pulse_generator_shanghai_tech +from sm_bluesky.common.server import GeneratorServerShanghaiTech -def test_connect_hardware_success(): - +@pytest.fixture +def mock_serial(): + """Patches Serial and returns the class mock.""" with patch( - "sm_bluesky.common.server.pulse_generator_shanghai_tech.Serial" - ) as mock_serial_class: - mock_serial_class.return_value = MagicMock(spec=Serial) - server = pulse_generator_shanghai_tech.GeneratorServerShanghaiTech( - host="localhost", port=8888, usb_port="COM4", baud_rate=9600, timeout=1.0 - ) - assert server.connect_hardware() is True + "sm_bluesky.common.server.pulse_generator_shanghai_tech.Serial", spec=True + ) as mock_serial: + # Ensure calling Serial() returns a Mock instance with Serial methods + # mock_serial.return_value = MagicMock(spec=Serial) + yield mock_serial -def test_connect_hardware_failure(caplog: pytest.LogCaptureFixture): +@pytest.fixture +def mock_server(mock_serial): + """Provides a fresh server instance with a mocked device for every test.""" + mock_server = GeneratorServerShanghaiTech( + host="localhost", port=8888, usb_port="COM4", baud_rate=9600, timeout=1.0 + ) + return mock_server + + +def test_connect_hardware_success(mock_server: GeneratorServerShanghaiTech): + assert mock_server.connect_hardware() is True + + +def test_connect_hardware_failure( + mock_server: GeneratorServerShanghaiTech, caplog: pytest.LogCaptureFixture +): with patch( "sm_bluesky.common.server.pulse_generator_shanghai_tech.Serial" ) as mock_serial_class: error_message = "Connection failed" mock_serial_class.side_effect = Exception(error_message) - server = pulse_generator_shanghai_tech.GeneratorServerShanghaiTech( - host="localhost", port=8888, usb_port="COM4", baud_rate=9600, timeout=1.0 - ) - server._send_error = MagicMock() - assert server.connect_hardware() is False + mock_server._send_error = MagicMock() + assert mock_server.connect_hardware() is False assert f"Failed to connect to hardware {error_message}" in caplog.text - server._send_error.assert_called_with( + mock_server._send_error.assert_called_with( f"Failed to connect to hardware {error_message}" ) -@patch("sm_bluesky.common.server.pulse_generator_shanghai_tech.Serial") def test_disconnect_hardware( - mock_serial_class: MagicMock, caplog: pytest.LogCaptureFixture + mock_server: GeneratorServerShanghaiTech, caplog: pytest.LogCaptureFixture ): - mock_serial_class.side_effect = MagicMock(spec=Serial) - server = pulse_generator_shanghai_tech.GeneratorServerShanghaiTech( - host="localhost", port=8888, usb_port="COM4", baud_rate=9600, timeout=1.0 - ) - server.connect_hardware() - with patch.object(server, "device") as mock_device: - server._send_response = MagicMock() - server.disconnect_hardware() + mock_server.connect_hardware() + with patch.object(mock_server, "device") as mock_device: + mock_server._send_response = MagicMock() + mock_server.disconnect_hardware() mock_device.close.assert_called_once() - assert server._hardware_connected is False - assert server.device is None + assert mock_server._hardware_connected is False + assert mock_server.device is None assert "Hardware disconnected successfully" in caplog.text - server._send_response.assert_called_with("Hardware disconnected") + mock_server._send_response.assert_called_with("Hardware disconnected") -@patch("sm_bluesky.common.server.pulse_generator_shanghai_tech.Serial") def test_disconnect_hardware_not_connected( - mock_serial_class: MagicMock, caplog: pytest.LogCaptureFixture + mock_server: GeneratorServerShanghaiTech, caplog: pytest.LogCaptureFixture ): - mock_serial_class.side_effect = MagicMock(spec=Serial) - server = pulse_generator_shanghai_tech.GeneratorServerShanghaiTech( - host="localhost", port=8888, usb_port="COM4", baud_rate=9600, timeout=1.0 - ) - server.connect_hardware() - with patch.object(server, "device") as mock_device: + + mock_server.connect_hardware() + with patch.object(mock_server, "device") as mock_device: mock_device.is_open = False - server._send_error = MagicMock() - server.disconnect_hardware() + mock_server._send_error = MagicMock() + mock_server.disconnect_hardware() mock_device.close.assert_not_called() - assert server._hardware_connected is False + assert mock_server._hardware_connected is False assert "Attempted to disconnect hardware that was not connected" in caplog.text - server._send_error.assert_called_with( + mock_server._send_error.assert_called_with( "Attempted to disconnect hardware that was not connected" ) -@patch("sm_bluesky.common.server.pulse_generator_shanghai_tech.Serial") def test_disconnect_hardware_exception_on_close( - mock_serial_class: MagicMock, caplog: pytest.LogCaptureFixture + mock_server: GeneratorServerShanghaiTech, caplog: pytest.LogCaptureFixture ): - mock_serial_class.side_effect = MagicMock(spec=Serial) - server = pulse_generator_shanghai_tech.GeneratorServerShanghaiTech( - host="localhost", port=8888, usb_port="COM4", baud_rate=9600, timeout=1.0 - ) - server.connect_hardware() - with patch.object(server, "device") as mock_device: + mock_server.connect_hardware() + with patch.object(mock_server, "device") as mock_device: mock_device.close.side_effect = Exception("Close failed") - server._send_error = MagicMock() - server.disconnect_hardware() + mock_server._send_error = MagicMock() + mock_server.disconnect_hardware() mock_device.close.assert_called_once() - assert server._hardware_connected is False - assert server.device is None + assert mock_server._hardware_connected is False + assert mock_server.device is None assert "Error occurred while closing hardware connection" in caplog.text - server._send_error.assert_called_with( + mock_server._send_error.assert_called_with( "Error occurred while closing hardware connection Close failed" ) + + +def test_set_delay_success( + mock_server: GeneratorServerShanghaiTech, +) -> None: + mock_respond = "set success: 500" + with patch.object(mock_server, "device") as mock_device: + mock_device.readline.return_value = mock_respond.encode() + mock_server._send_response = MagicMock() + mock_server._set_delay(b"500") + mock_server._send_response.assert_called_once_with(mock_respond) + mock_device.readline.assert_called_once() From eef7bd2115a813ba7802f48e145bae13fd738e5f Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 17 Mar 2026 16:16:37 +0000 Subject: [PATCH 26/56] clean up test --- tests/common/server/test_pulse_generator_shanghai_test.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/common/server/test_pulse_generator_shanghai_test.py b/tests/common/server/test_pulse_generator_shanghai_test.py index 2e890db1..89235b9f 100644 --- a/tests/common/server/test_pulse_generator_shanghai_test.py +++ b/tests/common/server/test_pulse_generator_shanghai_test.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock, patch import pytest +from serial import Serial from sm_bluesky.common.server import GeneratorServerShanghaiTech @@ -11,13 +12,11 @@ def mock_serial(): with patch( "sm_bluesky.common.server.pulse_generator_shanghai_tech.Serial", spec=True ) as mock_serial: - # Ensure calling Serial() returns a Mock instance with Serial methods - # mock_serial.return_value = MagicMock(spec=Serial) yield mock_serial @pytest.fixture -def mock_server(mock_serial): +def mock_server(mock_serial: Serial): """Provides a fresh server instance with a mocked device for every test.""" mock_server = GeneratorServerShanghaiTech( host="localhost", port=8888, usb_port="COM4", baud_rate=9600, timeout=1.0 From 9b277a1798c515c94a0ecf2b1a0b6e9dca9f14de Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 17 Mar 2026 16:55:50 +0000 Subject: [PATCH 27/56] make fixture for abstract_instrument_server test --- .../server/test_abstract_instrument_server.py | 69 ++++++++++--------- 1 file changed, 36 insertions(+), 33 deletions(-) diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 03b350ce..3b6cd442 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -7,6 +7,8 @@ class MockInstrument(AbstractInstrumentServer): + """Concrete implementation for testing the Abstract class.""" + def connect_hardware(self) -> bool: self._hardware_connected = True return True @@ -16,8 +18,20 @@ def disconnect_hardware(self) -> None: @pytest.fixture -def mock_instrument(): - return MockInstrument(host="localhost", port=8888) +def mock_socket_instance(): + """Provides a MagicMock that looks like a socket instance.""" + return MagicMock(spec=socket.socket) + + +@pytest.fixture +def mock_instrument(mock_socket_instance: MagicMock): + + with patch( + "sm_bluesky.common.server.abstract_instrument_server.socket.socket" + ) as mock_socket_class: + mock_socket_class.return_value = mock_socket_instance + mock_instrument = MockInstrument(host="localhost", port=8888) + yield mock_instrument def test_connect_hardware(mock_instrument: AbstractInstrumentServer): @@ -26,47 +40,41 @@ def test_connect_hardware(mock_instrument: AbstractInstrumentServer): assert mock_instrument._hardware_connected is True -@patch("socket.socket") def test_start_server_success( - mock_socket_class: MagicMock, mock_instrument: AbstractInstrumentServer, + mock_socket_instance: MagicMock, caplog: pytest.LogCaptureFixture, ): - mock_server_socket = MagicMock() - mock_socket_class.return_value = mock_server_socket mock_client_socket = MagicMock() - mock_server_socket.accept.return_value = (mock_client_socket, ("localhost", 8888)) + mock_socket_instance.accept.return_value = (mock_client_socket, ("localhost", 8888)) mock_instrument._serve_client = lambda: setattr( mock_instrument, "_is_running", False ) mock_instrument.start() - mock_server_socket.bind.assert_called_with(("localhost", 8888)) + mock_socket_instance.bind.assert_called_with(("localhost", 8888)) assert "Server started listening on localhost:8888" in caplog.text - mock_server_socket.listen.assert_called_once() - mock_server_socket.accept.assert_called_once() + mock_socket_instance.listen.assert_called_once() + mock_socket_instance.accept.assert_called_once() assert mock_instrument._is_running is False assert "Connection accepted from" in caplog.text -@patch("socket.socket") -def test_start_handles_timeout(mock_socket_class, mock_instrument): - mock_instance = MagicMock() - mock_socket_class.return_value = mock_instance - mock_instance.accept.side_effect = [ +def test_start_handles_timeout( + mock_instrument: AbstractInstrumentServer, + mock_socket_instance: MagicMock, +): + mock_socket_instance.accept.side_effect = [ socket.timeout, (MagicMock(), ("8.8.8.8", 1234)), ] + mock_instrument._serve_client = lambda: setattr( + mock_instrument, "_is_running", False + ) + mock_instrument.start() - with patch.object( - mock_instrument, - "_serve_client", - side_effect=lambda: setattr(mock_instrument, "_is_running", False), - ): - mock_instrument.start() - - assert mock_instance.accept.call_count == 2 + assert mock_socket_instance.accept.call_count == 2 def test_start_server_failure_hardware( @@ -81,17 +89,14 @@ def test_start_server_failure_hardware( assert mock_instrument._is_running is False -@patch("socket.socket") def test_start_server_failure_on_accept( - mock_socket: MagicMock, mock_instrument: AbstractInstrumentServer, + mock_socket_instance: MagicMock, caplog: pytest.LogCaptureFixture, ): error_message = "Simulated socket error" - mock_instance = MagicMock() - mock_socket.return_value = mock_instance - mock_instance.accept.side_effect = Exception(error_message) + mock_socket_instance.accept.side_effect = Exception(error_message) mock_instrument.start() assert mock_instrument._is_running is False @@ -99,17 +104,15 @@ def test_start_server_failure_on_accept( assert mock_instrument._conn is None -@patch("socket.socket") def test_stop_server( - mock_socket_class: MagicMock, mock_instrument: AbstractInstrumentServer, + mock_socket_instance: MagicMock, caplog: pytest.LogCaptureFixture, ): - mock_server_socket = MagicMock() - mock_socket_class.return_value = mock_server_socket + mock_client_socket = MagicMock() mock_instrument._conn = mock_client_socket - mock_server_socket.accept.return_value = (mock_client_socket, ("localhost", 8888)) + mock_socket_instance.accept.return_value = (mock_client_socket, ("localhost", 8888)) mock_instrument._conn.recv = MagicMock(return_value=b"shutdown\t\n") mock_instrument.start() assert mock_instrument._hardware_connected is False From e1446f932f6667f74308060a16c99fbbff9e823b Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 17 Mar 2026 16:59:03 +0000 Subject: [PATCH 28/56] clean up test --- .../server/test_abstract_instrument_server.py | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 03b350ce..7dfd9966 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -16,8 +16,19 @@ def disconnect_hardware(self) -> None: @pytest.fixture -def mock_instrument(): - return MockInstrument(host="localhost", port=8888) +def mock_socket_instance(): + return MagicMock(spec=socket.socket) + + +@pytest.fixture +def mock_instrument(mock_socket_instance: MagicMock): + + with patch( + "sm_bluesky.common.server.abstract_instrument_server.socket.socket" + ) as mock_socket_class: + mock_socket_class.return_value = mock_socket_instance + mock_instrument = MockInstrument(host="localhost", port=8888) + yield mock_instrument def test_connect_hardware(mock_instrument: AbstractInstrumentServer): @@ -26,47 +37,41 @@ def test_connect_hardware(mock_instrument: AbstractInstrumentServer): assert mock_instrument._hardware_connected is True -@patch("socket.socket") def test_start_server_success( - mock_socket_class: MagicMock, mock_instrument: AbstractInstrumentServer, + mock_socket_instance: MagicMock, caplog: pytest.LogCaptureFixture, ): - mock_server_socket = MagicMock() - mock_socket_class.return_value = mock_server_socket mock_client_socket = MagicMock() - mock_server_socket.accept.return_value = (mock_client_socket, ("localhost", 8888)) + mock_socket_instance.accept.return_value = (mock_client_socket, ("localhost", 8888)) mock_instrument._serve_client = lambda: setattr( mock_instrument, "_is_running", False ) mock_instrument.start() - mock_server_socket.bind.assert_called_with(("localhost", 8888)) + mock_socket_instance.bind.assert_called_with(("localhost", 8888)) assert "Server started listening on localhost:8888" in caplog.text - mock_server_socket.listen.assert_called_once() - mock_server_socket.accept.assert_called_once() + mock_socket_instance.listen.assert_called_once() + mock_socket_instance.accept.assert_called_once() assert mock_instrument._is_running is False assert "Connection accepted from" in caplog.text -@patch("socket.socket") -def test_start_handles_timeout(mock_socket_class, mock_instrument): - mock_instance = MagicMock() - mock_socket_class.return_value = mock_instance - mock_instance.accept.side_effect = [ +def test_start_handles_timeout( + mock_instrument: AbstractInstrumentServer, + mock_socket_instance: MagicMock, +): + mock_socket_instance.accept.side_effect = [ socket.timeout, (MagicMock(), ("8.8.8.8", 1234)), ] + mock_instrument._serve_client = lambda: setattr( + mock_instrument, "_is_running", False + ) + mock_instrument.start() - with patch.object( - mock_instrument, - "_serve_client", - side_effect=lambda: setattr(mock_instrument, "_is_running", False), - ): - mock_instrument.start() - - assert mock_instance.accept.call_count == 2 + assert mock_socket_instance.accept.call_count == 2 def test_start_server_failure_hardware( @@ -81,17 +86,14 @@ def test_start_server_failure_hardware( assert mock_instrument._is_running is False -@patch("socket.socket") def test_start_server_failure_on_accept( - mock_socket: MagicMock, mock_instrument: AbstractInstrumentServer, + mock_socket_instance: MagicMock, caplog: pytest.LogCaptureFixture, ): error_message = "Simulated socket error" - mock_instance = MagicMock() - mock_socket.return_value = mock_instance - mock_instance.accept.side_effect = Exception(error_message) + mock_socket_instance.accept.side_effect = Exception(error_message) mock_instrument.start() assert mock_instrument._is_running is False @@ -99,17 +101,15 @@ def test_start_server_failure_on_accept( assert mock_instrument._conn is None -@patch("socket.socket") def test_stop_server( - mock_socket_class: MagicMock, mock_instrument: AbstractInstrumentServer, + mock_socket_instance: MagicMock, caplog: pytest.LogCaptureFixture, ): - mock_server_socket = MagicMock() - mock_socket_class.return_value = mock_server_socket + mock_client_socket = MagicMock() mock_instrument._conn = mock_client_socket - mock_server_socket.accept.return_value = (mock_client_socket, ("localhost", 8888)) + mock_socket_instance.accept.return_value = (mock_client_socket, ("localhost", 8888)) mock_instrument._conn.recv = MagicMock(return_value=b"shutdown\t\n") mock_instrument.start() assert mock_instrument._hardware_connected is False From 82b4d18d42f14687ee189a6843c15d46832da3b4 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 17 Mar 2026 17:05:14 +0000 Subject: [PATCH 29/56] sync branch --- tests/common/server/test_abstract_instrument_server.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 99f4a165..7dfd9966 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -7,8 +7,6 @@ class MockInstrument(AbstractInstrumentServer): - """Concrete implementation for testing the Abstract class.""" - def connect_hardware(self) -> bool: self._hardware_connected = True return True From 8be2714238d45ea0e2c1bacdc095eb9754d2ce88 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 19 Mar 2026 13:52:00 +0000 Subject: [PATCH 30/56] add some test --- .../server/abstract_instrument_server.py | 4 +- .../server/pulse_generator_shanghai_tech.py | 41 ++++++---- .../test_pulse_generator_shanghai_test.py | 77 ++++++++++++++++--- 3 files changed, 95 insertions(+), 27 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 6e60576c..2a0c7911 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -128,9 +128,9 @@ def _send_error(self, error_message: str) -> None: if self._conn: self._conn.sendall(b"0\t" + error_message.encode() + b"\n") - def _send_response(self, response: str = "") -> None: + def _send_response(self, response: bytes = b"") -> None: if self._conn: - self._conn.sendall(b"1\t" + response.encode() + b"\n") + self._conn.sendall(b"1\t" + response + b"\n") def _handle_command(self, cmd: bytes, args: bytes) -> None: """Executes logic for a specific instrument command.""" diff --git a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py index 6c94a8f9..f0b728ae 100644 --- a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py +++ b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py @@ -20,15 +20,15 @@ def __init__( self.baud_rate: int = baud_rate self.timeout: float = timeout self.max_pulse_delay: float = max_delay - self.device: Serial | None = None + self.device: Serial # Expand the registry with Pulse Generator specific commands self._command_registry.update( { b"set_delay": self._set_delay, b"get_delay": self._get_delay, - # b"reset_output_buffer": self._reset_buffer, - # b"pass_command": self._passthrough, + b"reset_serial_buffer": self._reset_serial_buffer, + b"pass_command": self._passthrough, } ) @@ -38,7 +38,7 @@ def connect_hardware(self) -> bool: self.device = Serial( port=self.usb_port, baudrate=self.baud_rate, timeout=self.timeout ) - self._send_response("Hardware connected successfully") + self._send_response(b"Hardware connected successfully") return True except Exception as e: LOGGER.error(f"Failed to connect to hardware {e}") @@ -56,31 +56,42 @@ def disconnect_hardware(self): f"Error occurred while closing hardware connection {e}" ) self._hardware_connected = False - self.device = None LOGGER.info("Hardware disconnected successfully") - self._send_response("Hardware disconnected") + self._send_response(b"Hardware disconnected") else: LOGGER.warning("Attempted to disconnect hardware that was not connected") self._send_error("Attempted to disconnect hardware that was not connected") def _set_delay(self, value: bytes) -> None: delay = int(value.decode("utf-8")) - if 1024 > delay >= 0 and self.device is not None: + if 1024 > delay >= 0: try: self.device.write(b"AT+DLSET=" + value + b"\r\n") - device_respond = self.device.readline() - self._send_response(device_respond.decode("utf-8").strip()) + device_respond = self.device.readall() + self._send_response(device_respond) except Exception as e: self._error_helper(message="Set delay failed", error=e) else: - self._send_error("Delay must be between 0 and 1023") - LOGGER.error("Delay must be between 0 and 1023") + self._error_helper("Delay must be between 0 and 1023") def _get_delay(self): - if self.device is not None: + + try: self.device.write(b"AT+DLSET=?\r\n") - self._send_response(self.device.readline().decode("utf-8").strip()) + reading = self.device.readall() + LOGGER.info(f"Reading delay: {reading}") + self._send_response(reading) + except Exception as e: + self._error_helper(message="Read delay failed", error=e) - else: - self._send_error("Fail to read delay") + def _reset_serial_buffer(self): + try: + self.device.reset_input_buffer() + self.device.reset_output_buffer() + LOGGER.info("Resting buffers") + except Exception as e: + self._error_helper(message="Buffer reset failed", error=e) + + def _passthrough(self, value: bytes): + pass diff --git a/tests/common/server/test_pulse_generator_shanghai_test.py b/tests/common/server/test_pulse_generator_shanghai_test.py index 89235b9f..31d33585 100644 --- a/tests/common/server/test_pulse_generator_shanghai_test.py +++ b/tests/common/server/test_pulse_generator_shanghai_test.py @@ -21,6 +21,7 @@ def mock_server(mock_serial: Serial): mock_server = GeneratorServerShanghaiTech( host="localhost", port=8888, usb_port="COM4", baud_rate=9600, timeout=1.0 ) + mock_server.device = mock_serial return mock_server @@ -47,22 +48,19 @@ def test_connect_hardware_failure( def test_disconnect_hardware( mock_server: GeneratorServerShanghaiTech, caplog: pytest.LogCaptureFixture ): - mock_server.connect_hardware() + with patch.object(mock_server, "device") as mock_device: mock_server._send_response = MagicMock() mock_server.disconnect_hardware() mock_device.close.assert_called_once() assert mock_server._hardware_connected is False - assert mock_server.device is None assert "Hardware disconnected successfully" in caplog.text - mock_server._send_response.assert_called_with("Hardware disconnected") + mock_server._send_response.assert_called_with(b"Hardware disconnected") def test_disconnect_hardware_not_connected( mock_server: GeneratorServerShanghaiTech, caplog: pytest.LogCaptureFixture ): - - mock_server.connect_hardware() with patch.object(mock_server, "device") as mock_device: mock_device.is_open = False mock_server._send_error = MagicMock() @@ -78,14 +76,12 @@ def test_disconnect_hardware_not_connected( def test_disconnect_hardware_exception_on_close( mock_server: GeneratorServerShanghaiTech, caplog: pytest.LogCaptureFixture ): - mock_server.connect_hardware() with patch.object(mock_server, "device") as mock_device: mock_device.close.side_effect = Exception("Close failed") mock_server._send_error = MagicMock() mock_server.disconnect_hardware() mock_device.close.assert_called_once() assert mock_server._hardware_connected is False - assert mock_server.device is None assert "Error occurred while closing hardware connection" in caplog.text mock_server._send_error.assert_called_with( "Error occurred while closing hardware connection Close failed" @@ -95,10 +91,71 @@ def test_disconnect_hardware_exception_on_close( def test_set_delay_success( mock_server: GeneratorServerShanghaiTech, ) -> None: - mock_respond = "set success: 500" + mock_respond = b"set success: 500" with patch.object(mock_server, "device") as mock_device: - mock_device.readline.return_value = mock_respond.encode() + mock_device.readall.return_value = mock_respond mock_server._send_response = MagicMock() mock_server._set_delay(b"500") mock_server._send_response.assert_called_once_with(mock_respond) - mock_device.readline.assert_called_once() + mock_device.readall.assert_called_once() + + +def test_set_delay_failed(mock_server: GeneratorServerShanghaiTech) -> None: + with patch.object(mock_server, "device") as mock_device: + mock_device.write.side_effect = Exception("Write_failed") + mock_server._send_error = MagicMock() + mock_server._set_delay(b"112") + mock_server._send_error.assert_called_once_with( + "Set delay failed: Write_failed" + ) + + +def test_get_delay_success(mock_server: GeneratorServerShanghaiTech) -> None: + test_reading = b"Test reading" + with patch.object(mock_server, "device") as mock_device: + mock_device.readall.return_value = test_reading + mock_server._send_response = MagicMock() + mock_server._get_delay() + mock_device.write.assert_called_once_with(b"AT+DLSET=?\r\n") + mock_server._send_response.assert_called_once_with(test_reading) + + +def test_get_delay_failed(mock_server: GeneratorServerShanghaiTech) -> None: + with patch.object(mock_server, "device") as mock_device: + mock_device.write.side_effect = Exception("Read_failed") + mock_server._send_error = MagicMock() + mock_server._get_delay() + mock_server._send_error.assert_called_once_with( + "Read delay failed: Read_failed" + ) + + +def test_reset_serial_buffer_success(mock_server: GeneratorServerShanghaiTech): + with patch.object(mock_server, "device", spec=Serial) as mock_device: + mock_server.device = mock_device + mock_server._reset_serial_buffer() + mock_server.device.reset_output_buffer.assert_called_once() + mock_server.device.reset_input_buffer.assert_called_once() + + +def test_reset_serial_buffer_fail(mock_server: GeneratorServerShanghaiTech): + with patch.object(mock_server, "device", spec=Serial) as mock_device: + mock_server.device = mock_device + mock_server.device.reset_output_buffer.side_effect = Exception( + "Buffer reset failed" + ) + mock_server._send_error = MagicMock() + mock_server._reset_serial_buffer() + mock_server._send_error.assert_called_once_with( + "Buffer reset failed: Buffer reset failed" + ) + + +def test_passthrough_success(mock_server: GeneratorServerShanghaiTech): + command = b"some commands" + multi_line_responds = b"somethn\r\nsomethingelse\r\nmore\t\r\n" + with patch.object(mock_server, "device", spec=Serial) as mock_device: + mock_server.device = mock_device + mock_server.device.readall.return_value = multi_line_responds + mock_server.device.write.assert_called_once_with(command) + mock_server.device._send_response.assert_called_once_with(multi_line_responds) From 514098d4b564f6a8e8f72bfdb9f66b3312260d5e Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 19 Mar 2026 13:54:15 +0000 Subject: [PATCH 31/56] change response to take byes --- src/sm_bluesky/common/server/abstract_instrument_server.py | 4 ++-- tests/common/server/test_abstract_instrument_server.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 6e60576c..2a0c7911 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -128,9 +128,9 @@ def _send_error(self, error_message: str) -> None: if self._conn: self._conn.sendall(b"0\t" + error_message.encode() + b"\n") - def _send_response(self, response: str = "") -> None: + def _send_response(self, response: bytes = b"") -> None: if self._conn: - self._conn.sendall(b"1\t" + response.encode() + b"\n") + self._conn.sendall(b"1\t" + response + b"\n") def _handle_command(self, cmd: bytes, args: bytes) -> None: """Executes logic for a specific instrument command.""" diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 7dfd9966..862477b8 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -150,7 +150,7 @@ def handling_exception(): def test_send_response(mock_instrument: AbstractInstrumentServer): mock_instrument._conn = MagicMock() mock_instrument._conn.sendall = MagicMock() - mock_instrument._send_response("data data data") + mock_instrument._send_response(b"data data data") mock_instrument._conn.sendall.assert_called_once_with(b"1\tdata data data\n") From 7a76cf104982072b2b2c9d56e43d4297ba14c32e Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 19 Mar 2026 15:03:48 +0000 Subject: [PATCH 32/56] add passthrough and absracted hardware command and responds --- .../server/pulse_generator_shanghai_tech.py | 40 +++++++++++-------- .../test_pulse_generator_shanghai_test.py | 17 +++++++- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py index f0b728ae..3fdd994d 100644 --- a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py +++ b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py @@ -13,13 +13,13 @@ def __init__( usb_port: str = "COM4", baud_rate: int = 9600, timeout: float = 1.0, - max_delay=1024, + max_pulse_delay=1024, ): super().__init__(host, port, ipv6) self.usb_port: str = usb_port self.baud_rate: int = baud_rate self.timeout: float = timeout - self.max_pulse_delay: float = max_delay + self.max_pulse_delay: float = max_pulse_delay self.device: Serial # Expand the registry with Pulse Generator specific commands @@ -63,25 +63,23 @@ def disconnect_hardware(self): self._send_error("Attempted to disconnect hardware that was not connected") def _set_delay(self, value: bytes) -> None: - delay = int(value.decode("utf-8")) - if 1024 > delay >= 0: - try: - self.device.write(b"AT+DLSET=" + value + b"\r\n") - device_respond = self.device.readall() - self._send_response(device_respond) - except Exception as e: - self._error_helper(message="Set delay failed", error=e) - else: - self._error_helper("Delay must be between 0 and 1023") + try: + delay = int(value.decode("utf-8")) + if 1024 > delay >= 0: + self._send_hardware_command(b"AT+DLSET=" + value) + LOGGER.info(f"Setting delay to {value}") + else: + self._error_helper("Delay must be between 0 and 1023") + + except Exception as e: + self._error_helper(message="Set delay failed", error=e) def _get_delay(self): try: - self.device.write(b"AT+DLSET=?\r\n") - reading = self.device.readall() - LOGGER.info(f"Reading delay: {reading}") - self._send_response(reading) + self._send_hardware_command(b"AT+DLSET=?") + LOGGER.info("Reading delay") except Exception as e: self._error_helper(message="Read delay failed", error=e) @@ -94,4 +92,12 @@ def _reset_serial_buffer(self): self._error_helper(message="Buffer reset failed", error=e) def _passthrough(self, value: bytes): - pass + try: + self._send_hardware_command(value) + except Exception as e: + self._error_helper(message="Command pass through failed", error=e) + + def _send_hardware_command(self, cmd: bytes): + self.device.write(cmd + b"\r\n") + device_respond = self.device.readall() + self._send_response(device_respond) diff --git a/tests/common/server/test_pulse_generator_shanghai_test.py b/tests/common/server/test_pulse_generator_shanghai_test.py index 31d33585..a25135e4 100644 --- a/tests/common/server/test_pulse_generator_shanghai_test.py +++ b/tests/common/server/test_pulse_generator_shanghai_test.py @@ -155,7 +155,20 @@ def test_passthrough_success(mock_server: GeneratorServerShanghaiTech): command = b"some commands" multi_line_responds = b"somethn\r\nsomethingelse\r\nmore\t\r\n" with patch.object(mock_server, "device", spec=Serial) as mock_device: + mock_server._send_response = MagicMock() mock_server.device = mock_device mock_server.device.readall.return_value = multi_line_responds - mock_server.device.write.assert_called_once_with(command) - mock_server.device._send_response.assert_called_once_with(multi_line_responds) + mock_server._passthrough(command) + mock_server.device.write.assert_called_once_with(command + b"\r\n") + mock_server._send_response.assert_called_once_with(multi_line_responds) + + +def test_passthrough_failed(mock_server: GeneratorServerShanghaiTech): + with patch.object(mock_server, "device", spec=Serial) as mock_device: + mock_server.device = mock_device + mock_server.device.write.side_effect = Exception("Command pass through failed") + mock_server._send_error = MagicMock() + mock_server._passthrough(b"does not matter") + mock_server._send_error.assert_called_once_with( + "Command pass through failed: Command pass through failed" + ) From df5952f7e0ce5df84be0dfd32d27decdbe33bf48 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 19 Mar 2026 15:05:12 +0000 Subject: [PATCH 33/56] added typing --- src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py index 3fdd994d..369ebc59 100644 --- a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py +++ b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py @@ -97,7 +97,7 @@ def _passthrough(self, value: bytes): except Exception as e: self._error_helper(message="Command pass through failed", error=e) - def _send_hardware_command(self, cmd: bytes): + def _send_hardware_command(self, cmd: bytes) -> None: self.device.write(cmd + b"\r\n") device_respond = self.device.readall() self._send_response(device_respond) From fb8ed3d1129d87558026fc15dcdaba30022e2b20 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 19 Mar 2026 15:14:47 +0000 Subject: [PATCH 34/56] add missing test --- .../server/test_pulse_generator_shanghai_test.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/common/server/test_pulse_generator_shanghai_test.py b/tests/common/server/test_pulse_generator_shanghai_test.py index a25135e4..c56eec59 100644 --- a/tests/common/server/test_pulse_generator_shanghai_test.py +++ b/tests/common/server/test_pulse_generator_shanghai_test.py @@ -110,6 +110,17 @@ def test_set_delay_failed(mock_server: GeneratorServerShanghaiTech) -> None: ) +@pytest.mark.parametrize("delay", [b"-200", b"1024", b"-1"]) +def test_set_delay_failed_out_of_bound( + delay: bytes, + mock_server: GeneratorServerShanghaiTech, +) -> None: + + mock_server._send_error = MagicMock() + mock_server._set_delay(delay) + mock_server._send_error.assert_called_once_with("Delay must be between 0 and 1023") + + def test_get_delay_success(mock_server: GeneratorServerShanghaiTech) -> None: test_reading = b"Test reading" with patch.object(mock_server, "device") as mock_device: From 4316f982d38ce56d3e5d531268047d64dc428685 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 19 Mar 2026 15:20:59 +0000 Subject: [PATCH 35/56] make use of error_helper --- .../common/server/pulse_generator_shanghai_tech.py | 11 +++++++---- .../server/test_pulse_generator_shanghai_test.py | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py index 369ebc59..b71cfb17 100644 --- a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py +++ b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py @@ -1,3 +1,5 @@ +import logging + from serial import Serial from sm_bluesky.common.server import AbstractInstrumentServer @@ -41,8 +43,7 @@ def connect_hardware(self) -> bool: self._send_response(b"Hardware connected successfully") return True except Exception as e: - LOGGER.error(f"Failed to connect to hardware {e}") - self._send_error(f"Failed to connect to hardware {e}") + self._error_helper(message="Failed to connect to hardware", error=e) return False def disconnect_hardware(self): @@ -59,8 +60,10 @@ def disconnect_hardware(self): LOGGER.info("Hardware disconnected successfully") self._send_response(b"Hardware disconnected") else: - LOGGER.warning("Attempted to disconnect hardware that was not connected") - self._send_error("Attempted to disconnect hardware that was not connected") + self._error_helper( + message="Attempted to disconnect hardware that was not connected", + level=logging.WARNING, + ) def _set_delay(self, value: bytes) -> None: diff --git a/tests/common/server/test_pulse_generator_shanghai_test.py b/tests/common/server/test_pulse_generator_shanghai_test.py index c56eec59..a1a084a2 100644 --- a/tests/common/server/test_pulse_generator_shanghai_test.py +++ b/tests/common/server/test_pulse_generator_shanghai_test.py @@ -39,9 +39,9 @@ def test_connect_hardware_failure( mock_serial_class.side_effect = Exception(error_message) mock_server._send_error = MagicMock() assert mock_server.connect_hardware() is False - assert f"Failed to connect to hardware {error_message}" in caplog.text + assert f"Failed to connect to hardware: {error_message}" in caplog.text mock_server._send_error.assert_called_with( - f"Failed to connect to hardware {error_message}" + f"Failed to connect to hardware: {error_message}" ) From 46cfbdf1358dd84f5951b3474022b18e11185679 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 19 Mar 2026 16:38:36 +0000 Subject: [PATCH 36/56] move to use readlin and added flush --- .../common/server/pulse_generator_shanghai_tech.py | 9 ++++++--- .../server/test_pulse_generator_shanghai_test.py | 13 ++++++++----- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py index b71cfb17..b5f0b25f 100644 --- a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py +++ b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py @@ -69,11 +69,13 @@ def _set_delay(self, value: bytes) -> None: try: delay = int(value.decode("utf-8")) - if 1024 > delay >= 0: + if self.max_pulse_delay > delay >= 0: self._send_hardware_command(b"AT+DLSET=" + value) LOGGER.info(f"Setting delay to {value}") else: - self._error_helper("Delay must be between 0 and 1023") + raise ValueError( + f"Delay {delay} is out of bounds (0-{self.max_pulse_delay - 1})" + ) except Exception as e: self._error_helper(message="Set delay failed", error=e) @@ -102,5 +104,6 @@ def _passthrough(self, value: bytes): def _send_hardware_command(self, cmd: bytes) -> None: self.device.write(cmd + b"\r\n") - device_respond = self.device.readall() + self.device.flush() + device_respond = self.device.readline() self._send_response(device_respond) diff --git a/tests/common/server/test_pulse_generator_shanghai_test.py b/tests/common/server/test_pulse_generator_shanghai_test.py index a1a084a2..de331ca9 100644 --- a/tests/common/server/test_pulse_generator_shanghai_test.py +++ b/tests/common/server/test_pulse_generator_shanghai_test.py @@ -93,11 +93,11 @@ def test_set_delay_success( ) -> None: mock_respond = b"set success: 500" with patch.object(mock_server, "device") as mock_device: - mock_device.readall.return_value = mock_respond + mock_device.readline.return_value = mock_respond mock_server._send_response = MagicMock() mock_server._set_delay(b"500") mock_server._send_response.assert_called_once_with(mock_respond) - mock_device.readall.assert_called_once() + mock_device.readline.assert_called_once() def test_set_delay_failed(mock_server: GeneratorServerShanghaiTech) -> None: @@ -118,13 +118,16 @@ def test_set_delay_failed_out_of_bound( mock_server._send_error = MagicMock() mock_server._set_delay(delay) - mock_server._send_error.assert_called_once_with("Delay must be between 0 and 1023") + mock_server._send_error.assert_called_once_with( + f"Set delay failed: Delay {delay.decode('utf-8')}" + + f" is out of bounds (0-{mock_server.max_pulse_delay - 1})" + ) def test_get_delay_success(mock_server: GeneratorServerShanghaiTech) -> None: test_reading = b"Test reading" with patch.object(mock_server, "device") as mock_device: - mock_device.readall.return_value = test_reading + mock_device.readline.return_value = test_reading mock_server._send_response = MagicMock() mock_server._get_delay() mock_device.write.assert_called_once_with(b"AT+DLSET=?\r\n") @@ -168,7 +171,7 @@ def test_passthrough_success(mock_server: GeneratorServerShanghaiTech): with patch.object(mock_server, "device", spec=Serial) as mock_device: mock_server._send_response = MagicMock() mock_server.device = mock_device - mock_server.device.readall.return_value = multi_line_responds + mock_server.device.readline.return_value = multi_line_responds mock_server._passthrough(command) mock_server.device.write.assert_called_once_with(command + b"\r\n") mock_server._send_response.assert_called_once_with(multi_line_responds) From 8963f965e8c6d7cc15db356fe96d549cede607f6 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 19 Mar 2026 16:55:39 +0000 Subject: [PATCH 37/56] remove try and catch as it is done by the base class --- .../server/pulse_generator_shanghai_tech.py | 43 +++++++------------ .../test_pulse_generator_shanghai_test.py | 23 +++++----- 2 files changed, 27 insertions(+), 39 deletions(-) diff --git a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py index b5f0b25f..0d8f4e00 100644 --- a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py +++ b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py @@ -67,40 +67,27 @@ def disconnect_hardware(self): def _set_delay(self, value: bytes) -> None: - try: - delay = int(value.decode("utf-8")) - if self.max_pulse_delay > delay >= 0: - self._send_hardware_command(b"AT+DLSET=" + value) - LOGGER.info(f"Setting delay to {value}") - else: - raise ValueError( - f"Delay {delay} is out of bounds (0-{self.max_pulse_delay - 1})" - ) - - except Exception as e: - self._error_helper(message="Set delay failed", error=e) + # try: + delay = int(value.decode("utf-8")) + if self.max_pulse_delay > delay >= 0: + self._send_hardware_command(b"AT+DLSET=" + value) + LOGGER.info(f"Setting delay to {value}") + else: + raise ValueError( + f"Delay {delay} is out of bounds (0-{self.max_pulse_delay - 1})" + ) def _get_delay(self): - - try: - self._send_hardware_command(b"AT+DLSET=?") - LOGGER.info("Reading delay") - except Exception as e: - self._error_helper(message="Read delay failed", error=e) + self._send_hardware_command(b"AT+DLSET=?") + LOGGER.info("Reading delay") def _reset_serial_buffer(self): - try: - self.device.reset_input_buffer() - self.device.reset_output_buffer() - LOGGER.info("Resting buffers") - except Exception as e: - self._error_helper(message="Buffer reset failed", error=e) + self.device.reset_input_buffer() + self.device.reset_output_buffer() + LOGGER.info("Resting buffers") def _passthrough(self, value: bytes): - try: - self._send_hardware_command(value) - except Exception as e: - self._error_helper(message="Command pass through failed", error=e) + self._send_hardware_command(value) def _send_hardware_command(self, cmd: bytes) -> None: self.device.write(cmd + b"\r\n") diff --git a/tests/common/server/test_pulse_generator_shanghai_test.py b/tests/common/server/test_pulse_generator_shanghai_test.py index de331ca9..8844b64f 100644 --- a/tests/common/server/test_pulse_generator_shanghai_test.py +++ b/tests/common/server/test_pulse_generator_shanghai_test.py @@ -104,9 +104,9 @@ def test_set_delay_failed(mock_server: GeneratorServerShanghaiTech) -> None: with patch.object(mock_server, "device") as mock_device: mock_device.write.side_effect = Exception("Write_failed") mock_server._send_error = MagicMock() - mock_server._set_delay(b"112") + mock_server._handle_command(cmd=b"set_delay", args=b"112") mock_server._send_error.assert_called_once_with( - "Set delay failed: Write_failed" + "Error handling command 'set_delay': Write_failed" ) @@ -117,9 +117,9 @@ def test_set_delay_failed_out_of_bound( ) -> None: mock_server._send_error = MagicMock() - mock_server._set_delay(delay) + mock_server._handle_command(b"set_delay", delay) mock_server._send_error.assert_called_once_with( - f"Set delay failed: Delay {delay.decode('utf-8')}" + f"Error handling command 'set_delay': Delay {delay.decode('utf-8')}" + f" is out of bounds (0-{mock_server.max_pulse_delay - 1})" ) @@ -138,16 +138,17 @@ def test_get_delay_failed(mock_server: GeneratorServerShanghaiTech) -> None: with patch.object(mock_server, "device") as mock_device: mock_device.write.side_effect = Exception("Read_failed") mock_server._send_error = MagicMock() - mock_server._get_delay() + # mock_server._get_delay() + mock_server._handle_command(cmd=b"get_delay", args=b"") mock_server._send_error.assert_called_once_with( - "Read delay failed: Read_failed" + "Error handling command 'get_delay': Read_failed" ) def test_reset_serial_buffer_success(mock_server: GeneratorServerShanghaiTech): with patch.object(mock_server, "device", spec=Serial) as mock_device: mock_server.device = mock_device - mock_server._reset_serial_buffer() + mock_server._handle_command(cmd=b"reset_serial_buffer", args=b"") mock_server.device.reset_output_buffer.assert_called_once() mock_server.device.reset_input_buffer.assert_called_once() @@ -159,9 +160,9 @@ def test_reset_serial_buffer_fail(mock_server: GeneratorServerShanghaiTech): "Buffer reset failed" ) mock_server._send_error = MagicMock() - mock_server._reset_serial_buffer() + mock_server._handle_command(cmd=b"reset_serial_buffer", args=b"") mock_server._send_error.assert_called_once_with( - "Buffer reset failed: Buffer reset failed" + "Error handling command 'reset_serial_buffer': Buffer reset failed" ) @@ -182,7 +183,7 @@ def test_passthrough_failed(mock_server: GeneratorServerShanghaiTech): mock_server.device = mock_device mock_server.device.write.side_effect = Exception("Command pass through failed") mock_server._send_error = MagicMock() - mock_server._passthrough(b"does not matter") + mock_server._handle_command(cmd=b"pass_command", args=b"some command") mock_server._send_error.assert_called_once_with( - "Command pass through failed: Command pass through failed" + "Error handling command 'pass_command': Command pass through failed" ) From 11763c3c9bb8cdbb8855122d6692be9c4809c69d Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 19 Mar 2026 16:56:55 +0000 Subject: [PATCH 38/56] typo --- src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py index 0d8f4e00..fc40dffd 100644 --- a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py +++ b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py @@ -84,7 +84,7 @@ def _get_delay(self): def _reset_serial_buffer(self): self.device.reset_input_buffer() self.device.reset_output_buffer() - LOGGER.info("Resting buffers") + LOGGER.info("Reseting buffers") def _passthrough(self, value: bytes): self._send_hardware_command(value) From 1b12db9cfb9b550ad3c41c8dd43b25a8e9d0a22a Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 19 Mar 2026 17:08:53 +0000 Subject: [PATCH 39/56] add full commond flow test --- .../test_pulse_generator_shanghai_test.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/common/server/test_pulse_generator_shanghai_test.py b/tests/common/server/test_pulse_generator_shanghai_test.py index 8844b64f..3db437ac 100644 --- a/tests/common/server/test_pulse_generator_shanghai_test.py +++ b/tests/common/server/test_pulse_generator_shanghai_test.py @@ -1,3 +1,6 @@ +import socket +import threading +import time from unittest.mock import MagicMock, patch import pytest @@ -187,3 +190,42 @@ def test_passthrough_failed(mock_server: GeneratorServerShanghaiTech): mock_server._send_error.assert_called_once_with( "Error handling command 'pass_command': Command pass through failed" ) + + +@pytest.fixture +def running_server(): + with patch( + "sm_bluesky.common.server.pulse_generator_shanghai_tech.Serial" + ) as mock_serial_class: + mock_device = MagicMock() + mock_serial_class.return_value = mock_device + mock_device.readline.return_value = b"OK\r\n" + + server = GeneratorServerShanghaiTech(host="127.0.0.1", port=9999) + thread = threading.Thread(target=server.start, daemon=True) + thread.start() + time.sleep(0.1) + yield server + + # Cleanup + server.stop() + + +def test_full_tcp_command_flow(running_server): + """Client sends a command via TCP and verifies the protocol response.""" + client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + client.connect(("127.0.0.1", 9999)) + + # Test a successful hardware command + client.sendall(b"set_delay\t500\n") + response = client.recv(1024) + assert response.startswith(b"1\t") + assert b"OK" in response + + # Test except with bad argument + client.sendall(b"set_delay\t9999\n") + response = client.recv(1024) + assert response.startswith(b"0\t") + assert b"Delay 9999 is out of bounds (0-1023)" in response + + client.close() From 1b6182257e9773f5a38b22765156d5baf95c74fa Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 19 Mar 2026 17:11:02 +0000 Subject: [PATCH 40/56] remove comment --- src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py index fc40dffd..278b646c 100644 --- a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py +++ b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py @@ -66,8 +66,6 @@ def disconnect_hardware(self): ) def _set_delay(self, value: bytes) -> None: - - # try: delay = int(value.decode("utf-8")) if self.max_pulse_delay > delay >= 0: self._send_hardware_command(b"AT+DLSET=" + value) From 09381656e62640f8bf3198269cd8e9bfc53549ac Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 19 Mar 2026 17:19:13 +0000 Subject: [PATCH 41/56] add strip to _send_hardware_command --- src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py | 2 +- tests/common/server/test_pulse_generator_shanghai_test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py index 278b646c..7ad9b524 100644 --- a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py +++ b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py @@ -90,5 +90,5 @@ def _passthrough(self, value: bytes): def _send_hardware_command(self, cmd: bytes) -> None: self.device.write(cmd + b"\r\n") self.device.flush() - device_respond = self.device.readline() + device_respond = self.device.readline().strip() self._send_response(device_respond) diff --git a/tests/common/server/test_pulse_generator_shanghai_test.py b/tests/common/server/test_pulse_generator_shanghai_test.py index 3db437ac..babd1ea5 100644 --- a/tests/common/server/test_pulse_generator_shanghai_test.py +++ b/tests/common/server/test_pulse_generator_shanghai_test.py @@ -171,7 +171,7 @@ def test_reset_serial_buffer_fail(mock_server: GeneratorServerShanghaiTech): def test_passthrough_success(mock_server: GeneratorServerShanghaiTech): command = b"some commands" - multi_line_responds = b"somethn\r\nsomethingelse\r\nmore\t\r\n" + multi_line_responds = b"somethn\r\nsomethingelse\r\nmore" with patch.object(mock_server, "device", spec=Serial) as mock_device: mock_server._send_response = MagicMock() mock_server.device = mock_device From 6ee740910ec192715dd3e56805c25de9a08e126c Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 19 Mar 2026 17:35:47 +0000 Subject: [PATCH 42/56] add None to serial --- .../server/pulse_generator_shanghai_tech.py | 15 +++++++++++---- .../server/test_pulse_generator_shanghai_test.py | 2 +- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py index 7ad9b524..b9a2c604 100644 --- a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py +++ b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py @@ -22,7 +22,7 @@ def __init__( self.baud_rate: int = baud_rate self.timeout: float = timeout self.max_pulse_delay: float = max_pulse_delay - self.device: Serial + self.device: Serial | None = None # Expand the registry with Pulse Generator specific commands self._command_registry.update( @@ -52,9 +52,8 @@ def disconnect_hardware(self): try: self.device.close() except Exception as e: - LOGGER.error(f"Error occurred while closing hardware connection {e}") - self._send_error( - f"Error occurred while closing hardware connection {e}" + self._error_helper( + message="Error occurred while closing hardware connection", error=e ) self._hardware_connected = False LOGGER.info("Hardware disconnected successfully") @@ -80,6 +79,10 @@ def _get_delay(self): LOGGER.info("Reading delay") def _reset_serial_buffer(self): + if not self.device: + raise ConnectionError( + "Hardware not connected. Call connect_hardware first." + ) self.device.reset_input_buffer() self.device.reset_output_buffer() LOGGER.info("Reseting buffers") @@ -88,6 +91,10 @@ def _passthrough(self, value: bytes): self._send_hardware_command(value) def _send_hardware_command(self, cmd: bytes) -> None: + if not self.device: + raise ConnectionError( + "Hardware not connected. Call connect_hardware first." + ) self.device.write(cmd + b"\r\n") self.device.flush() device_respond = self.device.readline().strip() diff --git a/tests/common/server/test_pulse_generator_shanghai_test.py b/tests/common/server/test_pulse_generator_shanghai_test.py index babd1ea5..7169bbcb 100644 --- a/tests/common/server/test_pulse_generator_shanghai_test.py +++ b/tests/common/server/test_pulse_generator_shanghai_test.py @@ -87,7 +87,7 @@ def test_disconnect_hardware_exception_on_close( assert mock_server._hardware_connected is False assert "Error occurred while closing hardware connection" in caplog.text mock_server._send_error.assert_called_with( - "Error occurred while closing hardware connection Close failed" + "Error occurred while closing hardware connection: Close failed" ) From a5b19b0122e099f87f733310ab425ee2f532eb19 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 19 Mar 2026 17:38:41 +0000 Subject: [PATCH 43/56] add None to disconnect --- src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py index b9a2c604..3aa1d992 100644 --- a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py +++ b/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py @@ -56,6 +56,7 @@ def disconnect_hardware(self): message="Error occurred while closing hardware connection", error=e ) self._hardware_connected = False + self.device = None LOGGER.info("Hardware disconnected successfully") self._send_response(b"Hardware disconnected") else: From db6014be5c07848a07605cb9f65187069f6fc20c Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 19 Mar 2026 17:53:52 +0000 Subject: [PATCH 44/56] add docs --- docs/how-to/4_pulse_genertor_shanghai_tech.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 docs/how-to/4_pulse_genertor_shanghai_tech.md diff --git a/docs/how-to/4_pulse_genertor_shanghai_tech.md b/docs/how-to/4_pulse_genertor_shanghai_tech.md new file mode 100644 index 00000000..514d9368 --- /dev/null +++ b/docs/how-to/4_pulse_genertor_shanghai_tech.md @@ -0,0 +1,70 @@ +# Pulse Generator Server (ShanghaiTech) + +A TCP/IP instrument server designed to control Pulse Generators via USB Serial (RS232). + +## 1. Connection Specifications + +| Parameter | Default Value | +| :--- | :--- | +| **Host** | `localhost` (127.0.0.1) | +| **TCP Port** | `8888` | +| **Interface** | USB Serial (COM / /dev/tty) | +| **Baud Rate** | `9600` | +| **Data Protocol** | Tab-Separated, Newline-Terminated (`\t`, `\n`) | + +--- + +## 2. Communication Protocol + + + +The server follows a **Request-Response** model. Every command sent by a client will receive a response starting with a status bit. + +### Request Format +`COMMAND` + `\t` (Tab) + `ARGUMENT` (Optional) + `\n` (Newline) + +### Response Format +* **Success:** `1` + `\t` + `Data/Message` + `\n` +* **Error:** `0` + `\t` + `Error Description` + `\n` + +--- + +## 3. Command Registry + +| Command | Argument | Description | Example | +| :--- | :--- | :--- | :--- | +| `ping` | None | Heartbeat check to verify server status. | `ping\n` | +| `connect_hardware` | None | Initializes/Re-opens the Serial port. | `connect_hardware\n` | +| `set_delay` | `0-1023` | Sets the pulse delay on the hardware. | `set_delay\t512\n` | +| `get_delay` | None | Queries the current delay from hardware. | `get_delay\n` | +| `reset_serial_buffer`| None | Clears the hardware's internal I/O buffers. | `reset_serial_buffer\n` | +| `pass_command` | `string` | Sends a raw AT command to the device. | `pass_command\tAT+VER\n` | +| `shutdown` | None | Safely stops the server and releases hardware. | `shutdown\n` | + +--- + + +## 4. Quick Start: Python Client + +You can interact with the server using any language that supports sockets. Here is a minimal Python example: + +```python +import socket + +def send_pulse_command(ip, port, cmd, arg=None): + try: + with socket.create_connection((ip, port), timeout=2.0) as s: + message = f"{cmd}\t{arg}\n" if arg else f"{cmd}\n" + s.sendall(message.encode()) + response = s.recv(1024).decode().strip() + + status, data = response.split('\t', 1) + if status == '1': + print(f"SUCCESS: {data}") + else: + print(f"ERROR: {data}") + except Exception as e: + print(f"Connection Failed: {e}") + +# Example Usage +send_pulse_command("127.0.0.1", 8888, "set_delay", "250") From 2b89262c546bb605beb928a46ddd7ff69cac4852 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 23 Mar 2026 11:24:01 +0000 Subject: [PATCH 45/56] complete test for connection error --- .../server/test_pulse_generator_shanghai_test.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/common/server/test_pulse_generator_shanghai_test.py b/tests/common/server/test_pulse_generator_shanghai_test.py index 7169bbcb..6f7c83f2 100644 --- a/tests/common/server/test_pulse_generator_shanghai_test.py +++ b/tests/common/server/test_pulse_generator_shanghai_test.py @@ -169,6 +169,14 @@ def test_reset_serial_buffer_fail(mock_server: GeneratorServerShanghaiTech): ) +def test_reset_serial_buffer_fail_no_device(mock_server: GeneratorServerShanghaiTech): + mock_server.device = None + with pytest.raises( + ConnectionError, match="Hardware not connected. Call connect_hardware first." + ): + mock_server._reset_serial_buffer() + + def test_passthrough_success(mock_server: GeneratorServerShanghaiTech): command = b"some commands" multi_line_responds = b"somethn\r\nsomethingelse\r\nmore" @@ -192,6 +200,14 @@ def test_passthrough_failed(mock_server: GeneratorServerShanghaiTech): ) +def test_send_hardware_command_fail_no_device(mock_server: GeneratorServerShanghaiTech): + mock_server.device = None + with pytest.raises( + ConnectionError, match="Hardware not connected. Call connect_hardware first." + ): + mock_server._send_hardware_command(cmd=b"some command") + + @pytest.fixture def running_server(): with patch( From 981bbba8663e17980d44c6eaa75fcfe9b2e70805 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 23 Mar 2026 11:54:36 +0000 Subject: [PATCH 46/56] move pyserial to optional dependencies --- pyproject.toml | 5 ++++- uv.lock | 11 +++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9ec413b4..abe403d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,6 @@ dependencies = [ "dls-dodal>=2.1.0", "ophyd-async[sim]", "scanspec", - "pyserial", ] dynamic = ["version"] @@ -29,8 +28,12 @@ license.file = "LICENSE" readme = "README.md" requires-python = ">=3.11" + +[project.optional-dependencies] +server = ["pyserial"] [dependency-groups] dev = [ + "sm_bluesky[server]", "copier", "myst-parser", "pre-commit", diff --git a/uv.lock b/uv.lock index 9d628c61..af0e1975 100644 --- a/uv.lock +++ b/uv.lock @@ -5452,10 +5452,14 @@ dependencies = [ { name = "bluesky" }, { name = "dls-dodal" }, { name = "ophyd-async", extra = ["sim"] }, - { name = "pyserial" }, { name = "scanspec" }, ] +[package.optional-dependencies] +server = [ + { name = "pyserial" }, +] + [package.dev-dependencies] dev = [ { name = "blueapi" }, @@ -5475,6 +5479,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "sm-bluesky", extra = ["server"] }, { name = "sphinx-autobuild" }, { name = "sphinx-copybutton" }, { name = "sphinx-design" }, @@ -5487,9 +5492,10 @@ requires-dist = [ { name = "bluesky" }, { name = "dls-dodal", specifier = ">=2.1.0" }, { name = "ophyd-async", extras = ["sim"] }, - { name = "pyserial" }, + { name = "pyserial", marker = "extra == 'server'" }, { name = "scanspec" }, ] +provides-extras = ["server"] [package.metadata.requires-dev] dev = [ @@ -5509,6 +5515,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "sm-bluesky", extras = ["server"] }, { name = "sphinx-autobuild" }, { name = "sphinx-copybutton" }, { name = "sphinx-design" }, From 9872ef144559250e3fb0bdbf19b1ffc35b3bfbdf Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 24 Mar 2026 15:44:26 +0000 Subject: [PATCH 47/56] change args to a list --- src/sm_bluesky/common/server/abstract_instrument_server.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 2a0c7911..03250f78 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -143,7 +143,8 @@ def _handle_command(self, cmd: bytes, args: bytes) -> None: ) else: try: - handler(args) if args else handler() + arg_list = args.split(b"\t") if args else [] + handler(*arg_list) except Exception as e: self._error_helper( From bffd1c216d442aa31e97d2914abb239c92ab0dfa Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 26 Mar 2026 11:49:19 +0000 Subject: [PATCH 48/56] add contextmanager for hardware not responding --- .../server/abstract_instrument_server.py | 25 +++++++++++++++++-- .../server/test_abstract_instrument_server.py | 25 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 03250f78..f6659855 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -1,3 +1,4 @@ +import signal import socket from abc import ABC, abstractmethod from collections.abc import Callable @@ -70,6 +71,20 @@ def _manage_connection(self, client_info: tuple[socket.socket, str]): self._disconnect_client() LOGGER.info(f"Client {addr} disconnected. Server ready.") + @contextmanager + def _hardware_watch(self, seconds: int = 60): + """Context manager to interrupt hardware calls that took too long.""" + + def handler(signum, frame): + raise TimeoutError(f"Hardware call timed out after {seconds}s") + + signal.signal(signal.SIGALRM, handler) + signal.alarm(seconds) + try: + yield + finally: + signal.alarm(0) + def stop(self) -> None: """Stops the server, closes sockets, and disconnects hardware.""" self._disconnect_client() @@ -143,9 +158,15 @@ def _handle_command(self, cmd: bytes, args: bytes) -> None: ) else: try: - arg_list = args.split(b"\t") if args else [] - handler(*arg_list) + with self._hardware_watch(seconds=60): + arg_list = args.split(b"\t") if args else [] + handler(*arg_list) + except TimeoutError as te: + self._error_helper( + f"Error handling command: {cmd.decode()} - hardware not responding", + te, + ) except Exception as e: self._error_helper( message=f"Error handling command '{cmd.decode()}'", error=e diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 862477b8..fb382dce 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -1,3 +1,4 @@ +import signal import socket from unittest.mock import MagicMock, patch @@ -219,3 +220,27 @@ def test_dispatch_command_with_arg(mock_instrument: AbstractInstrumentServer): mock_instrument._handle_command.assert_called_once_with( b"command", b"argument\targument2" ) + + +def test_hardware_watch_timeout(mock_instrument: AbstractInstrumentServer): + """Tests that a TimeoutError (hardware_Watch trip) is caught and reported + correctly.""" + + cmd = b"getData" + args = b"0.1" + mock_handler = MagicMock(side_effect=TimeoutError("Hardware hung")) + mock_instrument._command_registry[cmd] = mock_handler + mock_instrument._error_helper = MagicMock() + mock_instrument._handle_command(cmd, args) + mock_instrument._error_helper.assert_called_once() + args_called, _ = mock_instrument._error_helper.call_args + assert "hardware not responding" in args_called[0].lower() + assert isinstance(args_called[1], TimeoutError) + + +def test_hardware_watch_handler_definition(mock_instrument): + with patch("signal.signal") as mock_signal_reg: + with mock_instrument._hardware_watch(seconds=5): + captured_handler = mock_signal_reg.call_args[0][1] + with pytest.raises(TimeoutError, match="Hardware call timed out after 5s"): + captured_handler(signal.SIGALRM, None) From 1c43785027a4eaaccbe5b861c8bfbeaeafb1e371 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 26 Mar 2026 11:50:47 +0000 Subject: [PATCH 49/56] complete hardware_watch test --- tests/common/server/test_abstract_instrument_server.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index fb382dce..0c30630c 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -244,3 +244,11 @@ def test_hardware_watch_handler_definition(mock_instrument): captured_handler = mock_signal_reg.call_args[0][1] with pytest.raises(TimeoutError, match="Hardware call timed out after 5s"): captured_handler(signal.SIGALRM, None) + + +def test_hardware_watch_disarms_on_success(mock_instrument): + with patch("signal.alarm") as mock_alarm: + with mock_instrument._hardware_watch(seconds=10): + mock_alarm.assert_called_with(10) + + mock_alarm.assert_called_with(0) From 59602ee04863a842564f9f7b1862704cbca6dc41 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 26 Mar 2026 12:22:37 +0000 Subject: [PATCH 50/56] fix sigal required main thread. --- .../server/abstract_instrument_server.py | 27 ++++++++---- .../server/test_abstract_instrument_server.py | 41 +++++++++++++------ 2 files changed, 48 insertions(+), 20 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index f6659855..600b840e 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -1,5 +1,6 @@ -import signal +import ctypes import socket +import threading from abc import ABC, abstractmethod from collections.abc import Callable from contextlib import contextmanager @@ -74,16 +75,28 @@ def _manage_connection(self, client_info: tuple[socket.socket, str]): @contextmanager def _hardware_watch(self, seconds: int = 60): """Context manager to interrupt hardware calls that took too long.""" + target_tid = threading.get_ident() + stop_event = threading.Event() + + def trigger_timeout(): + if not stop_event.wait(timeout=seconds): + # Inject TimeoutError and kill the thread + res = ctypes.pythonapi.PyThreadState_SetAsyncExc( + ctypes.c_long(target_tid), ctypes.py_object(TimeoutError) + ) + if res > 1: + # If it affected more than one thread, undo it! + ctypes.pythonapi.PyThreadState_SetAsyncExc( + ctypes.c_long(target_tid), None + ) - def handler(signum, frame): - raise TimeoutError(f"Hardware call timed out after {seconds}s") + monitor = threading.Thread(target=trigger_timeout, daemon=True) + monitor.start() - signal.signal(signal.SIGALRM, handler) - signal.alarm(seconds) try: yield finally: - signal.alarm(0) + stop_event.set() def stop(self) -> None: """Stops the server, closes sockets, and disconnects hardware.""" @@ -153,7 +166,7 @@ def _handle_command(self, cmd: bytes, args: bytes) -> None: if not handler: self._error_helper( message=f"Received unknown command: '{cmd.decode()}'", - error=Exception("Unknow command"), + error=Exception("Unknown command"), level=logging.WARNING, ) else: diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 0c30630c..18f97fa3 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -1,5 +1,5 @@ -import signal import socket +import time from unittest.mock import MagicMock, patch import pytest @@ -130,7 +130,7 @@ def test_send_unknow_command_error(mock_instrument: AbstractInstrumentServer): mock_instrument._conn.sendall = MagicMock() mock_instrument._handle_command(b"sdljkfnsdouifn", b"") mock_instrument._conn.sendall.assert_called_once_with( - b"0\tReceived unknown command: 'sdljkfnsdouifn': Unknow command\n" + b"0\tReceived unknown command: 'sdljkfnsdouifn': Unknown command\n" ) @@ -238,17 +238,32 @@ def test_hardware_watch_timeout(mock_instrument: AbstractInstrumentServer): assert isinstance(args_called[1], TimeoutError) -def test_hardware_watch_handler_definition(mock_instrument): - with patch("signal.signal") as mock_signal_reg: - with mock_instrument._hardware_watch(seconds=5): - captured_handler = mock_signal_reg.call_args[0][1] - with pytest.raises(TimeoutError, match="Hardware call timed out after 5s"): - captured_handler(signal.SIGALRM, None) +def test_hardware_watch_thread_lifecycle(mock_instrument): + """Verifies that the watchdog starts a monitor thread and cleans up.""" + with patch("threading.Thread") as mock_thread: + mock_thread_instance = mock_thread.return_value - -def test_hardware_watch_disarms_on_success(mock_instrument): - with patch("signal.alarm") as mock_alarm: with mock_instrument._hardware_watch(seconds=10): - mock_alarm.assert_called_with(10) + mock_thread.assert_called_once() + mock_thread_instance.start.assert_called_once() + + +def test_hardware_watch_injection(mock_instrument): + cmd = b"hang" + + def hanging_command(): + time.sleep(0.1) # Sleep longer than the watchdog + + mock_instrument._command_registry[cmd] = hanging_command + mock_instrument._error_helper = MagicMock() - mock_alarm.assert_called_with(0) + with patch.object( + mock_instrument, + "_handle_command", + side_effect=lambda c, a: mock_instrument.__class__._handle_command( + mock_instrument, c, a + ), + ): + with pytest.raises(TimeoutError): + with mock_instrument._hardware_watch(seconds=0.01): + hanging_command() From 45daa7502217d87decd913e8e5c9bffcfca30579 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 26 Mar 2026 12:31:02 +0000 Subject: [PATCH 51/56] cleanup test --- .../server/test_abstract_instrument_server.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 18f97fa3..92a69270 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -252,18 +252,11 @@ def test_hardware_watch_injection(mock_instrument): cmd = b"hang" def hanging_command(): - time.sleep(0.1) # Sleep longer than the watchdog + time.sleep(0.1) mock_instrument._command_registry[cmd] = hanging_command mock_instrument._error_helper = MagicMock() - with patch.object( - mock_instrument, - "_handle_command", - side_effect=lambda c, a: mock_instrument.__class__._handle_command( - mock_instrument, c, a - ), - ): - with pytest.raises(TimeoutError): - with mock_instrument._hardware_watch(seconds=0.01): - hanging_command() + with pytest.raises(TimeoutError): + with mock_instrument._hardware_watch(seconds=0.01): + hanging_command() From 292918b531782bf4d1b3fa601bfffe2a1d99f9fa Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 26 Mar 2026 17:26:16 +0000 Subject: [PATCH 52/56] change to use time_outcontext instead --- .../server/abstract_instrument_server.py | 34 +++++-------------- .../server/test_abstract_instrument_server.py | 29 ++-------------- 2 files changed, 11 insertions(+), 52 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 600b840e..771f752d 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -1,9 +1,8 @@ -import ctypes import socket -import threading from abc import ABC, abstractmethod from collections.abc import Callable from contextlib import contextmanager +from time import time from sm_bluesky.log import LOGGER, logging @@ -73,30 +72,15 @@ def _manage_connection(self, client_info: tuple[socket.socket, str]): LOGGER.info(f"Client {addr} disconnected. Server ready.") @contextmanager - def _hardware_watch(self, seconds: int = 60): - """Context manager to interrupt hardware calls that took too long.""" - target_tid = threading.get_ident() - stop_event = threading.Event() - - def trigger_timeout(): - if not stop_event.wait(timeout=seconds): - # Inject TimeoutError and kill the thread - res = ctypes.pythonapi.PyThreadState_SetAsyncExc( - ctypes.c_long(target_tid), ctypes.py_object(TimeoutError) - ) - if res > 1: - # If it affected more than one thread, undo it! - ctypes.pythonapi.PyThreadState_SetAsyncExc( - ctypes.c_long(target_tid), None - ) - - monitor = threading.Thread(target=trigger_timeout, daemon=True) - monitor.start() - + def _timeout_context(self, seconds: int): + """ + Provides a deadline for hardware operations. + """ + deadline = time() + seconds try: - yield + yield deadline finally: - stop_event.set() + pass def stop(self) -> None: """Stops the server, closes sockets, and disconnects hardware.""" @@ -171,7 +155,7 @@ def _handle_command(self, cmd: bytes, args: bytes) -> None: ) else: try: - with self._hardware_watch(seconds=60): + with self._timeout_context(seconds=60): arg_list = args.split(b"\t") if args else [] handler(*arg_list) diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 92a69270..600a6739 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -1,5 +1,4 @@ import socket -import time from unittest.mock import MagicMock, patch import pytest @@ -222,8 +221,8 @@ def test_dispatch_command_with_arg(mock_instrument: AbstractInstrumentServer): ) -def test_hardware_watch_timeout(mock_instrument: AbstractInstrumentServer): - """Tests that a TimeoutError (hardware_Watch trip) is caught and reported +def test__timeout_context_timeout(mock_instrument: AbstractInstrumentServer): + """Tests that a TimeoutError (hardware Watch trip) is caught and reported correctly.""" cmd = b"getData" @@ -236,27 +235,3 @@ def test_hardware_watch_timeout(mock_instrument: AbstractInstrumentServer): args_called, _ = mock_instrument._error_helper.call_args assert "hardware not responding" in args_called[0].lower() assert isinstance(args_called[1], TimeoutError) - - -def test_hardware_watch_thread_lifecycle(mock_instrument): - """Verifies that the watchdog starts a monitor thread and cleans up.""" - with patch("threading.Thread") as mock_thread: - mock_thread_instance = mock_thread.return_value - - with mock_instrument._hardware_watch(seconds=10): - mock_thread.assert_called_once() - mock_thread_instance.start.assert_called_once() - - -def test_hardware_watch_injection(mock_instrument): - cmd = b"hang" - - def hanging_command(): - time.sleep(0.1) - - mock_instrument._command_registry[cmd] = hanging_command - mock_instrument._error_helper = MagicMock() - - with pytest.raises(TimeoutError): - with mock_instrument._hardware_watch(seconds=0.01): - hanging_command() From ecf482ce3ac5cfb483526c1a6e28f54b9d814fda Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 30 Mar 2026 11:36:16 +0000 Subject: [PATCH 53/56] add _check_timeout for sub class to check and raise timeout if it took too long --- .../server/abstract_instrument_server.py | 19 ++++++++++++++----- .../server/test_abstract_instrument_server.py | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/server/abstract_instrument_server.py index 771f752d..ff7eb409 100644 --- a/src/sm_bluesky/common/server/abstract_instrument_server.py +++ b/src/sm_bluesky/common/server/abstract_instrument_server.py @@ -22,6 +22,8 @@ def __init__(self, host: str, port: int, ipv6: bool = False): self._hardware_connected: bool = False self._server_socket: socket.socket self._conn: socket.socket | None = None + self._current_deadline: float | None = None + self._timeout_seconds: float = 60.0 self.address_type = socket.AF_INET6 if ipv6 else socket.AF_INET self._command_registry: dict[bytes, Callable] = { b"connect_hardware": self.connect_hardware, @@ -72,15 +74,16 @@ def _manage_connection(self, client_info: tuple[socket.socket, str]): LOGGER.info(f"Client {addr} disconnected. Server ready.") @contextmanager - def _timeout_context(self, seconds: int): + def _timeout_context(self, seconds: float): """ Provides a deadline for hardware operations. """ - deadline = time() + seconds + self._timeout_seconds = seconds + self._current_deadline = time() + seconds try: - yield deadline + yield self._current_deadline finally: - pass + self._current_deadline = None def stop(self) -> None: """Stops the server, closes sockets, and disconnects hardware.""" @@ -155,7 +158,7 @@ def _handle_command(self, cmd: bytes, args: bytes) -> None: ) else: try: - with self._timeout_context(seconds=60): + with self._timeout_context(seconds=self._timeout_seconds): arg_list = args.split(b"\t") if args else [] handler(*arg_list) @@ -179,6 +182,12 @@ def _error_helper( LOGGER.log(level=level, msg=err_msg) self._send_error(err_msg) + def _check_timeout(self, context: str = "Hardware operation"): + """Raises TimeoutError if the current operation has exceeded its deadline.""" + if hasattr(self, "_current_deadline") and self._current_deadline is not None: + if time() > self._current_deadline: + raise TimeoutError(f"{context} exceeded {self._timeout_seconds}s limit") + @abstractmethod def connect_hardware(self) -> bool: """Establishes connection to the specific hardware device.""" diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index 600a6739..501b1e14 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -1,4 +1,5 @@ import socket +from time import sleep from unittest.mock import MagicMock, patch import pytest @@ -235,3 +236,21 @@ def test__timeout_context_timeout(mock_instrument: AbstractInstrumentServer): args_called, _ = mock_instrument._error_helper.call_args assert "hardware not responding" in args_called[0].lower() assert isinstance(args_called[1], TimeoutError) + + +def test_check_timeout_raises_when_expired(mock_instrument: AbstractInstrumentServer): + with mock_instrument._timeout_context(seconds=0.1): + sleep(0.15) + with pytest.raises(TimeoutError, match="Test Operation exceeded 0.1s limit"): + mock_instrument._check_timeout("Test Operation") + + +def test_check_timeout_passes_when_valid(mock_instrument: AbstractInstrumentServer): + """Verify that _check_timeout does nothing if time remains.""" + with mock_instrument._timeout_context(seconds=10): + try: + assert mock_instrument._current_deadline is not None + mock_instrument._check_timeout("Quick Task") + except TimeoutError: + pytest.fail("TimeoutError raised unexpectedly") + assert mock_instrument._current_deadline is None From 0920c79eb79d56f818b1a9867f6ee6538e5d0716 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 30 Mar 2026 11:42:00 +0000 Subject: [PATCH 54/56] add docs --- .../4_Use_abstract_instrument_server.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 docs/how-to/4_Use_abstract_instrument_server.md diff --git a/docs/how-to/4_Use_abstract_instrument_server.md b/docs/how-to/4_Use_abstract_instrument_server.md new file mode 100644 index 00000000..519b9c3b --- /dev/null +++ b/docs/how-to/4_Use_abstract_instrument_server.md @@ -0,0 +1,60 @@ +# AbstractInstrumentServer + +A TCP server framework designed for interfacing with scientific instruments. This base class handles the network management, socket lifecycles, and command buffering, allowing you to focus exclusively on hardware-specific logic. + +## Features + +* **Connection Lifecycle Management:** Automatic handling of client connections and disconnections via Python context managers. +* **Command Dispatcher:** Built-in registry for mapping byte-string commands (e.g., `b"move"`) to Python methods. +* **Buffered Parsing:** Correctly handles TCP fragmentation by buffering incoming data until a newline (`\n`) is reached. +* **Timeout Safety:** Includes a deadline-based timeout context to prevent hardware hangs from locking the server loop. +* **Standardized Logging:** Integrated with `sm_bluesky.log` for consistent tracking of server events and errors. + +--- + +## Communication Protocol + +The server communicates using a simple **Tab-Separated Value (TSV)** format over a raw TCP stream. + +### Request Format (Client → Server) +Commands must be newline-terminated. +`COMMAND` + `\t` + `ARG1` + `\t` + `ARG2...` + `\n` + +### Response Format (Server → Client) +* **Success:** `1` + `\t` + `[Optional Data]` + `\n` +* **Error:** `0` + `\t` + `[Error Message]` + `\n` + +--- + +## Implementation Guide + +To use this framework, create a subclass and implement the mandatory abstract methods. + +### 1. Define your Hardware Class +```python +from sm_bluesky.servers import AbstractInstrumentServer + +class MyMotorServer(AbstractInstrumentServer): + def __init__(self, host, port): + super().__init__(host, port) + # Add custom hardware commands to the registry + self._command_registry[b"move_abs"] = self.move_absolute + + def connect_hardware(self) -> bool: + # Logic to initialize your physical device + print("Initializing Motor...") + return True + + def disconnect_hardware(self) -> None: + # Logic to safely shut down hardware + print("Parking Motor...") + + def move_absolute(self, position: bytes): + # Hardware logic: convert bytes arg to float + pos_mm = float(position) + print(f"Moving to {pos_mm}mm") + + # Periodic timeout check for long operations + self._check_timeout("Motor Move") + + self._send_response(b"Moved to " + position) From ee5d1f00406e58271f75699ee9b7a26f5edef9d1 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 30 Mar 2026 11:58:36 +0000 Subject: [PATCH 55/56] correct docs --- .../4_Use_abstract_instrument_server.md | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/how-to/4_Use_abstract_instrument_server.md b/docs/how-to/4_Use_abstract_instrument_server.md index 519b9c3b..3fdd2dfb 100644 --- a/docs/how-to/4_Use_abstract_instrument_server.md +++ b/docs/how-to/4_Use_abstract_instrument_server.md @@ -1,4 +1,4 @@ -# AbstractInstrumentServer +# Instrument Server (AbstractInstrumentServer) A TCP server framework designed for interfacing with scientific instruments. This base class handles the network management, socket lifecycles, and command buffering, allowing you to focus exclusively on hardware-specific logic. @@ -26,6 +26,16 @@ Commands must be newline-terminated. --- +## Default Methods + +| Command | Arguments | Description | +| :--- | :--- | :--- | + |`ping` | None | Returns `1\t` if server is alive. | +| `connect_hardware`| None | Re-establishes connection to hardware server. | +| `disconnect_hardware`| None | Safely disconnects from hardware. | +| `shutdown` | None | Stops the server and disconnects hardware. | + + ## Implementation Guide To use this framework, create a subclass and implement the mandatory abstract methods. @@ -38,7 +48,7 @@ class MyMotorServer(AbstractInstrumentServer): def __init__(self, host, port): super().__init__(host, port) # Add custom hardware commands to the registry - self._command_registry[b"move_abs"] = self.move_absolute + self._command_registry[b"move_abs"] = self._move_absolute def connect_hardware(self) -> bool: # Logic to initialize your physical device @@ -49,7 +59,7 @@ class MyMotorServer(AbstractInstrumentServer): # Logic to safely shut down hardware print("Parking Motor...") - def move_absolute(self, position: bytes): + def _move_absolute(self, position: bytes): # Hardware logic: convert bytes arg to float pos_mm = float(position) print(f"Moving to {pos_mm}mm") @@ -58,3 +68,10 @@ class MyMotorServer(AbstractInstrumentServer): self._check_timeout("Motor Move") self._send_response(b"Moved to " + position) +if __name__ == "__main__": + # Initialize and start the server + server = MyInstrumentServer("127.0.0.1", 5000) + try: + server.start() + except KeyboardInterrupt: + server.stop() From 326d8f09ec7a4adec5f46c5ec42914f4d6c5823b Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 30 Mar 2026 12:13:09 +0000 Subject: [PATCH 56/56] rename docs --- ...nertor_shanghai_tech.md => 4c_pulse_genertor_shanghai_tech.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/how-to/{4_pulse_genertor_shanghai_tech.md => 4c_pulse_genertor_shanghai_tech.md} (100%) diff --git a/docs/how-to/4_pulse_genertor_shanghai_tech.md b/docs/how-to/4c_pulse_genertor_shanghai_tech.md similarity index 100% rename from docs/how-to/4_pulse_genertor_shanghai_tech.md rename to docs/how-to/4c_pulse_genertor_shanghai_tech.md