Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
46e10a9
feat: add Python bridge device support with MinimalCamera and Minimal…
tlambert03 Apr 3, 2026
b7ff987
fix: improve thread safety and exception handling in PyBridgeCamera, …
tlambert03 Apr 3, 2026
ccd8c7e
fix: update ndarray casting to include read-only and CPU device speci…
tlambert03 Apr 3, 2026
ddbfd90
feat: implement PropertyBridge for registering MM properties in Pytho…
tlambert03 Apr 3, 2026
d6e7ddb
feat: support python modules
tlambert03 Apr 3, 2026
aeb2fe9
feat: add support for new device types including Stage, XYStage, Stat…
tlambert03 Apr 4, 2026
d741969
feat: add documentation for Python bridge devices and their integrati…
tlambert03 Apr 4, 2026
cd95fbe
feat: add benefits section to bridge devices documentation for improv…
tlambert03 Apr 4, 2026
387d034
feat: enhance PropertyBridge to support limits and allowed values for…
tlambert03 Apr 4, 2026
0c3cb78
feat: refactor PropertyBridge to PropertyHandle for dynamic property …
tlambert03 Apr 4, 2026
a216772
feat: enhance device loading and property factory to support atomic c…
tlambert03 Apr 4, 2026
a05ad35
feat: refactor bridge device classes to use common base for shared st…
tlambert03 Apr 4, 2026
4ee0c9e
feat: delete default constructor for PropertyHandle and ensure size_t…
tlambert03 Apr 4, 2026
f76db69
feat: enhance PropertyHandle to manage device lifecycle with alive fl…
tlambert03 Apr 4, 2026
386a9c4
feat: update sequence acquisition to support image insertion with met…
tlambert03 Apr 4, 2026
01dfdb1
feat: implement DeviceCallbacks for device state notifications and up…
tlambert03 Apr 4, 2026
ae9dc17
feat: enhance error handling in Python bridge by catching exceptions …
tlambert03 Apr 4, 2026
0496236
feat: refactor SnapImage and GetImageBuffer methods in PyBridgeCamera…
tlambert03 Apr 4, 2026
d200285
feat: extend PyBridgeCamera and PyCamera protocols with additional me…
tlambert03 Apr 4, 2026
4a84b24
feat: enhance PyStage and PyXYStage protocols with additional methods…
tlambert03 Apr 4, 2026
4d8c6d7
feat: add acquisition finished notification and capturing state handl…
tlambert03 Apr 4, 2026
bb10538
feat: add error handling for unsupported device types in createBridge…
tlambert03 Apr 4, 2026
b945b7b
feat: implement device adapter ownership transfer and cleanup in CMMCore
tlambert03 Apr 4, 2026
33450c0
feat: add set_position_label method to DeviceCallbacks and update Min…
tlambert03 Apr 4, 2026
c164bc4
feat: enhance PyBridgeHub to discover peripherals and update protocol…
tlambert03 Apr 4, 2026
f0dcc7a
feat: update insert_image callable to return success status and modif…
tlambert03 Apr 4, 2026
6b6bd9c
feat: update ndarray type in SetImage methods for PyBridgeSLM to use …
tlambert03 Apr 4, 2026
bc46c8b
feat: enhance SLM image validation and support for multi-component pi…
tlambert03 Apr 4, 2026
8031691
fix packagefiles
tlambert03 Apr 4, 2026
2f642ac
feat: add destructor to PyBridgeCamera for safe resource cleanup
tlambert03 Apr 4, 2026
422d943
feat: add support for property sequencing in PropertyHandle and relat…
tlambert03 Apr 4, 2026
5e62cc3
Merge branch 'main' into unicore
tlambert03 Apr 4, 2026
c99eb1f
Merge branch 'unicore' of https://github.com/tlambert03/pymmcore-nano…
tlambert03 Apr 4, 2026
edcca03
feat: implement exposure and stage sequencing support in bridge devic…
tlambert03 Apr 4, 2026
01f56a5
feat: add PyBridgeSignalIO class and corresponding protocol for Signa…
tlambert03 Apr 4, 2026
1c9f2c0
feat: add PyBridgeGalvo class and corresponding protocol for Galvo de…
tlambert03 Apr 4, 2026
2eb43d3
feat: add PyBridgeMagnifier and PyBridgeSerial classes with correspon…
tlambert03 Apr 4, 2026
005f14b
feat: add device description support in bridge devices and tests
tlambert03 Apr 4, 2026
1b02067
feat: rename initialize method to initialize_bridge in protocols and …
tlambert03 Apr 4, 2026
e197fb6
fix patch
tlambert03 Apr 4, 2026
cd6d2c1
feat: improve device description retrieval in PyBridgeHub class
tlambert03 Apr 4, 2026
b83a229
feat: enhance tests for bridge devices with additional functionality …
tlambert03 Apr 4, 2026
e81f8da
feat: optimize image retrieval in PyBridgeCamera by caching dimension…
tlambert03 Apr 5, 2026
78d77e3
feat: cache SLM dimensions in PyBridgeSLM to reduce Python bridge cro…
tlambert03 Apr 6, 2026
fe6d983
Merge branch 'main' into unicore
tlambert03 May 14, 2026
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
254 changes: 254 additions & 0 deletions docs/bridge-devices.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
# Python Bridge Devices

