Repository: ManiacalLabs/BiblioPixel
Pull request #636 (2018-02-27 13:42:31)

Antepenultimate or Controller-related commits
These deal with the issues that I brought up earlier.

---

The first two commits are tweaks and refactorings.

---

The third one is quite significant - `collection.Collection.index` is now a property, but actually, the whole "Collection that has a `current_animation`" idea got spun off into a separate class, `collection.Indexed`.

`Collection` got too big with this new change, and I think you'll agree it makes no sense for `Parallel` and `Split` - which use multiple animations at once! - to have any idea of `current_animation`.

I didn't just do this to be annoying :-D - I need to be able to say `.animation.index = 0` in the control, and have all the loading of the new image, debug logging of the name, etc occur automatically.

---

And finally, we add a thread-safe event queue to the project.  It's unfortunately only thread-safe with respect to the animation engine, not to the driver engine, so it does not solve the "brightness" issue as I originally advertised!  Oh, well.

It came out nicely.  If you wanted to go right to the event queue suddenly in client code you'd write something like:

    project.deferred_set(Address('.animation.index'), 0)

which would set `project.animation.index` to `0` at the start of the next rendering frame!

rec: Ah, hold off on this for a bit.  I have a couple of last tweaks, and I just pushed some partial commits to the end of this by mistake...!


rec: All right!  This actually does something!!!

I rearranged things considerably.  :-D 

Now the first four commits are basically trivial.

Then the split-up of collection.Collection - fun fun.

A series of commits to the Project - adding an event queue, fixing a parser bug, and moving the start and cleanup logic out of main/run.py and into project/project.py.

Then there's a big deal - a final change to Address.  I realized that a lot of the time, when I trigger on some event, at the end of routing I am left with `()`!  So I need a way to add preset values for that very common case - and I did it with the `=` sign so you say things like `animation.index = 1`, which seems pretty clear to me.

The last commit is not so coherent - it's a bunch of small fixes and tweaks in order to actually get the controls seamlessly with projects, and actually, I'm going to tweak that just a hair.  However, there's nothing really alien in there, just a bunch of small  tweaks...
rec: Ping!

Patch:
diff --git a/bibliopixel/animation/animation.py b/bibliopixel/animation/animation.py
index 69c4f4c..e7d13f5 100644
--- a/bibliopixel/animation/animation.py
+++ b/bibliopixel/animation/animation.py
@@ -72,6 +72,17 @@ class BaseAnimation(object):
         if clean_layout:
             self.layout.cleanup()
 
+    def preframe_callback(self):
+        """
+        preframe_callback is called right before the start of a frame rendering
+        pass.
+
+        The ``Project`` writes over this method, for the top-level animation
+        only, in order to drain its event_queue at a moment where no rendering
+        is happening to avoid race conditions.
+        """
+        pass
+
     def start(self):
         self.threading.start()
 
@@ -102,6 +113,9 @@ class BaseAnimation(object):
     def _run_one_frame(self):
         timestamps = [time.time()]
 
+        self.preframe_callback()
+        timestamps = [time.time()]
+
         self.step(self.runner.amt)
         timestamps.append(time.time())
 
diff --git a/bibliopixel/animation/collection.py b/bibliopixel/animation/collection.py
index 129a4b0..611625d 100644
--- a/bibliopixel/animation/collection.py
+++ b/bibliopixel/animation/collection.py
@@ -5,6 +5,9 @@ from .. util import json, log
 
 
 class Collection(animation.BaseAnimation):
+    """
+    A ``Collection`` is a list of ``BaseAnimation``s
+    """
     FAIL_ON_EXCEPTION = False
 
     @staticmethod
@@ -48,7 +51,6 @@ class Collection(animation.BaseAnimation):
     def __init__(self, layout, animations=None, **kwds):
         super().__init__(layout, **kwds)
         self.animations = animations or []
-        self.index = 0
         self.internal_delay = 0  # never wait
 
     # Override to handle all the animations
@@ -64,14 +66,57 @@ class Collection(animation.BaseAnimation):
         self.animations.append(anim)
 
     def pre_run(self):
-        self.index = -1
         for a in self.animations:
             a.pre_run()
 
+
+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):
+        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.current_animation:
+            log.debug('%s: %s',
+                      self.__class__.__name__, self.current_animation.title)
+            self.frames = self.current_animation.generate_frames(False)
+
     @property
     def current_animation(self):
-        if 0 <= self.index < len(self.animations):
-            return self.animations[self.index]
+        """
+        :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]
 
 
 class Parallel(Collection):
@@ -84,7 +129,7 @@ class Parallel(Collection):
             a.layout = a.layout.mutable_copy()
 
 
-class Wrapper(Collection):
+class Wrapper(Indexed):
     # TODO: No unit tests cover any of this.
     @staticmethod
     def pre_recursion(desc):
@@ -93,9 +138,11 @@ class Wrapper(Collection):
         desc['animations'] = [desc.pop('animation')]
         return Collection.pre_recursion(desc)
 
+    def pre_run(self):
+        super().pre_run()
+        self.index = 0
+
     @property
     def animation(self):
-        return self.animations[0]
-
-    def step(self, amt=1):
-        self.animation.step(amt)
+        # This is used elsewhere in client code.  TODO: pick one or the other!
+        return self.current_animation
diff --git a/bibliopixel/animation/failed.py b/bibliopixel/animation/failed.py
new file mode 100644
index 0000000..74aceb8
--- /dev/null
+++ b/bibliopixel/animation/failed.py
@@ -0,0 +1,21 @@
+import traceback
+from .. util import log
+from . import animation
+
+
+class Failed(animation.BaseAnimation):
+    """
+    An animation that's created when we fail to load or construct the
+    animation that was originally specified
+    """
+
+    def __init__(self, layout, desc, exception):
+        super().__init__(layout)
+        self._set_runner({})
+        log.error('Unable to create animation for %s', desc)
+        debug = log.get_log_level() <= log.DEBUG
+        msg = traceback.format_exc() if debug else str(exception)
+        log.error('\n%s', msg)
+        self.desc = desc
+        self.exception = exception
+        self.empty = True
diff --git a/bibliopixel/animation/remote/control.py b/bibliopixel/animation/remote/control.py
index 2078775..0cd7ec5 100644
--- a/bibliopixel/animation/remote/control.py
+++ b/bibliopixel/animation/remote/control.py
@@ -23,7 +23,7 @@ def normalize_name(name):
     return ''.join('_' if e is ' ' else e for e in name if e.isalnum() or e is ' ')
 
 
-class RemoteControl(collection.Collection):
+class RemoteControl(collection.Indexed):
     @staticmethod
     def pre_recursion(desc):
         collection.Collection.pre_recursion(desc)
diff --git a/bibliopixel/animation/reprocess/reprocess.py b/bibliopixel/animation/reprocess/reprocess.py
index eccf908..a694978 100644
--- a/bibliopixel/animation/reprocess/reprocess.py
+++ b/bibliopixel/animation/reprocess/reprocess.py
@@ -17,7 +17,7 @@ class MatrixIndexer:
         self.cutter.apply(self.function)
 
 
-class Reprocess(collection.Collection):
+class Reprocess(collection.Wrapper):
     def __init__(self, *args, process=None, **kwds):
         super().__init__(*args, **kwds)
         self.preclear = False
@@ -36,6 +36,5 @@ class Reprocess(collection.Collection):
         self.process = datatype(layout=self.layout, **process)
 
     def step(self, amt=1):
-        anim = self.current_animation
-        anim and anim.step(amt)
+        super().step(amt)
         self.process()
diff --git a/bibliopixel/animation/sequence.py b/bibliopixel/animation/sequence.py
index 4126f69..2907120 100644
--- a/bibliopixel/animation/sequence.py
+++ b/bibliopixel/animation/sequence.py
@@ -3,7 +3,7 @@ from .. util import log
 import random as rand
 
 
-class Sequence(collection.Collection):
+class Sequence(collection.Indexed):
     def __init__(self, layout, random=False, length=None, **kwds):
         """
         Arguments
@@ -23,7 +23,6 @@ class Sequence(collection.Collection):
     def restart(self):
         self.random and rand.shuffle(self.animations)
         self.index = 0
-        self._load_animation()
 
     def pre_run(self):
         self.offset = 0
@@ -40,20 +39,17 @@ class Sequence(collection.Collection):
             pass
 
         self.index += 1
-        if self.index < len(self.animations):
-            self._load_animation()
-
-        elif self.runner.until_complete:
-            self.completed = True
-
-        else:
-            self.offset += len(self.animations)
-            self.restart()
-
-    def _load_animation(self):
-        log.debug('Sequence: %s', self.current_animation.title)
-        if self.length:
-            step = self.offset + self.index
-            length = self.length[step % len(self.length)]
-            self.current_animation.runner.seconds = length
-        self.frames = self.current_animation.generate_frames(False)
+        if self.index >= len(self.animations):
+            if self.runner.until_complete:
+                self.completed = True
+            else:
+                self.offset += len(self.animations)
+                self.restart()
+
+    def _on_index(self, old_index):
+        if self.current_animation:
+            super()._on_index(old_index)
+            if self.length:
+                step = self.offset + self.index
+                length = self.length[step % len(self.length)]
+                self.current_animation.runner.seconds = length
diff --git a/bibliopixel/control/address.py b/bibliopixel/control/address.py
index ddfe3a8..dfe5edc 100644
--- a/bibliopixel/control/address.py
+++ b/bibliopixel/control/address.py
@@ -6,11 +6,11 @@ An address description is a string looking like:
 
 ::
 
