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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
.python-version

# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
Expand Down Expand Up @@ -163,3 +163,6 @@ cython_debug/

# VS Code
.vscode/

# MADSci runtime state (logs, ID registry) — per-machine, not shared
.madsci/
821 changes: 249 additions & 572 deletions pdm.lock

Large diffs are not rendered by default.

11 changes: 5 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,13 @@ authors = [
]

dependencies = [
"madsci.node_module~=0.6.0",
"madsci.client~=0.6.0",
"madsci.common~=0.6.0",
"pywin32",
"comtypes",
"madsci.node_module~=0.8.0",
"madsci.client~=0.8.0",
"madsci.common~=0.8.0",
"requests>=2.32",
]

requires-python = ">=3.9.1"
requires-python = ">=3.10"
readme = "README.md"
license = {text = "MIT"}

Expand Down
32 changes: 32 additions & 0 deletions sidecar/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# bmg_sidecar

32-bit FastAPI HTTP service that owns the `BMG_ActiveX.BMGRemoteControl` COM connection on Windows. The MADSci `bmg_module` node (which runs in 64-bit Python alongside `madsci.common`, whose `psycopg2-binary` transitive dep has no win32 wheel) talks to this sidecar over loopback.

The architecture mirrors `inheco_incubator_module/src/inheco_interface_FastAPI_wrapper.py`: a single hardware singleton protected by `threading.Lock`, one endpoint per BMG operation, no MADSci dependency.

## Run

```powershell
cd C:\Users\RPL\source\repos\bmg_module\sidecar
.venv\Scripts\python.exe bmg_sidecar_server.py --host 127.0.0.1 --port 7002 --control-name CLARIOstar
```

Add `--extended-temp-range` for BMG models that support the 10-60 deg C range.

## Endpoints

| Method | Path | Body | Returns |
|---|---|---|---|
| GET | `/` | — | `{"status":"ok","control":...}` |
| GET | `/is_busy` | — | `{"busy": bool}` |
| GET | `/status` | — | `{"status": str}` |
| GET | `/error` | — | `{"error": str}` |
| GET | `/temps` | — | `{"Temp1": float, "Temp2": float, "Temp3": float}` |
| POST | `/plate_out` | — | `{"ok": true}` |
| POST | `/plate_in` | — | `{"ok": true}` |
| POST | `/set_temp` | `SetTempRequest` | `{"ok": true}` |
| POST | `/run_assay` | `RunAssayRequest` | `{"data_file_path": str}` |

## Requires 32-bit Python

The `BMG_ActiveX.BMGRemoteControl` COM server is a 32-bit in-proc server. Create this venv with a 32-bit Python 3.12 interpreter; `pdm install` will then resolve `comtypes`/`pywin32` against win32.
157 changes: 157 additions & 0 deletions sidecar/bmg_interface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"""Python driver for the BMG microplate reader (our model is BMG VANTAstar)."""

import ctypes
import logging
import time
from pathlib import Path
from typing import Any, Optional

import comtypes.client
import pythoncom

logger = logging.getLogger(__name__)


class BmgCom:
"""Communicate with BMG microplate readers via the ActiveX COM interface."""

def __init__(
self,
control_name: str,
extended_temperature_range_model: bool = False,
) -> None:
"""Initialize and open the connection to the BMG plate reader."""
self.control_name = control_name
self.extended_temperature_range_model = extended_temperature_range_model

pythoncom.CoInitialize()
self.com = comtypes.client.CreateObject("BMG_ActiveX.BMGRemoteControl")
self.open_connection()

def open_connection(self) -> None:
"""Open a connection to the BMG plate reader."""
ep = ctypes.c_char_p(self.control_name.encode("ascii"))
res = self.com.OpenConnection(ep)
if res:
raise Exception(f"OpenConnection failed: {res}")

def close_connection(self) -> None:
"""Close the connection to the BMG plate reader."""
res = self.com.CloseConnection()
if res:
raise Exception(f"CloseConnection failed: {res}")

def get_version(self) -> str:
"""Return the BMG instrument version."""
return self.com.GetVersion()

def get_status(self) -> str:
"""Return the current status of the BMG plate reader."""
item = ctypes.c_char_p(b"Status")
status = self.com.GetInfo(item)
return status.strip() if isinstance(status, str) else "unknown"

def get_error(self) -> str:
"""Return any errors on the BMG plate reader."""
item = ctypes.c_char_p(b"Error")
status = self.com.GetInfo(item)
return status.strip() if isinstance(status, str) else "unknown"

def init(self) -> None:
"""Initialize the BMG plate reader."""
self._exec("Init")

def plate_in(self) -> None:
"""Close the plate tray on the BMG plate reader."""
self._exec("PlateIn")

def plate_out(self) -> None:
"""Open the plate tray on the BMG plate reader."""
self._exec("PlateOut")

def set_temp(self, temp: float) -> None:
"""Set the temperature on the BMG plate reader.

Valid temp values: 0.0 (off), 0.1 (monitor only), 25.0-45.0 standard,
10.0-60.0 for extended-temperature-range models.
"""
min_temp, max_temp = (
(10.0, 60.0) if self.extended_temperature_range_model else (25.0, 45.0)
)
if not min_temp <= temp <= max_temp and temp not in [0.0, 0.1]:
raise ValueError(
f"Temp must be a float between {min_temp} and {max_temp}, or 0.0/0.1"
)

nominal_temp = str(temp)
response = self._exec("Temp", nominal_temp)
if response != 0:
logger.error("Failed to set temperature. response = %s", response)
raise Exception(f"Failed to set temperature. response = {response}")

def read_temps(self) -> dict:
"""Read the three internal temperature sensors.

Returns a dict with float values in Celsius, or None per-sensor when
the underlying GetInfo call returns no value (typical when the device
has not been initialized in SMART Control or the sensor is not yet
reporting).
"""

