Source code for automationshield.shields.baseshield

import serial
import serial.tools.list_ports
import time

from typing import Optional

from .. import arduino
from ..exception import AutomationShieldException


[docs] class BaseShield: """Base class for shield devices. Handles communication with Arduino, sensor calibration and unit conversions. The class can also install the proper firmware on an Arduino. Interface: * Actuator input should be provided in percent by default. * Potentiometer is provided in percent by default. * Sensor values are converted to relevant physical units in the child classes. """ TEST: int = 0 """Flag to write to Arduino. This flag is used to check if the correct firmware is installed. The response from the Arduino should be the C++ software version and the id of the shield.""" RUN: int = 1 """Flag to write to Arduino. This flag is used for normal running of the shield device. The actuator value is applied and the Arduino responds with the potentiometer and sensor values.""" STOP: int = 2 """Flag to write to Arduino. This flag is used to stop the actuator. The Arduino will stop the actuator to ensure the shield is safely stopped.""" # Wait time after opening connection TIMEOUT: int = 3 """Time to wait after opening the serial connection before writing to the Arduino.""" script: str = "" """Name of the script directory in which the ``.ino`` file for the specific shield is located.""" shield_id: str = "" """ID assigned to shield. This is used to check whether the correct firmware is installed on the Arduino.""" actuator_bits: int = 0 """Number of bits used for actuator.""" potentiometer_bits: int = 0 # resolution of system ad converter """Number of bits used for potentiometer.""" sensor_bits: int = 0 """Number of bits used for sensor."""
[docs] class PlotInfo: """This class contains information to improve the plots generated by the :py:class:`~automationshield.plotting.Plotter` \ and :py:class:`~automationshield.plotting.LivePlotter` classes in the plotting package. """ sensor_unit: str = "" """Sets the unit on the axis label of the output plot.""" sensor_type: str = "" """Sets the name of the axis of the output plot.""" sensor_min: float = 0 """Sets to lower bound of the sensor value. This is used to set up the :py:class:`~automationshield.plotting.LivePlotter`.""" sensor_max: float = 10 """Sets the upper bound of the sensor value. This is used to set up the :py:class:`~automationshield.plotting.LivePlotter`."""
def __init__(self, port:Optional[str]=None) -> None: """ :param port: Port on which the Arduino is connected, defaults to None. """ if port is None: port = self.find_arduino() self.port = port self.conn = serial.Serial(baudrate=115200) self.conn.port = self.port self.conn.timeout = 1 self.zero_reference = 0
[docs] def find_arduino(self) -> str: """Get the name of the port that is connected to Arduino. Raises exception if no port was found. :raises ~automationshield.exception.AutomationShieldException: Raised if no Arduino was found. :return: COM port of the Arduino. """ ports = serial.tools.list_ports.comports() for p in ports: if p.manufacturer is not None and "Arduino" in p.manufacturer: return p.device raise AutomationShieldException("No Arduino Found")
[docs] def check_firmware(self) -> int: """Check that the correct firmware is installed on the Arduino. When the arduino reads the :py:attr:`BaseShield.TEST` flag, it should respond with the code version and two bytes which, when converted to ASCII, should match the shield id. :raises ~automationshield.exception.AutomationShieldException: If the arduino didn't respond correctly. :return: version number of Arduino firmware. """ # set the upper limit of the range to the lower of the potentiometer bits divided by 2 and the sensor bits divided by 4, to guarantee the response fits in the bits. self.write(self.TEST, 0) bts = self.conn.read(size=3) id = f"{chr(bts[1])}{chr(bts[2])}" if id == self.shield_id: return bts[0] else: raise AutomationShieldException("Incorrect firmware installed on Arduino")
[docs] def install_firmware(self, device:str) -> None: """Upload compiled firmware for the current shield to the Arduino. :param device: FQBN of Arduino. FQBNs for common devices are provided in :py:mod:`automationshield.arduino`, e.g. :py:const:`automationshield.arduino.LEONARDO`: ``arduino:avr:leonardo``. """ print("\nUploading ...") arduino.upload_script(device, self.script, self.port) print("\nDone")
[docs] def convert_potentiometer_reading(self, raw: int) -> float: """Convert n-bit potentiometer reading to percentage value. A bit value of :math:`1023` is mapped to :math:`100` \%. :param raw: n-bit potentiometer value. :return: Potentiometer value as percentage [0, 100]. """ return raw / (2**self.potentiometer_bits - 1) * 100
[docs] def convert_actuator_input(self, percent: float) -> int: """Convert actuator input from percentage to n-bit value. A value of :math:`100` (\%) is mapped to :math:`2^n-1` where :math:`n` equals :py:attr:`BaseShield.actuator_bits`. :param percent: Actuator percentage. :return: n-bit number of actuator value. """ return percent / 100 * (2**self.actuator_bits - 1)
[docs] def convert_sensor_reading(self, sensor: int) -> float: """Convert sensor reading to physical units. Should be implemented on subclasses. By default, the sensor value is returned unchanged. :param sensor: Raw sensor value :return: Sensor value converted to physical units. """ return sensor
[docs] def calibrate_sensor_reading(self, sensor: int) -> int: """Calibrate the sensor reading with the zero reference. Should be implemented on subclass. The default implementation is .. math:: y_c = y_r - y_0 where :math:`y_c` is the calibrated sensor value, :math:`y_r` is the raw sensor value and :math:`y_0` is the zero reference of the sensor value. .. note:: The zero reference is obtained at the start of each experiment. It is therefore important that the system is at rest when an experiment is started. :param sensor: Raw sensor value. :return: Calibrated sensor value. """ return sensor - self.zero_reference
[docs] def _read(self) -> tuple[int, float]: """Construct potentiometer and sensor value from bytes received from arduino. Three bytes are received from the arduino. The second and third byte contain the first eight bits of the potentiometer and sensor \ values. The first byte contains the remaining bits of the potentiometer and sensor values. Byte 1: +-------------+-------------+-------------+-------------+----------------+----------------+-------------+-------------+ | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | +=============+=============+=============+=============+================+================+=============+=============+ | 0 | 0 | :math:`p_9` | :math:`p_8` | :math:`s_{11}` | :math:`s_{10}` | :math:`p_9` | :math:`p_8` | +-------------+-------------+-------------+-------------+----------------+----------------+-------------+-------------+ Byte 2: +-------------+-------------+-------------+-------------+-------------+-------------+-------------+-------------+ | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | +=============+=============+=============+=============+=============+=============+=============+=============+ | :math:`p_7` | :math:`p_6` | :math:`p_5` | :math:`p_4` | :math:`p_3` | :math:`p_2` | :math:`p_1` | :math:`p_0` | +-------------+-------------+-------------+-------------+-------------+-------------+-------------+-------------+ Byte 3: +-------------+-------------+-------------+-------------+-------------+-------------+-------------+-------------+ | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | +=============+=============+=============+=============+=============+=============+=============+=============+ | :math:`s_7` | :math:`s_6` | :math:`s_5` | :math:`s_4` | :math:`s_3` | :math:`s_2` | :math:`s_1` | :math:`s_0` | +-------------+-------------+-------------+-------------+-------------+-------------+-------------+-------------+ The bits are extracted from the bytes and reconstructed, after which they are converted to base 10 (integer numbers). :return: Potentiometer and sensor values, respectively. """ data = self.conn.read(size=3) pot = data[0] // 16 * 256 + data[1] sensor = data[0] % 16 * 256 + data[2] return pot, sensor
[docs] def read(self, raw: bool=False) -> tuple[float]: """Read data from Arduino. If ``raw == False``, the potentiometer value is rescaled to percentages; and the sensor is calibrated with the zero reference and converted to relevant units. This is the default. \ If ``raw == True``, none of that happens and the potentiometer and sensor are returned as n-bit values. No calibration is performed either. :param raw: If True, returns raw n-bit readings from potentiometer and sensor. Defaults to False, in which case the potentiometer is converted to percent and the sensor to appropriate units. :raises ~automationshield.AutomationShieldException: Raised if no data was received. This can happen if there was no :py:meth:`BaseShield.write` command invoked preceding a call to :py:meth:`BaseShield.read` or \ if there is something wrong with the serial connection or the cable. :return: (Converted and calibrated) potentiometer and sensor readings, in that order. """ try: pot, sensor = self._read() if raw: return pot, sensor else: return self.convert_potentiometer_reading(pot), self.convert_sensor_reading(self.calibrate_sensor_reading(sensor)) except IndexError: raise AutomationShieldException("No data received from Arduino")
[docs] @staticmethod def saturate_bits(value: float, bits: int) -> int: """Saturate value between :math:`0` and :math:`2^{bits - 1}` inclusive. :param value: Raw value. :param bits: Number of bits. :return: Saturated value. """ return int(min(max(value, 0), 2**bits - 1))
[docs] @staticmethod def saturate_percent(value: float) -> float: """Saturate value between :math:`0` and :math:`100` inclusive. :param value: Raw value in percent. :return: Saturated value in percent. """ return min(max(value, 0), 100)
[docs] def _write(self, flag: int, output: int) -> None: """Put flag and output value into bytes and send them to the arduino. The first byte is the flag value. Technically the flag only needs two bits, so the first byte looks like this: +-------------+-------------+-------------+-------------+-------------+-------------+-------------+-------------+ | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | +=============+=============+=============+=============+=============+=============+=============+=============+ | 0 | 0 | 0 | 0 | 0 | 0 | :math:`f_1` | :math:`f_0` | +-------------+-------------+-------------+-------------+-------------+-------------+-------------+-------------+ Depending the on the number of output bits (set by :py:const:`BaseShield.actuator_bits`), the actuator value is sent in one or two bytes. \ If :py:const:`BaseShield.actuator_bits` :math:`\\leq 8`, only the bottom byte is created and sent, otherwise both are constructed and sent \ in the order below. +----------------+----------------+----------------+----------------+----------------+----------------+-------------+-------------+ | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | +================+================+================+================+================+================+=============+=============+ | :math:`o_{15}` | :math:`o_{14}` | :math:`o_{13}` | :math:`o_{12}` | :math:`o_{11}` | :math:`o_{10}` | :math:`o_9` | :math:`o_8` | +----------------+----------------+----------------+----------------+----------------+----------------+-------------+-------------+ +-------------+-------------+-------------+-------------+-------------+-------------+-------------+-------------+ | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 | +=============+=============+=============+=============+=============+=============+=============+=============+ | :math:`o_7` | :math:`o_6` | :math:`o_5` | :math:`o_4` | :math:`o_3` | :math:`o_2` | :math:`o_1` | :math:`o_0` | +-------------+-------------+-------------+-------------+-------------+-------------+-------------+-------------+ :param flag: :py:const:`BaseShield.TEST`, :py:const:`BaseShield.RUN` or :py:const:`BaseShield.STOP`. :param output: Output value to send to the arduino, most often the actuator input. """ if self.actuator_bits > 8: bts = [flag, output//256, output%256] else: bts = [flag, output] self.conn.write(bytes(bts))
[docs] def write(self, flag: int, actuator: float, raw: bool=False) -> int: """Write run/stop flag and actuator value to Arduino. Convert and saturate the actuator value before sending. the flag must be one of :py:const:`BaseShield.TEST`, :py:const:`BaseShield.RUN` or :py:const:`BaseShield.STOP`. * :py:const:`BaseShield.TEST` returns the version number of the Arduino code and a shield id, which should match :py:const:`BaseShield.shield_id`. The actuator input is irrelevant for this command. * :py:const:`BaseShield.RUN` means normal running mode. Set the actuator value. * :py:const:`BaseShield.STOP` tells the arduino to stop the actuator. The supplied actuator value is ignored by the Arduino. :param flag: :py:const:`BaseShield.TEST`, :py:const:`BaseShield.RUN` or :py:const:`BaseShield.STOP`. :param actuator: actuator value. :param raw: If False, expects actuator as percentage [0, 100]. Value is converted before being sent. If True, expects actuator in range of [0, :math:`2^n - 1` ] with :math:`n` the number of :py:const:`BaseShield.actuator_bits`. Defaults to False. :return: Saturated n-bit motor value. """ if not raw: saturated_actuator = self.saturate_percent(actuator) actuator = self.convert_actuator_input(saturated_actuator) output = self.saturate_bits(actuator, self.actuator_bits) else: saturated_actuator = self.saturate_bits(actuator, self.actuator_bits) output = saturated_actuator self._write(flag, output) return saturated_actuator
[docs] def calibrate(self) -> None: """Read out a zero reference and save it. System should be at rest when calling this method.""" self.write(self.RUN, 0) _, self.zero_reference = self.read(raw=True)
[docs] def stop(self) -> None: """Send :py:const:`BaseShield.STOP` signal to Arduino.""" self.write(self.STOP, 0)
[docs] def open(self) -> None: """Reset buffers and open connection to Arduino if it is not open already. Wait for :py:const:`BaseShield.TIMEOUT` seconds to make sure connection is established.""" if not self.conn.is_open: self.conn.open() self.conn.reset_input_buffer() self.conn.reset_output_buffer() time.sleep(self.TIMEOUT)
[docs] def close(self, *args) -> None: """Close connection to Arduino.""" self.conn.close()
[docs] def __enter__(self) -> "BaseShield": """Implements the ``with`` context manager. This method calls * :py:meth:`BaseShield.open`, * :py:meth:`BaseShield.check_firmware`, * :py:meth:`BaseShield.calibrate`. :return: Shield instance. """ self.open() self.check_firmware() self.calibrate() return self
[docs] def __exit__(self, *args) -> None: """Closes the context manager. This method calls * :py:meth:`BaseShield.stop`, * :py:meth:`BaseShield.close`. """ self.stop() self.close(*args)
class BaseDummyShield(BaseShield): """Base dummy class that mimics a Shield interface. Can be used for testing when no hardware is available.""" def __init__(self) -> None: self.zero_reference = 0 def open(self): pass def close(self, *args): pass def __enter__(self): return self def __exit__(self, *args): pass