Repository: ManiacalLabs/BiblioPixel
Pull request #867 (2018-07-08 09:17:36)

For discussion: Refactor serial/devices.py and driver.py
The new code is somewhat clearer. 

More, it points out that under some codepaths, we can crash (raise an exception that is never handled) after initialization if any driver ever reports an error.

It is my belief that we shouldn't ever crash.  Perhaps it might be OK on construction of a driver (even then I don't think so), but certainly not just because we get an error during run.  It might be that there are several drivers and one gets unplugged, or simply a little line noise for a few minutes - it shouldn't bring the whole thing to a sticky end...


Patch:
diff --git a/bibliopixel/animation/collection.py b/bibliopixel/animation/collection.py
index 3670d3b..5e5851a 100644
--- a/bibliopixel/animation/collection.py
+++ b/bibliopixel/animation/collection.py
@@ -93,7 +93,9 @@ class _AnimationList:
         self._names = {a.name: i for i, a in enumerate(self._animations)}
 
     def _index(self, i):
-        return i if isinstance(i, int) else self._names[i]
+        if isinstance(i, int):
+            return i % len(self._animations)
+        return self._names[i]
 
     def append(self, animation):
         # We're forced to do the "fill in a name and make sure it's
diff --git a/bibliopixel/animation/fill.py b/bibliopixel/animation/fill.py
new file mode 100644
index 0000000..c3435ae
--- /dev/null
+++ b/bibliopixel/animation/fill.py
@@ -0,0 +1,31 @@
+from . animation import BaseAnimation
+from .. util import colors
+
+
+class Fill(BaseAnimation):
+    """
+    Fill the screen with a single color.
+    """
+    def __init__(self, *args, color='black', **kwds):
+        super().__init__(*args, preclear=False, **kwds)
+
+        is_numpy = hasattr(self.color_list, 'dtype')
+        self._set_color = self._set_numpy if is_numpy else self._set_classic
+
+    def pre_run(self):
+        self.color = self._color
+
+    @property
+    def color(self):
+        return self._color
+
+    @color.setter
+    def color(self, color):
+        self._color = colors.make_color(color)
+        self._set_color()
+
+    def _set_numpy(self):
+        self.color_list[:None] = self._color
+
+    def _set_classic(self):
+        self.color_list[:] = [self._color] * len(self.color_list)
diff --git a/bibliopixel/animation/gif_writer.py b/bibliopixel/animation/gif_writer.py
index 77f8642..633bc62 100644
--- a/bibliopixel/animation/gif_writer.py
+++ b/bibliopixel/animation/gif_writer.py
@@ -1,22 +1,11 @@
-import math, os, shutil, tempfile, time
 from . import wrapper
-from .. util.image import gif, renderer
-from .. util import colors, log
-
-
-DEFAULT_RENDER = {
-    'color': colors.Black,
-    'pixel_width': 4,
-    'pixel_height': None,
-    'ellipse': False,
-    'vertical': False,
-    'frame': 2,
-    'padding': 2,
-}
+from .. util.image import gif_writer as _gif_writer
 
 
 class GifWriter(wrapper.Wrapper):
