[RELEASE] Network UPS Tools (NUT) monitor and shutdown controller (upsmon)

This worked fine with my Makelsan UPS and NUT installed on raspberry PI (raspbian)

I made some additions to the driver.
Added button for beeper toggle.
And some attributes for power meter, temperature measurement, battery features.

here's my copy:


metadata {
    definition(
        name: "NUT upsmon driver", namespace: "cococafe", author: "Denny Page",
        importUrl: "https://raw.githubusercontent.com/dennypage/hubitat/master/drivers/nut-upsmon/nut-upsmon.groovy",
        singleThreaded: true
    )
    {
        capability "Initialize"
        capability "Refresh"
        capability "Battery"
        capability "VoltageMeasurement"
        capability "Temperature Measurement"
        capability "PowerMeter"

        attribute "status",   "String"
        attribute "load",     "number"
        attribute "battery'", "number"
        attribute "runtime",  "number"
        
        command "stop_beep"
    }
}

import groovy.transform.Field

// Variable name -> attribute map
@Field static final Map<String,Map> variableMap = [
    'input.voltage':  [name: 'voltage', unit: 'V', unknownValue: '0'],
    'output.voltage':  [name: 'voltageout', unit: 'V', unknownValue: '0'],
    'battery.voltage':  [name: 'batteryv', unit: 'V', unknownValue: '0'],
    'battery.runtime': [name: 'runtime', unit: 's', unknownValue: '0'],
    'ups.temperature':  [name: 'temperature', unit: 'Β°C', unknownValue: '0'],
    'ups.load':        [name: 'load',    unit: '%', unknownValue: '0'],
    'ups.status':      [name: 'status',  unit: '',  unknownValue: 'unknown']
]
@Field static final String statusName = 'status'
@Field static final String statusShutdownRequested = 'Shutdown Requested'

// UPSD statword -> status map
//   There are additional statwords that might be seen,
//   but this is the list that upsmon cares about.
@Field static final Map<String,String> statwordMap = [
    'OL':      'Online',
    'OB':      'On Battery',
    'LB':      'Low Battery',
    'RB':      'Battery Needs Replaced',
    'CAL':     'Runtime Calibration',
    'FSD':     'Forced Shutdown'
]
@Field static final String statwordOL = 'OL'
@Field static final String statwordOB = 'OB'
@Field static final String statwordLB = 'LB'
@Field static final String statwordFSD = 'FSD'

// UPSD error -> status map
@Field static final Map<String,String> errorMap = [
    'ACCESS-DENIED': 'Access denied',
    'UNKNOWN-UPS': 'Unknown ups',
    'DATA-STALE': 'Stale data',
    'DRIVER-NOT-CONNECTED': 'Driver not connected'
]
@Field static final String errAccessDenied = 'ACCESS-DENIED'
@Field static final String errUnknownUps = 'UNKNOWN-UPS'
@Field static final String errDataStale = 'DATA-STALE'
@Field static final String errDriverNotConnected = 'DRIVER-NOT-CONNECTED'
@Field static final String errVarNotSupported = 'VAR-NOT-SUPPORTED'

preferences {
    input name: "serverHost", type: "text", title: "NUT server host", required: true
    input name: "serverPort", type: "number", title: "NUT server port", defaultValue: 3493, range: "1..65535", required: true
    input name: "upsName", type: "text", title: "UPS name", required: true
    input name: "username", type: "text", title: "NUT username", required: true
    input name: "password", type: "password", title: "NUT password", required: true
    input name: "pollFreq", type: "number", title: "Polling frequency", defaultValue: 5, range: "1..30", required: true

    input name: "shutdownEnable", title: "<b>Enable Hub shutdown</b>", description: "Note that if you do not enable this, the hub will not actually shut down when the ups battery runs out", type: "bool", defaultValue: false
    input name: "logEnable", title: "Enable debug logging", type: "bool", defaultValue: false
}

void installed() {
    variableMap.each { variable, attribute ->
        sendEvent(name: attribute.name, unit: attribute.unit, value: attribute.unknownValue)
    }
}

void stop_beep() {
    if (state.upsdConnected) {
        telnetSend("INSTCMD ${upsName} beeper.toggle")
        variableMap.each { variable, attribute ->
            telnetSend("GET VAR ${upsName} ${variable}")
        }
    } else {
        stopbeep()
    }
}

