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
@@ -125,6 +125,12 @@ def socket_comms(socket_path: str, request_data: dict[str, Any], debug: int = 0)
return response_data
def list_devices(socket_path: str, client_name: str, debug: int = 0) -> list[str]:
"""Return the names of all configured devices, including those without data."""
data = socket_comms(socket_path, {"command": "DEVICES", "client": client_name}, debug)
return list(data["devices"])
def read_data(socket_path: str, client_name: str, ups: str | None = None, debug: int = 0) -> dict[str, UPS]:
"""Return {ups_name: UPS} for all devices, or just the named one."""
request: dict[str, Any] = {"command": "GET", "client": client_name}
+4
View File
@@ -433,6 +433,10 @@ def main():
debug,
)
case "DEVICES":
# Configured names, including devices that have no data yet.
send_response(connection, {"devices": list(ups_devices)}, client, debug)
case "GET":
ups_filter = request_data.get("ups")
targets = (
+30 -4
View File
@@ -1,16 +1,20 @@
import argparse
import atexit
import os
import sys
import time
import smtplib
import ssl
import socket
from collections import deque
from collections.abc import Collection
from bmspy import client
from bmspy.classes import UPS, BMSScalarField
DAEMON_UPDATE_PERIOD = 30
STARTUP_RETRY_PERIOD = 5
USAGE_ERROR_STATUS = 2
scheduled_shutdown = False
critical_sent = False
@@ -26,9 +30,11 @@ def _get_field_value(device: UPS, field_name: str) -> float | None:
return None
def _resolve_ups_device(data: dict[str, UPS], requested: str | None) -> str | None:
def _resolve_ups_device(data: Collection[str], requested: str | None) -> str | None:
"""Return the device name to monitor, or None and print an error if the selection is invalid.
*data* holds the available device names. A dict keyed by name also works.
Rules:
- No devices → error.
- requested is set and found → use it.
@@ -58,6 +64,25 @@ def _resolve_ups_device(data: dict[str, UPS], requested: str | None) -> str | No
return next(iter(data))
def _wait_for_ups_device(socket_path: str, requested: str | None) -> str | None:
"""Select the device to monitor, then wait until the server has data for it.
The selection uses the configured device names, not the devices that have
data now. A device that misses its first read at startup then cannot change
the selection. A wrong or missing choice is final: it returns None.
"""
name = _resolve_ups_device(client.list_devices(socket_path, "ups"), requested)
if name is None:
return None
waiting = False
while name not in client.read_data(socket_path, "ups", ups=name):
if not waiting:
print("UPS '{}' has no data yet, waiting...".format(name))
waiting = True
time.sleep(STARTUP_RETRY_PERIOD)
return name
def handle_shutdown(action: str = "cancel", delay: int = 0, debug: int = 0) -> None:
"""Schedule or cancel a system shutdown; only schedules once per incident."""
global scheduled_shutdown
@@ -224,10 +249,11 @@ def main() -> None:
client.handle_registration(args.socket, "ups", debug)
atexit.register(client.handle_registration, args.socket, "ups", debug)
initial_data = client.read_data(args.socket, "ups")
device_name = _resolve_ups_device(initial_data, args.device)
device_name = _wait_for_ups_device(args.socket, args.device)
if device_name is None:
return
# A wrong or missing --device is a usage error, as in argparse. A retry
# cannot fix it, so the service file does not restart on this status.
sys.exit(USAGE_ERROR_STATUS)
history = deque()
while True: