#! /usr/bin/python
# Copyright 2015 Cumulus Networks LLC, all rights reserved


try:
    import exceptions
    import warnings
    import sys
    import traceback
    import time
    import argparse
    import re
    import os
    import syslog
    import subprocess
    import signal
    import imp
    import json
    import poe_base
except ImportError, e:
    raise ImportError (str(e) + "- required module not found")

platform_detect = '/usr/bin/platform-detect'
platform_root = '/usr/share/cumulus-platform'
base_root = '/usr/lib/python2.7/dist-packages'

g_log=None
g_state = None
g_poe = None
g_dbg = None

class PoeState():
    state_root = '/var/cache/cumulus/unit_state/POE'
    POE_STATE_NOT_RUNNING='BAD'
    POE_STATE_OK='OK'
    POE_STATE_BAD='BAD'

    def __init__(self):
        self.state = self.POE_STATE_NOT_RUNNING
        self.error_ports = []

    def add_error_port(self, swp):
        if not swp in self.error_ports:
            self.error_ports.append(swp)

    def remove_error_port(self, swp):
        if swp in self.error_ports:
            self.error_ports.remove(swp)

    def update_state(self, stopped=False):
        if stopped:
            self.state = self.POE_STATE_NOT_RUNNING
        else:
            if len(self.error_ports):
                self.state = self.POE_STATE_BAD
            else:
                self.state = self.POE_STATE_OK
        self._save()

    def _save(self):
        if not os.path.exists(self.state_root):
            try:
                os.mkdir(self.state_root)
                return
            except:
                pass
        state_file = os.path.join(self.state_root, 'state')
        try:
            open(state_file, 'w').write(self.state+'\n')
        except:
            g_log.error('Could not write to state file: %s' % (state_file))

class PoedLog(object):
    def __init__(self, use_syslog=True, use_console=False):
        self.use_syslog = use_syslog
        self.use_console = use_console

    def error(self, msg):
        self._log(syslog.LOG_ERR, 'ERROR : %s' % (msg))

    def warn(self, msg):
        self._log(syslog.LOG_WARNING, 'WARN : %s' % (msg))

    def info(self, msg):
        self._log(syslog.LOG_INFO, 'INFO : %s' % (msg))

    def dbg(self, msg):
        self._log(syslog.LOG_INFO, 'DBG : %s' % (msg))

    def _log(self, level, msg):
        if self.use_syslog:
            syslog.syslog(level, msg)
        if level == syslog.LOG_ERR:
            sys.stderr.write('%s : %s' % (sys.argv[0][sys.argv[0].rfind('/')+1:], msg))
        if self.use_console:
            sys.stdout.write(msg)

