Skip to content

Commit

Permalink
refactor(emulation): emulation refactor (#8587)
Browse files Browse the repository at this point in the history
* proxy.

run_emulator

* Linting/Formatting

* emulator

* integration tests pass.

* lint passes.

* gcode parsing.

* gcode parsing uses process for emulator.

* run_emulator_client retries.

* wait for emulator connection method.

* scripts.

* utils

* g-code-testing lint

* refactor(emulation):  integrated with module control (#8586)

* Module control refactors

* new package.

* start module control integration.

* tests for module control integration.

* implement connection listener.

* module server integrated into module control.

* lint

* g-code-testing

* redo docker compose to deal with separate emulator apps.

* usb port.

* expand module emulation settings.

* update docker readme.

* format-js

* lint

* go back to threadings. I don't want to debug windows nonsense.

* fix bug.

* redo gcode testing's emulator setup.

* documentation.

* clean up.

* don't listen for emulators on robot.

* chore(gcode): reduce time of gcode-testing (#8683)

* faster g-code-tests in ci

* reduce hold time.

* use dev folder in s3

* wait.

Co-authored-by: Derek Maggio <[email protected]>
  • Loading branch information
amitlissack and Derek Maggio authored Nov 9, 2021
1 parent 4f50044 commit b97f1bb
Show file tree
Hide file tree
Showing 48 changed files with 1,662 additions and 325 deletions.
56 changes: 56 additions & 0 deletions DOCKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,62 @@ For example to use a `p300_multi` on the right add:
OT_EMULATOR_smoothie: '{"right": {"model": "p300_multi"}}'
```

### Adding more emulators

#### Magdeck

To add a second mag deck emulator make a copy of the existing `magdeck` section and change the key and `serial_number`.

For example this adds a `magdeck` with the serial number `magdeck2`:

```
magdeck2:
build: .
command: python3 -m opentrons.hardware_control.emulation.scripts.run_module_emulator magdeck emulator
links:
- 'emulator'
depends_on:
- 'emulator'
environment:
OT_EMULATOR_magdeck: '{"serial_number": "magdeck2", "model":"mag_deck_v20", "version":"2.0.0"}'
```

#### Tempdeck

To add a second temp deck emulator make a copy of the existing `tempdeck` section and change the key and `serial_number`.

For example this adds a `tempdeck` with the serial number `tempdeck2`:

```
tempdeck2:
build: .
command: python3 -m opentrons.hardware_control.emulation.scripts.run_module_emulator tempdeck emulator
links:
- 'emulator'
depends_on:
- 'emulator'
environment:
OT_EMULATOR_tempdeck: '{"serial_number": "tempdeck2", "model":"temp_deck_v20", "version":"v2.0.1", "temperature": {"starting":0.0, "degrees_per_tick": 2.0}}'
```

#### Thermocycler

To add a second thermocycler emulator make a copy of the existing `thermocycler` section and change the key and `serial_number`.

For example this adds a `thermocycler` with the serial number `thermocycler2`:

```
thermocycler2:
build: .
command: python3 -m opentrons.hardware_control.emulation.scripts.run_module_emulator thermocycler emulator
links:
- 'emulator'
depends_on:
- 'emulator'
environment:
OT_EMULATOR_thermocycler: '{"serial_number": "thermocycler2", "model":"v02", "version":"v1.1.0", "lid_temperature": {"starting":23.0, "degrees_per_tick": 2.0}, "plate_temperature": {"starting":23.0, "degrees_per_tick": 2.0}}'
```

## Known Issues

- Pipettes cannot be changed at run time.
8 changes: 6 additions & 2 deletions api/src/opentrons/hardware_control/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,9 @@ async def blink():

api_instance = cls(backend, loop=checked_loop, config=checked_config)
await api_instance.cache_instruments()
module_controls = await AttachedModulesControl.build(api_instance)
module_controls = await AttachedModulesControl.build(
api_instance, board_revision=backend.board_revision
)
backend.module_controls = module_controls
checked_loop.create_task(backend.watch(loop=checked_loop))
backend.start_gpio_door_watcher(
Expand Down Expand Up @@ -260,7 +262,9 @@ async def build_hardware_simulator(
)
api_instance = cls(backend, loop=checked_loop, config=checked_config)
await api_instance.cache_instruments()
module_controls = await AttachedModulesControl.build(api_instance)
module_controls = await AttachedModulesControl.build(
api_instance, board_revision=backend.board_revision
)
backend.module_controls = module_controls
await backend.watch()
return api_instance
Expand Down
3 changes: 1 addition & 2 deletions api/src/opentrons/hardware_control/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
aionotify = None

from opentrons.drivers.smoothie_drivers import SmoothieDriver
from opentrons.drivers.rpi_drivers import build_gpio_chardev, usb
from opentrons.drivers.rpi_drivers import build_gpio_chardev
import opentrons.config
from opentrons.config import pipette_config
from opentrons.config.types import RobotConfig
Expand Down Expand Up @@ -77,7 +77,6 @@ def __init__(self, config: RobotConfig, gpio: GPIODriverLike):
config=self.config, gpio_chardev=self._gpio_chardev
)
self._cached_fw_version: Optional[str] = None
self._usb = usb.USBBus(self._board_revision)
self._module_controls: Optional[AttachedModulesControl] = None
try:
self._event_watcher = self._build_event_watcher()
Expand Down
103 changes: 42 additions & 61 deletions api/src/opentrons/hardware_control/emulation/app.py
Original file line number Diff line number Diff line change
@@ -1,86 +1,67 @@
import asyncio
import logging
from opentrons.hardware_control.emulation.connection_handler import ConnectionHandler
from opentrons.hardware_control.emulation.magdeck import MagDeckEmulator

from opentrons.hardware_control.emulation.module_server import ModuleStatusServer
from opentrons.hardware_control.emulation.parser import Parser
from opentrons.hardware_control.emulation.proxy import Proxy
from opentrons.hardware_control.emulation.run_emulator import run_emulator_server
from opentrons.hardware_control.emulation.settings import Settings
from opentrons.hardware_control.emulation.tempdeck import TempDeckEmulator
from opentrons.hardware_control.emulation.thermocycler import ThermocyclerEmulator
from opentrons.hardware_control.emulation.smoothie import SmoothieEmulator
from opentrons.hardware_control.emulation.types import ModuleType

logger = logging.getLogger(__name__)


SMOOTHIE_PORT = 9996
THERMOCYCLER_PORT = 9997
TEMPDECK_PORT = 9998
MAGDECK_PORT = 9999

class Application:
"""The emulator application."""

class ServerManager:
"""
Class to start and stop emulated smoothie and modules.
"""
def __init__(self, settings: Settings) -> None:
"""Constructor.
def __init__(self, settings=Settings()) -> None:
host = settings.host
self._mag_emulator = MagDeckEmulator(parser=Parser())
self._temp_emulator = TempDeckEmulator(parser=Parser())
self._therm_emulator = ThermocyclerEmulator(parser=Parser())
Args:
settings: Application settings.
"""
self._settings = settings
self._status_server = ModuleStatusServer(settings.module_server)
self._smoothie_emulator = SmoothieEmulator(
parser=Parser(), settings=settings.smoothie
)

self._mag_server = self._create_server(
host=host,
port=MAGDECK_PORT,
handler=ConnectionHandler(self._mag_emulator),
self._magdeck = Proxy(
ModuleType.Magnetic, self._status_server, self._settings.magdeck_proxy
)
self._temp_server = self._create_server(
host=host,
port=TEMPDECK_PORT,
handler=ConnectionHandler(self._temp_emulator),
self._temperature = Proxy(
ModuleType.Temperature,
self._status_server,
self._settings.temperature_proxy,
)
self._therm_server = self._create_server(
host=host,
port=THERMOCYCLER_PORT,
handler=ConnectionHandler(self._therm_emulator),
self._thermocycler = Proxy(
ModuleType.Thermocycler,
self._status_server,
self._settings.thermocycler_proxy,
)
self._smoothie_server = self._create_server(
host=host,
port=SMOOTHIE_PORT,
handler=ConnectionHandler(self._smoothie_emulator),
self._heatershaker = Proxy(
ModuleType.Heatershaker,
self._status_server,
self._settings.heatershaker_proxy,
)

async def run(self):
async def run(self) -> None:
"""Run the application."""
await asyncio.gather(
self._mag_server,
self._temp_server,
self._therm_server,
self._smoothie_server,
self._status_server.run(),
run_emulator_server(
host=self._settings.smoothie.host,
port=self._settings.smoothie.port,
emulator=self._smoothie_emulator,
),
self._magdeck.run(),
self._temperature.run(),
self._thermocycler.run(),
self._heatershaker.run(),
)

@staticmethod
async def _create_server(host: str, port: int, handler: ConnectionHandler) -> None:
"""Run a server."""
server = await asyncio.start_server(handler, host, port)

async with server:
await server.serve_forever()

def reset(self):
self._smoothie_emulator.reset()
self._mag_emulator.reset()
self._temp_emulator.reset()
self._therm_emulator.reset()

def stop(self):
self._smoothie_server.close()
self._temp_server.close()
self._therm_server.close()
self._mag_server.close()


if __name__ == "__main__":
logging.basicConfig(format="%(asctime)s:%(message)s", level=logging.DEBUG)
asyncio.run(ServerManager().run())
s = Settings()
asyncio.run(Application(settings=s).run())
22 changes: 13 additions & 9 deletions api/src/opentrons/hardware_control/emulation/magdeck.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,29 @@
from opentrons.drivers.mag_deck.driver import GCODE
from opentrons.hardware_control.emulation.parser import Parser, Command
from .abstract_emulator import AbstractEmulator
from .settings import MagDeckSettings

logger = logging.getLogger(__name__)


SERIAL = "magnetic_emulator"
MODEL = "mag_deck_v20"
VERSION = "2.0.0"


class MagDeckEmulator(AbstractEmulator):
"""Magdeck emulator"""

def __init__(self, parser: Parser) -> None:
self.reset()
height: float = 0
position: float = 0

def __init__(self, parser: Parser, settings: MagDeckSettings) -> None:
self._settings = settings
self._parser = parser
self.reset()

def handle(self, line: str) -> Optional[str]:
"""Handle a line"""
results = (self._handle(c) for c in self._parser.parse(line))
joined = " ".join(r for r in results if r)
return None if not joined else joined

def reset(self):
def reset(self) -> None:
self.height: float = 0
self.position: float = 0

Expand All @@ -50,7 +50,11 @@ def _handle(self, command: Command) -> Optional[str]:
elif command.gcode == GCODE.GET_CURRENT_POSITION:
return f"Z:{self.position}"
elif command.gcode == GCODE.DEVICE_INFO:
return f"serial:{SERIAL} model:{MODEL} version:{VERSION}"
return (
f"serial:{self._settings.serial_number} "
f"model:{self._settings.model} "
f"version:{self._settings.version}"
)
elif command.gcode == GCODE.PROGRAMMING_MODE:
pass
return None
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""Package for the module status server."""
from .server import ModuleStatusServer
from .client import ModuleStatusClient

__all__ = [
"ModuleStatusServer",
"ModuleStatusClient",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from __future__ import annotations
import asyncio
from asyncio import IncompleteReadError, LimitOverrunError
from typing import Optional

from opentrons.hardware_control.emulation.module_server.models import Message
from opentrons.hardware_control.emulation.module_server.server import MessageDelimiter


class ModuleServerClientError(Exception):
pass


class ModuleStatusClient:
"""A module server client."""

def __init__(
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
"""Constructor."""
self._reader = reader
self._writer = writer

@classmethod
async def connect(
cls,
host: str,
port: int,
retries: int = 3,
interval_seconds: float = 0.1,
) -> ModuleStatusClient:
"""Connect to the module server.
Args:
host: module server host.
port: module server port.
retries: number of retries
interval_seconds: time between retries.
Returns:
None
Raises:
IOError on retry expiry.
"""
r: Optional[asyncio.StreamReader] = None
w: Optional[asyncio.StreamWriter] = None
for i in range(retries):
try:
r, w = await asyncio.open_connection(host=host, port=port)
break
except OSError:
await asyncio.sleep(interval_seconds)

if r is not None and w is not None:
return ModuleStatusClient(reader=r, writer=w)
else:
raise IOError(
f"Failed to connect to module_server at after {retries} retries."
)

async def read(self) -> Message:
"""Read a message from the module server."""
try:
b = await self._reader.readuntil(MessageDelimiter)
m: Message = Message.parse_raw(b)
return m
except (IncompleteReadError, LimitOverrunError) as e:
raise ModuleServerClientError(str(e))

def close(self) -> None:
"""Close the client."""
self._writer.close()
Loading

0 comments on commit b97f1bb

Please sign in to comment.