#!/usr/bin/python
#
# arch-tag: test version of PMACS server
# Time-stamp: <2006-03-22 11:12:28 mike>
#
# Interface to this SOAP server is described in the WSDL file:
#
#  http://pmacs.apl.washington.edu/wsdl/pmacs.wsdl
#
#
# EXTERNAL REQUIREMENTS
# ----------------------
#
# A Berkeley DB format database file is used to map the load, bus, and sensor
# names to the corresponding A/D channels and digital I/O lines on the Node
# Power Controller (NPC).  Another DB file is used to record the state of the
# server before it exits.  Both files are stored in the directory
# /var/lib/pmacs/ops_db (may be changed by config file, see below).
#
# The Round Robin Database (RRD) package is also required. It is used to maintain
# a "rotating archive" of the internal Load and Sensor data.
#
# ARCHITECTURE SYNOPSIS
# ----------------------
#
# There are four major object types corresponding to the components of the
# system which a client might want to know about:
#
#    * Loads
#    * Buses
#    * Sensors
#    * Ground Fault Detector
#
# Each type is modeled as a class.  The object initializer for the class takes
# a dictionary as an argument.  This dictionary maps the object attribute
# names to the corresponding analog or digital "interface" on the NPC.
#
# The server uses an npc.NPC object to periodically poll the NPC and read the
# latest sample record.  When the client requests the state of a component,
# the sample record is passed to the status method of the corresponding object.
# This method returns a dictionary which is packed into a SOAP structure and
# returned to the client.
#
# CONFIGURATION FILE
# --------------------
#
# The program takes a single optional command-line argument specifying the server
# configuration file. This file has a simple '.ini' format. The example below shows
# the format along with the default values for each entry::
#
#   [network]
#   npc = mars-npc1      # NPC hostname
#   psc = mars-psc       # PSC hostname
#   soapport = 8080      # TCP port the server listens on
#   interval = 1         # NPC sampling interval in seconds
#   checkauth = off      # If 'on', check authorization for 'setLoad' requests
#  
#   [dbase]
#   root = /var/lib/pmacs      # top-level database directory
#   statedb = ops_db/svcstate  # server-state database (relative to 'root')
#   iospecdb = ops_db/iospec   # I/O port database (relative to 'root')
#   archivedir = archive       # archive directory (relative to 'root')
#   passfile = passwords.txt   # password file (relative to 'root')
#   loadfile = loadauth.txt    # Load authorization file (relative to 'root')
#   calfile = defcal.csv       # Sensor calibration data
#
# AUTHENTICATION AND AUTHORIZATION
# --------------------------------
#
# User authentication is handled using the Web Services Security UsernameToken method and
# a plain-text password file specified by the 'passfile' config directive. Loads are matched
# to users by the Load Authorization file. The files have the following formats.
#
#    passfile
#    ^^^^^^^^
#      Ascii file. Each line of the file contains two colon (':') separated
#      fields. The first field is the username and the second is the password.
#      **Note that the password is stored in plaintext therefore this file
#      should only be readable by the userid associated with the PMACS
#      server process**
#
#    loadfile
#    ^^^^^^^^
#      Ascii file. Each line of the file contains two colon separated fields.
#      The first field is the load ID formated as follows:
#
#         TYPE/PORT/VOLTAGE   (e.g. internal/10/v48 or external/5/v400)
#
#      The second field is a comma separted list of usernames.
#
import sys
import os
import copy
import string
import struct
import time
import syslog
import signal
import threading
from SOAPpy import ThreadingSOAPServer, SOAPProxy, MethodSig
from SOAPpy.Types import typedArrayType, arrayType, structType, faultType
try:
    # For Pythons w/distutils pybsddb
    from bsddb3 import db, dbshelve
except ImportError:
    # For Python 2.3
    from bsddb import db, dbshelve

from elementtree import ElementTree
from pmacs import loads, npc, buses, sensors, auth, mv, io, validator, config, psc
from pmacs.archive import DataArchiver
from pmacs.gf import gfDetector
from pmacs.NPCData import adc_offset, dio_offset
from pmacs.calibration import Calibration
from pmacs.rrd import RRDB

# Optional first argument is the configuration file
try:
    Servercfg = config.ConfigFile(sys.argv[1])
except IndexError:
    Servercfg = config.ConfigFile()
    
# XML namespace for our complex data types
TYPE_NS = 'http://pmacs.apl.washington.edu/schema'

