diff --git a/.autostart.sh b/.autostart.sh new file mode 100644 index 0000000..9554531 --- /dev/null +++ b/.autostart.sh @@ -0,0 +1 @@ +sudo python3.8 -m ncl_rovers & \ No newline at end of file diff --git a/.github/workflows/python-testing.yml b/.github/workflows/python-testing.yml new file mode 100644 index 0000000..0e252cd --- /dev/null +++ b/.github/workflows/python-testing.yml @@ -0,0 +1,37 @@ +name: Python package testing + +on: + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.6, 3.7, 3.8] + + steps: + - uses: actions/checkout@v2 + + - name: Install Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install the package + run : | + python -m pip install --upgrade pip setuptools wheel + python -m pip install . + + - name: Static analysis (pylint + pydocstyle) + run: | + python -m pip install pylint pydocstyle + pylint --rcfile .pylintrc raspberry_pi tests setup.py + pydocstyle --config .pydocstylerc raspberry_pi tests setup.py + + - name: Tests (pytest) + run: | + python -m pip install pytest pytest-cov + pytest --cov=raspberry_pi \ No newline at end of file diff --git a/.gitignore b/.gitignore index b6e4761..969fbf1 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ dmypy.json # Pyre type checker .pyre/ + +# JetBrains +.idea diff --git a/.pydocstylerc b/.pydocstylerc new file mode 100644 index 0000000..779c1b5 --- /dev/null +++ b/.pydocstylerc @@ -0,0 +1,3 @@ +[pydocstyle] +add_ignore = D200, D105, D107 +add_select = D213, D214, D215, D404 diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..441ce75 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,11 @@ +[FORMAT] +max-line-length=120 + +[MESSAGES CONTROL] +disable = logging-fstring-interpolation, too-few-public-methods, too-many-instance-attributes + +[SIMILARITIES] +min-similarity-lines=4 +ignore-comments=yes +ignore-docstrings=yes +ignore-imports=yes diff --git a/raspberry_pi/__init__.py b/raspberry_pi/__init__.py new file mode 100644 index 0000000..202bf7b --- /dev/null +++ b/raspberry_pi/__init__.py @@ -0,0 +1,3 @@ +""" +Raspberry Pi package. +""" diff --git a/raspberry_pi/__main__.py b/raspberry_pi/__main__.py new file mode 100644 index 0000000..13d3cf9 --- /dev/null +++ b/raspberry_pi/__main__.py @@ -0,0 +1,16 @@ +""" +Raspberry Pi execution file. +""" +import os +import sys + +# Make sure a local raspberry-pi package can be found and overrides any installed versions +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from raspberry_pi.data_manager import DataManager # pylint: disable = wrong-import-position +from raspberry_pi.server import Server # pylint: disable = wrong-import-position + +if __name__ == '__main__': + dm = DataManager() + server = Server(dm) + server.start() diff --git a/raspberry_pi/arduino.py b/raspberry_pi/arduino.py new file mode 100644 index 0000000..95ae8af --- /dev/null +++ b/raspberry_pi/arduino.py @@ -0,0 +1,170 @@ +""" +Representation of an Arduino, including relevant networking functionalities. +""" +from threading import Thread +import msgpack +from msgpack import UnpackException +from serial import Serial, SerialException +from .data_manager import DataManager +from .constants import SERIAL_BAUDRATE, SERIAL_READ_TIMEOUT, SERIAL_WRITE_TIMEOUT +from .enums import Device +from .utils import logger + + +class Arduino: + """ + Handle serial communication between the ROV and an Arduino. + + The Arduino-s are created via server, and can be accessed in the following way: + + arduinos = server.arduinos + + While working, the code should check if the communication is happening, to detect when it stops: + + if not arduino.connected(): + arduino.reconnect() + + You can check __main__.py to see how the surface-rov and rov-arduino(s) connections are kept alive. + """ + + def __init__(self, dm: DataManager, port: str): + """ + Initialise the serial object and the thread. + + At first, the device is not identified (it's unassigned), and will be known after the pre-communication process. + """ + self._dm = dm + self._port = port + self._device = Device.UNASSIGNED + self._communication_thread = self._new_thread() + self._serial = Serial(baudrate=SERIAL_BAUDRATE) + self._serial.port = self._port + self._serial.write_timeout = SERIAL_WRITE_TIMEOUT + self._serial.timeout = SERIAL_READ_TIMEOUT + + def __str__(self): + return self._port + + @property + def connected(self) -> bool: + """ + Check if the communication is happening. + """ + return self._communication_thread.is_alive() + + def _new_thread(self) -> Thread: + """ + Create the communication thread. + """ + return Thread(target=self._communicate) + + def connect(self): + """ + Connect to the Arduino and start exchanging the data. + + Opens a serial connection and starts the communication thread. + """ + if self.connected: + logger.error(f"Can't connect - already connected to {self._port}") + return + + logger.info(f"Connecting to {self._port}") + while True: + try: + if not self._serial.is_open: + self._serial.open() + break + except SerialException as ex: + logger.debug(f"Failed to connect to {self._port} - {ex}") + + logger.info(f"Connected to {self._port}") + self._communication_thread.start() + + def _communicate(self): + """ + Exchange the data with the Arduino. + + Pre-communicates at first, to identify the device and set relevant ID, while ignoring empty data. Starts + exchanging information properly immediately after. + """ + while True: + try: + data = self._serial.read_until().strip() + if not data: + continue + + try: + self._device = Device(msgpack.unpackb(data.decode("utf-8"))["ID"]) + except (UnicodeError, UnpackException): + logger.exception(f"Failed to decode the following data in pre-communication: {data}") + return + + # Knowing the id, set the connection status to connected (True) and exit the pre-communication step + logger.info(f"Detected a valid device at {self._port} - {self._device.name}") + self._dm.set(self._device, **{self._device.value: True}) + break + + except SerialException: + logger.exception(f"Lost connection to {self._port}") + return + + except (KeyError, ValueError): + logger.error(f"Invalid device ID received from {self._port}") + return + + while True: + try: + if data: + logger.debug(f"Received data from {self._port} - {data}") + + try: + data = msgpack.unpackb(data.decode("utf-8").strip()) + except (UnicodeError, UnpackException): + logger.exception(f"Failed to decode following data: {data}") + + # Remove ID from the data to avoid setting it upstream, disconnect in case of errors + if "ID" not in data or data["ID"] != self._device.value: + logger.error(f"ID key not in {data} or key doesn't match {self._device.value}") + break + + del data["ID"] + self._dm.set(self._device, **data) + + else: + logger.debug(f"Timed out reading from {self._port}, clearing the buffer") + self._serial.reset_output_buffer() + + # Send data and wait for a response from Arduino (next set of data to process) + self._serial.write(bytes(msgpack.packb(self._dm.get(self._device)) + "\n")) + data = self._serial.read_until().strip() + + except SerialException: + logger.error(f"Lost connection to {self._port}") + break + + def disconnect(self): + """ + Disconnect from the Arduino and stop exchanging the data. + """ + try: + if self._serial.is_open: + self._serial.close() + except SerialException: + logger.exception(f"Failed to safely disconnect from {self._port}") + + # Clean up the communication thread + self._communication_thread = self._new_thread() + + # Set the connection status to disconnected, if the id was known + if self._device != Device.UNASSIGNED: + self._dm.set(self._device, **{self._device.value: False}) + + # Forget the device id + self._device = Device.UNASSIGNED + + def reconnect(self): + """ + Reconnect to the Arduino. + """ + self.disconnect() + self.connect() diff --git a/raspberry_pi/constants.py b/raspberry_pi/constants.py new file mode 100644 index 0000000..0ddd286 --- /dev/null +++ b/raspberry_pi/constants.py @@ -0,0 +1,76 @@ +""" +Constants and other static values. +""" +import os +import dotenv +from .enums import Device + +# Declare paths to relevant folders - tests folder shouldn't be known here +ROOT_DIR = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +RASPBERRY_PI_DIR = os.path.join(ROOT_DIR, "raspberry_pi") +RES_DIR = os.path.join(RASPBERRY_PI_DIR, "res") +LOG_DIR = os.path.join(RASPBERRY_PI_DIR, "log") + +# Load the environment variables from the root folder and/or the resources folder +dotenv.load_dotenv(dotenv_path=os.path.join(ROOT_DIR, ".env")) +dotenv.load_dotenv(dotenv_path=os.path.join(RES_DIR, ".env")) + +# Declare logging config +LOG_CONFIG_PATH = os.getenv("LOG_CONFIG_PATH", os.path.join(RES_DIR, "log-config.json")) +LOGGER_NAME = os.getenv("LOGGER_NAME", "raspberry-pi") + +# Declare surface connection information +CONNECTION_IP = os.getenv("CONNECTION_IP", "0.0.0.0") +CONNECTION_PORT = int(os.getenv("CONNECTION_PORT", "50000")) +CONNECTION_DATA_SIZE = int(os.getenv("CONNECTION_DATA_SIZE", "4096")) + +# Declare Arduino-related constants (timeouts in seconds) +ARDUINO_PORTS = tuple(port for port in os.getenv("ARDUINO_PORTS", "").split(",") if port) +SERIAL_WRITE_TIMEOUT = int(os.getenv("SERIAL_WRITE_TIMEOUT", "1")) +SERIAL_READ_TIMEOUT = int(os.getenv("SERIAL_READ_TIMEOUT", "1")) +SERIAL_BAUDRATE = int(os.getenv("SERIAL_BAUDRATE", "115200")) + +# Declare the constant to determine how often should the connection statuses be checked (in seconds) +CONNECTION_CHECK_DELAY = int(os.getenv("CONNECTION_CHECK_DELAY", "1")) + +# Declare constant for slowly changing up all values and keys affected +RAMP_RATE = 2 +RAMP_KEYS = { + "T_HFP", + "T_HFS", + "T_HAP", + "T_HAS", + "T_VFP", + "T_VFS", + "T_VAP", + "T_VAS", + "T_M" +} + + +# Declare the transmission sets with the default values as initial values +THRUSTER_IDLE = 1500 +GRIPPER_IDLE = 1500 +CORD_IDLE = 1500 +DEFAULTS = { + Device.SURFACE: { + "A_O": False, + "A_I": False, + "S_O": 0, + "S_I": 0 + }, + Device.ARDUINO_O: { + "T_HFP": THRUSTER_IDLE, + "T_HFS": THRUSTER_IDLE, + "T_HAP": THRUSTER_IDLE, + "T_HAS": THRUSTER_IDLE, + "T_VFP": THRUSTER_IDLE, + "T_VFS": THRUSTER_IDLE, + "T_VAP": THRUSTER_IDLE, + "T_VAS": THRUSTER_IDLE, + "T_M": THRUSTER_IDLE, + "M_G": GRIPPER_IDLE, + "M_C": CORD_IDLE + }, + Device.ARDUINO_I: {} +} diff --git a/raspberry_pi/data_manager.py b/raspberry_pi/data_manager.py new file mode 100644 index 0000000..21e4f24 --- /dev/null +++ b/raspberry_pi/data_manager.py @@ -0,0 +1,110 @@ +""" +Data manager handling access to Arduino and Surface specific values. +""" +from .enums import Device +from .constants import DEFAULTS, RAMP_KEYS, RAMP_RATE +from .utils import logger + + +class DataManager: + """ + Data manager with access to the internal per-device-dictionaries. + + Provides getter and setter methods to each dictionary. + + To use it, you should import the module and create the data manager: + + from .data_manager import DataManager + dm = DataManager() + + You must then pass a reference to the manager to other parts of the code: + + def func(dm: DataManager): + dm.set(Device.SURFACE, test=5) + print(dm.get(Device.ARDUINO_O)) + """ + + def __init__(self): + """ + Create a data dictionary for each device connected to the server. + + The data represents values to send to the device it is registered under. + """ + self._data = { + Device.SURFACE: DEFAULTS[Device.SURFACE].copy(), + Device.ARDUINO_O: DEFAULTS[Device.ARDUINO_O].copy(), + Device.ARDUINO_I: DEFAULTS[Device.ARDUINO_I].copy() + } + + def get(self, device: Device, *args) -> dict: + """ + Access stored values. + + Returns selected data or full dictionary if no args passed. + """ + if not args: + return self._data[device].copy() + + # Raise error early if any of the keys are not registered + if not set(args).issubset(set(self._data[device].keys())): + raise KeyError(f"{set(args)} is not a subset of {set(self._data[device].keys())}") + + return {key: self._data[device][key] for key in args} + + def set(self, from_device: Device, set_default: bool = False, **kwargs): + """ + Modify stored values. + + If the values are coming from surface, they are dispatched into separated dictionaries, specific to each + Arduino. Otherwise, the values from the Arduino override specific values in the surface transmission data. + + Keep in mind that if the keys received are within the RAMP_KEYS constant, the values will not be changed to the + target values, but will instead be modified by a small value (every time). + + `set_default` argument is treated with a priority, and if set to True the data is replaced with default values + immediately, ignoring kwargs and simply setting all values possible to default (surface only). + """ + if set_default: + logger.info(f"Setting the values for device {from_device.name} to default") + + # Surface will dispatch the values to different dictionaries + if from_device == Device.SURFACE: + + # Override each Arduino dictionary with the defaults if the `set_default` flag is set + if set_default: + self._data[Device.ARDUINO_O] = DEFAULTS[Device.ARDUINO_O] + self._data[Device.ARDUINO_I] = DEFAULTS[Device.ARDUINO_I] + else: + for key, value in kwargs.items(): + if key in self._data[Device.ARDUINO_O]: + self._handle_data_from_surface(Device.ARDUINO_O, key, value) + elif key in self._data[Device.ARDUINO_I]: + self._handle_data_from_surface(Device.ARDUINO_I, key, value) + else: + raise KeyError(f"Couldn't find key {key} in any of the Arduino dictionaries") + + # Arduino-s will simply override relevant values in the surface dictionary + else: + if set_default: + raise KeyError(f"Setting the default values is only supported for surface, not {from_device.name}") + + if not set(kwargs.keys()).issubset(set(self._data[Device.SURFACE].keys())): + raise KeyError(f"{set(kwargs.keys())} is not a subset of {set(self._data[Device.SURFACE].keys())}") + + for key, value in kwargs.items(): + self._data[Device.SURFACE][key] = value + + def _handle_data_from_surface(self, device: Device, key: str, value: int): + """ + Ramp up/down a specific value within an Arduino dictionary, or set it to a specific value. + """ + if key in RAMP_KEYS: + difference = self._data[device][key] - value + + if difference > 0: + self._data[device][key] -= RAMP_RATE + elif difference < 0: + self._data[device][key] += RAMP_RATE + + else: + self._data[device][key] = value diff --git a/raspberry_pi/enums.py b/raspberry_pi/enums.py new file mode 100644 index 0000000..83f76a3 --- /dev/null +++ b/raspberry_pi/enums.py @@ -0,0 +1,18 @@ +""" +Enumerations. +""" +from enum import Enum + + +class Device(Enum): + """ + Available devices. + + A device must be the surface control station or an Arduino, otherwise it's' unassigned (unknown) and must be + identified first. + """ + + UNASSIGNED = "UNASSIGNED" + SURFACE = "S" + ARDUINO_O = "A_O" + ARDUINO_I = "A_I" diff --git a/raspberry_pi/log/.keep b/raspberry_pi/log/.keep new file mode 100644 index 0000000..e69de29 diff --git a/raspberry_pi/res/log-config.json b/raspberry_pi/res/log-config.json new file mode 100644 index 0000000..1b80341 --- /dev/null +++ b/raspberry_pi/res/log-config.json @@ -0,0 +1,55 @@ +{ + "version": 1, + "formatters": { + "console": { + "format": "{message}", + "style": "{" + }, + "basic": { + "format": "{name:<24} | {asctime} {levelname:<8}\t| {message}", + "datefmt": "%y-%m-%d %H:%M:%S", + "style": "{" + }, + "verbose": { + "format": "{name:<24} | {asctime} {levelname:<8}\t| {processName:<16} {process}\t| {message}", + "datefmt": "%y-%m-%d %H:%M:%S", + "style": "{" + } + }, + "handlers": { + "console": { + "level": "INFO", + "class": "logging.StreamHandler", + "formatter": "console", + "stream": "ext://sys.stdout" + }, + "verbose": { + "level": "DEBUG", + "class": "logging.FileHandler", + "filename": "verbose.log", + "formatter": "verbose", + "encoding": "utf-8" + }, + "info": { + "level": "INFO", + "class": "logging.FileHandler", + "filename": "info.log", + "formatter": "basic", + "encoding": "utf-8" + }, + "warning": { + "level": "WARNING", + "class": "logging.FileHandler", + "filename": "warning.log", + "formatter": "basic", + "encoding": "utf-8" + } + }, + "loggers": { + "raspberry-pi": { + "level": "DEBUG", + "handlers": ["console", "verbose", "info", "warning"], + "propagate": false + } + } +} diff --git a/raspberry_pi/res/metadata.json b/raspberry_pi/res/metadata.json new file mode 100644 index 0000000..773474c --- /dev/null +++ b/raspberry_pi/res/metadata.json @@ -0,0 +1,8 @@ +{ + "__title__": "raspberry-pi", + "__version__": "0.0.0-alpha", + "__description__": "ROV Raspberry Pi", + "__lead__": "Kacper Florianski", + "__email__": "kacper.florianski@gmail.com", + "__url__": "https://github.com/ncl-ROVers/raspberry-pi" +} diff --git a/raspberry_pi/server.py b/raspberry_pi/server.py new file mode 100644 index 0000000..9c83d57 --- /dev/null +++ b/raspberry_pi/server.py @@ -0,0 +1,207 @@ +""" +Implementation of a socket-based and serial-based server, to exchange data with surface and the Arduino-s. +""" +import time +from typing import Tuple, Optional, Set +from socket import socket, SHUT_RDWR, error as socket_error +from threading import Thread +import msgpack +from msgpack import UnpackException +from .data_manager import DataManager +from .constants import CONNECTION_IP, CONNECTION_PORT, CONNECTION_DATA_SIZE, CONNECTION_CHECK_DELAY, ARDUINO_PORTS +from .arduino import Arduino +from .utils import logger +from .enums import Device + + +class Server: + """ + Communicate with all components (hence heart of the ROV). + + Threaded networking functionality is exposed by the `start` function, and allows handling data in a relatively fast + manner, while keeping a low level of code (and concurrency) complexity. + """ + + def __init__(self, dm: DataManager): + self._dm = dm + self._surface_connection = _SurfaceConnection(self._dm) + self._arduino_connections = _ArduinoConnections(self._dm) + + def _surface_thread(self): + """ + Communicate with surface. + """ + while True: + self._surface_connection.accept() + while self._surface_connection.connected: + time.sleep(CONNECTION_CHECK_DELAY) + self._surface_connection.cleanup() + + @staticmethod + def _arduino_thread(arduino: Arduino): + """ + Communicate with an arduino. + """ + arduino.connect() + while True: + while arduino.connected: + time.sleep(CONNECTION_CHECK_DELAY) + arduino.reconnect() + + def start(self): + """ + Start communicating with high-level and low-level ROV components. + + Threads are created for each communication channel - one for surface and several for arduino-s + """ + Thread(name="Surface connection", target=self._surface_thread).start() + + for arduino in self._arduino_connections.arduinos: + Thread( + name=" ".join(("Arduino", "connection", "-", str(arduino))), + target=self._arduino_thread, + args=(arduino,) + ).start() + + +class _SurfaceConnection: + """ + Communicate with Surface by establishing a 2-way data exchange via a TCP network. + + A surface connection is a non-enforced singleton featuring the following functionalities: + + - accept an incoming client connection (blocking) + - check if connected by inspecting whether the communication process is running + - communicate with the client in a non-blocking manner + - clean up resources + + Ensuring the server runs correctly should be verified by the calling code. + """ + + def __init__(self, dm: DataManager): + self._dm = dm + self._ip = CONNECTION_IP + self._data_size = CONNECTION_DATA_SIZE + self._port = CONNECTION_PORT + self._address = self._ip, self._port + self._socket = self._new_socket() + self._communication_thread = self._new_thread() + self._client_socket: Optional[socket] = None + self._client_address: Optional[Tuple] = None + + @property + def connected(self) -> bool: + """ + Check if the communication with surface is still happening. + """ + return self._communication_thread.is_alive() + + def _new_socket(self) -> socket: + """ + Create and configure a new TCP socket. + """ + server_socket = socket() + + try: + server_socket.bind(self._address) + except socket_error: + logger.exception(f"Failed to bind socket to {self._ip}:{self._port}") + + server_socket.listen(1) + return server_socket + + def _new_thread(self) -> Thread: + """ + Create a new communication thread. + """ + return Thread(target=self._communicate) + + def accept(self): + """ + Accept incoming connections from surface. + + On errors, the cleanup function is called. + """ + try: + logger.info(f"{self._socket.getsockname()} is waiting for a client to connect") + + # Wait for a connection (accept function blocks the program until a client connects to the server) + self._client_socket, self._client_address = self._socket.accept() + + # Once the client is connected, start the data exchange process + logger.info(f"Client with address {self._client_address} connected") + self._communication_thread.start() # TODO: Need this as a thread? Test plz + + except OSError: + logger.exception("Failed to listen to incoming connections") + self.cleanup() + + def _communicate(self): + """ + Exchange the data with surface. + + Breaks the infinite loop on errors, leaving the calling code to accommodate for that. + """ + while True: + try: + data = self._client_socket.recv(self._data_size) + + # Stop the communication process on the connection closed message + if not data: + logger.info("Connection closed by client") + break + + try: + data = msgpack.unpackb(data.decode("utf-8").strip()) + except (UnicodeError, UnpackException): + logger.warning(f"Failed to decode the following data: {data}") + + # Only handle valid, non-empty data + if data and isinstance(data, dict): + self._dm.set(Device.SURFACE, **data) + + # Fetch data to send and transfer it to surface + data = self._dm.get(Device.SURFACE) + self._client_socket.sendall(msgpack.packb(data)) + + except OSError: + logger.exception("An error occurred while communicating with the client") + break + + def cleanup(self): + """ + Cleanup server-related objects. + """ + try: + self._dm.set(Device.SURFACE, set_default=True) + self._communication_thread = self._new_thread() + self._client_socket.shutdown(SHUT_RDWR) + self._client_socket.close() + except OSError: + logger.exception("Ignoring an error in the cleanup function") + + +class _ArduinoConnections: + """ + Communicate with each Arduino by establishing a 2-way data exchange via a serial network. + + This class creates relevant Arduino instances and allows accessing them via a getter. The calling code must handle + keeping the networking flow running. + """ + + def __init__(self, dm: DataManager): + self._dm = dm + self._arduinos = {self._new_arduino(port) for port in ARDUINO_PORTS} + + @property + def arduinos(self) -> Set[Arduino]: + """ + Getter for the collection of Arduino instances. + """ + return self._arduinos + + def _new_arduino(self, port: str) -> Arduino: + """ + Instantiate an Arduino. + """ + return Arduino(self._dm, port) diff --git a/raspberry_pi/utils.py b/raspberry_pi/utils.py new file mode 100644 index 0000000..30b9525 --- /dev/null +++ b/raspberry_pi/utils.py @@ -0,0 +1,18 @@ +""" +Universal classes and other non-static constructs. +""" +import os +import json +import logging.config +from .constants import LOG_CONFIG_PATH, LOG_DIR, LOGGER_NAME + +# Configure logging and create a new logger instance +with open(LOG_CONFIG_PATH) as f: + log_config = json.loads(f.read()) + handlers = log_config["handlers"] + for handler in handlers: + handler_config = handlers[handler] + if "filename" in handler_config: + handler_config["filename"] = os.path.join(LOG_DIR, handler_config["filename"]) +logging.config.dictConfig(log_config) +logger = logging.getLogger(LOGGER_NAME) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..dc919f3 --- /dev/null +++ b/setup.py @@ -0,0 +1,43 @@ +""" +Installation configuration. +""" +import os +import json +import setuptools + +# Fetch the root folder to specify absolute paths to the "include" files +ROOT = os.path.normpath(os.path.dirname(__file__)) + +# Specify which files should be added to the installation +PACKAGE_DATA = [ + os.path.join(ROOT, "raspberry_pi", "res", "metadata.json"), + os.path.join(ROOT, "raspberry_pi", "log", ".keep") +] + +with open(os.path.join(ROOT, "raspberry_pi", "res", "metadata.json")) as f: + metadata = json.load(f) + +setuptools.setup( + name=metadata["__title__"], + description=metadata["__description__"], + version=metadata["__version__"], + author=metadata["__lead__"], + author_email=metadata["__email__"], + maintainer=metadata["__lead__"], + maintainer_email=metadata["__email__"], + url=metadata["__url__"], + packages=setuptools.find_namespace_packages(), + package_data={"raspberry_pi": PACKAGE_DATA}, + include_package_data=True, + classifiers=[ + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.6", + ], + install_requires=[ + "python-dotenv", + "msgpack", + "pyserial" + ], + python_requires=">=3.6", +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..1a303bb --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +""" +Raspberry Pi tests. +""" diff --git a/tests/test_directory_structure.py b/tests/test_directory_structure.py new file mode 100644 index 0000000..f24d124 --- /dev/null +++ b/tests/test_directory_structure.py @@ -0,0 +1,11 @@ +""" +Verify file and directory correctness. +""" + + +def test_testing_package_detection(): + """ + Test if the tests package can be found. + + The exit code of 5 will be returned if no tests are collected. + """