#!/usr/bin/python2 -OO

### Things you may want to configure: #########################################
#
#     System commands;
pon_command = "/usr/bin/pon &"          # start pppd
poff_command = "/usr/bin/poff"          # kill pppd
# The following string is taken if no -u option is present. If it is,
# a poff_command followed by a pon_command is used.
hangup_command = "/usr/bin/killall -HUP pppd" # hangup pppd
#
#     Initial size (and placement, if desired). See man X for the
#     required geometry format, examples: "70x70+200-100", "-200+100",
#     "30x30"
geometry = "105x40"                     # no placemant (size only)
geometry_nopt = "40x40"
#
#     Other configurable items
polling_rate = 200                      # milliseconds
colors = ("#ff3080", "#ffff70", "#86dc68") # (red, yellow, green)
loud_colors = ("red", "yellow", "green") # -l option colors
#
### end of configuration ######################################################

version = "nstat v0.4b for netcount v0.8i 2003-04-01"

def usage():
    print """
usage: nstat [options]
function:  Display information on the state of a ppp connection
           Also allows to hangup and to kill pppd if started as root.
options:
   -s, --sticky  Make window sticky.  Remove decoration and stay on top.
   -g, --geometry <pos>  Initial size and placement. <pos> Syntax is that of
           X geometry specifications, e.g. 80x80 to set initial size,
           +200-100 for initial position, 70x70-122+45 for size and pos.
   -l, --loud  Use alternate color set.
   -i, --idle <secs>  Enable before-the-minute-hangup. <secs> is the minimum
           idle time before hangup.  See also -b.           
   -b, --before <secs> This number of seconds before the full minute
           hangup will take place (default = 2)
   -u, --hard-hangup  hang up by killing and restarting pppd (instead of
           sending SIGHUP).
   -n, --notime  Omit connection time display.
   -k, --keep  keep pppd running if not explicitly switched off
   -f, --file <fnam>  Watch file <fnam> instead of /var/log/netcount/nc-up,
           do not evaluate PPP traffic.
   -V, --version  Show program version"""

#
# TODO: self.root.geometry() abfangen

import Tkinter, Dialog, signal, sys, os, atexit, stat
import time, getopt, re

def abort(s, e = "", retval = 1):
    sys.stderr.write(str(s))
    if e:
        sys.stderr.write("-- %s\n" % (str(e)))
    sys.exit(retval)

class Pic:
    bw = 2                              # borderwidth
    def __init__(self):
        resizing = 0
        self.root = Tkinter.Tk()
        try: self.root.geometry(geometry)
        except: pass
        self.root.configure(borderwidth = Pic.bw)
        self.icon = Tkinter.Canvas(self.root)
        self.icon.grid()
        self.icon.configure(borderwidth = Pic.bw)
        self.string = Tkinter.StringVar()
        self.elapsed = self.hungUp = None
        self.setTime(0)
        self.label = Tkinter.Label(self.root, width = 8,
                                   textvariable = self.string)
        if not nopt:
            self.label.grid(row = 0, column = 1, sticky = "WE")
        self.setSizeFromGeometry(geometry)
        self.upRelief()
        self.state = None
        # with the -s option, our window is not controlled by the window
        # manager, so we must provide a way for dragging it around:
        if sticky:
            self.root.overrideredirect(1)
            self.root.bind("<B1-Motion>", self.moveExec)
        # without -s, the canvas (and the symbols drawn on it) must
        # follow the geometry changes done through the window manager:
        else: 
            self.root.bind("<Configure>", self.configureEvent)
        self.root.bind("<Button-1>", self.moveStart)
        self.root.bind("<ButtonRelease-1>", self.endMoveOrDoMenu)
        self.root.bind("<Button-3>", self.action)
        self.root.bind("<ButtonRelease-3>", self.upRelief)
        self.periodic()                 # Start polling

    # The next three methods provide a way to drag the window around
    # on the screen, since the window manager is not available to do
    # this if nstat is started with the -s option.
    def moveStart(self, ev):
        self.downRelief()
        # Calculate cursor offset within the window. ev.x, ev.y cannot
        # be used, because ev.widget may be any of the widget in spite
        # of the event being bound to the root window.
        self.mouseRel_x = self.root.winfo_pointerx() - self.root.winfo_rootx()
        self.mouseRel_y = self.root.winfo_pointery() - self.root.winfo_rooty()
        self.moved = 0

    def moveExec(self, ev):
        newx = ev.x_root - self.mouseRel_x
        newy = ev.y_root - self.mouseRel_y
        self.root.configure(cursor = "diamond_cross")
        try: self.root.geometry("+%s+%s" % (newx, newy))
        except: pass
        self.moved = 1

    def endMoveOrDoMenu(self, ev):
        self.upRelief()
        self.root.configure(cursor = "") # restore cursor
        if not self.moved:
            doPopup(ev)

    def action(self, ev):               # mouse button 3 pressed
        if isroot:                      # only act if root
            self.downRelief()
            (start_pppd, stop_pppd, hup_pppd, hup_pppd)[self.state]()

    # Window (re-)sizing methods
    def setSizeFromGeometry(self, geometry):
        try:
            start, width, height, xpos, ypos, rest = re.split \
                    ("^(?:(\d+)x(\d+))?(?:([+-]\d+)([+-]\d+))?$", geometry)
        except ValueError:
            abort("invalid geometry expression: \"%s\"" % geometry, "", 2)
        if width is None:               # no width/height given,
            width, height = 105, 40     # so set in default values
        self.setSize(int(width), int(height))
            
    def setSize(self, x, y):
        if not nopt:
            label_x_len = self.label.winfo_reqwidth() + 2 * Pic.bw
            label_y_len = self.label.winfo_reqheight() + 2 * Pic.bw
        else:
            label_x_len = label_y_len = 0        
        self.icon_max = min(x - label_x_len, y) - 2 * Pic.bw
        self.icon_width = max( 1, 0.15 * self.icon_max)
        self.icon.configure(width = self.icon_max - 2 * Pic.bw,
                            height = self.icon_max - 2 * Pic.bw)
