diff --git a/circuitpython-workspaces/flight-software/src/pysquared/hardware/load_switch/__init__.py b/circuitpython-workspaces/flight-software/src/pysquared/hardware/load_switch/__init__.py new file mode 100644 index 00000000..c4125d9c --- /dev/null +++ b/circuitpython-workspaces/flight-software/src/pysquared/hardware/load_switch/__init__.py @@ -0,0 +1,3 @@ +""" +Load switch hardware interface. +""" diff --git a/circuitpython-workspaces/flight-software/src/pysquared/hardware/load_switch/manager/__init__.py b/circuitpython-workspaces/flight-software/src/pysquared/hardware/load_switch/manager/__init__.py new file mode 100644 index 00000000..a259b081 --- /dev/null +++ b/circuitpython-workspaces/flight-software/src/pysquared/hardware/load_switch/manager/__init__.py @@ -0,0 +1,3 @@ +""" +Load switch manager class. +""" diff --git a/circuitpython-workspaces/flight-software/src/pysquared/hardware/load_switch/manager/loadswitch_manager.py b/circuitpython-workspaces/flight-software/src/pysquared/hardware/load_switch/manager/loadswitch_manager.py new file mode 100644 index 00000000..2fb3e69e --- /dev/null +++ b/circuitpython-workspaces/flight-software/src/pysquared/hardware/load_switch/manager/loadswitch_manager.py @@ -0,0 +1,86 @@ +"""This is a generic load switch manager for controlling power to components. + +Usage: + +from lib.pysquared.hardware.load_switch.manager.loadswitch_manager import LoadSwitchManager + +load_switch_0 = LoadSwitchManager( + FACE0_ENABLE, True +) + +load_switch_0.enable_load() +load_switch_0.disable_load() +load_switch_0.reset_load() +is_enabled = load_switch_0.is_enabled + +""" + +import time + +from digitalio import DigitalInOut + +from pysquared.protos.loadswitch import LoadSwitchManagerProto + + +class LoadSwitchManager(LoadSwitchManagerProto): + """Manages load switch operations for any component or group of components that + has an independent load switch for power control. + + This class provides methods to enable, disable, and reset the load switch, + as well as check its current state. It is designed to work with a digital pin + that controls the load switch, allowing for high or low enable logic. + """ + + def __init__(self, load_switch_pin: DigitalInOut, enable_high: bool = True) -> None: + """Initialize the load switch manager. + :param load_switch_pin: DigitalInOut pin controlling the load switch + :param enable_high: If True, load switch enables when pin is HIGH. If False, enables when LOW + """ + self._load_switch_pin = load_switch_pin + self._enable_pin_value = enable_high + self._disable_pin_value = not enable_high + + def enable_load(self) -> None: + """Enables the load switch, allowing power to flow. + :raises RuntimeError: If the load switch cannot be enabled due to hardware issues + """ + try: + self._load_switch_pin.value = self._enable_pin_value + except Exception as e: + raise RuntimeError(f"Failed to enable load switch: {e}") from e + + def disable_load(self) -> None: + """Disables the load switch, cutting power. + :raises RuntimeError: If the load switch cannot be disabled due to hardware issues + """ + try: + self._load_switch_pin.value = self._disable_pin_value + except Exception as e: + raise RuntimeError(f"Failed to disable load switch: {e}") from e + + def reset_load(self) -> None: + """Reset the load switch by momentarily disabling then re-enabling it. + This method performs a momentary power cycle (0.1s) to reset the load switch + and any connected components. Errors from underlying drivers are reraised. + :raises RuntimeError: If the load switch cannot be reset due to hardware issues + """ + try: + was_enabled = self.is_enabled + self.disable_load() + time.sleep(0.1) + if was_enabled: + self.enable_load() + except Exception as e: + raise RuntimeError(f"Failed to reset load switch: {e}") from e + + @property + def is_enabled(self) -> bool: + """Check if the load switch is currently enabled. + :raises RuntimeError: If the load switch state cannot be read due to hardware issues + :return: True if the load switch is enabled, False otherwise + """ + try: + pin_value = self._load_switch_pin.value + return pin_value == self._enable_pin_value + except Exception as e: + raise RuntimeError(f"Failed to read load switch state: {e}") from e diff --git a/circuitpython-workspaces/flight-software/src/pysquared/protos/loadswitch.py b/circuitpython-workspaces/flight-software/src/pysquared/protos/loadswitch.py new file mode 100644 index 00000000..0cbd24a3 --- /dev/null +++ b/circuitpython-workspaces/flight-software/src/pysquared/protos/loadswitch.py @@ -0,0 +1,37 @@ +"""Load switch manager protocol for generic components.""" + + +class LoadSwitchManagerProto: + """Protocol for load switch management in generic systems. + This protocol defines the interface for managing load switches that control + power to components. Load switches can be enabled, disabled, + and reset with momentary power cycling. + """ + + def enable_load(self) -> None: + """Enable the load switch to provide power to the component. + :raises RuntimeError: If the load switch cannot be enabled due to hardware issues + """ + ... + + def disable_load(self) -> None: + """Disable the load switch to cut power to the component. + :raises RuntimeError: If the load switch cannot be disabled due to hardware issues + """ + ... + + def reset_load(self) -> None: + """Reset the load switch by momentarily disabling then re-enabling it. + This method performs a momentary power cycle (0.1s) to reset the load switch + and any connected components. Errors from underlying drivers are reraised. + :raises RuntimeError: If the load switch cannot be reset due to hardware issues + """ + ... + + @property + def is_enabled(self) -> bool: + """Check if the load switch is currently enabled. + :raises RuntimeError: If the load switch state cannot be read due to hardware issues + :return: True if the load switch is enabled, False otherwise + """ + ... diff --git a/cpython-workspaces/flight-software-unit-tests/src/unit-tests/hardware/load_switch/manager/test_loadswitch_manager.py b/cpython-workspaces/flight-software-unit-tests/src/unit-tests/hardware/load_switch/manager/test_loadswitch_manager.py new file mode 100644 index 00000000..233c7c44 --- /dev/null +++ b/cpython-workspaces/flight-software-unit-tests/src/unit-tests/hardware/load_switch/manager/test_loadswitch_manager.py @@ -0,0 +1,217 @@ +"""Unit tests for the LoadSwitchManager class. + +This module contains unit tests for the `LoadSwitchManager` class, which controls +load switch operations for power management. The tests cover initialization, +successful operations, error handling, and state management. +""" + +import sys +from unittest.mock import MagicMock, patch + +import pytest + +# Mock digitalio module before importing LoadSwitchManager +digitalio = MagicMock() +digitalio.DigitalInOut = MagicMock +sys.modules["digitalio"] = digitalio + +from pysquared.hardware.load_switch.manager.loadswitch_manager import ( # noqa: E402 + LoadSwitchManager, +) + + +@pytest.fixture +def mock_pin(): + """Provides a mock DigitalInOut pin for testing.""" + return MagicMock() + + +@pytest.fixture +def manager_enable_high(mock_pin): + """Provides a LoadSwitchManager with enable_high=True.""" + return LoadSwitchManager(load_switch_pin=mock_pin, enable_high=True) + + +@pytest.fixture +def manager_enable_low(mock_pin): + """Provides a LoadSwitchManager with enable_high=False.""" + return LoadSwitchManager(load_switch_pin=mock_pin, enable_high=False) + + +def test_loadswitch_initialization_enable_high(manager_enable_high, mock_pin): + """Tests LoadSwitchManager initialization with enable_high=True.""" + # Test behavior through public interface - enable should set pin to True + manager_enable_high.enable_load() + assert mock_pin.value is True + + +def test_loadswitch_initialization_enable_low(manager_enable_low, mock_pin): + """Tests LoadSwitchManager initialization with enable_high=False.""" + # Test behavior through public interface - enable should set pin to False + manager_enable_low.enable_load() + assert mock_pin.value is False + + +def test_loadswitch_initialization_default_enable_high(mock_pin): + """Tests LoadSwitchManager initialization with default enable_high=True.""" + manager = LoadSwitchManager(load_switch_pin=mock_pin) + # Test behavior through public interface - enable should set pin to True (default) + manager.enable_load() + assert mock_pin.value is True + + +@pytest.mark.parametrize( + "manager_fixture,expected_value", + [("manager_enable_high", True), ("manager_enable_low", False)], +) +def test_enable_load_success(manager_fixture, expected_value, request, mock_pin): + """Tests successful load enable operation for both enable logic types.""" + manager = request.getfixturevalue(manager_fixture) + manager.enable_load() + assert mock_pin.value is expected_value + + +def test_enable_load_hardware_failure(manager_enable_high, mock_pin): + """Tests enable_load error handling when hardware fails.""" + # Mock the pin to raise an exception when setting value + type(mock_pin).value = property( + fset=MagicMock(side_effect=RuntimeError("Hardware failure")) + ) + + with pytest.raises( + RuntimeError, match="Failed to enable load switch: Hardware failure" + ): + manager_enable_high.enable_load() + + +@pytest.mark.parametrize( + "manager_fixture,expected_value", + [("manager_enable_high", False), ("manager_enable_low", True)], +) +def test_disable_load_success(manager_fixture, expected_value, request, mock_pin): + """Tests successful load disable operation for both enable logic types.""" + manager = request.getfixturevalue(manager_fixture) + manager.disable_load() + assert mock_pin.value is expected_value + + +def test_disable_load_hardware_failure(manager_enable_high, mock_pin): + """Tests disable_load error handling when hardware fails.""" + # Mock the pin to raise an exception when setting value + type(mock_pin).value = property( + fset=MagicMock(side_effect=RuntimeError("Hardware failure")) + ) + + with pytest.raises( + RuntimeError, match="Failed to disable load switch: Hardware failure" + ): + manager_enable_high.disable_load() + + +@pytest.mark.parametrize( + "manager_fixture,pin_value,expected_enabled", + [ + ("manager_enable_high", True, True), + ("manager_enable_high", False, False), + ("manager_enable_low", False, True), + ("manager_enable_low", True, False), + ], +) +def test_is_enabled(manager_fixture, pin_value, expected_enabled, request, mock_pin): + """Tests is_enabled property for all combinations of enable logic and pin states.""" + manager = request.getfixturevalue(manager_fixture) + mock_pin.value = pin_value + assert manager.is_enabled is expected_enabled + + +def test_is_enabled_hardware_failure(manager_enable_high, mock_pin): + """Tests is_enabled error handling when hardware fails.""" + # Mock the pin to raise an exception when reading value + type(mock_pin).value = property( + fget=MagicMock(side_effect=RuntimeError("Hardware failure")) + ) + + with pytest.raises( + RuntimeError, match="Failed to read load switch state: Hardware failure" + ): + _ = manager_enable_high.is_enabled + + +@pytest.mark.parametrize( + "was_enabled,enable_should_be_called", + [(True, True), (False, False)], +) +@patch("pysquared.hardware.load_switch.manager.loadswitch_manager.time.sleep") +def test_reset_load_state_preservation( + mock_sleep, was_enabled, enable_should_be_called, manager_enable_high, mock_pin +): + """Tests reset_load preserves previous state correctly.""" + # Set up initial state + mock_pin.value = was_enabled + + with patch.object(manager_enable_high, "disable_load") as mock_disable: + with patch.object(manager_enable_high, "enable_load") as mock_enable: + manager_enable_high.reset_load() + + # Verify disable was called + mock_disable.assert_called_once() + # Verify sleep for 0.1 seconds + mock_sleep.assert_called_once_with(0.1) + # Verify enable behavior based on previous state + if enable_should_be_called: + mock_enable.assert_called_once() + else: + mock_enable.assert_not_called() + + +@pytest.mark.parametrize( + "failure_method,error_message,expected_match", + [ + ( + "disable_load", + "Disable failed", + "Failed to reset load switch: Disable failed", + ), + ("enable_load", "Enable failed", "Failed to reset load switch: Enable failed"), + ], +) +def test_reset_load_operation_failures( + failure_method, error_message, expected_match, manager_enable_high, mock_pin +): + """Tests reset_load error handling for disable and enable failures.""" + # Set up initial state as enabled + mock_pin.value = True + + patches = {} + if failure_method == "disable_load": + patches["disable_load"] = patch.object( + manager_enable_high, "disable_load", side_effect=RuntimeError(error_message) + ) + else: + patches["disable_load"] = patch.object(manager_enable_high, "disable_load") + patches["enable_load"] = patch.object( + manager_enable_high, "enable_load", side_effect=RuntimeError(error_message) + ) + + with patches["disable_load"]: + if "enable_load" in patches: + with patches["enable_load"]: + with pytest.raises(RuntimeError, match=expected_match): + manager_enable_high.reset_load() + else: + with pytest.raises(RuntimeError, match=expected_match): + manager_enable_high.reset_load() + + +def test_reset_load_is_enabled_check_failure(manager_enable_high, mock_pin): + """Tests reset_load error handling when is_enabled check fails.""" + # Mock the pin to raise an exception when reading value (which is used by is_enabled) + type(mock_pin).value = property( + fget=MagicMock(side_effect=RuntimeError("State check failed")) + ) + + with pytest.raises( + RuntimeError, + match="Failed to reset load switch: Failed to read load switch state: State check failed", + ): + manager_enable_high.reset_load()