diff --git a/README.md b/README.md
index fdb55f4..7f6aac6 100644
--- a/README.md
+++ b/README.md
@@ -6,9 +6,7 @@ Control the LArPix chip
 
 ## Setup and installation
 
-This code is intended to work on both Python 2.7+ and Python 3.6+,
-but it was designed in Python 3 and is not guaranteed to work in
-Python 2.
+This code is intended to work on both Python 2.7+ and Python 3.6+.
 
 Install larpix-control from pip with
 
@@ -28,8 +26,30 @@ expected. After `pip install`ing this package, you can run the tests
 from the repository root directory with the simple command `pytest`.
 
 You can read the tests to see examples of how to call all of the common
-functions. I imagine they will also come in handy when you're confused
-about the bit order. (Also see the section on endian-ness below.)
+functions.
+
+## Minimal working example
+
+So you're not a tutorials kind of person. Here's a minimal working
+example for you to play around with:
+
+```python
+>>> import larpix.larpix as larpix
+>>> controller = larpix.Controller()
+>>> controller.io = larpix.FakeIO()
+>>> chip1 = larpix.Chip(1, 0)  # (chipID, IO-chain index)
+>>> controller.chips.append(chip1)
+>>> chip1.config.global_threshold = 25
+>>> controller.write_configuration(chip1, 25)
+[ Config write | Chip: 1 | Register: 25 | Value:  16 | Parity: 1 (valid: True) ]
+>>> packet = larpix.Packet(b'\x04\x14\x80\xc4\x03\xf2 ')
+>>> packet_bytes = packet.bytes()
+>>> pretend_input = ([packet], packet_bytes)
+>>> controller.io.queue.append(pretend_input)
+>>> controller.run(0.05, 'test run')
+>>> print(controller.reads[0])
+[ Data | Chip: 1 | Channel: 5 | Timestamp: 123456 | ADC data: 120 | FIFO Half: False | FIFO Full: False | Parity: 1 (valid: True) ]
+```
 
 ## Tutorial
 
