diff --git a/.gitignore b/.gitignore index d4a80c8..18415e5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ .DS_Store *.pyc - +*~ diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/base.py b/api/base.py new file mode 100644 index 0000000..eee6891 --- /dev/null +++ b/api/base.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python +""" +Base API classes for Holiday interfaces + +Copyright (c) 2013 Justin Warren +License: MIT (see LICENSE for details) +""" + +__author__ = "Justin Warren" +__version__ = '0.02-dev' +__license__ = "MIT" + +import os +import logging + +# Set up logging +log = logging.getLogger('base') +handler = logging.StreamHandler() +handler.setFormatter(logging.Formatter("%(asctime)s: %(name)s [%(levelname)s]: %(message)s")) +log.addHandler(handler) +log.setLevel(logging.DEBUG) + +class HolidayBase(object): + """ + The base Holiday class for the main API + """ + NUM_GLOBES = 50 + + # Local representation of globe state + globes = [ (0,0,0), ] * NUM_GLOBES + + def setglobe(self, globenum, r, g, b): + # FIXME: This should be (self, globenum, color) where color is + # a tuple of (r g, b). + """Set a globe""" + if (globenum < 0) or (globenum >= self.NUM_GLOBES): + return + self.globes[globenum] = (r, g, b) + + def fill(self, r, g, b): + """Sets the whole string to a particular colour""" + self.globes = [ (int(r), int(g), int(b)), ] * self.NUM_GLOBES + #for e in self.globes: + # e[0] = int(r) + # e[1] = int(g) + # e[2] = int(b) + + def getglobe(self, globenum): + """Return a tuple representing a globe's RGB color value""" + if (globenum < 0) or (globenum >= self.NUM_GLOBES): + # Fail hard, don't ignore errors + raise IndexError("globenum %d does not exist", globenum) + return self.globes[globenum] + + def set_pattern(self, pattern): + """ + Set the entire string in one go + """ + if len(pattern) != self.NUM_GLOBES: + raise ValueError("pattern length incorrect: %d != %d" % ( len(pattern), self.NUM_GLOBES) ) + self.globes = pattern[:] + + def chase(self, direction=True): + """ + Rotate all globes around one step. + @param direction: Direction to rotate: up if True, down if False + """ + if direction: + # Rotate all globes around by one place + oldglobes = self.globes[:] + self.globes = oldglobes[1:] + self.globes.append(oldglobes[0]) + pass + else: + oldglobes = self.globes[:] + self.globes = oldglobes[:-1] + self.globes.insert(0, oldglobes[-1]) + pass + return + + def rotate(self, newr, newg, newb, direction=True): + """ + Rotate all globes, just like chase, but replace the 'first' + globe with new color. + """ + self.chase(direction) + if direction: + self.globes[-1] = (newr, newg, newb) + pass + else: + self.globes[0] = (newr, newg, newb) + pass + pass + + def render(self): + raise NotImplementedError + +class ButtonHoliday(HolidayBase): + """ + Used when running on a physical Holiday. + """ + def __init__(self): + super(ButtonHoliday, self).__init__() + self.pid = os.getpid() + self.pipename = '/run/compose.fifo' + try: + self.pipe = open(self.pipename, "wb") + except: + print "Couldn't open the pipe! Oh no!" + raise + self.pipe = None + pass + + def render(self): + """ + Render globe colours to local pipe + """ + rend = [] + rend.append("0x000010\n") + rend.append("0x%06x\n" % self.pid) + for g in self.globes: + tripval = (g[0] * 65536) + (g[1] * 256) + g[2] + rend.append("0x%06X\n" % tripval) + pass + self.pipe.write(''.join(rend)) + self.pipe.flush() + +class ButtonApp(object): + """ + A ButtonApp runs on a physical Holiday using the button interface. + """ + + def start(self): + """ + Do whatever is required to start up the app + """ + return + + def stop(self): + """ + Do whatever is required to stop the app + """ + return + + def up(self): + """ + Called when the Up button is pressed + """ + return + + def down(self): + """ + Called when the Down button is pressed + """ + return + diff --git a/api/restholiday.py b/api/restholiday.py new file mode 100644 index 0000000..0a321db --- /dev/null +++ b/api/restholiday.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python +""" +REST interface classes for Holiday + +Copyright (c) 2013 Justin Warren +License: MIT (see LICENSE for details) +""" + +__author__ = "Justin Warren" +__version__ = '0.02-dev' +__license__ = "MIT" + +import requests +import json + +from base import HolidayBase + +import logging + +# Set up logging +log = logging.getLogger('rest_holiday') +handler = logging.StreamHandler() +handler.setFormatter(logging.Formatter("%(asctime)s: %(name)s [%(levelname)s]: %(message)s")) +log.addHandler(handler) +log.setLevel(logging.DEBUG) + +class RESTHoliday(HolidayBase): + """ + A remote Holiday we talk to over the JSON REST web interface + """ + def __init__(self, addr, scheme='http'): + """ + Initialise the REST Holiday remote address + @param addr: A string of the remote address of form : + """ + super(RESTHoliday, self).__init__() + self.scheme = scheme + self.addr = addr + + def render(self): + """ + Render globe values via JSON to remote Holiday via REST interface + """ + hol_vals = [ "#%02x%02x%02x" % (x[0], x[1], x[2]) for x in self.globes ] + hol_msg = { "lights": hol_vals } + msg_str = json.dumps(hol_msg) + url_str = "%s://%s/device/light/setlights" % (self.scheme, self.addr) + r = requests.put(urlstr, data=msg_str) + pass + diff --git a/api/udpholiday.py b/api/udpholiday.py new file mode 100644 index 0000000..1747b96 --- /dev/null +++ b/api/udpholiday.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python +""" +UDP interface classes for Holiday + +Copyright (c) 2013 Justin Warren +License: MIT (see LICENSE for details) +""" + +__author__ = "Justin Warren" +__version__ = '0.02-dev' +__license__ = "MIT" + +import sys +import socket +import array + +from base import HolidayBase + +import logging + +# Set up logging +log = logging.getLogger('udp_holiday') +handler = logging.StreamHandler() +handler.setFormatter(logging.Formatter("%(asctime)s: %(name)s [%(levelname)s]: %(message)s")) +log.addHandler(handler) +log.setLevel(logging.DEBUG) + +class UDPHoliday(HolidayBase): + """ + A remote Holiday we talk to over the fast UDP + """ + def __init__(self, ipaddr, port=9988): + """ + Initialise the REST Holiday remote address + @param addr: A string of the remote address of form : + """ + super(UDPHoliday, self).__init__() + self.ipaddr = ipaddr + self.port = port + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + + def render(self): + """ + Render globe values via UDP to remote Holiday + """ + packet = array.array('B', [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) # initialize basic packet, ignore first 10 bytes + for g in self.globes: + packet.append(g[0]) + packet.append(g[1]) + packet.append(g[2]) + pass + # Send the packet to the Holiday + self.sock.sendto(packet, (self.ipaddr, self.port)) + pass + diff --git a/examples/candela.sh b/examples/candela.sh new file mode 100755 index 0000000..6ad5d8f --- /dev/null +++ b/examples/candela.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +./twinkle.py $1 -H 0.01 -S 0.1 -V 0.00 -c 0.5 -b '#b0771f' --satdiff-max 0.05 --huediff-max 0.05 diff --git a/examples/chase_demo.py b/examples/chase_demo.py new file mode 100644 index 0000000..3ff6b4a --- /dev/null +++ b/examples/chase_demo.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python +""" +Demonstrate the chase function from holiday.py + +Copyright (c) 2013 Justin Warren +License: MIT (see LICENSE for details) +""" + +__author__ = "Justin Warren" +__version__ = '0.01-dev' +__license__ = "MIT" + +import sys +import time +import logging + +import holiday + +# Simulator default address +SIM_ADDR = "localhost:8080" + +log = logging.getLogger(sys.argv[0]) +handler = logging.StreamHandler() +handler.setFormatter(logging.Formatter("%(asctime)s: %(name)s [%(levelname)s]: %(message)s")) +log.addHandler(handler) +log.setLevel(logging.DEBUG) + +class Chaser(object): + """ + Implements a chaser + """ + + def __init__(self, addr, + pattern=None, + reverse=False, + delay=0.1): + """ + Controls a single Holiday at addr + + @param pattern: The starting pattern to chase around + """ + self.hol = holiday.Holiday(addr=addr, + remote=True) + + if pattern is None: + pattern = [ + (0x00, 0x00, 0x00), + ] * self.hol.NUM_GLOBES + pattern[0] = (0xff, 0xff, 0xff) + pattern[10] = (0xff, 0x00, 0x00) + pattern[20] = (0x00, 0xff, 0x00) + pattern[30] = (0x00, 0x00, 0xff) + pattern[40] = (0xff, 0xff, 0x00) + + # Make a copy so we don't clobber the original + self.globe_pattern = pattern[:] + + self.reverse = reverse + self.delay = delay + + def animate(self): + # Animation sequence is to start blank, then light each + # globe in sequence until all are lit, then start again. + while True: + + # Move lights one step forwards or backwards, + # depending on the setting passed in + if self.reverse: + new_pattern = self.globe_pattern[1:] + new_pattern.append(self.globe_pattern[0]) + pass + + else: + new_pattern = self.globe_pattern[:-1] + new_pattern.insert(0, self.globe_pattern[-1]) + pass + + self.globe_pattern = new_pattern + self.hol.set_pattern(self.globe_pattern) + self.hol.render() + time.sleep(self.delay) + pass + +if __name__ == '__main__': + if len(sys.argv) > 1: + hostname = sys.argv[1] + log.debug("hostname: %s", hostname) + else: + # Assume we're on the simulator + log.info("Using simulator: %s", SIM_ADDR) + hostname = SIM_ADDR + pass + + obj = Chaser(hostname, + reverse=False) + obj.animate() diff --git a/examples/holibeats.py b/examples/holibeats.py new file mode 100755 index 0000000..fc70a52 --- /dev/null +++ b/examples/holibeats.py @@ -0,0 +1,490 @@ +#!/usr/bin/python +# +# A SoundLevel monitor on a Holiday for sound playing on your PC +# +import sys +import pyaudio +import audioop +import math +import numpy +import scipy +import time +import datetime +#from scipy.signal import kaiserord, lfilter, firwin, freqz + +from scipy import fft + +import optparse + +from secretapi.holidaysecretapi import HolidaySecretAPI + +FORMAT = pyaudio.paInt16 +CHANNELS = 2 +RATE = 44100 +BUFSIZE = 2048 + +import logging +log = logging.getLogger(sys.argv[0]) +handler = logging.StreamHandler() +handler.setFormatter(logging.Formatter("%(asctime)s: %(name)s [%(levelname)s]: %(message)s")) +log.addHandler(handler) +log.setLevel(logging.DEBUG) + +def list_devices(): + # List all audio input devices + p = pyaudio.PyAudio() + i = 0 + n = p.get_device_count() + + for i in range(0, n): + dev = p.get_device_info_by_index(i) + if dev['maxInputChannels'] > 0: + print "Found device: %d: %s" % (i, dev['name']) + i += 1 + pass + pass + pass + +def get_default_device_id(): + """ + Find the default audio device we want to monitor + """ + for i in range(0, n): + dev = p.get_device_info_by_index(i) + if dev['maxInputChannels'] > 0: + print "Found device: %d: %s" % (i, dev['name']) + i += 1 + pass + pass + pass + +def calc_samp_amp(sample): + """ + Figure out the sample value to use for our spectrum + analyser output for the given pre-filtered sample + """ + SCALE_FACTOR = 10e4 + #result = numpy.std(sample) * SCALE_FACTOR + result = max(abs(sample)) * SCALE_FACTOR + + return result + +def send_blinken(hols, visdata, pieces=1, + switchback=None, + maxval=None, + maxheight=None, + autorange=True, + colorscheme='default'): + """ + Create a light pattern for a remote Holidays + based on the values we receive. + + If maxval is supplied, scale values as proportion of + maxval, so the display auto-ranges. + """ + #log.debug("numhols: %d, buckets: %d", len(hols), len(visdata)) + #log.debug("pieces: %d, sb: %d", pieces, switchback) + + # Same bug as in holiscreen.py render_to_hols() + holglobes = [] + for i in range(len(hols)): + holglobes.append( [[0x00,0x00,0x00]] * HolidaySecretAPI.NUM_GLOBES ) + pass + + if maxheight is None: + if switchback: + maxheight = float(switchback) + else: + maxheight = float(HolidaySecretAPI.NUM_GLOBES) + pass + pass + + # Only use the first m values from visdata, based on how many we can display + m = len(hols) * pieces + + for i, value in enumerate(visdata[:m]): + holid = int(i / pieces) + + # Set the base globe index for each bucket in switchback mode + if switchback: + basenum = (i % pieces) * switchback + else: + # With no switchback, the bucket basenum is globe 0 + basenum = 0 + pass + + # Normalize value based on maxval == maxheight + if autorange and maxval: + value = value/maxval * maxheight + pass + + value = int(value) + + # Clip values greater than maxheight + if value > maxheight: + value = int(maxheight) + pass + + # Set the globes for the holiday for this bucket + # using switchback if required + # Use different colours for different height + # cutoffs, so low values are green, middle yellow/orange + # and high values are red. + + for j in range(0, value): + + # Set the globe index, reversing for switchback mode + # and using the basenum offset for each switchback bucket + if not (i % pieces) % 2: + globe_idx = basenum + j + else: + globe_idx = basenum + (switchback-j) - 1 + pass + + # Set the globe colour based on how far it + # is from the maximum value + (r, g, b) = get_val_color( j/maxheight, colorscheme ) + #log.debug("%d %d %d %d", globe_idx, r, g, b) + holglobes[holid][globe_idx] = [r,g,b] + pass + + # Blank globes above the value in this bucket + for j in range(value, int(maxheight)): + if not (i % pieces) % 2: + globe_idx = basenum + j + else: + globe_idx = basenum + (switchback-j) - 1 + pass + try: + holglobes[holid][globe_idx] = [0,0,0] + except IndexError: + log.error("Failed on holid %d, globe_idx %d", holid, globe_idx) + raise + pass + + # Render all the Holidays + for i, hol in enumerate(hols): + hol.set_pattern( holglobes[i] ) + hol.render() + pass + pass + +def get_val_color(val, scheme='default'): + """ + Return the globe color based on the scheme we're using. + + @param val: is a float value between 0.0 and 1.0 + """ + if scheme == 'default': + if val < 0.3: + r, g, b = 0, 200, 0 # green + elif val < 0.7: + r, g, b = 220, 220, 00 # yellow + else: + r, g, b = 240, 10, 10 # red + pass + + elif scheme == 'blue': + r, g, b = 0, 0, 30 + 225 * val + elif scheme == 'red': + r, g, b = 30 + 225 * val, 0, 0 + elif scheme == 'green': + r, g, b = 0, 30 + 225 * val, 0 + + elif scheme == 'yellow': + r, g, b = 30 + 225 * val, 30 + 225 * val, 0 + pass + + elif scheme == 'oz': + if val < 0.4: + r, g, b = 0, 30 + 225 * val, 0 + else: + r, g, b = 30 + 225 * val, 30 + 225 * val, 0 + pass + pass + + r = int(r) + g = int(g) + b = int(b) + return (r, g, b) + +class HolibeatOptions(optparse.OptionParser): + """ + Command-line options parser + """ + def __init__(self, *args, **kwargs): + optparse.OptionParser.__init__(self, **kwargs) + self.addOptions() + + def addOptions(self): + self.add_option('-n', '--numstrings', dest='numstrings', + help="Number of Holiday strings to simulate [%default]", + type="int", default=1) + + # Listen on multiple TCP/UDP ports, one for each Holiday we simulate + self.add_option('-p', '--portstart', dest='portstart', + help="Port number to start at for UDP listeners [%default]", + type="int", default=9988) + + self.add_option('-c', '--colorscheme', dest='colorscheme', + help=" [%default]", + type="choice", choices = ['default', 'blue', 'green', 'red', 'yellow', 'oz'], + default='default') + + self.add_option('-b', '--buckets', dest='numbuckets', + help="Number of frequency bands (buckets) for display", + type="int") + + self.add_option('-m', '--mode', dest='mode', + help="Frequency mode: amplitude or power [%default]", + type="choice", choices=['amp', 'power'], default='amp') + + self.add_option('-f', '--fps', dest='fps', + help="Frames per second, used to slow down data sending", + type="int", default=30) + + self.add_option('', '--switchback', dest='switchback', + help="'Switchback' strings, make a single string display like its " + "more than one every SWITCHBACK globes", + type="int") + + self.add_option('', '--autorange', dest='autorange', + help="Dynamically set range of display based on max value [%default]", + action='store_true', default=True) + + self.add_option('', '--no-autorange', dest='autorange', + help="Disable auto-ranging display", + action='store_false') + + self.add_option('', '--maxheight', dest='maxheight', + help="Manually set the maximum height value for buckets", + type="float") + + self.add_option('', '--avgvals', dest='num_maxvals', + help="Number of samples to calculate moving average for maxval over.", + type="int", default=50) + + def parseOptions(self): + """ + Emulate twistedmatrix options parser API + """ + options, args = self.parse_args() + self.options = options + self.args = args + + self.postOptions() + + return self.options, self.args + + def postOptions(self): + if len(self.args) < 1: + self.error("Specify address and port of remote Holiday(s)") + pass + pass + +def chunks(l, n): + """ + Generator for n-sized chunks from list l + """ + for i in xrange(0, len(l), n): + yield l[i:i+n] + pass + +if __name__ == '__main__': + #list_devices() + + usage = "Usage: %prog [options] [ ... ]" + + optparse = HolibeatOptions(usage=usage) + options, args = optparse.parseOptions() + + pa = pyaudio.PyAudio() + + stream = pa.open(format=FORMAT, + channels=CHANNELS, + rate=RATE, + input=True, + frames_per_buffer=BUFSIZE) + + # Sample rate in Hertz + sample_rate = float(RATE) + # The Nyquist rate of the signal + nyq_rate = sample_rate / 2.0 + + # A Hamming window, to help with putting frequencies into + # different buckets. Use twice as many as strings, because everything high is boring. + window = numpy.hamming(BUFSIZE*2) + + if options.switchback: + pieces = int(math.floor(float(HolidaySecretAPI.NUM_GLOBES) / options.switchback)) + numbuckets = int(pieces * options.numstrings * 1.5) + else: + pieces = 1 + numbuckets = int(options.numstrings * 1.5) + pass + + # Allow manual override of number of buckets + # Mostly used to clip the higher, less interesting bands, so we visualise the lower + # bands with more granularity + if options.numbuckets: + numbuckets = options.numbuckets + pass + + hols = [] + + if len(args) > 1: + for arg in args: + hol_addr, hol_port = arg.split(':') + hols.append(HolidaySecretAPI(addr=hol_addr, port=int(hol_port))) + else: + hol_addr, hol_port = args[0].split(':') + for i in range(options.numstrings): + hols.append(HolidaySecretAPI(addr=hol_addr, port=int(hol_port)+i)) + pass + + # Build the list of cutoff frequences as powers of 2 + cutoffs = [] + base = 20 + + maxfreq = 20000 # anything higher than this is super boring + steps = int(math.log(maxfreq, base)) + 1 + exp = range(1, steps, 1) + + sublist = range(0, base) + + for x in exp: + for i in range(1, base, 1): + for sub in sublist: + cutoffs.append( i*(base**x) + sub*(base**x/len(sublist)) ) + pass + pass + pass + + # ignore anything higher than 10kHz because it's boring + cutoffs = [ x for x in cutoffs if x <= maxfreq ] + #print cutoffs + + # chunk cutoffs based on number of strings, + # and use the max freq val of the chunk for our new cutoff + cutoffs = [ max(c) for c in chunks(cutoffs, len(cutoffs)/numbuckets) ] + #print cutoffs + #sys.exit(1) + + # Used for auto-ranging of display + maxvals = [] + current_maxval = 0 + + # Time limiting bits to slow down the UDP firehose + # This is stupid, but functional. Should be event driven, probably. + + sleeptime = 1.0/options.fps + while True: + data = stream.read(BUFSIZE) + numdata = numpy.fromstring(data, dtype=numpy.short) + + # Normalize data with our sample rate + normdata = numdata / sample_rate + + # Window the data, using a Hamming window, to better capture + normdata = normdata * window + + # Use a Fast Fourier Transform to convert to frequency domain + # Take the absolute value using numpy so the complex number + # returned by fft() is correctly converted to magnitude information + # rather than just chopping off the imaginary portion of the complex number + # discard the second half of the data because it's just aliased from + # the first half (i.e. repeated because of Nyquist reasons) + # For more detailed example, see: + # https://svn.enthought.com/enthought/browser/Chaco/trunk/examples/advanced/spectrum.py + # See also the doco for Numpy FFT, which explains how to get amplitude and + # power of the FFT output using different operations + # http://docs.scipy.org/doc/numpy/reference/routines.fft.html + fft_data = numpy.fft.fft(normdata) + freq = numpy.fft.fftfreq(fft_data.size) + + # Ignore the second half of the data + fft_data = fft_data[:len(fft_data)/2] + + # Convert frequency information into Hz based on the sample_rate + freq = freq[:len(freq)/2] * sample_rate + + # amplitude at each frequency + fft_amp = numpy.abs(fft_data) + + # power at each frequency + fft_power = numpy.abs(fft_data)**2 + + # Select the values we want to use + if options.mode == 'amp': + fft_vals = fft_amp[1:] + elif options.mode == 'power': + fft_vals = fft_power[1:] + else: + raise ValueError("Invalid mode selected: %s" % options.mode) + + # Average data into the number of buckets we want to display + # We use log-scale for buckets, because most of the interesting + # information (to humans) is in the lower frequencies. + visdata = [] + + #sys.exit(1) + vals = [] + + cutoff_idx = 0 + for val, f in zip(fft_vals, freq): + if f < cutoffs[cutoff_idx]: + vals.append(val) + else: + # average values for this bucket and save + if len(vals) == 0: + visdata.append(0) + else: + visdata.append( numpy.average(vals) ) + pass + vals = [] + # Update the cutoff value + cutoff_idx += 1 + + # Ignore anything higher than last cutoff + if cutoff_idx > len(cutoffs)-1: + break + + pass + pass + + #print visdata + #print ["%03d" % x for x in visdata] + + # scale bucket 0 down, because bass always seems to dominate the spectrum + # particularly for anything not classical music. + visdata[0] = visdata[0] / 1.8 + + bucket_maxval = 0 + for i in range(0, numbuckets): + # Update auto-ranging information + if visdata[i] > bucket_maxval: + bucket_maxval = visdata[i] + pass + pass + + # new maxval is the average of the last n maxvals + maxvals.insert(0, bucket_maxval) + maxvals = maxvals[:options.num_maxvals] + + current_maxval = numpy.average(maxvals) + #log.debug("maxval reset: %f", maxval) + + #print "current max:", current_maxval + + # Send data to Holidays + send_blinken(hols, visdata, pieces, + switchback=options.switchback, + maxval=current_maxval, + maxheight=options.maxheight, + autorange=options.autorange, + colorscheme=options.colorscheme) + pass + + # Wait for next timetick + time.sleep(sleeptime) + pass diff --git a/examples/holiday.py b/examples/holiday.py index 4e41a3d..40b76a6 100644 --- a/examples/holiday.py +++ b/examples/holiday.py @@ -31,17 +31,11 @@ class Holiday: NUM_GLOBES = 50 # Storage for all 50 globe values - # - globes = [ [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], - [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], - [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], - [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], - [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], - [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], - [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], - [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], - [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], - [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00], [ 0x00, 0x00, 0x00] ] + # Use a list of tuples, because tuples are immutable, as are specific colours. + # If you want a globe to display a new colour, you need to provide a new tuple + # for that colour, rather than changing the colour 'blue' to 'red' + + globes = [ (0,0,0), ] * NUM_GLOBES def __init__(self, remote=False, addr=''): """If remote, you better supply a valid address. @@ -57,16 +51,23 @@ def __init__(self, remote=False, addr=''): def setglobe(self, globenum, r, g, b): """Set a globe""" if (globenum < 0) or (globenum >= self.NUM_GLOBES): - return - self.globes[globenum][0] = r - self.globes[globenum][1] = g - self.globes[globenum][2] = b + # Fail hard, don't ignore errors + raise IndexError("globenum %d does not exist", globenum) + return self.globes[globenum] def getglobe(self, globenum): """Return a tuple representing a globe's RGB color value""" if (globenum < 0) or (globenum >= self.NUM_GLOBES): return False - return (self.globes[globenum][0], self.globes[globenum][1], self.globes[globenum][2]) + return self.globes[globenum] + + def set_pattern(self, pattern): + """ + Set the entire string in one go + """ + if len(pattern) != self.NUM_GLOBES: + raise ValueError("pattern length incorrect: %d != %d" % ( len(pattern), self.NUM_GLOBES) ) + self.globes = pattern[:] def chase(self, direction="True"): """Rotate all of the globes around - up if TRUE, down if FALSE""" diff --git a/examples/holiscreen.py b/examples/holiscreen.py new file mode 100755 index 0000000..83f6020 --- /dev/null +++ b/examples/holiscreen.py @@ -0,0 +1,345 @@ +#!/usr/bin/python +# +# Use a series of Holidays as a lo-res, flexible display screen that you +# can walk through like a beaded curtain. +# + +# FIXME: Update to be able to do video. + +import optparse +import Image +import math +import time +import numpy +import sys +import logging + +from api.udpholiday import UDPHoliday +NUM_GLOBES = UDPHoliday.NUM_GLOBES + +# Height of display +DEFAULT_HEIGHT = NUM_GLOBES + +# Width of display +DEFAULT_WIDTH = NUM_GLOBES + +log = logging.getLogger(sys.argv[0]) +handler = logging.StreamHandler() +handler.setFormatter(logging.Formatter("%(asctime)s: %(name)s [%(levelname)s]: %(message)s")) +log.addHandler(handler) +log.setLevel(logging.DEBUG) + +def image_to_globes(img, width=DEFAULT_WIDTH, height=DEFAULT_HEIGHT): + """ + Convert an image file into a Holiday string display + + @param imgfile: the image file to use + @param numstring: the number of Holiday strings in the display + """ + img = img.convert('RGB') + + # Resize image based on the longest side of our holiscreen + if width < height: + new_width = int(img.size[0] * (float(height) / img.size[1] )) + new_height = height + else: + new_width = width + new_height = int(img.size[0] * (float(width) / img.size[1] )) + + img.thumbnail( (new_width, new_height), Image.ANTIALIAS) + + # Now we want to sample display_width times across the width of + # the image + stringlist = [] + iw, ih = img.size + + slice_width = float(iw)/float(width) + slice_height = float(ih)/float(height) + + # Slice image into boxed partitions slice_width x slice_height + # and average the colour of the pixels in that box to give + # us the pixel colour for our downsampled display. + # FIXME: There's probably a function in PIL to do this, but + # I haven't been able to find it yet. + for h_slice in range(height): + globelist = [] + for w_slice in range(width): + bbox = ( int(w_slice * slice_width), + int(h_slice * slice_height), + int(math.ceil((w_slice+1) * slice_width)), + int(math.ceil((h_slice+1) * slice_height)) + ) + region = img.crop(bbox) + pixeldata = list(region.getdata()) + + # split out the r, b, g values for each pixel and average them + rlist, glist, blist = zip(*pixeldata) + globelist.append( (int(numpy.average(rlist)), + int(numpy.average(glist)), + int(numpy.average(blist)),) + ) + pass + # save the list of globe colours + # We reverse, because otherwise the display is upside-down + #globelist.reverse() + stringlist.append(globelist) + pass + return stringlist + +class HoliscreenOptions(optparse.OptionParser): + """ + Command-line options parser + """ + def __init__(self, *args, **kwargs): + optparse.OptionParser.__init__(self, **kwargs) + self.addOptions() + + def addOptions(self): + self.add_option('-n', '--numstrings', dest='numstrings', + help="Number of Holiday strings to simulate [%default]", + type="int", default=25) + + self.add_option('-f', '--file', dest='imgfile', + help="Image file to process. Overrides arguments.", + type="string" ) + + self.add_option('-a', '--animate', dest='animate', + help="Run in animation mode. Required animated GIF.", + action="store_true" ) + + self.add_option('-s', '--sleeptime', dest='anim_sleep', + help="Sleep between animation frames, in seconds [%default]", + type="float", default=0.1 ) + + + # Listen on multiple TCP/UDP ports, one for each Holiday we simulate + self.add_option('-p', '--portstart', dest='portstart', + help="Port number to start at for UDP listeners [%default]", + type="int", default=9988) + + self.add_option('-o', '--orientation', dest='orientation', + help="Orientation of the strings [%default]", + type="choice", choices=['vertical', 'horizontal'], default='vertical') + + self.add_option('', '--flipped', dest='flipped', + help="Flip direction of the strings [%default]", + action="store_true", default=False) + + self.add_option('', '--switchback', dest='switchback', + help="'Switchback' strings, make a single string display like its " + "more than one every m globes", + type="int") + pass + + + def parseOptions(self): + """ + Emulate twistedmatrix options parser API + """ + options, args = self.parse_args() + self.options = options + self.args = args + + self.postOptions() + + return self.options, self.args + + def postOptions(self): + if len(self.args) < 1: + self.error("Specify address and port of remote Holiday(s)") + pass + + if not self.options.imgfile: + self.error("Image filename not given.") + pass + pass + +def render_image(img, hols, width, height, + orientation='vertical', + switchback=None, flipped=False): + """ + Render an image to a set of remote Holidays + + @param switchback: How many globes per piece of a switchback + """ + #log.debug("w x h: %d x %d", width, height) + globelists = image_to_globes(img, width, height) + #log.debug("len globelists: %d", len(globelists)) + render_to_hols(globelists, hols, width, height, orientation, switchback, flipped) + +def render_to_hols(globelists, hols, width, height, + orientation='vertical', switchback=None, + flipped=True): + """ + Render a set of globe values to a set of Holidays + """ + # Using this indirect array method, rather than set_globes() + # directly, because some weird bug I can't find and squash makes the + # globelists all the same if we try to render the Holidays in one + # go, rather than rendering each 'line' of the switchback. + # Your guess is as good as mine. + holglobes = [] + for i in range(len(hols)): + holglobes.append( [(0x00,0x00,0x00)] * NUM_GLOBES ) + pass + + if orientation == 'vertical': + orientsize = height + else: + orientsize = width + + # If switchback mode is enabled, reverse order + # of every second line, so they display the right + # way on zigzag Holidays + pieces = int(math.floor(float(NUM_GLOBES) / orientsize)) + + # The globelist is a set of y values. List [0] is all the vertical globes + # for x = 0, list [1] is the vertical x=1 globes, etc. + # The orientation determines which holiday gets each pixel in the list. + # In vertical orientation, the first list goes to holiday 0, the second + # to holiday 2. In switchback mode, the first n lists go to holiday 0. + # In horizontal mode, the first row is spread between holidays, depending + # on switchback. + #log.debug("globelist len: %d", len(globelists)) + for l, line in enumerate(globelists): + + for i, values in enumerate(line): + r, g, b = values + + #log.debug("os: %d, pieces: %d, sb: %s, l: %d, i: %d", orientsize, pieces, switchback, l, i) + + # Which holiday are we talking to? + if orientation == 'horizontal': + basenum = (l%pieces) * orientsize + + if switchback: + holid = l*orientsize / (pieces * switchback) + else: + holid = l + + if not (l % pieces) % 2: + globe_idx = basenum + i + else: + globe_idx = basenum + (orientsize-i) - 1 + pass + + else: + basenum = (i % pieces) * orientsize + + if switchback: + holid = i / pieces + else: + holid = i + basenum = 0 + + if not (i % pieces) % 2: + if not flipped: + globe_idx = basenum + l + else: + globe_idx = basenum + (orientsize-l) - 1 + else: + if not flipped: + globe_idx = basenum + (orientsize-l) - 1 + else: + globe_idx = basenum + l + pass + + try: + #log.debug("holid: %d, l: %d, i: %d, basenum: %d, globeidx: %d", holid, l, i, basenum, globe_idx) + hol = hols[holid] + except IndexError: + log.error("Not enough Holidays for number of screen lines. Need at least %d." % (holid+1,)) + sys.exit(1) + + try: + holglobes[holid][globe_idx] = [r,g,b] + except IndexError: + log.error("Error at holid %d, globeidx %d", holid, globe_idx) + raise + + #print "line: %d, holid: %d, globe: %d, val: (%d, %d, %d)" % (l, holid, globe_idx, r,g,b) + + pass + pass + + # Render to each Holiday + for i, hol in enumerate(hols): + hol.set_pattern( holglobes[i] ) + hol.render() + pass + pass + + +if __name__ == '__main__': + + usage = "Usage: %prog [options] [ ... ]" + optparse = HoliscreenOptions(usage=usage) + options, args = optparse.parseOptions() + hols = [] + if len(args) > 1: + for arg in args: + hol_addr, hol_port = arg.split(':') + hols.append(UDPHoliday(ipaddr=hol_addr, port=int(hol_port))) + else: + hol_addr, hol_port = args[0].split(':') + for i in range(options.numstrings): + hols.append(UDPHoliday(ipaddr=hol_addr, port=int(hol_port)+i)) + pass + pass + + img = Image.open(options.imgfile) + + if options.switchback: + if options.orientation == 'vertical': + height = options.switchback + pieces = int(math.floor(float(NUM_GLOBES) / height)) + width = options.numstrings * pieces + else: + width = options.switchback + pieces = int(math.floor(float(NUM_GLOBES) / width)) + height = options.numstrings * pieces + else: + if options.orientation == 'vertical': + height = NUM_GLOBES + pieces = options.numstrings + width = options.numstrings + else: + width = NUM_GLOBES + pieces = options.numstrings + height = options.numstrings + pass + pass + + isanimated = False + + # Detect animated GIFs + if img.format == 'GIF': + try: + img.seek(img.tell()+1) + isanimated = True + except EOFError: + img.seek(0) + pass + pass + + if isanimated and options.animate: + # render first frame + render_image(img, hols, width, height, options.orientation, + options.switchback, options.flipped) + while True: + # get the next frame after a short delay + time.sleep(options.anim_sleep) + try: + img.seek(img.tell()+1) + + except EOFError: + img.seek(0) + pass + render_image(img, hols, width, height, options.orientation, + options.switchback, options.flipped) + pass + pass + else: + render_image(img, hols, width, height, options.orientation, + options.switchback, options.flipped) + pass diff --git a/examples/holivid.py b/examples/holivid.py new file mode 100755 index 0000000..d169533 --- /dev/null +++ b/examples/holivid.py @@ -0,0 +1,160 @@ +#!/usr/bin/python +""" +Display video on a set of Holidays +""" +import numpy as np +import cv2 +import math +import optparse +import time + +from api.udpholiday import UDPHoliday +from holiscreen import render_to_hols + +NUM_GLOBES = UDPHoliday.NUM_GLOBES + +class HolividOptions(optparse.OptionParser): + """ + Command-line options parser + """ + def __init__(self, *args, **kwargs): + optparse.OptionParser.__init__(self, **kwargs) + self.addOptions() + + def addOptions(self): + self.add_option('-n', '--numstrings', dest='numstrings', + help="Number of Holiday strings to simulate [%default]", + type="int", default=25) + + self.add_option('-f', '--file', dest='filename', + help="Video file to display.", + type="string" ) + + # Listen on multiple TCP/UDP ports, one for each Holiday we simulate + self.add_option('-p', '--portstart', dest='portstart', + help="Port number to start at for UDP listeners [%default]", + type="int", default=9988) + + self.add_option('-o', '--orientation', dest='orientation', + help="Orientation of the strings [%default]", + type="choice", choices=['vertical', 'horizontal'], default='vertical') + + self.add_option('', '--switchback', dest='switchback', + help="'Switchback' strings, make a single string display like its " + "more than one every m globes", + type="int") + + self.add_option('', '--fps', dest='fps', + help="Set video playback frames-per-second. [%default]", + type="int", default=25) + + pass + + + def parseOptions(self): + """ + Emulate twistedmatrix options parser API + """ + options, args = self.parse_args() + self.options = options + self.args = args + + self.postOptions() + + return self.options, self.args + + def postOptions(self): + if len(self.args) < 1: + self.error("Specify address and port of remote Holiday(s)") + pass + + if not self.options.filename: + self.error("Video filename not given.") + pass + pass + +if __name__ == '__main__': + + usage = "Usage: %prog [options] [ ... ]" + optparse = HolividOptions(usage=usage) + options, args = optparse.parseOptions() + + hols = [] + if len(args) > 1: + for arg in args: + hol_addr, hol_port = arg.split(':') + hols.append(UDPHoliday(ipaddr=hol_addr, port=int(hol_port))) + else: + hol_addr, hol_port = args[0].split(':') + for i in range(options.numstrings): + hols.append(UDPHoliday(ipaddr=hol_addr, port=int(hol_port)+i)) + pass + pass + + if options.switchback: + if options.orientation == 'vertical': + height = options.switchback + pieces = int(math.floor(float(NUM_GLOBES) / height)) + width = options.numstrings * pieces + else: + width = options.switchback + pieces = int(math.floor(float(NUM_GLOBES) / width)) + height = options.numstrings * pieces + else: + if options.orientation == 'vertical': + height = NUM_GLOBES + pieces = options.numstrings + width = options.numstrings + else: + width = NUM_GLOBES + pieces = options.numstrings + height = options.numstrings + pass + pass + + cap = cv2.VideoCapture(options.filename) + newsize = (width, height) + + skipframe = False + + while True: + loopstart = time.time() + ret, frame = cap.read() + + if ret != True: + print "No valid frame." + break + + # Resize the frame into the resolution of our Holiday array + holframe = cv2.resize(frame, newsize, interpolation=cv2.INTER_CUBIC) + + # The colours are in the wrong format, so convert them + holframe = cv2.cvtColor(holframe, cv2.COLOR_BGR2RGB) + + # Display the original frame, for the demo + cv2.imshow('holivid monitor display', frame) + + # A frame is just a Numpy array of pixel values, i.e. globelists. We need to take + # these values and map them onto our holiday array. + render_to_hols(holframe, hols, width, height, + options.orientation, options.switchback) + + # Wait period between keycapture (in milliseconds) + # This gives us approximately the right number of frames per second + wait_time = 1000/options.fps + + # Figure out how long the wait_time would be without the + # processing time + + loopend = time.time() + # Adjust waiting based on how long it takes us to process + process_time = (loopend - loopstart) * 1000 + + wait_time = int(wait_time - process_time) + if cv2.waitKey(wait_time) & 0xFF == ord('q'): + break + pass + + cap.release() + cv2.destroyAllWindows() + diff --git a/examples/led_scroller.py b/examples/led_scroller.py new file mode 100755 index 0000000..d20f3c6 --- /dev/null +++ b/examples/led_scroller.py @@ -0,0 +1,250 @@ +#!/usr/bin/python +""" +A simple hack to turn one or more Holidays into an LED scrolling display +""" + +import optparse +import time +import pygame +import sys +import math + +from api.udpholiday import UDPHoliday +from holiscreen import render_to_hols + +NUM_GLOBES = UDPHoliday.NUM_GLOBES + +# Initialise the font module +pygame.font.init() + +class LEDScrollerOptions(optparse.OptionParser): + """ + Command-line options parser + """ + def __init__(self, *args, **kwargs): + optparse.OptionParser.__init__(self, **kwargs) + self.addOptions() + + def addOptions(self): + self.add_option('-n', '--numstrings', dest='numstrings', + help="Number of Holiday strings to simulate [%default]", + type="int", default=7) + + self.add_option('-a', '--animate', dest='animate', + help="Run in scroller mode", + action="store_true" ) + + self.add_option('-s', '--sleeptime', dest='anim_sleep', + help="Sleep between animation frames, in seconds [%default]", + type="float", default=0.1 ) + + # Listen on multiple TCP/UDP ports, one for each Holiday we simulate + self.add_option('-p', '--portstart', dest='portstart', + help="Port number to start at for UDP listeners [%default]", + type="int", default=9988) + + self.add_option('', '--switchback', dest='switchback', + help="'Switchback' strings, make a single string display like its " + "more than one every SWITCHBACK globes", + type="int") + + self.add_option('', '--font', dest='fontname', + help="Name of the font to use. [%default]", + type="string", default="couriernew") + + self.add_option('', '--antialias', dest='antialias', + help="Use anti-aliasing for font rendering. [%default]", + action="store_true", default=False) + + self.add_option('', '--fontsize', dest='fontsize', + help="Size of the font, in pixels [%default]", + type="int") + + self.add_option('', '--color', dest='color', + help="Color of the font [%default]", + type="string", default="0xffffff") + + self.add_option('-o', '--orientation', dest='orientation', + help="Orientation of the strings [%default]", + type="choice", choices=['vertical', 'horizontal'], default='vertical') + + self.add_option('', '--list-fonts', dest='listfonts', + help="List fonts available for use", + action="store_true") + + self.add_option('', '--blank-padding', dest='blankpadding', + help="Spaces to leave between end of string " + "and start when wrapping around. [%default]", + type="int", default=2) + + def parseOptions(self): + """ + Emulate twistedmatrix options parser API + """ + options, args = self.parse_args() + self.options = options + self.args = args + + self.postOptions() + + return self.options, self.args + + def postOptions(self): + + if self.options.listfonts: + fontlist = pygame.font.get_fonts() + fontlist.sort() + print '\n'.join(fontlist) + sys.exit(0) + + if len(self.args) < 1: + self.error("Specify address and port of remote Holiday(s)") + pass + pass + +def pick_fontsize(fontname, height, text): + """ + Figure out the appropriate font size based on the font chosen + and the height available in the display. + """ + fontsize = 2 + for fontsize in range(1000): + font = pygame.font.SysFont(fontname, fontsize) + size_req = font.size(text) + if size_req[1] > height+4: + fontsize -= 1 + break + pass + pass + return fontsize + +def text_to_globes(text, width, height, + color=(255,255,255), + offset_left=0): + """ + Take a text string and return a list of globelists. + + One list will be returned for vertical line, each of width globes + long. This implies Holidays layed out horizontally for display. + + #FIXME: Allow rotation for horizontal display with vertically hung + Holidays. + + The left offset can be set (in pixels), which will control the + window of the text display. Incrementing the offset can be used + to create a scrolling display. + """ + #font = pygame.font.SysFont("None", 10) + + if options.fontsize is None: + fontsize = pick_fontsize(options.fontname, height, text) + else: + fontsize = options.fontsize + + font = pygame.font.SysFont(options.fontname, fontsize) + color = pygame.Color(color) + + surface = font.render(text, options.antialias, color, (0,0,0) ) + + globelists = [] + + # Now fetch the pixels as an array + pa = pygame.PixelArray(surface) + for i in range(len(pa[1])): + globes = [] + pixels = pa[:,i] + pixvals = [ pa.surface.unmap_rgb(x) for x in pixels ] + # Check to see if this is a blank line, i.e. + # all pixels are black. Ignore the line if this is the case. + # We don't want to waste lines on blanks. + if pixvals == [ (0,0,0,255) ] * len(pixvals): + #print "skipping blank line %d" % i + continue + + for (r, g, b, a) in pixvals: + # Convert from a surface color int to RGBA values + globes.append( (r,g,b) ) + pass + + # Pad with blanks if the text is shorter than width + if len(pa) < width: + globes.extend( [(0,0,0)] * (width - len(pa)) ) + pass + globelists.append(globes) + pass + return globelists + +if __name__ == '__main__': + + usage = "Usage: %prog [options] [ ... ]" + optparse = LEDScrollerOptions(usage=usage) + + options, args = optparse.parseOptions() + + # How big is our canvas window? + + if options.switchback: + width = options.switchback + pieces = int(math.floor(float(NUM_GLOBES) / width)) + height = pieces + else: + height = options.numstrings + width = NUM_GLOBES + pass + + # Orientation setup + if options.orientation == 'vertical': + old_w = width + width = height + height = old_w + pass + + hols = [] + if len(args) > 1: + for arg in args: + hol_addr, hol_port = arg.split(':') + hols.append(UDPHoliday(ipaddr=hol_addr, port=int(hol_port))) + else: + hol_addr, hol_port = args[0].split(':') + for i in range(options.numstrings): + hols.append(UDPHoliday(ipaddr=hol_addr, port=int(hol_port)+i)) + pass + + # The text to render is passed in from stdin + text = sys.stdin.read().rstrip() + if len(text) == 0: + text = "Holiday by MooresCloud. The world's most intelligent Christmas lights!" + pass + text = ''.join([text, ' ' * options.blankpadding]) + + glist = text_to_globes(text, width, height, color=options.color) + + #print "glist:", glist + # Scroll the display + offset = 0 + while True: + # Draw only as much of the globelists as will fit in the width + render_glist = [] + for hol_list in glist: + new_list = hol_list[offset:width+offset] + + # FIXME: Ability to scroll in both directions, via a flag + # wraparound + if len(new_list) < width: + new_list.extend(hol_list[:(width-len(new_list))]) + pass + render_glist.append(new_list) + pass + #print "renderlist:", render_glist + render_to_hols(render_glist, hols, width, height, + orientation=options.orientation, + switchback=options.switchback) + offset += 1 + if offset > len(glist[0]): + offset = 0 + pass + + if not options.animate: + sys.exit(0) + time.sleep(options.anim_sleep) + diff --git a/examples/patterns/greenandgold.json b/examples/patterns/greenandgold.json new file mode 100644 index 0000000..4efe00a --- /dev/null +++ b/examples/patterns/greenandgold.json @@ -0,0 +1,27 @@ +{ "lights": [ +"#008721", "#008721", "#008721", +"#008721", "#008721", +"#FCD116", "#FCD116", "#FCD116", +"#FCD116", "#FCD116", + +"#008721", "#008721", "#008721", +"#008721", "#008721", +"#FCD116", "#FCD116", "#FCD116", +"#FCD116", "#FCD116", + +"#008721", "#008721", "#008721", +"#008721", "#008721", +"#FCD116", "#FCD116", "#FCD116", +"#FCD116", "#FCD116", + +"#008721", "#008721", "#008721", +"#008721", "#008721", +"#FCD116", "#FCD116", "#FCD116", +"#FCD116", "#FCD116", + +"#008721", "#008721", "#008721", +"#008721", "#008721", +"#FCD116", "#FCD116", "#FCD116", +"#FCD116", "#FCD116" + + ] } diff --git a/examples/patterns/love01.json b/examples/patterns/love01.json new file mode 100644 index 0000000..4531471 --- /dev/null +++ b/examples/patterns/love01.json @@ -0,0 +1,32 @@ +{ "lights": [ +"#aa0000", "#aaaaaa", +"#aa0000", "#aaaaaa", +"#aa0000", "#aaaaaa", +"#aa0000", "#aaaaaa", +"#aa0000", "#aaaaaa", + +"#aa0000", "#aaaaaa", +"#aa0000", "#aaaaaa", +"#aa0000", "#aaaaaa", +"#aa0000", "#aaaaaa", +"#aa0000", "#aaaaaa", + +"#aa0000", "#aaaaaa", +"#aa0000", "#aaaaaa", +"#aa0000", "#aaaaaa", +"#aa0000", "#aaaaaa", +"#aa0000", "#aaaaaa", + +"#aa0000", "#aaaaaa", +"#aa0000", "#aaaaaa", +"#aa0000", "#aaaaaa", +"#aa0000", "#aaaaaa", +"#aa0000", "#aaaaaa", + +"#aa0000", "#aaaaaa", +"#aa0000", "#aaaaaa", +"#aa0000", "#aaaaaa", +"#aa0000", "#aaaaaa", +"#aa0000", "#aaaaaa" + + ] } diff --git a/examples/patterns/xmas.json b/examples/patterns/xmas.json new file mode 100644 index 0000000..ed3ac49 --- /dev/null +++ b/examples/patterns/xmas.json @@ -0,0 +1,10 @@ +{ "lights": [ "#FF0000", "#00FF00", "#FFFFFF", "#FF0000", "#00FF00", +"#FFFFFF", "#FF0000", "#00FF00", "#FFFFFF", "#FF0000", +"#00FF00", "#FFFFFF", "#FF0000", "#00FF00", "#FFFFFF", +"#FF0000", "#00FF00", "#FFFFFF", "#FF0000", "#00FF00", +"#FFFFFF", "#FF0000", "#00FF00", "#FFFFFF", "#FF0000", +"#00FF00", "#FFFFFF", "#FF0000", "#00FF00", "#FFFFFF", +"#FF0000", "#00FF00", "#FFFFFF", "#FF0000", "#00FF00", +"#FFFFFF", "#FF0000", "#00FF00", "#FFFFFF", "#FF0000", +"#00FF00", "#FFFFFF", "#FF0000", "#00FF00", "#FFFFFF", +"#FF0000", "#00FF00", "#FFFFFF", "#FF0000", "#00FF00" ] } diff --git a/examples/patterns/xmas2.json b/examples/patterns/xmas2.json new file mode 100644 index 0000000..8521bb6 --- /dev/null +++ b/examples/patterns/xmas2.json @@ -0,0 +1,19 @@ +{ "lights": [ +"#FF0000", "#FF0000", "#FF0000", +"#00FF00", "#00FF00", "#00FF00", +"#FFFFFF", "#FFFFFF", "#FFFFFF", +"#FF0000", "#FF0000", "#FF0000", +"#00FF00", "#00FF00", "#00FF00", +"#FFFFFF", "#FFFFFF", "#FFFFFF", +"#FF0000", "#FF0000", "#FF0000", +"#00FF00", "#00FF00", "#00FF00", +"#FFFFFF", "#FFFFFF", "#FFFFFF", +"#FF0000", "#FF0000", "#FF0000", +"#00FF00", "#00FF00", "#00FF00", +"#FFFFFF", "#FFFFFF", "#FFFFFF", +"#FF0000", "#FF0000", "#FF0000", +"#00FF00", "#00FF00", "#00FF00", +"#FFFFFF", "#FFFFFF", "#FFFFFF", +"#FF0000", "#FF0000", "#FF0000", +"#00FF00", "#00FF00" + ] } diff --git a/examples/patterns/xmas3.json b/examples/patterns/xmas3.json new file mode 100644 index 0000000..8309261 --- /dev/null +++ b/examples/patterns/xmas3.json @@ -0,0 +1,26 @@ +{ "lights": [ +"#FF0000", "#FF0000", "#FF0000", +"#FF0000", "#FF0000", +"#00FF00", "#00FF00", "#00FF00", +"#00FF00", "#00FF00", +"#FFFFFF", "#FFFFFF", "#FFFFFF", +"#FFFFFF", "#FFFFFF", + +"#FF0000", "#FF0000", "#FF0000", +"#FF0000", "#FF0000", +"#00FF00", "#00FF00", "#00FF00", +"#00FF00", "#00FF00", +"#FFFFFF", "#FFFFFF", "#FFFFFF", +"#FFFFFF", "#FFFFFF", + +"#FF0000", "#FF0000", "#FF0000", +"#FF0000", "#FF0000", +"#00FF00", "#00FF00", "#00FF00", +"#00FF00", "#00FF00", +"#FFFFFF", "#FFFFFF", "#FFFFFF", +"#FFFFFF", "#FFFFFF", + +"#FF0000", "#FF0000", "#FF0000", +"#00FF00", "#00FF00" + + ] } diff --git a/examples/patterns/xmas4.json b/examples/patterns/xmas4.json new file mode 100644 index 0000000..2cd3b11 --- /dev/null +++ b/examples/patterns/xmas4.json @@ -0,0 +1,32 @@ +{ "lights": [ +"#FF0000", "#00FF00", +"#FF0000", "#00FF00", +"#FF0000", "#00FF00", +"#FF0000", "#00FF00", +"#FF0000", "#00FF00", + +"#FF0000", "#00FF00", +"#FF0000", "#00FF00", +"#FF0000", "#00FF00", +"#FF0000", "#00FF00", +"#FF0000", "#00FF00", + +"#FF0000", "#00FF00", +"#FF0000", "#00FF00", +"#FF0000", "#00FF00", +"#FF0000", "#00FF00", +"#FF0000", "#00FF00", + +"#FF0000", "#00FF00", +"#FF0000", "#00FF00", +"#FF0000", "#00FF00", +"#FF0000", "#00FF00", +"#FF0000", "#00FF00", + +"#FF0000", "#00FF00", +"#FF0000", "#00FF00", +"#FF0000", "#00FF00", +"#FF0000", "#00FF00", +"#FF0000", "#00FF00" + + ] } diff --git a/examples/phloston.py b/examples/phloston.py new file mode 100755 index 0000000..190542f --- /dev/null +++ b/examples/phloston.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python +""" +Phloston Paradise Bomb-in-the-hotel style display for MooresCloud Holiday + +Copyright (c) 2013 Justin Warren +License: MIT (see LICENSE for details) +""" + +__author__ = "Justin Warren" +__version__ = '0.02-dev' +__license__ = "MIT" + +import sys +import time +import logging +import optparse +import math +import colorsys + +from api.base import HolidayBase +#from api.restholiday import RESTHoliday +from api.udpholiday import UDPHoliday + +NUM_GLOBES = HolidayBase.NUM_GLOBES + +log = logging.getLogger(sys.argv[0]) +handler = logging.StreamHandler() +handler.setFormatter(logging.Formatter("%(asctime)s: %(name)s [%(levelname)s]: %(message)s")) +log.addHandler(handler) +log.setLevel(logging.DEBUG) + +class PhlostonOptions(optparse.OptionParser): + """ + Command-line options parser + """ + def __init__(self, *args, **kwargs): + optparse.OptionParser.__init__(self, **kwargs) + self.addOptions() + + def addOptions(self): + self.add_option('-n', '--numstrings', dest='numstrings', + help="Number of Holiday strings to use [%default]", + type="int", default=1) + + self.add_option('-a', '--anim-sleep', dest='anim_sleep', + help="Sleep time between animation frames", + type="float", default=0.1) + + self.add_option('-c', '--color', dest='colorset', + help="Color of the string(s)", + action='append', default=[]) + + self.add_option('', '--reverse', dest='forwards', + help="Reverse direction of animation", + action="store_false", default=True) + + self.add_option('', '--switchback', dest='switchback', + help="'Switchback' strings, make a single string display like it's " + "more than one every m globes", + type="int") + + self.add_option('', '--sb-gap', dest='sb_gap', + help="Have a gap of n globes between each switchback", + type="int", default=0) + + self.add_option('', '--disable-swapdir', dest='disable_swapdir', + help="Disables swapping direction with each switchback", + action="store_true", default=False) + + def parseOptions(self): + """ + Emulate twistedmatrix options parser API + """ + options, args = self.parse_args() + self.options = options + self.args = args + + self.postOptions() + + return self.options, self.args + + def postOptions(self): + if len(self.args) < 1: + self.args = [ SIM_ADDR, ] + pass + + # If we want more strings than we have arguments, + # then use the first argument as the base addr:port combo + # for the set, and increment port numbers. This behaviour + # is used with the simulator. If we specify all of them + # individually, then the arguments length is how many + # strings we have, and overrides the -n setting + if self.options.numstrings < len(self.args): + self.options.numstrings = len(self.args) + + if self.options.colorset: + import webcolors + colorset = [ webcolors.hex_to_rgb(x) for x in self.options.colorset ] + self.options.colorset = colorset + +class vHoliday(object): + """ + A 'virtual' Holiday, used for implementing switchback mode and + other things on n real Holidays. + + A virtual Holiday can be shorter than a real Holiday, or exactly + as long; it cannot (for now) be longer than a real one. + + FIXME: Enable a virtual Holiday to be longer than a real Holiday + so we could span Holidays if we wanted to. + """ + def __init__(self, hols=None, start=0, length=NUM_GLOBES, direction=True): + """ + Each parameter is a list of one or more physical Holidays + + @param hols: a list of Holiday objects to talk to + @param start: The starting offset for this virtual holiday + @param length: the length of this virtual Holiday + @param direction: If True, move from low to high idx, if False, from high to low idx + """ + if hols is None: + self.hols = [] + self.hols.append(UDPHoliday('localhost', 9988)) + else: + self.hols = hols + pass + + self.start = start + if length > NUM_GLOBES: + raise ValueError("Virtual Holidays cannot be longer than real ones.") + self.length = length + + self.direction = direction + + def map_globe_idx(self, srcidx): + """ + Map the 'virtual' globe index onto the 'real' holiday and globe index + """ + # positive direction + if self.direction: + dstidx = self.start + srcidx + else: + dstidx = self.start + self.length - srcidx - 1 + pass + + # Check we haven't gone off the end of our 'virtual' string + if abs(dstidx - self.start) > self.length: + raise ValueError("globe %d not valid on this vHoliday" % srcidx) + + holid = 0 + return (holid, dstidx) + + # Implement the standard Holiday API for the virtual Holiday + def getglobe(self, globenum): + holid, idx = self.map_globe_idx(globenum) + return self.hols[holid].getglobe(idx) + + def setglobe(self, globenum, color): + holid, idx = self.map_globe_idx(globenum) + #log.debug("set globe: %d %s [ %d, %d ]", globenum, color, holid, idx) + res = self.hols[holid].setglobe(idx, color[0], color[1], color[2]) + + def set_pattern(self, pattern): + for globenum, color in enumerate(pattern): + holid, idx = self.map_globe_idx(globenum) + self.hols[holid].setglobe(idx, color[0], color[1], color[2]) + pass + pass + + def fill(self, color): + for hol in self.hols: + hol.fill(color[0], color[1], color[2]) + pass + pass + + def chase(self, direction=True): + raise NotImplementedError + + def rotate(self, direction=True): + raise NotImplementedError + + def render(self): + """ + Render all physical Holidays mapped to this virtual Holiday + """ + for hol in self.hols: + hol.render() + +class PhlostonString(vHoliday): + """ + A PhlostonString is a Holiday turned into a Phloston Paradise visual alarm light. + + Turns a Holiday into Phloston Paradise hotel evacuation lighting, from the movie + The Fifth Element. + """ + def __init__(self, hols=None, start=0, + length=NUM_GLOBES, direction=True, + color=None, pattern=None): + """ + @param color: An (r,g,b) tuple of the lights colour + @param pattern: An optional pattern of light colours to use + """ + super(PhlostonString, self).__init__(hols, start, length, direction) + + if pattern is not None: + self.pattern = pattern + elif color is not None: + self.pattern = [ color, ] * length + else: + self.pattern = [ (0xaa, 0x00, 0x00), ] * length + pass + + self.numlit = 0 + + def animate(self, forwards=True): + # Animation sequence is to start blank, then light each + # globe in sequence until all are lit, then start again. + + # Run animation 'forwards' + if forwards: + # Light the globes that are lit + for i in range(0, self.numlit): + #log.debug("lit: %d", i) + self.setglobe(i, self.pattern[i]) + pass + + # Blank those that are not lit + for i in range(self.numlit, self.length+1): + #log.debug("unlit: %d", i) + self.setglobe(i, (0x00, 0x00, 0x00)) + pass + else: + # Light the globes that are lit + for i in range(0, self.numlit): + #log.debug("lit: %d", i) + self.setglobe(self.length-i-1, self.pattern[i]) + pass + + # Blank those that are not lit + for i in range(self.numlit, self.length+1): + #log.debug("unlit: %d", i) + self.setglobe(self.length-i-1, (0x00, 0x00, 0x00)) + pass + + self.numlit += 1 + if self.numlit > self.length: + self.numlit = 0 + pass + + def set_pattern(self, pattern): + if len(pattern) > self.length: + self.pattern = pattern[:self.length] + elif len(pattern) < self.length: + # blank pad out short patterns + self.pattern = pattern + ( [(0,0,0),] * (self.length - len(self.pattern)) ) + else: + self.pattern = pattern + +if __name__ == '__main__': + + usage = "Usage: %prog [options] []" + optparse = PhlostonOptions() + + options, args = optparse.parseOptions() + + hols = [] + if len(args) > 1: + for arg in args: + hol_addr, hol_port = arg.split(':') + hols.append(UDPHoliday(ipaddr=hol_addr, port=int(hol_port))) + else: + hol_addr, hol_port = args[0].split(':') + for i in range(options.numstrings): + hols.append(UDPHoliday(ipaddr=hol_addr, port=int(hol_port)+i)) + pass + + # Figure out how many 'virtual' strings we have + vhols = [] + + if options.switchback: + length = options.switchback + pieces = int(math.floor(float(NUM_GLOBES) / options.switchback)) + + else: + length = NUM_GLOBES + pieces = 1 + pass + + num_vhols = pieces * options.numstrings + + if options.colorset: + # Use the last defined color to make up the full set + # This allows us to define one color for all strings, + # or up to n of m total string colors + if len(options.colorset) < num_vhols: + lastcolor = options.colorset[-1] + for i in range( num_vhols - len(options.colorset) ): + options.colorset.append(lastcolor) + pass + pass + + for i in range(num_vhols): + # Use the same physical holiday for each chunk of pieces + holid = int(i / pieces) + if options.disable_swapdir: + direction = True + else: + direction = not (i % 2) + + start = length * (i % pieces) + if i > 0: + start += options.sb_gap + pass + + #log.debug("holid: %d, direction: %d, start: %d", holid, direction, start) + if options.colorset: + vhol = PhlostonString( [hols[holid], ], + start, + length, + direction, + options.colorset[i]) + else: + vhol = PhlostonString( [hols[holid], ], + start, + length, + direction) + vhols.append(vhol) + pass + + while True: + for i, hol in enumerate(vhols): + vhols[i].animate(options.forwards) + pass + + for hol in hols: + hol.render() + + # Wait for next timetick + time.sleep(options.anim_sleep) + pass + pass + diff --git a/examples/phlostonapp.py b/examples/phlostonapp.py new file mode 100755 index 0000000..0d2cefe --- /dev/null +++ b/examples/phlostonapp.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python +""" +Phloston Paradise Bomb-in-the-hotel style display for MooresCloud Holiday +ButtonApp version + +Copyright (c) 2013 Justin Warren +License: MIT (see LICENSE for details) +""" + +__author__ = "Justin Warren" +__version__ = '0.02-dev' +__license__ = "MIT" + +import time +import logging +import colorsys +import threading + +from api.base import ButtonHoliday, ButtonApp + +from phloston import PhlostonString + +log = logging.getLogger('phloston_app') +handler = logging.StreamHandler() +handler.setFormatter(logging.Formatter("%(asctime)s: %(name)s [%(levelname)s]: %(message)s")) +log.addHandler(handler) +log.setLevel(logging.DEBUG) + +# Amount of time between frames of animation +SNOOZETIME = 0.02 + +# Amount of hue to change when Up/Down button is pressed (0.0 - 1.0) +HUESTEP = 0.05 + +# What color to start as. (r,g,b) tuple +START_COLOR = (100,0,0) + +class PhlostonApp(ButtonApp): + """ + An app for a physical Holiday + """ + def start(self): + self.t = PhlostonThread() + self.t.start() + + def stop(self): + self.t.terminate = True + + def change_hue(self, hdelta): + """ + Change the color hue by hdelta, a number between -1.0 and +0.1 + """ + (r,g,b) = self.t.get_color() + # Shift the colour hue by hdelta + (h,s,v) = colorsys.rgb_to_hsv(r/255.0, g/255.0, b/255.0) + h += hdelta + # Wraparound hue + if h < 0.0: + h += 1.0 + elif h > 1.0: + h -= 1.0 + pass + + (r,g,b) = colorsys.hsv_to_rgb(h, s, v) + # Convert to 0-255 range integers + newcol = (int(r*255.0), int(g*255.0), int(b*255.0)) + self.t.set_color(newcol) + + def up(self): + """ + Make the colour hue shift upwards + """ + self.change_hue(HUESTEP) + + def down(self): + self.change_hue(-HUESTEP) + +class PhlostonThread(threading.Thread): + """ + Singleton thread that runs the animation + """ + def run(self): + self.terminate = False + + # A Phloston string on 1 ButtonHoliday + self.phloston = PhlostonString([ButtonHoliday(),], color=START_COLOR) + + while True: + if self.terminate: + return + + # animate + self.phloston.animate() + self.phloston.render() + + # sleep + time.sleep(SNOOZETIME) + + pass + + def set_color(self, newcolor): + """ + Change the color of the string + """ + if not hasattr(self, 'phloston'): + return + self.phloston.set_pattern( [ newcolor, ] * self.phloston.length ) + + def get_color(self): + if not hasattr(self, 'phloston'): + return (0,0,0) + color = self.phloston.pattern[0] + return color + +if __name__ == '__main__': + app = PhlostonApp() + app.start() + time.sleep(1) + + app.up() + time.sleep(0.1) + app.up() + + time.sleep(0.1) + app.down() + + time.sleep(0.1) + app.down() + + time.sleep(0.1) + app.down() + + time.sleep(0.1) + app.down() + + time.sleep(0.1) + app.down() + + time.sleep(0.1) + app.down() + + time.sleep(0.1) + app.down() + + + time.sleep(3) + + app.stop() + diff --git a/examples/simplexnoise.py b/examples/simplexnoise.py new file mode 100644 index 0000000..231b783 --- /dev/null +++ b/examples/simplexnoise.py @@ -0,0 +1,576 @@ +# Copyright (c) 2012 Eliot Eshelman +# +# 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 . +############################################################################### + +"""2D, 3D and 4D Simplex Noise functions return 'random' values in (-1, 1). + +This algorithm was originally designed by Ken Perlin, but my code has been +adapted from the implementation written by Stefan Gustavson (stegu@itn.liu.se) + +Raw Simplex noise functions return the value generated by Ken's algorithm. + +Scaled Raw Simplex noise functions adjust the range of values returned from the +traditional (-1, 1) to whichever bounds are passed to the function. + +Multi-Octave Simplex noise functions compine multiple noise values to create a +more complex result. Each successive layer of noise is adjusted and scaled. + +Scaled Multi-Octave Simplex noise functions scale the values returned from the +traditional (-1,1) range to whichever range is passed to the function. + +In many cases, you may think you only need a 1D noise function, but in practice +2D is almost always better. For instance, if you're using the current frame +number as the parameter for the noise, all objects will end up with the same +noise value at each frame. By adding a second parameter on the second +dimension, you can ensure that each gets a unique noise value and they don't +all look identical. +""" + +import math + +def octave_noise_2d(octaves, persistence, scale, x, y): + """2D Multi-Octave Simplex noise. + + For each octave, a higher frequency/lower amplitude function will be added + to the original. The higher the persistence [0-1], the more of each + succeeding octave will be added. + """ + total = 0.0 + frequency = scale + amplitude = 1.0 + + # We have to keep track of the largest possible amplitude, + # because each octave adds more, and we need a value in [-1, 1]. + maxAmplitude = 0.0; + + for i in range(octaves): + total += raw_noise_2d(x * frequency, y * frequency) * amplitude + frequency *= 2.0 + maxAmplitude += amplitude; + amplitude *= persistence + + return total / maxAmplitude + +def octave_noise_3d(octaves, persistence, scale, x, y, z): + """3D Multi-Octave Simplex noise. + + For each octave, a higher frequency/lower amplitude function will be added + to the original. The higher the persistence [0-1], the more of each + succeeding octave will be added. + """ + total = 0.0 + frequency = scale + amplitude = 1.0 + + # We have to keep track of the largest possible amplitude, + # because each octave adds more, and we need a value in [-1, 1]. + maxAmplitude = 0.0; + + for i in range(octaves): + total += raw_noise_3d( x * frequency, + y * frequency, + z * frequency) * amplitude + frequency *= 2.0 + maxAmplitude += amplitude; + amplitude *= persistence + + return total / maxAmplitude + +def octave_noise_4d(octaves, persistence, scale, x, y, z, w): + """4D Multi-Octave Simplex noise. + + For each octave, a higher frequency/lower amplitude function will be added + to the original. The higher the persistence [0-1], the more of each + succeeding octave will be added. + """ + total = 0.0 + frequency = scale + amplitude = 1.0 + + # We have to keep track of the largest possible amplitude, + # because each octave adds more, and we need a value in [-1, 1]. + maxAmplitude = 0.0; + + for i in range(octaves): + total += raw_noise_4d( x * frequency, + y * frequency, + z * frequency, + w * frequency) * amplitude + frequency *= 2.0 + maxAmplitude += amplitude; + amplitude *= persistence + + return total / maxAmplitude + +def scaled_octave_noise_2d(octaves, persistence, scale, loBound, hiBound, x, y): + """2D Scaled Multi-Octave Simplex noise. + + Returned value will be between loBound and hiBound. + """ + return (octave_noise_2d(octaves, persistence, scale, x, y) * + (hiBound - loBound) / 2 + + (hiBound + loBound) / 2) + +def scaled_octave_noise_3d(octaves, persistence, scale, loBound, hiBound, x, y, z): + """3D Scaled Multi-Octave Simplex noise. + + Returned value will be between loBound and hiBound. + """ + return (octave_noise_3d(octaves, persistence, scale, x, y, z) * + (hiBound - loBound) / 2 + + (hiBound + loBound) / 2) + +def scaled_octave_noise_4d(octaves, persistence, scale, loBound, hiBound, x, y, z, w): + """4D Scaled Multi-Octave Simplex noise. + + Returned value will be between loBound and hiBound. + """ + return (octave_noise_4d(octaves, persistence, scale, x, y, z, w) * + (hiBound - loBound) / 2 + + (hiBound + loBound) / 2) + +def scaled_raw_noise_2d(loBound, hiBound, x, y): + """2D Scaled Raw Simplex noise. + + Returned value will be between loBound and hiBound. + """ + return (raw_noise_2d(x, y) * + (hiBound - loBound) / 2+ + (hiBound + loBound) / 2) + +def scaled_raw_noise_3d(loBound, hiBound, x, y, z): + """3D Scaled Raw Simplex noise. + + Returned value will be between loBound and hiBound. + """ + return (raw_noise_3d(x, y, z) * + (hiBound - loBound) / 2+ + (hiBound + loBound) / 2) + +def scaled_raw_noise_4d(loBound, hiBound, x, y, z, w): + """4D Scaled Raw Simplex noise. + + Returned value will be between loBound and hiBound. + """ + return (raw_noise_4d(x, y, z, w) * + (hiBound - loBound) / 2+ + (hiBound + loBound) / 2) + +def raw_noise_2d(x, y): + """2D Raw Simplex noise.""" + # Noise contributions from the three corners + n0, n1, n2 = 0.0, 0.0, 0.0 + + # Skew the input space to determine which simplex cell we're in + F2 = 0.5 * (math.sqrt(3.0) - 1.0) + # Hairy skew factor for 2D + s = (x + y) * F2 + i = int(x + s) + j = int(y + s) + + G2 = (3.0 - math.sqrt(3.0)) / 6.0 + t = float(i + j) * G2 + # Unskew the cell origin back to (x,y) space + X0 = i - t + Y0 = j - t + # The x,y distances from the cell origin + x0 = x - X0 + y0 = y - Y0 + + # For the 2D case, the simplex shape is an equilateral triangle. + # Determine which simplex we are in. + i1, j1 = 0, 0 # Offsets for second (middle) corner of simplex in (i,j) coords + if x0 > y0: # lower triangle, XY order: (0,0)->(1,0)->(1,1) + i1 = 1 + j1 = 0 + else: # upper triangle, YX order: (0,0)->(0,1)->(1,1) + i1 = 0 + j1 = 1 + + # A step of (1,0) in (i,j) means a step of (1-c,-c) in (x,y), and + # a step of (0,1) in (i,j) means a step of (-c,1-c) in (x,y), where + # c = (3-sqrt(3))/6 + x1 = x0 - i1 + G2 # Offsets for middle corner in (x,y) unskewed coords + y1 = y0 - j1 + G2 + x2 = x0 - 1.0 + 2.0 * G2 # Offsets for last corner in (x,y) unskewed coords + y2 = y0 - 1.0 + 2.0 * G2 + + # Work out the hashed gradient indices of the three simplex corners + ii = int(i) & 255 + jj = int(j) & 255 + gi0 = _perm[ii+_perm[jj]] % 12 + gi1 = _perm[ii+i1+_perm[jj+j1]] % 12 + gi2 = _perm[ii+1+_perm[jj+1]] % 12 + + # Calculate the contribution from the three corners + t0 = 0.5 - x0*x0 - y0*y0 + if t0 < 0: + n0 = 0.0 + else: + t0 *= t0 + n0 = t0 * t0 * dot2d(_grad3[gi0], x0, y0) + + t1 = 0.5 - x1*x1 - y1*y1 + if t1 < 0: + n1 = 0.0 + else: + t1 *= t1 + n1 = t1 * t1 * dot2d(_grad3[gi1], x1, y1) + + t2 = 0.5 - x2*x2-y2*y2 + if t2 < 0: + n2 = 0.0 + else: + t2 *= t2 + n2 = t2 * t2 * dot2d(_grad3[gi2], x2, y2) + + # Add contributions from each corner to get the final noise value. + # The result is scaled to return values in the interval [-1,1]. + return 70.0 * (n0 + n1 + n2) + +def raw_noise_3d(x, y, z): + """3D Raw Simplex noise.""" + # Noise contributions from the four corners + n0, n1, n2, n3 = 0.0, 0.0, 0.0, 0.0 + + # Skew the input space to determine which simplex cell we're in + F3 = 1.0/3.0 + # Very nice and simple skew factor for 3D + s = (x+y+z) * F3 + i = int(x + s) + j = int(y + s) + k = int(z + s) + + G3 = 1.0 / 6.0 + t = float(i+j+k) * G3 + # Unskew the cell origin back to (x,y,z) space + X0 = i - t + Y0 = j - t + Z0 = k - t + # The x,y,z distances from the cell origin + x0 = x - X0 + y0 = y - Y0 + z0 = z - Z0 + + # For the 3D case, the simplex shape is a slightly irregular tetrahedron. + # Determine which simplex we are in. + i1, j1, k1 = 0,0,0 # Offsets for second corner of simplex in (i,j,k) coords + i2, j2, k2 = 0,0,0 # Offsets for third corner of simplex in (i,j,k) coords + + if x0 >= y0: + if y0 >= z0: # X Y Z order + i1 = 1 + j1 = 0 + k1 = 0 + i2 = 1 + j2 = 1 + k2 = 0 + elif x0 >= z0: # X Z Y order + i1 = 1 + j1 = 0 + k1 = 0 + i2 = 1 + j2 = 0 + k2 = 1 + else: # Z X Y order + i1 = 0 + j1 = 0 + k1 = 1 + i2 = 1 + j2 = 0 + k2 = 1 + else: + if y0 < z0: # Z Y X order + i1 = 0 + j1 = 0 + k1 = 1 + i2 = 0 + j2 = 1 + k2 = 1 + elif x0 < z0: # Y Z X order + i1 = 0 + j1 = 1 + k1 = 0 + i2 = 0 + j2 = 1 + k2 = 1 + else: # Y X Z order + i1 = 0 + j1 = 1 + k1 = 0 + i2 = 1 + j2 = 1 + k2 = 0 + + # A step of (1,0,0) in (i,j,k) means a step of (1-c,-c,-c) in (x,y,z), + # a step of (0,1,0) in (i,j,k) means a step of (-c,1-c,-c) in (x,y,z), and + # a step of (0,0,1) in (i,j,k) means a step of (-c,-c,1-c) in (x,y,z), where + # c = 1/6. + x1 = x0 - i1 + G3 # Offsets for second corner in (x,y,z) coords + y1 = y0 - j1 + G3 + z1 = z0 - k1 + G3 + x2 = x0 - i2 + 2.0*G3 # Offsets for third corner in (x,y,z) coords + y2 = y0 - j2 + 2.0*G3 + z2 = z0 - k2 + 2.0*G3 + x3 = x0 - 1.0 + 3.0*G3 # Offsets for last corner in (x,y,z) coords + y3 = y0 - 1.0 + 3.0*G3 + z3 = z0 - 1.0 + 3.0*G3 + + # Work out the hashed gradient indices of the four simplex corners + ii = int(i) & 255 + jj = int(j) & 255 + kk = int(k) & 255 + gi0 = _perm[ii+_perm[jj+_perm[kk]]] % 12 + gi1 = _perm[ii+i1+_perm[jj+j1+_perm[kk+k1]]] % 12 + gi2 = _perm[ii+i2+_perm[jj+j2+_perm[kk+k2]]] % 12 + gi3 = _perm[ii+1+_perm[jj+1+_perm[kk+1]]] % 12 + + # Calculate the contribution from the four corners + t0 = 0.6 - x0*x0 - y0*y0 - z0*z0 + if t0 < 0: + n0 = 0.0 + else: + t0 *= t0 + n0 = t0 * t0 * dot3d(_grad3[gi0], x0, y0, z0) + + t1 = 0.6 - x1*x1 - y1*y1 - z1*z1 + if t1 < 0: + n1 = 0.0 + else: + t1 *= t1 + n1 = t1 * t1 * dot3d(_grad3[gi1], x1, y1, z1) + + t2 = 0.6 - x2*x2 - y2*y2 - z2*z2 + if t2 < 0: + n2 = 0.0 + else: + t2 *= t2 + n2 = t2 * t2 * dot3d(_grad3[gi2], x2, y2, z2) + + t3 = 0.6 - x3*x3 - y3*y3 - z3*z3 + if t3 < 0: + n3 = 0.0 + else: + t3 *= t3 + n3 = t3 * t3 * dot3d(_grad3[gi3], x3, y3, z3) + + # Add contributions from each corner to get the final noise value. + # The result is scaled to stay just inside [-1,1] + return 32.0 * (n0 + n1 + n2 + n3) + +def raw_noise_4d(x, y, z, w): + """4D Raw Simplex noise.""" + # Noise contributions from the five corners + n0, n1, n2, n3, n4 = 0.0, 0.0, 0.0, 0.0, 0.0 + + # The skewing and unskewing factors are hairy again for the 4D case + F4 = (math.sqrt(5.0)-1.0) / 4.0 + # Skew the (x,y,z,w) space to determine which cell of 24 simplices we're in + s = (x + y + z + w) * F4 + i = int(x + s) + j = int(y + s) + k = int(z + s) + l = int(w + s) + + G4 = (5.0-math.sqrt(5.0)) / 20.0 + t = (i + j + k + l) * G4 + # Unskew the cell origin back to (x,y,z,w) space + X0 = i - t + Y0 = j - t + Z0 = k - t + W0 = l - t + # The x,y,z,w distances from the cell origin + x0 = x - X0 + y0 = y - Y0 + z0 = z - Z0 + w0 = w - W0 + + # For the 4D case, the simplex is a 4D shape I won't even try to describe. + # To find out which of the 24 possible simplices we're in, we need to + # determine the magnitude ordering of x0, y0, z0 and w0. + # The method below is a good way of finding the ordering of x,y,z,w and + # then find the correct traversal order for the simplex we're in. + # First, six pair-wise comparisons are performed between each possible pair + # of the four coordinates, and the results are used to add up binary bits + # for an integer index. + c1 = 32 if x0 > y0 else 0 + c2 = 16 if x0 > z0 else 0 + c3 = 8 if y0 > z0 else 0 + c4 = 4 if x0 > w0 else 0 + c5 = 2 if y0 > w0 else 0 + c6 = 1 if z0 > w0 else 0 + c = c1 + c2 + c3 + c4 + c5 + c6 + + i1, j1, k1, l1 = 0,0,0,0 # The integer offsets for the second simplex corner + i2, j2, k2, l2 = 0,0,0,0 # The integer offsets for the third simplex corner + i3, j3, k3, l3 = 0,0,0,0 # The integer offsets for the fourth simplex corner + + # simplex[c] is a 4-vector with the numbers 0, 1, 2 and 3 in some order. + # Many values of c will never occur, since e.g. x>y>z>w makes x= 3 else 0 + j1 = 1 if _simplex[c][1] >= 3 else 0 + k1 = 1 if _simplex[c][2] >= 3 else 0 + l1 = 1 if _simplex[c][3] >= 3 else 0 + # The number 2 in the "simplex" array is at the second largest coordinate. + i2 = 1 if _simplex[c][0] >= 2 else 0 + j2 = 1 if _simplex[c][1] >= 2 else 0 + k2 = 1 if _simplex[c][2] >= 2 else 0 + l2 = 1 if _simplex[c][3] >= 2 else 0 + # The number 1 in the "simplex" array is at the second smallest coordinate. + i3 = 1 if _simplex[c][0] >= 1 else 0 + j3 = 1 if _simplex[c][1] >= 1 else 0 + k3 = 1 if _simplex[c][2] >= 1 else 0 + l3 = 1 if _simplex[c][3] >= 1 else 0 + # The fifth corner has all coordinate offsets = 1, so no need to look that up. + x1 = x0 - i1 + G4 # Offsets for second corner in (x,y,z,w) coords + y1 = y0 - j1 + G4 + z1 = z0 - k1 + G4 + w1 = w0 - l1 + G4 + x2 = x0 - i2 + 2.0*G4 # Offsets for third corner in (x,y,z,w) coords + y2 = y0 - j2 + 2.0*G4 + z2 = z0 - k2 + 2.0*G4 + w2 = w0 - l2 + 2.0*G4 + x3 = x0 - i3 + 3.0*G4 # Offsets for fourth corner in (x,y,z,w) coords + y3 = y0 - j3 + 3.0*G4 + z3 = z0 - k3 + 3.0*G4 + w3 = w0 - l3 + 3.0*G4 + x4 = x0 - 1.0 + 4.0*G4 # Offsets for last corner in (x,y,z,w) coords + y4 = y0 - 1.0 + 4.0*G4 + z4 = z0 - 1.0 + 4.0*G4 + w4 = w0 - 1.0 + 4.0*G4 + + # Work out the hashed gradient indices of the five simplex corners + ii = int(i) & 255 + jj = int(j) & 255 + kk = int(k) & 255 + ll = int(l) & 255 + gi0 = _perm[ii+_perm[jj+_perm[kk+_perm[ll]]]] % 32 + gi1 = _perm[ii+i1+_perm[jj+j1+_perm[kk+k1+_perm[ll+l1]]]] % 32 + gi2 = _perm[ii+i2+_perm[jj+j2+_perm[kk+k2+_perm[ll+l2]]]] % 32 + gi3 = _perm[ii+i3+_perm[jj+j3+_perm[kk+k3+_perm[ll+l3]]]] % 32 + gi4 = _perm[ii+1+_perm[jj+1+_perm[kk+1+_perm[ll+1]]]] % 32 + + # Calculate the contribution from the five corners + t0 = 0.6 - x0*x0 - y0*y0 - z0*z0 - w0*w0 + if t0 < 0: + n0 = 0.0 + else: + t0 *= t0 + n0 = t0 * t0 * dot4d(_grad4[gi0], x0, y0, z0, w0) + + t1 = 0.6 - x1*x1 - y1*y1 - z1*z1 - w1*w1 + if t1 < 0: + n1 = 0.0 + else: + t1 *= t1 + n1 = t1 * t1 * dot4d(_grad4[gi1], x1, y1, z1, w1) + + t2 = 0.6 - x2*x2 - y2*y2 - z2*z2 - w2*w2 + if t2 < 0: + n2 = 0.0 + else: + t2 *= t2 + n2 = t2 * t2 * dot4d(_grad4[gi2], x2, y2, z2, w2) + + t3 = 0.6 - x3*x3 - y3*y3 - z3*z3 - w3*w3 + if t3 < 0: + n3 = 0.0 + else: + t3 *= t3 + n3 = t3 * t3 * dot4d(_grad4[gi3], x3, y3, z3, w3) + + t4 = 0.6 - x4*x4 - y4*y4 - z4*z4 - w4*w4 + if t4 < 0: + n4 = 0.0 + else: + t4 *= t4 + n4 = t4 * t4 * dot4d(_grad4[gi4], x4, y4, z4, w4) + + # Sum up and scale the result to cover the range [-1,1] + return 27.0 * (n0 + n1 + n2 + n3 + n4) + + +def dot2d(g, x, y): + return g[0]*x + g[1]*y + +def dot3d(g, x, y, z): + return g[0]*x + g[1]*y + g[2]*z + +def dot4d(g, x, y, z, w): + return g[0]*x + g[1]*y + g[2]*z + g[3]*w + + +"""The gradients are the midpoints of the vertices of a cube.""" +_grad3 = [ + [1,1,0], [-1,1,0], [1,-1,0], [-1,-1,0], + [1,0,1], [-1,0,1], [1,0,-1], [-1,0,-1], + [0,1,1], [0,-1,1], [0,1,-1], [0,-1,-1] +] + +"""The gradients are the midpoints of the vertices of a cube.""" +_grad4 = [ + [0,1,1,1], [0,1,1,-1], [0,1,-1,1], [0,1,-1,-1], + [0,-1,1,1], [0,-1,1,-1], [0,-1,-1,1], [0,-1,-1,-1], + [1,0,1,1], [1,0,1,-1], [1,0,-1,1], [1,0,-1,-1], + [-1,0,1,1], [-1,0,1,-1], [-1,0,-1,1], [-1,0,-1,-1], + [1,1,0,1], [1,1,0,-1], [1,-1,0,1], [1,-1,0,-1], + [-1,1,0,1], [-1,1,0,-1], [-1,-1,0,1], [-1,-1,0,-1], + [1,1,1,0], [1,1,-1,0], [1,-1,1,0], [1,-1,-1,0], + [-1,1,1,0], [-1,1,-1,0], [-1,-1,1,0], [-1,-1,-1,0] +] + +"""Permutation table. The same list is repeated twice.""" +_perm = [ + 151,160,137,91,90,15,131,13,201,95,96,53,194,233,7,225,140,36,103,30,69,142, + 8,99,37,240,21,10,23,190,6,148,247,120,234,75,0,26,197,62,94,252,219,203,117, + 35,11,32,57,177,33,88,237,149,56,87,174,20,125,136,171,168,68,175,74,165,71, + 134,139,48,27,166,77,146,158,231,83,111,229,122,60,211,133,230,220,105,92,41, + 55,46,245,40,244,102,143,54,65,25,63,161,1,216,80,73,209,76,132,187,208,89, + 18,169,200,196,135,130,116,188,159,86,164,100,109,198,173,186,3,64,52,217,226, + 250,124,123,5,202,38,147,118,126,255,82,85,212,207,206,59,227,47,16,58,17,182, + 189,28,42,223,183,170,213,119,248,152,2,44,154,163,70,221,153,101,155,167,43, + 172,9,129,22,39,253,19,98,108,110,79,113,224,232,178,185,112,104,218,246,97, + 228,251,34,242,193,238,210,144,12,191,179,162,241,81,51,145,235,249,14,239, + 107,49,192,214,31,181,199,106,157,184,84,204,176,115,121,50,45,127,4,150,254, + 138,236,205,93,222,114,67,29,24,72,243,141,128,195,78,66,215,61,156,180, + + 151,160,137,91,90,15,131,13,201,95,96,53,194,233,7,225,140,36,103,30,69,142, + 8,99,37,240,21,10,23,190,6,148,247,120,234,75,0,26,197,62,94,252,219,203,117, + 35,11,32,57,177,33,88,237,149,56,87,174,20,125,136,171,168,68,175,74,165,71, + 134,139,48,27,166,77,146,158,231,83,111,229,122,60,211,133,230,220,105,92,41, + 55,46,245,40,244,102,143,54,65,25,63,161,1,216,80,73,209,76,132,187,208,89, + 18,169,200,196,135,130,116,188,159,86,164,100,109,198,173,186,3,64,52,217,226, + 250,124,123,5,202,38,147,118,126,255,82,85,212,207,206,59,227,47,16,58,17,182, + 189,28,42,223,183,170,213,119,248,152,2,44,154,163,70,221,153,101,155,167,43, + 172,9,129,22,39,253,19,98,108,110,79,113,224,232,178,185,112,104,218,246,97, + 228,251,34,242,193,238,210,144,12,191,179,162,241,81,51,145,235,249,14,239, + 107,49,192,214,31,181,199,106,157,184,84,204,176,115,121,50,45,127,4,150,254, + 138,236,205,93,222,114,67,29,24,72,243,141,128,195,78,66,215,61,156,180 +] + +"""A lookup table to traverse the simplex around a given point in 4D.""" +_simplex = [ + [0,1,2,3],[0,1,3,2],[0,0,0,0],[0,2,3,1],[0,0,0,0],[0,0,0,0],[0,0,0,0],[1,2,3,0], + [0,2,1,3],[0,0,0,0],[0,3,1,2],[0,3,2,1],[0,0,0,0],[0,0,0,0],[0,0,0,0],[1,3,2,0], + [0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0], + [1,2,0,3],[0,0,0,0],[1,3,0,2],[0,0,0,0],[0,0,0,0],[0,0,0,0],[2,3,0,1],[2,3,1,0], + [1,0,2,3],[1,0,3,2],[0,0,0,0],[0,0,0,0],[0,0,0,0],[2,0,3,1],[0,0,0,0],[2,1,3,0], + [0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0],[0,0,0,0], + [2,0,1,3],[0,0,0,0],[0,0,0,0],[0,0,0,0],[3,0,1,2],[3,0,2,1],[0,0,0,0],[3,1,2,0], + [2,1,0,3],[0,0,0,0],[0,0,0,0],[0,0,0,0],[3,1,0,2],[0,0,0,0],[3,2,0,1],[3,2,1,0] +] diff --git a/examples/soundlevel.py b/examples/soundlevel.py index 529d245..e5b69c3 100755 --- a/examples/soundlevel.py +++ b/examples/soundlevel.py @@ -48,11 +48,13 @@ def render(hol, value): CHANNELS = 2 RATE = 44100 INPUT_BLOCK_TIME = 0.02 - BUFFER = 4096 #Seems to work... + BUFFER = 1024 #Seems to work... # How do we select the appropriate input device? - input_device = 0 #Built-in Microphone (seems good for OSX) + #input_device = 0 #Built-in Microphone (seems good for OSX) #input_device = 3 # this seems to be correct for juno + input_device = 15 # Ubuntu default + pa = pyaudio.PyAudio() @@ -60,10 +62,10 @@ def render(hol, value): channels = CHANNELS, rate = RATE, input = True, - input_device_index = input_device, + #input_device_index = input_device, frames_per_buffer = BUFFER) - SCALE = 100 # Probably need to tweak this + SCALE = 300 # Probably need to tweak this MAX_LIGHT = 50 errorcount = 0 print "Press Ctrl-C to quit" @@ -78,4 +80,4 @@ def render(hol, value): #print amplitude render(hol, min(amplitude / SCALE, MAX_LIGHT)) - \ No newline at end of file + diff --git a/examples/twinkle.py b/examples/twinkle.py new file mode 100755 index 0000000..5f95aee --- /dev/null +++ b/examples/twinkle.py @@ -0,0 +1,399 @@ +#!/usr/bin/python +""" +Twinkling pretty lights on the Holiday +""" + +import optparse +import time +import sys +import random +import colorsys +import webcolors +import json + +from simplexnoise import raw_noise_2d + +from secretapi.holidaysecretapi import HolidaySecretAPI + +import logging +log = logging.getLogger(sys.argv[0]) +handler = logging.StreamHandler() +handler.setFormatter(logging.Formatter("%(asctime)s: %(name)s [%(levelname)s]: %(message)s")) +log.addHandler(handler) +log.setLevel(logging.DEBUG) + +DEFAULT_HOL_PORT = 9988 + +class TwinkleOptions(optparse.OptionParser): + """ + Command-line options parser + """ + def __init__(self, *args, **kwargs): + optparse.OptionParser.__init__(self, **kwargs) + self.addOptions() + + def addOptions(self): + self.add_option('-n', '--numstrings', dest='numstrings', + help="Number of Holiday strings to use [%default]", + type="int", default=1) + + self.add_option('-b', '--basecolor', dest='basecolor', + help="Color to initialize string to, as #nnnnnn", + type="string") + + self.add_option('-c', '--change_chance', dest='change_chance', + help="% chance a given globe will change each round [%default]", + type="float", default=1.0 ) + + self.add_option('-a', '--animsleep', dest='anim_sleep', + help="Sleep between animation frames, in seconds [%default]", + type="float", default=0.1 ) + + self.add_option('-f', '--patternfile', dest='patternfile', + help="Initalise string with a pattern from a JSON format file", + type="string") + + self.add_option('-t', '--twinkle-algo', dest='twinkle_algo', + help="Algorithm to use for twinkling [%default]", + type="choice", choices=['random', 'simplex', 'throb', 'chase'], default='simplex') + + self.add_option('-i', '--init-only', dest='initonly', + help="Initialize string(s) and exit", + action="store_true") + + self.add_option('-H', '--HUESTEP', dest='huestep_max', + help="Maximum step between hues [%default]", + type="float", default=0.1 ) + + self.add_option('-S', '--SATSTEP', dest='satstep_max', + help="Maximum step between saturations [%default]", + type="float", default=0.01 ) + + self.add_option('-V', '--VALSTEP', dest='valstep_max', + help="Maximum step between values [%default]", + type="float", default=0.2 ) + + self.add_option('', '--huediff-max', dest='huediff_max', + help="Maximum hue difference from basecolor [%default]", + type="float", default=0.0 ) + + self.add_option('', '--satdiff-max', dest='satdiff_max', + help="Maximum saturation difference from basecolor [%default]", + type="float", default=0.0 ) + + self.add_option('', '--valdiff-max', dest='valdiff_max', + help="Maximum value difference from basecolor [%default]", + type="float", default=0.0 ) + + self.add_option('', '--chase-forwards', dest='chase', + help="Lights chase around the string", + action="store_true") + + self.add_option('', '--chase-backwards', dest='chase', + help="Lights chase around the string", + action="store_false") + + self.add_option('', '--simplex-damper', dest='simplex_damper', + help="Amount of simplex noise dampening [%default]", + type="float", default=2.0) + + self.add_option('', '--throb-speed', dest='throb_speed', + help="Speed of throb animation [%default]", + type="float", default=2.0) + + self.add_option('', '--throb-up', dest='throb_dir', + help="Start throbbing up", + action="store_true") + + self.add_option('', '--throb-down', dest='throb_dir', + help="Start throbbing down", + action="store_false") + + def parseOptions(self): + """ + Emulate twistedmatrix options parser API + """ + options, args = self.parse_args() + self.options = options + self.args = args + + self.postOptions() + + return self.options, self.args + + def postOptions(self): + if len(self.args) < 1: + self.error("Specify address and port of remote Holiday(s)") + pass + + self.options.initpattern = None + if self.options.patternfile: + with open(self.options.patternfile) as fp: + jdata = json.load(fp) + self.options.initpattern = jdata['lights'] + pass + pass + +def init_hol(hol, basecolor=None, pattern=None): + """ + Initialize a Holiday to some random-ish colors + """ + if basecolor is not None: + (r, g, b) = webcolors.hex_to_rgb(basecolor) + hol.fill(r, g, b) + elif pattern is not None: + for globeidx, vals in enumerate(pattern): + (r, g, b) = webcolors.hex_to_rgb(vals) + hol.setglobe(globeidx, r, g, b) + pass + else: + for globeidx in range(0, HolidaySecretAPI.NUM_GLOBES): + color = [] + # red + color.append(random.randint(0, 130)) + #color.append(0) + # green + color.append(random.randint(0, 130)) + # blue + color.append(random.randint(0, 130)) + #color.append(0) + + r, g, b = color + + hol.setglobe(globeidx, r, g, b) + pass + hol.render() + + pattern = hol.globes[:] + return pattern + +def twinkle_holiday(hol, options, init_pattern, noise_array=None): + """ + Make a Holiday twinkle like the stars + """ + # For each globe, mostly have a random drift of brightness + # and hue by but occasionally jump in brightness up or down + if options.basecolor: + (base_r, base_g, base_b) = webcolors.hex_to_rgb(options.basecolor) + base_hsv = colorsys.rgb_to_hsv(base_r/255.0, base_g/255.0, base_b/255.0) + pass + + if noise_array is None: + noise_array = [ 0, ] * HolidaySecretAPI.NUM_GLOBES + pass + + if options.twinkle_algo == 'throb': + + # Increase brightness by throb_speed / anim_sleep + # until max brightness, and then decrease + r, g, b = hol.getglobe(0) + (h, s, v) = colorsys.rgb_to_hsv(r/255.0, g/255.0, b/255.0) + + # Check direction for throb. The limits are set to be visually + # interesting, because value above about 0.6 doesn't look much + # different. + if v > 0.6: + options.throb_dir = False + elif v < 0.02: + options.throb_dir = True + + throb_amount = (1.0 / options.throb_speed * options.anim_sleep) + for idx in range(0, HolidaySecretAPI.NUM_GLOBES): + r, g, b = hol.getglobe(idx) + (h, s, v) = colorsys.rgb_to_hsv(r/255.0, g/255.0, b/255.0) + if options.throb_dir: + v += throb_amount + if v > 0.91: + v = 0.91 + else: + v -= throb_amount + if v < 0.02: + v = 0.02 + pass + (r, g, b) = colorsys.hsv_to_rgb(h, s, v) + hol.setglobe(idx, int(255*r), int(255*g), int(255*b)) + pass + pass + + elif options.twinkle_algo in ['simplex', 'random']: + + for idx in range(0, HolidaySecretAPI.NUM_GLOBES): + + # Choose globe update algorithm + if options.twinkle_algo == 'simplex': + nv = raw_noise_2d(noise_array[idx], random.random()) / options.simplex_damper + noise_array[idx] += nv + if noise_array[idx] > 1.0: + noise_array[idx] = 1.0 + pass + elif noise_array[idx] < -1.0: + noise_array[idx] = -1.0 + pass + + ranger = (noise_array[idx] + 1.0) / 1.0 + #log.debug("ranger: %f", ranger) + # Adjust colour. If basecolor, adjust from basecolor + if options.basecolor: + (base_r, base_g, base_b) = webcolors.hex_to_rgb(options.basecolor) + #log.debug("adjusting from base: %d %d %d", base_r, base_g, base_b) + r = int(base_r * ranger) + g = int(base_g * ranger) + b = int(base_b * ranger) + pass + else: + # adjust from original color + (base_r, base_g, base_b) = init_pattern[idx] + #log.debug("init pattern: %s", init_pattern[idx]) + #log.debug("adjusting from orig: %d %d %d", base_r, base_g, base_b) + r = int(base_r * ranger) + #log.debug("adjusted red from orig: %d %d %d", base_r, base_g, base_b) + g = int(base_g * ranger) + b = int(base_b * ranger) + pass + + if r > 255: + r = 255 + if g > 255: + g = 255 + if b > 255: + b = 255 + + hol.setglobe(idx, r, g, b) + #log.debug("init pattern: %s", init_pattern[idx]) + else: + # % chance of updating a given globe + if random.random() < options.change_chance: + + r, g, b = hol.getglobe(idx) + (h, s, v) = colorsys.rgb_to_hsv(r/255.0, g/255.0, b/255.0) + #log.debug("start h s v: %f %f %f", h, s, v) + # Adjust hue by a random amount + huestep = random.random() * options.huestep_max + # 50% chance of positive or negative step + if random.randint(0, 1): + h += huestep + if options.basecolor and abs(base_hsv[0] - h) > options.huediff_max: + h = base_hsv[0] + options.huediff_max + if h > 1.0: + h = h - 1.0 + else: + h -= huestep + if options.basecolor and abs(h - base_hsv[0]) > options.huediff_max: + h = base_hsv[0] - options.huediff_max + + if h < 0.0: + h = 1.0 + h + pass + + satstep = random.random() * options.satstep_max + if random.randint(0, 1): + s += satstep + if options.basecolor and abs(base_hsv[1] - s) > options.satdiff_max: + s = base_hsv[1] + options.satdiff_max + + if s > 1.0: + s = 1.0 + else: + s -= satstep + if options.basecolor and abs(s - base_hsv[1]) > options.satdiff_max: + s = base_hsv[1] - options.satdiff_max + + # Make sure things stay bright and colorful! + if s < 0.0: + s = 0.0 + + # Adjust value by a random amount + valstep = random.random() * options.valstep_max + # 50% chance of positive or negative step + if random.randint(0, 1): + v += valstep + if options.basecolor and abs(base_hsv[2] - v) > options.valdiff_max: + v = base_hsv[2] + options.valdiff_max + + if v > 1.0: + v = 1.0 + else: + v -= valstep + if options.basecolor and abs(v - base_hsv[2]) > options.valdiff_max: + v = base_hsv[2] - options.valdiff_max + + if v < 0.2: + v = 0.2 + pass + + #log.debug("end h s v: %f %f %f", h, s, v) + + (r, g, b) = colorsys.hsv_to_rgb(h, s, v) + #log.debug("r g b: %f %f %f", r, g, b) + hol.setglobe(idx, int(255*r), int(255*g), int(255*b)) + pass + pass + pass + pass + elif options.twinkle_algo in ['chase']: + # Chase mode? + if options.chase: + # Rotate all globes around by one place + oldglobes = hol.globes[:] + hol.globes = oldglobes[1:] + hol.globes.append(oldglobes[0]) + pass + else: + #log.debug("old: %s", hol.globes) + oldglobes = hol.globes[:] + hol.globes = oldglobes[:-1] + hol.globes.insert(0, oldglobes[-1]) + #log.debug("new: %s", hol.globes) + pass + + hol.render() + +if __name__ == '__main__': + + usage = "Usage: %prog [options] [ ... ]" + optparse = TwinkleOptions(usage=usage) + + options, args = optparse.parseOptions() + + hols = [] + # List of holiday initial patterns + hol_inits = [] + + # List of holiday noise patterns + hol_noise = [] + + def split_addr_args(arg): + split_args = arg.split(':') + if len(split_args) == 1: + hol_addr = split_args[0] + hol_port = DEFAULT_HOL_PORT + else: + hol_addr = split_args[0] + hol_port = split_args[1] + return hol_addr, hol_port + + if len(args) > 1: + for arg in args: + hol_addr, hol_port = split_addr_args(arg) + hols.append(HolidaySecretAPI(addr=hol_addr, port=int(hol_port))) + else: + hol_addr, hol_port = split_addr_args(args[0]) + for i in range(options.numstrings): + hols.append(HolidaySecretAPI(addr=hol_addr, port=int(hol_port)+i)) + pass + + # Initialise holidays + for hol in hols: + hol_inits.append(init_hol(hol, options.basecolor, options.initpattern)) + hol_noise.append(None) + pass + + if options.initonly: + sys.exit(0) + + while True: + for i, hol in enumerate(hols): + noise = twinkle_holiday(hol, options, hol_inits[i], hol_noise[i]) + pass + time.sleep(options.anim_sleep) + diff --git a/examples/twinkleapp.py b/examples/twinkleapp.py new file mode 100644 index 0000000..62debb3 --- /dev/null +++ b/examples/twinkleapp.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python +""" +ButtonApp version of twinkle.py +Makes a Holiday 'twinkle' with various light patterns/styles + +Copyright (c) 2013 Justin Warren +License: MIT (see LICENSE for details) +""" + +import threading +import random +import json +import time +import os.path +import colorsys + +import webcolors + +from api.base import ButtonHoliday, ButtonApp + +from simplexnoise import raw_noise_2d + +BASEPATH = os.path.abspath(os.path.dirname(__file__)) +PATTERN_DIR = os.path.join(BASEPATH, 'patterns') + +def load_patternfile(filename): + with open(filename) as fp: + pattern = [ webcolors.hex_to_rgb(x) for x in json.load(fp)['lights'] ] + return pattern + pass + +MODES = [ + # candela mode + {'twinkle_algo': 'simplex', + 'simplex_damper': 4.0, + 'init_pattern': [(176, 119, 31),] * ButtonHoliday.NUM_GLOBES, + 'chase': None, + 'snoozetime': 0.04, + }, + + # Xmas twinkle + {'twinkle_algo': 'simplex', + 'simplex_damper': 4.0, + 'init_pattern': load_patternfile(os.path.join(PATTERN_DIR, 'xmas.json')), + 'chase': None, + 'snoozetime': 0.04, + }, + + # Xmas chase + {'twinkle_algo': 'chase_only', + 'init_pattern': load_patternfile(os.path.join(PATTERN_DIR, 'xmas2.json')), + 'chase': True, + 'snoozetime': 0.5, + }, + + # Australian Green and Gold twinkle + {'twinkle_algo': 'simplex', + 'simplex_damper': 4.0, + 'init_pattern': load_patternfile(os.path.join(PATTERN_DIR, 'greenandgold.json')), + 'chase': None, + 'snoozetime': 0.04, + }, + + # Australian Green and Gold chaser + {'twinkle_algo': 'chase_only', + 'init_pattern': load_patternfile(os.path.join(PATTERN_DIR, 'greenandgold.json')), + 'simplex_damper': 4.0, + 'chase': True, + 'snoozetime': 0.1, + }, + + # Simple random mode + {'twinkle_algo': 'random_shift', + 'init_pattern': [ (random.randint(0, 130), + random.randint(0, 130), + random.randint(0, 130)) for x in range(ButtonHoliday.NUM_GLOBES) ], + 'change_chance': 0.5, + 'huestep_max': 0.1, + 'satstep_max': 0.1, + 'valstep_max': 0.1, + 'chase': None, + 'snoozetime': 0.1, + }, + + # Random mode with limits + {'twinkle_algo': 'random_limits', + 'init_pattern': [ (random.randint(0, 130), + random.randint(0, 130), + random.randint(0, 130)) for x in range(ButtonHoliday.NUM_GLOBES) ], + 'change_chance': 0.2, + 'huestep_max': 0.05, + 'satstep_max': 0.05, + 'valstep_max': 0.05, + + 'huediff_max': 0.3, + 'satdiff_max': 0.3, + 'valdiff_max': 0.3, + 'chase': None, + 'snoozetime': 0.1, + }, + + ] + +class Twinkler(object): + + def __init__(self, hol, options): + self.hol = hol + self.options = options + self.noise_array = [ 0, ] * self.hol.NUM_GLOBES + + # initialise the holiday with the options.pattern + self.set_pattern(options['init_pattern']) + pass + + def set_pattern(self, pattern): + self.hol.set_pattern(pattern) + pass + + def render(self): + self.hol.render() + + def twinkle_simplex(self, idx): + nv = raw_noise_2d(self.noise_array[idx], + random.random()) / self.options.get('simplex_damper', 4.0) + self.noise_array[idx] += nv + if self.noise_array[idx] > 1.0: + self.noise_array[idx] = 1.0 + pass + elif self.noise_array[idx] < -1.0: + self.noise_array[idx] = -1.0 + pass + + ranger = (self.noise_array[idx] + 1.0) / 2.0 + + # Adjust colour. + (base_r, base_g, base_b) = self.options['init_pattern'][idx] + #log.debug("adjusting from orig: %d %d %d", base_r, base_g, base_b) + r = int(base_r * ranger) + g = int(base_g * ranger) + b = int(base_b * ranger) + self.hol.setglobe(idx, r, g, b) + pass + + def rand_change_colcomp(self, val, stepmax, baseval=None, diffmax=None): + """ + Change a colour component value from a baseline using a random perturbation + """ + if baseval is None: + baseval = val + pass + + valstep = random.random() * stepmax + # 50% chance of positive or negative step + if random.randint(0, 1): + val += valstep + if diffmax and abs(baseval - val) > diffmax: + val = baseval + diffmax + pass + if val > 1.0: + val -= 1.0 + else: + val -= valstep + if diffmax and abs(baseval - val) > diffmax: + val = baseval - diffmax + pass + if val < 0.0: + val += 1.0 + pass + return val + + def twinkle_random_shift(self, idx): + """ + Randomly change a bulb from its current value + """ + # % chance of updating a given globe + if random.random() < self.options['change_chance']: + + r, g, b = self.hol.getglobe(idx) + (h, s, v) = colorsys.rgb_to_hsv(r/255.0, g/255.0, b/255.0) + #log.debug("start h s v: %f %f %f", h, s, v) + + # Adjust color components by a random amount + h = self.rand_change_colcomp(h, self.options['huestep_max']) + s = self.rand_change_colcomp(s, self.options['satstep_max']) + v = self.rand_change_colcomp(v, self.options['valstep_max']) + + (r, g, b) = colorsys.hsv_to_rgb(h, s, v) + #log.debug("r g b: %f %f %f", r, g, b) + self.hol.setglobe(idx, int(255*r), int(255*g), int(255*b)) + pass + pass + + def twinkle_random_limits(self, idx): + """ + Randomly change a bulb from its baseline value within limits + """ + (base_r, base_g, base_b) = self.options['init_pattern'][idx] + (base_h, base_s, base_v) = colorsys.rgb_to_hsv(base_r/255.0, + base_g/255.0, + base_b/255.0) + + # % chance of updating a given globe + if random.random() < self.options['change_chance']: + + r, g, b = self.hol.getglobe(idx) + (h, s, v) = colorsys.rgb_to_hsv(r/255.0, g/255.0, b/255.0) + #log.debug("start h s v: %f %f %f", h, s, v) + + # Adjust color components by a random amount + h = self.rand_change_colcomp(h, self.options['huestep_max'], base_h, self.options['huediff_max']) + s = self.rand_change_colcomp(s, self.options['satstep_max'], base_s, self.options['satdiff_max']) + v = self.rand_change_colcomp(v, self.options['valstep_max'], base_v, self.options['valdiff_max']) + + (r, g, b) = colorsys.hsv_to_rgb(h, s, v) + #log.debug("r g b: %f %f %f", r, g, b) + self.hol.setglobe(idx, int(255*r), int(255*g), int(255*b)) + pass + pass + + def twinkle(self): + """ + Change globe colours using some algorithm + """ + for idx in range(0, self.hol.NUM_GLOBES): + + + if self.options['twinkle_algo'] == 'simplex': + self.twinkle_simplex(idx) + pass + + elif self.options['twinkle_algo'] == 'random_limits': + self.twinkle_random_limits(idx) + pass + + # No globe colour updates, just chase + elif self.options['twinkle_algo'] == 'chase_only': + pass + + else: + self.twinkle_random_shift(idx) + pass + pass + + # Chase enabled? + if self.options['chase'] is not None: + self.hol.chase(self.options['chase']) + pass + + self.hol.render() + pass + +class TwinkleApp(ButtonApp): + + def start(self): + self.t = TwinkleThread() + self.modenum = 0 + self.t.set_mode(self.modenum) + self.t.start() + + def stop(self): + self.t.terminate = True + + def up(self): + self.modenum += 1 + if self.modenum > len(MODES)-1: + self.modenum = 0 + pass + self.t.set_mode(self.modenum) + pass + + def down(self): + self.modenum -= 1 + if self.modenum < 0: + self.modenum = len(MODES)-1 + pass + self.t.set_mode(self.modenum) + pass + +class TwinkleThread(threading.Thread): + """ + Singleton thread that runs the animation + """ + def run(self): + self.terminate = False + + self.hol = ButtonHoliday() + self.modenum = 0 + self.twinkler = Twinkler(self.hol, MODES[self.modenum]) + + while True: + if self.terminate: + return + + self.twinkler.twinkle() + time.sleep(MODES[self.modenum]['snoozetime']) + pass + pass + + def set_mode(self, modenum): + self.modenum = modenum + if hasattr(self, 'twinkler'): + self.twinkler.options = MODES[modenum] + + # Reset the initialisation pattern + self.twinkler.set_pattern(MODES[modenum]['init_pattern']) + pass + pass + pass + +if __name__ == '__main__': + app = TwinkleApp() + app.start() + time.sleep(1) + app.down() + time.sleep(1) + app.up() + time.sleep(1) + + for i in range(len(MODES)-1): + app.up() + print "running in mode", app.modenum + time.sleep(2) + pass + + app.up() + print "running in mode", app.modenum + time.sleep(2) + + app.stop() diff --git a/iotas/devices/moorescloud/holiday/driver.py b/iotas/devices/moorescloud/holiday/driver.py index 8ac908f..19841ae 100644 --- a/iotas/devices/moorescloud/holiday/driver.py +++ b/iotas/devices/moorescloud/holiday/driver.py @@ -9,8 +9,8 @@ License: MIT (see LICENSE for details) """ -__author__ = 'Mark Pesce' -__version__ = '1.0b3' +__author__ = 'Justin Warren' +__version__ = '1.1b1' __license__ = 'MIT' import subprocess, time, os @@ -20,35 +20,6 @@ from bottle import request, abort class Holiday: -# def old__init__(self, remote=False, address='sim', name='nameless'): -# self.numleds = 50 -# self.leds = [] # Array of LED values. This may actually exist elsewhere eventually. -# self.address = '' -# self.name = name -# self.isSim = False -# -# if remote == False: -# self.remote = False -# if address == 'sim': -# self.pipename = os.path.join(os.path.expanduser('~'), 'pipelights.fifo') -# self.address = address -# else: -# self.pipename = "/run/pipelights.fifo" -# self.address = address -# try: -# self.pipe = open(self.pipename,"wb") -# except: -# print "Couldn't open the pipe, there's gonna be trouble!" -# ln = 0 -# else: -# self.address = address -# self.remote = True -# -# for ln in range(self.numleds): -# self.leds.append([0x00, 0x00, 0x00]) # Create and clear an array of RGB LED values -# -# return - def __init__(self, remote=False, address='sim', name='nameless', queue=None): self.numleds = 50 self.leds = [] # Array of LED values. This may actually exist elsewhere eventually. @@ -239,6 +210,54 @@ def do_rainbow(): return json.dumps({"success": success}) + @theapp.put(routebase + 'stopapps') + def do_stopapps(): + try: + fp = open('/run/pipebuttons.fifo', 'a') + fp.write('O\n') + fp.close() + success = True + except: + success = False + + return json.dumps({"success": success}) + + @theapp.put(routebase + 'button/mode') + def do_button_mode(): + try: + fp = open('/run/pipebuttons.fifo', 'a') + fp.write('M\n') + fp.close() + success = True + except: + success = False + + return json.dumps({"success": success}) + + @theapp.put(routebase + 'button/up') + def do_button_up(): + try: + fp = open('/run/pipebuttons.fifo', 'a') + fp.write('+\n') + fp.close() + success = True + except: + success = False + + return json.dumps({"success": success}) + + @theapp.put(routebase + 'button/down') + def do_button_down(): + try: + fp = open('/run/pipebuttons.fifo', 'a') + fp.write('-\n') + fp.close() + success = True + except: + success = False + + return json.dumps({"success": success}) + @theapp.put(routebase + 'runapp') def do_runapp(): """Starts/stops the named app""" @@ -260,20 +279,21 @@ def do_runapp(): return if (dj['isStart'] == True): - print "starting app %s" % dj['appname'] - app_path = os.path.join(self.appbase, dj['appname']) - print 'app_path: %s' % app_path - try: - c = subprocess.call(['/home/holiday/scripts/start-app.sh', app_path], shell=False) - print "%s app started" % dj['appname'] - success = True - except subprocess.CalledProcessError: - print "Error starting process" - success = False + appname = dj['appname'] + try: + fp = open('/run/pipebuttons.fifo', 'a') + fp.write('R%s\n' % appname) + fp.close() + success = True + print "%s app started" % appname + except: + success = False else: print "stopping %s app" % dj['appname'] try: - c = subprocess.call(['/home/holiday/scripts/stop-app.sh'], shell=True) + fp = open('/run/pipebuttons.fifo', 'a') + fp.write('O\n') + fp.close() print "%s app stopped" % dj['appname'] success = True except subprocess.CalledProcessError: @@ -288,7 +308,7 @@ def get_version(): @theapp.get(routebase + 'swift_version') def get_swift_version(): - return json.dumps({ "version": "1.0b3" }) + return json.dumps({ "version": "1.1b1" }) @theapp.get(routebase) diff --git a/iotas/iotas.py b/iotas/iotas.py index 2b24a09..44ec9db 100755 --- a/iotas/iotas.py +++ b/iotas/iotas.py @@ -62,6 +62,11 @@ def redirect_404(error): else: return server_root() +@app.error(401) +def access_error(error): + print 'Access error on: %s' % request.url + return 'access error' + @app.route('/') def server_root(): global docroot diff --git a/iotas/version b/iotas/version index 0c1cef8..53f805d 100644 --- a/iotas/version +++ b/iotas/version @@ -1 +1 @@ -1.0a5 +1.1b1 diff --git a/iotas/www/apps/rainbow/rainbow.js b/iotas/www/apps/rainbow/rainbow.js index e9ef00b..3d17e95 100755 --- a/iotas/www/apps/rainbow/rainbow.js +++ b/iotas/www/apps/rainbow/rainbow.js @@ -17,7 +17,6 @@ function rainbow() { function appStart() { console.log("rainbow.appStart"); $("head").append(''); - loadTest(); } // Quit App diff --git a/iotas/www/index.html b/iotas/www/index.html index d2b333d..414763a 100755 --- a/iotas/www/index.html +++ b/iotas/www/index.html @@ -45,7 +45,7 @@ console.log(addr); // Setup IoTAS - console.log('instancing iotas') + console.log('creating iotas object'); var iotasrv = new iotas(document.URL); iotasrv.get_status(); @@ -53,6 +53,12 @@ // This is what we use to communicate with the simulator. // A bit of a hack, but it seems to work. var loc = window.location.hostname + ":" + window.location.port; + if (window.location.port.length == 0) { + var loc = window.location.hostname; + } else { + var loc = window.location.hostname + ":" + window.location.port; + } + console.log(loc); var defaultholidays = [ ['sim', loc ] ]; @@ -163,4 +169,4 @@

