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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/how-to/4_Use_abstract_instrument_server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/how-to/4c_pulse_genertor_shanghai_tech.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

---
Expand Down
62 changes: 62 additions & 0 deletions docs/how-to/5_cli_documentation.md
Original file line number Diff line number Diff line change
@@ -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```
20 changes: 1 addition & 19 deletions src/sm_bluesky/__main__.py
Original file line number Diff line number Diff line change
@@ -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()
131 changes: 131 additions & 0 deletions src/sm_bluesky/common/cli.py
Original file line number Diff line number Diff line change
@@ -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()
3 changes: 3 additions & 0 deletions src/sm_bluesky/common/client/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .client import InstrumentClient

__all__ = ["InstrumentClient"]
48 changes: 48 additions & 0 deletions src/sm_bluesky/common/client/client.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
File renamed without changes.
63 changes: 63 additions & 0 deletions tests/common/client/test_client.py
Original file line number Diff line number Diff line change
@@ -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")
Empty file.
Loading
Loading