Skip to content

Commit

Permalink
feat(api): allow robot to discover thermocycler and return live data (#…
Browse files Browse the repository at this point in the history
…3239)

In addition to allowing for thermocyclers to be discovered by the hardware controller and returned
from a GET /modules call, this adds a synchronous adapter to the module class instances to allow for
communication with peripherals with out clogging the main pipes. Now all attached modules to a
hardware controller instance will be wrapped in a synchronous adapter. Calls to GET
/modules/{serial}/data should return the expected payload in the presence of a connected
thermocycler board. Note: the firmware should shim the serial and model strings until further
development on the FW side.

Closes #2958
  • Loading branch information
b-cooper authored Mar 22, 2019
1 parent f69da42 commit 34af269
Show file tree
Hide file tree
Showing 23 changed files with 375 additions and 242 deletions.
129 changes: 10 additions & 119 deletions api/src/opentrons/drivers/temp_deck/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
import logging
from threading import Event, Thread
from time import sleep
from typing import Optional
from typing import Optional, Mapping
from serial.serialutil import SerialException

from opentrons.drivers import serial_communication
from opentrons.drivers import serial_communication, utils
from opentrons.drivers.serial_communication import SerialNoResponse

'''
Expand Down Expand Up @@ -40,120 +40,11 @@
TEMP_DECK_COMMAND_TERMINATOR = '\r\n\r\n'
TEMP_DECK_ACK = 'ok\r\nok\r\n'

# Number of digits after the decimal point for temperatures being sent
# to/from Temp-Deck
GCODE_ROUNDING_PRECISION = 0


class TempDeckError(Exception):
pass


class ParseError(Exception):
pass


def _parse_string_value_from_substring(substring) -> str:
'''
Returns the ascii value in the expected string "N:aa11bb22", where "N" is
the key, and "aa11bb22" is string value to be returned
'''
try:
value = substring.split(':')[1]
return str(value)
except (ValueError, IndexError, TypeError, AttributeError):
log.exception('Unexpected arg to _parse_string_value_from_substring:')
raise ParseError(
'Unexpected arg to _parse_string_value_from_substring: {}'.format(
substring))


def _parse_number_from_substring(substring) -> Optional[float]:
'''
Returns the number in the expected string "N:12.3", where "N" is the
key, and "12.3" is a floating point value
For the temp-deck's temperature response, one expected input is something
like "T:none", where "none" should return a None value
'''
try:
value = substring.split(':')[1]
if value.strip().lower() == 'none':
return None
return round(float(value), GCODE_ROUNDING_PRECISION)
except (ValueError, IndexError, TypeError, AttributeError):
log.exception('Unexpected argument to _parse_number_from_substring:')
raise ParseError(
'Unexpected argument to _parse_number_from_substring: {}'.format(
substring))


def _parse_key_from_substring(substring) -> str:
'''
Returns the axis in the expected string "N:12.3", where "N" is the
key, and "12.3" is a floating point value
'''
try:
return substring.split(':')[0]
except (ValueError, IndexError, TypeError, AttributeError):
log.exception('Unexpected argument to _parse_key_from_substring:')
raise ParseError(
'Unexpected argument to _parse_key_from_substring: {}'.format(
substring))


def _parse_temperature_response(temperature_string) -> dict:
'''
Example input: "T:none C:25"
'''
err_msg = 'Unexpected argument to _parse_temperature_response: {}'.format(
temperature_string)
if not temperature_string or \
not isinstance(temperature_string, str):
raise ParseError(err_msg)
parsed_values = temperature_string.strip().split(' ')
if len(parsed_values) < 2:
log.error(err_msg)
raise ParseError(err_msg)

data = {
_parse_key_from_substring(s): _parse_number_from_substring(s)
for s in parsed_values[:2]
}
if 'C' not in data or 'T' not in data:
raise ParseError(err_msg)
data = {
'current': data['C'],
'target': data['T']
}
return data


def _parse_device_information(device_info_string) -> dict:
'''
Parse the temp-deck's device information response.
Example response from temp-deck: "serial:aa11 model:bb22 version:cc33"
'''
error_msg = 'Unexpected argument to _parse_device_information: {}'.format(
device_info_string)
if not device_info_string or \
not isinstance(device_info_string, str):
raise ParseError(error_msg)
parsed_values = device_info_string.strip().split(' ')
if len(parsed_values) < 3:
log.error(error_msg)
raise ParseError(error_msg)
res = {
_parse_key_from_substring(s): _parse_string_value_from_substring(s)
for s in parsed_values[:3]
}
for key in ['model', 'version', 'serial']:
if key not in res:
raise ParseError(error_msg)
return res


class TempDeck:
def __init__(self, config={}):
self.run_flag = Event()
Expand Down Expand Up @@ -203,7 +94,7 @@ def deactivate(self) -> str:

def set_temperature(self, celsius) -> str:
self.run_flag.wait()
celsius = round(float(celsius), GCODE_ROUNDING_PRECISION)
celsius = round(float(celsius), utils.GCODE_ROUNDING_PRECISION)
try:
self._send_command(
'{0} S{1}'.format(GCODES['SET_TEMP'], celsius))
Expand Down Expand Up @@ -251,7 +142,7 @@ def status(self) -> str:
else:
return 'idle'

def get_device_info(self) -> dict:
def get_device_info(self) -> Mapping[str, str]:
'''
Queries Temp-Deck for it's build version, model, and serial number
Expand Down Expand Up @@ -346,24 +237,24 @@ def _recursive_write_and_return(self, cmd, timeout, retries):
return self._recursive_write_and_return(
cmd, timeout, retries)