class PoeLldpPort(object):
    PORT_LLDP_STATE_NONE=' '
    PORT_LLDP_STATE_NEGOTIATING='negotiating'
    PORT_LLDP_STATE_POWERING='powering'

    PORT_MODE_2_PAIR='2-pair'
    PORT_MODE_4_PAIR='4-pair'

    PORT_LINK_STATUS_UP='up'
    PORT_LINK_STATUS_DOWN='down'

    def __init__(self, poe_system, poe_port):
        self.poe_system = poe_system
        self.poe_port = poe_port
        self._link_state = self.PORT_LINK_STATUS_DOWN
        self._update_needed = False
        self._tlv_update_needed = False
        self.lldp_power_cmd = None
        self._tlv_oui = '01'
        self.init_lldp_state()

    def init_lldp_state(self):
        self._port_ready = False
        self._lldp_state = self.PORT_LLDP_STATE_NONE
        self._requested_power = 0
        self._allocated_power = 0
        self._requested_pair_mode = self.PORT_MODE_2_PAIR
        self._advertised_pair_mode = self.PORT_MODE_2_PAIR

    def reinit_lldp_state(self):
        self.init_lldp_state()
        self.poe_port.set_hw_pair_mode(self.requested_pair_mode)

    def start_lldp(self):
        self._set_lldp_tlv()
        self._set_lldp_power()

    def handle_lldp_info(self, lldp_info):
        lldp_state = self.PORT_LLDP_STATE_NONE
        tlvs = lldp_info.get('unknown-tlvs')
        try:
            lldp_port_power = lldp_info.get('port')[0].get('power')
            lldp_state = self.PORT_LLDP_STATE_NEGOTIATING
            if lldp_port_power:
                power_item = lldp_port_power[0]
                self.requested_power = int(power_item.get('requested', [{'value':'0'}])[0].get('value', '0'))
                lldp_state = self.PORT_LLDP_STATE_POWERING
        except Exception, e:
            pass

        if tlvs:
            ''' look for a tlv with oui=00,01,42 and value=5
            '''
            for tlv in tlvs:
                unk_tlvs = tlv.get('unknown-tlv')
                for unk_tlv in unk_tlvs:
                    if unk_tlv.get('oui') == '00,01,42':
                        value = unk_tlv.get('value', '0')
                        if value == '05' or value == '5':
                            ''' the device is asking for 4 pair power
                            '''
                            self.requested_pair_mode = self.PORT_MODE_4_PAIR
                        elif value == '1' or value == '01':
                            self.requested_pair_mode = self.PORT_MODE_2_PAIR
        self.lldp_state = lldp_state

    def update(self):
        if self.update_needed and self.port_ready:
            if self.requested_pair_mode != self.poe_port.hw_pair_mode:
                self.poe_port.set_hw_pair_mode(self.requested_pair_mode)

            if self.advertised_pair_mode != self.poe_port.hw_pair_mode:
                self.advertised_pair_mode = self.poe_port.hw_pair_mode
                self._set_lldp_tlv()

            if self.allocated_power != self.requested_power:
                if self.requested_power <= self.poe_port.max_power:
                    self.allocated_power = self.requested_power
                    self.poe_system.set_l2_pm_data(self.poe_port, self.requested_power, self.allocated_power)
                    self._set_lldp_power()
        self._cfg_lldp_tlv()
        self._cfg_lldp_power()
        self.update_needed = False

    def _set_lldp_power(self):
        self.lldp_power_cmd = ['lldpcli', 'configure', 'ports', '%s' % self.poe_port.swp,
              'dot3', 'power',  'pse', 'class', 'class-4', 'supported', 'enabled',
              'powerpairs', 'spare', 'type', '2', 'source', 'primary', 'priority',
              'low', 'requested', '%u' % self.requested_power, 'allocated', '%u' % self.allocated_power]
        self.dbg(self.lldp_power_cmd)

    def _cfg_lldp_power(self):
        if self.lldp_power_cmd:
            subprocess.check_output(self.lldp_power_cmd)

    def _set_lldp_tlv(self):
        if self.poe_port.hw_pair_mode == self.PORT_MODE_4_PAIR:
            self._tlv_oui = '0D'
        else:
            self._tlv_oui = '01' if self.poe_port.support_4pair else '00'
        self._tlv_update_needed = True

    def _cfg_lldp_tlv(self):
        if self._tlv_update_needed and self.port_ready:
            subprocess.check_output(['lldpcli', 'pause'])
            subprocess.check_output(['lldpcli', 'unconfigure', 'ports', self.poe_port.swp, 'lldp', 'custom-tlv'])
            tlv = ['lldpcli', 'configure', 'ports', self.poe_port.swp, 'lldp', 'custom-tlv', 'oui', '00,01,42', 'subtype', '01', 'oui-info', self._tlv_oui]
            self.dbg(tlv)
            subprocess.check_output(tlv)
            subprocess.check_output(['lldpcli', 'resume'])
            self._tlv_update_needed = False

    def __str__(self):
        if self.lldp_state == self.PORT_LLDP_STATE_POWERING:
            return '%.1f W' % (self.allocated_power / 1000.0)
        return self.lldp_state

    def dbg(self, msg):
        self.poe_port.dbg(msg)

    @property
    def requested_power(self):
        return self._requested_power
    @requested_power.setter
    def requested_power(self, value):
        if value != self._requested_power:
            self.dbg('requested_power %s => %s' % (self._requested_power, value))
            self._update_needed = True
            self._requested_power = value

    @property
    def allocated_power(self):
        return self._allocated_power
    @allocated_power.setter
    def allocated_power(self, value):
        if value != self._allocated_power:
            self.dbg('allocated_power %s => %s' % (self._allocated_power, value))
            self._update_needed = True
            self._allocated_power = value

    @property
    def requested_pair_mode(self):
        return self._requested_pair_mode
    @requested_pair_mode.setter
    def requested_pair_mode(self, value):
        if value != self._requested_pair_mode:
            self.dbg('requested_pair_mode %s => %s' % (self._requested_pair_mode, value))
            self._requested_pair_mode = value
            self._update_needed = True

    @property
    def advertised_pair_mode(self):
        return self._advertised_pair_mode
    @advertised_pair_mode.setter
    def advertised_pair_mode(self, value):
        if value != self._advertised_pair_mode:
            self.dbg('advertised_pair_mode %s => %s' % (self._advertised_pair_mode, value))
            self._advertised_pair_mode = value

    @property
    def update_needed(self):
        return self._update_needed
    @update_needed.setter
    def update_needed(self, value):
        self._update_needed = value

    @property
    def port_ready(self):
        return self._port_ready
    @port_ready.setter
    def port_ready(self, value):
        if value != self.port_ready:
            self.dbg('port_ready %s => %s' % (self._port_ready, value))
            self._port_ready = value

    @property
    def link_state(self):
        return self._link_state

    @link_state.setter
    def link_state(self, value):
        if value != self.link_state:
            self.dbg('link_state: %s => %s' % (self._link_state, value))
            self._link_state = value
            if self.link_state == self.PORT_LINK_STATUS_UP:
                self.port_ready = True
                self.start_lldp()


    @property
    def lldp_state(self):
        return self._lldp_state
    @lldp_state.setter
    def lldp_state(self, value):
        if value != self._lldp_state:
            self.dbg('lldp_state "%s" => "%s"' % (self._lldp_state, value))
            self._lldp_state = value

