An I/O controller for virtual pinball machines: accelerometer nudge sensing, analog plunger input, button input encoding, LedWiz compatible output controls, and more.

Dependencies:   mbed FastIO FastPWM USBDevice

Fork of Pinscape_Controller by Mike R


This is Version 2 of the Pinscape Controller, an I/O controller for virtual pinball machines. (You can find the old version 1 software here.) Pinscape is software for the KL25Z that turns the board into a full-featured I/O controller for virtual pinball, with support for accelerometer-based nudging, a real plunger, button inputs, and feedback device control.

In case you haven't heard of the concept before, a "virtual pinball machine" is basically a video pinball simulator that's built into a real pinball machine body. A TV monitor goes in place of the pinball playfield, and a second TV goes in the backbox to serve as the "backglass" display. A third smaller monitor can serve as the "DMD" (the Dot Matrix Display used for scoring on newer machines), or you can even install a real pinball plasma DMD. A computer is hidden inside the cabinet, running pinball emulation software that displays a life-sized playfield on the main TV. The cabinet has all of the usual buttons, too, so it not only looks like the real thing, but plays like it too. That's a picture of my own machine to the right. On the outside, it's built exactly like a real arcade pinball machine, with the same overall dimensions and all of the standard pinball cabinet hardware.

A few small companies build and sell complete, finished virtual pinball machines, but I think it's more fun as a DIY project. If you have some basic wood-working skills and know your way around PCs, you can build one from scratch. The computer part is just an ordinary Windows PC, and all of the pinball emulation can be built out of free, open-source software. In that spirit, the Pinscape Controller is an open-source software/hardware project that offers a no-compromises, all-in-one control center for all of the unique input/output needs of a virtual pinball cabinet. If you've been thinking about building one of these, but you're not sure how to connect a plunger, flipper buttons, lights, nudge sensor, and whatever else you can think of, this project might be just what you're looking for.

You can find much more information about DIY Pin Cab building in general in the Virtual Cabinet Forum on Also visit my Pinscape Resources page for more about this project and other virtual pinball projects I'm working on.


  • Pinscape Release Builds: This page has download links for all of the Pinscape software. To get started, install and run the Pinscape Config Tool on your Windows computer. It will lead you through the steps for installing the Pinscape firmware on the KL25Z.
  • Config Tool Source Code. The complete C# source code for the config tool. You don't need this to run the tool, but it's available if you want to customize anything or see how it works inside.


The new Version 2 Build Guide is now complete! This new version aims to be a complete guide to building a virtual pinball machine, including not only the Pinscape elements but all of the basics, from sourcing parts to building all of the hardware.

You can also refer to the original Hardware Build Guide (PDF), but that's out of date now, since it refers to the old version 1 software, which was rather different (especially when it comes to configuration).

System Requirements

The new config tool requires a fairly up-to-date Microsoft .NET installation. If you use Windows Update to keep your system current, you should be fine. A modern version of Internet Explorer (IE) is required, even if you don't use it as your main browser, because the config tool uses some system components that Microsoft packages into the IE install set. I test with IE11, so that's known to work. IE8 doesn't work. IE9 and 10 are unknown at this point.

The Windows requirements are only for the config tool. The firmware doesn't care about anything on the Windows side, so if you can make do without the config tool, you can use almost any Windows setup.

Main Features

Plunger: The Pinscape Controller started out as a "mechanical plunger" controller: a device for attaching a real pinball plunger to the video game software so that you could launch the ball the natural way. This is still, of course, a central feature of the project. The software supports several types of sensors: a high-resolution optical sensor (which works by essentially taking pictures of the plunger as it moves); a slide potentionmeter (which determines the position via the changing electrical resistance in the pot); a quadrature sensor (which counts bars printed on a special guide rail that it moves along); and an IR distance sensor (which determines the position by sending pulses of light at the plunger and measuring the round-trip travel time). The Build Guide explains how to set up each type of sensor.

Nudging: The KL25Z (the little microcontroller that the software runs on) has a built-in accelerometer. The Pinscape software uses it to sense when you nudge the cabinet, and feeds the acceleration data to the pinball software on the PC. This turns physical nudges into virtual English on the ball. The accelerometer is quite sensitive and accurate, so we can measure the difference between little bumps and hard shoves, and everything in between. The result is natural and immersive.

Buttons: You can wire real pinball buttons to the KL25Z, and the software will translate the buttons into PC input. You have the option to map each button to a keyboard key or joystick button. You can wire up your flipper buttons, Magna Save buttons, Start button, coin slots, operator buttons, and whatever else you need.

Feedback devices: You can also attach "feedback devices" to the KL25Z. Feedback devices are things that create tactile, sound, and lighting effects in sync with the game action. The most popular PC pinball emulators know how to address a wide variety of these devices, and know how to match them to on-screen action in each virtual table. You just need an I/O controller that translates commands from the PC into electrical signals that turn the devices on and off. The Pinscape Controller can do that for you.

Expansion Boards

There are two main ways to run the Pinscape Controller: standalone, or using the "expansion boards".

In the basic standalone setup, you just need the KL25Z, plus whatever buttons, sensors, and feedback devices you want to attach to it. This mode lets you take advantage of everything the software can do, but for some features, you'll have to build some ad hoc external circuitry to interface external devices with the KL25Z. The Build Guide has detailed plans for exactly what you need to build.

The other option is the Pinscape Expansion Boards. The expansion boards are a companion project, which is also totally free and open-source, that provides Printed Circuit Board (PCB) layouts that are designed specifically to work with the Pinscape software. The PCB designs are in the widely used EAGLE format, which many PCB manufacturers can turn directly into physical boards for you. The expansion boards organize all of the external connections more neatly than on the standalone KL25Z, and they add all of the interface circuitry needed for all of the advanced software functions. The big thing they bring to the table is lots of high-power outputs. The boards provide a modular system that lets you add boards to add more outputs. If you opt for the basic core setup, you'll have enough outputs for all of the toys in a really well-equipped cabinet. If your ambitions go beyond merely well-equipped and run to the ridiculously extravagant, just add an extra board or two. The modular design also means that you can add to the system over time.

Expansion Board project page

Update notes

If you have a Pinscape V1 setup already installed, you should be able to switch to the new version pretty seamlessly. There are just a couple of things to be aware of.

First, the "configuration" procedure is completely different in the new version. Way better and way easier, but it's not what you're used to from V1. In V1, you had to edit the project source code and compile your own custom version of the program. No more! With V2, you simply install the standard, pre-compiled .bin file, and select options using the Pinscape Config Tool on Windows.