void stopbeep() {
    if (logEnable) {
        log.debug("attempting to connect to upsd on ${serverHost}:${serverPort}...")
    }

    try {
        telnetConnect([termChars:[10]], serverHost, serverPort.toInteger(), null, null)
        state.upsdConnected = true
        log.info("connected to upsd on ${serverHost}:${serverPort} - monitoring ${upsName} every ${pollFreq} seconds")

        telnetSend("USERNAME ${username}")
        telnetSend("PASSWORD ${password}")
        telnetSend("LOGIN ${upsName}")
        telnetSend("INSTCMD ${upsName} beeper.toggle")
        
        upsdPoll()
        schedule("0/${pollFreq} * * * * ? *", upsdPoll)
    }
    catch (e) {
        log.error("telnet connect error: ${e}")
        runIn(pollFreq, upsdConnect)
    }
}

void uninstalled() {
    upsdDisconnect()
}

void updated() {
    log.info("updated: ups ${upsName} on host ${serverHost}:${serverPort}")
    upsdDisconnect()
    runIn(1, upsdConnect)
}

void initialize() {
    upsdDisconnect()
    runIn(1, upsdConnect)
}

void refresh() {
    upsdPoll()
}

void upsdConnect() {
    if (logEnable) {
        log.debug("attempting to connect to upsd on ${serverHost}:${serverPort}...")
    }

    try {
        telnetConnect([termChars:[10]], serverHost, serverPort.toInteger(), null, null)
        state.upsdConnected = true
        log.info("connected to upsd on ${serverHost}:${serverPort} - monitoring ${upsName} every ${pollFreq} seconds")

        telnetSend("USERNAME ${username}")
        telnetSend("PASSWORD ${password}")
        telnetSend("LOGIN ${upsName}")

        upsdPoll()
        schedule("0/${pollFreq} * * * * ? *", upsdPoll)
    }
    catch (e) {
        log.error("telnet connect error: ${e}")
        runIn(pollFreq, upsdConnect)
    }
}

void upsdDisconnect() {
    unschedule()

    if (state.upsdConnected) {
        if (logEnable) {
            log.debug("disconnecting from upsd...")
        }

        state.upsdConnected = false
        telnetSend("LOGOUT")
        telnetClose()
        log.info("disconnected from upsd")
    }

    variableMap.each { variable, attribute ->
        sendEvent(name: attribute.name, unit: attribute.unit, value: attribute.unknownValue)
    }
}

void upsdPoll() {
    variableMap.each { variable, attribute ->
        telnetSend("GET VAR ${upsName} ${variable}")
    }
}

void telnetSend(String msg) {
    sendHubCommand(new hubitat.device.HubAction("${msg}", hubitat.device.Protocol.TELNET))
}

void telnetStatus(String message) {
    if (state.upsdConnected) {
        log.error("telnet status: ${message}")
        upsdDisconnect()
        runIn(pollFreq, upsdConnect)
    }
}

