diff --git a/examples/config-basic.json b/examples/config-basic.json
index ba98c22618855ed3c248ad2031b0de7c663cc092..7f656aa2ba89d23e6e037c4260384036af126455 100644
--- a/examples/config-basic.json
+++ b/examples/config-basic.json
@@ -29,6 +29,16 @@
 			"map": [
 				[ 0, 0, 0, 512 ]
 			]
+		},
+		{
+			"type": "enttec",
+			"serial": "EN075577",
+			"map": [
+                [ 0, 0, "r", 1 ],
+                [ 0, 0, "g", 2 ],
+                [ 0, 0, "b", 3 ],
+                [ 0, 1, "l", 4 ]
+			]
 		}
 	]
 }
diff --git a/server/README.md b/server/README.md
index be0b73bc0b276a483252d4aee58edebd2f86d929..2bb7af39897b5075a677234eef1c561bb1b6b79e 100644
--- a/server/README.md
+++ b/server/README.md
@@ -54,7 +54,9 @@ Byte   | **Set Global Color Correction** command
 Configuration
 -------------
 
-The JSON configuration file is a dictionary which contains global configuration and an array of device objects. For each device, a dictionary includes device properties as well as a mapping table with commands which wire outputs to their corresponding OPC inputs. The map is a list of objects which act as mapping commands. Supported mapping objects:
+The JSON configuration file is a dictionary which contains global configuration and an array of device objects. For each device, a dictionary includes device properties as well as a mapping table with commands which wire outputs to their corresponding OPC inputs. The map is a list of objects which act as mapping commands. 
+
+Supported mapping objects for Fadecandy devices:
 
 * [ *OPC Channel*, *First OPC Pixel*, *First output pixel*, *pixel count* ]
 	* Map a contiguous range of pixels from the specified OPC channel to the current device
@@ -111,3 +113,47 @@ On Debian or Ubuntu Linux (including the Raspberry Pi) libev can be installed wi
 	$ ./configure
 	$ make
 	$ sudo make install