#        self.root.minsize(width = label_x_len + label_y_len + 4 * Pic.bw,
#                          height = label_y_len + 2 * Pic.bw)
        
    def configureEvent(self, ev):       # user has resized window
        if ev.widget != self.root:      # unrequested event
            return
        self.setSize(ev.width, ev.height)
        self.draw(self.state)
        self.upRelief()

    # Checking and drawing methods
    def periodic(self):
        """The workhorse. Checks periodically for the network state
        and updates the display if necessary."""
        global restartPppdOnce
        newstate = f.checkFiles()
        if newstate != self.state:
            self.draw(newstate)
            if newstate == Off and isroot:
                # restore signals:
                for sig in Signals:
                    signal.signal(sig, signal.SIG_DFL)
                if restartPppdOnce or (kopt and not pppdSwitchedOff):
                    start_pppd()
                    restartPppdOnce = 0
        if newstate >= Idle:            # Idle or Traffic
            now = time.mktime(time.localtime())
            elapsed = now - f.mtime     # elapsed connection time
            if newstate == Traffic:     # connected and traffic
                self.label["fg"] = "red"
                self.idleStart = elapsed
                self.hungUp = 0
            if elapsed != self.elapsed:
                self.setTime(elapsed)   # refresh time display
                self.elapsed = elapsed
                if newstate == Idle \
                       and idleTime \
                       and (elapsed - self.idleStart) >= idleTime:
                    self.label["fg"] = "blue"
                    if isroot \
                           and not self.hungUp \
                           and (elapsed % 60) >= (60 - before):
                        hup_pppd()
                        self.hungUp = 1
        else:                           # not connected
            self.label["fg"] = "black"
        self.state = newstate
        self.root.after(polling_rate, self.periodic) # set polling interval

    def setTime(self, sec):
        ms = divmod(sec, 60)
        hms = divmod(ms[0], 60) + (ms[1],)
        self.string.set(":".join([ "%02d" % x for x in hms]))
        
    def upRelief(self, ev = "dummy"):
        self.icon.configure(relief = "raised")
        self.root.configure(relief = "groove")

    def downRelief(self):
        self.icon.configure(relief = "sunken")
        self.root.configure(relief = "ridge")

    def setbg(self, color):
        for bg in (self.root, self.icon, self.label):
            bg.configure(bg = color)

    def draw(self, state):
        """dispatch to one of the drawing methods according to self.state"""
        self.icon.delete("fig")
        (self.drawX, self.drawCircle,
         self.drawSmallBar, self.drawBar)[state]() # do the dispatch
        self.root.title("nstat: " + ("off", "down", "up", "active")[state])

    def drawCircle(self):
        self.setbg(colors[1])           # yellow
        max = self.icon_max - Pic.bw - self.icon_width
        min = Pic.bw + self.icon_width
        self.icon.create_oval(min, min, max, max,
                              width = self.icon_width,
                              tag = "fig", fill = colors[0])
        
    def drawBar(self):
        self.setbg(colors[2])           # green background
        xpos = 0.5 * self.icon_max
        self.icon.create_line(xpos, self.icon_width,
                           xpos, self.icon_max - self.icon_width,
                           width = self.icon_width, tag = "fig")
        
    def drawSmallBar(self):
        self.setbg(colors[2])           # green background
        pos = 0.5 * self.icon_max
        self.icon.create_line(pos, pos-self.icon_width,
                           pos, pos+self.icon_width,
                           width = self.icon_width, tag = "fig")
        
    def drawX(self):
        self.setbg(colors[0])           # red background
        max = self.icon_max - self.icon_width
        self.icon.create_line(self.icon_width, self.icon_width, max, max,
                           width=self.icon_width, tag = "fig")
        self.icon.create_line(max, self.icon_width,
                              self.icon_width, max,
                           width=self.icon_width, tag = "fig")