void parse(String message) {
    if (logEnable) {
        log.debug("parse: ${message}")
    }

    // If we are disconnecting, ignore the message
    if (!state.upsdConnected) {
        return
    }

    // Incoming message: VAR myups battery.runtime "1689.00"
    //   Split results:
    //     response[0]: VAR
    //     response[1]: myups
    //     response[2]: battery.runtime
    //     response[3]: 1689.00
    String[] response = message.split('"?( |\$)(?=(([^"]*"){2})*[^"]*\$)"?')
    if (response[0] == 'OK') {
        return
    }

    if (response[0] == 'ERR') {
        switch (response[1]) {
            case errVarNotSupported:
                // A variable we asked for, such as load, is not available
                break

            case errAccessDenied:
            case errUnknownUps:
                // Configuration error
                log.error("upsd: ${errorMap[response[1]]}")
                upsdDisconnect()
                runIn(pollFreq, upsdConnect)
                sendEvent(name: statusName, value: errorMap[response[1]])
                break

            case errDataStale:
            case errDriverNotConnected:
                // Connected, but data cannot be trusted
                log.warn("upsd: ${errorMap[response[1]]}")
                variableMap.each { variable, attribute ->
                    if (attribute.name != statusName) {
                        sendEvent(name: attribute.name, unit: attribute.unit, value: attribute.unknownValue)
                    }
                }
                sendEvent(name: statusName, value: errorMap[response[1]])
                break

            default:
                log.error("upsd: unexpected error message: ${response[1]}")
        }
        return
    }

    // Anything else should be a variable response
    if (response[0] != 'VAR') {
        log.error("upsd: unexpected message: ${message}")
        return
    }

    // Get the variable attribute
    Map attribute = variableMap[response[2]]

    if (attribute == null) {
        log.error("upsd: unexpected variable: ${response[2]} = ${response[3]}")
        return
    }

    // If the attribute isn't the status, process it as a simple numeric entry and return
    if (attribute.name != statusName) {
        Number n = response[3].toFloat()
        sendEvent(name: attribute.name, unit: attribute.unit, value: n)        
        if (attribute.name == 'batteryv') {
                BigDecimal bat = 0
                BigDecimal vMin = 18
                BigDecimal vMax = 28
                if(vMax - vMin > 0) {
                    bat = ((n - vMin) / (vMax - vMin)) * 100.0
                } else {
                    bat = 100
                }
                bat = bat.setScale(0, BigDecimal.ROUND_HALF_UP)
                bat = bat > 100 ? 100 : bat
                sendEvent(name:"battery", value: bat, unit: "%", isStateChange: false)
                state.battery = bat
        }
        if (attribute.name == 'load') {
                power = (n / 1200) * 100.0
                sendEvent(name:"power", value: power, unit: "W", isStateChange: false)
                state.power = power
        }

        return
    }

    // Process status
    List statwords = response[3].tokenize(' ')

    // Should we shutdown?
    Boolean shutdown = statwords.contains(statwordFSD) || (statwords.contains(statwordOB) && statwords.contains(statwordLB))

    // Build the status string, ensuring we have a primary status up front
    String status
    if (statwords.contains(statwordOL)) {
        status = statwordMap[statwordOL]
        statwords -= statwordOL
    }
    else if (statwords.contains(statwordOB)) {
        status = statwordMap[statwordOB]
        statwords -= statwordOB
    }
    else if (statwords.contains(statwordFSD)) {
        status = statwordMap[statwordFSD]
        statwords -= statwordFSD
    }

    // If any statwords are left, append them
    statwords.each { statword ->
        status += ',' + (statwordMap[statword] ?: statword)
    }

    // Send the status event
    sendEvent(name: statusName, value: status)

    // Handle a reqest to shut down
    if (shutdown) {
        log.warn("upsd requesting client shutdown")

        // Let the server know we're leaving
        upsdDisconnect()

        // Shut down the hub if enabled
        sendEvent(name: statusName, value: statusShutdownRequested)
        if (shutdownEnable) {
            sendHubShutdownCommand()
        }
        else {
            log.error("hub shutdown is not enabled")
        }

        // If were are still here in 60 seconds, try reconnecting
        runIn(60, upsdConnect)
    }
}

private void sendHubShutdownCommand() {
    String cookie = null
    if (security) {
        cookie = getCookie()
    }

    def postParams = [
        uri: "http://127.0.0.1:8080",
        path: "/hub/shutdown",
        headers:[
            "Cookie": cookie
        ]
    ]

    log.warn("sending hub shutdown command...")
    httpPost(postParams) { response ->
        log.warn("hub shutdown command sent")
    }
}

FWIW, I chose not to add variables beyond the big three...

YMMV

ok. thanks for your reply. I just added whatever I need for my own use.
I also have Grafana integration and sending voltage, battery & load values there is a very good addition for me.
Also the beeper silencing button is very useful :slight_smile:

Why would you not do this directly from the Pi?

well I already have the integration (thanks to your code) on Hubitat.
Why bother with another integration from PI to the Grafana server ?

The Pi is where the NUT server is, correct? Doing the data logging from the host where the NUT server is running is a lot more efficient than routing data via the Hubitat. It also avoids unnecessary load on the Hubitat. It's a trivial script.

