educelab-devices 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- educelab/devices/__init__.py +25 -0
- educelab/devices/__main__.py +5 -0
- educelab/devices/_serial.py +310 -0
- educelab/devices/app/__init__.py +1 -0
- educelab/devices/app/cli.py +51 -0
- educelab/devices/app/discover.py +74 -0
- educelab/devices/app/dslr.py +123 -0
- educelab/devices/app/foldio.py +225 -0
- educelab/devices/app/hub.py +195 -0
- educelab/devices/app/misha.py +81 -0
- educelab/devices/app/simulate.py +127 -0
- educelab/devices/app/spinnaker.py +404 -0
- educelab/devices/cameras/__init__.py +49 -0
- educelab/devices/cameras/_gphoto2.py +447 -0
- educelab/devices/cameras/base.py +233 -0
- educelab/devices/cameras/focus.py +98 -0
- educelab/devices/cameras/gphoto2.py +751 -0
- educelab/devices/cameras/simulated.py +282 -0
- educelab/devices/cameras/spinnaker.py +1110 -0
- educelab/devices/device.py +217 -0
- educelab/devices/errors.py +90 -0
- educelab/devices/hubs/__init__.py +7 -0
- educelab/devices/hubs/acroname.py +463 -0
- educelab/devices/hubs/base.py +93 -0
- educelab/devices/hubs/simulated.py +44 -0
- educelab/devices/lights/__init__.py +6 -0
- educelab/devices/lights/base.py +180 -0
- educelab/devices/lights/misha.py +266 -0
- educelab/devices/lights/simulated.py +66 -0
- educelab/devices/py.typed +0 -0
- educelab/devices/settings.py +155 -0
- educelab/devices/stages/__init__.py +7 -0
- educelab/devices/stages/base.py +351 -0
- educelab/devices/stages/foldio360.py +1091 -0
- educelab/devices/stages/simulated.py +190 -0
- educelab_devices-0.1.0.dist-info/METADATA +144 -0
- educelab_devices-0.1.0.dist-info/RECORD +42 -0
- educelab_devices-0.1.0.dist-info/WHEEL +5 -0
- educelab_devices-0.1.0.dist-info/entry_points.txt +2 -0
- educelab_devices-0.1.0.dist-info/licenses/LICENSE +661 -0
- educelab_devices-0.1.0.dist-info/licenses/NOTICE +16 -0
- educelab_devices-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
from educelab.devices.device import (
|
|
4
|
+
Device,
|
|
5
|
+
DeviceEvent,
|
|
6
|
+
DeviceInfo,
|
|
7
|
+
Disconnected,
|
|
8
|
+
Listener,
|
|
9
|
+
synchronized,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Device",
|
|
14
|
+
"DeviceEvent",
|
|
15
|
+
"DeviceInfo",
|
|
16
|
+
"Disconnected",
|
|
17
|
+
"Listener",
|
|
18
|
+
"__version__",
|
|
19
|
+
"synchronized",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
__version__ = version("educelab-devices")
|
|
24
|
+
except PackageNotFoundError:
|
|
25
|
+
__version__ = "0.0.0"
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Serial transport shared by the lab's serial drivers (MISHA, XLC, X-Carve).
|
|
2
|
+
|
|
3
|
+
Internal: drivers build on it, consumers don't. pyserial is imported only
|
|
4
|
+
when a port is listed or opened.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable, Iterable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any, TypeVar
|
|
12
|
+
|
|
13
|
+
from educelab.devices.errors import (
|
|
14
|
+
DeviceNotFound,
|
|
15
|
+
DeviceTimeout,
|
|
16
|
+
NotConnected,
|
|
17
|
+
import_optional,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"PortInfo",
|
|
22
|
+
"PortMatch",
|
|
23
|
+
"SerialTransport",
|
|
24
|
+
"find_ports",
|
|
25
|
+
"handshake",
|
|
26
|
+
"list_ports",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
T = TypeVar("T")
|
|
32
|
+
|
|
33
|
+
# Opens a port and returns an object with the parts of serial.Serial's API the
|
|
34
|
+
# transport uses. Tests pass one that returns a fake.
|
|
35
|
+
Opener = Callable[[str, int, float], Any]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class PortInfo:
|
|
40
|
+
"""A serial port as the host's USB stack describes it.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
device: The name to open, e.g. ``"COM3"`` or ``"/dev/ttyUSB0"``.
|
|
44
|
+
vid: USB vendor ID, if the port is a USB device.
|
|
45
|
+
pid: USB product ID, if the port is a USB device.
|
|
46
|
+
serial_number: USB serial number, if the device reports one.
|
|
47
|
+
description: The host's human-readable description of the port.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
device: str
|
|
51
|
+
vid: int | None = None
|
|
52
|
+
pid: int | None = None
|
|
53
|
+
serial_number: str | None = None
|
|
54
|
+
description: str | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def list_ports() -> list[PortInfo]:
|
|
58
|
+
"""Every serial port on the host.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ImportError: pyserial isn't installed.
|
|
62
|
+
"""
|
|
63
|
+
pyserial_list_ports = import_optional("serial.tools.list_ports", extra="serial")
|
|
64
|
+
return [
|
|
65
|
+
PortInfo(
|
|
66
|
+
device=port.device,
|
|
67
|
+
vid=port.vid,
|
|
68
|
+
pid=port.pid,
|
|
69
|
+
serial_number=port.serial_number,
|
|
70
|
+
description=port.description,
|
|
71
|
+
)
|
|
72
|
+
for port in pyserial_list_ports.comports()
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class PortMatch:
|
|
78
|
+
"""Criteria that pick a device's port out of the host's serial ports.
|
|
79
|
+
|
|
80
|
+
A port matches only if it meets every criterion given, and criteria that
|
|
81
|
+
aren't given are ignored. With no criteria at all, nothing matches.
|
|
82
|
+
|
|
83
|
+
Attributes:
|
|
84
|
+
vid: USB vendor ID, or a collection of acceptable IDs.
|
|
85
|
+
pid: USB product ID, or a collection of acceptable IDs.
|
|
86
|
+
serial_number: Text the port's serial number must contain, ignoring
|
|
87
|
+
case.
|
|
88
|
+
description: Text the port's description must contain, ignoring case.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
vid: int | Iterable[int] | None = None
|
|
92
|
+
pid: int | Iterable[int] | None = None
|
|
93
|
+
serial_number: str | None = None
|
|
94
|
+
description: str | None = None
|
|
95
|
+
|
|
96
|
+
def __post_init__(self) -> None:
|
|
97
|
+
for name in ("vid", "pid"):
|
|
98
|
+
value = getattr(self, name)
|
|
99
|
+
if value is not None and not isinstance(value, int):
|
|
100
|
+
object.__setattr__(self, name, frozenset(value))
|
|
101
|
+
|
|
102
|
+
def matches(self, port: PortInfo) -> bool:
|
|
103
|
+
"""Whether ``port`` meets every criterion given."""
|
|
104
|
+
checks = [
|
|
105
|
+
_id_matches(self.vid, port.vid),
|
|
106
|
+
_id_matches(self.pid, port.pid),
|
|
107
|
+
_text_matches(self.serial_number, port.serial_number),
|
|
108
|
+
_text_matches(self.description, port.description),
|
|
109
|
+
]
|
|
110
|
+
given = [check for check in checks if check is not None]
|
|
111
|
+
return bool(given) and all(given)
|
|
112
|
+
|
|
113
|
+
def matching(self, ports: Iterable[PortInfo]) -> list[PortInfo]:
|
|
114
|
+
"""The ports that match, in the order given."""
|
|
115
|
+
return [port for port in ports if self.matches(port)]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def find_ports(match: PortMatch) -> list[PortInfo]:
|
|
119
|
+
"""The host's serial ports that meet ``match``.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
ImportError: pyserial isn't installed.
|
|
123
|
+
"""
|
|
124
|
+
return match.matching(list_ports())
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# Each returns None when the criterion wasn't given.
|
|
128
|
+
def _id_matches(want: int | Iterable[int] | None, have: int | None) -> bool | None:
|
|
129
|
+
if want is None:
|
|
130
|
+
return None
|
|
131
|
+
if have is None:
|
|
132
|
+
return False
|
|
133
|
+
return have == want if isinstance(want, int) else have in want
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _text_matches(want: str | None, have: str | None) -> bool | None:
|
|
137
|
+
if want is None:
|
|
138
|
+
return None
|
|
139
|
+
return have is not None and want.casefold() in have.casefold()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class SerialTransport:
|
|
143
|
+
"""A line-oriented connection to one serial port.
|
|
144
|
+
|
|
145
|
+
Lines are ASCII and end with ``\\n`` on the way out; on the way in, the
|
|
146
|
+
line ending and surrounding whitespace are stripped and blank lines are
|
|
147
|
+
skipped.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
port: The port to open, e.g. ``"COM3"`` or ``"/dev/ttyUSB0"``.
|
|
151
|
+
baudrate: Baud rate.
|
|
152
|
+
timeout: Default time to wait for a line, in seconds.
|
|
153
|
+
reset_settle: Time to wait after opening, in seconds. Boards that
|
|
154
|
+
reset when the port opens (DTR) ignore commands until they've
|
|
155
|
+
booted; output from the boot is discarded.
|
|
156
|
+
opener: Opens the port. Defaults to pyserial; tests pass a fake.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
port: str,
|
|
162
|
+
*,
|
|
163
|
+
baudrate: int,
|
|
164
|
+
timeout: float = 1.0,
|
|
165
|
+
reset_settle: float = 0.0,
|
|
166
|
+
opener: Opener | None = None,
|
|
167
|
+
) -> None:
|
|
168
|
+
self.port = port
|
|
169
|
+
self.baudrate = baudrate
|
|
170
|
+
self.timeout = timeout
|
|
171
|
+
self.reset_settle = reset_settle
|
|
172
|
+
self._opener = opener or _open_pyserial
|
|
173
|
+
self._conn: Any = None
|
|
174
|
+
self._partial = b""
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def is_open(self) -> bool:
|
|
178
|
+
return self._conn is not None
|
|
179
|
+
|
|
180
|
+
def open(self) -> None:
|
|
181
|
+
"""Open the port and wait for the board to finish resetting.
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
DeviceNotFound: The port couldn't be opened.
|
|
185
|
+
ImportError: The default opener needs pyserial, which isn't
|
|
186
|
+
installed.
|
|
187
|
+
"""
|
|
188
|
+
if self._conn is not None:
|
|
189
|
+
return
|
|
190
|
+
try:
|
|
191
|
+
conn = self._opener(self.port, self.baudrate, self.timeout)
|
|
192
|
+
except OSError as exc:
|
|
193
|
+
raise DeviceNotFound(
|
|
194
|
+
f"could not open serial port {self.port!r}: {exc}"
|
|
195
|
+
) from exc
|
|
196
|
+
self._conn = conn
|
|
197
|
+
self._partial = b""
|
|
198
|
+
if self.reset_settle:
|
|
199
|
+
time.sleep(self.reset_settle)
|
|
200
|
+
self.reset_input()
|
|
201
|
+
|
|
202
|
+
def close(self) -> None:
|
|
203
|
+
"""Close the port. Does nothing if it isn't open."""
|
|
204
|
+
conn, self._conn = self._conn, None
|
|
205
|
+
if conn is None:
|
|
206
|
+
return
|
|
207
|
+
try:
|
|
208
|
+
conn.close()
|
|
209
|
+
except OSError as exc:
|
|
210
|
+
# The port is gone either way; there's nothing left to release.
|
|
211
|
+
logger.debug("closing %s failed: %s", self.port, exc)
|
|
212
|
+
|
|
213
|
+
def reset_input(self) -> None:
|
|
214
|
+
"""Discard anything received but not yet read."""
|
|
215
|
+
conn = self._require_open()
|
|
216
|
+
self._partial = b""
|
|
217
|
+
try:
|
|
218
|
+
conn.reset_input_buffer()
|
|
219
|
+
except OSError as exc:
|
|
220
|
+
raise self._lost(exc) from exc
|
|
221
|
+
|
|
222
|
+
def write_line(self, line: str) -> None:
|
|
223
|
+
"""Send ``line`` followed by a newline.
|
|
224
|
+
|
|
225
|
+
Raises:
|
|
226
|
+
NotConnected: The port isn't open or the connection was lost.
|
|
227
|
+
"""
|
|
228
|
+
conn = self._require_open()
|
|
229
|
+
logger.debug("%s > %s", self.port, line)
|
|
230
|
+
try:
|
|
231
|
+
conn.write(f"{line}\n".encode("ascii"))
|
|
232
|
+
conn.flush()
|
|
233
|
+
except OSError as exc:
|
|
234
|
+
raise self._lost(exc) from exc
|
|
235
|
+
|
|
236
|
+
def read_line(self, timeout: float | None = None) -> str:
|
|
237
|
+
"""Wait for the next non-blank line.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
timeout: Seconds to wait. Defaults to the transport's timeout.
|
|
241
|
+
|
|
242
|
+
Raises:
|
|
243
|
+
DeviceTimeout: No complete line arrived in time.
|
|
244
|
+
NotConnected: The port isn't open or the connection was lost.
|
|
245
|
+
"""
|
|
246
|
+
conn = self._require_open()
|
|
247
|
+
deadline = time.monotonic() + (self.timeout if timeout is None else timeout)
|
|
248
|
+
while (remaining := deadline - time.monotonic()) > 0:
|
|
249
|
+
conn.timeout = remaining
|
|
250
|
+
try:
|
|
251
|
+
chunk = conn.readline()
|
|
252
|
+
except OSError as exc:
|
|
253
|
+
raise self._lost(exc) from exc
|
|
254
|
+
# readline() returns what it has so far when it times out.
|
|
255
|
+
self._partial += chunk
|
|
256
|
+
if not self._partial.endswith(b"\n"):
|
|
257
|
+
continue
|
|
258
|
+
line = self._partial.decode("ascii", errors="replace").strip()
|
|
259
|
+
self._partial = b""
|
|
260
|
+
if line:
|
|
261
|
+
logger.debug("%s < %s", self.port, line)
|
|
262
|
+
return line
|
|
263
|
+
raise DeviceTimeout(f"no reply from serial port {self.port!r}")
|
|
264
|
+
|
|
265
|
+
def _require_open(self) -> Any:
|
|
266
|
+
if self._conn is None:
|
|
267
|
+
raise NotConnected(f"serial port {self.port!r} is not open")
|
|
268
|
+
return self._conn
|
|
269
|
+
|
|
270
|
+
def _lost(self, exc: OSError) -> NotConnected:
|
|
271
|
+
self.close()
|
|
272
|
+
return NotConnected(f"lost serial port {self.port!r}: {exc}")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def handshake(
|
|
276
|
+
transport: SerialTransport,
|
|
277
|
+
command: str,
|
|
278
|
+
parse: Callable[[str], T | None],
|
|
279
|
+
timeout: float | None = None,
|
|
280
|
+
) -> T | None:
|
|
281
|
+
"""Send an identification command and wait for a reply that parses.
|
|
282
|
+
|
|
283
|
+
Lines that ``parse`` rejects (boot chatter, another device's output) are
|
|
284
|
+
skipped until the timeout.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
transport: An open transport.
|
|
288
|
+
command: The identification command, e.g. ``"ID"``.
|
|
289
|
+
parse: Returns the parsed reply, or ``None`` for a line that isn't one.
|
|
290
|
+
timeout: Seconds to wait. Defaults to the transport's timeout.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
The first parsed reply, or ``None`` if none arrived in time.
|
|
294
|
+
"""
|
|
295
|
+
transport.reset_input()
|
|
296
|
+
transport.write_line(command)
|
|
297
|
+
deadline = time.monotonic() + (transport.timeout if timeout is None else timeout)
|
|
298
|
+
while (remaining := deadline - time.monotonic()) > 0:
|
|
299
|
+
try:
|
|
300
|
+
line = transport.read_line(remaining)
|
|
301
|
+
except DeviceTimeout:
|
|
302
|
+
return None
|
|
303
|
+
if (reply := parse(line)) is not None:
|
|
304
|
+
return reply
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _open_pyserial(port: str, baudrate: int, timeout: float) -> Any:
|
|
309
|
+
pyserial = import_optional("serial", extra="serial")
|
|
310
|
+
return pyserial.Serial(port, baudrate, timeout=timeout, write_timeout=timeout)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The ``el-devices`` command-line tool."""
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Entry point for ``el-devices``.
|
|
2
|
+
|
|
3
|
+
Each subcommand is a module in this package with a ``register(subparsers)``
|
|
4
|
+
function that adds its parser and sets ``func``. Subcommand modules import
|
|
5
|
+
their backends inside ``func``, so the CLI starts without any vendor SDK.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import importlib
|
|
10
|
+
import logging
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
|
|
14
|
+
from educelab.devices import __version__
|
|
15
|
+
from educelab.devices.errors import DeviceError
|
|
16
|
+
|
|
17
|
+
SUBCOMMANDS = ("discover", "dslr", "foldio", "hub", "misha", "simulate", "spinnaker")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
21
|
+
parser = argparse.ArgumentParser(
|
|
22
|
+
prog="el-devices",
|
|
23
|
+
description="Check EduceLab imaging hardware from the command line.",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--version", action="version", version=f"%(prog)s {__version__}"
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"-v", "--verbose", action="store_true", help="log device traffic"
|
|
30
|
+
)
|
|
31
|
+
subparsers = parser.add_subparsers(
|
|
32
|
+
title="commands", dest="command", metavar="COMMAND", required=True
|
|
33
|
+
)
|
|
34
|
+
for name in SUBCOMMANDS:
|
|
35
|
+
importlib.import_module(f"educelab.devices.app.{name}").register(subparsers)
|
|
36
|
+
return parser
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
40
|
+
args = build_parser().parse_args(argv)
|
|
41
|
+
logging.basicConfig(
|
|
42
|
+
level=logging.DEBUG if args.verbose else logging.WARNING,
|
|
43
|
+
format="%(levelname)s %(name)s: %(message)s",
|
|
44
|
+
)
|
|
45
|
+
try:
|
|
46
|
+
return int(args.func(args) or 0)
|
|
47
|
+
except DeviceError as exc:
|
|
48
|
+
print(f"el-devices: {exc}", file=sys.stderr)
|
|
49
|
+
return 1
|
|
50
|
+
except KeyboardInterrupt:
|
|
51
|
+
return 130
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""``el-devices discover``: list devices the installed backends can find."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import importlib
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from educelab.devices.device import Device
|
|
8
|
+
from educelab.devices.errors import DeviceError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Backend:
|
|
13
|
+
"""A driver whose `discover()` this command calls.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
name: Label shown in the output.
|
|
17
|
+
module: Module that defines the driver. It must import without the
|
|
18
|
+
vendor SDK.
|
|
19
|
+
device_class: Name of the driver class in ``module``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
name: str
|
|
23
|
+
module: str
|
|
24
|
+
device_class: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Each driver track adds its backend here.
|
|
28
|
+
BACKENDS: tuple[Backend, ...] = (
|
|
29
|
+
Backend("acroname", "educelab.devices.hubs.acroname", "AcronameHub"),
|
|
30
|
+
Backend("foldio360", "educelab.devices.stages.foldio360", "Foldio360"),
|
|
31
|
+
Backend("gphoto2", "educelab.devices.cameras.gphoto2", "GPhoto2Camera"),
|
|
32
|
+
Backend("misha", "educelab.devices.lights.misha", "MishaLight"),
|
|
33
|
+
Backend("spinnaker", "educelab.devices.cameras.spinnaker", "SpinnakerCamera"),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
|
|
38
|
+
parser = subparsers.add_parser(
|
|
39
|
+
"discover",
|
|
40
|
+
help="list devices the installed backends can find",
|
|
41
|
+
description="List devices that the installed backends can find, "
|
|
42
|
+
"without opening them.",
|
|
43
|
+
)
|
|
44
|
+
parser.set_defaults(func=run)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def run(args: argparse.Namespace) -> int:
|
|
48
|
+
found = 0
|
|
49
|
+
for backend in BACKENDS:
|
|
50
|
+
try:
|
|
51
|
+
module = importlib.import_module(backend.module)
|
|
52
|
+
device_class: type[Device] = getattr(module, backend.device_class)
|
|
53
|
+
infos = device_class.discover()
|
|
54
|
+
except ImportError as exc:
|
|
55
|
+
print(f"{backend.name}: unavailable. {exc}")
|
|
56
|
+
continue
|
|
57
|
+
except DeviceError as exc:
|
|
58
|
+
print(f"{backend.name}: discovery failed. {exc}")
|
|
59
|
+
continue
|
|
60
|
+
except Exception as exc: # noqa: BLE001
|
|
61
|
+
# One broken backend shouldn't hide the others.
|
|
62
|
+
print(f"{backend.name}: discovery failed. {type(exc).__name__}: {exc}")
|
|
63
|
+
continue
|
|
64
|
+
for info in infos:
|
|
65
|
+
found += 1
|
|
66
|
+
details = [f"{info.model} at {info.address or 'unknown address'}"]
|
|
67
|
+
if info.serial_number:
|
|
68
|
+
details.append(f"serial {info.serial_number}")
|
|
69
|
+
if info.firmware_version:
|
|
70
|
+
details.append(f"firmware {info.firmware_version}")
|
|
71
|
+
print(f"{backend.name}: {', '.join(details)}")
|
|
72
|
+
if not found:
|
|
73
|
+
print("No devices found.")
|
|
74
|
+
return 0
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""``el-devices dslr``: check a camera driven through libgphoto2."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path, PurePath
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
# Imported where it's used, so other subcommands don't load the driver.
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from educelab.devices.cameras.gphoto2 import GPhoto2Camera
|
|
11
|
+
from educelab.devices.settings import Setting
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
|
|
15
|
+
parser = subparsers.add_parser(
|
|
16
|
+
"dslr",
|
|
17
|
+
help="detect a gphoto2 camera, list its settings, capture, and step focus",
|
|
18
|
+
description="Open a camera through libgphoto2, list its settings and "
|
|
19
|
+
"whether it has a focus drive, then run whichever checks are asked for. "
|
|
20
|
+
"Without --port, uses the only camera attached.",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument("--port", help="libgphoto2 port, e.g. usb:020,007")
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--exposure",
|
|
25
|
+
type=float,
|
|
26
|
+
metavar="SECONDS",
|
|
27
|
+
help="set exposure_time to the shutter speed nearest this many seconds",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--capture",
|
|
31
|
+
type=Path,
|
|
32
|
+
metavar="DIR",
|
|
33
|
+
help="capture once and save every file to this directory, created if needed, "
|
|
34
|
+
"under the camera's file names",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--preview", action="store_true", help="grab one live-view frame"
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--focus-steps",
|
|
41
|
+
type=int,
|
|
42
|
+
metavar="N",
|
|
43
|
+
help="move focus N steps: positive toward far, negative toward near",
|
|
44
|
+
)
|
|
45
|
+
parser.set_defaults(func=run)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def run(args: argparse.Namespace) -> int:
|
|
49
|
+
from educelab.devices.cameras import EXPOSURE_TIME
|
|
50
|
+
from educelab.devices.cameras.gphoto2 import GPhoto2Camera
|
|
51
|
+
|
|
52
|
+
camera = GPhoto2Camera(args.port)
|
|
53
|
+
try:
|
|
54
|
+
camera.open()
|
|
55
|
+
except ImportError as exc:
|
|
56
|
+
print(f"el-devices: {exc}", file=sys.stderr)
|
|
57
|
+
return 1
|
|
58
|
+
with camera:
|
|
59
|
+
print(f"{camera.info.model} at {camera.info.address}")
|
|
60
|
+
for setting in camera.settings().values():
|
|
61
|
+
print(f" {_describe(setting)}")
|
|
62
|
+
print(f" focus drive: {'yes' if camera.focus is not None else 'no'}")
|
|
63
|
+
|
|
64
|
+
if args.exposure is not None:
|
|
65
|
+
nearest = camera.setting(EXPOSURE_TIME).nearest(args.exposure)
|
|
66
|
+
camera.set_setting(EXPOSURE_TIME, nearest)
|
|
67
|
+
print(f"exposure_time set to {nearest:.4g} s")
|
|
68
|
+
if args.capture is not None and not _capture(camera, args.capture):
|
|
69
|
+
return 1
|
|
70
|
+
if args.preview:
|
|
71
|
+
height, width = camera.preview_frame().shape[:2]
|
|
72
|
+
print(f"preview: {width}x{height}")
|
|
73
|
+
if args.focus_steps is not None and not _step_focus(camera, args.focus_steps):
|
|
74
|
+
return 1
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _describe(setting: "Setting") -> str:
|
|
79
|
+
value = setting.value
|
|
80
|
+
if value is None:
|
|
81
|
+
# e.g. exposure_time with the shutter at Bulb
|
|
82
|
+
text = f"{setting.name} = no number"
|
|
83
|
+
else:
|
|
84
|
+
shown = f"{value:.4g}" if isinstance(value, float) else repr(value)
|
|
85
|
+
text = f"{setting.name} = {shown}"
|
|
86
|
+
if setting.unit:
|
|
87
|
+
text += f" {setting.unit}"
|
|
88
|
+
if setting.read_only:
|
|
89
|
+
text += " (read-only)"
|
|
90
|
+
elif setting.choices is not None:
|
|
91
|
+
text += f" ({len(setting.choices)} choices)"
|
|
92
|
+
elif setting.minimum is not None and setting.maximum is not None:
|
|
93
|
+
text += f" ({setting.minimum:g} to {setting.maximum:g})"
|
|
94
|
+
return text
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _capture(camera: "GPhoto2Camera", directory: Path) -> bool:
|
|
98
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
files = camera.capture_files()
|
|
100
|
+
for file in files:
|
|
101
|
+
try:
|
|
102
|
+
path = file.save(directory, PurePath(file.name).stem)
|
|
103
|
+
except FileExistsError as exc:
|
|
104
|
+
print(f"el-devices: {exc}", file=sys.stderr)
|
|
105
|
+
return False
|
|
106
|
+
print(f"saved {path} ({len(file.data)} bytes)")
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _step_focus(camera: "GPhoto2Camera", steps: int) -> bool:
|
|
111
|
+
from educelab.devices.errors import FocusLimitReached
|
|
112
|
+
|
|
113
|
+
focus = camera.focus
|
|
114
|
+
if focus is None:
|
|
115
|
+
print(f"el-devices: {camera.info.model} has no focus drive", file=sys.stderr)
|
|
116
|
+
return False
|
|
117
|
+
try:
|
|
118
|
+
focus.step(steps)
|
|
119
|
+
except FocusLimitReached as exc:
|
|
120
|
+
print(f"focus reached the {exc.limit} limit before moving {steps:+d} steps")
|
|
121
|
+
return True
|
|
122
|
+
print(f"focus moved {steps:+d} steps")
|
|
123
|
+
return True
|