Skip to content

Commit 1d046ea

Browse files
authored
feat(api): Create framework for running protocols against emulation (#8149)
* feat(api): Add G-Code only text output mode * Put response property with other properties * Move device parsing to G-Code Watcher * Add smoothie protocols to validate against * feat(api) Add reset methods to all emulated hardware * feat(api): Refactor app.py into ServerManager class * feat(api): Create Protocol Runner * feat(api): Remove unused properties * feat(api): Fix linting errors * feat(api): Add support for settings * feat(api): More linting * feat(api): Cleanup * feat(api): Remove file that was used for testing
1 parent d67f0f9 commit 1d046ea

File tree

18 files changed

+338
-119
lines changed

18 files changed

+338
-119
lines changed
Lines changed: 58 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import asyncio
22
import logging
3-
43
from opentrons.hardware_control.emulation.connection_handler import \
54
ConnectionHandler
65
from opentrons.hardware_control.emulation.magdeck import MagDeckEmulator
@@ -9,7 +8,6 @@
98
from opentrons.hardware_control.emulation.tempdeck import TempDeckEmulator
109
from opentrons.hardware_control.emulation.thermocycler import ThermocyclerEmulator
1110
from opentrons.hardware_control.emulation.smoothie import SmoothieEmulator
12-
1311
logger = logging.getLogger(__name__)
1412

1513

@@ -19,37 +17,69 @@
1917
MAGDECK_PORT = 9999
2018

2119

22-
async def run_server(host: str, port: int, handler: ConnectionHandler) -> None:
23-
"""Run a server."""
24-
server = await asyncio.start_server(handler, host, port)
20+
class ServerManager:
21+
"""
22+
Class to start and stop emulated smoothie and modules.
23+
"""
24+
def __init__(self, settings=Settings()) -> None:
25+
host = settings.host
26+
self._mag_emulator = MagDeckEmulator(parser=Parser())
27+
self._temp_emulator = TempDeckEmulator(parser=Parser())
28+
self._therm_emulator = ThermocyclerEmulator(parser=Parser())
29+
self._smoothie_emulator = SmoothieEmulator(
30+
parser=Parser(), settings=settings.smoothie
31+
)
32+
33+
self._mag_server = self._create_server(
34+
host=host,
35+
port=MAGDECK_PORT,
36+
handler=ConnectionHandler(self._mag_emulator),
37+
)
38+
self._temp_server = self._create_server(
39+
host=host,
40+
port=TEMPDECK_PORT,
41+
handler=ConnectionHandler(self._temp_emulator),
42+
)
43+
self._therm_server = self._create_server(
44+
host=host,
45+
port=THERMOCYCLER_PORT,
46+
handler=ConnectionHandler(self._therm_emulator),
47+
)
48+
self._smoothie_server = self._create_server(
49+
host=host,
50+
port=SMOOTHIE_PORT,
51+
handler=ConnectionHandler(self._smoothie_emulator),
52+
)
53+
54+
async def run(self):
55+
await asyncio.gather(
56+
self._mag_server,
57+
self._temp_server,
58+
self._therm_server,
59+
self._smoothie_server
60+
)
2561

26-
async with server:
27-
await server.serve_forever()
62+
@staticmethod
63+
async def _create_server(host: str, port: int, handler: ConnectionHandler) -> None:
64+
"""Run a server."""
65+
server = await asyncio.start_server(handler, host, port)
2866

67+
async with server:
68+
await server.serve_forever()
2969

30-
async def run() -> None:
31-
"""Run the module emulators."""
32-
settings = Settings()
33-
host = settings.host
70+
def reset(self):
71+
self._smoothie_emulator.reset()
72+
self._mag_emulator.reset()
73+
self._temp_emulator.reset()
74+
self._therm_emulator.reset()
3475

35-
await asyncio.gather(
36-
run_server(host=host,
37-
port=MAGDECK_PORT,
38-
handler=ConnectionHandler(MagDeckEmulator(parser=Parser()))),
39-
run_server(host=host,
40-
port=TEMPDECK_PORT,
41-
handler=ConnectionHandler(TempDeckEmulator(parser=Parser()))),
42-
run_server(host=host,
43-
port=THERMOCYCLER_PORT,
44-
handler=ConnectionHandler(ThermocyclerEmulator(parser=Parser()))),
45-
run_server(host=host,
46-
port=SMOOTHIE_PORT,
47-
handler=ConnectionHandler(
48-
SmoothieEmulator(parser=Parser(), settings=settings.smoothie))
49-
),
50-
)
76+
def stop(self):
77+
self._smoothie_server.close()
78+
self._temp_server.close()
79+
self._therm_server.close()
80+
self._mag_server.close()
5181

5282

5383
if __name__ == "__main__":
5484
logging.basicConfig(format='%(asctime)s:%(message)s', level=logging.DEBUG)
55-
asyncio.run(run())
85+
asyncio.run(ServerManager().run())

api/src/opentrons/hardware_control/emulation/magdeck.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@ class MagDeckEmulator(AbstractEmulator):
2121
"""Magdeck emulator"""
2222

2323
def __init__(self, parser: Parser) -> None:
24-
self.height: float = 0
25-
self.position: float = 0
24+
self.reset()
2625
self._parser = parser
2726

2827
def handle(self, line: str) -> Optional[str]:
@@ -31,6 +30,10 @@ def handle(self, line: str) -> Optional[str]:
3130
joined = ' '.join(r for r in results if r)
3231
return None if not joined else joined
3332

33+
def reset(self):
34+
self.height: float = 0
35+
self.position: float = 0
36+
3437
def _handle(self, command: Command) -> Optional[str]:
3538
"""Handle a command."""
3639
logger.info(f"Got command {command}")

api/src/opentrons/hardware_control/emulation/smoothie.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,17 @@ class SmoothieEmulator(AbstractEmulator):
2626
INSTRUMENT_AND_MODEL_STRING_LENGTH = 64
2727

2828
def __init__(self, parser: Parser, settings: SmoothieSettings) -> None:
29-
"""Constructor"""
29+
self._parser = parser
30+
self._settings = settings
31+
self.reset()
32+
33+
def handle(self, line: str) -> Optional[str]:
34+
"""Handle a line"""
35+
results = (self._handle(c) for c in self._parser.parse(line))
36+
joined = ' '.join(r for r in results if r)
37+
return None if not joined else joined
38+
39+
def reset(self):
3040
_, fw_version = _find_smoothie_file()
3141
self._version_string = \
3242
f"Build version: {fw_version}, Build date: CURRENT, " \
@@ -52,22 +62,21 @@ def __init__(self, parser: Parser, settings: SmoothieSettings) -> None:
5262

5363
self._pipette_model = {
5464
"L": utils.string_to_hex(
55-
settings.left.model, self.INSTRUMENT_AND_MODEL_STRING_LENGTH
65+
self._settings.left.model, self.INSTRUMENT_AND_MODEL_STRING_LENGTH
5666
),
5767
"R": utils.string_to_hex(
58-
settings.right.model, self.INSTRUMENT_AND_MODEL_STRING_LENGTH
68+
self._settings.right.model, self.INSTRUMENT_AND_MODEL_STRING_LENGTH
5969
),
6070
}
6171

6272
self._pipette_id = {
6373
"L": utils.string_to_hex(
64-
settings.left.id, self.INSTRUMENT_AND_MODEL_STRING_LENGTH
74+
self._settings.left.id, self.INSTRUMENT_AND_MODEL_STRING_LENGTH
6575
),
6676
"R": utils.string_to_hex(
67-
settings.right.id, self.INSTRUMENT_AND_MODEL_STRING_LENGTH
77+
self._settings.right.id, self.INSTRUMENT_AND_MODEL_STRING_LENGTH
6878
),
6979
}
70-
self._parser = parser
7180

7281
self._gcode_to_function_mapping = {
7382
GCODE.HOMING_STATUS.value: self._get_homing_status,
@@ -81,11 +90,8 @@ def __init__(self, parser: Parser, settings: SmoothieSettings) -> None:
8190
GCODE.HOME.value: self._home_gantry,
8291
}
8392

84-
def handle(self, line: str) -> Optional[str]:
85-
"""Handle a line"""
86-
results = (self._handle(c) for c in self._parser.parse(line))
87-
joined = ' '.join(r for r in results if r)
88-
return None if not joined else joined
93+
def get_current_position(self):
94+
return self._pos
8995

9096
def _get_homing_status(self, command: Command) -> str:
9197
"""Get the current homing status of the emulated gantry"""

api/src/opentrons/hardware_control/emulation/tempdeck.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,7 @@ class TempDeckEmulator(AbstractEmulator):
2626
"""TempDeck emulator"""
2727

2828
def __init__(self, parser: Parser) -> None:
29-
self._temperature = Temperature(
30-
per_tick=.25, current=0.0
31-
)
29+
self.reset()
3230
self._parser = parser
3331

3432
def handle(self, line: str) -> Optional[str]:
@@ -37,6 +35,11 @@ def handle(self, line: str) -> Optional[str]:
3735
joined = ' '.join(r for r in results if r)
3836
return None if not joined else joined
3937

38+
def reset(self):
39+
self._temperature = Temperature(
40+
per_tick=.25, current=0.0
41+
)
42+
4043
def _handle(self, command: Command) -> Optional[str]:
4144
"""Handle a command."""
4245
logger.info(f"Got command {command}")

api/src/opentrons/hardware_control/emulation/thermocycler.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ class ThermocyclerEmulator(AbstractEmulator):
2525
"""Thermocycler emulator"""
2626

2727
def __init__(self, parser: Parser) -> None:
28+
self.reset()
29+
self._parser = parser
30+
31+
def handle(self, line: str) -> Optional[str]:
32+
"""Handle a line"""
33+
results = (self._handle(c) for c in self._parser.parse(line))
34+
joined = ' '.join(r for r in results if r)
35+
return None if not joined else joined
36+
37+
def reset(self):
2838
self._lid_temperate = Temperature(
2939
per_tick=2, current=util.TEMPERATURE_ROOM
3040
)
@@ -34,13 +44,6 @@ def __init__(self, parser: Parser) -> None:
3444
self.lid_status = ThermocyclerLidStatus.OPEN
3545
self.plate_volume = util.OptionalValue[float]()
3646
self.plate_ramp_rate = util.OptionalValue[float]()
37-
self._parser = parser
38-
39-
def handle(self, line: str) -> Optional[str]:
40-
"""Handle a line"""
41-
results = (self._handle(c) for c in self._parser.parse(line))
42-
joined = ' '.join(r for r in results if r)
43-
return None if not joined else joined
4447

4548
def _handle(self, command: Command) -> Optional[str]: # noqa: C901
4649
"""

api/src/opentrons/hardware_control/g_code_parsing/g_code.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,11 @@ def g_code_line(self) -> str:
193193
"""
194194
return f'{self.g_code} {self.g_code_body}'.strip()
195195

196+
@property
197+
def response(self):
198+
"""Unparsed G-Code Response"""
199+
return self._response
200+
196201
def get_gcode_function(self) -> str:
197202
"""
198203
Returns the function that the G-Code performs.
@@ -256,7 +261,3 @@ def get_explanation(self) -> Explanation:
256261
self.g_code_args,
257262
self.response
258263
)
259-
260-
@property
261-
def response(self):
262-
return self._response

api/src/opentrons/hardware_control/g_code_parsing/g_code_program/g_code_program.py

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
from __future__ import annotations
22
import os
33
import json
4-
from opentrons.hardware_control.emulation.app import \
5-
TEMPDECK_PORT, THERMOCYCLER_PORT, SMOOTHIE_PORT, MAGDECK_PORT
64
from typing import List, Union
75
from opentrons.hardware_control.g_code_parsing.g_code_watcher import GCodeWatcher
86
from opentrons.hardware_control.g_code_parsing.g_code import GCode
@@ -14,14 +12,6 @@ class GCodeProgram:
1412
Class for parsing various G-Code files and programs into a
1513
list of GCode objects
1614
"""
17-
18-
DEVICE_LOOKUP_BY_PORT = {
19-
SMOOTHIE_PORT: 'smoothie',
20-
TEMPDECK_PORT: 'tempdeck',
21-
THERMOCYCLER_PORT: 'thermocycler',
22-
MAGDECK_PORT: 'magdeck',
23-
}
24-
2515
@classmethod
2616
def from_g_code_watcher(cls, watcher: GCodeWatcher) -> GCodeProgram:
2717
"""
@@ -32,11 +22,10 @@ def from_g_code_watcher(cls, watcher: GCodeWatcher) -> GCodeProgram:
3222
"""
3323
g_codes = []
3424
for watcher_data in watcher.get_command_list():
35-
device = cls._parse_device(watcher_data.serial_connection)
3625
g_codes.extend(
3726
GCode.from_raw_code(
3827
watcher_data.raw_g_code,
39-
device,
28+
watcher_data.device,
4029
watcher_data.response
4130
)
4231
)
@@ -45,16 +34,6 @@ def from_g_code_watcher(cls, watcher: GCodeWatcher) -> GCodeProgram:
4534
def __init__(self, g_codes: List[GCode]):
4635
self._g_codes = g_codes
4736

48-
@classmethod
49-
def _parse_device(cls, serial_connection):
50-
"""
51-
Based on port specified in connection URL, parse out what the name
52-
of the device is
53-
"""
54-
serial_port = serial_connection.port
55-
device_port = serial_port[serial_port.rfind(':') + 1:]
56-
return cls.DEVICE_LOOKUP_BY_PORT[int(device_port)]
57-
5837
def add_g_code(self, g_code: GCode) -> None:
5938
"""Add singular G-Code to the end of the program"""
6039
self._g_codes.append(g_code)

api/src/opentrons/hardware_control/g_code_parsing/g_code_program/supported_text_modes.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,23 @@ def concise_builder(code: GCode):
8585
return MULTIPLE_SPACE_REGEX.sub(' ', message).strip()
8686

8787

88+
def g_code_only_builder(code: GCode):
89+
"""
90+
Function to build string that contains only the raw G-Code input and output
91+
92+
<Raw G-Code> -> <Raw G-Code Output>
93+
94+
Example:
95+
96+
G28.2 X -> Homing the following axes: X
97+
98+
:param code: G-Code object to parse into a string
99+
:return: Textual description
100+
"""
101+
message = f'{code.g_code_line} -> {code.response}'
102+
return MULTIPLE_SPACE_REGEX.sub(' ', message).strip()
103+
104+
88105
class SupportedTextModes(Enum):
89106
"""
90107
Class representing the different text modes that G-Codes can be parsed into
@@ -101,14 +118,20 @@ class SupportedTextModes(Enum):
101118
"""
102119
DEFAULT = 'Default'
103120
CONCISE = 'Concise'
121+
G_CODE = 'G-Code'
122+
123+
@classmethod
124+
def get_valid_modes(cls):
125+
return [cls.CONCISE.value, cls.DEFAULT.value, cls.G_CODE.value]
104126

105127
@classmethod
106128
def get_text_mode(cls, key: str):
107129
# Defining this inside of the function so that it does not show up
108130
# when using the __members__ attribute
109131
_internal_mapping = {
110132
cls.DEFAULT.value: TextMode(cls.DEFAULT.value, default_builder),
111-
cls.CONCISE.value: TextMode(cls.CONCISE.value, concise_builder)
133+
cls.CONCISE.value: TextMode(cls.CONCISE.value, concise_builder),
134+
cls.G_CODE.value: TextMode(cls.G_CODE.value, g_code_only_builder)
112135
}
113136
members = [member.value for member in list(cls.__members__.values())]
114137
if key not in members:

0 commit comments

Comments
 (0)