#!/usr/bin/python
#
#    powernapd - monitor a system process table; if IDLE amount of time
#               goes by with no MONITORED_PROCESSES running, run ACTION
#    Copyright (C) 2009 Canonical Ltd.
#
#    Authors: Dustin Kirkland <kirkland@canonical.com>
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, version 3 of the License.
#
#    This program 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, see <http://www.gnu.org/licenses/>.

# Imports
import commands
import logging, logging.handlers
import os
import re
import signal
import sys
import time

# Define globals
global PKG, LOCK, CONFIG
PKG = "powernap"
LOCK = "/var/run/%s.pid" % PKG
CONFIG = "/etc/%s/config" % PKG
LOG = "/var/log/%s.log" % PKG

logging.basicConfig(filename=LOG, format='%(asctime)s %(levelname)-8s %(message)s', datefmt='%Y-%m-%d_%H:%M:%S', level=logging.DEBUG,)

# CONFIG values can override these
global MONITORED_PROCESSES, ABSENT_SECONDS, ACTION, INTERVAL_SECONDS, GRACE_SECONDS, DEBUG
MONITORED_PROCESSES = []
ABSENT_SECONDS = sys.maxint
ACTION = "/usr/sbin/powernap"
INTERVAL_SECONDS = 1
GRACE_SECONDS = 60
DEBUG = 0

# Load configuration file
try:
    execfile(CONFIG)
except:
    # No error function yet, use a raw print
    print("Invalid configuration [%s]" % CONFIG)
    sys.exit(1)

# Define Process object to hold regex and absence counter
class Process(object):
    def __init__(self, process):
        self.process = process
        self.regex = re.compile(process)
        self.absent_seconds = 0

# Generic debug function
def debug(level, msg):
    if level >= (logging.ERROR - 10*DEBUG):
        logging.log(level, msg)

# Generic error function
def error(msg):
    debug(logging.ERROR, msg)
    sys.exit(1)

# Lock function, using a pidfile in /var/run
def establish_lock():
    if os.path.exists(LOCK):
        f = open(LOCK,'r')
        pid = f.read()
        f.close()
        error("Another instance is running [%s]" % pid)
    else:
        try:
            f = open(LOCK,'w')
        except:
            error("Administrative privileges are required to run %s" % PKG);
        f.write(str(os.getpid()))
        f.close()
        # Set signal handlers
        signal.signal(signal.SIGHUP, signal_handler)
        signal.signal(signal.SIGINT, signal_handler)
        signal.signal(signal.SIGQUIT, signal_handler)
        signal.signal(signal.SIGTERM, signal_handler)
        signal.signal(signal.SIGUSR1, take_action_handler)

# Clean up lock file on termination signals
def signal_handler(signal, frame):
    if os.path.exists(LOCK):
        os.remove(LOCK)
    debug(logging.INFO, "Stopping %s" % PKG)
    sys.exit(1)

# Search process list for regex, return on first match
def find_process(ps, p):
    for str in ps:
        if p.search(str):
            return 1
    return 0

# Check /dev/*, such that we don't powernap the system if someone
# is actively using a terminal device
def get_console_activity():
    dev = commands.getoutput("ls -t /dev").split('\n')
    c = 0
    d = "/dev/null"
    # Find the most recently touched device
    for d in dev:
      d = "/dev/%s" % d
      debug(logging.DEBUG, "      Examining [%s]" % d)
      if os.path.isdir(d):
          pass
      else:
          t = os.stat(d)
          c = time.time() - t.st_mtime
          break
    i = get_interrupts()
    return c, d, i

# Read keyboard/mouse from /proc/interrupts
# Note: only works for ps2 keyboards/mice
def get_interrupts():
    interrupts = 0
    f = open("/proc/interrupts", "r")
    for line in f.readlines():
        items = line.split()
        source = items.pop()
        if source == "i8042" or source == "keyboard" or source == "mouse":
            items.pop(0)
            items.pop()
            for i in items:
                interrupts += int(i)
    f.close()
    return interrupts