Second, if you're using the TSL1410R optical sensor for your plunger, there's a chance you'll need to boost your light source's brightness a little bit. The "shutter speed" is faster in this version, which means that it doesn't spend as much time collecting light per frame as before. The software actually does "auto exposure" adaptation on every frame, so the increased shutter speed really shouldn't bother it, but it does require a certain minimum level of contrast, which requires a certain minimal level of lighting. Check the plunger viewer in the setup tool if you have any problems; if the image looks totally dark, try increasing the light level to see if that helps.

New Features

V2 has numerous new features. Here are some of the highlights...

Dynamic configuration: as explained above, configuration is now handled through the Config Tool on Windows. It's no longer necessary to edit the source code or compile your own modified binary.

Improved plunger sensing: the software now reads the TSL1410R optical sensor about 15x faster than it did before. This allows reading the sensor at full resolution (400dpi), about 400 times per second. The faster frame rate makes a big difference in how accurately we can read the plunger position during the fast motion of a release, which allows for more precise position sensing and faster response. The differences aren't dramatic, since the sensing was already pretty good even with the slower V1 scan rate, but you might notice a little better precision in tricky skill shots.

Keyboard keys: button inputs can now be mapped to keyboard keys. The joystick button option is still available as well, of course. Keyboard keys have the advantage of being closer to universal for PC pinball software: some pinball software can be set up to take joystick input, but nearly all PC pinball emulators can take keyboard input, and nearly all of them use the same key mappings.

Local shift button: one physical button can be designed as the local shift button. This works like a Shift button on a keyboard, but with cabinet buttons. It allows each physical button on the cabinet to have two PC keys assigned, one normal and one shifted. Hold down the local shift button, then press another key, and the other key's shifted key mapping is sent to the PC. The shift button can have a regular key mapping of its own as well, so it can do double duty. The shift feature lets you access more functions without cluttering your cabinet with extra buttons. It's especially nice for less frequently used functions like adjusting the volume or activating night mode.

Night mode: the output controller has a new "night mode" option, which lets you turn off all of your noisy devices with a single button, switch, or PC command. You can designate individual ports as noisy or not. Night mode only disables the noisemakers, so you still get the benefit of your flashers, button lights, and other quiet devices. This lets you play late into the night without disturbing your housemates or neighbors.

Gamma correction: you can designate individual output ports for gamma correction. This adjusts the intensity level of an output to make it match the way the human eye perceives brightness, so that fades and color mixes look more natural in lighting devices. You can apply this to individual ports, so that it only affects ports that actually have lights of some kind attached.

IR Remote Control: the controller software can transmit and/or receive IR remote control commands if you attach appropriate parts (an IR LED to send, an IR sensor chip to receive). This can be used to turn on your TV(s) when the system powers on, if they don't turn on automatically, and for any other functions you can think of requiring IR send/receive capabilities. You can assign IR commands to cabinet buttons, so that pressing a button on your cabinet sends a remote control command from the attached IR LED, and you can have the controller generate virtual key presses on your PC in response to received IR commands. If you have the IR sensor attached, the system can use it to learn commands from your existing remotes.

Yet more USB fixes: I've been gradually finding and fixing USB bugs in the mbed library for months now. This version has all of the fixes of the last couple of releases, of course, plus some new ones. It also has a new "last resort" feature, since there always seems to be "just one more" USB bug. The last resort is that you can tell the device to automatically reboot itself if it loses the USB connection and can't restore it within a given time limit.

More Downloads

  • Custom VP builds: I created modified versions of Visual Pinball 9.9 and Physmod5 that you might want to use in combination with this controller. The modified versions have special handling for plunger calibration specific to the Pinscape Controller, as well as some enhancements to the nudge physics. If you're not using the plunger, you might still want it for the nudge improvements. The modified version also works with any other input controller, so you can get the enhanced nudging effects even if you're using a different plunger/nudge kit. The big change in the modified versions is a "filter" for accelerometer input that's designed to make the response to cabinet nudges more realistic. It also makes the response more subdued than in the standard VP, so it's not to everyone's taste. The downloads include both the updated executables and the source code changes, in case you want to merge the changes into your own custom version(s).

    Note! These features are now standard in the official VP releases, so you don't need my custom builds if you're using 9.9.1 or later and/or VP 10. I don't think there's any reason to use my versions instead of the latest official ones, and in fact I'd encourage you to use the official releases since they're more up to date, but I'm leaving my builds available just in case. In the official versions, look for the checkbox "Enable Nudge Filter" in the Keys preferences dialog. My custom versions don't include that checkbox; they just enable the filter unconditionally.
  • Output circuit shopping list: This is a saved shopping cart at with the parts needed to build one copy of the high-power output circuit for the LedWiz emulator feature, for use with the standalone KL25Z (that is, without the expansion boards). The quantities in the cart are for one output channel, so if you want N outputs, simply multiply the quantities by the N, with one exception: you only need one ULN2803 transistor array chip for each eight output circuits. If you're using the expansion boards, you won't need any of this, since the boards provide their own high-power outputs.
  • Cary Owens' optical sensor housing: A 3D-printable design for a housing/mounting bracket for the optical plunger sensor, designed by Cary Owens. This makes it easy to mount the sensor.
  • Lemming77's potentiometer mounting bracket and shooter rod connecter: Sketchup designs for 3D-printable parts for mounting a slide potentiometer as the plunger sensor. These were designed for a particular slide potentiometer that used to be available from an seller but is no longer listed. You can probably use this design as a starting point for other similar devices; just check the dimensions before committing the design to plastic.

Copyright and License

The Pinscape firmware is copyright 2014, 2021 by Michael J Roberts. It's released under an MIT open-source license. See License.

Warning to VirtuaPin Kit Owners

This software isn't designed as a replacement for the VirtuaPin plunger kit's firmware. If you bought the VirtuaPin kit, I recommend that you don't install this software. The VirtuaPin kit uses the same KL25Z microcontroller that Pinscape uses, but the rest of its hardware is different and incompatible. In particular, the Pinscape firmware doesn't include support for the IR proximity sensor used in the VirtuaPin plunger kit, so you won't be able to use your plunger device with the Pinscape firmware. In addition, the VirtuaPin setup uses a different set of GPIO pins for the button inputs from the Pinscape defaults, so if you do install the Pinscape firmware, you'll have to go into the Config Tool and reassign all of the buttons to match the VirtuaPin wiring.

