Repository: ManiacalLabs/BiblioPixel
Pull request #677 (2018-03-18 17:21:27)

Midi monitor and other tweaks
An aftershock from Controls.

I got rid of all that hard-coded event queue stuff by using a new QueuedAddress that automatically detects if updates need to be queued.

I cleaned up the whole routing section considering by giving it its own classes.

This also allows me to add a pre_routing section that's really useful just to stream all the data from a source - which is the MIDI monitor.




Patch:
diff --git a/bibliopixel/control/action.py b/bibliopixel/control/action.py
new file mode 100644
index 0000000..6d657b2
--- /dev/null
+++ b/bibliopixel/control/action.py
@@ -0,0 +1,65 @@
+from . ops import Ops
+from . queued_address import QueuedAddress
+from . receiver import Receiver
+
+
+class Action(Receiver):
+    """
+    An Action takes an incoming message, applies Ops to it, and then
+    uses it to set a value on a QueuedAddress.
+    """
+    def __init__(self, address, ops=()):
+        self.address = QueuedAddress(address)
+        self.ops = Ops(*ops)
+
+    def set_target(self, project):
+        self.address.set_target(project)
+
+    def receive(self, values):
+        if self.ops:
+            if len(values) == 1:
+                values = [self.ops(values[0])]
+            else:
+                # TODO:They specified ops, but we can't use it.
+                # Should we warn here?  Can we use the ops somehow?
+                pass
+        return self.address.receive(values)
+
+    def __bool__(self):
+        return bool(self.address or self.ops)
+
+    def __str__(self):
+        if self.ops:
+            return '%s->%s' % self.address, self.ops
+        return str(self.address)
+
+    @classmethod
+    def make(cls, action):
+        if isinstance(action, str):
+            return cls(action)
+        if isinstance(action, dict):
+            return cls(**action)
+        return cls(*action)
+
+
+class ActionList(Receiver):
+    """A list of Actions."""
+    def __init__(self, actions=None):
+        if isinstance(actions, (str, dict)):
+            actions = [actions]
+        self.actions = tuple(Action.make(a) for a in actions or ())
+
+    def set_target(self, project):
+        for a in self.actions:
+            a.set_target(project)
+
+    def receive(self, msg):
+        values = tuple(msg.values())
+        for a in self.actions:
+            a.receive(values)
+
+    def __bool__(self):
+        return bool(self.actions)
+
+    def __str__(self):
+        return ' + '.join(str(a) for a in self.actions)
diff --git a/bibliopixel/control/address.py b/bibliopixel/control/address.py
index fd30239..92d75e9 100644
--- a/bibliopixel/control/address.py
+++ b/bibliopixel/control/address.py
@@ -46,6 +46,9 @@ class Address:
         def _set(self, target, value):
             setattr(target, self.name, value)
 
+        def __str__(self):
+            return '.%s' % self.name
+
     class Index(Segment):
         def get(self, target):
             return target[self.name]
@@ -53,6 +56,9 @@ class Address:
         def _set(self, target, value):
             target[self.name] = value
 
+        def __str__(self):
+            return '[%s]' % self.name
+
     class Call(Segment):
         def __init__(self):
             pass
@@ -63,26 +69,29 @@ class Address:
         def set(self, target, *value):
             target(*value)
 
+        def __str__(self):
+            return '()'
+
     def __init__(self, name=None):
         if not name:
-            self.address = self.assignment = ()
+            self.segments = self.assignment = ()
             return
 
         self.name, *assignment = name.split('=', 1)
         self.name = self.name.strip()
 
         try:
-            self.address = list(_generate(self.name))
+            self.segments = list(_generate(self.name))
         except:
             raise ValueError('%s is not a legal address' % name)
 
-        if not self.address:
+        if not self.segments:
             raise ValueError('Empty Addresses are not allowed')
 
         if assignment:
-            if not self.address:
+            if not self.segments:
                 raise ValueError('Cannot assign to an empty address')
