Report a power return at any charge level

The charge tests and the power tests were one if/elif chain. If mains power returned while the charge was below the warning threshold, the loop did not report it, did not cancel the shutdown, and did not clear the flags for the next power failure.

The power tests now run first, on their own. The charge tests apply only while the battery discharges for two consecutive readings, so a low battery on mains power starts no shutdown. A device that reports no battery current is judged by its charge alone.
This commit is contained in:
tim
2026-10-10 10:59:35 +02:00
parent 3bc86b85a2
commit 98fd08e39b
2 changed files with 96 additions and 47 deletions
+45 -5
View File
@@ -360,7 +360,7 @@ class TestUpsMain:
data_seq = []
# Populate history with 5 readings all at 20% (below 30% critical)
for _ in range(5):
data_seq.append(self._make_data(0.0, 0.20))
data_seq.append(self._make_data(-1.0, 0.20))
mock_shutdown = MagicMock()
mock_email = MagicMock()
@@ -381,7 +381,7 @@ class TestUpsMain:
data_seq = []
# 5 readings at 60% (below 75% warning, above 30% critical)
for _ in range(5):
data_seq.append(self._make_data(0.0, 0.60))
data_seq.append(self._make_data(-1.0, 0.60))
mock_email = MagicMock()
with patch("sys.argv", ["bmspy-ups", "--warning", "75", "--critical", "30"]):
@@ -397,7 +397,7 @@ class TestUpsMain:
def test_main_mail_port_option_is_a_number(self):
"""A text port never equals 587, so the STARTTLS check would not match."""
ups_mod.warning_sent = False
data_seq = [self._make_data(0.0, 0.60)] * 5
data_seq = [self._make_data(-1.0, 0.60)] * 5
mock_smtp = MagicMock()
server = mock_smtp.return_value.__enter__.return_value
with patch("sys.argv", ["bmspy-ups", "--port", "587"]):
@@ -514,6 +514,46 @@ class TestUpsMain:
(14, "shutdown"), (14, "Critical alert"), (15, "shutdown"),
]
OUTAGE = [(1.0, 0.95)] * 8 + [(-5.0, 0.90)] * 4 # mains power fails at reading 9
def test_power_return_below_the_warning_level_is_reported(self):
"""The charge is 60% when mains power returns at reading 17."""
readings = self.OUTAGE + [(-5.0, 0.60)] * 4 + [(3.0, 0.60)] * 8 + [(3.0, 0.80)] * 4
assert self._events(readings) == [
(10, "Power loss alert"), (14, "Warning"), (18, "cancel"), (18, "Recovery alert"),
]
# The next power failure can send its emails again.
assert (ups_mod.alert_sent, ups_mod.warning_sent, ups_mod.critical_sent) == (False, False, False)
def test_power_return_below_the_critical_level_cancels_the_shutdown(self):
"""The charge is 25% when mains power returns at reading 17."""
readings = self.OUTAGE + [(-5.0, 0.25)] * 4 + [(3.0, 0.25)] * 8
events = self._events(readings)
assert events[:3] == [(10, "Power loss alert"), (14, "shutdown"), (14, "Critical alert")]
# The shutdown is canceled, and the low charge on mains power does not start it again.
assert events[-2:] == [(18, "cancel"), (18, "Recovery alert")]
assert [number for number, event in events if event == "shutdown"] == [14, 15, 16]
def test_second_power_failure_is_reported_again(self):
readings = self.OUTAGE + [(-5.0, 0.60)] * 4 + [(3.0, 0.60)] * 6
readings = readings + [(-5.0, 0.60)] * 4
assert [event for _, event in self._events(readings)] == [
"Power loss alert", "Warning", "cancel", "Recovery alert", "Power loss alert", "Warning",
]
def test_one_discharging_reading_on_mains_power_does_not_shut_down(self):
"""One reading can be wrong. A shutdown needs two discharging readings."""
events = self._events([(3.0, 0.20)] * 5 + [(-0.1, 0.20)] + [(3.0, 0.20)] * 4)
assert [event for _, event in events if event in ("shutdown", "Critical alert", "Warning")] == []
def test_low_battery_on_mains_power_does_not_shut_down(self):
"""At a restart after a long power failure, the battery is low and it charges."""
assert self._events([(3.0, 0.20)] * 8) == []
def test_device_without_current_is_judged_by_its_charge(self):
"""The River models report the charge but no battery current."""
events = self._events([(None, 0.95)] * 5 + [(None, 0.20)] * 2)
assert events == [(7, "shutdown"), (7, "Critical alert")]
def test_main_not_enough_history_debug_2(self, capsys):
"""With debug=2 and < 4 readings, prints 'not enough readings'."""
@@ -532,7 +572,7 @@ class TestUpsMain:
def test_main_below_warning_debug_1(self, capsys):
"""With debug=1, warning message is printed."""
ups_mod.warning_sent = False
data_seq = [self._make_data(0.0, 0.60)] * 5
data_seq = [self._make_data(-1.0, 0.60)] * 5
with patch("sys.argv", ["bmspy-ups", "-v", "--warning", "75", "--critical", "30"]):
with pytest.raises(StopIteration):
with patch("bmspy.ups.client.read_data", side_effect=[data_seq[0]] + data_seq + [StopIteration("done")]), \
@@ -603,7 +643,7 @@ class TestUpsMain:
def test_main_debug_1_prints_thresholds(self, capsys):
"""With debug=1 and below threshold, threshold messages are printed."""
ups_mod.critical_sent = False
data_seq = [self._make_data(0.0, 0.20)] * 5
data_seq = [self._make_data(-1.0, 0.20)] * 5
with patch("sys.argv", ["bmspy-ups", "-v", "--critical", "30"]):
with pytest.raises(StopIteration):
with patch("bmspy.ups.client.read_data", side_effect=[data_seq[0]] + data_seq + [StopIteration("done")]), \