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.
Files changed (42) hide show
  1. educelab/devices/__init__.py +25 -0
  2. educelab/devices/__main__.py +5 -0
  3. educelab/devices/_serial.py +310 -0
  4. educelab/devices/app/__init__.py +1 -0
  5. educelab/devices/app/cli.py +51 -0
  6. educelab/devices/app/discover.py +74 -0
  7. educelab/devices/app/dslr.py +123 -0
  8. educelab/devices/app/foldio.py +225 -0
  9. educelab/devices/app/hub.py +195 -0
  10. educelab/devices/app/misha.py +81 -0
  11. educelab/devices/app/simulate.py +127 -0
  12. educelab/devices/app/spinnaker.py +404 -0
  13. educelab/devices/cameras/__init__.py +49 -0
  14. educelab/devices/cameras/_gphoto2.py +447 -0
  15. educelab/devices/cameras/base.py +233 -0
  16. educelab/devices/cameras/focus.py +98 -0
  17. educelab/devices/cameras/gphoto2.py +751 -0
  18. educelab/devices/cameras/simulated.py +282 -0
  19. educelab/devices/cameras/spinnaker.py +1110 -0
  20. educelab/devices/device.py +217 -0
  21. educelab/devices/errors.py +90 -0
  22. educelab/devices/hubs/__init__.py +7 -0
  23. educelab/devices/hubs/acroname.py +463 -0
  24. educelab/devices/hubs/base.py +93 -0
  25. educelab/devices/hubs/simulated.py +44 -0
  26. educelab/devices/lights/__init__.py +6 -0
  27. educelab/devices/lights/base.py +180 -0
  28. educelab/devices/lights/misha.py +266 -0
  29. educelab/devices/lights/simulated.py +66 -0
  30. educelab/devices/py.typed +0 -0
  31. educelab/devices/settings.py +155 -0
  32. educelab/devices/stages/__init__.py +7 -0
  33. educelab/devices/stages/base.py +351 -0
  34. educelab/devices/stages/foldio360.py +1091 -0
  35. educelab/devices/stages/simulated.py +190 -0
  36. educelab_devices-0.1.0.dist-info/METADATA +144 -0
  37. educelab_devices-0.1.0.dist-info/RECORD +42 -0
  38. educelab_devices-0.1.0.dist-info/WHEEL +5 -0
  39. educelab_devices-0.1.0.dist-info/entry_points.txt +2 -0
  40. educelab_devices-0.1.0.dist-info/licenses/LICENSE +661 -0
  41. educelab_devices-0.1.0.dist-info/licenses/NOTICE +16 -0
  42. 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,5 @@
1
+ import sys
2
+
3
+ from educelab.devices.app.cli import main
4
+
5
+ sys.exit(main())
@@ -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