Select the bmspy-ups device from the configured devices

bmspy-ups selected its device from the devices that had data at startup, and exited with status 0 if the selection failed. One failed first read ended the monitor or made it select the wrong device.

The server has a new DEVICES command that lists the configured names, and client.list_devices calls it. bmspy-ups selects from that list and then waits for the first data of its device. A wrong or missing --device now exits with status 2. The service file does not restart on that status, because a restart cannot correct it.

An older server ends on the unknown DEVICES command, so update the server first.
This commit is contained in:
tim
2026-10-10 09:03:35 +02:00
parent 161ae7ee65
commit 82def5737f
7 changed files with 115 additions and 31 deletions
+6
View File
@@ -91,6 +91,12 @@ class TestReadData:
call_args = mock_comms.call_args[0][1]
assert call_args.get("ups") == "myups"
def test_list_devices_returns_configured_names(self):
from bmspy.client import list_devices
with patch("bmspy.client.socket_comms", return_value={"devices": ["network", "nas"]}) as mock_comms:
assert list_devices("/fake/socket", "test") == ["network", "nas"]
assert mock_comms.call_args[0][1] == {"command": "DEVICES", "client": "test"}
def test_no_ups_filter_not_in_request(self):
with patch("bmspy.client.socket_comms") as mock_comms:
mock_comms.return_value = {}
+14
View File
@@ -445,6 +445,20 @@ class TestServerMain:
finally:
self._stop_server(sock_path, t)
def test_devices_command_lists_configured_devices(self, tmp_path):
"""DEVICES names every configured device, through the real client."""
from bmspy import client
sock_path = str(tmp_path / "server_devices.sock")
ready = threading.Event()
t = self._make_server_thread(sock_path, ready, threading.Event())
t.start()
ready.wait(timeout=2)
try:
assert client.list_devices(sock_path, "test") == ["ttyUSB0"]
finally:
self._stop_server(sock_path, t)
def test_deregister_command(self, tmp_path):
"""Test DEREGISTER command via server main()."""
sock_path = str(tmp_path / "server_dereg.sock")
+52 -27
View File
@@ -1,6 +1,6 @@
import pytest
import smtplib
from unittest.mock import patch, MagicMock
from unittest.mock import call, patch, MagicMock
import bmspy.ups as ups_mod
from bmspy.classes import BMSScalarField, BMSMultiField, BMSInfoField, UPS
@@ -24,6 +24,13 @@ def _reset_globals():
ups_mod.alert_sent = False
@pytest.fixture(autouse=True)
def _configured_devices():
"""The main() tests use one configured device unless they patch this themselves."""
with patch("bmspy.ups.client.list_devices", return_value=["testups"]):
yield
# ---------------------------------------------------------------------------
# _get_field_value
# ---------------------------------------------------------------------------
@@ -569,31 +576,57 @@ class TestUpsMainDeviceSelection:
def _make_data(self, name, current_amps=0.0, charge_ratio=0.8):
return {name: _make_ups(current_amps, charge_ratio)}
def test_main_no_devices_returns_early(self, capsys):
"""main() exits cleanly when no devices are found."""
def test_main_waits_at_startup_until_the_device_has_data(self, capsys):
"""A failed first read must not end the monitor: systemd would not restart it."""
with patch("sys.argv", ["bmspy-ups"]), \
patch("bmspy.ups.client.read_data", return_value={}), \
patch("bmspy.ups.client.handle_registration"):
ups_mod.main()
assert "no UPS" in capsys.readouterr().out
patch("bmspy.ups.client.list_devices", return_value=["ups1"]), \
patch("bmspy.ups.client.read_data",
side_effect=[{}, {}, self._make_data("ups1"), StopIteration("done")]) as read_data, \
patch("bmspy.ups.client.handle_registration"), \
patch("bmspy.ups.time.sleep") as mock_sleep:
with pytest.raises(StopIteration):
ups_mod.main()
# Two empty answers, the answer with the device, then the first loop read.
assert [c.kwargs for c in read_data.call_args_list] == [{"ups": "ups1"}] * 4
assert mock_sleep.call_args_list == [call(ups_mod.STARTUP_RETRY_PERIOD)] * 2
# The reason is reported once, not on each retry.
assert capsys.readouterr().out.count("UPS 'ups1' has no data yet") == 1
def test_main_multiple_devices_no_flag_returns_early(self, capsys):
"""main() exits with an error when multiple devices exist and --device is not set."""
two_devices = {
"ups1": _make_ups(0.0, 0.9),
"ups2": _make_ups(0.0, 0.8),
}
with patch("sys.argv", ["bmspy-ups"]), \
patch("bmspy.ups.client.read_data", return_value=two_devices), \
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"]), \
patch("bmspy.ups.client.list_devices", return_value=["ups1", "ups2"]), \
patch("bmspy.ups.client.read_data",
side_effect=[{}, self._make_data("ups2"), StopIteration("done")]) as read_data, \
patch("bmspy.ups.client.handle_registration"), \
patch("bmspy.ups.time.sleep") as mock_sleep:
with pytest.raises(StopIteration):
ups_mod.main()
assert [c.kwargs for c in read_data.call_args_list] == [{"ups": "ups2"}] * 3
assert mock_sleep.call_count == 1
@pytest.mark.parametrize("argv,expected", [
([], "multiple UPS devices found, specify one with --device: ups1, ups2"),
(["--device", "ghost"], "UPS 'ghost' not found. Available: ups1, ups2"),
])
def test_main_wrong_or_missing_device_option_is_an_error_exit(self, capsys, argv, expected):
"""Only ups1 has data, but both are configured: do not guess, and do not wait."""
with patch("sys.argv", ["bmspy-ups"] + argv), \
patch("bmspy.ups.client.list_devices", return_value=["ups1", "ups2"]), \
patch("bmspy.ups.client.read_data", return_value=self._make_data("ups1")) as read_data, \
patch("bmspy.ups.client.handle_registration"):
ups_mod.main()
out = capsys.readouterr().out
assert "ups1" in out or "ups2" in out
with pytest.raises(SystemExit) as exit_info:
ups_mod.main()
# The argparse usage status: systemd reports it and does not restart.
assert exit_info.value.code == ups_mod.USAGE_ERROR_STATUS == 2
assert expected in capsys.readouterr().out
read_data.assert_not_called()
def test_main_ups_flag_selects_device(self):
"""--device selects the correct device from multiple available ones."""
data_seq = [self._make_data("ups2")] * 6
with patch("sys.argv", ["bmspy-ups", "--device", "ups2"]):
with patch("sys.argv", ["bmspy-ups", "--device", "ups2"]), \
patch("bmspy.ups.client.list_devices", return_value=["ups1", "ups2"]):
with pytest.raises(StopIteration):
with patch("bmspy.ups.client.read_data",
side_effect=[{"ups1": _make_ups(0.0, 0.9), "ups2": _make_ups(0.0, 0.8)}]
@@ -619,14 +652,6 @@ class TestUpsMainDeviceSelection:
ups_mod.main()
assert read_data.call_args_list[1] == (("/tmp/test.sock", "ups"), {"ups": "testups"})
def test_main_ups_flag_unknown_device_returns_early(self, capsys):
"""--device with an unknown device name exits with an error."""
with patch("sys.argv", ["bmspy-ups", "--device", "ghost"]), \
patch("bmspy.ups.client.read_data", return_value={"ups1": _make_ups(0.0, 0.9)}), \
patch("bmspy.ups.client.handle_registration"):
ups_mod.main()
assert "ghost" in capsys.readouterr().out
def test_main_device_disappears_in_loop_sleeps(self):
"""When the named device is missing from a loop response, main() sleeps and retries."""
initial = {"testups": _make_ups(0.0, 0.8)}