Skip to content

Commit

Permalink
feat: add device connection spec (#1)
Browse files Browse the repository at this point in the history
* feat: add device connection spec

* finish

* update readme

* pyyaml

* tada

* format

* fix type
  • Loading branch information
tlambert03 committed Oct 24, 2023
1 parent d564b0a commit e88370c
Show file tree
Hide file tree
Showing 10 changed files with 275 additions and 110 deletions.
56 changes: 54 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,60 @@

[![License](https://img.shields.io/pypi/l/pymmcore-midi.svg?color=green)](https://github.com/pymmcore-plus/pymmcore-midi/raw/main/LICENSE)
[![PyPI](https://img.shields.io/pypi/v/pymmcore-midi.svg?color=green)](https://pypi.org/project/pymmcore-midi)
[![Python Version](https://img.shields.io/pypi/pyversions/pymmcore-midi.svg?color=green)](https://python.org)
[![Python
Version](https://img.shields.io/pypi/pyversions/pymmcore-midi.svg?color=green)](https://python.org)
[![CI](https://github.com/pymmcore-plus/pymmcore-midi/actions/workflows/ci.yml/badge.svg)](https://github.com/pymmcore-plus/pymmcore-midi/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/pymmcore-plus/pymmcore-midi/branch/main/graph/badge.svg)](https://codecov.io/gh/pymmcore-plus/pymmcore-midi)

MIDI Device control for microscopes using pymmcore
MIDI Device control for microscopes using pymmcore

## Installation

```bash
pip install pymmcore-midi
```

## Usage

Create a `pymmcore_midi.DeviceMap` object (can be done from a YAML/JSON file),
then connect it to a [pymmcore-plus
`CMMCorePlus`](https://pymmcore-plus.github.io/pymmcore-plus/api/cmmcoreplus/)
object.

```yaml
device_name: X-TOUCH MINI
mappings:
- [button, 8, Camera, AllowMultiROI]
- [button, 9, Camera, Binning]
- [knob, 2, Camera, Gain]
- [knob, 9, Camera, CCDTemperature]
# can also use this form
- message_type: control_change
control_id: 1
device_label: Camera
property_name: Exposure
- message_type: button
control_id: 10
core_method: snap
- message_type: knob
control_id: 17
core_method: setAutoFocusOffset
```
```python
core = CMMCorePlus()
core.loadSystemConfiguration()

dev_map = DeviceMap.from_file(f)
dev_map.connect_to_core(core)
```

Now when you move a knob or press a button on your MIDI device, the
corresponding property/method will be updated/called on the `CMMCorePlus`
object. :tada:

## Debugging/Development

Use the environment variable `PYMMCORE_MIDI_DEBUG=1` to print out the MIDI
messages that are being received from your device. (This is useful to determine
the appropriate message type and control ID for your device map.)
37 changes: 0 additions & 37 deletions example.py

This file was deleted.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ classifiers = [
dependencies = ["psygnal>=0.9.0", "mido[ports-rtmidi]"]

[project.optional-dependencies]
test = ["pytest", "pytest-cov", "pymmcore_plus"]
test = ["pytest", "pytest-cov", "pymmcore_plus", "pyyaml"]
dev = [
"black",
"ipython",
Expand Down
10 changes: 4 additions & 6 deletions src/pymmcore_midi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,18 @@
except PackageNotFoundError: # pragma: no cover
__version__ = "uninstalled"

from ._core_connect import (
connect_button_to_property,
connect_device_to_core,
connect_knob_to_property,
)
from ._core_connect import connect_button_to_property, connect_knob_to_property
from ._device import Button, Knob, MidiDevice
from ._map_spec import DeviceMap, Mapping
from ._xtouch import XTouchMini

__all__ = [
"Button",
"connect_button_to_property",
"connect_device_to_core",
"connect_knob_to_property",
"DeviceMap",
"Knob",
"Mapping",
"MidiDevice",
"XTouchMini",
]
43 changes: 2 additions & 41 deletions src/pymmcore_midi/_core_connect.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
from __future__ import annotations

import contextlib
import warnings
from typing import TYPE_CHECKING, Any, Callable, cast
from typing import TYPE_CHECKING, Any, Callable

if TYPE_CHECKING:
from pymmcore_plus import CMMCorePlus

from pymmcore_midi import Button, Knob, MidiDevice
from pymmcore_midi import Button, Knob


def connect_knob_to_property(
Expand Down Expand Up @@ -140,41 +139,3 @@ def disconnect() -> None:
core.events.propertyChanged.disconnect(_update_button_value)

return disconnect


def connect_device_to_core(
device: MidiDevice, core: CMMCorePlus, connections: list[tuple[str, int, str, str]]
) -> Callable[[], None]:
disconnecters: list[Callable] = []
for type_, idx, dev, prop in connections:
if type_ not in ("button", "knob"): # pragma: no cover
raise ValueError(f"Unknown type {type_}")

midi_obj: Knob | Button = getattr(device, type_)[idx]
if dev == "Core":
# special case.... look for core method
if not hasattr(core, prop): # pragma: no cover
raise ValueError(f"MMCore object has no method {prop!r}")
method = getattr(core, prop)
if type_ == "button":
btn = cast("Button", midi_obj)
btn.pressed.connect(method)
disconnecters.append(lambda o=btn, m=method: o.pressed.disconnect(m))
elif type_ == "knob":
# NOTE: connecting a callback to a knob may be a bad idea
knb = cast("Knob", midi_obj)
knb.changed.connect(method)
disconnecters.append(lambda o=knb, m=method: o.changed.disconnect(m))
elif type_ == "button":
d = connect_button_to_property(cast("Button", midi_obj), core, dev, prop)
disconnecters.append(d)
elif type_ == "knob":
d = connect_knob_to_property(cast("Knob", midi_obj), core, dev, prop)
disconnecters.append(d)

def disconnect() -> None:
for d in disconnecters:
with contextlib.suppress(Exception):
d()

return disconnect
28 changes: 22 additions & 6 deletions src/pymmcore_midi/_device.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
from typing import (
Iterable,
Iterator,
Mapping,
TypeVar,
)
from __future__ import annotations

import os
from typing import TYPE_CHECKING, ClassVar, Iterable, Iterator, Mapping, TypeVar

import mido
import mido.backends
from psygnal import Signal

if TYPE_CHECKING:
from typing import Self


T = TypeVar("T")
DEBUG = os.getenv("PYMMCORE_MIDI_DEBUG", "0") == "1"


# just a read-only mapping
Expand Down Expand Up @@ -117,11 +120,21 @@ class MidiDevice:
The ids of the knobs on the device. (These correspond to the control numbers.)
"""

DEVICE_NAME: ClassVar[str]

@classmethod
def from_name(cls, device_name: str) -> Self:
for subcls in cls.__subclasses__():
if getattr(subcls, "DEVICE_NAME", None) == device_name:
return subcls() # type: ignore
raise KeyError(f"No Subclass implemented for device_name: {device_name!r}")

def __init__(
self,
device_name: str,
button_ids: Iterable[int] = (),
knob_ids: Iterable[int] = (),
debug: bool = DEBUG,
):
try:
self._input: mido.ports.BaseInput = mido.open_input(device_name)
Expand All @@ -136,6 +149,7 @@ def __init__(
self.device_name = device_name
self._buttons = Buttons(button_ids, self._output)
self._knobs = Knobs(knob_ids, self._output)
self._debug = debug

@property
def knob(self) -> Knobs:
Expand Down Expand Up @@ -167,6 +181,8 @@ def close(self) -> None:
self._output.close()

def _on_msg(self, message: mido.Message) -> None:
if self._debug:
print(self.device_name, message)
if message.type == "control_change":
self._knobs[message.control].changed.emit(message.value)
elif message.type == "note_on":
Expand Down
Loading

0 comments on commit e88370c

Please sign in to comment.