--- a/main.cpp	Fri Apr 21 18:50:37 2017 +0000
+++ b/main.cpp	Tue May 09 05:48:37 2017 +0000
@@ -47,34 +47,48 @@
 //    have native support for this type of input; as with the nudge setup, you just 
 //    have to set some options in VP to activate the plunger.
-//    The Pinscape software supports optical sensors (the TAOS TSL1410R and TSL1412R 
-//    linear sensor arrays) as well as slide potentiometers.  The specific equipment
-//    that's supported, along with physical mounting and wiring details, can be found
-//    in the Build Guide.
+//    We support several sensor types:
-//    Note that VP has built-in support for plunger devices like this one, but 
-//    some VP tables can't use it without some additional scripting work.  The 
-//    Build Guide has advice on adjusting tables to add plunger support when 
-//    necessary.
+//    - AEDR-8300-1K2 optical encoders.  These are quadrature encoders with
+//      reflective optical sensing and built-in lighting and optics.  The sensor
+//      is attached to the plunger so that it moves with the plunger, and slides
+//      along a guide rail with a reflective pattern of regularly spaces bars 
+//      for the encoder to read.  We read the plunger position by counting the
+//      bars the sensor passes as it moves across the rail.  This is the newest
+//      option, and it's my current favorite because it's highly accurate,
+//      precise, and fast, plus it's relatively inexpensive.
+//    - Slide potentiometers.  There are slide potentioneters available with a
+//      long enough travel distance (at least 85mm) to cover the plunger travel.
+//      Attach the plunger to the potentiometer knob so that the moving the
+//      plunger moves the pot knob.  We sense the position by simply reading
+//      the analog voltage on the pot brush.  A pot with a "linear taper" (that
+//      is, the resistance varies linearly with the position) is required.
+//      This option is cheap, easy to set up, and works well.
-//    For best results, the plunger sensor should be calibrated.  The calibration
-//    is stored in non-volatile memory on board the KL25Z, so it's only necessary
-//    to do the calibration once, when you first install everything.  (You might
-//    also want to re-calibrate if you physically remove and reinstall the CCD 
-//    sensor or the mechanical plunger, since their alignment shift change slightly 
-//    when you put everything back together.)  You can optionally install a
-//    dedicated momentary switch or pushbutton to activate the calibration mode;
-//    this is describe in the project documentation.  If you don't want to bother
-//    with the extra button, you can also trigger calibration using the Windows 
-//    setup software, which you can find on the Pinscape project page.
+//    - VL6108X time-of-flight distance sensor.  This is an optical distance
+//      sensor that measures the distance to a nearby object (within about 10cm)
+//      by measuring the travel time for reflected pulses of light.  It's fairly
+//      cheap and easy to set up, but I don't recommend it because it has very
+//      low precision.
-//    The calibration procedure is described in the project documentation.  Briefly,
-//    when you trigger calibration mode, the software will scan the CCD for about
-//    15 seconds, during which you should simply pull the physical plunger back
-//    all the way, hold it for a moment, and then slowly return it to the rest
-//    position.  (DON'T just release it from the retracted position, since that
-//    let it shoot forward too far.  We want to measure the range from the park
-//    position to the fully retracted position only.)
+//    - TSL1410R/TSL1412R linear array optical sensors.  These are large optical
+//      sensors with the pixels arranged in a single row.  The pixel arrays are
+//      large enough on these to cover the travel distance of the plunger, so we
+//      can set up the sensor near the plunger in such a way that the plunger 
+//      casts a shadow on the sensor.  We detect the plunger position by finding
+//      the edge of the sahdow in the image.  The optics for this setup are very
+//      simple since we don't need any lenses.  This was the first sensor we
+//      supported, and works very well, but unfortunately the sensor is difficult
+//      to find now since it's been discontinued by the manufacturer.
+//    The v2 Build Guide has details on how to build and configure all of the
+//    sensor options.
+//    Visual Pinball has built-in support for plunger devices like this one, but 
+//    some older VP tables (particularly for VP 9) can't use it without some
+//    modifications to their scripting.  The Build Guide has advice on how to
+//    fix up VP tables to add plunger support when necessary.
 //  - Button input wiring.  You can assign GPIO ports as inputs for physical
 //    pinball-style buttons, such as flipper buttons, a Start button, coin
@@ -107,28 +121,34 @@
 //    current handing.  The Build Guide has a reference circuit design for this
 //    purpose that's simple and inexpensive to build.
-//  - Enhanced LedWiz emulation with TLC5940 PWM controller chips.  You can attach
-//    external PWM controller chips for controlling device outputs, instead of using
-//    the on-board GPIO ports as described above.  The software can control a set of 
-//    daisy-chained TLC5940 chips.  Each chip provides 16 PWM outputs, so you just
-//    need two of them to get the full complement of 32 output ports of a real LedWiz.
-//    You can hook up even more, though.  Four chips gives you 64 ports, which should
-//    be plenty for nearly any virtual pinball project.  To accommodate the larger
-//    supply of ports possible with the PWM chips, the controller software provides
-//    a custom, extended version of the LedWiz protocol that can handle up to 128
-//    ports.  PC software designed only for the real LedWiz obviously won't know
-//    about the extended protocol and won't be able to take advantage of its extra
-//    capabilities, but the latest version of DOF (DirectOutput Framework) *does* 
-//    know the new language and can take full advantage.  Older software will still
-//    work, though - the new extensions are all backward compatible, so old software
-//    that only knows about the original LedWiz protocol will still work, with the
-//    obvious limitation that it can only access the first 32 ports.
+//  - Enhanced LedWiz emulation with TLC5940 and/or TLC59116 PWM controller chips. 
+//    You can attach external PWM chips for controlling device outputs, instead of
+//    using (or in addition to) the on-board GPIO ports as described above.  The 
+//    software can control a set of daisy-chained TLC5940 or TLC59116 chips.  Each
+//    chip provides 16 PWM outputs, so you just need two of them to get the full 
+//    complement of 32 output ports of a real LedWiz.  You can hook up even more, 
+//    though.  Four chips gives you 64 ports, which should be plenty for nearly any 
+//    virtual pinball project.  
 //    The Pinscape Expansion Board project (which appeared in early 2016) provides
 //    a reference hardware design, with EAGLE circuit board layouts, that takes full
 //    advantage of the TLC5940 capability.  It lets you create a customized set of
 //    outputs with full PWM control and power handling for high-current devices
