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

If memory serves, without authentication the attach fails. Without the attach, the sever will not view the hub as a secondary, and will not wait for the hub to detach before it begins shutdown. Yes, it should take a Synology much longer to shut down than the hub, but you can still have a situation with longer polling intervals wherein the Synology would get to the point of killing the NUT server before the hub polls again. The hub would simply view this as the connection to NUT being broken, and not know of the low battery situation and pending disconnect of mains power.

1 Like

Btw, it's occurred to me that in posts I am using verbs ATTACH and DETACH. These are the verbs used in the new NUT protocol specification, however if you are looking at the code, you will instead see LOGIN and LOGOUT, which are the verbs that the current version of NUT uses.

So, I'm trying to use my APC Smart-UPS 1000 connected to my Synology DS420+ (DSM 7.1.1) but not being able to connect. I configured the UPS network server on Synology and I can telnet to port 3493 on it... so it seems to be up/running. However, on device I keep getting "Unknown UPS".

Any ideas on what could be happening?

What did you use for the UPS name?

I use APC... should I have something else?

Synology hardcodes everything:

UPS name: ups
NUT username: monuser
NUT password: secret

1 Like

Worked! Thank you!

1 Like

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