Skip to content

Commit 38dbf81

Browse files
Mikefly123Copilotineskhou
authored
Introducing a Load Switch Manager (#297)
Signed-off-by: Michael Pham <[email protected]> Co-authored-by: Copilot <[email protected]> Co-authored-by: ineskhou <[email protected]>
1 parent 6194938 commit 38dbf81

File tree

5 files changed

+346
-0
lines changed

5 files changed

+346
-0
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""
2+
Load switch hardware interface.
3+
"""
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""
2+
Load switch manager class.
3+
"""
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""This is a generic load switch manager for controlling power to components.
2+
3+
Usage:
4+
5+
from lib.pysquared.hardware.load_switch.manager.loadswitch_manager import LoadSwitchManager
6+
7+
load_switch_0 = LoadSwitchManager(
8+
FACE0_ENABLE, True
9+
)
10+
11+
load_switch_0.enable_load()
12+
load_switch_0.disable_load()
13+
load_switch_0.reset_load()
14+
is_enabled = load_switch_0.is_enabled
15+
16+
"""
17+
18+
import time
19+
20+
from digitalio import DigitalInOut
21+
22+
from pysquared.protos.loadswitch import LoadSwitchManagerProto
23+
24+
25+
class LoadSwitchManager(LoadSwitchManagerProto):
26+
"""Manages load switch operations for any component or group of components that
27+
has an independent load switch for power control.
28+
29+
This class provides methods to enable, disable, and reset the load switch,
30+
as well as check its current state. It is designed to work with a digital pin
31+
that controls the load switch, allowing for high or low enable logic.
32+
"""
33+
34+
def __init__(self, load_switch_pin: DigitalInOut, enable_high: bool = True) -> None:
35+
"""Initialize the load switch manager.
36+
:param load_switch_pin: DigitalInOut pin controlling the load switch
37+
:param enable_high: If True, load switch enables when pin is HIGH. If False, enables when LOW
38+
"""
39+
self._load_switch_pin = load_switch_pin
40+
self._enable_pin_value = enable_high
41+
self._disable_pin_value = not enable_high
42+
43+
def enable_load(self) -> None:
44+
"""Enables the load switch, allowing power to flow.
45+
:raises RuntimeError: If the load switch cannot be enabled due to hardware issues
46+
"""
47+
try:
48+
self._load_switch_pin.value = self._enable_pin_value
49+
except Exception as e:
50+
raise RuntimeError(f"Failed to enable load switch: {e}") from e
51+
52+
def disable_load(self) -> None:
53+
"""Disables the load switch, cutting power.
54+
:raises RuntimeError: If the load switch cannot be disabled due to hardware issues
55+
"""
56+
try:
57+
self._load_switch_pin.value = self._disable_pin_value
58+
except Exception as e:
59+
raise RuntimeError(f"Failed to disable load switch: {e}") from e
60+
61+
def reset_load(self) -> None:
62+
"""Reset the load switch by momentarily disabling then re-enabling it.
63+
This method performs a momentary power cycle (0.1s) to reset the load switch
64+
and any connected components. Errors from underlying drivers are reraised.
65+
:raises RuntimeError: If the load switch cannot be reset due to hardware issues
66+
"""
67+
try:
68+
was_enabled = self.is_enabled
69+
self.disable_load()
70+
time.sleep(0.1)
71+
if was_enabled:
72+
self.enable_load()
73+
except Exception as e:
74+
raise RuntimeError(f"Failed to reset load switch: {e}") from e
75+
76+
@property
77+
def is_enabled(self) -> bool:
78+
"""Check if the load switch is currently enabled.
79+
:raises RuntimeError: If the load switch state cannot be read due to hardware issues
80+
:return: True if the load switch is enabled, False otherwise
81+
"""
82+
try:
83+
pin_value = self._load_switch_pin.value
84+
return pin_value == self._enable_pin_value
85+
except Exception as e:
86+
raise RuntimeError(f"Failed to read load switch state: {e}") from e
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Load switch manager protocol for generic components."""
2+
3+
4+
class LoadSwitchManagerProto:
5+
"""Protocol for load switch management in generic systems.
6+
This protocol defines the interface for managing load switches that control
7+
power to components. Load switches can be enabled, disabled,
8+
and reset with momentary power cycling.
9+
"""
10+
11+
def enable_load(self) -> None:
12+
"""Enable the load switch to provide power to the component.
13+
:raises RuntimeError: If the load switch cannot be enabled due to hardware issues
14+
"""
15+
...
16+
17+
def disable_load(self) -> None:
18+
"""Disable the load switch to cut power to the component.
19+
:raises RuntimeError: If the load switch cannot be disabled due to hardware issues
20+
"""
21+
...
22+
23+
def reset_load(self) -> None:
24+
"""Reset the load switch by momentarily disabling then re-enabling it.
25+
This method performs a momentary power cycle (0.1s) to reset the load switch
26+
and any connected components. Errors from underlying drivers are reraised.
27+
:raises RuntimeError: If the load switch cannot be reset due to hardware issues
28+
"""
29+
...
30+
31+
@property
32+
def is_enabled(self) -> bool:
33+
"""Check if the load switch is currently enabled.
34+
:raises RuntimeError: If the load switch state cannot be read due to hardware issues
35+
:return: True if the load switch is enabled, False otherwise
36+
"""
37+
...
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
"""Unit tests for the LoadSwitchManager class.
2+
3+
This module contains unit tests for the `LoadSwitchManager` class, which controls
4+
load switch operations for power management. The tests cover initialization,
5+
successful operations, error handling, and state management.
6+
"""
7+
8+
import sys
9+
from unittest.mock import MagicMock, patch
10+
11+
import pytest
12+
13+
# Mock digitalio module before importing LoadSwitchManager
14+
digitalio = MagicMock()
15+
digitalio.DigitalInOut = MagicMock
16+
sys.modules["digitalio"] = digitalio
17+
18+
from pysquared.hardware.load_switch.manager.loadswitch_manager import ( # noqa: E402
19+
LoadSwitchManager,
20+
)
21+
22+
23+
@pytest.fixture
24+
def mock_pin():
25+
"""Provides a mock DigitalInOut pin for testing."""
26+
return MagicMock()
27+
28+
29+
@pytest.fixture
30+
def manager_enable_high(mock_pin):
31+
"""Provides a LoadSwitchManager with enable_high=True."""
32+
return LoadSwitchManager(load_switch_pin=mock_pin, enable_high=True)
33+
34+
35+
@pytest.fixture
36+
def manager_enable_low(mock_pin):
37+
"""Provides a LoadSwitchManager with enable_high=False."""
38+
return LoadSwitchManager(load_switch_pin=mock_pin, enable_high=False)
39+
40+
41+
def test_loadswitch_initialization_enable_high(manager_enable_high, mock_pin):
42+
"""Tests LoadSwitchManager initialization with enable_high=True."""
43+
# Test behavior through public interface - enable should set pin to True
44+
manager_enable_high.enable_load()
45+
assert mock_pin.value is True
46+
47+
48+
def test_loadswitch_initialization_enable_low(manager_enable_low, mock_pin):
49+
"""Tests LoadSwitchManager initialization with enable_high=False."""
50+
# Test behavior through public interface - enable should set pin to False
51+
manager_enable_low.enable_load()
52+
assert mock_pin.value is False
53+
54+
55+
def test_loadswitch_initialization_default_enable_high(mock_pin):
56+
"""Tests LoadSwitchManager initialization with default enable_high=True."""
57+
manager = LoadSwitchManager(load_switch_pin=mock_pin)
58+
# Test behavior through public interface - enable should set pin to True (default)
59+
manager.enable_load()
60+
assert mock_pin.value is True
61+
62+
63+
@pytest.mark.parametrize(
64+
"manager_fixture,expected_value",
65+
[("manager_enable_high", True), ("manager_enable_low", False)],
66+
)
67+
def test_enable_load_success(manager_fixture, expected_value, request, mock_pin):
68+
"""Tests successful load enable operation for both enable logic types."""
69+
manager = request.getfixturevalue(manager_fixture)
70+
manager.enable_load()
71+
assert mock_pin.value is expected_value
72+
73+
74+
def test_enable_load_hardware_failure(manager_enable_high, mock_pin):
75+
"""Tests enable_load error handling when hardware fails."""
76+
# Mock the pin to raise an exception when setting value
77+
type(mock_pin).value = property(
78+
fset=MagicMock(side_effect=RuntimeError("Hardware failure"))
79+
)
80+
81+
with pytest.raises(
82+
RuntimeError, match="Failed to enable load switch: Hardware failure"
83+
):
84+
manager_enable_high.enable_load()
85+
86+
87+
@pytest.mark.parametrize(
88+
"manager_fixture,expected_value",
89+
[("manager_enable_high", False), ("manager_enable_low", True)],
90+
)
91+
def test_disable_load_success(manager_fixture, expected_value, request, mock_pin):
92+
"""Tests successful load disable operation for both enable logic types."""
93+
manager = request.getfixturevalue(manager_fixture)
94+
manager.disable_load()
95+
assert mock_pin.value is expected_value
96+
97+
98+
def test_disable_load_hardware_failure(manager_enable_high, mock_pin):
99+
"""Tests disable_load error handling when hardware fails."""
100+
# Mock the pin to raise an exception when setting value
101+
type(mock_pin).value = property(
102+
fset=MagicMock(side_effect=RuntimeError("Hardware failure"))
103+
)
104+
105+
with pytest.raises(
106+
RuntimeError, match="Failed to disable load switch: Hardware failure"
107+
):
108+
manager_enable_high.disable_load()
109+
110+
111+
@pytest.mark.parametrize(
112+
"manager_fixture,pin_value,expected_enabled",
113+
[
114+
("manager_enable_high", True, True),
115+
("manager_enable_high", False, False),
116+
("manager_enable_low", False, True),
117+
("manager_enable_low", True, False),
118+
],
119+
)
120+
def test_is_enabled(manager_fixture, pin_value, expected_enabled, request, mock_pin):
121+
"""Tests is_enabled property for all combinations of enable logic and pin states."""
122+
manager = request.getfixturevalue(manager_fixture)
123+
mock_pin.value = pin_value
124+
assert manager.is_enabled is expected_enabled
125+
126+
127+
def test_is_enabled_hardware_failure(manager_enable_high, mock_pin):
128+
"""Tests is_enabled error handling when hardware fails."""
129+
# Mock the pin to raise an exception when reading value
130+
type(mock_pin).value = property(
131+
fget=MagicMock(side_effect=RuntimeError("Hardware failure"))
132+
)
133+
134+
with pytest.raises(
135+
RuntimeError, match="Failed to read load switch state: Hardware failure"
136+
):
137+
_ = manager_enable_high.is_enabled
138+
139+
140+
@pytest.mark.parametrize(
141+
"was_enabled,enable_should_be_called",
142+
[(True, True), (False, False)],
143+
)
144+
@patch("pysquared.hardware.load_switch.manager.loadswitch_manager.time.sleep")
145+
def test_reset_load_state_preservation(
146+
mock_sleep, was_enabled, enable_should_be_called, manager_enable_high, mock_pin
147+
):
148+
"""Tests reset_load preserves previous state correctly."""
149+
# Set up initial state
150+
mock_pin.value = was_enabled
151+
152+
with patch.object(manager_enable_high, "disable_load") as mock_disable:
153+
with patch.object(manager_enable_high, "enable_load") as mock_enable:
154+
manager_enable_high.reset_load()
155+
156+
# Verify disable was called
157+
mock_disable.assert_called_once()
158+
# Verify sleep for 0.1 seconds
159+
mock_sleep.assert_called_once_with(0.1)
160+
# Verify enable behavior based on previous state
161+
if enable_should_be_called:
162+
mock_enable.assert_called_once()
163+
else:
164+
mock_enable.assert_not_called()
165+
166+
167+
@pytest.mark.parametrize(
168+
"failure_method,error_message,expected_match",
169+
[
170+
(
171+
"disable_load",
172+
"Disable failed",
173+
"Failed to reset load switch: Disable failed",
174+
),
175+
("enable_load", "Enable failed", "Failed to reset load switch: Enable failed"),
176+
],
177+
)
178+
def test_reset_load_operation_failures(
179+
failure_method, error_message, expected_match, manager_enable_high, mock_pin
180+
):
181+
"""Tests reset_load error handling for disable and enable failures."""
182+
# Set up initial state as enabled
183+
mock_pin.value = True
184+
185+
patches = {}
186+
if failure_method == "disable_load":
187+
patches["disable_load"] = patch.object(
188+
manager_enable_high, "disable_load", side_effect=RuntimeError(error_message)
189+
)
190+
else:
191+
patches["disable_load"] = patch.object(manager_enable_high, "disable_load")
192+
patches["enable_load"] = patch.object(
193+
manager_enable_high, "enable_load", side_effect=RuntimeError(error_message)
194+
)
195+
196+
with patches["disable_load"]:
197+
if "enable_load" in patches:
198+
with patches["enable_load"]:
199+
with pytest.raises(RuntimeError, match=expected_match):
200+
manager_enable_high.reset_load()
201+
else:
202+
with pytest.raises(RuntimeError, match=expected_match):
203+
manager_enable_high.reset_load()
204+
205+
206+
def test_reset_load_is_enabled_check_failure(manager_enable_high, mock_pin):
207+
"""Tests reset_load error handling when is_enabled check fails."""
208+
# Mock the pin to raise an exception when reading value (which is used by is_enabled)
209+
type(mock_pin).value = property(
210+
fget=MagicMock(side_effect=RuntimeError("State check failed"))
211+
)
212+
213+
with pytest.raises(
214+
RuntimeError,
215+
match="Failed to reset load switch: Failed to read load switch state: State check failed",
216+
):
217+
manager_enable_high.reset_load()

0 commit comments

Comments
 (0)