""" Utilities for running benchmarks. Classes: SerialMonitor -- captures serial output for a specific amount of time ShellMonitor -- captures UNIX program output for a specific amount of time Functions: get_monitor -- return Monitor class suitable for the selected multipass arch get_counter_limits -- return arch-specific multipass counter limits (max value, max overflow) """ import os import re import serial import serial.threaded import subprocess import sys import time class SerialReader(serial.threaded.Protocol): """ Character- to line-wise data buffer for serial interfaces. Reads in new data whenever it becomes available and exposes a line-based interface to applications. """ def __init__(self, callback=None): """Create a new SerialReader object.""" self.callback = callback self.recv_buf = "" self.lines = [] def __call__(self): return self def data_received(self, data): """Append newly received serial data to the line buffer.""" try: str_data = data.decode("UTF-8") self.recv_buf += str_data # We may get anything between \r\n, \n\r and simple \n newlines. # We assume that \n is always present and use str.strip to remove leading/trailing \r symbols # Note: Do not call str.strip on lines[-1]! Otherwise, lines may be mangled lines = self.recv_buf.split("\n") if len(lines) > 1: self.lines.extend(map(str.strip, lines[:-1])) self.recv_buf = lines[-1] if self.callback: for line in lines[:-1]: self.callback(str.strip(line)) except UnicodeDecodeError: pass # sys.stderr.write('UART output contains garbage: {data}\n'.format(data = data)) def get_lines(self) -> list: """ Return the latest batch of complete lines. The return value is a list and may be empty. Empties the internal line buffer to ensure that no line is returned twice. """ ret = self.lines self.lines = [] return ret def get_line(self) -> str: """ Return the latest complete line, or None. Empties the entire internal line buffer to ensure that no line is returned twice. """ if len(self.lines): ret = self.lines[-1] self.lines = [] return ret return None class SerialMonitor: """SerialMonitor captures serial output for a specific amount of time.""" def __init__(self, port: str, baud: int, callback=None): """ Create a new SerialMonitor connected to port at the specified baud rate. Communication uses no parity, no flow control, and one stop bit. Data collection starts immediately. """ self.ser = serial.serial_for_url(port, do_not_open=True) self.ser.baudrate = baud self.ser.parity = "N" self.ser.rtscts = False self.ser.xonxoff = False try: self.ser.open() except serial.SerialException as e: sys.stderr.write( "Could not open serial port {}: {}\n".format(self.ser.name, e) ) sys.exit(1) self.reader = SerialReader(callback=callback) self.worker = serial.threaded.ReaderThread(self.ser, self.reader) self.worker.start() def run(self, timeout: int = 10) -> list: """ Collect serial output for timeout seconds and return a list of all output lines. Blocks until data collection is complete. """ time.sleep(timeout) return self.reader.get_lines() def get_lines(self) -> list: return self.reader.get_lines() def get_files(self) -> list: return list() def get_config(self) -> dict: return dict() def close(self): """Close serial connection.""" self.worker.stop() self.ser.close() # TODO Optionale Kalibrierung mit bekannten Widerständen an GPIOs am Anfang # TODO Sync per LED? -> Vor und ggf nach jeder Transition kurz pulsen # TODO Für Verbraucher mit wenig Energiebedarf: Versorgung direkt per GPIO # -> Zu Beginn der Messung ganz ausknipsen class EnergyTraceMonitor(SerialMonitor): """EnergyTraceMonitor captures serial timing output and EnergyTrace energy data.""" def __init__(self, port: str, baud: int, callback=None, voltage=3.3): super().__init__(port=port, baud=baud, callback=callback) self._voltage = voltage self._output = time.strftime("%Y%m%d-%H%M%S.etlog") self._start_energytrace() def _start_energytrace(self): cmd = ["msp430-etv", "--save", self._output, "0"] self._logger = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True ) def close(self): super().close() self._logger.send_signal(subprocess.signal.SIGINT) stdout, stderr = self._logger.communicate(timeout=15) def get_files(self) -> list: return [self._output] def get_config(self) -> dict: return { "voltage": self._voltage, } class MIMOSAMonitor(SerialMonitor): """MIMOSAMonitor captures serial output and MIMOSA energy data for a specific amount of time.""" def __init__( self, port: str, baud: int, callback=None, offset=130, shunt=330, voltage=3.3 ): super().__init__(port=port, baud=baud, callback=callback) self._offset = offset self._shunt = shunt self._voltage = voltage self._start_mimosa() def _mimosactl(self, subcommand): cmd = ["mimosactl"] cmd.append(subcommand) res = subprocess.run(cmd) if res.returncode != 0: res = subprocess.run(cmd) if res.returncode != 0: raise RuntimeError( "{} returned {}".format(" ".join(cmd), res.returncode) ) def _mimosacmd(self, opts): cmd = ["MimosaCMD"] cmd.extend(opts) res = subprocess.run(cmd) if res.returncode != 0: raise RuntimeError("{} returned {}".format(" ".join(cmd), res.returncode)) def _start_mimosa(self): self._mimosactl("disconnect") self._mimosacmd(["--start"]) self._mimosacmd(["--parameter", "offset", str(self._offset)]) self._mimosacmd(["--parameter", "shunt", str(self._shunt)]) self._mimosacmd(["--parameter", "voltage", str(self._voltage)]) self._mimosacmd(["--mimosa-start"]) time.sleep(2) self._mimosactl("1k") # 987 ohm time.sleep(2) self._mimosactl("100k") # 99.3 kohm time.sleep(2) self._mimosactl("connect") def _stop_mimosa(self): # Make sure the MIMOSA daemon has gathered all needed data time.sleep(2) self._mimosacmd(["--mimosa-stop"]) mtime_changed = True mim_file = None time.sleep(1) # reverse sort ensures that we will get the latest file, which must # belong to the current measurements. This ensures that older .mim # files lying around in the directory will not confuse our # heuristic. for filename in sorted(os.listdir(), reverse=True): if re.search(r"[.]mim$", filename): mim_file = filename break while mtime_changed: mtime_changed = False if time.time() - os.stat(mim_file).st_mtime < 3: mtime_changed = True time.sleep(1) self._mimosacmd(["--stop"]) return mim_file def close(self): super().close() self.mim_file = self._stop_mimosa() def get_files(self) -> list: return [self.mim_file] def get_config(self) -> dict: return { "offset": self._offset, "shunt": self._shunt, "voltage": self._voltage, } class ShellMonitor: """SerialMonitor runs a program and captures its output for a specific amount of time.""" def __init__(self, script: str, callback=None): """ Create a new ShellMonitor object. Does not start execution and monitoring yet. """ self.script = script self.callback = callback def run(self, timeout: int = 4) -> list: """ Run program for timeout seconds and return a list of its stdout lines. stderr and return status are discarded at the moment. """ if type(timeout) != int: raise ValueError("timeout argument must be int") res = subprocess.run( ["timeout", "{:d}s".format(timeout), self.script], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, ) if self.callback: for line in res.stdout.split("\n"): self.callback(line) return res.stdout.split("\n") def monitor(self): raise NotImplementedError def close(self): """ Do nothing, successfully. Intended for compatibility with SerialMonitor. """ pass def build(arch, app, opts=[]): command = ["make", "arch={}".format(arch), "app={}".format(app), "clean"] command.extend(opts) res = subprocess.run( command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True ) if res.returncode != 0: raise RuntimeError( "Build failure, executing {}:\n".format(command) + res.stderr ) command = ["make", "-B", "arch={}".format(arch), "app={}".format(app)] command.extend(opts) res = subprocess.run( command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True ) if res.returncode != 0: raise RuntimeError( "Build failure, executing {}:\n ".format(command) + res.stderr ) return command def flash(arch, app, opts=[]): command = ["make", "arch={}".format(arch), "app={}".format(app), "program"] command.extend(opts) res = subprocess.run( command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True ) if res.returncode != 0: raise RuntimeError("Flash failure") return command def get_info(arch, opts: list = []) -> list: """ Return multipass "make info" output. Returns a list. """ command = ["make", "arch={}".format(arch), "info"] command.extend(opts) res = subprocess.run( command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True ) if res.returncode != 0: raise RuntimeError("make info Failure") return res.stdout.split("\n") def get_monitor(arch: str, **kwargs) -> object: """ Return an appropriate monitor for arch, depending on "make info" output. Port and Baud rate are taken from "make info". :param arch: architecture name, e.g. 'msp430fr5994lp' or 'posix' :param energytrace: `EnergyTraceMonitor` options. Returns an EnergyTrace monitor if not None. :param mimosa: `MIMOSAMonitor` options. Returns a MIMOSA monitor if not None. """ for line in get_info(arch): if "Monitor:" in line: _, port, arg = line.split(" ") if port == "run": return ShellMonitor(arg, **kwargs) elif "mimosa" in kwargs and kwargs["mimosa"] is not None: mimosa_kwargs = kwargs.pop("mimosa") return MIMOSAMonitor(port, arg, **mimosa_kwargs, **kwargs) elif "energytrace" in kwargs and kwargs["energytrace"] is not None: energytrace_kwargs = kwargs.pop("energytrace") return EnergyTraceMonitor(port, arg, **energytrace_kwargs, **kwargs) else: kwargs.pop("energytrace", None) kwargs.pop("mimosa", None) return SerialMonitor(port, arg, **kwargs) raise RuntimeError("Monitor failure") def get_counter_limits(arch: str) -> tuple: """Return multipass max counter and max overflow value for arch.""" for line in get_info(arch): match = re.match("Counter Overflow: ([^/]*)/(.*)", line) if match: overflow_value = int(match.group(1)) max_overflow = int(match.group(2)) return overflow_value, max_overflow raise RuntimeError("Did not find Counter Overflow limits") def get_counter_limits_us(arch: str) -> tuple: """Return duration of one counter step and one counter overflow in us.""" cpu_freq = 0 overflow_value = 0 max_overflow = 0 for line in get_info(arch): match = re.match(r"CPU\s+Freq:\s+(.*)\s+Hz", line) if match: cpu_freq = int(match.group(1)) match = re.match(r"Counter Overflow:\s+([^/]*)/(.*)", line) if match: overflow_value = int(match.group(1)) max_overflow = int(match.group(2)) if cpu_freq and overflow_value: return 1000000 / cpu_freq, overflow_value * 1000000 / cpu_freq, max_overflow raise RuntimeError("Did not find Counter Overflow limits")