-//    built in to the boards.  
+//    built in to the boards.
+//    To accommodate the larger supply of ports possible with the external chips,
+//    the controller software provides a custom, extended version of the LedWiz 
+//    protocol that can handle up to 128 ports.  Legacy PC software designed only
+//    for the original LedWiz obviously can't use the extended protocol, and thus 
+//    can't take advantage of its extra capabilities, but the latest version of 
+//    DOF (DirectOutput Framework) *does*  know the new language and can take full
+//    advantage.  Older software will still work, though - the new extensions are 
+//    all backwards compatible, so old software that only knows about the original 
+//    LedWiz protocol will still work, with the limitation that it can only access 
+//    the first 32 ports.  In addition, we provide a replacement LEDWIZ.DLL that 
+//    creates virtual LedWiz units representing additional ports beyond the first
+//    32.  This allows legacy LedWiz client software to address all ports by
+//    making them think that you have several physical LedWiz units installed.
 //  - Night Mode control for output devices.  You can connect a switch or button
 //    to the controller to activate "Night Mode", which disables feedback devices
@@ -222,6 +242,7 @@
 #include "FreescaleIAP.h"
 #include "crc32.h"
 #include "TLC5940.h"
+#include "TLC59116.h"
 #include "74HC595.h"
 #include "nvm.h"
 #include "TinyDigitalIn.h"
@@ -237,6 +258,7 @@
 #include "nullSensor.h"
 #include "barCodeSensor.h"
 #include "distanceSensor.h"
+#include "tsl14xxSensor.h"
@@ -646,17 +668,26 @@
 // about 50 GPIO pins.  So if you want to do everything with GPIO ports,
 // you have to ration pins among features.
-// To overcome some of these limitations, we also provide two types of
+// To overcome some of these limitations, we also support several external
 // peripheral controllers that allow adding many more outputs, using only
-// a small number of GPIO pins to interface with the peripherals.  First,
-// we support TLC5940 PWM controller chips.  Each TLC5940 provides 16 ports
-// with full PWM, and multiple TLC5940 chips can be daisy-chained.  The
-// chip only requires 5 GPIO pins for the interface, no matter how many
-// chips are in the chain, so it effectively converts 5 GPIO pins into 
-// almost any number of PWM outputs.  Second, we support 74HC595 chips.
-// These provide only digital outputs, but like the TLC5940 they can be
-// daisy-chained to provide almost unlimited outputs with a few GPIO pins
-// to control the whole chain.
+// a small number of GPIO pins to interface with the peripherals:
+// - TLC5940 PWM controller chips.  Each TLC5940 provides 16 ports with
+//   12-bit PWM, and multiple TLC5940 chips can be daisy-chained.  The
+//   chips connect via 5 GPIO pins, and since they're daisy-chainable,
+//   one set of 5 pins can control any number of the chips.  So this chip
+//   effectively converts 5 GPIO pins into almost any number of PWM outputs.
+// - TLC59116 PWM controller chips.  These are similar to the TLC5940 but
+//   a newer generation with an improved design.  These use an I2C bus,
+//   allowing up to 14 chips to be connected via 3 GPIO pins.
+// - 74HC595 shift register chips.  These provide 8 digital (on/off only)
+//   outputs per chip.  These need 4 GPIO pins, and like the other can be
+//   daisy chained to add more outputs without using more GPIO pins.  These
+//   are advantageous for outputs that don't require PWM, since the data
+//   transfer sizes are so much smaller.  The expansion boards use these
+//   for the chime board outputs.
 // Direct GPIO output ports and peripheral controllers can be mixed and
 // matched in one system.  The assignment of pins to ports and the 
@@ -735,7 +766,7 @@
 // Gamma correction table for 8-bit input values
