<filename>BlinkyTape.py
# -*- coding: utf-8 -*-

"""BlinkyTape Python communication library.

  This code assumes stock serialLoop() in the firmware.

  Commands are issued in 3-byte blocks, with pixel data
  encoded in RGB triplets in range 0-254, sent sequentially
  and a triplet ending with a 255 causes the accumulated pixel
  data to display (a show command).

  Note that with the stock firmware changing the maximum brightness
  over serial communication is impossible.
"""

import serial
import os.path
import subprocess
import re
from time import sleep

import time
from random import randint

# For Python3 support- always run strings through a bytes converter
import sys
if sys.version_info < (3,):
    def encode(x):
        return x
else:
    import codecs
    def encode(x):
        return codecs.latin_1_encode(x)[0]


class BlinkyTape(object):
    min_colour_val = 0
    max_colour_val = 254

    def __init__(self, port=None, ledCount=60, buffered=True):
        """Creates a BlinkyTape object and opens the port.

        Parameters:
          port
            Required, port name as accepted by PySerial library:
            http://pyserial.sourceforge.net/pyserial_api.html#serial.Serial
            It is the same port name that is used in Arduino IDE.
            Ex.: COM5 (Windows), /dev/ttyACM0 (Linux).
          ledCount
            Optional, total number of LEDs to work with,
            defaults to 60 LEDs. The limit is enforced and an
            attempt to send more pixel data will throw an exception.
          buffered
            Optional, enabled by default. If enabled, will buffer
            pixel data until a show command is issued. If disabled,
            the data will be sent in byte triplets as expected by firmware,
            with immediate flush of the serial buffers (slower).

        """
        self.port = self.__identify_port(port)
        print("port=%s", self.port)
        self.ledCount = ledCount
        self.position = 0
        self.buffered = buffered
        self.buf = ""
        self.serial = serial.Serial(self.port, 115200)
        self.show()  # Flush any incomplete data

    def __identify_port(self, port=None):
        if port is not None:
            return port
        elif os.name == 'posix': # covers all *nix machines, including Linux?
            if not os.path.exists( '/dev/serial') or not os.path.exists('/dev/serial/by-id'):
                raise RuntimeError("Assuming this is a *nix machine, cannot find the device path for BlinkyTape via 'ls -l /dev/serial/by-id'. Is it connected?")
            ls_l_output = subprocess.check_output(['ls', '-l', '/dev/serial/by-id'])
            m = re.search( 'BlinkyTape\S+\s+->\s+\S+(/tty\S+)', ls_l_output)
            if m == None:
                raise RuntimeError("Assuming this is a *nix machine, cannot find the device path for BlinkyTape via 'ls -l /dev/serial/by-id'. Is it connected?")
            device_path = '/dev' + m.group(1)
            return device_path
        elif os.name == 'nt': # covers all windows machines?
            cmd = "wmic path Win32_SerialPort Where 'Caption like \"%" + "BlinkyTape%\"' Get DeviceID" # Clumsy, but can't work out how else to avoid %B going weird
            com_name_output = subprocess.check_output(cmd)
            m = re.search( '(COM\d+)', com_name_output)
            if m == None: # but in Windows 10, the name BlinkyTape does not seem to appear, so just look for any device at all. Assuming this might fail if you have more than one device attached. Sorry. I hate the wmic thing.
                #cmd2 = "wmic path Win32_SerialPort Get DeviceID"
                cmd2 = "wmic path Win32_SerialPort Where 'PNPDeviceID like \"%" + "USB%\"' Get DeviceID"
                com_name_output2 = subprocess.check_output(cmd2)
                m = re.search( '(COM\d+)', com_name_output2)
            if m == None:
                raise RuntimeError("Assuming this is a Windows machine, cannot find a matching COM port for BlinkyTape. Is it connected?")
            com_name = m.group(1)
            return com_name
        else:
            raise RuntimeError("Cannot establish what kind OS is running, so don't know where to look for the BlinkyTape connection.")

    def send_list(self, colors):
        if len(colors) > self.ledCount:
            raise RuntimeError("Attempting to set pixel outside range!")
        for r, g, b in colors:
            self.sendPixel(r, g, b)
        self.show()

    def colour_chr(self, n):
        if n < self.min_colour_val:
            n = self.min_colour_val
        elif n > self.max_colour_val:
            n = self.max_colour_val
        return chr(n)

    def send_list(self, colors):
        data = ""
        for r, g, b in colors:
            data += self.colour_chr(r) + self.colour_chr(g) + self.colour_chr(b)
        self.serial.write(encode(data))
        self.show()

    def sendPixel(self, r, g, b):
        """Sends the next pixel data triplet in RGB format.

        Values are clamped to 0-254 automatically.

        Throws a RuntimeException if [ledCount] pixels are already set.
        """
        data = self.colour_chr(r) + self.colour_chr(g) + self.colour_chr(b)
        if self.position < self.ledCount:
            if self.buffered:
                self.buf += data
            else:
                self.serial.write(encode(data))
                self.serial.flush()
            self.position += 1
        else:
            raise RuntimeError("Attempting to set pixel outside range!")

    def show(self):
        """Sends the command(s) to display all accumulated pixel data.

        Resets the next pixel position to 0, flushes the serial buffer,
        and discards any accumulated responses from BlinkyTape.
        """
        control = chr(255)
        if self.buffered:
            # Fix an OS X specific bug where sending more than 383 bytes of data at once
            # hangs the BlinkyTape controller. Why this is???
            # TODO: Test me on other platforms
            CHUNK_SIZE = 300

            self.buf += control
            for i in range(0, len(self.buf), CHUNK_SIZE):
                self.serial.write(encode(self.buf[i:i+CHUNK_SIZE]))
                self.serial.flush()

            self.buf = ""
        else:
            self.serial.write(encode(control))
        self.serial.flush()
        self.serial.flushInput()  # Clear responses from BlinkyTape, if any
        self.position = 0

    def displayColor(self, r, g, b):
        """Fills [ledCount] pixels with RGB color and shows it."""
        for i in range(0, self.ledCount):
            self.sendPixel(r, g, b)
        self.show()

    def resetToBootloader(self):
        """Initiates a reset on BlinkyTape.

        Note that it will be disconnected.
        """
        self.serial.setBaudrate(1200)
        self.close()

    def close(self):
        """Safely closes the serial port."""
        self.serial.close()

    def clear_all(self):
        for x in range(self.ledCount):
            self.sendPixel(0, 0, 0)
        self.show()