-    .foo.bar[32][5][baz].bang
+    .foo.bar[32][5][baz].bang()
 
 which would mean
 
-"given an object target, the value ``target.foo.bar[32][5]['baz'].bang``".
+"given an object target, the value ``target.foo.bar[32][5]['baz'].bang()``".
 
 Addresses are divided into "segments".
 
@@ -61,18 +61,32 @@ class Address:
             return target()
 
         def set(self, target, *value):
-            print('!!!', target)
             target(*value)
 
-    def __init__(self, s):
+    def __init__(self, name):
+        self.name, *assignment = name.split('=', 1)
+        self.name = self.name.strip()
+
         try:
-            self.address = list(_generate(s))
+            self.address = list(_generate(self.name))
         except:
-            raise ValueError('%s is not a legal address' % s)
+            raise ValueError('%s is not a legal address' % name)
 
         if not self.address:
             raise ValueError('Empty Addresses are not allowed')
 
+        if assignment:
+            if isinstance(self.address[-1], Address.Call):
+                raise ValueError('Cannot assign to a call operation')
+            assignment = [s.strip() for s in assignment[0].split(',')]
+            assignment = [int(s) if s.isnumeric() else s for s in assignment]
+            self.assignment = tuple(assignment)
+        else:
+            self.assignment = ()
+
+    def __str__(self):
+        return self.name
+
     @staticmethod
     def _get(target, address):
         for a in address:
@@ -82,10 +96,10 @@ class Address:
     def get(self, target):
         return self._get(target, self.address)
 
-    def set(self, target, *value):
+    def set(self, target, *values):
         *first, last = self.address
         parent = self._get(target, first)
-        last.set(parent, *value)
+        last.set(parent, *(self.assignment + values))
 
 
 def _generate(s):
diff --git a/bibliopixel/control/control.py b/bibliopixel/control/control.py
index 70015b6..81d898d 100644
--- a/bibliopixel/control/control.py
+++ b/bibliopixel/control/control.py
@@ -4,6 +4,7 @@ from . extractor import Extractor
 from . ops import Ops
 from .. project import construct, importer
 from .. util.log_errors import LogErrors
+from .. util import flatten, log, json
 
 
 class OpsAddress(Address):
@@ -11,27 +12,33 @@ class OpsAddress(Address):
         super().__init__(address)
         self.ops = Ops(*ops)
 
-    def set(self, target, first, *rest):
-        if self.ops and not rest:
-            super().set(target, self.ops(first))
-        else:
-            super().set(target, first, *rest)
+    def set(self, project, *args):
+        if args and self.ops and len(args) == 1:
+            args = [self.ops(args[0])]
+        return super().set(project, *args)
 
 
 class Control:
     DEFAULT = {'datatype': OpsAddress}
 
     def __init__(self, routing, default=None, max_errors=16,
-                 python_path='bibliopixel.control'):
+                 python_path='bibliopixel.control', verbose=False):
         default = dict(self.DEFAULT, **(default or {}))
+        self.verbose = verbose
+        routing = flatten.unflatten(routing)
         self.routing = _make(routing, python_path, default, 'control')
         self.safe_receive = LogErrors(self.receive, max_errors)
 
-    def start(self, target):
-        self.target = target
+    def start(self, project):
+        if self.verbose:
+            log.info('Starting %s', self)
+        self.project = project
         self.thread = self._make_thread()
         self.thread.start()
 
+    def cleanup(self):
+        self.stop()
+
     def stop(self):
         self.thread.stop()
 
@@ -44,18 +51,26 @@ class Control:
         Receive a message from the input source.
         """
         routing, msg = self.routing, self._convert(msg)
-        while routing and msg:
+        if self.verbose and log.is_debug():
+            log.debug('Message %s', self._msg_to_str(msg))
+
+        while routing:
             if isinstance(routing, list):
+                if self.verbose:
+                    log.info('Routed message %s to %s',
+                             self._msg_to_str(msg), routing)
                 for address in routing:
-                    address.set(self.target, *msg.values())
+                    self.project.deferred_set(address, *msg.values())
                 return
 
-            if isinstance(routing, dict):
-                _, v = msg.popitem(last=False)
-                routing = routing.get(str(v))
-            else:
+            if not msg:
+                return
+            if not isinstance(routing, dict):
                 raise ValueError('Unexpected type %s' % type(routing))
 
+            k, v = msg.popitem(last=False)
+            routing = routing.get(str(v))
+
     def _convert(self, msg):
         """
         Convert the message to a new ``collections.OrderedDict``.
@@ -76,6 +91,9 @@ class Control:
         """
         return threading.Thread(target=self.loop, daemon=True)
 
+    def _msg_to_str(self, msg):
+        return '.'.join(msg.values()) or '.'
+
 
 class ExtractedControl(Control):
     EXTRACTOR = {}
diff --git a/bibliopixel/control/extractor.py b/bibliopixel/control/extractor.py
index d05a8d5..dff537d 100644
--- a/bibliopixel/control/extractor.py
+++ b/bibliopixel/control/extractor.py
@@ -39,7 +39,7 @@ class Extractor:
             want to see channel=1, type=note_on with each message.
         """
         def to_set(x):
-            if x is None:
+            if not x:
                 return set()
             if isinstance(x, (list, tuple)):
                 return set(x)
@@ -56,6 +56,8 @@ class Extractor:
         self.normalizers = normalizers or {}
         self.keys_by_type = {}
         for k, v in (keys_by_type or {}).items():
+            if isinstance(v, str):
+                v = [v]
             self.keys_by_type[k] = tuple(i for i in v if i not in omit)
 
     def extract(self, msg):
diff --git a/bibliopixel/control/keyboard.py b/bibliopixel/control/keyboard.py
index 7db69e1..e562735 100644
--- a/bibliopixel/control/keyboard.py
+++ b/bibliopixel/control/keyboard.py
@@ -35,6 +35,12 @@ class Keyboard(ExtractedControl):
         },
     }
 
+    def _press(self, key):
+        self.receive({'type': 'press', 'key': key})
+
+    def _release(self, key):
+        self.receive({'type': 'release', 'key': key})
+
     def _make_thread(self):
         try:
             import pynput
@@ -46,9 +52,7 @@ class Keyboard(ExtractedControl):
             if getpass.getuser() != 'root':
                 log.warning(DARWIN_ROOT_WARNING, sys.argv[0])
 
-        return pynput.keyboard.Listener(
-            lambda key: self.receive({'type': 'press', 'key': key}),
-            lambda key: self.receive({'type': 'release', 'key': key}))
+        return pynput.keyboard.Listener(self._press, self._release)
 
 
 class KeyboardTester:
@@ -56,6 +60,10 @@ class KeyboardTester:
         super().__setattr__(k, v)
         print(k, '=', v)
 
+    def deferred_set(self, address, key):
+        print(address, key)
+        setattr(self, str(address), key)
+
 
 def test_keyboard():
     keyboard = Keyboard(routing={'press': 'press', 'release': 'release'})
diff --git a/bibliopixel/drivers/PiWS281X.py b/bibliopixel/drivers/PiWS281X.py
index 5ebbbf8..bfdd6bf 100644
--- a/bibliopixel/drivers/PiWS281X.py
+++ b/bibliopixel/drivers/PiWS281X.py
@@ -59,7 +59,30 @@ class PiWS281X(DriverBase):
     Driver for controlling WS281X LEDs via the rpi_ws281x C-extension.
     Only supported on the Raspberry Pi 2, 3, and Zero
 
-    This driver needs to be run as sudo.
+    This driver needs to be run as sudo and requires the rpi_ws281x C extension.
+
+    Install rpi_ws281x with the following shell commands:
+
+        git clone https://github.com/jgarff/rpi_ws281x.git
+        cd rpi_ws281x
+
+        sudo apt-get install python-dev swig scons
+        sudo scons
+
+        cd python
+        # If using default system python3
+        sudo python3 setup.py build install
+        # If using virtualenv, enter env then run
+        python setup.py build install
+
+    Provides the same parameters of :py:class:`.driver_base.DriverBase` as
+    well as those below:
+
+    :param int gpio: GPIO pin to output to. Typically 18 or 13
+    :param int ledFreqHz: WS2812B base data frequency in Hz. Only change to
+        400000 if using very old WS218B LEDs
+    :param int ledDma: DMA channel to use for generating signal (Between 1 and 14)
+    :param bool ledInvert: True to invert the signal (when using NPN transistor level shift)
     """
     # Including follow as comment as URLs in docstrings don't play well with sphinx
     # Discussion re: running as sudo
@@ -67,16 +90,10 @@ class PiWS281X(DriverBase):
     # https://github.com/jgarff/rpi_ws281x/blob/master/python/neopixel.py#L106
 
     def __init__(
-            self, num, gamma=gamma.NEOPIXEL, c_order=ChannelOrder.RGB, gpio=18,
+            self, num, gamma=gamma.NEOPIXEL, c_order="RGB", gpio=18,
             ledFreqHz=800000, ledDma=5, ledInvert=False,
             color_channels=3, brightness=255, **kwds):
-        """
-        num - Number of LED pixels.
-        gpio - GPIO pin connected to the pixels (must support PWM! GPIO 13 or 18 (pins 33 or 12) on RPi 3).
-        ledFreqHz - LED signal frequency in hertz (800khz or 400khz)
-        ledDma - DMA channel to use for generating signal (Between 1 and 14)
-        ledInvert - True to invert the signal (when using NPN transistor level shift)
-        """
+
         if not NeoColor:
             raise ValueError(WS_ERROR)
         super().__init__(num, c_order=c_order, gamma=gamma, **kwds)
diff --git a/bibliopixel/drivers/SPI/APA102.py b/bibliopixel/drivers/SPI/APA102.py
index a8e590a..6124f5b 100644
--- a/bibliopixel/drivers/SPI/APA102.py
+++ b/bibliopixel/drivers/SPI/APA102.py
@@ -4,7 +4,12 @@ from . base import SPIBase
 
 
 class APA102(SPIBase):
-    """Driver for APA102/SK9822 based LED strips on devices like the Raspberry Pi and BeagleBone"""
+    """Driver for APA102/SK9822 based LED strips on devices like
+    the Raspberry Pi and BeagleBone
+
+    Provides the same parameters as
+    :py:class:`bibliopixel.drivers.SPI.SPIBase`
+    """
 
     def __init__(self, num, gamma=gamma.APA102, **kwargs):
         super().__init__(num, gamma=gamma, **kwargs)
diff --git a/bibliopixel/drivers/SPI/LPD8806.py b/bibliopixel/drivers/SPI/LPD8806.py
index ecb6c9a..0d14e46 100644
--- a/bibliopixel/drivers/SPI/LPD8806.py
+++ b/bibliopixel/drivers/SPI/LPD8806.py
@@ -5,7 +5,11 @@ from . base import SPIBase
 
 class LPD8806(SPIBase):
     """Main driver for LPD8806 based LED strips on devices like the Raspberry Pi