def _recursive_update_temperature(self, retries) -> Optional[dict]:
def _recursive_update_temperature(self, retries):
try:
res = self._send_command(GCODES['GET_TEMP'])
res = _parse_temperature_response(res)
res = utils.parse_temperature_response(res)
self._temperature.update(res)
return None
except ParseError as e:
except utils.ParseError as e:
retries -= 1
if retries <= 0:
raise TempDeckError(e)
sleep(DEFAULT_STABILIZE_DELAY)
return self._recursive_update_temperature(retries)

def _recursive_get_info(self, retries) -> dict:
def _recursive_get_info(self, retries) -> Mapping[str, str]:
try:
device_info = self._send_command(GCODES['DEVICE_INFO'])
return _parse_device_information(device_info)
except ParseError as e:
return utils.parse_device_information(device_info)
except utils.ParseError as e:
retries -= 1
if retries <= 0:
raise TempDeckError(e)
Expand Down
52 changes: 31 additions & 21 deletions api/src/opentrons/drivers/thermocycler/driver.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import logging
import os
import threading
Expand All @@ -7,9 +8,9 @@
except ModuleNotFoundError:
select = None # type: ignore
from time import sleep
from typing import Optional
from typing import Optional, Mapping
from serial.serialutil import SerialException
from opentrons.drivers import serial_communication
from opentrons.drivers import serial_communication, utils
from opentrons.drivers.serial_communication import SerialNoResponse


Expand All @@ -26,7 +27,8 @@
'GET_PLATE_TEMP': 'M105',
'SET_RAMP_RATE': 'M566',
'PAUSE': '',
'DEACTIVATE': 'M18'
'DEACTIVATE': 'M18',
'DEVICE_INFO': 'M115'
}


Expand Down Expand Up @@ -58,10 +60,6 @@ class ThermocyclerError(Exception):
pass


class ParseError(Exception):
pass


class TCPoller(threading.Thread):
def __init__(self, port, interrupt_callback, status_callback):
if not select:
Expand Down Expand Up @@ -218,14 +216,15 @@ def __init__(self, interrupt_callback):
self._lid_status = None
self._interrupt_cb = interrupt_callback

def connect(self, port: str) -> 'Thermocycler':
async def connect(self, port: str) -> 'Thermocycler':
self.disconnect()
self._poller = TCPoller(
port, self._interrupt_callback, self._temp_status_update_callback)

# Check initial device lid state
_lid_status_res = self._write_and_wait(GCODES['GET_LID_STATUS'])
self._lid_status = _lid_status_res.split()[-1].lower()
_lid_status_res = await self._write_and_wait(GCODES['GET_LID_STATUS'])
if _lid_status_res:
self._lid_status = _lid_status_res.split()[-1].lower()
return self

def disconnect(self) -> 'Thermocycler':
Expand All @@ -243,20 +242,23 @@ def is_connected(self) -> bool:
return False
return self._poller.is_alive()

def open(self):
self._write_and_wait(GCODES['OPEN_LID'])
async def open(self):
await self._write_and_wait(GCODES['OPEN_LID'])
self._lid_status = 'open'

def close(self):
self._write_and_wait(GCODES['CLOSE_LID'])
async def close(self):
await self._write_and_wait(GCODES['CLOSE_LID'])
self._lid_status = 'closed'

def set_temperature(self, temp, hold_time=None, ramp_rate=None):
async def set_temperature(self,
temp: float,
hold_time: float = None,
ramp_rate: float = None) -> None:
if ramp_rate:
ramp_cmd = '{} S{}'.format(GCODES['SET_RAMP_RATE'], ramp_rate)
self._write_and_wait(ramp_cmd)
await self._write_and_wait(ramp_cmd)
temp_cmd = _build_temp_code(temp, hold_time)
self._write_and_wait(temp_cmd)
await self._write_and_wait(temp_cmd)

def _temp_status_update_callback(self, temperature_response):
# Payload is shaped like `T:95.0 C:77.4 H:600` where T is the
Expand All @@ -265,7 +267,10 @@ def _temp_status_update_callback(self, temperature_response):
val_dict = {}
data = [d.split(':') for d in temperature_response.split()]
for datum in data:
val_dict[datum[0]] = datum[1]
cleanValue = datum[1]
if cleanValue == 'none':
cleanValue = None
val_dict[datum[0]] = cleanValue

self._current_temp = val_dict['C']
self._target_temp = val_dict['T']
Expand Down Expand Up @@ -312,10 +317,14 @@ def port(self) -> Optional[str]:
def lid_status(self):
return self._lid_status

def get_device_info(self):
raise NotImplementedError
async def get_device_info(self) -> Mapping[str, str]:
_device_info_res = await self._write_and_wait(GCODES['DEVICE_INFO'])
if _device_info_res:
return utils.parse_device_information(_device_info_res)
else:
raise ThermocyclerError("Thermocycler did not return device info")

def _write_and_wait(self, command):
async def _write_and_wait(self, command):
ret = None

def cb(cmd):
Expand All @@ -325,6 +334,7 @@ def cb(cmd):
self._poller.send(command, cb)

while None is ret:
await asyncio.sleep(0.05)
pass
return ret

Expand Down
Loading

0 comments on commit 34af269

Please sign in to comment.