|
| 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