Repository: ManiacalLabs/BiblioPixel
Pull request #639 (2018-03-08 10:35:06)

Tweaks, cleanups and improvements.
Extracted from the artnet commit.

I'm particularly happy about generalizing the server_cache architecture, because it will allow us to keep pretty well any server or thread going while switching between projects, _or_ to share one server connection to the same endpoint between multiple drivers in a thread-safe manner.



Patch:
diff --git a/bibliopixel/drivers/SimPixel/driver.py b/bibliopixel/drivers/SimPixel/driver.py
index 37464f3..807538d 100644
--- a/bibliopixel/drivers/SimPixel/driver.py
+++ b/bibliopixel/drivers/SimPixel/driver.py
@@ -1,10 +1,9 @@
 import struct
 from . import websocket
-from .. driver_base import DriverBase
-from ... util import log, server_cache
+from .. server_driver import ServerDriver
 
 
-class SimPixel(DriverBase):
+class SimPixel(ServerDriver):
     """Output a simulation of your displat to the web-based
     simulator at http://simpixel.io
 
@@ -18,37 +17,25 @@ class SimPixel(DriverBase):
         :py:mod:`bibliopixel.layout.geometry`
     """
 
-    CACHE = server_cache.ServerCache(websocket.Server, selectInterval=0.001)
+    SERVER_CLASS = websocket.Server
+    SERVER_KWDS = {'selectInterval': 0.001}
 
-    def __init__(self, num=1024, port=1337, pixel_positions=None, **kwds):
+    def __init__(self, num=1024, port=1337, **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, address=port, **kwds)
 
-        super().__init__(num, **kwds)
-        self.port = port
-        self.pixel_positions = self.server = self.thread = None
-
-        if pixel_positions:
-            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"""
+    def _on_positions(self):
         # 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))
         if self.server:
-            self.server.update(positions=self.pixel_positions)
-
-    def cleanup(self):
-        """SHOULD BE PRIVATE"""
-        self.server.close()
-
-    def _compute_packet(self):
-        self._render()
+            pl = [c for p in self.pixel_positions for c in p]
+            positions = bytearray(struct.pack('<%sh' % len(pl), *pl))
+            self.server.update(positions=positions)
 
     def _send_packet(self):
         self.server.update(pixels=self._buf)
diff --git a/bibliopixel/drivers/server_driver.py b/bibliopixel/drivers/server_driver.py
new file mode 100644
index 0000000..fe081ca
--- /dev/null
+++ b/bibliopixel/drivers/server_driver.py
@@ -0,0 +1,54 @@
+from . driver_base import DriverBase
+from .. util.server_cache import ServerCache
+from .. project import construct
+
+
+class ServerDriver(DriverBase):
+    SERVER_CLASS = None
+    SERVER_KWDS = {}
+
+    def __init__(self, *args, address, pixel_positions=None, **kwds):
+        """
+        Args:
+            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__(*args, **kwds)
+        self.address = address
+        self.server = self.thread = None
+        self.pixel_positions = pixel_positions
+        if pixel_positions:
+            self.set_pixel_positions(pixel_positions)
+
+    def start(self):
+        self.server = self.cache().get_server(self.address)
+        self._on_positions()
+
+    def set_pixel_positions(self, pixel_positions):
+        # This is the "automatic" update from the layout - don't overwrite
+        # any pixel_position set in the constructor.
+        if self.pixel_positions is None:
+            self.pixel_positions = pixel_positions
+            self._on_positions()
+
+    @classmethod
+    def cache(cls):
+        try:
+            return cls.CACHE
+        except AttributeError:
+            pass
+        cls.CACHE = ServerCache(cls.SERVER_CLASS, **cls.SERVER_KWDS)
+        return cls.CACHE
+
+    def _send_packet(self):
+        pass
+
+    def _on_positions(self):
+        pass
+
+    def cleanup(self):
+        self.server.close()
+
+    def _compute_packet(self):
+        self._render()
diff --git a/bibliopixel/util/colors/names.py b/bibliopixel/util/colors/names.py
index bc2f5c4..7fb5967 100644
--- a/bibliopixel/util/colors/names.py
+++ b/bibliopixel/util/colors/names.py
@@ -41,7 +41,7 @@ def name_to_color(name):
     return color
 
 