This document explains how pymmcore-nano enables Python-implemented devices
to be registered as real devices in CMMCore's device registry, so that all
CMMCore features (auto-shutter, config groups, device enumeration, property
system, etc.) work transparently.

## Overview

```
Python device object C++ bridge device CMMCore
(your code) (bridge_devices.h) (upstream)
┌──────────────┐ ┌──────────────────┐ ┌───────────┐
│ MyCamera │◄── GIL ────▶│ PyBridgeCamera │◄──────▶│ │
│ .snap_image │ forwarding │ : CCameraBase<> │ normal │ device │
│ .get_exp.. │ │ │ device │ Manager_ │
│ .set_exp.. │ │ owns nb::object │ calls │ │
└──────────────┘ └──────────────────┘ └───────────┘
```

Each Python device is wrapped in a C++ "bridge" class that inherits from the
real MM device base (`CCameraBase`, `CShutterBase`, etc.). The bridge
acquires the GIL and forwards every MM method call to the Python object.
CMMCore sees the bridge as a normal device — it lives in `deviceManager_`,
has properties in `PropertyCollection`, and participates in auto-shutter,
config groups, and state enumeration.

## How It Works (no upstream patches)

The bridge uses MMCore's existing `MockDeviceAdapter` infrastructure
(originally designed for unit testing). The flow:

1. Python creates a `DeviceAdapter` and registers device classes
2. `core.loadPyDeviceAdapter(name, adapter)` registers it with CMMCore
3. `core.loadDevice(label, adapterName, deviceName)` instantiates the Python
class and wraps it in the appropriate bridge
4. `core.initializeDevice(label)` calls `Initialize()` on the bridge, which
calls `py_device.initialize(bridge)` with a `PropertyBridge` for
registering properties

## Two Loading APIs

### `loadPyDeviceAdapter` — adapter with multiple device classes

```python
from pymmcore_nano import CMMCore, DeviceAdapter, DeviceType

adapter = DeviceAdapter()
adapter.add_device_class("MyCam", MyCameraClass, DeviceType.CameraDevice,
"My custom camera")
adapter.add_device_class("MyShutter", MyShutterClass, DeviceType.ShutterDevice,
"My custom shutter")

core = CMMCore()
core.loadPyDeviceAdapter("MyHardware", adapter)

# Standard CMMCore device discovery and loading:
core.getAvailableDevices("MyHardware") # ["MyCam", "MyShutter"]
core.loadDevice("Cam1", "MyHardware", "MyCam")
core.initializeDevice("Cam1")
```

#### Benefits

- All devices from one adapter share the same `LoadedDeviceAdapter` mutex,
matching the behavior of real C++ adapters (important for devices that share
a communication bus).
- Easier discovery on the application side
- Could be supported by python entry-points to allow third-party packages to
provide device adapter libraries without requiring explicit registration code

### `loadPyDevice` — convenience for a single pre-instantiated device

```python
cam = MyCameraClass()
core.loadPyDevice("Cam1", cam, DeviceType.CameraDevice)
core.initializeDevice("Cam1")
```

