Skip to content

Commit

Permalink
feat(api): Add modules api to hardware_control
Browse files Browse the repository at this point in the history
Closes #2237
  • Loading branch information
sfoster1 committed Oct 4, 2018
1 parent f956902 commit dea2bb8
Show file tree
Hide file tree
Showing 11 changed files with 616 additions and 14 deletions.
3 changes: 2 additions & 1 deletion api/opentrons/drivers/temp_deck/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,8 @@ def update_temperature(self, default=None) -> str:
try:
self._update_thread = Thread(
target=self._recursive_update_temperature,
args=[DEFAULT_COMMAND_RETRIES])
args=[DEFAULT_COMMAND_RETRIES],
name='Tempdeck recursive update temperature')
self._update_thread.start()
except (TempDeckError, SerialException, SerialNoResponse) as e:
return str(e)
Expand Down
19 changes: 16 additions & 3 deletions api/opentrons/hardware_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@
import functools
import logging
import enum
from typing import Dict, Union
from typing import Dict, Union, List, Optional
from opentrons import types
from .simulator import Simulator
try:
from .controller import Controller
except ModuleNotFoundError:
# implies windows
Controller = None # type: ignore
from . import modules


mod_log = logging.getLogger(__name__)
Expand Down Expand Up @@ -108,15 +109,23 @@ def build_hardware_controller(
@classmethod
def build_hardware_simulator(
cls,
attached_instruments,
attached_instruments: Dict[types.Mount, Optional[str]] = None,
attached_modules: List[modules.AbstractModule] = None,
config: dict = None,
loop: asyncio.AbstractEventLoop = None) -> 'API':
""" Build a simulating hardware controller.
This method may be used both on a real robot and on dev machines.
Multiple simulating hardware controllers may be active at one time.
"""
return cls(Simulator(attached_instruments, config, loop),
if None is attached_instruments:
attached_instruments = {types.Mount.LEFT: None,
types.Mount.RIGHT: None}
if None is attached_modules:
attached_modules = []
return cls(Simulator(attached_instruments,
attached_modules,
config, loop),
config=config, loop=loop)

# Query API
Expand Down Expand Up @@ -262,3 +271,7 @@ async def set_flow_rate(self, mount, aspirate=None, dispense=None):
@_log_call
async def set_pick_up_current(self, mount, amperes):
pass

@_log_call
async def discover_modules(self):
return self._backend.get_attached_modules()
7 changes: 6 additions & 1 deletion api/opentrons/hardware_control/controller.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import os
import fcntl
import threading
from typing import Dict
from typing import Dict, List
from opentrons.util import environment
from opentrons.drivers.smoothie_drivers import driver_3_0
from opentrons.legacy_api.robot import robot_configs
from . import modules


_lock = threading.Lock()

Expand Down Expand Up @@ -75,3 +77,6 @@ def home(self):

def get_attached_instruments(self, mount):
return self._smoothie_driver.read_pipette_model(mount.name.lower())

def get_attached_modules(self) -> List[modules.AbstractModule]:
return modules.discover_and_connect()
228 changes: 228 additions & 0 deletions api/opentrons/hardware_control/modules/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import os
import logging
import re
import asyncio
import abc
from typing import List, Type, Dict, Union
from .magdeck import MagDeck
from .tempdeck import TempDeck

log = logging.getLogger(__name__)

PORT_SEARCH_TIMEOUT = 5.5

# avrdude_options
PART_NO = 'atmega32u4'
PROGRAMMER_ID = 'avr109'
BAUDRATE = '57600'


class UnsupportedModuleError(Exception):
pass


class AbsentModuleError(Exception):
pass


class AbstractModule(abc.ABC):
""" Defines the common methods of a module. """

@classmethod
@abc.abstractmethod
def build(cls, port: str, simulating: bool = False) -> 'AbstractModule':
""" Modules should always be created using this factory.
This lets the (perhaps blocking) work of connecting to and initializing
a module be in a place that can be async.
"""
pass

@abc.abstractmethod
def disengage(self):
""" Deactivate the module. """
pass

@property
@abc.abstractmethod
def status(self) -> str:
""" Return some string describing status. """
pass

@property
@abc.abstractmethod
def device_info(self) -> Dict[str, str]:
""" Return a dict of the module's static information (serial, etc)"""
pass

@property
@abc.abstractmethod
def live_data(self) -> Dict[str, str]:
""" Return a dict of the module's dynamic information """
pass


AbstractModule.register(MagDeck)
AbstractModule.register(TempDeck)

SUPPORTED_MODULES: Dict[str, Union[Type[MagDeck], Type[TempDeck]]]\
= {'magdeck': MagDeck, 'tempdeck': TempDeck}


def build_simulated(which: str) -> AbstractModule:
return SUPPORTED_MODULES[which].build('', True)


def discover_and_connect() -> List[AbstractModule]:
""" Scan for connected modules and instantiate handler classes
"""
if os.environ.get('RUNNING_ON_PI') and os.path.isdir('/dev/modules'):
devices = os.listdir('/dev/modules')
else:
devices = []

discovered_modules = []

module_port_regex = re.compile('|'.join(SUPPORTED_MODULES.keys()), re.I)
for port in devices:
match = module_port_regex.search(port)
if match:
name = match.group().lower()
try:
module_class = SUPPORTED_MODULES[name]
except KeyError:
log.warning("Unexpected module connected: {} on {}"
.format(name, port))
continue
absolute_port = '/dev/modules/{}'.format(port)
discovered_modules.append(module_class.build(absolute_port))

log.debug('Discovered modules: {}'.format(discovered_modules))

return discovered_modules


async def enter_bootloader(module):
"""
Using the driver method, enter bootloader mode of the atmega32u4.
The bootloader mode opens a new port on the uC to upload the hex file.
After receiving a 'dfu' command, the firmware provides a 3-second window to
close the current port so as to do a clean switch to the bootloader port.
The new port shows up as 'ttyn_bootloader' on the pi; upload fw through it.
NOTE: Modules with old bootloader will have the bootloader port show up as
a regular module port- 'ttyn_tempdeck'/ 'ttyn_magdeck' with the port number
being either different or same as the one that the module was originally on
So we check for changes in ports and use the appropriate one
"""
# Required for old bootloader
ports_before_dfu_mode = await _discover_ports()

module._driver.enter_programming_mode()
module.disconnect()
new_port = ''
try:
new_port = await asyncio.wait_for(
_port_poll(_has_old_bootloader(module), ports_before_dfu_mode),
PORT_SEARCH_TIMEOUT)
except asyncio.TimeoutError:
pass
return new_port


async def update_firmware(module, firmware_file_path, config_file_path, loop):
"""
Run avrdude firmware upload command. Switch back to normal module port
Note: For modules with old bootloader, the kernel could assign the module
a new port after the update (since the board is automatically reset).
Scan for such a port change and use the appropriate port
"""
# TODO: Make sure the module isn't in the middle of operation

ports_before_update = await _discover_ports()

proc = await asyncio.create_subprocess_exec(
'avrdude', '-C{}'.format(config_file_path), '-v',
'-p{}'.format(PART_NO),
'-c{}'.format(PROGRAMMER_ID),
'-P{}'.format(module.port),
'-b{}'.format(BAUDRATE), '-D',
'-Uflash:w:{}:i'.format(firmware_file_path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, loop=loop)
await proc.wait()

_result = await proc.communicate()
result = _result[1].decode()
log.debug(result)
log.debug("Switching back to non-bootloader port")
module._port = _port_on_mode_switch(ports_before_update)

return _format_avrdude_response(result)


def _format_avrdude_response(raw_response):
response = {'message': '', 'avrdudeResponse': ''}
avrdude_log = ''
for line in raw_response.splitlines():
if 'avrdude:' in line and line != raw_response.splitlines()[1]:
avrdude_log += line.lstrip('avrdude:') + '..'
if 'flash verified' in line:
response['message'] = 'Firmware update successful'
response['avrdudeResponse'] = line.lstrip('avrdude: ')
if not response['message']:
response['message'] = 'Firmware update failed'
response['avrdudeResponse'] = avrdude_log
return response


async def _port_on_mode_switch(ports_before_switch):
ports_after_switch = await _discover_ports()
new_port = ''
if ports_after_switch and \
len(ports_after_switch) >= len(ports_before_switch) and \
not set(ports_before_switch) == set(ports_after_switch):
new_ports = list(filter(
lambda x: x not in ports_before_switch,
ports_after_switch))
if len(new_ports) > 1:
raise OSError('Multiple new ports found on mode switch')
new_port = '/dev/modules/{}'.format(new_ports[0])
return new_port


async def _port_poll(is_old_bootloader, ports_before_switch=None):
"""
Checks for the bootloader port
"""
new_port = ''
while not new_port:
if is_old_bootloader:
new_port = await _port_on_mode_switch(ports_before_switch)
else:
ports = await _discover_ports()
if ports:
discovered_ports = list(filter(
lambda x: x.endswith('bootloader'), ports))
if len(discovered_ports) == 1:
new_port = '/dev/modules/{}'.format(discovered_ports[0])
await asyncio.sleep(0.05)
return new_port


def _has_old_bootloader(module):
return True if module.device_info.get('model') == 'temp_deck_v1' or \
module.device_info.get('model') == 'temp_deck_v2' else False


async def _discover_ports():
if os.environ.get('RUNNING_ON_PI') and os.path.isdir('/dev/modules'):
for attempt in range(2):
# Measure for race condition where port is being switched in
# between calls to isdir() and listdir()
try:
return os.listdir('/dev/modules')
except (FileNotFoundError, OSError):
pass
await asyncio.sleep(2)
raise Exception("No /dev/modules found. Try again")
Loading

0 comments on commit dea2bb8

Please sign in to comment.