-def color_to_name(color):
+def color_to_name(color, use_hex=False):
     """
     :param tuple color: an RGB 3-tuple of integer colors
     :returns: a string name for this color
@@ -49,10 +49,14 @@ def color_to_name(color):
     ``name_to_color(color_to_name(c)) == c`` is guaranteed to be true (but the
     reverse is not true, because name_to_color is a many-to-one function).
     """
+    if use_hex:
+        return '#%02x%02x%02x' % color
+    if not isinstance(color, tuple):
+        color = tuple(color)
     try:
-        return TO_NAME[tuple(color)]
+        return TO_NAME[color]
     except:
-        return str(tuple(color))
+        return str(color)
 
 
 def toggle(s):
diff --git a/bibliopixel/util/colors/printer.py b/bibliopixel/util/colors/printer.py
new file mode 100644
index 0000000..110ed42
--- /dev/null
+++ b/bibliopixel/util/colors/printer.py
@@ -0,0 +1,18 @@
+from .. import log
+from . import names
+
+
+def printer(colors, use_hex=False):
+    if not colors:
+        return
+
+    # Try to flatten the list
+
+    try:
+        colors[0][0]
+    except:
+        assert len(colors) % 3 == 0
+        colors = zip(*([iter(colors)] * 3))
+
+    for i, color in enumerate(colors):
+        log.printer('%2d:' % i, names.color_to_name(color, use_hex))
diff --git a/bibliopixel/util/server_cache.py b/bibliopixel/util/server_cache.py
index 60e49ef..9048399 100644
--- a/bibliopixel/util/server_cache.py
+++ b/bibliopixel/util/server_cache.py
@@ -1,35 +1,35 @@
 import errno
-from . import log
+from .. import log
 
 
 ADDRESS_IN_USE_ERROR = """
 
-Port {0} on your machine is already in use.
+Cached server {0} on your machine is already in use.
 Perhaps BiblioPixel is already running on your machine?
 """
 
 CACHED_SERVER_ERROR = """
-Tried to open server of type {new_type} on port {port}, but there was already
+Tried to open server of type {new_type} on {port}, but there was already
 a server of type {old_type} running there.
 """
 
 CACHED_KWDS_WARNING = """
-Cached server for port {server.port} had keywords {server.kwds},
+Cached server for {server.port} had keywords {server.kwds},
 but keywords {kwds} were requested.
 """
 
 
 class _CachedServer:
-    def __init__(self, port, constructor, **kwds):
-        self.server = self._make_server(port, constructor, **kwds)
-        self.port = port
+    def __init__(self, constructor, key, kwds):
+        self.server = self._make_server(constructor, key, kwds)
+        self.key = key
         self.constructor = constructor
         self.kwds = kwds
 
-    def check_keywords(self, constructor, **kwds):
+    def check_keywords(self, constructor, kwds):
         if self.constructor != constructor:
             raise ValueError(CACHED_SERVER_ERROR.format(
-                port=self.port,
+                key=self.key,
                 new_type=str(constructor),
                 old_type=str(self.constructor)))
 
@@ -44,49 +44,55 @@ class _CachedServer:
         return getattr(self.server, key)
 
     @staticmethod
-    def _make_server(port, constructor, **kwds):
+    def _make_server(constructor, key, kwds):
         try:
-            return constructor(port, **kwds)
+            return constructor(key, **kwds)
         except OSError as e:
             if e.errno == errno.EADDRINUSE:
-                e.strerror += ADDRESS_IN_USE_ERROR.format(port)
+                e.strerror += ADDRESS_IN_USE_ERROR.format(key)
                 e.args = (e.errno, e.strerror)
             raise
 
 
 class ServerCache:
     """
-    A class that caches TCP/IP servers by port number so that you don't keep
-    closing and re-opening the same server and interrupting your connection.
-    """
-    ENABLE = True
+    A class that caches servers by key so you don't keep closing and re-opening
+    the same server and interrupting your connection.
 
+    The exact nature of the key depends on the sort of server.
+    For example, for a server socket like SimPixel, it would be just a port
+    number, whereas for a UDP connection like Art-Net, it would be a
+    port, ip_address pair.
+    """
     def __init__(self, constructor, **kwds):
         """
-        :param constructor: a function which takes a port and some keywords
-                            and returns a new TCP/IP server
+        :param constructor: a function which takes a key and some keywords,
+            and returns a new server
         :param kwds: keywords to the ``constructor`` function
         """
         self.servers = {}
-        self.kwds = dict(kwds, constructor=constructor)
+        self.constructor = constructor
+        self.kwds = kwds
 
-    def get_server(self, port):
+    def get_server(self, key):
         """
-        Get a new or existing server for this port.
-        :param port: TCP/IP port for the server to use
-        """
-        if not self.ENABLE:
-            return _CachedServer._make_server(port, **self.kwds)
+        Get a new or existing server for this key.
 
-        server = self.servers.get(port)
+        :param int key: key for the server to use
+        """
+        server = self.servers.get(key)
         if server:
-            server.check_keywords(**self.kwds)
+            # Make sure it's the right server.
+            server.check_keywords(self.constructor, self.kwds)
         else:
-            server = _CachedServer(port, **self.kwds)
-            self.servers[port] = server
+            # Make a new server
+            server = _CachedServer(self.constructor, key, self.kwds)
+            self.servers[key] = server
 
         return server
 
-    def close(self, port):
-        server = self.servers.pop(port)
-        server.server.close()
+    def close(self, key):
+        server = self.servers.pop(key, None)
+        if server:
+            server.server.close()
+            return True
diff --git a/bibliopixel/util/threads/threads.py b/bibliopixel/util/threads/threads.py
index 64bdfea..2848301 100644
--- a/bibliopixel/util/threads/threads.py
+++ b/bibliopixel/util/threads/threads.py
@@ -1,4 +1,4 @@
-import functools, threading
+import functools, threading, queue
 
 
 class Loop(threading.Thread):
@@ -14,6 +14,9 @@ class Loop(threading.Thread):
     def stop(self):
         self.running = False
 
+    def loop(self):
+        pass
+
 
 class Loops(object):
     def __init__(self, loops):
@@ -24,3 +27,22 @@ class Loops(object):
 
     def stop(self):
         map(lambda x: x.stop(), self.loops)
