source: pvMail/src/PvMail/pvMail.py @ 1104

Last change on this file since 1104 was 1104, checked in by jemian, 10 years ago

sending command-line email from Ubuntu is more idiosyncratic, depending on the version, 10.04 LTS uses postfix, not exim

  • Property svn:eol-style set to native
  • Property svn:executable set to *
  • Property svn:keywords set to Author Date Id Rev Url
File size: 16.1 KB
Line 
1#!/usr/bin/env python
2
3########### SVN repository information ###################
4# $Date: 2012-09-09 17:37:26 +0000 (Sun, 09 Sep 2012) $
5# $Author: jemian $
6# $Revision: 1104 $
7# $URL$
8# $Id: pvMail.py 1104 2012-09-09 17:37:26Z jemian $
9########### SVN repository information ###################
10
11'''
12===================================
13pvMail: combined CLI and GUI
14===================================
15
16Functionally based on pvMail UNIX shell script written in 1999.
17
18Summary
19--------
20
21Watches an EPICS PV and sends email when it changes from 0 to 1.
22PV value can be either integer or float.
23
24.. note::
25   When "running", wait for trigger PV to go from 0 to 1.  When that
26   happens, fetch mail message from message PV.  Then, send that
27   message out to each of the email addresses.  The message
28   content is prioritized for view on a small-screen device such
29   as a pager or a PDA or smartphone.
30
31:author: Kurt Goetze (original version)
32:author: Pete Jemian (this version)
33:organization: AES/BCDA, Advanced Photon Source, Argonne National Laboratory
34
35:license: Copyright (c) 2011, UChicago Argonne, LLC
36:license: Copyright (c) 2009, The University of Chicago, as Operator of Argonne
37     National Laboratory.
38:license: Copyright (c) 2009 The Regents of the University of California, as
39     Operator of Los Alamos National Laboratory.
40:license: This file is distributed subject to a Software License Agreement found
41     in the file LICENSE that is included with this distribution.
42
43:note: Version History:
44:note: 05.09.07  kag  Initial alpha version.  Needs testing.
45:note: 2009-12-02 prj: converted to use wxPython (no Tkinter or Pmw)
46:note: 2011-11-23 prj: complete rewrite using PyEpics
47    and combined GUI (Traits) and CLI
48   
49:requires: EPICS system (http://www.aps.anl.gov/epics)
50    with at least two process variables (PVs)
51    where the "Trigger PV" toggles between values of 0 and 1
52    and the "SendMessage PV" contains a string to send as part of
53    the email message.
54:requires: PyEpics (http://cars9.uchicago.edu/software/python/pyepics3/)
55:requires: Traits (http://code.enthought.com/projects/traits/)
56'''
57
58
59import argparse
60import datetime
61import epics
62import logging
63import os
64import socket
65import sys
66import threading
67import time
68import traceback
69
70
71__yyyymmdd__ = str(datetime.datetime.now()).split()[0]
72__project_name__ = "PvMail"
73__description__ = "Watch an EPICS PV. Send email when it changes from 0 to 1."
74__svnid__ = "$Id: pvMail.py 1104 2012-09-09 17:37:26Z jemian $"
75__version__ = "3"
76__minor_version__ = "0.1"
77__revision__ = __svnid__.split(" ")[2]
78#__full_version__ = "%s.%s-r%s" % (__version__, __minor_version__, __revision__)
79__full_version__ = "v%s.%s, %s" % (__version__, __minor_version__, __yyyymmdd__) 
80__author__ = "Pete Jemian"
81__institution__ = "Advanced Photon Source, Argonne National Laboratory"
82__author_email__= "jemian@anl.gov"
83__url__ = "http://subversion.xray.aps.anl.gov/admin_bcdaext/pvMail"
84__license__ = "(c) 2009-2012, UChicago Argonne, LLC"
85__license__ += " (see LICENSE file for details)"
86
87LOG_FILE = "pvMail-%d.log" % os.getpid()
88RETRY_INTERVAL_S = 0.2
89CHECKPOINT_INTERVAL_S = 5 * 60.0
90
91gui_object = None
92
93
94class PvMail(threading.Thread):
95    '''
96    Watch an EPICS PV (using PyEpics interface) and send an email
97    when the PV changes from 0 to 1.
98    '''
99   
100    def __init__(self):
101        self.trigger = False
102        self.message = "default message"
103        self.subject = "pvMail.py"
104        self.triggerPV = ""
105        self.messagePV = ""
106        self.recipients = []
107        self.old_value = None
108        self.monitoredPVs = []
109        self.ca_timestamp = None
110   
111    def basicChecks(self):
112        '''
113        check for valid inputs,
114        raise exceptions as discovered,
115        otherwise no return result
116        '''
117        if len(self.recipients) == 0:
118            msg = "need at least one email address for list of recipients"
119            raise RuntimeWarning, msg
120        fmt = "could not connect to %s PV: %s"
121        parts = {'message': self.messagePV, 
122                 'trigger': self.triggerPV}
123        for name, pv in parts.items():
124            if len(pv) == 0:
125                raise RuntimeWarning, "no name for the %s PV" % name
126            if pv not in self.monitoredPVs:
127                if self.testConnect(pv, timeout=0.5) is False:
128                    raise RuntimeWarning, fmt % (name, pv)
129       
130    def testConnect(self, pvname, timeout=5.0):
131        '''
132        create PV,
133        wait for connection,
134        return connection state (True | False)
135       
136        adapted from PyEpics __createPV() method
137        '''
138        logger("test connect with %s" % pvname)
139        retry_interval_s = 0.0001
140        start_time = time.time()
141        thispv = epics.PV(pvname)
142        thispv.connect()
143        while not thispv.connected:
144            time.sleep(retry_interval_s)
145            epics.ca.poll()
146            if time.time()-start_time > timeout:
147                break
148        return thispv.connected
149   
150    def do_start(self):
151        '''start watching for triggers'''
152        logger("do_start")
153        if len(self.monitoredPVs) == 0:
154            self.basicChecks()
155            logger("passed basicChecks(), starting monitors")
156            epics.camonitor(self.messagePV, callback=self.receiveMessageMonitor)
157            epics.camonitor(self.triggerPV, callback=self.receiveTriggerMonitor)
158            self.old_value = epics.caget(self.triggerPV)
159            # What happens if either self.messagePV or self.triggerPV
160            # are changed after self.do_start() is called?
161            # Keep a local list of what was initiated.
162            self.monitoredPVs.append(self.messagePV)
163            self.monitoredPVs.append(self.triggerPV)
164   
165    def do_stop(self):
166        '''stop watching for triggers'''
167        logger("do_stop")
168        for pv in self.monitoredPVs:
169            epics.camonitor_clear(pv)   # no problem if pv was not monitored
170        self.monitoredPVs = []
171   
172    def do_restart(self):
173        '''restart watching for triggers'''
174        self.do_stop()
175        self.do_start()
176   
177    def receiveMessageMonitor(self, value, **kw):
178        '''respond to EPICS CA monitors on message PV'''
179        logger("%s = %s" % (self.messagePV, value))
180        self.message = value
181   
182    def receiveTriggerMonitor(self, value, **kw):
183        '''respond to EPICS CA monitors on trigger PV'''
184        logger("%s = %s" % (self.triggerPV, value))
185        # print self.old_value, type(self.old_value), value, type(value)
186        if self.old_value == 0:
187            if value == 1:
188                self.ca_timestamp = None
189                # Cannot use this definition:
190                #     self.trigger = (value == 1)
191                # since the trigger PV just may transition back
192                # to zero before SendMessage() runs.
193                self.trigger = True
194                pv = epics._MONITORS_[self.triggerPV]
195                self.ca_timestamp = pv.timestamp
196                # or epics.ca.get_timestamp(pv.chid)
197                SendMessage(self)
198        self.old_value = value
199   
200    def send_test_message(self):
201        '''
202        sends a test message, used for development only
203        '''
204        logger("send_test_message")
205        self.recipients = ["jemian", "prjemian"]
206        message = ''
207        message += 'host: %s\n' % socket.gethostname()
208        message += 'date: %s (UNIX, not PV)\n' % datetime.datetime.now()
209        message += 'program: %s\n' % sys.argv[0]
210        message += 'trigger PV: %s\n' % self.triggerPV
211        message += 'message PV: %s\n' % self.messagePV
212        message += 'recipients: %s\n' % ", ".join(self.recipients)
213        self.subject = "pvMail development test"
214        sendMail(self.subject, self.message, self.recipients)
215
216
217class SendMessage(threading.Thread):
218    '''
219    initiate sending the message in a separate thread
220   
221    :param obj pvm: instance of PvMail object on which to report
222    '''
223
224    def __init__(self, pvm):
225        logger("SendMessage")
226        pvm.trigger = False        # triggered event received
227
228        try:
229            pvm.basicChecks()
230           
231            pvm.subject = "pvMail.py: " + pvm.triggerPV
232           
233            #msg = pvm.message
234            msg = ''        # start with a new message
235            msg += "\n\n"
236            msg += 'user: %s\n' % os.environ['LOGNAME']
237            msg += 'host: %s\n' % socket.gethostname()
238            msg += 'date: %s (UNIX, not PV)\n' % datetime.datetime.now()
239            if pvm.ca_timestamp not in ('None', 0):
240                msg += 'CA_timestamp: %d\n' % pvm.ca_timestamp
241            msg += 'program: %s\n' % sys.argv[0]
242            msg += 'PID: %d\n' % os.getpid()
243            msg += 'trigger PV: %s\n' % pvm.triggerPV
244            msg += 'message PV: %s\n' % pvm.messagePV
245            msg += 'recipients: %s\n' % ", ".join(pvm.recipients)
246            pvm.message = msg
247
248            sendMail(pvm.subject, pvm.message, pvm.recipients)
249            logger("message(s) sent")
250        except:
251            err_msg = traceback.format_exc()
252            final_msg = "pvm.subject = %s\nmsg = %s\ntraceback: %s" % (pvm.subject, str(msg), err_msg)
253            logger(final_msg)
254
255def sendMail(subject, message, recipients):
256    '''
257    send an email message using sendmail
258   
259    :param str subject: short text for email subject
260    :param str message: full text of email body
261    :param [str] recipients: list of email addresses to receive the message
262    '''
263    global gui_object
264   
265    from_addr = sys.argv[0]
266    to_addr = str(" ".join(recipients))
267
268    cmd = 'the mail configuration has not been set yet'
269    if 'el' in str(os.uname()):         # RHEL uses postfix
270        email_program = '/usr/lib/sendmail'
271        mail_command = "%s -F %s -t %s" % (email_program, from_addr, to_addr)
272        mail_message = [mail_command, "Subject: "+subject, message]
273        cmd = '''cat << +++ | %s\n+++''' % "\n".join(mail_message)
274   
275    if 'Ubuntu' in str(os.uname()):     # some Ubuntu (11) uses exim, some (10) postfix
276        email_program = '/usr/bin/mail'
277        mail_command = "%s %s" % (email_program, to_addr)
278        content = '%s\n%s' % (subject, message)
279        # TODO: needs to do THIS
280        '''
281        cat /tmp/message.txt | mail jemian@anl.gov
282        '''
283        cmd = '''echo %s | %s''' % (content, mail_command)
284
285    msg = "sending email to: %s" % to_addr
286    logger(msg)
287    if gui_object is not None:
288        gui_object.SetStatus(msg)   # FIXME: get the GUI status line to update
289
290    try:
291        logger( "email command:\n" + cmd )
292        if os.path.exists(email_program):
293            os.popen(cmd)    # send the message
294        else:
295            logger( 'email program (%s) does not exist' % email_program )
296    except:
297        err_msg = traceback.format_exc()
298        final_msg = "cmd = %s\ntraceback: %s" % (cmd, err_msg)
299        logger(final_msg)
300
301
302def logger(message):
303    '''
304    log a report from this class.
305
306    :param str message: words to be logged
307    '''
308    now = datetime.datetime.now()
309    name = sys.argv[0]
310    name = os.path.basename(name)
311    text = "(%s,%s) %s" % (name, now, message)
312    logging.info(text)
313
314
315def basicStartTest():
316    '''simple test of the PvMail class'''
317    logging.basicConfig(filename=LOG_FILE,level=logging.INFO)
318    logger("startup")
319    pvm = PvMail()
320    pvm.recipients = ['prjemian@gmail.com']
321    pvm.triggerPV = "pvMail:trigger"
322    pvm.messagePV = "pvMail:message"
323    retry_interval_s = 0.05
324    end_time = time.time() + 60
325    report_time = time.time() + 5.0
326    pvm.do_start()
327    while time.time() < end_time:
328        if time.time() > report_time:
329            report_time = time.time() + 5.0
330            logger("time remaining: %.1f seconds ..." % (end_time - time.time()))
331        time.sleep(retry_interval_s)
332    pvm.do_stop()
333
334
335def basicMailTest():
336    '''simple test sending mail using the PvMail class'''
337    pvm = PvMail()
338    pvm.send_test_message()
339
340
341def cli(results):
342    '''
343    command-line interface to the PvMail class
344   
345    :param obj results: default parameters from argparse, see main()
346    '''
347    logging_interval = min(60*60, max(5.0, results.logging_interval))
348    sleep_duration = min(5.0, max(0.0001, results.sleep_duration))
349
350    pvm = PvMail()
351    pvm.triggerPV = results.trigger_PV
352    pvm.messagePV = results.message_PV
353    pvm.recipients = results.email_addresses.strip().split(",")
354    checkpoint_time = time.time()
355    pvm.do_start()
356    while True:     # endless loop, kill with ^C or equal
357        if time.time() > checkpoint_time:
358            checkpoint_time += logging_interval
359            logger("checkpoint")
360        if pvm.trigger:
361            SendMessage(pvm)
362        time.sleep(sleep_duration)
363    pvm.do_stop()        # this will never be called
364
365
366def gui(results):
367    '''
368    graphical user interface to the PvMail class
369   
370    :param obj results: default parameters from argparse, see main()
371    '''
372   
373    import traits_gui
374    global gui_object
375   
376    gui_object = traits_gui.PvMail_GUI(results.trigger_PV, 
377                                results.message_PV, 
378                                results.email_addresses.strip().split(","),
379                                results.log_file,
380                                )
381    gui_object.configure_traits()
382
383
384def main():
385    '''parse command-line arguments and choose which interface to use'''
386    parser = argparse.ArgumentParser(description=__description__)
387
388    # positional arguments
389    # not required if GUI option is selected
390    parser.add_argument('trigger_PV', action='store', nargs='?',
391                        help="EPICS trigger PV name", default="")
392
393    parser.add_argument('message_PV', action='store', nargs='?',
394                        help="EPICS message PV name", default="")
395
396    parser.add_argument('email_addresses', action='store', nargs='?',
397                        help="email address(es), comma-separated if more than one", 
398                        default="")
399
400    # optional arguments
401    parser.add_argument('-l', action='store', dest='log_file',
402                        help="for logging program progress and comments", 
403                        default=LOG_FILE)
404
405    parser.add_argument('-i', action='store', dest='logging_interval', 
406                        type=float,
407                        help="checkpoint reporting interval (s) in log file", 
408                        default=CHECKPOINT_INTERVAL_S)
409
410    parser.add_argument('-r', action='store', dest='sleep_duration', 
411                        type=float,
412                        help="sleep duration (s) in main event loop", 
413                        default=RETRY_INTERVAL_S)
414
415    parser.add_argument('-g', '--gui', action='store_true', default=False,
416                        dest='interface',
417                        help='Use the graphical rather than command-line interface')
418
419    parser.add_argument('-v', '--version', action='version', version=__full_version__)
420
421    results = parser.parse_args()
422
423    addresses = results.email_addresses.strip().split(",")
424    interface = {False: 'command-line', True: 'GUI'}[results.interface]
425
426    logging.basicConfig(filename=results.log_file, level=logging.INFO)
427    logger("#"*60)
428    logger("startup")
429    logger("trigger PV       = " + results.trigger_PV)
430    logger("message PV       = " + results.message_PV)
431    logger("email list       = " + str(addresses) )
432    logger("log file         = " + results.log_file)
433    logger("logging interval = " + str( results.logging_interval ) )
434    logger("sleep duration   = " + str( results.sleep_duration ) )
435    logger("interface        = " + interface)
436    logger("user             = " + os.environ['LOGNAME'])
437    logger("host             = " + socket.gethostname() )
438    logger("program          = " + sys.argv[0] )
439    logger("PID              = " + str(os.getpid()) )
440   
441    if results.interface is False:
442        # When the GUI is not selected,
443        # ensure the positional arguments are given
444        tests = [
445                     len(results.trigger_PV),
446                     len(results.message_PV),
447                     len(" ".join(addresses)),
448                ]
449        if 0 in tests:
450            parser.print_usage()
451            sys.exit()
452   
453    # call the interface
454    {False: cli, True: gui}[results.interface](results)     
455
456
457if __name__ == '__main__':
458    #  ./pvMail.py  pvMail:trigger pvMail:message jemian
459    if False:  # code development only
460        sys.argv.append('pvMail:trigger')
461        sys.argv.append('pvMail:message')
462        sys.argv.append('jemian')
463        sys.argv.append('-l mylog.log')
464    main()
Note: See TracBrowser for help on using the repository browser.