BUS = {}
SENSORS = {}
MV = None
GFd = None
Node = None
LAST_LOAD = None,None,None

# Server proxy for load validation web service
Validator = None

    
# Class to model a bus as a list of Loads.
# TODO: refactor this functionality into the bus.Bus class.
class vBus(object):
    _nloads = {'external/v400' : 8,
               'external/v48' : 8,
               'internal/v48' : 16}
    def __init__(self, name, db, cal):
        """Initialize all of the loads on this bus from information
        in the database.
        """
        d = db[name]
        d['name'] = name
        nloads = self._nloads.get(name, 0)
        self.bus = buses.Bus(d)
        self.name = name
        type,v = name.split('/')
        self.loads = []
        for port in range(1, nloads+1):
            id = "%s/%d/%s" % (type, port, v)
            d = db[id]
            d['name'] = id
            d.setdefault('bus', name)
            d.setdefault('port', port)
            d['current_limit'] = 0
            try:
                c,units = cal[id]
                d['cal'] = c
            except KeyError:
                pass
            self.loads.append(loads.Load(d, Node))

    def __getitem__(self, port):
        return self.loads[port-1]
    def __iter__(self):
        return iter(self.loads)
    def __len__(self):
        return len(self.loads)
    def as_xml(self):
        """Return the XML description of the vBus as an ElementTree object"""
        root = self.bus.as_xml()
        for load in self.loads:
            root.append(load.as_xml())
        return root
    

########################################################################

LOAD_CACHE = {}
def valid_port(idx, location):
    if location == 'internal':
        return 1 <= idx <= 16
    else:
        return 1 <= idx <= 8

def lookup_internal(names):
    try:
        pnum = int(names[1])
        if pnum <= 0 or pnum > len(BUS['internal/v48']):
            raise ValueError, 'invalid port number %d' % pnum
        return [BUS['internal/v48'][pnum]]
    except (IndexError, KeyError):
        return BUS['internal/v48'].loads

def lookup_external(names):
    try:
        pnum = int(names[1])
        if pnum <= 0 or pnum > len(BUS['external/v48']):
            raise ValueError, 'invalid port number %d' % pnum
        try:
            return [BUS['external/%s' % names[2]][pnum]]
        except (IndexError, KeyError):
            return [BUS['external/v48'][pnum], BUS['external/v400'][pnum]]
    except (IndexError, KeyError):
        return BUS['external/v48'].loads + BUS['external/v400'].loads
    
def lookup(id):
    global LOAD_CACHE
    if LOAD_CACHE.has_key(id):
        return LOAD_CACHE[id]
    names = id.split('/')
    # If the path starts with '/', the first element of the
    # list will be a null string.  Remove it.
    if not names[0]:
        names = names[1:]
    if names[0] != '1':
        raise ValueError, "Can only access data from Node 1"
    try:
        if names[1] == 'internal':
            result = lookup_internal(names[1:])
        elif names[1] == 'external':
            result = lookup_external(names[1:])
        else:
            raise ValueError, "Invalid path: " + id
    except IndexError:
        result = lookup_internal([]) + lookup_external([])
    LOAD_CACHE[id] = result
    return result

def next_record():
    return Node.getSample()

####################### SOAP Interface Functions #######################

def setValidator(url, ns, _SOAPContext):
    """Allow the client to specify the URL and namespace for the load
    update validation service.  This web service will typically run on
    the Master Client.
    """
    global Validator
    c = _SOAPContext
    user,reason = SMan.authenticate(c.header['Security']['UsernameToken'])
    if reason != 'ok':
        raise faultType(faultcode='SOAP-ENV:FailedAuthentication',
                        faultstring=reason)
    if user != 'admin':
        raise faultType(faultcode='SOAP-ENV:FailedAuthentication',
                        faultstring='user not authorized')
    if not url:
        syslog.syslog(syslog.LOG_ALERT, "Removing Validator")
        Validator = None
    else:
        Validator = validator.Validator(url, ns)
        syslog.syslog(syslog.LOG_ALERT, "Validator set to %s" % str(Validator))
    return 'ok'

