Report a device that sends no data for 5 minutes

If the server had no data for the device, bmspy-ups slept and read again with no report. A battery that stopped answering during a power failure gave no email and no shutdown.

bmspy-ups now sends one email after 5 minutes with no data, and one more when the data returns. A new email for a loss needs another 5 minutes with no data after that return. The same rule applies to the wait at startup.
This commit is contained in:
tim
2026-10-10 11:05:16 +02:00
parent ec4d824153
commit 6b636cde81
2 changed files with 121 additions and 9 deletions
+64 -6
View File
@@ -469,13 +469,18 @@ class TestUpsMain:
def _events(self, readings, argv=None):
"""Run main() over (current, charge ratio) readings, 30 seconds apart.
Return (reading number, event) for each email and each shutdown call.
A reading of None is a read in which the server has no data for the
device. Return (reading number, event) for each email and each
shutdown call.
"""
sequence = [self._make_data(*reading) for reading in readings]
for data, (current, _) in zip(sequence, readings):
if current is None:
sequence = [{} if reading is None else self._make_data(*reading) for reading in readings]
for data, reading in zip(sequence, readings):
if reading is not None and reading[0] is None:
del data["testups"]._fields["bms_current_amps"]
events, number = [], [0]
events, number, clock = [], [0], [1000.0]
def sleep(seconds):
clock[0] += seconds
def read(*args, **kwargs):
if number[0] > len(sequence):
@@ -487,7 +492,8 @@ class TestUpsMain:
with patch("sys.argv", ["bmspy-ups"] + (argv or [])), \
patch("bmspy.ups.client.read_data", side_effect=read), \
patch("bmspy.ups.client.handle_registration"), \
patch("bmspy.ups.time.sleep"), \
patch("bmspy.ups.time.sleep", side_effect=sleep), \
patch("bmspy.ups.time.monotonic", side_effect=lambda: clock[0]), \
patch("bmspy.ups.handle_shutdown",
side_effect=lambda action, **kw: events.append((number[0] - 1, action))), \
patch("bmspy.ups.handle_email",
@@ -555,6 +561,34 @@ class TestUpsMain:
events = self._events([(None, 0.95)] * 5 + [(None, 0.20)] * 2)
assert events == [(7, "shutdown"), (7, "Critical alert")]
def test_no_data_for_five_minutes_is_reported_once_and_the_return_once(self):
"""Readings are 30 seconds apart, so the tenth read with no data is at 5 minutes."""
readings = (
[(1.0, 0.95)] * 3 # readings 1 to 3: data
+ [None] * 25 # 4 to 28: no data for 12.5 minutes
+ [(1.0, 0.95)] * 3 # 29 to 31: the data returns
+ [None] * 9 # 32 to 40: 4.5 minutes, below the limit
+ [(1.0, 0.95)] * 2 # 41 and 42
+ [None] * 12 # 43 to 54: no data for 6 minutes
)
assert self._events(readings) == [
(13, "Data loss alert"), (29, "Data recovery alert"), (52, "Data loss alert"),
]
def test_data_loss_email_names_the_device(self):
send_email = MagicMock()
with patch("bmspy.ups.time.monotonic", side_effect=[0, 299, 300, 900, 901]):
monitor = ups_mod._DataMonitor(send_email)
monitor.update("network", False) # 299 seconds: below the limit
send_email.assert_not_called()
monitor.update("network", False) # 300 seconds
monitor.update("network", False) # no second email
monitor.update("network", True)
assert [c.kwargs for c in send_email.call_args_list] == [
{"text": "no data from UPS 'network' for 5 minutes", "level": "Data loss alert"},
{"text": "data from UPS 'network' regained", "level": "Data recovery alert"},
]
def test_main_not_enough_history_debug_2(self, capsys):
"""With debug=2 and < 4 readings, prints 'not enough readings'."""
data_seq = [self._make_data(0.0, 0.95)] * 5
@@ -680,6 +714,30 @@ class TestUpsMainDeviceSelection:
# The reason is reported once, not on each retry.
assert capsys.readouterr().out.count("UPS 'ups1' has no data yet") == 1
def test_no_data_at_startup_for_five_minutes_is_reported(self):
"""The startup wait reads every 5 seconds, so read 61 is at 5 minutes."""
clock = [1000.0]
def sleep(seconds):
clock[0] += seconds
reads = [{}] * 70 + [self._make_data("ups1"), self._make_data("ups1"), StopIteration("done")]
mock_email = MagicMock()
with patch("sys.argv", ["bmspy-ups", "--to", "admin@example.com"]), \
patch("bmspy.ups.client.list_devices", return_value=["ups1"]), \
patch("bmspy.ups.client.read_data", side_effect=reads) as read_data, \
patch("bmspy.ups.client.handle_registration"), \
patch("bmspy.ups.time.sleep", side_effect=sleep), \
patch("bmspy.ups.time.monotonic", side_effect=lambda: clock[0]), \
patch("bmspy.ups.handle_email", mock_email):
with pytest.raises(StopIteration):
ups_mod.main()
assert [(c.kwargs["level"], c.kwargs["recipient"]) for c in mock_email.call_args_list] == [
("Data loss alert", "admin@example.com"), ("Data recovery alert", "admin@example.com"),
]
# The loss email goes out during the wait, and the other one after it.
assert read_data.call_count == 73
def test_main_selects_from_configured_devices_not_from_those_with_data(self):
"""One device with data must not be picked when the server has several."""
with patch("sys.argv", ["bmspy-ups", "--device", "ups2"]), \