diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..656a17fb --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,21 @@ +{ + "containerEnv": { + "BLINKA_FORCEBOARD": "GENERIC_LINUX_PC", + "BLINKA_FORCECHIP": "GENERIC_X86" + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance", + "ms-python.isort", + "ms-python.debugpy", + "redhat.vscode-yaml", + "tamasfe.even-better-toml" + ] + } + }, + "image": "mcr.microsoft.com/devcontainers/python:3.13", + "name": "pysquared-dev", + "postCreateCommand": "make .venv pre-commit-install" +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d07900ac..045412ed 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,11 +13,34 @@ repos: #- id: mixed-line-ending # args: [ --fix=lf ] + - repo: local + hooks: + - id: prevent-type-ignore + name: prevent type ignore annotations + description: 'Enforce that no `# type: ignore` annotations exist in the codebase.' + entry: '# type:? *ignore' + language: pygrep + types: [python] + + - repo: https://github.com/codespell-project/codespell + rev: v2.4.1 + hooks: + - id: codespell + - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.6 + rev: v0.12.3 hooks: - - id: ruff + - id: ruff-check args: [--fix] - - id: ruff + - id: ruff-check args: [--fix, --select, I] # import sorting - id: ruff-format + + # Move back to econchick/interrogate once + # https://github.com/econchick/interrogate/issues/187 has been resolved + - repo: https://github.com/nateinaction/interrogate/ + rev: 07d2503 # use-uv branch + hooks: + - id: interrogate + args: [--config=pyproject.toml] + pass_filenames: false # needed if excluding files with pyproject.toml or setup.cfg diff --git a/Makefile b/Makefile index 2d6abb34..85aeb787 100644 --- a/Makefile +++ b/Makefile @@ -27,11 +27,7 @@ typecheck: .venv ## Run type check .PHONY: test test: .venv ## Run tests -ifeq ($(TEST_SELECT),ALL) $(UV) run coverage run --rcfile=pyproject.toml -m pytest tests/unit -else - $(UV) run coverage run --rcfile=pyproject.toml -m pytest -m "not slow" tests/unit -endif @$(UV) run coverage html --rcfile=pyproject.toml > /dev/null @$(UV) run coverage xml --rcfile=pyproject.toml > /dev/null @@ -115,9 +111,8 @@ endef .PHONY: docs docs: uv - @$(UV) run mkdocs build - @$(UV) run mkdocs serve + @$(UV) run --group docs mkdocs serve .PHONY: docs-deploy docs-deploy: uv - @$(UV) run mkdocs gh-deploy --config-file mkdocs.yaml --force + @$(UV) run --group docs mkdocs gh-deploy --config-file mkdocs.yaml --force diff --git a/README.md b/README.md index 0724c8fd..e55490e4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PySquared -[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://proveskit.github.io/pysquared/license/) ![CI](https://github.com/proveskit/pysquared/actions/workflows/ci.yaml/badge.svg) CircuitPython based Flight Software Library for the PROVES Kit. This repo contains all of the core manager components, protocols, and libraries used by the PROVES Kit. @@ -8,12 +8,4 @@ CircuitPython based Flight Software Library for the PROVES Kit. This repo contai # Development Getting Started We welcome contributions, so please feel free to join us. If you have any questions about contributing please open an issue or a discussion. -You can find our Getting Started Guide [here](https://proveskit.github.io/pysquared/dev-guide/). - -## Supported Boards - -| Board Version | Proves Repo | Firmware | -|---------------|--------------------------------------|------------------------------| -| v4 | [proveskit/CircuitPython_RP2040_v4](https://github.com/proveskit/CircuitPython_RP2040_v4) | [proveskit_rp2040_v4](https://circuitpython.org/board/proveskit_rp2040_v4/) | -| v5 | [proveskit/CircuitPython_RP2040_v5](https://github.com/proveskit/CircuitPython_RP2040_v5) | [proveskit_rp2040_v5](https://drive.google.com/file/d/1S_xKkCfLgaMHhTQQ2uGI1fz-TgWfvwOZ/view?usp=drive_link/) | -| v5a | [proveskit/CircuitPython_RP2350_v5a](https://github.com/proveskit/CircuitPython_RP2350_v5a) | [proveskit_rp2350A_V5a](https://github.com/proveskit/flight_controller_board/blob/main/Firmware/FC_FIRM_v5a_V1.uf2) | +Please visit our [docs](https://proveskit.github.io/pysquared/) to get started! diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 00000000..63d17258 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,51 @@ +# Contributing Guide +Welcome to the contributing guide for PySquared! This guide will help you set up your development environment and get you started with contributing to the repository. + +### Setting up your code editor + +Every repository comes with a `.devcontainer` directory that contains configuration for a ready-to-use development environment. This environment includes all the necessary tools and dependencies to work on the repository. You can use any code editor that supports dev containers, such as [Visual Studio Code](https://code.visualstudio.com/), to open the repository in a dev container. + +### Testing custom versions of PySquared + +If you're making a change to PySquared, you can test it in a board specific repository by installing a specific version of PySquared. + +Start by pushing your PySquared changes to a branch in the pysquared repository. Then, you can install that version of PySquared in a board specific repository by running the following command: + +```sh +PYSQUARED_VERSION= make install-flight-software BOARD_MOUNT_POINT= +``` + +If you've forgotten how to find your board's mount point, the instructions are in the [Getting Started Guide](getting-started.md). + +### Testing Documentation Changes +We use [MkDocs](https://www.mkdocs.org/) to build our documentation. If you make changes to the documentation, you can build and test it locally by running: + +```sh +make docs +``` + +This will generate the documentation and serve it locally. You can then open your web browser and navigate to `http://localhost:8000` to see the changes. + +## Continuous Integration (CI) +This repo has a continuous integration system using Github Actions. Anytime you push code to the repo, it will run a series of tests. If you see a failure in the CI, you can click on the details to see what went wrong. + +### Common Build Failures +Here are some common build failures you might see and how to fix them: + +#### Lint Failure +Every time you make a change in git, it's called a commit. We have a tool called a pre-commit hook that will run before you make each commit to ensure your code is safe and formatted correctly. If you experience a lint failure you can run the following to fix it for you or tell you what's wrong. +```sh +make fmt +``` + +#### Test Failure +To ensure our code works as we expect we use automated testing. If you're seeing a testing failure in your build, you can see what's wrong by running those tests yourself with: +```sh +make test +``` + +#### Type Checking Failure +We use a tool called pyright to check our code for type errors. An example of a type error is if you try to add a string and an integer together. Pyright will catch these errors before they cause problems in your code. If you see a type checking failure in your build, you can run the following command to see what the error is: +```sh +make typecheck +``` diff --git a/docs/design-guide.md b/docs/design-guide.md new file mode 100644 index 00000000..57385fed --- /dev/null +++ b/docs/design-guide.md @@ -0,0 +1,213 @@ +# Design Guide + +This document provides an overview of the design principles and architecture of the PySquared Flight Software. It is intended for developers who want to understand how the software is structured and how to contribute effectively. + +## CircuitPython + +PySquared is built on top of CircuitPython, which is a version of Python designed for microcontrollers. CircuitPython is a fork of MicroPython which adhears to a subset of the Python language specification. Python 3.4 syntax is supported with some additional features pulled from later releases such as type hinting. + +### Resources + +- [Python 3.4 Reference](https://docs.python.org/3.4/reference/index.html) +- [Differences between MicroPython and Python](https://docs.micropython.org/en/latest/genrst/index.html#cpython-diffs) +- [Differences between CircuitPython and MicroPython](https://docs.circuitpython.org/en/latest/README.html#differences-from-micropython) +- [CircuitPython Shared Bindings Documentation](https://docs.circuitpython.org/en/latest/shared-bindings/index.html) +- [CircuitPython Standard Libraries Documentation](https://docs.circuitpython.org/en/latest/docs/library/index.html) + +## Types and Type Checking + +We use type hints throughout the PySquared codebase to ensure that our code is clear and maintainable. Type hints help us catch errors early and make it easier to understand the expected types of variables and function parameters. + +We do not accept changes with lines that are ignored the type checker i.e. `# type: ignore`. If you run into an issue where you think you need to ignore a type, it is likely a problem with the design of your component. Please take a moment to think about how you can fix the type error instead. If you need help, please reach out for assistance. + +??? note "Using the Typing Module" + For more advanced type hinting we can use the Python standard library's `typing` module which was introduced in Python 3.5. This module provides a variety of type hints that can be used to specify more complex types, such as `List`, `Dict`, and `Optional`. CircuitPython does not support the `typing` module so we must wrap the import in a try/except block to avoid import errors. For example: + + ```python + try: + from typing import List, Dict, Optional + except ImportError: + pass + ``` + + This pattern allows us to use type hints in our code while still being compatible with CircuitPython. + + Additionally we cannot use `typing`'s `Any` type hint in CircuitPython. Instead, we can use `object` as a generic type hint. + +## Protocols + +Protocols are a way to define a set of methods that a class must implement. They are similar to interfaces in other programming languages or header files in C. Protocols allow us to define a contract for classes to follow, ensuring that they implement the required methods. CircuitPython does not support Protocols, so we use base classes to define our protocols where all required methods are implemented with `...` (Ellipsis). All classes that implement the protocol must override these methods. Protocols can be found in `pysquared/protos/`. + +## Testing + +We use [pytest](https://docs.pytest.org/en/stable/) for unit testing our code. We are designing software for spacecraft, so it is important that we have a robust testing framework to ensure our code is reliable and works as expected. We write tests for all of our code, and we run these tests automatically using GitHub Actions. We aim to have 100% test coverage for all of our code, which means that every line of code is tested by at least one test case. + +## Documentation + +We use [MkDocs](https://www.mkdocs.org/) to build our documentation. We write our documentation in Markdown, which is a lightweight markup language that is easy to read and write. We document our code using docstrings, which are special comments that describe the purpose and usage of a function or class. We also use type hints in our docstrings to provide additional information about the expected types of parameters and return values. + +When documenting your code, use clear and concise examples that demonstrate real-world usage. Here are improved templates for module, class, and function documentation: + +TODO(nateinaction): Look at ruff's docstring formatting: https://docs.astral.sh/ruff/formatter/#docstring-formatting +https://spec.commonmark.org/0.30/#fenced-code-blocks +https://spec.commonmark.org/0.30/#fenced-code-blocks + + +### Module Documentation + +Start with a brief summary, followed by an optional extended description: + +```python +"""This module provides utilities for parsing and validating telemetry data from spacecraft sensors. + +It includes classes and functions for decoding sensor packets, verifying data integrity, and converting +raw readings into SI units for further analysis. +""" +``` + +### Class Documentation + +Begin with a short description, a detailed explanation, and a practical usage example: + +```python +"""The TelemetryParser class extracts and validates sensor readings from raw telemetry packets. + +TelemetryParser handles packet decoding, error checking, and conversion to SI units. It is designed +for use in spacecraft flight software where reliable sensor data is critical. + +**Usage:** +~~~ +from pysquared.telemetry import TelemetryParser + +parser = TelemetryParser() +packet = b'\x01\x02\x03\x04' +reading = parser.parse(packet) +print(reading.timestamp, reading.acceleration) # Output: 2024-06-01T12:00:00Z (0.0, 9.8, 0.0) +~~~ +""" +``` + +TODO(nateinaction): double check that we can use `~~~` for code blocks in mkdocs. + +### Function/Method Documentation + +Include a description, argument details, return values, and any exceptions raised: + +```python +""" +Validate a sensor reading and convert it to SI units. + +Args: + reading (dict): Raw sensor reading with keys 'value' and 'unit'. + sensor_type (str): Type of sensor (e.g., 'acceleration', 'temperature'). + +Returns: + float: The validated reading in SI units. + +Raises: + KeyError: If required keys are missing from the reading. + ValueError: If the reading value is out of expected range. +""" +``` + +## Sensor Readings + +All sensor readings must be in SI units and stored in a structure that includes the time of the reading. Including the time of the reading is important for analysing sensor data and ensuring that processes such as detumbling and attitude control can be performed accurately. + +The following table lists possible sensor properties, their corresponding types and units for common sensor readings. The table was pulled directly from the [CircuitPython Design Guide](https://docs.circuitpython.org/en/latest/docs/design_guide.html#sensor-properties-and-units): + +| Property Name | Python Type | Units / Description | +|------------------|----------------------|----------------------------------------------| +| acceleration | (float, float, float)| x, y, z meter per second² | +| alarm | (time.struct, str) | Sample alarm time and frequency string | +| CO2 | float | measured CO₂ in ppm | +| color | int | RGB, eight bits per channel (0xff0000 is red)| +| current | float | milliamps (mA) | +| datetime | time.struct | date and time | +| distance | float | centimeters (cm) | +| duty_cycle | int | 16-bit PWM duty cycle | +| eCO2 | float | equivalent/estimated CO₂ in ppm | +| frequency | int | Hertz (Hz) | +| gyro | (float, float, float)| x, y, z radians per second | +| light | float | non-unit-specific light levels | +| lux | float | SI lux | +| magnetic | (float, float, float)| x, y, z micro-Tesla (uT) | +| orientation | (float, float, float)| x, y, z degrees | +| pressure | float | hectopascal (hPa) | +| proximity | int | non-unit-specific proximity values | +| relative_humidity| float | percent | +| sound_level | float | non-unit-specific sound level | +| temperature | float | degrees Celsius | +| TVOC | float | Total Volatile Organic Compounds in ppb | +| voltage | float | volts (V) | +| weight | float | grams (g) | + +Definitions for sensor readings can be found in `pysquared/sensors/` + +!!! warning "Handling Sensor Reading Failures" + Sensor reading failures must be expected and handled gracefully. If a sensor reading fails, the code should log an error message and return a default value (e.g., `0.0` for numeric readings or `None` for optional readings). This ensures that the system can continue to operate even if a sensor is temporarily unavailable. In the case of a sensor hanging, the attempt must time out and return a default value. + +### Resources +- [Adafruit Unified Sensor Driver](https://learn.adafruit.com/using-the-adafruit-unified-sensor-driver?view=all) +- [Android Motion Sensor Documentation](https://developer.android.com/develop/sensors-and-location/sensors/sensors_motion) +- [Android Position Sensor Documentation](https://developer.android.com/develop/sensors-and-location/sensors/sensors_position) +- [Android Environment Sensor Documentation](https://developer.android.com/develop/sensors-and-location/sensors/sensors_environment) + +## Dependency Management +We use [`uv`](https://docs.astral.sh/uv/) for managing our python development environment and dependencies. It allows us to define our dependencies in a `pyproject.toml` file and provides a consistent way to install and manage them across different environments. We use dependency groups to separate the dependencies needed for running on the satellite `pyproject.dependencies`, development `pyproject.dev`, and documentation `pyproject.docs`. + +`uv` is downloaded and installed automatically when you use run `make` commands. Please see the [Makefile](Makefile) or `make help` for more information on how to use `uv` to manage your development environment. + +## Linting and Code Style +We use [`ruff`](https://docs.astral.sh/ruff/) for linting and formatting our code. `ruff` is a fast, extensible linter that checks our code for errors and enforces specific coding standards and style. We use `ruff`'s [default configuration](https://github.com/astral-sh/ruff/tree/0.12.3?tab=readme-ov-file#configuration) with only one addition, isort (`-I`), for linting and formatting our code. + +### Linting +`ruff` checks our code for errors following [pyflakes](https://pypi.org/project/pyflakes/) logic. + +### Code Style +By default `ruff`, enforces the [`black`](https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html) style with [a few deviations](https://docs.astral.sh/ruff/formatter/#style-guide) decided by `ruff` for formatting our code. Code formatting ensures that our code is consistent and easy to read. + +## Error Handling + +Error handling in PySquared is designed to be robust and predictable. We use standard `try...except` blocks to catch exceptions. When an exception is caught, it should be logged with the `logger.error()` or `logger.critical()` method. This ensures that we have a record of the error and can diagnose it later. + +```python +try: + # Code that may raise an exception +except Exception as e: + logger.error("An error occurred", err=e) +``` + +Custom exceptions should be used to represent specific error conditions in your code. This allows us to handle different types of errors in a more granular way. Custom exceptions should inherit from the built-in `Exception` class and should be named using the `Error` suffix. + +```python +class CustomError(Exception): + """Custom exception for specific error conditions.""" + pass + +try: + # Code that may raise a CustomError +except CustomError as e: + logger.error("A custom error occurred", err=e) +``` + +When raising exceptions, always provide a clear and descriptive error message. This will help us understand the context of the error when it is logged. + +```python +raise CustomError("This is a custom error message") +``` + + +## Logging + +The syntax for our logging module `logger` is based off the popular Python logger [`Loguru`](https://loguru.readthedocs.io/en/stable/). We use the `logger` module to log messages at different levels (`debug`, `info`, `warning`, `error`, `critical`) throughout our code. This allows us to track the flow of execution and diagnose issues when they arise. + +Logs are structured as JSON, which makes them easy to parse and analyze. When logging, you can include additional key-value pairs to provide context. + +```python +logger.info("User logged in", user_id=123) +``` + +Code that raises an exception should log at the `error` level. Code that failed but is recoverable should log at the `warning` level. The `debug` level should be used to understand the flow of the program during development and debugging. The `info` level should be used for general information about the program's execution, such as startup, shutdown, and other important updates. `critical` should be used for serious errors that may prevent the satellite from continuing operation, requiring a restart. + +TODO(nateinaction): Check circuitpython design guide again for anything else we should add to the doc. diff --git a/docs/dev-guide-linux.md b/docs/dev-guide-linux.md deleted file mode 100644 index 80d155a6..00000000 --- a/docs/dev-guide-linux.md +++ /dev/null @@ -1,37 +0,0 @@ -# Development Guide for Linux - -## Setup - -To set up your development environment on Linux, follow these steps: - -1. Update your package list and install the necessary packages: - ```sh - sudo apt update && sudo apt install make screen zip - ``` - -You should now be able to run the `make` command in the root of the repo to get started. - -### A note on `make install` -`make install` is a command that can be used to quickly install the code you're working on onto the board. On linux you can use the `findmnt` command to locate your board's mount point. -```sh -findmnt -... -├─/media/username/SOME-VALUE /dev/sdb1 vfat rw,nosuid,nodev,relatime 0 0 -``` - -For example, if the board is mounted at `/media/username/SOME-VALUE` then your install command will look like: -```sh -make install BOARD_MOUNT_POINT=/media/username/SOME-VALUE/ -``` - -## Accessing the Serial Console -To see streaming logs and use the on-board repl you must access the Circuit Python serial console. Accessing the serial console starts with finding the tty port for the board. The easiest way to do that is by plugging in your board and running: -```sh -ls -lah /dev -``` -Look for the file that was created around the same time that you plugged in the board. For Linux users the port typically looks like `/dev/ttyACM0`. You can then connect to the board using the `screen` command: -```sh -screen /dev/ttyACM0 -``` - -For more information visit the [Circuit Python Serial Console documentation](https://learn.adafruit.com/welcome-to-circuitpython/advanced-serial-console-on-linux). diff --git a/docs/dev-guide-macos.md b/docs/dev-guide-macos.md deleted file mode 100644 index 254582ee..00000000 --- a/docs/dev-guide-macos.md +++ /dev/null @@ -1,42 +0,0 @@ -# Development Guide for MacOS - -## Setup - -To get started with development on MacOS, follow these steps: - -1. **Install Xcode Command Line Tools**: These tools are necessary for compiling and building software. - ```sh - xcode-select --install - ``` -1. **Install Homebrew**: Homebrew is a package manager for MacOS. Follow the instructions on [brew.sh](https://brew.sh/) to install it. -1. **Install Required Packages**: Open a terminal and run the following command to install required packages: - ```sh - brew install screen - ``` - -You should now be able to run the `make` command in the root of the repo to get started. - -### A note on `make install` -`make install` is a command that can be used to quickly install the code you're working on onto the board. On Mac, you can find the location of your mount by looking for a mount named `PYSQUARED` in your `/Volumes` directory -```sh -ls -lah /Volumes | grep PYSQUARED -... -drwx------@ 1 nate staff 16K Jan 9 08:09 PYSQUARED/ -``` - -For example, if the board is mounted at `/Volumes/PYSQUARED/` then your install command will look like: -```sh -make install BOARD_MOUNT_POINT=/Volumes/PYSQUARED/ -``` - -## Accessing the Serial Console -To see streaming logs and use the on-board repl you must access the Circuit Python serial console. Accessing the serial console starts with finding the tty port for the board. The easiest way to do that is by plugging in your board and running: -```sh -ls -lah /dev -``` -Look for the file that was created around the same time that you plugged in the board. For Mac users the port typically looks like `/dev/tty.usbmodem101`. You can then connect to the board using the `screen` command: -```sh -screen /dev/tty.usbmodem101 -``` - -For more information visit the [Circuit Python Serial Console documentation](https://learn.adafruit.com/welcome-to-circuitpython/advanced-serial-console-on-mac-and-linux). diff --git a/docs/dev-guide-windows.md b/docs/dev-guide-windows.md deleted file mode 100644 index 82bc4281..00000000 --- a/docs/dev-guide-windows.md +++ /dev/null @@ -1,58 +0,0 @@ -# Development Guide for Windows -Welcome to the Windows Development Guide for our project! This guide will help you set up your development environment on a Windows machine and get you started with contributing to the repository. - -Follow the instructions below for either a native Windows setup or using the Windows Subsystem for Linux (WSL). - -## Native Windows Setup (Recommended) - -To set up your development environment on Windows, follow these steps: - -1. **Install Git**: Download and install Git from [git-scm.com](https://git-scm.com/downloads). Make sure to also install the Git Bash terminal during the setup process. -1. **Install Putty**: Download and install Putty from [putty.org](https://putty.org/). -1. **Install Chocolatey**: Chocolatey is a package manager for Windows. Follow the instructions on [chocolatey.org](https://chocolatey.org/install) to install it. -1. **Install Required Packages**: Open a command prompt or Git Bash terminal and run the following command to install required packages: - ```sh - choco install make rsync zip - ``` - -Using the Git Bash terminal, you should now be able to run the `make` command in the root of the repo to get started. - -### A note on `make install` - -`make install` is a command that can be used to quickly install the code you're working on onto the board. In Git Bash your mount point will be the letter of the drive location in windows. For example, if the board is mounted at `D:\` then your install command will look like: -```sh -make install BOARD_MOUNT_POINT=/d/ -``` - -## WSL Setup -Windows Subsystem for Linux (WSL) is a nice way to have a POSIX compatible workspace on your machine, the downside is a cumbersome USB [connecting][connect-usb] and [mounting][mount-disk] process that needs to be performed every time you reconnect the Satellite hardware to your computer. - -1. Download Ubuntu for WSL: - ```sh - wsl --install - ``` -1. Run WSL: - ```sh - wsl - ``` -1. If you have Satellite hardware, [connect][connect-usb] and [mount][mount-disk] it in WSL. -1. Continue with our [Linux Development Guide](dev-guide-linux.md). - -### A note on `make install` - -`make install` is a command that can be used to quickly install the code you're working on onto the board. In WSL your mount point will be the letter of the drive location in windows. For example, if the board is mounted at `D:\` then you must first mount the disk in WSL: -```sh -mkdir /mnt/d -sudo mount -t drvfs D: /mnt/d -``` - -And your install command will look like: -```sh -make install BOARD_MOUNT_POINT=/mnt/d/ -``` - -## Accessing the Serial Console -To see streaming logs and use the on-board repl you must access the Circuit Python serial console. For information on how to access the serial console, visit the [Circuit Python Serial Console documentation](https://learn.adafruit.com/welcome-to-circuitpython/advanced-serial-console-on-windows). - -[connect-usb]: https://learn.microsoft.com/en-us/windows/wsl/connect-usb "How to Connect USB to WSL" -[mount-disk]: https://learn.microsoft.com/en-us/windows/wsl/wsl2-mount-disk "How to Mount a Disk to WSL" diff --git a/docs/dev-guide.md b/docs/dev-guide.md deleted file mode 100644 index 59d13c65..00000000 --- a/docs/dev-guide.md +++ /dev/null @@ -1,62 +0,0 @@ -# Development Guide -Welcome to the development guide for our project! This guide will help you set up your development environment and get you started with contributing to the repository. - -#### CircuitPython -If this is your first time using CircuitPython, it is highly recommended that you check out Adafruit's [Welcome to CircuitPython](https://learn.adafruit.com/welcome-to-circuitpython/overview) to help you get started! - -## OS Specific Guides -We suggest you get started with the development guide for your operating system: - -- [Windows](dev-guide-windows.md) -- [MacOS](dev-guide-macos.md) -- [Linux](dev-guide-linux.md) - -Once you have your development environment set up, you should be able to run the following command to finish the setup: -```sh -make -``` - -## Manually testing code on a board -We are working on improving our automated testing but right now the best way to test your code is to run it on the board. - -Your board must be connected to your computer, the correct board repository cloned, and you must have installed the latest CircuitPython Firmware. - -Firmware can be installed by placing the board in Bootloader mode (by pressing both of the buttons simultaneously) and running the following command: -```sh -make install-firmware -``` - -In the board repository, you can run the following command to install code on the board: -```sh -make install BOARD_MOUNT_POINT=/PATH_TO_YOUR_BOARD -``` - -If you need to install a specific version of the `pysquared` library, you can do so by running: -```sh -PYSQUARED_VERSION=VERSION/BRANCH make install BOARD_MOUNT_POINT=/PATH_TO_YOUR_BOARD -``` - -There is more information in the OS specific guides on how to find your board's mount point. - -To see the output of your code you can connect to the board using the serial console. You can find more information on how to do that in the OS specific guides. - -### Notes on Serial Console -If all you see is a blank screen when you connect to the serial console, try pressing `CTRL+C` to see if you can get a prompt. If that doesn't work, try pressing `CTRL+D` to reset the board. - -## Continuous Integration (CI) -This repo has a continuous integration system using Github Actions. Anytime you push code to the repo, it will run a series of tests. If you see a failure in the CI, you can click on the details to see what went wrong. - -### Common Build Failures -Here are some common build failures you might see and how to fix them: - -#### Lint Failure -Everytime you make a change in git, it's called a commit. We have a tool called a pre-commit hook that will run before you make each commit to ensure your code is safe and formatted correctly. If you experience a lint failure you can run the following to fix it for you or tell you what's wrong. -```sh -make fmt -``` - -#### Test Failure -To ensure our code works as we expect we use automated testing. If you're seeing a testing failure in your build, you can see what's wrong by running those tests yourself with: -```sh -make test -``` diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 00000000..06f39da3 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,177 @@ +# Getting Started with PySquared + +## Introduction +PySquared is a flight software library designed for building and deploying satellite systems using CircuitPython. The library is hardware agnostic, meaning it can be used with various CircuitPython-compatible boards but is designed to run on [PROVES](https://docs.proveskit.space/en/latest/) hardware. Like the PROVES hardware, PySquared is an education first software project. We're here to help you learn to develop and launch satellites so be sure to ask questions! + +This guide will help you set up your development environment and get you started with building a satellite using the PySquared Flight Software. + +## Setting Up Your Computer +Set up your development environment by following the instructions in your OS specific guide. + +??? note "Linux Guide" + + Update your package list and install the necessary packages: + ```sh + sudo apt update && sudo apt install make screen zip + ``` + +??? note "MacOS Guide" + + 1. **Install Xcode Command Line Tools**: These tools are necessary for compiling and building software. + ```sh + xcode-select --install + ``` + 1. **Install Homebrew**: Homebrew is a package manager for MacOS. Follow the instructions on [brew.sh](https://brew.sh/) to install it. + 1. **Install Required Packages**: Open a terminal and run the following command to install required packages: + ```sh + brew install screen + ``` + +??? note "Windows Guide" + + 1. **Install Git**: Download and install Git from [git-scm.com](https://git-scm.com/downloads). Make sure to also install the Git Bash terminal during the setup process. + 1. **Install Putty**: Download and install Putty from [putty.org](https://putty.org/). + 1. **Install Chocolatey**: Chocolatey is a package manager for Windows. Follow the instructions on [chocolatey.org](https://chocolatey.org/install) to install it. + 1. **Install Required Packages**: Open a command prompt or Git Bash terminal and run the following command to install required packages: + ```sh + choco install make rsync zip + ``` + + Keep in mind that the rest of this guide expects that you are using Git Bash. + +??? note "WSL Guide" + Follow the steps in the Linux Guide + +## Cloning Your Repository + +Let's start by creating your own repository to host your satellite's software. You can use one of the PySquared template repositories to get started quickly. Find your board version below, visit the repository, and click "Fork" to create your own copy of the repository. + +| Board Version | Proves Repo | +|---------------|-------------| +| v4 | [proveskit/CircuitPython_RP2040_v4](https://github.com/proveskit/CircuitPython_RP2040_v4) | +| v5 | [proveskit/CircuitPython_RP2040_v5](https://github.com/proveskit/CircuitPython_RP2040_v5) | +| v5a | [proveskit/CircuitPython_RP2350_v5a](https://github.com/proveskit/CircuitPython_RP2350_v5a) | +| v5b | [proveskit/CircuitPython_RP2350_v5b](https://github.com/proveskit/CircuitPython_RP2350_v5b) | + +Then you can clone your repository to your local machine using the following command: + +```sh +git clone https://github.com/your-username/your-repository.git +``` + +??? tip "Learn how to use the git command line" + + +Next, change directory (`cd`) into the repo directory: + +```sh +cd your-repository +``` + +You are now in your repo directory. This is where you'll write code that makes its way onto your satellite! + +## Installing CircuitPython +Next, we need to install the latest CircuitPython firmware on your board. CircuitPython is a Python runtime for microcontrollers like the one on your board. + +First you must find your board's TTY port. You can find the TTY port by plugging in your board and running the following command: + +```sh +make list-tty +``` + +Example output: +```sh +TTY ports: +/dev/cu.usbmodem3101 +``` + +In this example, the TTY port is `/dev/cu.usbmodem3101`. This is the port you will use to communicate with your board. + +??? note "Seeing more than one TTY port?" + If you see more than one TTY port listed, you may need to unplug your board and run the command again to see which one is created when you plug it in. The new port is likely the one you want. + +Now you can install CircuitPython by running the following command: + +```sh +make install-circuit-python BOARD_TTY_PORT= +``` + +## Installing PySquared + +### Finding Your Board's Mount Point + +Next, make sure your PROVES flight control board is plugged in and we'll find its mount point. The mount point is the location on your computer where the board's filesystem is accessible. This varies by operating system, so follow the guide for your OS below to find it. + +??? note "Linux Guide" + On linux you can use the `findmnt` command to locate your board's mount point. + ```sh + findmnt + ... + ├─/media/username/SOME-VALUE /dev/sdb1 vfat rw,nosuid,nodev,relatime 0 0 + ``` + + In this example, the mount point is `/dev/sdb1`. Another common mount point for linux systems is `/media/username/`. + + +??? note "MacOS Guide" + On Mac, you can find the location of your mount by looking for a mount named `PYSQUARED`, `PROVESKIT` or `CIRCUITPYTHON` in your `/Volumes` directory + ```sh + ls -lah /Volumes + ... + drwx------@ 1 nate staff 16K Jan 9 08:09 PYSQUARED/ + ``` + + In this example, the mount point is `/Volumes/PYSQUARED/`. + +??? note "Windows Guide" + In Git Bash your mount point will be the letter of the drive location in windows. For example, if the board is mounted at `D:\` then your drive location for commands in Git Bash will be `/d/`. + +??? note "WSL Guide" + First you must follow the guides to [connect][wsl-connect-usb] and [mount][wsl-mount-disk] your board in WSL. + + After following those guides, your mount point will probably be the letter of the drive location in Windows with `/mnt/` prepended. For example, if the board is mounted at `D:\` then your mount point in WSL will likely be `/mnt/d/`. If you are unsure, you can check the available mount points by running `ls /mnt/` in your terminal. + +### Running the Install Command + +With the repository cloned and your boards mount point in hand you can now install the flight software to the board. Navigate to the root of your board specific repository and run: + +```sh +make install-flight-software BOARD_MOUNT_POINT= +``` +Replace `` with the mount point discovered in the previous section. + +## Accessing the Serial Console +To see streaming logs and use the on-board repl you must access the Circuit Python serial console. + +Remember the TTY port you found earlier? You will use that to connect to the board's serial console. The serial console allows you to interact with the board and see output from your code. If you don't remember the TTY port, you can run the `make list-tty` command again to find it. + +??? note "Linux & MacOS Guide" + You can then connect to the board using the `screen` command: + ```sh + screen /dev/cu.usbmodem3101 + ``` + + For more information visit the [Circuit Python Serial Console documentation](https://learn.adafruit.com/welcome-to-circuitpython/advanced-serial-console-on-mac-and-linux). + +??? note "Windows Guide" + For information on how to access the serial console, visit the [Circuit Python Serial Console documentation](https://learn.adafruit.com/welcome-to-circuitpython/advanced-serial-console-on-windows). + + +!!! WARNING + If all you see is a blank screen when you connect to the serial console, try pressing `CTRL+C` to see if you can get a prompt. If that doesn't work, try pressing `CTRL+D` to reset the board. + +[wsl-connect-usb]: https://learn.microsoft.com/en-us/windows/wsl/connect-usb "How to Connect USB to WSL" +[wsl-mount-disk]: https://learn.microsoft.com/en-us/windows/wsl/wsl2-mount-disk "How to Mount a Disk to WSL" + +## Congratulations! +You have successfully installed PySquared and have started a serial console session to view the output from your flight control board! Now you can start your journey of building and launching satellites using CircuitPython and PySquared. + +## Next Steps +Now that you have your development environment set up, you can start [exploring the PySquared library](api.md) and building on the repo you forked and cloned earlier in this guide. + +Here are some additional resources to help you get started: + + +- Are you interested in contributing to PySquared? Check out our [Contributing Guide](contributing.md). +- Learn more about PROVES hardware with the [PROVES Kit documentation](https://docs.proveskit.space/en/latest/). +- Learn more about CircuitPython with the [Welcome to CircuitPython guide](https://learn.adafruit.com/welcome-to-circuitpython/overview). diff --git a/docs/radio-test.md b/docs/radio-test.md new file mode 100644 index 00000000..e69de29b diff --git a/mkdocs.yaml b/mkdocs.yaml index b0a9282e..3141fe74 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -4,7 +4,7 @@ site_url: https://docs.proveskit.space/ repo_url: https://github.com/proveskit/pysquared repo_name: proveskit/pysquared site_dir: site -watch: [mkdocs.yaml, README.md, docs/] +watch: [mkdocs.yaml, README.md, docs/, pysquared/] validation: omitted_files: warn @@ -13,28 +13,27 @@ validation: nav: - Home: index.md - - Getting Started: - - Introduction: dev-guide.md - - Linux: dev-guide-linux.md - - Mac: dev-guide-macos.md - - Windows: dev-guide-windows.md + - Getting Started: getting-started.md + - Design Guide: design-guide.md + - Contributing Guide: contributing.md - API Reference: api.md + - License: license.md theme: name: material features: - - navigation.tabs - - navigation.sections - - navigation.top - - navigation.path - - toc.integrate - - search.suggest - - search.highlight - - content.tabs.link - - header.autohide - - content.code.copy - - content.code.select + - content.action.edit + - content.code.copy + - content.code.select + - content.tabs.link + - header.autohide + - navigation.sections + - navigation.tabs + - navigation.top + - search.highlight + - search.suggest + - toc.follow palette: # Palette toggle for automatic mode - media: "(prefers-color-scheme)" @@ -65,7 +64,21 @@ plugins: - search # To have search functionality on the document - autorefs - section-index - # - llmstxt # Investigate using https://github.com/pawamoy/mkdocs-llmstxt to help llms understand the repository + - llmstxt: + full_output: llms-full.txt + sections: + Home: + - index.md + Getting Started: + - getting-started.md + Design Guide: + - design-guide.md + Contributing Guide: + - contributing.md + API Reference: + - api.md + License: + - license.md - mkdocstrings: handlers: python: diff --git a/mocks/adafruit_ina219/ina219.py b/mocks/adafruit_ina219/ina219.py index f79a276d..57ca5092 100644 --- a/mocks/adafruit_ina219/ina219.py +++ b/mocks/adafruit_ina219/ina219.py @@ -1,12 +1,21 @@ -""" -Mock for Adafruit INA219 +"""Mock for the Adafruit INA219 power monitor. -https://github.com/adafruit/Adafruit_CircuitPython_INA219/blob/main/adafruit_ina219.py +This module provides a mock implementation of the Adafruit INA219 power monitor for +testing purposes. It allows for simulating the behavior of the INA219 without the +need for actual hardware. """ class INA219: + """A mock INA219 power monitor.""" + def __init__(self, i2c, addr) -> None: + """Initializes the mock INA219. + + Args: + i2c: The I2C bus to use. + addr: The I2C address of the INA219. + """ self.i2c = i2c self.addr = addr diff --git a/mocks/adafruit_lis2mdl/lis2mdl.py b/mocks/adafruit_lis2mdl/lis2mdl.py index 4d67fade..17a12345 100644 --- a/mocks/adafruit_lis2mdl/lis2mdl.py +++ b/mocks/adafruit_lis2mdl/lis2mdl.py @@ -1,11 +1,20 @@ -""" -Mock for Adafruit LIS2MDL -https://github.com/adafruit/Adafruit_CircuitPython_LIS2MDL/blob/main/adafruit_lis2mdl.py +"""Mock for the Adafruit LIS2MDL magnetometer. + +This module provides a mock implementation of the Adafruit LIS2MDL magnetometer for +testing purposes. It allows for simulating the behavior of the LIS2MDL without the +need for actual hardware. """ class LIS2MDL: + """A mock LIS2MDL magnetometer.""" + def __init__(self, i2c) -> None: + """Initializes the mock LIS2MDL. + + Args: + i2c: The I2C bus to use. + """ self.i2c = i2c magnetic: tuple[float, float, float] = (0.0, 0.0, 0.0) diff --git a/mocks/adafruit_lsm6ds/lsm6dsox.py b/mocks/adafruit_lsm6ds/lsm6dsox.py index 7dff49bf..041bc74a 100644 --- a/mocks/adafruit_lsm6ds/lsm6dsox.py +++ b/mocks/adafruit_lsm6ds/lsm6dsox.py @@ -1,8 +1,24 @@ +"""Mock for the Adafruit LSM6DSOX IMU. + +This module provides a mock implementation of the Adafruit LSM6DSOX IMU for +testing purposes. It allows for simulating the behavior of the LSM6DSOX without the +need for actual hardware. +""" + from busio import I2C class LSM6DSOX: - def __init__(self, i2c_bus: I2C, address: int) -> None: ... + """A mock LSM6DSOX IMU.""" + + def __init__(self, i2c_bus: I2C, address: int) -> None: + """Initializes the mock LSM6DSOX. + + Args: + i2c_bus: The I2C bus to use. + address: The I2C address of the LSM6DSOX. + """ + ... acceleration: tuple[float, float, float] = (0.0, 0.0, 0.0) gyro: tuple[float, float, float] = (0.0, 0.0, 0.0) diff --git a/mocks/adafruit_rfm/rfm9x.py b/mocks/adafruit_rfm/rfm9x.py index 3d2ddc30..9a7478da 100644 --- a/mocks/adafruit_rfm/rfm9x.py +++ b/mocks/adafruit_rfm/rfm9x.py @@ -1,6 +1,8 @@ -""" -Mock for Adafruit RFM9x -https://github.com/adafruit/Adafruit_CircuitPython_RFM/blob/8a55e345501e038996b2aa89e71d4e5e3ddbdebe/adafruit_rfm/rfm9x.py +"""Mock for the Adafruit RFM9x LoRa radio module. + +This module provides a mock implementation of the Adafruit RFM9x LoRa radio module +for testing purposes. It allows for simulating the behavior of the RFM9x without the +need for actual hardware. """ from .rfm_common import RFMSPI @@ -13,6 +15,8 @@ class RFM9x(RFMSPI): + """A mock RFM9x LoRa radio module.""" + ack_delay: float | None = None enable_crc: bool spreading_factor: Literal[6, 7, 8, 9, 10, 11, 12] @@ -24,4 +28,13 @@ class RFM9x(RFMSPI): tx_power: int radiohead: bool - def __init__(self, spi, cs, reset, frequency) -> None: ... + def __init__(self, spi, cs, reset, frequency) -> None: + """Initializes the mock RFM9x. + + Args: + spi: The SPI bus to use. + cs: The chip select pin. + reset: The reset pin. + frequency: The frequency to operate on. + """ + ... diff --git a/mocks/adafruit_rfm/rfm9xfsk.py b/mocks/adafruit_rfm/rfm9xfsk.py index 5a8f4f9e..10956c85 100644 --- a/mocks/adafruit_rfm/rfm9xfsk.py +++ b/mocks/adafruit_rfm/rfm9xfsk.py @@ -1,12 +1,16 @@ -""" -Mock for Adafruit RFM9xFSK -https://github.com/adafruit/Adafruit_CircuitPython_RFM/blob/8a55e345501e038996b2aa89e71d4e5e3ddbdebe/adafruit_rfm/rfm9xfsk.py +"""Mock for the Adafruit RFM9xFSK radio module. + +This module provides a mock implementation of the Adafruit RFM9xFSK radio module +for testing purposes. It allows for simulating the behavior of the RFM9xFSK without the +need for actual hardware. """ from .rfm_common import RFMSPI class RFM9xFSK(RFMSPI): + """A mock RFM9xFSK radio module.""" + modulation_type: int fsk_broadcast_address: int fsk_node_address: int @@ -15,4 +19,13 @@ class RFM9xFSK(RFMSPI): tx_power: int radiohead: bool - def __init__(self, spi, cs, reset, frequency) -> None: ... + def __init__(self, spi, cs, reset, frequency) -> None: + """Initializes the mock RFM9xFSK. + + Args: + spi: The SPI bus to use. + cs: The chip select pin. + reset: The reset pin. + frequency: The frequency to operate on. + """ + ... diff --git a/mocks/adafruit_rfm/rfm_common.py b/mocks/adafruit_rfm/rfm_common.py index f12ec721..d6f1332e 100644 --- a/mocks/adafruit_rfm/rfm_common.py +++ b/mocks/adafruit_rfm/rfm_common.py @@ -1,6 +1,8 @@ -""" -Mock for Adafruit RFM SPI -https://github.com/adafruit/Adafruit_CircuitPython_RFM/blob/8a55e345501e038996b2aa89e71d4e5e3ddbdebe/adafruit_rfm/rfm_common.py +"""Mock for the Adafruit RFM SPI interface. + +This module provides a mock implementation of the Adafruit RFM SPI interface for +testing purposes. It allows for simulating the behavior of the RFM SPI interface +without the need for actual hardware. """ from typing import Optional @@ -9,6 +11,8 @@ class RFMSPI: + """A mock RFM SPI interface.""" + node: int destination: int @@ -21,9 +25,32 @@ def send( node: Optional[int] = None, identifier: Optional[int] = None, flags: Optional[int] = None, - ) -> bool: ... + ) -> bool: + """Sends data over the radio. + + Args: + data: The data to send. + keep_listening: Whether to keep listening after sending. + destination: The destination node address. + node: The source node address. + identifier: The packet identifier. + flags: The packet flags. + + Returns: + True if the data was sent successfully, False otherwise. + """ + ... - def read_u8(self, address: int) -> int: ... + def read_u8(self, address: int) -> int: + """Reads a byte from the given address. + + Args: + address: The address to read from. + + Returns: + The byte read from the address. + """ + ... def receive( self, @@ -31,4 +58,15 @@ def receive( keep_listening: bool = True, with_header: bool = False, timeout: Optional[float] = None, - ) -> Optional[bytearray]: ... + ) -> Optional[bytearray]: + """Receives data from the radio. + + Args: + keep_listening: Whether to keep listening after receiving. + with_header: Whether to include the header in the received data. + timeout: The timeout for receiving data. + + Returns: + The received data, or None if no data was received. + """ + ... diff --git a/mocks/circuitpython/busio.py b/mocks/circuitpython/busio.py index 4f4f091b..fe13538b 100644 --- a/mocks/circuitpython/busio.py +++ b/mocks/circuitpython/busio.py @@ -1,6 +1,8 @@ -""" -Mock for Circuit Python busio -https://docs.circuitpython.org/en/latest/shared-bindings/microcontroller/index.html +"""Mock for the CircuitPython busio module. + +This module provides a mock implementation of the CircuitPython busio module for +testing purposes. It allows for simulating the behavior of the busio module without +the need for actual hardware. """ from __future__ import annotations @@ -11,10 +13,21 @@ class SPI: + """A mock SPI bus.""" + def __init__( self, clock: microcontroller.Pin, MOSI: Optional[microcontroller.Pin] = None, MISO: Optional[microcontroller.Pin] = None, half_duplex: bool = False, - ) -> None: ... + ) -> None: + """Initializes the mock SPI bus. + + Args: + clock: The clock pin. + MOSI: The MOSI pin. + MISO: The MISO pin. + half_duplex: Whether to use half-duplex mode. + """ + ... diff --git a/mocks/circuitpython/byte_array.py b/mocks/circuitpython/byte_array.py index 0bb9954c..ae6cc9f6 100644 --- a/mocks/circuitpython/byte_array.py +++ b/mocks/circuitpython/byte_array.py @@ -1,15 +1,40 @@ +"""Mock for the CircuitPython bytearray. + +This module provides a mock implementation of the CircuitPython bytearray for +testing purposes. It allows for simulating the behavior of the bytearray without the +need for actual hardware. +""" + + class ByteArray: - """ - ByteArray is a class that mocks the implementaion of the CircuitPython non-volatile memory API. - """ + """A mock bytearray that simulates the CircuitPython non-volatile memory API.""" def __init__(self, size: int = 1024) -> None: + """Initializes the mock bytearray. + + Args: + size: The size of the bytearray. + """ self.memory = bytearray(size) def __getitem__(self, index: slice | int) -> bytearray | int: + """Gets an item from the bytearray. + + Args: + index: The index of the item to get. + + Returns: + The item at the given index. + """ if isinstance(index, slice): return bytearray(self.memory[index]) return int(self.memory[index]) def __setitem__(self, index: int, value: int) -> None: + """Sets an item in the bytearray. + + Args: + index: The index of the item to set. + value: The value to set. + """ self.memory[index] = value diff --git a/mocks/circuitpython/digitalio.py b/mocks/circuitpython/digitalio.py index 76a4d639..27b33e94 100644 --- a/mocks/circuitpython/digitalio.py +++ b/mocks/circuitpython/digitalio.py @@ -1,6 +1,8 @@ -""" -Mock for Circuit Python digitalio -https://docs.circuitpython.org/en/latest/shared-bindings/digitalio/index.html +"""Mock for the CircuitPython digitalio module. + +This module provides a mock implementation of the CircuitPython digitalio module for +testing purposes. It allows for simulating the behavior of the digitalio module without +the need for actual hardware. """ from __future__ import annotations @@ -9,16 +11,30 @@ class DriveMode: + """A mock DriveMode.""" + pass class DigitalInOut: - def __init__(self, pin: microcontroller.Pin) -> None: ... + """A mock DigitalInOut.""" + + def __init__(self, pin: microcontroller.Pin) -> None: + """Initializes the mock DigitalInOut. + + Args: + pin: The pin to use. + """ + ... class Direction: + """A mock Direction.""" + pass class Pull: + """A mock Pull.""" + pass diff --git a/mocks/circuitpython/microcontroller.py b/mocks/circuitpython/microcontroller.py index e229f87b..d7bf20b0 100644 --- a/mocks/circuitpython/microcontroller.py +++ b/mocks/circuitpython/microcontroller.py @@ -1,12 +1,18 @@ -""" -Mock for Circuit Python microcontroller -https://docs.circuitpython.org/en/latest/shared-bindings/busio/index.html +"""Mock for the CircuitPython microcontroller module. + +This module provides a mock implementation of the CircuitPython microcontroller module +for testing purposes. It allows for simulating the behavior of the microcontroller +module without the need for actual hardware. """ class Pin: + """A mock Pin.""" + pass class Processor: + """A mock Processor.""" + temperature = 35.0 diff --git a/mocks/circuitpython/rtc.py b/mocks/circuitpython/rtc.py index b7d1fcc1..e7ba6bff 100644 --- a/mocks/circuitpython/rtc.py +++ b/mocks/circuitpython/rtc.py @@ -1,11 +1,21 @@ +"""Mock for the CircuitPython RTC module. + +This module provides a mock implementation of the CircuitPython RTC module for +testing purposes. It allows for simulating the behavior of the RTC module without +the need for actual hardware. +""" + from time import struct_time class RTC: + """A mock RTC that can be used as a singleton.""" + _instance = None datetime: struct_time | None = None def __new__(cls): + """Creates a new RTC instance if one does not already exist.""" if cls._instance is None: cls._instance = super(RTC, cls).__new__(cls) cls._instance.datetime = None @@ -13,4 +23,5 @@ def __new__(cls): @classmethod def destroy(cls): + """Destroys the RTC instance.""" cls._instance = None diff --git a/mocks/proves_sx126/sx1262.py b/mocks/proves_sx126/sx1262.py index 5cc57310..351d86fc 100644 --- a/mocks/proves_sx126/sx1262.py +++ b/mocks/proves_sx126/sx1262.py @@ -1,6 +1,8 @@ -""" -Mock for PROVES SX126 -https://github.com/proveskit/micropySX126X/blob/master/proves_sx126/sx1262.py +"""Mock for the PROVES SX1262 radio module. + +This module provides a mock implementation of the PROVES SX1262 radio module for +testing purposes. It allows for simulating the behavior of the SX1262 without the +need for actual hardware. """ from busio import SPI @@ -19,6 +21,8 @@ class SX1262: + """A mock SX1262 radio module.""" + radio_modulation: str = "FSK" def __init__( @@ -28,7 +32,17 @@ def __init__( irq: DigitalInOut, rst: DigitalInOut, gpio: DigitalInOut, - ) -> None: ... + ) -> None: + """Initializes the mock SX1262. + + Args: + spi: The SPI bus to use. + cs: The chip select pin. + irq: The interrupt request pin. + rst: The reset pin. + gpio: The GPIO pin. + """ + ... def begin( self, @@ -48,7 +62,9 @@ def begin( tcxoVoltage=1.6, useRegulatorLDO=False, blocking=True, - ): ... + ): + """Initializes the radio in LoRa mode.""" + ... def beginFSK( self, @@ -76,10 +92,16 @@ def beginFSK( tcxoVoltage=1.6, useRegulatorLDO=False, blocking=True, - ): ... + ): + """Initializes the radio in FSK mode.""" + ... - def send(self, data) -> tuple[Literal[0], int] | tuple[int, int]: ... + def send(self, data) -> tuple[Literal[0], int] | tuple[int, int]: + """Sends data over the radio.""" + ... def recv( self, len=0, timeout_en=False, timeout_ms=0 - ) -> tuple[bytes, int] | tuple[Literal[b""], int]: ... + ) -> tuple[bytes, int] | tuple[Literal[b""], int]: + """Receives data from the radio.""" + ... diff --git a/mocks/proves_sx1280/sx1280.py b/mocks/proves_sx1280/sx1280.py index d3baf6e3..df1ad30c 100644 --- a/mocks/proves_sx1280/sx1280.py +++ b/mocks/proves_sx1280/sx1280.py @@ -1,6 +1,8 @@ -""" -Mock for PROVES SX1280 -https://github.com/proveskit/CircuitPython_SX1280/blob/6bcb9fc2922801d1eddbe6cec2b515448c0578ca/proves_sx1280/sx1280.py +"""Mock for the PROVES SX1280 radio module. + +This module provides a mock implementation of the PROVES SX1280 radio module for +testing purposes. It allows for simulating the behavior of the SX1280 without the +need for actual hardware. """ from busio import SPI @@ -8,6 +10,8 @@ class SX1280: + """A mock SX1280 radio module.""" + def __init__( self, spi: SPI, @@ -19,7 +23,20 @@ def __init__( debug: bool = False, txen: DigitalInOut | bool = False, rxen: DigitalInOut | bool = False, - ) -> None: ... + ) -> None: + """Initializes the mock SX1280. + + Args: + spi: The SPI bus to use. + cs: The chip select pin. + reset: The reset pin. + busy: The busy pin. + frequency: The frequency to operate on. + debug: Whether to enable debug mode. + txen: The transmit enable pin. + rxen: The receive enable pin. + """ + ... def send( self, @@ -31,6 +48,10 @@ def send( target=0, action=0, keep_listening=False, - ): ... + ): + """Sends data over the radio.""" + ... - def receive(self, continuous=True, keep_listening=True) -> bytearray | None: ... + def receive(self, continuous=True, keep_listening=True) -> bytearray | None: + """Receives data from the radio.""" + ... diff --git a/mocks/rv3028.py b/mocks/rv3028.py index 3053593c..01adab76 100644 --- a/mocks/rv3028.py +++ b/mocks/rv3028.py @@ -1,11 +1,50 @@ +"""Mock for the RV3028 real-time clock. + +This module provides a mock implementation of the RV3028 real-time clock for testing +purposes. It allows for simulating the behavior of the RV3028 without the need for +actual hardware. +""" + from busio import I2C class RV3028: - def __init__(self, i2c_bus: I2C) -> None: ... + """A mock RV3028 real-time clock.""" + + def __init__(self, i2c_bus: I2C) -> None: + """Initializes the mock RV3028. + + Args: + i2c_bus: The I2C bus to use. + """ + ... + + def configure_backup_switchover(self, mode: str, interrupt: bool) -> None: + """Configures the backup switchover. + + Args: + mode: The switchover mode. + interrupt: Whether to enable the interrupt. + """ + ... + + def set_time(self, hour: int, minute: int, second: int) -> None: + """Sets the time. - def configure_backup_switchover(self, mode: str, interrupt: bool) -> None: ... + Args: + hour: The hour to set. + minute: The minute to set. + second: The second to set. + """ + ... - def set_time(self, hour: int, minute: int, second: int) -> None: ... + def set_date(self, year: int, month: int, date: int, weekday: int) -> None: + """Sets the date. - def set_date(self, year: int, month: int, date: int, weekday: int) -> None: ... + Args: + year: The year to set. + month: The month to set. + date: The date to set. + weekday: The weekday to set. + """ + ... diff --git a/pyproject.toml b/pyproject.toml index 30e007d5..32a7ec23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,11 +31,14 @@ dev = [ "pre-commit==4.2.0", "pyright[nodejs]==1.1.402", "pytest==8.4.1", +] +docs = [ "mkdocs-material==9.6.14", "mkdocstrings[python]==0.29.1", "mkdocs-section-index==0.3.10", "mkdocs-git-revision-date-localized-plugin==1.4.7", "mkdocs-minify-plugin==0.7.1", + "mkdocs-llmstxt==0.2.0", ] [tool.setuptools] @@ -66,9 +69,6 @@ line-ending = "lf" [tool.pytest.ini_options] pythonpath = "." -markers = [ - "slow: marks tests as slow (deselect with '-m \"not slow\"')", -] [tool.coverage.run] branch = true @@ -97,3 +97,10 @@ exclude = [ ] stubPath = "./typings" reportMissingModuleSource = false + +[tool.interrogate] +ignore-init-method = true +omit-covered-files = true +fail-under = 100 +verbose = 2 +color = true diff --git a/pysquared/__init__.py b/pysquared/__init__.py index e69de29b..781be751 100644 --- a/pysquared/__init__.py +++ b/pysquared/__init__.py @@ -0,0 +1,3 @@ +""" +PySquared Satellite Flight Software +""" diff --git a/pysquared/beacon.py b/pysquared/beacon.py index 17c4abe6..048d06ab 100644 --- a/pysquared/beacon.py +++ b/pysquared/beacon.py @@ -1,3 +1,19 @@ +"""This module provides a Beacon class for sending periodic status messages. + +The Beacon class collects data from various sensors and system components, formats it +as a JSON string, and sends it using a provided packet manager. This is typically +used for sending telemetry or health information from a satellite or remote device. + +**Usage:** +```python +logger = Logger() +packet_manager = PacketManager(logger, radio) +boot_time = time.time() +beacon = Beacon(logger, "MySat", packet_manager, boot_time, imu, power_monitor) +beacon.send() +``` +""" + import json import time from collections import OrderedDict @@ -8,7 +24,7 @@ from microcontroller import Processor from .hardware.radio.packetizer.packet_manager import PacketManager -from .logger import Logger +from .logger.logger_proto import LoggerProto from .nvm.counter import Counter from .nvm.flag import Flag from .protos.imu import IMUProto @@ -23,9 +39,11 @@ class Beacon: + """A beacon for sending status messages.""" + def __init__( self, - logger: Logger, + logger: LoggerProto, name: str, packet_manager: PacketManager, boot_time: float, @@ -37,7 +55,16 @@ def __init__( | Counter | Processor, ) -> None: - self._log: Logger = logger + """Initializes the Beacon. + + Args: + logger: The logger to use. + name: The name of the beacon. + packet_manager: The packet manager to use for sending the beacon. + boot_time: The time the system booted. + *args: A list of sensors and other components to include in the beacon. + """ + self._log: LoggerProto = logger self._name: str = name self._packet_manager: PacketManager = packet_manager self._boot_time: float = boot_time @@ -53,8 +80,10 @@ def __init__( ] = args def send(self) -> bool: - """ - Send the beacon + """Sends the beacon. + + Returns: + True if the beacon was sent successfully, False otherwise. """ state: OrderedDict[str, object] = OrderedDict() state["name"] = self._name @@ -82,14 +111,8 @@ def send(self) -> bool: ) if isinstance(sensor, IMUProto): sensor_name: str = sensor.__class__.__name__ - state[ - f"{sensor_name}_{ - index}_acceleration" - ] = sensor.get_acceleration() - state[ - f"{sensor_name}_{ - index}_gyroscope" - ] = sensor.get_gyro_data() + state[f"{sensor_name}_{index}_acceleration"] = sensor.get_acceleration() + state[f"{sensor_name}_{index}_gyroscope"] = sensor.get_gyro_data() if isinstance(sensor, PowerMonitorProto): sensor_name: str = sensor.__class__.__name__ state[f"{sensor_name}_{index}_current_avg"] = self.avg_readings( @@ -103,10 +126,7 @@ def send(self) -> bool: ) if isinstance(sensor, TemperatureSensorProto): sensor_name = sensor.__class__.__name__ - state[ - f"{sensor_name}_{ - index}_temperature" - ] = sensor.get_temperature() + state[f"{sensor_name}_{index}_temperature"] = sensor.get_temperature() b = json.dumps(state, separators=(",", ":")).encode("utf-8") return self._packet_manager.send(b) @@ -114,13 +134,14 @@ def send(self) -> bool: def avg_readings( self, func: Callable[..., float | None], num_readings: int = 50 ) -> float | None: - """ - Get the average of the readings from a function + """Gets the average of the readings from a function. + + Args: + func: The function to call. + num_readings: The number of readings to take. - :param func: The function to call - :param num_readings: The number of readings to take - :return: The average of the readings - :rtype: float | None + Returns: + The average of the readings, or None if the readings could not be taken. """ readings: float = 0 for _ in range(num_readings): diff --git a/pysquared/cdh.py b/pysquared/cdh.py index ff68b342..ebf96b1f 100644 --- a/pysquared/cdh.py +++ b/pysquared/cdh.py @@ -1,10 +1,18 @@ -""" -cdh Module -========== - -This module provides the CommandDataHandler class for managing and processing -commands received by the satellite, including command parsing, execution, -and radio communication handling. +"""This module provides the CommandDataHandler for managing and processing commands. + +This module is responsible for handling commands received by the satellite. It +includes command parsing, validation, execution, and handling of radio +communications. The CommandDataHandler class is the main entry point for this +functionality. + +**Usage:** +```python +logger = Logger() +config = Config("config.json") +packet_manager = PacketManager(logger, radio) +cdh = CommandDataHandler(logger, config, packet_manager) +cdh.listen_for_commands(timeout=60) +``` """ import json @@ -16,21 +24,11 @@ from .config.config import Config from .hardware.radio.packetizer.packet_manager import PacketManager -from .logger import Logger +from .logger.logger_proto import LoggerProto class CommandDataHandler: - """ - Handles command parsing, validation, and execution for the satellite. - - Attributes: - logger (Logger): Logger instance for logging events and errors. - _commands (dict[bytes, str]): Mapping of command codes to handler method names. - _joke_reply (list[str]): List of joke replies for the joke_reply command. - _super_secret_code (bytes): Passcode required for command execution. - _repeat_code (bytes): Passcode for repeating the last message. - radio_manager (RFM9xManager): Radio manager for communication. - """ + """Handles command parsing, validation, and execution for the satellite.""" command_reset: str = "reset" command_change_radio_modulation: str = "change_radio_modulation" @@ -38,20 +36,29 @@ class CommandDataHandler: def __init__( self, - logger: Logger, + logger: LoggerProto, config: Config, packet_manager: PacketManager, send_delay: float = 0.2, ) -> None: - self._log: Logger = logger + """Initializes the CommandDataHandler. + + Args: + logger: The logger to use. + config: The configuration to use. + packet_manager: The packet manager to use for sending and receiving data. + send_delay: The delay between sending an acknowledgement and the response. + """ + self._log: LoggerProto = logger self._config: Config = config self._packet_manager: PacketManager = packet_manager self._send_delay: float = send_delay def listen_for_commands(self, timeout: int) -> None: - """ - Listen for commands from the radio and handle them. - :param timeout: Timeout in seconds for listening for commands. + """Listens for commands from the radio and handles them. + + Args: + timeout: The time in seconds to listen for commands. """ self._log.debug("Listening for commands...", timeout=timeout) @@ -121,17 +128,16 @@ def listen_for_commands(self, timeout: int) -> None: return def send_joke(self) -> None: - """ - Send a random joke from the config. - """ + """Sends a random joke from the config.""" joke = random.choice(self._config.jokes) self._log.info("Sending joke", joke=joke) self._packet_manager.send(joke.encode("utf-8")) def change_radio_modulation(self, args: list[str]) -> None: - """ - Change the radio modulation. - :param modulation: The new radio modulation to set. + """Changes the radio modulation. + + Args: + args: A list of arguments, where the first argument is the new modulation. """ modulation = "UNSET" @@ -159,9 +165,7 @@ def change_radio_modulation(self, args: list[str]) -> None: ) def reset(self) -> None: - """ - Reset the hardware. - """ + """Resets the hardware.""" self._log.info("Resetting satellite") self._packet_manager.send(data="Resetting satellite".encode("utf-8")) microcontroller.on_next_reset(microcontroller.RunMode.NORMAL) diff --git a/pysquared/config/__init__.py b/pysquared/config/__init__.py index e69de29b..d82c5bc2 100644 --- a/pysquared/config/__init__.py +++ b/pysquared/config/__init__.py @@ -0,0 +1,3 @@ +""" +This module provides an interface for managing configuration settings in the PySquared project. +""" diff --git a/pysquared/config/config.py b/pysquared/config/config.py index 06a0a9ae..6db72768 100644 --- a/pysquared/config/config.py +++ b/pysquared/config/config.py @@ -1,7 +1,4 @@ -""" -Config module for PySquared. - -This module provides the `Config` class, which encapsulates the configuration +"""This module provides the Config, which encapsulates the configuration logic for the PySquared project. It loads, validates, and updates configuration values from a JSON file, and distributes these values across the application. @@ -9,14 +6,11 @@ Config: Handles loading, validating, and updating configuration values, including radio settings. -Usage: - Instantiate the `Config` class with the path to the configuration JSON file. - Use the `update_config` method to update configuration values, either - temporarily (RAM only) or permanently (persisted to file). - -Example: - config = Config("config.json") - config.update_config("cubesat_name", "Cube1", temporary=False) +**Usage:** +```python +config = Config("config.json") +config.update_config("cubesat_name", "Cube1", temporary=False) +``` """ import json diff --git a/pysquared/config/radio.py b/pysquared/config/radio.py index a2588a5c..ba50a647 100644 --- a/pysquared/config/radio.py +++ b/pysquared/config/radio.py @@ -1,18 +1,9 @@ -""" -Radio configuration module for PySquared. - -This module provides classes for handling and validating radio configuration -parameters, including support for both FSK and LoRa modulation schemes. +"""This module provides classes for handling and validating radio configuration parameters, including support for both FSK and LoRa modulation schemes. Classes: RadioConfig: Handles top-level radio configuration and validation. FSKConfig: Handles FSK-specific configuration and validation. LORAConfig: Handles LoRa-specific configuration and validation. - -Usage: - Instantiate `RadioConfig` with a dictionary of radio parameters. - Use the `validate` method to check if a given key/value pair is valid - according to the radio schema. """ # type-hinting only diff --git a/pysquared/detumble.py b/pysquared/detumble.py index 8352ac23..e5f93b65 100644 --- a/pysquared/detumble.py +++ b/pysquared/detumble.py @@ -1,8 +1,4 @@ -""" -detumble Module -=============== - -This module provides functions for satellite detumbling using magnetorquers. +"""This module provides functions for satellite detumbling using magnetorquers. Includes vector math utilities and the main dipole calculation for attitude control. """ @@ -39,7 +35,7 @@ def x_product(vector1: tuple, vector2: tuple) -> list: ] -def gain_func(): +def gain_func() -> float: """ Returns the gain value for the detumble control law. diff --git a/pysquared/hardware/__init__.py b/pysquared/hardware/__init__.py index e69de29b..5ddfd9e1 100644 --- a/pysquared/hardware/__init__.py +++ b/pysquared/hardware/__init__.py @@ -0,0 +1,3 @@ +""" +This module provides managers for various hardware components including sensors, actuators, communication interfaces, etc. +""" diff --git a/pysquared/hardware/burnwire/__init__.py b/pysquared/hardware/burnwire/__init__.py index e69de29b..66e76d1a 100644 --- a/pysquared/hardware/burnwire/__init__.py +++ b/pysquared/hardware/burnwire/__init__.py @@ -0,0 +1,3 @@ +""" +This module provides an interface for controlling burnwire systems. +""" diff --git a/pysquared/hardware/burnwire/manager/__init__.py b/pysquared/hardware/burnwire/manager/__init__.py index e69de29b..5e911950 100644 --- a/pysquared/hardware/burnwire/manager/__init__.py +++ b/pysquared/hardware/burnwire/manager/__init__.py @@ -0,0 +1,3 @@ +""" +This module provides the managers for various burnwire implementations +""" diff --git a/pysquared/hardware/burnwire/manager/burnwire.py b/pysquared/hardware/burnwire/manager/burnwire.py index f4f8471f..5ca1a1a8 100644 --- a/pysquared/hardware/burnwire/manager/burnwire.py +++ b/pysquared/hardware/burnwire/manager/burnwire.py @@ -1,41 +1,45 @@ +"""This module defines the `BurnwireManager` class, which provides a high-level interface +for controlling burnwire circuits, which are commonly used for deployment mechanisms in +satellites. It handles the timing and sequencing of the burnwire activation +and provides error handling and logging. + +**Usage:** +```python +logger = Logger() +enable_pin = DigitalInOut(board.D1) +fire_pin = DigitalInOut(board.D2) +burnwire = BurnwireManager(logger, enable_pin, fire_pin) +burnwire.burn() +``` +""" + import time from digitalio import DigitalInOut -from ....logger import Logger +from ....logger.logger_proto import LoggerProto from ....protos.burnwire import BurnwireProto -""" -Usage Example: - -from lib.pysquared.hardware.burnwire.manager.burnwire import BurnwireManager -... - -antenna_deployment = BurnwireManager(logger, board.FIRE_DEPLOY1A, board.FIRE_DEPLOY1B, enable_logic = False) - -antenna_deployment.burn() -""" - class BurnwireManager(BurnwireProto): - """Class for managing burnwire ports.""" + """Manages the activation of a burnwire.""" def __init__( self, - logger: Logger, + logger: LoggerProto, enable_burn: DigitalInOut, fire_burn: DigitalInOut, enable_logic: bool = True, ) -> None: - """ - Initializes the burnwire manager class. + """Initializes the burnwire manager. - :param Logger logger: Logger instance for logging messages. - :param Digitalio enable_burn: A pin used for enabling the initial stage of a burnwire circuit. - :param Digitalio fire_burn: A pin used for enabling a specific burnwire port. - :param bool enable_logic: Boolean defining whether the burnwire load switches are enabled when True or False. Defaults to `True`. + Args: + logger: The logger to use. + enable_burn: The pin used to enable the burnwire circuit. + fire_burn: The pin used to fire the burnwire. + enable_logic: The logic level to enable the burnwire. """ - self._log: Logger = logger + self._log: LoggerProto = logger self._enable_logic: bool = enable_logic self._enable_burn: DigitalInOut = enable_burn @@ -44,14 +48,16 @@ def __init__( self.number_of_attempts: int = 0 def burn(self, timeout_duration: float = 5.0) -> bool: - """Fires the burnwire for a specified amount of time + """Fires the burnwire for a specified amount of time. - :param float timeout_duration: The max amount of time to keep the burnwire on for. + Args: + timeout_duration: The maximum amount of time to keep the burnwire on. - :return: A Boolean indicating whether the burn occurred successfully - :rtype: bool + Returns: + True if the burn was successful, False otherwise. - :raises Exception: If there is an error toggling the burnwire pins. + Raises: + Exception: If there is an error toggling the burnwire pins. """ _start_time = time.monotonic() @@ -64,7 +70,7 @@ def burn(self, timeout_duration: float = 5.0) -> bool: except KeyboardInterrupt: self._log.debug( - f"Burn Attempt Interupted after {time.monotonic() - _start_time:.2f} seconds" + f"Burn Attempt Interrupted after {time.monotonic() - _start_time:.2f} seconds" ) return False @@ -118,14 +124,13 @@ def _disable(self): raise RuntimeError("Failed to safe burnwire pins") from e def _attempt_burn(self, duration: float = 5.0) -> None: - """Private function for actuating the burnwire ports for a set period of time. - - :param float duration: Defines how long the burnwire will remain on for. Defaults to 5s. + """Attempts to actuate the burnwire for a set period of time. - :return: None - :rtype: None + Args: + duration: The duration of the burn. - :raises RuntimeError: If there is an error toggling the burnwire pins. + Raises: + RuntimeError: If there is an error toggling the burnwire pins. """ error = None try: diff --git a/pysquared/hardware/busio.py b/pysquared/hardware/busio.py index 78d8f5de..22318a83 100644 --- a/pysquared/hardware/busio.py +++ b/pysquared/hardware/busio.py @@ -1,11 +1,6 @@ -""" -busio Module -============ - -This module provides functions for initializing and configuring SPI and I2C buses +"""This module provides functions for initializing and configuring SPI and I2C buses on the PySquared satellite hardware. Includes retry logic for robust hardware initialization and error handling. - """ import time @@ -13,8 +8,7 @@ from busio import I2C, SPI from microcontroller import Pin -from ..logger import Logger -from .decorators import with_retries +from ..logger.logger_proto import LoggerProto from .exception import HardwareInitializationError try: @@ -24,7 +18,7 @@ def initialize_spi_bus( - logger: Logger, + logger: LoggerProto, clock: Pin, mosi: Optional[Pin] = None, miso: Optional[Pin] = None, @@ -67,9 +61,8 @@ def initialize_spi_bus( ) from e -@with_retries(max_attempts=3, initial_delay=1) def _spi_init( - logger: Logger, + logger: LoggerProto, clock: Pin, mosi: Optional[Pin] = None, miso: Optional[Pin] = None, @@ -98,7 +91,7 @@ def _spi_init( def _spi_configure( - logger: Logger, + logger: LoggerProto, spi: SPI, baudrate: Optional[int], phase: Optional[int], @@ -147,9 +140,8 @@ def _spi_configure( return spi -@with_retries(max_attempts=3, initial_delay=1) def initialize_i2c_bus( - logger: Logger, + logger: LoggerProto, scl: Pin, sda: Pin, frequency: Optional[int], diff --git a/pysquared/hardware/decorators.py b/pysquared/hardware/decorators.py deleted file mode 100644 index e628f983..00000000 --- a/pysquared/hardware/decorators.py +++ /dev/null @@ -1,49 +0,0 @@ -""" -decorators Module -================= - -This module provides decorators for hardware initialization and error handling, -including retry logic with exponential backoff for robust hardware setup. - -""" - -import time - -from .exception import HardwareInitializationError - - -def with_retries(max_attempts: int = 3, initial_delay: float = 1.0): - """ - Decorator that retries hardware initialization with exponential backoff. - - Args: - max_attempts (int): Maximum number of attempts to try initialization (default 3). - initial_delay (float): Initial delay in seconds between attempts (default 1.0). - - Raises: - HardwareInitializationError: If all attempts fail, the last exception is raised. - - Returns: - function: The result of the decorated function if successful. - """ - - def decorator(func): - def wrapper(*args, **kwargs): - last_exception = Exception("with_retries decorator had unknown error") - delay = initial_delay - - for attempt in range(max_attempts): - try: - return func(*args, **kwargs) - except HardwareInitializationError as e: - last_exception = e - if attempt < max_attempts - 1: # Don't sleep on last attempt - time.sleep(delay) - delay *= 2 # Exponential backoff - - # If we get here, all attempts failed - raise last_exception - - return wrapper - - return decorator diff --git a/pysquared/hardware/digitalio.py b/pysquared/hardware/digitalio.py index e34c8bce..414b80db 100644 --- a/pysquared/hardware/digitalio.py +++ b/pysquared/hardware/digitalio.py @@ -1,23 +1,16 @@ -""" -digitalio Module -================ - -This module provides functions for initializing DigitalInOut pins on the PySquared +"""This module provides functions for initializing DigitalInOut pins on the PySquared satellite hardware. Includes retry logic for robust hardware initialization and error handling. - """ from digitalio import DigitalInOut, Direction from microcontroller import Pin -from ..logger import Logger -from .decorators import with_retries +from ..logger.logger_proto import LoggerProto from .exception import HardwareInitializationError -@with_retries(max_attempts=3, initial_delay=1) def initialize_pin( - logger: Logger, pin: Pin, direction: Direction, initial_value: bool + logger: LoggerProto, pin: Pin, direction: Direction, initial_value: bool ) -> DigitalInOut: """ Initializes a DigitalInOut pin with the specified direction and initial value. diff --git a/pysquared/hardware/exception.py b/pysquared/hardware/exception.py index a4c2b65c..ac85248c 100644 --- a/pysquared/hardware/exception.py +++ b/pysquared/hardware/exception.py @@ -1,2 +1,16 @@ +"""This module provides a custom exception for hardware initialization errors. + +This exception is raised when a hardware component fails to initialize after a +certain number of retries. + +**Usage:** +```python +raise HardwareInitializationError("Failed to initialize the IMU.") +``` +""" + + class HardwareInitializationError(Exception): + """Exception raised for errors in hardware initialization.""" + pass diff --git a/pysquared/hardware/imu/__init__.py b/pysquared/hardware/imu/__init__.py index e69de29b..5e7d0449 100644 --- a/pysquared/hardware/imu/__init__.py +++ b/pysquared/hardware/imu/__init__.py @@ -0,0 +1,3 @@ +""" +This module provides an interface for controlling inertial measurement units (IMUs). +""" diff --git a/pysquared/hardware/imu/manager/__init__.py b/pysquared/hardware/imu/manager/__init__.py index e69de29b..d5577b4a 100644 --- a/pysquared/hardware/imu/manager/__init__.py +++ b/pysquared/hardware/imu/manager/__init__.py @@ -0,0 +1,3 @@ +""" +This module provides the managers for various inertial measurement unit (IMU) implementations +""" diff --git a/pysquared/hardware/imu/manager/lsm6dsox.py b/pysquared/hardware/imu/manager/lsm6dsox.py index 5eb6625f..88393318 100644 --- a/pysquared/hardware/imu/manager/lsm6dsox.py +++ b/pysquared/hardware/imu/manager/lsm6dsox.py @@ -1,35 +1,47 @@ +"""This module defines the `LSM6DSOXManager` class, which provides a high-level interface +for interacting with the LSM6DSOX inertial measurement unit. It handles the initialization of the sensor and +provides methods for reading gyroscope, acceleration, and temperature data. + +**Usage:** +```python +logger = Logger() +i2c = busio.I2C(board.SCL, board.SDA) +imu = LSM6DSOXManager(logger, i2c, 0x6A) +gyro_data = imu.get_gyro_data() +accel_data = imu.get_acceleration() +temp_data = imu.get_temperature() +``` +""" + from adafruit_lsm6ds.lsm6dsox import LSM6DSOX from busio import I2C -from ....logger import Logger +from ....logger.logger_proto import LoggerProto from ....protos.imu import IMUProto from ....protos.temperature_sensor import TemperatureSensorProto -from ...decorators import with_retries from ...exception import HardwareInitializationError class LSM6DSOXManager(IMUProto, TemperatureSensorProto): - """Manager class for creating LIS2MDL IMU instances. - The purpose of the manager class is to hide the complexity of IMU initialization from the caller. - Specifically we should try to keep adafruit_lsm6ds to only this manager class. - """ + """Manages the LSM6DSOX IMU.""" - @with_retries(max_attempts=3, initial_delay=1) def __init__( self, - logger: Logger, + logger: LoggerProto, i2c: I2C, address: int, ) -> None: - """Initialize the manager class. + """Initializes the LSM6DSOXManager. - :param Logger logger: Logger instance for logging messages. - :param busio.I2C i2c: The I2C bus connected to the chip. - :param int address: The I2C address of the IMU. + Args: + logger: The logger to use. + i2c: The I2C bus connected to the chip. + address: The I2C address of the IMU. - :raises HardwareInitializationError: If the IMU fails to initialize. + Raises: + HardwareInitializationError: If the IMU fails to initialize. """ - self._log: Logger = logger + self._log: LoggerProto = logger try: self._log.debug("Initializing IMU") @@ -38,12 +50,11 @@ def __init__( raise HardwareInitializationError("Failed to initialize IMU") from e def get_gyro_data(self) -> tuple[float, float, float] | None: - """Get the gyroscope data from the inertial measurement unit. - - :return: A tuple containing the x, y, and z angular acceleration values in radians per second or None if not available. - :rtype: tuple[float, float, float] | None + """Gets the gyroscope data from the IMU. - :raises Exception: If there is an error retrieving the values. + Returns: + A tuple containing the x, y, and z angular acceleration values in + radians per second, or None if the data is not available. """ try: return self._imu.gyro @@ -51,12 +62,11 @@ def get_gyro_data(self) -> tuple[float, float, float] | None: self._log.error("Error retrieving IMU gyro sensor values", e) def get_acceleration(self) -> tuple[float, float, float] | None: - """Get the acceleration data from the inertial measurement unit. + """Gets the acceleration data from the IMU. - :return: A tuple containing the x, y, and z acceleration values in m/s^2 or None if not available. - :rtype: tuple[float, float, float] | None - - :raises Exception: If there is an error retrieving the values. + Returns: + A tuple containing the x, y, and z acceleration values in m/s^2, or + None if the data is not available. """ try: return self._imu.acceleration @@ -64,12 +74,10 @@ def get_acceleration(self) -> tuple[float, float, float] | None: self._log.error("Error retrieving IMU acceleration sensor values", e) def get_temperature(self) -> float | None: - """Get the temperature reading from the inertial measurement unit, if available. - - :return: The temperature in degrees Celsius or None if not available. - :rtype: float | None + """Gets the temperature reading from the IMU. - :raises Exception: If there is an error retrieving the temperature value. + Returns: + The temperature in degrees Celsius, or None if the data is not available. """ try: return self._imu.temperature diff --git a/pysquared/hardware/magnetometer/__init__.py b/pysquared/hardware/magnetometer/__init__.py index e69de29b..671058c9 100644 --- a/pysquared/hardware/magnetometer/__init__.py +++ b/pysquared/hardware/magnetometer/__init__.py @@ -0,0 +1,3 @@ +""" +This module provides an interface for controlling magnetormeters. +""" diff --git a/pysquared/hardware/magnetometer/manager/__init__.py b/pysquared/hardware/magnetometer/manager/__init__.py index e69de29b..ffde0c5c 100644 --- a/pysquared/hardware/magnetometer/manager/__init__.py +++ b/pysquared/hardware/magnetometer/manager/__init__.py @@ -0,0 +1,3 @@ +""" +This module provides the managers for various magnetometer implementations +""" diff --git a/pysquared/hardware/magnetometer/manager/lis2mdl.py b/pysquared/hardware/magnetometer/manager/lis2mdl.py index 2bbf46b9..16f9d5ef 100644 --- a/pysquared/hardware/magnetometer/manager/lis2mdl.py +++ b/pysquared/hardware/magnetometer/manager/lis2mdl.py @@ -1,32 +1,42 @@ +"""This module defines the `LIS2MDLManager` class, which provides a high-level interface +for interacting with the LIS2MDL magnetometer. It handles the initialization of the sensor +and provides a method for reading the magnetic field vector. + +**Usage:** +```python +logger = Logger() +i2c = busio.I2C(board.SCL, board.SDA) +magnetometer = LIS2MDLManager(logger, i2c) +mag_data = magnetometer.get_vector() +``` +""" + from adafruit_lis2mdl import LIS2MDL from busio import I2C -from ....logger import Logger +from ....logger.logger_proto import LoggerProto from ....protos.magnetometer import MagnetometerProto -from ...decorators import with_retries from ...exception import HardwareInitializationError class LIS2MDLManager(MagnetometerProto): - """Manager class for creating LIS2MDL magnetometer instances. - The purpose of the manager class is to hide the complexity of magnetometer initialization from the caller. - Specifically we should try to keep adafruit_lis2mdl to only this manager class. - """ + """Manages the LIS2MDL magnetometer.""" - @with_retries(max_attempts=3, initial_delay=1) def __init__( self, - logger: Logger, + logger: LoggerProto, i2c: I2C, ) -> None: - """Initialize the manager class. + """Initializes the LIS2MDLManager. - :param Logger logger: Logger instance for logging messages. - :param busio.I2C i2c: The I2C bus connected to the chip. + Args: + logger: The logger to use. + i2c: The I2C bus connected to the chip. - :raises HardwareInitializationError: If the magnetometer fails to initialize. + Raises: + HardwareInitializationError: If the magnetometer fails to initialize. """ - self._log: Logger = logger + self._log: LoggerProto = logger try: self._log.debug("Initializing magnetometer") @@ -37,12 +47,11 @@ def __init__( ) from e def get_vector(self) -> tuple[float, float, float] | None: - """Get the magnetic field vector from the magnetometer. - - :return: A tuple containing the x, y, and z magnetic field values in Gauss or None if not available. - :rtype: tuple[float, float, float] | None + """Gets the magnetic field vector from the magnetometer. - :raises Exception: If there is an error retrieving the values. + Returns: + A tuple containing the x, y, and z magnetic field values in Gauss, or + None if the data is not available. """ try: return self._magnetometer.magnetic diff --git a/pysquared/hardware/power_monitor/__init__.py b/pysquared/hardware/power_monitor/__init__.py new file mode 100644 index 00000000..20348d70 --- /dev/null +++ b/pysquared/hardware/power_monitor/__init__.py @@ -0,0 +1,3 @@ +""" +This module provides an interface for controlling power monitors. +""" diff --git a/pysquared/hardware/power_monitor/manager/__init__.py b/pysquared/hardware/power_monitor/manager/__init__.py index e69de29b..2a1b8ce4 100644 --- a/pysquared/hardware/power_monitor/manager/__init__.py +++ b/pysquared/hardware/power_monitor/manager/__init__.py @@ -0,0 +1,3 @@ +""" +This module provides the managers for various power monitor implementations +""" diff --git a/pysquared/hardware/power_monitor/manager/ina219.py b/pysquared/hardware/power_monitor/manager/ina219.py index c345ac42..250353c2 100644 --- a/pysquared/hardware/power_monitor/manager/ina219.py +++ b/pysquared/hardware/power_monitor/manager/ina219.py @@ -1,28 +1,46 @@ +"""This module defines the `INA219Manager` class, which provides a high-level interface +for interacting with the INA219 power monitor. It handles the initialization of the sensor +and provides methods for reading bus voltage, shunt voltage, and current. + +**Usage:** +```python +logger = Logger() +i2c = busio.I2C(board.SCL, board.SDA) +power_monitor = INA219Manager(logger, i2c, 0x40) +bus_voltage = power_monitor.get_bus_voltage() +shunt_voltage = power_monitor.get_shunt_voltage() +current = power_monitor.get_current() +``` +""" + from adafruit_ina219 import INA219 from busio import I2C -from ....logger import Logger +from ....logger.logger_proto import LoggerProto from ....protos.power_monitor import PowerMonitorProto -from ...decorators import with_retries from ...exception import HardwareInitializationError class INA219Manager(PowerMonitorProto): - @with_retries(max_attempts=3, initial_delay=1) + """Manages the INA219 power monitor.""" + def __init__( self, - logger: Logger, + logger: LoggerProto, i2c: I2C, addr: int, ) -> None: - """Initialize the INA219 power monitor. + """Initializes the INA219Manager. - :param busio.I2C i2c: The I2C bus connected to the chip. - :param int addr: The I2C address of the INA219. + Args: + logger: The logger to use. + i2c: The I2C bus connected to the chip. + addr: The I2C address of the INA219. - :raises HardwareInitializationError: If the INA219 fails to initialize. + Raises: + HardwareInitializationError: If the INA219 fails to initialize. """ - self._log: Logger = logger + self._log: LoggerProto = logger try: logger.debug("Initializing INA219 power monitor") self._ina219: INA219 = INA219(i2c, addr) @@ -32,11 +50,10 @@ def __init__( ) from e def get_bus_voltage(self) -> float | None: - """Get the bus voltage from the INA219. - - :return: The bus voltage in volts or None if not available. - :rtype: float | None + """Gets the bus voltage from the INA219. + Returns: + The bus voltage in volts, or None if the data is not available. """ try: return self._ina219.bus_voltage @@ -44,11 +61,10 @@ def get_bus_voltage(self) -> float | None: self._log.error("Error retrieving bus voltage", e) def get_shunt_voltage(self) -> float | None: - """Get the shunt voltage from the INA219. - - :return: The shunt voltage in volts or None if not available. - :rtype: float | None + """Gets the shunt voltage from the INA219. + Returns: + The shunt voltage in volts, or None if the data is not available. """ try: return self._ina219.shunt_voltage @@ -56,11 +72,10 @@ def get_shunt_voltage(self) -> float | None: self._log.error("Error retrieving shunt voltage", e) def get_current(self) -> float | None: - """Get the current from the INA219. - - :return: The current in amps or None if not available. - :rtype: float | None + """Gets the current from the INA219. + Returns: + The current in amps, or None if the data is not available. """ try: return self._ina219.current diff --git a/pysquared/hardware/radio/__init__.py b/pysquared/hardware/radio/__init__.py index e69de29b..d20af4da 100644 --- a/pysquared/hardware/radio/__init__.py +++ b/pysquared/hardware/radio/__init__.py @@ -0,0 +1,3 @@ +""" +This module provides an interface for controlling radios. +""" diff --git a/pysquared/hardware/radio/manager/__init__.py b/pysquared/hardware/radio/manager/__init__.py index e69de29b..9d17ce07 100644 --- a/pysquared/hardware/radio/manager/__init__.py +++ b/pysquared/hardware/radio/manager/__init__.py @@ -0,0 +1,3 @@ +""" +This module provides the managers for various radio implementations. +""" diff --git a/pysquared/hardware/radio/manager/base.py b/pysquared/hardware/radio/manager/base.py index 9b7f8cba..bbb7aa3d 100644 --- a/pysquared/hardware/radio/manager/base.py +++ b/pysquared/hardware/radio/manager/base.py @@ -1,7 +1,13 @@ +"""This module provides a base class for radio managers. + +This module defines the `BaseRadioManager` class, which serves as an abstract base +class for all radio managers in the system. It provides common functionality and +ensures that all radio managers adhere to a consistent interface. +""" + from ....config.radio import RadioConfig -from ....logger import Logger +from ....logger.logger_proto import LoggerProto from ....protos.radio import RadioProto -from ...decorators import with_retries from ...exception import HardwareInitializationError from ..modulation import FSK, LoRa, RadioModulation @@ -15,21 +21,21 @@ class BaseRadioManager(RadioProto): """Base class for radio managers (CircuitPython compatible).""" - @with_retries(max_attempts=3, initial_delay=1) def __init__( self, - logger: Logger, + logger: LoggerProto, radio_config: RadioConfig, **kwargs: object, ) -> None: - """Initialize the base manager class. + """Initializes the base manager class. - :param Logger logger: Logger instance for logging messages. - :param RadioConfig radio_config: Radio configuration object. - :param Flag use_fsk: Flag to determine whether to use FSK or LoRa mode. - :param Any kwargs: Hardware-specific arguments (e.g., spi, cs, rst). + Args: + logger: Logger instance for logging messages. + radio_config: Radio configuration object. + **kwargs: Hardware-specific arguments (e.g., spi, cs, rst). - :raises HardwareInitializationError: If the radio fails to initialize after retries. + Raises: + HardwareInitializationError: If the radio fails to initialize after retries. """ self._log = logger self._radio_config = radio_config @@ -52,11 +58,15 @@ def __init__( ) from e def send(self, data: bytes) -> bool: - """Send data over the radio. + """Sends data over the radio. + + This method must be implemented by subclasses. - Must be implemented by subclasses. + Args: + data: The data to send. - :param bytes data: The data to send. + Returns: + True if the data was sent successfully, False otherwise. """ try: if self._radio_config.license == "": @@ -75,76 +85,93 @@ def send(self, data: bytes) -> bool: return False def receive(self, timeout: Optional[int] = None) -> bytes | None: - """Receive data from the radio. + """Receives data from the radio. - Must be implemented by subclasses. + This method must be implemented by subclasses. - :param int | None timeout: Optional receive timeout in seconds.If None, use the default timeout. - :return: The received data as bytes, or None if no data was received. + Args: + timeout: Optional receive timeout in seconds. If None, use the default timeout. - :raises NotImplementedError: If not implemented by subclass. - :raises Exception: If receiving fails unexpectedly. + Returns: + The received data as bytes, or None if no data was received. + + Raises: + NotImplementedError: If not implemented by subclass. + Exception: If receiving fails unexpectedly. """ raise NotImplementedError def get_modulation(self) -> Type[RadioModulation]: - """Get the modulation mode from the initialized radio hardware. + """Gets the modulation mode from the initialized radio hardware. - :return: The current modulation mode of the hardware. + Returns: + The current modulation mode of the hardware. - :raises NotImplementedError: If not implemented by subclass. + Raises: + NotImplementedError: If not implemented by subclass. """ raise NotImplementedError def modify_config(self, key: str, value) -> None: - """Modify a specific radio configuration parameter. + """Modifies a specific radio configuration parameter. + + This method must be implemented by subclasses. - Must be implemented by subclasses. + Args: + key: The configuration parameter key to modify. + value: The new value to set for the parameter. - :param str key: The configuration parameter key to modify. - :param value: The new value to set for the parameter. - :raises NotImplementedError: If not implemented by subclass. + Raises: + NotImplementedError: If not implemented by subclass. """ raise NotImplementedError - # Methods to be overridden by subclasses def _initialize_radio(self, modulation: Type[RadioModulation]) -> None: - """Initialize the specific radio hardware. + """Initializes the specific radio hardware. - Must be implemented by subclasses. + This method must be implemented by subclasses. - :param RadioModulation modulation: The modulation mode to initialize with. - :param Any kwargs: Hardware-specific arguments passed from __init__. - :return: The initialized radio hardware object. - :raises NotImplementedError: If not implemented by subclass. - :raises Exception: If initialization fails. + Args: + modulation: The modulation mode to initialize with. + + Raises: + NotImplementedError: If not implemented by subclass. + Exception: If initialization fails. """ raise NotImplementedError def _send_internal(self, data: bytes) -> bool: - """Send data using the specific radio hardware's method. + """Sends data using the specific radio hardware's method. + + This method must be implemented by subclasses. - Must be implemented by subclasses. + Args: + data: The data to send. - :param bytes data: The data to send. - :return: True if sending was successful, False otherwise. - :raises NotImplementedError: If not implemented by subclass. - :raises Exception: If sending fails unexpectedly. + Returns: + True if sending was successful, False otherwise. + + Raises: + NotImplementedError: If not implemented by subclass. + Exception: If sending fails unexpectedly. """ raise NotImplementedError def get_rssi(self) -> int: - """Get the RSSI of the last received packet. + """Gets the RSSI of the last received packet. + + Returns: + The RSSI of the last received packet. - :return: The RSSI of the last received packet. - :raises NotImplementedError: If not implemented by subclass. - :raises Exception: If querying the hardware fails. + Raises: + NotImplementedError: If not implemented by subclass. """ raise NotImplementedError def get_max_packet_size(self) -> int: - """Get the maximum packet size supported by the radio. + """Gets the maximum packet size supported by the radio. - :return: The maximum packet size in bytes. + Returns: + The maximum packet size in bytes. """ return 128 # Placeholder value, should be overridden by subclasses diff --git a/pysquared/hardware/radio/manager/rfm9x.py b/pysquared/hardware/radio/manager/rfm9x.py index 43253b01..bd5ef60c 100644 --- a/pysquared/hardware/radio/manager/rfm9x.py +++ b/pysquared/hardware/radio/manager/rfm9x.py @@ -1,3 +1,21 @@ +"""This module provides a manager for RFM9x radios. + +This module defines the `RFM9xManager` class, which implements the `RadioProto` +interface for RFM9x radios. It handles the initialization and configuration of +the radio, as well as sending and receiving data. + +**Usage:** +```python +logger = Logger() +radio_config = RadioConfig() +spi = busio.SPI(board.SCK, MOSI=board.MOSI, MISO=board.MISO) +cs = digitalio.DigitalInOut(board.D5) +reset = digitalio.DigitalInOut(board.D6) +rfm9x_manager = RFM9xManager(logger, radio_config, spi, cs, reset) +rfm9x_manager.send(b"Hello world!") +``` +""" + from busio import SPI from digitalio import DigitalInOut @@ -9,7 +27,7 @@ from adafruit_rfm.rfm9xfsk import RFM9xFSK from ....config.radio import FSKConfig, LORAConfig, RadioConfig -from ....logger import Logger +from ....logger.logger_proto import LoggerProto from ....protos.temperature_sensor import TemperatureSensorProto from ..modulation import FSK, LoRa, RadioModulation from .base import BaseRadioManager @@ -22,28 +40,29 @@ class RFM9xManager(BaseRadioManager, TemperatureSensorProto): - """Manager class implementing RadioProto for RFM9x radios.""" + """Manages RFM9x radios, implementing the RadioProto interface.""" _radio: RFM9xFSK | RFM9x def __init__( self, - logger: Logger, + logger: LoggerProto, radio_config: RadioConfig, spi: SPI, chip_select: DigitalInOut, reset: DigitalInOut, ) -> None: - """Initialize the manager class and the underlying radio hardware. + """Initializes the RFM9xManager and the underlying radio hardware. - :param Logger logger: Logger instance for logging messages. - :param RadioConfig radio_config: Radio config object. - :param Flag use_fsk: Flag to determine whether to use FSK or LoRa mode. - :param busio.SPI spi: The SPI bus connected to the chip. Ensure SCK, MOSI, and MISO are connected. - :param ~digitalio.DigitalInOut chip_select: A DigitalInOut object connected to the chip's CS/chip select line. - :param ~digitalio.DigitalInOut reset: A DigitalInOut object connected to the chip's RST/reset line. + Args: + logger: Logger instance for logging messages. + radio_config: Radio configuration object. + spi: The SPI bus connected to the chip. + chip_select: A DigitalInOut object connected to the chip's CS/chip select line. + reset: A DigitalInOut object connected to the chip's RST/reset line. - :raises HardwareInitializationError: If the radio fails to initialize after retries. + Raises: + HardwareInitializationError: If the radio fails to initialize after retries. """ self._spi = spi self._chip_select = chip_select @@ -55,7 +74,11 @@ def __init__( ) def _initialize_radio(self, modulation: Type[RadioModulation]) -> None: - """Initialize the specific RFM9x radio hardware.""" + """Initializes the specific RFM9x radio hardware. + + Args: + modulation: The modulation mode to initialize with. + """ if modulation == FSK: self._radio = self._create_fsk_radio( @@ -77,15 +100,25 @@ def _initialize_radio(self, modulation: Type[RadioModulation]) -> None: self._radio.radiohead = False def _send_internal(self, data: bytes) -> bool: - """Send data using the RFM9x radio.""" + """Sends data using the RFM9x radio. + + Args: + data: The data to send. + + Returns: + True if the data was sent successfully, False otherwise. + """ return bool(self._radio.send(data)) def modify_config(self, key: str, value) -> None: - """Modify a specific radio configuration parameter. + """Modifies a specific radio configuration parameter. - :param str key: The configuration parameter key to modify. - :param object value: The new value to set for the parameter. - :raises ValueError: If the key is not recognized or invalid for the current radio type. + Args: + key: The configuration parameter key to modify. + value: The new value to set for the parameter. + + Raises: + ValueError: If the key is not recognized or invalid for the current radio type. """ self._radio_config.validate(key, value) @@ -114,11 +147,19 @@ def modify_config(self, key: str, value) -> None: self._radio.tx_power = value def get_modulation(self) -> Type[RadioModulation]: - """Get the modulation mode from the initialized RFM9x radio.""" + """Gets the modulation mode from the initialized RFM9x radio. + + Returns: + The current modulation mode of the hardware. + """ return FSK if self._radio.__class__.__name__ == "RFM9xFSK" else LoRa def get_temperature(self) -> float: - """Get the temperature reading from the radio sensor.""" + """Gets the temperature reading from the radio sensor. + + Returns: + The temperature in degrees Celsius. + """ try: raw_temp = self._radio.read_u8(0x5B) temp = raw_temp & 0x7F # Mask out sign bit @@ -144,7 +185,18 @@ def _create_fsk_radio( transmit_frequency: int, fsk_config: FSKConfig, ) -> RFM9xFSK: - """Create a FSK radio instance.""" + """Creates a FSK radio instance. + + Args: + spi: The SPI bus connected to the chip. + cs: A DigitalInOut object connected to the chip's CS/chip select line. + rst: A DigitalInOut object connected to the chip's RST/reset line. + transmit_frequency: The transmit frequency. + fsk_config: The FSK configuration. + + Returns: + A configured RFM9xFSK instance. + """ radio: RFM9xFSK = RFM9xFSK( spi, cs, @@ -166,7 +218,18 @@ def _create_lora_radio( transmit_frequency: int, lora_config: LORAConfig, ) -> RFM9x: - """Create a LoRa radio instance.""" + """Creates a LoRa radio instance. + + Args: + spi: The SPI bus connected to the chip. + cs: A DigitalInOut object connected to the chip's CS/chip select line. + rst: A DigitalInOut object connected to the chip's RST/reset line. + transmit_frequency: The transmit frequency. + lora_config: The LoRa configuration. + + Returns: + A configured RFM9x instance. + """ radio: RFM9x = RFM9x( spi, cs, @@ -185,10 +248,13 @@ def _create_lora_radio( return radio def receive(self, timeout: Optional[int] = None) -> bytes | None: - """Receive data from the radio. + """Receives data from the radio. - :param int | None timeout: Optional receive timeout in seconds. If None, use the default timeout. - :return: The received data as bytes, or None if no data was received. + Args: + timeout: Optional receive timeout in seconds. If None, use the default timeout. + + Returns: + The received data as bytes, or None if no data was received. """ _timeout = timeout if timeout is not None else self._receive_timeout self._log.debug(f"Attempting to receive data with timeout: {_timeout}s") @@ -208,10 +274,19 @@ def receive(self, timeout: Optional[int] = None) -> bytes | None: return None def get_max_packet_size(self) -> int: + """Gets the maximum packet size supported by the radio. + + Returns: + The maximum packet size in bytes. + """ return self._radio.max_packet_length def get_rssi(self) -> int: - """Get the RSSI of the last received packet.""" + """Gets the RSSI of the last received packet. + + Returns: + The RSSI of the last received packet. + """ # library reads rssi from an unsigned byte, so we know it's in the range 0-255 # it is safe to cast it to int return int(self._radio.last_rssi) diff --git a/pysquared/hardware/radio/manager/sx126x.py b/pysquared/hardware/radio/manager/sx126x.py index 3f3eb4c0..f4b610e6 100644 --- a/pysquared/hardware/radio/manager/sx126x.py +++ b/pysquared/hardware/radio/manager/sx126x.py @@ -1,3 +1,23 @@ +"""This module provides a manager for SX126x radios. + +This module defines the `SX126xManager` class, which implements the `RadioProto` +interface for SX126x radios. It handles the initialization and configuration of +the radio, as well as sending and receiving data. + +**Usage:** +```python +logger = Logger() +radio_config = RadioConfig() +spi = busio.SPI(board.SCK, MOSI=board.MOSI, MISO=board.MISO) +cs = digitalio.DigitalInOut(board.D5) +irq = digitalio.DigitalInOut(board.D6) +reset = digitalio.DigitalInOut(board.D7) +gpio = digitalio.DigitalInOut(board.D8) +sx126x_manager = SX126xManager(logger, radio_config, spi, cs, irq, reset, gpio) +sx126x_manager.send(b"Hello world!") +``` +""" + import time from busio import SPI @@ -6,7 +26,7 @@ from proves_sx126.sx1262 import SX1262 from ....config.radio import FSKConfig, LORAConfig, RadioConfig -from ....logger import Logger +from ....logger.logger_proto import LoggerProto from ..modulation import FSK, LoRa, RadioModulation from .base import BaseRadioManager @@ -18,13 +38,13 @@ class SX126xManager(BaseRadioManager): - """Manager class implementing RadioProto for SX126x radios.""" + """Manages SX126x radios, implementing the RadioProto interface.""" _radio: SX1262 def __init__( self, - logger: Logger, + logger: LoggerProto, radio_config: RadioConfig, spi: SPI, chip_select: DigitalInOut, @@ -32,18 +52,19 @@ def __init__( reset: DigitalInOut, gpio: DigitalInOut, ) -> None: - """Initialize the manager class and the underlying radio hardware. - - :param Logger logger: Logger instance for logging messages. - :param RadioConfig radio_config: Radio configuration object. - :param Flag use_fsk: Flag to determine whether to use FSK or LoRa mode. - :param busio.SPI spi: The SPI bus connected to the chip. Ensure SCK, MOSI, and MISO are connected. - :param ~digitalio.DigitalInOut chip_select: Chip select pin. - :param ~digitalio.DigitalInOut irq: Interrupt request pin. - :param ~digitalio.DigitalInOut reset: Reset pin. - :param ~digitalio.DigitalInOut gpio: General purpose IO pin (used by SX126x). - - :raises HardwareInitializationError: If the radio fails to initialize after retries. + """Initializes the SX126xManager and the underlying radio hardware. + + Args: + logger: Logger instance for logging messages. + radio_config: Radio configuration object. + spi: The SPI bus connected to the chip. + chip_select: Chip select pin. + irq: Interrupt request pin. + reset: Reset pin. + gpio: General purpose IO pin (used by SX126x). + + Raises: + HardwareInitializationError: If the radio fails to initialize after retries. """ self._spi = spi self._chip_select = chip_select @@ -54,7 +75,11 @@ def __init__( super().__init__(logger=logger, radio_config=radio_config) def _initialize_radio(self, modulation: Type[RadioModulation]) -> None: - """Initialize the specific SX126x radio hardware.""" + """Initializes the specific SX126x radio hardware. + + Args: + modulation: The modulation mode to initialize with. + """ self._radio = SX1262( self._spi, self._chip_select, self._irq, self._reset, self._gpio ) @@ -65,7 +90,14 @@ def _initialize_radio(self, modulation: Type[RadioModulation]) -> None: self._configure_lora(self._radio, self._radio_config.lora) def _send_internal(self, data: bytes) -> bool: - """Send data using the SX126x radio.""" + """Sends data using the SX126x radio. + + Args: + data: The data to send. + + Returns: + True if the data was sent successfully, False otherwise. + """ _, err = self._radio.send(data) if err != ERR_NONE: self._log.warning("SX126x radio send failed", error_code=err) @@ -73,14 +105,24 @@ def _send_internal(self, data: bytes) -> bool: return True def _configure_fsk(self, radio: SX1262, fsk_config: FSKConfig) -> None: - """Configure the radio for FSK mode.""" + """Configures the radio for FSK mode. + + Args: + radio: The SX1262 radio instance. + fsk_config: The FSK configuration. + """ radio.beginFSK( freq=self._radio_config.transmit_frequency, addr=fsk_config.broadcast_address, ) def _configure_lora(self, radio: SX1262, lora_config: LORAConfig) -> None: - """Configure the radio for LoRa mode.""" + """Configures the radio for LoRa mode. + + Args: + radio: The SX1262 radio instance. + lora_config: The LoRa configuration. + """ radio.begin( freq=self._radio_config.transmit_frequency, cr=lora_config.coding_rate, @@ -90,10 +132,13 @@ def _configure_lora(self, radio: SX1262, lora_config: LORAConfig) -> None: ) def receive(self, timeout: Optional[int] = None) -> bytes | None: - """Receive data from the radio. + """Receives data from the radio. - :param int | None timeout: Optional receive timeout in seconds. If None, use the default timeout. - :return: The received data as bytes, or None if no data was received. + Args: + timeout: Optional receive timeout in seconds. If None, use the default timeout. + + Returns: + The received data as bytes, or None if no data was received. """ if timeout is None: timeout = self._receive_timeout @@ -126,5 +171,9 @@ def receive(self, timeout: Optional[int] = None) -> bytes | None: return None def get_modulation(self) -> Type[RadioModulation]: - """Get the modulation mode from the initialized SX126x radio.""" + """Gets the modulation mode from the initialized SX126x radio. + + Returns: + The current modulation mode of the hardware. + """ return FSK if self._radio.radio_modulation == "FSK" else LoRa diff --git a/pysquared/hardware/radio/manager/sx1280.py b/pysquared/hardware/radio/manager/sx1280.py index 60dcaf78..7d0b1dce 100644 --- a/pysquared/hardware/radio/manager/sx1280.py +++ b/pysquared/hardware/radio/manager/sx1280.py @@ -1,10 +1,31 @@ +"""This module provides a manager for SX1280 radios. + +This module defines the `SX1280Manager` class, which implements the `RadioProto` +interface for SX1280 radios. It handles the initialization and configuration of +the radio, as well as sending and receiving data. + +**Usage:** +```python +logger = Logger() +radio_config = RadioConfig() +spi = busio.SPI(board.SCK, MOSI=board.MOSI, MISO=board.MISO) +cs = digitalio.DigitalInOut(board.D5) +reset = digitalio.DigitalInOut(board.D6) +busy = digitalio.DigitalInOut(board.D7) +txen = digitalio.DigitalInOut(board.D8) +rxen = digitalio.DigitalInOut(board.D9) +sx1280_manager = SX1280Manager(logger, radio_config, spi, cs, reset, busy, 2400.0, txen, rxen) +sx1280_manager.send(b"Hello world!") +``` +""" + from busio import SPI from digitalio import DigitalInOut from proves_sx1280.sx1280 import SX1280 from pysquared.config.radio import RadioConfig -from ....logger import Logger +from ....logger.logger_proto import LoggerProto from ..modulation import LoRa, RadioModulation from .base import BaseRadioManager @@ -16,13 +37,13 @@ class SX1280Manager(BaseRadioManager): - """Manager class implementing RadioProto for SX1280 radios.""" + """Manages SX1280 radios, implementing the RadioProto interface.""" _radio: SX1280 def __init__( self, - logger: Logger, + logger: LoggerProto, radio_config: RadioConfig, spi: SPI, chip_select: DigitalInOut, @@ -32,19 +53,18 @@ def __init__( txen: DigitalInOut, rxen: DigitalInOut, ) -> None: - """Initialize the manager class and the underlying radio hardware. - - :param Logger logger: Logger instance for logging messages. - :param RadioConfig radio_config: Radio configuration object. - :param Flag use_fsk: Flag to determine whether to use FSK or LoRa mode. - :param busio.SPI spi: The SPI bus connected to the chip. Ensure SCK, MOSI, and MISO are connected. - :param ~digitalio.DigitalInOut chip_select: Chip select pin. - :param ~digitalio.DigitalInOut busy: Interrupt request pin. - :param ~digitalio.DigitalInOut reset: Reset pin. - :param ~digitalio.DigitalInOut txen: Transmit enable pin. - :param ~digitalio.DigitalInOut rxen: Receive enable pin. - - :raises HardwareInitializationError: If the radio fails to initialize after retries. + """Initializes the SX1280Manager and the underlying radio hardware. + + Args: + logger: Logger instance for logging messages. + radio_config: Radio configuration object. + spi: The SPI bus connected to the chip. + chip_select: Chip select pin. + reset: Reset pin. + busy: Busy pin. + frequency: The frequency to operate on. + txen: Transmit enable pin. + rxen: Receive enable pin. """ self._spi = spi self._chip_select = chip_select @@ -57,7 +77,11 @@ def __init__( super().__init__(logger=logger, radio_config=radio_config) def _initialize_radio(self, modulation: Type[RadioModulation]) -> None: - """Initialize the specific SX1280 radio hardware.""" + """Initializes the specific SX1280 radio hardware. + + Args: + modulation: The modulation mode to initialize with. + """ self._radio = SX1280( self._spi, self._chip_select, @@ -69,18 +93,32 @@ def _initialize_radio(self, modulation: Type[RadioModulation]) -> None: ) def _send_internal(self, data: bytes) -> bool: - """Send data using the SX1280 radio.""" + """Sends data using the SX1280 radio. + + Args: + data: The data to send. + + Returns: + True if the data was sent successfully, False otherwise. + """ return bool(self._radio.send(data)) def get_modulation(self) -> Type[RadioModulation]: - """Get the modulation mode from the initialized SX1280 radio.""" + """Gets the modulation mode from the initialized SX1280 radio. + + Returns: + The current modulation mode of the hardware. + """ return LoRa def receive(self, timeout: Optional[int] = None) -> bytes | None: - """Receive data from the radio. + """Receives data from the radio. + + Args: + timeout: Optional receive timeout in seconds. If None, use the default timeout. - :param int | None timeout: Optional receive timeout in seconds. If None, use the default timeout. - :return: The received data as bytes, or None if no data was received. + Returns: + The received data as bytes, or None if no data was received. """ try: msg = self._radio.receive(keep_listening=True) diff --git a/pysquared/hardware/radio/modulation.py b/pysquared/hardware/radio/modulation.py index 9a352f13..411dc2b0 100644 --- a/pysquared/hardware/radio/modulation.py +++ b/pysquared/hardware/radio/modulation.py @@ -1,16 +1,24 @@ +"""This module defines the available radio modulation types. + +This module provides a set of classes that represent the different radio +modulation types that can be used by the radio hardware. These classes are used to +configure the radio and to identify the current modulation type. +""" + + class RadioModulation: - """Base type for radio modulation modes.""" + """Base class for radio modulation modes.""" pass class FSK(RadioModulation): - """FSK modulation mode.""" + """Represents the FSK modulation mode.""" pass class LoRa(RadioModulation): - """LoRa modulation mode.""" + """Represents the LoRa modulation mode.""" pass diff --git a/pysquared/hardware/radio/packetizer/__init__.py b/pysquared/hardware/radio/packetizer/__init__.py index e69de29b..0ba8abf6 100644 --- a/pysquared/hardware/radio/packetizer/__init__.py +++ b/pysquared/hardware/radio/packetizer/__init__.py @@ -0,0 +1,3 @@ +""" +This package provides an interface for packetizing data for radio communication. +""" diff --git a/pysquared/hardware/radio/packetizer/packet_manager.py b/pysquared/hardware/radio/packetizer/packet_manager.py index b15fc536..35119276 100644 --- a/pysquared/hardware/radio/packetizer/packet_manager.py +++ b/pysquared/hardware/radio/packetizer/packet_manager.py @@ -1,7 +1,23 @@ +"""This module provides a PacketManager for sending and receiving data over a radio. + +This module handles the fragmentation and reassembly of data into packets for +transmission over a radio. It also provides methods for sending and receiving +acknowledgments. + +**Usage:** +```python +logger = Logger() +radio = RFM9xManager(logger, radio_config, spi, cs, reset) +packet_manager = PacketManager(logger, radio, "my_license_key") +packet_manager.send(b"Hello world!") +received_data = packet_manager.listen() +``` +""" + import math import time -from ....logger import Logger +from ....logger.logger_proto import LoggerProto from ....protos.radio import RadioProto try: @@ -10,17 +26,25 @@ pass -# TODO(nateinaction): Add retransmission support. class PacketManager: + """Manages the sending and receiving of data packets over a radio.""" + def __init__( self, - logger: Logger, + logger: LoggerProto, radio: RadioProto, license: str, send_delay: float = 0.2, ) -> None: - """Initialize the packet manager with maximum packet size""" - self._logger: Logger = logger + """Initializes the PacketManager. + + Args: + logger: The logger to use. + radio: The radio instance to use for communication. + license: The license key for sending data. + send_delay: The delay between sending packets. + """ + self._logger: LoggerProto = logger self._radio: RadioProto = radio self._send_delay: float = send_delay self._license: str = license @@ -30,7 +54,14 @@ def __init__( self._payload_size: int = radio.get_max_packet_size() - self._header_size def send(self, data: bytes) -> bool: - """Send data""" + """Sends data over the radio. + + Args: + data: The data to send. + + Returns: + True if the data was sent successfully, False otherwise. + """ if self._license == "": self._logger.warning("License is required to send data") return False @@ -52,12 +83,19 @@ def send(self, data: bytes) -> bool: return True def _pack_data(self, data: bytes) -> list[bytes]: - """ - Takes input data and returns a list of packets ready for transmission + """Packs input data into a list of packets ready for transmission. + Each packet includes: - 2 bytes: sequence number (0-based) - 2 bytes: total number of packets + - 1 byte: RSSI - remaining bytes: payload + + Args: + data: The data to pack. + + Returns: + A list of packets. """ # Calculate number of packets needed total_packets: int = math.ceil(len(data) / self._payload_size) @@ -88,10 +126,13 @@ def _pack_data(self, data: bytes) -> list[bytes]: return packets def listen(self, timeout: Optional[int] = None) -> bytes | None: - """Listen for data from the radio. + """Listens for data from the radio. - :param int | None timeout: Optional receive timeout in seconds. If None, use the default timeout. - :return: The received data as bytes, or None if no data was received. + Args: + timeout: Optional receive timeout in seconds. If None, use the default timeout. + + Returns: + The received data as bytes, or None if no data was received. """ _timeout = timeout if timeout is not None else 10 @@ -140,14 +181,18 @@ def listen(self, timeout: Optional[int] = None) -> bytes | None: return self._unpack_data(received_packets) def send_acknowledgement(self) -> None: - """Send an acknowledgment to the radio.""" + """Sends an acknowledgment to the radio.""" self.send(b"ACK") self._logger.debug("Sent acknowledgment packet") def _unpack_data(self, packets: list[bytes]) -> bytes: - """ - Takes a list of packets and reassembles the original data - Returns None if packets are missing or corrupted + """Unpacks a list of packets and reassembles the original data. + + Args: + packets: A list of packets. + + Returns: + The reassembled data. """ sorted_packets: list = sorted( packets, key=lambda p: int.from_bytes(p[:2], "big") @@ -156,7 +201,14 @@ def _unpack_data(self, packets: list[bytes]) -> bytes: return b"".join(self._get_payload(packet) for packet in sorted_packets) def _get_header(self, packet: bytes) -> tuple[int, int, int]: - """Returns the sequence number and total packets stored in the header.""" + """Returns the sequence number, total packets, and RSSI stored in the header. + + Args: + packet: The packet to extract the header from. + + Returns: + A tuple containing the sequence number, total packets, and RSSI. + """ return ( int.from_bytes(packet[0:2], "big"), # sequence number int.from_bytes(packet[2:4], "big"), # total packets @@ -164,5 +216,12 @@ def _get_header(self, packet: bytes) -> tuple[int, int, int]: ) def _get_payload(self, packet: bytes) -> bytes: - """Returns the payload of the packet.""" + """Returns the payload of the packet. + + Args: + packet: The packet to extract the payload from. + + Returns: + The payload of the packet. + """ return packet[self._header_size :] diff --git a/pysquared/logger/__init__.py b/pysquared/logger/__init__.py new file mode 100644 index 00000000..84fa905a --- /dev/null +++ b/pysquared/logger/__init__.py @@ -0,0 +1 @@ +"""Logger module for PySquared.""" diff --git a/pysquared/logger.py b/pysquared/logger/default_logger.py similarity index 62% rename from pysquared/logger.py rename to pysquared/logger/default_logger.py index 4b617685..268ff149 100644 --- a/pysquared/logger.py +++ b/pysquared/logger/default_logger.py @@ -1,86 +1,19 @@ -""" -logger Module -============= - -Logger class for handling logging messages with different severity levels. -Logs can be output to standard output or saved to a file (functionality to be implemented). -Includes colorized output and error counting. -""" +"""Default logger implementation.""" import json import time import traceback from collections import OrderedDict -from .nvm.counter import Counter - - -def _color(msg, color="gray", fmt="normal") -> str: - """ - Returns a colorized string for terminal output. - - Args: - msg (str): The message to colorize. - color (str): The color name. - fmt (str): The formatting style. - - Returns: - str: The colorized message. - """ - _h = "\033[" - _e = "\033[0;39;49m" - - _c = { - "red": "1", - "green": "2", - "orange": "3", - "blue": "4", - "pink": "5", - "teal": "6", - "white": "7", - "gray": "9", - } - - _f = {"normal": "0", "bold": "1", "ulined": "4"} - return _h + _f[fmt] + ";3" + _c[color] + "m" + msg + _e - - -LogColors = { - "NOTSET": "NOTSET", - "DEBUG": _color(msg="DEBUG", color="blue"), - "INFO": _color(msg="INFO", color="green"), - "WARNING": _color(msg="WARNING", color="orange"), - "ERROR": _color(msg="ERROR", color="pink"), - "CRITICAL": _color(msg="CRITICAL", color="red"), -} - - -class LogLevel: - """ - Defines log level constants for Logger. - """ +from .log_level import LogLevel +from .logger_proto import LoggerProto - NOTSET = 0 - DEBUG = 1 - INFO = 2 - WARNING = 3 - ERROR = 4 - CRITICAL = 5 - -class Logger: - """ - Logger class for handling logging messages with different severity levels. - - Attributes: - _error_counter (Counter): Counter for error occurrences. - _log_level (int): Current log level. - colorized (bool): Whether to colorize output. - """ +class DefaultLogger(LoggerProto): + """Handles logging messages with different severity levels.""" def __init__( self, - error_counter: Counter, log_level: int = LogLevel.NOTSET, colorized: bool = False, ) -> None: @@ -88,13 +21,44 @@ def __init__( Initializes the Logger instance. Args: - error_counter (Counter): Counter for error occurrences. log_level (int): Initial log level. colorized (bool): Whether to colorize output. """ - self._error_counter: Counter = error_counter self._log_level: int = log_level - self.colorized: bool = colorized + self._colorized: bool = colorized + + @staticmethod + def _color(msg, color="gray", fmt="normal") -> str: + """ + Returns a colorized string for terminal output. + + Args: + msg (str): The message to colorize. + color (str): The color name. + fmt (str): The formatting style. + + Returns: + str: The colorized message. + """ + + _c = { + "red": "1", + "green": "2", + "orange": "3", + "blue": "4", + "gray": "9", + } + + return "\033[0;3" + _c[color] + "m" + msg + "\033[0;39;49m" + + log_colors = { + "NOTSET": "NOTSET", + "DEBUG": _color(msg="DEBUG", color="blue"), + "INFO": _color(msg="INFO", color="green"), + "WARNING": _color(msg="WARNING", color="orange"), + "ERROR": _color(msg="ERROR", color="pink"), + "CRITICAL": _color(msg="CRITICAL", color="red"), + } def _can_print_this_level(self, level_value: int) -> bool: """ @@ -109,13 +73,21 @@ def _can_print_this_level(self, level_value: int) -> bool: return level_value >= self._log_level def _is_valid_json_type(self, object) -> bool: + """Checks if the object is a valid JSON type. + + Args: + object: The object to check. + + Returns: + True if the object is a valid JSON type, False otherwise. + """ valid_types = {dict, list, tuple, str, int, float, bool, None} return type(object) in valid_types def _log(self, level: str, level_value: int, message: str, **kwargs) -> None: """ - Log a message with a given severity level and any addional key/values. + Log a message with a given severity level and any additional key/values. Args: level (str): The severity level as a string. @@ -159,13 +131,13 @@ def _log(self, level: str, level_value: int, message: str, **kwargs) -> None: ) if self._can_print_this_level(level_value): - if self.colorized: + if self._colorized: json_output = json_output.replace( - f'"level": "{level}"', f'"level": "{LogColors[level]}"' + f'"level": "{level}"', f'"level": "{self.log_colors[level]}"' ) print(json_output) - def debug(self, message: str, **kwargs) -> None: + def debug(self, message: str, **kwargs: object) -> None: """ Log a message with severity level DEBUG. @@ -175,7 +147,7 @@ def debug(self, message: str, **kwargs) -> None: """ self._log("DEBUG", 1, message, **kwargs) - def info(self, message: str, **kwargs) -> None: + def info(self, message: str, **kwargs: object) -> None: """ Log a message with severity level INFO. @@ -185,7 +157,7 @@ def info(self, message: str, **kwargs) -> None: """ self._log("INFO", 2, message, **kwargs) - def warning(self, message: str, **kwargs) -> None: + def warning(self, message: str, **kwargs: object) -> None: """ Log a message with severity level WARNING. @@ -195,7 +167,7 @@ def warning(self, message: str, **kwargs) -> None: """ self._log("WARNING", 3, message, **kwargs) - def error(self, message: str, err: Exception, **kwargs) -> None: + def error(self, message: str, err: Exception, **kwargs: object) -> None: """ Log a message with severity level ERROR. @@ -205,10 +177,9 @@ def error(self, message: str, err: Exception, **kwargs) -> None: **kwargs: Additional key/value pairs to include in the log. """ kwargs["err"] = traceback.format_exception(err) - self._error_counter.increment() self._log("ERROR", 4, message, **kwargs) - def critical(self, message: str, err: Exception, **kwargs) -> None: + def critical(self, message: str, err: Exception, **kwargs: object) -> None: """ Log a message with severity level CRITICAL. @@ -218,14 +189,4 @@ def critical(self, message: str, err: Exception, **kwargs) -> None: **kwargs: Additional key/value pairs to include in the log. """ kwargs["err"] = traceback.format_exception(err) - self._error_counter.increment() self._log("CRITICAL", 5, message, **kwargs) - - def get_error_count(self) -> int: - """ - Returns the current error count. - - Returns: - int: The number of errors logged. - """ - return self._error_counter.get() diff --git a/pysquared/logger/log_level.py b/pysquared/logger/log_level.py new file mode 100644 index 00000000..74229569 --- /dev/null +++ b/pysquared/logger/log_level.py @@ -0,0 +1,14 @@ +"""Log level constants for Logger.""" + + +class LogLevel: + """ + Defines log level constants for Logger. + """ + + NOTSET = 0 + DEBUG = 1 + INFO = 2 + WARNING = 3 + ERROR = 4 + CRITICAL = 5 diff --git a/pysquared/logger/logger_proto.py b/pysquared/logger/logger_proto.py new file mode 100644 index 00000000..ac14e946 --- /dev/null +++ b/pysquared/logger/logger_proto.py @@ -0,0 +1,25 @@ +"""Protocol to define the logger interface.""" + + +class LoggerProto: + """Protocol defining the interface for a logger.""" + + def debug(self, message: str, **kwargs) -> None: + """Logs a message with DEBUG level.""" + ... + + def info(self, message: str, **kwargs) -> None: + """Logs a message with INFO level.""" + ... + + def warning(self, message: str, **kwargs) -> None: + """Logs a message with WARNING level.""" + ... + + def error(self, message: str, err: Exception, **kwargs) -> None: + """Logs a message with ERROR level, including an exception.""" + ... + + def critical(self, message: str, err: Exception, **kwargs: object) -> None: + """Logs a message with CRITICAL level, including an exception.""" + ... diff --git a/pysquared/logger/middleware/error_counter/error_counter.py b/pysquared/logger/middleware/error_counter/error_counter.py new file mode 100644 index 00000000..e02f9715 --- /dev/null +++ b/pysquared/logger/middleware/error_counter/error_counter.py @@ -0,0 +1,116 @@ +""" +Error Counter Middleware for Logger + +This module provides a middleware for the Logger records the number of errors logged to a non-volatile memory (NVM) counter. + +Usage: +```python +from .nvm.counter import Counter +from .logger.default_logger import DefaultLogger +from .logger.middleware.error_counter.error_counter import add_logger_middleware_error_counter +c = Counter(0) +l = DefaultLogger() +l = add_logger_middleware_error_counter(c)(l) +l.critical("Test critical error", err=Exception("Test exception")) +``` +""" + +try: + from typing import Callable +except ImportError: + pass + +from ....nvm.counter import Counter +from ...logger_proto import LoggerProto + + +def add_logger_middleware_error_counter( + counter: Counter, +) -> Callable[[LoggerProto], LoggerProto]: + """Adds error counting middleware to a logger. + + Args: + counter (Counter): Counter for error occurrences. + Returns: + Callable[[LoggerProto], LoggerProto]: A function that takes a logger and returns a new logger with error counting middleware. + """ + return lambda next_logger: ErrorCountLogger(counter, next_logger) + + +class ErrorCountLogger(LoggerProto): + """ + Logger that counts the number of errors logged. + """ + + def __init__(self, counter: Counter, next_logger: LoggerProto) -> None: + """ + Initializes the ErrorCountLogger instance. + + Args: + error_counter (Counter): Counter for error occurrences. + """ + self._counter: Counter = counter + self._next_logger: LoggerProto = next_logger + + def debug(self, message: str, **kwargs: object) -> None: + """ + Log a message with severity level DEBUG. + + Args: + message (str): The log message. + **kwargs: Additional key/value pairs to include in the log. + """ + self._next_logger.debug(message, **kwargs) + + def info(self, message: str, **kwargs: object) -> None: + """ + Log a message with severity level INFO. + + Args: + message (str): The log message. + **kwargs: Additional key/value pairs to include in the log. + """ + self._next_logger.info(message, **kwargs) + + def warning(self, message: str, **kwargs: object) -> None: + """ + Log a message with severity level WARNING. + + Args: + message (str): The log message. + **kwargs: Additional key/value pairs to include in the log. + """ + self._next_logger.warning(message, **kwargs) + + def error(self, message: str, err: Exception, **kwargs: object) -> None: + """ + Log a message with severity level ERROR. + + Args: + message (str): The log message. + err (Exception): The exception to log. + **kwargs: Additional key/value pairs to include in the log. + """ + self._counter.increment() + self._next_logger.error(message, err, **kwargs) + + def critical(self, message: str, err: Exception, **kwargs: object) -> None: + """ + Log a message with severity level CRITICAL. + + Args: + message (str): The log message. + err (Exception): The exception to log. + **kwargs: Additional key/value pairs to include in the log. + """ + self._counter.increment() + self._next_logger.critical(message, err, **kwargs) + + def get_error_count(self) -> int: + """ + Returns the current error count. + + Returns: + int: The number of errors logged. + """ + return self._counter.get() diff --git a/pysquared/nvm/counter.py b/pysquared/nvm/counter.py index 34b9d2bd..8d4eddf7 100644 --- a/pysquared/nvm/counter.py +++ b/pysquared/nvm/counter.py @@ -1,10 +1,5 @@ -""" -counter Module -============== - -This module provides the Counter class for managing 8-bit counters stored in +"""This module provides the Counter class for managing 8-bit counters stored in non-volatile memory (NVM) on CircuitPython devices. - """ import microcontroller diff --git a/pysquared/nvm/flag.py b/pysquared/nvm/flag.py index 4a85c34f..adb61ee6 100755 --- a/pysquared/nvm/flag.py +++ b/pysquared/nvm/flag.py @@ -1,10 +1,5 @@ -""" -flag Module -=========== - -This module provides the Flag class for managing boolean flags stored in +"""This module provides the Flag class for managing boolean flags stored in non-volatile memory (NVM) on CircuitPython devices. - """ import microcontroller diff --git a/pysquared/power_health.py b/pysquared/power_health.py index 0a5f53d0..70c357aa 100644 --- a/pysquared/power_health.py +++ b/pysquared/power_health.py @@ -1,6 +1,22 @@ -from pysquared.config.config import Config -from pysquared.logger import Logger -from pysquared.protos.power_monitor import PowerMonitorProto +"""This module provides a PowerHealth class for monitoring the power system. + +The PowerHealth class checks the battery voltage and current draw to determine the +overall health of the power system. It returns one of four states: NOMINAL, +DEGRADED, CRITICAL, or UNKNOWN. + +**Usage:** +```python +logger = Logger() +config = Config("config.json") +power_monitor = INA219Manager(logger, i2c) +power_health = PowerHealth(logger, config, power_monitor) +health_status = power_health.get() +``` +""" + +from .config.config import Config +from .logger.logger_proto import LoggerProto +from .protos.power_monitor import PowerMonitorProto try: from typing import Callable, List @@ -10,39 +26,60 @@ class State: + """Base class for power health states.""" + pass class NOMINAL(State): + """Represents a nominal power health state.""" + pass class DEGRADED(State): + """Represents a degraded power health state.""" + pass class CRITICAL(State): + """Represents a critical power health state.""" + pass class UNKNOWN(State): + """Represents an unknown power health state.""" + pass class PowerHealth: + """Monitors the power system and determines its health.""" + def __init__( self, - logger: Logger, + logger: LoggerProto, config: Config, power_monitor: PowerMonitorProto, ) -> None: - self.logger: Logger = logger + """Initializes the PowerHealth monitor. + + Args: + logger: The logger to use. + config: The configuration to use. + power_monitor: The power monitor to use. + """ + self.logger: LoggerProto = logger self.config: Config = config self._power_monitor: PowerMonitorProto = power_monitor def get(self) -> NOMINAL | DEGRADED | CRITICAL | UNKNOWN: - """ - Get the power health + """Gets the current power health. + + Returns: + The current power health state. """ errors: List[str] = [] self.logger.debug("Power monitor: ", sensor=self._power_monitor) @@ -99,12 +136,14 @@ def get(self) -> NOMINAL | DEGRADED | CRITICAL | UNKNOWN: def _avg_reading( self, func: Callable[..., float | None], num_readings: int = 50 ) -> float | None: - """ - Get average reading from a sensor + """Gets the average reading from a sensor. + + Args: + func: The function to call to get a reading. + num_readings: The number of readings to take. - :param func: function to call - :param num_readings: number of readings to take - :return: average of the readings + Returns: + The average of the readings, or None if a reading could not be taken. """ readings: float = 0.0 for _ in range(num_readings): diff --git a/pysquared/protos/burnwire.py b/pysquared/protos/burnwire.py index 119a16f1..1014691f 100644 --- a/pysquared/protos/burnwire.py +++ b/pysquared/protos/burnwire.py @@ -1,17 +1,21 @@ -""" -Protocol defining the interface for a burnwire port. +"""This protocol specifies the interface that any burnwire implementation must adhere +to, ensuring consistent behavior across different burnwire hardware. """ class BurnwireProto: + """Protocol defining the interface for a burnwire port.""" + def burn(self, timeout_duration: float) -> bool: - """Fires the burnwire for a specified amount of time + """Fires the burnwire for a specified amount of time. - :param float timeout_duration: The max amount of time to keep the burnwire on for. + Args: + timeout_duration: The maximum amount of time to keep the burnwire on. - :return: A Boolean indicating whether the burn occurred successfully - :rtype: bool + Returns: + True if the burn occurred successfully, False otherwise. - :raises Exception: If there is an error toggling the burnwire pins. + Raises: + Exception: If there is an error toggling the burnwire pins. """ ... diff --git a/pysquared/protos/imu.py b/pysquared/protos/imu.py index 7af75743..777ae90a 100644 --- a/pysquared/protos/imu.py +++ b/pysquared/protos/imu.py @@ -1,25 +1,31 @@ -""" -Protocol defining the interface for an Inertial Measurement Unit (IMU). +"""This protocol specifies the interface that any IMU implementation must adhere to, +ensuring consistent behavior across different IMU hardware. """ class IMUProto: + """Protocol defining the interface for an Inertial Measurement Unit (IMU).""" + def get_gyro_data(self) -> tuple[float, float, float] | None: - """Get the gyroscope data from the inertial measurement unit. + """Gets the gyroscope data from the inertial measurement unit. - :return: A tuple containing the x, y, and z angular acceleration values in radians per second or None if not available. - :rtype: tuple[float, float, float] | None + Returns: + A tuple containing the x, y, and z angular acceleration values in + radians per second, or None if not available. - :raises Exception: If there is an error retrieving the values. + Raises: + Exception: If there is an error retrieving the values. """ ... def get_acceleration(self) -> tuple[float, float, float] | None: - """Get the acceleration data from the inertial measurement unit. + """Gets the acceleration data from the inertial measurement unit. - :return: A tuple containing the x, y, and z acceleration values in m/s^2 or None if not available. - :rtype: tuple[float, float, float] | None + Returns: + A tuple containing the x, y, and z acceleration values in m/s^2, or + None if not available. - :raises Exception: If there is an error retrieving the values. + Raises: + Exception: If there is an error retrieving the values. """ ... diff --git a/pysquared/protos/magnetometer.py b/pysquared/protos/magnetometer.py index 01dfbc50..d0fca3d4 100644 --- a/pysquared/protos/magnetometer.py +++ b/pysquared/protos/magnetometer.py @@ -1,15 +1,19 @@ -""" -Protocol defining the interface for a Magnetometer. +"""This protocol specifies the interface that any magnetometer implementation must +adhere to, ensuring consistent behavior across different magnetometer hardware. """ class MagnetometerProto: + """Protocol defining the interface for a Magnetometer.""" + def get_vector(self) -> tuple[float, float, float] | None: - """Get the magnetic field vector from the magnetometer. + """Gets the magnetic field vector from the magnetometer. - :return: A tuple containing the x, y, and z magnetic field values in Gauss or None if not available. - :rtype: tuple[float, float, float] | None + Returns: + A tuple containing the x, y, and z magnetic field values in Gauss, or + None if not available. - :raises Exception: If there is an error retrieving the values. + Raises: + Exception: If there is an error retrieving the values. """ ... diff --git a/pysquared/protos/power_monitor.py b/pysquared/protos/power_monitor.py index 13958a6d..b2a1e7a3 100644 --- a/pysquared/protos/power_monitor.py +++ b/pysquared/protos/power_monitor.py @@ -1,32 +1,31 @@ -""" -Protocol defining the interface for a Power Monitor. +"""This protocol specifies the interface that any power monitor implementation must +adhere to, ensuring consistent behavior across different power monitor hardware. """ class PowerMonitorProto: - def get_bus_voltage(self) -> float | None: - """Get the bus voltage from the power monitor. + """Protocol defining the interface for a Power Monitor.""" - :return: The bus voltage in volts - :rtype: float | None + def get_bus_voltage(self) -> float | None: + """Gets the bus voltage from the power monitor. + Returns: + The bus voltage in volts, or None if not available. """ ... def get_shunt_voltage(self) -> float | None: - """Get the shunt voltage from the power monitor. - - :return: The shunt voltage in volts - :rtype: float | None + """Gets the shunt voltage from the power monitor. + Returns: + The shunt voltage in volts, or None if not available. """ ... def get_current(self) -> float | None: - """Get the current from the power monitor. - - :return: The current in amps - :rtype: float | None + """Gets the current from the power monitor. + Returns: + The current in amps, or None if not available. """ ... diff --git a/pysquared/protos/radio.py b/pysquared/protos/radio.py index 1c776551..c88ad78d 100644 --- a/pysquared/protos/radio.py +++ b/pysquared/protos/radio.py @@ -1,5 +1,5 @@ -""" -Protocol defining the interface for a radio. +"""This protocol specifies the interface that any radio implementation must adhere +to, ensuring consistent behavior across different radio hardware. """ from ..hardware.radio.modulation import RadioModulation @@ -12,65 +12,78 @@ class RadioProto: + """Protocol defining the interface for a radio.""" + def send(self, data: bytes) -> bool: - """Send data over the radio. + """Sends data over the radio. + + Args: + data: The data to send. - :param bytes data: The data to send. - :return: True if the send was successful. - :rtype: bool + Returns: + True if the send was successful, False otherwise. """ ... def set_modulation(self, modulation: Type[RadioModulation]) -> None: - """Request a change in the radio modulation mode. - This change might take effect immediately or after a reset, depending on implementation. + """Requests a change in the radio modulation mode. - :param RadioModulation modulation: The desired modulation mode. + This change might take effect immediately or after a reset, depending on + implementation. + + Args: + modulation: The desired modulation mode. """ ... def get_modulation(self) -> Type[RadioModulation]: - """Get the currently configured or active radio modulation mode. + """Gets the currently configured or active radio modulation mode. - :return: The current modulation mode. - :rtype: RadioModulation + Returns: + The current modulation mode. """ ... def receive(self, timeout: Optional[int] = None) -> Optional[bytes]: - """Receive data from the radio. + """Receives data from the radio. + + Args: + timeout: Optional receive timeout in seconds. If None, use the default timeout. - :param int | None timeout: Optional receive timeout in seconds. If None, use the default timeout. - :return: The received data as bytes, or None if no data was received. - :rtype: Optional[bytes] + Returns: + The received data as bytes, or None if no data was received. """ ... def modify_config(self, key: str, value) -> None: - """Modify a specific radio configuration parameter. + """Modifies a specific radio configuration parameter. + + Args: + key (str): The configuration parameter key to modify. + value: The new value to set for the parameter. - :param str key: The configuration parameter key to modify. - :param value: The new value to set for the parameter. - :raises NotImplementedError: If not implemented by subclass. + Raises: + NotImplementedError: If not implemented by subclass. """ ... def get_rssi(self) -> int: - """Get the RSSI of the last received packet. + """Gets the RSSI of the last received packet. - :return: The RSSI value in dBm. - :rtype: int + Returns: + The RSSI value in dBm. - :raises NotImplementedError: If not implemented by subclass. + Raises: + NotImplementedError: If not implemented by subclass. """ ... def get_max_packet_size(self) -> int: - """Get the maximum packet size supported by the radio. + """Gets the maximum packet size supported by the radio. - :return: The maximum packet size in bytes. - :rtype: int + Returns: + The maximum packet size in bytes. """ ... diff --git a/pysquared/protos/rtc.py b/pysquared/protos/rtc.py index 8856edfb..698d9788 100644 --- a/pysquared/protos/rtc.py +++ b/pysquared/protos/rtc.py @@ -1,9 +1,11 @@ -""" -Protocol defining the interface for a Real Time Clock (RTC). +"""This protocol specifies the interface that any Real-Time Clock (RTC) implementation +must adhere to, ensuring consistent behavior across different RTC hardware. """ class RTCProto: + """Protocol defining the interface for a Real Time Clock (RTC).""" + def set_time( self, year: int, @@ -14,16 +16,18 @@ def set_time( second: int, weekday: int, ) -> None: - """Set the time on the real time clock. + """Sets the time on the real-time clock. - :param year: The year value (0-9999) - :param month: The month value (1-12) - :param date: The date value (1-31) - :param hour: The hour value (0-23) - :param minute: The minute value (0-59) - :param second: The second value (0-59) - :param weekday: The nth day of the week (0-6), where 0 represents Sunday and 6 represents Saturday + Args: + year: The year value (0-9999). + month: The month value (1-12). + date: The date value (1-31). + hour: The hour value (0-23). + minute: The minute value (0-59). + second: The second value (0-59). + weekday: The nth day of the week (0-6), where 0 represents Sunday and 6 represents Saturday. - :raises Exception: If there is an error setting the values. + Raises: + Exception: If there is an error setting the values. """ ... diff --git a/pysquared/protos/temperature_sensor.py b/pysquared/protos/temperature_sensor.py index 0a22b162..82902268 100644 --- a/pysquared/protos/temperature_sensor.py +++ b/pysquared/protos/temperature_sensor.py @@ -1,13 +1,16 @@ -""" -Protocol defining the interface for a temperature sensor. +"""This protocol specifies the interface that any temperature sensor implementation +must adhere to, ensuring consistent behavior across different temperature sensor +hardware. """ class TemperatureSensorProto: + """Protocol defining the interface for a temperature sensor.""" + def get_temperature(self) -> float | None: - """Get the temperature reading of the sensor. + """Gets the temperature reading of the sensor. - :return: The temperature in degrees Celsius or None if not available. - :rtype: float | None + Returns: + The temperature in degrees Celsius, or None if not available. """ ... diff --git a/pysquared/rtc/__init__.py b/pysquared/rtc/__init__.py index e69de29b..313dcdd1 100644 --- a/pysquared/rtc/__init__.py +++ b/pysquared/rtc/__init__.py @@ -0,0 +1,3 @@ +""" +This module provides Real-Time Clock (RTC) management functionality for the PySquared satellite. +""" diff --git a/pysquared/rtc/manager/__init__.py b/pysquared/rtc/manager/__init__.py index e69de29b..a11eeeb9 100644 --- a/pysquared/rtc/manager/__init__.py +++ b/pysquared/rtc/manager/__init__.py @@ -0,0 +1,3 @@ +""" +This module provides the managers for various Real-Time Clock (RTC) implementations +""" diff --git a/pysquared/rtc/manager/microcontroller.py b/pysquared/rtc/manager/microcontroller.py index d07be887..0c5dfcef 100644 --- a/pysquared/rtc/manager/microcontroller.py +++ b/pysquared/rtc/manager/microcontroller.py @@ -1,3 +1,16 @@ +"""This module provides a manager for the Microcontroller's Real-Time Clock (RTC). + +This module defines the `MicrocontrollerManager` class, which provides an interface +for interacting with the microcontroller's built-in RTC. It allows for setting +the current time. + +**Usage:** +```python +rtc_manager = MicrocontrollerManager() +rtc_manager.set_time(2024, 7, 8, 10, 30, 0, 1) # Set to July 8, 2024, 10:30:00 AM, Monday +``` +""" + import time from ...protos.rtc import RTCProto @@ -9,17 +22,12 @@ class MicrocontrollerManager(RTCProto): - """ - Class for interfacing with the Microcontroller's Real Time Clock (RTC) via CircuitPython. - - rtc.RTC is a singleton and does not need to be stored as a class variable. - """ + """Manages the Microcontroller's Real Time Clock (RTC).""" def __init__(self) -> None: - """ - Initialize the RTC + """Initializes the RTC. - Required on every boot to ensure the RTC is ready for use + This method is required on every boot to ensure the RTC is ready for use. """ microcontroller_rtc = rtc.RTC() microcontroller_rtc.datetime = time.localtime() @@ -34,16 +42,16 @@ def set_time( second: int, weekday: int, ) -> None: - """ - Updates the Microcontroller's Real Time Clock (RTC) to the date and time passed - - :param year: The year value (0-9999) - :param month: The month value (1-12) - :param date: The date value (1-31) - :param hour: The hour value (0-23) - :param minute: The minute value (0-59) - :param second: The second value (0-59) - :param day_of_week: The nth day of the week (0-6), where 0 represents Sunday and 6 represents Saturday + """Updates the Microcontroller's Real Time Clock (RTC). + + Args: + year: The year value (0-9999). + month: The month value (1-12). + date: The date value (1-31). + hour: The hour value (0-23). + minute: The minute value (0-59). + second: The second value (0-59). + weekday: The nth day of the week (0-6), where 0 represents Sunday and 6 represents Saturday. """ microcontroller_rtc = rtc.RTC() microcontroller_rtc.datetime = time.struct_time( diff --git a/pysquared/rtc/manager/rv3028.py b/pysquared/rtc/manager/rv3028.py index 239f7a1c..93b183f7 100644 --- a/pysquared/rtc/manager/rv3028.py +++ b/pysquared/rtc/manager/rv3028.py @@ -1,32 +1,44 @@ +"""This module provides a manager for the RV3028 Real-Time Clock (RTC). + +This module defines the `RV3028Manager` class, which provides a high-level interface +for interacting with the RV3028 RTC. It handles the initialization of the sensor +and provides methods for setting the current time. + +**Usage:** +```python +logger = Logger() +i2c = busio.I2C(board.SCL, board.SDA) +rtc_manager = RV3028Manager(logger, i2c) +rtc_manager.set_time(2024, 7, 8, 10, 30, 0, 1) # Set to July 8, 2024, 10:30:00 AM, Monday +``` +""" + from busio import I2C from rv3028.rv3028 import RV3028 -from ...hardware.decorators import with_retries from ...hardware.exception import HardwareInitializationError -from ...logger import Logger +from ...logger.logger_proto import LoggerProto from ...protos.rtc import RTCProto class RV3028Manager(RTCProto): - """Manager class for creating RV3028 RTC instances. - The purpose of the manager class is to hide the complexity of RTC initialization from the caller. - Specifically we should try to keep adafruit_lis2mdl to only this manager class. - """ + """Manages the RV3028 RTC.""" - @with_retries(max_attempts=3, initial_delay=1) def __init__( self, - logger: Logger, + logger: LoggerProto, i2c: I2C, ) -> None: - """Initialize the manager class. + """Initializes the RV3028Manager. - :param Logger logger: Logger instance for logging messages. - :param busio.I2C i2c: The I2C bus connected to the chip. + Args: + logger: The logger to use. + i2c: The I2C bus connected to the chip. - :raises HardwareInitializationError: If the RTC fails to initialize. + Raises: + HardwareInitializationError: If the RTC fails to initialize. """ - self._log: Logger = logger + self._log: LoggerProto = logger try: self._log.debug("Initializing RTC") @@ -46,17 +58,19 @@ def set_time( second: int, weekday: int, ) -> None: - """Set the time on the real time clock. + """Sets the time on the real-time clock. - :param year: The year value (0-9999) - :param month: The month value (1-12) - :param date: The date value (1-31) - :param hour: The hour value (0-23) - :param minute: The minute value (0-59) - :param second: The second value (0-59) - :param weekday: The nth day of the week (0-6), where 0 represents Sunday and 6 represents Saturday + Args: + year: The year value (0-9999). + month: The month value (1-12). + date: The date value (1-31). + hour: The hour value (0-23). + minute: The minute value (0-59). + second: The second value (0-59). + weekday: The nth day of the week (0-6), where 0 represents Sunday and 6 represents Saturday. - :raises Exception: If there is an error setting the values. + Raises: + Exception: If there is an error setting the values. """ try: self._rtc.set_date(year, month, date, weekday) diff --git a/pysquared/sleep_helper.py b/pysquared/sleep_helper.py index 800d2ca3..a1cf96d4 100644 --- a/pysquared/sleep_helper.py +++ b/pysquared/sleep_helper.py @@ -1,8 +1,4 @@ -""" -sleep_helper Module -================== - -This module provides the SleepHelper class for managing safe sleep and hibernation +"""This module provides the SleepHelper class for managing safe sleep and hibernation modes for the PySquared satellite. It ensures the satellite sleeps for specified durations while maintaining system safety and watchdog activity. @@ -14,7 +10,7 @@ from alarm.time import TimeAlarm from .config.config import Config -from .logger import Logger +from .logger.logger_proto import LoggerProto from .watchdog import Watchdog @@ -29,17 +25,16 @@ class SleepHelper: config (Config): Configuration object. """ - def __init__(self, logger: Logger, config: Config, watchdog: Watchdog) -> None: + def __init__(self, logger: LoggerProto, config: Config, watchdog: Watchdog) -> None: """ Creates a SleepHelper object. Args: - cubesat (Satellite): The Satellite object. logger (Logger): Logger instance for logging events and errors. watchdog (Watchdog): Watchdog instance for system safety. config (Config): Configuration object. """ - self.logger: Logger = logger + self.logger: LoggerProto = logger self.config: Config = config self.watchdog: Watchdog = watchdog diff --git a/pysquared/watchdog.py b/pysquared/watchdog.py index 1d94bdb5..d3da055a 100644 --- a/pysquared/watchdog.py +++ b/pysquared/watchdog.py @@ -1,11 +1,6 @@ -""" -watchdog Module -=============== - -This module provides the Watchdog class for managing the hardware watchdog timer +"""This module provides the Watchdog class for managing the hardware watchdog timer on the PySquared satellite. The watchdog helps ensure system reliability by requiring periodic "petting" to prevent system resets. - """ import time @@ -14,7 +9,7 @@ from microcontroller import Pin from .hardware.digitalio import initialize_pin -from .logger import Logger +from .logger.logger_proto import LoggerProto class Watchdog: @@ -26,7 +21,7 @@ class Watchdog: _digital_in_out (DigitalInOut): Digital output for controlling the watchdog pin. """ - def __init__(self, logger: Logger, pin: Pin) -> None: + def __init__(self, logger: LoggerProto, pin: Pin) -> None: """ Initializes the Watchdog timer. diff --git a/tests/unit/files/config.test.json b/tests/unit/files/config.test.json index ea3405bc..925f609e 100644 --- a/tests/unit/files/config.test.json +++ b/tests/unit/files/config.test.json @@ -33,7 +33,7 @@ "What are computers favorite snacks? Chips!", "Wait! I think I see a White 2019 Subaru Crosstrek 2.0i Premium", "IS THAT A SUPRA?!", - "Finally escpaed the LA Traffic", + "Finally escaped the LA Traffic", "My CubeSat is really good at jokes, but its delivery is always delayed.", "exec order 66", "I had a joke about UDP, but I am not sure if you'd get it.", diff --git a/tests/unit/hardware/imu/manager/test_lsm6dsox_manager.py b/tests/unit/hardware/imu/manager/test_lsm6dsox_manager.py index d80d8332..ec346812 100644 --- a/tests/unit/hardware/imu/manager/test_lsm6dsox_manager.py +++ b/tests/unit/hardware/imu/manager/test_lsm6dsox_manager.py @@ -1,3 +1,10 @@ +"""Unit tests for the LSM6DSOXManager class. + +This module contains unit tests for the `LSM6DSOXManager` class, which manages +the LSM6DSOX IMU. The tests cover initialization, successful data retrieval, +and error handling for acceleration, gyroscope, and temperature readings. +""" + import math from typing import Generator from unittest.mock import MagicMock, PropertyMock, patch @@ -27,35 +34,56 @@ def mock_logger() -> MagicMock: @pytest.fixture def mock_lsm6dsox(mock_i2c: MagicMock) -> Generator[MagicMock, None, None]: + """Mocks the LSM6DSOX class. + + Args: + mock_i2c: Mocked I2C bus. + + Yields: + A MagicMock instance of LSM6DSOX. + """ with patch("pysquared.hardware.imu.manager.lsm6dsox.LSM6DSOX") as mock_class: mock_class.return_value = LSM6DSOX(mock_i2c, address) yield mock_class def test_create_imu( - mock_lsm6dsox: MagicMock, mock_i2c: MagicMock, mock_logger: MagicMock + mock_lsm6dsox: MagicMock, + mock_i2c: MagicMock, + mock_logger: MagicMock, ) -> None: - """Test successful creation of an LSM6DSOX IMU instance.""" + """Tests successful creation of an LSM6DSOX IMU instance. + + Args: + mock_lsm6dsox: Mocked LSM6DSOX class. + mock_i2c: Mocked I2C bus. + mock_logger: Mocked Logger instance. + """ imu_manager = LSM6DSOXManager(mock_logger, mock_i2c, address) assert isinstance(imu_manager._imu, LSM6DSOX) mock_logger.debug.assert_called_once_with("Initializing IMU") -@pytest.mark.slow -def test_create_with_retries( +def test_create_imu_failed( mock_lsm6dsox: MagicMock, mock_i2c: MagicMock, mock_logger: MagicMock, ) -> None: - """Test that initialization is retried when it fails.""" + """Tests that initialization is retried when it fails. + + Args: + mock_lsm6dsox: Mocked LSM6DSOX class. + mock_i2c: Mocked I2C bus. + mock_logger: Mocked Logger instance. + """ mock_lsm6dsox.side_effect = Exception("Simulated LSM6DSOX failure") with pytest.raises(HardwareInitializationError): _ = LSM6DSOXManager(mock_logger, mock_i2c, address) mock_logger.debug.assert_called_with("Initializing IMU") - assert mock_lsm6dsox.call_count <= 3 + mock_lsm6dsox.assert_called_once() def test_get_acceleration_success( @@ -63,7 +91,13 @@ def test_get_acceleration_success( mock_i2c: MagicMock, mock_logger: MagicMock, ) -> None: - """Test successful retrieval of the acceleration vector.""" + """Tests successful retrieval of the acceleration vector. + + Args: + mock_lsm6dsox: Mocked LSM6DSOX class. + mock_i2c: Mocked I2C bus. + mock_logger: Mocked Logger instance. + """ imu_manager = LSM6DSOXManager(mock_logger, mock_i2c, address) # Replace the automatically created mock instance with a MagicMock we can configure imu_manager._imu = MagicMock(spec=LSM6DSOX) @@ -79,7 +113,13 @@ def test_get_acceleration_failure( mock_i2c: MagicMock, mock_logger: MagicMock, ) -> None: - """Test handling of exceptions when retrieving the acceleration vector.""" + """Tests handling of exceptions when retrieving the acceleration vector. + + Args: + mock_lsm6dsox: Mocked LSM6DSOX class. + mock_i2c: Mocked I2C bus. + mock_logger: Mocked Logger instance. + """ imu_manager = LSM6DSOXManager(mock_logger, mock_i2c, address) mock_imu_instance = MagicMock(spec=LSM6DSOX) imu_manager._imu = mock_imu_instance @@ -105,7 +145,13 @@ def test_get_gyro_success( mock_i2c: MagicMock, mock_logger: MagicMock, ) -> None: - """Test successful retrieval of the gyro vector.""" + """Tests successful retrieval of the gyro vector. + + Args: + mock_lsm6dsox: Mocked LSM6DSOX class. + mock_i2c: Mocked I2C bus. + mock_logger: Mocked Logger instance. + """ imu_manager = LSM6DSOXManager(mock_logger, mock_i2c, address) imu_manager._imu = MagicMock(spec=LSM6DSOX) expected_gyro = (0.1, 0.2, 0.3) @@ -120,7 +166,13 @@ def test_get_gyro_failure( mock_i2c: MagicMock, mock_logger: MagicMock, ) -> None: - """Test handling of exceptions when retrieving the gyro vector.""" + """Tests handling of exceptions when retrieving the gyro vector. + + Args: + mock_lsm6dsox: Mocked LSM6DSOX class. + mock_i2c: Mocked I2C bus. + mock_logger: Mocked Logger instance. + """ imu_manager = LSM6DSOXManager(mock_logger, mock_i2c, address) mock_imu_instance = MagicMock(spec=LSM6DSOX) imu_manager._imu = mock_imu_instance @@ -145,7 +197,13 @@ def test_get_temperature_success( mock_i2c: MagicMock, mock_logger: MagicMock, ) -> None: - """Test successful retrieval of the temperature.""" + """Tests successful retrieval of the temperature. + + Args: + mock_lsm6dsox: Mocked LSM6DSOX class. + mock_i2c: Mocked I2C bus. + mock_logger: Mocked Logger instance. + """ imu_manager = LSM6DSOXManager(mock_logger, mock_i2c, address) imu_manager._imu = MagicMock(spec=LSM6DSOX) expected_temp = 25.5 @@ -157,9 +215,17 @@ def test_get_temperature_success( def test_get_temperature_failure( - mock_lsm6dsox: MagicMock, mock_i2c: MagicMock, mock_logger: MagicMock + mock_lsm6dsox: MagicMock, + mock_i2c: MagicMock, + mock_logger: MagicMock, ) -> None: - """Test handling of exceptions when retrieving the temperature.""" + """Tests handling of exceptions when retrieving the temperature. + + Args: + mock_lsm6dsox: Mocked LSM6DSOX class. + mock_i2c: Mocked I2C bus. + mock_logger: Mocked Logger instance. + """ imu_manager = LSM6DSOXManager(mock_logger, mock_i2c, address) mock_imu_instance = MagicMock(spec=LSM6DSOX) imu_manager._imu = mock_imu_instance diff --git a/tests/unit/hardware/magnetometer/manager/test_lis2mdl_manager.py b/tests/unit/hardware/magnetometer/manager/test_lis2mdl_manager.py index cb252f69..a2eb197f 100644 --- a/tests/unit/hardware/magnetometer/manager/test_lis2mdl_manager.py +++ b/tests/unit/hardware/magnetometer/manager/test_lis2mdl_manager.py @@ -1,3 +1,10 @@ +"""Unit tests for the LIS2MDLManager class. + +This module contains unit tests for the `LIS2MDLManager` class, which manages +the LIS2MDL magnetometer. The tests cover initialization, successful data +retrieval, and error handling for magnetic field vector readings. +""" + from typing import Generator from unittest.mock import MagicMock, PropertyMock, patch @@ -10,16 +17,26 @@ @pytest.fixture def mock_i2c(): + """Fixture for mock I2C bus.""" return MagicMock() @pytest.fixture def mock_logger(): + """Fixture for mock Logger.""" return MagicMock() @pytest.fixture def mock_lis2mdl(mock_i2c: MagicMock) -> Generator[MagicMock, None, None]: + """Mocks the LIS2MDL class. + + Args: + mock_i2c: Mocked I2C bus. + + Yields: + A MagicMock instance of LIS2MDL. + """ with patch("pysquared.hardware.magnetometer.manager.lis2mdl.LIS2MDL") as mock_class: mock_class.return_value = LIS2MDL(mock_i2c) yield mock_class @@ -30,20 +47,31 @@ def test_create_magnetometer( mock_i2c: MagicMock, mock_logger: MagicMock, ) -> None: - """Test successful creation of a LIS2MDL magnetometer instance.""" + """Tests successful creation of a LIS2MDL magnetometer instance. + + Args: + mock_lis2mdl: Mocked LIS2MDL class. + mock_i2c: Mocked I2C bus. + mock_logger: Mocked Logger instance. + """ magnetometer = LIS2MDLManager(mock_logger, mock_i2c) assert isinstance(magnetometer._magnetometer, LIS2MDL) mock_logger.debug.assert_called_once_with("Initializing magnetometer") -@pytest.mark.slow -def test_create_with_retries( +def test_create_magnetometer_failed( mock_lis2mdl: MagicMock, mock_i2c: MagicMock, mock_logger: MagicMock, ) -> None: - """Test that initialization is retried when it fails.""" + """Tests that initialization is retried when it fails. + + Args: + mock_lis2mdl: Mocked LIS2MDL class. + mock_i2c: Mocked I2C bus. + mock_logger: Mocked Logger instance. + """ mock_lis2mdl.side_effect = Exception("Simulated LIS2MDL failure") # Verify that HardwareInitializationError is raised after retries @@ -62,7 +90,13 @@ def test_get_vector_success( mock_i2c: MagicMock, mock_logger: MagicMock, ) -> None: - """Test successful retrieval of the magnetic field vector.""" + """Tests successful retrieval of the magnetic field vector. + + Args: + mock_lis2mdl: Mocked LIS2MDL class. + mock_i2c: Mocked I2C bus. + mock_logger: Mocked Logger instance. + """ magnetometer = LIS2MDLManager(mock_logger, mock_i2c) magnetometer._magnetometer = MagicMock(spec=LIS2MDL) magnetometer._magnetometer.magnetic = (1.0, 2.0, 3.0) @@ -76,10 +110,16 @@ def test_get_vector_failure( mock_i2c: MagicMock, mock_logger: MagicMock, ) -> None: - """Test handling of exceptions when retrieving the magnetic field vector.""" + """Tests handling of exceptions when retrieving the magnetic field vector. + + Args: + mock_lis2mdl: Mocked LIS2MDL class. + mock_i2c: Mocked I2C bus. + mock_logger: Mocked Logger instance. + """ magnetometer = LIS2MDLManager(mock_logger, mock_i2c) - # Confgure the mock to raise an exception when accessing the magnetic property + # Configure the mock to raise an exception when accessing the magnetic property mock_mag_instance = MagicMock(spec=LIS2MDL) magnetometer._magnetometer = mock_mag_instance mock_magnetic_property = PropertyMock( diff --git a/tests/unit/hardware/power_monitor/manager/test_ina219_manager.py b/tests/unit/hardware/power_monitor/manager/test_ina219_manager.py index f80c3ad8..4d474e2c 100644 --- a/tests/unit/hardware/power_monitor/manager/test_ina219_manager.py +++ b/tests/unit/hardware/power_monitor/manager/test_ina219_manager.py @@ -1,3 +1,10 @@ +"""Unit tests for the INA219Manager class. + +This module contains unit tests for the `INA219Manager` class, which manages +the INA219 power monitor. The tests cover initialization, successful data +retrieval, and error handling for bus voltage, shunt voltage, and current readings. +""" + from typing import Generator from unittest.mock import MagicMock, PropertyMock, patch @@ -12,32 +19,53 @@ @pytest.fixture def mock_i2c(): + """Fixture for mock I2C bus.""" return MagicMock() @pytest.fixture def mock_logger(): + """Fixture for mock Logger.""" return MagicMock() @pytest.fixture def mock_ina219(mock_i2c: MagicMock) -> Generator[MagicMock, None, None]: + """Mocks the INA219 class. + + Args: + mock_i2c: Mocked I2C bus. + + Yields: + A MagicMock instance of INA219. + """ with patch("pysquared.hardware.power_monitor.manager.ina219.INA219") as mock_class: mock_class.return_value = INA219(mock_i2c, address) yield mock_class def test_create_power_monitor(mock_ina219, mock_i2c, mock_logger): - """Test successful creation of an INA219 power monitor instance.""" + """Tests successful creation of an INA219 power monitor instance. + + Args: + mock_ina219: Mocked INA219 class. + mock_i2c: Mocked I2C bus. + mock_logger: Mocked Logger instance. + """ power_monitor = INA219Manager(mock_logger, mock_i2c, address) assert isinstance(power_monitor._ina219, INA219) mock_logger.debug.assert_called_once_with("Initializing INA219 power monitor") -@pytest.mark.slow -def test_create_with_retries(mock_ina219, mock_i2c, mock_logger): - """Test that initialization is retried when it fails.""" +def test_create_power_monitor_failed(mock_ina219, mock_i2c, mock_logger): + """Tests that initialization is retried when it fails. + + Args: + mock_ina219: Mocked INA219 class. + mock_i2c: Mocked I2C bus. + mock_logger: Mocked Logger instance. + """ mock_ina219.side_effect = Exception("Simulated INA219 failure") # Verify that HardwareInitializationError is raised after retries @@ -52,7 +80,13 @@ def test_create_with_retries(mock_ina219, mock_i2c, mock_logger): def test_get_bus_voltage_success(mock_ina219, mock_i2c, mock_logger): - """Test successful retrieval of the bus voltage.""" + """Tests successful retrieval of the bus voltage. + + Args: + mock_ina219: Mocked INA219 class. + mock_i2c: Mocked I2C bus. + mock_logger: Mocked Logger instance. + """ power_monitor = INA219Manager(mock_logger, mock_i2c, address) power_monitor._ina219 = MagicMock(spec=INA219) power_monitor._ina219.bus_voltage = MagicMock() @@ -63,7 +97,13 @@ def test_get_bus_voltage_success(mock_ina219, mock_i2c, mock_logger): def test_get_bus_voltage_failure(mock_ina219, mock_i2c, mock_logger): - """Test handling of exceptions when retrieving the bus voltage.""" + """Tests handling of exceptions when retrieving the bus voltage. + + Args: + mock_ina219: Mocked INA219 class. + mock_i2c: Mocked I2C bus. + mock_logger: Mocked Logger instance. + """ power_monitor = INA219Manager(mock_logger, mock_i2c, address) # Configure the mock to raise an exception when accessing the bus_voltage property @@ -79,7 +119,13 @@ def test_get_bus_voltage_failure(mock_ina219, mock_i2c, mock_logger): def test_get_shunt_voltage_success(mock_ina219, mock_i2c, mock_logger): - """Test successful retrieval of the shunt voltage.""" + """Tests successful retrieval of the shunt voltage. + + Args: + mock_ina219: Mocked INA219 class. + mock_i2c: Mocked I2C bus. + mock_logger: Mocked Logger instance. + """ power_monitor = INA219Manager(mock_logger, mock_i2c, address) power_monitor._ina219 = MagicMock(spec=INA219) power_monitor._ina219.shunt_voltage = MagicMock() @@ -90,7 +136,13 @@ def test_get_shunt_voltage_success(mock_ina219, mock_i2c, mock_logger): def test_get_shunt_voltage_failure(mock_ina219, mock_i2c, mock_logger): - """Test handling of exceptions when retrieving the shunt voltage.""" + """Tests handling of exceptions when retrieving the shunt voltage. + + Args: + mock_ina219: Mocked INA219 class. + mock_i2c: Mocked I2C bus. + mock_logger: Mocked Logger instance. + """ power_monitor = INA219Manager(mock_logger, mock_i2c, address) # Configure the mock to raise an exception when accessing the shunt_voltage property @@ -106,7 +158,13 @@ def test_get_shunt_voltage_failure(mock_ina219, mock_i2c, mock_logger): def test_get_current_success(mock_ina219, mock_i2c, mock_logger): - """Test successful retrieval of the current.""" + """Tests successful retrieval of the current. + + Args: + mock_ina219: Mocked INA219 class. + mock_i2c: Mocked I2C bus. + mock_logger: Mocked Logger instance. + """ power_monitor = INA219Manager(mock_logger, mock_i2c, address) power_monitor._ina219 = MagicMock(spec=INA219) power_monitor._ina219.current = MagicMock() @@ -117,7 +175,13 @@ def test_get_current_success(mock_ina219, mock_i2c, mock_logger): def test_get_current_failure(mock_ina219, mock_i2c, mock_logger): - """Test handling of exceptions when retrieving the current.""" + """Tests handling of exceptions when retrieving the current. + + Args: + mock_ina219: Mocked INA219 class. + mock_i2c: Mocked I2C bus. + mock_logger: Mocked Logger instance. + """ power_monitor = INA219Manager(mock_logger, mock_i2c, address) # Configure the mock to raise an exception when accessing the current property diff --git a/tests/unit/hardware/radio/manager/test_base.py b/tests/unit/hardware/radio/manager/test_base.py index 117d1c35..fb7bce57 100644 --- a/tests/unit/hardware/radio/manager/test_base.py +++ b/tests/unit/hardware/radio/manager/test_base.py @@ -1,3 +1,10 @@ +"""Unit tests for the BaseRadioManager class. + +This module contains unit tests for the `BaseRadioManager` class, focusing on +ensuring that abstract methods raise `NotImplementedError` as expected and that +the default `get_max_packet_size` returns the correct value. +""" + import pytest from pysquared.hardware.radio.manager.base import BaseRadioManager @@ -5,7 +12,12 @@ def test_initialize_radio_not_implemented(): - """Test that the _initialize_radio method raises NotImplementedError.""" + """Tests that the _initialize_radio method raises NotImplementedError. + + This test verifies that the abstract `_initialize_radio` method in the + `BaseRadioManager` correctly raises a `NotImplementedError` when called + directly, as it is intended to be overridden by subclasses. + """ # Create a mock instance of the BaseRadioManager mock_manager = BaseRadioManager.__new__(BaseRadioManager) @@ -15,7 +27,12 @@ def test_initialize_radio_not_implemented(): def test_receive_not_implemented(): - """Test that the _initialize_radio method raises NotImplementedError.""" + """Tests that the receive method raises NotImplementedError. + + This test verifies that the abstract `receive` method in the + `BaseRadioManager` correctly raises a `NotImplementedError` when called + directly, as it is intended to be overridden by subclasses. + """ # Create a mock instance of the BaseRadioManager mock_manager = BaseRadioManager.__new__(BaseRadioManager) @@ -25,7 +42,12 @@ def test_receive_not_implemented(): def test_send_internal_not_implemented(): - """Test that the _initialize_radio method raises NotImplementedError.""" + """Tests that the _send_internal method raises NotImplementedError. + + This test verifies that the abstract `_send_internal` method in the + `BaseRadioManager` correctly raises a `NotImplementedError` when called + directly, as it is intended to be overridden by subclasses. + """ # Create a mock instance of the BaseRadioManager mock_manager = BaseRadioManager.__new__(BaseRadioManager) @@ -35,7 +57,12 @@ def test_send_internal_not_implemented(): def test_get_modulation_not_implemented(): - """Test that the get_modulation method raises NotImplementedError.""" + """Tests that the get_modulation method raises NotImplementedError. + + This test verifies that the abstract `get_modulation` method in the + `BaseRadioManager` correctly raises a `NotImplementedError` when called + directly, as it is intended to be overridden by subclasses. + """ # Create a mock instance of the BaseRadioManager mock_manager = BaseRadioManager.__new__(BaseRadioManager) @@ -45,7 +72,12 @@ def test_get_modulation_not_implemented(): def test_get_max_packet_size(): - """Test that the get_max_packet_size method returns the default value.""" + """Tests that the get_max_packet_size method returns the default value. + + This test verifies that the `get_max_packet_size` method in the + `BaseRadioManager` returns the default packet size, as it provides a + concrete implementation that can be overridden by subclasses. + """ # Create a mock instance of the BaseRadioManager mock_manager = BaseRadioManager.__new__(BaseRadioManager) diff --git a/tests/unit/hardware/radio/manager/test_rfm9x_manager.py b/tests/unit/hardware/radio/manager/test_rfm9x_manager.py index 9f9682e5..bd37ee3d 100644 --- a/tests/unit/hardware/radio/manager/test_rfm9x_manager.py +++ b/tests/unit/hardware/radio/manager/test_rfm9x_manager.py @@ -1,3 +1,11 @@ +"""Unit tests for the RFM9xManager class. + +This module contains unit tests for the `RFM9xManager` class, which manages +RFM9x radios. The tests cover initialization, sending and receiving data, +modifying radio configuration, and retrieving radio parameters like temperature +and RSSI. +""" + import math from typing import Generator from unittest.mock import MagicMock, patch @@ -17,26 +25,31 @@ @pytest.fixture def mock_spi() -> MagicMock: + """Mocks the SPI bus.""" return MagicMock(spec=SPI) @pytest.fixture def mock_chip_select() -> MagicMock: + """Mocks the chip select DigitalInOut pin.""" return MagicMock(spec=DigitalInOut) @pytest.fixture def mock_reset() -> MagicMock: + """Mocks the reset DigitalInOut pin.""" return MagicMock(spec=DigitalInOut) @pytest.fixture def mock_logger() -> MagicMock: + """Mocks the Logger class.""" return MagicMock(spec=Logger) @pytest.fixture def mock_radio_config() -> RadioConfig: + """Provides a mock RadioConfig instance with default values.""" return RadioConfig( { "license": "testlicense", @@ -59,6 +72,16 @@ def mock_radio_config() -> RadioConfig: def mock_rfm9x( mock_spi: MagicMock, mock_chip_select: MagicMock, mock_reset: MagicMock ) -> Generator[MagicMock, None, None]: + """Mocks the RFM9x class. + + Args: + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + + Yields: + A MagicMock instance of RFM9x. + """ with patch("pysquared.hardware.radio.manager.rfm9x.RFM9x") as mock_class: mock_class.return_value = RFM9x(mock_spi, mock_chip_select, mock_reset, 0) yield mock_class @@ -68,6 +91,16 @@ def mock_rfm9x( def mock_rfm9xfsk( mock_spi: MagicMock, mock_chip_select: MagicMock, mock_reset: MagicMock ) -> Generator[MagicMock, None, None]: + """Mocks the RFM9xFSK class. + + Args: + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + + Yields: + A MagicMock instance of RFM9xFSK. + """ with patch("pysquared.hardware.radio.manager.rfm9x.RFM9xFSK") as mock_class: mock_class.return_value = RFM9xFSK(mock_spi, mock_chip_select, mock_reset, 0) yield mock_class @@ -82,7 +115,17 @@ def test_init_fsk_success( mock_reset: MagicMock, mock_radio_config: RadioConfig, ): - """Test successful initialization when radio_config.modulation == "FSK".""" + """Tests successful initialization when radio_config.modulation is FSK. + + Args: + mock_rfm9x: Mocked RFM9x class. + mock_rfm9xfsk: Mocked RFM9xFSK class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_fsk_instance = MagicMock(spec=RFM9xFSK) mock_rfm9xfsk.return_value = mock_fsk_instance @@ -122,7 +165,17 @@ def test_init_lora_success( mock_reset: MagicMock, mock_radio_config: RadioConfig, ): - """Test successful initialization when radio_config.modulation == "LoRa".""" + """Tests successful initialization when radio_config.modulation is LoRa. + + Args: + mock_rfm9x: Mocked RFM9x class. + mock_rfm9xfsk: Mocked RFM9xFSK class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_radio_config.modulation = "LoRa" mock_lora_instance = MagicMock(spec=RFM9x) mock_rfm9x.return_value = mock_lora_instance @@ -170,7 +223,17 @@ def test_init_lora_high_sf_success( mock_reset: MagicMock, mock_radio_config: RadioConfig, # Use base config ): - """Test LoRa initialization with high spreading factor.""" + """Tests LoRa initialization with high spreading factor. + + Args: + mock_rfm9x: Mocked RFM9x class. + mock_rfm9xfsk: Mocked RFM9xFSK class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_radio_config.modulation = "LoRa" # Modify config for high SF mock_radio_config.lora.spreading_factor = 10 @@ -193,8 +256,7 @@ def test_init_lora_high_sf_success( assert mock_lora_instance.preamble_length == 10 -@pytest.mark.slow -def test_init_with_retries_fsk( +def test_init_failed_fsk( mock_rfm9xfsk: MagicMock, mock_logger: MagicMock, mock_spi: MagicMock, @@ -202,7 +264,16 @@ def test_init_with_retries_fsk( mock_reset: MagicMock, mock_radio_config: RadioConfig, ): - """Test __init__ retries on FSK initialization failure.""" + """Tests __init__ retries on FSK initialization failure. + + Args: + mock_rfm9xfsk: Mocked RFM9xFSK class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_rfm9xfsk.side_effect = Exception("Simulated FSK failure") with pytest.raises(HardwareInitializationError): @@ -217,11 +288,10 @@ def test_init_with_retries_fsk( mock_logger.debug.assert_called_with( "Initializing radio", radio_type="RFM9xManager", modulation=FSK.__name__ ) - assert mock_rfm9xfsk.call_count == 3 + mock_rfm9xfsk.assert_called_once() -@pytest.mark.slow -def test_init_with_retries_lora( +def test_init_failed_lora( mock_rfm9x: MagicMock, mock_logger: MagicMock, mock_spi: MagicMock, @@ -229,7 +299,16 @@ def test_init_with_retries_lora( mock_reset: MagicMock, mock_radio_config: RadioConfig, ): - """Test __init__ retries on LoRa initialization failure.""" + """Tests __init__ retries on LoRa initialization failure. + + Args: + mock_rfm9x: Mocked RFM9x class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_radio_config.modulation = "LoRa" mock_rfm9x.side_effect = Exception("Simulated LoRa failure") @@ -245,10 +324,9 @@ def test_init_with_retries_lora( mock_logger.debug.assert_called_with( "Initializing radio", radio_type="RFM9xManager", modulation=LoRa.__name__ ) - assert mock_rfm9x.call_count == 3 + mock_rfm9x.assert_called_once() -# Test send Method def test_send_success_bytes( mock_rfm9x: MagicMock, mock_logger: MagicMock, @@ -257,7 +335,16 @@ def test_send_success_bytes( mock_reset: MagicMock, mock_radio_config: RadioConfig, ): - """Test successful sending of bytes.""" + """Tests successful sending of bytes. + + Args: + mock_rfm9x: Mocked RFM9x class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_radio_config.modulation = "LoRa" mock_radio_instance = MagicMock(spec=RFM9x) mock_radio_instance.send = MagicMock() @@ -285,7 +372,16 @@ def test_send_unlicensed( mock_reset: MagicMock, mock_radio_config: RadioConfig, ): - """Test send attempt when not licensed.""" + """Tests send attempt when not licensed. + + Args: + mock_rfm9x: Mocked RFM9x class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_radio_config.modulation = "LoRa" mock_radio_instance = MagicMock(spec=RFM9x) mock_radio_instance.send = MagicMock() @@ -318,7 +414,16 @@ def test_send_exception( mock_reset: MagicMock, mock_radio_config: RadioConfig, ): - """Test handling of exception during radio.send().""" + """Tests handling of exception during radio.send(). + + Args: + mock_rfm9x: Mocked RFM9x class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_radio_config.modulation = "LoRa" mock_radio_instance = MagicMock(spec=RFM9x) mock_radio_instance.send = MagicMock() @@ -349,7 +454,17 @@ def test_get_modulation_initialized( mock_reset: MagicMock, mock_radio_config: RadioConfig, ): - """Test get_modulation when radio is initialized.""" + """Tests get_modulation when radio is initialized. + + Args: + mock_rfm9x: Mocked RFM9x class. + mock_rfm9xfsk: Mocked RFM9xFSK class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_radio_config: Mocked RadioConfig instance. + """ # Test FSK instance manager_fsk = RFM9xManager( mock_logger, @@ -372,7 +487,6 @@ def test_get_modulation_initialized( assert manager_lora.get_modulation() == LoRa -# Test get_temperature Method @pytest.mark.parametrize( "raw_value, expected_temperature", [ @@ -394,7 +508,19 @@ def test_get_temperature_success( raw_value: int, expected_temperature: float, ): - """Test successful temperature reading and calculation.""" + """Tests successful temperature reading and calculation. + + Args: + mock_rfm9x: Mocked RFM9x class. + mock_rfm9xfsk: Mocked RFM9xFSK class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_radio_config: Mocked RadioConfig instance. + raw_value: Raw temperature value from the radio. + expected_temperature: Expected calculated temperature. + """ mock_radio_instance = MagicMock(spec=RFM9x) mock_radio_instance.read_u8 = MagicMock() mock_radio_instance.read_u8.return_value = raw_value @@ -425,7 +551,17 @@ def test_get_temperature_read_exception( mock_reset: MagicMock, mock_radio_config: RadioConfig, ): - """Test handling exception during radio.read_u8().""" + """Tests handling exception during radio.read_u8(). + + Args: + mock_rfm9x: Mocked RFM9x class. + mock_rfm9xfsk: Mocked RFM9xFSK class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_radio_config.modulation = "LoRa" manager = RFM9xManager( mock_logger, @@ -457,7 +593,16 @@ def test_receive_success( mock_reset: MagicMock, mock_radio_config: RadioConfig, ): - """Test successful reception of a message.""" + """Tests successful reception of a message. + + Args: + mock_rfm9x: Mocked RFM9x class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_radio_config.modulation = "LoRa" mock_radio_instance = MagicMock(spec=RFM9x) expected_data = b"Received Data" @@ -488,7 +633,16 @@ def test_receive_no_message( mock_reset: MagicMock, mock_radio_config: RadioConfig, ): - """Test receiving when no message is available (timeout).""" + """Tests receiving when no message is available (timeout). + + Args: + mock_rfm9x: Mocked RFM9x class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_radio_config.modulation = "LoRa" mock_radio_instance = MagicMock(spec=RFM9x) mock_radio_instance.receive = MagicMock() @@ -519,7 +673,16 @@ def test_receive_exception( mock_reset: MagicMock, mock_radio_config: RadioConfig, ): - """Test handling of exception during radio.receive().""" + """Tests handling of exception during radio.receive(). + + Args: + mock_rfm9x: Mocked RFM9x class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_radio_config.modulation = "LoRa" mock_radio_instance = MagicMock(spec=RFM9x) mock_radio_instance.receive = MagicMock() @@ -549,7 +712,15 @@ def test_modify_lora_config( mock_reset: MagicMock, mock_radio_config: RadioConfig, ): - """Test modifying the radio configuration.""" + """Tests modifying the radio configuration for LoRa mode. + + Args: + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_radio_config: Mocked RadioConfig instance. + """ # Create manager without initializing the radio manager = RFM9xManager.__new__(RFM9xManager) manager._log = mock_logger @@ -587,7 +758,15 @@ def test_modify_lora_config_high_sf_success( mock_reset: MagicMock, mock_radio_config: RadioConfig, # Use base config ): - """Test LoRa initialization with high spreading factor.""" + """Tests LoRa initialization with high spreading factor. + + Args: + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_radio_config: Mocked RadioConfig instance. + """ # Create manager without initializing the radio manager = RFM9xManager.__new__(RFM9xManager) manager._log = mock_logger @@ -618,7 +797,15 @@ def test_modify_fsk_config( mock_reset: MagicMock, mock_radio_config: RadioConfig, ): - """Test modifying the radio configuration.""" + """Tests modifying the radio configuration for FSK mode. + + Args: + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_radio_config: Mocked RadioConfig instance. + """ # Create manager without initializing the radio manager = RFM9xManager.__new__(RFM9xManager) manager._log = mock_logger @@ -652,7 +839,15 @@ def test_get_max_packet_size_lora( mock_reset: MagicMock, mock_radio_config: RadioConfig, ): - """Test get_max_packet_size method with LoRa radio.""" + """Tests get_max_packet_size method with LoRa radio. + + Args: + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_radio_config: Mocked RadioConfig instance. + """ # Create manager without initializing the radio manager = RFM9xManager.__new__(RFM9xManager) manager._log = mock_logger @@ -675,7 +870,15 @@ def test_get_max_packet_size_fsk( mock_reset: MagicMock, mock_radio_config: RadioConfig, ): - """Test get_max_packet_size method with FSK radio.""" + """Tests get_max_packet_size method with FSK radio. + + Args: + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_radio_config: Mocked RadioConfig instance. + """ # Create manager without initializing the radio manager = RFM9xManager.__new__(RFM9xManager) manager._log = mock_logger @@ -699,7 +902,16 @@ def test_get_rssi( mock_reset: MagicMock, mock_radio_config: RadioConfig, ): - """Test getting the RSSI value from the radio.""" + """Tests getting the RSSI value from the radio. + + Args: + mock_rfm9x: Mocked RFM9x class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_radio_config.modulation = "LoRa" mock_radio_instance = MagicMock(spec=RFM9x) expected_rssi = 70.0 diff --git a/tests/unit/hardware/radio/manager/test_sx126x_manager.py b/tests/unit/hardware/radio/manager/test_sx126x_manager.py index 08a88e96..627ae49f 100644 --- a/tests/unit/hardware/radio/manager/test_sx126x_manager.py +++ b/tests/unit/hardware/radio/manager/test_sx126x_manager.py @@ -1,3 +1,10 @@ +"""Unit tests for the SX126xManager class. + +This module contains unit tests for the `SX126xManager` class, which manages +SX126x radios. The tests cover initialization, sending and receiving data, +and retrieving the current modulation. +""" + from typing import Generator from unittest.mock import MagicMock, call, patch @@ -16,36 +23,43 @@ @pytest.fixture def mock_spi() -> MagicMock: + """Mocks the SPI bus.""" return MagicMock(spec=SPI) @pytest.fixture def mock_chip_select() -> MagicMock: + """Mocks the chip select DigitalInOut pin.""" return MagicMock(spec=DigitalInOut) @pytest.fixture def mock_reset() -> MagicMock: + """Mocks the reset DigitalInOut pin.""" return MagicMock(spec=DigitalInOut) @pytest.fixture def mock_irq() -> MagicMock: + """Mocks the IRQ DigitalInOut pin.""" return MagicMock(spec=DigitalInOut) @pytest.fixture def mock_gpio() -> MagicMock: + """Mocks the GPIO DigitalInOut pin.""" return MagicMock(spec=DigitalInOut) @pytest.fixture def mock_logger() -> MagicMock: + """Mocks the Logger class.""" return MagicMock(spec=Logger) @pytest.fixture def mock_radio_config() -> RadioConfig: + """Provides a mock RadioConfig instance with default values.""" # Using the same config as RFM9x for consistency, adjust if needed return RadioConfig( { @@ -77,6 +91,18 @@ def mock_sx1262( mock_irq: MagicMock, mock_gpio: MagicMock, ) -> Generator[MagicMock, None, None]: + """Mocks the SX1262 class. + + Args: + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_irq: Mocked IRQ pin. + mock_gpio: Mocked GPIO pin. + + Yields: + A MagicMock instance of SX1262. + """ with patch("pysquared.hardware.radio.manager.sx126x.SX1262") as mock_class: mock_class.return_value = SX1262( mock_spi, mock_chip_select, mock_reset, mock_irq, mock_gpio @@ -94,7 +120,18 @@ def test_init_fsk_success( mock_gpio: MagicMock, mock_radio_config: RadioConfig, ): - """Test successful initialization when radio_config.modulation == "FSK".""" + """Tests successful initialization when radio_config.modulation is FSK. + + Args: + mock_sx1262: Mocked SX1262 class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_irq: Mocked IRQ pin. + mock_gpio: Mocked GPIO pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_sx1262_instance = mock_sx1262.return_value mock_sx1262_instance.beginFSK = MagicMock() mock_sx1262_instance.begin = MagicMock() @@ -134,7 +171,18 @@ def test_init_lora_success( mock_gpio: MagicMock, mock_radio_config: RadioConfig, ): - """Test successful initialization when radio_config.modulation == "LoRa".""" + """Tests successful initialization when radio_config.modulation is LoRa. + + Args: + mock_sx1262: Mocked SX1262 class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_irq: Mocked IRQ pin. + mock_gpio: Mocked GPIO pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_radio_config.modulation = "LoRa" mock_sx1262_instance = mock_sx1262.return_value mock_sx1262_instance.beginFSK = MagicMock() @@ -169,8 +217,7 @@ def test_init_lora_success( ) -@pytest.mark.slow -def test_init_with_retries_fsk( +def test_init_failed_fsk( mock_sx1262: MagicMock, mock_logger: MagicMock, mock_spi: MagicMock, @@ -180,7 +227,18 @@ def test_init_with_retries_fsk( mock_gpio: MagicMock, mock_radio_config: RadioConfig, ): - """Test __init__ retries on FSK initialization failure.""" + """Tests __init__ retries on FSK initialization failure. + + Args: + mock_sx1262: Mocked SX1262 class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_irq: Mocked IRQ pin. + mock_gpio: Mocked GPIO pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_sx1262_instance = mock_sx1262.return_value mock_sx1262_instance.beginFSK = MagicMock() mock_sx1262_instance.beginFSK.side_effect = Exception("SPI Error") @@ -199,11 +257,10 @@ def test_init_with_retries_fsk( mock_logger.debug.assert_any_call( "Initializing radio", radio_type="SX126xManager", modulation=FSK.__name__ ) - assert mock_sx1262_instance.beginFSK.call_count == 3 + mock_sx1262_instance.beginFSK.assert_called_once() -@pytest.mark.slow -def test_init_with_retries_lora( +def test_init_failed_lora( mock_sx1262: MagicMock, mock_logger: MagicMock, mock_spi: MagicMock, @@ -213,7 +270,18 @@ def test_init_with_retries_lora( mock_gpio: MagicMock, mock_radio_config: RadioConfig, ): - """Test __init__ retries on FSK initialization failure.""" + """Tests __init__ retries on FSK initialization failure. + + Args: + mock_sx1262: Mocked SX1262 class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_irq: Mocked IRQ pin. + mock_gpio: Mocked GPIO pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_radio_config.modulation = "LoRa" mock_sx1262_instance = mock_sx1262.return_value mock_sx1262_instance.begin = MagicMock() @@ -235,7 +303,7 @@ def test_init_with_retries_lora( radio_type="SX126xManager", modulation=LoRa.__name__, ) - assert mock_sx1262_instance.begin.call_count == 3 + mock_sx1262_instance.begin.assert_called_once() @pytest.fixture @@ -249,7 +317,21 @@ def initialized_manager( mock_gpio: MagicMock, mock_radio_config: RadioConfig, ) -> SX126xManager: - """Provides an initialized SX126xManager instance with a mock radio.""" + """Provides an initialized SX126xManager instance with a mock radio. + + Args: + mock_sx1262: Mocked SX1262 class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_irq: Mocked IRQ pin. + mock_gpio: Mocked GPIO pin. + mock_radio_config: Mocked RadioConfig instance. + + Returns: + An initialized SX126xManager instance. + """ return SX126xManager( mock_logger, mock_radio_config, @@ -265,7 +347,12 @@ def test_send_success_bytes( initialized_manager: SX126xManager, mock_logger: MagicMock, ): - """Test successful sending of bytes.""" + """Tests successful sending of bytes. + + Args: + initialized_manager: Initialized SX126xManager instance. + mock_logger: Mocked Logger instance. + """ data_bytes = b"Hello SX126x" initialized_manager._radio = MagicMock(spec=SX1262) @@ -285,7 +372,18 @@ def test_send_unlicensed( mock_gpio: MagicMock, mock_radio_config: RadioConfig, ): - """Test send attempt when not licensed.""" + """Tests send attempt when not licensed. + + Args: + mock_sx1262: Mocked SX1262 class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_irq: Mocked IRQ pin. + mock_gpio: Mocked GPIO pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_radio_config.license = "" # Simulate unlicensed state manager = SX126xManager( @@ -312,7 +410,13 @@ def test_send_radio_error( mock_logger: MagicMock, mock_radio_config: RadioConfig, ): - """Test handling of error code returned by radio.send().""" + """Tests handling of error code returned by radio.send(). + + Args: + initialized_manager: Initialized SX126xManager instance. + mock_logger: Mocked Logger instance. + mock_radio_config: Mocked RadioConfig instance. + """ initialized_manager._radio = MagicMock(spec=SX1262) initialized_manager._radio.send = MagicMock() initialized_manager._radio.send.return_value = (0, -1) @@ -332,7 +436,13 @@ def test_send_exception( mock_logger: MagicMock, mock_radio_config: RadioConfig, ): - """Test handling of exception during radio.send().""" + """Tests handling of exception during radio.send(). + + Args: + initialized_manager: Initialized SX126xManager instance. + mock_logger: Mocked Logger instance. + mock_radio_config: Mocked RadioConfig instance. + """ initialized_manager._radio = MagicMock(spec=SX1262) initialized_manager._radio.send = MagicMock() @@ -352,7 +462,13 @@ def test_receive_success( initialized_manager: SX126xManager, mock_logger: MagicMock, ): - """Test successful reception of a message.""" + """Tests successful reception of a message. + + Args: + mock_time: Mocked time module. + initialized_manager: Initialized SX126xManager instance. + mock_logger: Mocked Logger instance. + """ expected_data = b"SX Received" initialized_manager._radio = MagicMock(spec=SX1262) initialized_manager._radio.recv = MagicMock() @@ -374,7 +490,13 @@ def test_receive_timeout( initialized_manager: SX126xManager, mock_logger: MagicMock, ): - """Test receiving when no message arrives before timeout.""" + """Tests receiving when no message arrives before timeout. + + Args: + mock_time: Mocked time module. + initialized_manager: Initialized SX126xManager instance. + mock_logger: Mocked Logger instance. + """ initialized_manager._radio = MagicMock(spec=SX1262) initialized_manager._radio.recv = MagicMock() initialized_manager._radio.recv.return_value = (b"", ERR_NONE) @@ -402,7 +524,13 @@ def test_receive_radio_error( initialized_manager: SX126xManager, mock_logger: MagicMock, ): - """Test handling of error code returned by radio.recv().""" + """Tests handling of error code returned by radio.recv(). + + Args: + mock_time: Mocked time module. + initialized_manager: Initialized SX126xManager instance. + mock_logger: Mocked Logger instance. + """ error_code = -5 initialized_manager._radio = MagicMock(spec=SX1262) initialized_manager._radio.recv = MagicMock() @@ -424,7 +552,13 @@ def test_receive_exception( initialized_manager: SX126xManager, mock_logger: MagicMock, ): - """Test handling of exception during radio.recv().""" + """Tests handling of exception during radio.recv(). + + Args: + mock_time: Mocked time module. + initialized_manager: Initialized SX126xManager instance. + mock_logger: Mocked Logger instance. + """ initialized_manager._radio = MagicMock(spec=SX1262) receive_error = RuntimeError("SPI Comms Failed") initialized_manager._radio.recv = MagicMock() @@ -450,7 +584,18 @@ def test_get_modulation_initialized( mock_gpio: MagicMock, mock_radio_config: RadioConfig, ): - """Test get_modulation when radio is initialized.""" + """Tests get_modulation when radio is initialized. + + Args: + mock_sx1262: Mocked SX1262 class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_irq: Mocked IRQ pin. + mock_gpio: Mocked GPIO pin. + mock_radio_config: Mocked RadioConfig instance. + """ manager = SX126xManager( mock_logger, diff --git a/tests/unit/hardware/radio/manager/test_sx1280_manager.py b/tests/unit/hardware/radio/manager/test_sx1280_manager.py index bfec9bd2..4d2521eb 100644 --- a/tests/unit/hardware/radio/manager/test_sx1280_manager.py +++ b/tests/unit/hardware/radio/manager/test_sx1280_manager.py @@ -1,3 +1,10 @@ +"""Unit tests for the SX1280Manager class. + +This module contains unit tests for the `SX1280Manager` class, which manages +SX1280 radios. The tests cover initialization, sending and receiving data, +and retrieving the current modulation. +""" + from typing import Generator from unittest.mock import MagicMock, patch @@ -16,41 +23,49 @@ @pytest.fixture def mock_spi() -> MagicMock: + """Mocks the SPI bus.""" return MagicMock(spec=SPI) @pytest.fixture def mock_chip_select() -> MagicMock: + """Mocks the chip select DigitalInOut pin.""" return MagicMock(spec=DigitalInOut) @pytest.fixture def mock_reset() -> MagicMock: + """Mocks the reset DigitalInOut pin.""" return MagicMock(spec=DigitalInOut) @pytest.fixture def mock_busy() -> MagicMock: + """Mocks the busy DigitalInOut pin.""" return MagicMock(spec=DigitalInOut) @pytest.fixture def mock_txen() -> MagicMock: + """Mocks the transmit enable DigitalInOut pin.""" return MagicMock(spec=DigitalInOut) @pytest.fixture def mock_rxen() -> MagicMock: + """Mocks the receive enable DigitalInOut pin.""" return MagicMock(spec=DigitalInOut) @pytest.fixture def mock_logger() -> MagicMock: + """Mocks the Logger class.""" return MagicMock(spec=Logger) @pytest.fixture def mock_radio_config() -> RadioConfig: + """Provides a mock RadioConfig instance with default values.""" return RadioConfig( { "license": "testlicense", @@ -78,6 +93,19 @@ def mock_sx1280( mock_txen: MagicMock, mock_rxen: MagicMock, ) -> Generator[MagicMock, None, None]: + """Mocks the SX1280 class. + + Args: + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_busy: Mocked busy pin. + mock_txen: Mocked transmit enable pin. + mock_rxen: Mocked receive enable pin. + + Yields: + A MagicMock instance of SX1280. + """ with patch("pysquared.hardware.radio.manager.sx1280.SX1280") as mock_class: mock_class.return_value = SX1280( mock_spi, @@ -102,7 +130,19 @@ def test_init_fsk_success( mock_rxen: MagicMock, mock_radio_config: RadioConfig, ): - """Test successful initialization when radio_config.modulation is set to "FSK".""" + """Tests successful initialization when radio_config.modulation is set to FSK. + + Args: + mock_sx1280: Mocked SX1280 class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_busy: Mocked busy pin. + mock_txen: Mocked transmit enable pin. + mock_rxen: Mocked receive enable pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_radio_instance = MagicMock(spec=SX1280) mock_sx1280.return_value = mock_radio_instance @@ -144,7 +184,19 @@ def test_init_lora_success( mock_rxen: MagicMock, mock_radio_config: RadioConfig, ): - """Test successful initialization when radio_config.modulation is set to "LoRa".""" + """Tests successful initialization when radio_config.modulation is set to LoRa. + + Args: + mock_sx1280: Mocked SX1280 class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_busy: Mocked busy pin. + mock_txen: Mocked transmit enable pin. + mock_rxen: Mocked receive enable pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_radio_config.modulation = "LoRa" mock_radio_instance = MagicMock(spec=SX1280) mock_sx1280.return_value = mock_radio_instance @@ -176,8 +228,7 @@ def test_init_lora_success( ) -@pytest.mark.slow -def test_init_with_retries_fsk( +def test_init_failed_fsk( mock_sx1280: MagicMock, mock_logger: MagicMock, mock_spi: MagicMock, @@ -188,7 +239,19 @@ def test_init_with_retries_fsk( mock_rxen: MagicMock, mock_radio_config: RadioConfig, ): - """Test successful initialization when radio_config.modulation is set to "FSK".""" + """Tests initialization retries on FSK initialization failure. + + Args: + mock_sx1280: Mocked SX1280 class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_busy: Mocked busy pin. + mock_txen: Mocked transmit enable pin. + mock_rxen: Mocked receive enable pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_sx1280.side_effect = Exception("Simulated FSK failure") with pytest.raises(HardwareInitializationError): @@ -207,11 +270,10 @@ def test_init_with_retries_fsk( mock_logger.debug.assert_called_with( "Initializing radio", radio_type="SX1280Manager", modulation="FSK" ) - assert mock_sx1280.call_count == 3 + mock_sx1280.assert_called_once() -@pytest.mark.slow -def test_init_with_retries_lora( +def test_init_failed_lora( mock_sx1280: MagicMock, mock_logger: MagicMock, mock_spi: MagicMock, @@ -222,7 +284,19 @@ def test_init_with_retries_lora( mock_rxen: MagicMock, mock_radio_config: RadioConfig, ): - """Test successful initialization when radio_config.modulation is set to "FSK".""" + """Tests initialization retries on LoRa initialization failure. + + Args: + mock_sx1280: Mocked SX1280 class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_busy: Mocked busy pin. + mock_txen: Mocked transmit enable pin. + mock_rxen: Mocked receive enable pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_sx1280.side_effect = Exception("Simulated FSK failure") with pytest.raises(HardwareInitializationError): @@ -241,10 +315,9 @@ def test_init_with_retries_lora( mock_logger.debug.assert_called_with( "Initializing radio", radio_type="SX1280Manager", modulation="FSK" ) - assert mock_sx1280.call_count == 3 + mock_sx1280.assert_called_once() -# Test send Method def test_send_success_bytes( mock_sx1280: MagicMock, mock_logger: MagicMock, @@ -256,7 +329,19 @@ def test_send_success_bytes( mock_rxen: MagicMock, mock_radio_config: RadioConfig, ): - """Test successful sending of bytes.""" + """Tests successful sending of bytes. + + Args: + mock_sx1280: Mocked SX1280 class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_busy: Mocked busy pin. + mock_txen: Mocked transmit enable pin. + mock_rxen: Mocked receive enable pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_radio_config.modulation = "LoRa" mock_radio_instance = MagicMock(spec=RFM9x) mock_radio_instance.send = MagicMock() @@ -291,7 +376,19 @@ def test_send_unlicensed( mock_rxen: MagicMock, mock_radio_config: RadioConfig, ): - """Test send attempt when not licensed.""" + """Tests send attempt when not licensed. + + Args: + mock_sx1280: Mocked SX1280 class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_busy: Mocked busy pin. + mock_txen: Mocked transmit enable pin. + mock_rxen: Mocked receive enable pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_radio_config.modulation = "LoRa" mock_radio_instance = MagicMock(spec=RFM9x) mock_radio_instance.send = MagicMock() @@ -331,7 +428,19 @@ def test_send_exception( mock_rxen: MagicMock, mock_radio_config: RadioConfig, ): - """Test handling of exception during radio.send().""" + """Tests handling of exception during radio.send(). + + Args: + mock_sx1280: Mocked SX1280 class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_busy: Mocked busy pin. + mock_txen: Mocked transmit enable pin. + mock_rxen: Mocked receive enable pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_radio_config.modulation = "LoRa" mock_radio_instance = MagicMock(spec=RFM9x) mock_radio_instance.send = MagicMock() @@ -368,7 +477,19 @@ def test_get_modulation_initialized( mock_rxen: MagicMock, mock_radio_config: RadioConfig, ): - """Test get_modulation when radio is initialized.""" + """Tests get_modulation when radio is initialized. + + Args: + mock_sx1280: Mocked SX1280 class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_busy: Mocked busy pin. + mock_txen: Mocked transmit enable pin. + mock_rxen: Mocked receive enable pin. + mock_radio_config: Mocked RadioConfig instance. + """ # Test FSK instance manager = SX1280Manager( mock_logger, @@ -410,7 +531,19 @@ def test_receive_success( mock_rxen: MagicMock, mock_radio_config: RadioConfig, ): - """Test successful reception of a message.""" + """Tests successful reception of a message. + + Args: + mock_sx1280: Mocked SX1280 class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_busy: Mocked busy pin. + mock_txen: Mocked transmit enable pin. + mock_rxen: Mocked receive enable pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_radio_config.modulation = "LoRa" mock_radio_instance = MagicMock(spec=RFM9x) expected_data = b"Received Data" @@ -448,7 +581,19 @@ def test_receive_no_message( mock_rxen: MagicMock, mock_radio_config: RadioConfig, ): - """Test receiving when no message is available (timeout).""" + """Tests receiving when no message is available (timeout). + + Args: + mock_sx1280: Mocked SX1280 class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_busy: Mocked busy pin. + mock_txen: Mocked transmit enable pin. + mock_rxen: Mocked receive enable pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_radio_config.modulation = "LoRa" mock_radio_instance = MagicMock(spec=RFM9x) mock_radio_instance.receive = MagicMock() @@ -486,7 +631,19 @@ def test_receive_exception( mock_rxen: MagicMock, mock_radio_config: RadioConfig, ): - """Test handling of exception during radio.receive().""" + """Tests handling of exception during radio.receive(). + + Args: + mock_sx1280: Mocked SX1280 class. + mock_logger: Mocked Logger instance. + mock_spi: Mocked SPI bus. + mock_chip_select: Mocked chip select pin. + mock_reset: Mocked reset pin. + mock_busy: Mocked busy pin. + mock_txen: Mocked transmit enable pin. + mock_rxen: Mocked receive enable pin. + mock_radio_config: Mocked RadioConfig instance. + """ mock_radio_config.modulation = "LoRa" mock_radio_instance = MagicMock(spec=RFM9x) mock_radio_instance.receive = MagicMock() diff --git a/tests/unit/hardware/radio/packetizer/test_packet_manager.py b/tests/unit/hardware/radio/packetizer/test_packet_manager.py index 8aa6dac0..2655ba0d 100644 --- a/tests/unit/hardware/radio/packetizer/test_packet_manager.py +++ b/tests/unit/hardware/radio/packetizer/test_packet_manager.py @@ -1,3 +1,11 @@ +"""Unit tests for the PacketManager class. + +This module contains unit tests for the `PacketManager` class, which handles +packetization, sending, and receiving of data over a radio. The tests cover +initialization, data packing, sending, listening for data, and acknowledgment +mechanisms. +""" + import random from unittest.mock import MagicMock, patch @@ -10,11 +18,16 @@ @pytest.fixture def mock_logger() -> MagicMock: + """Mocks the Logger class.""" return MagicMock(spec=Logger) @pytest.fixture def mock_radio() -> MagicMock: + """Mocks the RadioProto class. + + Sets a default max packet size and RSSI value for testing. + """ radio = MagicMock(spec=RadioProto) radio.get_max_packet_size.return_value = 100 # Mock packet size for tests radio.get_rssi.return_value = -70 # Mock RSSI value @@ -22,7 +35,12 @@ def mock_radio() -> MagicMock: def test_packet_manager_init(mock_logger, mock_radio): - """Test PacketManager initialization.""" + """Tests PacketManager initialization. + + Args: + mock_logger: Mocked Logger instance. + mock_radio: Mocked RadioProto instance. + """ license_str = "TEST_LICENSE" packet_manager = PacketManager(mock_logger, mock_radio, license_str, send_delay=0.5) @@ -36,7 +54,12 @@ def test_packet_manager_init(mock_logger, mock_radio): def test_pack_data_single_packet(mock_logger, mock_radio): - """Test packing data that fits in a single packet.""" + """Tests packing data that fits in a single packet. + + Args: + mock_logger: Mocked Logger instance. + mock_radio: Mocked RadioProto instance. + """ license_str = "TEST" packet_manager = PacketManager(mock_logger, mock_radio, license_str) @@ -62,7 +85,12 @@ def test_pack_data_single_packet(mock_logger, mock_radio): def test_pack_data_multiple_packets(mock_logger, mock_radio): - """Test packing data that requires multiple packets.""" + """Tests packing data that requires multiple packets. + + Args: + mock_logger: Mocked Logger instance. + mock_radio: Mocked RadioProto instance. + """ license_str = "TEST" packet_manager = PacketManager(mock_logger, mock_radio, license_str) @@ -92,7 +120,13 @@ def test_pack_data_multiple_packets(mock_logger, mock_radio): @patch("time.sleep") def test_send_success(mock_sleep, mock_logger, mock_radio): - """Test successful execution of send method.""" + """Tests successful execution of send method. + + Args: + mock_sleep: Mocked time.sleep function. + mock_logger: Mocked Logger instance. + mock_radio: Mocked RadioProto instance. + """ license_str = "TEST" packet_manager = PacketManager(mock_logger, mock_radio, license_str, send_delay=0.1) @@ -121,7 +155,13 @@ def test_send_success(mock_sleep, mock_logger, mock_radio): @patch("time.sleep") def test_send_success_multipacket(mock_sleep, mock_logger, mock_radio): - """Test successful execution of send method.""" + """Tests successful execution of send method with multiple packets. + + Args: + mock_sleep: Mocked time.sleep function. + mock_logger: Mocked Logger instance. + mock_radio: Mocked RadioProto instance. + """ license_str = "TEST" packet_manager = PacketManager(mock_logger, mock_radio, license_str, send_delay=0.1) @@ -150,7 +190,12 @@ def test_send_success_multipacket(mock_sleep, mock_logger, mock_radio): def test_send_unlicensed(mock_logger, mock_radio): - """Test unlicensed execution of send method.""" + """Tests unlicensed execution of send method. + + Args: + mock_logger: Mocked Logger instance. + mock_radio: Mocked RadioProto instance. + """ license_str = "" packet_manager = PacketManager(mock_logger, mock_radio, license_str, send_delay=0.1) @@ -164,7 +209,13 @@ def test_send_unlicensed(mock_logger, mock_radio): @patch("time.time") def test_unpack_data(mock_time, mock_logger, mock_radio): - """Test unpacking data from received packets.""" + """Tests unpacking data from received packets. + + Args: + mock_time: Mocked time.time function. + mock_logger: Mocked Logger instance. + mock_radio: Mocked RadioProto instance. + """ packet_manager = PacketManager(mock_logger, mock_radio, "") # Create test packets with proper headers @@ -200,7 +251,13 @@ def test_unpack_data(mock_time, mock_logger, mock_radio): @patch("time.time") def test_receive_success(mock_time, mock_logger, mock_radio): - """Test successfully receiving all packets.""" + """Tests successfully receiving all packets. + + Args: + mock_time: Mocked time.time function. + mock_logger: Mocked Logger instance. + mock_radio: Mocked RadioProto instance. + """ packet_manager = PacketManager(mock_logger, mock_radio, "") # Set up mock time to control the flow @@ -244,7 +301,13 @@ def test_receive_success(mock_time, mock_logger, mock_radio): @patch("time.time") def test_receive_timeout(mock_time, mock_logger, mock_radio): - """Test timeout during reception.""" + """Tests timeout during reception. + + Args: + mock_time: Mocked time.time function. + mock_logger: Mocked Logger instance. + mock_radio: Mocked RadioProto instance. + """ packet_manager = PacketManager(mock_logger, mock_radio, "") # Set up mock time to simulate timeout @@ -268,7 +331,12 @@ def test_receive_timeout(mock_time, mock_logger, mock_radio): def test_get_header_and_payload(mock_logger, mock_radio): - """Test _get_header and _get_payload helper methods.""" + """Tests _get_header and _get_payload helper methods. + + Args: + mock_logger: Mocked Logger instance. + mock_radio: Mocked RadioProto instance. + """ packet_manager = PacketManager(mock_logger, mock_radio, "") # Create a test packet @@ -296,7 +364,12 @@ def test_get_header_and_payload(mock_logger, mock_radio): def test_send_acknowledgement(mock_logger, mock_radio): - """Test sending acknowledgment packet.""" + """Tests sending acknowledgment packet. + + Args: + mock_logger: Mocked Logger instance. + mock_radio: Mocked RadioProto instance. + """ packet_manager = PacketManager(mock_logger, mock_radio, "TEST") # Call the send_acknowledgement method diff --git a/tests/unit/hardware/test_burnwire.py b/tests/unit/hardware/test_burnwire.py index 2a1ca311..7077eeb1 100644 --- a/tests/unit/hardware/test_burnwire.py +++ b/tests/unit/hardware/test_burnwire.py @@ -1,3 +1,10 @@ +"""Unit tests for the BurnwireManager class. + +This module contains unit tests for the `BurnwireManager` class, which controls +the activation of burnwires. The tests cover initialization, successful burn +operations, error handling, and cleanup procedures. +""" + from unittest.mock import ANY, MagicMock, patch import pytest @@ -9,21 +16,25 @@ @pytest.fixture def mock_logger(): + """Mocks the Logger class.""" return MagicMock(spec=Logger) @pytest.fixture def mock_enable_burn(): + """Mocks the DigitalInOut pin for enabling the burnwire.""" return MagicMock(spec=DigitalInOut) @pytest.fixture def mock_fire_burn(): + """Mocks the DigitalInOut pin for firing the burnwire.""" return MagicMock(spec=DigitalInOut) @pytest.fixture def burnwire_manager(mock_logger, mock_enable_burn, mock_fire_burn): + """Provides a BurnwireManager instance for testing.""" return BurnwireManager( logger=mock_logger, enable_burn=mock_enable_burn, @@ -35,7 +46,13 @@ def burnwire_manager(mock_logger, mock_enable_burn, mock_fire_burn): def test_burnwire_initialization_default_logic( mock_logger, mock_enable_burn, mock_fire_burn ): - """Test burnwire initialization with default enable_logic=True.""" + """Tests burnwire initialization with default enable_logic=True. + + Args: + mock_logger: Mocked Logger instance. + mock_enable_burn: Mocked enable_burn pin. + mock_fire_burn: Mocked fire_burn pin. + """ manager = BurnwireManager(mock_logger, mock_enable_burn, mock_fire_burn) assert manager._enable_logic is True assert manager.number_of_attempts == 0 @@ -44,7 +61,13 @@ def test_burnwire_initialization_default_logic( def test_burnwire_initialization_inverted_logic( mock_logger, mock_enable_burn, mock_fire_burn ): - """Test burnwire initialization with enable_logic=False.""" + """Tests burnwire initialization with enable_logic=False. + + Args: + mock_logger: Mocked Logger instance. + mock_enable_burn: Mocked enable_burn pin. + mock_fire_burn: Mocked fire_burn pin. + """ manager = BurnwireManager( mock_logger, mock_enable_burn, mock_fire_burn, enable_logic=False ) @@ -53,7 +76,11 @@ def test_burnwire_initialization_inverted_logic( def test_successful_burn(burnwire_manager): - """Test a successful burnwire activation.""" + """Tests a successful burnwire activation. + + Args: + burnwire_manager: BurnwireManager instance for testing. + """ with patch("time.sleep") as mock_sleep: result = burnwire_manager.burn(timeout_duration=1.0) @@ -71,7 +98,11 @@ def test_successful_burn(burnwire_manager): def test_burn_error_handling(burnwire_manager): - """Test error handling during burnwire activation.""" + """Tests error handling during burnwire activation. + + Args: + burnwire_manager: BurnwireManager instance for testing. + """ # Mock the enable_burn pin to raise an exception when setting value type(burnwire_manager._enable_burn).value = property( fset=MagicMock(side_effect=RuntimeError("Hardware failure")) @@ -88,7 +119,11 @@ def test_burn_error_handling(burnwire_manager): def test_cleanup_on_error(burnwire_manager): - """Test that cleanup occurs even when an error happens during burn.""" + """Tests that cleanup occurs even when an error happens during burn. + + Args: + burnwire_manager: BurnwireManager instance for testing. + """ with patch("time.sleep") as mock_sleep: mock_sleep.side_effect = RuntimeError("Unexpected error") @@ -104,7 +139,11 @@ def test_cleanup_on_error(burnwire_manager): def test_attempt_burn_exception_handling(burnwire_manager): - """Test that _attempt_burn properly handles and propagates exceptions.""" + """Tests that _attempt_burn properly handles and propagates exceptions. + + Args: + burnwire_manager: BurnwireManager instance for testing. + """ # Mock the enable_burn pin to raise an exception when setting value type(burnwire_manager._enable_burn).value = property( fset=MagicMock(side_effect=RuntimeError("Hardware failure")) @@ -117,14 +156,18 @@ def test_attempt_burn_exception_handling(burnwire_manager): def test_burn_keyboard_interrupt(burnwire_manager): - """Test that a KeyboardInterrupt during burn is handled and logged, including in _attempt_burn.""" + """Tests that a KeyboardInterrupt during burn is handled and logged, including in _attempt_burn. + + Args: + burnwire_manager: BurnwireManager instance for testing. + """ # Patch _attempt_burn to raise KeyboardInterrupt with patch.object(burnwire_manager, "_attempt_burn", side_effect=KeyboardInterrupt): result = burnwire_manager.burn(timeout_duration=1.0) assert result is False # Check that the log contains the interruption message from burn() found = any( - "Burn Attempt Interupted after" in str(call[0][0]) + "Burn Attempt Interrupted after" in str(call[0][0]) for call in burnwire_manager._log.debug.call_args_list ) assert found @@ -140,7 +183,11 @@ def test_burn_keyboard_interrupt(burnwire_manager): def test_enable_fire_burn_pin_error(burnwire_manager): - """Test that a RuntimeError is raised if setting fire_burn pin fails in _enable.""" + """Tests that a RuntimeError is raised if setting fire_burn pin fails in _enable. + + Args: + burnwire_manager: BurnwireManager instance for testing. + """ # Allow enable_burn to succeed burnwire_manager._enable_burn.value = burnwire_manager._enable_logic # Make fire_burn raise an exception when set @@ -153,7 +200,11 @@ def test_enable_fire_burn_pin_error(burnwire_manager): def test_disable_cleanup_critical_log(burnwire_manager): - """Test that a critical log is made if _disable fails during cleanup and no prior error occurred.""" + """Tests that a critical log is made if _disable fails during cleanup and no prior error occurred. + + Args: + burnwire_manager: BurnwireManager instance for testing. + """ # Patch _enable to succeed with patch.object(burnwire_manager, "_enable", return_value=None): # Patch time.sleep to avoid delay diff --git a/tests/unit/hardware/test_busio.py b/tests/unit/hardware/test_busio.py index e8521a2c..4e0d3033 100644 --- a/tests/unit/hardware/test_busio.py +++ b/tests/unit/hardware/test_busio.py @@ -1,3 +1,11 @@ +"""Unit tests for the busio module. + +This module contains unit tests for the `busio` module, which provides +functionality for initializing SPI and I2C buses. The tests cover successful +initialization, and various failure scenarios including exceptions during +initialization and configuration. +""" + from unittest.mock import MagicMock, patch import pytest @@ -10,6 +18,11 @@ @patch("pysquared.hardware.busio.SPI") def test_initialize_spi_bus_success(mock_spi: MagicMock): + """Tests successful initialization of an SPI bus. + + Args: + mock_spi: Mocked SPI class. + """ # Mock the logger mock_logger = MagicMock(spec=Logger) @@ -43,9 +56,13 @@ def test_initialize_spi_bus_success(mock_spi: MagicMock): assert result == mock_spi_instance -@pytest.mark.slow @patch("pysquared.hardware.busio.SPI") def test_initialize_spi_bus_failure(mock_spi: MagicMock): + """Tests SPI bus initialization failure with retries. + + Args: + mock_spi: Mocked SPI class. + """ # Mock the logger mock_logger = MagicMock(spec=Logger) @@ -62,12 +79,17 @@ def test_initialize_spi_bus_failure(mock_spi: MagicMock): initialize_spi_bus(mock_logger, mock_clock, mock_mosi, mock_miso) # Assertions - assert mock_spi.call_count == 3 # Called 3 times due to retries + mock_spi.assert_called_once() mock_logger.debug.assert_called_with("Initializing spi bus") @patch("pysquared.hardware.busio.SPI") def test_spi_bus_configure_try_lock_failure(mock_spi: MagicMock): + """Tests SPI bus configuration when try_lock fails. + + Args: + mock_spi: Mocked SPI class. + """ # Mock the logger mock_logger = MagicMock(spec=Logger) @@ -93,6 +115,11 @@ def test_spi_bus_configure_try_lock_failure(mock_spi: MagicMock): @patch("pysquared.hardware.busio.SPI") def test_spi_bus_configure_failure(mock_spi: MagicMock): + """Tests SPI bus configuration when configure fails. + + Args: + mock_spi: Mocked SPI class. + """ # Mock the logger mock_logger = MagicMock(spec=Logger) @@ -119,6 +146,11 @@ def test_spi_bus_configure_failure(mock_spi: MagicMock): @patch("pysquared.hardware.busio.I2C") def test_initialize_i2c_bus_success(mock_i2c: MagicMock): + """Tests successful initialization of an I2C bus. + + Args: + mock_i2c: Mocked I2C class. + """ # Mock the logger mock_logger = MagicMock(spec=Logger) @@ -141,9 +173,13 @@ def test_initialize_i2c_bus_success(mock_i2c: MagicMock): assert result == mock_i2c_instance -@pytest.mark.slow @patch("pysquared.hardware.busio.I2C") def test_initialize_i2c_bus_failure(mock_i2c: MagicMock): + """Tests I2C bus initialization failure with retries. + + Args: + mock_i2c: Mocked I2C class. + """ # Mock the logger mock_logger = MagicMock(spec=Logger) @@ -159,5 +195,5 @@ def test_initialize_i2c_bus_failure(mock_i2c: MagicMock): initialize_i2c_bus(mock_logger, mock_scl, mock_sda, 1) # Assertions - assert mock_i2c.call_count == 3 # Called 3 times due to retries + mock_i2c.assert_called_once() mock_logger.debug.assert_called() diff --git a/tests/unit/hardware/test_digitalio.py b/tests/unit/hardware/test_digitalio.py index 5cff6edc..35403ea7 100644 --- a/tests/unit/hardware/test_digitalio.py +++ b/tests/unit/hardware/test_digitalio.py @@ -1,3 +1,10 @@ +"""Unit tests for the digitalio module. + +This module contains unit tests for the `digitalio` module, which provides +functionality for initializing digital input/output pins. The tests cover +successful initialization and failure scenarios. +""" + from unittest.mock import MagicMock, patch import pytest @@ -11,6 +18,12 @@ @patch("pysquared.hardware.digitalio.DigitalInOut") @patch("pysquared.hardware.digitalio.Pin") def test_initialize_pin_success(mock_pin: MagicMock, mock_digital_in_out: MagicMock): + """Tests successful initialization of a digital pin. + + Args: + mock_pin: Mocked Pin class. + mock_digital_in_out: Mocked DigitalInOut class. + """ # Mock the logger mock_logger = MagicMock(spec=Logger) @@ -32,10 +45,15 @@ def test_initialize_pin_success(mock_pin: MagicMock, mock_digital_in_out: MagicM mock_logger.debug.assert_called_once() -@pytest.mark.slow @patch("pysquared.hardware.digitalio.DigitalInOut") @patch("pysquared.hardware.digitalio.Pin") def test_initialize_pin_failure(mock_pin: MagicMock, mock_digital_in_out: MagicMock): + """Tests digital pin initialization failure with retries. + + Args: + mock_pin: Mocked Pin class. + mock_digital_in_out: Mocked DigitalInOut class. + """ # Mock the logger mock_logger = MagicMock(spec=Logger) @@ -52,5 +70,5 @@ def test_initialize_pin_failure(mock_pin: MagicMock, mock_digital_in_out: MagicM initialize_pin(mock_logger, mock_pin, mock_direction, initial_value) # Assertions - assert mock_digital_in_out.call_count == 3 # Called 3 times due to retries + mock_digital_in_out.assert_called_once_with(mock_pin) mock_logger.debug.assert_called() diff --git a/tests/unit/nvm/test_counter.py b/tests/unit/nvm/test_counter.py index f35616c9..b10fb878 100644 --- a/tests/unit/nvm/test_counter.py +++ b/tests/unit/nvm/test_counter.py @@ -1,3 +1,10 @@ +"""Unit tests for the Counter class in the NVM module. + +This module contains unit tests for the `Counter` class, which provides a +persistent counter stored in non-volatile memory (NVM). The tests cover +counter initialization, incrementing, and handling of NVM availability. +""" + from unittest.mock import MagicMock, patch import pytest @@ -8,8 +15,10 @@ @patch("pysquared.nvm.counter.microcontroller") def test_counter_bounds(mock_microcontroller: MagicMock): - """ - Test that the counter class correctly handles values that are inside and outside the bounds of its bit length + """Tests that the counter class correctly handles values that are inside and outside the bounds of its bit length. + + Args: + mock_microcontroller: Mocked microcontroller module. """ datastore = ByteArray(size=1) mock_microcontroller.nvm = datastore @@ -32,6 +41,11 @@ def test_counter_bounds(mock_microcontroller: MagicMock): def test_writing_to_multiple_counters_in_same_datastore( mock_microcontroller: MagicMock, ): + """Tests writing to multiple counters that share the same datastore. + + Args: + mock_microcontroller: Mocked microcontroller module. + """ datastore = ByteArray(size=2) mock_microcontroller.nvm = datastore @@ -45,6 +59,11 @@ def test_writing_to_multiple_counters_in_same_datastore( @patch("pysquared.nvm.counter.microcontroller") def test_counter_raises_error_when_nvm_is_none(mock_microcontroller: MagicMock): + """Tests that the Counter raises a ValueError when NVM is not available. + + Args: + mock_microcontroller: Mocked microcontroller module. + """ mock_microcontroller.nvm = None with pytest.raises(ValueError, match="nvm is not available"): @@ -53,6 +72,11 @@ def test_counter_raises_error_when_nvm_is_none(mock_microcontroller: MagicMock): @patch("pysquared.nvm.counter.microcontroller") def test_get_name(mock_microcontroller: MagicMock): + """Tests the get_name method of the Counter class. + + Args: + mock_microcontroller: Mocked microcontroller module. + """ datastore = ByteArray(size=2) mock_microcontroller.nvm = datastore diff --git a/tests/unit/nvm/test_flag.py b/tests/unit/nvm/test_flag.py index 4d68a65e..bdef9aae 100644 --- a/tests/unit/nvm/test_flag.py +++ b/tests/unit/nvm/test_flag.py @@ -1,3 +1,10 @@ +"""Unit tests for the Flag class in the NVM module. + +This module contains unit tests for the `Flag` class, which provides a persistent +boolean flag stored in non-volatile memory (NVM). The tests cover flag +initialization, getting and setting flag values, and handling of NVM availability. +""" + from unittest.mock import MagicMock, patch import pytest @@ -8,11 +15,18 @@ @pytest.fixture def setup_datastore(): + """Sets up a mock datastore for NVM components.""" return ByteArray(size=17) @patch("pysquared.nvm.flag.microcontroller") def test_init(mock_microcontroller: MagicMock, setup_datastore: ByteArray): + """Tests Flag initialization. + + Args: + mock_microcontroller: Mocked microcontroller module. + setup_datastore: Fixture providing the mock datastore. + """ mock_microcontroller.nvm = ( setup_datastore # Mock the nvm module to use the ByteArray ) @@ -24,6 +38,12 @@ def test_init(mock_microcontroller: MagicMock, setup_datastore: ByteArray): @patch("pysquared.nvm.flag.microcontroller") def test_get(mock_microcontroller: MagicMock, setup_datastore: ByteArray): + """Tests getting the flag value. + + Args: + mock_microcontroller: Mocked microcontroller module. + setup_datastore: Fixture providing the mock datastore. + """ mock_microcontroller.nvm = ( setup_datastore # Mock the nvm module to use the ByteArray ) @@ -37,6 +57,12 @@ def test_get(mock_microcontroller: MagicMock, setup_datastore: ByteArray): @patch("pysquared.nvm.flag.microcontroller") def test_toggle(mock_microcontroller: MagicMock, setup_datastore: ByteArray): + """Tests toggling the flag value. + + Args: + mock_microcontroller: Mocked microcontroller module. + setup_datastore: Fixture providing the mock datastore. + """ mock_microcontroller.nvm = ( setup_datastore # Mock the nvm module to use the ByteArray ) @@ -61,6 +87,12 @@ def test_toggle(mock_microcontroller: MagicMock, setup_datastore: ByteArray): @patch("pysquared.nvm.flag.microcontroller") def test_edge_cases(mock_microcontroller: MagicMock, setup_datastore: ByteArray): + """Tests edge cases for flag manipulation. + + Args: + mock_microcontroller: Mocked microcontroller module. + setup_datastore: Fixture providing the mock datastore. + """ mock_microcontroller.nvm = ( setup_datastore # Mock the nvm module to use the ByteArray ) @@ -77,6 +109,11 @@ def test_edge_cases(mock_microcontroller: MagicMock, setup_datastore: ByteArray) @patch("pysquared.nvm.flag.microcontroller") def test_counter_raises_error_when_nvm_is_none(mock_microcontroller: MagicMock): + """Tests that the Flag raises a ValueError when NVM is not available. + + Args: + mock_microcontroller: Mocked microcontroller module. + """ mock_microcontroller.nvm = None with pytest.raises(ValueError, match="nvm is not available"): @@ -85,6 +122,11 @@ def test_counter_raises_error_when_nvm_is_none(mock_microcontroller: MagicMock): @patch("pysquared.nvm.flag.microcontroller") def test_get_name(mock_microcontroller: MagicMock): + """Tests the get_name method of the Flag class. + + Args: + mock_microcontroller: Mocked microcontroller module. + """ mock_microcontroller.nvm = ( setup_datastore # Mock the nvm module to use the ByteArray ) diff --git a/tests/unit/other/other_test_config.py b/tests/unit/other/other_test_config.py index 833d8651..be2f9241 100644 --- a/tests/unit/other/other_test_config.py +++ b/tests/unit/other/other_test_config.py @@ -1,3 +1,10 @@ +"""Unit tests for the configuration validation logic. + +This module contains unit tests for the configuration validation functions, +ensuring that the configuration data adheres to the defined schema and business +rules. It covers type checking, range validation, and presence of required fields. +""" + import json from pathlib import Path from typing import Any, Dict @@ -32,7 +39,15 @@ def validate_config(config: Dict[str, Any]) -> None: - """Validate config data against schema and business rules.""" + """Validates config data against schema and business rules. + + Args: + config: The configuration dictionary to validate. + + Raises: + ValueError: If a required field is missing, a value is out of range, or a list is empty. + TypeError: If a field has an incorrect type. + """ # Validate field presence and types for field, expected_type in CONFIG_SCHEMA.items(): if field not in config: @@ -139,7 +154,17 @@ def validate_config(config: Dict[str, Any]) -> None: def load_config(config_path: str) -> dict: - """Load and parse the config file.""" + """Loads and parses the config file. + + Args: + config_path: The path to the configuration file. + + Returns: + A dictionary containing the loaded configuration. + + Raises: + pytest.fail: If the JSON is invalid or the file is not found. + """ try: with open(config_path, "r") as f: return json.load(f) @@ -151,26 +176,49 @@ def load_config(config_path: str) -> dict: @pytest.fixture def config_data(): - """Fixture to load the config data.""" + """Fixture to load the config data from the default config.json. + + Returns: + A dictionary containing the loaded configuration data. + """ workspace_root = Path(__file__).parent.parent.parent config_path = workspace_root / "config.json" return load_config(str(config_path)) def test_config_file_exists(): - """Test that config.json exists.""" + """Tests that config.json exists. + + This test verifies that the `config.json` file is present in the expected + location within the project structure. + """ workspace_root = Path(__file__).parent.parent.parent config_path = workspace_root / "config.json" assert config_path.exists(), "config.json file not found" def test_config_is_valid_json(config_data): - """Test that config.json is valid JSON.""" + """Tests that config.json is valid JSON. + + Args: + config_data: Fixture providing the loaded configuration data. + + This test ensures that the content of `config.json` can be successfully + parsed as a JSON object. + """ assert isinstance(config_data, dict), "Config file is not a valid JSON object" def test_config_validation(config_data): - """Test that config.json matches the expected schema and business rules.""" + """Tests that config.json matches the expected schema and business rules. + + Args: + config_data: Fixture providing the loaded configuration data. + + This test calls the `validate_config` function to ensure that the loaded + configuration adheres to all defined validation rules, including field + presence, types, and ranges. + """ try: validate_config(config_data) except (ValueError, TypeError) as e: @@ -178,7 +226,15 @@ def test_config_validation(config_data): def test_field_types(config_data): - """Test that all fields have correct types.""" + """Tests that all fields have correct types. + + Args: + config_data: Fixture providing the loaded configuration data. + + This test individually checks the data type of various fields within the + configuration to ensure they match the expected Python types (string, int, + float, bool, list, dict). + """ # Test string fields string_fields = ["cubesat_name", "callsign", "super_secret_code", "repeat_code"] for field in string_fields: @@ -222,9 +278,9 @@ def test_field_types(config_data): list_fields = ["jokes"] for field in list_fields: assert isinstance(config_data[field], list), f"{field} must be a list" - assert all( - isinstance(item, str) for item in config_data[field] - ), f"All items in {field} must be strings" + assert all(isinstance(item, str) for item in config_data[field]), ( + f"All items in {field} must be strings" + ) # Test radio config assert isinstance(config_data["radio"], dict), "radio must be a dictionary" @@ -235,28 +291,28 @@ def test_field_types(config_data): "start_time": int, } for field, expected_type in radio_basic_fields.items(): - assert isinstance( - config_data["radio"][field], expected_type - ), f"radio.{field} must be a {expected_type.__name__}" + assert isinstance(config_data["radio"][field], expected_type), ( + f"radio.{field} must be a {expected_type.__name__}" + ) # Test FSK fields - assert isinstance( - config_data["radio"]["fsk"], dict - ), "radio.fsk must be a dictionary" + assert isinstance(config_data["radio"]["fsk"], dict), ( + "radio.fsk must be a dictionary" + ) fsk_fields = { "broadcast_address": int, "node_address": int, "modulation_type": int, } for field, expected_type in fsk_fields.items(): - assert isinstance( - config_data["radio"]["fsk"][field], expected_type - ), f"radio.fsk.{field} must be a {expected_type.__name__}" + assert isinstance(config_data["radio"]["fsk"][field], expected_type), ( + f"radio.fsk.{field} must be a {expected_type.__name__}" + ) # Test LoRa fields - assert isinstance( - config_data["radio"]["lora"], dict - ), "radio.lora must be a dictionary" + assert isinstance(config_data["radio"]["lora"], dict), ( + "radio.lora must be a dictionary" + ) lora_fields = { "ack_delay": float, "coding_rate": int, @@ -265,13 +321,20 @@ def test_field_types(config_data): "transmit_power": int, } for field, expected_type in lora_fields.items(): - assert isinstance( - config_data["radio"]["lora"][field], expected_type - ), f"radio.lora.{field} must be a {expected_type.__name__}" + assert isinstance(config_data["radio"]["lora"][field], expected_type), ( + f"radio.lora.{field} must be a {expected_type.__name__}" + ) def test_voltage_ranges(config_data): - """Test that voltage values are within expected ranges.""" + """Tests that voltage values are within expected ranges. + + Args: + config_data: Fixture providing the loaded configuration data. + + This test verifies that battery-related voltage values fall within a + reasonable operational range (5.2V to 8.4V). + """ voltage_fields = [ "battery_voltage", "normal_battery_voltage", @@ -283,19 +346,40 @@ def test_voltage_ranges(config_data): def test_time_values(config_data): - """Test that time values are positive.""" + """Tests that time values are positive. + + Args: + config_data: Fixture providing the loaded configuration data. + + This test ensures that `sleep_duration` and `reboot_time` are positive + integers, as negative or zero values would be illogical for these settings. + """ assert config_data["sleep_duration"] > 0, "sleep_duration must be positive" assert config_data["reboot_time"] > 0, "reboot_time must be positive" def test_current_draw_positive(config_data): - """Test that current draw is not negative.""" + """Tests that current draw is not negative. + + Args: + config_data: Fixture providing the loaded configuration data. + + This test verifies that the `current_draw` value is non-negative, as a + negative current draw would imply an impossible scenario in this context. + """ assert config_data["current_draw"] >= 0, "current_draw cannot be negative" def test_lists_not_empty(config_data): - """Test that list fields are not empty.""" + """Tests that list fields are not empty. + + Args: + config_data: Fixture providing the loaded configuration data. + + This test specifically checks that the `jokes` list is not empty and that + all its elements are strings, ensuring valid content for this field. + """ assert len(config_data["jokes"]) > 0, "jokes list cannot be empty" - assert all( - isinstance(joke, str) for joke in config_data["jokes"] - ), "All jokes must be strings" + assert all(isinstance(joke, str) for joke in config_data["jokes"]), ( + "All jokes must be strings" + ) diff --git a/tests/unit/rtc/manager/test_microcontroller_manager.py b/tests/unit/rtc/manager/test_microcontroller_manager.py index 7ff1bada..ba768ef8 100644 --- a/tests/unit/rtc/manager/test_microcontroller_manager.py +++ b/tests/unit/rtc/manager/test_microcontroller_manager.py @@ -1,3 +1,10 @@ +"""Unit tests for the MicrocontrollerManager class. + +This module contains unit tests for the `MicrocontrollerManager` class, which +manages the microcontroller's built-in Real-Time Clock (RTC). The tests cover +initialization and setting the time. +""" + import time import pytest @@ -8,23 +15,34 @@ @pytest.fixture(autouse=True) def cleanup(): + """Cleans up the MockRTC instance after each test.""" yield MockRTC().destroy() def test_init(): - """Test that the RTC.datetime is initialized with a time.struct_time""" + """Tests that the RTC.datetime is initialized with a time.struct_time. + + This test verifies that upon initialization of `MicrocontrollerManager`, + the underlying mock RTC's `datetime` attribute is set to a `time.struct_time` + instance, indicating proper setup. + """ MicrocontrollerManager() mrtc: MockRTC = MockRTC() assert mrtc.datetime is not None, "Mock RTC datetime should be set" - assert isinstance( - mrtc.datetime, time.struct_time - ), "Mock RTC datetime should be a time.struct_time instance" + assert isinstance(mrtc.datetime, time.struct_time), ( + "Mock RTC datetime should be a time.struct_time instance" + ) def test_set_time(): - """Test that the RP2040RTCManager.set_time method correctly sets RTC.datetime""" + """Tests that the MicrocontrollerManager.set_time method correctly sets RTC.datetime. + + This test verifies that calling `set_time` on the `MicrocontrollerManager` + updates the mock RTC's `datetime` attribute with the provided time components, + and that the individual components of the `struct_time` match the input. + """ year = 2025 month = 3 day = 6 @@ -40,9 +58,9 @@ def test_set_time(): # Get the mock RTC instance and check its datetime mrtc: MockRTC = MockRTC() assert mrtc.datetime is not None, "Mock RTC datetime should be set" - assert isinstance( - mrtc.datetime, time.struct_time - ), "Mock RTC datetime should be a time.struct_time instance" + assert isinstance(mrtc.datetime, time.struct_time), ( + "Mock RTC datetime should be a time.struct_time instance" + ) assert mrtc.datetime.tm_year == year, "Year should match" assert mrtc.datetime.tm_mon == month, "Month should match" diff --git a/tests/unit/rtc/manager/test_rv3028_manager.py b/tests/unit/rtc/manager/test_rv3028_manager.py index df273d04..bf54764d 100644 --- a/tests/unit/rtc/manager/test_rv3028_manager.py +++ b/tests/unit/rtc/manager/test_rv3028_manager.py @@ -1,3 +1,10 @@ +"""Unit tests for the RV3028Manager class. + +This module contains unit tests for the `RV3028Manager` class, which manages +the RV3028 Real-Time Clock (RTC). The tests cover initialization, successful +time setting, and error handling during time setting operations. +""" + from typing import Generator from unittest.mock import MagicMock, patch @@ -24,26 +31,45 @@ def mock_logger() -> MagicMock: @pytest.fixture def mock_rv3028(mock_i2c: MagicMock) -> Generator[MagicMock, None, None]: + """Mocks the RV3028 class. + + Args: + mock_i2c: Mocked I2C bus. + + Yields: + A MagicMock instance of RV3028. + """ with patch("pysquared.rtc.manager.rv3028.RV3028") as mock_class: mock_class.return_value = RV3028(mock_i2c) yield mock_class def test_create_rtc(mock_rv3028, mock_i2c: MagicMock, mock_logger: MagicMock) -> None: - """Test successful creation of an RV3028 RTC instance.""" + """Tests successful creation of an RV3028 RTC instance. + + Args: + mock_rv3028: Mocked RV3028 class. + mock_i2c: Mocked I2C bus. + mock_logger: Mocked Logger instance. + """ rtc_manager = RV3028Manager(mock_logger, mock_i2c) assert isinstance(rtc_manager._rtc, RV3028) mock_logger.debug.assert_called_once_with("Initializing RTC") -@pytest.mark.slow -def test_create_with_retries( +def test_create_rtc_failed( mock_rv3028: MagicMock, mock_i2c: MagicMock, mock_logger: MagicMock, ) -> None: - """Test that initialization is retried when it fails.""" + """Tests that initialization is retried when it fails. + + Args: + mock_rv3028: Mocked RV3028 class. + mock_i2c: Mocked I2C bus. + mock_logger: Mocked Logger instance. + """ mock_rv3028.side_effect = Exception("Simulated RV3028 failure") with pytest.raises(HardwareInitializationError): @@ -57,7 +83,13 @@ def test_create_with_retries( def test_set_time_success( mock_rv3028, mock_i2c: MagicMock, mock_logger: MagicMock ) -> None: - """Test successful setting of the time.""" + """Tests successful setting of the time. + + Args: + mock_rv3028: Mocked RV3028 class. + mock_i2c: Mocked I2C bus. + mock_logger: Mocked Logger instance. + """ rtc_manager = RV3028Manager(mock_logger, mock_i2c) rtc_manager._rtc = MagicMock(spec=RV3028) @@ -76,7 +108,13 @@ def test_set_time_success( def test_set_time_failure_set_date( mock_rv3028, mock_i2c: MagicMock, mock_logger: MagicMock ) -> None: - """Test handling of exceptions during set_date.""" + """Tests handling of exceptions during set_date. + + Args: + mock_rv3028: Mocked RV3028 class. + mock_i2c: Mocked I2C bus. + mock_logger: Mocked Logger instance. + """ rtc_manager = RV3028Manager(mock_logger, mock_i2c) rtc_manager._rtc = MagicMock(spec=RV3028) rtc_manager._rtc.set_date = MagicMock() @@ -97,7 +135,13 @@ def test_set_time_failure_set_date( def test_set_time_failure_set_time( mock_rv3028, mock_i2c: MagicMock, mock_logger: MagicMock ) -> None: - """Test handling of exceptions during set_time.""" + """Tests handling of exceptions during set_time. + + Args: + mock_rv3028: Mocked RV3028 class. + mock_i2c: Mocked I2C bus. + mock_logger: Mocked Logger instance. + """ rtc_manager = RV3028Manager(mock_logger, mock_i2c) rtc_manager._rtc = MagicMock(spec=RV3028) rtc_manager._rtc.set_date = MagicMock() diff --git a/tests/unit/test_beacon.py b/tests/unit/test_beacon.py index b5cd2718..09f7d04f 100644 --- a/tests/unit/test_beacon.py +++ b/tests/unit/test_beacon.py @@ -1,3 +1,10 @@ +"""Unit tests for the Beacon class. + +This module contains unit tests for the `Beacon` class, which is responsible for +collecting and sending telemetry data. The tests cover initialization, basic +sending functionality, and sending with various sensor types. +""" + import json import time from typing import Optional, Type @@ -22,70 +29,103 @@ @pytest.fixture def mock_logger() -> MagicMock: + """Mocks the Logger class.""" return MagicMock(spec=Logger) @pytest.fixture def mock_packet_manager() -> MagicMock: + """Mocks the PacketManager class.""" return MagicMock(spec=PacketManager) class MockRadio(RadioProto): + """Mocks the RadioProto for testing.""" + def send(self, data: object) -> bool: + """Mocks the send method.""" return True def set_modulation(self, modulation: Type[RadioModulation]) -> None: + """Mocks the set_modulation method.""" pass def get_modulation(self) -> Type[RadioModulation]: + """Mocks the get_modulation method.""" return LoRa def receive(self, timeout: Optional[int] = None) -> Optional[bytes]: + """Mocks the receive method.""" return b"test_data" class MockFlag(Flag): + """Mocks the Flag class for testing.""" + def get(self) -> bool: + """Mocks the get method.""" return True def get_name(self) -> str: + """Mocks the get_name method.""" return "test_flag" class MockCounter(Counter): + """Mocks the Counter class for testing.""" + def get(self) -> int: + """Mocks the get method.""" return 42 def get_name(self) -> str: + """Mocks the get_name method.""" return "test_counter" class MockPowerMonitor(PowerMonitorProto): + """Mocks the PowerMonitorProto for testing.""" + def get_current(self) -> float: + """Mocks the get_current method.""" return 0.5 def get_bus_voltage(self) -> float: + """Mocks the get_bus_voltage method.""" return 3.3 def get_shunt_voltage(self) -> float: + """Mocks the get_shunt_voltage method.""" return 0.1 class MockTemperatureSensor(TemperatureSensorProto): + """Mocks the TemperatureSensorProto for testing.""" + def get_temperature(self) -> float: + """Mocks the get_temperature method.""" return 22.5 class MockIMU(IMUProto): + """Mocks the IMUProto for testing.""" + def get_gyro_data(self) -> tuple[float, float, float]: + """Mocks the get_gyro_data method.""" return (0.1, 2.3, 4.5) def get_acceleration(self) -> tuple[float, float, float]: + """Mocks the get_acceleration method.""" return (5.4, 3.2, 1.0) def test_beacon_init(mock_logger, mock_packet_manager): - """Test Beacon initialization.""" + """Tests Beacon initialization. + + Args: + mock_logger: Mocked Logger instance. + mock_packet_manager: Mocked PacketManager instance. + """ boot_time = time.time() beacon = Beacon(mock_logger, "test_beacon", mock_packet_manager, boot_time) @@ -99,7 +139,13 @@ def test_beacon_init(mock_logger, mock_packet_manager): @freeze_time(time_to_freeze="2025-05-16 12:34:56", tz_offset=0) @patch("time.time") def test_beacon_send_basic(mock_time, mock_logger, mock_packet_manager): - """Test sending a basic beacon with no sensors.""" + """Tests sending a basic beacon with no sensors. + + Args: + mock_time: Mocked time.time function. + mock_logger: Mocked Logger instance. + mock_packet_manager: Mocked PacketManager instance. + """ boot_time = 1000.0 mock_time.return_value = 1060.0 # 60 seconds after boot @@ -116,6 +162,7 @@ def test_beacon_send_basic(mock_time, mock_logger, mock_packet_manager): @pytest.fixture def setup_datastore(): + """Sets up a mock datastore for NVM components.""" return ByteArray(size=17) @@ -127,7 +174,14 @@ def test_beacon_send_with_sensors( mock_logger, mock_packet_manager, ): - """Test sending a beacon with sensors.""" + """Tests sending a beacon with various sensor types. + + Args: + mock_flag_microcontroller: Mocked microcontroller for Flag. + mock_counter_microcontroller: Mocked microcontroller for Counter. + mock_logger: Mocked Logger instance. + mock_packet_manager: Mocked PacketManager instance. + """ mock_flag_microcontroller.nvm = ( setup_datastore # Mock the nvm module to use the ByteArray ) @@ -185,11 +239,17 @@ def test_beacon_send_with_sensors( def test_beacon_avg_readings(mock_logger, mock_packet_manager): - """Test the avg_readings method in the context of the Beacon class.""" + """Tests the avg_readings method in the context of the Beacon class. + + Args: + mock_logger: Mocked Logger instance. + mock_packet_manager: Mocked PacketManager instance. + """ beacon = Beacon(mock_logger, "test_beacon", mock_packet_manager, 0.0) # Test with a function that returns consistent values def constant_func(): + """Returns a constant value.""" return 5.0 result = beacon.avg_readings(constant_func, num_readings=5) @@ -197,6 +257,7 @@ def constant_func(): # Test with a function that returns None def none_func(): + """Returns None to simulate a sensor failure.""" return None result = beacon.avg_readings(none_func) @@ -205,7 +266,12 @@ def none_func(): def test_avg_readings_varying_values(mock_logger, mock_packet_manager): - """Test avg_readings with values that vary.""" + """Tests avg_readings with values that vary. + + Args: + mock_logger: Mocked Logger instance. + mock_packet_manager: Mocked PacketManager instance. + """ beacon = Beacon(mock_logger, "test_beacon", mock_packet_manager, 0.0) # Create a simple counter function that returns incrementing values @@ -216,6 +282,7 @@ def test_avg_readings_varying_values(mock_logger, mock_packet_manager): read_count = 0 def incrementing_func(): + """Returns incrementing values from the list.""" nonlocal read_count value = values[read_count % len(values)] read_count += 1 diff --git a/tests/unit/test_cdh.py b/tests/unit/test_cdh.py index 4654beec..d935969e 100644 --- a/tests/unit/test_cdh.py +++ b/tests/unit/test_cdh.py @@ -1,3 +1,10 @@ +"""Unit tests for the CommandDataHandler class. + +This module contains unit tests for the `CommandDataHandler` class, which is +responsible for processing commands received by the satellite. The tests cover +initialization, command parsing, and execution of various commands. +""" + import json from unittest.mock import MagicMock, patch @@ -11,16 +18,19 @@ @pytest.fixture def mock_logger() -> MagicMock: + """Mocks the Logger class.""" return MagicMock(spec=Logger) @pytest.fixture def mock_packet_manager() -> MagicMock: + """Mocks the PacketManager class.""" return MagicMock(spec=PacketManager) @pytest.fixture def mock_config() -> MagicMock: + """Mocks the Config class.""" config = MagicMock(spec=Config) config.super_secret_code = "test_password" config.cubesat_name = "test_satellite" @@ -30,6 +40,7 @@ def mock_config() -> MagicMock: @pytest.fixture def cdh(mock_logger, mock_config, mock_packet_manager) -> CommandDataHandler: + """Provides a CommandDataHandler instance for testing.""" return CommandDataHandler( logger=mock_logger, config=mock_config, @@ -38,7 +49,13 @@ def cdh(mock_logger, mock_config, mock_packet_manager) -> CommandDataHandler: def test_cdh_init(mock_logger, mock_config, mock_packet_manager): - """Test CommandDataHandler initialization.""" + """Tests CommandDataHandler initialization. + + Args: + mock_logger: Mocked Logger instance. + mock_config: Mocked Config instance. + mock_packet_manager: Mocked PacketManager instance. + """ cdh = CommandDataHandler(mock_logger, mock_config, mock_packet_manager) assert cdh._log is mock_logger @@ -47,7 +64,12 @@ def test_cdh_init(mock_logger, mock_config, mock_packet_manager): def test_listen_for_commands_no_message(cdh, mock_packet_manager): - """Test listen_for_commands when no message is received.""" + """Tests listen_for_commands when no message is received. + + Args: + cdh: CommandDataHandler instance. + mock_packet_manager: Mocked PacketManager instance. + """ mock_packet_manager.listen.return_value = None cdh.listen_for_commands(30) @@ -57,7 +79,13 @@ def test_listen_for_commands_no_message(cdh, mock_packet_manager): def test_listen_for_commands_invalid_password(cdh, mock_packet_manager, mock_logger): - """Test listen_for_commands with invalid password.""" + """Tests listen_for_commands with invalid password. + + Args: + cdh: CommandDataHandler instance. + mock_packet_manager: Mocked PacketManager instance. + mock_logger: Mocked Logger instance. + """ # Create a message with wrong password message = {"password": "wrong_password", "command": "send_joke", "args": []} mock_packet_manager.listen.return_value = json.dumps(message).encode("utf-8") @@ -69,7 +97,13 @@ def test_listen_for_commands_invalid_password(cdh, mock_packet_manager, mock_log def test_listen_for_commands_invalid_name(cdh, mock_packet_manager, mock_logger): - """Test listen_for_commands with missing command field.""" + """Tests listen_for_commands with missing command field. + + Args: + cdh: CommandDataHandler instance. + mock_packet_manager: Mocked PacketManager instance. + mock_logger: Mocked Logger instance. + """ # Create a message with valid password and satellite name but no command message = {"password": "test_password", "name": "wrong_name", "args": []} mock_packet_manager.listen.return_value = json.dumps(message).encode("utf-8") @@ -81,7 +115,13 @@ def test_listen_for_commands_invalid_name(cdh, mock_packet_manager, mock_logger) def test_listen_for_commands_missing_command(cdh, mock_packet_manager, mock_logger): - """Test listen_for_commands with missing command field.""" + """Tests listen_for_commands with missing command field. + + Args: + cdh: CommandDataHandler instance. + mock_packet_manager: Mocked PacketManager instance. + mock_logger: Mocked Logger instance. + """ # Create a message with valid password but no command message = {"password": "test_password", "name": "test_satellite", "args": []} mock_packet_manager.listen.return_value = json.dumps(message).encode("utf-8") @@ -93,7 +133,13 @@ def test_listen_for_commands_missing_command(cdh, mock_packet_manager, mock_logg def test_listen_for_commands_nonlist_args(cdh, mock_packet_manager, mock_logger): - """Test listen_for_commands with missing command field.""" + """Tests listen_for_commands with missing command field. + + Args: + cdh: CommandDataHandler instance. + mock_packet_manager: Mocked PacketManager instance. + mock_logger: Mocked Logger instance. + """ # Create a message with valid password but no command message = { "password": "test_password", @@ -112,7 +158,13 @@ def test_listen_for_commands_nonlist_args(cdh, mock_packet_manager, mock_logger) def test_listen_for_commands_invalid_json(cdh, mock_packet_manager, mock_logger): - """Test listen_for_commands with invalid JSON.""" + """Tests listen_for_commands with invalid JSON. + + Args: + cdh: CommandDataHandler instance. + mock_packet_manager: Mocked PacketManager instance. + mock_logger: Mocked Logger instance. + """ message = b"this is not valid json" mock_packet_manager.listen.return_value = message @@ -125,7 +177,14 @@ def test_listen_for_commands_invalid_json(cdh, mock_packet_manager, mock_logger) @patch("random.choice") def test_send_joke(mock_random_choice, cdh, mock_packet_manager, mock_config): - """Test the send_joke method.""" + """Tests the send_joke method. + + Args: + mock_random_choice: Mocked random.choice function. + cdh: CommandDataHandler instance. + mock_packet_manager: Mocked PacketManager instance. + mock_config: Mocked Config instance. + """ mock_random_choice.return_value = mock_config.jokes[0] cdh.send_joke() @@ -137,7 +196,13 @@ def test_send_joke(mock_random_choice, cdh, mock_packet_manager, mock_config): def test_change_radio_modulation_success(cdh, mock_config, mock_logger): - """Test change_radio_modulation with valid modulation value.""" + """Tests change_radio_modulation with valid modulation value. + + Args: + cdh: CommandDataHandler instance. + mock_config: Mocked Config instance. + mock_logger: Mocked Logger instance. + """ modulation = ["FSK"] cdh.change_radio_modulation(modulation) @@ -151,7 +216,14 @@ def test_change_radio_modulation_success(cdh, mock_config, mock_logger): def test_change_radio_modulation_failure( cdh, mock_config, mock_logger, mock_packet_manager ): - """Test change_radio_modulation with an error case.""" + """Tests change_radio_modulation with an error case. + + Args: + cdh: CommandDataHandler instance. + mock_config: Mocked Config instance. + mock_logger: Mocked Logger instance. + mock_packet_manager: Mocked PacketManager instance. + """ modulation = ["INVALID"] mock_config.update_config.side_effect = ValueError("Invalid modulation") @@ -164,7 +236,13 @@ def test_change_radio_modulation_failure( def test_change_radio_modulation_no_modulation(cdh, mock_logger, mock_packet_manager): - """Test change_radio_modulation when no modulation is specified.""" + """Tests change_radio_modulation when no modulation is specified. + + Args: + cdh: CommandDataHandler instance. + mock_logger: Mocked Logger instance. + mock_packet_manager: Mocked PacketManager instance. + """ # Call the method with an empty list cdh.change_radio_modulation([]) @@ -182,7 +260,13 @@ def test_change_radio_modulation_no_modulation(cdh, mock_logger, mock_packet_man @patch("pysquared.cdh.microcontroller") def test_reset(mock_microcontroller, cdh, mock_logger): - """Test the reset method.""" + """Tests the reset method. + + Args: + mock_microcontroller: Mocked microcontroller module. + cdh: CommandDataHandler instance. + mock_logger: Mocked Logger instance. + """ mock_microcontroller.reset = MagicMock() mock_microcontroller.on_next_reset = MagicMock() mock_microcontroller.RunMode = MagicMock() @@ -199,7 +283,13 @@ def test_reset(mock_microcontroller, cdh, mock_logger): @patch("pysquared.cdh.microcontroller") def test_listen_for_commands_reset(mock_microcontroller, cdh, mock_packet_manager): - """Test listen_for_commands with reset command.""" + """Tests listen_for_commands with reset command. + + Args: + mock_microcontroller: Mocked microcontroller module. + cdh: CommandDataHandler instance. + mock_packet_manager: Mocked PacketManager instance. + """ # Set up mocked attributes mock_microcontroller.reset = MagicMock() mock_microcontroller.on_next_reset = MagicMock() @@ -224,7 +314,14 @@ def test_listen_for_commands_reset(mock_microcontroller, cdh, mock_packet_manage def test_listen_for_commands_send_joke( mock_random_choice, cdh, mock_packet_manager, mock_config ): - """Test listen_for_commands with send_joke command.""" + """Tests listen_for_commands with send_joke command. + + Args: + mock_random_choice: Mocked random.choice function. + cdh: CommandDataHandler instance. + mock_packet_manager: Mocked PacketManager instance. + mock_config: Mocked Config instance. + """ message = { "password": "test_password", "name": "test_satellite", @@ -244,7 +341,13 @@ def test_listen_for_commands_send_joke( def test_listen_for_commands_change_radio_modulation( cdh, mock_packet_manager, mock_config ): - """Test listen_for_commands with change_radio_modulation command.""" + """Tests listen_for_commands with change_radio_modulation command. + + Args: + cdh: CommandDataHandler instance. + mock_packet_manager: Mocked PacketManager instance. + mock_config: Mocked Config instance. + """ message = { "password": "test_password", "name": "test_satellite", @@ -261,7 +364,13 @@ def test_listen_for_commands_change_radio_modulation( def test_listen_for_commands_unknown_command(cdh, mock_packet_manager, mock_logger): - """Test listen_for_commands with an unknown command.""" + """Tests listen_for_commands with an unknown command. + + Args: + cdh: CommandDataHandler instance. + mock_packet_manager: Mocked PacketManager instance. + mock_logger: Mocked Logger instance. + """ message = { "password": "test_password", "name": "test_satellite", diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 5052218f..2670bf74 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -1,3 +1,10 @@ +"""Unit tests for the Config class. + +This module contains unit tests for the `Config` class, which is responsible for +loading, validating, and updating configuration settings. The tests cover various +data types, validation rules, and update scenarios. +""" + import json import os import tempfile @@ -9,6 +16,7 @@ @pytest.fixture(autouse=True) def cleanup(): + """Sets up a temporary config file for testing and cleans it up afterwards.""" temp_dir = tempfile.mkdtemp() file = os.path.join(temp_dir, "config.test.json") @@ -21,6 +29,11 @@ def cleanup(): def test_radio_cfg(cleanup) -> None: + """Tests the radio configuration properties. + + Args: + cleanup: Fixture providing the path to the temporary config file. + """ file = cleanup with open(file, "r") as f: json_data = json.loads(f.read()) @@ -31,29 +44,29 @@ def test_radio_cfg(cleanup) -> None: assert ( config.radio.transmit_frequency == json_data["radio"]["transmit_frequency"] ), "No match for: transmit_frequency" - assert ( - config.radio.start_time == json_data["radio"]["start_time"] - ), "No match for: start_time" + assert config.radio.start_time == json_data["radio"]["start_time"], ( + "No match for: start_time" + ) # Test FSK config properties assert ( config.radio.fsk.broadcast_address == json_data["radio"]["fsk"]["broadcast_address"] ), "No match for: fsk.broadcast_address" - assert ( - config.radio.fsk.node_address == json_data["radio"]["fsk"]["node_address"] - ), "No match for: fsk.node_address" + assert config.radio.fsk.node_address == json_data["radio"]["fsk"]["node_address"], ( + "No match for: fsk.node_address" + ) assert ( config.radio.fsk.modulation_type == json_data["radio"]["fsk"]["modulation_type"] ), "No match for: fsk.modulation_type" # Test LoRa config properties - assert ( - config.radio.lora.ack_delay == json_data["radio"]["lora"]["ack_delay"] - ), "No match for: lora.ack_delay" - assert ( - config.radio.lora.coding_rate == json_data["radio"]["lora"]["coding_rate"] - ), "No match for: lora.coding_rate" + assert config.radio.lora.ack_delay == json_data["radio"]["lora"]["ack_delay"], ( + "No match for: lora.ack_delay" + ) + assert config.radio.lora.coding_rate == json_data["radio"]["lora"]["coding_rate"], ( + "No match for: lora.coding_rate" + ) assert ( config.radio.lora.cyclic_redundancy_check == json_data["radio"]["lora"]["cyclic_redundancy_check"] @@ -68,38 +81,48 @@ def test_radio_cfg(cleanup) -> None: def test_strings(cleanup) -> None: + """Tests string configuration properties. + + Args: + cleanup: Fixture providing the path to the temporary config file. + """ file = cleanup with open(file, "r") as f: json_data = json.loads(f.read()) config = Config(file) - assert ( - config.cubesat_name == json_data["cubesat_name"] - ), "No match for: cubesat_name" - assert ( - config.super_secret_code == json_data["super_secret_code"] - ), "No match for: super_secret_code" + assert config.cubesat_name == json_data["cubesat_name"], ( + "No match for: cubesat_name" + ) + assert config.super_secret_code == json_data["super_secret_code"], ( + "No match for: super_secret_code" + ) assert config.repeat_code == json_data["repeat_code"], "No match for: repeat_code" def test_ints(cleanup) -> None: + """Tests integer configuration properties. + + Args: + cleanup: Fixture providing the path to the temporary config file. + """ file = cleanup with open(file, "r") as f: json_data = json.loads(f.read()) config = Config(file) - assert ( - config.sleep_duration == json_data["sleep_duration"] - ), "No match for: sleep_duration" + assert config.sleep_duration == json_data["sleep_duration"], ( + "No match for: sleep_duration" + ) assert config.normal_temp == json_data["normal_temp"], "No match for: normal_temp" - assert ( - config.normal_battery_temp == json_data["normal_battery_temp"] - ), "No match for: normal_battery_temp" - assert ( - config.normal_micro_temp == json_data["normal_micro_temp"] - ), "No match for: normal_micro_temp" + assert config.normal_battery_temp == json_data["normal_battery_temp"], ( + "No match for: normal_battery_temp" + ) + assert config.normal_micro_temp == json_data["normal_micro_temp"], ( + "No match for: normal_micro_temp" + ) assert config.reboot_time == json_data["reboot_time"], "No match for: reboot_time" assert ( @@ -108,48 +131,63 @@ def test_ints(cleanup) -> None: def test_floats(cleanup) -> None: + """Tests float configuration properties. + + Args: + cleanup: Fixture providing the path to the temporary config file. + """ file = cleanup with open(file, "r") as f: json_data = json.loads(f.read()) config = Config(file) - assert ( - config.normal_charge_current == json_data["normal_charge_current"] - ), "No match for: normal_charge_current" - assert ( - config.normal_battery_voltage == json_data["normal_battery_voltage"] - ), "No match for: normal_battery_voltage" - assert ( - config.degraded_battery_voltage == json_data["degraded_battery_voltage"] - ), "No match for: degraded_battery_voltage" - assert ( - config.critical_battery_voltage == json_data["critical_battery_voltage"] - ), "No match for: critical_battery_voltage" + assert config.normal_charge_current == json_data["normal_charge_current"], ( + "No match for: normal_charge_current" + ) + assert config.normal_battery_voltage == json_data["normal_battery_voltage"], ( + "No match for: normal_battery_voltage" + ) + assert config.degraded_battery_voltage == json_data["degraded_battery_voltage"], ( + "No match for: degraded_battery_voltage" + ) + assert config.critical_battery_voltage == json_data["critical_battery_voltage"], ( + "No match for: critical_battery_voltage" + ) def test_bools(cleanup) -> None: + """Tests boolean configuration properties. + + Args: + cleanup: Fixture providing the path to the temporary config file. + """ file = cleanup with open(file, "r") as f: json_data = json.loads(f.read()) config = Config(file) - assert ( - config.detumble_enable_z == json_data["detumble_enable_z"] - ), "No match for: detumble_enable_z" - assert ( - config.detumble_enable_x == json_data["detumble_enable_x"] - ), "No match for: detumble_enable_x" - assert ( - config.detumble_enable_y == json_data["detumble_enable_y"] - ), "No match for: detumble_enable_y" + assert config.detumble_enable_z == json_data["detumble_enable_z"], ( + "No match for: detumble_enable_z" + ) + assert config.detumble_enable_x == json_data["detumble_enable_x"], ( + "No match for: detumble_enable_x" + ) + assert config.detumble_enable_y == json_data["detumble_enable_y"], ( + "No match for: detumble_enable_y" + ) assert config.debug == json_data["debug"], "No match for: debug" assert config.heating == json_data["heating"], "No match for: heating" assert config.turbo_clock == json_data["turbo_clock"], "No match for: turbo_clock" def test_validation_updateable(cleanup) -> None: + """Tests validation of updateable configuration fields. + + Args: + cleanup: Fixture providing the path to the temporary config file. + """ file = cleanup config = Config(file) @@ -177,6 +215,11 @@ def test_validation_updateable(cleanup) -> None: def test_validation_type(cleanup) -> None: + """Tests validation of configuration field types. + + Args: + cleanup: Fixture providing the path to the temporary config file. + """ file = cleanup config = Config(file) @@ -192,6 +235,11 @@ def test_validation_type(cleanup) -> None: def test_validation_range(cleanup) -> None: + """Tests validation of configuration field ranges. + + Args: + cleanup: Fixture providing the path to the temporary config file. + """ file = cleanup config = Config(file) @@ -227,6 +275,11 @@ def test_validation_range(cleanup) -> None: def test_save_config(cleanup) -> None: + """Tests saving configuration changes. + + Args: + cleanup: Fixture providing the path to the temporary config file. + """ file = cleanup config = Config(file) try: @@ -236,6 +289,11 @@ def test_save_config(cleanup) -> None: def test_update_config(cleanup) -> None: + """Tests updating configuration settings. + + Args: + cleanup: Fixture providing the path to the temporary config file. + """ file = cleanup config = Config(file) @@ -274,6 +332,11 @@ def test_update_config(cleanup) -> None: def test_allowed_values(cleanup) -> None: + """Tests validation of allowed values for configuration fields. + + Args: + cleanup: Fixture providing the path to the temporary config file. + """ file = cleanup config = Config(file) diff --git a/tests/unit/test_detumble.py b/tests/unit/test_detumble.py index 0ef06314..2d5bd09e 100644 --- a/tests/unit/test_detumble.py +++ b/tests/unit/test_detumble.py @@ -1,7 +1,9 @@ -# After following the necessary steps in README.md, you can use "make test" to run all tests in the unit_tests folder -# To run this file specifically: cd Tests > cd unit_tests > pytest test_detumble.py -# pytest test_detumble.py -v displays which tests ran and their respective results (fail or pass) -# Note: If you encounter a ModuleNotFoundError, try: export PYTHONPATH= +"""Unit tests for the detumble module. + +This module contains unit tests for the `detumble` module, which provides +functions for spacecraft detumbling. The tests cover dot product, cross product, +and magnetorquer dipole calculations. +""" import pytest @@ -9,6 +11,7 @@ def test_dot_product(): + """Tests the dot_product function with positive values.""" # dot_product is only ever called to give the square of mag_field mag_field_vector = (30.0, 45.0, 60.0) result = detumble.dot_product(mag_field_vector, mag_field_vector) @@ -16,7 +19,7 @@ def test_dot_product(): def test_dot_product_negatives(): - # testing with negative vectors + """Tests the dot_product function with negative vectors.""" vector1 = (-1, -2, -3) vector2 = (-4, -5, -6) result = detumble.dot_product(vector1, vector2) @@ -24,7 +27,7 @@ def test_dot_product_negatives(): def test_dot_product_large_val(): - # testing with large value vectors + """Tests the dot_product function with large value vectors.""" vector1 = (1e6, 1e6, 1e6) vector2 = (1e6, 1e6, 1e6) result = detumble.dot_product(vector1, vector2) @@ -32,13 +35,14 @@ def test_dot_product_large_val(): def test_dot_product_zero(): - # testing with zero values + """Tests the dot_product function with zero values.""" vector = (0.0, 0.0, 0.0) result = detumble.dot_product(vector, vector) assert result == 0.0 def test_x_product(): + """Tests the x_product (cross product) function.""" mag_field_vector = (30.0, 45.0, 60.0) ang_vel_vector = (0.0, 0.02, 0.015) expected_result = [-0.525, 0.45, 0.6] @@ -53,6 +57,7 @@ def test_x_product(): def test_x_product_negatives(): + """Tests the x_product function with negative values.""" mag_field_vector = (-30.0, -45.0, -60.0) ang_vel_vector = (-0.02, -0.02, -0.015) expected_result = [-0.525, -0.75, -0.3] @@ -63,6 +68,7 @@ def test_x_product_negatives(): def test_x_product_large_val(): + """Tests the x_product function with large values.""" mag_field_vector = (1e6, 1e6, 1e6) ang_vel_vector = (1e6, 1e6, 1e6) # cross product of parallel vector equals 0 result = detumble.x_product(mag_field_vector, ang_vel_vector) @@ -70,6 +76,7 @@ def test_x_product_large_val(): def test_x_product_zero(): + """Tests the x_product function with zero values.""" mag_field_vector = (0.0, 0.0, 0.0) ang_vel_vector = (0.0, 0.02, 0.015) result = detumble.x_product(mag_field_vector, ang_vel_vector) @@ -80,6 +87,7 @@ def test_x_product_zero(): # mag_field: mag. field strength at x, y, & z axis (tuple) (magnetometer reading) # ang_vel: ang. vel. at x, y, z axis (tuple) (gyroscope reading) def test_magnetorquer_dipole(): + """Tests the magnetorquer_dipole function with valid inputs.""" mag_field = (30.0, -45.0, 60.0) ang_vel = (0.0, 0.02, 0.015) expected_result = [0.023211, -0.00557, -0.007426] @@ -90,7 +98,7 @@ def test_magnetorquer_dipole(): def test_magnetorquer_dipole_zero_mag_field(): - # testing throwing of exception when mag_field = 0 (division by 0) + """Tests magnetorquer_dipole with a zero magnetic field, expecting ZeroDivisionError.""" mag_field = (0.0, 0.0, 0.0) ang_vel = (0.0, 0.02, 0.015) with pytest.raises(ZeroDivisionError): @@ -98,7 +106,7 @@ def test_magnetorquer_dipole_zero_mag_field(): def test_magnetorquer_dipole_zero_ang_vel(): - # testing ang_vel with zero value + """Tests magnetorquer_dipole with zero angular velocity.""" mag_field = (30.0, -45.0, 60.0) ang_vel = (0.0, 0.0, 0.0) result = detumble.magnetorquer_dipole(mag_field, ang_vel) diff --git a/tests/unit/test_logger.py b/tests/unit/test_logger.py index 0e8acaa1..87502680 100644 --- a/tests/unit/test_logger.py +++ b/tests/unit/test_logger.py @@ -1,3 +1,9 @@ +"""Unit tests for the Logger class. + +This module contains unit tests for the `Logger` class, which provides logging +functionality with different severity levels, colorized output, and error counting. +""" + from unittest.mock import MagicMock import pytest @@ -9,17 +15,25 @@ @pytest.fixture def logger(): + """Provides a Logger instance for testing without colorization.""" count = MagicMock(spec=counter.Counter) return Logger(count) @pytest.fixture def logger_color(): + """Provides a Logger instance for testing with colorization enabled.""" count = MagicMock(spec=counter.Counter) return Logger(error_counter=count, colorized=True) def test_debug_log(capsys, logger): + """Tests logging a debug message without colorization. + + Args: + capsys: Pytest fixture to capture stdout/stderr. + logger: Logger instance for testing. + """ logger.debug("This is a debug message", blake="jameson") captured = capsys.readouterr() assert "DEBUG" in captured.out @@ -28,6 +42,12 @@ def test_debug_log(capsys, logger): def test_debug_with_err(capsys, logger): + """Tests logging a debug message with an error object without colorization. + + Args: + capsys: Pytest fixture to capture stdout/stderr. + logger: Logger instance for testing. + """ logger.debug( "This is another debug message", err=OSError("Manually creating an OS Error") ) @@ -38,6 +58,12 @@ def test_debug_with_err(capsys, logger): def test_info_log(capsys, logger): + """Tests logging an info message without colorization. + + Args: + capsys: Pytest fixture to capture stdout/stderr. + logger: Logger instance for testing. + """ logger.info( "This is a info message!!", foo="bar", @@ -49,6 +75,12 @@ def test_info_log(capsys, logger): def test_info_with_err(capsys, logger): + """Tests logging an info message with an error object without colorization. + + Args: + capsys: Pytest fixture to capture stdout/stderr. + logger: Logger instance for testing. + """ logger.info( "This is a info message!!", foo="barrrr", @@ -62,6 +94,12 @@ def test_info_with_err(capsys, logger): def test_warning_log(capsys, logger): + """Tests logging a warning message without colorization. + + Args: + capsys: Pytest fixture to capture stdout/stderr. + logger: Logger instance for testing. + """ logger.warning( "This is a warning message!!??!", boo="bar", @@ -79,23 +117,33 @@ def test_warning_log(capsys, logger): def test_error_log(capsys, logger): + """Tests logging an error message without colorization. + + Args: + capsys: Pytest fixture to capture stdout/stderr. + logger: Logger instance for testing. + """ logger.error( "This is an error message", OSError("Manually creating an OS Error for testing"), - hee="haa", pleiades="five", please="work", ) captured = capsys.readouterr() assert "ERROR" in captured.out assert "This is an error message" in captured.out - assert '"hee": "haa"' in captured.out assert '"pleiades": "five"' in captured.out assert '"please": "work"' in captured.out assert "OSError: Manually creating an OS Error for testing" in captured.out def test_critical_log(capsys, logger): + """Tests logging a critical message without colorization. + + Args: + capsys: Pytest fixture to capture stdout/stderr. + logger: Logger instance for testing. + """ logger.critical( "THIS IS VERY CRITICAL", OSError("Manually creating an OS Error"), @@ -116,6 +164,12 @@ def test_critical_log(capsys, logger): def test_debug_log_color(capsys, logger_color): + """Tests logging a debug message with colorization. + + Args: + capsys: Pytest fixture to capture stdout/stderr. + logger_color: Colorized Logger instance for testing. + """ logger_color.debug("This is a debug message", blake="jameson") captured = capsys.readouterr() assert _color(msg="DEBUG", color="blue") in captured.out @@ -124,6 +178,12 @@ def test_debug_log_color(capsys, logger_color): def test_info_log_color(capsys, logger_color): + """Tests logging an info message with colorization. + + Args: + capsys: Pytest fixture to capture stdout/stderr. + logger_color: Colorized Logger instance for testing. + """ logger_color.info("This is a info message!!", foo="bar") captured = capsys.readouterr() assert _color(msg="INFO", color="green") in captured.out @@ -132,6 +192,12 @@ def test_info_log_color(capsys, logger_color): def test_warning_log_color(capsys, logger_color): + """Tests logging a warning message with colorization. + + Args: + capsys: Pytest fixture to capture stdout/stderr. + logger_color: Colorized Logger instance for testing. + """ logger_color.warning( "This is a warning message!!??!", boo="bar", pleiades="maia", cube="sat" ) @@ -144,9 +210,14 @@ def test_warning_log_color(capsys, logger_color): def test_error_log_color(capsys, logger_color): + """Tests logging an error message with colorization. + + Args: + capsys: Pytest fixture to capture stdout/stderr. + logger_color: Colorized Logger instance for testing. + """ logger_color.error( "This is an error message", - hee="haa", pleiades="five", please="work", err=OSError("Manually creating an OS Error"), @@ -154,12 +225,17 @@ def test_error_log_color(capsys, logger_color): captured = capsys.readouterr() assert _color(msg="ERROR", color="pink") in captured.out assert "This is an error message" in captured.out - assert '"hee": "haa"' in captured.out assert '"pleiades": "five"' in captured.out assert '"please": "work"' in captured.out def test_critical_log_color(capsys, logger_color): + """Tests logging a critical message with colorization. + + Args: + capsys: Pytest fixture to capture stdout/stderr. + logger_color: Colorized Logger instance for testing. + """ logger_color.critical( "THIS IS VERY CRITICAL", ad="astra", @@ -181,6 +257,12 @@ def test_critical_log_color(capsys, logger_color): # testing a kwarg of value type bytes, which previously caused a TypeError exception def test_invalid_json_type_bytes(capsys, logger): + """Tests logging with a bytes type keyword argument. + + Args: + capsys: Pytest fixture to capture stdout/stderr. + logger: Logger instance for testing. + """ byte_message = b"forming a bytes message" logger.debug("This is a random message", attempt=byte_message) captured = capsys.readouterr() @@ -190,6 +272,12 @@ def test_invalid_json_type_bytes(capsys, logger): # testing a kwarg of value type Pin, which previously caused a TypeError exception def test_invalid_json_type_pin(capsys, logger): + """Tests logging with a Pin type keyword argument. + + Args: + capsys: Pytest fixture to capture stdout/stderr. + logger: Logger instance for testing. + """ mock_pin = MagicMock(spec=Pin) logger.debug("Initializing watchdog", pin=mock_pin) captured = capsys.readouterr() diff --git a/tests/unit/test_power_health.py b/tests/unit/test_power_health.py index 2d40f065..ae60d307 100644 --- a/tests/unit/test_power_health.py +++ b/tests/unit/test_power_health.py @@ -1,3 +1,11 @@ +"""Unit tests for the PowerHealth class. + +This module contains unit tests for the `PowerHealth` class, which assesses the +health of the power system based on voltage and current readings. The tests cover +various scenarios, including nominal, degraded, and critical states, as well as +error handling during sensor readings. +""" + from unittest.mock import MagicMock import pytest @@ -10,11 +18,13 @@ @pytest.fixture def mock_logger(): + """Mocks the Logger class.""" return MagicMock(spec=Logger) @pytest.fixture def mock_config(): + """Mocks the Config class with predefined power thresholds.""" config = MagicMock(spec=Config) config.normal_charge_current = 100.0 config.normal_battery_voltage = 7.2 @@ -25,11 +35,13 @@ def mock_config(): @pytest.fixture def mock_power_monitor(): + """Mocks the PowerMonitorProto class.""" return MagicMock(spec=PowerMonitorProto) @pytest.fixture def power_health(mock_logger, mock_config, mock_power_monitor): + """Provides a PowerHealth instance for testing.""" return PowerHealth( logger=mock_logger, config=mock_config, @@ -40,14 +52,25 @@ def power_health(mock_logger, mock_config, mock_power_monitor): def test_power_health_initialization( power_health, mock_logger, mock_config, mock_power_monitor ): - """Test that PowerHealth initializes correctly""" + """Tests that PowerHealth initializes correctly. + + Args: + power_health: PowerHealth instance for testing. + mock_logger: Mocked Logger instance. + mock_config: Mocked Config instance. + mock_power_monitor: Mocked PowerMonitorProto instance. + """ assert power_health.logger == mock_logger assert power_health.config == mock_config assert power_health._power_monitor == mock_power_monitor def test_get_nominal_state(power_health): - """Test that get() returns NOMINAL when all readings are within normal range""" + """Tests that get() returns NOMINAL when all readings are within normal range. + + Args: + power_health: PowerHealth instance for testing. + """ # Mock normal readings power_health._power_monitor.get_bus_voltage.return_value = 7.2 # Normal voltage power_health._power_monitor.get_current.return_value = 100.0 # Normal current @@ -59,7 +82,11 @@ def test_get_nominal_state(power_health): def test_get_critical_state_low_voltage(power_health): - """Test that get() returns CRITICAL when battery voltage is at/below critical threshold""" + """Tests that get() returns CRITICAL when battery voltage is at/below critical threshold. + + Args: + power_health: PowerHealth instance for testing. + """ # Mock critical voltage reading power_health._power_monitor.get_bus_voltage.return_value = ( 5.8 # Below critical (6.0) @@ -75,7 +102,11 @@ def test_get_critical_state_low_voltage(power_health): def test_get_critical_state_exactly_critical_voltage(power_health): - """Test that get() returns CRITICAL when battery voltage is exactly at critical threshold""" + """Tests that get() returns CRITICAL when battery voltage is exactly at critical threshold. + + Args: + power_health: PowerHealth instance for testing. + """ # Mock exactly critical voltage reading power_health._power_monitor.get_bus_voltage.return_value = 6.0 # Exactly critical power_health._power_monitor.get_current.return_value = 100.0 @@ -89,7 +120,11 @@ def test_get_critical_state_exactly_critical_voltage(power_health): def test_get_degraded_state_current_deviation(power_health): - """Test that get() returns DEGRADED when current is outside normal range""" + """Tests that get() returns DEGRADED when current is outside normal range. + + Args: + power_health: PowerHealth instance for testing. + """ # Mock readings with current deviation power_health._power_monitor.get_bus_voltage.return_value = 7.2 # Normal voltage power_health._power_monitor.get_current.return_value = ( @@ -106,7 +141,11 @@ def test_get_degraded_state_current_deviation(power_health): def test_get_degraded_state_voltage_deviation(power_health): - """Test that get() returns DEGRADED when voltage is at or below degraded threshold but not critical""" + """Tests that get() returns DEGRADED when voltage is at or below degraded threshold but not critical. + + Args: + power_health: PowerHealth instance for testing. + """ power_health._power_monitor.get_bus_voltage.return_value = ( 6.8 # Below degraded threshold (7.0) but above critical (6.0) ) @@ -122,7 +161,11 @@ def test_get_degraded_state_voltage_deviation(power_health): def test_get_nominal_with_minor_voltage_deviation(power_health): - """Test that get() returns NOMINAL when voltage is above degraded threshold""" + """Tests that get() returns NOMINAL when voltage is above degraded threshold. + + Args: + power_health: PowerHealth instance for testing. + """ power_health._power_monitor.get_bus_voltage.return_value = ( 7.1 # Above degraded threshold (7.0) ) @@ -135,7 +178,11 @@ def test_get_nominal_with_minor_voltage_deviation(power_health): def test_avg_reading_normal_operation(power_health): - """Test _avg_reading() with normal sensor readings""" + """Tests _avg_reading() with normal sensor readings. + + Args: + power_health: PowerHealth instance for testing. + """ mock_func = MagicMock(return_value=7.5) result = power_health._avg_reading(mock_func, num_readings=10) @@ -145,7 +192,11 @@ def test_avg_reading_normal_operation(power_health): def test_avg_reading_with_none_values(power_health): - """Test _avg_reading() when sensor returns None""" + """Tests _avg_reading() when sensor returns None. + + Args: + power_health: PowerHealth instance for testing. + """ mock_func = MagicMock(return_value=None) mock_func.__name__ = "test_sensor_function" @@ -157,7 +208,11 @@ def test_avg_reading_with_none_values(power_health): def test_avg_reading_with_varying_values(power_health): - """Test _avg_reading() with varying sensor readings""" + """Tests _avg_reading() with varying sensor readings. + + Args: + power_health: PowerHealth instance for testing. + """ mock_func = MagicMock(side_effect=[7.0, 7.2, 7.4, 7.6, 7.8]) result = power_health._avg_reading(mock_func, num_readings=5) @@ -168,7 +223,11 @@ def test_avg_reading_with_varying_values(power_health): def test_avg_reading_default_num_readings(power_health): - """Test _avg_reading() uses default of 50 readings""" + """Tests _avg_reading() uses default of 50 readings. + + Args: + power_health: PowerHealth instance for testing. + """ mock_func = MagicMock(return_value=7.0) result = power_health._avg_reading(mock_func) @@ -178,7 +237,11 @@ def test_avg_reading_default_num_readings(power_health): def test_get_with_none_voltage_reading(power_health): - """Test get() when voltage reading returns None""" + """Tests get() when voltage reading returns None. + + Args: + power_health: PowerHealth instance for testing. + """ power_health._power_monitor.get_bus_voltage.return_value = None power_health._power_monitor.get_current.return_value = 100.0 @@ -194,7 +257,11 @@ def test_get_with_none_voltage_reading(power_health): def test_get_with_none_current_reading(power_health): - """Test get() when current reading returns None""" + """Tests get() when current reading returns None. + + Args: + power_health: PowerHealth instance for testing. + """ power_health._power_monitor.get_bus_voltage.return_value = 7.2 power_health._power_monitor.get_current.return_value = None @@ -210,7 +277,11 @@ def test_get_with_none_current_reading(power_health): def test_get_with_exception_during_voltage_reading(power_health): - """Test get() when exception occurs during voltage reading""" + """Tests get() when exception occurs during voltage reading. + + Args: + power_health: PowerHealth instance for testing. + """ # Mock _avg_reading to raise an exception on first call (voltage) test_exception = RuntimeError("Sensor communication error") power_health._avg_reading = MagicMock(side_effect=test_exception) @@ -225,7 +296,11 @@ def test_get_with_exception_during_voltage_reading(power_health): def test_get_with_exception_during_current_reading(power_health): - """Test get() when exception occurs during current reading""" + """Tests get() when exception occurs during current reading. + + Args: + power_health: PowerHealth instance for testing. + """ # Mock _avg_reading to return normal voltage, then raise exception for current test_exception = RuntimeError("Current sensor failed") power_health._avg_reading = MagicMock(side_effect=[7.2, test_exception]) @@ -240,7 +315,11 @@ def test_get_with_exception_during_current_reading(power_health): def test_get_with_sensor_method_exception(power_health): - """Test get() when the sensor method itself raises an exception""" + """Tests get() when the sensor method itself raises an exception. + + Args: + power_health: PowerHealth instance for testing. + """ # Make the sensor method raise an exception directly test_exception = OSError("I2C communication failed") power_health._power_monitor.get_bus_voltage.side_effect = test_exception @@ -255,7 +334,11 @@ def test_get_with_sensor_method_exception(power_health): def test_get_logs_sensor_debug_info(power_health): - """Test that get() logs debug information about the sensor""" + """Tests that get() logs debug information about the sensor. + + Args: + power_health: PowerHealth instance for testing. + """ power_health._power_monitor.get_bus_voltage.return_value = 7.2 power_health._power_monitor.get_current.return_value = 100.0 @@ -267,7 +350,11 @@ def test_get_logs_sensor_debug_info(power_health): def test_degraded_vs_critical_voltage_boundaries(power_health): - """Test boundary conditions between degraded and critical voltage thresholds""" + """Tests boundary conditions between degraded and critical voltage thresholds. + + Args: + power_health: PowerHealth instance for testing. + """ # Test voltage just above critical but below degraded power_health._power_monitor.get_bus_voltage.return_value = ( 6.5 # Above critical (6.0) but below degraded (7.0) @@ -284,8 +371,12 @@ def test_degraded_vs_critical_voltage_boundaries(power_health): def test_current_deviation_threshold(power_health): - """Test current deviation uses normal_charge_current as threshold""" - # normal_charge_current = 100.0, so deviation > 100.0 should trigger error + """Tests current deviation uses normal_charge_current as threshold. + + Args: + power_health: PowerHealth instance for testing. + """ + # normal_charge_current = 100.0, so deviation = 150 > 100 should trigger error power_health._power_monitor.get_bus_voltage.return_value = 7.2 power_health._power_monitor.get_current.return_value = ( 250.0 # deviation = 150 > 100 @@ -301,7 +392,11 @@ def test_current_deviation_threshold(power_health): def test_degraded_battery_voltage_threshold(power_health): - """Test that get() returns DEGRADED when voltage is exactly at degraded threshold""" + """Tests that get() returns DEGRADED when voltage is exactly at degraded threshold. + + Args: + power_health: PowerHealth instance for testing. + """ power_health._power_monitor.get_bus_voltage.return_value = ( 7.0 # Exactly at degraded threshold ) @@ -317,7 +412,11 @@ def test_degraded_battery_voltage_threshold(power_health): def test_voltage_just_above_degraded_threshold(power_health): - """Test that get() returns NOMINAL when voltage is just above degraded threshold""" + """Tests that get() returns NOMINAL when voltage is just above degraded threshold. + + Args: + power_health: PowerHealth instance for testing. + """ power_health._power_monitor.get_bus_voltage.return_value = ( 7.01 # Just above degraded threshold (7.0) ) diff --git a/tests/unit/test_sleep_helper.py b/tests/unit/test_sleep_helper.py index eed9a4c2..71cef8ed 100644 --- a/tests/unit/test_sleep_helper.py +++ b/tests/unit/test_sleep_helper.py @@ -1,3 +1,10 @@ +"""Unit tests for the SleepHelper class. + +This module contains unit tests for the `SleepHelper` class, which provides +functionality for safe sleep operations, including watchdog petting and handling +of sleep duration limits. +""" + import sys from unittest.mock import MagicMock, patch @@ -20,11 +27,13 @@ @pytest.fixture def mock_logger() -> MagicMock: + """Mocks the Logger class.""" return MagicMock(spec=Logger) @pytest.fixture def mock_config() -> MagicMock: + """Mocks the Config class with a predefined longest allowable sleep time.""" config = MagicMock(spec=Config) config.longest_allowable_sleep_time = 100 return config @@ -32,20 +41,32 @@ def mock_config() -> MagicMock: @pytest.fixture def mock_watchdog() -> MagicMock: + """Mocks the Watchdog class.""" return MagicMock(spec=Watchdog) @pytest.fixture def sleep_helper( - mock_logger: MagicMock, mock_config: MagicMock, mock_watchdog: MagicMock + mock_logger: MagicMock, + mock_config: MagicMock, + mock_watchdog: MagicMock, ) -> SleepHelper: + """Provides a SleepHelper instance for testing.""" return SleepHelper(mock_logger, mock_config, mock_watchdog) def test_init( - mock_logger: MagicMock, mock_config: MagicMock, mock_watchdog: MagicMock + mock_logger: MagicMock, + mock_config: MagicMock, + mock_watchdog: MagicMock, ) -> None: - """Test SleepHelper initialization.""" + """Tests SleepHelper initialization. + + Args: + mock_logger: Mocked Logger instance. + mock_config: Mocked Config instance. + mock_watchdog: Mocked Watchdog instance. + """ sleep_helper = SleepHelper(mock_logger, mock_config, mock_watchdog) assert sleep_helper.logger is mock_logger @@ -60,7 +81,14 @@ def test_safe_sleep_within_limit( mock_logger: MagicMock, mock_watchdog: MagicMock, ) -> None: - """Test safe_sleep with duration within the allowable limit.""" + """Tests safe_sleep with duration within the allowable limit. + + Args: + mock_time: Mocked time module. + sleep_helper: SleepHelper instance for testing. + mock_logger: Mocked Logger instance. + mock_watchdog: Mocked Watchdog instance. + """ # Reset mocks mock_alarm.reset_mock() mock_time_alarm.reset_mock() @@ -95,7 +123,15 @@ def test_safe_sleep_exceeds_limit( mock_config: MagicMock, mock_watchdog: MagicMock, ) -> None: - """Test safe_sleep with duration exceeding the allowable limit.""" + """Tests safe_sleep with duration exceeding the allowable limit. + + Args: + mock_time: Mocked time module. + sleep_helper: SleepHelper instance for testing. + mock_logger: Mocked Logger instance. + mock_config: Mocked Config instance. + mock_watchdog: Mocked Watchdog instance. + """ # Reset mocks mock_alarm.reset_mock() mock_time_alarm.reset_mock() @@ -129,9 +165,17 @@ def test_safe_sleep_exceeds_limit( @patch("pysquared.sleep_helper.time") def test_safe_sleep_multiple_watchdog_pets( - mock_time: MagicMock, sleep_helper: SleepHelper, mock_watchdog: MagicMock + mock_time: MagicMock, + sleep_helper: SleepHelper, + mock_watchdog: MagicMock, ) -> None: - """Test safe_sleep with multiple watchdog pets during longer sleep.""" + """Tests safe_sleep with multiple watchdog pets during longer sleep. + + Args: + mock_time: Mocked time module. + sleep_helper: SleepHelper instance for testing. + mock_watchdog: Mocked Watchdog instance. + """ # Reset mocks mock_alarm.reset_mock() mock_time_alarm.reset_mock() diff --git a/tests/unit/test_watchdog.py b/tests/unit/test_watchdog.py index c303866b..07f2669a 100644 --- a/tests/unit/test_watchdog.py +++ b/tests/unit/test_watchdog.py @@ -1,3 +1,9 @@ +"""Unit tests for the Watchdog class. + +This module contains unit tests for the `Watchdog` class, which provides +functionality for petting a watchdog timer to prevent system resets. +""" + from unittest.mock import MagicMock, patch import pytest @@ -10,11 +16,13 @@ @pytest.fixture def mock_pin() -> MagicMock: + """Mocks a microcontroller Pin.""" return MagicMock(spec=Pin) @pytest.fixture def mock_logger() -> MagicMock: + """Mocks the Logger class.""" return MagicMock(spec=Logger) @@ -22,7 +30,13 @@ def mock_logger() -> MagicMock: def test_watchdog_init( mock_initialize_pin: MagicMock, mock_logger: MagicMock, mock_pin: MagicMock ) -> None: - """Test Watchdog initialization.""" + """Tests Watchdog initialization. + + Args: + mock_initialize_pin: Mocked initialize_pin function. + mock_logger: Mocked Logger instance. + mock_pin: Mocked Pin instance. + """ mock_digital_in_out = MagicMock(spec=DigitalInOut) mock_initialize_pin.return_value = mock_digital_in_out @@ -45,7 +59,14 @@ def test_watchdog_pet( mock_logger: MagicMock, mock_pin: MagicMock, ) -> None: - """Test Watchdog pet method using side_effect on sleep.""" + """Tests Watchdog pet method using side_effect on sleep. + + Args: + mock_initialize_pin: Mocked initialize_pin function. + mock_sleep: Mocked time.sleep function. + mock_logger: Mocked Logger instance. + mock_pin: Mocked Pin instance. + """ mock_digital_in_out = MagicMock(spec=DigitalInOut) mock_initialize_pin.return_value = mock_digital_in_out @@ -54,6 +75,7 @@ def test_watchdog_pet( value_during_sleep = None def check_value_and_sleep(_: float) -> None: + """Check the pin value and set it during sleep.""" nonlocal value_during_sleep value_during_sleep = mock_digital_in_out.value @@ -64,6 +86,6 @@ def check_value_and_sleep(_: float) -> None: mock_sleep.assert_called_once_with(0.01) assert value_during_sleep, "Watchdog pin value should be True when sleep is called" - assert ( - mock_digital_in_out.value is False - ), "Watchdog pin value should be False after pet() finishes" + assert mock_digital_in_out.value is False, ( + "Watchdog pin value should be False after pet() finishes" + ) diff --git a/typings/micropython.pyi b/typings/micropython.pyi index c5db06d6..250c2d0f 100644 --- a/typings/micropython.pyi +++ b/typings/micropython.pyi @@ -77,7 +77,7 @@ def mem_info() -> None: """ @overload -def mem_info(verbose: Any, /) -> None: +def mem_info(verbose: int, /) -> None: """ Print information about currently used memory. If the *verbose* argument is given then extra information is printed. diff --git a/uv.lock b/uv.lock index 52cb7e47..5273ee04 100644 --- a/uv.lock +++ b/uv.lock @@ -293,6 +293,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/ff/392bff89415399a979be4a65357a41d92729ae8580a66073d8ec8d810f98/backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60", size = 380265, upload-time = "2025-06-22T19:34:12.405Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067, upload-time = "2025-04-15T17:05:13.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285, upload-time = "2025-04-15T17:05:12.221Z" }, +] + [[package]] name = "binho-host-adapter" version = "0.1.6" @@ -552,6 +565,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/2b/34cc11786bc00d0f04d0f5fdc3a2b1ae0b6239eef72d3d345805f9ad92a1/markdown-3.8.2-py3-none-any.whl", hash = "sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24", size = 106827, upload-time = "2025-06-19T17:12:42.994Z" }, ] +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[package]] +name = "markdownify" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/78/c48fed23c7aebc2c16049062e72de1da3220c274de59d28c942acdc9ffb2/markdownify-1.1.0.tar.gz", hash = "sha256:449c0bbbf1401c5112379619524f33b63490a8fa479456d41de9dc9e37560ebd", size = 17127, upload-time = "2025-03-05T11:54:40.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/11/b751af7ad41b254a802cf52f7bc1fca7cabe2388132f2ce60a1a6b9b9622/markdownify-1.1.0-py3-none-any.whl", hash = "sha256:32a5a08e9af02c8a6528942224c91b933b4bd2c7d078f9012943776fc313eeef", size = 13901, upload-time = "2025-03-05T11:54:39.454Z" }, +] + [[package]] name = "markupsafe" version = "3.0.2" @@ -580,6 +618,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, ] +[[package]] +name = "mdformat" +version = "0.7.22" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/eb/b5cbf2484411af039a3d4aeb53a5160fae25dd8c84af6a4243bc2f3fedb3/mdformat-0.7.22.tar.gz", hash = "sha256:eef84fa8f233d3162734683c2a8a6222227a229b9206872e6139658d99acb1ea", size = 34610, upload-time = "2025-01-30T18:00:51.418Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/6f/94a7344f6d634fe3563bea8b33bccedee37f2726f7807e9a58440dc91627/mdformat-0.7.22-py3-none-any.whl", hash = "sha256:61122637c9e1d9be1329054f3fa216559f0d1f722b7919b060a8c2a4ae1850e5", size = 34447, upload-time = "2025-01-30T18:00:48.708Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mergedeep" version = "1.3.4" @@ -656,6 +715,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/53/b6/106fcc15287e7228658fbd0ad9e8b0d775becced0a089cc39984641f4a0f/mkdocs_git_revision_date_localized_plugin-1.4.7-py3-none-any.whl", hash = "sha256:056c0a90242409148f1dc94d5c9d2c25b5b8ddd8de45489fa38f7fa7ccad2bc4", size = 25382, upload-time = "2025-05-28T18:26:18.907Z" }, ] +[[package]] +name = "mkdocs-llmstxt" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "markdownify" }, + { name = "mdformat" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/25/263ea9c16d1f95f30d9eb1b76e63eb50a88a1ec9fad1829281bab7a371eb/mkdocs_llmstxt-0.2.0.tar.gz", hash = "sha256:104f10b8101167d6baf7761942b4743869be3d8f8a8d909f4e9e0b63307f709e", size = 41376, upload-time = "2025-04-08T13:18:48.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/29/0a33f7d8499a01dd7fd0d90fb163b2d8eefa9c90ac0ecbc1a7770e50614e/mkdocs_llmstxt-0.2.0-py3-none-any.whl", hash = "sha256:907de892e0c8be74002e8b4d553820c2b5bbcf03cc303b95c8bca48fb49c1a29", size = 23244, upload-time = "2025-04-08T13:18:47.516Z" }, +] + [[package]] name = "mkdocs-material" version = "9.6.14" @@ -702,18 +775,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/de/d264cd0360796a70b42413317134357deb537953e271fd0a62dada955c93/mkdocs_minify_plugin-0.7.1-py3-none-any.whl", hash = "sha256:29bd6a1aa5b0217a55b08333194e20cf1ff83b63fb6a22a33f10f8fa9745c28a", size = 5510, upload-time = "2023-08-01T16:33:28.414Z" }, ] -[[package]] -name = "mkdocs-redirects" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mkdocs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f1/a8/6d44a6cf07e969c7420cb36ab287b0669da636a2044de38a7d2208d5a758/mkdocs_redirects-1.2.2.tar.gz", hash = "sha256:3094981b42ffab29313c2c1b8ac3969861109f58b2dd58c45fc81cd44bfa0095", size = 7162, upload-time = "2024-11-07T14:57:21.109Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/ec/38443b1f2a3821bbcb24e46cd8ba979154417794d54baf949fefde1c2146/mkdocs_redirects-1.2.2-py3-none-any.whl", hash = "sha256:7dbfa5647b79a3589da4401403d69494bd1f4ad03b9c15136720367e1f340ed5", size = 6142, upload-time = "2024-11-07T14:57:19.143Z" }, -] - [[package]] name = "mkdocs-section-index" version = "0.3.10" @@ -952,15 +1013,17 @@ dev = [ { name = "circuitpython-stubs" }, { name = "coverage" }, { name = "freezegun" }, + { name = "pre-commit" }, + { name = "pyright", extra = ["nodejs"] }, + { name = "pytest" }, +] +docs = [ { name = "mkdocs-git-revision-date-localized-plugin" }, + { name = "mkdocs-llmstxt" }, { name = "mkdocs-material" }, { name = "mkdocs-minify-plugin" }, - { name = "mkdocs-redirects" }, { name = "mkdocs-section-index" }, { name = "mkdocstrings", extra = ["python"] }, - { name = "pre-commit" }, - { name = "pyright", extra = ["nodejs"] }, - { name = "pytest" }, ] [package.metadata] @@ -988,15 +1051,17 @@ dev = [ { name = "circuitpython-stubs", specifier = "==9.2.8" }, { name = "coverage", specifier = "==7.9.1" }, { name = "freezegun", specifier = ">=1.5.2" }, + { name = "pre-commit", specifier = "==4.2.0" }, + { name = "pyright", extras = ["nodejs"], specifier = "==1.1.402" }, + { name = "pytest", specifier = "==8.4.1" }, +] +docs = [ { name = "mkdocs-git-revision-date-localized-plugin", specifier = "==1.4.7" }, + { name = "mkdocs-llmstxt", specifier = "==0.2.0" }, { name = "mkdocs-material", specifier = "==9.6.14" }, { name = "mkdocs-minify-plugin", specifier = "==0.7.1" }, - { name = "mkdocs-redirects", specifier = "==1.2.2" }, { name = "mkdocs-section-index", specifier = "==0.3.10" }, { name = "mkdocstrings", extras = ["python"], specifier = "==0.29.1" }, - { name = "pre-commit", specifier = "==4.2.0" }, - { name = "pyright", extras = ["nodejs"], specifier = "==1.1.402" }, - { name = "pytest", specifier = "==8.4.1" }, ] [[package]] @@ -1107,6 +1172,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, ] +[[package]] +name = "soupsieve" +version = "2.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418, upload-time = "2025-04-20T18:50:08.518Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677, upload-time = "2025-04-20T18:50:07.196Z" }, +] + [[package]] name = "sysv-ipc" version = "1.1.0"