+
+Using Open Pixel Control with DMX
+---------------------------------
+
+The Fadecandy server is designed to make it easy to drive all your lighting via Open Pixel Control, even when you're using a mixture of addressable LED strips and DMX devices.
+
+For DMX, `fcserver` supports the common [Enttec DMX USB Pro adapter](http://www.enttec.com/index.php?main_menu=Products&pn=70304). This device attaches over USB, has inputs and outputs for one DMX universe, and it has an LED indicator. With Fadecandy, the LED will flash any time we process a new frame of video.
+
+The Enttec adapter uses an FTDI FT245 USB FIFO chip internally. For the smoothest USB performance and the simplest configuration, we do not use FTDI's serial port emulation driver. Instead, we talk directly to the FT232 chip using libusb. On Linux this happens without any special consideration. On Mac OS, libusb does not support detaching existing drivers from a device. If you've installed the official FTDI driver, you can temporarily unload it until your next reboot by running:
+
+	sudo kextunload -b com.FTDI.driver.FTDIUSBSerialDriver
+
+Enttec DMX devices can be configured in the same way as a Fadecandy device. For example:
+
+	{
+	        "listen": [null, 7890],
+	        "verbose": true,
+
+	        "devices": [
+	                {
+	                        "type": "fadecandy",
+	                        "map": [
+	                                [ 0, 0, 0, 512 ]
+	                        ]
+	                },
+	                {
+	                        "type": "enttec",
+	                        "serial": "EN075577",
+	                        "map": [
+	                                [ 0, 0, "r", 1 ],
+	                                [ 0, 0, "g", 2 ],
+	                                [ 0, 0, "b", 3 ],
+	                                [ 0, 1, "l", 4 ]
+	                        ]
+	                }
+	        ]
+	}
+
+Enttec DMX devices use a different format for their mapping objects:
+
+* [ *OPC Channel*, *OPC Pixel*, *Pixel Color*, *DMX Channel* ]
+    * Map a single OPC pixel to a single DMX channel
+    * The "Pixel color" can be "r", "g", or "b" to sample a single color channel from the pixel, or "l" to use an average luminosity.
+    * DMX channels are numbered from 1 to 512.
diff --git a/server/enttecdmxdevice.cpp b/server/enttecdmxdevice.cpp
index 0856f5729d7e4a969f0e41c31ae57f3e70fc7389..c61b5af6dbe8dad4d9497ed229aa5595236d4f27 100644
--- a/server/enttecdmxdevice.cpp
+++ b/server/enttecdmxdevice.cpp
@@ -22,35 +22,313 @@
  */
 
 #include "enttecdmxdevice.h"
+#include <sstream>
+#include <iostream>
 
 
+EnttecDMXDevice::Transfer::Transfer(EnttecDMXDevice *device, void *buffer, int length)
+	: transfer(libusb_alloc_transfer(0)),
+	  device(device)
+{
+	libusb_fill_bulk_transfer(transfer, device->mHandle,
+		OUT_ENDPOINT, (uint8_t*) buffer, length, EnttecDMXDevice::completeTransfer, this, 2000);
+}
+
+EnttecDMXDevice::Transfer::~Transfer()
+{
+	libusb_free_transfer(transfer);
+}
+
 EnttecDMXDevice::EnttecDMXDevice(libusb_device *device, bool verbose)
-	: USBDevice(device, verbose)
-{}
+	: USBDevice(device, verbose),
+	  mFoundEnttecStrings(false),
+	  mConfigMap(0)
+{
+	mSerial[0] = '\0';
+
+	// Initialize a minimal valid DMX packet
+	memset(&mChannelBuffer, 0, sizeof mChannelBuffer);
+	mChannelBuffer.start = START_OF_MESSAGE;
+	mChannelBuffer.label = SEND_DMX_PACKET;
+	mChannelBuffer.data[0] = START_CODE;
+	setChannel(1, 0);
+}
 
 EnttecDMXDevice::~EnttecDMXDevice()
-{}
+{
+	/*
+	 * If we have pending transfers, cancel them and jettison them
+	 * from the EnttecDMXDevice. The Transfer objects themselves will be freed
+	 * once libusb completes them.
+	 */
+
+	for (std::set<Transfer*>::iterator i = mPending.begin(), e = mPending.end(); i != e; ++i) {
+		Transfer *fct = *i;
+		libusb_cancel_transfer(fct->transfer);
+		fct->device = 0;
+	}
+}
 
 bool EnttecDMXDevice::probe(libusb_device *device)
 {
-	return false;
+	/*
+	 * Prior to opening the device, all we can do is look for an FT245 device.
+	 * We'll take a closer look in probeAfterOpening(), once we can see the
+	 * string descriptors.
+	 */
+
+	libusb_device_descriptor dd;
+
+	if (libusb_get_device_descriptor(device, &dd) < 0) {
+		// Can't access descriptor?
+		return false;
+	}
+
+	// FTDI FT245
+	return dd.idVendor == 0x0403 && dd.idProduct == 0x6001;
 }
 
 int EnttecDMXDevice::open()
 {
+	libusb_device_descriptor dd;
+	int r = libusb_get_device_descriptor(mDevice, &dd);
+	if (r < 0) {
+		return r;
+	}
+
+	r = libusb_open(mDevice, &mHandle);
+	if (r < 0) {
+		return r;
+	}
+
+	/*
+	 * Match the manufacturer and product strings! This is the least intrusive way to
+	 * determine that the attached device is in fact an Enttec DMX USB Pro, since it doesn't
+	 * have a unique vendor/product ID.
+	 */
+
+    if (dd.iManufacturer && dd.iProduct && dd.iSerialNumber) {
+		char manufacturer[256];
+		char product[256];
+
+		r = libusb_get_string_descriptor_ascii(mHandle, dd.iManufacturer, (uint8_t*)manufacturer, sizeof manufacturer);
+		if (r < 0) {
+			return r;
+		}
+		r = libusb_get_string_descriptor_ascii(mHandle, dd.iProduct, (uint8_t*)product, sizeof product);
+		if (r < 0) {
+			return r;
+		}
+
+		mFoundEnttecStrings = !strcmp(manufacturer, "ENTTEC") && !strcmp(product, "DMX USB PRO");
+	}
+
+	/*
+	 * Only go further if we have in fact found evidence that this is the right device.
+	 */
+
+	if (mFoundEnttecStrings) {
+
+		// Only relevant on linux; try to detach the FTDI driver.
+		libusb_detach_kernel_driver(mHandle, 0);
+
+		r = libusb_claim_interface(mHandle, 0);
+		if (r < 0) {
+			return r;
+		}
+
+		r = libusb_get_string_descriptor_ascii(mHandle, dd.iSerialNumber, (uint8_t*)mSerial, sizeof mSerial);
+		if (r < 0) {
+			return r;
+		}
+	}
+
 	return 0;
 }
- 
+
+bool EnttecDMXDevice::probeAfterOpening()
+{
+    // By default, any device is supported by the time we get to opening it.
+    return mFoundEnttecStrings;
+}
+
 bool EnttecDMXDevice::matchConfiguration(const Value &config)
 {
+	if (matchConfigurationWithTypeAndSerial(config, "enttec", mSerial)) {
+		mConfigMap = findConfigMap(config);
+		return true;
+	}
+
 	return false;
 }
 
+std::string EnttecDMXDevice::getName()
+{
+	std::ostringstream s;
+	s << "Enttec DMX USB Pro";
+	if (mSerial[0]) {
+		s << " (Serial# " << mSerial << ")";
+	}
+	return s.str();
+}
+
+void EnttecDMXDevice::setChannel(unsigned n, uint8_t value)
+{
+	if (n >= 1 && n <= 512) {
+		unsigned len = std::max<unsigned>(mChannelBuffer.length, n + 1);
+		mChannelBuffer.length = len;
+		mChannelBuffer.data[n] = value;
+		mChannelBuffer.data[len] = END_OF_MESSAGE;
+	}
+}
+
+void EnttecDMXDevice::submitTransfer(Transfer *fct)
+{
+	/*
+	 * Submit a new USB transfer. The Transfer object is guaranteed to be freed eventually.
+	 * On error, it's freed right away.
+	 */
+
+	int r = libusb_submit_transfer(fct->transfer);
+
+	if (r < 0) {
+		if (mVerbose && r != LIBUSB_ERROR_PIPE) {
+			std::clog << "Error submitting USB transfer: " << libusb_strerror(libusb_error(r)) << "\n";
+		}
+		delete fct;
+	} else {
+		mPending.insert(fct);
+	}
+}
+
+void EnttecDMXDevice::completeTransfer(struct libusb_transfer *transfer)
+{
+	/*
+	 * Transfer complete. The EnttecDMXDevice may or may not still exist; if the device was unplugged,
+	 * fct->device will be set to 0 by ~EnttecDMXDevice().
+	 */
+
+	EnttecDMXDevice::Transfer *fct = static_cast<EnttecDMXDevice::Transfer*>(transfer->user_data);
+	EnttecDMXDevice *self = fct->device;
+
+	if (self) {
+		self->mPending.erase(fct);
+	}
+
+	delete fct;
+}
+
+void EnttecDMXDevice::writeDMXPacket()
+{
+	/*
+	 * Asynchronously write an FTDI packet containing an Enttec packet containing
+	 * our set of DMX channels.
+	 *
+	 * XXX: We should probably throttle this so that we don't send DMX messages
+	 *      faster than the Enttec device can keep up!
+	 */
+
+	submitTransfer(new Transfer(this, &mChannelBuffer, mChannelBuffer.length + 5));
+}
+
 void EnttecDMXDevice::writeMessage(const OPCSink::Message &msg)
 {
+	/*
+	 * Dispatch an incoming OPC command
+	 */
+
+	switch (msg.command) {
+
+		case OPCSink::SetPixelColors:
+			opcSetPixelColors(msg);
+			writeDMXPacket();
+			return;
+
+		case OPCSink::SystemExclusive:
+			// No relevant SysEx for this device
+			return;
+	}
+
+	if (mVerbose) {
+		std::clog << "Unsupported OPC command: " << unsigned(msg.command) << "\n";
+	}
+}
+
+void EnttecDMXDevice::opcSetPixelColors(const OPCSink::Message &msg)
+{
+	/*
+	 * Parse through our device's mapping, and store any relevant portions of 'msg'
+	 * in the framebuffer.
+	 */
+
+	if (!mConfigMap) {
+		// No mapping defined yet. This device is inactive.
+		return;
+	}
+
+	const Value &map = *mConfigMap;
+	for (unsigned i = 0, e = map.Size(); i != e; i++) {
+		opcMapPixelColors(msg, map[i]);
+	}
 }
 
-std::string EnttecDMXDevice::getName()
+void EnttecDMXDevice::opcMapPixelColors(const OPCSink::Message &msg, const Value &inst)
 {
-	return "Enttec DMX Pro";
+	/*
+	 * Parse one JSON mapping instruction, and copy any relevant parts of 'msg'
+	 * into our framebuffer. This looks for any mapping instructions that we
+	 * recognize:
+	 *
+     *   [ OPC Channel, OPC Pixel, Pixel Color, DMX Channel ]
+     */
+
+    unsigned msgPixelCount = msg.length() / 3;
+
+    if (inst.IsArray() && inst.Size() == 4) {
+    	// Map a range from an OPC channel to our framebuffer
+
+    	const Value &vChannel = inst[0u];
+    	const Value &vPixelIndex = inst[1];
+    	const Value &vPixelColor = inst[2];
+    	const Value &vDMXChannel = inst[3];
+
+    	if (vChannel.IsUint() && vPixelIndex.IsUint() && vPixelColor.IsString() && vDMXChannel.IsUint()) {
+    		unsigned channel = vChannel.GetUint();
+    		unsigned pixelIndex = vPixelIndex.GetUint();
+    		const char *pixelColor = vPixelColor.GetString();
+    		unsigned dmxChannel = vDMXChannel.GetUint();
+
+    		if (channel != msg.channel || pixelIndex >= msgPixelCount) {
+    			return;
+    		}
+
+    		const uint8_t *pixel = msg.data + (pixelIndex * 3);
+
+    		switch (pixelColor[0]) {
+
+    			case 'r':
+    				setChannel(dmxChannel, pixel[0]);
+    				break;
+
+    			case 'g':
+    				setChannel(dmxChannel, pixel[1]);
+    				break;
+
+    			case 'b':
+    				setChannel(dmxChannel, pixel[2]);
+    				break;
+
+    			case 'l':
+    				setChannel(dmxChannel, (unsigned(pixel[0]) + unsigned(pixel[1]) + unsigned(pixel[2])) / 3);
+    				break;
+
+    		}
+   			return;
+    	}
+	}
+
+	// Still haven't found a match?
+    if (mVerbose) {
+    	std::clog << "Unsupported JSON mapping instruction\n";
+    }
 }
diff --git a/server/enttecdmxdevice.h b/server/enttecdmxdevice.h
index bbc1c75b2cacebc698c979d49d9466cd30432f95..a73f6e032928d5fdafccc3ca565f8a0e7467bf27 100644
--- a/server/enttecdmxdevice.h
+++ b/server/enttecdmxdevice.h
@@ -23,6 +23,7 @@
 
 #pragma once
 #include "usbdevice.h"
+#include <set>
 
 
 class EnttecDMXDevice : public USBDevice
@@ -34,7 +35,44 @@ public:
     static bool probe(libusb_device *device);
 
     virtual int open();
+    virtual bool probeAfterOpening();
     virtual bool matchConfiguration(const Value &config);
     virtual void writeMessage(const OPCSink::Message &msg);
     virtual std::string getName();
+
+    void writeDMXPacket();
+    void setChannel(unsigned n, uint8_t value);
+
+private:
+    static const unsigned OUT_ENDPOINT = 2;
+    static const unsigned START_OF_MESSAGE = 0x7e;
+    static const unsigned END_OF_MESSAGE = 0xe7;
+    static const unsigned SEND_DMX_PACKET = 0x06;
+    static const unsigned START_CODE = 0x00;
+
+    struct Packet {
+        uint8_t start;
+        uint8_t label;
+        uint16_t length;
+        uint8_t data[514];
+    };
+
+    struct Transfer {
+        Transfer(EnttecDMXDevice *device, void *buffer, int length);
+        ~Transfer();
+        libusb_transfer *transfer;
+        EnttecDMXDevice *device;
+    };
+
+    char mSerial[256];
+    bool mFoundEnttecStrings;
+    const Value *mConfigMap;
+    Packet mChannelBuffer;
+    std::set<Transfer*> mPending;
+
+    void submitTransfer(Transfer *fct);
+    static void completeTransfer(struct libusb_transfer *transfer);
+
+    void opcSetPixelColors(const OPCSink::Message &msg);
+    void opcMapPixelColors(const OPCSink::Message &msg, const Value &inst);
 };
