#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Created on Tue Apr 3 16:36:26 2018

@author: hank
@page:   https://github.com/hankso


This gpio module is implemented on the basis of Sysfs gpio interface.
What is Sysfs?
Sysfs is a gpio access that been exposed to user space by kernel.

Paths in Sysfs
--------------
There are three kinds of entry in /sys/class/gpio:

   - Control interfaces used to get userspace control over GPIOs;

   - GPIOs themselves; and

   - GPIO controllers ("gpio_chip" instances).

That's in addition to standard files including the "device" symlink.

The control interfaces are write-only:

    /sys/class/gpio/

        "export" ... Userspace may ask the kernel to export control of
        a GPIO to userspace by writing its number to this file.

        Example:  "echo 19 > export" will create a "gpio19" node
        for GPIO #19, if that's not requested by kernel code.

        "unexport" ... Reverses the effect of exporting to userspace.

        Example:  "echo 19 > unexport" will remove a "gpio19"
        node exported using the "export" file.

GPIO signals have paths like /sys/class/gpio/gpio42/ (for GPIO #42)
and have the following read/write attributes:

    /sys/class/gpio/gpioN/

    "direction" ... reads as either "in" or "out".  This value may
        normally be written.  Writing as "out" defaults to
        initializing the value as low.  To ensure glitch free
        operation, values "low" and "high" may be written to
        configure the GPIO as an output with that initial value.

        Note that this attribute *will not exist* if the kernel
        doesn't support changing the direction of a GPIO, or
        it was exported by kernel code that didn't explicitly
        allow userspace to reconfigure this GPIO's direction.

    "value" ... reads as either 0 (low) or 1 (high).  If the GPIO
        is configured as an output, this value may be written;
        any nonzero value is treated as high.

        If the pin can be configured as interrupt-generating interrupt
        and if it has been configured to generate interrupts (see the
        description of "edge"), you can poll(2) on that file and
        poll(2) will return whenever the interrupt was triggered. If
        you use poll(2), set the events POLLPRI and POLLERR. If you
        use select(2), set the file descriptor in exceptfds. After
        poll(2) returns, either lseek(2) to the beginning of the sysfs
        file and read the new value or close the file and re-open it
        to read the value.

    "edge" ... reads as either "none", "rising", "falling", or
        "both". Write these strings to select the signal edge(s)
        that will make poll(2) on the "value" file return.

        This file exists only if the pin can be configured as an
        interrupt generating input pin.

    "active_low" ... reads as either 0 (false) or 1 (true).  Write
        any nonzero value to invert the value attribute both
        for reading and writing.  Existing and subsequent
        poll(2) support configuration via the edge attribute
        for "rising" and "falling" edges will follow this
        setting.

GPIO controllers have paths like /sys/class/gpio/gpiochip42/ (for the
controller implementing GPIOs starting at #42) and have the following
read-only attributes:

    /sys/class/gpio/gpiochipN/

        "base" ... same as N, the first GPIO managed by this chip

        "label" ... provided for diagnostics (not always unique)

        "ngpio" ... how many GPIOs this manges (N to N + ngpio - 1)

Board documentation should in most cases cover what GPIOs are used for
what purposes.  However, those numbers are not always stable; GPIOs on
a daughtercard might be different depending on the base board being used,
or other cards in the stack.  In such cases, you may need to use the
gpiochip nodes (possibly in conjunction with schematics) to determine
the correct GPIO number to use for a given signal.
"""

# built-in
import os
import re
import glob
import time
import select
import threading
import os.path as op

from . import constants


class SysfsBase(object):
    _attrs = {}

    def __init__(self, path):
        self._path = str(path)
        self._files = {}
        self._write_lock = threading.Lock()
        self._read_lock = threading.Lock()

    @property
    def path(self): return self._path

    @staticmethod
    def _attr_string(attr, read, write):
        def R(self, attr=attr): return self._read(attr)
        def W(self, val, attr=attr): return self._write(attr, val)
        return property(read and R or None, write and W or None)

    @staticmethod
    def _attr_number(attr, read, write):
        def R(self, attr=attr): return self._read(attr, 10)
        def W(self, val, attr=attr): return self._write(attr, val)
        return property(read and R or None, write and W or None)

    @staticmethod
    def _attr_boolean(attr, read, write):
        def R(self, attr=attr): return bool(self._read(attr, 10))
        def W(self, val, attr=attr): return self._write(attr, int(val))
        return property(read and R or None, write and W or None)

    def setup(self, reopen=True):                   # open or reopen attr files
        if not op.isdir(self.path):
            raise IOError('Cannot open %s at `%s`' % (
                self.__class__.__name__, self.path))
        for attr in set(self._files).difference(self._attrs):
            self._files.pop(attr).close()
            if hasattr(self.__class__, attr):
                delattr(self.__class__, attr)
        for attr in self._attrs.keys():
            fn = op.join(self.path, attr)
            try:
                assert op.isfile(fn)                # attribute file removed
                perm = os.stat(fn).st_mode >> (3 if os.geteuid() else 6) & 0o7
                mode = [None, 'wb', 'rb', 'w+b'][perm >> 1]
                assert mode is not None             # attribute mode changed
            except Exception:
                if attr in self._files:
                    self._files.pop(attr).close()
                continue
            if attr in self._files:
                if not reopen:
                    continue
                self._files[attr].close()
            self._files[attr] = open(fn, mode, buffering=0)
            RD, WR = 'r' in mode or '+' in mode, 'w' in mode
            if self._attrs[attr] and not hasattr(self.__class__, attr):
                prop_func = getattr(self, '_attr_' + self._attrs[attr])
                setattr(self.__class__, attr, prop_func(attr, RD, WR))

    def clear(self):                                # close attr files
        for f in list(self._files.values()):
            f.close()
        self._files.clear()

    def fileno(self, attr=None):
        if attr is None:
            attr = (list(self._attrs) or [None])[0]
        if attr not in self._files:
            return -1
        return self._files[attr].fileno()

    def _read(self, attr, intbase=1):
        # value may be string or integer or hex
        if attr not in self._files:
            return 0 if intbase != 1 else b''
        with self._read_lock:
            self._files[attr].seek(0)
            value = self._files[attr].read().strip()
        return int(value, intbase) if intbase != 1 else value

    def _write(self, attr, data):
        if attr not in self._files:
            return 0
        if not isinstance(data, (bytes, bytearray)):
            data = str(data).encode('latin')
        with self._write_lock:
            self._files[attr].seek(0)
            self._files[attr].write(data)
        return len(data)


class SysfsGPIO(SysfsBase):
    _folder = '/sys/class/gpio'
    _attrs = {
        'value': 'number', 'direction': 'string',
        'edge': 'string', 'active_low': 'boolean'
    }

    def __init__(self, pin):
        self.pin = int(pin)
        super().__init__(op.join(self._folder, 'gpio{:d}'.format(self.pin)))
        self.export = True

    def __repr__(self):
        if self.export:
            return '<gpio{} {} edge:{} mode:{} at 0x{:x}>'.format(
                self.pin, 'HIGH' if self.value else 'LOW',
                self.edge.title(), self.direction.upper(), id(self))
        else:
            return '<gpio{} unexported at 0x{:x}>'.format(self.pin, id(self))

    def _do_export(self, value):
        if value:                   # register gpio pin if necessary
            if not self.export:
                with open(op.join(self._folder, 'export'), 'w') as f:
                    f.write(str(self.pin))
                time.sleep(0.1)     # chown from root:root to root:gpio
            self.setup()
        else:                       # gpio will be unexported if it exists
            if self.export:
                with open(op.join(self._folder, 'unexport'), 'w') as f:
                    f.write(str(self.pin))
            self.clear()

    export = property(lambda self: op.exists(self.path), _do_export)


class SysfsPWM(SysfsBase):
    _attrs = {
        'period': 'number', 'duty_cycle': 'number',
        'polarity': 'string', 'enable': 'boolean',
    }

    def __init__(self, num):
        for chip in glob.glob('/sys/class/pwm/pwmchip*'):
            try:
                base = int(re.findall(r'pwmchip(\d+)', chip)[0])
                with open(op.join(chip, 'npwm'), 'r') as f:
                    npwm = int(f.read())
                assert 0 <= (num - base) < npwm
                self.num = int(num - base)
                self._folder = chip
                break
            except Exception:
                continue
        else:
            raise ValueError('Invalid PWM channel: %s' % num)
        super().__init__(op.join(self._folder, 'pwm{:d}'.format(self.num)))
        self.export = True

    def __repr__(self):
        if self.export:
            return '<pwm{} {:.3f}Hz {:.2f}% {} at 0x{:x}>'.format(
                self.num, self.frequency, self.percentage,
                self.polarity, id(self))
        else:
            return '<pwm{} unexported at 0x{:x}>'.format(self.num, id(self))

    def _do_export(self, value):
        if value:
            if not self.export:
                with open(op.join(self._folder, 'export'), 'w') as f:
                    f.write(str(self.num))
                time.sleep(0.1)
            self.setup()
        else:
            if self.export:
                with open(op.join(self._folder, 'unexport'), 'w') as f:
                    f.write(str(self.num))
            self.clear()

    export = property(lambda self: op.exists(self.path), _do_export)

    def _set_freq(self, hz): self.period = hz and int(1e9 / hz)  # unit: ns
    def _set_pert(self, pt): self.duty_cycle = int(pt / 100 * self.period)

    def _get_freq(self):
        period = self.period
        return period and 1e9 / period

    def _get_pert(self):
        period = self.period
        return period and self.duty_cycle * 100 / period

    frequency = property(_get_freq, _set_freq)
    percentage = property(_get_pert, _set_pert)


class SysfsLED(SysfsBase):
    _folder = '/sys/class/leds'
    _attrs = {
        'brightness': 'number', 'max_brightness': None, 'trigger': None,
    }
    _new_attrs = {
        'delay_on': 'number', 'delay_off': 'number', 'gpio': 'number',
        'desired_brightness': 'number', 'duration': 'number',
        'invert': None, 'inverted': None, 'shot': 'number',
    }
    _trig_tpl = re.compile(rb'\[([\w\-]+)\]')
    _trig_attrs = {
        'timer': ['delay_on', 'delay_off'],
        'oneshot': ['delay_on', 'delay_off', 'invert', 'shot'],
        'heartbeat': ['invert'], 'backlight': ['inverted'],
        'gpio': ['desired_brightness', 'gpio', 'inverted'],
        'transient': ['duration', 'activate', 'state'],
    }

    def __init__(self, name):
        self.name = op.basename(str(name))
        super().__init__(op.join(self._folder, self.name))
        self.setup()
        self.triggers = [
            t.strip('[]') for t in self._read('trigger').decode().split(' ')
        ]
        self.max_brightness = self._read('max_brightness', 10) or 255

    def __repr__(self):
        return '<led({}) brightness: {}/{} at 0x{:x}>'.format(
            self.name, self.brightness, self.max_brightness, id(self))

    @property
    def trigger(self):
        triggers = self._read('trigger')
        found = self._trig_tpl.findall(triggers)
        return found[0] if found else triggers

    @trigger.setter
    def trigger(self, trig):
        oldlist = self._trig_attrs.get(self.trigger.decode(), [])
        if trig not in self.triggers or not self._write('trigger', trig):
            return
        newlist = self._trig_attrs.get(trig, [])
        for attr in oldlist:
            self._attrs.pop(attr, None)
        for attr in newlist:
            self._attrs[attr] = self._new_attrs.get(attr, None)
        if oldlist or newlist:
            self.setup(reopen=False)

    def _set_invert(self, value):
        if 'invert' in self._files:
            return self._write('invert', value)
        return self._write('inverted', value)  # alias

    invert = property(lambda self: self._read('invert', 10), _set_invert)


class GPIO(object):
    '''Global GPIO Class'''
    IN = constants.INPUT
    OUT = constants.OUTPUT
    # PULLUP = constants.INPUT_PULLUP
    # PULLDN = constants.INPUT_PULLDN
    HIGH = constants.HIGH
    LOW = constants.LOW
    RISING = constants.RISING
    FALLING = constants.FALLING
    BOTH = constants.CHANGE
    BOARD = constants.BOARD_SUNXI
    BCM = constants.BCM
    VERSION = 1.0

    _pin_dict = {}
    _pwm_dict = {}
    _irq_dict = {}
    _flag_interrupts = threading.Event()
    _epoll = select.epoll()

    def __init__(self):
        self._mode = self.BOARD  # default mode

    def _time_ms(self):
        return time.time() * 1000

    def _get_pin_num(self, pin, must_in_dict=False):
        try:
            p = self._mode[pin]
        except Exception:
            raise KeyError(('Invalid pin({}) or unsupported mode!\n'
                            'Reset mode and check pin num.').format(pin))
        if must_in_dict and (p not in self._pin_dict):
            raise NameError(('Pin {} is not setup yet, please run'
                             '`GPIO.setup({}, state)` first!'
                             '').format(pin, pin))
        return p

    def _listify(self, *args, **kwargs):
        # convert all args to list and pad them to a certain length
        args = list(args)  # tuple to list
        for i, arg in enumerate(args):
            if not isinstance(arg, list):
                if isinstance(arg, tuple):
                    args[i] = list(arg)
                else:
                    args[i] = [arg]
            if 'padlen' in kwargs:
                padlen = kwargs['padlen']
                if len(args[i]) < padlen:
                    args[i] += [args[i][-1]] * (padlen - len(args[i]))
                elif len(args[i]) > padlen:
                    args[i] = args[i][:padlen]
        if len(args) == 1:
            return args[0]
        return args

    def setup(self, pin, state, initial=None):
        # listify pin for multichannel operation
        pins = [self._get_pin_num(p) for p in self._listify(pin)]
        # pad state_list and initial_list in case someone
        # want to setup more than one pin at one time
        states, initials = self._listify(state, initial, padlen=len(pin))

        # register all pins and init them
        for p, s, i in zip(pins, states, initials):
            # if s == self.PULLUP:
            #     s, i = self.IN, self.HIGH
            # elif s == self.PULLDN:
            #     s, i = self.IN, self.LOW
            if s not in [self.IN, self.OUT]:
                raise ValueError('Invalid state: {}!'.format(s))
            if p not in self._pin_dict:
                self._pin_dict[p] = SysfsGPIO(p)
                self._pin_dict[p].export = True
            self._pin_dict[p].direction = s
            if s == self.OUT and i in [self.HIGH, self.LOW]:
                self._pin_dict[p].value = i

    def input(self, pin):
        # single channel value
        if type(pin) not in [list, tuple]:
            return self._pin_dict[
                self._get_pin_num(pin, must_in_dict=True)].value
        # multichannel values
        pins = [self._get_pin_num(pin, must_in_dict=True)
                for p in self._listify(pin)]
        return [self._pin_dict[p].value for p in pins]

    def output(self, pin, value):
        pins = [self._get_pin_num(p, must_in_dict=True)
                for p in self._listify(pin)]
        values = self._listify(value, padlen=len(pins))
        for p, v in zip(pins, values):
            if v not in [True, False, self.HIGH, self.LOW]:
                raise ValueError('Invalid value: {}'.format(v))
            self._pin_dict[p].value = int(v)

    def cleanup(self, pin=None):
        if pin is None:
            pins = list(self._pin_dict.keys())  # py2&py3 compatiable
        else:
            pins = [self._get_pin_num(p) for p in self._listify(pin)]
        for p in pins:
            pin = self._pin_dict.pop(p, None)
            if pin:
                pin.export = False
            pwm = self._pwm_dict.pop(p, None)
            if pwm:
                pwm.clear()
            irq = self._irq_dict.pop(p, None)
            if irq:
                irq['flag_stop'].set()
                irq['flag_triggered'].clear()

    def enable_interrupts(self):
        if hasattr(self, '_thread_irq'):
            self._flag_interrupts_pause.clear()
            return
        self._thread_irq = threading.Thread(target=self._handle_interrupts)
        self._thread_irq.setDeamon(True)
        self._thread_irq.start()

    def disable_interrupts(self):
        self._flag_interrupts_pause.set()

    def close_interrupts(self):
        self._flag_interrupts_stop.set()

    def _recheck_bounce(self, p, bouncetime):
        time.sleep(bouncetime / 1000.0)
        edge = self._pin_dict[p].edge
        value = self._pin_dict[p].value
        rise = (edge == self.RISING and value == self.HIGH)
        fall = (edge == self.FALLING and value == self.LOW)
        return (rise or fall)

    def _handle_interrupts(self):
        while not self._flag_interrupts_stop.isSet():
            self._flag_interrupts_pause.wait()
            rst = self._epoll.poll(timeout=1)
            if not rst:
                continue
            for fd, event in rst:
                for p in self._irq_dict:
                    if self._irq_dict[p]['fd'] == fd:
                        bt = self._irq_dict[p]['bouncetime']
                        break
                if bt and not self._recheck_bounce(p, bt):
                    continue
                self._irq_dict[p]['interrupted'].set()
                for c in self._irq_dict[p]['callbacks']:
                    try:
                        c(self._irq_dict[p]['pin_name'])
                    except Exception:
                        pass
        print('[GPIO interrupts handler] shutdown')

    def add_event_detect(self, pin, edge, func=None, bouncetime=None):
        p = self._get_pin_num(pin, must_in_dict=True)
        if edge not in [self.RISING, self.FALLING, self.BOTH]:
            raise ValueError('Invalid edge: {}'.format(edge))
        if p in self._irq_dict:
            raise NameError(('Pin {} is already been attached to an interrupt '
                             'on {} edge, if you want to reset it, please run '
                             '`GPIO.remove_event_detect({})` first'
                             '').format(pin, self._pin_dict[p].edge, pin))
        self._pin_dict[p].direction = 'in'
        self._pin_dict[p].edge = edge
        fd = self._pin_dict[p].fileno('value')
        self._irq_dict[p] = {
            'fd': fd, 'interrupted': threading.Event(), 'pin_name': pin,
            'bouncetime': bouncetime or 0,
            'callbacks': self._listify(func) or []
        }
        self._epoll.register(fd, select.EPOLLPRI | select.EPOLLET)

    def remove_event_detect(self, pin):
        p = self._get_pin_num(pin, must_in_dict=True)
        if p in self._irq_dict:
            self._epoll.unregister(self._irq_dict[p]['fd'])
            self._irq_dict.remove(p)

    def add_event_callback(self, pin, callback):
        p = self._get_pin_num(pin, must_in_dict=True)
        if p not in self._irq_dict:
            raise NameError(('Pin {} is not initialized with edge yet, please '
                             'run `GPIO.add_event_detect({}, edge)` first'
                             '').format(pin, pin))
        self._irq_dict[p]['callbacks'] += self._listify(callback)

    def wait_for_edge(self, pin, edge, timeout=constants.FOREVER_ms):
        p = self._get_pin_num(pin, must_in_dict=True)
        if edge not in [self.RISING, self.FALLING, self.BOTH]:
            raise ValueError('Invalid edge: {}'.format(edge))
        if p in self._irq_dict and edge != self._pin_dict[p].edge:
            raise NameError(('Pin {} is already been attached to an interrupt '
                             'on {} edge, if you want to reset it, please run '
                             '`GPIO.remove_event_detect({})` first'
                             '').format(pin, self._pin_dict[p].edge, pin))
        start = self._time_ms()
        while not self._irq_dict[p]['interrupted'].isSet():
            if (self._time_ms() - start) > timeout:
                return None
            time.sleep(1.0/10)  # sensibility: refresh 10 times per second
        self._irq_dict[p]['interrupted'].clear()
        return pin

    def setmode(self, mode):
        self._mode = mode

    def getmode(self):
        return self._mode

    def PWM(self, pin, frequency=None):
        '''
        if pin is already initialized before:
            if frequency provided:
                update frequency and return PWM instance
            else:
                return PWM instance with no operation
        else:
            if frequency provided:
                initialize with this frequency and return PWM instance
            else:
                initialize with 1Hz(default) and return PWM instance
        '''
        pins = [self._get_pin_num(p) for p in self._listify(pin)]
        frequencys = self._listify(frequency, padlen=len(pins))
        return_list = []
        for p, f in zip(pins, frequencys):
            if p not in self._pwm_dict:
                self.setup(self._listify(pin)[pins.index(p)], self.OUT)
                if f is None:
                    raise NameError(('PWM on pin {} is not initialized yet, '
                                     'please provide pin num and freq'
                                     '').format(p))
                self._pwm_dict[p] = _PWM(self._pin_dict[p], f)
            elif f:
                self._pwm_dict[p].ChangeFrequency(f)
            return_list.append(self._pwm_dict[p])
        if len(pins) == 1:
            return return_list[0]
        else:
            return return_list


class _PWM:
    '''Software PWM used by class GPIO. SysfsPWM is more suggested.'''
    def __init__(self, sysfsgpio, frequency):
        self._sysfsgpio = sysfsgpio
        self.ChangeFrequency(frequency)
        self._flag_pause = threading.Event()
        self._flag_stop = threading.Event()
        self._t = threading.Thread(target=self._pwm)
        self._t.setDeamon(True)
        self._t.start()

    def _pwm(self):
        while not self._flag_stop.isSet():
            self._flag_pause.wait()
            self._sysfsgpio.value = 1
            time.sleep(self._high_time)
            self._sysfsgpio.value = 0
            time.sleep(self._low_time)

    def start(self, dc):
        self.ChangeDutyCycle(dc)
        self._flag_pause.set()

    def stop(self):
        self._flag_pause.clear()

    def ChangeFrequency(self, frequency):
        if frequency <= 0:
            raise ValueError('Invalid frequency: {}'.format(frequency))
        self._frequency = frequency
        self._period = 1.0/frequency
        if hasattr(self, 'dc'):
            self._high_time = self._dc * self._period
            self._low_time = (1 - self._dc) * self._period

    def ChangeDutyCycle(self, dc):
        if dc > 100 or dc < 0:
            raise ValueError('Invalid duty cycle: {}'.format(dc))
        self._dc = float(dc) / 100
        self._high_time = dc * self._period
        self._low_time = (1 - dc) * self._period

    def clear(self):
        self.stop()
        self._flag_stop.set()


from . import arduino                                           # noqa: E402

__all__ = ['arduino', 'constants', 'GPIO', 'SysfsGPIO']
