Source code for FIREQ_SERVER.fireq_server

"""FIREQ TCP Server - single-client TCP server for hardware experiments.

Handles client connections at startup, owns the client socket and handles the connection lifetime
Handles the execution of commands received from the client

Architecture: 3 threads communicate via queue_in/queue_out.
- Main thread: executes commands, interfaces hardware (this module)
- Receiver thread: accepts connections, parses messages
- Sender thread: writes responses to client

Protocol:
- Requests: 4-byte big-endian length prefix + UTF-8 JSON payload.
- Responses: JSON messages (4-byte length) and streamed binary data (acquistion frames + timing).
  For run_experiment/run_sweep: StreamHeader (JSON) -> BinaryChunk (frames) -> StreamTiming (JSON).
"""

import logging
import socket
import time
from pathlib import Path
from queue import Empty
from threading import Event

from FIREQ_SYSTEM import FIREQSystemNode

from .execution import SweepExperiment
from .network import FIREQNetworkPacket, NetworkDMAPayload, ReceiveWorker, SendWorker, get_command, get_sweep_variables

MAX_PAYLOAD_BYTES = 10 * 1024 * 1024  # 10 MB max payload
QUEUE_MAX_MEMORY_BYTES = 1024 * 1024 * 1024  # 1 GB queue memory limit
HANDSHAKE_TIMEOUT_SECONDS = 3.0
SWEEP_WAIT_TIMEOUT_SECONDS = 10.0

# Exception handling configuration: (error_type, log_level, log_suffix)
# log_level: "warning", "error", or "exception"
# _EXCEPTION_CONFIG: dict[type, tuple[str, str, str]] = {
#    DMATimeoutError: ("timeout", "warning", "timed out"),
#    ConfigurationError: ("config", "warning", "config error"),
#    WaveCompilationError: ("data", "warning", "data error"),
#    EnvelopeUploadError: ("data", "warning", "data error"),
#    HardwareResourceError: ("hardware", "error", "hardware error"),
# }


