1 | #!/usr/bin/env python |
---|
2 | |
---|
3 | ########### SVN repository information ################### |
---|
4 | # $Date: 2012-09-12 18:18:34 +0000 (Wed, 12 Sep 2012) $ |
---|
5 | # $Author: jemian $ |
---|
6 | # $Revision: 1110 $ |
---|
7 | # $URL$ |
---|
8 | # $Id: pvMail.py 1110 2012-09-12 18:18:34Z jemian $ |
---|
9 | ########### SVN repository information ################### |
---|
10 | |
---|
11 | ''' |
---|
12 | =================================== |
---|
13 | pvMail: combined CLI and GUI |
---|
14 | =================================== |
---|
15 | |
---|
16 | Functionally based on pvMail UNIX shell script written in 1999. |
---|
17 | |
---|
18 | Summary |
---|
19 | -------- |
---|
20 | |
---|
21 | Watches an EPICS PV and sends email when it changes from 0 to 1. |
---|
22 | PV 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 | |
---|
59 | import argparse |
---|
60 | import datetime |
---|
61 | import epics |
---|
62 | import logging |
---|
63 | import os |
---|
64 | import socket |
---|
65 | import sys |
---|
66 | import threading |
---|
67 | import time |
---|
68 | import 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 1110 2012-09-12 18:18:34Z 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 | |
---|
87 | LOG_FILE = "pvMail-%d.log" % os.getpid() |
---|
88 | RETRY_INTERVAL_S = 0.2 |
---|
89 | CHECKPOINT_INTERVAL_S = 5 * 60.0 |
---|
90 | |
---|
91 | gui_object = None |
---|
92 | |
---|
93 | |
---|
94 | class 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 | |
---|
217 | class 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 | |
---|
255 | def 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 | |
---|
302 | def 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 | |
---|
315 | def 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 | |
---|
335 | def basicMailTest(): |
---|
336 | '''simple test sending mail using the PvMail class''' |
---|
337 | pvm = PvMail() |
---|
338 | pvm.send_test_message() |
---|
339 | |
---|
340 | |
---|
341 | def 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 | |
---|
366 | def 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 | |
---|
384 | def 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 | |
---|
457 | if __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() |
---|