Your Holiday Name

- \ No newline at end of file + diff --git a/iotas/www/js/iotas.js b/iotas/www/js/iotas.js index 01c7f52..7a4daa8 100755 --- a/iotas/www/js/iotas.js +++ b/iotas/www/js/iotas.js @@ -7,7 +7,7 @@ // Requires a string 'address' (i.e. IP address 192.168.0.20) or resolvable name (i.e. 'light.local') function iotas(address) { - console.log("Instancing iotas"); + console.log("Instancing iotas with ", address); this.address = address; if (address.length == 0) { @@ -20,17 +20,22 @@ function iotas(address) { this.ip_addr = null; this.vers = null; this.hostnm = null; - this.apis = null; + this.apis = null; + this.local_device = null; + this.local_name = null; + this.device_url = null; this.get_status = get_status; // Get the status of IoTAS, config information, etc. // Bit sneaky in its use of global variables, but whatevs. function get_status() { - $.ajax({ + console.log('get_status: ', iotasrv.urlbase + 'iotas'); + $.ajax({ type: "GET", async: false, url: iotasrv.urlbase + 'iotas', - success: function(data) { + success: function(data) { + console.log('get_status success'); jd = JSON.parse(data) console.log(jd); iotasrv.ip_addr = jd.ip; @@ -43,8 +48,8 @@ function iotas(address) { console.log("iotas.device_url is " + iotasrv.device_url); console.log(iotasrv); }, - error: function() { - console.log("iotas.get_status failed"); + error: function(jqXHR, textStatus, errorThrown) { + console.log(textStatus, errorThrown); } }); } diff --git a/simgame/__init__.py b/simgame/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/simgame/holiday.py b/simgame/holiday.py new file mode 100644 index 0000000..0691087 --- /dev/null +++ b/simgame/holiday.py @@ -0,0 +1,199 @@ +""" +Simulated Holiday Server +""" +import select, socket, os +import array + +from Queue import Empty +from multiprocessing import Process, Queue +from iotas import iotas + +UDP_HEADER_LENGTH = 10 +UDP_DATA_LENGTH = 150 +UDP_MSG_LENGTH = UDP_HEADER_LENGTH + UDP_DATA_LENGTH + +class HolidayRemote(object): + """ + A simulated remote Holiday. + + This object pretends to be a physical Holiday light, which + listens for instructions via both TCP (for the WebAPI) and + also via UDP (using the SecretLabsAPI). + """ + # FIXME: The core code should all be in one place, not duplicated + # in every subdirectory the way it is now. + NUM_GLOBES = 50 + def __init__(self, remote=False, addr='', + tcpport=None, + udpport=None, + notcp=False, + noudp=False, + nofifo=False, + ): + + # Initialise globes to zero + self.globes = [ [0x00, 0x00, 0x00] ] * self.NUM_GLOBES + + self.notcp = notcp + self.noudp = noudp + self.nofifo = nofifo + + if not remote: + self.addr = addr + + if not noudp: + if udpport is None: + bound_port = False + for udpport in range(9988, 10100): + try: + self.bind_udp(udpport) + bound_port = True + break + except socket.error, e: + num, s = e + # Try again if port is in use, else raise + if num != 98: + raise + pass + pass + # If we get this far, bail out + if not bound_port: + raise ValueError("Can't find available UDP port in range 9988 to 10100") + + else: + self.bind_udp(udpport) + pass + print "UDP listening on (%s, %s)" % (self.addr, self.udpport) + pass + + # Set up REST API on a TCP port + if not notcp: + self.q = Queue() + + self.iop = Process(target=iotas.run, kwargs={ 'port': 8080, + 'queue': self.q }) + self.iop.start() + + # Set up a named pipe for local single string simulation + if not nofifo: + fd = os.open('/run/compose.fifo', os.O_RDONLY | os.O_NONBLOCK) + self.fifofp = os.fdopen(fd, 'r') + self.fifobuf = '' + self.fifobytes = (18) + (9 * self.NUM_GLOBES) + else: + raise NotImplementedError("Listening Simulator only. Does not send to remote devices.") + + def exit(self): + """ + Force shutdown of processes + """ + if hasattr(self, 'iop'): + self.iop.terminate() + + def __del__(self): + self.exit() + + def bind_udp(self, udpport): + """ + Try to bind to a UDP port, retrying a range if one is already in use + """ + self.udpport = udpport + self.udpsock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.udpsock.setblocking(0) + self.udpsock.bind((self.addr, self.udpport)) + + def recv_udp(self): + """ + Receive data on the UDP port and process it + """ + try: + data, addr = self.udpsock.recvfrom(512) # 512 byte buffer. Should be heaps + except socket.error, e: + num, msg = e + # This is EWOULDBLOCK, which we ignore + if num == 11: + return + + # Basic check that data is in the right format + # All SecretLabsAPI packets should be 160 bytes: + # 10 bytes of header (unused atm) and 150 bytes of data + if len(data) != UDP_MSG_LENGTH: + # Just ignore it for now + print "Incorrect msg length: %d" % len(data) + return + + header = data[:10] + globedata = array.array('B', data[10:]) + + # Iterate over globedata 3 at a time + # FIXME: replace with something from itertools? + for i in range(0, self.NUM_GLOBES): + coldata = globedata[:3] + globedata = globedata[3:] + self.globes[i] = [ coldata[0], coldata[1], coldata[2] ] + pass + + def recv_tcp(self): + """ + Receive data on the TCP port and process it + + Reception of data is via the Queue + """ + # Get all the data available, and only use the latest + # This will throw away all old data if the main loop is + # too slow, so we at least catch up. + data = None + while True: + try: + data = self.q.get(block=False) + + except Empty: + break + pass + + if data is not None: + # Data is a list of globe values encoded as + # 3 x 2-char hex values, one per line + globedata = data.split() + for i, vals in enumerate(globedata): + r = int(vals[:2], 16) + g = int(vals[2:4], 16) + b = int(vals[4:6], 16) + self.globes[i] = [ r, g, b ] + pass + pass + pass + + def recv_fifo(self): + """ + Try to read data from the FIFO. + Data starts with a header of 0x000010 followed by the hex encoded + PID value of the sending process. + Each globe value is encoded as 3 x 2-char hex values, + with a 0x prefix. + It is a total of 468 bytes long for a 50 globe string. + """ + try: + self.fifobuf += self.fifofp.read(self.fifobytes) + except IOError, e: + # Error 11 is when data isn't available on the pipe + # so we ignore it. + if e.errno == 11: + pass + + # Only process when there is enough data + while len(self.fifobuf) >= self.fifobytes: + data = self.fifobuf[:self.fifobytes] + self.fifobuf = self.fifobuf[self.fifobytes:] + header = data[:9] + pid = data[9:18] + globeraw = data[18:] + globedata = globeraw.split('\n') + + for i, vals in enumerate(globedata[:self.NUM_GLOBES]): + if len(vals) < 8: + break + r = int(vals[2:4], 16) + g = int(vals[4:6], 16) + b = int(vals[6:8], 16) + self.globes[i] = [ r, g, b ] diff --git a/simgame/simgame.py b/simgame/simgame.py new file mode 100755 index 0000000..15f4897 --- /dev/null +++ b/simgame/simgame.py @@ -0,0 +1,382 @@ +#!/usr/bin/python +# +""" +Holiday simulator with a PyGame display for local development. + +Not as portable as the simpype in-browser display, but is more +responsive for things that need closer to hardware responsiveness. + +Also allows you to simulate a multi-Holiday display by aggregating +the display of multiple Holiday instances if required. +""" +import sys +import optparse +import math +import pygame +from holiday import HolidayRemote + +class BaseOptParser(optparse.OptionParser): + """ + Command-line options parser + """ + def __init__(self, *args, **kwargs): + optparse.OptionParser.__init__(self, **kwargs) + self.addOptions() + + def addOptions(self): + self.add_option('-f', '--fps', dest='fps', + help="Frames per second, used to slow down simulator", + type="int", default=30) + self.add_option('-n', '--numstrings', dest='numstrings', + help="Number of Holiday strings to simulate [%default]", + type="int", default=1) + + # Listen on multiple TCP/UDP ports, one for each Holiday we simulate + self.add_option('-t', '--tcp-portstart', dest='tcp_portstart', + help="Port number to start at for REST API listeners [%default]", + type="int", default=8080) + + self.add_option('-u', '--udp-portstart', dest='udp_portstart', + help="Port number to start at for UDP listeners [%default]", + type="int", default=9988) + + # Which way to draw multiple strings? Horizontally, like in the browser, + # or vertically, like a string curtain? + self.add_option('-o', '--orientation', dest='orientation', + help="Orientation of the strings [%default]", + type="choice", choices=['vertical', 'horizontal'], default='vertical') + + self.add_option('', '--flipped', dest='flipped', + help="Flip direction of the strings [%default]", + action="store_true", default=False) + + self.add_option('', '--spacing', dest='spacing', + help="Spacing between strings, in pixels. Overrides dynamic even spacing", + type="int") + + self.add_option('', '--switchback', dest='switchback', + help="'Switchback' strings, make a single string display like its " + "more than one every m globes", + type="int") + + self.add_option('', '--no-tcp', dest='notcp', + help="Disable TCP listener.", + action="store_true", default=False) + + self.add_option('', '--no-udp', dest='noudp', + help="Disable UDP listener.", + action="store_true", default=False) + + self.add_option('', '--no-fifo', dest='nofifo', + help="Disable FIFO listener.", + action="store_true", default=False) + + pass + + def parseOptions(self): + """ + Emulate twistedmatrix options parser API + """ + options, args = self.parse_args() + self.options = options + self.args = args + + self.postOptions() + + return self.options, self.args + + def postOptions(self): + pass + +class SimRunner(object): + """ + A simulator for multiple Holidays + + Each Holiday listens on a local IP address, but each on its own port. + """ + # Pixel width of gutters on each side of the screen + gutter_width = 50 + + # Size of each bulb + bulb_diam = 10 + + # Colour of the 'string' between the bulbs + string_color = pygame.Color(40,40,40) + + # width of the 'string' line, in px + string_width = 1 + + # Number of bulbs per Holiday + num_bulbs = 50 + + # space to leave at each end of the string, in px + string_header = 20 + string_footer = 10 + + def __init__(self, options): + self.options = options + self.setup() + + def setup(self): + + self.numstrings = self.options.numstrings + + self.screen_x = 1024 + self.screen_y = 768 + + self.HolidayList = [ ] + for i in range(0, self.numstrings): + self.HolidayList.append( HolidayRemote(notcp=self.options.notcp, + noudp=self.options.noudp, + nofifo=self.options.nofifo) ) + pass + + if self.options.switchback: + self.bulbs_per_piece = self.options.switchback + self.num_pieces = int(math.ceil( float(self.num_bulbs) / self.options.switchback)) + pass + else: + self.bulbs_per_piece = self.num_bulbs + self.num_pieces = 1 + + pass + + if not self.options.spacing and not self.options.switchback: + if self.numstrings > 1: + if self.options.orientation == 'vertical': + self.spacer_width = ((self.screen_x - (2*self.gutter_width)) / self.numstrings) - self.bulb_diam + elif self.options.orientation == 'horizontal': + self.spacer_width = ((self.screen_y - self.gutter_width) / self.numstrings) - self.bulb_diam + pass + pass + else: + self.spacer_width = 0 + pass + pass + elif self.options.spacing: + self.spacer_width = self.options.spacing + pass + else: + self.spacer_width = 2 * self.bulb_diam + pass + + pygame.init() + # Default font to use + self.fontsize = 24 + + self.myFont = pygame.font.SysFont("None", self.fontsize) + + self.start_x = 0 + self.gutter_width + self.start_y = (3 * self.myFont.get_height()) + + if self.options.orientation == 'vertical': + self.string_length = self.screen_y - self.start_y - self.gutter_width + elif self.options.orientation == 'horizontal': + self.string_length = self.screen_x - self.start_x - 2*self.gutter_width + + pass + + def run(self): + paused = False + + screen = pygame.display.set_mode([self.screen_x, self.screen_y]) + screen.fill((0,0,0)) + running, x, y, color, delta, fps = True, 25 , 0, (32,32,32), 1, 1000 + Clock = pygame.time.Clock() + + DISPLAY_REFRESH = pygame.USEREVENT + pygame.time.set_timer(DISPLAY_REFRESH, int(1000.0/self.options.fps)) + color = (255,255,255) + + while running: + + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + + elif event.type == pygame.KEYDOWN: + + # If quite key pressed, flag for exit + if event.key == pygame.K_ESCAPE or event.key == pygame.K_q: + running = False + + # Toggle pause + elif event.key == pygame.K_p: + if paused: + paused = False + else: + paused = True + pass + pass + + # reset all strings to blank + elif event.key == pygame.K_r: + self.blank_strings() + pass + pass + + elif event.type == DISPLAY_REFRESH: + + pygame.display.set_caption("Press Esc or q to quit. p to pause. r to reset. FPS: %.2f" % (Clock.get_fps())) + + # Black screen and update timer + screen.fill((0,0,0)) + + screen.blit(self.myFont.render("Holiday by MooresCloud Simulator", True, (color)), (x,y)) + screen.blit(self.myFont.render("(c) Justin Warren ", True, (color)), (x,y+self.myFont.get_height())) + + # Let the strings receive and process data + self.recv_data() + + # Draw the Holiday strings + self.draw_strings(screen) + + # Update the screen + pygame.display.update() + pass + pass + pygame.time.wait(0) + pass + pygame.quit() + + for hol in self.HolidayList: + hol.exit() + pass + pass + + def recv_data(self): + """ + Process any data each simulated string may have received + """ + for hol in self.HolidayList: + if not self.options.noudp: + hol.recv_udp() + if not self.options.notcp: + hol.recv_tcp() + if not self.options.nofifo: + hol.recv_fifo() + pass + + def blank_strings(self): + """ + Reset all strings to black + """ + for hol in self.HolidayList: + hol.globes = [ (0x00, 0x00, 0x00) ] * hol.NUM_GLOBES + pass + + def draw_strings(self, screen): + """ + Draw the Holiday strings on the screen + """ + # Figure out screen dimensions and place the strings accordingly + offset_per_bulb = (self.string_length-( + self.string_header+self.string_footer)) / self.bulbs_per_piece + + for i, hol in enumerate(self.HolidayList): + + if self.options.orientation == 'vertical': + for piece in range(self.num_pieces): + string_offset = self.start_x + ( i*( + self.num_pieces*(self.bulb_diam+self.spacer_width) + )) #+ i*self.spacer_width + xpos = string_offset + piece*(self.bulb_diam + self.spacer_width) + + pygame.draw.line(screen, self.string_color, + (xpos, self.start_y), + (xpos, self.start_y + self.string_length), + self.string_width) + + # Label the string with its number + if piece == 0: + screen.blit(self.myFont.render("%d" % i, True, (255,255,255)), + (xpos - self.bulb_diam/2, self.start_y + self.string_length + self.bulb_diam)) + pass + + # Draw the lights + for m, j in enumerate(range(piece * self.bulbs_per_piece, + min((piece+1)*self.bulbs_per_piece, hol.NUM_GLOBES) + ) + ): + # If even, start at the top and go down, unless flipped + if not piece % 2: + if self.options.flipped: + bulb_y = self.start_y + self.string_header + ( (self.bulbs_per_piece-1-m) * offset_per_bulb) + else: + bulb_y = self.start_y + self.string_header + (m * offset_per_bulb) + + # if odd, go from bottom to top + else: + if self.options.flipped: + bulb_y = self.start_y + self.string_header + (m * offset_per_bulb) + + else: + bulb_y = self.start_y + self.string_header + ( (self.bulbs_per_piece-1-m) * offset_per_bulb) + pass + + # Fetch the globe color for globe j + r, g, b = hol.globes[j] + pygame.draw.circle(screen, + pygame.Color(r, g, b), + (xpos, bulb_y), + self.bulb_diam / 2, + ) + pass + pass + pass + + elif self.options.orientation == 'horizontal': + + for piece in range(self.num_pieces): + string_offset = self.start_y + ( i*( + self.num_pieces*(self.bulb_diam+self.spacer_width) + )) #+ i*self.spacer_width + + ypos = string_offset + piece*(self.bulb_diam + self.spacer_width) + + pygame.draw.line(screen, self.string_color, + (self.start_x, ypos), + (self.start_x + self.string_length, ypos), + self.string_width) + + # Label the string with its number + if piece == 0: + screen.blit(self.myFont.render("%d" % i, True, (255,255,255)), + (self.bulb_diam, ypos-self.bulb_diam)) + pass + + # Draw the lights + for m, j in enumerate(range(piece * self.bulbs_per_piece, + min((piece+1)*self.bulbs_per_piece, hol.NUM_GLOBES) + ) + ): + # If even, start at the top and go down + if not piece % 2: + bulb_x = self.start_x + self.string_header + (m * offset_per_bulb) + # if odd, go from bottom to top + else: + bulb_x = self.start_x + self.string_header + ( (self.bulbs_per_piece-1-m) * offset_per_bulb) + pass + + # Fetch the globe color for globe j + r, g, b = hol.globes[j] + pygame.draw.circle(screen, + pygame.Color(r, g, b), + (bulb_x, ypos), + self.bulb_diam / 2, + ) + pass + pass + pass + pass + pass + pass + +if __name__ == '__main__': + optparse = BaseOptParser() + options, args = optparse.parseOptions() + + sim = SimRunner(options) + sim.run() + +