-       and BeagleBone."""
+       and BeagleBone.
+
+    Provides the same parameters as
+    :py:class:`bibliopixel.drivers.SPI.SPIBase`
+    """
 
     def __init__(self, num, gamma=gamma.LPD8806, **kwargs):
         super().__init__(num, gamma=gamma, **kwargs)
diff --git a/bibliopixel/drivers/SPI/WS2801.py b/bibliopixel/drivers/SPI/WS2801.py
index f007b3a..3642a20 100644
--- a/bibliopixel/drivers/SPI/WS2801.py
+++ b/bibliopixel/drivers/SPI/WS2801.py
@@ -4,7 +4,12 @@ from ... util.colors import gamma
 
 
 class WS2801(SPIBase):
-    """Main driver for WS2801 based LED strips on devices like the Raspberry Pi and BeagleBone"""
+    """Main driver for WS2801 based LED strips on devices like the
+    Raspberry Pi and BeagleBone
+
+    Provides the same parameters as
+    :py:class:`bibliopixel.drivers.SPI.SPIBase`
+    """
 
     def __init__(self, num, gamma=gamma.WS2801, spi_speed=1, **kwargs):
         if not (0 < spi_speed <= 1):
diff --git a/bibliopixel/drivers/SPI/WS281X.py b/bibliopixel/drivers/SPI/WS281X.py
index 9675c0d..b0a6666 100644
--- a/bibliopixel/drivers/SPI/WS281X.py
+++ b/bibliopixel/drivers/SPI/WS281X.py
@@ -11,6 +11,9 @@ class WS281X(SPIBase):
     """
     SPI driver for WS2812(b) based LED strips on devices like
     Raspberry Pi, OrangePi, BeagleBone,..
+
+    Provides the same parameters as
+    :py:class:`bibliopixel.drivers.SPI.SPIBase`
     """
 
     def __init__(self, num, gamma=_gamma.WS2812, spi_speed=3.2, **kwargs):
diff --git a/bibliopixel/drivers/SPI/base.py b/bibliopixel/drivers/SPI/base.py
index 5880086..c4d9e42 100644
--- a/bibliopixel/drivers/SPI/base.py
+++ b/bibliopixel/drivers/SPI/base.py
@@ -6,7 +6,18 @@ from . import interfaces
 
 class SPIBase(DriverBase):
     """Base driver for controling SPI devices on systems like the Raspberry Pi
-       and BeagleBone"""
+      and BeagleBone
+
+    Provides the same parameters of
+    :py:class:`bibliopixel.drivers.driver_base.DriverBase` as
+    well as those below:
+
+    :param str dev: SPI device path
+    :param str spi_interface: Interface API with which to connect to the SPI
+        device. One of :py:class:`bibliopixel.drivers.spi_interfaces.SPI_INTERFACES`
+    :param int spi_speed: Output data rate, in MHz
+    :param interface: DEPRECATED - Use spi_interface
+    """
     def __init__(self, num, dev='/dev/spidev0.0',
                  spi_interface=None, spi_speed=1,
                  interface='FILE',  # DEPRECATED
diff --git a/bibliopixel/drivers/SPI/driver.py b/bibliopixel/drivers/SPI/driver.py
index 95e6fdb..2b2b6b7 100644
--- a/bibliopixel/drivers/SPI/driver.py
+++ b/bibliopixel/drivers/SPI/driver.py
@@ -22,7 +22,16 @@ SPI_DRIVERS = {
 
 def SPI(ledtype=None, num=0, **kwargs):
     """Wrapper function for using SPI device drivers on systems like the
-    Raspberry Pi and BeagleBone"""
+    Raspberry Pi and BeagleBone. This allows using any of the SPI drivers
+    from a single entry point instead importing the driver for a specific
+    LED type.
+
+    Provides the same parameters of
+    :py:class:`bibliopixel.drivers.SPI.SPIBase` as
+    well as those below:
+
+    :param ledtype: One of: LPD8806, WS2801, WS281X, or APA102
+    """
 
     from ...project.types.ledtype import make
     if ledtype is None:
diff --git a/bibliopixel/drivers/SPI/interfaces.py b/bibliopixel/drivers/SPI/interfaces.py
index b7dac15..73dfb07 100644
--- a/bibliopixel/drivers/SPI/interfaces.py
+++ b/bibliopixel/drivers/SPI/interfaces.py
@@ -13,12 +13,15 @@ class SpiBaseInterface(object):
         self._spi_speed = spi_speed
 
     def send_packet(self, data):
+        """SHOULD BE PRIVATE"""
         raise NotImplementedError
 
     def compute_packet(self, data):
+        """SHOULD BE PRIVATE"""
         return data
 
     def error(self, text):
+        """SHOULD BE PRIVATE"""
         msg = 'Error with dev: {}, spi_speed: {} - {}'.format(self._dev, self._spi_speed, text)
         log.error(msg)
         raise IOError(msg)
@@ -37,6 +40,7 @@ class SpiFileInterface(SpiBaseInterface):
         log.info('file io spi dev {:s}'.format(self._dev))
 
     def send_packet(self, data):
+        """SHOULD BE PRIVATE"""
         package_size = 4032  # bit smaller than 4096 because of headers
         for i in range(int(math.ceil(len(data) / package_size))):
             start = i * package_size
@@ -63,6 +67,7 @@ class SpiPeripheryInterface(SpiBaseInterface):
             self._dev, self._spi.max_speed / 1e6))
 
     def send_packet(self, data):
+        """SHOULD BE PRIVATE"""
         package_size = 4032  # bit smaller than 4096 because of headers
         for i in range(int(math.ceil(len(data) / package_size))):
             start = i * package_size
@@ -106,6 +111,7 @@ class SpiPyDevInterface(SpiBaseInterface):
             'py-spidev dev {:s} speed @ {:.2f} MHz'.format(self._dev, self._spi.max_speed_hz / 1e6))
 
     def send_packet(self, data):
+        """SHOULD BE PRIVATE"""
         self._spi.xfer2(list(data))
 
 
diff --git a/bibliopixel/drivers/SimPixel/SimpleWebSocketServer.py b/bibliopixel/drivers/SimPixel/SimpleWebSocketServer.py
index 874d79a..6c6561c 100644
--- a/bibliopixel/drivers/SimPixel/SimpleWebSocketServer.py
+++ b/bibliopixel/drivers/SimPixel/SimpleWebSocketServer.py
@@ -1,7 +1,3 @@
-'''
-The MIT License (MIT)
-Copyright (c) 2013 Dave P.
-'''
 # flake8: noqa
 import sys
 VER = sys.version_info[0]
diff --git a/bibliopixel/drivers/SimPixel/driver.py b/bibliopixel/drivers/SimPixel/driver.py
index 7768aa0..37464f3 100644
--- a/bibliopixel/drivers/SimPixel/driver.py
+++ b/bibliopixel/drivers/SimPixel/driver.py
@@ -5,16 +5,23 @@ from ... util import log, server_cache
 
 
 class SimPixel(DriverBase):
+    """Output a simulation of your displat to the web-based
+    simulator at http://simpixel.io
+
+    Provides the same parameters of
+    :py:class:`bibliopixel.drivers.driver_base.DriverBase` as
+    well as those below:
+
+    :param int port: Port to serve websocket server on.
+    :param pixel_positions: Override the automatic generation of pyhsical
+        pixel layout. This value can be generated via the methods in
+        :py:mod:`bibliopixel.layout.geometry`
+    """
+
     CACHE = server_cache.ServerCache(websocket.Server, selectInterval=0.001)
 
     def __init__(self, num=1024, port=1337, pixel_positions=None, **kwds):
-        """
-        Args:
-            num:  number of LEDs being visualizer.
-            port:  the port on which the SimPixel server is running.
-            pixel_positions:  the positions of the LEDs in 3-d space.
-            **kwds:  keywords passed to DriverBase.
-        """
+
         super().__init__(num, **kwds)
         self.port = port
         self.pixel_positions = self.server = self.thread = None