-    """Write animated GIFs for each frame in the contained animation"""
+    """
+    An animation that writes animated GIFs for each frame in the contained
+    animation."""
 
     def __init__(self, *args, filename='gif_writer', render=None,
                  divide=1, frames=128, time=0, scale=1.0, gif_options=None,
@@ -45,95 +34,15 @@ class GifWriter(wrapper.Wrapper):
             directory, and do not delete them when done.  For testing purposes.
         """
         super().__init__(*args, **kwds)
-        render = dict(DEFAULT_RENDER, **(render or {}))
-        self.render = renderer.renderer(self.layout, **render)
-        self.divide = divide
-        self.frames = frames
-        self.time = time
-        self.frame_files = []
-        self.scale = scale
-        self.gif_options = gif_options or {}
-
-        filename = os.path.expanduser(os.path.abspath(filename))
-        file_root = filename[:-4] if filename.endswith('.gif') else filename
-        self.filename = file_root + '.gif'
-        os.makedirs(os.path.dirname(self.filename), exist_ok=True)
-
-        if tmp_dir:
-            self.tmp_dir_name = tmp_dir
-            shutil.rmtree(tmp_dir, ignore_errors=True)
-            os.makedirs(self.tmp_dir_name, exist_ok=True)
-        else:
-            self.tmp_dir = tempfile.TemporaryDirectory()
-            self.tmp_dir_name = self.tmp_dir.name
-        self.basename = os.path.basename(file_root)
-        self.finished = False
-        self.stop_after_write = True
-
-    def pre_run(self):
-        super().pre_run()
-        self.times = []
+        self._writer = _gif_writer.GifWriter(
+            filename, render, divide, frames, time, scale, gif_options, tmp_dir)
+        self._writer.set_layout(self.layout)
 
     def step(self, amt=1):
         super().step(amt)
-        if self.finished or (self.divide >= 1 and self.cur_step % self.divide):
-            return
-
-        frame = self.cur_step / max(self.divide, 1)
-        self.times.append(time.time())
-
-        if self.time:
-            elapsed = self.times[-1] - self.times[0]
-            self.finished = (elapsed >= self.time)
-        else:
-            self.finished = (frame >= self.frames)
-
-        if self.finished:
-            self._write_gif_file()
+        if self._writer.step(self.cur_step):
             self.completed = True
-        else:
-            frame_name = '%s%04d.gif' % (self.basename, frame)
-            filename = os.path.join(self.tmp_dir_name, frame_name)
-
-            self.render().save(filename)
-            self.frame_files.append(filename)
-
-    def _write_gif_file(self):
-        if not self.frame_files:
-            return
-
-        ff, self.frame_files = self.frame_files, []
-        go = dict(self.gif_options)
-
-        if 'duration' in go:
-            go.pop('fps', None)
-            try:
-                go['duration'] *= self.scale
-            except:
-                go['duration'] = [g * self.scale for g in go['duration']]
-
-        elif 'fps' in go:
-            go['fps'] /= self.scale
-
-        else:
-            # Compute the list of durations from the list of times
-            duration = []
-            for i, time in enumerate(self.times):  # noqa: 402
-                if not i:
-                    previous_time = time
-                else:
-                    # GIFs only accept time in 0.01 second increments
-                    duration.append(round(time - previous_time, 2))
-                    previous_time += duration[-1]
-
-            duration.append(duration[-1])
-            go = dict(go, duration=duration)
-
-        gif.write_animation(self.filename, ff, **go)
-        self.tmp_dir = None
 
-    def __del__(self):
-        try:
-            self._write_gif_file()
-        except Exception as e:
-            log.error('Failed to write GIF file: %s', e)
+    def cleanup(self, clean_layout=True):
+        super().cleanup(clean_layout)
+        self._writer.write()
diff --git a/bibliopixel/animation/indexed.py b/bibliopixel/animation/indexed.py
new file mode 100644
index 0000000..8d64199
--- /dev/null
+++ b/bibliopixel/animation/indexed.py
@@ -0,0 +1,79 @@
+from . collection import Collection
+from .. util import log
+
+
+class Indexed(Collection):
+    """
+    An ``Indexed`` is a :py:class:`bibliopixel.animation.Collection`
+    which keeps track of the current animation through an index into the list of
+    animations, and gets a callback after that index changes.
+
+    An ``Indexed`` has two properties.
+
+    ``index`` is a mutable property indexing the current animation in the list
+    of animations.
+
+    ``current_animation`` returns the animation at position ``index`` or None
+    if it is out of the bounds of the ``Collection``.
+    """
+    _index = -1
+
+    @property
+    def index(self):
+        """
+        :returns int: index of the current animation within the Collection.
+        """
+        return self._index
+
+    @index.setter
+    def index(self, index):
+        index = self.animations._index(index)
+        self._index, old_index = index, self._index
+        self._on_index(old_index)
+
+    def _on_index(self, old_index):
+        """
+        Override this method to get called right after ``self.index`` is set.
+
+        :param int old_index: the previous index, before it was changed.
+        """
+        if self.animation:
+            log.debug('%s: %s',
+                      self.__class__.__name__, self.current_animation.title)
+            self.frames = self.animation.generate_frames(False)
+
+    def pre_run(self):
+        super().pre_run()
+        self.index = 0
+
+    def step(self, amt=1):
+        try:
+            next(self.frames)
+            return True
+        except StopIteration:
+            pass
+        except Exception as e:
+            log.error('Exception %s in wrapper.step()', e)
+
+    def forward(self, *unused):
+        self.index += 1
+
+    def backward(self, *unused):
+        self.index -= 1
+
+    @property
+    def animation(self):
+        """
+        :returns: the selected animation based on self.index, or None if
+            self.index is out of bounds
+        """
+        if 0 <= self._index < len(self.animations):
+            return self.animations[self._index]
+
+    @property
+    def current_animation(self):
+        """
+        DEPRECATED:
+        :returns: self.animation
+        """
+        return self.animation
diff --git a/bibliopixel/animation/mixer.py b/bibliopixel/animation/mixer.py
index 0ac569d..27d7661 100644
--- a/bibliopixel/animation/mixer.py
+++ b/bibliopixel/animation/mixer.py
@@ -12,7 +12,14 @@ class Mixer(parallel.Parallel):
             self.color_list,
             [a.color_list for a in self.animations],
             levels)
-        self.levels = self.mixer.levels
+
+    @property
+    def levels(self):
+        return self.mixer.levels
+
+    @levels.setter
+    def levels(self, levels):
+        self.mixer.levels[:] = levels
 
     def step(self, amt=1):
         super().step(amt)
diff --git a/bibliopixel/animation/sequence.py b/bibliopixel/animation/sequence.py
index 786b8a9..88e4d85 100644
--- a/bibliopixel/animation/sequence.py
+++ b/bibliopixel/animation/sequence.py
@@ -25,8 +25,8 @@ class Sequence(wrapper.Indexed):
         self.index = 0
 
     def pre_run(self):
-        super().pre_run()
         self.offset = 0
+        super().pre_run()
         self.restart()
 
     def step(self, amt=1):
diff --git a/bibliopixel/animation/wrapper.py b/bibliopixel/animation/wrapper.py
index 0d441ee..0337bc2 100644
--- a/bibliopixel/animation/wrapper.py
+++ b/bibliopixel/animation/wrapper.py
@@ -1,72 +1,4 @@
-from . collection import Collection
-from .. util import log
-
-
-class Indexed(Collection):
-    """
-    An ``Indexed`` is a :py:class:`bibliopixel.animation.Collection`
-    which keeps track of the current animation through an index into the list of
-    animations, and gets a callback after that index changes.
-
-    An ``Indexed`` has two properties.
-
-    ``index`` is a mutable property indexing the current animation in the list
-    of animations.
-
-    ``current_animation`` returns the animation at position ``index`` or None
-    if it is out of the bounds of the ``Collection``.
-    """
-    _index = -1
-
-    @property
-    def index(self):
-        """
-        :returns int: index of the current animation within the Collection.
-        """
-        return self._index
-
-    @index.setter
-    def index(self, index):
-        index = self.animations._index(index)
-        self._index, old_index = index, self._index
-        self._on_index(old_index)
-
-    def _on_index(self, old_index):
-        """
-        Override this method to get called right after ``self.index`` is set.
-
-        :param int old_index: the previous index, before it was changed.
-        """
-        if self.animation:
-            log.debug('%s: %s',
-                      self.__class__.__name__, self.current_animation.title)
-            self.frames = self.animation.generate_frames(False)
-
-    def step(self, amt=1):
-        try:
-            next(self.frames)
-            return True
-        except StopIteration:
-            pass
-        except Exception as e:
-            log.error('Exception %s in wrapper.step()', e)
-
-    @property
-    def animation(self):
-        """
-        :returns: the selected animation based on self.index, or None if
-            self.index is out of bounds
-        """
-        if 0 <= self._index < len(self.animations):
-            return self.animations[self._index]
-
-    @property
-    def current_animation(self):
-        """
-        DEPRECATED:
-        :returns: self.animation
-        """
-        return self.animation
+from . indexed import Indexed
 
 
 class Wrapper(Indexed):
@@ -76,12 +8,8 @@ class Wrapper(Indexed):
         if 'animations' in desc:
             raise ValueError('Cannot specify animations in a Wrapper')
         desc['animations'] = [desc.pop('animation')]
-        return Collection.pre_recursion(desc)
+        return Indexed.pre_recursion(desc)
 
     @property
     def animation(self):
         return self.animations[0]
-
-    def pre_run(self):
-        super().pre_run()
-        self.index = 0
diff --git a/bibliopixel/control/address.py b/bibliopixel/control/address.py
index 16330a8..38456df 100644
--- a/bibliopixel/control/address.py
+++ b/bibliopixel/control/address.py
@@ -25,22 +25,17 @@ You can use an Address to either get or set values in the root object.
 
 Any key that's entirely numeric is taken to be an integer index.  This is
 convenient but prevents the creation of dictionaries like ``{1: 'x', '1': 'y'}``
-which probably didn't want to do that anyway.
+which you probably didn't want to do anyway.
 """
 
-from .. util import log
+from .. util import data_file, log
 
 
 def number(s):
     try:
-        return int(s)
-    except ValueError:
-        pass
-    try:
-        return float(s)
-    except ValueError:
-        pass
-    return s
+        return data_file.loads(s)
+    except:
+        return s
 
 
 class Address:
diff --git a/bibliopixel/control/editor.py b/bibliopixel/control/editor.py
index 0086e65..f87cc34 100644
--- a/bibliopixel/control/editor.py
+++ b/bibliopixel/control/editor.py
@@ -10,26 +10,23 @@ class Editor(Receiver):
     When the `set_root` method is called, `Editor` searches
     down through the address and stores the most recent `edit_queue`
     method it finds.
-
     """
 
-    def __init__(self, address=None):
+    def __init__(self, address=None, root=None):
         self.address = Address(address)
+        if root is not None:
+            self.set_root(root)
 
     def set_root(self, root):
         self.root = root
-        self.last_segment = self.address.segments[-1]
+        self.segments = self.address.segments[:]
+        self.last_segment = self.address.segments.pop()
         edit_queue = None
 
         for segment in self.address.segments:
             edit_queue = getattr(root, 'edit_queue', edit_queue)
-            if segment is not self.last_segment:
-                root = segment.get(root)
-                if root is None:
-                    raise ValueError(
-                        'Resolving the address "%s" failed at "%s"' %
-                        (self.address, segment))
-        self.subroot = root
+            root = segment.get(root)
+
         self.edit_queue = edit_queue
 
     def receive(self, msg):
@@ -44,7 +41,7 @@ class Editor(Receiver):
             self._set(msg)
 
     def get(self):
-        return self.last_segment.get(self.subroot)
+        return self.last_segment.get(self._get())
 
     def set(self, value):
         self.receive((number(value),))
@@ -55,6 +52,12 @@ class Editor(Receiver):
     def __str__(self):
         return str(self.address)
 
+    def _get(self):
+        root = self.root
+        for segment in self.address.segments:
+            root = segment.get(root)
+        return root
+
     def _set(self, values):
         args = self.address.assignment + values
-        self.last_segment.set(self.subroot, *args)
+        self.last_segment.set(self._get(), *args)
diff --git a/bibliopixel/control/rest.py b/bibliopixel/control/rest.py
index fd8ad82..3d49551 100644
--- a/bibliopixel/control/rest.py
+++ b/bibliopixel/control/rest.py
@@ -1,20 +1,112 @@
-import contextlib, fractions, flask, http.client, traceback, werkzeug.serving
+import contextlib, flask, fractions, functools, traceback
+import http.client, werkzeug.serving
+from werkzeug.datastructures import ImmutableOrderedMultiDict
+
 from . import control, editor
 from .. util import log
 from .. util.threads import runnable
 
 PORT = 8787
 
+"""
+The REST control opens four Endpoints that allow you to get and set Fields in
+BiblioPixel Objects by Address.
+
+* The **Basic Get Endpoint** and **Basic Set Endpoint** are simplified for
+casual users.
+
+* The **Single Endpoint** provides a REST API to `GET` and `PUT` a BiblioPixel
+Object Field.
+
+* The **Multi Endpoint** provides a REST-like API to `GET` and `PUT` multiple
+BiblioPixel Object Fields.
+
+---
+
+The Basic Get Endpoint looks like /get/<address>.  It returns JSON
+representing the BiblioPixel Object or Field at that Address.
+
+The Basic Set Endpoint is /set/<address>/<value> - it sets a Field in a
+BiblioPixel Object.
+
+For casual use, the two Basic endpoints are easiest to use, because they
+just respond to `GET` methods - "pasting a URL into a browser".
+
+*Examples of Basic endpoints*
+
+In the example projects/26-simple-rest.yml:
+
+1. `http://localhost:8787/get/animation.levels` returns a JSON list of the
+levels for each animation, `[0, 0, 0]` at the start.
+
+2. `http://localhost:8787/set/animation.levels[2]/1` sets the level of the
+third animation to be `1`.
+
+---
+
+The Single Endpoint, /single/<address>, provides a strict
+`GET`/`PUT` REST API for one BiblioPixel Object or Field at a time.
+
+With the `GET` method, this endpoint returns JSON representing the
+whole Object or Field.
+
+With the `PUT` method, it sets the value of a BiblioPixel Object Field
+from the form data with key `"value"`.
+
+*Examples of the Single endpoint*
+
+Again looking at projects/26-simple-rest.yml:
+
+1. `GET`ting `http://localhost:8787/single/animation.levels` again returns
+`[0, 0, 0]` at the start.
+
+2. `PUT`ting `http://localhost:8787/single/animation.levels[2]` with
+form data `value=1` sets the level of the third animation to be `1`.
+
+---
+
+The Multi Endpoint, /multi or /multi/<address>, provides a `GET`/`PUT`
+REST-like API for multiple BiblioPixel Objects or Fields at one time.
+
+The `GET` method uses the keys in the HTTP request dictionary as
+BiblioPixel Object or Field Addresses, and returns a dictionary
+mapping the Addresses to their JSON values.
+
+The `GET` method uses the keys and values in the HTTP request dictionary as
+to set BiblioPixel Object Fields using the key as the Address.
+
+If an `<address>` field is provided, then this is prepended onto each key.
+
+*Examples of the Multi endpoint*
+
+From projects/26-simple-rest.yml again:
+
+1. `GET`ting `http://localhost:8787/multi` with form data
+`animation.levels=0` again returns `animation.levels` or
+`[0, 0, 0]` at the start.
+
+2. `PUT`ting `http://localhost:8787/multi` with
+form data `animation.levels=[1, 1, 1]&animation.master=0.5` sets the level of
+all three animations to be `1`, and the `master` to be `0.5`.
+
+3. `PUT`ting `http://localhost:8787/multi/master` with
+form data `levels=[1, 1, 1]&master=0.5` does the same as the previous example.
+"""
+
+
+class OrderedFlask(flask.Flask):
+    # http://flask.pocoo.org/docs/1.0/patterns/subclassing/
+
+    class request_class(flask.Request):
+        parameter_storage_class = ImmutableOrderedMultiDict
+
 
 class RestServer(runnable.LoopThread):
-    def __init__(self, port, external_access, get, put):
+    def __init__(self, port, external_access):
         super().__init__()
         self.port = port
         self.hostname = '0.0.0.0' if external_access else 'localhost'
-        self.app = flask.Flask(__name__)
-        self.app.route('/')(lambda: 'BiblioPixel REST server')
-        self.app.route('/get/<address>')(get)
-        self.app.route('/put/<address>/<value>', methods=['GET', 'PUT'])(put)
+        self.app = OrderedFlask(__name__)
 
     def run_once(self):
         werkzeug.serving.run_simple(self.hostname, self.port, self.app)
@@ -31,46 +123,101 @@ class RestServer(runnable.LoopThread):
             log.error('Exception shutting werkzeug down')
 
 
-class Rest(control.ExtractedControl):
-    """
-    REST control that uses http GET and PUT commands to get and set values
-    at Addresses in a Project.
+def _single(method):
+    """Decorator for Rest methods that take a single address"""
+    @functools.wraps(method)
+    def single(self, address, value=None):
+        with self._abort(http.client.NOT_FOUND):
+            ed = editor.Editor(address, self.root)
+
+        with self._abort(http.client.BAD_REQUEST):
+            if value is None:
+                result = method(self, ed)
+            else:
+                result = method(self, ed, value)
+
+            return flask.jsonify(result)
+
+    return single
+
+
+def _multi(method):
+    """Decorator for Rest methods that take multiple addresses"""
+    @functools.wraps(method)
+    def multi(self, address=''):
+        values = flask.request.values
+        if address and values and not address.endswith('.'):
+            address += '.'
 
-    GET commands use the URL /get/<address> and return a JSON document
-    containing the value at that address, if that exists.
+        result = {}
+        for a in values or '':
+            try:
+                ed = editor.Editor(address + a, self.root)
+                result[address + a] = method(self, ed, a)
+            except:
+                if self.verbose:
+                    traceback.print_exc()
+                    log.error('Could not multi addr %s', a)
 
-    PUT commands use the URL /put/<address>/<value> and then set
-    that address to be that value
+        return flask.jsonify(result)
 
-    In the example projects/25-rest.yml, possible URLs might be:
+    return multi
 
-        http://localhost:8787/get/animation.levels
-        http://localhost:8787/put/animation.levels[2]/1
-    """
+
+class Rest(control.ExtractedControl):
     def __init__(self, *args, port=PORT, external_access=False, verbose=True,
                  **kwds):
         super().__init__(*args, verbose=verbose, **kwds)
-        self.server = RestServer(port, external_access, self.get, self.put)
+        self.server = RestServer(port, external_access)
 
-    def _make_thread(self):
-        return self.server
+        app = self.server.app
+        app.route('/')(lambda: 'BiblioPixel REST server')
 
-    def get(self, address):
-        ed = self._editor(address)
-        with self._abort(http.client.BAD_REQUEST):
-            return flask.jsonify(ed.get())
+        # Basic Endpoints
+        app.route('/get/<address>')(self.get)
+        app.route('/set/<address>/<value>')(self.put)
 
-    def put(self, address, value):
-        ed = self._editor(address)
-        with self._abort(http.client.BAD_REQUEST):
-            ed.set(value)
-            return 'ok'
+        # GET endpoints
+        app.route('/single/<address>')(self.get)
 
-    def _editor(self, address):
-        with self._abort(http.client.NOT_FOUND):
-            ed = editor.Editor(address)
-            ed.set_root(self.root)
-            return ed
+        app.route('/multi')(self.multi_get)
+        app.route('/multi/<address>')(self.multi_get)
+
+        # This is a GET endpoint that puts values to the server
+
+        # PUT endpoints
+        app.route('/single/<address>', methods=['PUT'])(self.single_put)
+
+        app.route('/multi', methods=['PUT'])(self.multi_put)
+        app.route('/multi/<address>', methods=['PUT'])(self.multi_put)
+
+    def _make_thread(self):
+        return self.server
+
+    @_single
+    def get(self, editor):
+        return editor.get()
+
+    @_single
+    def put(self, editor, value):
+        editor.set(value)
+        return True
+
+    @_single
+    def single_put(self, editor):
+        value = flask.request.values['value']
+        editor.set(value)
+        return True
+
+    @_multi
+    def multi_get(self, editor, address):
+        return editor.get()
+
+    @_multi
+    def multi_put(self, editor, address):
+        value = flask.request.values[address]
+        editor.set(value)
+        return True
 
     @contextlib.contextmanager
     def _abort(self, code):
diff --git a/bibliopixel/drivers/driver_base.py b/bibliopixel/drivers/driver_base.py
index c93608b..6671966 100644
--- a/bibliopixel/drivers/driver_base.py
+++ b/bibliopixel/drivers/driver_base.py
@@ -93,6 +93,9 @@ class DriverBase(object):
             raise ValueError('Needed %d colors but found %d' % (
                 end, len(self._colors)))
 
+    def set_project(self, project):
+        self.project = project
+
     def start(self):
         """
         Called right before this driver will run.  This is the place
diff --git a/bibliopixel/drivers/gif_writer.py b/bibliopixel/drivers/gif_writer.py
new file mode 100644
index 0000000..a67a275
--- /dev/null
+++ b/bibliopixel/drivers/gif_writer.py
@@ -0,0 +1,55 @@
+from . driver_base import DriverBase
+from .. util.image import gif_writer as _gif_writer
+
+
+class GifWriter(DriverBase):
+    """
+    An animation that writes animated GIFs for each frame in the contained
+    animation."""
+
+    def __init__(self, *args, filename='gif_writer', render=None,
+                 divide=1, frames=0, time=10, scale=1.0, gif_options=None,
+                 tmp_dir=None, **kwds):
+        """
+        :param str filename: Base filename to write the animated GIF file
+
+        :param dict render: Parameters to the renderer function -
+            see ``bibliopixel.util.image.render.renderer``
+
+        :param int divide: If greater than 1, only rendered one in ``divide``
+            frames
+
+        :param int frames: Number of frames to write
+
+        :param float time: Total time to write.  If non-zero, takes precedence
+            over `frames`
+
+        :param float speed: the length of the GIF is scaled by this factor, so
+            if speed=2 then a 1 second animation will become a 2 second GIF.
+
+        :param dict gif_options: Options to
+            ``bibliopixel.util.image.gif.write_animation``
+
+        :param str tmp_dir: If set, write individual GIF frame files to this
+            directory, and do not delete them when done.  For testing purposes.
+        """
+        # TODO: this is duplicated here so that it's duplicated in the
+        # documentation.  Is there a better way to do this?
+        super().__init__(*args, **kwds)
+        self.cur_step = 1
+        self._writer = _gif_writer.GifWriter(
+            filename, render, divide, frames, time, scale, gif_options, tmp_dir)
+
+    def set_project(self, project):
+        super().set_project(project)
+        self._writer.set_layout(project.layout)
+
+    def update_colors(self):
+        super().update_colors()
+        if self._writer.step(self.cur_step):
+            self.project.stop()
+        else:
+            self.cur_step += 1
+
+    def cleanup(self):
+        self._writer.write()
diff --git a/bibliopixel/drivers/serial/devices.py b/bibliopixel/drivers/serial/devices.py
index bf09f43..b5bed23 100644
--- a/bibliopixel/drivers/serial/devices.py
+++ b/bibliopixel/drivers/serial/devices.py
@@ -1,7 +1,8 @@
 import serial, serial.tools.list_ports
 from distutils.version import LooseVersion
 from . codes import CMDTYPE, LEDTYPE, SPIChipsets, BufferChipsets
-from ... drivers.return_codes import RETURN_CODES, raise_error
+from . import io
+from ... drivers.return_codes import raise_error
 from ... util import log, util
 from ... project.importer import import_module
 
@@ -94,40 +95,21 @@ class Devices(object):
         if id < 0 or id > 255:
             raise ValueError("ID must be an unsigned byte!")
 
-        com = serial.Serial(dev, baudrate=baudrate, timeout=5)
-
-        packet = util.generate_header(CMDTYPE.SETID, 1)
-        packet.append(id)
-        com.write(packet)
-
-        resp = com.read(1)
-        if len(resp) == 0:
-            self.error(action='set_device_id')
-        elif ord(resp) != RETURN_CODES.SUCCESS:
-            raise_error(ord(resp))
+        com, code, ok = io.send_packet(CMDTYPE.SETID, 1, dev, baudrate, 5, id)
+        if not ok:
+            raise_error(code)
 
     def get_device_id(self, dev, baudrate=921600):
         """Get device ID at given address/path.
 
         :param str dev: Serial device address/path
-        :param baudrate: Baudrate to use when connectinh (optional)
+        :param baudrate: Baudrate to use when connecting (optional)
         """
-        packet = util.generate_header(CMDTYPE.GETID, 0)
-        com = serial.Serial(dev, baudrate=baudrate, timeout=5)
-        com.write(packet)
-        resp = com.read(1)
-        if resp:
-            return ord(resp)
-        self.error(action='get_device_id')
+        com, code, ok = io.send_packet(CMDTYPE.GETID, 0, dev, baudrate, 5)
+        if code is None:
+            self.error(action='get_device_id')
+        return code
 
     def _get_device_version(self, dev, baudrate=921600):
-        packet = util.generate_header(CMDTYPE.GETVER, 0)
-        com = serial.Serial(dev, baudrate=baudrate, timeout=0.5)
-        com.write(packet)
-        ver = 0
-        resp = com.read(1)
-        if len(resp) > 0:
-            resp = ord(resp)
-            if resp == RETURN_CODES.SUCCESS:
-                ver = ord(com.read(1))
-        return ver
+        com, code, ok = io.send_packet(CMDTYPE.GETVER, 0, dev, baudrate, 0.5)
+        return ok and io.read_byte(com) or 0
diff --git a/bibliopixel/drivers/serial/driver.py b/bibliopixel/drivers/serial/driver.py
index 0431240..18bca18 100644
--- a/bibliopixel/drivers/serial/driver.py
+++ b/bibliopixel/drivers/serial/driver.py
@@ -2,6 +2,7 @@ import os, sys, serial, time, traceback
 
 from . codes import CMDTYPE, LEDTYPE, SPIChipsets, BufferChipsets
 from . devices import Devices
+from . import io
 from .. channel_order import ChannelOrder
 from .. driver_base import DriverBase
 from ... util import log, util
@@ -116,11 +117,11 @@ class Serial(DriverBase):
             packet.append(self._spi_speed)
             self._write(packet)
 
-            resp = self._read(1) or ''
-            if len(resp) == 0:
+            code = self._read()
+            if code is None:
                 self.devices.error()
 
-            return ord(resp)
+            return code
 
         except serial.SerialException as e:
             error = ("Unable to connect to the device. Please check that "
@@ -133,21 +134,19 @@ class Serial(DriverBase):
         packet = util.generate_header(CMDTYPE.BRIGHTNESS, 1)
         packet.append(self._brightness)
         self._write(packet)
-        resp = ord(self._read(1) or chr(RETURN_CODES.ERROR))
-        if resp == RETURN_CODES.SUCCESS:
+        code = self._read()
+        if code == RETURN_CODES.SUCCESS:
             return True
-        print_error(resp)
+        print_error(code)
 
     def _send_packet(self):
         self._write(self._packet)
 
-        resp = self._read(1)
-        if resp is None:
-            return
-        if len(resp) == 0:
+        code = self._read()
+        if code is None:
             self.devices.error(fail=False)
-        elif ord(resp) != RETURN_CODES.SUCCESS:
-            print_error(ord(resp))
+        elif code != RETURN_CODES.SUCCESS:
+            print_error(code)
         else:
             self._flushInput()
             return True
@@ -163,11 +162,8 @@ class Serial(DriverBase):
     def _send_sync(self):
         self._write(self._sync_packet)
 
-    def _read(self, *args):
-        try:
-            return self._com.read(*args)
-        except Exception as e:
-            log.error('Serial exception %s in read', e)
+    def _read(self):
+        return io.read_byte(self._com)
 
     def _close(self):
         try:
diff --git a/bibliopixel/drivers/serial/io.py b/bibliopixel/drivers/serial/io.py
new file mode 100644
index 0000000..170a9e1
--- /dev/null
+++ b/bibliopixel/drivers/serial/io.py
@@ -0,0 +1,24 @@
+import serial
+from ... drivers.return_codes import RETURN_CODES
+from ... util import log, util
+
+
+def send_packet(cmd, size, dev, baudrate, timeout, more_data=None):
+    packet = util.generate_header(cmd, size)
+    if more_data:
+        packet += more_data
+
+    com = serial.Serial(dev, baudrate=baudrate, timeout=timeout)
+    com.write(packet)
+    code = read_byte(com)
+    ok = (code == RETURN_CODES.SUCCESS)
+    return com, code, ok
+
+
+def read_byte(com):
+    try:
+        resp = com.read(1)
+        if resp:
+            return ord(resp)
+    except Exception as e:
+        log.error('Serial exception %s in read', e)
diff --git a/bibliopixel/main/commands.py b/bibliopixel/main/commands.py
index 73a673d..2f707dc 100644
--- a/bibliopixel/main/commands.py
+++ b/bibliopixel/main/commands.py
@@ -4,7 +4,7 @@ from .. util import log
 
 COMMANDS = (
     'all_pixel', 'all_pixel_test', 'clear_cache', 'color', 'demo', 'devices',
-    'info', 'kill', 'list', 'load', 'monitor', 'pid', 'remove', 'reset',
+    'info', 'kill', 'list', 'load', 'monitor', 'new', 'pid', 'remove', 'reset',
     'restart', 'run', 'save', 'set', 'show', 'shutdown', 'update')
 
 MODULES = {c: import_module('bibliopixel.main.' + c) for c in COMMANDS}
diff --git a/bibliopixel/main/common_flags.py b/bibliopixel/main/common_flags.py
index 6c7498d..2180e56 100644
--- a/bibliopixel/main/common_flags.py
+++ b/bibliopixel/main/common_flags.py
@@ -66,7 +66,8 @@ def add_project_flags(parser):
 
     parser.add_argument(
         '--dump', action='store_true',
-        help='Dump the full project as DATA_FILE after loading but before running')
+        help='Dump the full project as DATA_FILE after loading but ' +
+             'before running')
 
     parser.add_argument(
         '--dry_run', action='store_true',
@@ -76,6 +77,9 @@ def add_project_flags(parser):
         '-f', '--fail_on_exception', action='store_true',
         help='If true, bp fail if any subanimation fails to construct')
 
+    parser.add_argument(
+        '-g', '--gif', default='', nargs='?', help=GIF_HELP)
+
     parser.add_argument(
         '-i', '--ignore_exceptions', action='store_true',
         help='If true, continue running the next project if one project fails')
@@ -220,6 +224,16 @@ Run BiblioPixel in v4 compatibility mode, to see if it will work with
 future releases v4.x
 """
 
+GIF_HELP = """
+Write an animated GIF file.
+
+If --gif has no argument, the name of the GIF file is the same as the name of
+the project.
+
+If it has a single string argument, then it's the name of the GIF file.
+
+Otherwise, the argument is read as JSON and used to construct a GifWriter class.
+"""
 
 # TODO: this should go somewhere else
 """
diff --git a/bibliopixel/main/new.py b/bibliopixel/main/new.py
new file mode 100644
index 0000000..5c6293a
--- /dev/null
+++ b/bibliopixel/main/new.py
@@ -0,0 +1,106 @@
+"""
+Create a new project
+"""
+
+import os, re, string
+from .. util import log
+
+DESCRIPTION = """
+Example:
+
+```
+    # Create a new project directory named my-project/ in the current directory
+    bp new my-project
+
+    # Create a new project directory named my-project/ in ~/projects
+    bp new my-project ~/projects
+```
+"""
+
+PUNCTUATION = '.-_'
+LEGAL_CHARS = set(string.ascii_letters + string.digits + PUNCTUATION)
+DIR_NAME = os.path.dirname(__file__)
+
+
+def run(args):
+    name = args.project_name
+    bad = set(name) - LEGAL_CHARS
+    if bad:
+        raise ValueError('Bad characters in project: "%s"' % ''.join(bad))
+    if not name[0].isalpha():
+        raise ValueError('Project names must start with a character')
+
+    directory = args.directory or name
+    if os.path.exists(directory):
+        raise ValueError('Directory "%s" already exists' % directory)
+
+    os.makedirs(directory)
+    words = _split_words(name)
+    class_name = ''.join(w.capitalize() for w in words)
+    py_name = name.replace('.', '_').replace('-', '_')
+
+    context = {
+        'class_name': class_name,
+        'name': name,
+        'py_name': py_name,
+    }
+
+    for template, out_file in TEMPLATES.items():
+        out_file = out_file.format(**context)
+        out_file = os.path.join(directory, out_file)
+        tmpl_file = os.path.join(DIR_NAME, template)
+        tmpl_data = open(tmpl_file).read()
+
+        with open(out_file, 'w') as fp:
+            fp.write(tmpl_data.format(**context))
+            log.printer('Written', out_file)
+
+    log.printer('Created new project in', directory)
+
+
+def set_parser(parser):
+    parser.set_defaults(run=run)
+    parser.add_argument('project_name', help=PROJECT_NAME_HELP)
+    parser.add_argument('directory', default='', nargs='?', help=DIRECTORY_HELP)
+
+
+TEMPLATES = {
+    'template/project.yml.tmpl': '{name}.yml',
+    'template/animation.py.tmpl': '{py_name}.py',
+}
+
+PROJECT_NAME_HELP = """
+The Name of the new BLiPS project you want to create.
+
+A Project Name can contain letters, numbers, `-` or `_`. Nothing else is
+allowed, which means no whitespace, "no `/`" and no `.`.
+
+A project name must start with a letter.
+
+Valid project names are:
+
+* P
+* project_name
+* IHeartArea51
+
+Invalid project names are:
+
+* 23skidoo
+* project.name
+* _23skidoo
+
+"""
+
+DIRECTORY_HELP = """
+Optional directory where you want to create the project.
+If absent, use the name of the project as the directory name.
+"""
+
+_split_words = re.compile(r'''
+    # Find words in a string. Order matters!
+    [A-Z]+(?=[A-Z][a-z]) |  # All upper case before a capitalized word
+    [A-Z]?[a-z]+ |  # Capitalized words / all lower case
+    [A-Z]+ |  # All upper case
+    \d+  # Numbers
+''', re.VERBOSE).findall
+# https://stackoverflow.com/a/41510011/43839
diff --git a/bibliopixel/main/run.py b/bibliopixel/main/run.py
index 1865fd5..2eb5fca 100644
--- a/bibliopixel/main/run.py
+++ b/bibliopixel/main/run.py
@@ -11,6 +11,7 @@ from .. project.project import Project
 
 RUN_ERROR = """When reading file {filename}:
 {desc}
+{exception}
 """
 
 FAILURE_ERROR = """\
@@ -44,15 +45,27 @@ def _load_py(filename):
 
 
 def _dump(args, desc):
-    if not isinstance(desc, str):
-        dump = data_file.yaml.dump if args.yaml else data_file.dumps
-        desc = dump(desc, default=repr)
-    return desc.strip()
+    return data_file.dumps(desc, args.yaml, safe=False)
 
 
 def _get_projects(args):
     projects, failed = [], []
 
+    gif = args.gif
+    if gif is not '':
+        log.info('writing animated gifs')
+        if gif is None:
+            # --get appeared but without a value
+            gif = '{}'
+        if '{' in gif:
+            gif = data_file.loads(gif, filename='--gif')
+        if isinstance(gif, str):
+            gif = {'filename': gif}
+        if 'filename' in gif and len(args.name) > 1:
+            log.warning('Writing multiple documents to the same GIF file name')
+
+        gif['typename'] = '.gif_writer'
+
     for filename in args.name:
         saved_path = sys.path[:]
         desc = '(not loaded)'
@@ -65,6 +78,13 @@ def _get_projects(args):
                 desc = data_file.load(desc, filename)
                 root_file = os.path.abspath(filename)
 
+            if gif:
+                if 'filename' in gif:
+                    desc['driver'] = gif
+                else:
+                    f, suffix = os.path.splitext(filename)
+                    desc['driver'] = dict(gif, filename=f + '.gif')
+
             project = common_flags.make_project(args, desc, root_file)
             projects.append(project)
             if args.dump:
@@ -74,12 +94,12 @@ def _get_projects(args):
             failed.append(('%s: %s' % (e.strerror, e.filename), ()))
 
         except Exception as exception:
-            if args.verbose or filename.endswith('.py'):
+            if filename.endswith('.py'):
                 raise
             eargs = exception.args
+            desc = desc and _dump(args, desc)
             if args.verbose:
                 exception = traceback.format_exc()
-            desc = desc and _dump(args, desc)
             msg = RUN_ERROR.format(**locals())
             failed.append((msg, eargs))
 
diff --git a/bibliopixel/main/template/animation.py.tmpl b/bibliopixel/main/template/animation.py.tmpl
new file mode 100644
index 0000000..97db76d
--- /dev/null
+++ b/bibliopixel/main/template/animation.py.tmpl
@@ -0,0 +1,45 @@
+from bibliopixel import animation
+from bibliopixel.colors import COLORS
+
+"""
+This is an example Animation class which has a single field -
+`color` with the default value `COLORS.green`.
+
+You can edit this as you like, add and remove fields anda
+do Pythonic things
+"""
+
+class {class_name}(animation.BaseAnimation):
+    def __init__(
+            self, *args,
+            # The fields for your class go here!
+            color=COLORS.green,
+
+            # End of the fields for your class
+            **kwds):
+        super().__init__(*args, **kwds)
+        self.color = color
+        # Your initialization code goes here.
+
+    def step(self, amt=1):
+        this_pixel = self.cur_step % len(self.color_list)
+
+        # Set the previous pixel to black.
+        self.color_list[this_pixel - 1] = COLORS.black
+
+        # Set this pixel to the color
+        self.color_list[this_pixel] = self.color
+
+    #
+    # Everything below here is optional, and you can delete it if you aren't
+    # using it.
+    #
+
+    # pre_run is called right before the animation starts running.
+    def pre_run(self):
+        super().pre_run()
+        # Your code goes here.
+
+    def cleanup(self, clean_layout=True):
+        super().cleanup(clean_layout)
+        # Your code goes here.
diff --git a/bibliopixel/main/template/project.yml.tmpl b/bibliopixel/main/template/project.yml.tmpl
new file mode 100644
index 0000000..f156a03
--- /dev/null
+++ b/bibliopixel/main/template/project.yml.tmpl
@@ -0,0 +1,62 @@
+# Lines that start with the # character are comments - they don't
+# get executed, but are simply there to document.
+#
+# When you're finished creating your project, you can delete all these
+# comment lines and put your own in.
+
+
+# The `shape` section is the arrangement of the lights in your system - change
+# this to match your actual lights.  The example is for a matrix that is 32
+# pixels wide and 48 high.
+#
+# Using `shape` only works if you have a strip, a matrix, or a cube.
+# For more complex shapes, you will use the `layout` section
+
+shape: [32, 48]
+
+
+# The `driver` section describes the hardware outputs you are using.
+#
+# In this sample file, the driver is the SimPixel LED simulator which runs
+# in the browser; in a real project, that would be replaced by the right
+# hardware driver and its settings.
+
+driver:
+  typename: simpixel
+
+
+# This is the alias section where you can have short aliases for
+# longer strings for convenience.
+
+aliases:
+  $bpa: BiblioPixelAnimations.matrix
+
+
+# This is the animation section, which describes how your lights change over
+# time.
+
+animation: $bpa.bloom
+
+
+# Here are alternative animation sections that are commented out that you
+# could edit and use.
+
+
+# # Here's the animation section for a looping sequence of animations, each 10
+# # seconds long.
+#
+# animation:
+#   typename: sequence
+#   length: 10
+#   animations:
+#     - $bpa.bloom
+#     - $bpa.pinwheel
+#     - $bpa.GameOfLife.GameOfLifeRGB
+#     - $bpa.SpinningTriangle
+
+
+# # Here is the animation section if you wanted to use a custom class.
+#
+# animation:
+#   typename: {py_name}.{class_name}
+#   color: blue
diff --git a/bibliopixel/project/fill.py b/bibliopixel/project/fill.py
index 87af421..f20d884 100644
--- a/bibliopixel/project/fill.py
+++ b/bibliopixel/project/fill.py
@@ -4,7 +4,7 @@ so they have exactly the right constructor.
 """
 
 import copy
-from . import alias_lists, construct, merge
+from . import aliases, alias_lists, construct, merge
 from .. import layout, util
 
 DEFAULT_DRIVERS = [construct.to_type('simpixel')]
@@ -25,7 +25,13 @@ def fill_layout(animation):
 
 
 def fill_aliases(desc):
-    alias_lists.PROJECT_ALIASES = desc.pop('aliases', {})
+    def unalias(k):
+        if any(k.startswith(m) for m in aliases.ALIAS_MARKERS):
+            return k[1:]
+        return k
+
+    al = desc.pop('aliases', {})
+    alias_lists.PROJECT_ALIASES = {unalias(k): v for k, v in al.items()}
     return desc
 
 
@@ -45,7 +51,8 @@ def fill_animation(desc):
         desc['animation'], 'bibliopixel.animation')
     datatype = desc['animation'].get('datatype')
     if not datatype:
-        raise ValueError('Missing "datatype" in "animation" section')
+        e = desc['animation'].get('_exception')
+        raise e or ValueError('Missing "datatype" in "animation" section')
 
     desc['animation'].setdefault('name', datatype.__name__)
 
diff --git a/bibliopixel/project/project.py b/bibliopixel/project/project.py
index 9febb49..c6df5d1 100644
--- a/bibliopixel/project/project.py
+++ b/bibliopixel/project/project.py
@@ -73,6 +73,9 @@ class Project:
 
         self.controls = [create(c, 'control') for c in controls]
 
+        for d in self.drivers:
+            d.set_project(self)
+
     def start(self):
         with self.LOCK:
             if self.running:
diff --git a/bibliopixel/util/colors/__init__.py b/bibliopixel/util/colors/__init__.py
index 05f05e7..b1cb033 100644
--- a/bibliopixel/util/colors/__init__.py
+++ b/bibliopixel/util/colors/__init__.py
@@ -50,7 +50,8 @@ from . conversions import hsv2rgb_spectrum, hsv2rgb_rainbow, hsv2rgb_360
 from . conversions import hsv2rgb, hue2rgb, hue_gradient, hue2rgb_360
 from . conversions import hue_helper, hue_helper360
 from . wheel import wheel_color, wheel_helper