########## Files #####################################################

Procnetdev = "/proc/net/dev"
Off, On, Idle, Traffic = range(4)       # result values from f.checkFiles

class StatFiles:
    # Methods for the files nstat derives the network status from
    ppp0Line = ""
    def __init__(self):
        if fopt:
            return
        try:
            self.devfile = open(Procnetdev)
        except Exception, e:
            abort(Procnetdev, e, 1)
        
    def checkFiles(self):
        """ returns: Off (0) if no pppd active, On (1) if pppd active,
        but no connection exists, Idle (2) if connected, no traffic
        and Traffic (3) if connected and traffic.  if fopt is set,
        return Off (0) or Traffic (3)."""
        if not fopt:
            try:
                self.devfile.seek(0)
                s = self.devfile.read() # read /proc/net/dev
            except Exception, e:
                abort(Procnetdev, e, 1)
            if s.find(" ppp0") < 0:
                return Off                # no pppd running
        try:
            self.mtime = os.stat(nc_up)[stat.ST_MTIME] # get file time
        except OSError, e:
            if e.errno == 2:            # No such file
                return (On, Off)[fopt]  # no preceding ip-up
            else:
                abort(nc_up, e)
        # nc-up exists
        # isolate the line starting with " ppp0:" and compare it:
        if not fopt:
            ppp0Line = s.split(" ppp0:")[1].split("\n")[0]
            if ppp0Line == self.ppp0Line:
                return Idle             # connected, no traffic
            self.ppp0Line = ppp0Line
        return Traffic                  # connected, traffic


########################### Menu stuff ###############################

Signals = (signal.SIGTERM, signal.SIGHUP, signal.SIGINT)
# Menu field numbers:
M_Cancel, M_Sep, \
          M_Hangup, M_TerminatePppd, M_StartPppd, \
          M_Sep, \
          M_Exit, M_Max = range(8)

pppdSwitchedOff = 0

def start_pppd():
    # pppd sends signals on exit and hangup if there is/was a
    # connection, so we must ignore it from now on:
    global pppdSwitchedOff
    check_pppd(0)
    for sig in Signals:
        signal.signal(sig, signal.SIG_IGN)
    os.system(pon_command)
    pppdSwitchedOff = 0

def stop_pppd():
    global pppdSwitchedOff
    check_pppd(1)
    pppdSwitchedOff = 1
    os.system(poff_command)

restartPppdOnce = 0
def hup_pppd():
    global restartPppdOnce
    check_pppd(1)
    if uopt:
        stop_pppd()
        restartPppdOnce = 1
    else:
        os.system(hangup_command)

def check_pppd(nAllowed):
    n = len(os.popen("pidof pppd").read().split())
    if n > nAllowed:
        Dialog.Dialog(
            title = "nstat: Aborting",
            text = "Already %d pppd%s running.\nnstat cannot handle this situation." % (n, ("", "s")[n != 1]),
            strings = ("Abort",),
            bitmap = "error",
            default = 0)
        sys.exit(3)
    

def createPopup():
    p.root.menu = Tkinter.Menu(p.root, tearoff = 0)
    p.root.menu.add_command(label = "Cancel")
    if isroot:
        p.root.menu.add_separator()
        p.root.menu.add_command(label = "Hangup", command = hup_pppd)
        p.root.menu.add_command(label = "Terminate pppd", command = stop_pppd)
        p.root.menu.add_command(label = "Start pppd", command = start_pppd)
        p.root.menu.add_separator()
    if __debug__:
        import pdb
        p.root.menu.add_command(label = "Run pdb", command = pdb.set_trace)
        p.root.menu.add_separator()
    p.root.menu.add_command(label = "Exit nstat", command = p.root.quit)

