Files
AareDAQ/src/aare/daq/server.py
T
perl_d dd04a2fc89
CI / lint (push) Canceled after 12s
CI / test (3.11) (push) Canceled after 10s
CI / test (3.12) (push) Canceled after 7s
CI / test (3.13) (push) Canceled after 5s
CI / test-with-beamline-plugins (pxi_bec) (push) Canceled after 2s
CI / test-with-beamline-plugins (pxii_bec) (push) Canceled after 0s
CI / test-with-beamline-plugins (pxiii_bec) (push) Canceled after 0s
CI / test-with-coverage (push) Canceled after 0s
CI / coverage-analysis (push) Canceled after 0s
Build and Publish / release (push) Canceled after 0s
Docs build and publish / docker (push) Successful in 8s
fix: read beamline state from BEC on server startup
2026-08-20 12:12:50 +02:00

2576 lines
71 KiB
Python

import asyncio
import hmac
import importlib
import json
import os
import time
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from typing import Any, ClassVar
import uvicorn
from aarecommon.config.beamline import mx_beamline
from aarecommon.config.logger import get_uvicorn_logging_config, setup_logger
from aarecommon.errors.codes import AareErrorCode, export_error_codes_grouped
from aarecommon.errors.exception_handler import (
MaintenanceStateException,
SampleException,
UserRightsException,
)
from aarecommon.math.coordinate import AerotechCoordinate, Coordinate, SmargonCoordinate
from aarecommon.math.sample_geometry import SampleGeometryModel
from aarecommon.models.auth import BatonRequestStatus, BatonStatus
from aarecommon.models.automation import AutomationProgress
from aarecommon.models.models import (
AutofocusSettings,
BeamlineSettingsModel,
BeamlineStateEnum,
CryojetSettingsModel,
CrystalSize,
DAQStatusModel,
FluorescenceSpectrumOutputModel,
FluorescenceSpectrumParameterModel,
RecoveryActionRequest,
SampleCameraSettings,
SampleShortInfo,
SampleShortInfoList,
SessionStatus,
SimpleScanParameters,
TokenData,
)
from aarecommon.models.raster_grid import CompletedRasterGrid, RasterGridRequest
from aarecommon.models.rotation_scan import CompletedRotationScan, RotationScanRequest
from aareDB import SampleEventType
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi import status as api_status
from fastapi.concurrency import run_in_threadpool
from fastapi.security import OAuth2PasswordBearer
from starlette.responses import StreamingResponse
from uvicorn.workers import UvicornWorker # deprecated shim, present in pinned 0.34.2
from aare.beamline_dispatch.beamline_dispatch import get_beamline_dispatch
from aare.beamline_dispatch.protocols import BeamlineDispatch
from aare.daq import auth
from aare.daq.config import BeamlineConfig
from aare.daq.config_model import LocalContactConfigModel
from aare.daq.daq import AareDAQ
from aare.daq.server_exception_handler import register_exception_handlers
logger = setup_logger("aareDAQ")
# OAuth2 setup
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
# ── Per-worker state: populated inside the lifespan, after fork ──
cfg: BeamlineConfig
daq: AareDAQ
bl_dispatch: BeamlineDispatch
_all_pgroups_cache: dict[str, tuple[list[str], float]] = {}
_ALL_PGROUPS_TTL_S = 60.0 # adjust TTL as needed
_face_detection_state: dict = {
"seq": 0,
"running": False,
"samples": [],
"height_fit": {},
"area_fit": {},
}
_face_detection_state_lock = asyncio.Lock()
_automation_progress_state: dict = {"seq": 0, "progress": None}
_automation_progress_state_lock = asyncio.Lock()
class AareUvicornWorker(UvicornWorker):
# CONFIG_KWARGS merged last into uvicorn Config (uvicorn/workers.py:69) →
# keeps our access-log filter + proxy_headers under gunicorn.
CONFIG_KWARGS: ClassVar[dict[str, Any]] = {
"log_config": get_uvicorn_logging_config(),
"proxy_headers": False,
}
@asynccontextmanager
async def lifespan(application: FastAPI):
"""
Runs once per worker process, AFTER fork() and BEFORE serving requests.
All stateful / connection-opening initialisation belongs here so that
each worker gets its own fresh Redis, BEC, EPICS, and TELL connections.
"""
global cfg, daq, bl_dispatch
logger.info(f"Worker {os.getpid()} setting up JWT authentication...")
bl_dispatch = get_beamline_dispatch()
auth.init_jwt_key(bl_dispatch.auth)
logger.info(f"Worker {os.getpid()} starting initialisation...")
# ── Core objects (Redis, EPICS PVs, BEC, TELL, JFJoch, etc.) ──
bl = mx_beamline()
cfg = BeamlineConfig(bl)
daq = AareDAQ(cfg, bl)
cfg.state = daq.read_current_state_from_bec()
try:
cfg.reset_automation_progress()
except Exception as e:
logger.warning(f"Failed to reset automation progress Redis keys: {e}", exc_info=True)
try:
daq.refresh_detector_metadata_cache()
except Exception as e:
logger.warning(f"Initial hardware metadata refresh failed: {e}", exc_info=True)
# ── Initial TELL sync ──
try:
daq.sync_current_sample_from_tell(force=True)
except Exception as e:
logger.warning(f"Initial sample sync from TELL failed: {e}", exc_info=True)
# ── Wire callbacks ──
daq.set_face_detection_progress_callback(_push_face_detection_progress)
daq.set_automation_progress_callback(_push_automation_progress)
logger.info(f"Worker {os.getpid()} initialised successfully.")
yield # ── application serves requests here ──
# Shutdown: add cleanup here if needed
logger.info(f"Worker {os.getpid()} shutting down.")
app = FastAPI(lifespan=lifespan)
register_exception_handlers(app)
def _required_recovery_code() -> str:
"""
Get the required recovery confirmation code from environment variables.
Returns:
The recovery confirmation code.
Raises:
HTTPException: If AARE_RECOVERY_CODE is not configured.
"""
code = os.getenv("AARE_RECOVERY_CODE", "").strip()
if not code:
logger.error("AARE_RECOVERY_CODE is not configured.")
raise HTTPException(
status_code=api_status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Recovery confirmation code is not configured on the server.",
)
return code
def _validate_recovery_code(confirmation_code: str) -> None:
"""
Validate the provided recovery confirmation code against the server configuration.
Args:
confirmation_code: The code provided by the user.
Raises:
HTTPException: If the code is invalid or not configured.
"""
expected = _required_recovery_code()
provided = str(confirmation_code or "").strip()
if not hmac.compare_digest(provided, expected):
logger.warning("Invalid recovery confirmation code.")
raise HTTPException(
status_code=api_status.HTTP_403_FORBIDDEN, detail="Invalid confirmation code."
)
def _sample_is_mounted() -> bool:
"""
Check if a sample is currently mounted on the beamline.
Returns:
True if a sample is mounted, False otherwise.
"""
try:
return daq.sample is not None
except Exception:
logger.debug("Could not determine whether a sample is mounted", exc_info=True)
return False
def _push_face_detection_progress(payload: dict) -> None:
"""
Update the global face detection state with new progress information.
Args:
payload: Dictionary containing face detection progress data.
"""
global _face_detection_state
try:
next_seq = int(_face_detection_state.get("seq", 0)) + 1
_face_detection_state = {"seq": next_seq, **payload}
except Exception as e:
logger.warning(f"Failed to update face detection progress: {e}", exc_info=True)
def _get_automation_progress_state() -> dict:
"""
Read automation progress state from shared config/Redis storage.
"""
try:
return cfg.get_automation_progress_state()
except Exception as e:
logger.warning(f"Failed to read automation progress from Redis: {e}", exc_info=True)
return {"seq": 0, "progress": None}
def _push_automation_progress(progress: AutomationProgress) -> None:
"""
Update the shared automation progress state.
Args:
progress: Current automation progress model.
"""
try:
state = cfg.set_automation_progress_state(progress)
logger.info(
f"[automation_progress push] pid={os.getpid()} "
f"seq={state.get('seq')} "
f"current_step={state.get('progress', {}).get('current_step')}"
)
except Exception as e:
logger.warning(
f"Failed to update automation progress: {type(e).__name__}: {e}", exc_info=True
)
async def face_detection_event_stream() -> AsyncGenerator[str, None]:
"""
Generator for Server-Sent Events (SSE) of face detection progress.
Yields:
Formatted SSE data strings.
"""
last_seq = -1
try:
while True:
state = dict(_face_detection_state)
seq = int(state.get("seq", 0))
if seq != last_seq:
last_seq = seq
yield f"data: {json.dumps(state, separators=(',', ':'))}\n\n"
await asyncio.sleep(0.15)
except asyncio.CancelledError:
return
async def automation_progress_event_stream() -> AsyncGenerator[str, None]:
"""
Generator for Server-Sent Events (SSE) of automation progress.
"""
last_seq = -1
try:
while True:
state = _get_automation_progress_state()
seq = int(state.get("seq", 0))
if seq != last_seq:
last_seq = seq
logger.info(
f"[automation_progress sse] pid={os.getpid()} seq={seq} has_progress={state.get('progress') is not None}"
)
yield f"data: {json.dumps(state, separators=(',', ':'))}\n\n"
await asyncio.sleep(0.15)
except asyncio.CancelledError:
return
@app.post("/token")
async def login(request: Request):
"""
Authenticate a user and return an access token.
The request must carry an X-Remote-User header set by the Apache Kerberos
proxy. The client obtains a token by authenticating via Kerberos (NEGOTIATE)
against the Apache proxy, which forwards the request with X-Remote-User set.
Args:
request: The incoming HTTP request (used to inspect headers and client IP).
form_data: OAuth2 password request form (unused, required by OAuth2 spec).
Returns:
A dictionary containing the access token and token type.
"""
username = auth.authenticate_from_proxy_header(request)
data = await run_in_threadpool(auth.authenticate_user, cfg, username)
return {"access_token": data, "token_type": "bearer"}
@app.get("/meta/error-codes")
async def meta_error_codes() -> dict[str, dict[str, str]]:
"""
Public, stable registry of machine-readable error codes.
Useful for GUIs, tests, and diagnostics.
"""
return export_error_codes_grouped()
@app.get("/status")
async def status(token: str = Depends(oauth2_scheme)) -> DAQStatusModel:
"""
Get the current status of the DAQ system.
Args:
token: OAuth2 access token.
Returns:
DAQStatusModel containing the current status.
"""
data = auth.parse_token(token)
cfg.touch_gui_session(
session=data.session,
username=data.sub,
staff=data.staff,
expiry_sec=cfg.GUI_SESSION_EXPIRE_SECONDS,
)
full = daq.status
active_pgroup = cfg.pgroup
is_staff = data.staff
in_allowed_groups = active_pgroup is not None and active_pgroup in data.pgroups
in_ro = is_staff or in_allowed_groups
sample = getattr(full, "sample", None)
sample_pgroup = sample.user if sample is not None else None
sample_view_allowed = sample_pgroup in data.pgroups or is_staff
full.sample = sample if in_ro and sample_view_allowed else None
full.box = getattr(full, "box", None) if in_ro else None
full.last_best_res = getattr(full, "last_best_res", None) if in_ro else None
full.last_best_b_factor = getattr(full, "last_best_b_factor", None) if in_ro else None
full.crystal_size = (
getattr(full, "crystal_size", CrystalSize(x=0, y=0, z=0))
if in_ro
else CrystalSize(x=0, y=0, z=0)
)
full.session = SessionStatus(
current_pgroup=cfg.pgroup, session=cfg.session_state(data.session), staff=data.staff
)
if data.staff:
full.open_guis = cfg.get_open_gui_sessions()
else:
own_gui = cfg.get_gui_session(data.session)
full.open_guis = [own_gui] if own_gui is not None else []
return full
@app.get("/beamline/geometry")
async def sample_geometry(token: str = Depends(oauth2_scheme)) -> SampleGeometryModel:
"""
Get the sample geometry information.
Args:
token: OAuth2 access token.
Returns:
SampleGeometryModel object.
"""
auth.check_jwt_ro(cfg, auth.parse_token(token))
return daq.status.geom
@app.put("/beamline/omega")
async def set_omega_abs(val: float, token: str = Depends(oauth2_scheme)):
"""
Set the omega angle of the goniometer.
Args:
val: The target omega angle in degrees.
token: OAuth2 access token.
Returns:
"OK" on success.
"""
logger.debug(f"Setting omega to {val}")
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.omega = val
return "OK"
@app.put("/beamline/omega_rel")
async def set_omega_rel(val: float, token: str = Depends(oauth2_scheme)):
"""
Move the omega angle relatively.
Args:
val: The relative omega movement in degrees.
token: OAuth2 access token.
Returns:
"OK" on success.
"""
logger.debug(f"Moving omega by {val}")
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.omega_rel(val)
return "OK"
@app.put("/beamline/front_light")
async def front_light(val: float, token: str = Depends(oauth2_scheme)):
"""
Set the front light intensity.
Args:
val: Light intensity value.
token: OAuth2 access token.
Returns:
"OK" on success.
"""
logger.debug(f"Setting light to {val}")
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.front_light = val
return "OK"
@app.put("/beamline/back_light")
async def back_light(val: float, token: str = Depends(oauth2_scheme)):
"""
Set the back light intensity.
Args:
val: Light intensity value.
token: OAuth2 access token.
Returns:
"OK" on success.
"""
logger.debug(f"Setting back light to {val}")
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.back_light = val
return "OK"
@app.put("/beamline/zoom")
async def zoom(val: float, token: str = Depends(oauth2_scheme)):
"""
Set the camera zoom level.
Args:
val: Zoom level.
token: OAuth2 access token.
Returns:
"OK" on success.
"""
logger.debug(f"Setting zoom to {val}")
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.zoom = val
return "OK"
@app.post("/beamline/mono_pitch_scan")
async def mono_pitch_scan(plot: bool = False, token: str = Depends(oauth2_scheme)):
"""
Run a monochromator pitch scan. Staff only.
Args:
plot: Whether to enable plotting in the BEC worker.
token: OAuth2 access token.
Returns:
"OK" on success.
"""
logger.debug(f"Running mono pitch scan (plot={plot})")
auth.check_jwt_staff(cfg, auth.parse_token(token))
daq.mono_pitch_scan(plot=plot)
return "OK"
@app.get("/beamline/steer_beam_available")
async def steer_beam_available(token: str = Depends(oauth2_scheme)):
"""
Check if the beam centring routine is available.
Args:
token: OAuth2 access token.
Returns:
"OK" on success.
"""
auth.parse_token(token)
return daq.steer_beam_available()
@app.post("/beamline/steer_beam")
async def steer_beam(
x: int | None = None, y: int | None = None, token: str = Depends(oauth2_scheme)
):
"""
Adjust the mirrors to move the beam to the box. Staff only.
Args:
x (int, optional): x-coordinate in sample camera pixels to steer to
y (int, optional): y-coordinate in sample camera pixels to steer to
token: OAuth2 access token.
Returns:
"OK" on success.
"""
logger.debug("Running beam steering routine")
auth.check_jwt_staff(cfg, auth.parse_token(token))
daq.steer_beam(x, y)
return "OK"
@app.put("/beamline/change_energy")
async def change_energy(value: float, plot: bool = False, token: str = Depends(oauth2_scheme)):
"""
Change monochromator energy. Staff only.
Args:
value: Target energy.
plot: Whether to enable plotting in the BEC worker.
token: OAuth2 access token.
Returns:
"OK" on success.
"""
logger.debug(f"Changing energy to {value} (plot={plot})")
auth.check_jwt_staff(cfg, auth.parse_token(token))
daq.change_energy(value=value, plot=plot)
return "OK"
@app.put("/beamline/smargon")
async def smargon(val: SmargonCoordinate, token: str = Depends(oauth2_scheme)):
"""
Move the Smargon goniometer to specified coordinates.
Args:
val: SmargonCoordinate object.
token: OAuth2 access token.
Returns:
"OK" on success.
"""
logger.debug(f"Setting smargon to {val}")
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.smargon = val
logger.debug(f"smargon set to {daq.smargon}")
return "OK"
@app.post("/beamline/tweak_abr_meas_pos")
async def tweak_abr_meas_pos(val: AerotechCoordinate, token: str = Depends(oauth2_scheme)):
"""
Tweak the Aerotech measurement position. Staff only.
Args:
val: AerotechCoordinate object with tweaks.
token: OAuth2 access token.
Returns:
"OK" on success.
"""
auth.check_jwt_staff(cfg, auth.parse_token(token))
daq.tweak_abr_meas_pos(val)
return "OK"
@app.post("/beamline/save_abr_meas_pos")
async def save_abr_meas_pos(token: str = Depends(oauth2_scheme)):
"""
Save the current Aerotech measurement position. Staff only.
Args:
token: OAuth2 access token.
Returns:
"OK" on success.
"""
logger.debug("Save abr")
auth.check_jwt_staff(cfg, auth.parse_token(token))
daq.save_abr_meas_pos()
return "OK"
@app.post("/beamline/save_beam_location_camera_setting")
async def save_beam_location_camera_setting(token: str = Depends(oauth2_scheme)):
"""
Persist the camera's current gain/exposure as the beam-location preset for
the current zoom (stored in Redis, re-applied on future zoom changes).
Returns:
"OK" on success.
"""
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.save_beam_location_camera_setting()
return "OK"
@app.post("/beamline/anneal")
async def anneal(time_s: float, token: str = Depends(oauth2_scheme)):
"""
Perform sample annealing for a specified duration.
Args:
time_s: Annealing time in seconds.
token: OAuth2 access token.
Returns:
"OK" on success.
"""
logger.debug(f"Anneal {time_s}")
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.anneal(time_s)
return "OK"
@app.post("/smargon/initialize")
async def initialise_smargon(token: str = Depends(oauth2_scheme)) -> dict:
"""
Initialise Smargon. Staff only.
Args:
token: OAuth2 access token.
Returns:
Dictionary with status and message.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
daq.initialise_smargon()
return {"ok": True, "message": "Smargon initialised."}
@app.post("/bec/load_user_macros")
async def bec_load_user_macros(token: str = Depends(oauth2_scheme)) -> dict:
"""
Load BEC user macros. Staff only.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
daq.bec_load_user_macros()
return {"ok": True, "message": "BEC user macros loaded."}
@app.get("/bec/user_macros")
async def bec_list_all_user_macros(token: str = Depends(oauth2_scheme)) -> list:
"""
List all BEC user macros. Staff only.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
return daq.bec_list_all_user_macros()
@app.get("/bec/devices")
async def bec_list_all_devices(token: str = Depends(oauth2_scheme)) -> list:
"""
List all BEC position devices. Staff only.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
return daq.bec_list_all_devices()
@app.post("/bec/reinitialise_planner_and_position_devices")
async def bec_reinitialise_planner_and_position_devices(
method: str = "auto", token: str = Depends(oauth2_scheme)
) -> dict:
"""
Reinitialise BEC planner and position devices. Staff only.
Args:
method:
"auto" - use beamline default.
"beamline" - force init_beamline_environment().
"sample" - force init_se_devices() and planner creation.
token: OAuth2 access token.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
position_devices = daq.bec_reinitialise_planner_and_position_devices(method=method)
return {
"ok": True,
"method": method,
"position_devices": position_devices,
"message": "BEC planner and position devices reinitialised.",
}
@app.post("/bec/save_current_bs_pos")
async def bec_save_current_bs_pos(token: str = Depends(oauth2_scheme)) -> dict:
"""
Save the current BEC beamstop work position. Staff only.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
daq.bec_save_current_bs_pos()
return {"ok": True, "message": "Saved current BEC beamstop work position."}
@app.post("/bec/save_current_collimator_pos")
async def bec_save_current_collimator_pos(token: str = Depends(oauth2_scheme)) -> dict:
"""
Save the current BEC collimator work position. Staff only.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
daq.bec_save_current_collimator_pos()
return {"ok": True, "message": "Saved current BEC collimator 'in' positions."}
@app.post("/bec/save_current_aerotech_position")
async def bec_save_current_aerotech_position(token: str = Depends(oauth2_scheme)) -> dict:
"""
Save the current BEC aerotech work position and reload device config. Staff only.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
daq.bec_save_current_aerotech_position()
return {"ok": True, "message": "Saved current BEC aerotech work position and reloaded devices."}
def initialise_aerotech(self):
self._cfg.try_set_busy(timeout=360)
try:
self._devs.aerotech.home_aerotech()
self._cfg.state_busy = False
except Exception as e:
self._cfg.state_busy = False
logger.error(f"Failed to initialise Aerotech: {e}")
raise
def detector_take_pedestal(self):
self._cfg.try_set_busy(timeout=360)
try:
self._jfjoch.take_pedestal()
self._cfg.state_busy = False
except Exception as e:
self._cfg.state_busy = False
logger.error(f"Failed to take detector pedestal: {e}")
raise
def initialise_detector(self):
self._cfg.try_set_busy(timeout=360)
try:
self._jfjoch.initialize()
self._cfg.state_busy = False
except Exception as e:
self._cfg.state_busy = False
logger.error(f"Failed to initialise detector: {e}")
raise
@app.get("/about/installed_version")
async def running_version() -> str:
return importlib.metadata.version("aare")
@app.get("/about/server_file_path")
async def server_file() -> str:
return __file__
@app.get("/local_contact/simulation_state")
async def local_contact_simulation_state(token: str = Depends(oauth2_scheme)) -> dict:
"""
Return current runtime simulation state for Local Contact tools. Staff only.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
return daq.get_runtime_simulation_state()
@app.get("/local_contact/device_state")
async def local_contact_device_state(token: str = Depends(oauth2_scheme)) -> dict:
"""
Return Local Contact device mode/error state. Staff only.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
return daq.get_local_contact_device_state()
@app.get("/local_contact/links")
async def local_contact_links(token: str = Depends(oauth2_scheme)) -> dict:
"""
Return Local Contact web control links. Staff only.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
return daq.get_local_contact_links()
@app.post("/local_contact/simulate/{device}")
async def local_contact_set_simulation(
device: str, enabled: bool, token: str = Depends(oauth2_scheme)
) -> dict:
"""
Enable or disable runtime simulation for a backend device and restart its wrapper. Staff only.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
result = daq.set_runtime_simulation(device, enabled)
result["message"] = f"{device} simulation set to {enabled}."
return result
@app.post("/local_contact/restart/{device}")
async def local_contact_restart_device(device: str, token: str = Depends(oauth2_scheme)) -> dict:
"""
Restart a Local Contact backend wrapper. Staff only.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
device = str(device).strip().lower()
if device == "bec":
result = daq.restart_bec_worker()
elif device == "detector":
result = daq.restart_detector()
elif device == "tell":
result = daq.restart_tell()
elif device == "aerotech":
result = daq.restart_aerotech()
elif device == "smargon":
result = daq.restart_smargon()
else:
raise HTTPException(
status_code=api_status.HTTP_400_BAD_REQUEST,
detail="Unknown restart device. Expected one of: bec, detector, tell, aerotech, smargon.",
)
result["message"] = f"{device} backend restarted."
return result
@app.post("/local_contact/resync/detector_metadata")
async def local_contact_resync_detector_metadata(token: str = Depends(oauth2_scheme)) -> dict:
"""
Refresh cached detector metadata and DTZ limits. Staff only.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
payload = daq.refresh_detector_metadata_cache()
return {"ok": True, "message": "Hardware metadata cache resynced.", "payload": payload}
@app.get("/local_contact/config")
async def local_contact_config(token: str = Depends(oauth2_scheme)) -> LocalContactConfigModel:
"""
Return Local Contact config values. Staff only.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
return daq.get_local_contact_config()
@app.put("/local_contact/config")
async def local_contact_set_config(
payload: LocalContactConfigModel, token: str = Depends(oauth2_scheme)
) -> LocalContactConfigModel:
"""
Update Local Contact config values. Staff only.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
return daq.set_local_contact_config(payload)
@app.post("/beamline/goto_abr_meas_pos")
async def goto_abr_meas_pos(token: str = Depends(oauth2_scheme)):
"""
Move the Aerotech to the saved measurement position.
Args:
token: OAuth2 access token.
Returns:
"OK" on success.
"""
logger.debug("Go to ABR meas pos")
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.goto_abr_meas_pos()
return "OK"
@app.post("/beam_mark/add")
async def mark_beam(x: float, y: float, token: str = Depends(oauth2_scheme)):
"""
Mark the beam position on the camera image. Staff only.
Args:
x: X coordinate in pixels.
y: Y coordinate in pixels.
token: OAuth2 access token.
Returns:
"OK" on success.
"""
logger.debug(f"Marking beam to {x}, {y}")
auth.check_jwt_staff(cfg, auth.parse_token(token))
daq.mark_beam(x, y)
return "OK"
@app.post("/beam_mark/clear")
async def clear_beam_mark(token: str = Depends(oauth2_scheme)):
"""
Clear the beam mark from the camera image. Staff only.
Args:
token: OAuth2 access token.
Returns:
"OK" on success.
"""
logger.debug("Clear beam mark")
auth.check_jwt_staff(cfg, auth.parse_token(token))
daq.clear_mark_beam()
return "OK"
@app.post("/beamline/beam_center")
async def beam_center(x: float, y: float, token: str = Depends(oauth2_scheme)):
"""
Set the beam center position. Staff only.
Args:
x: X coordinate in pixels.
y: Y coordinate in pixels.
token: OAuth2 access token.
Returns:
"OK" on success.
"""
logger.debug(f"Beam Center {x}, {y}")
auth.check_jwt_staff(cfg, auth.parse_token(token))
daq.beam_center = (x, y)
return "OK"
@app.post("/beamline/beam_size_mm")
async def beam_size_mm(x: float, y: float, token: str = Depends(oauth2_scheme)):
"""
Set the beam size in millimeters. Staff only.
Args:
x: Beam width in mm.
y: Beam height in mm.
token: OAuth2 access token.
Returns:
"OK" on success.
"""
logger.debug(f"Beam Size {x}, {y}")
auth.check_jwt_staff(cfg, auth.parse_token(token))
daq.beam_size_mm = Coordinate(x=x, y=y)
return "OK"
@app.put("/beamline/samcam")
async def samcam_settings(s: SampleCameraSettings, token: str = Depends(oauth2_scheme)):
"""
Update the sample camera settings (exposure, gain, etc.).
Args:
s: SampleCameraSettings object.
token: OAuth2 access token.
Returns:
"OK" on success.
"""
logger.debug(f"SamCam settings: {s}")
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.samcam_settings = s
return "OK"
@app.put("/beamline/autoexposure")
async def samcam_autoexposure(token: str = Depends(oauth2_scheme)):
"""
Update the sample camera settings (exposure, gain, etc.).
Args:
s: SampleCameraSettings object.
token: OAuth2 access token.
Returns:
"OK" on success.
"""
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.auto_exposure()
return "OK"
@app.post("/samcam/autofocus")
async def samcam_autofocus(s: AutofocusSettings, token: str = Depends(oauth2_scheme)):
"""
Trigger the sample camera autofocus procedure.
Args:
s: AutofocusSettings object.
token: OAuth2 access token.
Returns:
"OK" on success.
"""
logger.debug("SamCam AutoFocus")
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.auto_focus(s)
return "OK"
@app.post("/beamline/shutter")
async def shutter(val: bool, token: str = Depends(oauth2_scheme)):
"""
Open or close the beamline shutter.
Args:
val: True to open, False to close.
token: OAuth2 access token.
Returns:
"OK" on success.
"""
logger.debug(f"Shutter {val}")
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.shutter = val
return "OK"
# TELL procedures
@app.get("/sample/curr_sample")
async def sample(token: str = Depends(oauth2_scheme)) -> SampleShortInfo:
"""
Get information about the currently mounted sample.
Args:
token: OAuth2 access token.
Returns:
SampleShortInfo object.
"""
token_data = auth.parse_token(token)
auth.check_jwt_ro(cfg, auth.parse_token(token))
s = daq.sample
if token_data.staff or s.user in token_data.pgroups:
return daq.sample
else:
return SampleShortInfo(
sample_name="Other user sample",
run_number=0,
puck_name="",
dewar_name="",
db_id=-1,
pin=s.pin,
location=s.location,
)
@app.post("/tell/park_and_dry")
async def park_and_dry(token: str = Depends(oauth2_scheme)):
"""
Execute the 'park and dry' procedure for the sample changer (TELL).
Args:
token: OAuth2 access token.
Returns:
Dictionary with status and message.
"""
# TODO: add unmount toggle, and combine aprk_and_dry and tell_dry to one command that takes park and unmount as signals
auth.check_jwt_rw(cfg, auth.parse_token(token))
logger.debug("Executing unmount, dry and park")
daq.park_and_dry(park=True, unmount=True)
return {"ok": True, "message": "TELL has been dried and parked"}
@app.post("/tell/dry")
async def tell_dry(token: str = Depends(oauth2_scheme)) -> dict:
"""
Execute a TELL dry cycle. Staff only.
Args:
token: OAuth2 access token.
Returns:
Dictionary with status and message.
"""
data = auth.parse_token(token)
auth.check_jwt_rw(data)
logger.debug("Executing dry and return to dewar")
daq.park_and_dry(park=False, unmount=False)
return {"ok": True, "message": "TELL dry cycle completed."}
@app.post("/tell/toggle_blower")
async def tell_toggle_blower(token: str = Depends(oauth2_scheme)) -> dict:
"""
Toggle the TELL blower. Staff only.
Args:
token: OAuth2 access token.
Returns:
Dictionary with status and message.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
daq.blower_control()
return {"ok": True, "message": "TELL blower toggled."}
@app.post("/sample/mount")
async def mount(dbid: int, token: str = Depends(oauth2_scheme), reference: bool = False):
"""
Mount a sample from the spreadsheet onto the goniometer.
Args:
dbid: Database ID of the sample to mount.
token: OAuth2 access token.
reference: Whether to look for the sample in the reference tools list.
Returns:
Result of the mounting procedure.
"""
token_data = auth.parse_token(token)
logger.debug(f"Mount {dbid}")
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.check_tell_mount_start_conditions()
if reference:
st = daq.reference_tools
else:
st = daq.sample_spreadsheet
index = -1
for i in range(len(st.s)):
if st.s[i].db_id == dbid:
index = i
if index == -1:
raise SampleException(message="Sample not found")
if not (token_data.staff or st.s[index].user in token_data.pgroups):
raise UserRightsException(message="Sample belongs to a different user.")
daq.sample = st.s[index]
return "OK"
@app.post("/sample/unmount")
async def unmount(token: str = Depends(oauth2_scheme)):
"""
Unmount the current sample from the goniometer.
Args:
token: OAuth2 access token.
Returns:
"OK" on success.
"""
logger.debug("Unmount")
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.sample = None
return "OK"
@app.post("/sample/manual")
async def manual(s: SampleShortInfo, token: str = Depends(oauth2_scheme)):
"""
Manually create or update a sample.
Args:
s: SampleShortInfo object.
token: OAuth2 access token.
"""
logger.debug(f"Manual Sample {s.sample_name}")
auth.check_jwt_rw(cfg, auth.parse_token(token))
logger.debug(f"DB ID prior creating {s.db_id}")
daq.create_sample(s)
logger.debug(f"DB ID after creating {s.db_id}")
@app.post("/sample/resync")
async def sample_resync(token: str = Depends(oauth2_scheme)) -> dict:
"""
Manually trigger a resynchronization of the sample information from the changer (TELL).
Args:
token: OAuth2 access token.
Returns:
Dictionary with status and message.
"""
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.sync_current_sample_from_tell(force=True)
logger.info("TELL sample cache resynced via API request.")
return {"ok": True, "message": "TELL sample cache resynced."}
def get_spreadsheet(data: TokenData) -> SampleShortInfoList:
"""
Get the sample spreadsheet for the given user/pgroup.
Args:
data: TokenData containing user information and pgroups.
Returns:
SampleShortInfoList object.
"""
if data.staff:
return cfg.spreadsheet
else:
return cfg.spreadsheet_pgroup(data.pgroups)
def get_reference_tools() -> SampleShortInfoList:
"""
Get the list of reference tools.
Returns:
SampleShortInfoList object.
"""
return cfg.reference_tools
async def reference_tools_event_stream() -> AsyncGenerator[str, None]:
"""
Generator for SSE of reference tools updates.
Yields:
JSON string of the reference tools list.
"""
try:
while True:
yield get_reference_tools().model_dump_json()
await asyncio.sleep(10)
except asyncio.CancelledError:
return
async def spreadsheet_event_stream(data: TokenData) -> AsyncGenerator[str, None]:
"""
Generator for SSE of sample spreadsheet updates.
Args:
data: TokenData for filtering the spreadsheet.
Yields:
JSON string of the user's sample spreadsheet.
"""
try:
while True:
yield get_spreadsheet(data).model_dump_json()
await asyncio.sleep(10)
except asyncio.CancelledError:
return
@app.get("/sse/spreadsheet")
async def spreadsheet_sse(token: str = Depends(oauth2_scheme)):
"""
SSE endpoint for sample spreadsheet updates.
Args:
token: OAuth2 access token.
Returns:
StreamingResponse.
"""
data = auth.parse_token(token)
return StreamingResponse(
spreadsheet_event_stream(data),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Cache-Control",
},
)
@app.get("/sse/reference_tools")
async def reference_tools_sse():
"""
SSE endpoint for reference tools updates.
Returns:
StreamingResponse.
"""
return StreamingResponse(
reference_tools_event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Cache-Control",
},
)
@app.get("/sample/spreadsheet")
async def spreadsheet(token: str = Depends(oauth2_scheme)) -> SampleShortInfoList:
"""
Get the current sample spreadsheet.
Args:
token: OAuth2 access token.
Returns:
SampleShortInfoList.
"""
return get_spreadsheet(auth.parse_token(token))
@app.get("/sample/reference_tools")
async def reference_tools(token: str = Depends(oauth2_scheme)) -> SampleShortInfoList:
"""
Get the reference tools list.
Args:
token: OAuth2 access token.
Returns:
SampleShortInfoList.
"""
auth.check_jwt_ro(cfg, auth.parse_token(token))
return get_reference_tools()
# State transitions
@app.post("/state/dewar_exchange")
async def dewar_exchange(token: str = Depends(oauth2_scheme)):
"""
Transition beamline state to DewarTransfer.
Args:
token: OAuth2 access token.
Returns:
"OK" on success.
"""
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.state = BeamlineStateEnum.DewarTransfer
return "OK"
@app.post("/state/sample_exchange")
async def sample_exchange(token: str = Depends(oauth2_scheme)):
"""
Transition beamline state to SampleExchange.
Args:
token: OAuth2 access token.
Returns:
"OK" on success.
"""
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.state = BeamlineStateEnum.SampleExchange
return "OK"
@app.post("/state/sample_alignment")
async def sample_alignment(token: str = Depends(oauth2_scheme)):
"""
Transition beamline state to SampleAlignment.
Args:
token: OAuth2 access token.
Returns:
"OK" on success.
"""
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.state = BeamlineStateEnum.SampleAlignment
@app.post("/state/beam_location")
async def beam_location(token: str = Depends(oauth2_scheme)):
"""
Transition beamline state to BeamLocation. Staff only.
Args:
token: OAuth2 access token.
"""
auth.check_jwt_staff(cfg, auth.parse_token(token))
daq.state = BeamlineStateEnum.BeamLocation
@app.post("/state/beamstop_alignment")
async def beamstop_alignment(token: str = Depends(oauth2_scheme)):
"""
Transition beamline state to BeamstopAlignment.
Args:
token: OAuth2 access token.
Returns:
"OK" on success.
"""
auth.check_jwt_staff(cfg, auth.parse_token(token))
daq.state = BeamlineStateEnum.BeamstopAlignment
return "OK"
@app.post("/state/flux_measurement")
async def flux_measurement(token: str = Depends(oauth2_scheme)):
"""
Transition beamline state to FluxMeasurement.
Args:
token: OAuth2 access token.
Returns:
"OK" on success.
"""
auth.check_jwt_staff(cfg, auth.parse_token(token))
daq.state = BeamlineStateEnum.FluxMeasurement
return "OK"
@app.post("/state/data_collection")
async def data_collection(token: str = Depends(oauth2_scheme)):
"""
Transition beamline state to DewarTransfer.
Args:
token: OAuth2 access token.
Returns:
"OK" on success.
"""
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.state = BeamlineStateEnum.DataCollection
return "OK"
@app.post("/state/robot_sample_exchange")
async def robot_sample_exchange(token: str = Depends(oauth2_scheme)):
"""
Transition beamline state to SampleExchange.
Args:
token: OAuth2 access token.
Returns:
"OK" on success.
"""
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.state = BeamlineStateEnum.RobotSampleExchange
return "OK"
@app.post("/state/xray_fluorescence")
async def xray_fluorescence(token: str = Depends(oauth2_scheme)):
"""
Transition beamline state to SampleAlignment.
Args:
token: OAuth2 access token.
Returns:
"OK" on success.
"""
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.state = BeamlineStateEnum.XrayFluorescence
@app.post("/state/xtal_snapshot")
async def xtal_snapshot(token: str = Depends(oauth2_scheme)):
"""
Transition beamline state to BeamLocation. Staff only.
Args:
token: OAuth2 access token.
"""
auth.check_jwt_staff(cfg, auth.parse_token(token))
daq.state = BeamlineStateEnum.XtalSnapshot
@app.post("/access/take_over_beamline")
async def take_over_beamline(
payload: RecoveryActionRequest, token: str = Depends(oauth2_scheme)
) -> str:
"""
Take over the beamline session. Staff only.
Args:
payload: RecoveryActionRequest with confirmation code.
token: OAuth2 access token.
Returns:
"OK" on success.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
_validate_recovery_code(payload.confirmation_code)
auth.force_current_sesion(cfg, data)
logger.warning(
"Beamline session forcefully taken over.", extra={"session": getattr(data, "session", None)}
)
return "OK"
@app.post("/state/force_clear_busy")
async def force_clear_busy(
payload: RecoveryActionRequest, token: str = Depends(oauth2_scheme)
) -> str:
"""
Clear the beamline busy flag. Staff only.
Args:
payload: RecoveryActionRequest with confirmation code.
token: OAuth2 access token.
Returns:
"OK" on success.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
_validate_recovery_code(payload.confirmation_code)
cfg.state_busy = False
logger.warning(
"Beamline busy flag cleared via protected endpoint.",
extra={"session": getattr(data, "session", None)},
)
return "OK"
@app.post("/recovery/force_maintenance_state")
async def force_maintenance_state(
payload: RecoveryActionRequest, token: str = Depends(oauth2_scheme)
) -> dict:
"""
Recover the beamline from an error state. Staff only.
Args:
payload: RecoveryActionRequest with confirmation code.
token: OAuth2 access token.
Returns:
Dictionary with status and previous state information.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
_validate_recovery_code(payload.confirmation_code)
sample_mounted = _sample_is_mounted()
prev_state = cfg.state
prev_busy = cfg.state_busy
auth.force_current_sesion(cfg, data)
cfg.state_busy = False
cfg.state = BeamlineStateEnum.Maintenance
logger.warning(
"Beamline recovery action executed - forced maintenance state.",
extra={
"session": getattr(data, "session", None),
"previous_state": getattr(prev_state, "name", str(prev_state)),
"previous_busy": prev_busy,
"sample_mounted": sample_mounted,
},
)
return {
"ok": True,
"sample_mounted": sample_mounted,
"previous_state": getattr(prev_state, "name", str(prev_state)),
"previous_busy": prev_busy,
"new_state": BeamlineStateEnum.Maintenance.name,
}
@app.post("/recovery/unmount_sample")
async def recovery_unmount_sample(
payload: RecoveryActionRequest, token: str = Depends(oauth2_scheme)
) -> dict:
"""
Forcefully unmount a sample during recovery. Staff only.
Args:
payload: RecoveryActionRequest with confirmation code.
token: OAuth2 access token.
Returns:
Dictionary with status of the recovery unmount action.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
_validate_recovery_code(payload.confirmation_code)
auth.force_current_sesion(cfg, data)
if cfg.state_busy:
raise HTTPException(
status_code=api_status.HTTP_409_CONFLICT,
detail="Beamline is busy. Clear or recover the beamline before attempting recovery unmount.",
)
status = daq.status
if not getattr(status, "tell_connected", False):
raise HTTPException(
status_code=api_status.HTTP_409_CONFLICT,
detail=f"TELL is not connected: {getattr(status, 'tell_error', 'unknown error')}",
)
sample_mounted = _sample_is_mounted()
if not sample_mounted:
return {"ok": True, "sample_mounted": False, "message": "No sample appears to be mounted."}
prev_state = cfg.state
daq.recovery_unmount_sample()
logger.warning(
"Recovery sample unmount executed.",
extra={
"session": getattr(data, "session", None),
"previous_state": getattr(prev_state, "name", str(prev_state)),
},
)
return {
"ok": True,
"sample_mounted": True,
"previous_state": getattr(prev_state, "name", str(prev_state)),
"new_state": getattr(cfg.state, "name", str(cfg.state)),
"message": "Recovery unmount completed.",
}
# Scans
@app.post("/scan/raster")
async def raster(
val: RasterGridRequest, auto_center: bool = False, token: str = Depends(oauth2_scheme)
) -> CompletedRasterGrid:
"""
Execute a raster scan.
Args:
val: RasterGridRequest parameters.
auto: Whether to perform automated analysis after scan.
token: OAuth2 access token.
Returns:
CompletedRasterGrid object.
"""
auth.check_jwt_rw(cfg, auth.parse_token(token))
return daq.measure_raster(val, auto_center)
@app.post("/scan/rotation")
async def rotation(
val: RotationScanRequest, token: str = Depends(oauth2_scheme)
) -> CompletedRotationScan:
"""
Execute a rotation scan.
Args:
val: RotationScanRequest parameters.
token: OAuth2 access token.
Returns:
CompletedRotationScan object.
"""
auth.check_jwt_rw(cfg, auth.parse_token(token))
return daq.measure_rotation(val)
@app.post("/scan/auto")
async def auto(s: SampleShortInfo, token: str = Depends(oauth2_scheme)):
"""
Execute a fully automated measurement sequence for a sample.
Args:
s: SampleShortInfo object.
token: OAuth2 access token.
Returns:
Formatted string of the total runtime.
"""
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.check_tell_mount_start_conditions()
try:
runtime = daq.measure(s)
except Exception as e:
logger.exception("Critical automation failure in /scan/auto")
raise HTTPException(
status_code=api_status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": AareErrorCode.INTERNAL_ERROR.value,
"message": str(e) or "Critical automation failure",
},
) from e
return f"{runtime:0.3f}"
@app.post("/scan/smart_params")
async def set_smart_params(p: SimpleScanParameters, token: str = Depends(oauth2_scheme)) -> str:
"""
Set the 'smart' scan parameters.
Args:
p: SimpleScanParameters object.
token: OAuth2 access token.
Returns:
"OK" on success.
"""
auth.check_jwt_rw(cfg, auth.parse_token(token))
cfg.auto_params = p
return "OK"
@app.post("/scan/cancel")
async def cancel(token: str = Depends(oauth2_scheme)):
"""
Cancel the currently running scan or automation.
Args:
token: OAuth2 access token.
"""
logger.debug("Scan Cancel")
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.cancel()
# ALC routines
@app.post("/alc/center_loop")
async def alc_center_loop(token: str = Depends(oauth2_scheme)) -> str:
"""
Trigger the automated loop centering procedure (ALC).
Args:
token: OAuth2 access token.
Returns:
"OK" on success.
"""
logger.debug("ALC")
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.auto_loop_center()
return "OK"
@app.post("/alc/ml_bounding_box")
async def alc_ml_bounding_box(token: str = Depends(oauth2_scheme)) -> RasterGridRequest | None:
"""
Request an ML-based bounding box for the sample.
Args:
token: OAuth2 access token.
Returns:
RasterGridRequest if a box was found, else None.
"""
auth.check_jwt_rw(cfg, auth.parse_token(token))
return daq.ml_bounding_box()
@app.post("/face_detection/run")
async def face_detection_run(
steps: int, step_size: int, token: str = Depends(oauth2_scheme)
) -> dict:
"""
Trigger the face detection sequence.
Args:
steps: Number of rotation steps.
step_size: Degrees per step.
token: OAuth2 access token.
Returns:
Dictionary containing face detection results.
"""
logger.debug(f"Face detection run: {steps} steps, {step_size} step size")
auth.check_jwt_rw(cfg, auth.parse_token(token))
_push_face_detection_progress(
{"running": True, "status": "starting", "samples": [], "height_fit": {}, "area_fit": {}}
)
result = daq.face_detection(steps=steps, step_size=step_size)
return result
@app.get("/sse/face_detection")
async def sse_face_detection(token: str = Depends(oauth2_scheme)):
"""
SSE endpoint for face detection progress updates.
Args:
token: OAuth2 access token.
Returns:
StreamingResponse.
"""
auth.check_jwt_ro(cfg, auth.parse_token(token))
return StreamingResponse(
face_detection_event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Cache-Control",
},
)
@app.get("/sse/automation_progress")
async def sse_automation_progress(token: str = Depends(oauth2_scheme)):
"""
SSE endpoint for DAQ automation progress updates.
Args:
token: OAuth2 access token.
Returns:
StreamingResponse.
"""
auth.check_jwt_ro(cfg, auth.parse_token(token))
return StreamingResponse(
automation_progress_event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Cache-Control",
},
)
# Access management
@app.get("/access/pgroup")
async def pgroup(token: str = Depends(oauth2_scheme)) -> str:
"""
Get the currently active pgroup.
Args:
token: OAuth2 access token.
Returns:
The pgroup string.
"""
auth.check_jwt_ro(cfg, auth.parse_token(token))
return cfg.pgroup
@app.put("/access/pgroup")
async def set_pgroup(val: str, token: str = Depends(oauth2_scheme)) -> str:
"""
Set the active pgroup.
Args:
val: The pgroup string to set.
token: OAuth2 access token.
Returns:
"OK" on success.
"""
data = auth.parse_token(token)
holder = cfg.baton_holder
is_current_holder = holder is not None and holder.session == data.session
# Staff or current baton holder may change p-group even if it is not currently active.
# Everyone else must still belong to the active p-group.
if not (data.staff or is_current_holder):
auth.check_jwt_ro(cfg, data)
cfg.pgroup = val
return "OK"
@app.delete("/access/pgroup")
async def del_pgroup(token: str = Depends(oauth2_scheme)) -> str:
"""
Clear the active pgroup.
Args:
token: OAuth2 access token.
Returns:
"OK" on success.
"""
auth.check_jwt_ro(cfg, auth.parse_token(token))
cfg.pgroup = None
return "OK"
@app.put("/beamline/commissioning_mode")
async def set_commissioning_mode(val: bool, token: str = Depends(oauth2_scheme)) -> str:
"""
Set the commissioning mode. Staff only.
Args:
val: True to enable commissioning mode, False to disable.
token: OAuth2 access token.
Returns:
"OK" on success.
"""
auth.check_jwt_staff(cfg, auth.parse_token(token))
cfg.commissioning_mode = val
return "OK"
@app.get("/admin/gui_sessions")
async def get_gui_sessions(token: str = Depends(oauth2_scheme)) -> list[dict]:
"""
Staff-only list of currently active GUIs.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
return [item.model_dump() for item in cfg.get_open_gui_sessions()]
@app.post("/admin/gui_sessions/{session_id}/request_close")
async def request_gui_close(
session_id: int, grace_seconds: int = 60, token: str = Depends(oauth2_scheme)
) -> dict:
"""
Staff-only request for a remote GUI to close gracefully.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
payload = cfg.request_gui_close(
session=session_id, requested_by=data.sub, grace_seconds=grace_seconds
)
if payload is None:
raise HTTPException(
status_code=api_status.HTTP_404_NOT_FOUND, detail="GUI session not found."
)
return {"ok": True, "session": session_id, "grace_seconds": grace_seconds}
@app.delete("/admin/gui_sessions/{session_id}")
async def force_remove_gui_session(session_id: int, token: str = Depends(oauth2_scheme)) -> dict:
"""
Staff-only hard removal of a GUI session from Redis.
This is intended for stuck/crashed GUIs and clears:
- GUI presence entry
- active session/baton if the same session holds it
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
cfg.end_active_session(session_id)
cfg.remove_gui_session(session_id)
return {"ok": True, "session": session_id, "message": "GUI session force removed."}
@app.post("/admin/gui_sessions/{session_id}/interaction")
async def update_gui_interaction(session_id: int, token: str = Depends(oauth2_scheme)) -> dict:
"""
GUI-side activity heartbeat.
This is distinct from /status so idle timeout ignores status polling.
"""
data = auth.parse_token(token)
if data.session != session_id:
raise HTTPException(
status_code=api_status.HTTP_403_FORBIDDEN,
detail="Cannot update interaction for another session.",
)
payload = cfg.update_gui_interaction(session=session_id, last_interaction_ts=time.time())
if payload is None:
raise HTTPException(
status_code=api_status.HTTP_404_NOT_FOUND, detail="GUI session not found."
)
return {"ok": True}
@app.post("/access/end_session")
async def end_session(token: str = Depends(oauth2_scheme)) -> str:
"""
End the current session and release the baton.
Args:
token: OAuth2 access token.
Returns:
"OK" on success.
"""
logger.debug("Try to end Session")
# End active session will only delete session, if it is equal to token value
# so no need to check R/W permissions
token_data = auth.parse_token(token)
cfg.end_active_session(token_data.session)
cfg.remove_gui_session(token_data.session)
return "OK"
@app.post("/access/force_current_session")
async def force_current_session(token: str = Depends(oauth2_scheme)) -> str:
"""
Forcefully set the current session as active.
Args:
token: OAuth2 access token.
Returns:
"OK" on success.
"""
logger.debug("Try to grab session")
data = auth.parse_token(token)
# Counterintuitive, this operation requires only R/O permission
# as this is actually acquiring R/W permissions
auth.check_jwt_ro(cfg, data)
auth.force_current_sesion(cfg, data)
return "OK"
# ========== BATON CONTROL ENDPOINTS ==========
@app.get("/baton/status")
async def baton_status(token: str = Depends(oauth2_scheme)) -> BatonStatus:
"""
Get the current baton status for the requesting user.
Args:
token: OAuth2 access token.
Returns:
BatonStatus object.
"""
data = auth.parse_token(token)
auth.resolve_baton_timeout_if_needed(cfg)
return auth.get_baton_status(cfg, data)
@app.post("/baton/request")
async def baton_request(token: str = Depends(oauth2_scheme)) -> dict:
"""
Request control (baton) of the beamline.
- If vacant: granted immediately
- If staff requesting: granted immediately (or queued if busy)
- If same level: creates pending request with timeout
- Non-staff cannot request from staff
Args:
token: OAuth2 access token.
Returns:
Dictionary with request result.
"""
logger.debug(cfg.allow_non_staff_request_from_staff)
data = auth.parse_token(token)
return auth.request_baton(cfg, data)
@app.post("/baton/respond")
async def baton_respond(accept: bool, token: str = Depends(oauth2_scheme)) -> dict:
"""
Current baton holder responds to a pending request.
- accept=true: transfers baton (or queues if busy)
- accept=false: refuses the request
Args:
accept: True to accept, False to refuse.
token: OAuth2 access token.
Returns:
Dictionary with response result.
"""
data = auth.parse_token(token)
return auth.respond_to_baton_request(cfg, data, accept)
@app.post("/baton/release")
async def baton_release(token: str = Depends(oauth2_scheme)) -> dict:
"""
Voluntarily release the baton, making the beamline vacant.
Args:
token: OAuth2 access token.
Returns:
Dictionary with release result.
"""
data = auth.parse_token(token)
return auth.release_baton(cfg, data)
@app.post("/baton/cancel")
async def baton_cancel(token: str = Depends(oauth2_scheme)) -> dict:
"""
Cancel your own pending baton request.
Args:
token: OAuth2 access token.
Returns:
Dictionary with cancellation result.
"""
data = auth.parse_token(token)
return auth.cancel_baton_request(cfg, data)
@app.get("/baton/check_timeout")
async def baton_check_timeout(token: str = Depends(oauth2_scheme)) -> dict:
"""
Check if a pending request has timed out and process it.
Called by GUI to poll for timeout completion.
Args:
token: OAuth2 access token.
Returns:
Dictionary with timeout check result.
"""
data = auth.parse_token(token)
auth.resolve_baton_timeout_if_needed(cfg)
pending = cfg.pending_baton_request
if pending is None:
if cfg.baton_holder and cfg.baton_holder.session == data.session:
return {"granted": True, "message": "Baton acquired!"}
queued = cfg.queued_baton_transfer
if queued and queued.target_session == data.session:
return {"queued": True, "message": "Transfer queued"}
return {"no_pending": True}
if pending.requester_session != data.session:
return {"not_your_request": True}
if pending.status == BatonRequestStatus.REFUSED:
cfg.clear_pending_baton_request()
return {"refused": True, "message": "Request refused"}
elapsed = time.time() - pending.created_at
if elapsed < pending.timeout_seconds:
return {"pending": True, "remaining_seconds": pending.timeout_seconds - elapsed}
return auth.request_baton(cfg, data)
@app.put("/access/allow_non_staff_request_from_staff")
async def set_allow_non_staff_request_from_staff(
val: bool, token: str = Depends(oauth2_scheme)
) -> str:
"""
Set whether non-staff users can request the baton from staff members. Staff only.
Args:
val: True to allow, False to disallow.
token: OAuth2 access token.
Returns:
"OK" on success.
"""
data = auth.parse_token(token)
auth.check_jwt_staff_only(data)
cfg.allow_non_staff_request_from_staff = val
return "OK"
async def baton_status_event_stream(data: TokenData) -> AsyncGenerator[str, None]:
"""
SSE stream for baton status updates.
Args:
data: TokenData for the requesting user.
Yields:
Baton status as SSE data.
"""
last_status = None
try:
while True:
auth.resolve_baton_timeout_if_needed(cfg)
new_baton_status = auth.get_baton_status(cfg, data)
status_json = new_baton_status.model_dump_json()
if status_json != last_status:
last_status = status_json
yield f"data: {status_json}\n\n"
cfg.process_queued_transfer_if_ready(auth.SESSION_EXPIRE_SECONDS)
await asyncio.sleep(0.5)
except asyncio.CancelledError:
return
@app.get("/sse/baton")
async def sse_baton(token: str = Depends(oauth2_scheme)):
"""
SSE endpoint for real-time baton status updates.
Args:
token: OAuth2 access token.
Returns:
StreamingResponse.
"""
data = auth.parse_token(token)
return StreamingResponse(
baton_status_event_stream(data),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Cache-Control",
},
)
@app.get("/beamline/settings")
async def get_settings(token: str = Depends(oauth2_scheme)) -> BeamlineSettingsModel:
"""
Get the current beamline settings. Staff only.
Args:
token: OAuth2 access token.
Returns:
BeamlineSettingsModel.
"""
logger.debug("Get settings")
auth.check_jwt_staff(cfg, auth.parse_token(token))
return cfg.settings
@app.put("/beamline/settings")
async def put_settings(s: BeamlineSettingsModel, token: str = Depends(oauth2_scheme)):
"""
Update the beamline settings. Staff only.
Args:
s: BeamlineSettingsModel object.
token: OAuth2 access token.
"""
logger.debug(f"Putting settings: {s}")
auth.check_jwt_staff(cfg, auth.parse_token(token))
cfg.settings = s
@app.get("/beamline/cryo_settings")
async def get_cryo_settings(token: str = Depends(oauth2_scheme)) -> CryojetSettingsModel:
"""
Get the current cryojet settings. Staff only.
Args:
token: OAuth2 access token.
Returns:
CryojetSettingsModel.
"""
logger.debug("Get Cryo Settings")
auth.check_jwt_staff(cfg, auth.parse_token(token))
return cfg.cryojet_settings
@app.put("/beamline/cryo_settings")
async def put_cryo_settings(s: CryojetSettingsModel, token: str = Depends(oauth2_scheme)):
"""
Update the cryojet settings. Staff only.
Args:
s: CryojetSettingsModel object.
token: OAuth2 access token.
"""
logger.debug(f"Put Cryo Settings: {s}")
auth.check_jwt_staff(cfg, auth.parse_token(token))
cfg.cryojet_settings = s
@app.put("/access/all_pgroups")
async def get_all_pgroups(token: str = Depends(oauth2_scheme)):
"""
Get a list of all available pgroups. Staff only.
Args:
token: OAuth2 access token.
Returns:
List of pgroup strings.
"""
logger.debug("Get all pgroups")
auth.check_jwt_staff(cfg, auth.parse_token(token))
base_path = "/sls/mx/data/"
now = time.monotonic()
cached = _all_pgroups_cache.get(base_path)
if cached is not None:
items, ts = cached
if (now - ts) < _ALL_PGROUPS_TTL_S:
return items
# Refresh: fast scan using scandir, no regex
try:
names: list[str] = []
with os.scandir(base_path) as it:
for entry in it:
name = entry.name
# quick name filter: p + 5 digits, and directory
if (
len(name) == 6
and name[0] == "p"
and name[1:].isdigit()
and entry.is_dir(follow_symlinks=False)
):
names.append(name)
names.sort(key=lambda d: int(d[1:]))
_all_pgroups_cache[base_path] = (names, now)
return names
except Exception:
logger.exception("Failed to list pgroups")
return []
@app.post("/fluorimeter/spectrum")
async def fluorimeter_spectrum(
input: FluorescenceSpectrumParameterModel, token: str = Depends(oauth2_scheme)
) -> FluorescenceSpectrumOutputModel:
"""
Request a fluorescence spectrum measurement.
Args:
input: FluorescenceSpectrumParameterModel parameters.
token: OAuth2 access token.
Returns:
FluorescenceSpectrumOutputModel.
"""
auth.check_jwt_rw(cfg, auth.parse_token(token))
return daq.fluorimeter_take_spectrum(input)
@app.post("/fluorimeter/start")
async def fluorimeter_start(erase: bool = False, token: str = Depends(oauth2_scheme)) -> str:
"""
Start the fluorimeter measurement.
Args:
erase: Whether to erase previous data.
token: OAuth2 access token.
Returns:
"OK" on success.
"""
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.fluorimeter_start(erase)
return "OK"
@app.post("/fluorimeter/stop")
async def fluorimeter_stop(token: str = Depends(oauth2_scheme)) -> str:
"""
Stop the fluorimeter measurement.
Args:
token: OAuth2 access token.
Returns:
"OK" on success.
"""
auth.check_jwt_rw(cfg, auth.parse_token(token))
daq.fluorimeter_stop()
return "OK"
@app.get("/fluorimeter/status")
async def fluorimeter_status(token: str = Depends(oauth2_scheme)) -> int | None:
"""
Get the current fluorimeter status.
Args:
token: OAuth2 access token.
Returns:
Fluorimeter status code.
"""
auth.check_jwt_ro(cfg, auth.parse_token(token))
return daq.fluorimeter_status()
@app.get("/fluorimeter/data")
async def fluorimeter_data(token: str = Depends(oauth2_scheme)) -> list[int] | None:
"""
Get the latest fluorimeter data.
Args:
token: OAuth2 access token.
Returns:
List of data points.
"""
auth.check_jwt_ro(cfg, auth.parse_token(token))
return daq.fluorimeter_data()
@app.get("/fluorimeter/background")
async def fluorimeter_background(token: str = Depends(oauth2_scheme)) -> list[int] | None:
"""
Get the fluorimeter background data.
Args:
token: OAuth2 access token.
Returns:
List of background data points.
"""
auth.check_jwt_ro(cfg, auth.parse_token(token))
return daq.fluorimeter_background()
# Optional: SSE stream for live spectra while acquiring
async def fluorimeter_stream() -> AsyncGenerator[str, None]:
"""
Generator for SSE of fluorimeter status and data.
Yields:
JSON string of fluorimeter status/data as SSE.
"""
logger.debug("start fluorimeter stream...")
try:
while True:
s = daq.fluorimeter_status()
d = daq.fluorimeter_data()
b = daq.fluorimeter_background()
# Ensure JSON-serializable lists; avoid numpy truthiness
status = "stopped" if s == 0 else "running"
logger.debug(
f"fluorimeter status: {status}. got data from fluorimeter: {d[0]} and background: {b[0]}"
)
data = (
d.tolist()
if hasattr(d, "tolist")
else (list(d) if isinstance(d, (tuple, list)) else ([] if d is None else [d]))
)
bkg = (
b.tolist()
if hasattr(b, "tolist")
else (list(b) if isinstance(b, (tuple, list)) else ([] if b is None else [b]))
)
logger.debug(f"fluorimeter data: {data[0]}. fluorimeter background: {bkg[0]}")
payload = json.dumps(
{"status": s, "data": data, "background": bkg}, separators=(",", ":")
)
yield f"data: {payload}\n\n"
if s == 0:
await asyncio.sleep(0.2)
logger.debug("fluorimeter stream stopped")
break
await asyncio.sleep(0.2)
except asyncio.CancelledError as e:
logger.error(f">>> Fluorimeter stream cancelled: {e}")
return
@app.get("/sse/fluorimeter")
async def sse_fluorimeter(token: str = Depends(oauth2_scheme)):
"""
SSE endpoint for fluorimeter updates.
Args:
token: OAuth2 access token.
Returns:
StreamingResponse.
"""
auth.check_jwt_ro(cfg, auth.parse_token(token))
try:
daq.fluorimeter_start(erase=False)
except Exception:
# Ignore if already running or start not needed
logger.debug("Fluorimeter already running or start not needed", exc_info=True)
return StreamingResponse(
fluorimeter_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Cache-Control",
},
)
@app.post("/samcam/send_screenshot_db")
async def send_screenshot_db(
filename: str | None = None, message: str | None = None, token: str = Depends(oauth2_scheme)
) -> str:
"""
Capture a screenshot and send it to the database.
Args:
filename: Optional filename.
message: Optional message.
token: OAuth2 access token.
Returns:
"OK" on success.
"""
data = auth.parse_token(token)
auth.check_jwt_rw(cfg, data)
daq.send_screenshot_db(filename=filename, message=message)
return "OK"
async def send_message_db(
db_id: int,
event_type: SampleEventType,
comment: str | None = None,
token: str = Depends(oauth2_scheme),
):
data = auth.parse_token(token)
auth.check_jwt_rw(cfg, data)
daq.send_screenshot_db(db_id=db_id, event_type=event_type, comment=comment)
return "OK"
@app.post("/state/maintenance")
async def maintenance(token: str = Depends(oauth2_scheme)) -> str:
"""
Transition beamline state to Maintenance. Staff only.
Args:
token: OAuth2 access token.
Returns:
"OK" on success.
"""
auth.check_jwt_staff(cfg, auth.parse_token(token))
cfg.state = BeamlineStateEnum.Maintenance
logger.warning("Beamline state set to Maintenance via protected endpoint.")
raise MaintenanceStateException("Beamline was set to Maintenance. Automation must stop.")
def main():
uvicorn.run(
"aare.daq.server:app",
host="127.0.0.1",
port=5210,
workers=4,
proxy_headers=False,
log_config=get_uvicorn_logging_config(),
timeout_worker_healthcheck=30,
)
if __name__ == "__main__":
main()