+
+
+class QueueHandler(Loop):
+    def __init__(self, timeout=0.1, send=None, **kwds):
+        super().__init__(**kwds)
+        self.timeout = timeout
+        self.queue = queue.Queue
+        self.send = send or self.send
+
+    def loop(self):
+        try:
+            msg = self.queue.get(timeout=self.timeout)
+        except queue.Empty:
+            pass
+        else:
+            self.send(msg)
+
+    def send(self, msg):
+        pass
diff --git a/bibliopixel/util/udp.py b/bibliopixel/util/udp.py
new file mode 100644
index 0000000..424c80b
--- /dev/null
+++ b/bibliopixel/util/udp.py
@@ -0,0 +1,102 @@
+"""
+A server that queues and sends UDP requests for a specific port on a
+separate thread.
+"""
+
+import queue, socket
+from .. util.threads import threads
+
+
+class Sender:
+    def __init__(self, address):
+        """
+        :param str address: a pair (port, ip_address) to pass to socket.connect
+        """
+        super().__init__()
+        self.address = address
+        self.running = True
+        self._socket = None
+        self.reuse_socket = True
+
+    @property
+    def socket(self):
+        if not (self.reuse_socket and self._socket):
+            self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+        return self._socket
+
+    def start(self):
+        pass
+
+    def stop(self):
+        pass
+
+    def send(self, msg):
+        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
+            sock.connect(self.address)
+            sock.send(msg)
+            # sock.sendto(msg, self.address)
+
+
+class QueuedSender(threads.QueueHandler):
+    """
+    The UPD protocol is stateless but not necessarily thread-safe.
+
+    ``QueuedSender`` uses a queue to send all UDP messages to one address
+    from a new thread.
+
+    """
+    def __init__(self, address, **kwds):
+        """
+        :param str address: a pair (port, ip_address) to pass to socket.connect
+        :param bool use_queue: if True, run the connection in a different thread
+            with a queue
+        """
+        super().__init__(**kwds)
+        self.sender = Sender(address)
+
+    def send(self, msg):
+        self.sender.send(msg)
+
+
+def sender(address, use_queue=True, **kwds):
+    """
+    :param tuple address:
+    """
+    return QueuedSender(address, **kwds) if use_queue else Sender(address)
+
+
+class Receiver(threads.Loop):
+    """
+    Receive UDP messages in a thread
+    """
+    def __init__(self, address, bufsize=0x1000, receive=None, **kwds):
+        super().__init__(**kwds)
+        self.address = address
+        self.bufsize = bufsize
+        self.receive = receive or self.receive
+
+    def run(self):
+        self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+        self.socket.bind(self.address)
+
+        super().run()
+
+    def loop(self):
+        data, addr = self.socket.recvfrom(self.bufsize)
+        if data:
+            self.receive(data)
+
+    def receive(self, msg):
+        pass
+
+
+class QueuedReceiver(Receiver):
+    """
+    Receive UDP messages in a thread and put them on a queue.
+    """
+    def run(self):
+        self.queue = queue.Queue()
+        super().run()
+
+    def receive(self, msg):
+        self.queue.put(msg)
diff --git a/projects/50-tiny.yml b/projects/50-tiny.yml
new file mode 100644
index 0000000..976a971
--- /dev/null
+++ b/projects/50-tiny.yml
@@ -0,0 +1,23 @@
+driver:
+    num: 1024
+    typename: simpixel
+
+layout:
+    typename: matrix
+    width: 32
+    height: 32
+
+run:
+    fps: 30
+    seconds: 3
+
+aliases:
+    bpa: BiblioPixelAnimations.matrix
+
+animation:
+    typename: sequence
+    length: 1
+    animations:
+        - "@bpa.ImageAnim"
+        - "@bpa.ImageShow"
+        - "@bpa.ImageDissolve"


Test patch:
diff --git a/test/bibliopixel/util/udp_test.py b/test/bibliopixel/util/udp_test.py
new file mode 100644
index 0000000..a59f749
--- /dev/null
+++ b/test/bibliopixel/util/udp_test.py
@@ -0,0 +1,36 @@
+import contextlib, queue, time, unittest
+
+from bibliopixel.util import udp
+
+TEST_ADDRESS = '127.0.0.1', 5678
+TIMEOUT = 0.2
+
+
+@contextlib.contextmanager
+def receive_udp(address, results):
+    receiver = udp.QueuedReceiver(address)
+    receiver.start()
+
+    yield
+
+    try:
+        while True:
+            results.append(receiver.queue.get(timeout=TIMEOUT))
+    except queue.Empty:
+        pass
+
+
+class UDPTest(unittest.TestCase):
+    def test_full(self):
+        messages = [s.encode() for s in ('foo', '', 'bar', 'baz', '', 'bing')]
+        expected = [s for s in messages if s]
+        # Note that empty messages are either not sent, or not received.
+
+        actual = []
+        with receive_udp(TEST_ADDRESS, actual):
+            sender = udp.QueuedSender(TEST_ADDRESS)
+            sender.start()
+            for m in messages:
+                sender.send(m)
+
+        self.assertEquals(actual, expected)