Close

U1LiUPSRPi Python Code

A project log for U1LiUPSRPi

The Ultimate Single Cell Lithium UPS for any Raspberry Pi

bud-bennettBud Bennett 03/04/2019 at 19:173 Comments

I assembled the latest PCBs for the Hat and 18650 yesterday. Both are performing as designed. Time to finalize the code. The logging information contains the battery chemistry and the charger termination target voltage when the ups monitor program begins. The log also records power failures, power recovery, fault events, charging events, and shutdowns. 

#!/usr/bin/python3
#-------------------------------------------------------------------------------
# Name:        ups_u1liupsrpi.py
# Purpose:
#
# Author:      Bud Bennett
#
# Created:     31-01-2019
# Copyright:   (c) Bud Bennett 2017
# Licence:     <your licence>
#-------------------------------------------------------------------------------
 
 
 
'''
This program monitors the status of the UPS via the i2c interface and
manages the Raspberry Pi shutdown when the battery is exhausted.
Normally: This programs powers off the Raspberry Pi when UPS indicates that
PWRGOOD = 0 (power has failed) and the battery voltage is less than 3.0V (for LiIon) or
2.75V (for LiFePO4).
Otherwise: The UPS will assert BATLOW = 1, wait 20 seconds and then disconnect power when
it determines that the battery has dropped below 2.75V (for LiIon) or 2.5V (for LiFePO4).
If the UPS battery voltage (VBAT) is greater than 3.3V, the status is checked
every minute. If VBAT < 3.3V then the checking interval is shortened to 1 second.
Normally: If the Pi sets SHUTDN = 1 then the UPS asserts BATLOW = 1 as confirmation and
immediately begins a 20 second timer to terminate the power.
Otherwise: When the UPS asserts BATLOW = 1, the UPS will wait 5 seconds for the
Raspberry Pi to acknowledge by setting the SHUTDN bit. If the Pi has
not set the SHUTDN bit within the 5 second period then the UPS begins the 20 second timer irregardless.
So the Pi must initiate it's shutdown sequence immediately after receiving the BATLOW
signal from the UPS and sending/confirming the SHUTDN acknowledgement.
'''
 
try:
    from smbus import SMBus
except ImportError as e:
    print("Python module smbus not found, install it first")
    # Install the i2c interface using the following instructions:
    # sudo apt-get install i2c-tools
    # sudo apt-get install python-smbus
    # sudo raspi-config , goto Interfacing Options, enable i2c
    # sudo reboot # to make the i2c interface active
    # now check to see if the USB i2c device is present at address 36
    # sudo i2cdetect -y 1
    sys.exit(0)
 
import time, os
from datetime import datetime
import logging
import logging.handlers
import traceback
import sys
 

# instantiate smbus interface
i2c = SMBus(1)  # set interface to i2c-1 (newer RPi's)


#--logger definitions
# save daily logs for 7 days
LOG_FILENAME = "/var/log/bud_logs/ups_mon.log"
LOG_LEVEL = logging.INFO  # Could be e.g. "TRACE", "ERROR", "" or "WARNING"
logger = logging.getLogger(__name__)
logger.setLevel(LOG_LEVEL)
handler = logging.handlers.RotatingFileHandler(LOG_FILENAME, maxBytes=10000, backupCount=4) # save 4 logs
formatter = logging.Formatter('%(asctime)s %(levelname)-8s %(message)s')
handler.setFormatter(formatter)
logger.addHandler(handler)
 
class MyLogger():
    '''
    A class that can be used to capture stdout and sterr to put it in the log

    '''
    def __init__(self, level, logger):
            '''Needs a logger and a logger level.'''
            self.logger = logger
            self.level = level

    def write(self, message):
        # Only log if there is a message (not just a new line)
        if message.rstrip() != "":
                self.logger.log(self.level, message.rstrip())

    def flush(self):
        pass  # do nothing -- just to handle the attribute for now

# Global flag to print more information
DEBUG = False
# If this script is installed as a Daemon by systemd, set this flag to True:
DAEMON = True # when run as daemon, pipe all console information to the log file
# --Replace stdout and stderr with logging to file so we can run it as a daemon
# and still see what is going on
if DAEMON :
    sys.stdout = MyLogger(logging.INFO, logger)
    sys.stderr = MyLogger(logging.ERROR, logger)
 