class PoePort(object):

    PORT_HW_STATUS_NONE='none'
    PORT_HW_STATUS_FAULT='fault'
    PORT_HW_STATUS_DISABLED='disabled'
    PORT_HW_STATUS_DENIED='power-denied'
    PORT_HW_STATUS_CONNECTED='connected'

    def __init__(self, poe_system, port_id, port_params):
        self.poe_system = poe_system
        self.port_id = port_id
        self.swp = port_params.swp
        self.support_4pair = port_params.support_4pair
        self.base_power = port_params.base_power
        self.max_power = port_params.max_power
        self.update_hw_pair_mode = False
        self._hw_pair_mode = PoeLldpPort.PORT_MODE_2_PAIR
        self._hw_status = self.PORT_HW_STATUS_NONE
        self.lldp_port = PoeLldpPort(poe_system, self)
        self.status = {}

    def update_hw_status(self, new_status):
        self.status = new_status
        if self.update_hw_pair_mode:
            result = self.poe_system.poe.get_port_4pair_state(self.port_id)
            self.dbg(result)
            self.hw_pair_mode = result.get(self.port_id, {}).get('pair_mode')
            self.dbg('read hw pair mode %s' % self.hw_pair_mode)
            if self.hw_pair_mode == self.lldp_port.requested_pair_mode:
                self.update_hw_pair_mode = False

        ps = self.hw_status
        ns = new_status.get('status')

        if ns != ps:
            self.hw_status = ns
            if ns == self.PORT_HW_STATUS_FAULT:
                g_log.error('POE error on %s: "%s"\n' % (self.swp, new_status.get('error_str')))
                g_state.add_error_port(self.swp)
                return
            if ps == self.PORT_HW_STATUS_FAULT:
                g_state.remove_error_port(self.swp)
            if ns == self.PORT_HW_STATUS_DISABLED:
                g_log.info('POE operation is administratively disabled on port %s\n' % (self.swp))
            if ns == self.PORT_HW_STATUS_DENIED:
                g_log.warn( 'POE power denied to device on port %s\n' % (self.swp))
            if ps == self.PORT_HW_STATUS_DISABLED:
                g_log.info('POE operation enabled on port %s\n' % (self.swp))
            if ns == self.PORT_HW_STATUS_CONNECTED:
                g_log.info('POE device is connected to port %s\n' % (self.swp))
            if ps == self.PORT_HW_STATUS_CONNECTED:
                g_log.info('POE device has been removed from port %s\n' % (self.swp))
                self.lldp_port.reinit_lldp_state()

    def update_link_status(self):
        link = self.lldp_port.PORT_LINK_STATUS_DOWN
        if self.hw_status == self.PORT_HW_STATUS_CONNECTED:
            try:
                if open('/sys/class/net/%s/carrier' % self.swp, 'r').read().strip() == '1':
                    link = self.lldp_port.PORT_LINK_STATUS_UP
            except Exception, e:
                pass
        self.lldp_port.link_state = link
        return link == self.lldp_port.PORT_LINK_STATUS_UP

    def update_lldp_status(self, lldp_info={}):
        self.lldp_port.handle_lldp_info(lldp_info)
        self.lldp_port.update()

    def set_hw_pair_mode(self, pair_mode):
        self.dbg('set_hw_pair_mode: %s' % pair_mode)
        if pair_mode == PoeLldpPort.PORT_MODE_4_PAIR:
            self.dbg('  set_enable_4pair_power()')
            self.poe_system.poe.set_enable_4pair_power(self.port_id)
        else:
            self.dbg('  set_disable_4pair_power()')
            self.poe_system.poe.set_disable_4pair_power(self.port_id,
                        self.hw_status == self.PORT_HW_STATUS_DISABLED)
        self.update_hw_pair_mode = True

    def get_running_state(self):
        status = {
            "swp":self.swp, "hw_status":self.hw_status, "lldp_status":str(self.lldp_port)
        }
        for option in ['pd_type', 'pd_class']:
            val = self.status.get(option)
            if val:
                status[option] = val
        return status

    @property
    def hw_status(self):
        return self._hw_status

    @hw_status.setter
    def hw_status(self, value):
        if value != self.hw_status:
            if self._hw_status == self.PORT_HW_STATUS_NONE and value == self.PORT_HW_STATUS_CONNECTED:
                self.dbg('hw_status: %s => %s' % (self._hw_status, value))
            self._hw_status = value

    @property
    def hw_pair_mode(self):
        return self._hw_pair_mode

    @hw_pair_mode.setter
    def hw_pair_mode(self, value):
        if value and value != self.hw_pair_mode:
            self.dbg('hw_pair_mode: %s => %s' % (self._hw_pair_mode, value))
            self._hw_pair_mode = value
            self.lldp_port.update_needed = True

    def dbg(self, msg):
        if g_dbg and self.swp in g_dbg and self.hw_status == self.PORT_HW_STATUS_CONNECTED:
            g_log.dbg('%5s: %s\n' % (self.swp, str(msg)))

