check_temperature 11.4 KB
#! /usr/bin/env python
#
# check_temperature - Nagios temperature check for DS18B20 sensor on RaspberryPi
#
# Version 1.0, latest version, documentation and bugtracker available at:
#              https://gitlab.lindenaar.net/scripts/nagios-plugins
#
# Copyright (c) 2016 Frederik Lindenaar
#
# This script is free software: you can redistribute and/or modify it under the
# terms of version 3 of the GNU General Public License as published by the Free
# Software Foundation, or (at your option) any later version of the license.
#
# This script is distributed in the hope that it will be useful but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# this program.  If not, visit <http://www.gnu.org/licenses/> to download it.

from sys import exit
from os.path import basename, splitext
from glob import glob
from time import time, sleep
from argparse import ArgumentParser as StandardArgumentParser, FileType, \
              _StoreAction as StoreAction, _StoreConstAction as StoreConstAction
import logging

# Constants (no need to change but allows for easy customization)
VERSION="1.0"
PROG_NAME=splitext(basename(__file__))[0]
PROG_VERSION=PROG_NAME + ' ' + VERSION
SENSOR_SCALE=1000
SENSOR_DEV_DIR =  '/sys/bus/w1/devices/'
SENSOR_DEV_PREFIX = '28-'
SENSOR_DEV_SUFFIX = '/w1_slave'
SENSOR_READ_RETRIES=10

LOG_FORMAT='%(levelname)s - %(message)s'
LOG_FORMAT_FILE='%(asctime)s - ' + LOG_FORMAT
LOGGING_NONE=logging.CRITICAL + 10
NAGIOS_OK = ( 'OK', 0)
NAGIOS_WARN = ( 'WARNING', 1)
NAGIOS_CRITICAL = ( 'CRITICAL', 2 )
NAGIOS_UNKNOWN = ( 'UNKNOWN', 3 )

# Setup logging
logging.basicConfig(format=LOG_FORMAT)
logging.addLevelName(LOGGING_NONE, 'NONE')
logger = logging.getLogger(PROG_NAME)
logger.setLevel(logging.CRITICAL)

################[ wrapper to stop ArgumentParser from exiting ]################
# based on http://stackoverflow.com/questions/14728376/i-want-python-argparse-to-throw-an-exception-rather-than-usage/14728477#14728477
# the only way to do this is overriding the error method and throw and Exception
class ArgumentParserError(Exception): pass

class ArgumentParser(StandardArgumentParser):
    """ArgumentParser not exiting with non-Nagios format message upon errors"""
    def error(self, message):
        raise ArgumentParserError(message)

##################[ Action to immediately set the log level ]##################
class SetLogLevel(StoreConstAction):
    """ArgumentParser action to set log level to provided const value"""
    def __call__(self, parser, namespace, values, option_string=None):
        logging.getLogger(PROG_NAME).setLevel(self.const)

####################[ Action to immediately log to a file ]####################
class SetLogFile(StoreAction):
    """ArgumentParser action to log to file (sets up FileHandler accordingly)"""
    def __call__(self, parser, namespace, values, option_string=None):
        super(SetLogFile, self).__call__(parser,namespace,values,option_string)
        formatter = logging.Formatter(LOG_FORMAT_FILE)
        handler = logging.FileHandler(values)
        handler.setFormatter(formatter)
        logger = logging.getLogger(PROG_NAME)
        logger.propagate = False
        logger.addHandler(handler)

###############################################################################

def convert_celcius(temp_read):
    """Converts raw temperature sensore value to degrees Celcius"""
    return float(temp_read) / float(SENSOR_SCALE)
CONVERT_CELCIUS = ( convert_celcius, 'C', 'Celcius' )


def convert_farenheit(temp_read):
    """Converts raw temperature sensore value to degrees Farenheit"""
    return float(temp_read * 9) / float(5 * SENSOR_SCALE) + 32.0
CONVERT_FARENHEIT = ( convert_farenheit, 'F', 'Farenheit' )


def isempty(string):
    """Checks whether string 'str' provided is unset or empty"""
    return string is None or len(string) == 0


def parse_args():
    """Parse command line and get parameters from environment, if present"""

    # Setup argument parser, the workhorse gluing it all together
    parser = ArgumentParser(
        epilog='(*) by default the script will look for the first device that '
               'matches %s* in %s, if multiple entries are found -s or -f must '
               'be used to specify which sensor to read.' %
               (SENSOR_DEV_PREFIX, SENSOR_DEV_DIR),
        description='Nagios check plugin for 1-wire temp. sensor on RaspberryPi'
    )
    parser.add_argument('-V', '--version',action="version",version=PROG_VERSION)

    pgroup = parser.add_mutually_exclusive_group(required=False)
    pgroup.add_argument('-C', '--celcius',  action='store_const',
                        dest='converter',   const=CONVERT_CELCIUS,
                        help='measure, critical and warn values in Celcius '
                             '(default)',   default=CONVERT_CELCIUS)
    pgroup.add_argument('-F', '--farenheit',action='store_const',
                        dest='converter',   const=CONVERT_FARENHEIT,
                        help='measure, critical and warn values in Farenheit')

    parser.add_argument('-w', '--warn',     type=float,
                        help='temperature for warning status')
    parser.add_argument('-c','--critical',  type=float,
                        help='temperature for critical status')

    parser.add_argument('-r', '--retries', type=int,default=SENSOR_READ_RETRIES,
                        help='number of times to retry reading sensor data when'
                             ' unstable (defaults to %d)' % SENSOR_READ_RETRIES)

    pgroup = parser.add_mutually_exclusive_group(required=False)
    pgroup.add_argument('-s', '--serial',
                        help='(unique part of) temperature sensor serial (*)')
    pgroup.add_argument('-f', '--file',
                        help='input file (or device) to obtain data from (*)')

    pgroup = parser.add_mutually_exclusive_group(required=False)
    pgroup.add_argument('-q', '--quiet',    default=logging.CRITICAL,
                        action=SetLogLevel, const=LOGGING_NONE,
                        help='quiet (no output, only exit with exit code)')
    pgroup.add_argument('-v', '--verbose',  help='more verbose output',
                        action=SetLogLevel, const=logging.INFO)
    pgroup.add_argument('-d', '--debug',    help='debug output (more verbose)',
                        action=SetLogLevel, const=logging.DEBUG)

    parser.add_argument('-l', '--logfile',  action=SetLogFile,
                        help='send logging output to logfile')

    # parse arguments and post-process command line options
    args = parser.parse_args()

    # if we got here all seems OK
    return args