@@ -23,11 +30,13 @@ class SimPixel(DriverBase):
             self.set_pixel_positions(pixel_positions)
 
     def start(self):
+        """SHOULD BE PRIVATE"""
         self.server = self.CACHE.get_server(self.port)
         if self.pixel_positions:
             self.server.update(positions=self.pixel_positions)
 
     def set_pixel_positions(self, pixel_positions):
+        """SHOULD BE PRIVATE"""
         # Flatten list of led positions.
         pl = [c for p in pixel_positions for c in p]
         self.pixel_positions = bytearray(struct.pack('<%sh' % len(pl), *pl))
@@ -35,6 +44,7 @@ class SimPixel(DriverBase):
             self.server.update(positions=self.pixel_positions)
 
     def cleanup(self):
+        """SHOULD BE PRIVATE"""
         self.server.close()
 
     def _compute_packet(self):
diff --git a/bibliopixel/drivers/channel_order.py b/bibliopixel/drivers/channel_order.py
index 6a66eac..d766473 100644
--- a/bibliopixel/drivers/channel_order.py
+++ b/bibliopixel/drivers/channel_order.py
@@ -1,4 +1,8 @@
 class ChannelOrder(object):
+    """
+    Helper class to automatically convert string values into tuple
+    values needed to define color channel order.
+    """
 
     RGB = 0, 1, 2
     RBG = 0, 2, 1
diff --git a/bibliopixel/drivers/driver_base.py b/bibliopixel/drivers/driver_base.py
index 20d56cd..05537c0 100644
--- a/bibliopixel/drivers/driver_base.py
+++ b/bibliopixel/drivers/driver_base.py
@@ -5,7 +5,15 @@ import threading, time
 
 
 class DriverBase(object):
-    """Base driver class to build other drivers from."""
+    """
+    Base driver class to build other drivers from.
+
+    :param int num: Number of total pixels held by this driver
+    :param int width: Width of matrix, for use with :py:class:`bibliopixel.layout.matrix.Matrix`
+    :param int height: Height of matrix, for use with :py:class:`bibliopixel.layout.matrix.Matrix`
+    :param str c_order: Color channel order
+    :param gamma: Gamma correction table. Preset tables available in :py:mod:`bibliopixel.util.colors.gamma`
+    """
 
     # If set_device_brightness is not empty, it's a method that allows you
     # to directly set the brightness for the device.
@@ -21,7 +29,7 @@ class DriverBase(object):
         """Construct a driver from a project and a description."""
         return cls(maker=project.maker, **desc)
 
-    def __init__(self, num=0, width=0, height=0, c_order=ChannelOrder.RGB,
+    def __init__(self, num=0, width=0, height=0, c_order="RGB",
                  gamma=None, maker=data_maker.MAKER, **kwds):
         attributes.set_reserved(self, 'driver', **kwds)
 
@@ -53,9 +61,23 @@ class DriverBase(object):
         self._waiting_brightness = None
 
     def set_pixel_positions(self, pixel_positions):
+        """
+        Internal Use Only
+
+        Placeholder callback for sending physical pixel layout data to the
+        :py:mod:`.SimPixel` driver.
+        """
         pass
 
     def set_colors(self, colors, pos):
+        """
+        Use with caution!
+
+        Directly set the pixel buffers.
+
+        :param colors: A list of color tuples
+        :param int pos: Position in color list to begin set operation.
+        """
         self._colors = colors
         self._pos = pos
 
@@ -78,6 +100,11 @@ class DriverBase(object):
         pass
 
     def bufByteCount(self):
+        """
+        Total number of bytes that the pixel buffer represents.
+        Mainly used for drivers such as :py:mod:`bibliopixel.drivers.serial`
+        and :py:mod:`.network`
+        """
         return 3 * self.numLEDs
 
     def sync(self):
@@ -107,6 +134,11 @@ class DriverBase(object):
         pass
 
     def update_colors(self):
+        """Apply any corrections to the current color list
+        and send the results to the driver output. This function primarily
+        provided as a wrapper for each driver's implementation of
+        :py:func:`_compute_packet` and :py:func:`_send_packet`.
+        """
         start = time.time()
 
         with self.brightness_lock:
@@ -125,10 +157,19 @@ class DriverBase(object):
         self.lastUpdate = time.time() - start
 
     def set_brightness(self, brightness):
+        """Set the global brightness for this driver's output.
+
+        :param int brightness: 0-255 value representing the desired
+            brightness level
+        """
         with self.brightness_lock:
             self._waiting_brightness = brightness
 
     def _render(self):
+        """Typically called from :py:func:`_compute_packet` this applies
+        brightness and gamma correction to the pixels controlled by this
+        driver.
+        """
         if self.set_device_brightness:
             level = 1.0
         else:
diff --git a/bibliopixel/drivers/hue.py b/bibliopixel/drivers/hue.py
index 50e1674..f8cbfe8 100644
--- a/bibliopixel/drivers/hue.py
+++ b/bibliopixel/drivers/hue.py
@@ -12,6 +12,15 @@ import colorsys
 
 
 class Hue(DriverBase):
+    """
+    Driver for interacting with Philips Hue lights.
+
+    Provides the same parameters of :py:class:`.driver_base.DriverBase` as
+    well as those below:
+
+    :param str ip: Network hostname or IP address of the Hue base.
+    :param list nameMap: List of names to map to each pixel index
+    """
 
     def __init__(self, num, ip, nameMap=None, **kwds):
         super().__init__(num, **kwds)
diff --git a/bibliopixel/drivers/ledtype.py b/bibliopixel/drivers/ledtype.py
index aa4d72f..b1a962f 100644
--- a/bibliopixel/drivers/ledtype.py
+++ b/bibliopixel/drivers/ledtype.py
@@ -2,6 +2,10 @@ from enum import IntEnum
 
 
 class LEDTYPE(IntEnum):
+    """Enumerated LED type names to be used with
+    :py:mod:`bibliopixel.drivers.serial` and
+    :py:mod:`bibliopixel.drivers.SPI`
+    """
     GENERIC = 0  # Use if the serial device only supports one chipset
     LPD8806 = 1
     WS2801 = 2
diff --git a/bibliopixel/drivers/network.py b/bibliopixel/drivers/network.py
index 5bc8572..75d7cab 100644
--- a/bibliopixel/drivers/network.py
+++ b/bibliopixel/drivers/network.py
@@ -13,7 +13,17 @@ class CMDTYPE:
 
 
 class Network(DriverBase):
-    """Driver for communicating with another device on the network."""
+    """Driver for communicating with another device on the network.
+
+    To be used with either BiblioPixelAnimations.receivers.GenericNetworkReceiver
+    or :py:class:`.network_receiver.NetworkReceiver`
+
+    Provides the same parameters of :py:class:`.driver_base.DriverBase` as
+    well as those below:
+
+    :param str host: Network hostname or IP address of receiver.
+    :param int port: Network port of receiver
+    """
 
     def __init__(self, num=0, width=0, height=0, host="localhost", port=3142, **kwds):
         super().__init__(num, width, height, **kwds)
@@ -58,6 +68,15 @@ class Network(DriverBase):
             raise IOError(error)
 
     def set_device_brightness(self, brightness):
+        """Hardware specific method to set the global brightness for
+        this driver's output. This method is required to be implemented,
+        however, users should call
+        :py:meth:`.driver_base.DriverBase.set_brightness`
+        instead of calling this method directly.
+
+        :param int brightness: 0-255 value representing the desired
+            brightness level
+        """
         packet = util.generate_header(CMDTYPE.BRIGHTNESS, 1)
         packet.append(self._brightness)
         s = self._connect()
diff --git a/bibliopixel/drivers/network_udp.py b/bibliopixel/drivers/network_udp.py
index 0ea48eb..d3e065b 100644
--- a/bibliopixel/drivers/network_udp.py
+++ b/bibliopixel/drivers/network_udp.py
@@ -12,7 +12,18 @@ class CMDTYPE:
 
 
 class NetworkUDP(DriverBase):
-    """Driver for communicating with another device on the network."""
+    """Driver for communicating with another device on the network via UDP
+
+    To be used with either BiblioPixelAnimations.receivers.GenericNetworkReceiver
+    or :py:class:`.network_receiver.NetworkReceiver`
+
+    Provides the same parameters of :py:class:`.driver_base.DriverBase` as
+    well as those below:
+
+    :param str host: Network hostname or IP address of receiver.
+    :param int port: Network port of receiver
+    :param bool broadcast: If True, broadcast to subnet instead of specific host
+    """
 
     def __init__(self, num=0, width=0, height=0, host="localhost",
                  broadcast=False, port=3142, broadcast_interface='', **kwds):
diff --git a/bibliopixel/drivers/serial/devices.py b/bibliopixel/drivers/serial/devices.py
index 4846a28..b8d701c 100644
--- a/bibliopixel/drivers/serial/devices.py
+++ b/bibliopixel/drivers/serial/devices.py
@@ -6,7 +6,11 @@ from ... project.importer import import_module
 
 
 class Devices(object):
-    """Manage a list of serial devices."""
+    """Manage a list of serial devices.
+
+    :param str hardware_id: A valid USB VID:PID pair such as "1D50:60AB"
+    :param int baudrate: Baud rate to connect to serial device
+    """
 
     def __init__(self, hardware_id, baudrate):
         self.hardware_id = hardware_id
@@ -16,6 +20,10 @@ class Devices(object):
         self.list_ports = import_module('serial.tools.list_ports')
 
     def find_serial_devices(self):
+        """Scan and report all compatible serial devices on system.
+
+        :returns: List of discovered devices
+        """
         self.devices = {}
         hardware_id = "(?i)" + self.hardware_id  # forces case insensitive
 
@@ -41,6 +49,13 @@ class Devices(object):
         return self.devices
 
     def get_device(self, id=None):
+        """Returns details of either the first or specified device
+
+        :param int id: Identifier of desired device. If not given, first device
+            found will be returned
+
+        :returns tuple: Device ID, Device Address, Firmware Version
+        """
         if id is None:
             if not self.devices:
                 raise ValueError('No default device for %s' % self.hardware_id)
@@ -59,12 +74,21 @@ class Devices(object):
         return id, device, version
 
     def error(self, fail=True):
+        """
+        SHOULD BE PRIVATE METHOD
+        """
         error = "There was an unknown error communicating with the device."
         log.error(error)
         if fail:
             raise IOError(error)
 
     def set_device_id(self, dev, id, baudrate=921600):
+        """Set device ID to new value.
+
+        :param str dev: Serial device address/path
+        :param id: Device ID to set
+        :param baudrate: Baudrate to use when connectinh (optional)
+        """
         if id < 0 or id > 255:
             raise ValueError("ID must be an unsigned byte!")
 
@@ -81,6 +105,11 @@ class Devices(object):
             raise_error(ord(resp))
 
     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)
