Skip to content
Draft
6 changes: 3 additions & 3 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 All @@ -47,8 +48,6 @@ 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
Expand All @@ -58,7 +57,8 @@ class MyMotorServer(AbstractInstrumentServer):
def disconnect_hardware(self) -> None:
# Logic to safely shut down hardware
print("Parking Motor...")


@register_command(b"move_abs")
def _move_absolute(self, position: bytes):
# Hardware logic: convert bytes arg to float
pos_mm = float(position)
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.shutdown()
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
@@ -1,4 +1,8 @@
from .abstract_instrument_server import AbstractInstrumentServer
from .abstract_instrument_server import AbstractInstrumentServer, register_command
from .pulse_generator_shanghai_tech import GeneratorServerShanghaiTech

__all__ = ["AbstractInstrumentServer", "GeneratorServerShanghaiTech"]
__all__ = [
"AbstractInstrumentServer",
"GeneratorServerShanghaiTech",
"register_command",
]
Loading
Loading