-            if isinstance(self.address[-1], Address.Call):
+            if isinstance(self.segments[-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]
@@ -91,7 +100,7 @@ class Address:
             self.assignment = ()
 
     def __bool__(self):
-        return bool(self.address)
+        return bool(self.segments)
 
     def __str__(self):
         return self.name
@@ -103,10 +112,10 @@ class Address:
         return target
 
     def get(self, target):
-        return self._get(target, self.address)
+        return self._get(target, self.segments)
 
     def set(self, target, *values):
-        *first, last = self.address
+        *first, last = self.segments
         parent = self._get(target, first)
         last.set(parent, *(self.assignment + values))
 
diff --git a/bibliopixel/control/control.py b/bibliopixel/control/control.py
index 81d898d..0f930ed 100644
--- a/bibliopixel/control/control.py
+++ b/bibliopixel/control/control.py
@@ -1,38 +1,37 @@
 import collections, functools, sys, threading
-from . address import Address
 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):
-    def __init__(self, address, ops=()):
-        super().__init__(address)
-        self.ops = Ops(*ops)
-
-    def set(self, project, *args):
-        if args and self.ops and len(args) == 1:
-            args = [self.ops(args[0])]
-        return super().set(project, *args)
+from . routing import ActionList, Routing
 
 
 class Control:
-    DEFAULT = {'datatype': OpsAddress}
+    DEFAULT = {'datatype': ActionList}
 
-    def __init__(self, routing, default=None, max_errors=16,
-                 python_path='bibliopixel.control', verbose=False):
-        default = dict(self.DEFAULT, **(default or {}))
+    def __init__(self, routing=None, default=None, max_errors=16,
+                 python_path='bibliopixel.control', verbose=False,
+                 pre_routing=None):
+        """
+        :param Address pre_routing: This Address is set with with the message
+            after the message is received and converted, but before it is
+            routed.
+        """
         self.verbose = verbose
-        routing = flatten.unflatten(routing)
-        self.routing = _make(routing, python_path, default, 'control')
-        self.safe_receive = LogErrors(self.receive, max_errors)
+        self.receive = LogErrors(self._receive, max_errors)
+        default = dict(self.DEFAULT, **(default or {}))
+        self.pre_routing = ActionList(pre_routing)
+        self.routing = Routing(routing or {}, default or {}, python_path)
+        self.running = False
 
     def start(self, project):
         if self.verbose:
             log.info('Starting %s', self)
-        self.project = project
+
+        self.project = project  # Can I now delete this?
+        self.pre_routing.set_target(project)
+        self.routing.set_target(project)
+        self.running = True
         self.thread = self._make_thread()
         self.thread.start()
 
@@ -40,46 +39,42 @@ class Control:
         self.stop()
 
     def stop(self):
-        self.thread.stop()
+        self.running = False
 
     def loop(self):
         for msg in self:
-            self.safe_receive(msg)
+            self.receive(msg)
+            if not self.running:
+                return
 
-    def receive(self, msg):
+    def _receive(self, msg):
         """
-        Receive a message from the input source.
+        Receive a message from the input source and perhaps raise an Exception.
         """
