Files
bmspy/bmspy/ups.py
T
tim 98fd08e39b 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.
2026-10-10 10:59:35 +02:00

416 lines
13 KiB
Python

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
warning_sent = False
alert_sent = False
def _get_field_value(device: UPS, field_name: str) -> float | None:
"""Return the raw_value of a named BMSScalarField from a UPS, or None if not found."""
for name, field in device.items():
if name == field_name and isinstance(field, BMSScalarField):
return field.raw_value
return 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.
- requested is set but not found → error listing available devices.
- requested is None and exactly one device → use it.
- requested is None and multiple devices → error; user must specify one.
"""
if not data:
print("Error: no UPS devices found")
return None
if requested is not None:
if requested not in data:
print(
"Error: UPS '{}' not found. Available: {}".format(
requested, ", ".join(sorted(data))
)
)
return None
return requested
if len(data) > 1:
print(
"Error: multiple UPS devices found, specify one with --device: {}".format(
", ".join(sorted(data))
)
)
return None
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
if action == "shutdown":
if scheduled_shutdown is False:
scheduled_shutdown = time.time() + delay * 60
os.system("/sbin/shutdown {}".format(delay))
elif action == "cancel":
os.system("/sbin/shutdown -c")
# Allow the next incident to schedule a shutdown again.
scheduled_shutdown = False
return
def handle_email(
text: str,
level: str | None,
recipient: str = "root",
mailserver: str = "localhost",
port: int = 25,
mailuser: str | None = None,
mailpass: str | None = None,
debug: int = 0,
) -> None:
"""Send an alert email using SMTP, with optional STARTTLS and authentication."""
isSSL = False
hostname = socket.gethostname()
if "@" not in recipient:
recipient = "{}@{}".format(recipient, hostname)
sender = "bmspy on {} <nobody@{}>".format(hostname, hostname)
if port == 465 or port == 587:
isSSL = True
if level is not None:
msg = "From: {}\r\nTo: {}\r\nSubject: {} from BMSPY UPS on {}\r\n\r\n{}\r\n".format(
sender, recipient, level, hostname, text
)
if isSSL:
context = ssl.create_default_context()
with smtplib.SMTP(mailserver, port) as server:
if isSSL:
server.starttls(context=context)
server.ehlo()
if mailuser is not None and mailpass is not None:
server.login(mailuser, mailpass)
server.sendmail(sender, recipient, msg)
return
def main() -> None:
global alert_sent, warning_sent, critical_sent
parser = argparse.ArgumentParser(
description="Query JBD BMS and alert or shutdown when certain thresholds are reached",
add_help=True,
)
parser.add_argument(
"--alert",
"-a",
dest="alert",
action="store_true",
default=True,
help="Email an alert when UPS detects A/C loss (default: true)",
)
parser.add_argument(
"--warning",
"-w",
dest="warning_threshold",
action="store",
type=int,
default=75,
help="Email an alert when remaining capacity percentage drops below this figure (default: 75)",
)
parser.add_argument(
"--critical",
"-c",
dest="critical_threshold",
action="store",
type=int,
default=30,
help="Shut system down when remaining capacity percentage drops below this figure (default: 30)",
)
parser.add_argument(
"--delay",
"-d",
dest="shutdown_delay",
action="store",
type=int,
default=5,
help="Delay system shutdown (default: 5 minutes)",
)
parser.add_argument(
"--mailserver",
"-m",
dest="mailserver",
action="store",
default="localhost",
help="Mail server (default: localhost)",
)
parser.add_argument(
"--port",
"-p",
dest="port",
action="store",
type=int,
default=25,
help="Mail server port (default: 25)",
)
parser.add_argument(
"--user", dest="mailuser", action="store", default=None, help="Mail server user"
)
parser.add_argument(
"--pass",
dest="mailpass",
action="store",
default=None,
help="Mail server password",
)
parser.add_argument(
"--to",
"-t",
dest="recipient",
action="store",
default="root",
help="Email recipient (default: root)",
)
parser.add_argument(
"--socket",
"-s",
dest="socket",
action="store",
default="/run/bmspy/bms",
help="Socket to communicate with daemon",
)
parser.add_argument(
"--device",
dest="device",
action="store",
default=None,
metavar="NAME",
help="Name of the UPS device to monitor (required if multiple devices are available)",
)
parser.add_argument(
"--verbose",
"-v",
action="count",
default=0,
help="Print more verbose information (can be specified multiple times)",
)
args = parser.parse_args()
debug = args.verbose
print("Running BMS UPS daemon on socket {}".format(args.socket))
client.handle_registration(args.socket, "ups", debug)
atexit.register(client.handle_registration, args.socket, "ups", debug)
device_name = _wait_for_ups_device(args.socket, args.device)
if device_name is None:
# 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:
data = client.read_data(args.socket, "ups", ups=device_name)
device_obj = data.get(device_name)
if device_obj is None:
time.sleep(DAEMON_UPDATE_PERIOD)
continue
history.append(device_obj)
# Remove the oldest data from the history
while len(history) > 10:
history.popleft()
if len(history) > 3:
# The newest reading is last. Compare it with the readings before it.
comparison_1 = history[-2]
comparison_2 = history[-3]
comparison_3 = history[-4]
else:
if debug > 1:
print("Not enough readings, sleeping...")
time.sleep(DAEMON_UPDATE_PERIOD)
continue
current_amps = float(_get_field_value(device_obj, "bms_current_amps") or 0.0)
charge_ratio = (
float(_get_field_value(device_obj, "bms_capacity_charge_ratio") or 0.0) * 100
)
comparison_1_current_amps = float(
_get_field_value(comparison_1, "bms_current_amps") or 0.0
)
comparison_1_charge_ratio = (
float(_get_field_value(comparison_1, "bms_capacity_charge_ratio") or 0.0)
* 100
)
comparison_2_current_amps = float(
_get_field_value(comparison_2, "bms_current_amps") or 0.0
)
comparison_2_charge_ratio = (
float(_get_field_value(comparison_2, "bms_capacity_charge_ratio") or 0.0)
* 100
)
comparison_3_current_amps = float(
_get_field_value(comparison_3, "bms_current_amps") or 0.0
)
comparison_3_charge_ratio = (
float(_get_field_value(comparison_3, "bms_capacity_charge_ratio") or 0.0)
* 100
)
if debug > 1:
print(
"current: {:>3.2f}A\ncapacity remaining: {:>4.0f}%".format(
current_amps, charge_ratio
)
)
# A change of the power state is reported at every charge level.
# Current needs to be negative for two consecutive reads
if (
args.alert
and current_amps < 0
and comparison_1_current_amps < 0
and comparison_2_current_amps >= 0
):
if debug > 0:
print("Alert: discharging!")
if alert_sent is False:
handle_email(
text="power lost",
level="Power loss alert",
recipient=args.recipient,
mailserver=args.mailserver,
port=args.port,
mailuser=args.mailuser,
mailpass=args.mailpass,
debug=debug,
)
alert_sent = True
# Current needs to be zero or positive for two consecutive reads
elif (
args.alert
and current_amps >= 0
and comparison_1_current_amps >= 0
and comparison_2_current_amps < 0
):
if debug > 0:
print("Alert: power regained!")
handle_shutdown(action="cancel", debug=debug)
handle_email(
text="power regained",
level="Recovery alert",
recipient=args.recipient,
mailserver=args.mailserver,
port=args.port,
mailuser=args.mailuser,
mailpass=args.mailpass,
debug=debug,
)
critical_sent = False
warning_sent = False
alert_sent = False
# The charge level matters only while the battery supplies the load,
# which needs two consecutive reads, as above. With mains power, a low
# battery charges again. A device that reports no current is judged by
# its charge alone.
has_current = _get_field_value(device_obj, "bms_current_amps") is not None
discharging = current_amps < 0 and comparison_1_current_amps < 0
if has_current and not discharging:
pass
elif (
charge_ratio <= args.critical_threshold
and comparison_1_charge_ratio <= args.critical_threshold
):
if debug > 0:
print("Below critical threshold, shutting down")
handle_shutdown(action="shutdown", delay=args.shutdown_delay, debug=debug)
if critical_sent is False:
handle_email(
text="remaining capacity below {}%, shutting down".format(
args.critical_threshold
),
level="Critical alert",
recipient=args.recipient,
mailserver=args.mailserver,
port=args.port,
mailuser=args.mailuser,
mailpass=args.mailpass,
debug=debug,
)
critical_sent = True
elif (
charge_ratio <= args.warning_threshold
and comparison_1_charge_ratio <= args.warning_threshold
):
if debug > 0:
print("Below warning threshold")
if warning_sent is False:
handle_email(
text="remaining capacity below {}%".format(args.warning_threshold),
level="Warning",
recipient=args.recipient,
mailserver=args.mailserver,
port=args.port,
mailuser=args.mailuser,
mailpass=args.mailpass,
debug=debug,
)
warning_sent = True
time.sleep(DAEMON_UPDATE_PERIOD)
if __name__ == "__main__":
main()