""" alarm_multi.py An example of a WeeWX service that implements multiple alarms. This code is based on the multiple alarm service created by user William Phelps in 2013 in the following WeeWX user group post: https://groups.google.com/d/msg/weewx-user/-IGQC3CpXAE/ItUpebyZlL8Jalarm_multi. The multiple alarm service was in turn based on the then example alarm service included in WeeWX v2.3.x which is copyright (c) 2009-2015 Tom Keffer <tkeffer@gmail.com>. William Phelps original multiple alarm service was subsequently modified by Gary Roderick as follows: - on 6 April 2017 to work under WeeWX v3.7.1 - on 21 September 2020 to work under WeeWX 4.x and python 2/3 and to include mail transport changes incorporated in the original example alarm service since WeeWX v2.3.x Further changes were made in October 2020 and version numbering adopted starting at v2.0.0 Version: 2.0.0 Date: 1 October 2020 Revision History 1 October 2020 v2.0.0 - implemented the 'include_full_record' config item which controls whether the full archive record or an abbreviated archive record is included in the alarm email message body - restructured alarm parsing removing the need for the 'count' config item - restructured imports - renamed class MyAlarm to AlarmMulti - renamed some methods/variable to quieten pycharm complaints - reformatted/rewrote lead in comments/instructions - minor reformatting of email body - reworked --help output Abbreviated instructions for use: To configure this service, add the following to the WeeWX configuration file weewx.conf: [Alarm] time_wait = 3600 smtp_host = smtp.example.com smtp_user = myusername smtp_password = mypassword from = sally@example.com mailto = jane@example.com, bob@example.com expression.0 = "outTemp < 40.0" subject.0 = "Alarm message from WeeWX - Low temperature!" expression.1 = "outTemp > 90.0" subject.1 = "Alarm message from WeeWX- High temperature!" In this example, if the outside temperature falls below 40, or rises above 90, it will send an email to the the comma separated list specified in option 'mailto', in this case jane@example.com and bob@example.com. The example assumes an SMTP email server at smtp.example.com that requires login. If the SMTP server does not require login, leave out the lines for smtp_user and smtp_password. Setting an email "from" is optional. If not supplied, one will be filled in, but your SMTP server may or may not accept it. Setting an email "subject" is optional. If not supplied, one will be filled in. To avoid a flood of emails, emails will only be sent every 3600 seconds (one hour). The option include_full_record can be used to control whether the full archive record is included in any alarm emails or whether to include only those fields involved in the triggered alarm expression. Optional, set to True or False. Default is true. To enable this service: 1. copy this file to the user directory 2. modify the WeeWX configuration file by adding this service to the "report_services" option, located in section [Engine] [[Services]], eg: [Engine] [[Services]] ... report_services = weewx.engine.StdPrint, weewx.engine.StdReport, user.alarm_multi.AlarmMulti 3. restart WeeWX Note: If you wish to use both this example and the lowBattery.py example, simply merge the two configuration options together under [Alarm] and add both services to report_services. """ # python imports from __future__ import print_function import smtplib import socket import threading import time from email.mime.text import MIMEText # WeeWX imports import weewx import weewx.engine import weeutil.weeutil # import/setup logging, WeeWX v3 is syslog based but WeeWX v4 is logging based, # try v4 logging and if it fails use v3 logging try: # WeeWX4 logging import logging from weeutil.logger import log_traceback log = logging.getLogger(__name__) def logcrit(msg): log.critical(msg) def logdbg(msg): log.debug(msg) def logerr(msg): log.error(msg) def loginf(msg): log.info(msg) # log_traceback() generates the same output but the signature and code is # different between v3 and v4. We only need log_traceback at the log.error # level so define a suitable wrapper function. def log_traceback_error(prefix=''): log_traceback(log.error, prefix=prefix) except ImportError: # WeeWX legacy (v3) logging via syslog import syslog from weeutil.weeutil import log_traceback def logmsg(level, msg): syslog.syslog(level, 'alarm: %s' % msg) def logcrit(msg): logmsg(syslog.LOG_CRIT, msg) def logdbg(msg): logmsg(syslog.LOG_DEBUG, msg) def logerr(msg): logmsg(syslog.LOG_ERR, msg) def loginf(msg): logmsg(syslog.LOG_INFO, msg) # log_traceback() generates the same output but the signature and code is # different between v3 and v4. We only need log_traceback at the log.error # level so define a suitable wrapper function. def log_traceback_error(prefix=''): log_traceback(prefix=prefix, loglevel=syslog.LOG_ERR) ALARM_MULTI_VERSION = '2.0.0' # Define the AlarmMulti class which is inherited from the base class StdService class AlarmMulti(weewx.engine.StdService): """Service to send an email if any one of multiple expressions evaluate true.""" # define the default record content if an abbreviated recor dis included in # the alarm email body default_manifest = ['dateTime', ] def __init__(self, engine, config_dict): # pass the initialization information on to my superclass super(AlarmMulti, self).__init__(engine, config_dict) try: alarm_config = config_dict['Alarm'] # Dig the needed options out of the configuration dictionary. # If a critical option is missing, an exception will be raised and # the alarm will not be set. # get the minimum time between alarm emails, default to one hour self.time_wait = int(alarm_config.get('time_wait', 3600)) # get the timeout when waiting for a server to respond, default # to 10 seconds self.timeout = int(alarm_config.get('timeout', 10)) self.smtp_host = alarm_config['smtp_host'] self.smtp_user = alarm_config.get('smtp_user') self.smtp_password = alarm_config.get('smtp_password') # get the from address, use a default if not specified self.FROM = alarm_config.get('from', 'alarm@example.com') self.TO = weeutil.weeutil.option_as_list(alarm_config['mailto']) self.last_msg_ts = {} self.expression = {} self.subject = {} # construct/populate a number of dicts to support the multiple alarm # expressions for scalar in alarm_config.scalars: if 'expression.' in scalar.lower(): _i = scalar.split('.')[1] i = int(_i) self.last_msg_ts[i] = 0 self.expression[i] = alarm_config[scalar] # get the subject, use a default if not specified self.subject[i] = alarm_config.get('.'.join(['subject', _i]), "Alarm message from WeeWX") # log the expression to be used loginf("Alarm set for expression %s: \"%s\"" % (_i, self.expression[i])) # do we include the full archive record in the email body or an # abbreviated version based on the alarm expression self.full_rec = weeutil.weeutil.to_bool(alarm_config.get('include_full_record', True)) # if we got this far, it's ok to start intercepting events self.bind(weewx.NEW_ARCHIVE_RECORD, self.new_archive_record) except KeyError as e: # we had a missing parameter for which we do not have a suitable # default so log it and abort our loading loginf("No alarm set. Missing parameter: %s" % e) def new_archive_record(self, event): """Called on a new archive record event.""" # Tto avoid a flood of nearly identical emails, this will do the check # only if we have never sent an email, or if we haven't sent one in the # last self.time_wait seconds for key in self.expression.keys(): if not self.last_msg_ts[key] or abs(time.time() - self.last_msg_ts[key]) >= self.time_wait: # get the new archive record record = event.record # be prepared to catch an exception in the case that the # expression contains a variable that is not in the record try: # Evaluate the expression in the context of the event # archive record. Sound the alarm if it evaluates true. if eval(self.expression[key], None, record): # sound the alarm! # launch in a separate thread so it doesn't block the # main LOOP thread t = threading.Thread(target=AlarmMulti.sound_the_alarm, args=(self, record, self.expression[key], self.subject[key])) t.start() # record when the message went out self.last_msg_ts[key] = time.time() except NameError as e: # The record was missing a named variable. Write a debug # message, then keep going logdbg("%s" % e) def sound_the_alarm(self, rec, expr, subj): """Sound the alarm.""" # wrap in a 'try' block so we can catch and log any failure try: self.do_alarm(rec, expr, subj) except socket.gaierror: # a gaierror exception is usually caused by an unknown host logcrit("unknown host %s" % self.smtp_host) # Reraise the exception. This will cause the thread to exit. raise except Exception as e: # some other exception occurred, log it and reraise logcrit("unable to sound alarm. Reason: %s" % e) # Reraise the exception. This will cause the thread to exit. raise def do_alarm(self, rec, expr, subj): """Send an alarm email.""" # get the time and convert to a string t_str = weeutil.weeutil.timestamp_to_string(rec['dateTime']) # log the alarm loginf("Alarm expression \"%s\" evaluated True at %s" % (expr, t_str)) # include the full archive record in the email body or just the fields # of interest if self.full_rec: # full record msg_rec = rec # create an appropriate message body text msg_str = "Alarm expression '%s' evaluated True at %s\n\nRecord: %s" else: # Just the fields of interest. Perform a simple search of the alarm # expression for any archive record keys, this is a fairly basic # search and may be prone to false triggers on similarly named # fields but it will do the job. # Start with the default list of fields manifest = list(AlarmMulti.default_manifest) # iterate over the archive record keys looking for the key in the # alarm expression. for key in rec.keys(): if key in expr: # found an occurrence, add the key to our manifest manifest.append(key) # now construct an abbreviated record to use in the email body msg_rec = {} # add archive record fields to our message record for any keys in # our manifest for key in manifest: msg_rec[key] = rec[key] # create an appropriate message body text msg_str = "Alarm expression '%s' evaluated True at %s\n\nAbbreviated record: %s" # form the message text msg_text = msg_str % (expr, t_str, weeutil.weeutil.to_sorted_string(msg_rec)) # convert to MIME msg = MIMEText(msg_text) # fill in the MIME headers msg['Subject'] = subj msg['From'] = self.FROM msg['To'] = ','.join(self.TO) try: # first try end-to-end encryption s = smtplib.SMTP_SSL(self.smtp_host, timeout=self.timeout) logdbg("using SMTP_SSL") except (AttributeError, socket.timeout, socket.error, ConnectionRefusedError): logdbg("unable to use SMTP_SSL connection.") # if that doesn't work, try creating an insecure host, then upgrading try: s = smtplib.SMTP(self.smtp_host, timeout=self.timeout) # be prepared to catch an exception if the server does not # support encrypted transport s.ehlo() s.starttls() s.ehlo() logdbg("using SMTP encrypted transport") except smtplib.SMTPException: # we can't use an encrypted transport try an unencrypted # transport logdbg("using SMTP unencrypted transport") except ConnectionRefusedError as e: # connection was refused, log it and reraise logdbg("Connection was refused: %s" % (e,)) raise try: # if a username has been given, assume that login is required for this host if self.smtp_user: s.login(self.smtp_user, self.smtp_password) logdbg("logged in with user name %s" % self.smtp_user) # send the email s.sendmail(msg['From'], self.TO, msg.as_string()) # log out of the server s.quit() except Exception as e: # we encountered an exception, log it and reraise logerr("SMTP mailer refused message with error %s" % e) raise # the email was successfully sent so log it loginf("email sent to: %s" % self.TO) # for backwards compatibility MyAlarm = AlarmMulti if __name__ == '__main__': """This section is used to test alarm_multi.py. It uses a record and alarm expression that are guaranteed to trigger an alert. You will need a valid weewx.conf configuration file with an [Alarm] section that has been set up as illustrated at the top of this file. """ from optparse import OptionParser import weecfg import weeutil usage = """Usage: python -m user.alarm-multi --help python -m user.alarm-multi [CONFIG_FILE|--config=CONFIG_FILE]""" epilog = """You must be sure the WeeWX modules are in your PYTHONPATH. For example: PYTHONPATH=/home/weewx/bin python -m user.alarm-multi --help\n Depending on your system configuration your may also need to replace 'python' in the above command with 'python2' or 'python3' to ensure the correct python version is used.""" weewx.debug = 1 # Now we can set up the user customized logging but we need to handle both # v3 and v4 logging. V4 logging is very easy but v3 logging requires us to # set up syslog and raise our log level based on weewx.debug try: # assume v 4 logging weeutil.logger.setup('weewx', dict()) except AttributeError: # must be v3 logging, so first set the defaults for the system logger syslog.openlog('alarm_multi.py', syslog.LOG_PID | syslog.LOG_CONS) # now raise the log level if required if weewx.debug > 0: syslog.setlogmask(syslog.LOG_UPTO(syslog.LOG_DEBUG)) # create a command line parser parser = OptionParser(usage=usage, epilog=epilog) parser.add_option("--config", dest="config_path", metavar="CONFIG_FILE", help="Use configuration file CONFIG_FILE.") # parse the arguments and options (options, args) = parser.parse_args() try: config_path, config_dict = weecfg.read_config(options.config_path, args) except IOError as e: exit("Unable to open configuration file: %s" % e) print("Using configurdation file %s" % config_path) if 'Alarm' not in config_dict: exit("No [Alarm] section in the configuration file %s" % config_path) # this is a fake record that we'll use rec = {'extraTemp1': 1.0, 'outTemp': 38.2, 'dateTime': int(time.time())} # use an expression that will evaluate to True by our fake record config_dict['Alarm']['expression.1'] = "outTemp < 40.0" config_dict['Alarm']['subject.1'] = "outTemp is too low" config_dict['Alarm']['expression.3'] = "extraTemp1 > 0.0" config_dict['Alarm']['subject.3'] = "extraTemp1 is too high" # we need the main WeeWX engine in order to bind to the event, but we don't # need for it to completely start up. So get rid of all services config_dict['Engine']['Services'] = {} # now we can instantiate our slim engine... engine = weewx.engine.StdEngine(config_dict) # ... and set the alarm using it alarm = AlarmMulti(engine, config_dict) # create a NEW_ARCHIVE_RECORD event event = weewx.Event(weewx.NEW_ARCHIVE_RECORD, record=rec) # use it to trigger the alarm alarm.new_archive_record(event) print("Alarms triggered, check log and email for successful operation.")