[docs] class FIREQServer: """TCP server for FIREQ experiments. Spawns three threads: - a client handler thread, which communicates with the client to receive commands and send info/configuration - an execution thread, which manages the hw and runs commands received from the client - a data streamer, which sends acquisition data back to the client """ # ========================================================================= # PUBLIC API # ========================================================================= def __init__( self, overlay_file: Path = None, host: str = "0.0.0.0", port: int = 5000, auth_token: str = "fireq", logger: logging.Logger | None = None, ) -> None: """Initialize the FIREQ TCP server. :param overlay_file: Path to the overlay to load. :type overlay_file: Path :param host: TCP bind address (use "0.0.0.0" for all interfaces). :type host: str :param port: TCP port to bind. :type port: int :param auth_token: Shared token for client authentication. :type auth_token: str :param log: Optional log instance. :type log: logging.log | None """ # load the system if overlay_file is None: raise ValueError("overlay_file must be provided") self._fireq_soc = FIREQSystemNode(overlay_file) # set the dma payload interface class to use a class that provides a # compatible network interface self._fireq_soc.set_dma_payload_interface_class(NetworkDMAPayload) self._host = host self._port = port self._auth_token = auth_token self.log = logger or logging.getLogger(__name__) # Server state self._stop_event = Event() self._thread_running = Event() self._abort_in_progress = Event() self._client_connected = Event() # Thread objects for the receive and transmit channel self._receive_worker = ReceiveWorker(self._client_connected) self._send_worker = SendWorker(self._client_connected) # Getting the two network packet queues self._queue_in = self._receive_worker.queue_in self._queue_out = self._send_worker.queue_out # sockets self._server_socket: socket.socket self._client_socket: socket.socket
[docs] def start(self) -> None: """Start the server and block on the main thread. Starts the network accept/receiver thread (daemon), then runs the main experiment loop in the current thread. Stops when :meth:`stop` is called. """ self._receive_worker.start() self._send_worker.start() self.log.info(f"FIREQ Server started on {self._host}:{self._port}") self._run()
[docs] def stop(self) -> None: """Stop the server gracefully. Sets running flag to False, triggers stop event to abort sweeps, unblocks the main loop, and closes server/client sockets. """ self.log.info("Stopping server...") self._stop_event.set() for sock in (self._server_socket, self._client_socket): if sock: try: sock.close() except (OSError, Exception): pass # stop threads self._receive_worker.stop() self._send_worker.stop()
# ========================================================================= # MAIN THREAD - Command Execution # ========================================================================= def _run(self) -> None: """Handle a client connection and execute the commands received from the queue_in.""" self._thread_running.set() while not self._stop_event.is_set(): # accept a client connection self._client_socket = self._accept_client() # assign client socket and inform threads self._receive_worker.client_socket = self._client_socket self._send_worker.client_socket = self._client_socket self._client_connected.set() # perform the handshake self.log.info("Performing handshake on client...") self._client_socket.settimeout(HANDSHAKE_TIMEOUT_SECONDS) if not self._perform_handshake(): self.log.warning("A client connection failed to complete the handshake, closing connection") self._close_client() # now start the actual main loop self._main_loop() # if the main loop exits, understand why and perform cleanup if necessary self.log.info("Execution thread exited, cleaning up") def _main_loop(self) -> None: """Gather a message from the input queue, parse it and execute commands. Will exit on stop_event or client disconnect. """ while not self._stop_event.is_set() and self._client_connected.is_set(): # receive message from the input queue try: msg = self._queue_in.get(timeout=1.0) except Empty: continue # process the message self._process_message(msg) self.log.debug("main loop exited") def _process_message(self, msg: dict) -> None: """Execute one command message and enqueue a response. :param msg: Parsed command dictionary from the client. :type msg: dict Note: ``abort`` is handled in the receiver loop for immediacy. """ cmd = get_command(msg) # session_id = msg.get("session_id", "") self.log.debug(f"Processing command: {cmd}") try: if cmd == "apply_configuration": # apply the system configuration dictionary to the fireq soc callbacks = self._config_from_message(msg) if len(callbacks) > 0: self.log.warning("Configuration from apply_configuration has sweepable parameters") elif cmd == "config_and_run": # run an experiment callbacks = self._config_from_message(msg) if callbacks is None: self.log.warning("Configuration failed to apply, aborting experiment run") return if len(callbacks) > 0: variables = get_sweep_variables(msg) if not variables: self.log_and_send_warning( "Message from client tryed to execute a sweep experiment without specifing variables" ) return exp = SweepExperiment(self, self._queue_out) execution_time = exp.run(callbacks, variables) self._queue_out.put( FIREQNetworkPacket({"type": "status", "msg": "sweep ended", "time": f"{execution_time} ns"}) ) else: self._run_experiment() elif cmd == "ping": self._queue_out.put(FIREQNetworkPacket({"resp": "pong"})) elif cmd == "reset_all": self._fireq_soc.reset_all() self._queue_out.put(FIREQNetworkPacket({"type": "status", "msg": "successfully reset"})) # elif cmd == "status": # self.log.debug("status") # deprecated functions: # rf_mapping # calibrate_adc # reset_waves # reset_envelopes elif cmd == "logout": self._close_client() # elif cmd == "reset_all": # self.log.debug("reset all") else: self.log.debug("received a non-supported command from the client") self._queue_out.put(FIREQNetworkPacket({"type": "error", "msg": f"unrecognized command '{cmd}'"})) except Exception as e: self.log.exception("Caught error while running command") # ========================================================================= # Connection Handling # ========================================================================= def _accept_client(self) -> socket.socket: """Accept a TCP connection on the server socket. Will continue to wait for a connection indefinetly, and return when a connection has been established. Will raise on errors. :return: The client socket. :rtype: socket.socket """ # create the server socket and self._server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self._server_socket.bind((self._host, self._port)) self._server_socket.listen(1) self._server_socket.settimeout(1.0) self.log.info(f"Listening on {self._host}:{self._port}") while not self._stop_event.is_set(): try: try: client_socket, addr = self._server_socket.accept() except TimeoutError: continue except OSError: self.log.error("Accept failed") continue self.log.info(f"Client connected from {addr}") # set the socket options client_socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) client_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) # return the client socket return client_socket except Exception as e: self.log.critical(f"Unexpected error in network loop: {e}") raise def _close_client(self) -> None: if getattr(self, "_client_socket", None): try: self._client_socket.close() except OSError: pass # signal to the workers that the client is disconnected self._client_connected.clear() self._receive_worker.clear_input_queue() self._send_worker.clear_output_queue() self.log.debug("closed client connection and cleared queues") def _perform_handshake(self) -> bool: """Perform handshake authentication with the client. This function tryes to be as safe as possible, returning false on errors. :return: True if authenticated, False otherwise. :rtype: bool """ # put the handshake payload in the output queue self._queue_out.put(FIREQNetworkPacket({"type": "handshake"})) self.log.info("Handshake put in queue, waiting for client response...") # wait for the response on the input queue while not self._stop_event.is_set() and self._client_connected.is_set(): try: response = self._queue_in.get(timeout=0.5) except Empty: continue # wait for the client response and handle it if response.get("type") != "handshake_ack": self.log.warning(f"Invalid handshake response: {response.get('type')}") return False client_token = response.get("token", "") client_name = response.get("client_name", "unknown") if client_token != self._auth_token: self.log.warning(f"Authentication failed for client '{client_name}'") return False self.log.info(f"Client '{client_name}' authenticated successfully") return True self.log.debug("Received the stop condition or client disconnected on handshake") return False def _config_from_message(self, message: dict) -> list: config = message.get("system", None) if config is None: self.log.warning("Could not get the system dict from the client message, aborting apply") self._queue_out.put( FIREQNetworkPacket({"type": "error", "msg": "system dict not found in command message"}) ) return None # apply the configuration to the system try: callbacks = self._fireq_soc.apply_configuration(config) except Exception as e: self.log.warning(f"Invalid configuration: {e}") self._queue_out.put( FIREQNetworkPacket({"type": "error", "msg": f"system dict for run experiment command is invalid: {e}"}) ) return None # return the callbacks return callbacks def _run_experiment(self) -> None: # actually run the experiment self._queue_out.put(FIREQNetworkPacket({"type": "status", "msg": "experiment started"})) try: start = time.perf_counter_ns() self._fireq_soc.run_experiment(self._queue_out) end = time.perf_counter_ns() except Exception as e: self.log.exception("Exception occurred while running experiment") self._queue_out.put(FIREQNetworkPacket({"type": "error", "msg": "error while running experiment"})) return self._queue_out.put( FIREQNetworkPacket({"type": "status", "msg": "experiment ended", "time": f"{end-start} ns"}) )
[docs] def log_and_send_warning(self, warning: str): self.log.warning(warning) self._queue_out.put(FIREQNetworkPacket({"type": "warning", "msg": f"{warning}"}))
[docs] def log_and_send_error(self, error: str): self.log.warning(error) self._queue_out.put(FIREQNetworkPacket({"type": "warning", "msg": f"{error}"}))