def getLoads(**kw):
    """Return the timestamped state of one or more loads"""
    global LAST_LOAD
    rec = next_record()
    id = kw['id']
    if id == LAST_LOAD[0] and rec[0] == LAST_LOAD[1]:
        return LAST_LOAD[2]
    try:
        loads = lookup(id)
    except:
        raise faultType(faultcode='SOAP-ENV:Client',
                        faultstring='Invalid name: ' + kw['id'])

    lstat = [None]*len(loads)
    for i in range(len(loads)):
        s = loads[i].status(rec)
        s.update({'_typename' : 'loadType'})
        lstat[i] = structType(data=s)
        
    items = typedArrayType(data=lstat)
    items._ns = TYPE_NS
    result = structType(data={'tsecs' : rec[0],
                              'tusecs' : rec[1],
                              'data' : items,
                              '_typename' : 'loadListType'})
    LAST_LOAD = id,rec[0],result
    return result

def getBuses(**kw):
    """Return the timestamped state of all system buses"""
    rec = next_record()
    bstat = []
    for name,obj in BUS.items():
        b = obj.bus.status(rec)
        b.update({'_typename' : 'busType'})
        bstat.append(structType(data=b))
        
    items = typedArrayType(data=bstat)
    items._ns = TYPE_NS
    return structType(data={'tsecs' : rec[0],
                            'tusecs' : rec[1],
                            'data' : items,
                            '_typename' : 'busListType'})

def getSensors(**kw):
    """Return the timestamped state of all system sensors"""
    rec = next_record()
    sstat = []
    for name,obj in SENSORS.items():
        s = obj.status(rec)
        s.update({'_typename' : 'sensorType'})
        sstat.append(structType(data=s))
    items = typedArrayType(data=sstat)
    items._ns = TYPE_NS
    return structType(data={'tsecs' : rec[0],
                            'tusecs' : rec[1],
                            'data' : items,
                            '_typename' : 'sensorListType'})

def getMV(**kw):
    """Return the current state of the MV converter"""
    rec = next_record()
    s = MV.status(rec)
    astages = typedArrayType(data=[0]*6)
    bstages = typedArrayType(data=[0]*6)
    astages._ns = TYPE_NS
    bstages._ns = TYPE_NS
    return structType(data={'tsecs' : rec[0],
                            'tusecs' : rec[1],
                            'voltage' : s['voltage'],
                            'current' : s['current'],
                            'current_diff' : s['current_diff'],
                            'aworking' : 1,
                            'bworking' : 1,
                            'aok' : 1,
                            'bok' : 1,
                            's2open' : 1,
                            'v400a' : 0,
                            'v400b' : 0,
                            'i400a' : 0,
                            'i400b' : 0,
                            'astages' : astages,
                            'bstages' : bstages,
                            '_typename' : 'mvType'})
    

def checkGF(id):
    """Check a bus line for ground faults"""
    idmap = {'v400' : ['v400r'],
             'v400r' : ['v400'],
             'v48' : ['v48r'],
             'v48r' : ['v48'],
             'test' : ['test', 'v48']}
    try:
        switches = idmap[id]
    except KeyError:
        raise faultType(faultcode='SOAP-ENV:Client',
                        faultstring='Invalid name: ' + id)
    GFlock.acquire()
    try:
        for name in switches:
            GFd.select(name)
        # Allow time for the next record to arrive before sending
        # updated status information back to the client.
        time.sleep(2)
        rec = next_record()
        s = GFd.status(rec)
        GFd.unselect()
        return s['current']
    finally:
        GFlock.release()

def setLoad(id, newstate, _SOAPContext):
    """Update the state of one or more loads"""
    c = _SOAPContext
    if Servercfg.checkauth:
        user,reason = SMan.authenticate(c.header['Security']['UsernameToken'])
        if reason != 'ok':
            raise faultType(faultcode='SOAP-ENV:FailedAuthentication',
                            faultstring=reason)
        if not SMan.authorize(user, id):
            raise faultType(faultcode='SOAP-ENV:FailedAuthentication',
                            faultstring='Not authorized to change ' + id)
    try:
        loads = lookup(id)
    except:
        raise faultType(faultcode='SOAP-ENV:Client',
                        faultstring='Invalid name: ' + id)
    stat = {}
    for key in newstate['_keyord']:
        stat[key] = newstate[key]
    is_ok = 0
    client = str(c.connection.getpeername())
    for l in loads:
        try:
            if stat.has_key('state') or stat.has_key('current_limit'):
                is_ok = Validator.check(l.bus, l.port, stat)
            else:
                is_ok = 1
        except:
            is_ok = 1
        if is_ok:
            syslog.syslog(syslog.LOG_INFO, "%s updating %s" % (client, l.name))
            l.update(stat)
        else:
            syslog.syslog(syslog.LOG_INFO,
                          "%s, setLoad disallowed for %s" % (client, l.name))
    return 'ok'