This creates a single-device adapter internally. The Python object is shared
(not copied) — the bridge holds a reference to the same instance.

#### Benefits

- Simpler for users who just want to directly instantiate and load a single
python device without needing to create an adapter library to wrap it.

## Device Protocols

The C++ bridge expects Python device objects to implement specific methods.
These are documented as `typing.Protocol` classes in
`pymmcore_nano.protocols`:

- `PyDevice` — base: `initialize(bridge)`, `shutdown()`, `busy()`
- `PyCamera` — camera methods (snap, exposure, ROI, binning, etc.)
- `PyShutter` — `set_open()`, `get_open()`, `fire()`
- `PyStage` — single-axis positioning
- `PyXYStage` — dual-axis positioning (step-based)
- `PyState` — filter wheel / turret (`get_number_of_positions()`)
- `PyAutoFocus` — continuous/incremental focus, offset, scores
- `PyGeneric` — properties only (no device-specific methods)
- `PyHub` — peripheral discovery (`detect_installed_devices()`)
- `PySLM` — spatial light modulator (image display, exposure)

These protocols are `@runtime_checkable`. The bridge does not enforce them
at registration time — missing methods will raise `AttributeError` at call
time, just as a missing method on any Python object would.

## Property System

MM devices have a string-based property system (name/value pairs with
optional types, limits, and allowed values). The bridge integrates with
this through the `PropertyBridge` object.

### How it works

During `initialize(bridge)`, the Python device registers properties:

```python
def initialize(self, bridge):
bridge.create_property(
"Gain", "1.0", PropertyType.Float, read_only=False,
getter=lambda: self._gain,
setter=lambda v: setattr(self, '_gain', float(v)),
limits=(0.0, 100.0)
)
# can also set limits separately:
# bridge.set_property_limits("Gain", 0.0, 100.0)

bridge.create_property(
"Mode", "Normal", PropertyType.String, read_only=False,
getter=lambda: self._mode,
setter=lambda v: setattr(self, '_mode', v),
allowed_values=["Normal", "Fast", "Slow"]
)
# can also set allowed values separately:
# bridge.set_allowed_values("Mode", ["Normal", "Fast", "Slow"])
```

The `PropertyBridge`:

- Wraps `CDeviceBase::CreateProperty()` with an `MM::ActionLambda` that
calls the Python getter/setter
- On `BeforeGet` (CMMCore reads the property): calls `getter()`, converts
to string, stores in the MM property
- On `AfterSet` (CMMCore writes the property): reads the string value from
the MM property, passes to `setter(value_str)`
- `set_property_limits()` and `set_allowed_values()` wrap the corresponding
`CDeviceBase` methods

After `initialize()` returns, the `PropertyBridge` is invalidated. Calling
its methods after that raises `RuntimeError`. This is enforced via a shared
validity flag that survives even if Python stores a reference to the bridge.

### Property lifecycle

```
bridge.create_property("Gain", ...)
→ CDeviceBase::CreateProperty("Gain", "1.0", Float, false, ActionLambda)
→ MM::PropertyCollection stores MM::FloatProperty with the lambda

core.getProperty("dev", "Gain")
→ CDeviceBase::GetProperty → PropertyCollection::Get
→ MM::FloatProperty::Update → ActionLambda(BeforeGet)
→ GIL acquire → getter() → pProp->Set(str(value))
→ returns string value to CMMCore

core.setProperty("dev", "Gain", "42.5")
→ CDeviceBase::SetProperty → PropertyCollection::Set
→ validates limits (0.0-100.0) → MM::FloatProperty::Set("42.5")
→ MM::FloatProperty::Apply → ActionLambda(AfterSet)
→ GIL acquire → pProp->Get(val) → setter("42.5000")
```

CDeviceBase handles validation (limits, allowed values, read-only checks)
before the lambda is ever called.

## Key Files

| File | Purpose |
|------|---------|
| `src/bridge_devices.h` | All C++ bridge device classes + PropertyBridge + PyBridgeAdapter |
| `src/_pymmcore_nano.cc` | nanobind bindings for `DeviceAdapter`, `PropertyBridge`, `loadPyDevice`, `loadPyDeviceAdapter` |
| `src/pymmcore_nano/protocols.py` | Python `Protocol` classes documenting the bridge contract |
| `tests/test_bridge_devices.py` | Tests for all device types, properties, and adapter loading |