def read_ups_data(address=0x36, cmd=0x00):
    global DAEMON, DEBUG
 
    if (DEBUG) and (not DAEMON) : print('Sending command {0:02x}'.format(cmd))
    # logger.trace("Sending command {0:02x}".format(command))
 
    try:
        word = i2c.read_word_data(address, cmd)
        bus_fail = 0
    except Exception as e: # except all exceptions
        write_log("ERROR", "read_ups: I2C exception")
        bus_fail = 1
 
    if bus_fail == 1:
        VBAT = 5.0
        BATLOW = 0
        SHUTDN = 0
        F2 = 1
        F1 = 0
        F0 = 0
        CHRG = 0
        FAULT = 0
        PWRGOOD = 1
    else:
        byte1 = 255 & word
        byte2 = word >> 8
        PWRGOOD = byte1 & 0x01
        BATLOW = (byte1 & 0x02) >> 1
        SHUTDN = (byte1 & 0x04) >> 2
        F0 = (byte1 & 0x08) >> 3
        F1 = (byte1 & 0x10) >> 4
        F2 = (byte1 & 0x20) >> 5
        CHRG = (byte1 & 0x40) >> 6
        FAULT = (byte1 & 0x80) >> 7
        if byte2 != 0:
            VBAT = 4.08 * byte2/255
        else:
            VBAT = 5.0
            write_log("ERROR", "ADC value error")
 
    if (F2):
        bat_type = "LiIon"
    else:
        bat_type = "LiFePO4"
        
    if (F2):
        # charger voltages for LiIon:
        if (not F1 and not F0):
            chrg_v = 3.95
        elif(not F1 and F0):
            chrg_v = 4.00
        elif(F1 and not F0):
            chrg_v = 4.05
        else:
            chrg_v = 4.10
    else:
        # charger voltages for LiFePO4:
        if (not F1 and not F0):
            chrg_v = 3.45
        elif(not F1 and F0):
            chrg_v = 3.50
        elif(F1 and not F0):
            chrg_v = 3.55
        else:
            chrg_v = 3.60
 
    if (DEBUG) and (not DAEMON):
        print('PWRGOOD = {0}'.format(int(PWRGOOD)))
        print('BATLOW = {0}'.format(int(BATLOW)))
        print('SHUTDN = {0}'.format(int(SHUTDN)))
        print('BAT Type = {0}'.format(bat_type))
        print('Charging Termination Voltage = {0}'.format(chrg_v))
        if (CHRG): print("Battery is charging.")
        if (FAULT): print("Charger is Faulted")
        print('VBAT = {0:.2f}\n'.format(VBAT))
 
    return PWRGOOD, BATLOW, SHUTDN, bat_type, chrg_v, CHRG, FAULT, VBAT, bus_fail
 
def write_log(level="INFO", msg=""):
    global DEBUG, DAEMON
    if (level is "ERROR"):
        if (DEBUG) and (not DAEMON) : print("ERROR: " + msg + "\n")
        logger.error(msg)
    else:
        if (DEBUG) and (not DAEMON) : print(msg + "\n")
        logger.info(msg)
 
