# remote.py

# CircuitPython - Remote Communication

# This module provides a class for communicating with an outside system via the
# USB serial port.  This can be a user, a control program, or a proxy script
# communicating with a remote system.

# ----------------------------------------------------------------
# Import the standard Python math library and time functions.
import math, time, sys

# Import the runtime for checking serial port status.
import supervisor

# ----------------------------------------------------------------
class RemoteSerial:
    def __init__(self):
        """Communication manager for receiving messages over the USB serial port."""

        # Keep a mutable buffer to accumulate input lines.  This will incur less
        # allocation overhead than constantly extending strings.
        self.line_buffer = bytearray(80)
        self.next_char = 0

        # Keep a dictionary mapping input tokens to callback functions.
        self.dispatch_table = {}

        # Callback entry for unrecognized tokens.
        self.default_handler = None

    def add_handler(self, token, function):
        self.dispatch_table[token] = function

    def add_default_handler(self, function):
        self.default_handler = function

    def poll(self, elapsed):
        """Polling function to be called as frequently as possible from the event loop
        to read and process new samples.

        :param elapsed: nanoseconds elapsed since the last cycle.
        """

        # Check the serial input for new remote data.
        while supervisor.runtime.serial_bytes_available:
            char = sys.stdin.read(1)

            # if an end-of-line character is received, process the current line buffer
            if char == '\n':
                # convert the valid characters from bytearray to a string
                line_string = self.line_buffer[0:self.next_char].decode()

                # pass string through the dispatch system
                self.process_line(line_string)

                # reset the buffer
                self.next_char = 0

            else:
                # if the input buffer is full, increase its length
                if self.next_char == len(self.line_buffer):
                    self.line_buffer += bytearray(80)

                # insert the new character into the buffer
                self.line_buffer[self.next_char] = ord(char)
                self.next_char += 1


    def process_line(self, line):
        """Process a string representing a line of remote input including an initial
        endpoint token and optional arguments separated by whitespace."""
        tokens = line.split()
        if len(tokens) > 0:
            key_token = tokens[0]

            # convert arguments into Python values
            args = [self.convert_arg(arg) for arg in tokens[1:]]

            # Look for a dispatch callback.
            callback = self.dispatch_table.get(key_token)
            if callback is not None:
                # Call the method.  Note: the Python *args form will pass a list
                # as individual arguments.  Any return values are ignored; this
                # is assumed to be an imperative function called for side effects.
                try:
                    callback(*args)
                except Exception as e:
                    print("Warning:", e)
            elif self.default_handler is not None:
                # If the key token isn't found in the table, try the unknown message handler.
                try:
                    self.default_handler(key_token, *args)
                except Exception as e:
                    print("Warning:", e)


    def convert_arg(self, arg):
        """Attempt converting a string into either an integer or float, returning the
        Python value.  If conversion fails, returns the argument."""
        try:
            return int(arg)
        except ValueError:
            try:
                return float(arg)
            except ValueError:
                return arg