-from . names import COLORS, COLOR_DICT, toggle
+from . names import (
+    COLORS, COLOR_DICT, color_to_name, name_to_color, to_color, toggle)
 
 from .. import deprecated
 
diff --git a/bibliopixel/util/colors/names.py b/bibliopixel/util/colors/names.py
index 7fb5967..93931b1 100644
--- a/bibliopixel/util/colors/names.py
+++ b/bibliopixel/util/colors/names.py
@@ -2,7 +2,7 @@
 Convert back and forth between colors and string names.
 """
 
-import re
+import numbers, re
 from . import juce, classic
 
 
@@ -33,7 +33,7 @@ def name_to_color(name):
     try:
         color = to_color(name)
     except:
-        raise ValueError('Unknown color name %s' % name)
+        raise ValueError('Unknown color name %s' % str(name))
 
     if not all(0 <= i <= 255 for i in color):
         raise ValueError('Component out of range: %s' % color)
@@ -49,10 +49,14 @@ def color_to_name(color, use_hex=False):
     ``name_to_color(color_to_name(c)) == c`` is guaranteed to be true (but the
     reverse is not true, because name_to_color is a many-to-one function).
     """
+    if isinstance(color, list):
+        color = tuple(color)
+    elif not isinstance(color, tuple):
+        raise ValueError('Not a color')
+
     if use_hex:
         return '#%02x%02x%02x' % color
