2022-09-29 17:38:59 -04:00
#!/usr/bin/env python3
2022-11-29 18:26:02 -05:00
""" A check plugin for Nagios/Icinga/etc. that queries Home Assistant for the current state of entities. """
2022-09-29 17:38:59 -04:00
import argparse
2022-11-26 11:11:03 -05:00
import dataclasses
2022-09-29 17:38:59 -04:00
import logging
2022-12-30 21:46:49 -05:00
import re
2022-09-29 17:38:59 -04:00
import sys
from urllib . parse import urljoin
2022-11-26 19:44:26 -05:00
from typing import Optional
2022-11-29 17:54:54 -05:00
from pathlib import Path
2022-09-29 17:38:59 -04:00
import requests
import nagiosplugin
_log = logging . getLogger ( " nagiosplugin " )
2023-03-10 21:49:35 -05:00
class ScalarOrUnknownContext ( nagiosplugin . ScalarContext ) :
2023-03-10 22:16:43 -05:00
"""
Same as a : class : ` nagiosplugin . ScalarContext ` , but returns UNKNOWN when the
value is not an int or float
"""
2023-03-10 21:49:35 -05:00
def evaluate ( self , metric : nagiosplugin . Metric , resource : nagiosplugin . Resource ) :
if isinstance ( metric . value , ( int , float ) ) :
return super ( ) . evaluate ( metric , resource )
else :
2023-03-10 22:16:43 -05:00
return self . result_cls (
nagiosplugin . state . Unknown , " non-scalar value " , metric
)
2023-03-10 21:49:35 -05:00
def performance ( self , metric : nagiosplugin . Metric , resource : nagiosplugin . Resource ) :
if isinstance ( metric . value , ( int , float ) ) :
return super ( ) . performance ( metric , resource )
else :
return nagiosplugin . Performance (
2023-03-10 22:16:43 -05:00
metric . name ,
" U " ,
metric . uom ,
self . warning ,
self . critical ,
metric . min ,
metric . max ,
)
2023-03-10 21:49:35 -05:00
2023-03-11 00:55:38 -05:00
@dataclasses.dataclass
class AttributeFilter :
attribute : str
value : str
negated : bool
@classmethod
def from_str ( cls , arg : str ) :
k , _ , v = arg . partition ( " = " )
if k . endswith ( " ! " ) :
return cls ( k . removesuffix ( " ! " ) , v , True )
else :
return cls ( k , v , False )
def check ( self , attributes : dict [ str , str ] ) - > bool :
return self . negated ^ ( attributes . get ( self . attribute ) == self . value )
2022-11-26 11:11:03 -05:00
@dataclasses.dataclass
class Entities ( nagiosplugin . Resource ) :
url : str
token : str
2022-11-26 19:42:49 -05:00
device_class : str
2022-12-30 21:46:49 -05:00
numeric : bool
2023-03-11 00:55:38 -05:00
filters : list [ AttributeFilter ]
2022-11-26 19:44:26 -05:00
attribute : Optional [ str ]
2022-11-29 15:42:08 -05:00
friendly_name : bool
2023-03-10 22:16:43 -05:00
ignore_missing : bool
2023-03-11 00:41:22 -05:00
include : list [ str ]
exclude : list [ str ]
2022-12-30 21:46:49 -05:00
min : Optional [ float ] = None
max : Optional [ float ] = None
2022-09-29 17:38:59 -04:00
def hass_get ( self , endpoint : str ) - > requests . Response :
headers = {
" Authorization " : " Bearer " + self . token ,
" Content-Type " : " application/json " ,
}
r = requests . get ( urljoin ( self . url , endpoint ) , headers = headers )
if not r . ok :
raise Exception ( " Failed to query Home Assistant API: " + r . text )
return r . json ( )
def check_api ( self ) :
message = self . hass_get ( " /api/ " ) . get ( " message " )
if message != " API running. " :
print ( " ERROR: " + message )
sys . exit ( 1 )
else :
print ( " OK: " + message )
2023-03-11 00:21:23 -05:00
def _state_to_metric ( self , state ) :
""" Convert a state into a metric """
if self . attribute is not None :
if self . ignore_missing and self . attribute not in state [ " attributes " ] :
return
else :
value = state [ " attributes " ] . get ( self . attribute , " missing attribute " )
uom = None
else :
if self . numeric :
try :
value = float ( state [ " state " ] )
except ValueError :
value = state [ " state " ]
else :
value = state [ " state " ]
uom = state [ " attributes " ] . get ( " unit_of_measurement " )
if self . friendly_name :
name = (
state [ " attributes " ]
. get ( " friendly_name " , state [ " entity_id " ] )
. translate ( { ord ( c ) : " _ " for c in " ' = " } )
)
else :
name = state [ " entity_id " ]
yield nagiosplugin . Metric (
name ,
value ,
uom ,
context = self . device_class ,
min = self . min ,
max = self . max ,
)
2022-09-29 17:38:59 -04:00
def probe ( self ) :
response = self . hass_get ( " /api/states " )
for state in response :
2023-03-11 00:41:22 -05:00
if (
state [ " attributes " ] . get ( " device_class " ) == self . device_class
2023-03-11 00:55:38 -05:00
and all ( filter . check ( state [ " attributes " ] ) for filter in self . filters )
2023-03-11 00:41:22 -05:00
and ( len ( self . include ) == 0 or state [ " entity_id " ] in self . include )
and state [ " entity_id " ] not in self . exclude
2022-11-26 19:44:08 -05:00
) :
2023-03-11 00:21:23 -05:00
yield from self . _state_to_metric ( state )
2022-09-29 17:38:59 -04:00
2022-12-30 21:46:49 -05:00
class RegexContext ( nagiosplugin . Context ) :
def __init__ (
self ,
name : str ,
ok : str = None ,
warning : str = None ,
critical : str = None ,
fmt_metric = None ,
result_cls = nagiosplugin . Result ,
) :
super ( ) . __init__ ( name , fmt_metric = fmt_metric , result_cls = result_cls )
# '.^' should match nothing
self . ok = re . compile ( ok or " .^ " )
self . warning = re . compile ( warning or " .^ " )
self . critical = re . compile ( critical or " .^ " )
def fmt_reason ( self , regex : re . Pattern , metric : nagiosplugin . Metric ) - > str :
return f " { metric . name } matched regex ' { regex . pattern } ' ( { metric . value } ) "
def evaluate ( self , metric : nagiosplugin . Metric , resource ) :
if self . critical . match ( metric . value ) :
return self . result_cls (
nagiosplugin . Critical , self . fmt_reason ( self . critical , metric ) , metric
)
elif self . warning . match ( metric . value ) :
return self . result_cls (
nagiosplugin . Warn , self . fmt_reason ( self . warning , metric ) , metric
)
elif self . ok . match ( metric . value ) :
return self . result_cls (
nagiosplugin . Ok , self . fmt_reason ( self . ok , metric ) , metric
)
else :
return self . result_cls (
nagiosplugin . Unknown ,
f " { metric . name } did not match any defined patterns ( { metric . value } ) " ,
metric ,
)
2022-11-29 17:54:54 -05:00
class Icinga2ConfAction ( argparse . Action ) :
def __init__ (
self ,
option_strings ,
dest = argparse . SUPPRESS ,
default = argparse . SUPPRESS ,
help = " Generate conf file for icinga2 CheckCommand " ,
) :
super ( ) . __init__ (
option_strings = option_strings ,
dest = dest ,
default = default ,
nargs = 0 ,
help = help ,
)
2023-03-10 22:16:43 -05:00
def _format_actions (
self ,
param_prefix : str ,
parser : argparse . ArgumentParser ,
order : Optional [ int ] = None ,
) :
2022-11-29 17:54:54 -05:00
for action in parser . _actions :
2022-12-30 21:46:49 -05:00
if action . dest in [ self . dest , " help " , " version " ] or isinstance (
action , argparse . _SubParsersAction
) :
2022-11-29 17:54:54 -05:00
continue
arg_str = action . option_strings [ - 1 ]
icinga2_var = arg_str . lstrip ( " - " ) . replace ( " - " , " _ " )
print ( f ' " { arg_str } " = {{ ' )
if action . required :
print ( " required = true " )
2022-12-26 17:45:38 -05:00
if isinstance (
action ,
(
argparse . _StoreConstAction ,
argparse . _CountAction ,
argparse . _AppendConstAction ,
) ,
) :
2022-12-30 21:46:49 -05:00
print ( f ' set_if = " $ { param_prefix } _ { icinga2_var } $ " ' )
2022-11-29 17:54:54 -05:00
else :
2022-12-30 21:46:49 -05:00
print ( f ' value = " $ { param_prefix } _ { icinga2_var } $ " ' )
2022-11-29 17:54:54 -05:00
print ( f ' description = " { action . help } " ' )
2022-12-30 21:46:49 -05:00
if order is not None :
2023-03-10 22:16:43 -05:00
print ( f " order = { order } " )
2022-12-30 21:46:49 -05:00
2022-11-29 17:54:54 -05:00
print ( " } " )
2022-12-30 21:46:49 -05:00
def __call__ (
self , parser : argparse . ArgumentParser , namespace , values , option_string = None
) :
filename = Path ( __file__ ) . absolute ( )
command_name = filename . stem . removeprefix ( " check_ " )
# FIXME: assumes that script will be in a subdirectory of PluginContribDir
command_path = filename . relative_to ( filename . parents [ 1 ] )
if parser . _subparsers :
# TODO: this seems a bit more hacky than it needs to be
choices = parser . _subparsers . _group_actions [ 0 ] . choices
for subname , subparser in choices . items ( ) :
print ( f ' object CheckCommand " { command_name } _ { subname } " {{ ' )
2023-03-10 22:16:43 -05:00
print ( f ' command = [ PluginContribDir + " / { command_path } " ] ' )
2022-12-30 21:46:49 -05:00
print ( " arguments = { " )
self . _format_actions ( command_name , parser , order = - 2 )
print ( f ' " { subname } " = {{ ' )
2023-03-10 22:16:43 -05:00
print ( " set_if = true " )
print ( " order = -1 " )
print ( " } " )
2022-12-30 21:46:49 -05:00
self . _format_actions ( f " { command_name } _ { subname } " , subparser )
print ( " } \n } " )
else :
print ( f ' object CheckCommand " { command_name } " {{ ' )
print ( f ' command = [ PluginContribDir + " / { command_path } " ] ' )
print ( " arguments = { " )
self . _format_actions ( command_name , parser )
print ( " } \n } " )
2022-11-29 17:54:54 -05:00
parser . exit ( )
2022-09-29 17:38:59 -04:00
@nagiosplugin.guarded
def main ( ) :
argp = argparse . ArgumentParser ( description = __doc__ )
2022-11-29 18:00:21 -05:00
2022-12-30 21:51:13 -05:00
argp . add_argument ( " --version " , action = " version " , version = " 0.2.0 " )
2022-11-29 18:00:21 -05:00
argp . add_argument ( " -v " , " --verbose " , action = " count " , default = 0 )
argp . add_argument ( " --make-icinga-conf " , action = Icinga2ConfAction )
2022-11-26 19:34:37 -05:00
argp . add_argument (
2022-11-29 17:56:13 -05:00
" -t " ,
" --token " ,
required = True ,
type = str ,
help = " token for Home Assistant REST API (see https://developers.home-assistant.io/docs/api/rest/) " ,
2022-11-26 19:34:37 -05:00
)
argp . add_argument (
" -u " , " --url " , required = True , type = str , help = " URL for Home Assistant "
)
2022-12-30 21:46:49 -05:00
shared_parser = argparse . ArgumentParser ( add_help = False )
common_args = shared_parser . add_argument_group (
" Common Arguments " , " Arguments shared between metric types "
)
common_args . add_argument (
2022-11-26 19:42:49 -05:00
" -d " ,
" --device-class " ,
type = str ,
required = True ,
2022-11-29 17:55:46 -05:00
help = " device class of entities to monitor " ,
2022-11-26 19:42:49 -05:00
)
2022-12-30 21:46:49 -05:00
common_args . add_argument (
" -a " , " --attribute " , type = str , help = " check attribute instead of value "
)
common_args . add_argument (
" -f " ,
" --filter " ,
2023-03-11 00:55:38 -05:00
type = AttributeFilter . from_str ,
2022-12-30 21:46:49 -05:00
default = [ ] ,
nargs = " * " ,
2023-03-11 00:55:38 -05:00
help = " filter by ' attribute=value ' (may be specified multiple times). Use != to negate the match " ,
2022-12-30 21:46:49 -05:00
)
2023-03-11 00:41:22 -05:00
common_args . add_argument (
" -i " ,
" --include " ,
default = [ ] ,
nargs = " * " ,
help = " explicitly include entities by id. Other entities will not be considered if this is specified. Listed entities must also match the specified device class " ,
)
common_args . add_argument (
" -e " ,
" --exclude " ,
default = [ ] ,
nargs = " * " ,
help = " exclude entities by id " ,
)
2022-12-30 21:46:49 -05:00
common_args . add_argument (
" --friendly " ,
action = " store_true " ,
help = " use friendly name, when available " ,
)
2023-03-10 22:16:43 -05:00
common_args . add_argument (
" --ignore-missing " , action = " store_true " , help = " Ignore missing attributes "
)
2022-12-30 21:46:49 -05:00
subparsers = argp . add_subparsers (
title = " Query type " , required = True , dest = " subparser_name "
)
scalar_parser = subparsers . add_parser (
" scalar " , help = " Numeric metrics " , parents = [ shared_parser ]
)
scalar_parser . add_argument (
2022-09-29 17:38:59 -04:00
" -w " ,
" --warning " ,
metavar = " RANGE " ,
2022-11-26 19:34:06 -05:00
required = True ,
2022-11-30 12:59:42 -05:00
help = " return warning if value is outside %(metavar)s " ,
2022-09-29 17:38:59 -04:00
)
2022-12-30 21:46:49 -05:00
scalar_parser . add_argument (
2022-09-29 17:38:59 -04:00
" -c " ,
" --critical " ,
metavar = " RANGE " ,
2022-11-26 19:34:06 -05:00
required = True ,
2022-11-30 12:59:42 -05:00
help = " return critical if value is outside %(metavar)s " ,
2022-09-29 17:38:59 -04:00
)
2022-12-30 21:46:49 -05:00
scalar_parser . add_argument (
2022-11-26 19:40:57 -05:00
" --min " ,
type = float ,
2022-11-29 17:55:46 -05:00
help = " min for performance data " ,
2022-11-26 19:40:57 -05:00
)
2022-12-30 21:46:49 -05:00
scalar_parser . add_argument (
2022-11-26 19:40:57 -05:00
" --max " ,
type = float ,
2022-11-29 17:55:46 -05:00
help = " max for performance data " ,
2022-11-26 19:40:57 -05:00
)
2022-12-30 21:46:49 -05:00
text_parser = subparsers . add_parser (
" text " , help = " Textual metrics " , parents = [ shared_parser ]
2022-11-26 19:44:26 -05:00
)
2022-12-30 21:46:49 -05:00
text_parser . add_argument (
" -o " ,
" --ok " ,
metavar = " REGEX " ,
help = " return ok if value matches %(metavar)s " ,
2022-11-26 19:44:08 -05:00
)
2022-12-30 21:46:49 -05:00
text_parser . add_argument (
" -w " ,
" --warning " ,
metavar = " REGEX " ,
help = " return warning if value matches %(metavar)s " ,
)
text_parser . add_argument (
" -c " ,
" --critical " ,
metavar = " REGEX " ,
help = " return critical if value matches %(metavar)s " ,
2022-11-29 15:42:08 -05:00
)
2022-11-26 19:34:37 -05:00
2022-09-29 17:38:59 -04:00
args = argp . parse_args ( )
2023-03-11 00:41:22 -05:00
parsed_common_args = {
" device_class " : args . device_class ,
" attribute " : args . attribute ,
" filters " : args . filter ,
" include " : args . include ,
" exclude " : args . exclude ,
" friendly_name " : args . friendly ,
" ignore_missing " : args . ignore_missing ,
}
2022-12-30 21:46:49 -05:00
if args . subparser_name == " scalar " :
check = nagiosplugin . Check (
Entities (
url = args . url ,
token = args . token ,
numeric = True ,
min = args . min ,
max = args . max ,
2023-03-11 00:41:22 -05:00
* * parsed_common_args ,
2022-12-30 21:46:49 -05:00
) ,
2023-03-10 21:49:35 -05:00
ScalarOrUnknownContext ( args . device_class , args . warning , args . critical ) ,
2022-12-30 21:46:49 -05:00
)
elif args . subparser_name == " text " :
check = nagiosplugin . Check (
Entities (
url = args . url ,
token = args . token ,
numeric = False ,
2023-03-11 00:41:22 -05:00
* * parsed_common_args ,
2022-12-30 21:46:49 -05:00
) ,
RegexContext ( args . device_class , args . ok , args . warning , args . critical ) ,
)
2022-09-29 17:38:59 -04:00
check . main ( args . verbose )
if __name__ == " __main__ " :
main ( )