diff --git a/server/fcdevice.cpp b/server/fcdevice.cpp
index 5caf1bb86ff40146973c965ba33044b3e67d7f22..20cc8331d15ab0ab28d180dc40adca0c4cadc700 100644
--- a/server/fcdevice.cpp
+++ b/server/fcdevice.cpp
@@ -111,49 +111,12 @@ int FCDevice::open()
 
 bool FCDevice::matchConfiguration(const Value &config)
 {
-	/*
-	 * Parse out the portions of our JSON configuration document which matter to us.
-	 */
-
-	if (!config.IsObject()) {
-		return false;
-	}
-
-	const Value &vtype = config["type"];
-	const Value &vserial = config["serial"];
-	const Value &vmap = config["map"];
-
-	if (!vtype.IsString() || strcmp(vtype.GetString(), "fadecandy")) {
-		// Wrong type
-		return false;
+	if (matchConfigurationWithTypeAndSerial(config, "fadecandy", mSerial)) {
+		mConfigMap = findConfigMap(config);
+		return true;
 	}
 
-	if (!vserial.IsNull()) {
-		// Not a wildcard serial number?
-		// If a serial was not specified, it matches any device.
-
-		if (!vserial.IsString()) {
-			// Non-string serial number. Bad form.
-			return false;
-		}
-
-		if (strcmp(vserial.GetString(), mSerial)) {
-			// Not a match
-			return false;
-		}
-	}
-
-	if (vmap.IsArray()) {
-		// The map is optional, but if it exists it needs to be an array.
-		mConfigMap = &vmap;
-	} else {
-		mConfigMap = 0;
-		if (!vmap.IsNull() && mVerbose) {
-			std::clog << "Device configuration 'map' must be an array.\n";
-		}
-	}
-
-	return true;
+	return false;
 }
 
 void FCDevice::submitTransfer(Transfer *fct)
@@ -446,7 +409,10 @@ void FCDevice::opcSetGlobalColorCorrection(const OPCSink::Message &msg)
 std::string FCDevice::getName()
 {
 	std::ostringstream s;
-	s << "Fadecandy (Serial# " << mSerial << ")";
+	s << "Fadecandy";
+	if (mSerial[0]) {
+		s << " (Serial# " << mSerial << ")";
+	}
 	return s.str();
 }
 	
\ No newline at end of file
diff --git a/server/fcserver.cpp b/server/fcserver.cpp
index 3fe5085adcf0afdb9447ed697d41ca321eaba521..1b635580aa90112c49cbc76f92c7b0b810e66f5f 100644
--- a/server/fcserver.cpp
+++ b/server/fcserver.cpp
@@ -172,6 +172,12 @@ void FCServer::usbDeviceArrived(libusb_device *device)
 		return;
 	}
 