yes. but I don'T like having several integrations. The nut driver (your code) already works on Hubitat.
And I already have Influx DB logger which already sends a hundred state changes to my Grafana Influx DB.
Adding 3 variables from NUT driver to that is more simple than writing a script on PI server and adding another integration.
well, that might seem inefficient to you but that's the way I like doing things. "Where possible, one less script is prefferred"

Yea, we are from different worlds. My focus is on efficiency and minimizing failure scenarios. Anyway, to each their own.

Any chance of adding the powerSource capability? This would just indicate if it is on mains or battery power. The other driver used this and I built a rule around it that pings me updates as the battery depletes. I could add it myself but then I have to figure out the existing code and keep merging my changes if you ever have updates.

Otherwise, I have been testing this out and it works great. Gets just the minimal info you need for the hub and nothing more.

I can look at this, but you know that this is what status is, yes? If status begins with "Online" it means on mains. It will always be the first item in the status, even if there are other items following it. Similarly, "On Battery" will always be the first item even if there are additional items in the status. This is guaranteed by NUT (and by the driver).

I have a version with powerSource coded if you want to tell me that using status is too much of a pain. :slight_smile:

I know I could derive it from the status which I may convert my rule to do, just thought it would be good (for me and others possibly) to translate that to the system supported powerSource attribute.

Pushed.

1 Like

That works thanks. Just also noticed that when doing a rule there is a battery attribute with a ' at the end. Checked the code and you are defining it that way (on accident I assume). You do not need to define it at all since the Battery capability will add that attribute automatically.

image

2 Likes

Well, that's incredibly embarrassing... but thank you for letting me know. Fix pushed.

2 Likes

Can this be configured to use ssh versus telnet. I can’t get my pi to open the telnet port (yet)

It connects directly to the NUT server, which uses a telnet connection but typically on port 3493 unless you change it in the settings. It is not connecting directly to the system shell.

2 Likes

As @jtp10181 notes, the driver The driver is not trying to open a connection to telnetd (port 23). The driver connects directly to the nut server (upsd), which is usually on port 3493. Hubitat's telnet method is just Hubitat's way to open a generic TCP connection to any port.

Is anybody successfully using this driver with the Enable Hub Shutdown switch turned on?

I had a very brief power failure here this afternoon at 2:54pm and sure enough since that switch was enabled the hub shut down - IMMEDIATELY. But when I brought it back up an hour later at 4:03pm by power cycling it shut down again right away. The log shows this sequence of status and error messages:

It kind of looks like there was still some pending shutdown status from the UPS that was retrieved each time my HE restarted again, which caused it to once again shut down immediately.

The only way I got my HE to reboot and stay up was to power-cycle the NUT server (my Synology NAS) which flushed or reset the UPS status being read by the HE.

Anybody seen this one before?

I'm inclined to turn off the Enable Hub Shutdown switch and just write a rule that will trigger on the UPS switching over to battery power, then monitor the UPS's power source and available runtime before sending a shutdown to the Hub. Is that how most people are employing this driver?

Yes, certainly I am. :slight_smile:

The immediate shutdown used to be indicative of the UPS declaring a low battery situation immediately upon power failure. Some UPSs were known to do this, but it's pretty rare these days. Questions: What model of UPS are you using? What did the Synology report about the event? What version of Synology are you running? Were there any other upsmon instances attached to upsd at the time of the event? If so, what did they report?

If it's actually a ups that reports an immediate LB, the traditional way of dealing with it is to use the ignorelb directive, but I do not currently have support for the ignorelb concept in the driver. Further, I don't believe that Synology supports configuring the ignorelb directive, so this would seem unlikely. I can add support for ignorelb, but I would like to understand more first.

Of course, it's also possible that the batteries in your UPS are worn out, and there actually is a LB situation as soon as power fails. :slight_smile: How old are the batteries in the UPS? Does the UPS pass its self test? A run-time calibration?

The shutdown following the cycle is indicative of something being seriously wrong. The USERNAME-REQUIRED error message is obvious, and indicates that there is a user/password mismatch, or (more likely) that the Synology is in shutdown mode and waiting for the UPS to cut the power (which it has failed to do). [Edit: the more I think about it, the more this seems to be a good possibility.]

If you can recreate, can you please disable the hub shutdown, enable debug logging, and post the log please? Thanks.