-    if not isinstance(color, tuple):
-        color = tuple(color)
+
     try:
         return TO_NAME[color]
     except:
@@ -119,6 +123,30 @@ def _from_number(s):
     return int(s)
 
 
+def to_color(c):
+    """Try to coerce the argument into a color - a 3-tuple of numbers-"""
+    if isinstance(c, numbers.Number):
+        return c, c, c
+
+    if not c:
+        raise ValueError('Cannot create color from empty "%s"' % c)
+
+    if isinstance(c, str):
+        return name_to_color(c)
+
+    if isinstance(c, list):
+        c = tuple(c)
+
+    if isinstance(c, tuple):
+        if len(c) > 3:
+            return c[:3]
+        while len(c) < 3:
+            c += (c[-1],)
+        return c
+
+    raise ValueError('Cannot create color from "%s"' % c)
+
+
 """
 COLOR is a "magic" color name variable.
 
diff --git a/bibliopixel/util/data_file.py b/bibliopixel/util/data_file.py
index 2b5982f..86cbabd 100644
--- a/bibliopixel/util/data_file.py
+++ b/bibliopixel/util/data_file.py
@@ -6,7 +6,7 @@ open = __builtins__['open']
 ALWAYS_LOAD_YAML, ALWAYS_DUMP_YAML = True, True
 
 
-def dumps(data, use_yaml=None, **kwds):
+def dumps(data, use_yaml=None, safe=True, **kwds):
     """
     Dumps data into a nicely formatted JSON string.
 
