11 Feb 2014

Hacking a Disco Ball - Part 2

In my last blog post, I gave you an overview of the Disco Ball project. A couple weeks ago I received the printed circuit boards from OSH Park. So here are a couple of pictures of the boards and assembly :





After soldering the circuit, I was really relieved that everything worked perfectly. The first thing I did was to test if I was able to identify the Propeller chip with the programming software and load a program in the EEPROM memory. Once done, I soldered the 3 MOSFET LED drivers and fired a 3.3V signal on their gates. The LEDs of the disco ball turned on as normal. I was a bit stressed with the last part, driving the 120V motor with the Triac. I tested the connections on the breadboard before doing the PCB, but there is always a chance that something is not correct. I closed my eyes when I plugged the 120V in... no explosion or blue fume, phew :)

Software

Originally, I wrote a command interpreter in Python that compile a sequence file and send the compiled sequence to the microcontroller on the PCB. The compiled sequence was stored in the unused part of the boot EEPROM and the main program purpose was to run the loaded sequence. The Python code is shown below :

# -*- coding: utf-8 -*-
"""
    This program is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with this program.  If not, see <http://www.gnu.org/licenses/>.
"""
import sys, re
#Pyserial needed to connect to the printed circuit board
try:
    import serial
except:
    sys.exit(COM_LIBRARY_UNDEFINED)

#This constant value needs to fit with the one in the microcontroller, it won't work otherwise    
MAX_COMMANDS = 1024 
    
#Error codes, ease the debugging process and script interpreting troubleshooting
OK = 0                      #Everything is OK
COM_UNDEFINED = 1           #COM Port undefined on the first line
COM_ERROR = 2               #Unable to connect to the board
SYNTAX_ERROR = 3            #Incorrect command syntax
COLOR_ERROR = 4             #bad color range (0-100)
DELAY_ERROR = 5             #bad delay range
LABEL_NOT_FOUND_ERROR = 6   #unable to find loop label
LABEL_DUPLICATE_ERROR = 10  #loop lable duplicate
SCRIPT_NOT_FOUND = 7        #wrong script path
COM_LIBRARY_UNDEFINED = 8   #pyserial not installed on the machine
ARGS_ERROR = 9              #need path as command line argument

#we need the disco light sequence script path as first argument
try:
    script_path = sys.argv[1]
except:
    sys.exit(ARGS_ERROR)

#read the script and put it in a list    
try:
    script_file = open(script_path)
    raw_script = script_file.readlines()
    script_file.close()
except:
    sys.exit(SCRIPT_NOT_FOUND)

#only keep the important stuff (remove comments)    
def strip(line):
    if line[0] == '[':
        index = line.find(']')
        if index==-1:
            return
        else:
            return line[1:index].lower()

#list comprehension to execute the above function on all the lines
cleaned_script = [strip(line) for line in raw_script if strip(line)]
print "Clean script:\n"+str(cleaned_script)

#find all the labels
labels = {}
index = -1 #Strip the COM Port line
for command in cleaned_script:
    index += 1
    if command[0] == '.':
        labels[command[1:]] = index

#look for duplicates        
unique_labels = tuple(set(labels))
if tuple(labels) != unique_labels:
    sys.exit(LABEL_DUPLICATE_ERROR)

print "Labels:\n"+str(labels)

#regex patterns to parse commands
pat_com = re.compile("(com\d+)")
pat_col = re.compile("([rgb]):(\d+)")
pat_del = re.compile("\$(\d+)")
pat_jmp = re.compile("jmp\.(.+)?(\d+)")
pat_jmpINF = re.compile("jmp\.(.+)")
pat_lbl = re.compile("\.(.+)")

#get the serial COM Port and set the link speed
m = pat_com.match(cleaned_script[0])
if not m:
    sys.exit(COM_UNDEFINED)
else:
    com_port = m.group(1)
    print "Port: "+str(com_port)
try:
    BAUDRATE = 115200
    ser = serial.Serial(com_port,BAUDRATE)
except:
    sys.exit(COM_ERROR)



