Back to index | Discuss

Connecting multiple devices to a single UPS over USB

2023 OCT 10

Consumer UPS units, or battery powered backup systems, tend to come with a single USB port for status monitoring. The idea being to turn off your computer system before the UPS runs out of battery in case of a grid failure.

Unfortunately, users tend to have more than one computer connected to the same UPS. I have at-least two. The most common solution in this case is to use NUT - Network UPS Tools. This comes with the obvious downside of requiring a stable network connection in adverse power situations. Perhaps connecting a switch to the same UPS will solve the network issue. But I’m more interested in a network-less solution.

Ideally all computers connected to the UPS could have access to its USB status report. The USB protocol is simple enough. The USB talks HID with simple and well documented packets. But duplicating the HID packets and rebroadcasting them over multiple USB links is something else. Making a stable and reliable USB host isn’t easy either. This approach seems unfeasible without serious engineering. Perhaps there is a shortcut. What if we use the USB HID protocol to emulate a USP, but instead of monitoring the status of the real UPS, we simply monitor the grid? By acting as a HID-compliant UPS device we automatically gain support for apcupsd and similar programs.

HID Power Device

Turns out I’m not the first person with this idea. Here is all the code we need already written and tested:

https://github.com/abratchik/HIDPowerDevice

This project allows an Arduino Micro to act as an HID-compliant UPS. Perfect. We only need to adjust the pins slightly in this example code.

It does not seem to work with a Teensy 2.0 which uses the same ATMEGA32U4 MCU. It works great with an Arduino Micro though.

The example code sets the PRESENTSTATUS_DISCHARGING flag when digital input 4 goes low. This in turn tells apcupsd that our system runs on battery. From here we can shutdown the computer directly or after a short delay.

Hardware

The plan is as follows. Have an Arduino Micro act as a HID-compliant UPS device. Plug a 5V phone charger into a mains socket not on backup power. Monitor that 5V line using the Arduino and an opto-coupler. Have HIDPowerDevice set the PRESENTSTATUS_DISCHARGING flag when the opto-coupler loses power. Shutdown our computer when apcupsd detects that we run on battery power.

The required hardware:

Required hardware

Try not to use an SMD opto-coupler. And perhaps not an Arduino with headers installed.

Here is the schematic rendered using DaveCAD (the astute observer will notice the that I’ve accidentally swapped the pin numbers for pin three and four):

Schematic rendered using DaveCAD

The external 5V power (not connected to the UPS) goes through the 1k Ohm resistor. Then into the first pin on the opto-coupler. Then back out through pin two. Pin three connect to the Arduino’s ground pin. While pin four connects directly to digital pin ten.

Opto-copler and resistor spliced onto a USB-cable

The end of a USB-cable was cut off to expose the internals. The red and black cables are connected to 5V and ground respectively. The green and white are superfluous for our detecting if voltage is present.

I botched everything together directly onto the cable. It would be easier to use a DIP opto-couple and perhaps a perf board. It’s up to you. Remember to use lots of heat shrink and it will be all right.

The final assembly, make sure to also add a large heat shrink tube around everything

Firmware

We’ll configure pin ten as a digital input with pull-up:

pinMode(10, INPUT_PULLUP);

Other pins are left unused. Except the LED_BUILTIN pin which is used to indicate USB activity.

Since we don’t sample the battery voltage a counter was added instead. It start counting down when the grid goes down – simulating a draining battery. This is rather overkill and something that will be removed in a future revision. It is better to configure apcupsd to shutdown after a timeout instead of relying on the Arduino to provide that timeout.

// Arduino Micro (real)
// Based on https://github.com/abratchik/HIDPowerDevice/blob/master/examples/UPS/UPS.ino

#include "HIDPowerDevice.h"

#define PIN_LED LED_BUILTIN // Blinks when connected to host
#define PIN_IN  10 // Driven low when external power is available

#define MAX_UPDATE_INTERVAL 26 // Seconds

int iUpdateTimer = 0;

// String constants
const char STRING_DEVICECHEMISTRY[] PROGMEM = "PbAc";
const char STRING_OEMVENDOR[] PROGMEM = "MyCoolUPS";
const char STRING_SERIAL[] PROGMEM = "UPS10";

const byte bDeviceChemistry = IDEVICECHEMISTRY;
const byte bOEMVendor = IOEMVENDOR;

uint16_t iPresentStatus = 0, iPreviousStatus = 0;

byte bRechargable = 1;
byte bCapacityMode = 2;  // units are in %%

// Physical parameters
const uint16_t iConfigVoltage = 1380;
uint16_t iVoltage = 1300, iPrevVoltage = 0;
uint16_t iRunTimeToEmpty = 0, iPrevRunTimeToEmpty = 0;
uint16_t iAvgTimeToFull = 7200; // Seconds
uint16_t iAvgTimeToEmpty = 300; // Seconds
uint16_t iRemainTimeLimit = iAvgTimeToEmpty / 2; // Seconds
int16_t  iDelayBeforeReboot = -1;
int16_t  iDelayBeforeShutDown = -1;

