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 new file mode 100644 index 00000000..91fbd852 --- /dev/null +++ b/docs/how-to/5_cli_documentation.md @@ -0,0 +1,62 @@ +# 📟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 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 + +### 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 "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/__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..93eb12d4 --- /dev/null +++ b/src/sm_bluesky/common/cli.py @@ -0,0 +1,131 @@ +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 command ---------------------------------------------- + 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 192.168.1.88 --port 7891 ", + ) + 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", + ) + + # ----------------- 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", + epilog='Example usage:\n sm-bluesky send "SET_DELAY 512" --host 192.168.1.88' + " --port 8888", + ) + send_parser.add_argument( + "payload", + type=str, + 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" + ) + 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": + 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: + 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/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/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/__init__.py b/tests/common/client/__init__.py similarity index 100% rename from tests/common/server/__init__.py rename to tests/common/client/__init__.py diff --git a/tests/common/client/test_client.py b/tests/common/client/test_client.py new file mode 100644 index 00000000..52392d01 --- /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("command_list") + + +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") diff --git a/tests/common/servers/__init__.py b/tests/common/servers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/common/server/test_abstract_instrument_server.py b/tests/common/servers/test_abstract_instrument_server.py similarity index 96% rename from tests/common/server/test_abstract_instrument_server.py rename to tests/common/servers/test_abstract_instrument_server.py index d13cec5d..e7982cb7 100644 --- a/tests/common/server/test_abstract_instrument_server.py +++ b/tests/common/servers/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): @@ -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) @@ -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/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 6f7c83f2..21e5c73c 100644 --- a/tests/common/server/test_pulse_generator_shanghai_test.py +++ b/tests/common/servers/test_pulse_generator_shanghai_test.py @@ -6,14 +6,14 @@ import pytest from serial import Serial -from sm_bluesky.common.server import GeneratorServerShanghaiTech +from sm_bluesky.common.servers import GeneratorServerShanghaiTech @pytest.fixture 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)) diff --git a/tests/common/test_cli.py b/tests/common/test_cli.py new file mode 100644 index 00000000..ca8c0ae7 --- /dev/null +++ b/tests/common/test_cli.py @@ -0,0 +1,165 @@ +import subprocess +import sys +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from sm_bluesky import __version__ +from sm_bluesky.common.cli import main + + +@pytest.fixture +def mock_sh_generator() -> Generator[MagicMock, None, None]: + 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_start_default_arguments( + mock_sh_generator: MagicMock, +) -> None: + 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_start_custom_flags(mock_sh_generator: MagicMock) -> None: + 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: MagicMock) -> None: + 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), + ( + ["junk"], + "invalid choice: 'junk'", + True, + ), + ( + ["start", "junk"], + "invalid choice: 'junk'", + True, + ), + ( + ["start"], + "usage: ", + False, + ), + ], +) +def test_cli_shows_help_on_invalid_command( + command: list[str], + expected_output: str, + look_in_stderr: bool, + capsys: pytest.CaptureFixture[str], +) -> None: + 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__ + + +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() 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__