diff --git a/api/Pipfile.lock b/api/Pipfile.lock index 4bb207d4eb9..abdeedcc7a3 100644 --- a/api/Pipfile.lock +++ b/api/Pipfile.lock @@ -872,10 +872,10 @@ }, "urllib3": { "hashes": [ - "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", - "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" + "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", + "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" ], - "version": "==1.25.8" + "version": "==1.25.9" }, "urwid": { "hashes": [ diff --git a/api/mypy.ini b/api/mypy.ini index b0571566ca2..97f303583af 100644 --- a/api/mypy.ini +++ b/api/mypy.ini @@ -1,5 +1,5 @@ [mypy] -ignore_missing_imports=false +ignore_missing_imports=False check_untyped_defs=True [mypy-opentrons.legacy_api.*] diff --git a/api/src/opentrons/api/session.py b/api/src/opentrons/api/session.py index a04ca7951fc..f235e768819 100755 --- a/api/src/opentrons/api/session.py +++ b/api/src/opentrons/api/session.py @@ -7,6 +7,7 @@ from typing import List, Dict, Any, Optional from uuid import uuid4 from opentrons.drivers.smoothie_drivers.driver_3_0 import SmoothieAlarm +from opentrons.drivers.rpi_drivers.gpio_simulator import SimulatingGPIOCharDev from opentrons import robot from opentrons.broker import Broker from opentrons.commands import tree, types as command_types @@ -267,6 +268,7 @@ def _hw_iface(self): def prepare(self): if not self._use_v2: robot.discover_modules() + self.refresh() def get_instruments(self): @@ -372,6 +374,7 @@ def on_command(message): robot.broker = self._broker # we don't rely on being connected anymore so make sure we are robot.connect() + robot._driver.gpio_chardev = SimulatingGPIOCharDev('sim_chip') robot.cache_instrument_models() robot.disconnect() @@ -504,6 +507,11 @@ def on_command(message): 'Internal error: v1 should only be used for python' if not robot.is_connected(): robot.connect() + # backcompat patch: gpiod can only be used from one place so + # we have to give the instance of the smoothie driver used by + # the apiv1 singletons a reference to the main gpio driver + robot._driver.gpio_chardev\ + = self._hardware._backend.gpio_chardev self.resume() self._pre_run_hooks() robot.cache_instrument_models() diff --git a/api/src/opentrons/drivers/rpi_drivers/__init__.py b/api/src/opentrons/drivers/rpi_drivers/__init__.py index e69de29bb2d..07e66d81449 100755 --- a/api/src/opentrons/drivers/rpi_drivers/__init__.py +++ b/api/src/opentrons/drivers/rpi_drivers/__init__.py @@ -0,0 +1,18 @@ +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .dev_types import GPIODriverLike + +MODULE_LOG = logging.getLogger(__name__) + + +def build_gpio_chardev(chip_name: str) -> 'GPIODriverLike': + try: + from .gpio import GPIOCharDev + return GPIOCharDev(chip_name) + except (ImportError, OSError): + MODULE_LOG.info( + 'Failed to initialize character device, cannot control gpios') + from .gpio_simulator import SimulatingGPIOCharDev + return SimulatingGPIOCharDev(chip_name) diff --git a/api/src/opentrons/drivers/rpi_drivers/dev_types.py b/api/src/opentrons/drivers/rpi_drivers/dev_types.py new file mode 100644 index 00000000000..898d46d87be --- /dev/null +++ b/api/src/opentrons/drivers/rpi_drivers/dev_types.py @@ -0,0 +1,59 @@ +from typing import Dict, Tuple +from typing_extensions import Protocol + + +class GPIODriverLike(Protocol): + """ Interface for the GPIO drivers + """ + def __init__(self, chip_name: str): + ... + + @property + def chip(self) -> str: + ... + + @property + def lines(self) -> Dict[int, str]: + ... + + async def setup(self): + ... + + def set_high(self, offset: int): + ... + + def set_low(self, offset: int): + ... + + def set_button_light(self, + red: bool = False, + green: bool = False, + blue: bool = False): + ... + + def set_rail_lights(self, on: bool = True): + ... + + def set_reset_pin(self, on: bool = True): + ... + + def set_isp_pin(self, on: bool = True): + ... + + def set_halt_pin(self, on: bool = True): + ... + + def get_button_light(self) -> Tuple[bool, bool, bool]: + ... + + def get_rail_lights(self) -> bool: + ... + + def read_button(self) -> bool: + ... + + def read_window_switches(self) -> bool: + ... + + def release_line(self, offset: int): + ... diff --git a/api/src/opentrons/drivers/rpi_drivers/gpio.py b/api/src/opentrons/drivers/rpi_drivers/gpio.py index 400ae486972..69539db185b 100755 --- a/api/src/opentrons/drivers/rpi_drivers/gpio.py +++ b/api/src/opentrons/drivers/rpi_drivers/gpio.py @@ -1,34 +1,13 @@ -import os -from sys import platform -from typing import Tuple -""" -Raspberry Pi GPIO control module - -Read/Write from RPi GPIO pins is performed by exporting a pin by number, -writing the direction for the pin, and then writing a high or low signal -to the pin value. - -To export a pin, find the desired pin in OUTPUT_PINS or INPUT_PINS, and write -the corresponding number to `/sys/class/gpio/export`. - -After export, set pin direction by writing either "in" or "out" to -`/sys/class/gpio/gpio/direction`. - -After direction is set, set a pin high by writing a "1" or set the pin low by -writing "0" (zero) to `/sys/class/gpio/gpio/value`. - -This library abstracts those operations by providing an `initialize` function -to set up all pins correctly, and then providing `set_low` and `set_high` -functions that accept a pin number. The OUTPUT_PINS and INPUT_PINS dicts -provide pin-mappings so calling code does not need to use raw integers. -""" +import asyncio +import logging +from typing import Dict, Tuple import time -IN = "in" -OUT = "out" +import gpiod # type: ignore -LOW = "0" -HIGH = "1" +""" +Raspberry Pi GPIO control module +""" # Note: in test pins are sorted by value, so listing them in that order here # makes it easier to read the tests. Pin numbers defined by bridge wiring @@ -49,171 +28,129 @@ 'WINDOW_INPUT': 20 } -_path_prefix = "/sys/class/gpio" - - -def _enable_pin(pin, direction): - """ - In order to enable a GPIO pin, the pin number must be written into - /sys/class/gpio/export, and then the direction ("in" or "out" must be - written to /sys/class/gpio/gpio/direction - - :param pin: An integer corresponding to the GPIO number of the pin in RPi - GPIO board numbering (not physical pin numbering) - - :param direction: "in" or "out" - """ - _write_value(pin, "{}/export".format(_path_prefix)) - _write_value(direction, "{0}/gpio{1}/direction".format(_path_prefix, pin)) - - -def _write_value(value, path): - """ - Writes specified value into path. Note that the value is wrapped in single - quotes in the command, to prevent injecting bash commands. - :param value: The value to write (usually a number or string) - :param path: A valid system path - """ - base_command = "echo '{0}' > {1}" - # There is no common method for redirecting stderr to a null sink, so the - # command string is platform-dependent - if platform == 'win32': - command = "{0} > NUL".format(base_command) - else: - command = "exec 2> /dev/null; {0}".format(base_command) - os.system(command.format(value, path)) - - -def _read_value(path): - """ - Reads value of specified path. - :param path: A valid system path - """ - read_value = 0 - if not os.path.exists(path): - # Path will generally only exist on a Raspberry Pi - pass - else: - with open(path) as f: - read_value = int(f.read()) - return read_value - - -def set_high(pin): - """ - Sets a pin high by number. This pin must have been previously initialized - and set up as with direction of OUT, otherwise this operation will not - behave as expected. - - High represents "on" for lights, and represents normal running state for - HALT and RESET pins. - - :param pin: An integer corresponding to the GPIO number of the pin in RPi - GPIO board numbering (not physical pin numbering) - """ - _write_value(HIGH, "{0}/gpio{1}/value".format(_path_prefix, pin)) - - -def set_low(pin): - """ - Sets a pin low by number. This pin must have been previously initialized - and set up as with direction of OUT, otherwise this operation will not - behave as expected. - - Low represents "off" for lights, and writing the RESET or HALT pins low - will terminate Smoothie operation until written high again. - - :param pin: An integer corresponding to the GPIO number of the pin in RPi - GPIO board numbering (not physical pin numbering) - """ - _write_value(LOW, "{0}/gpio{1}/value".format(_path_prefix, pin)) - - -def read(pin): - """ - Reads a pin's value. If the pin has been previously initialized with - a direction of IN, the value will be the input signal. If pin is configured - as OUT, the value will be the current output state. - - :param pin: An integer corresponding to the GPIO number of the pin in RPi - GPIO board numbering (not physical pin numbering) - """ - return _read_value("{0}/gpio{1}/value".format(_path_prefix, pin)) - - -def initialize(): - """ - All named pins in OUTPUT_PINS and INPUT_PINS are exported, and set the - HALT pin high (normal running state), since the default value after export - is low. - """ - for pin in sorted(OUTPUT_PINS.values()): - _enable_pin(pin, OUT) - - for pin in sorted(INPUT_PINS.values()): - _enable_pin(pin, IN) - - -def robot_startup_sequence(): - """ - Gets the robot ready for operation by initializing GPIO pins, resetting - the Smoothie and enabling the audio pin. This only needs to be done - after power cycling the machine. - """ - initialize() - - # audio-enable pin can stay HIGH always, unless there is noise coming - # from the amplifier, then we can set to LOW to disable the amplifier - set_high(OUTPUT_PINS['AUDIO_ENABLE']) - - # smoothieware programming pins, must be in a known state (HIGH) - set_high(OUTPUT_PINS['HALT']) - set_high(OUTPUT_PINS['ISP']) - set_low(OUTPUT_PINS['RESET']) - time.sleep(0.25) - set_high(OUTPUT_PINS['RESET']) - time.sleep(0.25) - - -def turn_on_blue_button_light(): - set_button_light(blue=True) - - -def set_button_light(red=False, green=False, blue=False): - color_pins = { - OUTPUT_PINS['RED_BUTTON']: red, - OUTPUT_PINS['GREEN_BUTTON']: green, - OUTPUT_PINS['BLUE_BUTTON']: blue - } - for pin, state in color_pins.items(): - if state: - set_high(pin) +MODULE_LOG = logging.getLogger(__name__) + + +class GPIOCharDev: + def __init__(self, chip_name: str): + self._chip = gpiod.Chip(chip_name) + self._lines = self._initialize() + + @property + def chip(self) -> gpiod.Chip: + return self._chip + + @property + def lines(self) -> Dict[int, gpiod.Line]: + return self._lines + + def _request_line( + self, + offset: int, name: str, request_type) -> gpiod.Line: + line = self.chip.get_line(offset) + + def _retry_request_line(retries: int = 0): + try: + line.request( + consumer=name, type=request_type, default_vals=[0]) + except OSError as e: + retries -= 1 + if retries <= 0: + raise e + time.sleep(0.25) + return _retry_request_line(retries) + return line + + if name == 'BLUE_BUTTON': + return _retry_request_line(3) else: - set_low(pin) - + return _retry_request_line() + + def _initialize(self) -> Dict[int, gpiod.Line]: + MODULE_LOG.info("Initializing GPIOs") + lines = {} + # setup input lines + for name, offset in INPUT_PINS.items(): + lines[offset] = self._request_line( + offset, name, gpiod.LINE_REQ_DIR_IN) + # setup output lines + for name, offset in OUTPUT_PINS.items(): + lines[offset] = self._request_line( + offset, name, gpiod.LINE_REQ_DIR_OUT) + return lines + + async def setup(self): + MODULE_LOG.info("Setting up GPIOs") + # smoothieware programming pins, must be in a known state (HIGH) + self.set_halt_pin(True) + self.set_isp_pin(True) + self.set_reset_pin(False) + await asyncio.sleep(0.25) + self.set_reset_pin(True) + await asyncio.sleep(0.25) + + def set_high(self, offset: int): + self.lines[offset].set_value(1) + + def set_low(self, offset: int): + self.lines[offset].set_value(0) + + def set_button_light(self, + red: bool = False, + green: bool = False, + blue: bool = False): + color_pins = { + OUTPUT_PINS['RED_BUTTON']: red, + OUTPUT_PINS['GREEN_BUTTON']: green, + OUTPUT_PINS['BLUE_BUTTON']: blue} + for pin, state in color_pins.items(): + if state: + self.set_high(pin) + else: + self.set_low(pin) + + def set_rail_lights(self, on: bool = True): + if on: + self.set_high(OUTPUT_PINS['FRAME_LEDS']) + else: + self.set_low(OUTPUT_PINS['FRAME_LEDS']) -def get_button_light() -> Tuple[bool, bool, bool]: - return (read(OUTPUT_PINS['RED_BUTTON']) == 1, - read(OUTPUT_PINS['GREEN_BUTTON']) == 1, - read(OUTPUT_PINS['BLUE_BUTTON']) == 1) + def set_reset_pin(self, on: bool = True): + if on: + self.set_high(OUTPUT_PINS['RESET']) + else: + self.set_low(OUTPUT_PINS['RESET']) + def set_isp_pin(self, on: bool = True): + if on: + self.set_high(OUTPUT_PINS['ISP']) + else: + self.set_low(OUTPUT_PINS['ISP']) -def set_rail_lights(on=True): - if on: - set_high(OUTPUT_PINS['FRAME_LEDS']) - else: - set_low(OUTPUT_PINS['FRAME_LEDS']) + def set_halt_pin(self, on: bool = True): + if on: + self.set_high(OUTPUT_PINS['HALT']) + else: + self.set_low(OUTPUT_PINS['HALT']) + def _read(self, offset): + return self.lines[offset].get_value() -def get_rail_lights() -> bool: - value = read(OUTPUT_PINS['FRAME_LEDS']) - return True if value == 1 else False + def get_button_light(self) -> Tuple[bool, bool, bool]: + return (bool(self._read(OUTPUT_PINS['RED_BUTTON'])), + bool(self._read(OUTPUT_PINS['GREEN_BUTTON'])), + bool(self._read(OUTPUT_PINS['BLUE_BUTTON']))) + def get_rail_lights(self) -> bool: + return bool(self._read(OUTPUT_PINS['FRAME_LEDS'])) -def read_button(): - # button is normal-HIGH, so invert - return not bool(read(INPUT_PINS['BUTTON_INPUT'])) + def read_button(self) -> bool: + # button is normal-HIGH, so invert + return not bool(self._read(INPUT_PINS['BUTTON_INPUT'])) + def read_window_switches(self) -> bool: + return bool(self._read(INPUT_PINS['WINDOW_INPUT'])) -def read_window_switches(): - return bool(read(INPUT_PINS['WINDOW_INPUT'])) + def release_line(self, offset: int): + self.lines[offset].release() + self.lines.pop(offset) diff --git a/api/src/opentrons/drivers/rpi_drivers/gpio_simulator.py b/api/src/opentrons/drivers/rpi_drivers/gpio_simulator.py new file mode 100644 index 00000000000..94f130efe19 --- /dev/null +++ b/api/src/opentrons/drivers/rpi_drivers/gpio_simulator.py @@ -0,0 +1,94 @@ +from typing import Dict, Tuple + + +OUTPUT_PINS = { + 'FRAME_LEDS': 6, + 'BLUE_BUTTON': 13, + 'HALT': 18, + 'GREEN_BUTTON': 19, + 'AUDIO_ENABLE': 21, + 'ISP': 23, + 'RESET': 24, + 'RED_BUTTON': 26 +} + +INPUT_PINS = { + 'BUTTON_INPUT': 5, + 'WINDOW_INPUT': 20 +} + + +class SimulatingGPIOCharDev: + def __init__(self, chip_name: str): + self._chip = chip_name + self._lines = self._initialize() + + @property + def chip(self) -> str: + return self._chip + + @property + def lines(self) -> Dict[int, str]: + return self._lines + + def _initialize(self) -> Dict[int, str]: + lines = {} + for name, offset in INPUT_PINS.items(): + lines[offset] = name + for name, offset in OUTPUT_PINS.items(): + lines[offset] = name + self._initialize_values(list(lines.keys())) + return lines + + def _initialize_values(self, offsets): + self._values: Dict[int, int] = {} + for offset in offsets: + self._values[offset] = 0 + + async def setup(self): + pass + + def set_high(self, offset: int): + self._values[offset] = 1 + + def set_low(self, offset: int): + self._values[offset] = 0 + + def set_button_light(self, + red: bool = False, + green: bool = False, + blue: bool = False): + pass + + def set_rail_lights(self, on: bool = True): + pass + + def set_reset_pin(self, on: bool = True): + pass + + def set_isp_pin(self, on: bool = True): + pass + + def set_halt_pin(self, on: bool = True): + pass + + def _read(self, offset: int) -> int: + return self._values[offset] + + def get_button_light(self) -> Tuple[bool, bool, bool]: + return (bool(self._read(OUTPUT_PINS['RED_BUTTON'])), + bool(self._read(OUTPUT_PINS['GREEN_BUTTON'])), + bool(self._read(OUTPUT_PINS['BLUE_BUTTON']))) + + def get_rail_lights(self) -> bool: + return bool(self._read(OUTPUT_PINS['FRAME_LEDS'])) + + def read_button(self) -> bool: + pass + + def read_window_switches(self) -> bool: + pass + + def release_line(self, offset: int): + self.lines.pop(offset) + self._values.pop(offset) diff --git a/api/src/opentrons/drivers/smoothie_drivers/driver_3_0.py b/api/src/opentrons/drivers/smoothie_drivers/driver_3_0.py index 79a14e46f13..3a5c0f167f9 100755 --- a/api/src/opentrons/drivers/smoothie_drivers/driver_3_0.py +++ b/api/src/opentrons/drivers/smoothie_drivers/driver_3_0.py @@ -2,7 +2,7 @@ import contextlib from os import environ import logging -from time import sleep +from time import sleep, time from threading import Event, RLock from typing import Any, Dict, Optional, Union, List, Tuple @@ -11,8 +11,8 @@ from opentrons.drivers import serial_communication from opentrons.drivers.types import MoveSplits -from opentrons.drivers.rpi_drivers import gpio from opentrons.drivers.utils import AxisMoveTimestamp +from opentrons.drivers.rpi_drivers.gpio_simulator import SimulatingGPIOCharDev from opentrons.system import smoothie_update ''' - Driver is responsible for providing an interface for motion control @@ -308,7 +308,7 @@ def _parse_homing_status_values(raw_homing_status_values): class SmoothieDriver_3_0_0: - def __init__(self, config, handle_locks=True): + def __init__(self, config, gpio_chardev=None, handle_locks=True): self.run_flag = Event() self.run_flag.set() @@ -322,6 +322,8 @@ def __init__(self, config, handle_locks=True): self._connection = None self._config = config + self._gpio_chardev = gpio_chardev or SimulatingGPIOCharDev('simulated') + # Current settings: # The amperage of each axis, has been organized into three states: # Current-Settings is the amperage each axis was last set to @@ -391,6 +393,14 @@ def __exit__(self, *args, **kwargs): #: Cache of currently configured splits from callers self._axes_moved_at = AxisMoveTimestamp(AXES) + @property + def gpio_chardev(self): + return self._gpio_chardev + + @gpio_chardev.setter + def gpio_chardev(self, gpio_chardev): + self._gpio_chardev = gpio_chardev + @property def homed_position(self): return self._homed_position.copy() @@ -447,7 +457,7 @@ def configure_splits_for(self, config: MoveSplits): def read_pipette_id(self, mount) -> Optional[str]: ''' Reads in an attached pipette's ID - The ID is unique to this pipette, and is a string of unknown length + The ID is unique to this pipette, and is a string of unktimen length :param mount: string with value 'left' or 'right' :return id string, or None @@ -492,14 +502,14 @@ def read_pipette_model(self, mount) -> Optional[str]: def write_pipette_id(self, mount: str, data_string: str): ''' Writes to an attached pipette's ID memory location - The ID is unique to this pipette, and is a string of unknown length + The ID is unique to this pipette, and is a string of unktimen length NOTE: To enable write-access to the pipette, it's button must be held mount: String (str) with value 'left' or 'right' data_string: - String (str) that is of unknown length, and should be unique to + String (str) that is of unktimen length, and should be unique to this one pipette ''' self._write_to_pipette( @@ -1151,7 +1161,7 @@ def _home_x(self): self._send_command(command) self.dwell_axes('Y') - # now it is safe to home the X axis + # time it is safe to home the X axis try: # override firmware's default XY homing speed, to avoid resonance self.set_axis_max_speed({'X': XY_HOMING_SPEED}) @@ -1219,7 +1229,7 @@ def _setup(self): self._wait_for_ack() except serial_communication.SerialNoResponse: # incase motor-driver is stuck in bootloader and unresponsive, - # use gpio to reset into a known state + # use gpio to reset into a ktimen state log.debug("wait for ack failed, resetting") self._smoothie_reset() log.debug("wait for ack done") @@ -1649,7 +1659,7 @@ def _do_relative_splits_during_home_for(self, axes: str): """ Handle split moves for unsticking axes before home. This is particularly ugly bit of code that flips the motor controller - into relative mode since we don't necessarily know where we are. + into relative mode since we don't necessarily ktime where we are. It will induce a movement. It should really only be called before a home because it doesn't update the position cache. @@ -1713,7 +1723,7 @@ def _do_relative_splits_during_home_for(self, axes: str): def fast_home(self, axis, safety_margin): ''' home after a controlled motor stall - Given a known distance we have just stalled along an axis, move + Given a ktimen distance we have just stalled along an axis, move that distance away from the homing switch. Then finish with home. ''' # move some mm distance away from the target axes endstop switch(es) @@ -1752,7 +1762,7 @@ def unstick_axes( ''' for ax in axes: if ax not in AXES: - raise ValueError('Unknown axes: {}'.format(axes)) + raise ValueError('Unktimen axes: {}'.format(axes)) if distance is None: distance = UNSTICK_DISTANCE @@ -1826,38 +1836,42 @@ def probe_axis( raise RuntimeError("Cant probe axis {}".format(axis)) def turn_on_blue_button_light(self): - gpio.set_button_light(blue=True) + self._gpio_chardev.set_button_light(blue=True) + + def turn_on_green_button_light(self): + self._gpio_chardev.set_button_light(green=True) def turn_on_red_button_light(self): - gpio.set_button_light(red=True) + self._gpio_chardev.set_button_light(red=True) def turn_off_button_light(self): - gpio.set_button_light(red=False, green=False, blue=False) + self._gpio_chardev.set_button_light( + red=False, green=False, blue=False) def turn_on_rail_lights(self): - gpio.set_rail_lights(True) + self._gpio_chardev.set_rail_lights(True) def turn_off_rail_lights(self): - gpio.set_rail_lights(False) + self._gpio_chardev.set_rail_lights(False) def get_rail_lights_on(self): - return gpio.get_rail_lights() + return self._gpio_chardev.get_rail_lights() def read_button(self): - return gpio.read_button() + return self._gpio_chardev.read_button() def read_window_switches(self): - return gpio.read_window_switches() + return self._gpio_chardev.read_window_switches() def set_lights(self, button: bool = None, rails: bool = None): if button is not None: - gpio.set_button_light(blue=button) + self._gpio_chardev.set_button_light(blue=button) if rails is not None: - gpio.set_rail_lights(rails) + self._gpio_chardev.set_rail_lights(rails) def get_lights(self) -> Dict[str, bool]: - return {'button': gpio.get_button_light()[2], - 'rails': gpio.get_rail_lights()} + return {'button': self._gpio_chardev.get_button_light()[2], + 'rails': self._gpio_chardev.get_rail_lights()} def kill(self): """ @@ -1892,10 +1906,10 @@ def _smoothie_reset(self): if self.simulating: pass else: - gpio.set_low(gpio.OUTPUT_PINS['RESET']) - gpio.set_high(gpio.OUTPUT_PINS['ISP']) + self._gpio_chardev.set_reset_pin(False) + self._gpio_chardev.set_isp_pin(True) sleep(0.25) - gpio.set_high(gpio.OUTPUT_PINS['RESET']) + self._gpio_chardev.set_reset_pin(True) sleep(0.25) self._wait_for_ack() self._reset_from_error() @@ -1906,12 +1920,12 @@ def _smoothie_programming_mode(self): if self.simulating: pass else: - gpio.set_low(gpio.OUTPUT_PINS['RESET']) - gpio.set_low(gpio.OUTPUT_PINS['ISP']) + self._gpio_chardev.set_reset_pin(False) + self._gpio_chardev.set_isp_pin(False) sleep(0.25) - gpio.set_high(gpio.OUTPUT_PINS['RESET']) + self._gpio_chardev.set_reset_pin(True) sleep(0.25) - gpio.set_high(gpio.OUTPUT_PINS['ISP']) + self._gpio_chardev.set_isp_pin(True) sleep(0.25) def hard_halt(self): @@ -1921,9 +1935,9 @@ def hard_halt(self): pass else: self._is_hard_halting.set() - gpio.set_low(gpio.OUTPUT_PINS['HALT']) + self._gpio_chardev.set_halt_pin(False) sleep(0.25) - gpio.set_high(gpio.OUTPUT_PINS['HALT']) + self._gpio_chardev.set_halt_pin(True) sleep(0.25) self.run_flag.set() @@ -1974,9 +1988,14 @@ async def update_firmware(self, # noqa(C901) if loop: kwargs['loop'] = loop log.info(update_cmd) + before = time() proc = await asyncio.create_subprocess_shell( update_cmd, **kwargs) + created = time() + log.info(f"created lpc21isp subproc in {created-before}") out_b, err_b = await proc.communicate() + done = time() + log.info(f"ran lpc21isp subproc in {done-created}") if proc.returncode != 0: log.error( f"Smoothie update failed: {proc.returncode}" diff --git a/api/src/opentrons/hardware_control/api.py b/api/src/opentrons/hardware_control/api.py index 7410789e45b..c7d718f914b 100644 --- a/api/src/opentrons/hardware_control/api.py +++ b/api/src/opentrons/hardware_control/api.py @@ -1,8 +1,9 @@ import asyncio import contextlib import logging +import pathlib from collections import OrderedDict -from typing import Dict, Union, List, Optional +from typing import Dict, Union, List, Optional, Tuple from opentrons import types as top_types from opentrons.util import linal from opentrons.config import robot_configs, pipette_config @@ -20,7 +21,6 @@ MustHomeError, NoTipAttachedError) from . import modules - mod_log = logging.getLogger(__name__) @@ -77,7 +77,8 @@ def __init__(self, async def build_hardware_controller( cls, config: robot_configs.robot_config = None, port: str = None, - loop: asyncio.AbstractEventLoop = None) -> 'API': + loop: asyncio.AbstractEventLoop = None, + firmware: Tuple[pathlib.Path, str] = None) -> 'API': """ Build a hardware controller that will actually talk to hardware. This method should not be used outside of a real robot, and on a @@ -92,14 +93,46 @@ async def build_hardware_controller( """ checked_loop = use_or_initialize_loop(loop) backend = Controller(config) - await backend.connect(port) + await backend.setup_gpio_chardev() + backend.set_lights(button=None, rails=False) - api_instance = cls(backend, loop=checked_loop, config=config) - await api_instance.cache_instruments() - checked_loop.create_task(backend.watch_modules( + async def blink(): + while True: + backend.set_lights(button=True, rails=None) + await asyncio.sleep(0.5) + backend.set_lights(button=False, rails=None) + await asyncio.sleep(0.5) + + blink_task = checked_loop.create_task(blink()) + try: + try: + await backend.connect(port) + fw_version = backend.fw_version + except Exception: + mod_log.exception( + 'Motor driver could not connect, reprogramming if possible' + ) + fw_version = None + + if firmware is not None: + if fw_version != firmware[1]: + await backend.update_firmware( + str(firmware[0]), checked_loop, True) + await backend.connect(port) + elif firmware is None and fw_version is None: + msg = 'Motor controller could not be connected and no '\ + 'firmware was provided for (re)programming' + mod_log.error(msg) + raise RuntimeError(msg) + + api_instance = cls(backend, loop=checked_loop, config=config) + await api_instance.cache_instruments() + checked_loop.create_task(backend.watch_modules( loop=checked_loop, register_modules=api_instance.register_modules)) - return api_instance + return api_instance + finally: + blink_task.cancel() @classmethod async def build_hardware_simulator( diff --git a/api/src/opentrons/hardware_control/controller.py b/api/src/opentrons/hardware_control/controller.py index bc20f80a46e..3abb0151561 100644 --- a/api/src/opentrons/hardware_control/controller.py +++ b/api/src/opentrons/hardware_control/controller.py @@ -8,7 +8,7 @@ aionotify = None # type: ignore from opentrons.drivers.smoothie_drivers import driver_3_0 -from opentrons.drivers.rpi_drivers import gpio +from opentrons.drivers.rpi_drivers import build_gpio_chardev import opentrons.config from opentrons.types import Mount @@ -17,6 +17,8 @@ if TYPE_CHECKING: from .dev_types import RegisterModules # noqa (F501) + from opentrons.drivers.rpi_drivers.dev_types\ + import GPIODriverLike # noqa(F501) MODULE_LOG = logging.getLogger(__name__) @@ -41,9 +43,12 @@ def __init__(self, config): 'will fail') self.config = config or opentrons.config.robot_configs.load() + + self._gpio_chardev = build_gpio_chardev('gpiochip0') # We handle our own locks in the hardware controller thank you self._smoothie_driver = driver_3_0.SmoothieDriver_3_0_0( - config=self.config, handle_locks=False) + config=self.config, gpio_chardev=self._gpio_chardev, + handle_locks=False) self._cached_fw_version: Optional[str] = None try: self._module_watcher = aionotify.Watcher() @@ -56,6 +61,13 @@ def __init__(self, config): 'Failed to initiate aionotify, cannot watch modules,' 'likely because not running on linux') + @property + def gpio_chardev(self) -> 'GPIODriverLike': + return self._gpio_chardev + + async def setup_gpio_chardev(self): + await self.gpio_chardev.setup() + def update_position(self) -> Dict[str, float]: self._smoothie_driver.update_position() return self._smoothie_driver.position @@ -215,15 +227,15 @@ def disengage_axes(self, axes: List[str]): def set_lights(self, button: Optional[bool], rails: Optional[bool]): if opentrons.config.IS_ROBOT: if button is not None: - gpio.set_button_light(blue=button) + self.gpio_chardev.set_button_light(blue=button) if rails is not None: - gpio.set_rail_lights(rails) + self.gpio_chardev.set_rail_lights(rails) def get_lights(self) -> Dict[str, bool]: if not opentrons.config.IS_ROBOT: return {} - return {'button': gpio.get_button_light()[2], - 'rails': gpio.get_rail_lights()} + return {'button': self.gpio_chardev.get_button_light()[2], + 'rails': self.gpio_chardev.get_rail_lights()} def pause(self): self._smoothie_driver.pause() diff --git a/api/src/opentrons/hardware_control/simulator.py b/api/src/opentrons/hardware_control/simulator.py index ace880599d2..a608893b9f0 100644 --- a/api/src/opentrons/hardware_control/simulator.py +++ b/api/src/opentrons/hardware_control/simulator.py @@ -9,10 +9,14 @@ config_names, configs) from opentrons.drivers.smoothie_drivers import SimulatingDriver +from opentrons.drivers.rpi_drivers.gpio_simulator import SimulatingGPIOCharDev + from . import modules from .execution_manager import ExecutionManager if TYPE_CHECKING: from .dev_types import RegisterModules # noqa (F501) + from opentrons.drivers.rpi_drivers.dev_types\ + import GPIODriverLike # noqa(F501) MODULE_LOG = logging.getLogger(__name__) @@ -101,6 +105,14 @@ def __init__( self._run_flag.set() self._log = MODULE_LOG.getChild(repr(self)) self._strict_attached = bool(strict_attached_instruments) + self._gpio_chardev = SimulatingGPIOCharDev('gpiochip0') + + @property + def gpio_chardev(self) -> 'GPIODriverLike': + return self._gpio_chardev + + async def setup_gpio_chardev(self): + await self.gpio_chardev.setup() def update_position(self) -> Dict[str, float]: return self._position diff --git a/api/src/opentrons/hardware_control/thread_manager.py b/api/src/opentrons/hardware_control/thread_manager.py index 037fbd78d28..29d27ae0d12 100644 --- a/api/src/opentrons/hardware_control/thread_manager.py +++ b/api/src/opentrons/hardware_control/thread_manager.py @@ -83,6 +83,10 @@ class ThreadManager: is stored as a member of the class, and a synchronous interface to the managed object's members is also exposed for convenience. + If you want to wait for the managed object's creation separately + (with managed_thread_ready_blocking or managed_thread_ready_async) + then pass threadmanager_nonblocking=True as a kwarg + Example ------- .. code-block:: @@ -104,15 +108,36 @@ def __init__(self, builder, *args, **kwargs): self._sync_managed_obj = None is_running = threading.Event() self._is_running = is_running + + # TODO: remove this if we switch to python 3.8 + # https://docs.python.org/3/library/asyncio-subprocess.html#subprocess-and-threads # noqa + # On windows, the event loop and system interface is different and + # this won't work. + try: + asyncio.get_child_watcher() + except NotImplementedError: + pass + blocking = not kwargs.pop('threadmanager_nonblocking', False) target = object.__getattribute__(self, '_build_and_start_loop') thread = threading.Thread(target=target, name='ManagedThread', args=(builder, *args), kwargs=kwargs, daemon=True) self._thread = thread thread.start() - is_running.wait() + if blocking: + object.__getattribute__(self, 'managed_thread_ready_blocking')() + + def managed_thread_ready_blocking(self): + object.__getattribute__(self, '_is_running').wait() + if not object.__getattribute__(self, 'managed_obj'): + raise ThreadManagerException("Failed to create Managed Object") + + async def managed_thread_ready_async(self): + is_running = object.__getattribute__(self, '_is_running') + while not is_running.is_set(): + await asyncio.sleep(0.1) # Thread initialization is done. - if not self.managed_obj: + if not object.__getattribute__(self, 'managed_obj'): raise ThreadManagerException("Failed to create Managed Object") def _build_and_start_loop(self, builder, *args, **kwargs): diff --git a/api/src/opentrons/legacy_api/robot/robot.py b/api/src/opentrons/legacy_api/robot/robot.py index 0c5a4097a32..8022d809fb6 100755 --- a/api/src/opentrons/legacy_api/robot/robot.py +++ b/api/src/opentrons/legacy_api/robot/robot.py @@ -132,7 +132,8 @@ def __init__(self, config=None, broker=None): """ super().__init__(broker) self.config = config or load() - self._driver = driver_3_0.SmoothieDriver_3_0_0(config=self.config) + self._driver = driver_3_0.SmoothieDriver_3_0_0( + config=self.config) self._attached_modules: Dict[str, Any] = {} # key is port + model self.fw_version = self._driver.get_fw_version() diff --git a/api/src/opentrons/main.py b/api/src/opentrons/main.py index 3ae5d68fc2c..d6e18e5c586 100644 --- a/api/src/opentrons/main.py +++ b/api/src/opentrons/main.py @@ -3,7 +3,7 @@ import logging import asyncio import re -from typing import List, Optional +from typing import List, Tuple from opentrons import HERE from opentrons import server from opentrons.hardware_control import API, ThreadManager @@ -13,7 +13,6 @@ from opentrons.config import (feature_flags as ff, name, robot_configs, IS_ROBOT, ROBOT_FIRMWARE_DIR) from opentrons.util import logging_config -from opentrons.drivers.smoothie_drivers.driver_3_0 import SmoothieDriver_3_0_0 try: from opentrons.hardware_control.socket_server\ @@ -25,11 +24,15 @@ async def install_hardware_server(sock_path, api): # type: ignore log = logging.getLogger(__name__) -SMOOTHIE_HEX_RE = re.compile('smoothie-(.*).hex') +try: + import systemd.daemon # type: ignore +except ImportError: + log.info("Systemd couldn't be imported, not notifying") +SMOOTHIE_HEX_RE = re.compile('smoothie-(.*).hex') -def _find_smoothie_file(): +def _find_smoothie_file() -> Tuple[Path, str]: resources: List[Path] = [] # Search for smoothie files in /usr/lib/firmware first then fall back to @@ -48,89 +51,41 @@ def _find_smoothie_file(): raise OSError(f"Could not find smoothie firmware file in {resources_path}") -async def _do_fw_update(driver, new_fw_path, new_fw_ver): - """ Update the connected smoothie board, with retries - - When the API server boots, it talks to the motor controller board for the - first time. Sometimes the board is in a bad state - it might have the - wrong firmware version (i.e. this is the first boot after an update), or it - might just not be communicating correctly. Sometimes, the motor controller - not communicating correctly in fact means it needs a firmware update; other - times, it might mean it just needs to be reset. - - This function is called when the API server boots if either of the above - cases happens. Its job is to make the motor controller board ready by - updating its firmware, regardless of the state of the rest of the stack. - - To that end, this function uses the smoothie driver directly (so it can - ignore the rest of the stack) and has a couple retries with different - hardware line changes in between (so it can catch all failure modes). If - this method ultimately fails, it lets the server boot by telling it to - consider itself virtual. - - After this function has completed, it is always safe to call - hardware.connect() - it just might be virtual - """ - explicit_modeset = False - for attempts in range(3): - try: - await driver.update_firmware( - new_fw_path, - explicit_modeset=explicit_modeset) - except RuntimeError: - explicit_modeset = True - continue - - if driver.get_fw_version() == new_fw_ver: - log.info(f"Smoothie fw update complete in {attempts} tries") - break - else: - log.error( - "Failed to update smoothie: did not connect after update") - else: - log.error("Could not update smoothie, forcing virtual") - os.environ['ENABLE_VIRTUAL_SMOOTHIE'] = 'true' - - -async def check_for_smoothie_update(): - driver = SmoothieDriver_3_0_0(robot_configs.load()) - - try: - driver.connect() - fw_version: Optional[str] = driver.get_fw_version() - except Exception as e: - # The most common reason for this exception (aside from hardware - # failures such as a disconnected smoothie) is that the smoothie - # is in programming mode. If it is, then we still want to update - # it (so it can boot again), but we don’t have to do the GPIO - # manipulations that _put_ it in programming mode - log.exception("Error while connecting to motor driver: {}".format(e)) - fw_version = None - - log.info(f"Smoothie FW version: {fw_version}") - packed_smoothie_fw_file, packed_smoothie_fw_ver = _find_smoothie_file() - if fw_version != packed_smoothie_fw_ver: - log.info(f"Executing smoothie update: current vers {fw_version}," - f" packed vers {packed_smoothie_fw_ver}") - await _do_fw_update(driver, packed_smoothie_fw_file, - packed_smoothie_fw_ver) - else: - log.info(f"FW version OK: {packed_smoothie_fw_ver}") - - async def initialize_robot() -> ThreadManager: if os.environ.get("ENABLE_VIRTUAL_SMOOTHIE"): log.info("Initialized robot using virtual Smoothie") return ThreadManager(API.build_hardware_simulator) + packed_smoothie_fw_file, packed_smoothie_fw_ver = _find_smoothie_file() + systemd.daemon.notify("READY=1") + hardware = ThreadManager(API.build_hardware_controller, + threadmanager_nonblocking=True, + firmware=(packed_smoothie_fw_file, + packed_smoothie_fw_ver)) + try: + await hardware.managed_thread_ready_async() + except RuntimeError: + log.exception( + 'Could not build hardware controller, forcing virtual') + return ThreadManager(API.build_hardware_simulator) - await check_for_smoothie_update() + loop = asyncio.get_event_loop() - hardware = ThreadManager(API.build_hardware_controller) + async def blink(): + while True: + await hardware.set_lights(button=True) + await asyncio.sleep(0.5) + await hardware.set_lights(button=False) + await asyncio.sleep(0.5) + + blink_task = loop.create_task(blink()) if not ff.disable_home_on_boot(): log.info("Homing Z axes") await hardware.home_z() + blink_task.cancel() + await hardware.set_lights(button=True) + return hardware diff --git a/api/src/opentrons/tools/factory_test.py b/api/src/opentrons/tools/factory_test.py index 454bb1c6783..0dfc7245de5 100644 --- a/api/src/opentrons/tools/factory_test.py +++ b/api/src/opentrons/tools/factory_test.py @@ -7,7 +7,6 @@ from typing import Dict from opentrons.config import infer_config_base_dir -from opentrons.drivers.rpi_drivers import gpio from opentrons.drivers import serial_communication from opentrons.drivers.smoothie_drivers.driver_3_0\ import SmoothieDriver_3_0_0 @@ -62,7 +61,7 @@ def _erase_data(filepath): def _reset_lights(driver: SmoothieDriver_3_0_0): driver.turn_off_rail_lights() - gpio.set_button_light(blue=True) + driver.turn_on_blue_button_light() def _get_state_of_inputs(driver: SmoothieDriver_3_0_0): @@ -94,7 +93,7 @@ def _set_lights(state: Dict[str, Dict[str, bool]], green = True if state['button']: blue = True - gpio.set_button_light(red=red, green=green, blue=blue) + driver._gpio_chardev.set_button_light(red=red, green=green, blue=blue) def run_quiet_process(command): diff --git a/api/src/opentrons/tools/gantry_test.py b/api/src/opentrons/tools/gantry_test.py index 91cec2c1d53..cbeaf5ed5e5 100644 --- a/api/src/opentrons/tools/gantry_test.py +++ b/api/src/opentrons/tools/gantry_test.py @@ -9,7 +9,6 @@ """ import logging -from opentrons.drivers.rpi_drivers import gpio from opentrons.hardware_control.adapters import SynchronousAdapter from opentrons.drivers.smoothie_drivers.driver_3_0 import \ SmoothieError, DEFAULT_AXES_SPEED @@ -137,7 +136,7 @@ def _exit_test(driver: SmoothieDriver_3_0_0): hardware, driver) run_y_axis(num_cycles, b_x_max, b_y_max, tolerance_mm, hardware, driver) - gpio.set_button_light(red=False, green=True, blue=False) + driver.turn_on_green_button_light() print("PASS") _exit_test(driver) except KeyboardInterrupt: diff --git a/api/tests/opentrons/conftest.py b/api/tests/opentrons/conftest.py index b7f9208bc9f..66503934846 100755 --- a/api/tests/opentrons/conftest.py +++ b/api/tests/opentrons/conftest.py @@ -518,6 +518,7 @@ def cntrlr_mock_connect(monkeypatch): async def mock_connect(obj, port=None): return monkeypatch.setattr(hc.Controller, 'connect', mock_connect) + monkeypatch.setattr(hc.Controller, 'fw_version', 'virtual') @pytest.fixture diff --git a/api/tests/opentrons/drivers/rpi_drivers/test_gpio.py b/api/tests/opentrons/drivers/rpi_drivers/test_gpio.py deleted file mode 100755 index f6cfa7ffffd..00000000000 --- a/api/tests/opentrons/drivers/rpi_drivers/test_gpio.py +++ /dev/null @@ -1,59 +0,0 @@ -from opentrons.drivers.rpi_drivers import gpio - - -def test_init_sequence(monkeypatch): - init_log = [] - - def capture_write(value, path): - init_log.append((value, path)) - - monkeypatch.setattr(gpio, '_write_value', capture_write) - - gpio.initialize() - - expected_log = [ - # Enable output pins - (gpio.OUTPUT_PINS['FRAME_LEDS'], "{}/export".format(gpio._path_prefix)), # NOQA - (gpio.OUT, "{0}/gpio{1}/direction".format(gpio._path_prefix, gpio.OUTPUT_PINS['FRAME_LEDS'])), # NOQA - (gpio.OUTPUT_PINS['BLUE_BUTTON'], "{}/export".format(gpio._path_prefix)), # NOQA - (gpio.OUT, "{0}/gpio{1}/direction".format(gpio._path_prefix, gpio.OUTPUT_PINS['BLUE_BUTTON'])), # NOQA - (gpio.OUTPUT_PINS['HALT'], "{}/export".format(gpio._path_prefix)), # NOQA - (gpio.OUT, "{0}/gpio{1}/direction".format(gpio._path_prefix, gpio.OUTPUT_PINS['HALT'])), # NOQA - (gpio.OUTPUT_PINS['GREEN_BUTTON'], "{}/export".format(gpio._path_prefix)), # NOQA - (gpio.OUT, "{0}/gpio{1}/direction".format(gpio._path_prefix, gpio.OUTPUT_PINS['GREEN_BUTTON'])), # NOQA - (gpio.OUTPUT_PINS['AUDIO_ENABLE'], "{}/export".format(gpio._path_prefix)), # NOQA - (gpio.OUT, "{0}/gpio{1}/direction".format(gpio._path_prefix, gpio.OUTPUT_PINS['AUDIO_ENABLE'])), # NOQA - (gpio.OUTPUT_PINS['ISP'], "{}/export".format(gpio._path_prefix)), # NOQA - (gpio.OUT, "{0}/gpio{1}/direction".format(gpio._path_prefix, gpio.OUTPUT_PINS['ISP'])), # NOQA - (gpio.OUTPUT_PINS['RESET'], "{}/export".format(gpio._path_prefix)), # NOQA - (gpio.OUT, "{0}/gpio{1}/direction".format(gpio._path_prefix, gpio.OUTPUT_PINS['RESET'])), # NOQA - (gpio.OUTPUT_PINS['RED_BUTTON'], "{}/export".format(gpio._path_prefix)), # NOQA - (gpio.OUT, "{0}/gpio{1}/direction".format(gpio._path_prefix, gpio.OUTPUT_PINS['RED_BUTTON'])), # NOQA - # Enable input pins - (gpio.INPUT_PINS['BUTTON_INPUT'], "{}/export".format(gpio._path_prefix)), # NOQA - (gpio.IN, "{0}/gpio{1}/direction".format(gpio._path_prefix, gpio.INPUT_PINS['BUTTON_INPUT'])), # NOQA - (gpio.INPUT_PINS['WINDOW_INPUT'], "{}/export".format(gpio._path_prefix)), # NOQA - (gpio.IN, "{0}/gpio{1}/direction".format(gpio._path_prefix, gpio.INPUT_PINS['WINDOW_INPUT'])) # NOQA - ] - - assert init_log == expected_log - - -def test_commands(monkeypatch): - command_log = [] - - def capture_write(value, path): - command_log.append((value, path)) - - monkeypatch.setattr(gpio, '_write_value', capture_write) - - gpio.set_high(gpio.OUTPUT_PINS['HALT']) - gpio.set_low(gpio.OUTPUT_PINS['RED_BUTTON']) - - expected_log = [ - # Set command sequence - (gpio.HIGH, "{0}/gpio{1}/value".format(gpio._path_prefix, gpio.OUTPUT_PINS['HALT'])), # NOQA - (gpio.LOW, "{0}/gpio{1}/value".format(gpio._path_prefix, gpio.OUTPUT_PINS['RED_BUTTON'])) # NOQA - ] - - assert command_log == expected_log diff --git a/robot-server/opentrons-robot-server.service b/robot-server/opentrons-robot-server.service index d4c113e16b0..e616861fc30 100644 --- a/robot-server/opentrons-robot-server.service +++ b/robot-server/opentrons-robot-server.service @@ -4,11 +4,11 @@ Requires=nginx.service After=nginx.service [Service] -Type=exec +Type=notify ExecStart=python -m robot_server.main # Stop the button blinking ExecStartPost=systemctl stop opentrons-gpio-setup.service Environment=OT_SMOOTHIE_ID=AMA RUNNING_ON_PI=true [Install] -WantedBy=opentrons.target \ No newline at end of file +WantedBy=opentrons.target