## Supported Device Types

| Type | Bridge Class | Base Class | Python Protocol |
|------|-------------|------------|-----------------|
| Camera | `PyBridgeCamera` | `CCameraBase<>` | `PyCamera` |
| Shutter | `PyBridgeShutter` | `CShutterBase<>` | `PyShutter` |
| Stage | `PyBridgeStage` | `CStageBase<>` | `PyStage` |
| XYStage | `PyBridgeXYStage` | `CXYStageBase<>` | `PyXYStage` |
| State | `PyBridgeState` | `CStateDeviceBase<>` | `PyState` |
| AutoFocus | `PyBridgeAutoFocus` | `CAutoFocusBase<>` | `PyAutoFocus` |
| Generic | `PyBridgeGeneric` | `CGenericBase<>` | `PyGeneric` |
| Hub | `PyBridgeHub` | `HubBase<>` | `PyHub` |
| SLM | `PyBridgeSLM` | `CSLMBase<>` | `PySLM` |

## Adding a New Device Type

1. In `bridge_devices.h`:
- Create a new class inheriting from the appropriate `C*Base<>`
- Implement pure virtuals that don't have base defaults
- Use `py_get`, `py_set`, `py_call` helpers for method forwarding
- Use `initializeWithBridge(this, py_)` in `Initialize()`
- Store `deviceName_` for `GetName()`
2. Add the type to `createBridgeDevice()` switch
3. In `protocols.py`: add a `Py*` protocol class
4. In `tests/test_bridge_devices.py`: add a minimal stub and test
5. Rebuild and run tests

## GIL and Threading

- Every bridge method that calls into Python acquires the GIL via
`nb::gil_scoped_acquire`
- `GetImageBuffer()` returns a C++-owned buffer pointer without the GIL
- GIL acquisition is re-entrant (safe for nested calls)
- `std::atomic<bool> capturing_` is used for `IsCapturing()` to avoid
GIL acquisition on a hot path

## Lifecycle and Cleanup

- Bridge devices hold `nb::object` references to Python devices
- Destructors acquire the GIL before releasing references (`py_.reset()`)
- All destructors are wrapped in `try/catch(...)` to prevent
`std::terminate` if GIL acquisition fails during shutdown
- `PyBridgeAdapter` destructors similarly acquire GIL before clearing
the device vector
- Adapter capsules are stored in a module-level `_bridge_adapters` dict
(not a static C++ map) to ensure cleanup happens during Python GC,
not during static destruction after interpreter shutdown
- `PropertyBridge` getter/setter callables are wrapped in a
`shared_ptr<PyCallbacks>` whose destructor acquires the GIL — this
handles the case where `~CDeviceBase` destroys `ActionLambda` captures
without the GIL

## Known Limitations

- **Sequence acquisition**: The bridge's `StartSequenceAcquisition` calls
into Python, but there is no mechanism for Python to push frames into
CMMCore's circular buffer via `InsertImage`. This requires further design.
- **`IsCapturing` flag**: Managed by the C++ bridge, not forwarded to Python.
If the Python device's sequence finishes independently, the flag may be
stale.
- **RGB cameras**: `GetNumberOfComponents()` defaults to 1 (from
`CCameraBase`). Not currently forwarded to Python.
- **Adapter cleanup on CMMCore destruction**: The `MockDeviceAdapter` API
provides no unload notification. Adapter capsules may outlive the CMMCore
that references them (benign — they hold Python objects, not C++ pointers).
- **SLM `set_image` array shapes**: The 8-bit overload passes a flat
`uint8` array of total bytes; the 32-bit overload passes a `uint32`
array of pixel count. Python implementations must handle both.
1 change: 1 addition & 0 deletions meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ custom_target(
py.install_sources(
files(
'src/pymmcore_nano/__init__.py',
'src/pymmcore_nano/protocols.py',
'src/pymmcore_nano/py.typed',
),
subdir: 'pymmcore_nano',
Expand Down
Loading
Loading