+        """
         packet = util.generate_header(CMDTYPE.GETID, 0)
         com = self.Serial(dev, baudrate=baudrate, timeout=5)
         com.write(packet)
diff --git a/bibliopixel/drivers/serial/driver.py b/bibliopixel/drivers/serial/driver.py
index 6f83308..af75230 100644
--- a/bibliopixel/drivers/serial/driver.py
+++ b/bibliopixel/drivers/serial/driver.py
@@ -10,10 +10,26 @@ from ... drivers.return_codes import (
 
 
 class Serial(DriverBase):
-    """Main driver for Serial based LED strips"""
+    """Main driver for Serial based LED strips and devices like the AllPixel
+
+    Provides the same parameters of
+    :py:class:`bibliopixel.drivers.driver_base.DriverBase` as
+    well as those below:
+
+    :param ledtype: LED protocol type. One of
+        :py:func:`bibliopixel.drivers.ledtype.LEDTYPE`
+    :param str dev: Serial device address/path. If left empty, first device
+        found will be used.
+    :param int spi_speed: SPI datarate for applicable LED types, in MHz
+    :param int restart_timeout: Seconds to wait between reconfigure reboot
+        and reconnection attempt
+    :param int device_id: Device ID to connect to.
+    :param str hardwareID: A valid USB VID:PID pair such as "1D50:60AB"
+    :param int baudrate: Baud rate to connect to serial device
+    """
 
     def __init__(self, ledtype=None, num=0, dev="",
-                 c_order=ChannelOrder.RGB, spi_speed=2,
+                 c_order='RGB', spi_speed=2,
                  gamma=None, restart_timeout=3,
                  device_id=None, hardwareID="1D50:60AB",
                  baudrate=921600, **kwds):
@@ -63,6 +79,9 @@ class Serial(DriverBase):
         self.set_device_brightness = self.set_brightness
 
     def cleanup(self):
+        """
+        SHOULD BE PRIVATE
+        """
         if self._com:
             log.info("Closing connection to: %s", self.dev)
             self._close()
@@ -178,6 +197,16 @@ DriverSerial = Serial
 
 
 class TeensySmartMatrix(Serial):
+    """Variant of :py:class:`Serial` for use with the Teensy and
+    SmartMatrix library. The following provides compatible firmware:
+    https://github.com/ManiacalLabs/BiblioPixelSmartMatrix
+
+    All parameters are the same as with :py:class:`Serial`, except the
+    default hardwareID is changed to match the Teensy.
+
+    The main difference is that SmartMatrix requires a sync command to keep multiple
+    instances of this driver running smoothly.
+    """
     def __init__(self, width, height, dev="", device_id=None,
                  hardwareID="16C0:0483", **kwds):
         super().__init__(ledtype=LEDTYPE.GENERIC, num=width * height,
diff --git a/bibliopixel/layout/layout.py b/bibliopixel/layout/layout.py
index 2755613..39130a8 100644
--- a/bibliopixel/layout/layout.py
+++ b/bibliopixel/layout/layout.py
@@ -5,27 +5,32 @@ from .. util.threads.update_threading import UpdateThreading
 
 
 class Layout(object):
+    """
+    Base Layer class. Use Strip, Matrix, Cube, or Circle instead!
+
+    :param drivers: A list of drivers
+    :param threadedUpdate: If True, updates to this layout are done in a
+        separate thread
+    :param brightness: An initial brightness value from 0 to 255
+    :param maker: A data maker to make color_lists. TODO: Link to what a maker
+        is.
+    :param color_list: If non-Null, the layout uses this color_list instead
+        of creating its own
+
+    :ivar int numLEDs: Total number of pixels held by this layout instance
+    """
+
     pre_recursion = fields.default_converter
 
     @classmethod
     def construct(cls, project, **desc):
-        """Construct a layout."""
+        """Construct a layout.
+        SHOULD BE PRIVATE
+        """
         return cls(project.drivers, maker=project.maker, **desc)
 
     def __init__(self, drivers, threadedUpdate=False, brightness=255,
                  maker=data_maker.MAKER, color_list=None, **kwds):
-        """
-        Base LED class. Use Strip or Matrix instead!
-
-        Arguments
-            drivers -- a list of drivers
-            threadedUpdate -- if True, updates to this layout are done in a
-                separate thread
-            brightness - a brightness multiple from 0 to 255
-            maker - a data maker to make color_lists
-            color_list -- if non-Null, the layout uses this color_list instead
-                of creating its own
-        """
         attributes.set_reserved(self, 'layout', **kwds)
         self.drivers = drivers if isinstance(drivers, list) else [drivers]
         self.maker = maker
@@ -53,11 +58,12 @@ class Layout(object):
         self.set_brightness(brightness)
 
     def set_pixel_positions(self, pixel_positions):
+        """SHOULD BE PRIVATE"""
         for d in self.drivers:
             d.set_pixel_positions(pixel_positions)
 
     def update(self):
-        """DEPRECATED - use self.push_to_driver()"""
+        """DEPRECATED - use :py:func:`push_to_driver`"""
         return self.push_to_driver()
 
     def start(self):
diff --git a/bibliopixel/main/commands.py b/bibliopixel/main/commands.py
index 3a561b7..9848079 100644
--- a/bibliopixel/main/commands.py
+++ b/bibliopixel/main/commands.py
@@ -9,8 +9,7 @@ COMMANDS = (
 MODULES = {c: import_module('bibliopixel.main.' + c) for c in COMMANDS}
 
 RST_HELP = """\
-``bp`` - The BiblioPixel Project Runner
-========================================
+## ``bp`` - The BiblioPixel Project Runner
 
 ``bp`` is a command-line script installed with Bibliopixel.  It can run
 projects and demos, configure hardware, save and load defaults, and more.
@@ -21,29 +20,29 @@ projects and demos, configure hardware, save and load defaults, and more.
 
 Some commands take *arguments* - often names of files.
 
-Examples of ``bp`` commands and arguments
-------------------------------------------
+### Examples of ``bp`` commands and arguments
 
-    bp info
-    bp demo circle
-    bp run foo.json bar.json
-    bp foo.json bar.json      # run is the default command
+``````
+bp info
+bp demo circle
+bp run foo.json bar.json
+bp foo.json bar.json      # run is the default command
+``````
 
 The commands are summarized below, and you can get more detailed help about
 each one by typing:
 
-::
-
-    bp <command> --help
+``````
+bp <command> --help
+``````
 
 or
 
-::
-
-    bp <command> -h
+``````
+bp <command> -h
+``````
 
-The ``bp`` command line
--------------------------
+### The ``bp`` command line
 
 ``bp`` commands have *flags* that control how ``bp`` runs its commands.
 
@@ -60,37 +59,35 @@ Other flags do not, like ``--verbose`` or ``-v``.
 A ``bp`` command line can optionally include a command, a list of arguments,
 and a list of flags:
 
-::
-
-    bp [<command>] [<argument> ...] [<flag> ...]
-
-Examples of ``bp`` command lines
---------------------------------
+``````
+bp [<command>] [<argument> ...] [<flag> ...]
+``````
 
-::
+### Examples of ``bp`` command lines
 
-    bp info --help
-    bp run -h
-    bp info
-    bp color "indian red 4"
-    bp foo.json bar.json -v
-    bp foo.json bar.json -v --numbers=float --dump --defaults=default.json
+``````
+bp info --help
+bp run -h
+bp info
+bp color "indian red 4"
+bp foo.json bar.json -v
+bp foo.json bar.json -v --numbers=float --dump --defaults=default.json
+``````
 """.format(len(COMMANDS),
            ', '.join(COMMANDS[0:8]),
            ', '.join(COMMANDS[8:]))
 
-SEP = '\n::\n'
+SEP = '``````'
 
 
-def un_rst(s):
+def un_md(s):
     return s.replace(SEP, '').replace('``', '`')
 
 
-HELP = un_rst(RST_HELP)
+HELP = un_md(RST_HELP)
 
 COMMAND_HELP_PREFIX = """
