"""
Pencompy is a library for controlling Pencom banks of relays.  The
connection is done serially through an RS232 to Ethernet adaptor (NPort)
that implements TCP sockets.

the state for a relay isn't updated until a response from the board or a
polling request occurs.

Michael Dubno - 2018 - New York
"""

from threading import Thread
import time
import socket
import select
import logging

_LOGGER = logging.getLogger(__name__)

RELAYS_PER_BOARD = 8

BOARD_NUM = lambda b: chr(ord('A')+b)
POLLING_FREQ = 2.

class Pencompy(Thread):
    """Interface with a Pencom relay controller."""
    # pylint: disable=too-many-instance-attributes
    _polling_thread = None
    _socket = None
    _running = False
    polling_board = 0

    def __init__(self, host, port, boards=1, callback=None):
        Thread.__init__(self, target=self)
        self._host = host
        self._port = port
        self._callback = callback

        self.boards = boards

        self._connect()
        if self._socket == None:
            raise ConnectionError("Couldn't connect to '%s:%d'" % (host, port))
        self._polling_thread = Polling(self, POLLING_FREQ)
        self._polling_thread.start()
        self.start()

    def _connect(self):
        self._states = [[None for _ in range(RELAYS_PER_BOARD)] for _ in range(self.boards)]
        try:
            self._socket = socket.create_connection((self._host, self._port))
        except (BlockingIOError, ConnectionError, TimeoutError) as error:
            _LOGGER.error("Connection: %s", error)

    def set(self, board, addr, state):
        """Turn a relay on/off."""
        if 0 <= board < self.boards and 0 <= addr < RELAYS_PER_BOARD:
            self.send('%s%s%d' % (BOARD_NUM(board), 'H' if state else 'L', addr+1))
        else:
            _LOGGER.error('SET Board or Addr out of range: %s, %s', board, addr)

    def get(self, board, addr):
        """Get the relay's state."""
        if 0 <= board < self.boards and 0 <= addr < RELAYS_PER_BOARD:
            return self._states[board][addr]
        else:
            _LOGGER.error('GET Board or Addr out of range: %s, %s', board, addr)
        return None 

    def _update_state(self, board, addr, new_state):
        old_state = self._states[board][addr]
        if old_state != new_state:
            if self._callback:
                self._callback(board, addr, old_state, new_state)
            self._states[board][addr] = new_state

    def send(self, command):
        """Send data to the relay controller."""
        try:
            self._socket.send((command+'\r').encode('utf8'))
            return True
        except (ConnectionError, AttributeError):
            self._socket = None
            return False

    def run(self):
        self._running = True
        data = ''
        while self._running:
            if self._socket == None:
                time.sleep(POLLING_FREQ)
                self._connect()
            else:
                try:
                    readable, _, _ = select.select([self._socket], [], [], POLLING_FREQ)
                    if len(readable) != 0:
                        byte = self._socket.recv(1)
                        if byte == b'\r':
                            self._processReceivedData(data.strip())
                            data = ''
                        else:
                            data += byte.decode('utf-8')
                except (ConnectionError, AttributeError):
                    self._socket = None

    def _processReceivedData(self, data):
        try:
            if len(data) > 0:
                bits = int(data)
                mask = 0x0001
                for relay in range(RELAYS_PER_BOARD):
                    self._update_state(self.polling_board, relay, (bits & mask) != 0)
                    mask <<= 1
        except ValueError:
            _LOGGER.error("Received weird data: %s", data)

    def close(self):
        """Close the connection."""
        self._running = False
        if self._socket:
            time.sleep(POLLING_FREQ)
            self._socket.close()
            self._socket = None


class Polling(Thread):
    """Thread that asks for each board's status."""

    def __init__(self, pencom, delay):
        super(Polling, self).__init__()
        self._pencom = pencom
        self._delay = delay

    def run(self):
        for board in range(self._pencom.boards):
            self._pencom.polling_board = board
            self._pencom.send('%sR0' % BOARD_NUM(board))
            time.sleep(self._delay)