@@ -19,9 +19,14 @@ def dumps(data, use_yaml=None, **kwds):
         use_yaml = ALWAYS_DUMP_YAML
 
     if use_yaml:
-        return yaml.safe_dump(data, canonical=True, **kwds)
+        dumps = yaml.safe_dump if safe else yaml.dump
+        kwds.update(canonical=True)
     else:
-        return json.dumps(data, indent=4, sort_keys=True, **kwds)
+        dumps = json.dumps
+        kwds.update(indent=4, sort_keys=True)
+        if not safe:
+            kwds.update(default=repr)
+    return dumps(data, **kwds)
 
 
 def dump(data, file=sys.stdout, use_yaml=None, **kwds):
diff --git a/bibliopixel/util/image/gif.py b/bibliopixel/util/image/gif.py
index d713229..8632e30 100644
--- a/bibliopixel/util/image/gif.py
+++ b/bibliopixel/util/image/gif.py
@@ -4,8 +4,8 @@ from .. import log
 
 # PIL has a limit on how many image files can be open at once - you get an
 # exception when you try to open more files than that in save_images.  The exact
-# number varies for some unknown reason but I consistently was able to get at
-# least 240, but I use 192 for safety.
+# number varies for some unknown reason - I consistently was able to get at
+# least 240 in informal testing - but we use 192 for safety.
 MAX_IMAGES_IN_MEMORY = 192
 WINDOW_SIZE = MAX_IMAGES_IN_MEMORY // 2
 