-        routing, msg = self.routing, self._convert(msg)
+        msg = self._convert(msg)
+
+        str_msg = self.verbose and self._msg_to_str(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:
-                    self.project.deferred_set(address, *msg.values())
-                return
+            log.debug('Message %s', str_msg)
 
-            if not msg:
-                return
-            if not isinstance(routing, dict):
-                raise ValueError('Unexpected type %s' % type(routing))
+        if self.pre_routing:
+            self.pre_routing.receive(msg)
 
-            k, v = msg.popitem(last=False)
-            routing = routing.get(str(v))
+        receiver, msg = self.routing.receive(msg)
+        if receiver:
+            receiver.receive(msg)
+            if self.verbose:
+                log.info('Routed message %s to %s', str_msg, receiver)
 
     def _convert(self, msg):
         """
-        Convert the message to a new ``collections.OrderedDict``.
+        Convert the message to a Control-specific format
         """
-        return collections.OrderedDict(msg)
+        raise NotImplementedError
 
     def __iter__(self):
         """Should yield a sequence of messages from the input source."""
-        raise NotImplemented
+        raise NotImplementedError
 
     def _make_thread(self):
         """
@@ -92,8 +87,13 @@ class Control:
         return threading.Thread(target=self.loop, daemon=True)
 
     def _msg_to_str(self, msg):
+        if msg is None:
+            return '(None)'
         return '.'.join(msg.values()) or '.'
 
+    def __bool__(self):
+        return bool(self.routing or self.pre_routing)
+
 
 class ExtractedControl(Control):
     EXTRACTOR = {}
@@ -102,26 +102,3 @@ class ExtractedControl(Control):
         super().__init__(**kwds)
         extractor = dict(self.EXTRACTOR, **(extractor or {}))
         self._convert = Extractor(**extractor).extract
-
-
-def _make(value, python_path, default, *keys):
-    assert isinstance(default, dict)
-
-    if isinstance(value, dict):
-        if not (value.get('address') or value.get('typename')):
-            return {k: _make(v, k, default, *keys) for k, v in value.items()}
-
-        value = [value]
-
-    elif isinstance(value, str):
-        value = [{'address': value}]
-
-    elif not isinstance(value, list):
-        raise ValueError('Keyed value is not dict, list, or str',
-                         '.'.join(reversed(keys)))
-
-    result = []
-    for v in value:
-        result.append(construct.construct_type(dict(default, **v), python_path))
-
-    return result
diff --git a/bibliopixel/control/keyboard.py b/bibliopixel/control/keyboard.py
index e562735..947354d 100644
--- a/bibliopixel/control/keyboard.py
+++ b/bibliopixel/control/keyboard.py
@@ -58,18 +58,14 @@ class Keyboard(ExtractedControl):
 class KeyboardTester:
     def __setattr__(self, k, v):
         super().__setattr__(k, v)
-        print(k, '=', v)
+        log.printer(k, '=', v)
 
-    def deferred_set(self, address, key):
-        print(address, key)
-        setattr(self, str(address), key)
 
-
-def test_keyboard():
+def main():
     keyboard = Keyboard(routing={'press': 'press', 'release': 'release'})
     keyboard.start(KeyboardTester())
     keyboard.thread.join()
 
 
 if __name__ == '__main__':
-    test_keyboard()
+    main()
diff --git a/bibliopixel/control/midi.py b/bibliopixel/control/midi.py
index 162bc1e..b3cdd28 100644
--- a/bibliopixel/control/midi.py
+++ b/bibliopixel/control/midi.py
@@ -1,5 +1,6 @@
 import fractions
 from . import control
+from .. util import log
 
 try:
     import mido
@@ -46,9 +47,28 @@ class Midi(control.ExtractedControl):
     def __iter__(self):
         ports = [mido.open_input(i) for i in mido.get_input_names()]
 
+        if not ports:
+            log.error('control.midi: no MIDI ports found')
+            return
+
+        log.info('Starting to listen for MIDI')
         for port, msg in mido.ports.MultiPort(ports, yield_ports=True):
             mdict = dict(vars(msg), port=port)
             if self.use_note_off or msg.type != 'note_off':
                 yield mdict
             else:
                 yield dict(mdict, type='note_on', velocity=0)
+
+
+def _print_midi(*args, **kwds):
+    print('MIDI:', *args)
+
+
+def main():
+    midi = Midi(pre_routing='()')
+    midi.start(_print_midi)
+    midi.thread.join()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/bibliopixel/control/ops.py b/bibliopixel/control/ops.py
index 5f93382..491df10 100644
--- a/bibliopixel/control/ops.py
+++ b/bibliopixel/control/ops.py
@@ -41,6 +41,10 @@ class Op:
     def __call__(self, x):
         return self.op(x, *self.values)
 
+    def __str__(self):
+        values = ', '.join(str(v) for v in self.values)
+        return '%s(%s)' % self.opname, values
+
 
 class Ops:
     def __init__(self, *ops):
@@ -54,6 +58,9 @@ class Ops:
     def __bool__(self):
         return bool(self.ops)
 
+    def __str__(self):
+        return '->'.join(str(i) for i in self.ops)
+
 
 def _make_ops(ops):
     op, values = None, []
diff --git a/bibliopixel/control/queued_address.py b/bibliopixel/control/queued_address.py
new file mode 100644
index 0000000..e58553b
--- /dev/null
+++ b/bibliopixel/control/queued_address.py
@@ -0,0 +1,50 @@
+from . address import Address
+from . receiver import Receiver
+
+
+class QueuedAddress(Receiver):
+    """
+    A `QueuedAddress` is a `Receiver` which sets `Address`es, perhaps using an
+    an optional `EventQueue`.
+
+    When the `set_target` method is called, `QueuedAddress` searches
+    down through the address and stores the most recent `event_queue`
+    method it finds.
+
+    """
+    def __init__(self, address=None):
+        self.address = Address(address)
+
+    def set_target(self, target):
+        self.last_segment = self.address.segments[-1]
+        self.queue = None
+        self.subtarget = target
+
+        for segment in self.address.segments:
+            self.queue = getattr(self.subtarget, 'event_queue', self.queue)
+            if segment is not self.last_segment:
+                self.subtarget = segment.get(self.subtarget)
+
+    def receive(self, msg):
+        """
+        Receives a message, and either sets it immediately, or puts it on the
+        event queue if there is one.
+
+        """
+        if self.queue:
+            self.queue.put_event(self._set, msg)
+        else:
+            self._set(msg)
+
+    def __bool__(self):
+        return bool(self.address)
+
+    def __str__(self):
+        return str(self.address)
+
+    def get(self):
+        return self.last_segment.get(self.subtarget)
+
+    def _set(self, values):
+        args = self.address.assignment + values
+        self.last_segment.set(self.subtarget, *args)
diff --git a/bibliopixel/control/receiver.py b/bibliopixel/control/receiver.py
new file mode 100644
index 0000000..e460a04
--- /dev/null
+++ b/bibliopixel/control/receiver.py
@@ -0,0 +1,19 @@
+import abc
+
+
+class Receiver(abc.ABC):
+    """
+    Several classes receive data and route it to a target, and this is their
+    base class, mainly for documentation.
+
+    Receivers must have two methods, `set_target` and `receive`.  `set_target`
+    must be called exactly once for each Receiver, and this must be before
+    `receive` is ever.
+    """
+    @abc.abstractmethod
+    def set_target(self, target):
+        pass
+
+    @abc.abstractmethod
+    def receive(self, values):
+        pass
diff --git a/bibliopixel/control/routing.py b/bibliopixel/control/routing.py
new file mode 100644
index 0000000..f22e272
--- /dev/null
+++ b/bibliopixel/control/routing.py
@@ -0,0 +1,76 @@
+from .. project import construct
+from .. util import flatten
+from . action import ActionList
+from . receiver import Receiver
+
+
+class Routing(Receiver):
+    """
+    A dict that routes a message to an ActionList.
+    """
+    def __init__(self, routing, default, python_path):
+        """
+        :param dict routing: `routing` is a dict that maps addresses
+           to lists of actions.
+
+           The values in the input dictionary `routing` are recursively visited
+           to build the routing table:
+
+           * values that are strings or lists are used to construct ActionLists
+           * dictionaries that contain "typename" or "datatype" keys are
+             used to construct a class of that type.
+           * otherwise, dictionaries are visited recursively
+           * all other types are forbidden
+
+        """
+        def make(x):
+            if isinstance(x, (list, str)):
+                return ActionList(x)
+
+            assert isinstance(x, dict)
+            if 'datatype' in x or 'typename' in x:
+                x = dict(default, **x)
+                return construct.construct_type(x, python_path)
+
+            return {k: make(v) for k, v in x.items()}
+
+        routing = flatten.unflatten(routing)
+        self.routing = make(routing)
+
+    def set_target(self, project):
+        """Set the base project for routing."""
+        def visit(x):
+            # Try to set_target, then recurse through any values()
+            set_target = getattr(x, 'set_target', None)
+            if set_target:
+                set_target(project)
+            values = getattr(x, 'values', lambda: ())
+            for v in values():
+                visit(v)
+
+        visit(self.routing)
+
+    def receive(self, msg):
+        """
+        Returns a (receiver, msg) pair, where receiver is `None` if no route for
+        the message was found, or otherwise an object with a `receive` method
+        that can accept that `msg`.
+        """
+        x = self.routing
+        while not isinstance(x, ActionList):
+            if not x or not msg:
+                return None, msg
+
+            if not isinstance(x, dict):
+                raise ValueError('Unexpected type %s' % type(x))
+
+            _, value = msg.popitem(last=False)
+            x = x.get(str(value))
+
+        return x, msg
+
+    def __bool__(self):
+        return bool(self.routing)
+
+    def __str__(self):
+        return str(self.routing)
diff --git a/bibliopixel/main/commands.py b/bibliopixel/main/commands.py
index d163273..e6ca422 100644
--- a/bibliopixel/main/commands.py
+++ b/bibliopixel/main/commands.py
@@ -3,8 +3,8 @@ from .. project.importer import import_module
 
 COMMANDS = (
     'all_pixel', 'all_pixel_test', 'clear_cache', 'color', 'demo',
-    'devices', 'info', 'load', 'list', 'remove', 'reset', 'run', 'save', 'set',
-    'show', 'update')
+    'devices', 'info', 'load', 'list', 'monitor', 'remove', 'reset', 'run',
+    'save', 'set', 'show', 'update')
 
 MODULES = {c: import_module('bibliopixel.main.' + c) for c in COMMANDS}
 
diff --git a/bibliopixel/main/monitor.py b/bibliopixel/main/monitor.py
new file mode 100644
index 0000000..9a7942e
--- /dev/null
+++ b/bibliopixel/main/monitor.py
@@ -0,0 +1,31 @@
+"""
+Monitor a control source
+"""
+
+import sys
+from .. project import importer
+from .. util import log
+
+
+def run(args):
+    try:
+        tc = importer.import_module(args.control[0], 'bibliopixel.control')
+    except:
+        log.error('Do not understand control "%s"', args.control[0])
+        raise
+
+    try:
+        main = tc.main
+    except:
+        log.error('No main() function for control "%s"', args.control)
+        raise
+
+    main()
+
+
+def set_parser(parser):
+    parser.add_argument(
+        'control', nargs=1,
+        help='Name of a control to monitor')
+
+    parser.set_defaults(run=run)
diff --git a/bibliopixel/project/project.py b/bibliopixel/project/project.py
index da4dc9b..768e4a2 100644
--- a/bibliopixel/project/project.py
+++ b/bibliopixel/project/project.py
@@ -48,8 +48,9 @@ class Project:
 
         self.animation = create(animation, 'animation')
 
-        self.event_queue = event_queue.EventQueue(maxsize=event_queue_maxsize)
-        self.animation.preframe_callback = self.event_queue.get_and_run_events
+        eq = event_queue.EventQueue(maxsize=event_queue_maxsize)
+        self.animation.event_queue = eq
+        self.animation.preframe_callback = eq.get_and_run_events
 
         # Unfortunately, the whole animation cycle is controlled by methods on
         # the topmost animation - but we need to get called back at a certain
@@ -81,13 +82,6 @@ class Project:
         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):
     """
diff --git a/bibliopixel/util/log_errors.py b/bibliopixel/util/log_errors.py
index 3695c66..64b47d1 100644
--- a/bibliopixel/util/log_errors.py
+++ b/bibliopixel/util/log_errors.py
@@ -28,7 +28,6 @@ class LogErrors:
             return self.function(*args, **kwds)
         except Exception as e:
             args = (class_name.class_name(e),) + e.args
-            raise
 
         self.errors += 1
         if self.max_errors < 0 or self.errors <= self.max_errors:


Test patch:
diff --git a/test/bibliopixel/control/address_test.py b/test/bibliopixel/control/address_test.py
index 181faa4..ddba904 100644
--- a/test/bibliopixel/control/address_test.py
+++ b/test/bibliopixel/control/address_test.py
@@ -7,7 +7,7 @@ class AddressTest(unittest.TestCase):
     def test_empty(self):
         address = Address('')
         self.assertFalse(address)
-        self.assertFalse(address.address)
+        self.assertFalse(address.segments)
         self.assertFalse(address.assignment)
         self.assertEqual(address.get(23), 23)
         with self.assertRaises(ValueError):
@@ -15,7 +15,7 @@ class AddressTest(unittest.TestCase):
 
     def test_attrib(self):
         address = Address('attr')
-        self.assertEqual(len(address.address), 1)
+        self.assertEqual(len(address.segments), 1)
         self.attr = 'bingo'
         self.assertIs(address.get(self), 'bingo')
         address.set(self, 'bang')
@@ -31,7 +31,7 @@ class AddressTest(unittest.TestCase):
 
     def test_array(self):
         address = Address('[1]')
-        self.assertEqual(len(address.address), 1)
+        self.assertEqual(len(address.segments), 1)
         data = [2, 4, 6]
         self.assertEqual(address.get(data), 4)
         address.set(data, 3)
@@ -54,7 +54,7 @@ class AddressTest(unittest.TestCase):
         self.attr3 = 'bingo'
 
         address = Address('.attr1[0][test][1][heck].attr2.attr3')
-        self.assertEqual(len(address.address), 7)
+        self.assertEqual(len(address.segments), 7)
         self.assertEqual(address.get(self), 'bingo')
         address.set(self, 'bang')
         self.assertEqual(self.attr3, 'bang')
@@ -64,7 +64,7 @@ class AddressTest(unittest.TestCase):
 
     def test_trivial_call(self):
         address = Address('()')
-        self.assertEqual(len(address.address), 1)
+        self.assertEqual(len(address.segments), 1)
         result = []
 
         address.set(result.append, 'value')
@@ -72,7 +72,7 @@ class AddressTest(unittest.TestCase):
 
     def test_call(self):
         address = Address('.call()')
-        self.assertEqual(len(address.address), 2)
+        self.assertEqual(len(address.segments), 2)
         address.set(self, 23)
         self.assertEqual(self.call_result, 23)
 
@@ -82,7 +82,7 @@ class AddressTest(unittest.TestCase):
     def test_call_complex(self):
         self.results = []
         address = Address('.call2()[1]().call()')
-        self.assertEqual(len(address.address), 6)
+        self.assertEqual(len(address.segments), 6)
         address.set(self, 23)
         self.assertEqual(self.call_result, 23)
 
diff --git a/test/bibliopixel/control/queued_address_test.py b/test/bibliopixel/control/queued_address_test.py
new file mode 100644
index 0000000..a93526e
--- /dev/null
+++ b/test/bibliopixel/control/queued_address_test.py
@@ -0,0 +1,47 @@
+import unittest
+from bibliopixel.control.queued_address import QueuedAddress
+from bibliopixel.project.event_queue import EventQueue
+
+
+class Base:
+    class Foo:
+        def __init__(self):
+            self.foo = 12
+
+    class Bar:
+        def __init__(self):
+            self.foo = Base.Foo()
+
+    class BarQ(Bar):
+        def __init__(self):
+            super().__init__()
+            self.event_queue = EventQueue()
+
+    class Baz:
+        def __init__(self):
+            self.bar = Base.Bar()
+            self.barq = Base.BarQ()
+            self.bing = 15
+
+    def __init__(self):
+        self.baz = Base.Baz()
+
+
+class QueuedAddressTest(unittest.TestCase):
+    def test_no_queue(self):
+        test = Base()
+        qa = QueuedAddress('baz.bar.foo.foo')
+        qa.set_target(test)
+        self.assertEqual(qa.get(), 12)
+        qa.receive(('hello', ))
+        self.assertEqual(qa.get(), 'hello')
+
+    def test_queued_address(self):
+        test = Base()
+        qa = QueuedAddress('baz.barq.foo.foo')
+        qa.set_target(test)
+        self.assertEqual(qa.get(), 12)
+        qa.receive(('hello',))
+        self.assertEqual(qa.get(), 12)
+        test.baz.barq.event_queue.get_and_run_events()
+        self.assertEqual(qa.get(), 'hello')
diff --git a/test/bibliopixel/util/udp_test.py b/test/bibliopixel/util/udp_test.py
index a59f749..3be5bf8 100644
--- a/test/bibliopixel/util/udp_test.py
+++ b/test/bibliopixel/util/udp_test.py
@@ -3,7 +3,7 @@ import contextlib, queue, time, unittest
 from bibliopixel.util import udp
 
 TEST_ADDRESS = '127.0.0.1', 5678
-TIMEOUT = 0.2
+TIMEOUT = 0.3
 
 
 @contextlib.contextmanager