# Example code

if __name__ == "__main__":

    import glob
    import optparse

    parser = optparse.OptionParser()
    parser.add_option("-p", "--port", dest="portname",
                      help="serial port (ex: /dev/ttyUSB0)", default=None)
    parser.add_option("-c", "--ledcount", dest="ledcount",
                      help="number of LEDs attached", type="int", default=60)
    parser.add_option("-b", action="store_true", default="True", dest="buffered")
    parser.add_option("-u", action="store_false", dest="buffered")
    parser.add_option("--off", action="store_false", default="False", dest="disablelights")

    (options, args) = parser.parse_args()

    bt = BlinkyTape(options.portname, options.ledcount, options.buffered)

    while True:
        led_count     = 60
        rgb_max       = 100
        pixel_half_on = [rgb_max, 0, 0]
        pixel_off     = [0, 0, 0]

        def shuttle_extend(bb, n=1,step=2):
            for i in range(n):
                bb.clear_all()
                for s in range(0,led_count,step):
                    pixel_random_on = [randint(30,rgb_max), randint(0,10), randint(0,10)]
                    for t in range(s+1):
                        pixel_list = map(lambda x: pixel_random_on if x<=t else pixel_off, range(led_count))
                        bb.send_list(pixel_list)
                    for t in range(s, -1, -1):
                        pixel_list = map(lambda x: pixel_random_on if x<=t else pixel_off, range(led_count))
                        bb.send_list(pixel_list)
            bb.clear_all()

        def kitt_eye_pixel(x,w,s): # kitt's eye is w pixels wide and starts at position s
            return pixel_half_on if (x>=s and x<(s+w)) else pixel_off

        def kitt_eye(bb, w=5): # kitt's eye is w pixels wide
            bb.clear_all()
            for s in range(led_count-w):
                pixel_list = map(lambda x: kitt_eye_pixel(x,w,s), range(led_count))
                bb.send_list(pixel_list)

            for s in range(led_count-w, -1, -1):
                pixel_list = map(lambda x: kitt_eye_pixel(x,w,s), range(led_count))
                bb.send_list(pixel_list)
            bb.clear_all()

        def kitt_eye_sequence(bb, step=2):
            for s in range(0, led_count -1, step):
                kitt_eye(bb, led_count - s)

            for s in range(led_count -1, -1, - step):
                kitt_eye(bb, led_count - s)


        def impulse(bb, max_loops=2000):
            bb.clear_all()
            v0 = 0.5 # keep v < 1.0
            h0 = 0
            max_h = 60
            g  = -0.0025
            cor = 0.99

            loop = 0
            v=v0
            h=h0
            while loop<=max_loops:
                loop += 1
                h = h + v
                v = v + g
                if h<0:
                    v = -v * cor
                    h = h + v
                    v = v + g
                elif v>max_h:
                    h=max_h
                    v=0

                pixel_list = map(lambda x: pixel_half_on if (led_count-int(h))==x else pixel_off, range(led_count))
                bb.send_list(pixel_list)


        def multiple_impulses(bb, max_loops=2000, num_particles=5):
            bb.clear_all()
            v0 = 0.5 # keep v < 1.0
            h0 = 0
            max_h = 60
            g  = -0.0025
            cor = 0.99
            line =  [None] * led_count # to hold all the particles and empty spaces: [ None, None, [vel, height], None, ..., None, [vel, height], ...]

            for x in range(num_particles): # prime it with particles, each with [velocity,height]
                h = randint(0,max_h-1)
                line[h] = [0,h]

            loop = 0
            skip_next = False
            while loop <= max_loops:
                loop += 1
                for i in range(len(line)): # scan from low to high, adjusting one pixel at a time.
                    if line[i] is None or skip_next:
                        skip_next = False
                    else:
                        v = line[i][0]
                        h = line[i][1]
                        h = h + v
                        v = v + g
                        if h<0: # bounce off bottom
                            v = -v * cor
                            h = h + v
                            v = v + g
                        elif h>max_h: # stop at top
                            v = 0
                            h = max_h
                        elif (int(h) != i) and (line[int(h)] is not None): # is a collsion
                            other_i = int(h)
                            h = line[i][1] # remove prev vel contrib to ensure stay in same pixel
                            v = line[i][0]
                            # swap vels (simple physics of direct elastic collision between two identical objects)
                            v, line[other_i][0] = line[other_i][0] * cor, v * cor

                        vh = [v,h]
                        if int(h) == i: # stays in same pixel
                            line[i] = vh
                        elif int(h) < i: # moves down
                            line[i-1] = vh
                            line[i] = None
                        else:             # moves up
                            line[i+1] = vh
                            line[i] = None
                            skip_next = True # don't recalc its position in this sweep
                pixel_list = map(lambda x: pixel_half_on if (x is not None) else pixel_off, reversed(line))
                bb.send_list(pixel_list)

        if options.disablelights:
            bt.clear_all()
        else:
            shuttle_extend(bt)