def getSPS(id):
    """Return the state of the Shore Power Supply"""
    return structType(data={'voltage' : Psc.getVoltage(),
                            'current' : Psc.getCurrent(),
                            'state' : Psc.getHVState(),
                            'tc' : Psc.getTc(),
                            '_typename' : 'spsType'})

def setSPSVoltage(value):
    r = Psc.setVoltage(value)
    if r == 1:
        return 'ok'
    elif r == 0:
        return 'busy'
    elif r == -1:
        raise faultType(faultcode='SOAP-ENV:Server',
                        faultstring='Cannot set voltage')

def setSPSCurrent(value):
    Psc.setCurrent(value)
    return 'ok'

def setSPSState(value):
    Psc.setHVState(value)
    return 'ok'

def setSPSTc(value):
    Psc.setTc(value)
    return 'ok'

def getNPCState(**kwds):
    return Node.hostup

def getPSCState(**kwds):
    return Psc.hostup

##################### END SOAP Interface Functions #####################

def daemon_mode():
    """Move process into the background and disconnect from the
    controlling terminal.
    """
    pid = os.fork()
    if pid < 0:
        print >> sys.stderr, "Cannot fork a background process!"
        return
    if pid > 0:
        os._exit(0)
    os.setsid()
    os.chdir('/')
    fd = os.open('/dev/null', os.O_RDWR)
    if fd != -1:
        os.dup2(fd, 0)
        os.dup2(fd, 1)
        if fd > 2:
            os.close(fd)
    # Redirect stderr to a file so we can catch Python stack backtraces
    # if an exception occurs.
    fd = os.open(Servercfg.root + '/error_log', os.O_WRONLY|os.O_CREAT)
    if fd != -1:
        os.dup2(fd, 2)
        if fd > 2:
            os.close(fd)

def checkpoint(env, filename):
    """Write the server state to a database file"""
    store = dbshelve.DBShelf(env)
    store.open(filename, db.DB_HASH, db.DB_CREATE|db.DB_TRUNCATE)
    for k in BUS.keys():
        store[k] = BUS[k]
    for k in SENSORS.keys():
        store[k] = SENSORS[k]
    store['mv'] = MV
    store['gf'] = GFd
    store['validator'] = Validator
    store.close()

def sig_to_exception(a, b):
    raise KeyboardInterrupt

############# Start of main program #############

# Open a connection to the node XMLRPC server
try:
    Node = npc.NPC(host=Servercfg.npc)
except npc.commError:
    print >> sys.stderr,"Cannot start server until NPC is on-line"
    sys.exit(1)

# Open a connection to the psc XMLRPC server
Psc = psc.PSC(host=Servercfg.psc)

# Initialize the Security Manager
SMan = auth.SecurityManager(Servercfg.passfile, Servercfg.loadfile)

# Initialize database and environment
envflags = db.DB_INIT_MPOOL | db.DB_CREATE
dbtype = db.DB_HASH
dbflags = 0

env = db.DBEnv()
env.open(Servercfg.root, envflags)

# Initialize the data structures from the saved state of a previous
# run (if available) or from the "iospec" database which maps the
# loads, buses, and sensors to the corresponding ADC channels and
# digital I/O lines.
sensor_types = ('pressure', 'temp', 'humidity')
bus_names = ('external/v400', 'external/v48', 'internal/v48',
             'internal/v12', 'internal/v5', 'internal/v-12')
if os.path.isfile(Servercfg.statedb):
    store = dbshelve.DBShelf(env)
    store.open(Servercfg.statedb, dbtype, dbflags)
    BUS = dict(zip(bus_names, [store[name] for name in bus_names]))
    MV = store['mv']
    GFd = store['gf']
    Validator = store.get('validator')
    for k in store.keys():
        f = k.split('/')
        if f[0] in sensor_types:
            SENSORS[k] = store[k]
    store.close()