+	if (!dev->probeAfterOpening()) {
+		// We were mistaken, this device isn't actually one we want.
+		delete dev;
+		return;
+	}
+
 	for (unsigned i = 0; i < mDevices.Size(); ++i) {
 		if (dev->matchConfiguration(mDevices[i])) {
 			// Found a matching configuration for this device. We're keeping it!
diff --git a/server/usbdevice.cpp b/server/usbdevice.cpp
index 456853395cec16051b2c58a674ccf9308b9eabdc..783a0281f1dc2d8556dc41c468617649d1cce5a0 100644
--- a/server/usbdevice.cpp
+++ b/server/usbdevice.cpp
@@ -22,6 +22,7 @@
  */
 
 #include "usbdevice.h"
+#include <iostream>
 
 
 USBDevice::USBDevice(libusb_device *device, bool verbose)
@@ -40,7 +41,62 @@ USBDevice::~USBDevice()
     }
 }
 
+bool USBDevice::probeAfterOpening()
+{
+    // By default, any device is supported by the time we get to opening it.
+    return true;
+}
+
 void USBDevice::writeColorCorrection(const Value &color)
 {
     // Optional. By default, ignore color correction messages.
 }
+
+bool USBDevice::matchConfigurationWithTypeAndSerial(const Value &config, const char *type, const char *serial)
+{
+    if (!config.IsObject()) {
+        return false;
+    }
+
+    const Value &vtype = config["type"];
+    const Value &vserial = config["serial"];
+
+    if (!vtype.IsString() || strcmp(vtype.GetString(), type)) {
+        // Wrong type
+        return false;
+    }
+
+    if (!vserial.IsNull()) {
+        // Not a wildcard serial number?
+        // If a serial was not specified, it matches any device.
+
+        if (!vserial.IsString()) {
+            // Non-string serial number. Bad form.
+            return false;
+        }
+
+        if (strcmp(vserial.GetString(), serial)) {
+            // Not a match
+            return false;
+        }
+    }
+
+    return true;
+}
+
+
+const USBDevice::Value *USBDevice::findConfigMap(const Value &config)
+{
+    const Value &vmap = config["map"];
+
+    if (vmap.IsArray()) {
+        // The map is optional, but if it exists it needs to be an array.
+        return &vmap;
+    }
+    
+    if (!vmap.IsNull() && mVerbose) {
+        std::clog << "Device configuration 'map' must be an array.\n";
+    }
+
+    return 0;
+}
diff --git a/server/usbdevice.h b/server/usbdevice.h
index 3f03d40d3b3fbf4b5f388804b6bd563e6334c637..2a16e7538cff77fa23766e6e02cb2d3a16bd792b 100644
--- a/server/usbdevice.h
+++ b/server/usbdevice.h
@@ -39,6 +39,9 @@ public:
     // Must be opened before any other methods are called.
     virtual int open() = 0;
 
+    // Some drivers can't determine whether this is a supported device prior to open()
+    virtual bool probeAfterOpening();
+
     // Check a configuration. If it describes this device, load it and return true. If not, return false.
     virtual bool matchConfiguration(const Value &config) = 0;
 
@@ -55,4 +58,8 @@ protected:
     libusb_device *mDevice;
     libusb_device_handle *mHandle;
 	bool mVerbose;
+
+    // Utilities
+    bool matchConfigurationWithTypeAndSerial(const Value &config, const char *type, const char *serial);
+    const Value *findConfigMap(const Value &config);
 };