def doPopup(ev):
    if isroot:
        for menu_item in (M_StartPppd, M_TerminatePppd, M_Hangup):
            p.root.menu.entryconfig(menu_item, state = Tkinter.DISABLED)
            if p.state != None:
                for menu_item in ((M_StartPppd, ), (M_TerminatePppd, ),
                         (M_Hangup, M_TerminatePppd),
                         (M_Hangup, M_TerminatePppd))[p.state]:
                    p.root.menu.entryconfig(menu_item, state = Tkinter.NORMAL)
    p.root.menu.post(ev.x_root, ev.y_root)

#### Lock file stuff #########################################################

LockFNam = "/var/run/nstat.pid"

def lockFileError(e):
    abort("Lock file error", e)
    
def lock():
    global isroot
    isroot = os.getuid() == 0
    if not isroot:
        return
    while 1:
        try:
            otherNstat = open(LockFNam).read() # get running nstat's pid
        except IOError, e:
            if e.errno != 2: abort(str(e)) # != "No such file or directory"
            break
        except Exception, e: lockFileError(e)
        else:
            # No error, i.e. lock file exists
            try: 
                res = os.popen("ps w " + otherNstat).read().find("nstat")
            except Exception, e: lockFileError(e)
            if res < 0:
                print "Stale lockfile detected (%s, pid=%s)" %\
                      (LockFNam, otherNstat)
                break
            else:
                # lockfile exists and points to an nstat process:
                action = Dialog.Dialog(
                    title = "nstat: Lock Dialog",
                    text = "Another root nstat process (pid = %s)\nis already running.\nChoose action:" % otherNstat,
                    strings = ("Continue, disabling root functions",
                               "Kill other nstat",
                               "Abort this nstat"),
                    bitmap = "question",
                    default = 2)
                if action.num == 2:     # "Abort"
                    sys.exit(0)
                elif action.num == 0:   # "Continue"
                    isroot = 0          # disable root functions
                    return
                else:                   # "kill"
                    os.system("kill -USR1 " + otherNstat)
                    break
    # (Re)Create lockfile
    try:
        open(LockFNam, "w").write(str(os.getpid()))
    except Exception, e: lockFileError(e)
    atexit.register(unlock)

def unlock():
    os.unlink(LockFNam)

#### Command line arguments ##################################################

sticky = fopt = nopt = gopt = kopt = uopt = isroot = 0
idleTime = 0
before = 2
isroot = os.getuid() == 0

nc_up = "/var/log/netcount/nc-up"

def intOpt(opt, arg):
    try:
        return int(arg)
    except Exception, e:
        abort("Invalid %s argument" % opt, e)
        
def handleOpts():
    global geometry, sticky, colors, nc_up, fopt, nopt, gopt, idleTime
    global kopt, uopt, before
    try:
        opts, pargs = getopt.getopt(sys.argv[1:],
                                 "g:h?sVvf:lni:kub:",
                                 ["geometry=", "help", "sticky", "version",
                                  "file=", "loud", "notime", "idle=",
                                  "keep", "use-sighup", "before="])
    except getopt.GetoptError, e:
        abort(e, "Use the -h option for help on command line syntax", 2)
    for opt, arg in opts:
        if opt in ("-g", "--geometry"): gopt = 1; geometry = arg
        if opt in ("-h", "-?", "--help"): usage(); sys.exit(0)
        if opt in ("-s", "--sticky"): sticky = 1
        if opt in ("-V", "-v", "--version") : print version; sys.exit(0)
        if opt in ("-l", "--loud"): colors = loud_colors
        if opt in ("-f", "--file"): fopt = 1; nc_up = arg
        if opt in ("-n", "--notime"): nopt = 1
        if opt in ("-i", "--idle"): idleTime = intOpt(opt, arg)
        if opt in ("-k", "--keep"): kopt = 1
        if opt in ("-u", "--hard-hangup"): uopt = 1
        if opt in ("-b", "--before"): before = intOpt(opt, arg)
    if not gopt:
        if nopt: geometry = geometry_nopt
    if pargs:
        abort("Excess argument(s): %s\n" % pargs)
    if not isroot and (idleTime or kopt or uopt):
        abort("must be root to use selected option(s).\nAborted.")

############################################################################
        
    
if __name__ == "__main__":
    handleOpts()
    f = StatFiles()
    p = Pic()
    lock()
    createPopup()
    p.root.mainloop()