-APPENDIX: ``bp <command> --help`` for each command
------------------------------------------------------
+### APPENDIX: ``bp <command> --help`` for each command
 """
 
 
diff --git a/bibliopixel/main/demo.py b/bibliopixel/main/demo.py
index 9c2d96e..7ae99fc 100644
--- a/bibliopixel/main/demo.py
+++ b/bibliopixel/main/demo.py
@@ -5,9 +5,9 @@ Run a bibliopixel demo.
 DESCRIPTION = """
 For the list of possible demos, type
 
-::
-
-    bp demo list
+``````
+bp demo list
+``````
 """
 
 import random
diff --git a/bibliopixel/main/main.py b/bibliopixel/main/main.py
index de05843..48d4751 100644
--- a/bibliopixel/main/main.py
+++ b/bibliopixel/main/main.py
@@ -41,7 +41,7 @@ def get_args(argv=sys.argv):
         common_flags.add_common_flags(subparser)
         module.set_parser(subparser)
         description = getattr(module, 'DESCRIPTION', '')
-        subparser.description = doc + commands.un_rst(description)
+        subparser.description = doc + commands.un_md(description)
 
     if argv == ['-h']:
         log.printer(commands.HELP)
diff --git a/bibliopixel/main/reset.py b/bibliopixel/main/reset.py
index f5f6e8d..d685481 100644
--- a/bibliopixel/main/reset.py
+++ b/bibliopixel/main/reset.py
@@ -5,13 +5,13 @@ Reset sections in the project defaults
 DESCRIPTION = """
 Example:
 
-::
+``````
+# Reset the layout and animation sections
+$ bp default reset layout animation
 
-    # Reset the layout and animation sections
-    $ bp default reset layout animation
-
-    # Reset all sections
-    $ bp default reset
+# Reset all sections
+$ bp default reset
+``````
 """
 
 from .. project import defaults
diff --git a/bibliopixel/main/run.py b/bibliopixel/main/run.py
index 94dadc1..53a2ec8 100644
--- a/bibliopixel/main/run.py
+++ b/bibliopixel/main/run.py
@@ -48,8 +48,8 @@ def _dump(args, desc):
     return desc.strip()
 
 
-def _get_animations(args):
-    animations, failed = [], []
+def _get_projects(args):
+    projects, failed = [], []
 
     for filename in args.name:
         saved_path = sys.path[:]
@@ -64,7 +64,7 @@ def _get_animations(args):
                 root_file = os.path.abspath(filename)
 
             project = common_flags.make_project(args, desc, root_file)
-            animations.append(project.animation)
+            projects.append(project)
             if args.dump:
                 print(_dump(args, project.desc))
 
@@ -85,7 +85,7 @@ def _get_animations(args):
             sys.path[:] = saved_path
 
     if not failed:
-        return animations
+        return projects
 
     log.error(FAILURE_ERROR.format(
         count=len(failed), s='' if len(failed) == 1 else 's'))
@@ -97,10 +97,10 @@ def _get_animations(args):
     raise ValueError('Run aborted')
 
 
-def _run_animations(animations, args):
+def _run_projects(projects, args):
     needs_pause = False
-    assert len(animations) == len(args.name)
-    for animation, name in zip(animations, args.name):
+    assert len(projects) == len(args.name)
+    for project, name in zip(projects, args.name):
         log.debug('Running file %s', name)
         if needs_pause:
             args.pause and time.sleep(float(args.pause))
@@ -108,13 +108,12 @@ def _run_animations(animations, args):
             needs_pause = True
 
         try:
-            animation.layout.start()
-            animation.start()
+            project.run()
 
         except KeyboardInterrupt:
             if not args.ignore_exceptions:
                 raise
-            log.warning('\nTermination requested by user.')
+            log.warning('\nKeyboardInterrupt terminated project.')
             needs_pause = False
 
         except Exception as e:
@@ -123,16 +122,13 @@ def _run_animations(animations, args):
             log.error('Exception %s', e)
             traceback.print_exc()
 
-        animation.cleanup()
-        animation.layout.cleanup_drivers()
-
 
 def run(args):
     if args.fail_on_exception:
         Collection.FAIL_ON_EXCEPTION = True
 
     args.name = args.name or ['']
-    animations = _get_animations(args)
+    projects = _get_projects(args)
 
     if args.dry_run:
         print('(dry run - nothing executed)')
@@ -142,7 +138,7 @@ def run(args):
     elif args.s:
         simpixel.open_simpixel()
 
-    _run_animations(animations, args)
+    _run_projects(projects, args)
 
 
 def set_parser(parser):
diff --git a/bibliopixel/main/set.py b/bibliopixel/main/set.py
index 3f9bbe6..b7d109b 100644
--- a/bibliopixel/main/set.py
+++ b/bibliopixel/main/set.py
@@ -5,15 +5,15 @@ Set some or all sections of the project defaults
 DESCRIPTION = """
 Each argument to `bp default set` has to look like
 
-::
-
-  section=value
+``````
+section=value
+``````
 
 Example:
 
