A SiLA 2 connector for the OpenFlexure Microscope, built with the UniteLabs CDK in the same style as the Acceleration Consortium's opentrons-ot2 connector.
It wraps the HTTP API of the OpenFlexure Microscope Server v2 (http://<microscope>:5000/api/v2, server 2.x on the
Raspbian-OpenFlexure image) as SiLA 2 features: stage movement, image acquisition, stored captures, camera settings,
autofocus and device information. The connector starts in simulation mode by default and can switch to the real
microscope at runtime.
Version 0.1.0, not yet released.
- Verified on a real microscope (server 2.11.0, Raspberry Pi camera v2, 2026-10-21): identity and status, camera
settings, preview and full-resolution images, stored captures (create, list, download, tag, delete), sharpness
measurement, simulation/real mode switching, and the defined errors for unavailable hardware. It was driven over SiLA
and from UniteLabs Ground Control. Capture annotations,
ExportCaptureand the travel-limit refusals were checked over SiLA on the same microscope with the current code. Its backlash setting is 0 and the stage was detached, so the backlash-overshoot check, the one-at-a-time rule and Stop during a move are so far tested in simulation only. - Not yet verified on hardware: stage motion,
StageController.Stop, autofocus (RunFastAutofocus,RunAutofocus,AutofocusController.Stop) and the server's busy/error responses. The test microscope's motors were detached, so the connector correctly refused these commands. - Not yet tried: the Docker image and registration with the UniteLabs cloud platform.
| SiLA feature | Commands | Properties | OpenFlexure v2 endpoints |
|---|---|---|---|
StageController |
MoveTo, MoveBy, SetZeroPosition, Stop |
Position, LivePosition†, StageType, Backlash, TravelLimits |
actions/stage/move, actions/stage/zero, instrument/state/stage/position |
ImageAcquisitionController |
GrabPreviewFrame, AcquireImage, CaptureToStorage |
MJPEGStreamURL |
streams/snapshot, actions/camera/ram-capture, actions/camera/capture |
CaptureStorageService |
DownloadCapture, DownloadThumbnail, ReadCaptureMetadata, SetCaptureTags, DeleteCapture, ExportCapture |
Captures, ExportFolder |
captures, captures/{id}, captures/{id}/download/{name}, captures/{id}/tags |
CameraController |
SetExposureTime, SetAnalogGain, SetWhiteBalanceGains, SetDigitalGain‡, SetExposureMode‡, SetWhiteBalanceMode‡, SetJPEGQuality‡ |
ExposureTime, AnalogGain, WhiteBalanceGains, StreamResolution, ImageResolution, DigitalGain‡, ExposureMode‡, WhiteBalanceMode‡, JPEGQuality‡ |
instrument/settings/camera/... |
AutofocusController |
RunFastAutofocus, RunAutofocus, MeasureSharpness, Stop |
— | extensions/org.openflexure.autofocus/{fast_autofocus,autofocus,measure_sharpness} |
MicroscopeProvider |
— | MicroscopeName, MicroscopeId, ServerVersion, CameraType, InstalledExtensions, APIBaseURL, Reachable† |
instrument/configuration, instrument/settings, extensions |
SimulationController (SiLA core) |
StartSimulationMode, StartRealMode |
SimulationMode |
— |
LockController (SiLA core) |
LockServer, UnlockServer |
IsLocked |
— (see note §) |
* observable command (progress + intermediate responses, execution mode SINGLE) † observable property
‡ only offered when the microscope's camera supports it (Raspberry Pi camera)
§ the lock is not enforced: no command requires the lock identifier yet, so locking does not block other clients
Conventions on the SiLA interface:
- Stage positions and distances are motor steps (integers), exactly as the OpenFlexure server reports them.
- Images are SiLA
Binaryvalues with content typeimage/jpeg. Images larger than 2 MiB (full-resolution captures) travel through SiLA binary transfer automatically. - Enumerations travel as their member names in lower case with spaces, e.g.
ExposureModeacceptsoff,auto,night preview, …;Resolutionacceptsprevieworfull;StageTypereportssanga stage,sanga delta stageormissing stage. - The SiLA 2 base protocol has no cancel call, and this connector does not implement the optional
CancelControllerfeature, so a client cannot abort the command it started. UseStageController.StoporAutofocusController.Stopinstead: they cancel the running action on the microscope, and also stop a move or autofocus that has not reached the microscope yet (it is still reading the stage, or waiting for a previous one). The stopped command finishes with aMoveFailedError/AutofocusFailedErrorthat says so, andStopreturns once it has ended (at most about 2 s), so the same command can be sent again straight away.StoppedOperationscounts only what the microscope actually cancelled: an action whose cancellation fails (no answer, or an HTTP error) keeps running, stays tracked, and the nextStoptries again. - Every failure is a Defined Execution Error with an actionable message (
MicroscopeUnreachableError,StageUnavailableError,StageBusyError,MoveFailedError,CameraUnavailableError,CaptureFailedError,CaptureNotFoundError,CameraSettingUnsupportedError,AutofocusUnavailableError,AutofocusFailedError,TravelLimitExceededError,ZeroPositionBlockedError,ExportFailedError).
- Travel limits. The OpenFlexure stage has no limit switches. Set
stage_{x,y,z}_minandstage_{x,y,z}_maxinconfig.json(motor steps, both bounds of an axis together) and the connector refuses anyMoveTo,MoveBy,RunFastAutofocusorRunAutofocusthat would leave the range, withTravelLimitExceededError, before any move is sent to the microscope (it reads the stage position and backlash first).StageController.TravelLimitslists the limited axes (empty when none are set).- Only the axes a move changes are checked, so an axis that is already outside its range does not block moves of
the others; move it back inside with a
MoveToorMoveBythat ends inside the range. - The stage's backlash correction counts too: moving against the sign of the
Backlashsetting first overshoots the target by that many steps, and that overshoot must stay inside the limits. The slowRunAutofocusalways corrects 256 steps of backlash (the server forces it), so its sweep is checked 256 steps further down. A fast autofocus is checked as a sweep ofZRangesteps centred on the current z (it corrects no backlash). The overshoot check follows the standard Sanga stage. A delta stage corrects backlash per motor, so its overshoot cannot be checked: while limits are set on a delta stage, moves are refused unless itsBacklashis 0, and the slowRunAutofocusis refused (useRunFastAutofocus). - Moves are sent to the microscope as absolute targets for the axes that change, so the end point stays inside
the limits even if the stage was moved elsewhere after the connector read its position, once that other move has
finished. While limits are set, the connector runs its own moves and autofocus runs one at a time: a second one
waits up to 1 s for the first to finish (enough for one that was just stopped), then fails with
StageBusyError. - The limits are positions in the stage's current coordinates, so
SetZeroPositionis refused withZeroPositionBlockedErrorwhile any limit is configured. Zeroing from the microscope's web interface or power cycling the Sangaboard also resets the coordinates and shifts the protected range with them. - The limits only cover moves made through this connector. Moves from the microscope's web interface or other programs are not checked, and while one of those is running at the same time, neither the end point nor the backlash overshoot of a move from the connector is guaranteed.
- Only the axes a move changes are checked, so an axis that is already outside its range does not block moves of
the others; move it back inside with a
- Annotations.
CaptureToStoragetakes anAnnotationslist ofKey/Valuepairs (may be empty; keys must be non-empty and unique). They are stored with the capture on the microscope and reported inCaptures. - Exporting.
CaptureStorageService.ExportCapturesaves a stored capture as a JPEG file inCaptureStorageService.ExportFolderon the computer running the connector, and returns the saved path. The file keeps the capture's name, made safe for Windows, macOS and Linux: invalid characters are replaced with_, leading and trailing dots and spaces are removed (no hidden files),.jpegis added unless the name ends in.jpegor.jpg, long names are shortened and reserved device names get a_prefix. An existing file or folder is never overwritten (name (1).jpeg,name (2).jpeg, … instead).DownloadCapturestill returns the image bytes to the client.
- Python 3.10 or newer (the repository pins 3.13 in
.python-version) and uv. - For real mode: an OpenFlexure Microscope running server v2.x reachable on the network (default
microscope.local:5000; the IP address works as well if.localnames do not resolve on your machine).
uv sync --all-extras
uv run config create --app unitelabs.openflexure_microscope:create_app # writes ./config.json (git-ignored)
uv run connector start --app unitelabs.openflexure_microscope:create_appThe log shows Server bound to '0.0.0.0:<port>' and Registering service 'OpenFlexure Microscope'. Use the
SiLA Browser to discover and try the server, or run the smoke client:
uv run python scripts/smoke_client.py --port <port> # read identity, position, grab a preview frame
uv run python scripts/smoke_client.py --port <port> --move-z 20 # also move the stage up 20 steps and backWindows note.
--appis required on Windows (the CDK cannot auto-detect the factory). If your machine blocks the.venv\Scripts\*.exelaunchers (Windows Application Control), call the CDK commands through Python instead:uv run python -c "from unitelabs.cdk.cli import config; config()" create --app unitelabs.openflexure_microscope:create_app uv run python -c "from unitelabs.cdk.cli import connector; connector()" start --app unitelabs.openflexure_microscope:create_app uv run python -m pytest
Edit the config.json that config create wrote: it holds this installation's own sila_server.uuid, its
identity in Ground Control. config/openflexure_config.example.json shows
every key but has no uuid. If you copy it instead, add a "uuid" under sila_server (for example from
python -c "import uuid; print(uuid.uuid4())"), or the connector gets a new identity on every start. The keys to
change:
{
"simulation_mode": false,
"host": "microscope.local",
"port": 5000
}Then start the connector again. In real mode it probes GET /api/v2/ and GET /api/v2/instrument/configuration at
startup and refuses to start (no silent fallback to simulation) if the microscope does not answer or does not
serve the v2 API. You can also keep simulation_mode: true and switch at runtime with
SimulationController.StartRealMode; if the microscope is unreachable the command fails with StartRealModeFailed
and the connector stays simulated. Switching in either direction is refused while a move, capture or autofocus
is still running (stop it first).
| Key | Default | Meaning |
|---|---|---|
simulation_mode |
true |
Serve the in-memory simulated microscope instead of talking to hardware. |
api_version |
"v2" |
OpenFlexure server API generation. Only v2 is implemented. |
host / port |
"microscope.local" / 5000 |
Address of the OpenFlexure Microscope server. |
request_timeout_s |
10.0 |
Timeout of a single HTTP request. |
action_poll_interval_s |
0.25 |
Poll interval for long-running server actions (move, capture, autofocus). |
position_poll_interval_s |
0.5 |
Poll interval behind the LivePosition subscription. |
stage_x_min / stage_x_max, stage_y_min / stage_y_max, stage_z_min / stage_z_max |
null |
Software travel limits in motor steps; set both bounds of an axis or neither. null means the axis is not limited. |
export_folder |
~/Downloads/OpenFlexure |
Folder where ExportCapture saves images, on the computer running the connector; created when needed. ~ is the home folder of the user running the connector, and a relative path is relative to the folder the connector was started from. Must not be empty. |
discovery.network_interfaces |
null |
IPv4 addresses the connector announces itself on for discovery in Ground Control. Left null, the connector picks them at each start (see below). A list is used as given; [] lets the operating system pick one interface. |
sila_server, cloud_servers, discovery, logging |
CDK defaults | Standard UniteLabs settings (local port, UniteLabs cloud endpoints, mDNS, logging). |
Set sila_server.port to a fixed value (e.g. 50052) for a stable local endpoint. To register the connector with the
UniteLabs platform, add your tenant's cloud endpoint under cloud_servers.endpoints (not yet tried with this connector).
Each installation generates its own sila_server.uuid; keep it stable once the connector is registered.
Discovery on the lab network. Ground Control finds connectors through mDNS announcements. Left to itself, the
SiLA library sends them through one interface the operating system picks, and Windows often picks a virtual adapter
(Tailscale or another VPN, WSL, Docker Desktop): the connector then shows up only in Ground Control on the same
computer. So when discovery.network_interfaces is null, the connector announces on the default route's address
and the other IPv4 addresses the operating system reports for the computer's name (loopback and 169.254.x.x
addresses are skipped), and logs them at startup (Announcing the connector for discovery on ...).
- On Windows the list can include virtual adapters such as WSL's. Announcing there is harmless, and an error logged
for one address does not mean the others failed. On Linux usually only the default route's address is used, so a
computer on two lab networks should list its addresses in
discovery.network_interfaces. - The addresses are looked up at each start and never written to
config.json, but they are fixed while the connector runs. After the computer gets a new address (a new DHCP lease, another network), discovery goes quiet until the connector restarts. With no network connection at start, the connector logs a warning and the operating system keeps choosing; restart it once the network is up. For an always-on installation, reserve the address on the router or setdiscovery.network_interfaces. - Ground Control on Windows can have the same problem with its own discovery queries, so a connector that started before Ground Control may only appear after the connector restarts.
uv run python -m pytest # unit tests, simulated backend, gRPC round trips (hardware tests skipped)
uv run python -m pytest tests/integration --microscope-host microscope.local # against a real microscope
uv run ruff check src tests scripts && uv run ruff format --check src tests scriptsThe hardware tests read identity, position and settings, grab a preview frame and in-memory captures (preview and full resolution), store one test capture and delete it again, and measure sharpness. With a stage attached they also move z by 20 steps and back. They are the final verification step for a new microscope; everything else runs without hardware.
Add --allow-autofocus to also run the autofocus Stop test. It starts a fast autofocus that sweeps z by up to 2000
steps, stops it after a second and moves z back to where it started. Make sure the objective and sample have room for
that before using the flag.
Not yet tested: the image has not been built and run against a microscope.
uv run config create --app unitelabs.openflexure_microscope:create_app # config.json is mounted into the container
docker compose up --builddocker-compose.yml uses host networking so mDNS discovery and microscope.local work; that requires a Linux Docker
host. On Docker Desktop (Windows/macOS) publish the SiLA port instead and set host to the microscope's IP address.
ExportCapture saves into the container's ~/Downloads/OpenFlexure (/root/Downloads/OpenFlexure) by default.
docker-compose.yml mounts the exports folder next to it there, so exported images appear in ./exports on the host
and survive rebuilding the container. If you set export_folder in the container's config.json, mount that folder
instead; ExportFolder and SavedPath report paths inside the container.
- Server v2 only. The v3 beta server (labthings-fastapi,
/api/v3) has a different API. The io layer is built around a version-neutralMicroscopeApicontract (io/api.py), so a v3 implementation can be added asio/v3/without touching the SiLA features;api_versionselects the implementation. - No illumination control. The v2 HTTP API has no LED endpoint; it only exists on v3 (planned follow-up via
Capability.ILLUMINATION). - Autofocus needs the
org.openflexure.autofocusextension on the microscope (installed by default). config showcrashes in UniteLabs CDK 0.16.0 on the basesila_server.versionfield (a CDK bug); useconfig create/config schemaor the example config instead.
uv sync --all-extras
uv run pre-commit install
uv run ruff format src tests && uv run ruff check src tests --fix
uv run python -m pytestLayout (see AGENTS.md for the design rules):
src/unitelabs/openflexure_microscope/
__init__.py create_app(): builds the API client, registers the features
config.py OpenflexureMicroscopeConfig
io/api.py MicroscopeApi contract + Capability + build_api()
io/types.py, errors.py, units.py
io/v2/backend.py HttpBackend (httpx) and the Backend protocol
io/v2/simulation.py SimulatedBackend: in-memory OpenFlexure v2 server
io/v2/client.py V2Client: MicroscopeApi over the v2 HTTP API (LabThings actions, polling, cancellation)
features/ one SiLA feature per module, thin wrappers over MicroscopeApi
tests/ unit tests, SiLA definition and gRPC round-trip tests, opt-in hardware tests
The project was generated from the UniteLabs connector-factory
template (.cruft.json) and depends only on PyPI packages (unitelabs-cdk, httpx).
Issues: https://github.com/AccelerationConsortium/openflexure-microscope-connector/issues