def main():
    global DEBUG, DAEMON
    ups_address = 0x36
    command = 0x00 # initialize command code
    pgood_flag = 1
    CHRG_old = 0
    FAULT_old = 0
    time_now = datetime.now()
    PWRGOOD, BATLOW, SHUTDN, bat_type, chrg_v, CHRG, FAULT, VBAT, bus_fail = read_ups_data(ups_address, command)
    write_log("INFO", "*** UPS Monitor is active ***\n\tBattery Type is {0}\n\tCharge Termination Voltage = {1}\n".format(bat_type, chrg_v))
    if (CHRG):
        write_log("INFO","Battery is Charging. VBAT = {0:.2f}".format(VBAT))
    if (FAULT and CHRG):
        write_log("INFO","Charger is temporarily Faulted due to temperature outside limits.")
    elif(FAULT and not CHRG):
        write_log("ERROR","Charger is permanently faulted. Check Battery")

    CHRG_old = CHRG
    FAULT_old = FAULT
 
    try:
        while True:
            # read the UPS
            PWRGOOD, BATLOW, SHUTDN, bat_type, chrg_v, CHRG, FAULT, VBAT, bus_fail = read_ups_data(ups_address, command)
            
            # de-clutter log -- only send powerfail info once
            if (not PWRGOOD and pgood_flag):
                   time_now = datetime.now()
                   pgood_flag = 0
                   write_log("INFO", "Power failed: PWRGOOD = {0} VBAT = {1:.2f}".format(PWRGOOD, VBAT))
                   VBAT_old = VBAT
            elif(PWRGOOD and not pgood_flag):
                   time_now = datetime.now()
                   pgood_flag = 1
                   write_log("INFO", "Power restored: PWRGOOD = {0} VBAT = {1:.2f}".format(PWRGOOD, VBAT))
            if (not PWRGOOD and VBAT < VBAT_old - 0.05):
                logger.info("VBAT = {0:.2f}".format(VBAT))
                VBAT_old = VBAT
            # if the UPS has set BATLOW or VBAT < 3.0V, then send SHUTDN command to initiate UPS shutdown
            if ((BATLOW or ((bat_type is "LiIon" and VBAT < 3.0) or (bat_type is "LiFePO4" and VBAT < 2.8))) and not PWRGOOD):
                write_log("INFO", "Sending shutdown command to UPS: PWRGOOD = {0} VBAT = {1:.2f}".format(PWRGOOD, VBAT))
                command = 0x04
                PWRGOOD, BATLOW, SHUTDN, bat_type, chrg_v, CHRG, FAULT, VBAT, bus_fail = read_ups_data(ups_address, command)
                                                                
            # confirm that the UPS received the shutdown command and then shutdown the Pi
            if (SHUTDN and command == 0x04):
                write_log("INFO", "UPS confirmed, shutting down now!")
                os.system("sudo shutdown -h now")  # temporarily disable while debugging
                while True:
                    time.sleep(10)

            if (CHRG != CHRG_old):
                if (CHRG):
                    write_log("INFO", "Battery is charging. VBAT = {0:.2f}".format(VBAT))
                elif(PWRGOOD):
                    write_log("INFO", "Battery charging terminated. VBAT = {0:.2f}".format(VBAT))
                CHRG_old = CHRG

            if (FAULT != FAULT_old):
                if (FAULT and CHRG):
                    write_log("INFO", "Charger is temporarily faulted due to temperature outside limits.")
                elif(FAULT and not CHRG):
                    write_log("INFO", "Charger is permanently faulted. Check Battery")
                else:
                    write_log("INFO", "Fault has been reset.")
                FAULT_old = FAULT
 
            # check UPS status at 1 minute intervals until battery voltage drops below 3.2V, then
            # decrease interval to 1 second.
            if ((bat_type is "LiIon" and (VBAT < 3.3)) or (bat_type is "LiFePO4" and VBAT < 3.0)):
                time.sleep(1)
            else:
                time.sleep(60) # use 10 seconds during testing, 60 when running normally
 
    except KeyboardInterrupt:
        if (DEBUG) and (not DAEMON): print ("\nCtrl-C Terminating")
 
    except Exception as e:
        sys.stderr.write("Got exception: %s" % e)
        if (DEBUG) and (not DAEMON): print(traceback.format_exc())
        logger.error(str(traceback.format_exc()))
        os._exit(1)
 
if __name__ == '__main__':
    main()

systemd is used to manage the program as a daemon. It requires the ups.service file to be located in /lib/systemd/system. The service is started after the network is available to get the system time and the system is idle -- very late in the boot process. Otherwise the monitor program will report the incorrect time in the logs (as shown later).

# This service installs a python script that communicates with the UPS hardware.
# It also provides logging to a file
# the ups.service file is located in /lib/systemd/system/
# To test, use sudo systemctl start|stop|status ups
# To install during the boot process, use: sudo systemctl enable ups
# If this file gets changed, use: sudo systemctl daemon-reload
# If the Python script is changed, use : sudo systemctl restart ups
 
[Unit]
Description=UPS Service
Requires=basic.target
After=network.target
 
[Service]
Type=idle
User=pi
ExecStart=/usr/bin/python3 /home/pi/programs/ups/ups_u1liupsrpi.py
Restart=on-failure
RestartSec=10
TimeoutSec=10
 
# The number of times the service is restarted within a time period can be set
# If that condition is met, the RPi can be rebooted
# WARNING:
# Only use these options with a working system!
#StartLimitBurst=4
#StartLimitInterval=180s
# actions can be none|reboot|reboot-force|reboot-immidiate
#StartLimitAction=reboot
 
# The following are defined the /etc/systemd/system.conf file and are
# global for all services
#
#DefaultTimeoutStartSec=90s
#DefaultTimeoutStopSec=90s
#
# They can also be set on a per process here:
# if they are not defined here, they fall back to the system.conf values
#TimeoutStartSec=2s
#TimeoutStopSec=2s
 
[Install]
WantedBy=multi-user.target

 This is what the ups monitor reported for the 18650 version supplying power to a raspberry pi zero W:

2019-03-03 18:20:48,568 INFO     *** UPS Monitor is active ***
Battery Type is LiIon
Charge Termination Voltage = 4.1
2019-03-03 18:20:48,571 INFO     Battery is Charging. VBAT = 4.08
2019-03-03 21:45:32,586 INFO     Power failed: PWRGOOD = 0 VBAT = 4.08
2019-03-03 21:46:32,639 INFO     Power restored: PWRGOOD = 1 VBAT = 4.08
2019-03-03 21:46:32,642 INFO     Battery is charging. VBAT = 4.08
2019-03-03 21:54:33,048 INFO     Battery charging terminated. VBAT = 4.08
2019-03-03 22:28:34,618 INFO     Power failed: PWRGOOD = 0 VBAT = 4.08
2019-03-03 23:44:37,630 INFO     VBAT = 4.02
2019-03-04 00:27:39,611 INFO     VBAT = 3.95
2019-03-04 01:20:42,628 INFO     VBAT = 3.89
2019-03-04 02:18:45,992 INFO     VBAT = 3.82
2019-03-04 03:11:49,067 INFO     VBAT = 3.76
2019-03-04 04:06:52,238 INFO     VBAT = 3.70
2019-03-04 05:11:55,970 INFO     VBAT = 3.63
2019-03-04 06:42:01,158 INFO     VBAT = 3.57
2019-03-04 07:39:03,514 INFO     VBAT = 3.50
2019-03-04 08:22:05,248 INFO     VBAT = 3.44
2019-03-04 09:07:07,203 INFO     VBAT = 3.38
2019-03-04 09:22:07,787 INFO     VBAT = 3.31
2019-03-04 09:33:33,994 INFO     VBAT = 3.25
2019-03-04 09:43:41,436 INFO     VBAT = 3.18
2019-03-04 09:53:57,893 INFO     VBAT = 3.12
2019-03-04 10:03:05,188 INFO     VBAT = 3.06
2019-03-04 10:09:46,139 INFO     VBAT = 2.99
2019-03-04 10:09:46,142 INFO     Sending shutdown command to UPS: PWRGOOD = 0 VBAT = 2.99
2019-03-04 10:09:46,147 INFO     UPS confirmed, shutting down now!
2019-03-04 10:10:18,085 INFO     *** UPS Monitor is active ***
Battery Type is LiIon
Charge Termination Voltage = 4.1
2019-03-04 10:10:18,092 INFO     Battery is Charging. VBAT = 3.44

Note that the 18650 ups supplied power to the idling Zero W for almost 12 hours!! Note that the first couple of log entries after boot up have an incorrect time stamp. That is because the service started before the clock was set to the correct time. Setting the service unit to start after the network has started and the system is idle should prevent incorrect times in the log. (Does anybody have a better idea?)

Discussions

Paul Versteeg wrote 04/21/2019 at 06:40 point

While the unit is great as an UPS, I needed a version that would let you pull the power plug and do a graceful shutdown after a small period (e.g. 60 seconds to ride out power glitches) and perform a task like sending an SMS or email before shutting down.

Here is the relevant changed portion of the code in main() that will allow you to do just that:

...

            # de-clutter log -- only send powerfail info once
            if (not PWRGOOD and pgood_flag):
                   time_now = datetime.now()
                   pgood_flag = 0
                   write_log("INFO", "Power failed: PWRGOOD = {0} VBAT = {1:.2f}".format(PWRGOOD, VBAT))
                   VBAT_old = VBAT
                   # start the shutdown timer
                   tstamp_s = datetime.now()

            elif(PWRGOOD and not pgood_flag):
                   time_now = datetime.now()
                   pgood_flag = 1
                   tdelta = 0 # reset the shutdown counter
                   write_log("INFO", "Power restored: PWRGOOD = {0} VBAT = {1:.2f}".format(PWRGOOD, VBAT))
            if (not PWRGOOD and VBAT < VBAT_old - 0.05):
                logger.info("VBAT = {0:.2f}".format(VBAT))
                VBAT_old = VBAT

            if (not PWRGOOD):
                # check the shutdown timer
                tstamp_f = datetime.now() # current time
                tdelta = (tstamp_f - tstamp_s).seconds # duration time
                write_log("INFO", "Shutdown timer : {}".format(tdelta))
                if ( tdelta > 60 ) :

                    # send an SMS, or email then shutdown

                    write_log("INFO", "Shutdown timer forces shutdown")
                    command = 0x04  # send SHUTDN command to initiate UPS shutdown
                    PWRGOOD, BATLOW, SHUTDN, bat_type, chrg_v, CHRG, FAULT, VBAT, bus_fail = \
                        read_ups_data(ups_address, command)

            # if the UPS has set BATLOW or VBAT < 3.0V, then send SHUTDN command to initiate UPS shutdown

...

  Are you sure? yes | no

Bud Bennett wrote 04/22/2019 at 22:02 point

I was thinking about implementing this on the UPS that is used with my development system. I could then just unplug the AC adapter and walk away. Thanks for the code update...I'l take a look at it.

  Are you sure? yes | no

Paul Versteeg wrote 04/21/2019 at 06:30 point

Hi Bud, I finally got around testing the version you gracefully sent me. While comparing with the previous UPS 14500 version, it worked much better. I'm using it with Efest IMR Li-Ion cells and I am impressed with the capabilities.

Well done, again...

Paulv

  Are you sure? yes | no