diff --git a/bibliopixel/util/image/gif_writer.py b/bibliopixel/util/image/gif_writer.py
new file mode 100644
index 0000000..904ce3d
--- /dev/null
+++ b/bibliopixel/util/image/gif_writer.py
@@ -0,0 +1,107 @@
+import os, shutil, tempfile, time, traceback
+from . import gif, renderer
+from .. import colors, log
+
+
+DEFAULT_RENDER = {
+    'color': colors.Black,
+    'pixel_width': 16,
+    'pixel_height': None,
+    'ellipse': True,
+    'vertical': False,
+    'frame': 4,
+    'padding': 2,
+}
+
+
+class GifWriter:
+    """Write an animated GIF given frames from an animation."""
+
+    def __init__(self, filename='gif_writer', render=None,
+                 divide=1, frames=128, time=10, scale=1.0, gif_options=None,
+                 tmp_dir=None):
+        self.render = dict(DEFAULT_RENDER, **(render or {}))
+        self.divide = divide
+        self.frames = frames
+        self.time = time
+        self.frame_files = []
+        self.scale = scale
+        self.gif_options = gif_options or {}
+
+        filename = os.path.expanduser(os.path.abspath(filename))
+        file_root = filename[:-4] if filename.endswith('.gif') else filename
+        self.filename = file_root + '.gif'
+        os.makedirs(os.path.dirname(self.filename), exist_ok=True)
+
+        if tmp_dir:
+            self.tmp_dir_name = tmp_dir
+            shutil.rmtree(tmp_dir, ignore_errors=True)
+            os.makedirs(self.tmp_dir_name, exist_ok=True)
+        else:
+            self.tmp_dir = tempfile.TemporaryDirectory()
+            self.tmp_dir_name = self.tmp_dir.name
+        self.basename = os.path.basename(file_root)
+        self.finished = False
+        self.stop_after_write = True
+        self.times = []
+
+    def set_layout(self, layout):
+        self.render = renderer.renderer(layout, **self.render)
+        assert self.render
+
+    def step(self, cur_step):
+        if self.finished or (self.divide >= 1 and cur_step % self.divide):
+            return True
+
+        frame = cur_step / max(self.divide, 1)
+        self.times.append(time.time())
+
+        if self.time:
+            elapsed = self.times[-1] - self.times[0]
+            self.finished = (elapsed >= self.time)
+        else:
+            self.finished = (frame >= self.frames)
+
+        if self.finished:
+            self.write()
+            return True
+
+        frame_name = '%s%04d.gif' % (self.basename, frame)
+        filename = os.path.join(self.tmp_dir_name, frame_name)
+
+        self.render().save(filename)
+        self.frame_files.append(filename)
+
+    def write(self):
+        if not self.frame_files:
+            return
+
+        ff, self.frame_files = self.frame_files, []
+        go = dict(self.gif_options)
+
+        if 'duration' in go:
+            go.pop('fps', None)
+            try:
+                go['duration'] *= self.scale
+            except:
+                go['duration'] = [g * self.scale for g in go['duration']]
+
+        elif 'fps' in go:
+            go['fps'] /= self.scale
+
+        else:
+            # Compute the list of durations from the list of times
+            duration = []
+            for i, time in enumerate(self.times):  # noqa: 402
+                if not i:
+                    previous_time = time
+                else:
+                    # GIFs only accept time in 0.01 second increments
+                    duration.append(round(time - previous_time, 2))
+                    previous_time += duration[-1]
+
+            duration.append(duration[-1])
+            go = dict(go, duration=duration)
+
+        gif.write_animation(self.filename, ff, **go)
+        self.tmp_dir = None
diff --git a/bibliopixel/util/image/renderer.py b/bibliopixel/util/image/renderer.py
index 0c5dff6..4b080a5 100644
--- a/bibliopixel/util/image/renderer.py
+++ b/bibliopixel/util/image/renderer.py
@@ -1,11 +1,11 @@
-from ... util import colors
+from ... util import colors, log
 from ... layout import Matrix, Strip
 
 
 def renderer(layout, color, pixel_width, pixel_height,
              ellipse, vertical, frame, padding):
     if not isinstance(layout, (Matrix, Strip)):
-        return
+        raise ValueError('Cannot render a layout of type %s' % type(layout))
 
     from PIL import Image, ImageDraw
 
diff --git a/projects/01-matrix.yml b/projects/01-matrix.yml
index 85d097e..11de40f 100644
--- a/projects/01-matrix.yml
+++ b/projects/01-matrix.yml
@@ -1,11 +1,4 @@
-driver:
-    num: 1024
-    typename: simpixel
-
-layout:
-    typename: matrix
-    width: 32
-    height: 32
+shape: [32, 32]
 
 run:
     fps: 30
@@ -19,28 +12,28 @@ animation:
     typename: sequence
     length: 2
     animations:
-        - "@bpa.ImageAnim"
-        - "@bpa.ImageShow"
-        - "@bpa.ImageDissolve"
-        - "@bpa.ScreenGrab"
-        - "@bpa.AnalogClock"
-        - "@bpa.AnalogClock"
-        - "@bpa.BinaryClock.MatrixBinaryClock"
-        - "@bpa.CPUUsage"
-        - "@bpa.GameOfLife"
-        - "@bpa.GameOfLife.GameOfLifeRGB"
-        - "@bpa.GradientClock"
-        - "@bpa.LangtonsAnt"
-        - "@bpa.LangtonsAnt.LangtonsAntRainbow"
-        - "@bpa.Mainframe"
-        - "@bpa.MatrixRain"
-        - "@bpa.MatrixRain.MatrixRainBow"
-        - "@bpa.SpinningTriangle"
-        - "@bpa.TallClock"
-        - "@bpa.Text.BounceText"
-        - "@bpa.Text.ScrollText"
-        - "@bpa.TicTacToe"
-        - "@bpa.bloom"
-        - "@bpa.circlepop"
-        - "@bpa.perlin_simplex"
-        - "@bpa.pinwheel"
+        - $bpa.ImageAnim
+        - $bpa.ImageShow
+        - $bpa.ImageDissolve
+        - $bpa.ScreenGrab
+        - $bpa.AnalogClock
+        - $bpa.AnalogClock
+        - $bpa.BinaryClock.MatrixBinaryClock
+        - $bpa.CPUUsage
+        - $bpa.GameOfLife
+        - $bpa.GameOfLife.GameOfLifeRGB
+        - $bpa.GradientClock
+        - $bpa.LangtonsAnt
+        - $bpa.LangtonsAnt.LangtonsAntRainbow
+        - $bpa.Mainframe
+        - $bpa.MatrixRain
+        - $bpa.MatrixRain.MatrixRainBow
+        - $bpa.SpinningTriangle
+        - $bpa.TallClock
+        - $bpa.Text.BounceText
+        - $bpa.Text.ScrollText
+        - $bpa.TicTacToe
+        - $bpa.bloom
+        - $bpa.circlepop
+        - $bpa.perlin_simplex
+        - $bpa.pinwheel
diff --git a/projects/11-split.json b/projects/11-split.json
index 2119047..0722669 100644
--- a/projects/11-split.json
+++ b/projects/11-split.json
@@ -1,11 +1,5 @@
 {
-    "driver": {
-        "num": 200,
-        "typename": "simpixel"
-    },
-
-    "layout": "strip",
-
+    "shape": 200,
     "aliases": {
         "bpa": "BiblioPixelAnimations.strip",
         "Fill": "BiblioPixelAnimations.simple.Fill"
@@ -16,6 +10,8 @@
         "fps": 30
     },
 
+    "driver": "simpixel",
+
     "animation": {
         "typename": "split",
         "size": 50,
diff --git a/projects/12-gifwriter.yml b/projects/12-gifwriter.yml
index f69454b..305f823 100644
--- a/projects/12-gifwriter.yml
+++ b/projects/12-gifwriter.yml
@@ -7,9 +7,7 @@ run:
   until_complete: true
 
 animation:
-  typename: bibliopixel.animation.gif_writer.GifWriter
-
-  time: 23
+  typename: .gif_writer.GifWriter
 
   run:
     fps: 24
@@ -24,7 +22,7 @@ animation:
     typename: sequence
     length: 2
     animations:
-      - "@bpa.bloom"
-      - "@bpa.circlepop"
-      - "@bpa.perlin_simplex"
-      - "@bpa.pinwheel"
+      - $bpa.bloom
+      - $bpa.circlepop
+      - $bpa.perlin_simplex
+      - $bpa.pinwheel
diff --git a/projects/13-gifwriter-driver.yml b/projects/13-gifwriter-driver.yml
new file mode 100644
index 0000000..5451f69
--- /dev/null
+++ b/projects/13-gifwriter-driver.yml
@@ -0,0 +1,22 @@
+shape: [8, 8]
+
+driver:
+  typename: .gif_writer.GifWriter
+#  tmp_dir: /tmp
+
+aliases:
+  bpa: BiblioPixelAnimations.matrix
+
+run:
+  until_complete: true
+  fps: 16
+  seconds: 3
+
+animation:
+  typename: sequence
+  length: 2
+  animations:
+    - $bpa.bloom
+    - $bpa.circlepop
+    - $bpa.perlin_simplex
+    - $bpa.pinwheel
diff --git a/projects/23-midi-control.json b/projects/23-midi-control.json
deleted file mode 100644
index b62ba8e..0000000
--- a/projects/23-midi-control.json
+++ /dev/null
@@ -1,36 +0,0 @@
-{
-    "aliases": {
-        "bpa": "BiblioPixelAnimations.strip"
-    },
-
-    "controls": {
-        "typename": "midi",
-        "verbose": false,
-        "extractor": {
-            "accept": {"type": "control_change"}
-        },
-        "routing": {
-            "0": "animation.levels[0]",
-            "1": "animation.levels[1]",
-            "2": "animation.levels[2]",
-            "3": "animation.levels[3]"
-        }
-    },
-
-    "shape": [50],
-
-    "run": {
-        "fps": 30
-    },
-
-    "animation": {
-        "typename": "mixer",
-        "levels": [1, 0, 0, 0],
-        "animations": [
-            "@bpa.Rainbows.RainbowCycle",
-            "@bpa.Wave",
-            "@bpa.HalvesRainbow",
-            "@bpa.PartyMode"
-        ]
-    }
-}
diff --git a/projects/23-midi-control.yml b/projects/23-midi-control.yml
new file mode 100644
index 0000000..0a7aaa4
--- /dev/null
+++ b/projects/23-midi-control.yml
@@ -0,0 +1,28 @@
+aliases:
+    bpa: BiblioPixelAnimations.strip
+
+shape: [50]
+
+animation:
+    typename: mixer
+    levels: [1, 0, 0, 0]
+    animations:
+        - $bpa.Rainbows.RainbowCycle
+        - $bpa.Wave
+        - $bpa.HalvesRainbow
+        - $bpa.PartyMode
+
+controls:
+    typename: midi
+    extractor:
+        accept:
+          type: control_change
+
+    routing:
+        0: animation.levels[0]
+        1: animation.levels[1]
+        2: animation.levels[2]
+        3: animation.levels[3]
+
+run:
+    fps: 30
diff --git a/projects/24-midi-select.yml b/projects/24-midi-select.yml
new file mode 100644
index 0000000..f9e5ed1
--- /dev/null
+++ b/projects/24-midi-select.yml
@@ -0,0 +1,25 @@
+aliases:
+    bpa: BiblioPixelAnimations.strip
+
+controls:
+    typename: midi
+    extractor:
+        accept:
+          type: control_change
+
+    routing:
+        0: animation.forward()
+        1: animation.backward()
+
+shape: [50]
+
+run:
+    fps: 30
+
+animation:
+    typename: indexed
+    animations:
+        - $bpa.Rainbows.RainbowCycle
+        - $bpa.Wave
+        - $bpa.HalvesRainbow
+        - $bpa.PartyMode
diff --git a/projects/26-simple-rest.yml b/projects/26-simple-rest.yml
index ec48f8d..396f4ed 100644
--- a/projects/26-simple-rest.yml
+++ b/projects/26-simple-rest.yml
@@ -4,13 +4,14 @@ aliases:
 controls: rest
 
 shape: [32, 32]
+numbers: float
 
 run:
     fps: 30
 
 animation:
     typename: mixer
-    levels: [0, 0, 0]
+    levels: [1, 1, 1]
     animations:
         - {typename: $fill, color: red}
         - {typename: $fill, color: green}


Test patch:
diff --git a/scripts/test_rest_server b/scripts/test_rest_server
index 94b189c..904805a 100755
--- a/scripts/test_rest_server
+++ b/scripts/test_rest_server
@@ -4,36 +4,74 @@
 # command line and pops up
 
 
-import requests, subprocess, threading, time
+import contextlib, requests, subprocess, threading, time
 
-ADDRESS = 'http://localhost:8787/put/animation.levels[{0}]/{1}'
 START_BP = 'bp', '-vs', 'projects/26-simple-rest.yml'
 STOP_BP = 'bp', 'shutdown'
 
+ROOT = 'http://localhost:8787/'
+PAUSE = 1
+
+
+def get(name, url, method='GET'):
+    print(method, name + ':', requests.get(ROOT + url).json())
+
+
+def put(name, url, data):
+    resp = requests.put(ROOT + url, data=data or {})
+    print('PUT', name + ':', resp.text)
 
-def test_rest():
-    threading.Thread(
-        target=subprocess.check_call, args=(START_BP,)).start()
 
-    time.sleep(3)
+def bp(function):
+    def wrapped():
+        threading.Thread(
+            target=subprocess.check_call, args=(START_BP,)).start()
+        time.sleep(2 * PAUSE)
 
-    requests.put(ADDRESS.format(0, 1))
-    time.sleep(1)
+        function()
+        subprocess.check_call(STOP_BP)
+
+    return wrapped
+
+@bp
+def test_other():
+    put('single', 'single/animation.levels', {'value': '[0, 0, 0]'})
+    time.sleep(PAUSE)
+    put('single', 'single/animation.levels', {'value': '[0, 1, 0]'})
+    time.sleep(PAUSE)
+    put('single', 'single/animation.levels', {'value': '[0, 0, 0]'})
+    time.sleep(PAUSE)
+    put('single', 'single/animation.levels', {'value': '[0, 1, 0]'})
+    time.sleep(PAUSE)
+
+@bp
+def test_rest():
+    get('basic', 'get/animation.levels')
+    get('single', 'single/animation.levels')
+    get('multi', 'multi?animation.levels=0')
+    get('multi2', 'multi/animation?levels=0')
+    time.sleep(PAUSE)
 
-    requests.get(ADDRESS.format(1, 1))
-    time.sleep(1)
+    get('basic', 'set/animation.levels[0]/0', method='PUT')
+    time.sleep(PAUSE)
 
-    requests.put(ADDRESS.format(2, 1))
-    time.sleep(1)
+    put('single', 'single/animation.levels[1]', {'value': 0})
+    time.sleep(PAUSE)
 
-    requests.get(ADDRESS.format(0, 0))
-    time.sleep(1)
+    data = {'animation.levels[0]': 1, 'animation.levels[1]': 1,
+            'animation.master': 0.5}
+    put('multi', 'multi', data)
+    time.sleep(PAUSE)
 
-    requests.put(ADDRESS.format(1, 0))
-    time.sleep(1)
+    put('multi', 'multi/animation', {'levels[0]': 0, 'master': 1.0})
+    time.sleep(PAUSE)
 
-    subprocess.check_call(STOP_BP)
+    put('multi', 'multi/animation', {'levels': '[1, 1, 1]'})
+    time.sleep(PAUSE)
 
 
 if __name__ == '__main__':
-    test_rest()
+    if True:
+        test_rest()
+    else:
+        test_other()
diff --git a/test/bibliopixel/project/make.py b/test/bibliopixel/project/make.py
index 4866978..d051794 100644
--- a/test/bibliopixel/project/make.py
+++ b/test/bibliopixel/project/make.py
@@ -1,4 +1,5 @@
 import tempfile
+from unittest.mock import patch
 from bibliopixel.project import project
 from bibliopixel.util import data_file
 from .. mark_tests import SKIP_LONG_TESTS
@@ -30,6 +31,7 @@ def make(data, run_start=not SKIP_LONG_TESTS):
     project = make_project(data)
 
     if run_start:
-        project.animation.start()
+        with patch('time.sleep', autospec=True):
+            project.animation.start()
 
     return project.animation
diff --git a/test/bibliopixel/util/colors/__init__.py b/test/bibliopixel/util/colors/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/test/bibliopixel/util/colors/names_test.py b/test/bibliopixel/util/colors/names_test.py
new file mode 100644
index 0000000..dfa7ff2
--- /dev/null
+++ b/test/bibliopixel/util/colors/names_test.py
@@ -0,0 +1,42 @@
+import unittest
+
+from bibliopixel.util import colors
+COLORS = 'red', 'white', 'magenta', 'lime green', '(0, 1, 2)'
+
+
+class NamesTest(unittest.TestCase):
+    def test_round_trip(self):
+        for name in COLORS:
+            color = colors.name_to_color(name)
+            name2 = colors.color_to_name(color)
+            self.assertEqual(name, name2)
+
+    def test_to_color(self):
+        for name in COLORS:
+            color1 = colors.name_to_color(name)
+            color2 = colors.to_color(name)
+            color3 = colors.to_color(color2)
+            self.assertEqual(color1, color2)
+            self.assertEqual(color2, color3)
+
+        self.assertEqual(colors.to_color(0), colors.COLORS.black)
+        self.assertEqual(colors.to_color([255]), colors.COLORS.white)
+        self.assertEqual(colors.to_color((255, 0)), colors.COLORS.red)
+
+    def test_name_to_color_error(self):
+        colors.name_to_color('red')
+        with self.assertRaises(ValueError):
+            colors.name_to_color(3)
+        with self.assertRaises(ValueError):
+            colors.name_to_color('xxx')
+        with self.assertRaises(ValueError):
+            colors.name_to_color((1, 2, 3))
+
+    def test_color_to_name_error(self):
+        with self.assertRaises(ValueError):
+            colors.color_to_name('red')
+        with self.assertRaises(ValueError):
+            colors.color_to_name(3)
+        with self.assertRaises(ValueError):
+            colors.color_to_name('xxx')
+        colors.color_to_name((1, 2, 3))