class PoeSystem(object):
    def __init__(self, poe_params, poe):
        self.poe = poe
        self.consumed_power = 0.0
        self.power_limit = 0.0
        self.ports = {}
        self.swps = []
        self.update_pair_modes = False
        self.lldp_ready = False
        for port_id, port_params in poe_params.ports.items():
            poe_port = PoePort(self, port_id, port_params)
            self.swps.append(port_params.swp)
            self.ports[port_params.swp] = poe_port
            self.ports[port_id] = poe_port

    def update_status(self, sys_status):
        ''' system status '''
        self.consumed_power = float(sys_status.get('sys_consumed_power', 0))
        self.power_limit =  float(sys_status.get('sys_power_limit'))

        ''' port hw status '''
        for port_id, port_status in  sys_status.get('ports', {}).items():
            self.ports.get(port_status.get('swp')).update_hw_status(port_status)

        ''' port link status '''
        for swp in self.swps:
            if self.ports.get(swp).update_link_status():
                self.lldp_ready = True

        ''' port lldp status '''
        self._update_ports_lldp_status()

    def get_running_state(self):
        running_state = {
            'sys_consumed_power':self.consumed_power,
            'sys_power_limit':self.power_limit,
            'ports':self.get_ports_running_state()
        }
        return running_state

    def set_l2_pm_data(self, poe_port, requested, allocated):
        self.poe.set_port_l2_power(poe_port.port_id, requested, allocated)

    def get_poe_port(self, port_id):
        return self.ports.get(port_id)

    def get_ports_running_state(self):
        running_state = {}
        for swp, port in self.ports.items():
            running_state[port.port_id] = port.get_running_state()
        return running_state

    def _update_ports_lldp_status(self):
        lldp_status = {}
        if self.lldp_ready:
            try:
                lldp_status = json.loads(subprocess.check_output(['lldpcli', 'show', 'neighbors', 'protocol', 'lldp', 'hidden', 'details', '-f', 'json']))
            except:
                pass
        interfaces = []
        lldp = lldp_status.get('lldp')
        if lldp:
            interfaces = lldp[0].get('interface', [])
        all_ports = set(self.swps)
        for interface in interfaces:
            swp = interface.get('name')
            poe_port = self.get_poe_port(swp)
            if poe_port and poe_port.hw_status == poe_port.PORT_HW_STATUS_CONNECTED:
                all_ports.remove(poe_port.swp)
                poe_port.update_lldp_status(interface)
        for swp in all_ports:
            poe_port = self.get_poe_port(swp)
            if poe_port:
                poe_port.update_lldp_status()

