Source code for microscope.lasers.toptica

#!/usr/bin/env python3

## Copyright (C) 2020 David Miguel Susano Pinto <>
## This file is part of Microscope.
## Microscope is free software: you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation, either version 3 of the License, or
## (at your option) any later version.
## Microscope is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## GNU General Public License for more details.
## You should have received a copy of the GNU General Public License
## along with Microscope.  If not, see <>.

import logging
import re
import threading
import typing

import serial

import microscope

_logger = logging.getLogger(__name__)

class _SharedSerial:
    def __init__(self, serial: serial.Serial) -> None:
        self._serial = serial
        self._lock = threading.RLock()

    def lock(self) -> threading.RLock:
        return self._lock

    def readline(self) -> bytes:
        with self._lock:
            return self._serial.readline()

    def read_until(
        self, terminator: bytes = b"\n", size: typing.Optional[int] = None
    ) -> bytes:
        with self._lock:
            return self._serial.read_until(terminator=terminator, size=size)

    def write(self, data: bytes) -> int:
        with self._lock:
            return self._serial.write(data)

    def readlines(self, hint: int = -1) -> bytes:
        with self._lock:
            return self._serial.readlines(hint)

[docs]def _get_table_value(table: bytes, key: bytes) -> bytes: """Get the value for a key in a table/multiline output. Some commands return something like a table of key/values. There may be even empty lines on this table. This searches for the first line with a specific key (hopefully there's only one line with such key) and returns the associated value. """ # Key might be the first line, hence '(?:^|\r\n)' match ="(?:^|\r\n) *" + key + b": (.*)\r\n", table) if match is None: raise microscope.DeviceError( "failed to find key %s on table: %s" % (key, table) ) return
[docs]class _iBeamConnection: """Connection to a specific Toptica iBeam smart laser. This class wraps the serial connection to the device, and provides access to some of its commands performing most of the parsing and validation. Args: port (str): port name (Windows) or path to port (everything else) to connect to. For example, `/dev/ttyS1`, `COM1`, or `/dev/cuad1`. """ def __init__(self, port: str): # From the Toptica iBeam SMART manual: # Direct connection via COMx with 115200,8,N,1 and serial # interface handshake "none". That means that no hardware # handshake (DTR, RTS) and no software handshake (XON,XOFF) of # the underlying operating system is supported. serial_conn = serial.Serial( port=port, baudrate=115200, timeout=1.0, bytesize=serial.EIGHTBITS, stopbits=serial.STOPBITS_ONE, parity=serial.PARITY_NONE, xonxoff=False, rtscts=False, dsrdtr=False, ) self._serial = _SharedSerial(serial_conn) # We don't know what is the current verbosity state and so we # don't know yet what we should be reading back. So blindly # set to the level we want, flush all output, and then check # if indeed this is a Toptica iBeam device. with self._serial.lock: self._serial.write(b"echo off\r\n") self._serial.write(b"prompt off\r\n") # The talk level we want is 'usual'. In theory we should # be able to use 'quiet' which only answers queries but in # practice 'quiet' does not answer some queries like 'show # serial'. self._serial.write(b"talk usual\r\n") self._serial.readlines() # discard all pending lines # Empty command does nothing and returns nothing extra so we # use it to ensure this at least behaves like a Toptica iBeam. try: self.command(b"") except microscope.DeviceError as e: raise microscope.InitialiseError( "Failed to confirm Toptica iBeam on %s" % (port) ) from e answer = self.command(b"show serial") if not answer.startswith(b"SN: "): raise microscope.DeviceError( "Failed to parse serial from %s" % answer )"got connection to Toptica iBeam %s", answer.decode())
[docs] def command(self, command: bytes) -> bytes: """Run command and return answer after minimal validation. The output of a command has the format:: \r\nANSWER\r\n[OK]\r\n The returned bytes only include `ANSWER` without its own final `\r\n`. This means that the return value might be an empty array of bytes. """ # We expect to be on 'talk usual' mode without prompt so each # command will end with [OK] on its own line. with self._serial.lock: self._serial.write(command + b"\r\n") # An answer always starts with \r\n so there will be one # before [OK] even if this command is not a query. answer = self._serial.read_until(b"\r\n[OK]\r\n") if not answer.startswith(b"\r\n"): raise microscope.DeviceError( "answer to command %s does not start with CRLF." " This may be leftovers from a previous command:" " %s" % (command, answer) ) if not answer.endswith(b"\r\n[OK]\r\n"): raise microscope.DeviceError( "Command %s failed or failed to read answer: %s" % (command, answer) ) # If an error occurred, the answer still ends in [OK]. We # need to check if the second line (first line is \r\n) is an # error code with the format "%SYS-L-XXX, error description" # where L is the error level (I for Information, W for # Warning, E for Error, and F for Fatal), and XXX is the error # code number. if answer[2:7] == b"%SYS-" and answer[7] != ord(b"I"): # Errors of level I (information) should not raise an # exception since they can be replies to normal commands. raise microscope.DeviceError( "Command %s failed: %s" % (command, answer) ) # Exclude the first \r\n, the \r\n from a possible answer, and # the final [OK]\r\n return answer[2:-8]
[docs] def laser_on(self) -> None: """Activate LD driver.""" self.command(b"laser on")
[docs] def laser_off(self) -> None: """Deactivate LD driver.""" self.command(b"laser off")
[docs] def set_normal_channel_power(self, power: float) -> None: """Set power in mW for channel 2 (normal operating level channel). We don't have channel number as an argument because we only want to be setting the power via channel 2 (channel 1 is the bias and we haven't seen a laser with a channel 3 yet). """ self.command(b"channel 2 power %f" % power)
[docs] def show_power_uW(self) -> float: """Returns actual laser power in µW.""" answer = self.command(b"show power") if not answer.startswith(b"PIC = ") and not answer.endswith(b" uW "): raise microscope.DeviceError( "failed to parse power from answer: %s" % answer ) return float(answer[7:-5])
[docs] def status_laser(self) -> bytes: """Returns actual status of the LD driver (ON or OFF).""" return self.command(b"status laser")
[docs] def show_max_power(self) -> float: # There should be a cleaner way to get these, right? We can # query the current limits (mA) but how do we go from there to # the power limits (mW)? table = self.command(b"show satellite") key = _get_table_value(table, b"Pmax") if not key.endswith(b" mW"): raise microscope.DeviceError("failed to parse power from %s" % key) return float(key[:-3])
[docs]class TopticaiBeam( """Toptica iBeam smart laser. Control of laser power is performed by setting the power level on the normal channel (#2) only. The bias channel (#1) is left unmodified and so defines the lowest level power. """ def __init__(self, port: str, **kwargs) -> None: super().__init__(**kwargs) self._conn = _iBeamConnection(port) # The Toptica iBeam has up to five operation modes, named # "channels" on the documentation. Only the first three # channels have any sort of documentation: # # Ch 1: bias level channel # Ch 2: normal operating level channel # Ch 3: only used at high-power models # # We haven't come across a laser with a channel 3 so we are # ignoring it until then. So we just leave the bias channel # (1) alone and control power via the normal channel (2). self._max_power = self._conn.show_max_power()
[docs] def initialize(self) -> None: pass
def _on_shutdown(self) -> None: pass
[docs] def get_status(self) -> typing.List[str]: status: typing.List[str] = [] return status
[docs] def enable(self) -> None: self._conn.laser_on()
[docs] def disable(self) -> None: self._conn.laser_off()
[docs] def get_is_on(self) -> bool: state = self._conn.status_laser() if state == b"ON": return True elif state == b"OFF": return False else: raise microscope.DeviceError( "Unexpected laser status: %s" % state.decode() )
def _get_max_power_mw(self) -> float: return self._max_power def _get_power_mw(self) -> float: return self._conn.show_power_uW() * (10 ** -3) def _set_power_mw(self, mw: float) -> None: self._conn.set_normal_channel_power(mw) def _do_set_power(self, power: float) -> None: self._set_power_mw(power * self._get_max_power_mw()) def _do_get_power(self) -> float: return self._get_power_mw() / self._get_max_power_mw()