# Send a message to system users, that we're about to take an action,
# and sleep for a grace period
def warn_users():
    msg1 = "PowerNap will take the following action in [%s] seconds: [%s]" % (GRACE_SECONDS, ACTION)
    msg2 = "To cancel this operation, press any key in any terminal"
    debug(logging.WARNING, msg1)
    commands.getoutput("echo '%s\n%s' | wall" % (msg1, msg2))
    t = 0
    c, d, last_i = get_console_activity()
    while t < GRACE_SECONDS:
        time.sleep(INTERVAL_SECONDS)
        t += INTERVAL_SECONDS
        c, d, i = get_console_activity()
        if c < INTERVAL_SECONDS or i != last_i:
            msg = "PowerNap detected activity on [%s]; Canceling action [%s]" % (d, ACTION)
            debug(logging.WARNING, msg)
            commands.getoutput("echo '%s' | wall" % msg)
            return 1
        last_i = i
    return 0

# TODO: notify authorities about action taken
def notify_authorities():
    debug(logging.WARNING, "Taking action [%s]" % ACTION)

# Zero the counters and take the action
def take_action():
    notify_authorities()
    debug(logging.DEBUG, "Reseting counters prior to taking action")
    for p in PROCESSES:
        p.absent_seconds = 0
    os.system(ACTION)

# Handler for asynchronous external signals
def take_action_handler(signal, frame):
    take_action()

# Main loop, search process table, increment counters, take actions, sleep
def powernapd_loop():
    last_i = 0
    while 1:
        debug(logging.DEBUG, "Sleeping [%d] seconds" % INTERVAL_SECONDS)
        time.sleep(INTERVAL_SECONDS)
        # Examine process table, compute absent time of each monitored process
        debug(logging.DEBUG, "Examining process table")
        absent_processes = 0
        ps = commands.getoutput("ps -eo args").splitlines()
        for p in PROCESSES:
            debug(logging.DEBUG, "  Looking for [%s]" % p.process)
            if find_process(ps, p.regex):
                # process running, so reset absent time
                p.absent_seconds = 0
                debug(logging.DEBUG, "    Process found, reset absent time [%d/%d]" % (p.absent_seconds, ABSENT_SECONDS))
            else:
                # process not running, increment absent time
                p.absent_seconds += INTERVAL_SECONDS
                debug(logging.DEBUG, "    Process not found, increment absent time [%d/%d]" % (p.absent_seconds, ABSENT_SECONDS))
                if p.absent_seconds >= ABSENT_SECONDS:
                    # process missing for >= absent_seconds threshold, mark absent
                    debug(logging.DEBUG, "    Process absent for >= threshold, so mark absent")
                    absent_processes += 1
        # Determine if action needs to be taken
        if absent_processes > 0 and absent_processes == len(PROCESSES):
            debug(logging.DEBUG, "    Checking for console or terminal activity")
            c, d, i = get_console_activity()
            if c > ABSENT_SECONDS and i == last_i:
                last_i = i
                # All processes are absent, take action!
                if warn_users() == 0:
                    take_action()
            else:
                last_i = i
                debug(logging.DEBUG, "    There appears to be activity on [%s] within the last [%d] seconds" % (d, ABSENT_SECONDS))


# "Forking a Daemon Process on Unix" from The Python Cookbook
def daemonize (stdin="/dev/null", stdout="/var/log/%s.log" % PKG, stderr="/var/log/%s.err" % PKG):
    try:
        pid = os.fork()
        if pid > 0:
            sys.exit(0)
    except OSError, e:
        sys.stderr.write("fork #1 failed: (%d) %sn" % (e.errno, e.strerror))
        sys.exit(1)
    os.chdir("/")
    os.setsid()
    try:
        pid = os.fork()
        if pid > 0:
            sys.exit(0)
    except OSError, e:
        sys.stderr.write("fork #2 failed: (%d) %sn" % (e.errno, e.strerror))
        sys.exit(1)
    f = open(LOCK,'w')
    f.write(str(os.getpid()))
    f.close()
    for f in sys.stdout, sys.stderr: f.flush()
    si = file(stdin, 'r')
    so = file(stdout, 'a+')
    se = file(stderr, 'a+', 0)
    os.dup2(si.fileno(), sys.stdin.fileno())
    os.dup2(so.fileno(), sys.stdout.fileno())
    os.dup2(se.fileno(), sys.stderr.fileno())


# Main program
if __name__ == '__main__':
    # Ensure that only one instance runs
    establish_lock()
    daemonize()
    try:
        # Run the main powernapd loop
        PROCESSES = [Process(p) for p in MONITORED_PROCESSES]
        debug(logging.INFO, "Starting %s" % PKG)
        powernapd_loop()
    finally:
        # Clean up the lock file
        if os.path.exists(LOCK):
            os.remove(LOCK)