def _parse(name: bytes) -> Optional[float]:
raw = self.com.GetInfo(ctypes.c_char_p(name))
try:
return float(raw) / 10
except (TypeError, ValueError):
return None

return {
"Temp1": _parse(b"Temp1"),
"Temp2": _parse(b"Temp2"),
"Temp3": _parse(b"Temp3"),
}

def run_assay(
self,
assay_name: str,
protocol_database_path: str,
data_output_directory_path: str,
data_output_file_name: Optional[str] = None,
plate_id1: int = 1,
plate_id2: int = 2,
plate_id3: int = 3,
) -> str:
"""Run an assay on the BMG plate reader. Returns the output data file path."""
if not data_output_file_name:
data_output_file_name = str(int(time.time())) + ".txt"

data_file_path = Path(data_output_directory_path) / data_output_file_name

response = self._exec(
"Run",
assay_name,
str(protocol_database_path),
str(data_output_directory_path),
plate_id1,
plate_id2,
plate_id3,
str(data_output_directory_path),
data_output_file_name,
)
if response != 0:
logger.error("Failed to run assay. response = %s", response)
raise Exception(f"Failed to run assay. response = {response}")

return str(data_file_path)

def _exec(self, cmd: str, *args: Any) -> int:
"""Execute a command over the established BMG connection."""
args = (cmd, *args)
response = self.com.ExecuteAndWait(args)
logger.info("exec response: %s", response)
return response


if __name__ == "__main__":
com = BmgCom("CLARIOstar")
print(f"BMG LABTECH Remote Control Version: {com.get_version()}") # noqa: T201
171 changes: 171 additions & 0 deletions sidecar/bmg_sidecar_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
"""FastAPI sidecar exposing the BMG ActiveX COM connection over HTTP.

This service is intended to run in 32-bit Python because the
BMG_ActiveX.BMGRemoteControl COM server is a 32-bit in-proc server. The MADSci
bmg_module REST node runs in 64-bit Python and talks to this sidecar over
loopback. A single threading.Lock serializes COM access; uvicorn is run with
workers=1 for the same reason.
"""

import argparse
import logging
import threading
from contextlib import asynccontextmanager
from typing import AsyncIterator

import uvicorn
from fastapi import FastAPI, HTTPException

from bmg_interface import BmgCom
from pydantic_models import RunAssayRequest, SetTempRequest

logger = logging.getLogger("bmg_sidecar")


class _State:
"""Holds the single BmgCom instance and the lock that serializes COM calls."""

bmg: BmgCom = None
lock: threading.Lock = threading.Lock()
control_name: str = "CLARIOstar"
extended_temp_range: bool = False


state = _State()


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Open the COM connection at startup and close it at shutdown."""
logger.info(
"Initializing BmgCom: control=%s extended_temp_range=%s",
state.control_name,
state.extended_temp_range,
)
state.bmg = BmgCom(
control_name=state.control_name,
extended_temperature_range_model=state.extended_temp_range,
)
logger.info("BmgCom ready.")
try:
yield
finally:
if state.bmg is not None:
try:
state.bmg.close_connection()
logger.info("BmgCom connection closed.")
except Exception as e:
logger.warning("Error closing BmgCom: %s", e)
state.bmg = None


app = FastAPI(
title="bmg_sidecar",
description="32-bit BMG ActiveX HTTP sidecar",
lifespan=lifespan,
)


def _require_bmg() -> BmgCom:
if state.bmg is None:
raise HTTPException(status_code=503, detail="BMG interface not initialized")
return state.bmg


@app.get("/")
def root() -> dict:
"""Liveness probe."""
return {"status": "ok", "control": state.control_name}


@app.get("/is_busy")
def is_busy() -> dict:
"""Return whether the COM lock is currently held by another request."""
return {"busy": state.lock.locked()}


@app.get("/status")
def status() -> dict:
bmg = _require_bmg()
with state.lock:
return {"status": bmg.get_status()}


@app.get("/error")
def error() -> dict:
bmg = _require_bmg()
with state.lock:
return {"error": bmg.get_error()}


@app.get("/temps")
def temps() -> dict:
bmg = _require_bmg()
with state.lock:
return bmg.read_temps()


@app.post("/plate_out")
def plate_out() -> dict:
bmg = _require_bmg()
with state.lock:
bmg.plate_out()
return {"ok": True}


@app.post("/plate_in")
def plate_in() -> dict:
bmg = _require_bmg()
with state.lock:
bmg.plate_in()
return {"ok": True}


@app.post("/set_temp")
def set_temp(req: SetTempRequest) -> dict:
bmg = _require_bmg()
with state.lock:
try:
bmg.set_temp(req.temp)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"ok": True}


@app.post("/run_assay")
def run_assay(req: RunAssayRequest) -> dict:
bmg = _require_bmg()
with state.lock:
path = bmg.run_assay(
assay_name=req.assay_name,
protocol_database_path=req.protocol_database_path,
data_output_directory_path=req.data_output_directory_path,
data_output_file_name=req.data_output_file_name,
plate_id1=req.plate_id1,
plate_id2=req.plate_id2,
plate_id3=req.plate_id3,
)
return {"data_file_path": str(path)}


if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=7002)
parser.add_argument("--control-name", default="CLARIOstar")
parser.add_argument(
"--extended-temp-range",
action="store_true",
help="Enable the 10-60 deg C range supported by extended-range BMG models.",
)
args = parser.parse_args()

logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(name)s %(levelname)s %(message)s",
)

state.control_name = args.control_name
state.extended_temp_range = args.extended_temp_range

uvicorn.run(app, host=args.host, port=args.port, workers=1)
Loading
Loading