Skip to content

Commit 7e21484

Browse files
committed
microscope/controllers/toptica.py: add support for Toptica iChrome MLE (#71)
1 parent da85f2d commit 7e21484

File tree

3 files changed

+306
-1
lines changed

3 files changed

+306
-1
lines changed

NEWS.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ Version 0.7.0 (upcoming)
1313
`Camera.get_trigger_type` does not return the same as
1414
`Camera.trigger_type` property.
1515

16+
* New devices supported:
17+
18+
* Toptica iChrome MLE
19+
1620
* The device server logging was broken in version 0.6.0 for Windows
1721
and macOS (systems not using fork for multiprocessing). This
1822
version fixes that issue.

doc/architecture/supported-devices.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ Controllers
2626

2727
- CoolLED (:class:`microscope.controllers.coolled.CoolLED`)
2828
- Prior ProScan III (:class:`microscope.controllers.prior.ProScanIII`)
29-
- Lumencor Spectra III light engine (:class:`microscope.controllers.lumencor.SpectraIIILightEngine`)
29+
- Lumencor Spectra III light engine
30+
(:class:`microscope.controllers.lumencor.SpectraIIILightEngine`)
31+
- Toptica iChrome MLE (:class:`microscope.controllers.toptica.iChromeMLE`)
3032
- Zaber daisy chain devices
3133
(:class:`microscope.controllers.zaber.ZaberDaisyChain`)
3234
- Zaber LED controller (:class:`microscope.controllers.zaber.ZaberDaisyChain`)

microscope/controllers/toptica.py

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
#!/usr/bin/env python3
2+
3+
## Copyright (C) 2021 David Miguel Susano Pinto <carandraug@gmail.com>
4+
##
5+
## This file is part of Microscope.
6+
##
7+
## Microscope is free software: you can redistribute it and/or modify
8+
## it under the terms of the GNU General Public License as published by
9+
## the Free Software Foundation, either version 3 of the License, or
10+
## (at your option) any later version.
11+
##
12+
## Microscope is distributed in the hope that it will be useful,
13+
## but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
## GNU General Public License for more details.
16+
##
17+
## You should have received a copy of the GNU General Public License
18+
## along with Microscope. If not, see <http://www.gnu.org/licenses/>.
19+
20+
import logging
21+
import typing
22+
23+
import serial
24+
25+
import microscope
26+
import microscope._utils
27+
import microscope.abc
28+
29+
30+
_LOGGER = logging.getLogger(__name__)
31+
32+
_QUOTATION_CODE = ord(b'"')
33+
34+
35+
def _parse_string(answer: bytes) -> str:
36+
assert answer[0] == _QUOTATION_CODE and answer[-1] == _QUOTATION_CODE
37+
return answer[1:-1].decode()
38+
39+
40+
def _parse_bool(answer: bytes) -> bool:
41+
assert answer in [b"#f", b"#t"]
42+
return answer == b"#t"
43+
44+
45+
class _iChromeConnection:
46+
"""Connection to the iChrome MLE.
47+
48+
This is a simple wrapper to the iChrome MLE interface. It only
49+
supports the parameter commands which reply with a single line
50+
which is all we need to support this on Python-Microscope.
51+
52+
"""
53+
54+
def __init__(self, shared_serial: microscope._utils.SharedSerial) -> None:
55+
self._serial = shared_serial
56+
57+
self._serial.readlines() # discard anything that may be on the line
58+
59+
if self.get_system_type() != "iChrome-MLE":
60+
raise microscope.DeviceError("not an iChrome MLE device")
61+
62+
def _param_command(self, command: bytes) -> bytes:
63+
"""Run command and return raw answer (minus prompt and echo)."""
64+
command = command + b"\r\n"
65+
with self._serial.lock:
66+
self._serial.write(command)
67+
answer = self._serial.read_until(b"\r\n> ")
68+
69+
# When we read, we are reading the whole command console
70+
# including the prompt and even the command is echoed back.
71+
assert answer[: len(command)] == command and answer[-4:] == b"\r\n> "
72+
73+
# Errors are indicated by the string "Error: " at the
74+
# beginning of a new line.
75+
if answer[len(command) : len(command) + 7] == b"Error: ":
76+
base_command = command[:-2]
77+
error_msg = answer[len(command) + 8 : -4]
78+
raise microscope.DeviceError(
79+
"error on command '%s': %s"
80+
% (base_command.decode(), error_msg.decode())
81+
)
82+
83+
# Return the answer minus the "echoed" command and the prompt
84+
# for the next command.
85+
return answer[len(command) : -4]
86+
87+
def param_ref(self, name: bytes) -> bytes:
88+
"""Get parameter value (`param-ref` operator)."""
89+
return self._param_command(b"(param-ref '%s)" % name)
90+
91+
def param_set(self, name: bytes, value: bytes) -> None:
92+
"""Change parameter (`param-set!` operator)."""
93+
answer = self._param_command(b"(param-set! '%s %s)" % (name, value))
94+
status = int(answer)
95+
if status < 0:
96+
raise microscope.DeviceError(
97+
"Failed to set parameter %s (return value %d)"
98+
% (name.decode(), status)
99+
)
100+
101+
def get_serial_number(self) -> str:
102+
return _parse_string(self.param_ref(b"serial-number"))
103+
104+
def get_system_type(self) -> str:
105+
return _parse_string(self.param_ref(b"system-type"))
106+
107+
108+
class _iChromeLaserConnection:
109+
def __init__(self, conn: _iChromeConnection, laser_number: int) -> None:
110+
self._conn = conn
111+
self._param_prefix = b"laser%d:" % laser_number
112+
113+
# We Need to confirm that indeed there is a laser at this
114+
# position. There is no command to check this, we just try to
115+
# read a parameter and check if it works.
116+
try:
117+
self.get_label()
118+
except microscope.DeviceError as ex:
119+
raise microscope.DeviceError(
120+
"failed to get label, probably no laser %d" % laser_number
121+
) from ex
122+
123+
def _param_ref(self, name: bytes) -> bytes:
124+
return self._conn.param_ref(self._param_prefix + name)
125+
126+
def _param_set(self, name: bytes, value: bytes) -> None:
127+
self._conn.param_set(self._param_prefix + name, value)
128+
129+
def get_label(self) -> str:
130+
return _parse_string(self._param_ref(b"label"))
131+
132+
def get_type(self) -> str:
133+
return _parse_string(self._param_ref(b"type"))
134+
135+
def get_delay(self) -> int:
136+
return int(self._param_ref(b"delay"))
137+
138+
def get_enable(self) -> bool:
139+
return _parse_bool(self._param_ref(b"enable"))
140+
141+
def set_enable(self, state: bool) -> None:
142+
value = b"#t" if state else b"#f"
143+
self._param_set(b"enable", value)
144+
145+
def get_cw(self) -> bool:
146+
return _parse_bool(self._param_ref(b"cw"))
147+
148+
def set_cw(self, state: bool) -> None:
149+
value = b"#t" if state else b"#f"
150+
self._param_set(b"cw", value)
151+
152+
def get_use_ttl(self) -> bool:
153+
return _parse_bool(self._param_ref(b"use-ttl"))
154+
155+
def set_use_ttl(self, state: bool) -> None:
156+
value = b"#t" if state else b"#f"
157+
self._param_set(b"use-ttl", value)
158+
159+
def get_level(self) -> float:
160+
return float(self._param_ref(b"level"))
161+
162+
def set_level(self, level: float) -> None:
163+
value = b"%.1f" % level
164+
self._param_set(b"level", value)
165+
166+
def get_status_txt(self) -> str:
167+
return _parse_string(self._param_ref(b"status-txt"))
168+
169+
170+
class _iChromeLaser(microscope.abc.LightSource):
171+
def __init__(self, conn: _iChromeConnection, laser_number: int) -> None:
172+
super().__init__()
173+
self._conn = _iChromeLaserConnection(conn, laser_number)
174+
175+
# FIXME: set values to '0' because we need to pass an int as
176+
# values for settings of type str. Probably a bug on
177+
# Device.set_setting.
178+
self.add_setting("label", "str", self._conn.get_label, None, values=0)
179+
self.add_setting("type", "str", self._conn.get_type, None, values=0)
180+
181+
self.add_setting(
182+
"delay", "int", self._conn.get_delay, None, values=tuple()
183+
)
184+
185+
def get_status(self) -> typing.List[str]:
186+
return self._conn.get_status_txt().split()
187+
188+
def get_is_on(self) -> bool:
189+
if self._conn.get_enable():
190+
if self._conn.get_cw():
191+
return True
192+
else:
193+
# There doesn't seem to be command to check whether
194+
# the TTL line is currently high, so just return True
195+
# if set that way.
196+
return self._conn.get_use_ttl()
197+
else:
198+
return False
199+
200+
def _do_get_power(self) -> float:
201+
return self._conn.get_level() / 100.0
202+
203+
def _do_set_power(self, power: float) -> None:
204+
self._conn.set_level(power * 100.0)
205+
206+
def _do_enable(self) -> None:
207+
self._conn.set_enable(True)
208+
209+
def _do_disable(self) -> None:
210+
self._conn.set_enable(False)
211+
212+
def _do_shutdown(self) -> None:
213+
pass # Nothing to do
214+
215+
@property
216+
def trigger_mode(self) -> microscope.TriggerMode:
217+
return microscope.TriggerMode.BULB
218+
219+
@property
220+
def trigger_type(self) -> microscope.TriggerType:
221+
if self._conn.get_use_ttl():
222+
return microscope.TriggerType.HIGH
223+
else:
224+
return microscope.TriggerType.SOFTWARE
225+
226+
def set_trigger(
227+
self, ttype: microscope.TriggerType, tmode: microscope.TriggerMode
228+
) -> None:
229+
if tmode is not microscope.TriggerMode.BULB:
230+
raise microscope.UnsupportedFeatureError(
231+
"only TriggerMode.BULB mode is supported"
232+
)
233+
234+
# From the manual it seems that cw and ttl parameters are
235+
# mutually exclusive but also still need to be set separately.
236+
if ttype is microscope.TriggerType.HIGH:
237+
self._conn.set_cw(False)
238+
self._conn.set_use_ttl(True)
239+
elif ttype is microscope.TriggerType.SOFTWARE:
240+
self._conn.set_use_ttl(False)
241+
self._conn.set_cw(True)
242+
else:
243+
raise microscope.UnsupportedFeatureError(
244+
"only trigger type HIGH and SOFTWARE are supported"
245+
)
246+
247+
def _do_trigger(self) -> None:
248+
raise microscope.IncompatibleStateError(
249+
"trigger does not make sense in trigger mode bulb, only enable"
250+
)
251+
252+
253+
class iChromeMLE(microscope.abc.Controller):
254+
"""Toptica iChrome MLE (multi-laser engine).
255+
256+
The names of the light devices are `laser1`, `laser2`, `laser3`,
257+
...
258+
259+
"""
260+
261+
def __init__(self, port: str, **kwargs) -> None:
262+
super().__init__(**kwargs)
263+
self._lasers: typing.Dict[str, _iChromeLaser] = {}
264+
265+
# Setting specified on the manual (M-051 version 03)
266+
serial_conn = serial.Serial(
267+
port=port,
268+
baudrate=115200,
269+
timeout=1,
270+
bytesize=serial.EIGHTBITS,
271+
stopbits=serial.STOPBITS_ONE,
272+
parity=serial.PARITY_NONE,
273+
xonxoff=False,
274+
rtscts=False,
275+
dsrdtr=False,
276+
)
277+
shared_serial = microscope._utils.SharedSerial(serial_conn)
278+
ichrome_connection = _iChromeConnection(shared_serial)
279+
280+
_LOGGER.info("Connected to %s", ichrome_connection.get_serial_number())
281+
282+
# According to the manual the iChrome can have between 3 and 5
283+
# lasers. There doesn't seem to be a simple command to check
284+
# what's installed, we'd have to parse the whole summary
285+
# table. So we try/except to each laser line.
286+
for i in range(1, 6):
287+
name = "laser%d" % i
288+
try:
289+
laser = _iChromeLaser(ichrome_connection, i)
290+
except microscope.DeviceError:
291+
_LOGGER.info("no %s available", name)
292+
continue
293+
else:
294+
_LOGGER.info("found %s on iChrome MLE", name)
295+
self._lasers[name] = laser
296+
297+
@property
298+
def devices(self) -> typing.Dict[str, _iChromeLaser]:
299+
return self._lasers

0 commit comments

Comments
 (0)