def main():
    global g_poe
    global g_dbg
    if not os.geteuid() == 0:
        raise RuntimeError("must be root to run")

    parser = argparse.ArgumentParser(description="Power Over Ethernet (POE) system monitor")
    parser.add_argument('-c', '--console', action='store_true', help =argparse.SUPPRESS)
    parser.add_argument('debug_ports', nargs='*', help=argparse.SUPPRESS)
    args = parser.parse_args()
    if args.console:
        g_log.use_console = True

    syslog.syslog(syslog.LOG_INFO, "Starting POE Daemon")

    #
    # determine the platform
    #
    try:
        ph = subprocess.Popen((platform_detect), stdout=subprocess.PIPE,
                              shell=False, stderr=subprocess.STDOUT)
        cmdout = ph.communicate()[0]
        ph.wait()
    except OSError, e:
        raise OSError("cannot detect platform")

    [platform, model] = cmdout.rstrip('\n').split(',')
    platform_path = '/'.join([platform_root, platform, model])
    #
    # load the poe class file and instantiate the object
    #
    try:
        m = imp.load_source('poe','/'.join([platform_path, 'bin/poe.py']))
    except IOError:
        m = None
    if not m:
        try:
            name = '/'.join([base_root, 'poe_base.py'])
            m = imp.load_source('poe',name)
        except IOError:
            raise IOError("cannot load module: " + name)
    poe = getattr(m, 'poe')()

    pidfile = "/var/run/poed.pid"
    if already_running(pidfile):
        sys.exit()
    else:
        file(pidfile, 'w').write(str(os.getpid()))

    poe_params = poe.get_poe_params()
    if poe_params.supported:
        g_log.info('POE hardware version %s\n' % (poe.get_poe_version_info().get('ver_str')))
        if len(args.debug_ports):
            g_dbg = args.debug_ports
        g_poe = poe
        poe.handle_poed_start()
        if poe.is_first_boot:
            poe.handle_first_boot()
        poe.handle_warm_cold_boot()
        prev_system_status = {}
        prev_power = None
        g_state.update_state()
        poe_system = PoeSystem(poe_params, poe)
        while True:
            time.sleep(poe_params.interval)
            try:
                prev_power = poe.adjust_system_poe_power(prev_power)
                new_system_status = poe.get_poe_system_status(prev_system_status != {})
                poe_system.update_status(new_system_status)
                poe.save_running_state(poe_system.get_running_state())
            except poe_base.POEDSequenceError:
                pass
            except poe_base.POEDRuntimeError, errstr:
                g_log.error("%s\n" % str(errstr))
                exit(1)
            g_state.update_state()
    else:
        g_log.info('POE is not supported on this platform.\n')
        g_state.update_state(True)
        while True:
            time.sleep(poe_params.interval)

#--------------------
#
# check to see if an instance is already running
#
def already_running(pidfile):
    myname=os.path.basename(sys.argv[0])
    try:
        if not os.path.isfile(pidfile):
            return False
        oldpid = re.findall('\D*(\d+).*', (file(pidfile, 'r').readline()))[0]
        if not os.path.exists('/proc/%s' % oldpid):
            return False
        if myname not in file('/proc/%s/cmdline' % oldpid, 'r').readline():
            return False
        sys.stderr.write("%s already running as process %s\n" % (myname, oldpid))
        return True
    except Exception as inst:
        raise poe_base.POEDRuntimeError("unable to validate pidfile %s: %s" %
                               (pidfile, str(inst)))

#---------------
#
# normal exit
#
def exit_normally(signum=0, frame=None):
    g_log.info('exiting normally\n')
    sys.stderr.write("%s : exiting normally\n" % sys.argv[0])
    if g_poe:
        g_poe.poe_disable()
    g_state.update_state(True)
    exit(0)

#--------------------
#
# execution check
#
if __name__ == "__main__":
    try:
        signal.signal(signal.SIGTERM, exit_normally)
        syslog.openlog()
        g_log = PoedLog()
        g_state = PoeState()
        exit(main())
    except KeyboardInterrupt:
        exit_normally()
    except Exception, e:
        (exc_type, exc_value, exc_traceback) = sys.exc_info()
        err = ''.join(traceback.format_exception(exc_type, exc_value, exc_traceback))
        g_log.error('Unhandled Exception : %s\n' % err)
        g_state.update_state(True)