#The next lines parse all the commands and generate
#   a list of parsed command in an easy to use way
commands = []
for command in cleaned_script[1:]:
    m = pat_col.match(command)
    if m:
        color = m.group(1)
        brightness = m.group(2)
        if int(brightness) > 100:
            print brightness
            sys.exit(COLOR_ERROR)
        commands.append(("COLOR",color,brightness))
        continue
    m = pat_del.match(command)
    if m:
        delay = m.group(1)
        if int(delay) > 100000:
            sys.exit(DELAY_ERROR)
        delay = int(delay)
        b0 = delay >>  0 & 0b11111111
        b1 = delay >>  8 & 0b11111111
        b2 = delay >> 16 & 0b11111111
        b3 = delay >> 24 & 0b11111111
        commands.append(("DELAY",b0,b1,b2,b3))
        continue
    m = pat_jmp.match(command)
    if m:
        label = m.group(1)
        label = label[:-1] # Strip the question mark
        times = m.group(2)
        if not label in labels:
            sys.exit(LABEL_NOT_FOUND_ERROR)
        commands.append(("REPEAT",labels[label],times))
        continue
    m = pat_jmpINF.match(command)
    if m:
        label = m.group(1)
        if not label in labels:
            sys.exit(LABEL_NOT_FOUND_ERROR)
        commands.append(("LOOP FOREVER",labels[label]))
        continue
    m = pat_lbl.match(command)
    if m:
        label = m.group(1)
        commands.append(("LABEL",labels[label]))
        continue
    if command == "stop":
        commands.append(("MOTOR",False))
        continue
    if command == "start":
        commands.append(("MOTOR",True))
        continue
    sys.exit(SYNTAX_ERROR)

print "Commands:\n"+str(commands)

#Patch for the ser.write : we want to send 
#   the decimal number as a byte
commands_sent = 0
def write(num):
    global commands_sent
    commands_sent += 1
    if num != 0:
        pass#print num
    ser.write(chr(int(num)))

#Special read : read the bytes as string and when there is a newline, it marks the end of string
#Return all the strings in a array (tuple) stop reading when null character received chr(0)
def readLineFeed():
    data = []
    buffer = []
    while(True):
        value = ser.read()
        if value == "\n": #LineFeed
            data.append("".join(buffer))
            buffer = [] #Flush the buffer
        elif value == chr(0):
            break
        else:
            buffer.append(value)
    return tuple(data)

def readINF():
    while(True):
        value = ser.read()
        print value
#After the commands are parsed, we send 
#   serial commands to the controller PCB
for command in commands:    
    if command[0] == "COLOR":
        if command[1] == 'r':
            write(1)
            write(command[2]) #brightness
        elif command[1] == 'g':
            write(2)
            write(command[2]) #brightness
        elif command[1] == 'b':
            write(3)
            write(command[2]) #brightness
            
    elif command[0] == "MOTOR":
        if command[1]:
            write(4) #Start
        else:
            write(5) #Stop
            
    elif command[0] == "DELAY":
        write(6)
        write(command[1]) #delay b0
        write(command[2]) #delay b1
        write(command[3]) #delay b2
        write(command[4]) #delay b3
        
    elif command[0] == "LABEL":
        write(7)
        write(command[1])
        
    elif command[0] == "REPEAT":
        write(8)
        write(command[1]) #label
        write(command[2]) #times

    elif command[0] == "LOOP FOREVER":
        write(9)
        write(command[1]) #label

if commands_sent != MAX_COMMANDS:
    print "Before looping: "+str(commands_sent)
    to_max = MAX_COMMANDS-commands_sent
    for index in range(to_max):
        write('0')
    print "After looping: "+str(commands_sent)

   
#Print the data echoed (debug)
for data in readLineFeed():
    print(data)
    
#Everything should be fine if we get there   
ser.close()
print "Script OK!\n"
sys.exit(OK)

In the end, my friend and I decided to control the board with a Raspberry Pi, by using a serial port on each side. The interface is really simple : you have commands to dim the 3 LEDs from 0 to 100% and a command to turn the motor on and off. The reason for the Raspberry Pi is that with an internet connected device, we can easily make a simple webpage to control the board with our mobile phones.

I love Python, so for the web framework on the Pi I decided to use Flask. For the HTML code, I used jQuery Mobile for a good page rendering on mobile devices. There is an excellent jQuery Mobile tutorial on the w3schools website. To make the page interactive, I used a bit of AJAX with jQuery. I really should do a little tutorial about all that in another article. It is really cool to be able to control the IO of the Pi with a webpage! Pictures of the webpage:



Here is a YouTube video showing the Disco Ball in action




Finally, I did not gave too much explanation about the software side of the project (webpage source and microcontroller programs)... I might just be a little bit lazy :) If someone is interested, I can send my source code. Thanks for reading!

3 comments:

  1. Hey cool. Would like to see a video of the final result in action! Was also wondering if you needed to snub the triac - I've had problems with them sticking on in the past.

    ReplyDelete
    Replies
    1. I did not need any snubbing components for the triac. I tested it first on a breadboard and it worked well. A snubber circuit is used when the load is strongly inductive, meaning that there is a phase angle between the voltage and current. In this situation, the triac could trigger itself because there is still voltage across it even if there is no more current. I'll try to update the post with a video :)

      Delete
  2. This comment has been removed by the author.

    ReplyDelete