Skip to content

Commit e88370c

Browse files
authored
feat: add device connection spec (#1)
* feat: add device connection spec * finish * update readme * pyyaml * tada * format * fix type
1 parent d564b0a commit e88370c

File tree

10 files changed

+275
-110
lines changed

10 files changed

+275
-110
lines changed

README.md

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,60 @@
22

33
[![License](https://img.shields.io/pypi/l/pymmcore-midi.svg?color=green)](https://github.com/pymmcore-plus/pymmcore-midi/raw/main/LICENSE)
44
[![PyPI](https://img.shields.io/pypi/v/pymmcore-midi.svg?color=green)](https://pypi.org/project/pymmcore-midi)
5-
[![Python Version](https://img.shields.io/pypi/pyversions/pymmcore-midi.svg?color=green)](https://python.org)
5+
[![Python
6+
Version](https://img.shields.io/pypi/pyversions/pymmcore-midi.svg?color=green)](https://python.org)
67
[![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)
78
[![codecov](https://codecov.io/gh/pymmcore-plus/pymmcore-midi/branch/main/graph/badge.svg)](https://codecov.io/gh/pymmcore-plus/pymmcore-midi)
89

9-
MIDI Device control for microscopes using pymmcore
10+
MIDI Device control for microscopes using pymmcore
11+
12+
## Installation
13+
14+
```bash
15+
pip install pymmcore-midi
16+
```
17+
18+
## Usage
19+
20+
Create a `pymmcore_midi.DeviceMap` object (can be done from a YAML/JSON file),
21+
then connect it to a [pymmcore-plus
22+
`CMMCorePlus`](https://pymmcore-plus.github.io/pymmcore-plus/api/cmmcoreplus/)
23+
object.
24+
25+
```yaml
26+
device_name: X-TOUCH MINI
27+
mappings:
28+
- [button, 8, Camera, AllowMultiROI]
29+
- [button, 9, Camera, Binning]
30+
- [knob, 2, Camera, Gain]
31+
- [knob, 9, Camera, CCDTemperature]
32+
# can also use this form
33+
- message_type: control_change
34+
control_id: 1
35+
device_label: Camera
36+
property_name: Exposure
37+
- message_type: button
38+
control_id: 10
39+
core_method: snap
40+
- message_type: knob
41+
control_id: 17
42+
core_method: setAutoFocusOffset
43+
```
44+
45+
```python
46+
core = CMMCorePlus()
47+
core.loadSystemConfiguration()
48+
49+
dev_map = DeviceMap.from_file(f)
50+
dev_map.connect_to_core(core)
51+
```
52+
53+
Now when you move a knob or press a button on your MIDI device, the
54+
corresponding property/method will be updated/called on the `CMMCorePlus`
55+
object. :tada:
56+
57+
## Debugging/Development
58+
59+
Use the environment variable `PYMMCORE_MIDI_DEBUG=1` to print out the MIDI
60+
messages that are being received from your device. (This is useful to determine
61+
the appropriate message type and control ID for your device map.)

example.py

Lines changed: 0 additions & 37 deletions
This file was deleted.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ classifiers = [
3333
dependencies = ["psygnal>=0.9.0", "mido[ports-rtmidi]"]
3434

3535
[project.optional-dependencies]
36-
test = ["pytest", "pytest-cov", "pymmcore_plus"]
36+
test = ["pytest", "pytest-cov", "pymmcore_plus", "pyyaml"]
3737
dev = [
3838
"black",
3939
"ipython",

src/pymmcore_midi/__init__.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,18 @@
77
except PackageNotFoundError: # pragma: no cover
88
__version__ = "uninstalled"
99

10-
from ._core_connect import (
11-
connect_button_to_property,
12-
connect_device_to_core,
13-
connect_knob_to_property,
14-
)
10+
from ._core_connect import connect_button_to_property, connect_knob_to_property
1511
from ._device import Button, Knob, MidiDevice
12+
from ._map_spec import DeviceMap, Mapping
1613
from ._xtouch import XTouchMini
1714

1815
__all__ = [
1916
"Button",
2017
"connect_button_to_property",
21-
"connect_device_to_core",
2218
"connect_knob_to_property",
19+
"DeviceMap",
2320
"Knob",
21+
"Mapping",
2422
"MidiDevice",
2523
"XTouchMini",
2624
]

src/pymmcore_midi/_core_connect.py

Lines changed: 2 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
from __future__ import annotations
22

3-
import contextlib
43
import warnings
5-
from typing import TYPE_CHECKING, Any, Callable, cast
4+
from typing import TYPE_CHECKING, Any, Callable
65

76
if TYPE_CHECKING:
87
from pymmcore_plus import CMMCorePlus
98

10-
from pymmcore_midi import Button, Knob, MidiDevice
9+
from pymmcore_midi import Button, Knob
1110

1211

1312
def connect_knob_to_property(
@@ -140,41 +139,3 @@ def disconnect() -> None:
140139
core.events.propertyChanged.disconnect(_update_button_value)
141140

142141
return disconnect
143-
144-
145-
def connect_device_to_core(
146-
device: MidiDevice, core: CMMCorePlus, connections: list[tuple[str, int, str, str]]
147-
) -> Callable[[], None]:
148-
disconnecters: list[Callable] = []
149-
for type_, idx, dev, prop in connections:
150-
if type_ not in ("button", "knob"): # pragma: no cover
151-
raise ValueError(f"Unknown type {type_}")
152-
153-
midi_obj: Knob | Button = getattr(device, type_)[idx]
154-
if dev == "Core":
155-
# special case.... look for core method
156-
if not hasattr(core, prop): # pragma: no cover
157-
raise ValueError(f"MMCore object has no method {prop!r}")
158-
method = getattr(core, prop)
159-
if type_ == "button":
160-
btn = cast("Button", midi_obj)
161-
btn.pressed.connect(method)
162-
disconnecters.append(lambda o=btn, m=method: o.pressed.disconnect(m))
163-
elif type_ == "knob":
164-
# NOTE: connecting a callback to a knob may be a bad idea
165-
knb = cast("Knob", midi_obj)
166-
knb.changed.connect(method)
167-
disconnecters.append(lambda o=knb, m=method: o.changed.disconnect(m))
168-
elif type_ == "button":
169-
d = connect_button_to_property(cast("Button", midi_obj), core, dev, prop)
170-
disconnecters.append(d)
171-
elif type_ == "knob":
172-
d = connect_knob_to_property(cast("Knob", midi_obj), core, dev, prop)
173-
disconnecters.append(d)
174-
175-
def disconnect() -> None:
176-
for d in disconnecters:
177-
with contextlib.suppress(Exception):
178-
d()
179-
180-
return disconnect

src/pymmcore_midi/_device.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
1-
from typing import (
2-
Iterable,
3-
Iterator,
4-
Mapping,
5-
TypeVar,
6-
)
1+
from __future__ import annotations
2+
3+
import os
4+
from typing import TYPE_CHECKING, ClassVar, Iterable, Iterator, Mapping, TypeVar
75

86
import mido
97
import mido.backends
108
from psygnal import Signal
119

10+
if TYPE_CHECKING:
11+
from typing import Self
12+
13+
1214
T = TypeVar("T")
15+
DEBUG = os.getenv("PYMMCORE_MIDI_DEBUG", "0") == "1"
1316

1417

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

123+
DEVICE_NAME: ClassVar[str]
124+
125+
@classmethod
126+
def from_name(cls, device_name: str) -> Self:
127+
for subcls in cls.__subclasses__():
128+
if getattr(subcls, "DEVICE_NAME", None) == device_name:
129+
return subcls() # type: ignore
130+
raise KeyError(f"No Subclass implemented for device_name: {device_name!r}")
131+
120132
def __init__(
121133
self,
122134
device_name: str,
123135
button_ids: Iterable[int] = (),
124136
knob_ids: Iterable[int] = (),
137+
debug: bool = DEBUG,
125138
):
126139
try:
127140
self._input: mido.ports.BaseInput = mido.open_input(device_name)
@@ -136,6 +149,7 @@ def __init__(
136149
self.device_name = device_name
137150
self._buttons = Buttons(button_ids, self._output)
138151
self._knobs = Knobs(knob_ids, self._output)
152+
self._debug = debug
139153

140154
@property
141155
def knob(self) -> Knobs:
@@ -167,6 +181,8 @@ def close(self) -> None:
167181
self._output.close()
168182

169183
def _on_msg(self, message: mido.Message) -> None:
184+
if self._debug:
185+
print(self.device_name, message)
170186
if message.type == "control_change":
171187
self._knobs[message.control].changed.emit(message.value)
172188
elif message.type == "note_on":

0 commit comments

Comments
 (0)