diff --git a/bmspy-ups.service b/bmspy-ups.service new file mode 100644 index 0000000..6aa6bc5 --- /dev/null +++ b/bmspy-ups.service @@ -0,0 +1,13 @@ +[Unit] +Description=UPS monitoring for BMS +Requires=bmspy-server.service + +[Service] +Type=exec +WorkingDirectory=/usr/local/bmspy +ExecStart=poetry run bmspy-ups +RestartSec=5 +Restart=on-failure + +[Install] +WantedBy=default.target diff --git a/bmspy/ups.py b/bmspy/ups.py new file mode 100644 index 0000000..83936e5 --- /dev/null +++ b/bmspy/ups.py @@ -0,0 +1,194 @@ +from collections import deque +import argparse +import atexit, datetime, os, re, sys, time +import smtplib, ssl, socket +from bmspy import client + +DAEMON_UPDATE_PERIOD = 30 + +scheduled_shutdown = False +critical_sent = False +warning_sent = False +alert_sent = False + +def handle_shutdown(action = 'cancel', delay = 0, debug = 0): + global scheduled_shutdown + + if action == 'shutdown': + if scheduled_shutdown is False: + scheduled_shutdown = time.time() + delay * 60 * 1000 + os.system("/sbin/shutdown {}".format(delay)) + + elif action == 'cancel': + os.system("/sbin/shutdown -c") + + return + + +def handle_email(text, level, recipient = "root", mailserver = "localhost", port = 25, mailuser = None, mailpass = None, debug = 0): + isSSL = False + hostname = socket.gethostname() + + if re.match("/@/", recipient) is None: + recipient = "{}@{}".format(recipient, hostname) + sender = "bmspy on {} ".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(): + 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', + 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('--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) + + history = deque() + while True: + data = client.read_data(args.socket, 'ups') + history.append(data) + + # Remove the oldest data from the history + while len(history) > 10: + history.popleft() + + if len(history) > 3: + comparison_1 = history[1] + comparison_2 = history[2] + comparison_3 = history[3] + else: + if debug > 1: + print("Not enough readings, sleeping...") + time.sleep(DAEMON_UPDATE_PERIOD) + continue + + current_amps = float(data['bms_current_amps']['raw_value']) + charge_ratio = float(data['bms_capacity_charge_ratio']['raw_value']) * 100 + comparison_1_current_amps = float(comparison_1['bms_current_amps']['raw_value']) + comparison_1_charge_ratio = float(comparison_1['bms_capacity_charge_ratio']['raw_value']) * 100 + comparison_2_current_amps = float(comparison_2['bms_current_amps']['raw_value']) + comparison_2_charge_ratio = float(comparison_2['bms_capacity_charge_ratio']['raw_value']) * 100 + comparison_3_current_amps = float(comparison_3['bms_current_amps']['raw_value']) + comparison_3_charge_ratio = float(comparison_3['bms_capacity_charge_ratio']['raw_value']) * 100 + + if debug > 1: + print("current: {:>3.2f}A\ncapacity remaining: {:>4.0f}%".format(current_amps, charge_ratio)) + + if 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 + + # Current needs to be negative 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: 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 + + time.sleep(DAEMON_UPDATE_PERIOD) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 5b8a7de..1b92afa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,4 +25,5 @@ bmspy = "bmspy:main" bmspy-server = "bmspy.server:main" bmspy-influxdb = "bmspy.influxdb:main" bmspy-prometheus = "bmspy.prometheus:main" +bmspy-ups = "bmspy.ups:main" bmspy-usbd = "bmspy.usbhid:main"