byte iAudibleAlarmCtrl = 2; // 1 - Disabled, 2 - Enabled, 3 - Muted

// Parameters for ACPI compliancy
const byte iDesignCapacity = 100;
byte iWarnCapacityLimit = 10; // warning at 10%
byte iRemnCapacityLimit = 5; // low at 5%
const byte bCapacityGranularity1 = 1;
const byte bCapacityGranularity2 = 1;
byte iFullChargeCapacity = 100;

byte iRemaining = 0, iPrevRemaining = 0;

int iDischargeCounter = 0;
int iRes = 0;

void setup()
{
    Serial.begin(9600);

    PowerDevice.begin();

    // Serial No is set in a special way as it forms Arduino port name
    PowerDevice.setSerial(STRING_SERIAL);

    // Used for debugging purposes.
    PowerDevice.setOutput(Serial);

    pinMode(PIN_IN, INPUT_PULLUP);
    pinMode(PIN_LED, OUTPUT);

    digitalWrite(PIN_LED, HIGH);

    PowerDevice.setFeature(HID_PD_PRESENTSTATUS, &iPresentStatus, sizeof(iPresentStatus));

    PowerDevice.setFeature(HID_PD_RUNTIMETOEMPTY, &iRunTimeToEmpty, sizeof(iRunTimeToEmpty));
    PowerDevice.setFeature(HID_PD_AVERAGETIME2FULL, &iAvgTimeToFull, sizeof(iAvgTimeToFull));
    PowerDevice.setFeature(HID_PD_AVERAGETIME2EMPTY, &iAvgTimeToEmpty, sizeof(iAvgTimeToEmpty));
    PowerDevice.setFeature(HID_PD_REMAINTIMELIMIT, &iRemainTimeLimit, sizeof(iRemainTimeLimit));
    PowerDevice.setFeature(HID_PD_DELAYBE4REBOOT, &iDelayBeforeReboot, sizeof(iDelayBeforeReboot));
    PowerDevice.setFeature(HID_PD_DELAYBE4SHUTDOWN, &iDelayBeforeShutDown, sizeof(iDelayBeforeShutDown));

    PowerDevice.setFeature(HID_PD_RECHARGEABLE, &bRechargable, sizeof(bRechargable));
    PowerDevice.setFeature(HID_PD_CAPACITYMODE, &bCapacityMode, sizeof(bCapacityMode));
    PowerDevice.setFeature(HID_PD_CONFIGVOLTAGE, &iConfigVoltage, sizeof(iConfigVoltage));
    PowerDevice.setFeature(HID_PD_VOLTAGE, &iVoltage, sizeof(iVoltage));

    PowerDevice.setStringFeature(HID_PD_IDEVICECHEMISTRY, &bDeviceChemistry, STRING_DEVICECHEMISTRY);
    PowerDevice.setStringFeature(HID_PD_IOEMINFORMATION, &bOEMVendor, STRING_OEMVENDOR);

    PowerDevice.setFeature(HID_PD_AUDIBLEALARMCTRL, &iAudibleAlarmCtrl, sizeof(iAudibleAlarmCtrl));

    PowerDevice.setFeature(HID_PD_DESIGNCAPACITY, &iDesignCapacity, sizeof(iDesignCapacity));
    PowerDevice.setFeature(HID_PD_FULLCHRGECAPACITY, &iFullChargeCapacity, sizeof(iFullChargeCapacity));
    PowerDevice.setFeature(HID_PD_REMAININGCAPACITY, &iRemaining, sizeof(iRemaining));
    PowerDevice.setFeature(HID_PD_WARNCAPACITYLIMIT, &iWarnCapacityLimit, sizeof(iWarnCapacityLimit));
    PowerDevice.setFeature(HID_PD_REMNCAPACITYLIMIT, &iRemnCapacityLimit, sizeof(iRemnCapacityLimit));
    PowerDevice.setFeature(HID_PD_CPCTYGRANULARITY1, &bCapacityGranularity1, sizeof(bCapacityGranularity1));
    PowerDevice.setFeature(HID_PD_CPCTYGRANULARITY2, &bCapacityGranularity2, sizeof(bCapacityGranularity2));
}