-static const uint8_t gamma[] = {
+static const uint8_t dof_to_gamma_8bit[] = {
       0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0, 
       0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   1,   1,   1,   1, 
       1,   1,   1,   1,   1,   1,   1,   1,   1,   2,   2,   2,   2,   2,   2,   2, 
@@ -761,7 +792,7 @@
     LwGammaOut(LwOut *o) : out(o) { }
-    virtual void set(uint8_t val) { out->set(gamma[val]); }
+    virtual void set(uint8_t val) { out->set(dof_to_gamma_8bit[val]); }
     LwOut *out;
@@ -903,10 +934,55 @@
     uint8_t prv;
+// TLC59116 interface object
+TLC59116 *tlc59116 = 0;
+void init_tlc59116(Config &cfg)
+    // Create the interface if any chips are enabled
+    if (cfg.tlc59116.chipMask != 0)
+    {
+        // set up the interface
+        tlc59116 = new TLC59116(
+            wirePinName(cfg.tlc59116.sda),
+            wirePinName(cfg.tlc59116.scl),
+            wirePinName(cfg.tlc59116.reset));
+        // initialize the chips
+        tlc59116->init();
+    }
+// LwOut class for TLC59116 outputs.  The 'addr' value in the constructor
+// is low 4 bits of the chip's I2C address; this is the part of the address
+// that's configurable per chip.  'port' is the output number on the chip
+// (0-15).
+// Note that we don't need a separate gamma-corrected subclass for this
+// output type, since there's no loss of precision with the standard layered
+// gamma (it emits 8-bit values, and we take 8-bit inputs).
+class Lw59116Out: public LwOut
+    Lw59116Out(uint8_t addr, uint8_t port) : addr(addr), port(port) { prv = 0; }
+    virtual void set(uint8_t val)
+    {
+        if (val != prv)
+            tlc59116->set(addr, port, prv = val);
+    }
+    uint8_t addr;
+    uint8_t port;
+    uint8_t prv;
 // 74HC595 interface object.  Set this up with the port assignments in
 // config.h.
 HC595 *hc595 = 0;
 // initialize the 74HC595 interface
@@ -1276,13 +1352,22 @@
     case PortType74HC595:
-        // 74HC595 port (if we don't have an HC595 controller object, or it's not a valid
-        // output number, create a virtual port)
+        // 74HC595 port (if we don't have an HC595 controller object, or it's not 
+        // a valid output number, create a virtual port)
         if (hc595 != 0 && pin < cfg.hc595.nchips*8)
             lwp = new Lw595Out(pin);
             lwp = new LwVirtualOut();
+    case PortTypeTLC59116:
+        // TLC59116 port.  The pin number in the config encodes the chip address
+        // in the high 4 bits and the output number on the chip in the low 4 bits.
+        // There's no gamma-corrected version of this output handler, so we don't
+        // need to worry about that here; just use the layered gamma as needed.
+        if (tlc59116 != 0)
+            lwp = new Lw59116Out((pin >> 4) & 0x0F, pin & 0x0F);
+        break;
     case PortTypeVirtual:
     case PortTypeDisabled:
@@ -3907,10 +3992,8 @@
             // The latch didn't stick, so PSU2 was still off at
-            // our last check.  Try pulsing it again in case PSU2
-            // was turned on since the last check.
-            psu2_status_set->write(1);
-            psu2_state = 2;
+            // our last check.  Return to idle state.
+            psu2_state = 1;
@@ -4197,6 +4280,8 @@
         saveConfigSucceededFlag = 0x40;
         // start the followup timer
+        saveConfigFollowupTime = tFollowup;
+        saveConfigFollowupTimer.reset();
         // if a reboot is pending, flag it
@@ -4336,10 +4421,10 @@
 // the plunger sensor interface object
 PlungerSensor *plungerSensor = 0;
-// wait for the plunger sensor to complete any outstanding read
+// wait for the plunger sensor to complete any outstanding DMA transfer
 static void waitPlungerIdle(void)
-    while (!plungerSensor->ready()) { }
+    while (plungerSensor->dmaBusy()) { }
 // Create the plunger sensor based on the current configuration.  If 
@@ -4409,8 +4494,9 @@
-    // set the jitter filter
-    plungerSensor->setJitterWindow(cfg.plunger.jitterWindow);
+    // initialize the config variables affecting the plunger
+    plungerSensor->onConfigChange(19, cfg);
+    plungerSensor->onConfigChange(20, cfg);
 // Global plunger calibration mode flag
@@ -4473,9 +4559,6 @@
         // not in a firing event yet
         firing = 0;
-        // no history yet
-        histIdx = 0;
     // Collect a reading from the plunger sensor.  The main loop calls
@@ -4492,25 +4575,6 @@
         PlungerReading r;
         if (plungerSensor->read(r))
-            // Pull the previous reading from the history
-            const PlungerReading &prv = nthHist(0);
-            // If the new reading is within 1ms of the previous reading,
-            // ignore it.  We require a minimum time between samples to
-            // ensure that we have a usable amount of precision in the
-            // denominator (the time interval) for calculating the plunger
-            // velocity.  The CCD sensor hardware takes about 2.5ms to
-            // read, so it will never be affected by this, but other sensor
-            // types don't all have the same hardware cycle time, so we need
-            // to throttle them artificially.  E.g., the potentiometer only
-            // needs one ADC sample per reading, which only takes about 15us;
-            // the quadrature sensor needs no time at all since it keeps
-            // track of the position continuously via interrupts.  We don't
-            // need to check which sensor type we have here; we just ignore 
-            // readings until the minimum interval has passed.
-            if (uint32_t(r.t - prv.t) < 1000UL)
-                return;
             // check for calibration mode
             if (plungerCalMode)
@@ -4575,98 +4639,130 @@
                     r.pos = -JOYMAX;
-            // Calculate the velocity from the second-to-last reading
-            // to here, in joystick distance units per microsecond.
-            // Note that we use the second-to-last reading rather than
-            // the very last reading to give ourselves a little longer
-            // time base.  The time base is so short between consecutive
-            // readings that the error bars in the position would be too
-            // large.
+            // Look for a firing event - the user releasing the plunger and
+            // allowing it to shoot forward at full speed.  Wait at least 5ms
+            // between samples for this, to help distinguish random motion 
+            // from the rapid motion of a firing event.  
-            // For reference, the physical plunger velocity ranges up
-            // to about 100,000 joystick distance units/sec.  This is 
-            // based on empirical measurements.  The typical time for 
-            // a real plunger to travel the full distance when released 
-            // from full retraction is about 85ms, so the average velocity 
-            // covering this distance is about 56,000 units/sec.  The 
-            // peak is probably about twice that.  In real-world units, 
-            // this translates to an average speed of about .75 m/s and 
-            // a peak of about 1.5 m/s.
+            // There's a trade-off in the choice of minimum sampling interval.
+            // The longer we wait, the more certain we can be of the trend.
+            // But if we wait too long, the user will perceive a delay.  We
+            // also want to sample frequently enough to see the release motion
+            // at intermediate steps along the way, so the sampling has to be
+            // considerably faster than the whole travel time, which is about
+            // 25-50ms.
+            if (uint32_t(r.t - prv.t) < 5000UL)
+                return;
+            // assume that we'll report this reading as-is
+            z = r.pos;
+            // Firing event detection.
+            //
+            // A "firing event" is when the player releases the plunger from
+            // a retracted position, allowing it to shoot forward under the
+            // spring tension.
-            // Note that we actually calculate the value here in units
-            // per *microsecond* - the discussion above is in terms of
-            // units/sec because that's more on a human scale.  Our
-            // choice of internal units here really isn't important,
-            // since we only use the velocity for comparison purposes,
-            // to detect acceleration trends.  We therefore save ourselves
-            // a little CPU time by using the natural units of our inputs.
+            // We monitor the plunger motion for these events, and when they
+            // occur, we report an "idealized" version of the motion to the
+            // PC.  The idealized version consists of a series of readings
+            // frozen at the fully retracted position for the whole duration 
+            // of the forward travel, followed by a series of readings at the
+            // fully forward position for long enough for the plunger to come
+            // mostly to rest.  The series of frozen readings aren't meant to
+            // be perceptible to the player - we try to keep them short enough
+            // that they're not apparent as delay.  Instead, they're for the
+            // PC client software's benefit.  PC joystick clients use polling,
+            // so they only see an unpredictable subset of the readings we
+            // send.  The only way to be sure that the client sees a particular 
+            // reading is to hold it for long enough that the client is sure to
+            // poll within the hold interval.  In the case of the plunger 
+            // firing motion, it's important that the client sees the *ends*
+            // of the travel - the fully retracted starting position in
+            // particular.  If the PC client only polls for a sample while the
+            // plunger is somewhere in the middle of the travel, the PC will
+            // think that the firing motion *started* in that middle position,
+            // so it won't be able to model the right amount of momentum when
+            // the plunger hits the ball.  We try to ensure that the PC sees
+            // the right starting point by reporting the starting point for 
+            // extra time during the forward motion.  By the same token, we
+            // want the PC to know that the plunger has moved all the way
+            // forward, rather than mistakenly thinking that it stopped
+            // somewhere in the middle of the travel, so we freeze at the
+            // forward position for a short time.
-            // We don't care about the absolute velocity; this is a purely
-            // relative calculation.  So to speed things up, calculate it
-            // in the integer domain, using a fixed-point representation
-            // with a 64K scale.  In other words, with the stored values
-            // shifted left 16 bits from the actual values: the value 1
-            // is stored as 1<<16.  The position readings are in the range
-            // -JOYMAX..JOYMAX, which fits in 16 bits, and the time 
-            // differences will generally be on the scale of a few 
-            // milliseconds = thousands of microseconds.  So the velocity
-            // figures will fit nicely into a 32-bit fixed point value with
-            // a 64K scale factor.
-            const PlungerReading &prv2 = nthHist(1);
-            int v = ((r.pos - prv2.pos) * 65536L)/int(r.t - prv2.t);
-            // presume we'll report the latest instantaneous reading
-            z = r.pos;
-            // Check firing events
+            // To detect a firing event, we look for forward motion that's
+            // fast enough to be a firing event.  To determine how fast is
+            // fast enough, we use a simple model of the plunger motion where 
+            // the acceleration is constant.  This is only an approximation, 
+            // as the spring force actually varies with spring's compression, 
+            // but it's close enough for our purposes here.
+            //
+            // Do calculations in fixed-point 2^48 scale with 64-bit ints.
+            // acc2 = acceleration/2 for 50ms release time, units of unit
+            // distances per microsecond squared, where the unit distance
+            // is the overall travel from the starting retracted position
+            // to the park position.
+            const int32_t acc2 = 112590;  // 2^48 scale
             switch (firing)
             case 0:
-                // Default state - not in a firing event.  
-                // If we have forward motion from a position that's retracted 
-                // beyond a threshold, enter phase 1.  If we're not pulled back
-                // far enough, don't bother with this, as a release wouldn't
-                // be strong enough to require the synthetic firing treatment.
-                if (v < 0 && r.pos > JOYMAX/6)
+                // Not in firing mode.  If we're retracted a bit, and the
+                // motion is forward at a fast enough rate to look like a
+                // release, enter firing mode.
+                if (r.pos > JOYMAX/6)
-                    // enter firing phase 1
-                    firingMode(1);
-                    // if in calibration state 1 (at rest), switch to state 2 (not 
-                    // at rest)
-                    if (calState == 1)
-                        calState = 2;
-                    // we don't have a freeze position yet, but note the start time
-                    f1.pos = 0;
-                    f1.t = r.t;
-                    // Figure the barrel spring "bounce" position in case we complete 
-                    // the firing event.  This is the amount that the forward momentum
-                    // of the plunger will compress the barrel spring at the peak of
-                    // the forward travel during the release.  Assume that this is
-                    // linearly proportional to the starting retraction distance.  
-                    // The barrel spring is about 1/6 the length of the main spring, 
-                    // so figure it compresses by 1/6 the distance.  (This is overly
-                    // simplistic and not very accurate, but it seems to give good 
-                    // visual results, and that's all it's for.)
-                    f2.pos = -r.pos/6;
+                    const uint32_t dt = uint32_t(r.t - prv.t);
+                    const uint32_t dt2 = dt*dt;  // dt^2
+                    if (r.pos < prv.pos - int((prv.pos*acc2*uint64_t(dt2)) >> 48))
+                    {
+                        // Tentatively enter firing mode.  Use the prior reading
+                        // as the starting point, and freeze reports for now.
+                        firingMode(1);
+                        f0 = prv;
+                        z = f0.pos;
+                        // if in calibration state 1 (at rest), switch to 
+                        // state 2 (not at rest)
+                        if (calState == 1)
+                            calState = 2;
+                    }
             case 1:
-                // Phase 1 - acceleration.  If we cross the zero point, trigger
-                // the firing event.  Otherwise, continue monitoring as long as we
-                // see acceleration in the forward direction.
+                // Tentative firing mode: the plunger was moving forward
+                // at last check.  To stay in firing mode, the plunger has
+                // to keep moving forward fast enough to look like it's 
+                // moving under spring force.  To figure out how fast is
+                // fast enough, we use a simple model where the acceleration
+                // is constant over the whole travel distance and the total
+                // travel time is 50ms.  The acceleration actually varies
+                // slightly since it comes from the spring force, which
+                // is linear in the displacement; but the plunger spring is
+                // fairly compressed even when the plunger is all the way
+                // forward, so the difference in tension from one end of
+                // the travel to the other is fairly small, so it's not too
+                // far off to model it as constant.  And the real travel
+                // time obviously isn't a constant, but all we need for 
+                // that is an upper bound.  So: we'll figure the time since 
+                // we entered firing mode, and figure the distance we should 
+                // have traveled to complete the trip within the maximum
+                // time allowed.  If we've moved far enough, we'll stay
+                // in firing mode; if not, we'll exit firing mode.  And if
+                // we cross the finish line while still in firing mode,
+                // we'll switch to the next phase of the firing event.
                 if (r.pos <= 0)
-                    // switch to the synthetic firing mode
+                    // We crossed the park position.  Switch to the second
+                    // phase of the firing event, where we hold the reported
+                    // position at the "bounce" position (where the plunger
+                    // is all the way forward, compressing the barrel spring).
+                    // We'll stick here long enough to ensure that the PC
+                    // client (Visual Pinball or whatever) sees the reading
+                    // and processes the release motion via the simulated
+                    // physics.
-                    z = f2.pos;
-                    // note the start time for the firing phase
-                    f2.t = r.t;
                     // if in calibration mode, and we're in state 2 (moving), 
                     // collect firing statistics for calibration purposes
@@ -4676,142 +4772,96 @@
                         // come to rest
                         calState = 0;
-                        // collect average firing time statistics in millseconds, if 
-                        // it's in range (20 to 255 ms)
-                        int dt = uint32_t(r.t - f1.t)/1000UL;
-                        if (dt >= 20 && dt <= 255)
+                        // collect average firing time statistics in millseconds,
+                        // if it's in range (20 to 255 ms)
+                        const int dt = uint32_t(r.t - f0.t)/1000UL;
+                        if (dt >= 15 && dt <= 255)
                             calRlsTimeSum += dt;
                             calRlsTimeN += 1;
                    = uint8_t(calRlsTimeSum / calRlsTimeN);
-                }
-                else if (v < vprv2)
-                {
-                    // We're still accelerating, and we haven't crossed the zero
-                    // point yet - stay in phase 1.  (Note that forward motion is
-                    // negative velocity, so accelerating means that the new 
-                    // velocity is more negative than the previous one, which
-                    // is to say numerically less than - that's why the test
-                    // for acceleration is the seemingly backwards 'v < vprv'.)
-                    // If we've been accelerating for at least 20ms, we're probably
-                    // really doing a release.  Jump back to the recent local
-                    // maximum where the release *really* started.  This is always
-                    // a bit before we started seeing sustained accleration, because
-                    // the plunger motion for the first few milliseconds is too slow
-                    // for our sensor precision to reliably detect acceleration.
-                    if (f1.pos != 0)
-                    {
-                        // we have a reset point - freeze there
-                        z = f1.pos;
-                    }
-                    else if (uint32_t(r.t - f1.t) >= 20000UL)
-                    {
-                        // it's been long enough - set a reset point.
-                        f1.pos = z = histLocalMax(r.t, 50000UL);
-                    }
+                    // Figure the "bounce" position as forward of the park
+                    // position by 1/6 of the starting retraction distance.
+                    // This simulates the momentum of the plunger compressing
+                    // the barrel spring on the rebound.  The barrel spring
+                    // can compress by about 1/6 of the maximum retraction 
+                    // distance, so we'll simply treat its compression as
+                    // proportional to the retraction.  (It might be more
+                    // realistic to use a slightly higher value here, maybe
+                    // 1/4 or 1/3 or the retraction distance, capping it at
+                    // a maximum of 1/6, because the real plunger probably 
+                    // compresses the barrel spring by 100% with less than 
+                    // 100% retraction.  But that won't affect the physics
+                    // meaningfully, just the animation, and the effect is
+                    // small in any case.)
+                    z = f0.pos = -f0.pos / 6;
+                    // reset the starting time for this phase
+                    f0.t = r.t;
-                    // We're not accelerating.  Cancel the firing event.
-                    firingMode(0);
-                    calState = 1;
+                    // check for motion since the start of the firing event
+                    const uint32_t dt = uint32_t(r.t - f0.t);
+                    const uint32_t dt2 = dt*dt;  // dt^2
+                    if (dt < 50000 
+                        && r.pos < f0.pos - int((f0.pos*acc2*uint64_t(dt2)) >> 48))
+                    {
+                        // It's moving fast enough to still be in a release
+                        // motion.  Continue reporting the start position, and
+                        // stay in the first release phase.
+                        z = f0.pos;
+                    }
+                    else
+                    {
+                        // It's not moving fast enough to be a release
+                        // motion.  Return to the default state.
+                        firingMode(0);
+                        calState = 1;
+                    }
             case 2:
-                // Phase 2 - start of synthetic firing event.  Report the fake
-                // bounce for 25ms.  VP polls the joystick about every 10ms, so 
-                // this should be enough time to guarantee that VP sees this
-                // report at least once.
-                if (uint32_t(r.t - f2.t) < 25000UL)
+                // Firing mode, holding at forward compression position.
+                // Hold here for 25ms.
+                if (uint32_t(r.t - f0.t) < 25000)
-                    // report the bounce position
-                    z = f2.pos;
+                    // stay here for now
+                    z = f0.pos;
-                    // it's been long enough - switch to phase 3, where we
-                    // report the park position until the real plunger comes
-                    // to rest
+                    // advance to the next phase, where we report the park
+                    // position until the plunger comes to rest
                     z = 0;
-                    // set the start of the "stability window" to the rest position
-                    f3s.t = r.t;
-                    f3s.pos = 0;
-                    // set the start of the "retraction window" to the actual position
-                    f3r = r;
+                    // remember when we started
+                    f0.t = r.t;
             case 3:
-                // Phase 3 - in synthetic firing event.  Report the park position
-                // until the plunger position stabilizes.  Left to its own devices, 
-                // the plunger will usualy bounce off the barrel spring several 
-                // times before coming to rest, so we'll see oscillating motion
-                // for a second or two.  In the simplest case, we can aimply wait
-                // for the plunger to stop moving for a short time.  However, the
-                // player might intervene by pulling the plunger back again, so
-                // watch for that motion as well.  If we're just bouncing freely,
-                // we'll see the direction change frequently.  If the player is
-                // moving the plunger manually, the direction will be constant
-                // for longer.
-                if (v >= 0)
+                // Firing event, holding at park position.  Stay here for
+                // a few moments so that the PC client can simulate the
+                // full release motion, then return to real readings.
+                if (uint32_t(r.t - f0.t) < 250000)
-                    // We're moving back (or standing still).  If this has been
-                    // going on for a while, the user must have taken control.
-                    if (uint32_t(r.t - f3r.t) > 65000UL)
-                    {
-                        // user has taken control - cancel firing mode
-                        firingMode(0);
-                        break;
-                    }
+                    // stay here a while longer
+                    z = 0;
-                    // forward motion - reset retraction window
-                    f3r.t = r.t;
-                }
-                // Check if we're close to the last starting point.  The joystick
-                // positive axis range (0..4096) covers the retraction distance of 
-                // about 2.5", so 1" is about 1638 joystick units, hence 1/16" is
-                // about 100 units.
-                if (abs(r.pos - f3s.pos) < 100)
-                {
-                    // It's at roughly the same position as the starting point.
-                    // Consider it stable if this has been true for 300ms.
-                    if (uint32_t(r.t - f3s.t) > 300000UL)
-                    {
-                        // we're done with the firing event
-                        firingMode(0);
-                    }
-                    else
-                    {
-                        // it's close to the last position but hasn't been
-                        // here long enough; stay in firing mode and continue
-                        // to report the park position
-                        z = 0;
-                    }
-                }
-                else
-                {
-                    // It's not close enough to the last starting point, so use
-                    // this as a new starting point, and stay in firing mode.
-                    f3s = r;
-                    z = 0;
+                    // it's been long enough - return to normal mode
+                    firingMode(0);
-            // save the velocity reading for next time
-            vprv2 = vprv;
-            vprv = v;
             // Check for auto-zeroing, if enabled
             if ((cfg.plunger.autoZero.flags & PlungerAutoZeroEnabled) != 0)
@@ -4837,10 +4887,8 @@
-            // add the new reading to the history
-            hist[histIdx] = r;
-            if (++histIdx >= countof(hist))
-                histIdx = 0;
+            // this new reading becomes the previous reading for next time
+            prv = r;
@@ -4851,9 +4899,6 @@
         return z;
-    // get the timestamp of the current joystick report (microseconds)
-    uint32_t getTimestamp() const { return nthHist(0).t; }
     // Set calibration mode on or off
     void setCalMode(bool f) 
@@ -4942,6 +4987,12 @@
     bool isFiring() { return firing == 3; }
+    // current reported joystick reading
+    int z;
+    // previous reading
+    PlungerReading prv;
     // Calibration state.  During calibration mode, we watch for release
     // events, to measure the time it takes to complete the release
     // motion; and we watch for the plunger to come to reset after a
@@ -4978,105 +5029,21 @@
         firing = m;
-    // Find the most recent local maximum in the history data, up to
-    // the given time limit.
-    int histLocalMax(uint32_t tcur, uint32_t dt)
-    {
-        // start with the prior entry
-        int idx = (histIdx == 0 ? countof(hist) : histIdx) - 1;
-        int hi = hist[idx].pos;
-        // scan backwards for a local maximum
-        for (int n = countof(hist) - 1 ; n > 0 ; idx = (idx == 0 ? countof(hist) : idx) - 1)
-        {
-            // if this isn't within the time window, stop
-            if (uint32_t(tcur - hist[idx].t) > dt)
-                break;
-            // if this isn't above the current hith, stop
-            if (hist[idx].pos < hi)
-                break;
-            // this is the new high
-            hi = hist[idx].pos;
-        }
-        // return the local maximum
-        return hi;
-    }
-    // velocity at previous reading, and the one before that
-    int vprv, vprv2;
-    // Circular buffer of recent readings.  We keep a short history
-    // of readings to analyze during firing events.  We can only identify
-    // a firing event once it's somewhat under way, so we need a little
-    // retrospective information to accurately determine after the fact
-    // exactly when it started.  We throttle our readings to no more
-    // than one every 1ms, so we have at least N*1ms of history in this
-    // array.
-    PlungerReading hist[32];
-    int histIdx;
-    // get the nth history item (0=last, 1=2nd to last, etc)
-    inline const PlungerReading &nthHist(int n) const
-    {
-        // histIdx-1 is the last written; go from there
-        n = histIdx - 1 - n;
-        // adjust for wrapping
-        if (n < 0)
-            n += countof(hist);
-        // return the item
-        return hist[n];
-    }
     // Firing event state.
-    //   0 - Default state.  We report the real instantaneous plunger 
-    //       position to the joystick interface.
-    //
-    //   1 - Moving forward
+    //   0 - Default state: not in firing event.  We report the true
+    //       instantaneous plunger position to the joystick interface.
-    //   2 - Accelerating
+    //   1 - Moving forward at release speed
-    //   3 - Firing.  We report the rest position for a minimum interval,
-    //       or until the real plunger comes to rest somewhere.
+    //   2 - Firing - reporting the bounce position
+    //
+    //   3 - Firing - reporting the park position
     int firing;
-    // Position/timestamp at start of firing phase 1.  When we see a
-    // sustained forward acceleration, we freeze joystick reports at
-    // the recent local maximum, on the assumption that this was the
-    // start of the release.  If this is zero, it means that we're
-    // monitoring accelerating motion but haven't seen it for long
-    // enough yet to be confident that a release is in progress.
-    PlungerReading f1;
-    // Position/timestamp at start of firing phase 2.  The position is
-    // the fake "bounce" position we report during this phase, and the
-    // timestamp tells us when the phase began so that we can end it
-    // after enough time elapses.
-    PlungerReading f2;
-    // Position/timestamp of start of stability window during phase 3.
-    // We use this to determine when the plunger comes to rest.  We set
-    // this at the beginning of phase 3, and then reset it when the 
-    // plunger moves too far from the last position.
-    PlungerReading f3s;
-    // Position/timestamp of start of retraction window during phase 3.
-    // We use this to determine if the user is drawing the plunger back.
-    // If we see retraction motion for more than about 65ms, we assume
-    // that the user has taken over, because we should see forward
-    // motion within this timeframe if the plunger is just bouncing
-    // freely.
-    PlungerReading f3r;
-    // next Z value to report to the joystick interface (in joystick 
-    // distance units)
-    int z;
+    // Starting position for current firing mode phase
+    PlungerReading f0;
 // plunger reader singleton
@@ -5574,10 +5541,9 @@
         // in a variable-dependent format.
-        // If updating the jitter window (variable 19), apply it immediately
-        // to the plunger sensor object
-        if (data[1] == 19)
-            plungerSensor->setJitterWindow(cfg.plunger.jitterWindow);
+        // notify the plunger, so that it can update relevant variables
+        // dynamically
+        plungerSensor->onConfigChange(data[1], cfg);
     else if (data[0] == 67)
@@ -5775,6 +5741,9 @@
     // set up the TLC5940 interface, if these chips are present
+    // initialize the TLC5916 interface, if these chips are present
+    init_tlc59116(cfg);
     // set up 74HC595 interface, if these chips are present
@@ -5787,7 +5756,7 @@
     // start the TLC5940 refresh cycle clock
     if (tlc5940 != 0)
     // Assume that nothing uses keyboard keys.  We'll check for keyboard
     // usage when initializing the various subsystems that can send keys
     // (buttons, IR).  If we find anything that does, we'll create the
@@ -5927,6 +5896,8 @@
     if (hc595 != 0)
+    if (tlc59116 != 0)
+        tlc59116->enable(true);
     // start the LedWiz flash cycle timer
@@ -5985,6 +5956,10 @@
         // send TLC5940 data updates if applicable
         if (tlc5940 != 0)
+        // send TLC59116 data updates
+        if (tlc59116 != 0)
+            tlc59116->send();
         // collect diagnostic statistics, checkpoint 1
         IF_DIAG(mainLoopIterCheckpt[1] += mainLoopTimer.read_us();)
@@ -6255,6 +6230,8 @@
                     // the power first comes on.
                     if (tlc5940 != 0)
+                    if (tlc59116 != 0)
+                        tlc59116->enable(false);
                     if (hc595 != 0)
@@ -6263,7 +6240,7 @@
         // if we have a reboot timer pending, check for completion
         if (saveConfigFollowupTimer.isRunning() 
-            && > saveConfigFollowupTime)
+            && saveConfigFollowupTimer.read_us() > saveConfigFollowupTime*1000000UL)
             // if a reboot is pending, execute it now
             if (saveConfigRebootPending)
@@ -6323,6 +6300,10 @@
                 // send TLC5940 data if necessary
                 if (tlc5940 != 0)
+                // update TLC59116 outputs
+                if (tlc59116 != 0)
+                    tlc59116->send();
                 // show a diagnostic flash every couple of seconds
                 if (diagTimer.read_us() > 2000000)
@@ -6364,10 +6345,9 @@
             // Enable peripheral chips and update them with current output data
             if (tlc5940 != 0)
-            {
-                tlc5940->update(true);
-            }
+            if (tlc59116 != 0)
+                tlc59116->enable(true);
             if (hc595 != 0)