def get_sensor_device_filename(args, dev_dir=SENSOR_DEV_DIR,
                            prefix=SENSOR_DEV_PREFIX, suffix=SENSOR_DEV_SUFFIX):
    """Auto-determine sensor datafile name (unless args.file is set)"""
    if isempty(args.file):
        search_pat = dev_dir + ('/' if dev_dir[-1]!='/' else '')
        search_pat+= prefix + '*' if isempty(args.serial) else '*' + args.serial
        logger.debug('looking for sensors with search pattern %s', search_pat)

        device_folders = glob(search_pat)
        if len(device_folders) == 1:
            filename = device_folders[0] + suffix
        else:
            if len(device_folders) == 0:
                errmsg = 'no supported temperature sensors in %s' % dev_dir
            else:
                serials=map(lambda x: basename(x) if x.find(prefix)<0
                                 else basename(x)[len(prefix):], device_folders)
                errmsg = 'found multiple temperature sensors (%s), please '\
                         'specify which one to use' % ', '.join(serials)
            logger.critical(errmsg)
            raise ValueError(errmsg)
    else:
        filename = args.file
    logger.debug('using temperature sensor at %s', filename)
    return filename


def read_sensor_raw(device_file):
    """Reads the raw data from the sensor device file, returns array of lines"""
    with open(device_file, 'r') as f:
        lines = f.readlines()
        logger.debug('Temperature sensor data read from %s: %s', f.name, lines)
    return lines


def read_temp(device_file, converter=CONVERT_CELCIUS, maxretries=10):
    """Reads sensor data and converts it to desired unit, returns temperature"""
    lines = read_sensor_raw(device_file)
    tries = 1
    while lines[0].strip()[-3:] != 'YES' and tries <= maxretries:
        tries += 1
        sleep(0.2)
        logger.warn('Temperature sensor data not stable, reading once more')
        lines = read_temp_raw(device_file)

    if lines[0].strip()[-3:] != 'YES':
        errmsg = 'no stable temperature sensor data after %d tries' % tries
    else:
        equals_pos = lines[1].find('t=')
        if equals_pos == -1:
            errmsg = 'temperature sensor data format is not supported'
        else:
            temp_read = int(lines[1][equals_pos+2:])
            temp = converter[0](temp_read)
            logger.debug('Temperature sensor value %d is %.2f%s', temp_read,
                         temp, converter[1])
            return temp, tries

    logger.critical(errmsg)
    raise ValueError(errmsg)


def nagios_exit(status, message, data=None):
    """exit 'nagios-style', print status and message followed by perf. data"""
    if logger.isEnabledFor(logging.CRITICAL):
        if data is not None and len(data) > 0:
            perfdata=map(lambda (k,v): "'%s'=%s" %(k,v if not isinstance(v,list)
                     else ';'.join(map(lambda x:'' if x is None else str(x),v)))
                         ,data.iteritems())
            perfstr = ' | ' + ' '.join(perfdata)
        else:
            perfstr = ''
        print 'Temperature %s: %s%s' % (status[0], message, perfstr)
    exit(status[1])


if __name__ == '__main__':
    try:
        args = parse_args()
    except ArgumentParserError as e:
        nagios_exit(NAGIOS_UNKNOWN,'error with setup: ' + e.message)
    except (KeyboardInterrupt, EOFError) as e:
        print
        nagios_exit(NAGIOS_UNKNOWN,'initialization aborted')

    try:
        starttime = time()
        devicefile = get_sensor_device_filename(args)
        temperature, tries = read_temp(devicefile, args.converter, args.retries)
        endtime = time()

    except (KeyboardInterrupt) as e:
        nagios_exit(NAGIOS_UNKNOWN,'temperature sensor read aborted by user')

    except (IOError, ValueError) as e:
        nagios_exit(NAGIOS_UNKNOWN,'temperature sensor read failed: %s' % e)

    elapse = endtime-starttime
    logger.info('Got temperature reading of %.2f degrees %s in %fs',
                temperature, args.converter[2], elapse)

    temp_unit = args.converter[1]
    message = 'current temperature is %.2f%s' % (temperature, temp_unit)
    if args.critical is not None and temperature > args.critical:
        nagiosresult = NAGIOS_CRITICAL
        message += ', above critical threshold %.2f%s'%(args.critical,temp_unit)
    elif args.warn is not None and temperature > args.warn:
        nagiosresult = NAGIOS_WARN
        message += ', above warning threshold %.2f%s' % (args.warn, temp_unit)
    else:
        nagiosresult = NAGIOS_OK

    nagios_exit(nagiosresult, message, {
          'temperature': [ temperature, args.warn, args.critical, None, None],
          'retries': [ tries-1, None, args.retries, 0, None ],
          'time': [ '%f' % elapse, None, None, 0, None]
    })