-::
-
-   bp set numbers=float dimensions=80
+``````
+bp set numbers=float dimensions=80
+``````
 """
 
 import sys
diff --git a/bibliopixel/project/event_queue.py b/bibliopixel/project/event_queue.py
new file mode 100644
index 0000000..daff434
--- /dev/null
+++ b/bibliopixel/project/event_queue.py
@@ -0,0 +1,53 @@
+import functools, queue
+
+
+class EventQueue(queue.Queue):
+    """
+    ``Event``s are thread-unsafe operations to be executed later at a
+    thread-safe time.  In the animation loop, this happens in
+    :py:method:`bibliopixel.animation.BaseAnimation.preframe_callback`.
+
+    The typical `Event` is a change to some parameter of an animation,
+    but in general is represented by a call to a function with fixed parameters.
+    """
+
+    def put_event(self, f, *args, **kwds):
+        """
+        Defer an event to run on the EventQueue.
+
+        :param callable f: The function to be called
+        :param tuple args: Positional arguments to the function
+        :param tuple kwds: Keyword arguments to the function
+        :throws queue.Full: if the queue is full
+        """
+        self.put_nowait(functools.partial(f, *args, **kwds))
+
+    def get_and_run_events(self):
+        """
+        Get all the events in the queue, then execute them.
+
+        The algorithm gets all events, and then executes all of them.  It does
+        *not* pull off one event, execute, repeat until the queue is empty, and
+        that means that the queue might not be empty at the end of
+        ``run_events``, because new events might have entered the queue
+        while the previous events are being executed.
+
+        This has the advantage that if events enter the queue faster than they
+        can be processed, ``get_and_run_events`` won't go into an infinite loop,
+        but rather the queue will grow unboundedly, which that can be
+        detected, and mitigated and reported on - or if Queue.maxsize is
+        set, `bp` will report a fairly clear error and just dump the events
+        on the ground.
+        """
+        if self.empty():
+            return
+
+        events = []
+        while True:
+            try:
+                events.append(self.get_nowait())
+            except queue.Empty:
+                break
+
+        for e in events:
+            e()
diff --git a/bibliopixel/project/merge.py b/bibliopixel/project/merge.py
index fa66feb..bdba41b 100644
--- a/bibliopixel/project/merge.py
+++ b/bibliopixel/project/merge.py
@@ -17,11 +17,13 @@ DEFAULT_PROJECT = collections.OrderedDict((
     ('layout', {}),
     ('run', {}),
     ('animation', {}),
+    ('controls', []),
 ))
 PROJECT_SECTIONS = tuple(DEFAULT_PROJECT.keys())
 
 NOT_MERGEABLE = (
-    'datatype', 'dimensions', 'drivers', 'numbers', 'path', 'typename')
+    'controls', 'datatype', 'dimensions', 'drivers', 'numbers', 'path',
+    'typename')
 
 SECTION_ISNT_DICT_ERROR = 'Project section "%s" is %s, should be dictionary'
 
diff --git a/bibliopixel/project/project.py b/bibliopixel/project/project.py
index 45399b8..5191f49 100644
--- a/bibliopixel/project/project.py
+++ b/bibliopixel/project/project.py
@@ -1,11 +1,14 @@
-import traceback
-from . import attributes, construct, cleanup, defaults, load, recurse
-from .. util import exception, json, log
-from .. animation import BaseAnimation
+import queue
+from . import (
+    attributes, construct, cleanup, defaults, event_queue, load, recurse)
+from .. util import exception, json
+from .. animation import BaseAnimation, failed
+
+EVENT_QUEUE_MAXSIZE = 1000
 
 
 class Project:
-    CHILDREN = 'maker', 'drivers', 'layout', 'animation'
+    CHILDREN = 'maker', 'drivers', 'layout', 'animation', 'controls'
 
     @staticmethod
     def pre_recursion(desc):
@@ -17,7 +20,13 @@ class Project:
             return construct(self, **kwds)
         return datatype(**kwds)
 
-    def __init__(self, *, drivers, layout, maker, path, animation, **kwds):
+    def __init__(self, *,
+                 drivers, layout, maker, path, animation, controls,
+                 event_queue_maxsize=EVENT_QUEUE_MAXSIZE, **kwds):
+        """
+        :param int event_queue_maxsize: maxsize parameter to queue.Queue.
+            0 means an unbounded queue.
+        """
         def post(desc):
             return self.construct_child(**desc)
 
@@ -37,29 +46,59 @@ class Project:
         self.maker = self.construct_child(**maker)
         self.drivers = [create(d, 'drivers') for d in drivers]
         with exception.add('Unable to create layout'):
-            layout = self.layout = self.construct_child(**layout)
+            self.layout = self.construct_child(**layout)
 
-        class Empty(BaseAnimation):
-            def __init__(self, desc, exception):
-                super().__init__(layout)
-                self._set_runner({})
-                log.error('Unable to create animation for %s', desc)
-                debug = log.get_log_level() <= log.DEBUG
-                msg = traceback.format_exc() if debug else str(exception)
-                log.error('\n%s', msg)
-                self.desc = desc
-                self.exception = exception
-                self.empty = True
+        def Failed(*args):
+            return failed.Failed(self.layout, *args)
 
-        self.animation = create(animation, 'animation', Empty)
+        self.animation = create(animation, 'animation', Failed)
 
+        self.event_queue = event_queue.EventQueue(maxsize=event_queue_maxsize)
+        self.animation.preframe_callback = self.event_queue.get_and_run_events
 
-def project(*descs, root_file=None):
-    def default(o):
-        if isinstance(o, type):
-            return str(o)
-        return str(o.__class__).replace('<class ', '<').replace('>', ' object>')
+        # Unfortunately, the whole animation cycle is controlled by methods on
+        # the topmost animation - but we need to get called back at a certain
+        # point in that animation cycle in order to run the event queue safely -
+        # and animations don't know about the Project!
+        #
+        # So the monkey-patch above.  Ugly.  When we rewrite the
+        # animation cycle, it will be freed from the topmost animation, and
+        # this problem will go away.  (Also, animations should have access to
+        # the Project, but that's a whole separate issue.)
+
+        self.controls = [create(c, 'control') for c in controls]
+
+    def start(self):
+        self.layout.start()
+        for c in self.controls:
+            c.start(self)
+        self.animation.start()
+
+    def cleanup(self):
+        self.animation.cleanup()
+        for c in self.controls:
+            c.cleanup()
+        self.layout.cleanup_drivers()
 
+    def run(self):
+        try:
+            self.start()
+        finally:
+            self.cleanup()
+
+    def deferred_set(self, address, *value):
+        """
+        Use an Address to set a value within the project, but defer it to
+        happen on the event queue.
+        """
+        self.event_queue.put_event(address.set, self, *value)
+
+
+def project(*descs, root_file=None):
+    """
+    Make a project with recursion and alias resolution.  Use this
+    instead of calling Project() directly.
+    """
     desc = defaults.merge(*descs)
 
     load.ROOT_FILE = root_file
diff --git a/bibliopixel/project/recurse.py b/bibliopixel/project/recurse.py
index cbe51fe..0c34823 100644
--- a/bibliopixel/project/recurse.py
+++ b/bibliopixel/project/recurse.py
@@ -46,6 +46,7 @@ def recurse(desc, pre='pre_recursion', post=None, python_path=None,
     try:
         # Automatically load strings that look like JSON or Yaml filenames.
         desc = load.load_if_filename(desc) or desc
+
         desc = construct.to_type_constructor(desc, python_path)
         datatype = desc.get('datatype')
 
@@ -54,12 +55,20 @@ def recurse(desc, pre='pre_recursion', post=None, python_path=None,
         for child_name in getattr(datatype, 'CHILDREN', []):
             child = desc.get(child_name)
             if child:
-                new_path = python_path or ('bibliopixel.' + child_name)
-                if child_name.endswith('s'):
+                is_plural = child_name.endswith('s')
+                remove_s = is_plural and child_name != 'drivers'
+                # This is because it's the "drivers" directory, whereas
+                # the others are animation, control, layout, project
+                # without the s. TODO: rename drivers/ to driver/ in v4
+
+                cname = child_name[:-1] if remove_s else child_name
+                new_path = python_path or ('bibliopixel.' + cname)
+                if is_plural:
                     if isinstance(child, (dict, str)):
                         child = [child]
                     for i, c in enumerate(child):
                         child[i] = recurse(c, pre, post, new_path)
+                    desc[child_name] = child
                 else:
                     desc[child_name] = recurse(child, pre, post, new_path)
 
diff --git a/bibliopixel/util/log.py b/bibliopixel/util/log.py
index 87ad453..7c18912 100644
--- a/bibliopixel/util/log.py
+++ b/bibliopixel/util/log.py
@@ -107,6 +107,10 @@ def get_log_level():
     return logger.getEffectiveLevel()
 
 
+def is_debug():
+    return get_log_level() <= DEBUG
+
+
 # Add a new logging level FRAME for messages which appear on every frame.
 _addLoggingLevel('FRAME', FRAME)
 logger = _new_custom_logger()
diff --git a/doc/.gitignore b/doc/.gitignore
index d796407..303b28f 100644
--- a/doc/.gitignore
+++ b/doc/.gitignore
@@ -1,4 +1,4 @@
-bp-Command.rst
+bp-Command.md
 _build
 _templates
 api
diff --git a/doc/Aliases-and-defaults.md b/doc/Aliases-and-defaults.md
new file mode 100644
index 0000000..f61cb9d
--- /dev/null
+++ b/doc/Aliases-and-defaults.md
@@ -0,0 +1,41 @@
+# Aliases and Defaults
+
+All this can amount to a lot of typing, so BiblioPixel gives you some shortcuts.
+
+## Typename expansion.
+
+If you are only specifying a `typename` for a component, then you don't have to specify the entire dictionary, just the name of the class.
+
+This means that a project
+
+    {"driver": {"typename": "bibliopixel.drivers.API.LPD8806.LPD8806"}}
+
+is identical to the easier-to-read
+
+    {"driver": "bibliopixel.drivers.API.LPD8806.LPD8806"}
+
+### Aliases.
+Aliases are just shorthand for some common classes.  It lets you write the project
+
+    {"driver": "bibliopixel.drivers.API.LPD8806.LPD8806"}
+
+as
+
+    {"driver": "LPD8806"}
+
+You can find a current list list of the aliases in [this file](https://github.com/ManiacalLabs/BiblioPixel/blob/master/bibliopixel/project/aliases.py).  
+
+Aliases are case insensitive.
+
+### Command-line flags.
+
+There are three command line flags that are used to fill in missing components in your project file - `--driver`, `--layout`, and `--animation`.
+
+You can either pass in an alias, or for more control, a JSON dictionary, which you will have to quote for your shell:
+
+    bibliopixel --animation=matrix_test
+    bibliopixel --layout='{"typename": "matrix", "width": 12, "height": 12}'
+
+### The `--ledtype` command line flag.
+
+The [[SPI]] and [[Serial]] drivers require an `"ledtype"` field to identify the hardware type of the LED - see [LED Types](Serial#led-types).  You can use the `--ledtype` flag to enter this value from the command line as either a string or an integer.
diff --git a/doc/Makefile b/doc/Makefile
index 632b7a4..13eef21 100644
--- a/doc/Makefile
+++ b/doc/Makefile
@@ -17,4 +17,7 @@ help:
 # Catch-all target: route all unknown targets to Sphinx using the new
 # "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
 %: Makefile
-	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
\ No newline at end of file
+	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+livehtml:
+	sphinx-autobuild -b html $(SPHINXOPTS) "$(SOURCEDIR)" "$(BUILDDIR)/html"
diff --git a/doc/conf.py b/doc/conf.py
index f8176fc..d6da3ce 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -21,6 +21,9 @@ import os
 import sys
 sys.path.insert(0, os.path.abspath('../'))
 
+import recommonmark
+from recommonmark.parser import CommonMarkParser
+from recommonmark.transform import AutoStructify
 
 # -- General configuration ------------------------------------------------
 
@@ -44,18 +47,22 @@ extensions = [
 # Add any paths that contain templates here, relative to this directory.
 templates_path = ['_templates']
 
+
+source_parsers = {
+    '.md': CommonMarkParser,
+}
+
 # The suffix(es) of source filenames.
 # You can specify multiple suffix as a list of string:
 #
-# source_suffix = ['.rst', '.md']
-source_suffix = '.rst'
+source_suffix = ['.rst', '.md']
 
 # The master toctree document.
 master_doc = 'index'
 
 # General information about the project.
 project = 'BiblioPixel'
-copyright = '2017, Maniacal Labs'
+copyright = '2018, Maniacal Labs'
 author = 'Maniacal Labs'
 
 # The version info for the project you're documenting, acts as replacement for
@@ -100,10 +107,10 @@ todo_include_todos = True
 # The theme to use for HTML and HTML Help pages.  See the documentation for
 # a list of builtin themes.
 #
-html_theme = "classic"
+html_theme = "sphinx_rtd_theme"
 html_theme_options = {
     # "rightsidebar": "true",
-    "relbarbgcolor": "black"
+    # "relbarbgcolor": "black"
 }
 
 # Theme options are theme-specific and customize the look and feel of a theme
@@ -186,13 +193,55 @@ texinfo_documents = [
      'Miscellaneous'),
 ]
 
+API_EXCLUDES = [
+    'main/',
+    'util/threads/',
+    'util/colors/arithmetic.py',
+    'util/colors/conversions.py',
+    'util/colors/classic.py',
+    'util/colors/juce.py',
+    'drivers/return_codes.py',
+    'drivers/serial/codes.py',
+    'drivers/SimPixel/SimpleWebSocketServer.py',
+    'drivers/SimPixel/websocket.py',
+    'drivers/SPI/errors.py',
+]
+
 
 def run_apidoc(_):
     from sphinx.apidoc import main
     import os
     import sys
-    main(['./run_apidoc.sh'])
+    import shutil
+    sys.path.append(os.path.join(os.path.dirname(__file__), '..'))
+    cur_dir = os.path.abspath(os.path.dirname(__file__))
+    api_dir = os.path.join(cur_dir, 'api')
+    shutil.rmtree(api_dir)  # clear out anything old
+    try:
+        os.mkdir(api_dir)  # make it if not exists
+    except:
+        pass
+    module = os.path.abspath(os.path.join(cur_dir, '..', 'bibliopixel'))
+    args = ['-e', '-o', api_dir, module]
+    for ex in API_EXCLUDES:
+        args.append(os.path.join(module, ex))
+
+    main(args)
+
+# No longer going to build API docs
+# Leaving in case we ever want it back
+# def setup(app):
+#     app.connect('builder-inited', run_apidoc)
+
+
+github_doc_root = 'https://github.com/ManiacalLabs/BiblioPixel/tree/master/doc'
 
 
 def setup(app):
-    app.connect('builder-inited', run_apidoc)
+    app.add_config_value('recommonmark_config', {
+        'url_resolver': lambda url: github_doc_root + url,
+        'auto_toc_tree_section': 'Contents',
+        'enable_eval_rst': True,
+        'enable_auto_doc_ref': True,
+    }, True)
+    app.add_transform(AutoStructify)
diff --git a/doc/doc_requirements.txt b/doc/doc_requirements.txt
index 4814049..fbf1aba 100644
--- a/doc/doc_requirements.txt
+++ b/doc/doc_requirements.txt
@@ -2,3 +2,5 @@ Sphinx
 sphinx-autobuild
 sphinxcontrib-fulltoc
 sphinxcontrib-websupport
+recommonmark
+sphinx-rtd-theme
diff --git a/doc/index.rst b/doc/index.md
similarity index 62%
rename from doc/index.rst
rename to doc/index.md
index 7888ede..17e65c3 100644
--- a/doc/index.rst
+++ b/doc/index.md
@@ -1,7 +1,4 @@
-:orphan:
-
-Welcome to BiblioPixel
-======================
+# Welcome to BiblioPixel
 
 BiblioPixel is a pure Python 3 library for all your LED animations
 needs. Through its fully output agnostic design you can write your code
@@ -13,26 +10,12 @@ existing hardware models are LED strips, matrixes or circles, but there
 are no real limits on what the output device can be, due to the output
 drivers model.
 
-User Guide
-----------
-
-.. toctree::
-   :maxdepth: 2
-
-   bp-Command
-
-API Reference
--------------
-
-.. toctree::
-    :maxdepth: 1
-    :glob:
+Contents
+--------
 
-    api/*
+* [bp Command](bp-Command.md)
+* [Aliases and Defaults](Aliases-and-defaults.md)
 
-Indices and tables
-==================
+## Getting Started
 
-* :ref:`genindex`
-* :ref:`modindex`
-* :ref:`search`
+Read the Docs
diff --git a/doc/run_apidoc.sh b/doc/run_apidoc.sh
deleted file mode 100644
index eb32221..0000000
--- a/doc/run_apidoc.sh
+++ /dev/null
@@ -1,15 +0,0 @@
-#!/bin/bash
-
-# delete ./api/ dir if exists
-rm -rf ./api/ || true
-
-sphinx-apidoc \
-    -o api \
-    ../bibliopixel/ \
-    ../bibliopixel/main/ \
-    ../bibliopixel/util/threads/ \
-    ../bibliopixel/util/colors/arithmetic.py \
-    ../bibliopixel/util/colors/conversions.py \
-    ../bibliopixel/util/colors/classic.py \
-    ../bibliopixel/util/colors/gamma.py \
-    ../bibliopixel/util/colors/juce.py \
diff --git a/gen_docs.sh b/gen_docs.sh
index 21ce37c..5e73129 100755
--- a/gen_docs.sh
+++ b/gen_docs.sh
@@ -1,22 +1,10 @@
 #!/bin/bash
 
-scripts/extract_bp_help doc/bp-Command.rst
+scripts/extract_bp_help doc/bp-Command.md
 pushd ./doc/
 
 # delete ./_build/ dir if exists
 rm -rf ./_build/ || true
 
-sphinx-apidoc \
-    -o api \
-    ../bibliopixel/ \
-    ../bibliopixel/main/ \
-    ../bibliopixel/util/threads/ \
-    ../bibliopixel/util/colors/arithmetic.py \
-    ../bibliopixel/util/colors/conversions.py \
-    ../bibliopixel/util/colors/classic.py \
-    ../bibliopixel/util/colors/gamma.py \
-    ../bibliopixel/util/colors/juce.py \
-#
-
 make html
 popd
diff --git a/projects/21-key-control.json b/projects/21-key-control.json
new file mode 100644
index 0000000..0c3f9e6
--- /dev/null
+++ b/projects/21-key-control.json
@@ -0,0 +1,52 @@
+{
+    "aliases": {
+        "bpa": "BiblioPixelAnimations.strip"
+    },
+
+    "controls": {
+        "typename": "keyboard",
+        "verbose": true,
+        "extractor": {
+            "accept": {"type": "press"}
+        },
+        "routing": {
+            "a": "animation.index = 0",
+            "s": "animation.index = 1",
+            "d": "animation.index = 2",
+            "f": "animation.index = 3"
+        }
+    },
+
+    "dimensions": [50],
+
+    "run": {
+        "fps": 30
+    },
+
+    "animation": {
+        "typename": "sequence",
+        "animations": [
+            "@bpa.Alternates",
+            "@bpa.BinaryEpochClock.BEClock",
+            "@bpa.ColorChase",
+            "@bpa.ColorFade",
+            "@bpa.ColorPattern",
+            "@bpa.ColorWipe",
+            "@bpa.FireFlies",
+            "@bpa.HalvesRainbow",
+            "@bpa.LarsonScanners.LarsonRainbow",
+            "@bpa.LarsonScanners.LarsonScanner",
+            "@bpa.LinearRainbow",
+            "@bpa.PartyMode",
+            "@bpa.PixelPingPong",
+            "@bpa.RGBClock",
+            "@bpa.Rainbows.Rainbow",
+            "@bpa.Rainbows.RainbowCycle",
+            "@bpa.Searchlights",
+            "@bpa.Wave",
+            "@bpa.Wave.WaveMove",
+            "@bpa.WhiteTwinkle",
+            "@bpa.hexclock"
+        ]
+    }
+}


Test patch:
diff --git a/bibliopixel/main/all_pixel_test.py b/bibliopixel/main/all_pixel_test.py
index 86853da..1d3628c 100644
--- a/bibliopixel/main/all_pixel_test.py
+++ b/bibliopixel/main/all_pixel_test.py
@@ -12,10 +12,10 @@ from bibliopixel.project.types import ledtype
 DESCRIPTION = """
 Equivalent to
 
