Protocol-level simulation of the flOMNI hardware injected via socket_cls: real device/controller classes run unchanged against state machines implementing fgalil.dmc, galil_micos_upr.dmc, the Smaract MCS protocol and the Orchestra CommunicationServer. Cameras reuse the real device classes with a synthetic frame source. Includes simulated_flomni device config (boots referenced at 'in' positions) and an offline harness covering moves, referencing, rt feedback/tracker, flyer scan readout and the gripper transfer routine.
111 lines
4.0 KiB
Python
111 lines
4.0 KiB
Python
"""
|
|
Core infrastructure for simulated controller sockets.
|
|
|
|
The socket-based controllers in ophyd_devices (`Controller.on`) create their transport via
|
|
``self._socket_cls(host=..., port=...)``. Any class exposing the `SocketIO` interface
|
|
(`put`, `receive`, `open`, `close`, `host`, `port`, `is_open`) can be injected instead of a
|
|
real TCP socket. The simulated sockets in this package implement the wire protocols of the
|
|
flOMNI hardware (Galil DMC, Smaract MCS, flOMNI Orchestra communication server) as small
|
|
state machines, so that the *real* device and controller classes can be used unchanged.
|
|
|
|
Commands are dispatched synchronously within `put()`; replies are appended to an internal
|
|
queue that `receive()` drains. Commands that do not produce a reply on the real hardware
|
|
must not enqueue one here, otherwise the command/reply stream desynchronizes.
|
|
|
|
Simulation state is shared per (host, port) via a registry, mirroring the singleton
|
|
behavior of `ophyd_devices.utils.controller.Controller`. This allows the simulated device
|
|
classes to seed axis parameters (initial position, velocity, resolution) at construction
|
|
time, before the controller opens its socket.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
|
|
from bec_lib.logger import bec_logger
|
|
|
|
logger = bec_logger.logger
|
|
|
|
|
|
class SimStateRegistry:
|
|
"""Registry of simulation states, shared per (host, port) like the controllers."""
|
|
|
|
_states = {}
|
|
_lock = threading.RLock()
|
|
|
|
@classmethod
|
|
def get(cls, state_cls, host, port):
|
|
"""Return the simulation state for (host, port), creating it if needed."""
|
|
key = (state_cls, str(host), int(port))
|
|
with cls._lock:
|
|
if key not in cls._states:
|
|
cls._states[key] = state_cls(host=host, port=port)
|
|
return cls._states[key]
|
|
|
|
@classmethod
|
|
def reset(cls):
|
|
"""Clear all simulation states (mainly for tests)."""
|
|
with cls._lock:
|
|
cls._states.clear()
|
|
|
|
|
|
class SimSocketBase:
|
|
"""
|
|
Drop-in replacement for `ophyd_devices.utils.socket.SocketIO`.
|
|
|
|
Children must set `state_cls` and implement `handle_command(line) -> str | None`.
|
|
A returned string is encoded and appended to the reply queue; None means no reply.
|
|
"""
|
|
|
|
state_cls = None
|
|
|
|
def __init__(self, host, port, socket_timeout: int = 2):
|
|
self.host = host
|
|
self.port = port
|
|
self.socket_timeout = socket_timeout
|
|
self.is_open = False
|
|
self._recv_buffer = []
|
|
self._cmd_buffer = b""
|
|
self._lock = threading.RLock()
|
|
self.state = SimStateRegistry.get(self.state_cls, host, port)
|
|
|
|
# --- SocketIO interface -------------------------------------------------
|
|
def open(self, timeout: int = 10):
|
|
logger.info(f"[sim] Connecting to simulated controller {self.host}:{self.port}.")
|
|
self.is_open = True
|
|
|
|
def connect(self, timeout: int = 10):
|
|
self.is_open = True
|
|
|
|
def close(self):
|
|
self.is_open = False
|
|
|
|
def put(self, msg: bytes):
|
|
with self._lock:
|
|
self._cmd_buffer += msg
|
|
# both \r (Galil) and \n (RT, Smaract) terminate commands
|
|
normalized = self._cmd_buffer.replace(b"\r", b"\n")
|
|
*lines, rest = normalized.split(b"\n")
|
|
self._cmd_buffer = rest
|
|
for line in lines:
|
|
line = line.decode(errors="replace").strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
reply = self.handle_command(line)
|
|
except Exception: # pylint: disable=broad-except
|
|
logger.exception(f"[sim] {self.host}:{self.port} failed to handle '{line}'")
|
|
reply = None
|
|
if reply is not None:
|
|
self._recv_buffer.append(reply.encode())
|
|
|
|
def receive(self, buffer_length=1024):
|
|
with self._lock:
|
|
if self._recv_buffer:
|
|
return self._recv_buffer.pop(0)
|
|
return b""
|
|
|
|
# --- to be implemented by children ---------------------------------------
|
|
def handle_command(self, line: str):
|
|
raise NotImplementedError
|