diff --git a/pyproject.toml b/pyproject.toml index 42bfa120..5c68ecf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,8 @@ packages = [ "pysquared.hardware", "pysquared.hardware.imu", "pysquared.hardware.imu.manager", + "pysquared.hardware.load_switch", + "pysquared.hardware.load_switch.manager", "pysquared.hardware.magnetometer", "pysquared.hardware.magnetometer.manager", "pysquared.hardware.radio", diff --git a/pysquared/docs/load_switch.md b/pysquared/docs/load_switch.md new file mode 100644 index 00000000..519a057d --- /dev/null +++ b/pysquared/docs/load_switch.md @@ -0,0 +1,154 @@ +# Load Switch Manager + +The Load Switch Manager provides a consistent interface for controlling load switches on PySquared satellite hardware. Load switches are used to control power to various subsystems and components. + +## Overview + +The `LoadSwitchManager` class implements the `LoadSwitchProto` interface and provides: + +- Individual switch control (turn on/off specific switches) +- Bulk operations (turn all switches on/off) +- State tracking (public dictionary of switch states) +- Dynamic switch management (add/remove switches at runtime) +- Custom naming for each load switch +- Configurable enable logic (active high/low) + +## Usage + +### Basic Setup + +```python +from lib.pysquared.hardware.load_switch.manager.load_switch import LoadSwitchManager +from digitalio import DigitalInOut +import board + +# Define your load switches with custom names +load_switches = { + "radio": DigitalInOut(board.RADIO_ENABLE), + "imu": DigitalInOut(board.IMU_ENABLE), + "magnetometer": DigitalInOut(board.MAG_ENABLE), + "camera": DigitalInOut(board.CAMERA_ENABLE), +} + +# Initialize the manager +load_switch_manager = LoadSwitchManager(logger, load_switches, enable_logic=True) +``` + +### Individual Switch Control + +```python +# Turn on a specific switch +success = load_switch_manager.turn_on("radio") +if success: + print("Radio turned on successfully") + +# Turn off a specific switch +success = load_switch_manager.turn_off("camera") +if success: + print("Camera turned off successfully") +``` + +### Bulk Operations + +```python +# Turn all switches on +success = load_switch_manager.turn_all_on() +if success: + print("All switches turned on") + +# Turn all switches off +success = load_switch_manager.turn_all_off() +if success: + print("All switches turned off") +``` + +### State Monitoring + +```python +# Check individual switch state +radio_state = load_switch_manager.get_switch_state("radio") +if radio_state is True: + print("Radio is on") +elif radio_state is False: + print("Radio is off") +else: + print("Radio switch not found") + +# Get all switch states +all_states = load_switch_manager.get_all_states() +print(f"All switch states: {all_states}") + +# Access the public state dictionary directly +print(f"Radio state: {load_switch_manager.switch_states['radio']}") +``` + +### Dynamic Switch Management + +```python +# Add a new switch at runtime +new_switch = DigitalInOut(board.NEW_DEVICE_ENABLE) +success = load_switch_manager.add_switch("new_device", new_switch) +if success: + print("New switch added successfully") + +# Remove a switch +success = load_switch_manager.remove_switch("camera") +if success: + print("Camera switch removed") + +# Get list of all switch names +switch_names = load_switch_manager.get_switch_names() +print(f"Available switches: {switch_names}") +``` + +## Configuration + +### Enable Logic + +The `enable_logic` parameter determines whether switches are activated with a high or low signal: + +```python +# Active high (default) - switches turn on when pin is HIGH +manager_high = LoadSwitchManager(logger, switches, enable_logic=True) + +# Active low - switches turn on when pin is LOW +manager_low = LoadSwitchManager(logger, switches, enable_logic=False) +``` + +## Error Handling + +The LoadSwitchManager includes comprehensive error handling: + +- Invalid switch names return `False` for operations +- Hardware errors are logged and return `False` +- Initialization errors raise `HardwareInitializationError` +- All operations are logged for debugging + +## Interface Methods + +### Required Methods (LoadSwitchProto) + +- `turn_on(switch_name: str) -> bool` +- `turn_off(switch_name: str) -> bool` +- `turn_all_on() -> bool` +- `turn_all_off() -> bool` +- `get_switch_state(switch_name: str) -> bool | None` +- `get_all_states() -> Dict[str, bool]` + +### Additional Methods + +- `add_switch(switch_name: str, switch_pin: DigitalInOut) -> bool` +- `remove_switch(switch_name: str) -> bool` +- `get_switch_names() -> list[str]` + +## Properties + +- `switch_states: Dict[str, bool]` - Public dictionary tracking all switch states + +## Dependencies + +- `digitalio.DigitalInOut` - For pin control +- `pysquared.logger.Logger` - For logging +- `pysquared.protos.load_switch.LoadSwitchProto` - Interface definition +- `pysquared.hardware.decorators.with_retries` - For retry logic +- `pysquared.hardware.exception.HardwareInitializationError` - For error handling diff --git a/pysquared/hardware/load_switch/__init__.py b/pysquared/hardware/load_switch/__init__.py new file mode 100644 index 00000000..97bb3035 --- /dev/null +++ b/pysquared/hardware/load_switch/__init__.py @@ -0,0 +1,10 @@ +""" +Load Switch Hardware Module +=========================== + +This module provides load switch management functionality for PySquared satellite hardware. +""" + +from .manager import LoadSwitchManager + +__all__ = ["LoadSwitchManager"] diff --git a/pysquared/hardware/load_switch/manager/__init__.py b/pysquared/hardware/load_switch/manager/__init__.py new file mode 100644 index 00000000..818f24a8 --- /dev/null +++ b/pysquared/hardware/load_switch/manager/__init__.py @@ -0,0 +1,10 @@ +""" +Load Switch Manager Module +========================== + +This module provides load switch manager implementations for PySquared satellite hardware. +""" + +from .load_switch import LoadSwitchManager + +__all__ = ["LoadSwitchManager"] diff --git a/pysquared/hardware/load_switch/manager/load_switch.py b/pysquared/hardware/load_switch/manager/load_switch.py new file mode 100644 index 00000000..59042cb3 --- /dev/null +++ b/pysquared/hardware/load_switch/manager/load_switch.py @@ -0,0 +1,255 @@ +""" +Load Switch Manager +================== + +This module provides a manager for controlling load switches on PySquared satellite hardware. +Load switches are used to control power to various subsystems and components. + +Usage Example: + +from lib.pysquared.hardware.load_switch.manager.load_switch import LoadSwitchManager +from digitalio import DigitalInOut +import board + +# Define your load switches with custom names +load_switches = { + "radio": DigitalInOut(board.RADIO_ENABLE), + "imu": DigitalInOut(board.IMU_ENABLE), + "magnetometer": DigitalInOut(board.MAG_ENABLE), + "camera": DigitalInOut(board.CAMERA_ENABLE), +} + +# Initialize the manager +load_switch_manager = LoadSwitchManager(logger, load_switches, enable_logic=True) + +# Control individual switches +load_switch_manager.turn_on("radio") +load_switch_manager.turn_off("camera") + +# Control all switches +load_switch_manager.turn_all_off() +load_switch_manager.turn_all_on() + +# Check states +radio_state = load_switch_manager.get_switch_state("radio") +all_states = load_switch_manager.get_all_states() +""" + +from digitalio import DigitalInOut + +from ....logger import Logger +from ....protos.load_switch import LoadSwitchProto +from ...decorators import with_retries +from ...exception import HardwareInitializationError + + +class LoadSwitchManager(LoadSwitchProto): + """Class for managing load switch ports.""" + + def __init__( + self, + logger: Logger, + load_switches: dict, + enable_logic: bool = True, + ) -> None: + """ + Initializes the load switch manager class. + + :param Logger logger: Logger instance for logging messages. + :param dict load_switches: Dictionary mapping switch names to their DigitalInOut pins. + :param bool enable_logic: Boolean defining whether the load switches are enabled when True or False. Defaults to `True`. + """ + self._log: Logger = logger + self._enable_logic: bool = enable_logic + self._load_switches: dict = load_switches + + # Initialize all switches to the off state + self._initialize_switches() + + # Public dictionary to track switch states + self.switch_states: dict = {name: False for name in load_switches.keys()} + + def turn_on(self, switch_name: str) -> bool: + """Turn on a specific load switch. + + :param str switch_name: The name of the load switch to turn on. + + :return: A Boolean indicating whether the operation was successful + :rtype: bool + + :raises Exception: If there is an error toggling the load switch. + """ + if switch_name not in self._load_switches: + self._log.warning(f"Load switch '{switch_name}' not found") + return False + + try: + self._load_switches[switch_name].value = self._enable_logic + self.switch_states[switch_name] = True + self._log.debug(f"Turned on load switch: {switch_name}") + return True + except Exception as e: + self._log.error(f"Failed to turn on load switch '{switch_name}'", err=e) + return False + + def turn_off(self, switch_name: str) -> bool: + """Turn off a specific load switch. + + :param str switch_name: The name of the load switch to turn off. + + :return: A Boolean indicating whether the operation was successful + :rtype: bool + + :raises Exception: If there is an error toggling the load switch. + """ + if switch_name not in self._load_switches: + self._log.warning(f"Load switch '{switch_name}' not found") + return False + + try: + self._load_switches[switch_name].value = not self._enable_logic + self.switch_states[switch_name] = False + self._log.debug(f"Turned off load switch: {switch_name}") + return True + except Exception as e: + self._log.error(f"Failed to turn off load switch '{switch_name}'", err=e) + return False + + def turn_all_on(self) -> bool: + """Turn on all load switches. + + :return: A Boolean indicating whether the operation was successful + :rtype: bool + + :raises Exception: If there is an error toggling the load switches. + """ + success = True + for switch_name in self._load_switches.keys(): + if not self.turn_on(switch_name): + success = False + + if success: + self._log.info("Turned on all load switches") + else: + self._log.warning("Some load switches failed to turn on") + + return success + + def turn_all_off(self) -> bool: + """Turn off all load switches. + + :return: A Boolean indicating whether the operation was successful + :rtype: bool + + :raises Exception: If there is an error toggling the load switches. + """ + success = True + for switch_name in self._load_switches.keys(): + if not self.turn_off(switch_name): + success = False + + if success: + self._log.info("Turned off all load switches") + else: + self._log.warning("Some load switches failed to turn off") + + return success + + def get_switch_state(self, switch_name: str) -> bool | None: + """Get the current state of a specific load switch. + + :param str switch_name: The name of the load switch to check. + + :return: The current state of the load switch (True for on, False for off) or None if not found + :rtype: bool | None + """ + if switch_name not in self.switch_states: + self._log.warning(f"Load switch '{switch_name}' not found") + return None + + return self.switch_states[switch_name] + + def get_all_states(self) -> dict: + """Get the current state of all load switches. + + :return: A dictionary mapping switch names to their current states + :rtype: dict + """ + return self.switch_states.copy() + + @with_retries(max_attempts=3, initial_delay=0.1) + def _initialize_switches(self) -> None: + """Initialize all load switches to the off state. + + :raises HardwareInitializationError: If any switch fails to initialize. + """ + try: + for switch_name, switch_pin in self._load_switches.items(): + # Set all switches to the off state initially + switch_pin.value = not self._enable_logic + self._log.debug(f"Initialized load switch '{switch_name}' to off state") + except Exception as e: + raise HardwareInitializationError( + "Failed to initialize load switches" + ) from e + + def add_switch(self, switch_name: str, switch_pin: DigitalInOut) -> bool: + """Add a new load switch to the manager. + + :param str switch_name: The name for the new load switch. + :param DigitalInOut switch_pin: The DigitalInOut pin for the new switch. + + :return: A Boolean indicating whether the operation was successful + :rtype: bool + """ + if switch_name in self._load_switches: + self._log.warning(f"Load switch '{switch_name}' already exists") + return False + + try: + # Initialize the new switch to off state + switch_pin.value = not self._enable_logic + + # Add to internal dictionaries + self._load_switches[switch_name] = switch_pin + self.switch_states[switch_name] = False + + self._log.debug(f"Added new load switch: {switch_name}") + return True + except Exception as e: + self._log.error(f"Failed to add load switch '{switch_name}'", err=e) + return False + + def remove_switch(self, switch_name: str) -> bool: + """Remove a load switch from the manager. + + :param str switch_name: The name of the load switch to remove. + + :return: A Boolean indicating whether the operation was successful + :rtype: bool + """ + if switch_name not in self._load_switches: + self._log.warning(f"Load switch '{switch_name}' not found") + return False + + try: + # Turn off the switch before removing + self.turn_off(switch_name) + + # Remove from internal dictionaries + del self._load_switches[switch_name] + del self.switch_states[switch_name] + + self._log.debug(f"Removed load switch: {switch_name}") + return True + except Exception as e: + self._log.error(f"Failed to remove load switch '{switch_name}'", err=e) + return False + + def get_switch_names(self) -> list[str]: + """Get a list of all load switch names. + + :return: A list of all load switch names + :rtype: list[str] + """ + return list(self._load_switches.keys()) diff --git a/pysquared/protos/load_switch.py b/pysquared/protos/load_switch.py new file mode 100644 index 00000000..1e2e7567 --- /dev/null +++ b/pysquared/protos/load_switch.py @@ -0,0 +1,69 @@ +""" +Protocol defining the interface for load switch management. +""" + + +class LoadSwitchProto: + """Protocol defining the interface for load switch management.""" + + def turn_on(self, switch_name: str) -> bool: + """Turn on a specific load switch. + + :param str switch_name: The name of the load switch to turn on. + + :return: A Boolean indicating whether the operation was successful + :rtype: bool + + :raises Exception: If there is an error toggling the load switch. + """ + ... + + def turn_off(self, switch_name: str) -> bool: + """Turn off a specific load switch. + + :param str switch_name: The name of the load switch to turn off. + + :return: A Boolean indicating whether the operation was successful + :rtype: bool + + :raises Exception: If there is an error toggling the load switch. + """ + ... + + def turn_all_on(self) -> bool: + """Turn on all load switches. + + :return: A Boolean indicating whether the operation was successful + :rtype: bool + + :raises Exception: If there is an error toggling the load switches. + """ + ... + + def turn_all_off(self) -> bool: + """Turn off all load switches. + + :return: A Boolean indicating whether the operation was successful + :rtype: bool + + :raises Exception: If there is an error toggling the load switches. + """ + ... + + def get_switch_state(self, switch_name: str) -> bool | None: + """Get the current state of a specific load switch. + + :param str switch_name: The name of the load switch to check. + + :return: The current state of the load switch (True for on, False for off) or None if not found + :rtype: bool | None + """ + ... + + def get_all_states(self) -> dict: + """Get the current state of all load switches. + + :return: A dictionary mapping switch names to their current states + :rtype: dict + """ + ... diff --git a/tests/unit/hardware/load_switch/manager/test_load_switch_manager.py b/tests/unit/hardware/load_switch/manager/test_load_switch_manager.py new file mode 100644 index 00000000..19fce0e8 --- /dev/null +++ b/tests/unit/hardware/load_switch/manager/test_load_switch_manager.py @@ -0,0 +1,170 @@ +""" +Test Load Switch Manager +======================= + +This module provides unit tests for the LoadSwitchManager class. +""" + +from unittest.mock import Mock + +import pytest + +from pysquared.hardware.load_switch.manager.load_switch import LoadSwitchManager +from pysquared.logger import Logger + + +class TestLoadSwitchManager: + """Test cases for LoadSwitchManager.""" + + @pytest.fixture + def mock_logger(self): + """Create a mock logger for testing.""" + return Mock(spec=Logger) + + @pytest.fixture + def mock_switches(self): + """Create mock load switches for testing.""" + switches = {} + for name in ["radio", "imu", "camera"]: + mock_switch = Mock() + mock_switch.value = False + switches[name] = mock_switch + return switches + + @pytest.fixture + def load_switch_manager(self, mock_logger, mock_switches): + """Create a LoadSwitchManager instance for testing.""" + return LoadSwitchManager(mock_logger, mock_switches, enable_logic=True) + + def test_initialization(self, mock_logger, mock_switches): + """Test LoadSwitchManager initialization.""" + manager = LoadSwitchManager(mock_logger, mock_switches, enable_logic=True) + + assert manager.switch_states == {"radio": False, "imu": False, "camera": False} + assert len(manager.get_switch_names()) == 3 + + def test_turn_on_switch(self, load_switch_manager, mock_switches): + """Test turning on a specific switch.""" + result = load_switch_manager.turn_on("radio") + + assert result is True + assert load_switch_manager.switch_states["radio"] is True + mock_switches["radio"].value = True + + def test_turn_off_switch(self, load_switch_manager, mock_switches): + """Test turning off a specific switch.""" + # First turn on the switch + load_switch_manager.turn_on("radio") + + # Then turn it off + result = load_switch_manager.turn_off("radio") + + assert result is True + assert load_switch_manager.switch_states["radio"] is False + mock_switches["radio"].value = False + + def test_turn_on_nonexistent_switch(self, load_switch_manager): + """Test turning on a switch that doesn't exist.""" + result = load_switch_manager.turn_on("nonexistent") + + assert result is False + + def test_turn_all_on(self, load_switch_manager, mock_switches): + """Test turning on all switches.""" + result = load_switch_manager.turn_all_on() + + assert result is True + assert all(load_switch_manager.switch_states.values()) + for switch in mock_switches.values(): + assert switch.value is True + + def test_turn_all_off(self, load_switch_manager, mock_switches): + """Test turning off all switches.""" + # First turn all on + load_switch_manager.turn_all_on() + + # Then turn all off + result = load_switch_manager.turn_all_off() + + assert result is True + assert not any(load_switch_manager.switch_states.values()) + for switch in mock_switches.values(): + assert switch.value is False + + def test_get_switch_state(self, load_switch_manager): + """Test getting the state of a specific switch.""" + # Initially all switches are off + state = load_switch_manager.get_switch_state("radio") + assert state is False + + # Turn on the switch + load_switch_manager.turn_on("radio") + state = load_switch_manager.get_switch_state("radio") + assert state is True + + def test_get_switch_state_nonexistent(self, load_switch_manager): + """Test getting the state of a nonexistent switch.""" + state = load_switch_manager.get_switch_state("nonexistent") + assert state is None + + def test_get_all_states(self, load_switch_manager): + """Test getting all switch states.""" + states = load_switch_manager.get_all_states() + expected = {"radio": False, "imu": False, "camera": False} + assert states == expected + + # Turn on one switch and check again + load_switch_manager.turn_on("radio") + states = load_switch_manager.get_all_states() + expected["radio"] = True + assert states == expected + + def test_add_switch(self, load_switch_manager): + """Test adding a new switch.""" + mock_new_switch = Mock() + mock_new_switch.value = False + + result = load_switch_manager.add_switch("new_switch", mock_new_switch) + + assert result is True + assert "new_switch" in load_switch_manager.get_switch_names() + assert load_switch_manager.switch_states["new_switch"] is False + + def test_add_duplicate_switch(self, load_switch_manager): + """Test adding a switch that already exists.""" + mock_new_switch = Mock() + + result = load_switch_manager.add_switch("radio", mock_new_switch) + + assert result is False + + def test_remove_switch(self, load_switch_manager): + """Test removing a switch.""" + result = load_switch_manager.remove_switch("radio") + + assert result is True + assert "radio" not in load_switch_manager.get_switch_names() + assert "radio" not in load_switch_manager.switch_states + + def test_remove_nonexistent_switch(self, load_switch_manager): + """Test removing a switch that doesn't exist.""" + result = load_switch_manager.remove_switch("nonexistent") + + assert result is False + + def test_get_switch_names(self, load_switch_manager): + """Test getting all switch names.""" + names = load_switch_manager.get_switch_names() + expected = ["radio", "imu", "camera"] + assert sorted(names) == sorted(expected) + + def test_enable_logic_false(self, mock_logger, mock_switches): + """Test LoadSwitchManager with enable_logic=False.""" + manager = LoadSwitchManager(mock_logger, mock_switches, enable_logic=False) + + # Turn on a switch + result = manager.turn_on("radio") + assert result is True + assert manager.switch_states["radio"] is True + # With enable_logic=False, the pin should be set to False to turn on + mock_switches["radio"].value = False