Repository: ManiacalLabs/BiblioPixel (python)

Issue:
Better reporting of bad ports for SimPixel connection.
> Currently I am working on my local machine (MacBook). I have been able to change the port to other four-digit port numbers and display on SimPixel just fine. However, when I choose port 80 for the driver in my project config, I get this cryptic error message:

`ERROR: 'NoneType' object has no attribute 'update'`

> Why might port 80 not be valid? 

(Answer: "you really can't use port 80. Unix-based systems disallow binding to any port below 1024 for non-root users.")

We need to report such errors better!

Pull request:
Better reporting of bad ports for SimPixel connection (fix #720)
Now there's a much better error (if not in -v mode):

    SimPixel attempted to open a port at 80 and got a PermissionError.

    Many systems disallow binding to any port below 1024 for non-root users.
    Try again with a port number that's 1024 or greater.

I felt this was a decent blend between "too much information" and not enough. 

Patch:
diff --git a/bibliopixel/animation/game.py b/bibliopixel/animation/game.py
index c7cf260..d73db8a 100644
--- a/bibliopixel/animation/game.py
+++ b/bibliopixel/animation/game.py
@@ -48,15 +48,15 @@ class BaseGameAnim(BaseMatrixAnim):
             val = self._keys[key]
             if key in kf:
                 cfg = kf[key]
-                speedPass = self._checkSpeed(cfg.speed)
+                speed_pass = self._checkSpeed(cfg.speed)
 
                 if cfg.hold:
-                    if speedPass:
+                    if speed_pass:
                         if (val or cfg.inter):
                             cfg.func()
                         else:
                             cfg.inter = cfg.last = val
-                elif speedPass:
+                elif speed_pass:
                     if (val or cfg.inter) and not cfg.last:
                         cfg.func()
                     cfg.inter = cfg.last = val
diff --git a/bibliopixel/animation/runner.py b/bibliopixel/animation/runner.py
index 760ee15..4a9f42e 100644
--- a/bibliopixel/animation/runner.py
+++ b/bibliopixel/animation/runner.py
@@ -3,6 +3,8 @@ from enum import IntEnum
 from .. util import log
 from .. project import attributes, load
 
+DEFAULT_FPS = 24
+
 
 class STATE(IntEnum):
     ready = 0
@@ -15,7 +17,7 @@ class STATE(IntEnum):
 
 class Runner(object):
 
-    def __init__(self, *, amt=1, fps=None, sleep_time=0, max_steps=0,
+    def __init__(self, *, amt=1, fps=0, sleep_time=0, max_steps=0,
                  until_complete=False, max_cycles=0, seconds=None,
                  threaded=False, main=None, **kwds):
         attributes.check(kwds, 'run')
@@ -29,6 +31,9 @@ class Runner(object):
         if max_cycles < 0:
             log.error('max_cycles %s < 0', max_cycles)
             max_cycles = 0
+        if fps < 0:
+            log.error('fps %s < 0', fps)
+            fps = 0
 
         if sleep_time and fps:
             log.error('sleep_time=%s and fps=%s cannot both be set',
@@ -40,7 +45,14 @@ class Runner(object):
             max_steps = 0
 
         self.amt = amt
-        self.sleep_time = 1 / fps if fps else sleep_time
+
+        if fps:
+            self.sleep_time = 1 / fps
+        elif sleep_time:
+            self.sleep_time = sleep_time
+        else:
+            self.sleep_time = 1 / DEFAULT_FPS
+
         self.until_complete = until_complete
         self.seconds = seconds
         self.run_start_time = 0
diff --git a/bibliopixel/control/artnet.py b/bibliopixel/control/artnet.py
index 164f48a..d277ac8 100644
--- a/bibliopixel/control/artnet.py
+++ b/bibliopixel/control/artnet.py
@@ -1,6 +1,6 @@
 import collections, copy, queue
 from . import control
-from .. util import artnet_message, log, server_cache, udp
+from .. util import artnet_message, log, offset_range, server_cache, udp
 
 QUEUE_TIMEOUT = 0.1
 
@@ -10,22 +10,14 @@ class ArtNet(control.ExtractedControl):
                  offset=0, **kwds):
         super().__init__(*args, **kwds)
         self.address = ip_address, port
-        self.offset = offset
+        self.offset = offset_range.DMXChannel.make(offset)
 
     def _convert(self, msg):
         msg = artnet_message.bytes_to_message(msg)
         if not msg:
             return
 
-        if self.offset == 0:
-            data = bytes(msg.data)
-        else:
-            pad = bytes(0 for i in range(abs(self.offset)))
-            if self.offset < 0:
-                data = bytes(msg.data[-self.offset:]) + pad
-            else:
-                data = pad + bytes(msg.data[:-self.offset])
-
+        data = bytes(self.offset.read_from(msg.data))
         msg = collections.OrderedDict((
             ('type', 'dmx'),
             ('net', msg.net),
diff --git a/bibliopixel/drivers/SimPixel/websocket.py b/bibliopixel/drivers/SimPixel/websocket.py
index 4a9c463..be1b0ec 100644
--- a/bibliopixel/drivers/SimPixel/websocket.py
+++ b/bibliopixel/drivers/SimPixel/websocket.py
@@ -37,13 +37,19 @@ class Client(WebSocket):
 
 
 class Server:
-
     def __init__(self, port, selectInterval):
         self.clients = set()
         self.state = {}
 
-        self.ws_server = SimpleWebSocketServer(
-            '', port, Client, server=self, selectInterval=selectInterval)
+        try:
+            self.ws_server = SimpleWebSocketServer(
+                '', port, Client, server=self, selectInterval=selectInterval)
+        except PermissionError as e:
+            if port < 1024:
+                msg = PORT_BELOW_1024_ERROR.format(**locals())
+                raise ValueError(msg, *e.args)
+            raise e
+
         self.thread = threading.Thread(target=self.target, daemon=True)
         self.thread.start()
 
@@ -81,3 +87,11 @@ class Server:
             self.clients.remove(client)
         except:
             pass
+
+
+PORT_BELOW_1024_ERROR = """
+SimPixel attempted to open a port at {port} and got a PermissionError.
+
+Many systems disallow binding to any port below 1024 for non-root users.
+Try again with a port number that's 1024 or greater.
+"""
diff --git a/bibliopixel/drivers/artnet.py b/bibliopixel/drivers/artnet.py
index d242c9d..705f03c 100644
--- a/bibliopixel/drivers/artnet.py
+++ b/bibliopixel/drivers/artnet.py
@@ -1,6 +1,6 @@
 import copy, ctypes, enum
 from . server_driver import ServerDriver
-from .. util import artnet_message, log, server_cache, udp
+from .. util import artnet_message, log, offset_range, server_cache, udp
 
 
 class ArtNet(ServerDriver):
@@ -15,7 +15,7 @@ class ArtNet(ServerDriver):
         """
         super().__init__(*args, address=(ip_address, port), **kwds)
         self.filter_dupes = filter_dupes
-        self.offset = offset
+        self.offset = offset_range.DMXChannel(offset)
         self.msg = artnet_message.dmx_message()
         self.last_message = None
 
@@ -27,18 +27,7 @@ class ArtNet(ServerDriver):
             self.last_message = msg
 
     def _copy_buffer_into_data(self):
-        buf, offset, data = self._buf, self.offset, self.msg.data
-        if offset < 0:
-            buf = buf[-offset:]
-            offset = 0
-
-        delta = len(data) - len(buf) - offset
-        if delta <= 0:
-            # data is shorter
-            data[:] = buf[offset:offset + len(data)]
-        else:
-            # data is longer
-            data[:len(buf) - offset] = buf[offset:]
+        self.offset.copy_to(self._buf, self.msg.data)
 
     def _on_positions(self):
         pass
diff --git a/bibliopixel/drivers/network_receiver.py b/bibliopixel/drivers/network_receiver.py
index 7505644..e7a048f 100644
--- a/bibliopixel/drivers/network_receiver.py
+++ b/bibliopixel/drivers/network_receiver.py
@@ -1,14 +1,8 @@
-import threading
-import os
+import os, threading
 
-try:
-    import SocketServer
-except:
-    import socketserver as SocketServer
+import socketserver as SocketServer
 from .. drivers.return_codes import RETURN_CODES
 from . network import CMDTYPE
-
-os.sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
 from .. util import log
 
 
@@ -22,12 +16,12 @@ class ThreadedDataHandler(SocketServer.BaseRequestHandler):
 
             if cmd == CMDTYPE.PIXEL_DATA:
                 data = bytearray()
-                emptyCount = 0
+                empty_count = 0
                 while len(data) < size:
                     buf = self.request.recv(4096)
                     if len(buf) == 0:
-                        emptyCount += 1
-                    if emptyCount >= 5:
+                        empty_count += 1
+                    if empty_count >= 5:
                         log.exception(
                             "Failed to receive expected amount of data! Expected: %s bytes / Received: %s bytes", size, len(data))
                         self.request.sendall(RETURN_CODES.ERROR_SIZE)
diff --git a/bibliopixel/drivers/server_driver.py b/bibliopixel/drivers/server_driver.py
index 1939b0f..1337f6c 100644
--- a/bibliopixel/drivers/server_driver.py
+++ b/bibliopixel/drivers/server_driver.py
@@ -36,7 +36,7 @@ class ServerDriver(DriverBase, StaticCache):
         pass
 
     def cleanup(self):
-        self.server.close()
+        self.server and self.server.close()
 
     def _compute_packet(self):
         self._render()
diff --git a/bibliopixel/layout/layout.py b/bibliopixel/layout/layout.py
index 9781395..b064392 100644
--- a/bibliopixel/layout/layout.py
+++ b/bibliopixel/layout/layout.py
@@ -90,7 +90,10 @@ class Layout(object):
 
     def cleanup(self):
         self.all_off()
-        self.push_to_driver()
+        try:
+            self.push_to_driver()
+        except:
+            pass
         self.threading.wait_for_update()
 
     def clone(self):
diff --git a/bibliopixel/layout/matrix_drawing.py b/bibliopixel/layout/matrix_drawing.py
index 88c1651..ce835d0 100644
--- a/bibliopixel/layout/matrix_drawing.py
+++ b/bibliopixel/layout/matrix_drawing.py
@@ -372,26 +372,26 @@ def draw_char(fonts, setter, width, height, x, y, c, color, bg, aa=False, font=f
 
     fw = len(c_data)
     for i in range(fw + f['sep']):
-        xPos = x + (i * font_scale)
-        if max(0, 1 - font_scale * fw) <= xPos < width:
+        x_pos = x + (i * font_scale)
+        if max(0, 1 - font_scale * fw) <= x_pos < width:
             if i >= fw:
                 line = 0
             else:
                 line = FONT[c][i]
             for j in range(fh):
-                yPos = y + (j * font_scale)
-                if ((yPos < height) and
-                        (yPos + fh * font_scale - 1) >= 0):
+                y_pos = y + (j * font_scale)
+                if ((y_pos < height) and
+                        (y_pos + fh * font_scale - 1) >= 0):
                     if line & 0x1:
                         if font_scale == 1:
-                            setter(xPos, yPos, color)
+                            setter(x_pos, y_pos, color)
                         else:
-                            fill_rect(setter, xPos, yPos, font_scale, font_scale, color, aa)
+                            fill_rect(setter, x_pos, y_pos, font_scale, font_scale, color, aa)
                     elif bg != color and bg is not None:
                         if font_scale == 1:
-                            setter(xPos, yPos, bg)
+                            setter(x_pos, y_pos, bg)
                         else:
-                            fill_rect(setter, xPos, yPos, font_scale, font_scale, bg, aa)
+                            fill_rect(setter, x_pos, y_pos, font_scale, font_scale, bg, aa)
                 line >>= 1
     return fw + f['sep']
 
diff --git a/bibliopixel/main/all_pixel.py b/bibliopixel/main/all_pixel.py
index 13972f1..8551d3e 100644
--- a/bibliopixel/main/all_pixel.py
+++ b/bibliopixel/main/all_pixel.py
@@ -83,15 +83,15 @@ def run(args):
             raise ValueError()
 
         spi = 2
-        useSPI = t[0] in SPIChipsets
-        if useSPI:
+        use_spi = t[0] in SPIChipsets
+        if use_spi:
             spi = get_int('SPI Speed (1-24): ', invalid=0)
             if spi <= 0:
                 log.printer('\nInvalid choice!')
                 raise ValueError()
 
         details = 'Device: {}\nLED Type: {}\nNum LEDs: {}'.format(d, t[1], num)
-        if useSPI:
+        if use_spi:
             details += '\nSPI Speed: {}'.format(spi)
 
         log.printer('\n' + details + '\n')
diff --git a/bibliopixel/project/construct.py b/bibliopixel/project/construct.py
index 0a4e32f..a084488 100644
--- a/bibliopixel/project/construct.py
+++ b/bibliopixel/project/construct.py
@@ -17,7 +17,15 @@ def to_type(d):
     fn = load.load_if_filename(d)
     if fn:
         return fn
-    return {'typename': d} if isinstance(d, str) else copy.deepcopy(d)
+    if isinstance(d, str):
+        return {'typename': d}
+
+    result = copy.deepcopy(d)
+
+    # 'datatype' always overrides 'typename'
+    if 'datatype' in result:
+        result.pop('typename', None)
+    return result
 
 
 def to_type_constructor(value, python_path=None):
@@ -46,6 +54,7 @@ def to_type_constructor(value, python_path=None):
         try:
             value['datatype'] = importer.import_symbol(
                 r, python_path=python_path)
+            del value['typename']
         except Exception as e:
             value['_exception'] = e
 
diff --git a/bibliopixel/project/merge.py b/bibliopixel/project/merge.py
index dab9034..e367e9d 100644
--- a/bibliopixel/project/merge.py
+++ b/bibliopixel/project/merge.py
@@ -16,7 +16,7 @@ DEFAULT_PROJECT = collections.OrderedDict((
     ('shape', ()),
     ('layout', {}),
     ('run', {}),
-    ('animation', {}),
+    ('animation', {'typename': 'animation'}),
     ('controls', []),
 ))
 PROJECT_SECTIONS = tuple(DEFAULT_PROJECT.keys())
@@ -26,6 +26,7 @@ NOT_MERGEABLE = (
     'typename')
 
 SECTION_ISNT_DICT_ERROR = 'Project section "%s" is %s, should be dictionary'
+UNKNOWN_SECTION_ERROR = 'There is no Project section named "%s"'
 
 
 def merge(*projects):
@@ -40,6 +41,9 @@ def merge(*projects):
                 result[name] = section
                 continue
 
+            if name not in DEFAULT_PROJECT:
+                raise ValueError(UNKNOWN_SECTION_ERROR % name)
+
             if section and not isinstance(section, (dict, str)):
                 cname = section.__class__.__name__
                 raise ValueError(SECTION_ISNT_DICT_ERROR % (name, cname))
@@ -52,5 +56,7 @@ def merge(*projects):
                     section['run'] = adesc.get('run', {})
 
             result.setdefault(name, {}).update(construct.to_type(section))
+            if 'datatype' in result[name]:
+                result[name].pop('typename', None)
 
     return result
diff --git a/bibliopixel/project/project.py b/bibliopixel/project/project.py
index 6ec62b2..b3121cd 100644
--- a/bibliopixel/project/project.py
+++ b/bibliopixel/project/project.py
@@ -4,6 +4,7 @@ from . import (
 from .. util import exception, json, log
 
 EVENT_QUEUE_MAXSIZE = 1000
+NO_DATATYPE_ERROR = 'No "datatype" field in section "%s"'
 
 
 class Project:
@@ -13,7 +14,11 @@ class Project:
     def pre_recursion(desc):
         return cleanup.cleanup(desc)
 
-    def construct_child(self, datatype, typename=None, **kwds):
+    def construct_child(self, section_name, datatype=None, typename=None,
+                        **kwds):
+        if not datatype:
+            raise ValueError(NO_DATATYPE_ERROR % section_name)
+
         construct = getattr(datatype, 'construct', None)
         if construct:
             return construct(self, **kwds)
@@ -26,13 +31,13 @@ class Project:
         :param int event_queue_maxsize: maxsize parameter to queue.Queue.
             0 means an unbounded queue.
         """
-        def post(desc):
-            exception = desc.get('_exception')
-            if exception:
-                raise exception
-            return self.construct_child(**desc)
-
         def create(root, name):
+            def post(desc):
+                exception = desc.get('_exception')
+                if exception:
+                    raise exception
+                return self.construct_child(name, **desc)
+
             with exception.add('Unable to create ' + name):
                 return recurse.recurse(
                     root,
@@ -43,10 +48,10 @@ class Project:
         attributes.check(kwds, 'project')
         self.path = path
         layout = layout or cleanup.cleanup_layout(animation)
-        self.maker = self.construct_child(**maker)
+        self.maker = self.construct_child('maker', **maker)
         self.drivers = [create(d, 'drivers') for d in drivers]
         with exception.add('Unable to create layout'):
-            self.layout = self.construct_child(**layout)
+            self.layout = self.construct_child('layout', **layout)
 
         self.animation = create(animation, 'animation')
 
diff --git a/bibliopixel/util/deprecated.py b/bibliopixel/util/deprecated.py
index 3def279..9faa315 100644
--- a/bibliopixel/util/deprecated.py
+++ b/bibliopixel/util/deprecated.py
@@ -16,21 +16,7 @@ Specify what to do when a project uses deprecated features:
 DEPRECATED = set()
 FLAG = '--deprecated'
 V4_FLAG = '--v4'
-DEPRECATION_ON = None
-
-
-def _deprecation_on():
-    global DEPRECATION_ON
-
-    if DEPRECATION_ON is None:
-        try:
-            sys.argv.remove(FLAG)
-            DEPRECATION_ON = True
-        except:
-            pass
-        DEPRECATION_ON = DEPRECATION_ON or V4_FLAG in sys.argv
-
-    return DEPRECATION_ON
+ENVIRONMENT_VARIABLE = 'BP_DEPRECATED'
 
 
 def allowed():
@@ -65,13 +51,16 @@ def _compute_action():
         raise ValueError('%s needs an argument (one of %s)' %
                          (FLAG, ', '.join(CHOICES)))
 
-    d = [i for i, v in enumerate(sys.argv) if v.startswith(FLAG + '=')]
-    if not d:
-        ACTION = DEFAULT
+    if V4_FLAG in sys.argv:
+        ACTION = 'fail'
 
-    elif len(d) > 1:
+    d = [i for i, v in enumerate(sys.argv) if v.startswith(FLAG + '=')]
+    if len(d) > 1:
         raise ValueError('Only one %s argument can be used' % FLAG)
 
+    if not d:
+        ACTION = os.getenv(ENVIRONMENT_VARIABLE, ACTION or DEFAULT)
+
     else:
         arg = sys.argv.pop(d[0])
         _, *rest = arg.split('=')
@@ -82,12 +71,9 @@ def _compute_action():
         if not (rest and rest[0].strip()):
             raise ValueError('%s needs an argument (one of %s)' %
                              (FLAG, ', '.join(CHOICES)))
-
-        if rest[0] not in CHOICES:
-            raise ValueError('Unknown argument to %s (must be one of %s)' %
-                             (FLAG, ', '.join(CHOICES)))
-            raise ValueError('Bad deprecated value %s' % rest[0])
-
         ACTION = rest[0]
 
-    return ACTION
+    if ACTION not in CHOICES:
+        ACTION = None
+        raise ValueError('Unknown deprecation value (must be one of %s)' %
+                         ', '.join(CHOICES))
diff --git a/bibliopixel/util/offset_range.py b/bibliopixel/util/offset_range.py
new file mode 100644
index 0000000..9231c91
--- /dev/null
+++ b/bibliopixel/util/offset_range.py
@@ -0,0 +1,83 @@
+"""
+Handle DMX and MIDI channel offsets
+"""
+
+
+class OffsetRange:
+    def __init__(self, offset=0, begin=None, end=None):
+        """
+        Unlike a `range`, an OffsetRange includes both its begin *and* its end,
+        so it's closer to how regular people think of a range - for example
+        that DMX channels are in the range 1-512.
+        """
+
+        self.begin = self.BEGIN if begin is None else begin
+        self.end = self.END if end is None else end
+
+        if not (self.BEGIN <= self.begin <= self.end <= self.END):
+            raise ValueError('Bad range: FAILED %s <= %s <=  %s <= %s' %
+                             (self.BEGIN, self.begin, self.end, self.END))
+
+        self.offset = offset
+
+    def full_range(self):
+        return self.END - self.BEGIN + 1
+
+    def index(self, i, length=None):
+        """Return an integer index or None"""
+        if self.begin <= i <= self.end:
+            index = i - self.BEGIN - self.offset
+            if length is None:
+                length = self.full_range()
+            else:
+                length = min(length, self.full_range())
+
+            if 0 <= index < length:
+                return index
+
+    def read_from(self, data, pad=0):
+        """
+        Returns a generator with the elements "data" taken by offset, restricted
+        by self.begin and self.end, and padded on either end by `pad` to get
+        back to the original length of `data`
+        """
+        for i in range(self.BEGIN, self.END + 1):
+            index = self.index(i, len(data))
+            yield pad if index is None else data[index]
+
+    def copy_to(self, source, target):
+        if not target:
+            return
+
+        offset = self.offset
+        if offset < 0:
+            source = source[-offset:]
+            offset = 0
+
+        begin = min(self.begin - self.BEGIN, len(target) - 1)
+        end = min(self.end - self.BEGIN, len(target) - 1)
+        length = min(len(source) - offset, end - begin + 1)
+
+        target[begin:begin + length] = source[offset:offset + length]
+
+    @classmethod
+    def make(cls, x=0):
+        try:
+            return cls(**x)
+        except TypeError:
+            return cls(offset=x)
+
+
+class MidiChannel(OffsetRange):
+    BEGIN = 1
+    END = 16
+
+
+class MidiNote(OffsetRange):
+    BEGIN = 0
+    END = 127
+
+
+class DMXChannel(OffsetRange):
+    BEGIN = 1
+    END = 512
diff --git a/bibliopixel/util/util.py b/bibliopixel/util/util.py
index 93f2e73..bee2320 100644
--- a/bibliopixel/util/util.py
+++ b/bibliopixel/util/util.py
@@ -60,7 +60,7 @@ def genVector(width, height, x_mult=1, y_mult=1):
     x_mult - value to scale x-axis by
     y_mult - value to scale y-axis by
     """
-    centerX = (width - 1) / 2.0
-    centerY = (height - 1) / 2.0
+    center_x = (width - 1) / 2.0
+    center_y = (height - 1) / 2.0
 
-    return [[int(math.sqrt(math.pow(x - centerX, 2 * x_mult) + math.pow(y - centerY, 2 * y_mult))) for x in range(width)] for y in range(height)]
+    return [[int(math.sqrt(math.pow(x - center_x, 2 * x_mult) + math.pow(y - center_y, 2 * y_mult))) for x in range(width)] for y in range(height)]


Test patch:
diff --git a/test/bibliopixel/project/bad_project_test.py b/test/bibliopixel/project/bad_project_test.py
index b70e6a4..6cefa8b 100644
--- a/test/bibliopixel/project/bad_project_test.py
+++ b/test/bibliopixel/project/bad_project_test.py
@@ -6,10 +6,8 @@ from . make import make
 
 PYTHON_FILE = 'driver = {"a": "b"}'
 MISSING_LAYOUT = '{"animation": "off", "driver": "dummy"}'
-MISSING_ANIMATION = '{"layout": "matrix", "driver": "dummy"}'
 
 
-# This one works, but prints a warning.
 BAD_SECTION = """
 {
     "bad_section": {"foo": true},
@@ -19,13 +17,33 @@ BAD_SECTION = """
 }
 """
 
+BAD_SECTION2 = """
+{
+    "verbose": true,
+    "driver": "dummy",
+    "layout": "matrix",
+    "animation": "off"
+}
+"""
+
+MISSING_DATATYPE = """
+{
+    "driver": {
+         "width": 16,
+         "height": 16
+    },
+    "layout": "matrix",
+    "animation": "off"
+}
+"""
+
 BAD_DRIVER_ATTRIBUTE = """
 {
     "driver": {
          "typename": "simpixel",
          "width": 16,
          "height": 16,
-        "bad_attribute": 16
+         "bad_attribute": 16
     },
     "layout": "matrix",
     "animation": "off"
@@ -80,7 +98,24 @@ class BadProjectTest(unittest.TestCase):
 
         self.assertEquals(
             e.exception.args,
-            ('Unknown attribute for project: "bad_section"',))
+            ('There is no Project section named "bad_section"',))
+
+    def test_bad_section2(self):
+        # See https://github.com/ManiacalLabs/BiblioPixel/issues/787
+        with self.assertRaises(ValueError) as e:
+            make(BAD_SECTION2)
+
+        self.assertEquals(
+            e.exception.args,
+            ('There is no Project section named "verbose"',))
+
+    def test_missing_datatype(self):
+        with self.assertRaises(ValueError) as e:
+            make(MISSING_DATATYPE)
+        self.assertEquals(
+            e.exception.args,
+            ('Unable to create drivers',
+             'No "datatype" field in section "drivers"'))
 
     def test_bad_driver_attribute(self):
         with self.assertRaises(ValueError) as e:
@@ -117,16 +152,9 @@ class BadProjectTest(unittest.TestCase):
             e.exception.args,
             ('Missing "layout" section',))
 
-    def test_missing_animation(self):
-        with self.assertRaises(ValueError) as e:
-            make(MISSING_ANIMATION)
-        self.assertEquals(
-            e.exception.args,
-            ('Missing "animation" section',))
-
     def test_missing_everything(self):
         with self.assertRaises(ValueError) as e:
             make('{}')
         self.assertEquals(
             e.exception.args,
-            ('Missing "animation" section',))
+            ('Missing "layout" section',))
diff --git a/test/bibliopixel/project/cleanup_test.py b/test/bibliopixel/project/cleanup_test.py
index d529d10..9b3cdab 100644
--- a/test/bibliopixel/project/cleanup_test.py
+++ b/test/bibliopixel/project/cleanup_test.py
@@ -10,7 +10,6 @@ BASE = {
         'run': {},
         'datatype': OffAnim,
         'name': 'Off',
-        'typename': 'off',
     },
     'controls': [],
     'drivers': [{'typename': 'simpixel'}],
diff --git a/test/bibliopixel/project/recurse_test.py b/test/bibliopixel/project/recurse_test.py
index 8d0d992..cda5031 100644
--- a/test/bibliopixel/project/recurse_test.py
+++ b/test/bibliopixel/project/recurse_test.py
@@ -35,7 +35,6 @@ class RecurseTest(unittest.TestCase):
             'bing': 'bang',
         }
         expected = {
-            'typename': '%s.%s' % (Tester.__module__, Tester.__name__),
             'datatype': Tester,
             'foo': {'datatype': RecurseTest, 'foo': 'bar', 'color': colors.Red},
             'bing': 'post-bang',
diff --git a/test/bibliopixel/util/offset_range_test.py b/test/bibliopixel/util/offset_range_test.py
new file mode 100644
index 0000000..887b2f6
--- /dev/null
+++ b/test/bibliopixel/util/offset_range_test.py
@@ -0,0 +1,114 @@
+import unittest
+from bibliopixel.util import offset_range
+
+
+class OffsetRangeTest(unittest.TestCase):
+    def test_empty(self):
+        dmx = offset_range.DMXChannel.make()
+
+        self.assertEqual(dmx.index(0), None)
+        self.assertEqual(dmx.index(1), 0)
+        self.assertEqual(dmx.index(2), 1)
+        self.assertEqual(dmx.index(511), 510)
+        self.assertEqual(dmx.index(512), 511)
+        self.assertEqual(dmx.index(513), None)
+
+        l256 = list(range(256))
+        r = list(dmx.read_from(l256))
+        self.assertEqual(r, l256 + ([0] * 256))
+
+        target = [23] * 128
+        dmx.copy_to(l256, target)
+        self.assertEqual(target, list(range(128)))
+
+    def test_positive_offset(self):
+        midi = offset_range.MidiChannel(offset=4)
+        self.assertEqual(midi.index(0), None)
+        self.assertEqual(midi.index(1), None)
+        self.assertEqual(midi.index(4), None)
+        self.assertEqual(midi.index(5), 0)
+        self.assertEqual(midi.index(6), 1)
+        self.assertEqual(midi.index(15), 10)
+        self.assertEqual(midi.index(16), 11)
+        self.assertEqual(midi.index(16), 11)
+        self.assertEqual(midi.index(17), None)
+
+        expected = [-1, -1, -1, -1] + list(range(12))
+        actual = list(midi.read_from(range(16), pad=-1))
+        self.assertEqual(expected, actual)
+
+        target = [100] * 100
+        midi.copy_to(list(range(16)), target)
+        expected = list(range(4, 16)) + [100] * 88
+        self.assertEqual(target, expected)
+
+    def test_negative_offset(self):
+        midi = offset_range.MidiChannel(-4)
+        self.assertEqual(midi.index(0), None)
+        self.assertEqual(midi.index(1), 4)
+        self.assertEqual(midi.index(2), 5)
+        self.assertEqual(midi.index(12), 15)
+        self.assertEqual(midi.index(13), None)
+
+        actual = list(midi.read_from(range(16), pad=-1))
+        expected = list(range(4, 16)) + [-1, -1, -1, -1]
+        self.assertEqual(expected, actual)
+
+        target = [100] * 8
+        midi.copy_to(list(range(16)), target)
+
+        expected = [4, 5, 6, 7, 8, 9, 10, 11]
+        self.assertEqual(target, expected)
+
+    def test_begin_end_offset(self):
+        midi = offset_range.MidiChannel(offset=-5, begin=6, end=8)
+        self.assertEqual(midi.index(0), None)
+        self.assertEqual(midi.index(4), None)
+        self.assertEqual(midi.index(5), None)
+        self.assertEqual(midi.index(6), 10)
+        self.assertEqual(midi.index(7), 11)
+        self.assertEqual(midi.index(8), 12)
+        self.assertEqual(midi.index(9), None)
+        self.assertEqual(midi.index(10), None)
+
+        actual = list(midi.read_from(range(16)))
+        expected = [0, 0, 0, 0, 0, 10, 11, 12, 0, 0, 0, 0, 0, 0, 0, 0]
+
+        self.assertEqual(expected, actual)
+
+        target = [100] * 24
+        midi.copy_to(list(range(7)), target)
+        expected = 5 * [100] + [5, 6] + 17 * [100]
+        self.assertEqual(target, expected)
+
+        target = [100] * 24
+        midi.copy_to(list(range(8)), target)
+        expected = 5 * [100] + [5, 6, 7] + 16 * [100]
+        self.assertEqual(target, expected)
+
+        target = [100] * 24
+        midi.copy_to(list(range(9)), target)
+        expected = 5 * [100] + [5, 6, 7] + 16 * [100]
+        self.assertEqual(target, expected)
+
+    def test_errors(self):
+        with self.assertRaises(ValueError):
+            offset_range.MidiChannel(begin=0)
+
+        offset_range.MidiChannel(begin=1)
+        offset_range.MidiChannel(begin=16)
+
+        with self.assertRaises(ValueError):
+            offset_range.MidiChannel(begin=17)
+
+        with self.assertRaises(ValueError):
+            offset_range.MidiChannel(end=0)
+
+        offset_range.MidiChannel(end=1)
+        offset_range.MidiChannel(end=16)
+
+        with self.assertRaises(ValueError):
+            offset_range.MidiChannel(end=17)
+
+        with self.assertRaises(ValueError):
+            offset_range.MidiChannel(begin=2, end=1)