@@ -45,6 +65,260 @@ import larpix.larpix as larpix  # use the larpix namespace
 from larpix.larpix import *  # import all larpix classes into the current namespace
 ```
 
+### Create a LArPix Controller
+
+The LArPix Controller translates high-level ideas like "read
+configuration register 10" into communications to and from LArPix ASICs,
+and interprets the received data into a usable format.
+
+Controller objects communicate with LArPix ASICs via an IO interface.
+Currently available IO interfaces are ``SerialPort``, ``ZMQ_IO`` and
+``FakeIO``. We'll work with ``FakeIO`` in this tutorial, but all the
+code will still work with properly initialized versions of the other IO
+interfaces.
+
+Set things up with
+
+```python
+controller = larpix.Controller()
+controller.io = larpix.FakeIO()
+```
+
+The ``FakeIO`` object imitates a real IO interface for testing purposes.
+It directs its output to stdout (i.e. it prints the output), and it
+takes its input from a manually-updated queue. At the end of each
+relevant section of the tutorial will be code for adding the expected
+output to the queue. You'll have to refill the queue each time you run
+the code.
+
+### Set up LArPix Chips
+
+Chip objects represent actual LArPix ASICs. For each ASIC you want to
+communicate with, create a LArPix Chip object and add it to the
+Controller.
+
+```python
+chipid = 5
+io_chain = 0  # Currently unused
+controller.chips.append(larpix.Chip(chipid, io_chain))
+```
+
+Note that the second argument to the constructor is required but
+currently unused.
+
+### Adjust the configuration of the LArPix Chips
+
+Each Chip object manages its own configuration in software.
+Configurations can be adjusted by name using attributes of the Chip's
+configuration:
+
+```python
+chip5 = controller.chips[0]
+chip5.config.global_threshold = 35  # entire register = 1 number
+chip5.config.periodic_reset = 1  # one bit as part of a register
+chip5.config.channel_mask[20] = 1  # one bit per channel
+```
+
+Values are validated, and invalid values will raise exceptions.
+
+Note: Changing the configuration of a Chip object does *not* change the
+configuration on the ASIC.
+
+Once the configuration is set, the new values must be sent to the LArPix
+ASICs. There is an appropriate Controller method for that:
+
+```python
+controller.write_configuration(chip5)  # send all registers
+controller.write_configuration(chip5, 32)  # send only register 32
+controller.write_configuration(chip5, [32, 50])  # send registers 32 and 50
+```
+
+Register addresses can be looked up using the configuration object:
+
+```python
+global_threshold_reg = chip5.config.global_threshold_address
+```
+
+For configurations which extend over multiple registers, the relevant
+attribute will end in ``_addresses``. Certain configurations share a
+single register, whose attribute has all of the names in it. View the
+documentation or source code to find the name to look up. (Or look at
+the LArPix data sheet.)
+
+### Reading the configuration from LArPix ASICs
+
+The current configuration state of the LArPix ASICs can be requested by
+sending out "configuration read" requests using the Controller:
+
+```python
+controller.read_configuration(chip5)
+```
+
+The same variations to read only certain registers are implemented for
+reading as for writing.
+
+The responses from the LArPix ASICs are stored for inspection. See the
+section on "Inspecting received data" for more.
+
+FakeIO queue code:
+
+```python
+packets = chip5.get_configuration_packets(larpix.Packet.CONFIG_READ_PACKET)
+bytestream = b'bytes for the config read packets'
+controller.io.queue.append((packets, bytestream))
+```
+
+### Receiving data from LArPix ASICs
+
+When it is first initialized, the LArPix Controller ignores and discards
+all data that it receives from LArPix. The Controller must be activated
+by calling ``start_listening()``. All received data will then be
+accumulated in an implementation-dependent queue or buffer, depending
+on the IO interface used. To read the data from the buffer, call the
+controller's ``read()`` method, which returns both the raw bytestream
+received as well as a list of LArPix Packet objects which have been
+extracted from the bytestream. To stop listening for new data, call
+``stop_listening()``. Finally, to store the data in the controller
+object, call the ``store_packets`` method. All together:
+
+```python
+controller.start_listening()
+# Data arrives...
+packets, bytestream = controller.read()
+# More data arrives...
+packets2, bytestream2 = controller.read()
+controller.stop_listening()
+message = 'First data arrived!'
+message2 = 'More data arrived!'
+controller.store_packets(packets, bytestream, message)
+controller.store_packets(packets, bytestream2, message2)
+```
+
+There is a common pattern for reading data, namely to start listening,
+then check in periodically for new data, and then after a certain amount
+of time has passed, stop listening and store all the data as one
+collection. The method ``run(timelimit, message)`` accomplishes just this.
+
+```python
+duration = 10  # seconds
+message = '10-second data run'
+controller.run(duration, message)
+```
+
+FakeIO queue code for the first code block:
+
+```python
+packets = [Packet()] * 40
+bytestream = b'bytes from the first set of packets'
+controller.io.queue.append((packets, bytestream))
+packets2 = [Packet()] * 30
+bytestream2 = b'bytes from the second set of packets'
+controller.io.queue.append((packets2, bytestream2))
+```
+
+fakeIO queue code for the second code block:
+
+```python
+packets = [Packet()] * 5
+bytestream = b'[bytes from read #%d] '
+for i in range(100):
+    controller.io.queue.append((packets, bytestream%i))
+```
+
+### Inspecting received data
+
+Once data is stored in the controller, it is available in the ``reads``
+attribute as a list of all data runs. Each element of the list is a
+PacketCollection object, which functions like a list of Packet objects
+each representing one LArPix packet.
+
+PacketCollection objects can be indexed like a list:
+
+```python
+run1 = controller.runs[0]
+first_packet = run1[0]  # Packet object
+first_ten_packets = run1[0:10]  # smaller PacketCollection object
+
+first_packet_bits = run1[0, 'bits']  # string representation of bits in packet
+first_ten_packet_bits = run1[0:10, 'bits']  # list of strings
+```
+
+PacketCollections can be printed to display the contents of the Packets
+they contain. To prevent endless scrolling, only the first ten and last ten
+packets are displayed, and the number of omitted packets is noted. To
+view the omitted packets, use a slice around the area of interest.
+
+```python
+print(run1)  # prints the contents of the packets
+print(run1[10:30])  # prints 20 packets from the middle of the run
+```
+
+In interactive Python, returned objects are not printed, but rather
+their "representation" is printed (cf. the ``__repr__`` method). The
+representation of PacketCollections is a listing of the number of
+packets, the "read id" (a.k.a. the run number), and the message
+associated with the PacketCollection when it was created.
+
+### Individual LArPix Packets
+
+LArPix Packet objects represent individual LArPix UART packets. They
+have attributes which can be used to inspect or modify the contents of
+the packet.
+
+```python
+packet = run1[0]
+# all packets
+packet.packet_type  # unique in that it gives the bits representation
+packet.chipid  # all other properties return Python numbers
+packet.parity_bit_value
+# data packets
+packet.channel_id
+packet.dataword
+packet.timestamp
+packet.fifo_half_flag  # 1 or 0
+packet.fifo_full_flag  # 1 or 0
+# config packets
+packet.register_address
+packet.register_data
+# test packets
+packet.test_counter
+```
+
+Internally, packets are represented as an array of bits, and the
+different attributes use Python "properties" to seamlessly convert
+between the bits representation and a more intuitive integer
+representation. The bits representation can be inspected with the
+``bits`` attribute.
+
+Packet objects do not restrict you from adjusting an attribute for an
+inappropriate packet type. For example, you can create a data packet and
+then set ``packet.register_address = 5``. This will adjust the packet
+bits corresponding to a configuration packet's "register\_address"
+region, which is probably not what you want for your data packet.
+
+Packets have a parity bit which enforces odd parity, i.e. the sum of
+all the individual bits in a packet must be an odd number. The parity
+bit can be accessed as above using the ``parity_bit_value`` attribute.
+The correct parity bit can be computed using ``compute_parity()``,
+and the validity of a packet's parity can be checked using
+``has_valid_parity()``. When constructing a new packet, the correct
+parity bit can be assigned using ``assign_parity()``.
+
+Individual packets can be printed to show a human-readable
+interpretation of the packet contents. The printed version adjusts its
+output based on the packet type, so a data packet will show the data
+word, timestamp, etc., while a configuration packet will show the register
+address and register data.
+
+Like with PacketCollections, Packets also have a "representation" view
+based on the bytes that make up the packet. This can be useful for
+creating new packets since a Packet's representation is also a vaild
+call to the Packet constructor. So the output from an interactive
+session can be copied as input or into a script to create the same
+packet.
+
+## Miscellaneous implementation details
+
 ### Endian-ness
 
 We use the convention that the LSB is sent out first and read in first.
@@ -63,26 +337,6 @@ the least significant bit of a bytestring is the *last bit* of the
 `0000 0010 0000 0001` ( = 0x0201 = 513), then the bytes will be sent out as
 `b'\x01\x02'`.
 
-### Creating a LArPix Chip
-
-The `Chip` object represents a single LArPix chip and knows about
-everything happening on the chip regarding configuration, data sent in,
-and data read out. To create a Chip, just provide the chip ID number
-(hard-wired into the PCB) and the index for the IO Chain (daisy chain)
-that the chip is part of:
-
-```python
-myChip = Chip(100, 0)
-```
-
-The Chip object uses these ID values when it creates data packets to
-ensure that the packet reaches the correct chip. And other objects use
-the ID values to ensure that received data from the physical chip makes
-its way to the right Chip object.
-
-The chip's configuration register is represented by the `myChip.config`
-attribute, which is an instance of the `Configuration` object.
-
 ### The Configuration object
 
 The `Configuration` object represents all of the options in the LArPix
@@ -141,103 +395,5 @@ Configurations can be saved by calling `chip.config.write` with the
 desired filename.
 
 Once the Chip object has been configured, the configuration must be sent
-to the physical chip. This is accomplished with the `Controller` object,
-which we'll discuss next.
-
-### Communicating with the physical LArPix chip
-
-Communication between the computer and the physical LArPix chip is
-handled by the `Controller` object and uses a Serial interface. (The
-interface specification is given in the fpga\_interface.txt file. It's
-based on RS-232 8N1.) To initialize a Controller object, simply provide
-the port you'd like to communicate over. For the envisioned normal
-application (with an FTDI chip as USB-serial bridge), this will likely
-be something like `/dev/ttyUSB0`.
-
-```python
-controller = Controller('/dev/ttyUSB0')
-```
-
-An important attribute of the Controller object is `chips`, which is a
-list of Chip objects controlled by the particular Controller. Add a
-single Chip object to the list with `controller.chips.append(myChip)`,
-or add a whole list with `controller.chips.extend(list_of_chips)`.
-
-You might want to change the following
-attributes at some point, but their defaults should work in most cases:
-
- - `baudrate`: default = 1000000 baud. Controls the number of bits per
-   second, including RS-232 start and stop bits.
- - `timeout`: default = 1 second. Controls how long to wait before
-   ending a read command
- - `max_write`: default = 8192 bytes. Controls the maximum number of
-   bytes to send with a single write command. The limit is entirely due
-   to the buffer capacity of the FTDI chip.
-
-#### Sending data
-
-The only data that LArPix can receive is configuration data. To send all
-of the configuration packets in write mode, simply call
-
-```python
-myChip = Chip(chip_id, io_chain)
-# Edit the configuration
-# ...
-myController = Controller('/dev/ttyUSB0')
-myController.write_configuration(myChip)
-```
-
-To send only a particular configuration register or list of
-configuration registers, pass the register or list of registers to the
-function:
-
-```python
-register_to_update = 51
-myController.write_configuration(myChip, register_to_update)
-# or pass a list ...
-registers_to_update = [0, 5, 42]
-myController.write_configuration(myChip, registers_to_update)
-```
-
-There is currently not a way to specify which register to update by
-passing a string or other way of identifying the register by name.
-
-Similar functionality exists to read the configuration data. This
-requires both sending data to and receiving data from the LArPix chip.
-To send the "read configuration" commands, call `read_configuration`
-exactly the same way you would call `write_configuration`. Read on to
-learn about receiving data from LArPix in more detail.
-
-#### Receiving data
-
-There are 3 reasons to receive data from LArPix: because it's real data
-(ADC counts, etc.), because it's configuration data that has been
-requested, or because it's test data from either the UART test or the
-FIFO test.
-
-The simplest way to receive data from LArPix is to just listen for a
-certain amount of time and save all the packets received. This is
-accomplished with the `run` method:
-
-```python
-myController.run(10)  # listens for 10 seconds
-```
-
-This method makes sense for physics runs or any special runs that aren't
-provided by the following other methods.
-
-To read configuration data, call `read_configuration`, as mentioned
-earlier.
-
-To make it easy to run tests, the following methods will configure the
-chip, run the test, and record the data received: `run_testpulse`,
-`run_fifo_test`, and `run_analog_monitor_test`.
-
-#### Accessing received data
+to the physical chip.
 
-Every method that reads data processes the data from a bytestream into a
-Packet object. The Packet objects are appended to the list stored in the
-`reads` attribute of the correct Chip object, as defined by the `chipid`
-returned by the Packet. It's worth noting here that the Controller
-object is only aware of Chip objects listed in the `controller.chips`
-attribute.
diff --git a/larpix/larpix.py b/larpix/larpix.py
index ce500ca..1ae772a 100644
--- a/larpix/larpix.py
+++ b/larpix/larpix.py
@@ -7,11 +7,10 @@ from __future__ import absolute_import
 import time
 from bitarray import bitarray
 import larpix.bitarrayhelper as bah
+from collections import deque
 import json
 import os
 import errno
-import re
-import platform
 import math
 
 import larpix.configs as configs
@@ -771,16 +770,29 @@ class Controller(object):
     '''
     Controls a collection of LArPix Chip objects.
 
+    Reading data:
+
+    The specific interface for reading data is selected by specifying
+    an ``io`` object to be ``Controller.io``. These objects all have
+    similar behavior for reading in new data. On initialization, the
+    object will discard any LArPix packets sent from ASICs. To begin
+    saving incoming packets, call ``Controller.start_listening()``.
+    Data will then build up in some form of internal register or queue.
+    The queue can be emptied with a call to ``Controller.read()``,
+    which empties the queue and returns a list of Packet objects that
+    were in the queue. The ``io`` object will still be listening for
+    new packets during and after this process. If the queue/register
+    fills up, data may be discarded/lost. To stop saving incoming
+    packets and retrieve any packets still in the queue, call
+    ``Controller.stop_listening()``. While the Controller is listening,
+    packets can be sent using the appropriate methods without
+    interrupting the incoming data stream.
+
     Properties and attributes:
 
     - ``chips``: the ``Chip`` objects that the controller controls
     - ``all_chip``: all possible ``Chip`` objects (considering there are
       a finite number of chip IDs), initialized on object construction
-    - ``port``: the path to the serial port, i.e. "/dev/(whatever)"
-      (default: ``None`` [will attempt to auto-find correct port])
-    - ``timeout``: the timeout used for serial commands, in seconds.
-      This can be changed between calls to the read and write commands.
-      (default: ``1``)
     - ``reads``: list of all the PacketCollections that have been sent
       back to this controller. PacketCollections are created by
       ``run``, ``write_configuration``, and ``read_configuration``, but
@@ -790,24 +802,13 @@ class Controller(object):
       ``False``)
 
     '''
-    start_byte = b'\x73'
-    stop_byte = b'\x71'
-    def __init__(self, port=None, timeout=1):
+    def __init__(self):
         self.chips = []
         self.all_chips = self._init_chips()
         self.use_all_chips = False
         self.reads = []
         self.nreads = 0
-        self.port = port
-        if self.port is None:
-            self.port = SerialPort.guess_port()
-        self.baudrate = 1000000
-        self.timeout = timeout
-        self.max_write = 250
-        self._test_mode = False
-        self._serial = SerialPort(port=self.port,
-                                  baudrate=self.baudrate,
-                                  timeout=self.timeout)
+        self.io = None
 
     def _init_chips(self, nchips = 256, iochain = 0):
         '''
@@ -827,74 +828,63 @@ class Controller(object):
         raise ValueError('Could not find chip (%d, %d) (using all_chips'
                 '? %s)' % (chip_id, io_chain, self.use_all_chips))
 
-    #def serial_flush(self):
-    #    with self._serial(self.port, baudrate=self.baudrate,
-    #            timeout=self.timeout) as serial:
-    #        serial.reset_output_buffer()
-    #        serial.reset_input_buffer()
-    def serial_close(self):
-        if not self._serial is None:
-            self._serial.close()
-        return
+    def send(self, packets):
+        '''
+        Send the specified packets to the LArPix ASICs.
 
-    def serial_read(self, timelimit):
-        data_in = b''
-        start = time.time()
-        close_port = False
-        if not self._serial._keep_open:
-            close_port = True
-        self._serial._keep_open = True
-        try:
-            while time.time() - start < timelimit:
-                stream = self._serial.read(self.max_write)
-                if len(stream) > 0:
-                    data_in += stream
-        except Exception as e:
-            if getattr(self, '_read_tries_left', None) is None:
-                self._read_tries_left = 3
-                self.serial_read(timelimit)
-            elif self._read_tries_left > 0:
-                self._read_tries_left -= 1
-                self.serial_read(timelimit)
-            else:
-                del self._read_tries_left
-                raise
-        if close_port:
-            self.serial_close()
-            self._serial._keep_open = False
-        return data_in
-
-    def serial_write(self, bytestreams):
-        for bytestream in bytestreams:
-            self._serial.write(bytestream)
-
-    def serial_write_read(self, bytestreams, timelimit):
-        data_in = b''
-        close_port = False
-        if not self._serial._keep_open:
-            close_port = True
-        self._serial._keep_open = True
-        start = time.time()
-        # First do a fast write-read loop until everything is
-        # written out, then just read
-        self._serial.timeout = 0  # Return whatever's already waiting
-        for bytestream in bytestreams:
-            self._serial.write(bytestream)
-            stream = self._serial.read(self.max_write)
-            if len(stream) > 0:
-                data_in += stream
-        self._serial.timeout = self.timeout
-        while time.time() - start < timelimit:
-            stream = self._serial.read(self.max_write)
-            if len(stream) > 0:
-                data_in += stream
-        if close_port:
-            self.serial_close()
-            self._serial._keep_open = False
-        return data_in
+        '''
+        self.io.send(packets)
+
+    def start_listening(self):
+        '''
+        Listen for packets to arrive.
+
+        '''
+        self.io.start_listening()
+
+    def stop_listening(self):
+        '''
+        Stop listening for new packets to arrive.
+
+        '''
+        return self.io.stop_listening()
+
+    def read(self):
+        '''
+        Read any packets that have arrived and return (packets,
+        bytestream) where bytestream is the bytes that were received.
+
+        The returned list will contain packets that arrived since the
+        last call to ``read`` or ``start_listening``, whichever was most
+        recent.
+
+        '''
+        packets, bytestream = self.io.empty_queue()
+        return packets, bytestream
 
     def write_configuration(self, chip, registers=None, write_read=0,
             message=None):
+        '''
+        Send the configurations stored in chip.config to the LArPix
+        ASIC.
+
+        By default, sends all registers. If registers is an int, then
+        only that register is sent. If registers is an iterable, then
+        all of the registers in the iterable are sent.
+
+        If write_read == 0 (default), the configurations will be sent
+        and the current listening state will not be affected. If the
+        controller is currently listening, then the listening state
+        will not change and the value of write_read will be ignored. If
+        write_read > 0 and the controller is not currently listening,
+        then the controller will listen for ``write_read`` seconds
+        beginning immediately before the packets are sent out, read the
+        io queue, and save the packets into the ``reads`` data member.
+        Note that the controller will only read the queue once, so if a
+        lot of data is expected, you should handle the reads manually
+        and set write_read to 0 (default).
+
+        '''
         if registers is None:
             registers = list(range(Configuration.num_registers))
         elif isinstance(registers, int):
@@ -905,18 +895,38 @@ class Controller(object):
             message = 'configuration write'
         else:
             message = 'configuration write: ' + message
-        bytestreams = self.get_configuration_bytestreams(chip,
+        packets = chip.get_configuration_packets(
                 Packet.CONFIG_WRITE_PACKET, registers)
-        if write_read == 0:
-            self.serial_write(bytestreams)
-        else:
-            miso_bytestream = self.serial_write_read(bytestreams,
-                    timelimit=write_read)
-            packets = self.parse_input(miso_bytestream)
-            self.store_packets(packets, miso_bytestream, message)
+        already_listening = self.io.is_listening
+        mess_with_listening = write_read != 0 and not already_listening
+        if mess_with_listening:
+            self.start_listening()
+            stop_time = time.time() + write_read
+        self.send(packets)
+        if mess_with_listening:
+            time.sleep(stop_time - time.time())
+            packets, bytestream = self.read()
+            self.stop_listening()
+            self.store_packets(packets, bytestream, message)
 
     def read_configuration(self, chip, registers=None, timeout=1,
             message=None):
+        '''
+        Send "configuration read" requests to the LArPix ASIC.
+
+        By default, request all registers. If registers is an int, then
+        only that register is reqeusted. If registers is an iterable,
+        then all of the registers in the iterable are requested.
+
+        If the controller is currently listening, then the requests
+        will be sent and no change to the listening state will occur.
+        (The value of ``timeout`` will be ignored.) If the controller
+        is not currently listening, then the controller will listen
+        for ``timeout`` seconds beginning immediately before the first
+        packet is sent out, and will save any received packets in the
+        ``reads`` data member.
+
+        '''
         if registers is None:
             registers = list(range(Configuration.num_registers))
         elif isinstance(registers, int):
@@ -927,11 +937,18 @@ class Controller(object):
             message = 'configuration read'
         else:
             message = 'configuration read: ' + message
-        bytestreams = self.get_configuration_bytestreams(chip,
+        packets = chip.get_configuration_packets(
                 Packet.CONFIG_READ_PACKET, registers)
-        data = self.serial_write_read(bytestreams, timeout)
-        packets = self.parse_input(data)
-        self.store_packets(packets, data, message)
+        already_listening = self.io.is_listening
+        if not already_listening:
+            self.start_listening()
+            stop_time = time.time() + timeout
+        self.send(packets)
+        if not already_listening:
+            time.sleep(stop_time - time.time())
+            packets, bytestream = self.read()
+            self.stop_listening()
+            self.store_packets(packets, bytestream, message)
 
     def multi_write_configuration(self, chip_reg_pairs, write_read=0,
             message=None):
@@ -963,7 +980,7 @@ class Controller(object):
             message = 'multi configuration write'
         else:
             message = 'multi configuration write: ' + message
-        final_bytestream = []
+        packets = []
         for chip_reg_pair in chip_reg_pairs:
             if isinstance(chip_reg_pair, Chip):
                 chip_reg_pair = (chip_reg_pair, None)
@@ -974,17 +991,20 @@ class Controller(object):
                 registers = [registers]
             else:
                 pass
-            bytestreams = self.get_configuration_bytestreams(chip,
+            one_chip_packets = chip.get_configuration_packets(
                     Packet.CONFIG_WRITE_PACKET, registers)
-            final_bytestream += bytestreams
-        final_bytestream = self.format_bytestream(final_bytestream)
-        if write_read == 0:
-            self.serial_write(final_bytestream)
-        else:
-            miso_bytestream = self.serial_write_read(final_bytestream,
-                    timelimit=write_read)
-            packets = self.parse_input(miso_bytestream)
-            self.store_packets(packets, miso_bytestream, message)
+            packets.extend(one_chip_packets)
+        already_listening = self.io.is_listening
+        mess_with_listening = write_read != 0 and not already_listening
+        if mess_with_listening:
+            self.start_listening()
+            stop_time = time.time() + write_Read
+        self.send(packets)
+        if mess_with_listening:
+            time.sleep(stop_time - time.time())
+            packets, bytestream = self.read()
+            self.stop_listening()
+            self.store_packets(packets, data, message)
 
     def multi_read_configuration(self, chip_reg_pairs, timeout=1,
             message=None):
@@ -1015,7 +1035,7 @@ class Controller(object):
             message = 'multi configuration read'
         else:
             message = 'multi configuration read: ' + message
-        final_bytestream = []
+        packets = []
         for chip_reg_pair in chip_reg_pairs:
             if isinstance(chip_reg_pair, Chip):
                 chip_reg_pair = (chip_reg_pair, None)
@@ -1026,38 +1046,35 @@ class Controller(object):
                 registers = [registers]
             else:
                 pass
-            bytestreams = self.get_configuration_bytestreams(chip,
+            one_chip_packets = chip.get_configuration_packets(
                     Packet.CONFIG_READ_PACKET, registers)
-            final_bytestream += bytestreams
-        mosi_bytestream = self.format_bytestream(final_bytestream)
-        miso_bytestream = self.serial_write_read(mosi_bytestream,
-                timelimit=timeout)
-        packets = self.parse_input(miso_bytestream)
-        self.store_packets(packets, miso_bytestream, message)
-
-    def get_configuration_bytestreams(self, chip, packet_type, registers):
-        # The configuration must be sent one register at a time
-        configuration_packets = \
-            chip.get_configuration_packets(packet_type, registers);
-        formatted_packets = [self.format_UART(chip, p) for p in
-                configuration_packets]
-        bytestreams = self.format_bytestream(formatted_packets)
-        return bytestreams
+            packets += one_chip_packets
+        already_listening = self.io.is_listening
+        if not already_listening:
+            self.start_listening()
+            stop_time = time.time() + timeout
+        self.send(packets)
+        if not already_listening:
+            time.sleep(stop_time - time.time())
+            packets, bytestream = self.read()
+            self.stop_listening()
+            self.store_packets(packets, bytestream, message)
 
     def run(self, timelimit, message):
-        data = self.serial_read(timelimit)
-        packets = self.parse_input(data)
+        sleeptime = 0.1
+        self.start_listening()
+        start_time = time.time()
+        packets = []
+        bytestreams = []
+        while time.time() - start_time < timelimit:
+            time.sleep(sleeptime)
+            read_packets, read_bytestream = self.read()
+            packets.extend(read_packets)
+            bytestreams.append(read_bytestream)
+        self.stop_listening()
+        data = b''.join(bytestreams)
         self.store_packets(packets, data, message)
 
-    def run_testpulse(self, list_of_channels):
-        return
-
-    def run_fifo_test(self):
-        return
-
-    def run_analog_monitor_test(self):
-        return
-
     def verify_configuration(self, chip_id=None, io_chain=0):
         '''
         Read chip configuration from specified chip and return a bool that is True if the
@@ -1229,60 +1246,6 @@ class Controller(object):
             chip.config.enable_channels(channel_list)
             self.write_configuration(chip, Configuration.channel_mask_addresses)
 
-    def format_UART(self, chip, packet):
-        packet_bytes = packet.bytes()
-        daisy_chain_byte = bah.fromuint(chip.io_chain, 8).tobytes()
-        formatted_packet = (Controller.start_byte + packet_bytes +
-                daisy_chain_byte + Controller.stop_byte)
-        return formatted_packet
-
-    @classmethod
-    def parse_input(cls, bytestream):
-        packet_size = Configuration.fpga_packet_size  #vb
-        start_byte = Controller.start_byte[0]
-        stop_byte = Controller.stop_byte[0]
-        metadata_byte_index = 8
-        data_bytes = slice(1,8)
-        # parse the bytestream into Packets + metadata
-        byte_packets = []
-        skip_slices = []
-        #current_stream = bytestream
-        bytestream_len = len(bytestream)
-        last_possible_start = bytestream_len - packet_size
-        index = 0
-        while index <= last_possible_start:
-            if (bytestream[index] == start_byte and
-                    bytestream[index+packet_size-1] == stop_byte):
-                '''
-                metadata = current_stream[metadata_byte_index]
-                # This is necessary because of differences between
-                # Python 2 and Python 3
-                if isinstance(metadata, int):  # Python 3
-                    code = 'uint:8='
-                elif isinstance(metadata, str):  # Python 2
-                    code = 'bytes:1='
-                byte_packets.append((Bits(code + str(metadata)),
-                    Packet(current_stream[data_bytes])))
-                '''
-                byte_packets.append(Packet(bytestream[index+1:index+8]))
-                #current_stream = current_stream[packet_size:]
-                index += packet_size
-            else:
-                # Throw out everything between here and the next start byte.
-                # Note: start searching after byte 0 in case it's
-                # already a start byte
-                index = bytestream.find(start_byte, index+1)
-                if index == -1:
-                    index = bytestream_len
-                #if next_start_index != 0:
-                #        print('Warning: %d extra bytes in data stream!' %
-                #        (next_start_index+1))
-                #current_stream = current_stream[1:][next_start_index:]
-        #if len(current_stream) != 0:
-        #    print('Warning: %d extra bytes at end of data stream!' %
-        #          len(current_stream))
-        return byte_packets
-
     def store_packets(self, packets, data, message):
         '''
         Store the packets in ``self`` and in ``self.chips``
@@ -1294,7 +1257,6 @@ class Controller(object):
         self.reads.append(new_packets)
         #self.sort_packets(new_packets)
 
-
     def sort_packets(self, collection):
         '''
         Sort the packets in ``collection`` into each chip in
@@ -1311,19 +1273,6 @@ class Controller(object):
             elif not self._test_mode:
                 print('Warning chip id %d not in chips.' % chip_id)
 
-    def format_bytestream(self, formatted_packets):
-        bytestreams = []
-        current_bytestream = bytes()
-        for packet in formatted_packets:
-            if len(current_bytestream) + len(packet) <= self.max_write:  #vb
-                current_bytestream += packet
-            else:
-                bytestreams.append(current_bytestream)
-                current_bytestream = bytes()
-                current_bytestream += packet
-        bytestreams.append(current_bytestream)
-        return bytestreams
-
     def save_output(self, filename, message):
         '''Save the data read by each chip to the specified file.'''
         data = {}
@@ -1807,202 +1756,30 @@ class PacketCollection(object):
             to_return[chipid] = new_collection
         return to_return
 
+class FakeIO(object):
+    '''
+    An IO stand-in that sends output to stdout (i.e. print) and reads
+    input from a data member that can be set in advance.
 
-class SerialPort(object):
-    '''Wrapper for various serial port interfaces across platforms'''
-    # Guesses for default port name by platform
-    _default_port_map = {
-        'Default':['/dev/ttyUSB2','/dev/ttyUSB1'], # Same as Linux
-        'Linux':['/dev/ttyAMA0', '/dev/ttyUSB2','/dev/ttyUSB1',
-        '/dev/ttyUSB0'],   # Linux
-        'Darwin':['scan-ftdi',],     # OS X
-    }
-    _logger = None
-
-    def __init__(self, port=None, baudrate=9600, timeout=None):
-        self.port = port
-        self.resolved_port = ''
-        self.port_type = ''
-        self.baudrate = baudrate
-        self.timeout = timeout
-        self._keep_open = False
-        self.serial_com = None
-        self._initialize_serial_com()
-        self.logger = None
-        if not (self._logger is None):
-            self.logger = self._logger
-        return
-
-    @classmethod
-    def guess_port(cls):
-        '''Guess at correct port name based on platform'''
-        platform_default = 'Default'
-        platform_name = platform.system()
-        if platform_name not in cls._default_port_map:
-            platform_name = platform_default
-        default_devs = cls._default_port_map[platform_name]
-        osx_cmd = 'system_profiler SPUSBDataType | grep -C 7 FTDI | grep Serial'
-        for default_dev in default_devs:
-            if default_dev.startswith('/dev'): # pyserial
-                try:
-                    if os.stat(default_dev):
-                        return default_dev
-                except OSError:
-                    continue
-            elif default_dev == 'scan-ftdi':
-                if platform_name == 'Darwin':  # scan for pylibftdi on OS X
-                    # Scan for FTDI devices
-                    result = os.popen(osx_cmd).read()
-                    if len(result) > 0:
-                        idx = result.find('Serial Number:')
-                        dev_name = result[idx+14:idx+24].strip()
-                        print('Autoscan found FTDI device: "%s"' % dev_name)
-                        return dev_name
-            elif not default_dev.startswith('/dev'):  # assume pylibftdi
-                return default_dev
-        raise OSError('Cannot find serial device for platform: %s' %
-                      platform_name)
-
-    def _ready_port(self):
-        '''Function handle.  Will be reset to appropriate method'''
-        raise NotImplementedError('Serial port type has not been defined.')
-
-    def _ready_port_pyserial(self):
-        '''Ready a pyserial port'''
-        if self.serial_com is None:
-            # Create serial port
-            import serial
-            self.serial_com = serial.Serial(self.resolved_port,
-                                            baudrate=self.baudrate,
-                                            timeout=self.timeout)
-        if not self.serial_com.is_open:
-            # Open, if necessary
-            self.serial_com.open()
-        return
-
-    def _ready_port_pylibftdi(self):
-        '''Ready a pylibftdi port'''
-        if self.serial_com is None:
-            # Construct serial port
-            import pylibftdi
-            self.serial_com = pylibftdi.Device(self.resolved_port)
-        if self.serial_com.closed:
-            # Open port
-            self.serial_com.open()
-        # Confirm baudrate (Required for OS X)
-        self._confirm_baudrate()
-        return
-
-    def _ready_port_test(self):
-        if self.serial_com is None:
-            # Get FakeSerialPort from testing module
-            import test.test_larpix as test_lib
-            self.serial_com = test_lib.FakeSerialPort()
-        return
-
-    def _confirm_baudrate(self):
-        '''Check and set the baud rate'''
-        if self.serial_com.baudrate != self.baudrate:
-            # Reset baudrate
-            self.serial_com.baudrate = self.baudrate
-        return
+    '''
+    def __init__(self):
+        self.is_listening = False
+        self.queue = deque()
 
-    def _initialize_serial_com(self):
-        '''Initialize the low-level serial com connection'''
-        self.resolved_port = self._resolve_port_name()
-        self.port_type = self._resolve_port_type()
-        if self.port_type is 'pyserial':
-            self._ready_port = self._ready_port_pyserial
-            self._keep_open = False
-        elif self.port_type is 'pylibftdi':
-            self._ready_port = self._ready_port_pylibftdi
-            self._keep_open = True
-        elif self.port_type is 'test':
-            self._ready_port = self._ready_port_test
-            self._keep_open = True
-        else:
-            raise ValueError('Port type must be either pyserial, pylibftdi, or test')
-        return
+    def send(self, packets):
+        for packet in packets:
+            print(packet)
 
-    def _resolve_port_name(self):
-        '''Resolve the serial port name, based on user request'''
-        if self.port is None:
-            # Must set port
-            raise ValueError('You must choose a serial port for operation')
-        # FIXME: incorporate auto-scan feature
-        if self.port is 'auto':
-            # Try to guess the correct port
-            return self.guess_port()
-        # FIXME: incorporate list option?
-        #elif isinstance(self.port, list):
-        #    # Try to determine best choice from list
-        #    for port_name in list:
-        #        if self._port_exists(port_name):
-        #            return port_name
-        return self.port
-
-    def _resolve_port_type(self):
-        '''Resolve the type of serial port, based on the name'''
-        if self.resolved_port.startswith('/dev'):
-            # Looks like a tty device.  Use pyserial.
-            return 'pyserial'
-        elif self.resolved_port is 'test':
-            # Testing port. Don't use an external library
-            return 'test'
-        elif not self.resolved_port.startswith('/dev'):
-            # Looks like a libftdi raw device.  Use pylibftdi.
-            return 'pylibftdi'
-        raise ValueError('Unknown port: %s' % self.port)
-
-    def open(self):
-        '''Open the port'''
-        self._ready_port()
-        return
+    def start_listening(self):
+        self.is_listening = True
 
-    def close(self):
-        '''Close the port'''
-        if self.serial_com is None: return
-        self.serial_com.close()
-
-    def write(self, data):
-        '''Write data to serial port'''
-        self._ready_port()
-        write_time = time.time()
-        self.serial_com.write(data)
-        if self.logger:
-            self.logger.record({'data_type':'write','data':data,'time':write_time})
-        if not self._keep_open:
-            self.close()
-        return
+    def stop_listening(self):
+        self.is_listening = False
 
-    def read(self, nbytes):
-        '''Read data from serial port'''
-        self._ready_port()
-        read_time = time.time()
-        data = self.serial_com.read(nbytes)
-        if self.logger:
-            self.logger.record({'data_type':'read','data':data,'time':read_time})
-        if not self._keep_open:
-            self.close()
+    def empty_queue(self):
+        if not self.is_listening:
+            raise RuntimeError('Cannot empty queue when not'
+                    ' listening')
+        data = self.queue.popleft()
         return data
 
-def enable_logger(filename=None):
-    '''Enable serial data logger'''
-    if SerialPort._logger is None:
-        from larpix.datalogger import DataLogger
-        SerialPort._logger = DataLogger(filename)
-    if not SerialPort._logger.is_enabled():
-        SerialPort._logger.enable()
-    return
-
-def disable_logger():
-    '''Disable serial data logger'''
-    if SerialPort._logger is not None:
-        SerialPort._logger.disable()
-    return
-
-def flush_logger():
-    '''Flush serial data logger data to output file'''
-    if SerialPort._logger is not None:
-        SerialPort._logger.flush()
-    return
diff --git a/larpix/quickstart.py b/larpix/quickstart.py
index a3ceeb7..ae1e55f 100644
--- a/larpix/quickstart.py
+++ b/larpix/quickstart.py
@@ -4,6 +4,7 @@ Quickstart commands for test boards
 
 from __future__ import absolute_import
 import larpix.larpix as larpix
+from larpix.serialport import SerialPort
 from larpix.tasks import get_chip_ids
 
 ## For interactive mode
@@ -34,9 +35,11 @@ board_info_list = [
 #Create handy map by board name
 board_info_map = dict([(elem['name'],elem) for elem in board_info_list])
 
-def create_controller(timeout=0.01):
+def create_controller(timeout=0.01, io=None):
     '''Create a default controller'''
-    return larpix.Controller(timeout=timeout)
+    c = larpix.Controller(timeout=timeout)
+    c.io = io
+    return c
 
 def init_controller(controller, board='pcb-5'):
     '''Initialize controller'''
@@ -47,13 +50,13 @@ def init_controller(controller, board='pcb-5'):
         controller.chips.append( larpix.Chip(chip_info[0],chip_info[1]) )
     controller.board_info = board_info
     return controller
-        
+
 def silence_chips(controller, interactive):
     '''Silence all chips in controller'''
     #for _ in controller.chips:
     for chip in controller.chips:
         if interactive:
-	    print('Silencing chip %d' % chip.chip_id)
+            print('Silencing chip %d' % chip.chip_id)
         chip.config.global_threshold = 255
         controller.write_configuration(chip,32)
         if interactive:
@@ -103,10 +106,14 @@ def flush_stale_data(controller):
     controller.reads = []
     return
     
-def quickcontroller(board='pcb-1', interactive=False):
+def quickcontroller(board='pcb-1', interactive=False, io=None):
     '''Quick jump through all controller creation and config steps'''
+    if io is None:
+        port = SerialPort.guess_port()
+        io = SerialPort(port=port, baudrate=1000000,
+                timeout=0.01)
     larpix.enable_logger()
-    cont = create_controller()
+    cont = create_controller(io=io)
     init_controller(cont,board)
     silence_chips(cont, interactive)
     if cont.board_info['name'] == 'unknown':
diff --git a/larpix/serialport.py b/larpix/serialport.py
new file mode 100644
index 0000000..51ddd53
--- /dev/null
+++ b/larpix/serialport.py
@@ -0,0 +1,322 @@
+'''
+The serial port IO interface.
+
+'''
+from __future__ import absolute_import
+import time
+import os
+import platform
+from larpix.larpix import (Configuration, Packet)
+
+class SerialPort(object):
+    '''Wrapper for various serial port interfaces across platforms.
+
+       Automatically loads correct driver based on the supplied port
+       name:
+
+           - ``'/dev/anything'`` ==> Linux ==> pySerial
+           - ``'scan-ftdi'`` ==> MacOS ==> libFTDI
+           - ``('zmq', push_address, pull_address)`` ==> ZeroMQ
+    '''
+    # Guesses for default port name by platform
+    _default_port_map = {
+        'Default':['/dev/ttyUSB2','/dev/ttyUSB1'], # Same as Linux
+        'Linux':['/dev/ttyAMA0', '/dev/ttyUSB2','/dev/ttyUSB1',
+        '/dev/ttyUSB0'],   # Linux
+        'Darwin':['scan-ftdi',],     # OS X
+    }
+    _logger = None
+    start_byte = b'\x73'
+    stop_byte = b'\x71'
+    max_write = 250
+    def __init__(self, port=None, baudrate=9600, timeout=0):
+        self.port = port
+        self.resolved_port = ''
+        self.port_type = ''
+        self.baudrate = baudrate
+        self.timeout = timeout
+        self.max_write = 250
+        self.serial_com = None
+        self._initialize_serial_com()
+        self.is_listening = False
+        self.logger = None
+        if not (self._logger is None):
+            self.logger = self._logger
+        return
+
+    @staticmethod
+    def format_UART(packet):
+        packet_bytes = packet.bytes()
+        daisy_chain_byte = b'\x00'
+        formatted_packet = (SerialPort.start_byte + packet_bytes +
+                daisy_chain_byte + SerialPort.stop_byte)
+        return formatted_packet
+
+    @staticmethod
+    def parse_input(bytestream):
+        packet_size = Configuration.fpga_packet_size  #vb
+        start_byte = SerialPort.start_byte[0]
+        stop_byte = SerialPort.stop_byte[0]
+        metadata_byte_index = 8
+        data_bytes = slice(1,8)
+        # parse the bytestream into Packets + metadata
+        byte_packets = []
+        skip_slices = []
+        #current_stream = bytestream
+        bytestream_len = len(bytestream)
+        last_possible_start = bytestream_len - packet_size
+        index = 0
+        while index <= last_possible_start:
+            if (bytestream[index] == start_byte and
+                    bytestream[index+packet_size-1] == stop_byte):
+                '''
+                metadata = current_stream[metadata_byte_index]
+                # This is necessary because of differences between
+                # Python 2 and Python 3
+                if isinstance(metadata, int):  # Python 3
+                    code = 'uint:8='
+                elif isinstance(metadata, str):  # Python 2
+                    code = 'bytes:1='
+                byte_packets.append((Bits(code + str(metadata)),
+                    Packet(current_stream[data_bytes])))
+                '''
+                byte_packets.append(Packet(bytestream[index+1:index+8]))
+                #current_stream = current_stream[packet_size:]
+                index += packet_size
+            else:
+                # Throw out everything between here and the next start byte.
+                # Note: start searching after byte 0 in case it's
+                # already a start byte
+                index = bytestream.find(start_byte, index+1)
+                if index == -1:
+                    index = bytestream_len
+                #if next_start_index != 0:
+                #        print('Warning: %d extra bytes in data stream!' %
+                #        (next_start_index+1))
+                #current_stream = current_stream[1:][next_start_index:]
+        #if len(current_stream) != 0:
+        #    print('Warning: %d extra bytes at end of data stream!' %
+        #          len(current_stream))
+        return byte_packets
+
+    @staticmethod
+    def format_bytestream(formatted_packets):
+        bytestreams = []
+        current_bytestream = bytes()
+        for packet in formatted_packets:
+            if len(current_bytestream) + len(packet) <= SerialPort.max_write:  #vb
+                current_bytestream += packet
+            else:
+                bytestreams.append(current_bytestream)
+                current_bytestream = bytes()
+                current_bytestream += packet
+        bytestreams.append(current_bytestream)
+        return bytestreams
+
+    def send(self, packets):
+        '''
+        Format the packets as a bytestream and send it to the FPGA and on
+        to the LArPix ASICs.
+
+        '''
+        packet_bytes = [self.format_UART(p) for p in packets]
+        bytestreams = self.format_bytestream(packet_bytes)
+        for bytestream in bytestreams:
+            self.write(bytestream)
+
+    def start_listening(self):
+        self.open()
+        self.is_listening = True
+
+    def stop_listening(self, read):
+        if read:
+            data = self.empty_queue()
+        else:
+            data = None
+        self.close()
+        self.is_listening = False
+        return data
+
+    def empty_queue(self):
+        data_in = b''
+        keep_reading = True
+        while keep_reading:
+            new_data = self.read(self.max_write)
+            data_in += new_data
+            keep_reading = (len(new_data) == self.max_write)
+        packets = self.parse_input(data_in)
+        return (packets, data_in)
+
+    @classmethod
+    def guess_port(cls):
+        '''Guess at correct port name based on platform'''
+        platform_default = 'Default'
+        platform_name = platform.system()
+        if platform_name not in cls._default_port_map:
+            platform_name = platform_default
+        default_devs = cls._default_port_map[platform_name]
+        osx_cmd = 'system_profiler SPUSBDataType | grep -C 7 FTDI | grep Serial'
+        for default_dev in default_devs:
+            if default_dev.startswith('/dev'): # pyserial
+                try:
+                    if os.stat(default_dev):
+                        return default_dev
+                except OSError:
+                    continue
+            elif default_dev == 'scan-ftdi':
+                if platform_name == 'Darwin':  # scan for pylibftdi on OS X
+                    # Scan for FTDI devices
+                    result = os.popen(osx_cmd).read()
+                    if len(result) > 0:
+                        idx = result.find('Serial Number:')
+                        dev_name = result[idx+14:idx+24].strip()
+                        print('Autoscan found FTDI device: "%s"' % dev_name)
+                        return dev_name
+            elif not default_dev.startswith('/dev'):  # assume pylibftdi
+                return default_dev
+        raise OSError('Cannot find serial device for platform: %s' %
+                      platform_name)
+
+    def _ready_port(self):
+        '''Function handle.  Will be reset to appropriate method'''
+        raise NotImplementedError('Serial port type has not been defined.')
+
+    def _ready_port_pyserial(self):
+        '''Ready a pyserial port'''
+        if not self.serial_com.is_open:
+            # Open, if necessary
+            self.serial_com.open()
+        return
+
+    def _ready_port_pylibftdi(self):
+        '''Ready a pylibftdi port'''
+        if self.serial_com.closed:
+            # Open port
+            self.serial_com.open()
+        # Confirm baudrate (Required for OS X)
+        self._confirm_baudrate()
+        return
+
+    def _ready_port_test(self):
+        return True
+
+    def _ready_port_zmq(self):
+        return True
+
+    def _confirm_baudrate(self):
+        '''Check and set the baud rate'''
+        if self.serial_com.baudrate != self.baudrate:
+            # Reset baudrate
+            self.serial_com.baudrate = self.baudrate
+        return
+
+    def _initialize_serial_com(self):
+        '''Initialize the low-level serial com connection'''
+        self.resolved_port = self._resolve_port_name()
+        self.port_type = self._resolve_port_type()
+        if self.port_type is 'pyserial':
+            self._ready_port = self._ready_port_pyserial
+            import serial
+            self.serial_com = serial.Serial(self.resolved_port,
+                                            baudrate=self.baudrate,
+                                            timeout=self.timeout)
+        elif self.port_type is 'pylibftdi':
+            self._ready_port = self._ready_port_pylibftdi
+            import pylibftdi
+            self.serial_com = pylibftdi.Device(self.resolved_port)
+        elif self.port_type is 'test':
+            self._ready_port = self._ready_port_test
+            import test.test_larpix as test_lib
+            self.serial_com = test_lib.FakeSerialPort()
+        elif self.port_type is 'zmq':
+            self._ready_port = self._ready_port_zmq
+            from larpix.zmqcontroller import Serial_ZMQ
+            self.serial_com = Serial_ZMQ(self.port[1], self.timeout)
+        else:
+            raise ValueError('Port type must be either pyserial, pylibftdi, or test')
+        return
+
+    def _resolve_port_name(self):
+        '''Resolve the serial port name, based on user request'''
+        if self.port is None:
+            # Must set port
+            raise ValueError('You must choose a serial port for operation')
+        # FIXME: incorporate auto-scan feature
+        if self.port is 'auto':
+            # Try to guess the correct port
+            return self.guess_port()
+        # FIXME: incorporate list option?
+        #elif isinstance(self.port, list):
+        #    # Try to determine best choice from list
+        #    for port_name in list:
+        #        if self._port_exists(port_name):
+        #            return port_name
+        return self.port
+
+    def _resolve_port_type(self):
+        '''Resolve the type of serial port, based on the name'''
+        if isinstance(self.resolved_port, str):
+            if self.resolved_port.startswith('/dev'):
+                # Looks like a tty device.  Use pyserial.
+                return 'pyserial'
+            elif self.resolved_port is 'test':
+                # Testing port. Don't use an external library
+                return 'test'
+            elif not self.resolved_port.startswith('/dev'):
+                # Looks like a libftdi raw device.  Use pylibftdi.
+                return 'pylibftdi'
+        elif self.resolved_port[0] == 'zmq':
+            # ZeroMQ communication
+            return 'zmq'
+        raise ValueError('Unknown port: %s' % self.port)
+
+    def open(self):
+        '''Open the port'''
+        self._ready_port()
+        return
+
+    def close(self):
+        '''Close the port'''
+        if self.serial_com is None: return
+        self.serial_com.close()
+
+    def write(self, data):
+        '''Write data to serial port'''
+        self._ready_port()
+        write_time = time.time()
+        self.serial_com.write(data)
+        if not self.is_listening:
+            self.close()
+        if self.logger:
+            self.logger.record({'data_type':'write','data':data,'time':write_time})
+        return
+
+    def read(self, nbytes):
+        '''Read data from serial port'''
+        self._ready_port()
+        read_time = time.time()
+        data = self.serial_com.read(nbytes)
+        if self.logger:
+            self.logger.record({'data_type':'read','data':data,'time':read_time})
+        return data
+
+def enable_logger(filename=None):
+    '''Enable serial data logger'''
+    if SerialPort._logger is None:
+        from larpix.datalogger import DataLogger
+        SerialPort._logger = DataLogger(filename)
+    if not SerialPort._logger.is_enabled():
+        SerialPort._logger.enable()
+    return
+
+def disable_logger():
+    '''Disable serial data logger'''
+    if SerialPort._logger is not None:
+        SerialPort._logger.disable()
+    return
+
+def flush_logger():
+    '''Flush serial data logger data to output file'''
+    if SerialPort._logger is not None:
+        SerialPort._logger.flush()
+    return
diff --git a/larpix/zmq_io.py b/larpix/zmq_io.py
new file mode 100644
index 0000000..03f07a1
--- /dev/null
+++ b/larpix/zmq_io.py
@@ -0,0 +1,97 @@
+import time
+import zmq
+from .larpix import Packet
+
+class ZMQ_IO(object):
+    def __init__(self, address):
+        self.context = zmq.Context()
+        self.sender = self.context.socket(zmq.REQ)
+        self.receiver = self.context.socket(zmq.SUB)
+        send_address = address + ':5555'
+        receive_address = address + ':5556'
+        self.sender.connect(send_address)
+        self.receiver.connect(receive_address)
+        self.sender_replies = []
+        self.is_listening = False
+        self.poller = zmq.Poller()
+        self.poller.register(self.receiver, zmq.POLLIN)
+
+    def send(self, packets):
+        self.sender_replies = []
+        for packet in packets:
+            tosend = b'SNDWORD 0x00%s 0' % packet.bytes()[::-1].hex().encode()
+            self.sender.send(tosend)
+            self.sender_replies.append(self.sender.recv())
+
+    def start_listening(self):
+        if self.is_listening:
+            raise RuntimeError('Already listening')
+        self.is_listening = True
+        self.receiver.setsockopt(zmq.SUBSCRIBE, b'')
+
+    def stop_listening(self):
+        if not self.is_listening:
+            raise RuntimeError('Already not listening')
+        self.is_listening = False
+        self.receiver.setsockopt(zmq.UNSUBSCRIBE, b'')
+
+    def empty_queue(self):
+        packets = []
+        bytestream_list = []
+        while self.poller.poll(0):
+            message = self.receiver.recv()
+            bytestream_list.append(message)
+            if len(message) % 8 == 0:
+                for start_index in range(0, len(message), 8):
+                    packet_bytes = message[start_index:start_index+7]
+                    packets.append(Packet(packet_bytes))
+            bytestream = b''.join(bytestream_list)
+        return packets, bytestream
+
+    def reset(self):
+        '''
+        Send a reset pulse to the LArPix ASICs.
+
+        '''
+        self.sender.send(b'SYRESET')
+        return self.sender.recv()
+
+    def set_clock(self, freq_khz):
+        '''
+        Set the LArPix CLK2X freqency (in kHz).
+
+        '''
+        self.sender.send(b'SETFREQ %s' % hex(freq_khz).encode())
+        return self.sender.recv()
+
+    def set_testpulse_freq(self, divisor):
+        '''
+        Set the testpulse frequency, computed by dividing the CLK2X
+        frequency by ``divisor``.
+
+        '''
+        self.sender.send(b'SETFTST %s' % hex(divisor).encode())
+        return self.sender.recv()
+
+    def get_packet_count(self, io_channel):
+        '''
+        Get the number of packets received, as determined by the number
+        of UART "start" bits processed.
+
+        '''
+        self.sender.send(b'GETSTAT %d' % io_channel)
+        result = self.sender.recv()
+        space = result.find(b' ')
+        number = int(result[:space])
+        return number
+
+    def ping(self):
+        '''
+        Send a ping to the system and return True if the first two bytes
+        of the response are b'OK'.
+
+        '''
+        self.sender.send(b'PING_HB')
+        result = self.sender.recv()
+        return result[:2] == b'OK'
+

Implement TCP communication as alternative to serial comms using ZeroMQ
This will allow a remote control computer to interface with the new control hardware the Bern group plans to use. It seems like a good idea.
diff --git a/larpix/test_serial.py b/larpix/test_serial.py
index 8e1379a..ef8e4df 100644
--- a/larpix/test_serial.py
+++ b/larpix/test_serial.py
@@ -4,7 +4,7 @@ Test serial port interface
 
 from __future__ import absolute_import
 import pytest
-from larpix.larpix import SerialPort, enable_logger
+from larpix.serialport import SerialPort, enable_logger
 
 @pytest.mark.skip(reason='This is not a pytest file')
 def test_serial_loopback(port_name='auto', enable_logging=False):
diff --git a/test/test_larpix.py b/test/test_larpix.py
index e9c6836..f565cc1 100644
--- a/test/test_larpix.py
+++ b/test/test_larpix.py
@@ -5,7 +5,7 @@ Use the pytest framework to write tests for the larpix module.
 from __future__ import print_function
 import pytest
 from larpix.larpix import (Chip, Packet, Configuration, Controller,
-        PacketCollection, Smart_List)
+        PacketCollection, Smart_List, FakeIO)
 from larpix.Timestamp import *
 #from bitstring import BitArray
 from bitarray import bitarray
@@ -13,6 +13,9 @@ import larpix.bitarrayhelper as bah
 import json
 import os
 
+def list_of_packets_str(packets):
+    return '\n'.join(map(str, packets)) + '\n'
+
 class FakeSerialPort(object):
     '''
     This class implements the interface of the pyserial Serial class so
@@ -189,7 +192,7 @@ def test_chip_export_reads_all():
     assert chip.new_reads_index == 1
 
 def test_controller_save_output(tmpdir):
-    controller = Controller(port='test')
+    controller = Controller()
     chip = Chip(1, 0)
     p = Packet()
     chip.reads.append(p)
@@ -218,7 +221,7 @@ def test_controller_save_output(tmpdir):
 
 @pytest.mark.skip
 def test_controller_load(tmpdir):
-    controller = Controller(port='test')
+    controller = Controller()
     chip = Chip(1, 0)
     p = Packet()
     p.chipid = 1
@@ -229,7 +232,7 @@ def test_controller_load(tmpdir):
     name = str(tmpdir.join('test.json'))
     expected_message = 'this is a test'
     controller.save_output(name, expected_message)
-    new_controller = Controller(port='test')
+    new_controller = Controller()
     result_message = new_controller.load(name)
     assert result_message == expected_message
     assert new_controller.reads == controller.reads
@@ -1501,26 +1504,26 @@ def test_configuration_from_dict_reg_reset_cycles():
     assert result == expected
 
 def test_controller_init_chips():
-    controller = Controller(port='test')
+    controller = Controller()
     result = list(map(repr, controller._init_chips()))
     expected = list(map(repr, (Chip(i, 0) for i in range(256))))
     assert result == expected
 
 def test_controller_get_chip():
-    controller = Controller(port='test')
+    controller = Controller()
     chip = Chip(1, 3)
     controller.chips.append(chip)
     assert controller.get_chip(1, 3) == chip
 
 def test_controller_get_chip_all_chips():
-    controller = Controller(port='test')
+    controller = Controller()
     controller.use_all_chips = True
     result = controller.get_chip(5, 0)
     expected = controller.all_chips[5]
     assert result == expected
 
 def test_controller_get_chip_error():
-    controller = Controller(port='test')
+    controller = Controller()
     chip = Chip(1, 3)
     controller.chips.append(chip)
     with pytest.raises(ValueError, message='Should fail: bad chipid'):
@@ -1528,259 +1531,163 @@ def test_controller_get_chip_error():
     with pytest.raises(ValueError, message='Should fail: bad chainid'):
         controller.get_chip(1, 1)
 
-def test_controller_serial_read_mock():
-    controller = Controller(port='test')
-    FakeSerialPort.data_to_mock_read = bytes(bytearray(10))
-    result = controller.serial_read(0.1)
-    expected = bytes(bytearray(10))
-    assert result == expected
-
-def test_controller_serial_write_mock(capfd):
-    controller = Controller(port='test')
-    to_write = [b's12345678q', b's87654321q',]
-    controller.serial_write(to_write)
+def test_controller_read():
+    controller = Controller()
+    controller.io = FakeIO()
+    controller.io.queue.append(([Packet()], b'\x00\x00'))
+    controller.start_listening()
+    result = controller.read()
+    controller.stop_listening()
+    expected =([Packet()], b'\x00\x00')
+    assert result == expected
+
+def test_controller_send(capfd):
+    controller = Controller()
+    controller.io = FakeIO()
+    to_send = [Packet(b'1234567'), Packet(b'abcdefg')]
+    controller.send(to_send)
     result, err = capfd.readouterr()
-    expected = ' END'.join(map(bytes2str, to_write)) + ' END'
-    assert result == expected
-
-def test_controller_serial_write_read_mock(capfd):
-    controller = Controller(port='test')
-
-    to_write = [b's12345678q', b's9862983aq']
-    FakeSerialPort.data_to_mock_read = bytes(bytearray(range(256)))
-    read_result = controller.serial_write_read(to_write, 0.1)
-    write_result, err = capfd.readouterr()
-    read_expected = bytes(bytearray(range(256)))
-    write_expected = ' END'.join(map(bytes2str, to_write)) + ' END'
-    assert read_result == read_expected
-    assert write_result == write_expected
-
-def test_controller_format_UART():
-    controller = Controller(port='test')
-    chip = Chip(2, 4)
-    packet = chip.get_configuration_packets(Packet.CONFIG_READ_PACKET)[10]
-    result = controller.format_UART(chip, packet)
-    expected = b'\x73' + packet.bytes() + b'\x04\x71'
-    assert result == expected
-
-def test_controller_format_bytestream():
-    controller = Controller(port='test')
-    chip = Chip(2, 4)
-    packets = chip.get_configuration_packets(Packet.CONFIG_READ_PACKET)
-    fpackets = [controller.format_UART(chip, p) for p in packets]
-    result = controller.format_bytestream(fpackets[:1])
-    assert result == fpackets[:1]
-    result = controller.format_bytestream(fpackets[:2])
-    assert result == [b''.join(fpackets[:2])]
-    test_total_packets = 2000
-    result = controller.format_bytestream(fpackets[:1]*test_total_packets)
-    expected = []
-    total_packets = test_total_packets
-    while total_packets >= int(controller.max_write/Configuration.fpga_packet_size):
-        expected.append(b''.join(fpackets[:1]*int(controller.max_write/Configuration.fpga_packet_size)))
-        total_packets -= int(controller.max_write/Configuration.fpga_packet_size)
-    if total_packets > 0:
-        expected.append(b''.join(fpackets[:1]*int(total_packets)))
+    expected = list_of_packets_str(to_send)
     assert result == expected
 
 
 def test_controller_read_configuration(capfd):
-    controller = Controller(port='test')
-    controller._test_mode = True
-    chip = Chip(2, 4)
+    controller = Controller()
+    controller.io = FakeIO()
+    chip = Chip(2, 0)
     conf_data = chip.get_configuration_packets(Packet.CONFIG_READ_PACKET)
-    expected_bytes = b''.join(controller.format_UART(chip, conf_data_i) for
-            conf_data_i in conf_data)
-    expected = packets2mock_serialstream(controller, chip, conf_data)
-    FakeSerialPort.data_to_mock_read = expected_bytes
+    sent_expected = list_of_packets_str(conf_data)
+    controller.io.queue.append((conf_data,b'hi'))
+    received_expected = PacketCollection(conf_data, b'hi', read_id=0,
+            message='configuration read')
     controller.read_configuration(chip)
-    result, err = capfd.readouterr()
-    assert result == expected
+    received_result = controller.reads[-1]
+    sent_result, err = capfd.readouterr()
+    assert sent_result == sent_expected
+    assert received_result == received_expected
+
 
 def test_controller_read_configuration_reg(capfd):
-    controller = Controller(port='test')
-    controller._test_mode = True
-    chip = Chip(2, 4)
-    conf_data = chip.get_configuration_packets(Packet.CONFIG_READ_PACKET)[0]
-    expected_bytes = controller.format_UART(chip, conf_data)
-    expected = bytes2str(controller.format_UART(chip, conf_data)) + ' END'
-    FakeSerialPort.data_to_mock_read = expected_bytes
+    controller = Controller()
+    controller.io = FakeIO()
+    chip = Chip(2, 0)
+    conf_data = chip.get_configuration_packets(Packet.CONFIG_READ_PACKET)[0:1]
+    sent_expected = list_of_packets_str(conf_data)
+    controller.io.queue.append((conf_data,b'hi'))
+    received_expected = PacketCollection(conf_data, b'hi', read_id=0,
+            message='configuration read')
     controller.read_configuration(chip, 0)
-    result, err = capfd.readouterr()
-    assert result == expected
+    received_result = controller.reads[-1]
+    sent_result, err = capfd.readouterr()
+    assert sent_result == sent_expected
+    assert received_result == received_expected
 
 def test_controller_write_configuration(capfd):
-    controller = Controller(port='test')
-    controller._test_mode = True
-    chip = Chip(2, 4)
+    controller = Controller()
+    controller.io = FakeIO()
+    chip = Chip(2, 0)
     controller.write_configuration(chip)
     conf_data = chip.get_configuration_packets(Packet.CONFIG_WRITE_PACKET)
-    expected = packets2mock_serialstream(controller, chip, conf_data)
+    expected = list_of_packets_str(conf_data)
     result, err = capfd.readouterr()
     assert result == expected
 
 def test_controller_write_configuration_one_reg(capfd):
-    controller = Controller(port='test')
-    controller._test_mode = True
-    chip = Chip(2, 4)
+    controller = Controller()
+    controller.io = FakeIO()
+    chip = Chip(2, 0)
     controller.write_configuration(chip, 0)
-    conf_data = chip.get_configuration_packets(Packet.CONFIG_WRITE_PACKET)[0]
-    expected = bytes2str(controller.format_UART(chip, conf_data)) + ' END'
+    conf_data = chip.get_configuration_packets(Packet.CONFIG_WRITE_PACKET)[0:1]
+    expected = list_of_packets_str(conf_data)
     result, err = capfd.readouterr()
     assert result == expected
 
 def test_controller_write_configuration_write_read(capfd):
-    controller = Controller(port='test')
-    controller.timeout=0.01
+    controller = Controller()
+    controller.io = FakeIO()
     chip = Chip(2, 0)
     controller.chips.append(chip)
-    to_read = b's\x08\x0034567\x00q'
-    FakeSerialPort.data_to_mock_read = to_read
+    to_read = ([Packet(b'1234567')], b'hi')
+    controller.io.queue.append(to_read)
+    expected_read = PacketCollection(*to_read, read_id=0,
+            message='configuration write')
     controller.write_configuration(chip, registers=5, write_read=0.1)
-    assert controller.reads[0].bytestream == to_read
-    conf_data = chip.get_configuration_packets(Packet.CONFIG_WRITE_PACKET)[5]
-    expected = bytes2str(controller.format_UART(chip, conf_data)) + ' END'
-    result, err = capfd.readouterr()
-    assert result == expected
+    result_read = controller.reads[0]
+    assert result_read == expected_read
+    conf_data = chip.get_configuration_packets(Packet.CONFIG_WRITE_PACKET)[5:6]
+    expected_sent = list_of_packets_str(conf_data)
+    result_sent, err = capfd.readouterr()
+    assert result_sent == expected_sent
 
 def test_controller_multi_write_configuration(capfd):
-    controller = Controller(port='test')
-    controller._test_mode = True
-    chip = Chip(2, 4)
-    chip2 = Chip(3, 4)
+    controller = Controller()
+    controller.io = FakeIO()
+    chip = Chip(2, 0)
+    chip2 = Chip(3, 0)
     controller.multi_write_configuration((chip, chip2))
     conf_data = chip.get_configuration_packets(Packet.CONFIG_WRITE_PACKET)
     conf_data2 = chip2.get_configuration_packets(Packet.CONFIG_WRITE_PACKET)
-    final_string_1 = packets2mock_serialstream(controller, chip, conf_data)
-    final_string_2 = packets2mock_serialstream(controller, chip2, conf_data2)
+    final_string_1 = list_of_packets_str(conf_data)
+    final_string_2 = list_of_packets_str(conf_data2)
     expected = final_string_1 + final_string_2
     result, err = capfd.readouterr()
     assert result == expected
 
 def test_controller_multi_write_configuration_specify_registers(capfd):
-    controller = Controller(port='test')
-    controller._test_mode = True
-    chip = Chip(2, 4)
-    chip2 = Chip(3, 4)
+    controller = Controller()
+    controller.io = FakeIO()
+    chip = Chip(2, 0)
+    chip2 = Chip(3, 0)
     controller.multi_write_configuration([(chip, 0), chip2])
     conf_data = chip.get_configuration_packets(Packet.CONFIG_WRITE_PACKET)[:1]
     conf_data2 = chip2.get_configuration_packets(Packet.CONFIG_WRITE_PACKET)
-    final_string_1 = packets2mock_serialstream(controller, chip, conf_data)
-    final_string_2 = packets2mock_serialstream(controller, chip2, conf_data2)
+    final_string_1 = list_of_packets_str(conf_data)
+    final_string_2 = list_of_packets_str(conf_data2)
     expected = final_string_1 + final_string_2
     result, err = capfd.readouterr()
     assert result == expected
 
 
 def test_controller_multi_read_configuration(capfd):
-    controller = Controller(port='test')
+    controller = Controller()
+    controller.io = FakeIO()
     controller.use_all_chips = True
-    controller._test_mode = True
-    chip = Chip(2, 4)
-    chip2 = Chip(3, 4)
-    controller.multi_read_configuration((chip, chip2))
+    chip = Chip(2, 0)
+    chip2 = Chip(3, 0)
     conf_data = chip.get_configuration_packets(Packet.CONFIG_READ_PACKET)
     conf_data2 = chip2.get_configuration_packets(Packet.CONFIG_READ_PACKET)
-    final_string_1 = packets2mock_serialstream(controller, chip, conf_data)
-    final_string_2 = packets2mock_serialstream(controller, chip2, conf_data2)
-    expected = final_string_1 + final_string_2
-    result, err = capfd.readouterr()
-    assert result == expected
+    final_string_1 = list_of_packets_str(conf_data)
+    final_string_2 = list_of_packets_str(conf_data2)
+    expected_sent = final_string_1 + final_string_2
+    controller.io.queue.append((conf_data+conf_data2, b'hi'))
+    expected_read = PacketCollection(conf_data+conf_data2, b'hi',
+            read_id=0, message='multi configuration read')
+    controller.multi_read_configuration((chip, chip2))
+    result_sent, err = capfd.readouterr()
+    result_read = controller.reads[-1]
+    assert result_sent == expected_sent
+    assert result_read == expected_read
 
 def test_controller_multi_read_configuration_specify_registers(capfd):
-    controller = Controller(port='test')
+    controller = Controller()
+    controller.io = FakeIO()
     controller.use_all_chips = True
-    controller._test_mode = True
     chip = Chip(2, 4)
     chip2 = Chip(3, 4)
-    controller.multi_read_configuration([(chip, 0), chip2])
     conf_data = chip.get_configuration_packets(Packet.CONFIG_READ_PACKET)[:1]
     conf_data2 = chip2.get_configuration_packets(Packet.CONFIG_READ_PACKET)
-    final_string_1 = packets2mock_serialstream(controller, chip, conf_data)
-    final_string_2 = packets2mock_serialstream(controller, chip2, conf_data2)
-    expected = final_string_1 + final_string_2
-    result, err = capfd.readouterr()
-    assert result == expected
-
-def test_controller_get_configuration_bytestreams():
-    controller = Controller(port='test')
-    chip = Chip(0, 0)
-    controller.chips.append(chip)
-    result = controller.get_configuration_bytestreams(chip,
-            Packet.CONFIG_WRITE_PACKET, [1, 0])
-    all_packets = chip.get_configuration_packets(Packet.CONFIG_WRITE_PACKET)
-    bytestream_reg_0 = controller.format_UART(chip, all_packets[0])
-    bytestream_reg_1 = controller.format_UART(chip, all_packets[1])
-    expected = [bytestream_reg_0 + bytestream_reg_1]
-    assert result == expected
-
-def test_controller_parse_input():
-    controller = Controller(port='test')
-    chip = Chip(2, 4)
-    controller.chips.append(chip)
-    packets = chip.get_configuration_packets(Packet.CONFIG_READ_PACKET)
-    fpackets = [controller.format_UART(chip, p) for p in packets]
-    bytestream = b''.join(controller.format_bytestream(fpackets))
-    result = controller.parse_input(bytestream)
-    expected = packets
-    assert result == expected
-
-def test_controller_parse_input_dropped_data_byte():
-    # Test whether the parser can recover from dropped bytes
-    controller = Controller(port='test')
-    chip = Chip(2, 4)
-    controller.chips.append(chip)
-    packets = chip.get_configuration_packets(Packet.CONFIG_READ_PACKET)
-    fpackets = [controller.format_UART(chip, p) for p in packets]
-    bytestream = b''.join(controller.format_bytestream(fpackets))
-    # Drop a byte in the first packet
-    bytestream_faulty = bytestream[:5] + bytestream[6:]
-    result = controller.parse_input(bytestream_faulty)
-    #skipped = [(slice(0, 9), bytestream_faulty[0:9])]
-    expected = packets[1:]
-    assert result == expected
-
-def test_controller_parse_input_dropped_start_byte():
-    controller = Controller(port='test')
-    chip = Chip(2, 4)
-    controller.chips.append(chip)
-    packets = chip.get_configuration_packets(Packet.CONFIG_READ_PACKET)
-    fpackets = [controller.format_UART(chip, p) for p in packets]
-    bytestream = b''.join(controller.format_bytestream(fpackets))
-    # Drop the first start byte
-    bytestream_faulty = bytestream[1:]
-    #skipped = [(slice(0, 9), bytestream_faulty[0:9])]
-    result = controller.parse_input(bytestream_faulty)
-    expected = packets[1:]
-    assert result == expected
-
-def test_controller_parse_input_dropped_stop_byte():
-    controller = Controller(port='test')
-    chip = Chip(2, 4)
-    controller.chips.append(chip)
-    packets = chip.get_configuration_packets(Packet.CONFIG_READ_PACKET)
-    fpackets = [controller.format_UART(chip, p) for p in packets]
-    bytestream = b''.join(controller.format_bytestream(fpackets))
-    # Drop the first stop byte
-    bytestream_faulty = bytestream[:9] + bytestream[10:]
-    #skipped = [(slice(0, 9), bytestream_faulty[0:9])]
-    result = controller.parse_input(bytestream_faulty)
-    expected = packets[1:]
-    assert result == expected
+    final_string_1 = list_of_packets_str(conf_data)
+    final_string_2 = list_of_packets_str(conf_data2)
+    expected_sent = final_string_1 + final_string_2
+    controller.io.queue.append((conf_data+conf_data2, b'hi'))
+    expected_read = PacketCollection(conf_data+conf_data2, b'hi',
+            read_id=0, message='multi configuration read')
+    controller.multi_read_configuration([(chip, 0), chip2])
+    result_sent, err = capfd.readouterr()
+    result_read = controller.reads[-1]
+    assert result_sent == expected_sent
+    assert result_read == expected_read
 
-def test_controller_parse_input_dropped_stopstart_bytes():
-    controller = Controller(port='test')
-    chip = Chip(2, 4)
-    controller.chips.append(chip)
-    packets = chip.get_configuration_packets(Packet.CONFIG_READ_PACKET)
-    fpackets = [controller.format_UART(chip, p) for p in packets]
-    bytestream = b''.join(controller.format_bytestream(fpackets))
-    # Drop the first stop byte
-    bytestream_faulty = bytestream[:9] + bytestream[11:]
-    #skipped = [(slice(0, 18), bytestream_faulty[:18])]
-    result = controller.parse_input(bytestream_faulty)
-    expected = packets[2:]
-    assert result == expected
 
 def test_packetcollection_getitem_int():
     expected = Packet()
diff --git a/test/test_serialport.py b/test/test_serialport.py
new file mode 100644
index 0000000..4d69e96
--- /dev/null
+++ b/test/test_serialport.py
@@ -0,0 +1,93 @@
+'''
+Tests for parsing and formatting for the SerialPort IO interface.
+
+'''
+from __future__ import print_function
+import pytest
+from larpix.serialport import SerialPort
+from larpix.larpix import (Chip, Packet, Configuration)
+
+def test_serialport_format_UART():
+    chip = Chip(2, 0)
+    packet = chip.get_configuration_packets(Packet.CONFIG_READ_PACKET)[10]
+    result = SerialPort.format_UART(packet)
+    expected = b'\x73' + packet.bytes() + b'\x00\x71'
+    assert result == expected
+
+def test_serialport_format_bytestream():
+    chip = Chip(2, 0)
+    packets = chip.get_configuration_packets(Packet.CONFIG_READ_PACKET)
+    fpackets = [SerialPort.format_UART(p) for p in packets]
+    result = SerialPort.format_bytestream(fpackets[:1])
+    assert result == fpackets[:1]
+    result = SerialPort.format_bytestream(fpackets[:2])
+    assert result == [b''.join(fpackets[:2])]
+    test_total_packets = 2000
+    result = SerialPort.format_bytestream(fpackets[:1]*test_total_packets)
+    expected = []
+    total_packets = test_total_packets
+    while total_packets >= int(SerialPort.max_write/Configuration.fpga_packet_size):
+        expected.append(b''.join(fpackets[:1]*int(SerialPort.max_write/Configuration.fpga_packet_size)))
+        total_packets -= int(SerialPort.max_write/Configuration.fpga_packet_size)
+    if total_packets > 0:
+        expected.append(b''.join(fpackets[:1]*int(total_packets)))
+    assert result == expected
+
+def test_serialport_parse_input():
+    chip = Chip(2, 0)
+    packets = chip.get_configuration_packets(Packet.CONFIG_READ_PACKET)
+    fpackets = [SerialPort.format_UART(p) for p in packets]
+    bytestream = b''.join(SerialPort.format_bytestream(fpackets))
+    result = SerialPort.parse_input(bytestream)
+    expected = packets
+    assert result == expected
+
+def test_serialport_parse_input_dropped_data_byte():
+    # Test whether the parser can recover from dropped bytes
+    chip = Chip(2, 0)
+    packets = chip.get_configuration_packets(Packet.CONFIG_READ_PACKET)
+    fpackets = [SerialPort.format_UART(p) for p in packets]
+    bytestream = b''.join(SerialPort.format_bytestream(fpackets))
+    # Drop a byte in the first packet
+    bytestream_faulty = bytestream[:5] + bytestream[6:]
+    result = SerialPort.parse_input(bytestream_faulty)
+    #skipped = [(slice(0, 9), bytestream_faulty[0:9])]
+    expected = packets[1:]
+    assert result == expected
+
+def test_serialport_parse_input_dropped_start_byte():
+    chip = Chip(2, 2)
+    packets = chip.get_configuration_packets(Packet.CONFIG_READ_PACKET)
+    fpackets = [SerialPort.format_UART(p) for p in packets]
+    bytestream = b''.join(SerialPort.format_bytestream(fpackets))
+    # Drop the first start byte
+    bytestream_faulty = bytestream[1:]
+    #skipped = [(slice(0, 9), bytestream_faulty[0:9])]
+    result = SerialPort.parse_input(bytestream_faulty)
+    expected = packets[1:]
+    assert result == expected
+
+def test_serialport_parse_input_dropped_stop_byte():
+    chip = Chip(2, 2)
+    packets = chip.get_configuration_packets(Packet.CONFIG_READ_PACKET)
+    fpackets = [SerialPort.format_UART(p) for p in packets]
+    bytestream = b''.join(SerialPort.format_bytestream(fpackets))
+    # Drop the first stop byte
+    bytestream_faulty = bytestream[:9] + bytestream[10:]
+    #skipped = [(slice(0, 9), bytestream_faulty[0:9])]
+    result = SerialPort.parse_input(bytestream_faulty)
+    expected = packets[1:]
+    assert result == expected
+
+def test_serialport_parse_input_dropped_stopstart_bytes():
+    chip = Chip(2, 2)
+    packets = chip.get_configuration_packets(Packet.CONFIG_READ_PACKET)
+    fpackets = [SerialPort.format_UART(p) for p in packets]
+    bytestream = b''.join(SerialPort.format_bytestream(fpackets))
+    # Drop the first stop byte
+    bytestream_faulty = bytestream[:9] + bytestream[11:]
+    #skipped = [(slice(0, 18), bytestream_faulty[:18])]
+    result = SerialPort.parse_input(bytestream_faulty)
+    expected = packets[2:]
+    assert result == expected
+
diff --git a/test/test_simulation.py b/test/test_simulation.py
index 3fbdb7d..313a22c 100644
--- a/test/test_simulation.py
+++ b/test/test_simulation.py
@@ -8,6 +8,7 @@ is tested.
 '''
 from __future__ import print_function
 import pytest
+pytestmark = pytest.mark.skip('skipping old simulation module...deal with it later')
 from collections import deque
 from larpix.larpix import (Packet)
 from larpix.simulation import (MockLArPix, MockSerial, MockFormatter)

pip install -e .
parse_log_pytest
pytest
pytest
3.9
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
bitarray==3.3.0
bitstring==4.3.1
chardet==5.2.0
exceptiongroup @ file:///croot/exceptiongroup_1706031385326/work
fire==0.7.0
iniconfig @ file:///home/linux1/recipes/ci/iniconfig_1610983019677/work
-e git+https://github.com/samkohn/larpix-control.git@3bafa2f4671eac976a497f3c2f9a70d007463935#egg=larpix_control
larpix-geometry==0.4.0
packaging @ file:///croot/packaging_1734472117206/work
pillow==11.1.0
pluggy @ file:///croot/pluggy_1733169602837/work
pyserial==3.5
pytest @ file:///croot/pytest_1738938843180/work
PyYAML==6.0.2
reportlab==4.3.1
termcolor==2.5.0
tomli @ file:///opt/conda/conda-bld/tomli_1657175507142/work

name: larpix-control
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - exceptiongroup=1.2.0=py39h06a4308_0
  - iniconfig=1.1.1=pyhd3eb1b0_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - packaging=24.2=py39h06a4308_0
  - pip=25.0=py39h06a4308_0
  - pluggy=1.5.0=py39h06a4308_0
  - pytest=8.3.4=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tomli=2.0.1=py39h06a4308_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - bitarray==3.3.0
      - bitstring==4.3.1
      - chardet==5.2.0
      - fire==0.7.0
      - larpix-geometry==0.4.0
      - pillow==11.1.0
      - pyserial==3.5
      - pyyaml==6.0.2
      - reportlab==4.3.1
      - termcolor==2.5.0
prefix: /opt/conda/envs/larpix-control

test/test_larpix.py::test_FakeSerialPort_write test/test_larpix.py::test_FakeSerialPort_read test/test_larpix.py::test_FakeSerialPort_read_multi test/test_larpix.py::test_chip_str test/test_larpix.py::test_chip_get_configuration_packets test/test_larpix.py::test_chip_sync_configuration test/test_larpix.py::test_chip_export_reads test/test_larpix.py::test_chip_export_reads_no_new_reads test/test_larpix.py::test_chip_export_reads_all test/test_larpix.py::test_controller_save_output test/test_larpix.py::test_packet_bits_bytes test/test_larpix.py::test_packet_init_default test/test_larpix.py::test_packet_init_bytestream test/test_larpix.py::test_packet_bytes_zeros test/test_larpix.py::test_packet_bytes_custom test/test_larpix.py::test_packet_bytes_properties test/test_larpix.py::test_packet_export_test test/test_larpix.py::test_packet_export_data test/test_larpix.py::test_packet_export_config_read test/test_larpix.py::test_packet_export_config_write test/test_larpix.py::test_packet_set_packet_type test/test_larpix.py::test_packet_get_packet_type test/test_larpix.py::test_packet_set_chipid test/test_larpix.py::test_packet_get_chipid test/test_larpix.py::test_packet_set_parity_bit_value test/test_larpix.py::test_packet_get_parity_bit_value test/test_larpix.py::test_packet_compute_parity test/test_larpix.py::test_packet_assign_parity test/test_larpix.py::test_packet_has_valid_parity test/test_larpix.py::test_packet_set_channel_id test/test_larpix.py::test_packet_get_channel_id test/test_larpix.py::test_packet_set_timestamp test/test_larpix.py::test_packet_get_timestamp test/test_larpix.py::test_packet_set_dataword test/test_larpix.py::test_packet_get_dataword test/test_larpix.py::test_packet_get_dataword_ADC_bug test/test_larpix.py::test_packet_set_fifo_half_flag test/test_larpix.py::test_packet_get_fifo_half_flag test/test_larpix.py::test_packet_set_fifo_full_flag test/test_larpix.py::test_packet_get_fifo_full_flag test/test_larpix.py::test_packet_set_register_address test/test_larpix.py::test_packet_get_register_address test/test_larpix.py::test_packet_set_register_data test/test_larpix.py::test_packet_get_register_data test/test_larpix.py::test_packet_set_test_counter test/test_larpix.py::test_packet_get_test_counter test/test_larpix.py::test_configuration_no_error_on_known_register_name test/test_larpix.py::test_configuration_no_error_on_underscore test/test_larpix.py::test_configuration_no_error_on_hasattr test/test_larpix.py::test_configuration_get_nondefault_registers test/test_larpix.py::test_configuration_get_nondefault_registers_array test/test_larpix.py::test_configuration_get_nondefault_registers_many_changes test/test_larpix.py::test_configuration_set_pixel_trim_thresholds test/test_larpix.py::test_configuration_get_pixel_trim_thresholds test/test_larpix.py::test_configuration_set_global_threshold test/test_larpix.py::test_configuration_get_global_threshold test/test_larpix.py::test_configuration_set_csa_gain test/test_larpix.py::test_configuration_get_csa_gain test/test_larpix.py::test_configuration_set_csa_bypass test/test_larpix.py::test_configuration_get_csa_bypass test/test_larpix.py::test_configuration_set_internal_bypass test/test_larpix.py::test_configuration_get_internal_bypass test/test_larpix.py::test_configuration_set_csa_bypass_select test/test_larpix.py::test_configuration_get_csa_bypass_select test/test_larpix.py::test_configuration_set_csa_monitor_select test/test_larpix.py::test_configuration_get_csa_monitor_select test/test_larpix.py::test_configuration_set_csa_testpulse_enable test/test_larpix.py::test_configuration_get_csa_testpulse_enable test/test_larpix.py::test_configuration_set_csa_testpulse_dac_amplitude test/test_larpix.py::test_configuration_get_csa_testpulse_dac_amplitude test/test_larpix.py::test_configuration_set_test_mode test/test_larpix.py::test_configuration_get_test_mode test/test_larpix.py::test_configuration_set_cross_trigger_mode test/test_larpix.py::test_configuration_get_cross_trigger_mode test/test_larpix.py::test_configuration_set_periodic_reset test/test_larpix.py::test_configuration_get_periodic_reset test/test_larpix.py::test_configuration_set_fifo_diagnostic test/test_larpix.py::test_configuration_get_fifo_diagnostic test/test_larpix.py::test_configuration_set_test_burst_length test/test_larpix.py::test_configuration_get_test_burst_length test/test_larpix.py::test_configuration_set_adc_burst_length test/test_larpix.py::test_configuration_get_adc_burst_length test/test_larpix.py::test_configuration_set_channel_mask test/test_larpix.py::test_configuration_get_channel_mask test/test_larpix.py::test_configuration_set_external_trigger_mask test/test_larpix.py::test_configuration_get_external_trigger_mask test/test_larpix.py::test_configuration_set_reset_cycles test/test_larpix.py::test_configuration_get_reset_cycles test/test_larpix.py::test_configuration_disable_channels test/test_larpix.py::test_configuration_disable_channels_default test/test_larpix.py::test_configuration_enable_channels test/test_larpix.py::test_configuration_enable_channels_default test/test_larpix.py::test_configuration_enable_external_trigger test/test_larpix.py::test_configuration_enable_external_trigger_default test/test_larpix.py::test_configuration_disable_external_trigger test/test_larpix.py::test_configuration_enable_testpulse test/test_larpix.py::test_configuration_enable_testpulse_default test/test_larpix.py::test_configuration_disable_testpulse test/test_larpix.py::test_configuration_disable_testpulse_default test/test_larpix.py::test_configuration_enable_analog_monitor test/test_larpix.py::test_configuration_disable_analog_monitor test/test_larpix.py::test_configuration_trim_threshold_data test/test_larpix.py::test_configuration_global_threshold_data test/test_larpix.py::test_configuration_csa_gain_and_bypasses_data test/test_larpix.py::test_configuration_csa_bypass_select_data test/test_larpix.py::test_configuration_csa_monitor_select_data test/test_larpix.py::test_configuration_csa_testpulse_enable_data test/test_larpix.py::test_configuration_csa_testpulse_dac_amplitude_data test/test_larpix.py::test_configuration_test_mode_xtrig_reset_diag_data test/test_larpix.py::test_configuration_sample_cycles_data test/test_larpix.py::test_configuration_test_burst_length_data test/test_larpix.py::test_configuration_adc_burst_length_data test/test_larpix.py::test_configuration_channel_mask_data test/test_larpix.py::test_configuration_external_trigger_mask_data test/test_larpix.py::test_configuration_reset_cycles_data test/test_larpix.py::test_configuration_to_dict test/test_larpix.py::test_configuration_from_dict test/test_larpix.py::test_configuration_write test/test_larpix.py::test_configuration_write_force test/test_larpix.py::test_configuration_read_absolute test/test_larpix.py::test_configuration_read_default test/test_larpix.py::test_configuration_read_local test/test_larpix.py::test_configuration_from_dict_reg_pixel_trim test/test_larpix.py::test_configuration_from_dict_reg_global_threshold test/test_larpix.py::test_configuration_from_dict_reg_csa_gain test/test_larpix.py::test_configuration_from_dict_reg_csa_bypass test/test_larpix.py::test_configuration_from_dict_reg_internal_bypass test/test_larpix.py::test_configuration_from_dict_reg_csa_bypass_select test/test_larpix.py::test_configuration_from_dict_reg_csa_monitor_select test/test_larpix.py::test_configuration_from_dict_reg_csa_testpulse_enable test/test_larpix.py::test_configuration_from_dict_reg_csa_testpulse_dac_amplitude test/test_larpix.py::test_configuration_from_dict_reg_test_mode test/test_larpix.py::test_configuration_from_dict_reg_cross_trigger_mode test/test_larpix.py::test_configuration_from_dict_reg_periodic_reset test/test_larpix.py::test_configuration_from_dict_reg_fifo_diagnostic test/test_larpix.py::test_configuration_from_dict_reg_sample_cycles test/test_larpix.py::test_configuration_from_dict_reg_test_burst_length test/test_larpix.py::test_configuration_from_dict_reg_adc_burst_length test/test_larpix.py::test_configuration_from_dict_reg_channel_mask test/test_larpix.py::test_configuration_from_dict_reg_external_trigger_mask test/test_larpix.py::test_configuration_from_dict_reg_reset_cycles test/test_larpix.py::test_controller_init_chips test/test_larpix.py::test_controller_get_chip test/test_larpix.py::test_controller_get_chip_all_chips test/test_larpix.py::test_controller_read test/test_larpix.py::test_controller_send test/test_larpix.py::test_controller_read_configuration test/test_larpix.py::test_controller_read_configuration_reg test/test_larpix.py::test_controller_write_configuration test/test_larpix.py::test_controller_write_configuration_one_reg test/test_larpix.py::test_controller_write_configuration_write_read test/test_larpix.py::test_controller_multi_write_configuration test/test_larpix.py::test_controller_multi_write_configuration_specify_registers test/test_larpix.py::test_controller_multi_read_configuration test/test_larpix.py::test_controller_multi_read_configuration_specify_registers test/test_larpix.py::test_packetcollection_getitem_int test/test_larpix.py::test_packetcollection_getitem_int_bits test/test_larpix.py::test_packetcollection_getitem_slice test/test_larpix.py::test_packetcollection_getitem_slice_bits test/test_larpix.py::test_packetcollection_origin test/test_larpix.py::test_packetcollection_extract test/test_larpix.py::test_packetcollection_to_dict test/test_larpix.py::test_packetcollection_from_dict test/test_larpix.py::test_timestamp_init test/test_larpix.py::test_timestamp_same_serial_read test/test_larpix.py::test_timestamp_diff_serial_read test/test_larpix.py::test_timestamp_ambiguous_rollover test/test_larpix.py::test_Smart_List_assignment test/test_serialport.py::test_serialport_format_UART test/test_serialport.py::test_serialport_format_bytestream test/test_serialport.py::test_serialport_parse_input test/test_serialport.py::test_serialport_parse_input_dropped_data_byte test/test_serialport.py::test_serialport_parse_input_dropped_start_byte test/test_serialport.py::test_serialport_parse_input_dropped_stop_byte test/test_serialport.py::test_serialport_parse_input_dropped_stopstart_bytes
test/test_larpix.py::test_configuration_error_on_unknown_field test/test_larpix.py::test_configuration_set_pixel_trim_thresholds_errors test/test_larpix.py::test_configuration_set_global_threshold_errors test/test_larpix.py::test_configuration_set_csa_gain_errors test/test_larpix.py::test_configuration_set_csa_bypass_errors test/test_larpix.py::test_configuration_set_internal_bypass_errors test/test_larpix.py::test_configuration_set_csa_bypass_select_errors test/test_larpix.py::test_configuration_set_csa_monitor_select_errors test/test_larpix.py::test_configuration_set_csa_testpulse_enable_errors test/test_larpix.py::test_configuration_set_csa_testpulse_dac_amplitude_errors test/test_larpix.py::test_configuration_set_test_mode_errors test/test_larpix.py::test_configuration_set_cross_trigger_mode_errors test/test_larpix.py::test_configuration_set_periodic_reset_errors test/test_larpix.py::test_configuration_set_fifo_diagnostic_errors test/test_larpix.py::test_configuration_set_test_burst_length_errors test/test_larpix.py::test_configuration_set_adc_burst_length_errors test/test_larpix.py::test_configuration_set_channel_mask_errors test/test_larpix.py::test_configuration_set_external_trigger_mask_errors test/test_larpix.py::test_configuration_set_reset_cycles_errors test/test_larpix.py::test_configuration_write_errors test/test_larpix.py::test_controller_get_chip_error test/test_larpix.py::test_timestamp_error test/test_larpix.py::test_Smart_List_error test/test_larpix.py::test_Smart_List_config_error
larpix/larpix.py larpix/serialport.py README.md larpix/zmq_io.py larpix/quickstart.py
larpix/larpix.py larpix/serialport.py README.md larpix/zmq_io.py larpix/quickstart.py