else:
    store = dbshelve.DBShelf(env)
    store.open(Servercfg.iospecdb, dbtype, dbflags)
    cal = Calibration()
    if os.path.isfile(Servercfg.defcalfile):
        cal.update(Servercfg.defcalfile)
    if os.path.isfile(Servercfg.calfile):
        cal.update(Servercfg.calfile)

    BUS = dict(zip(bus_names, [vBus(name, store, cal) for name in bus_names]))
    MV = mv.MV(store['mv'], cal=cal)
    GFd = gfDetector(store['gf'], Node)
    for k in store.keys():
        f = k.split('/')
        if f[0] in sensor_types:
            d = {}
            d.update(store[k])
            d['name'] = k
            try:
                c,units = cal[k]
                d['cal'] = c
                d['units'] = units
            except KeyError:
                pass
            SENSORS[k] = sensors.Sensor(d)
    store.close()

# Dump object information to an XML file
root = ElementTree.Element('nodedesc')
root.set('timestamp',
         time.strftime('%Y%m%dT%H%M%SZ', time.gmtime()))
child = ElementTree.SubElement(root, 'adc')
child.set('bits', '16')
child.set('vpc', str(5./32768.))
for name,obj in BUS.items():
    root.append(obj.as_xml())
child = ElementTree.SubElement(root, 'eng')
for name,obj in SENSORS.items():
    child.append(obj.as_xml())
root.append(MV.as_xml())
root.append(GFd.as_xml())
ElementTree.ElementTree(element=root).write(Servercfg.archivedir+'/nodedesc.xml',
                                            encoding='utf-8')

daemon_mode()
syslog.openlog('pmacs-server', syslog.LOG_PID, syslog.LOG_LOCAL6)

syslog.syslog(syslog.LOG_ALERT, "starting NPC poll")

Psc.set_logger(lambda s: syslog.syslog(syslog.LOG_ALERT, s))
Node.set_logger(lambda s: syslog.syslog(syslog.LOG_ALERT, s))

# Start data archiver and register it as a "listener" with the
# NPC interface.
da = DataArchiver(base=Servercfg.archivedir,
                  log=lambda s: syslog.syslog(syslog.LOG_ALERT, s))
Node.register(da)

# Initialize Round-Robin database for the internal loads and
# the temperature sensors.  The database object is registered
# as a 'listener" with the NPC interface
if Servercfg.userrdb:
    trans = string.maketrans('/', '_')
    rr_sources = [('load%02d' % l.port, l.adc.elem) for l in BUS['internal/v48']]
    rr_sources.extend([(s.name.translate(trans), s.adc.elem) for s in SENSORS.values()])
    rrdb = RRDB(Servercfg.archivedir + '/internal.rrd', rr_sources,
                interval=Servercfg.interval, newdb=False)
    Node.register(rrdb)

# Start periodic polling of the node power controller
Node.start_poll(interval=Servercfg.interval)

# Delay long enough to get the first data record
time.sleep(Servercfg.interval)

# We need a thread lock for the checkGF function because it
# is not re-entrant.
GFlock = threading.Lock()

# Initialize the SOAP server
syslog.syslog(syslog.LOG_ALERT, "starting SOAP server")
server = ThreadingSOAPServer(('', Servercfg.soapport))

# Register functions
for func in (getLoads, getBuses, getSensors, getMV, checkGF, getSPS,
             setSPSVoltage, setSPSCurrent, setSPSState, setSPSTc, getNPCState,
             getPSCState):
    server.registerKWFunction(func, namespace='urn:pmacs-test',
                              path='/pmacs')
# Functions which authenticate the client require SOAP context information
server.registerFunction(MethodSig(setLoad, keywords=0, context=1),
                        namespace='urn:pmacs-test', path='/pmacs')
server.registerFunction(MethodSig(setValidator, keywords=0, context=1),
                        namespace='urn:pmacs-test', path='/pmacs')

# Python catches SIGINT and raises a KeyboardInterrupt exception,
# we will do the same with SIQQUIT and SIGTERM.
signal.signal(signal.SIGQUIT, sig_to_exception)
signal.signal(signal.SIGTERM, sig_to_exception)

# Use SIGUSR1 to force a dump of the server state
signal.signal(signal.SIGUSR1, lambda a,b: checkpoint(env, Servercfg.statedb))

# Accept connections until interrupted
try:
    try:
        server.socket.set_timeout(None)
    except AttributeError:
        pass
    server.serve_forever()
except KeyboardInterrupt:
    syslog.syslog(syslog.LOG_ALERT, "stopping NPC poll")
    Node.stop_poll()
    syslog.syslog(syslog.LOG_ALERT, "saving server state")
    checkpoint(env, Servercfg.statedb)
    syslog.syslog(syslog.LOG_ALERT, "shutting down")