-::
-
-    bp --num=10 --loglevel=debug --animation=strip_test --driver=serial \\
-       --fail_on_exception --layout=strip --ledtype=<argument>
+``````
+bp --num=10 --loglevel=debug --animation=strip_test --driver=serial \\
+   --fail_on_exception --layout=strip --ledtype=<argument>
+``````
 """
 
 LEDTYPES = """
diff --git a/test/bibliopixel/control/address_test.py b/test/bibliopixel/control/address_test.py
index f4300af..fe7dbef 100644
--- a/test/bibliopixel/control/address_test.py
+++ b/test/bibliopixel/control/address_test.py
@@ -98,3 +98,8 @@ class AddressTest(unittest.TestCase):
         Address('[1]')
         with self.assertRaises(ValueError):
             Address('foo.[1]')
+
+    def test_assignment(self):
+        self.attr = None
+        Address('.attr = 1').set(self)
+        self.assertEqual(self.attr, 1)
diff --git a/test/bibliopixel/control/midi_test.py b/test/bibliopixel/control/midi_test.py
index 41cc261..d654d6a 100644
--- a/test/bibliopixel/control/midi_test.py
+++ b/test/bibliopixel/control/midi_test.py
@@ -43,7 +43,8 @@ class MidiTest(unittest.TestCase):
     def run_test(self, msgs, expected, routing=None, **kwds):
         with patch.patch(midi, 'mido', FakeMido(msgs)):
             class Settable:
-                pass
+                def deferred_set(self, address, *value):
+                    address.set(self, *value)
 
             settable = Settable()
             m = midi.Midi(routing=routing or self.routing, **kwds)
diff --git a/test/bibliopixel/project/cleanup_test.py b/test/bibliopixel/project/cleanup_test.py
index f1ac867..816af52 100644
--- a/test/bibliopixel/project/cleanup_test.py
+++ b/test/bibliopixel/project/cleanup_test.py
@@ -11,6 +11,7 @@ BASE = {
         'datatype': OffAnim,
         'typename': 'off',
     },
+    'controls': [],
     'drivers': [{'typename': 'simpixel'}],
     'layout': {},
     'maker': {'typename': 'bibliopixel.project.data_maker.Maker'},
diff --git a/test/bibliopixel/project/merge_test.py b/test/bibliopixel/project/merge_test.py
index 9b37010..10b0404 100644
--- a/test/bibliopixel/project/merge_test.py
+++ b/test/bibliopixel/project/merge_test.py
@@ -31,6 +31,7 @@ class MergeTest(unittest.TestCase):
         expected = {
             'aliases': {},
             'animation': {'typename': 'bfoo'},
+            'controls': [],
             'dimensions': (),
             'driver': {'typename': 'bar'},
             'drivers': ['bang', 'bop'],