From 1ec500d7c9e30c0db7e72da4760b6cd6620567c8 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 25 Jun 2026 11:12:17 +0000 Subject: [PATCH 1/9] add extra cli commands to start server --- src/sm_bluesky/__main__.py | 20 +--- src/sm_bluesky/common/cli.py | 76 ++++++++++++ .../common/{server => servers}/__init__.py | 0 .../abstract_instrument_server.py | 0 .../pulse_generator_shanghai_tech.py | 2 +- .../server/test_abstract_instrument_server.py | 2 +- .../test_pulse_generator_shanghai_test.py | 2 +- tests/common/test_cli.py | 109 ++++++++++++++++++ tests/test_cli.py | 9 -- 9 files changed, 189 insertions(+), 31 deletions(-) create mode 100644 src/sm_bluesky/common/cli.py rename src/sm_bluesky/common/{server => servers}/__init__.py (100%) rename src/sm_bluesky/common/{server => servers}/abstract_instrument_server.py (100%) rename src/sm_bluesky/common/{server => servers}/pulse_generator_shanghai_tech.py (98%) create mode 100644 tests/common/test_cli.py delete mode 100644 tests/test_cli.py diff --git a/src/sm_bluesky/__main__.py b/src/sm_bluesky/__main__.py index 06b7fbeb..9123b385 100644 --- a/src/sm_bluesky/__main__.py +++ b/src/sm_bluesky/__main__.py @@ -1,24 +1,6 @@ """Interface for ``python -m sm_bluesky``.""" -from argparse import ArgumentParser -from collections.abc import Sequence - -from . import __version__ - -__all__ = ["main"] - - -def main(args: Sequence[str] | None = None) -> None: - """Argument parser for the CLI.""" - parser = ArgumentParser() - parser.add_argument( - "-v", - "--version", - action="version", - version=__version__, - ) - parser.parse_args(args) - +from sm_bluesky.common.cli import main if __name__ == "__main__": main() diff --git a/src/sm_bluesky/common/cli.py b/src/sm_bluesky/common/cli.py new file mode 100644 index 00000000..4f66dd94 --- /dev/null +++ b/src/sm_bluesky/common/cli.py @@ -0,0 +1,76 @@ +from argparse import ArgumentParser +from collections.abc import Sequence + +from sm_bluesky import __version__ + +__all__ = ["main"] + + +def main(args: Sequence[str] | None = None) -> None: + """Argument parser for the CLI.""" + parser = ArgumentParser(description="sm-bluesky CLI") + parser.add_argument( + "-v", + "--version", + action="version", + version=__version__, + ) + + subparsers = parser.add_subparsers(dest="command", help="Commands") + start_parser = subparsers.add_parser("start", help="Start an instrument server") + + server_subparsers = start_parser.add_subparsers( + dest="server_type", help="Server types" + ) + + # --- config for shanghaiTech pulse generator --- + sh_parser = server_subparsers.add_parser( + "sh_pulse_generator", help="Launch ShanghaiTech pulse Generator Server" + ) + sh_parser.add_argument( + "--host", type=str, default="0.0.0.0", help="Binding host IP" + ) + sh_parser.add_argument("--port", type=int, default=7891, help="TCP Port") + sh_parser.add_argument("--ipv6", action="store_true", help="Enable IPv6 support") + sh_parser.add_argument( + "--usb-port", type=str, default="COM4", help="Serial USB COM port" + ) + sh_parser.add_argument( + "--baud-rate", type=int, default=9600, help="Serial Baud rate" + ) + sh_parser.add_argument( + "--timeout", type=float, default=1.0, help="Serial timeout duration" + ) + sh_parser.add_argument( + "--max-pulse-delay", + type=int, + default=1024, + help="Max pulse delay configuration", + ) + + parsed_args = parser.parse_args(args) + + if parsed_args.command == "start": + if parsed_args.server_type == "sh_pulse_generator": + from sm_bluesky.common.servers import GeneratorServerShanghaiTech + + print( + f"🚀 Initializing ShanghaiTech Generator on {parsed_args.usb_port}..." + ) + server = GeneratorServerShanghaiTech( + host=parsed_args.host, + port=parsed_args.port, + ipv6=parsed_args.ipv6, + usb_port=parsed_args.usb_port, + baud_rate=parsed_args.baud_rate, + timeout=parsed_args.timeout, + max_pulse_delay=parsed_args.max_pulse_delay, + ) + try: + server.start() + except KeyboardInterrupt: + print("\nStopping server ...") + server.stop() + + else: + parser.print_help() diff --git a/src/sm_bluesky/common/server/__init__.py b/src/sm_bluesky/common/servers/__init__.py similarity index 100% rename from src/sm_bluesky/common/server/__init__.py rename to src/sm_bluesky/common/servers/__init__.py diff --git a/src/sm_bluesky/common/server/abstract_instrument_server.py b/src/sm_bluesky/common/servers/abstract_instrument_server.py similarity index 100% rename from src/sm_bluesky/common/server/abstract_instrument_server.py rename to src/sm_bluesky/common/servers/abstract_instrument_server.py diff --git a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py b/src/sm_bluesky/common/servers/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/servers/pulse_generator_shanghai_tech.py index b7f038c6..e852b76e 100644 --- a/src/sm_bluesky/common/server/pulse_generator_shanghai_tech.py +++ b/src/sm_bluesky/common/servers/pulse_generator_shanghai_tech.py @@ -2,7 +2,7 @@ from serial import Serial -from sm_bluesky.common.server import AbstractInstrumentServer +from sm_bluesky.common.servers import AbstractInstrumentServer from sm_bluesky.log import LOGGER diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/server/test_abstract_instrument_server.py index d13cec5d..f991e21a 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/server/test_abstract_instrument_server.py @@ -4,7 +4,7 @@ import pytest -from sm_bluesky.common.server import AbstractInstrumentServer +from sm_bluesky.common.servers import AbstractInstrumentServer class MockInstrument(AbstractInstrumentServer): diff --git a/tests/common/server/test_pulse_generator_shanghai_test.py b/tests/common/server/test_pulse_generator_shanghai_test.py index 6f7c83f2..f9b9aa83 100644 --- a/tests/common/server/test_pulse_generator_shanghai_test.py +++ b/tests/common/server/test_pulse_generator_shanghai_test.py @@ -6,7 +6,7 @@ import pytest from serial import Serial -from sm_bluesky.common.server import GeneratorServerShanghaiTech +from sm_bluesky.common.servers import GeneratorServerShanghaiTech @pytest.fixture diff --git a/tests/common/test_cli.py b/tests/common/test_cli.py new file mode 100644 index 00000000..70d91a89 --- /dev/null +++ b/tests/common/test_cli.py @@ -0,0 +1,109 @@ +import subprocess +import sys +from unittest.mock import patch + +import pytest + +from sm_bluesky import __version__ +from sm_bluesky.common.cli import main + + +@pytest.fixture +def mock_sh_generator(): + with patch("sm_bluesky.common.servers.GeneratorServerShanghaiTech") as mock_cls: + yield mock_cls + + +@patch("sm_bluesky.common.servers.GeneratorServerShanghaiTech") +def test_cli_shanghai_tech_default_arguments(mock_sh_generator): + """Verify 'sm-bluesky start sh_pulse_generator' passes correct defaults.""" + mock_instance = mock_sh_generator.return_value + + main(["start", "sh_pulse_generator"]) + + mock_sh_generator.assert_called_once_with( + host="0.0.0.0", + port=7891, + ipv6=False, + usb_port="COM4", + baud_rate=9600, + timeout=1.0, + max_pulse_delay=1024, + ) + mock_instance.start.assert_called_once() + + +def test_cli_shanghai_tech_custom_flags(mock_sh_generator): + main( + [ + "start", + "sh_pulse_generator", + "--host", + "127.0.0.1", + "--port", + "8080", + "--ipv6", + "--usb-port", + "COM9", + "--baud-rate", + "115200", + "--timeout", + "2.5", + "--max-pulse-delay", + "2048", + ] + ) + + mock_sh_generator.assert_called_once_with( + host="127.0.0.1", + port=8080, + ipv6=True, + usb_port="COM9", + baud_rate=115200, + timeout=2.5, + max_pulse_delay=2048, + ) + + +def test_cli_handles_keyboard_interrupt(mock_sh_generator): + mock_instance = mock_sh_generator.return_value + mock_instance.start.side_effect = KeyboardInterrupt() + main(["start", "sh_pulse_generator"]) + mock_instance.stop.assert_called_once() + + +@pytest.mark.parametrize( + "command, expected_output, look_in_stderr", + [ + ([], "sm-bluesky CLI", False), + ( + ["start", "junk"], + "invalid choice: 'junk'", + True, + ), + ( + ["junk"], + "invalid choice: 'junk'", + True, + ), + ], +) +def test_cli_shows_help_on_invalid_command( + command, expected_output, look_in_stderr, capsys +): + if look_in_stderr: + with pytest.raises(SystemExit) as exc_info: + main(command) + assert exc_info.value.code == 2 + else: + main(command) + + captured = capsys.readouterr() + output = captured.err if look_in_stderr else captured.out + + assert expected_output in output + + +def test_cli_version(): + cmd = [sys.executable, "-m", "sm_bluesky", "--version"] + assert subprocess.check_output(cmd).decode().strip() == __version__ diff --git a/tests/test_cli.py b/tests/test_cli.py deleted file mode 100644 index a82937d0..00000000 --- a/tests/test_cli.py +++ /dev/null @@ -1,9 +0,0 @@ -import subprocess -import sys - -from sm_bluesky import __version__ - - -def test_cli_version(): - cmd = [sys.executable, "-m", "sm_bluesky", "--version"] - assert subprocess.check_output(cmd).decode().strip() == __version__ From e3fa089e746612390ae78afe6902dc375808fc10 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 25 Jun 2026 11:29:55 +0000 Subject: [PATCH 2/9] add typing --- src/sm_bluesky/common/cli.py | 2 ++ tests/common/test_cli.py | 29 +++++++++++++++++++---------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/sm_bluesky/common/cli.py b/src/sm_bluesky/common/cli.py index 4f66dd94..93744865 100644 --- a/src/sm_bluesky/common/cli.py +++ b/src/sm_bluesky/common/cli.py @@ -71,6 +71,8 @@ def main(args: Sequence[str] | None = None) -> None: except KeyboardInterrupt: print("\nStopping server ...") server.stop() + else: + start_parser.print_help() else: parser.print_help() diff --git a/tests/common/test_cli.py b/tests/common/test_cli.py index 70d91a89..9ed7f40d 100644 --- a/tests/common/test_cli.py +++ b/tests/common/test_cli.py @@ -1,6 +1,7 @@ import subprocess import sys -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import MagicMock, patch import pytest @@ -9,13 +10,12 @@ @pytest.fixture -def mock_sh_generator(): +def mock_sh_generator() -> Generator[MagicMock, None, None]: with patch("sm_bluesky.common.servers.GeneratorServerShanghaiTech") as mock_cls: yield mock_cls -@patch("sm_bluesky.common.servers.GeneratorServerShanghaiTech") -def test_cli_shanghai_tech_default_arguments(mock_sh_generator): +def test_cli_shanghai_tech_default_arguments(mock_sh_generator: MagicMock) -> None: """Verify 'sm-bluesky start sh_pulse_generator' passes correct defaults.""" mock_instance = mock_sh_generator.return_value @@ -33,7 +33,7 @@ def test_cli_shanghai_tech_default_arguments(mock_sh_generator): mock_instance.start.assert_called_once() -def test_cli_shanghai_tech_custom_flags(mock_sh_generator): +def test_cli_shanghai_tech_custom_flags(mock_sh_generator: MagicMock) -> None: main( [ "start", @@ -65,7 +65,7 @@ def test_cli_shanghai_tech_custom_flags(mock_sh_generator): ) -def test_cli_handles_keyboard_interrupt(mock_sh_generator): +def test_cli_handles_keyboard_interrupt(mock_sh_generator: MagicMock) -> None: mock_instance = mock_sh_generator.return_value mock_instance.start.side_effect = KeyboardInterrupt() main(["start", "sh_pulse_generator"]) @@ -77,20 +77,29 @@ def test_cli_handles_keyboard_interrupt(mock_sh_generator): [ ([], "sm-bluesky CLI", False), ( - ["start", "junk"], + ["junk"], "invalid choice: 'junk'", True, ), ( - ["junk"], + ["start", "junk"], "invalid choice: 'junk'", True, ), + ( + ["start"], + "usage: ", + False, + ), ], ) def test_cli_shows_help_on_invalid_command( - command, expected_output, look_in_stderr, capsys -): + command: list[str], + expected_output: str, + look_in_stderr: bool, + capsys: pytest.CaptureFixture[str], + mock_sh_generator: MagicMock, +) -> None: if look_in_stderr: with pytest.raises(SystemExit) as exc_info: main(command) From 6e601fa0f0e14eb46b5725a041175b8f87efb634 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 25 Jun 2026 11:37:40 +0000 Subject: [PATCH 3/9] clean up --- tests/common/{server => servers}/__init__.py | 0 .../test_abstract_instrument_server.py | 2 +- .../test_pulse_generator_shanghai_test.py | 8 ++++---- 3 files changed, 5 insertions(+), 5 deletions(-) rename tests/common/{server => servers}/__init__.py (100%) rename tests/common/{server => servers}/test_abstract_instrument_server.py (99%) rename tests/common/{server => servers}/test_pulse_generator_shanghai_test.py (96%) diff --git a/tests/common/server/__init__.py b/tests/common/servers/__init__.py similarity index 100% rename from tests/common/server/__init__.py rename to tests/common/servers/__init__.py diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/servers/test_abstract_instrument_server.py similarity index 99% rename from tests/common/server/test_abstract_instrument_server.py rename to tests/common/servers/test_abstract_instrument_server.py index f991e21a..c4a983b3 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/servers/test_abstract_instrument_server.py @@ -25,7 +25,7 @@ def mock_socket_instance(): def mock_instrument(mock_socket_instance: MagicMock): with patch( - "sm_bluesky.common.server.abstract_instrument_server.socket.socket" + "sm_bluesky.common.servers.abstract_instrument_server.socket.socket" ) as mock_socket_class: mock_socket_class.return_value = mock_socket_instance mock_instrument = MockInstrument(host="localhost", port=8888) diff --git a/tests/common/server/test_pulse_generator_shanghai_test.py b/tests/common/servers/test_pulse_generator_shanghai_test.py similarity index 96% rename from tests/common/server/test_pulse_generator_shanghai_test.py rename to tests/common/servers/test_pulse_generator_shanghai_test.py index f9b9aa83..21e5c73c 100644 --- a/tests/common/server/test_pulse_generator_shanghai_test.py +++ b/tests/common/servers/test_pulse_generator_shanghai_test.py @@ -13,7 +13,7 @@ def mock_serial(): """Patches Serial and returns the class mock.""" with patch( - "sm_bluesky.common.server.pulse_generator_shanghai_tech.Serial", spec=True + "sm_bluesky.common.servers.pulse_generator_shanghai_tech.Serial", spec=True ) as mock_serial: yield mock_serial @@ -36,7 +36,7 @@ def test_connect_hardware_failure( mock_server: GeneratorServerShanghaiTech, caplog: pytest.LogCaptureFixture ): with patch( - "sm_bluesky.common.server.pulse_generator_shanghai_tech.Serial" + "sm_bluesky.common.servers.pulse_generator_shanghai_tech.Serial" ) as mock_serial_class: error_message = "Connection failed" mock_serial_class.side_effect = Exception(error_message) @@ -211,7 +211,7 @@ def test_send_hardware_command_fail_no_device(mock_server: GeneratorServerShangh @pytest.fixture def running_server(): with patch( - "sm_bluesky.common.server.pulse_generator_shanghai_tech.Serial" + "sm_bluesky.common.servers.pulse_generator_shanghai_tech.Serial" ) as mock_serial_class: mock_device = MagicMock() mock_serial_class.return_value = mock_device @@ -227,7 +227,7 @@ def running_server(): server.stop() -def test_full_tcp_command_flow(running_server): +def test_full_tcp_command_flow(running_server: MagicMock): """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)) From b28e0027affcefe09a94ceb64762a79b07f93e1d Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 25 Jun 2026 15:41:16 +0000 Subject: [PATCH 4/9] add a simple client --- src/sm_bluesky/common/client/__init__.py | 3 ++ src/sm_bluesky/common/client/client.py | 48 ++++++++++++++++++ tests/common/client/test_client.py | 63 ++++++++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 src/sm_bluesky/common/client/__init__.py create mode 100644 src/sm_bluesky/common/client/client.py create mode 100644 tests/common/client/test_client.py diff --git a/src/sm_bluesky/common/client/__init__.py b/src/sm_bluesky/common/client/__init__.py new file mode 100644 index 00000000..8cd0443b --- /dev/null +++ b/src/sm_bluesky/common/client/__init__.py @@ -0,0 +1,3 @@ +from .client import InstrumentClient + +__all__ = ["InstrumentClient"] diff --git a/src/sm_bluesky/common/client/client.py b/src/sm_bluesky/common/client/client.py new file mode 100644 index 00000000..46b13a45 --- /dev/null +++ b/src/sm_bluesky/common/client/client.py @@ -0,0 +1,48 @@ +import socket + + +class InstrumentClient: + """A lightweight, TCP client for interacting with AbstractInstrumentServers.""" + + def __init__(self, host: str = "127.0.0.1", port: int = 7891, timeout: float = 2.0): + self.host = host + self.port = port + self.timeout = timeout + + def send_payload(self, command: str, *args: str | int | float) -> str: + """Sends a command to the server, returns the stripped text message data.""" + + payload_parts = [command] + [str(arg) for arg in args] + payload = "\t".join(payload_parts) + + if not payload.endswith("\n"): + payload += "\n" + + try: + with socket.create_connection( + (self.host, self.port), timeout=self.timeout + ) as s: + s.sendall(payload.encode("utf-8")) + + with s.makefile("r", encoding="utf-8", errors="strict") as reader: + response_line = reader.readline() + + if not response_line: + raise ConnectionError( + "Server closed connection without returning data." + ) + + response = response_line.strip() + + if "\t" in response: + status, data = response.split("\t", 1) + else: + status, data = response, "" + + if status == "1": + return data + else: + raise RuntimeError(f"Server Error: {data}") + + except Exception as e: + raise ConnectionError(f"Communication layer failure: {e}") from e diff --git a/tests/common/client/test_client.py b/tests/common/client/test_client.py new file mode 100644 index 00000000..a73c5c8c --- /dev/null +++ b/tests/common/client/test_client.py @@ -0,0 +1,63 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from sm_bluesky.common.client import InstrumentClient + + +@pytest.fixture +def client(): + return InstrumentClient(host="127.0.0.1", port=8888, timeout=8.0) + + +@pytest.fixture +def mock_network(): + mock_socket = MagicMock() + mock_reader = MagicMock() + + with ( + patch("socket.create_connection") as mock_connect, + patch.object(mock_socket, "makefile") as mock_makefile, + ): + mock_connect.return_value.__enter__.return_value = mock_socket + mock_makefile.return_value.__enter__.return_value = mock_reader + yield { + "connect": mock_connect, + "socket": mock_socket, + "reader": mock_reader, + } + + +def test_send_payload_success(client, mock_network): + mock_network["reader"].readline.return_value = "1\t512\n" + result = client.send_payload("SET_DELAY", 512) + assert result == "512" + mock_network["connect"].assert_called_once_with(("127.0.0.1", 8888), timeout=8.0) + mock_network["socket"].sendall.assert_called_once_with(b"SET_DELAY\t512\n") + + +def test_send_payload_server_error(client, mock_network): + mock_network["reader"].readline.return_value = "0\tInvalid parameter boundary\n" + + with pytest.raises( + ConnectionError, + match="Communication layer failure: Server Error: Invalid parameter boundary", + ): + client.send_payload("SET_DELAY", -999) + + +def test_send_payload_network_failure(client, mock_network): + mock_network["connect"].create_connection.return_value = TimeoutError( + "Connection timed out" + ) + with pytest.raises(ConnectionError, match="Communication layer failure"): + client.send_payload("GET_STATUS") + + +def test_send_payload_empty_response(client, mock_network): + mock_network["reader"].readline.return_value = "" + + with pytest.raises( + ConnectionError, match="Server closed connection without returning data." + ): + client.send_payload("PING") From f3678f13b49effb32402877e1ea0cb4462bb6ce2 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 25 Jun 2026 16:01:22 +0000 Subject: [PATCH 5/9] add cli to use the server --- src/sm_bluesky/common/cli.py | 42 +++++++++++++++++++++++++- tests/common/client/__init__.py | 0 tests/common/test_cli.py | 52 +++++++++++++++++++++++++++++++-- 3 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 tests/common/client/__init__.py diff --git a/src/sm_bluesky/common/cli.py b/src/sm_bluesky/common/cli.py index 93744865..af3bbe8c 100644 --- a/src/sm_bluesky/common/cli.py +++ b/src/sm_bluesky/common/cli.py @@ -17,8 +17,9 @@ def main(args: Sequence[str] | None = None) -> None: ) subparsers = parser.add_subparsers(dest="command", help="Commands") - start_parser = subparsers.add_parser("start", help="Start an instrument server") + # ----------------- start command ---------------------------------------------- + start_parser = subparsers.add_parser("start", help="Start an instrument server") server_subparsers = start_parser.add_subparsers( dest="server_type", help="Server types" ) @@ -48,6 +49,22 @@ def main(args: Sequence[str] | None = None) -> None: help="Max pulse delay configuration", ) + # ----------------- send command ---------------------------------------------- + """Quick command line to interact with server """ + send_parser = subparsers.add_parser( + "send", help="Send a single text command to a running server" + ) + send_parser.add_argument("payload", type=str, help="The command string to transmit") + send_parser.add_argument( + "--host", type=str, default="127.0.0.1", help="Target server IP" + ) + send_parser.add_argument( + "--port", type=int, default=7891, help="Target server TCP port" + ) + send_parser.add_argument( + "--timeout", type=float, default=2.0, help="Socket timeout" + ) + parsed_args = parser.parse_args(args) if parsed_args.command == "start": @@ -73,6 +90,29 @@ def main(args: Sequence[str] | None = None) -> None: server.stop() else: start_parser.print_help() + elif parsed_args.command == "send": + from sm_bluesky.common.client import InstrumentClient + + print( + f"Sending command:{parsed_args.payload} to {parsed_args.host}:" + + f"{parsed_args.port}" + ) + try: + parts = parsed_args.payload.split() + if not parts: + raise ValueError("Payload cannot be empty") + + command = parts[0] + arguments = parts[1:] + client = InstrumentClient( + host=parsed_args.host, + port=parsed_args.port, + timeout=parsed_args.timeout, + ) + result = client.send_payload(command, *arguments) + print(f"✅ SUCCESS: {result}" if result else "✅ SUCCESS") + except Exception as err: + print(f"\u274c FAILED: {err}") else: parser.print_help() diff --git a/tests/common/client/__init__.py b/tests/common/client/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/common/test_cli.py b/tests/common/test_cli.py index 9ed7f40d..e0ededd4 100644 --- a/tests/common/test_cli.py +++ b/tests/common/test_cli.py @@ -11,8 +11,14 @@ @pytest.fixture def mock_sh_generator() -> Generator[MagicMock, None, None]: - with patch("sm_bluesky.common.servers.GeneratorServerShanghaiTech") as mock_cls: - yield mock_cls + with patch("sm_bluesky.common.servers.GeneratorServerShanghaiTech") as mock_server: + yield mock_server + + +@pytest.fixture +def mock_instrument_client() -> Generator[MagicMock, None, None]: + with patch("sm_bluesky.common.client.InstrumentClient") as mock_client: + yield mock_client def test_cli_shanghai_tech_default_arguments(mock_sh_generator: MagicMock) -> None: @@ -98,7 +104,6 @@ def test_cli_shows_help_on_invalid_command( expected_output: str, look_in_stderr: bool, capsys: pytest.CaptureFixture[str], - mock_sh_generator: MagicMock, ) -> None: if look_in_stderr: with pytest.raises(SystemExit) as exc_info: @@ -116,3 +121,44 @@ def test_cli_shows_help_on_invalid_command( def test_cli_version(): cmd = [sys.executable, "-m", "sm_bluesky", "--version"] assert subprocess.check_output(cmd).decode().strip() == __version__ + + +def test_cli_send_command_success( + mock_instrument_client: MagicMock, capsys: pytest.CaptureFixture[str] +) -> None: + mock_instance = mock_instrument_client.return_value + mock_instance.send_payload.return_value = "512" + + main(["send", "SET_DELAY 512", "--host", "0.0.0.0", "--port", "8888"]) + + mock_instrument_client.assert_called_once_with( + host="0.0.0.0", port=8888, timeout=2.0 + ) + mock_instance.send_payload.assert_called_once_with("SET_DELAY", "512") + + captured = capsys.readouterr() + assert "Sending command:SET_DELAY 512" in captured.out + assert "SUCCESS: 512" in captured.out + + +def test_cli_send_command_failure( + mock_instrument_client: MagicMock, capsys: pytest.CaptureFixture[str] +) -> None: + mock_instance = mock_instrument_client.return_value + mock_instance.send_payload.side_effect = ConnectionError("Help help") + + main(["send", "do not matter"]) + + captured = capsys.readouterr() + assert "FAILED: Help help" in captured.out + + +def test_cli_send_empty_payload( + mock_instrument_client: MagicMock, capsys: pytest.CaptureFixture[str] +) -> None: + + main(["send", " "]) + + captured = capsys.readouterr() + assert "FAILED: Payload cannot be empty" in captured.out + mock_instrument_client.assert_not_called() From 73ba244ee84cfd10f2fadb095ec9fbb1f9c73a24 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 25 Jun 2026 16:20:13 +0000 Subject: [PATCH 6/9] add some examples for help --- src/sm_bluesky/common/cli.py | 20 ++++++++++++++++---- tests/common/test_cli.py | 2 +- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/sm_bluesky/common/cli.py b/src/sm_bluesky/common/cli.py index af3bbe8c..77a68e5d 100644 --- a/src/sm_bluesky/common/cli.py +++ b/src/sm_bluesky/common/cli.py @@ -19,7 +19,12 @@ def main(args: Sequence[str] | None = None) -> None: subparsers = parser.add_subparsers(dest="command", help="Commands") # ----------------- start command ---------------------------------------------- - start_parser = subparsers.add_parser("start", help="Start an instrument server") + start_parser = subparsers.add_parser( + "start", + help="Start an instrument server", + epilog="Example usage:\n sm-bluesky start sh_pulse_generator --usb-port COM4 " + "--host 8.8.8.8 --port 7891 ", + ) server_subparsers = start_parser.add_subparsers( dest="server_type", help="Server types" ) @@ -29,7 +34,7 @@ def main(args: Sequence[str] | None = None) -> None: "sh_pulse_generator", help="Launch ShanghaiTech pulse Generator Server" ) sh_parser.add_argument( - "--host", type=str, default="0.0.0.0", help="Binding host IP" + "--host", type=str, default="127.0.0.1", help="Binding host IP" ) sh_parser.add_argument("--port", type=int, default=7891, help="TCP Port") sh_parser.add_argument("--ipv6", action="store_true", help="Enable IPv6 support") @@ -52,9 +57,16 @@ def main(args: Sequence[str] | None = None) -> None: # ----------------- send command ---------------------------------------------- """Quick command line to interact with server """ send_parser = subparsers.add_parser( - "send", help="Send a single text command to a running server" + "send", + help="Send a single text command to a running server", + epilog='Example usage:\n sm-bluesky send "SET_DELAY 512" --host 8.8.8.8' + " --port 8888", + ) + send_parser.add_argument( + "payload", + type=str, + help='The command and arguments to send (e.g."SET_DELAY 512" or "GET_STATUS")', ) - send_parser.add_argument("payload", type=str, help="The command string to transmit") send_parser.add_argument( "--host", type=str, default="127.0.0.1", help="Target server IP" ) diff --git a/tests/common/test_cli.py b/tests/common/test_cli.py index e0ededd4..0c5f1ac6 100644 --- a/tests/common/test_cli.py +++ b/tests/common/test_cli.py @@ -28,7 +28,7 @@ def test_cli_shanghai_tech_default_arguments(mock_sh_generator: MagicMock) -> No main(["start", "sh_pulse_generator"]) mock_sh_generator.assert_called_once_with( - host="0.0.0.0", + host="127.0.0.1", port=7891, ipv6=False, usb_port="COM4", From 4b4ebc44b4b92dd8526bed17c9ac1ecc1831bf08 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 26 Jun 2026 09:44:24 +0000 Subject: [PATCH 7/9] add docs --- docs/how-to/5_cli_documentation.md | 60 +++++++++++++++++++ src/sm_bluesky/common/cli.py | 6 +- .../test_abstract_instrument_server.py | 6 +- tests/common/test_cli.py | 2 +- 4 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 docs/how-to/5_cli_documentation.md diff --git a/docs/how-to/5_cli_documentation.md b/docs/how-to/5_cli_documentation.md new file mode 100644 index 00000000..12a6b451 --- /dev/null +++ b/docs/how-to/5_cli_documentation.md @@ -0,0 +1,60 @@ +# 🔫sm-bluesky CLI Reference🔫 + +The `sm-bluesky` command-line interface provides tools to launch instrument servers and interact with running instances via quick payloads (commands). + +--- + +## 🚀 Commands Overview + +The interface is split into two primary operational modes: +* **`-v`, `--version`**: Displays the currently installed package version of `sm-bluesky`. +* **`start`**: Configures and spins up background hardware device drivers. +* **`send`**: A lightweight diagnostic utility to send arbitrary string payloads to an active server. + +--- + +## 🛠️ Detailed Usage + +### 1. Starting an Instrument Server +Launches a dedicated hardware communication server. By default, it listens on all available interfaces (`0.0.0.0`) to ensure remote beamline workstations can reach the server. + +```bash +sm-bluesky start sh_pulse_generator [FLAGS] +``` +| Flag | Type | Default | Description| +| ----- | -----| ------ | -----------| +| --host | str | 0.0.0.0 | Binding host network address.| +| --port | int | 7891 | TCP port boundary for socket listening. | +| --ipv6 | bool | None | Flags the socket to use IPv6 dual-stack addressing. | + +### sh_pulse_generator Specific FlAG + +| Flag | Type | Default | Description| +| ----- | -----| ------ | -----------| +| --usb-port | str | COM4 | Target serial/USB backend port identifier. | +| --baud-rate | int | 9600 | Serial connection baud rate. | +| --timeout | float | 1.0 | Read/write timeout duration in seconds. | +| --max-pulse-delay | int | 1024 | Boundary constraints for safe pulse adjustments. | +```bash +sm-bluesky start sh_pulse_generator --usb-port COM9 --port 8080 +``` + +### 2. Sending Payloads (Commands) + +Sends a single string payload directly to an active server. It defaults to the local loopback address (127.0.0.1) to ensure accidental commands don't interact with external instruments. +```bash +sm-bluesky send "PAYLOAD" [FLAGS] +``` + +| Flag | Type | Default | Description | +| -----| -----| ------ | -----------| +| --host | str | 127.0.0.1 | Target server network address.| +| --port | int | 7891 | Target server TCP communications port.| +| --timeout | float | 2.0 | Connection timeout duration in seconds.| + +```bash +# Verify status locally +sm-bluesky send "GET_STATUS" + +# Adjust settings on a remote beamline server +sm-bluesky send "SET_DELAY 512" --host 192.168.1.50 --port 7891``` diff --git a/src/sm_bluesky/common/cli.py b/src/sm_bluesky/common/cli.py index 77a68e5d..f889fe8c 100644 --- a/src/sm_bluesky/common/cli.py +++ b/src/sm_bluesky/common/cli.py @@ -23,7 +23,7 @@ def main(args: Sequence[str] | None = None) -> None: "start", help="Start an instrument server", epilog="Example usage:\n sm-bluesky start sh_pulse_generator --usb-port COM4 " - "--host 8.8.8.8 --port 7891 ", + "--host 192.168.1.88 --port 7891 ", ) server_subparsers = start_parser.add_subparsers( dest="server_type", help="Server types" @@ -34,7 +34,7 @@ def main(args: Sequence[str] | None = None) -> None: "sh_pulse_generator", help="Launch ShanghaiTech pulse Generator Server" ) sh_parser.add_argument( - "--host", type=str, default="127.0.0.1", help="Binding host IP" + "--host", type=str, default="0.0.0.0", help="Binding host IP" ) sh_parser.add_argument("--port", type=int, default=7891, help="TCP Port") sh_parser.add_argument("--ipv6", action="store_true", help="Enable IPv6 support") @@ -59,7 +59,7 @@ def main(args: Sequence[str] | None = None) -> None: send_parser = subparsers.add_parser( "send", help="Send a single text command to a running server", - epilog='Example usage:\n sm-bluesky send "SET_DELAY 512" --host 8.8.8.8' + epilog='Example usage:\n sm-bluesky send "SET_DELAY 512" --host 192.168.1.88' " --port 8888", ) send_parser.add_argument( diff --git a/tests/common/servers/test_abstract_instrument_server.py b/tests/common/servers/test_abstract_instrument_server.py index c4a983b3..e7982cb7 100644 --- a/tests/common/servers/test_abstract_instrument_server.py +++ b/tests/common/servers/test_abstract_instrument_server.py @@ -65,7 +65,7 @@ def test_start_handles_timeout( ): mock_socket_instance.accept.side_effect = [ socket.timeout, - (MagicMock(), ("8.8.8.8", 1234)), + (MagicMock(), ("192.168.1.88", 1234)), ] mock_instrument._serve_client = lambda: setattr( mock_instrument, "_is_running", False @@ -193,11 +193,11 @@ def test_serve_client_exception( def test_full_connection_cycle_cleanup(mock_instrument, caplog): mock_instance = MagicMock() - mock_instance.accept.return_value = (MagicMock(), ("8.8.8.8", 1234)) + mock_instance.accept.return_value = (MagicMock(), ("192.168.1.88", 1234)) mock_instrument._server_socket = mock_instance with patch.object(mock_instrument, "_serve_client", side_effect=None): - client_info = (MagicMock(), "8.8.8.8") + client_info = (MagicMock(), "192.168.1.88") with mock_instrument._manage_connection(client_info): pass client_info[0].close.assert_called_once() diff --git a/tests/common/test_cli.py b/tests/common/test_cli.py index 0c5f1ac6..e0ededd4 100644 --- a/tests/common/test_cli.py +++ b/tests/common/test_cli.py @@ -28,7 +28,7 @@ def test_cli_shanghai_tech_default_arguments(mock_sh_generator: MagicMock) -> No main(["start", "sh_pulse_generator"]) mock_sh_generator.assert_called_once_with( - host="127.0.0.1", + host="0.0.0.0", port=7891, ipv6=False, usb_port="COM4", From 6a106d96a3549cfa47901263a6ffe47095c84a38 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 26 Jun 2026 09:58:36 +0000 Subject: [PATCH 8/9] add docs --- docs/how-to/4_Use_abstract_instrument_server.md | 1 + docs/how-to/4c_pulse_genertor_shanghai_tech.md | 1 + docs/how-to/5_cli_documentation.md | 12 +++++++----- src/sm_bluesky/common/cli.py | 3 ++- tests/common/client/test_client.py | 2 +- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/how-to/4_Use_abstract_instrument_server.md b/docs/how-to/4_Use_abstract_instrument_server.md index 3fdd2dfb..907e143b 100644 --- a/docs/how-to/4_Use_abstract_instrument_server.md +++ b/docs/how-to/4_Use_abstract_instrument_server.md @@ -34,6 +34,7 @@ Commands must be newline-terminated. | `connect_hardware`| None | Re-establishes connection to hardware server. | | `disconnect_hardware`| None | Safely disconnects from hardware. | | `shutdown` | None | Stops the server and disconnects hardware. | +| `command_list` | None | Return a list of available commands. | ## Implementation Guide diff --git a/docs/how-to/4c_pulse_genertor_shanghai_tech.md b/docs/how-to/4c_pulse_genertor_shanghai_tech.md index 514d9368..1b821412 100644 --- a/docs/how-to/4c_pulse_genertor_shanghai_tech.md +++ b/docs/how-to/4c_pulse_genertor_shanghai_tech.md @@ -39,6 +39,7 @@ The server follows a **Request-Response** model. Every command sent by a client | `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` | +| `command_list` | None | Return a list of available commands. | | `shutdown` | None | Safely stops the server and releases hardware. | `shutdown\n` | --- diff --git a/docs/how-to/5_cli_documentation.md b/docs/how-to/5_cli_documentation.md index 12a6b451..91fbd852 100644 --- a/docs/how-to/5_cli_documentation.md +++ b/docs/how-to/5_cli_documentation.md @@ -1,4 +1,4 @@ -# 🔫sm-bluesky CLI Reference🔫 +# 📟sm-bluesky CLI Reference The `sm-bluesky` command-line interface provides tools to launch instrument servers and interact with running instances via quick payloads (commands). @@ -6,11 +6,13 @@ The `sm-bluesky` command-line interface provides tools to launch instrument serv ## 🚀 Commands Overview -The interface is split into two primary operational modes: -* **`-v`, `--version`**: Displays the currently installed package version of `sm-bluesky`. -* **`start`**: Configures and spins up background hardware device drivers. +The interface is split into two primary modes: +* **`start`**: Configures and spins up background hardware device server. * **`send`**: A lightweight diagnostic utility to send arbitrary string payloads to an active server. +For extra information use: +* **`-h`, `--help`**: Displays general or command-specific help menus. +* **`-v`, `--version`**: Displays the currently installed package version of `sm-bluesky`. --- ## 🛠️ Detailed Usage @@ -54,7 +56,7 @@ sm-bluesky send "PAYLOAD" [FLAGS] ```bash # Verify status locally -sm-bluesky send "GET_STATUS" +sm-bluesky send "command_list" # Adjust settings on a remote beamline server sm-bluesky send "SET_DELAY 512" --host 192.168.1.50 --port 7891``` diff --git a/src/sm_bluesky/common/cli.py b/src/sm_bluesky/common/cli.py index f889fe8c..93eb12d4 100644 --- a/src/sm_bluesky/common/cli.py +++ b/src/sm_bluesky/common/cli.py @@ -65,7 +65,8 @@ def main(args: Sequence[str] | None = None) -> None: send_parser.add_argument( "payload", type=str, - help='The command and arguments to send (e.g."SET_DELAY 512" or "GET_STATUS")', + help='The command and arguments to send (e.g."SET_DELAY 512" or ' + '"command_list")', ) send_parser.add_argument( "--host", type=str, default="127.0.0.1", help="Target server IP" diff --git a/tests/common/client/test_client.py b/tests/common/client/test_client.py index a73c5c8c..52392d01 100644 --- a/tests/common/client/test_client.py +++ b/tests/common/client/test_client.py @@ -51,7 +51,7 @@ def test_send_payload_network_failure(client, mock_network): "Connection timed out" ) with pytest.raises(ConnectionError, match="Communication layer failure"): - client.send_payload("GET_STATUS") + client.send_payload("command_list") def test_send_payload_empty_response(client, mock_network): From f596c1a6998033ff05698d3646aa8d73f81f75e5 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 26 Jun 2026 10:10:29 +0000 Subject: [PATCH 9/9] docstring --- tests/common/test_cli.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/common/test_cli.py b/tests/common/test_cli.py index e0ededd4..ca8c0ae7 100644 --- a/tests/common/test_cli.py +++ b/tests/common/test_cli.py @@ -21,8 +21,9 @@ def mock_instrument_client() -> Generator[MagicMock, None, None]: yield mock_client -def test_cli_shanghai_tech_default_arguments(mock_sh_generator: MagicMock) -> None: - """Verify 'sm-bluesky start sh_pulse_generator' passes correct defaults.""" +def test_cli_shanghai_tech_start_default_arguments( + mock_sh_generator: MagicMock, +) -> None: mock_instance = mock_sh_generator.return_value main(["start", "sh_pulse_generator"]) @@ -39,7 +40,7 @@ def test_cli_shanghai_tech_default_arguments(mock_sh_generator: MagicMock) -> No mock_instance.start.assert_called_once() -def test_cli_shanghai_tech_custom_flags(mock_sh_generator: MagicMock) -> None: +def test_cli_shanghai_tech_start_custom_flags(mock_sh_generator: MagicMock) -> None: main( [ "start",