void loop()
{
    const bool bCharging = !digitalRead(PIN_IN);
    const bool bAcPresent = bCharging;
    const bool bDischarging = !bCharging;

    if (bDischarging)
    {
        if (iDischargeCounter < iAvgTimeToEmpty)
        {
            iDischargeCounter++;
        }
    }
    else
    {
        iDischargeCounter = 0;
    }

    iRunTimeToEmpty = iAvgTimeToEmpty - iDischargeCounter;
    iRemaining = (uint16_t)round((float)100 * iRunTimeToEmpty / iAvgTimeToEmpty);

    const bool bFullCharge = iRemaining == iFullChargeCapacity;
    const bool bRtlExpired = bDischarging && iRunTimeToEmpty < iRemainTimeLimit;
    const bool bShutdownRequested = iDelayBeforeShutDown > 0;
    const bool bShutdownImminent = bShutdownRequested && bRtlExpired;

    bitWrite(iPresentStatus, PRESENTSTATUS_CHARGING, bCharging);
    bitWrite(iPresentStatus, PRESENTSTATUS_ACPRESENT, bAcPresent);
    bitWrite(iPresentStatus, PRESENTSTATUS_FULLCHARGE, bFullCharge);
    bitWrite(iPresentStatus, PRESENTSTATUS_DISCHARGING, bDischarging);
    bitWrite(iPresentStatus, PRESENTSTATUS_RTLEXPIRED, bRtlExpired);
    bitWrite(iPresentStatus, PRESENTSTATUS_SHUTDOWNREQ, bShutdownRequested);
    bitWrite(iPresentStatus, PRESENTSTATUS_SHUTDOWNIMNT, bShutdownImminent);
    bitSet(iPresentStatus, PRESENTSTATUS_BATTPRESENT);

    if (bShutdownRequested)
    {
        Serial.println("Shutdown requested");
    }

    if (bShutdownImminent)
    {
        Serial.println("Shutdown imminent");
    }

    if (iPresentStatus != iPreviousStatus ||
        iRemaining != iPrevRemaining ||
        iRunTimeToEmpty != iPrevRunTimeToEmpty ||
        iUpdateTimer > MAX_UPDATE_INTERVAL)
    {
        PowerDevice.sendReport(HID_PD_REMAININGCAPACITY, &iRemaining, sizeof(iRemaining));

        if (bDischarging)
        {
            PowerDevice.sendReport(HID_PD_RUNTIMETOEMPTY, &iRunTimeToEmpty, sizeof(iRunTimeToEmpty));
        }

        iRes = PowerDevice.sendReport(HID_PD_PRESENTSTATUS, &iPresentStatus, sizeof(iPresentStatus));

        iUpdateTimer = 0;
        iPreviousStatus = iPresentStatus;
        iPrevRemaining = iRemaining;
        iPrevRunTimeToEmpty = iRunTimeToEmpty;
    }

    iUpdateTimer++;
    digitalWrite(PIN_LED, iRes >= 0);
    delay(1000);

    Serial.print("bCharging = ");
    Serial.print(bCharging);
    Serial.print(", iRemaining = ");
    Serial.print(iRemaining);
    Serial.print(", iRunTimeToEmpty = ");
    Serial.print(iRunTimeToEmpty);
    Serial.print(", iRes = ");
    Serial.println(iRes);
}

Software

Sources: 1, 2

On the computer side we’ll rely on apcupsd which is a Linux daemon for monitoring UPS:es and responding to changes in their status. Start by installing the package:

pacman -Suy apcupsd

Then run apcaccess to verify that our USB HID device is found and recognized as a HID-compliant UPS device:

APC      : 001,026,0616
DATE     : 2023-10-10 21:20:02 +0000
HOSTNAME : computer
VERSION  : 3.14.14 (31 May 2016) unknown
UPSNAME  : computer
CABLE    : USB Cable
DRIVER   : USB UPS Driver
UPSMODE  : Stand Alone
STARTTIME: 2023-10-08 19:30:52 +0000
MODEL    : Arduino Micro
STATUS   : ONLINE
BCHARGE  : 100.0 Percent
TIMELEFT : 5.0 Minutes
MBATTCHG : 50 Percent
MINTIMEL : 1 Minutes
MAXTIME  : 10 Seconds
ALARMDEL : 30 Seconds
BATTV    : 13.0 Volts
NUMXFERS : 0
TONBATT  : 0 Seconds
CUMONBATT: 0 Seconds
XOFFBATT : N/A
STATFLAG : 0x05000008
MANDATE  : 1980-00-00
SERIALNO : UPS10
NOMBATTV : 13.8 Volts
END APC  : 2023-10-10 21:20:06 +0000

Now its time to modify how apcupsd reacts to power failure. I choose to shutdown after one minute on battery.

Modify /etc/apcupsd/apcupsd.conf and set TIMEOUT to 60. This causes apcupsd to run /etc/apcupsd/doshutdown after 60 seconds running on battery.

Create a new script at /etc/apcupsd/doshutdown to shutdown the system properly (the default behavior only halts the system):

$ cat /etc/apcupsd/doshutdown

#!/bin/bash

WALL=wall

echo "Shutdown initiated by apcupsd" | ${WALL}
sudo /usr/bin/shutdown now

Now unplug the USB from your 5V source and see if everything works as expected.

Copyright (c) wronex.com 2023