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

/media/uploads/mjr/pinscape_no_background_small_L7Miwr6.jpg

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 vpforums.org. Also visit my Pinscape Resources page for more about this project and other virtual pinball projects I'm working on.

Downloads

  • 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.

Documentation

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 mouser.com 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 Aliexpress.com 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.

Revision:
77:0b96f6867312
Parent:
76:7f5912b6340e
Child:
78:1e00b3fa11af
--- a/main.cpp	Fri Feb 03 20:50:02 2017 +0000
+++ b/main.cpp	Fri Mar 17 22:02:08 2017 +0000
@@ -52,9 +52,10 @@
 //    that's supported, along with physical mounting and wiring details, can be found
 //    in the Build Guide.
 //
-//    Note 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.
+//    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.
 //
 //    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
@@ -75,12 +76,11 @@
 //    let it shoot forward too far.  We want to measure the range from the park
 //    position to the fully retracted position only.)
 //
-//  - Button input wiring.  24 of the KL25Z's GPIO ports are mapped as digital inputs
-//    for buttons and switches.  You can wire each input to a physical pinball-style
-//    button or switch, such as flipper buttons, Start buttons, coin chute switches,
-//    tilt bobs, and service buttons.  Each button can be configured to be reported
-//    to the PC as a joystick button or as a keyboard key (you can select which key
-//    is used for each button).
+//  - Button input wiring.  You can assign GPIO ports as inputs for physical
+//    pinball-style buttons, such as flipper buttons, a Start button, coin
+//    chute switches, tilt bobs, and service panel buttons.  You can configure
+//    each button input to report a keyboard key or joystick button press to
+//    the PC when the physical button is pushed.
 //
 //  - LedWiz emulation.  The KL25Z can pretend to be an LedWiz device.  This lets
 //    you connect feedback devices (lights, solenoids, motors) to GPIO ports on the 
@@ -140,9 +140,26 @@
 //    power to the cabinet comes on, with a configurable delay timer.  This feature
 //    is for TVs that don't turn themselves on automatically when first plugged in.
 //    To use this feature, you have to build some external circuitry to allow the
-//    software to sense the power supply status, and you have to run wires to your
-//    TV's on/off button, which requires opening the case on your TV.  The Build
-//    Guide has details on the necessary circuitry and connections to the TV.
+//    software to sense the power supply status.  The Build Guide has details 
+//    on the necessary circuitry.  You can use this to switch your TV on via a
+//    hardwired connection to the TV's "on" button, which requires taking the
+//    TV apart to gain access to its internal wiring, or optionally via the IR
+//    remote control transmitter feature below.
+//
+//  - Infrared (IR) remote control receiver and transmitter.  You can attach an
+//    IR LED and/or an IR sensor (we recommend the TSOP384xx series) to make the
+//    KL25Z capable of sending and/or receiving IR remote control signals.  This
+//    can be used with the TV ON feature above to turn your TV(s) on when the
+//    system power comes on by sending the "on" command to them via IR, as though
+//    you pressed the "on" button on the remote control.  The sensor lets the
+//    Pinscape software learn the IR codes from your existing remotes, in the
+//    same manner as a handheld universal remote control, and the IR LED lets
+//    it transmit learned codes.  The sensor can also be used to receive codes
+//    during normal operation and turn them into PC keystrokes; this lets you
+//    access extra commands on the PC without adding more buttons to your
+//    cabinet.  The IR LED can also be used to transmit other codes when you
+//    press selected cabinet buttons, allowing you to assign cabinet buttons
+//    to send IR commands to your cabinet TV or other devices.
 //
 //
 //
@@ -212,6 +229,9 @@
 #include "potSensor.h"
 #include "nullSensor.h"
 #include "TinyDigitalIn.h"
+#include "IRReceiver.h"
+#include "IRTransmitter.h"
+#include "NewPwm.h"
 
 
 #define DECL_EXTERNS
@@ -313,83 +333,73 @@
 // left over after counting the static writable data and reserving space
 // for a reasonable stack.  I haven't looked at the mbed malloc to see why 
 // they're so stingy, but it appears from empirical testing that we can 
-// create a static array up to about 9K before things get crashy.
-
-// Dynamic memory pool.  We'll reserve space for all dynamic 
-// allocations by creating a simple C array of bytes.  The size
-// of this array is the maximum number of bytes we can allocate
-// with malloc or operator 'new'.
+// create a static array up to about 8K before things get crashy.
+
+
+// halt with a diagnostic display if we run out of memory
+void HaltOutOfMem()
+{
+    printf("\r\nOut Of Memory\r\n");
+    // halt with the diagnostic display (by looping forever)
+    for (;;)
+    {
+        diagLED(1, 0, 0);
+        wait_us(200000);
+        diagLED(1, 0, 1);
+        wait_us(200000);
+    }
+}
+
+#if 0//$$$
+// Memory pool.  We allocate two blocks at fixed addresses: one for
+// the malloc heap, and one for the native stack.  
 //
-// The maximum safe size for this array is, in essence, the
-// amount of physical KL25Z RAM left over after accounting for
-// static data throughout the rest of the program, the run-time
-// stack, and any other space reserved for compiler or MCU
-// overhead.  Unfortunately, it's not straightforward to
-// determine this analytically.  The big complication is that
-// the minimum stack size isn't easily predictable, as the stack
-// grows according to what the program does.  In addition, the
-// mbed platform tools don't give us detailed data on the
-// compiler/linker memory map.  All we get is a generic total
-// RAM requirement, which doesn't necessarily account for all
-// overhead (e.g., gaps inserted to get proper alignment for
-// particular memory blocks).  
+// We allocate the stack block at the very top of memory.  This is what 
+// the mbed startup code does anyway, so we don't actually ever move the 
+// stack pointer into this area ourselves.  The point of this block is
+// to reserve space with the linker, so that it won't put any other static
+// data here in this region.
 //
-// A very rough estimate: the total RAM size reported by the 
-// linker is about 3.5K (currently - that can obviously change 
-// as the project evolves) out of 16K total.  Assuming about a 
-// 3K stack, that leaves in the ballpark of 10K.  Empirically,
-// that seems pretty close.  In testing, we start to see some
-// instability at 10K, while 9K seems safe.  To be conservative,
-// we'll reduce this to 8K.
+// The heap block goes just below the stack block.  This is a contiguous 
+// block of bytes from which we allocate blocks for malloc() and 'operator 
+// new' requests.
 //
-// Our measured total usage in the base configuration (22 GPIO
-// output ports, TSL1410R plunger sensor) is about 4000 bytes.
-// A pretty fully decked-out configuration (121 output ports,
-// with 8 TLC5940 chips and 3 74HC595 chips, plus the TSL1412R
-// sensor with the higher pixel count, and all expansion board
-// features enabled) comes to about 6700 bytes.  That leaves
-// us with about 1.5K free out of our 8K, so we still have a 
-// little more headroom for future expansion.
-//
-// For comparison, the standard mbed malloc() runs out of
-// memory at about 6K.  That's what led to this custom malloc:
-// we can just fit the base configuration into that 4K, but
-// it's not enough space for more complex setups.  There's
-// still a little room for squeezing out unnecessary space
-// from the mbed library code, but at this point I'd prefer
-// to treat that as a last resort, since it would mean having
-// to fork private copies of the libraries.
-static const size_t XMALLOC_POOL_SIZE = 8*1024;
-static char xmalloc_pool[XMALLOC_POOL_SIZE];
+// WARNING!  When adding static data, be sure to check the build statistics
+// to ensure that static data fits in the available RAM.  The linker doesn't
+// seem to make such a check on its own, so you might not see an error if
+// added data pushes us past the 16K limit.
+
+// KL25Z address of top of RAM (one byte past end of RAM)
+const uint32_t TOP_OF_RAM = 0x20003000UL;
+
+// malloc pool size
+const size_t XMALLOC_POOL_SIZE = 8*1024;
+
+// stack size
+const size_t XMALLOC_STACK_SIZE = 2*1024;
+
+// figure the fixed locations of the malloc pool and stack: the stack goes
+// at the very top of RAM, and the malloc pool goes just below the stack
+const uint32_t XMALLOC_STACK_BASE = TOP_OF_RAM - XMALLOC_STACK_SIZE;
+const uint32_t XMALLOC_POOL_BASE = XMALLOC_STACK_BASE - XMALLOC_POOL_SIZE;
+
+// allocate the pools - use __attribute__((at)) to give them fixed addresses
+static char xmalloc_stack[XMALLOC_STACK_SIZE] __attribute__((at(XMALLOC_STACK_BASE)));
+static char xmalloc_pool[XMALLOC_POOL_SIZE] __attribute__((at(XMALLOC_POOL_BASE)));
+
+// malloc pool free pointer and space remaining
 static char *xmalloc_nxt = xmalloc_pool;
 static size_t xmalloc_rem = XMALLOC_POOL_SIZE;
     
+// allocate from our pool
 void *xmalloc(size_t siz)
 {
     // align to a 4-byte increment
     siz = (siz + 3) & ~3;
     
-    // If insufficient memory is available, halt and show a fast red/purple 
-    // diagnostic flash.  We don't want to return, since we assume throughout
-    // the program that all memory allocations must succeed.  Note that this
-    // is generally considered bad programming practice in applications on
-    // "real" computers, but for the purposes of this microcontroller app,
-    // there's no point in checking for failed allocations individually
-    // because there's no way to recover from them.  It's better in this 
-    // context to handle failed allocations as fatal errors centrally.  We
-    // can't recover from these automatically, so we have to resort to user
-    // intervention, which we signal with the diagnostic LED flashes.
+    // if we're out of memory, halt with a diagnostic display
     if (siz > xmalloc_rem)
-    {
-        // halt with the diagnostic display (by looping forever)
-        for (;;)
-        {
-            diagLED(1, 0, 0);
-            wait_us(200000);
-            diagLED(1, 0, 1);
-            wait_us(200000);
-        }
-    }
+        HaltOutOfMem();
 
     // get the next free location from the pool to return   
     char *ret = xmalloc_nxt;
@@ -401,8 +411,89 @@
     // return the allocated block
     return ret;
 };
-
-// our malloc() replacement
+#elif 1//$$$
+// For our custom malloc, we take advantage of the known layout of the
+// mbed library memory management.  The mbed library puts all of the
+// static read/write data at the low end of RAM; this includes the
+// initialized statics and the "ZI" (zero-initialized) statics.  The
+// malloc heap starts just after the last static, growing upwards as
+// memory is allocated.  The stack starts at the top of RAM and grows
+// downwards.  
+//
+// To figure out where the free memory starts, we simply call the system
+// malloc() to make a dummy allocation the first time we're called, and 
+// use the address it returns as the start of our free memory pool.  The
+// first malloc() call presumably returns the lowest byte of the pool in
+// the compiler RTL's way of thinking, and from what we know about the
+// mbed heap layout, we know everything above this point should be free,
+// at least until we reach the lowest address used by the stack.
+//
+// The ultimate size of the stack is of course dynamic and unpredictable.
+// In testing, it appears that we currently need a little over 1K.  To be
+// conservative, we'll reserve 2K for the stack, by taking it out of the
+// space at top of memory we consider fair game for malloc.
+//
+// Note that we could do this a little more low-level-ly if we wanted.
+// The ARM linker provides a pre-defined extern char[] variable named 
+// Image$$RW_IRAM1$$ZI$$Limit, which is always placed just after the
+// last static data variable.  In principle, this tells us the start
+// of the available malloc pool.  However, in testing, it doesn't seem
+// safe to use this as the start of our malloc pool.  I'm not sure why,
+// but probably something in the startup code (either in the C RTL or 
+// the mbed library) is allocating from the pool before we get control. 
+// So we won't use that approach.  Besides, that would tie us even more
+// closely to the ARM compiler.  With our malloc() probe approach, we're
+// at least portable to any compiler that uses the same basic memory
+// layout, with the heap above the statics and the stack at top of 
+// memory; this isn't universal, but it's very typical.
+
+static char *xmalloc_nxt = 0;
+size_t xmalloc_rem = 0;
+void *xmalloc(size_t siz)
+{
+    if (xmalloc_nxt == 0)
+    {
+        xmalloc_nxt = (char *)malloc(4);
+        xmalloc_rem = 0x20003000UL - 2*1024 - uint32_t(xmalloc_nxt);
+    }
+    
+    siz = (siz + 3) & ~3;
+    if (siz > xmalloc_rem)
+        HaltOutOfMem();
+        
+    char *ret = xmalloc_nxt;
+    xmalloc_nxt += siz;
+    xmalloc_rem -= siz;
+    
+    return ret;
+}
+#else //$$$
+extern char Image$$RW_IRAM1$$ZI$$Limit[]; // linker marker for top of ZI region
+static char *xmalloc_nxt = Image$$RW_IRAM1$$ZI$$Limit;
+const uint32_t xmallocMinStack = 2*1024;
+char *const TopOfRAM = (char *)0x20003000UL;
+uint16_t xmalloc_rem = uint16_t(TopOfRAM - Image$$RW_IRAM1$$ZI$$Limit - xmallocMinStack);
+void *xmalloc(size_t siz)
+{
+    // align to a 4-byte increment
+    siz = (siz + 3) & ~3;
+    
+    // check to ensure we're leaving enough stack free
+    if (xmalloc_nxt + siz > TopOfRAM - xmallocMinStack)
+        HaltOutOfMem();
+        
+    // get the next free location from the pool to return
+    char *ret = xmalloc_nxt;
+    
+    // advance past the allocated memory
+    xmalloc_nxt += siz;
+    xmalloc_rem -= siz;
+    
+    // return the allocated block
+    printf("malloc(%d) -> %lx\r\n", siz, ret);
+    return ret;
+}
+#endif//$$$
 
 // Overload operator new to call our custom malloc.  This ensures that
 // all 'new' allocations throughout the program (including library code)
@@ -427,7 +518,8 @@
 // ---------------------------------------------------------------------------
 // utilities
 
-// floating point square of a number
+// int/float point square of a number
+inline int square(int x) { return x*x; }
 inline float square(float x) { return x*x; }
 
 // floating point rounding
@@ -439,10 +531,10 @@
 // Extended verison of Timer class.  This adds the ability to interrogate
 // the running state.
 //
-class Timer2: public Timer
+class ExtTimer: public Timer
 {
 public:
-    Timer2() : running(false) { }
+    ExtTimer() : running(false) { }
 
     void start() { running = true; Timer::start(); }
     void stop()  { running = false; Timer::stop(); }
@@ -455,13 +547,6 @@
 
 
 // --------------------------------------------------------------------------
-//
-// Reboot timer.  When we have a deferred reboot operation pending, we
-// set the target time and start the timer.
-Timer2 rebootTimer;
-long rebootTime_us;
-
-// --------------------------------------------------------------------------
 // 
 // USB product version number
 //
@@ -564,7 +649,8 @@
 DigitalOut *ledR, *ledG, *ledB;
 
 // Power on timer state for diagnostics.  We flash the blue LED when
-// nothing else is going on.  State 0-1 = off, 2-3 = on
+// nothing else is going on.  State 0-1 = off, 2-3 = on blue.  Also
+// show red when transmitting an LED signal, indicated by state 4.
 uint8_t powerTimerDiagState = 0;
 
 // Show the indicated pattern on the diagnostic LEDs.  0 is off, 1 is
@@ -578,7 +664,10 @@
     // if turning everything off, use the power timer state instead, 
     // applying it to the blue LED
     if (diagLEDState == 0)
-        b = (powerTimerDiagState >= 2);
+    {
+        b = (powerTimerDiagState == 2 || powerTimerDiagState == 3);
+        r = (powerTimerDiagState == 4);
+    }
         
     // set the new state
     if (ledR != 0 && r != -1) ledR->write(!r);
@@ -592,7 +681,7 @@
     diagLED(
         diagLEDState & 0x01,
         (diagLEDState >> 1) & 0x01,
-        (diagLEDState >> 1) & 0x02);
+        (diagLEDState >> 2) & 0x01);
 }
 
 // check an output port assignment to see if it conflicts with
@@ -868,8 +957,10 @@
     LwOut *out;
 };
 
-// global night mode flag
-static bool nightMode = false;
+// Global night mode flag.  To minimize overhead when reporting
+// the status, we set this to the status report flag bit for
+// night mode, 0x02, when engaged.
+static uint8_t nightMode = 0x00;
 
 // Noisy output.  This is a filter object that we layer on top of
 // a physical pin output.  This filter disables the port when night
@@ -1122,110 +1213,55 @@
     0.925022f, 0.935504f, 0.946062f, 0.956696f, 0.967407f, 0.978194f, 0.989058f, 1.000000f
 };
 
-// MyPwmOut - a slight customization of the base mbed PwmOut class.  The 
-// mbed version of PwmOut.write() resets the PWM cycle counter on every 
-// update.  That's problematic, because the counter reset interrupts the
-// cycle in progress, causing a momentary drop in brightness that's visible
-// to the eye if the output is connected to an LED or other light source.
-// This is especially noticeable when making gradual changes consisting of
-// many updates in a short time, such as a slow fade, because the light 
-// visibly flickers on every step of the transition.  This customized 
-// version removes the cycle reset, which makes for glitch-free updates 
-// and nice smooth fades.
+// Polled-update PWM output list
 //
-// Initially, I thought the counter reset in the mbed code was simply a
-// bug.  According to the KL25Z hardware reference, you update the duty
-// cycle by writing to the "compare values" (CvN) register.  There's no
-// hint that you should reset the cycle counter, and indeed, the hardware
-// goes out of its way to allow updates mid-cycle (as we'll see shortly).
-// They went to lengths specifically so that you *don't* have to reset
-// that counter.  And there's no comment in the mbed code explaining the
-// cycle reset, so it looked to me like something that must have been
-// added by someone who didn't read the manual carefully enough and didn't
-// test the result thoroughly enough to find the glitch it causes.
+// This is a workaround for a KL25Z hardware bug/limitation.  The bug (more
+// about this below) is that we can't write to a PWM output "value" register
+// more than once per PWM cycle; if we do, outputs after the first are lost.
+// The value register controls the duty cycle, so it's what you have to write
+// if you want to update the brightness of an output.
 //
-// After some experimentation, though, I've come to think the code was
-// added intentionally, as a workaround for a rather nasty KL25Z hardware
-// bug.   Whoever wrote the code didn't add any comments explaning why it's
-// there, so we can't know for sure, but it does happen to work around the 
-// bug, so it's a good bet the original programmer found the same hardware
-// problem and came up with the counter reset as an imperfect solution.
+// Our solution is to simply repeat all PWM updates periodically.  If a write
+// is lost on one cycle, it'll eventually be applied on a subseuqent periodic
+// update.  For low overhead, we do these repeat updates periodically during
+// the main loop.
 //
-// We'll get to the KL25Z hardware bug shortly, but first we need to look at
-// how the hardware is *supposed* to work.  The KL25Z is *supposed* to make
-// it super easy for software to do glitch-free updates of the duty cycle of 
-// a PWM channel.  With PWM hardware in general, you have to be careful to
-// update the duty cycle counter between grayscale cycles, beacuse otherwise
-// you might interrupt the cycle in progress and cause a brightness glitch.  
-// The KL25Z TPM simplifies this with a "staging" register for the duty
-// cycle counter.  At the end of each cycle, the TPM moves the value from
-// the staging register into its internal register that actually controls 
-// the duty cycle.  The idea is that the software can write a new value to
-// the staging register at any time, and the hardware will take care of
-// synchronizing the actual internal update with the grayscale cycle.  In
-// principle, this frees the software of any special timing considerations
-// for PWM updates.  
+// The mbed library has its own solution to this bug, but it creates a 
+// separate problem of its own.  The mbed solution is to write the value
+// register immediately, and then also reset the "count" register in the 
+// TPM unit containing the output.  The count reset truncates the current
+// PWM cycle, which avoids the hardware problem with more than one write per
+// cycle.  The problem is that the truncated cycle causes visible flicker if
+// the output is connected to an LED.  This is particularly noticeable during
+// fades, when we're updating the value register repeatedly and rapidly: an
+// attempt to fade from fully on to fully off causes rapid fluttering and 
+// flashing rather than a smooth brightness fade.
 //
-// Now for the bug.  The staging register works as advertised, except for
-// one little detail: it seems to be implemented as a one-element queue
-// that won't accept a new write until the existing value has been read.
-// The read only happens at the start of the new cycle.  So the effect is
-// that we can only write one update per cycle.  Any writes after the first
-// are simply dropped, lost forever.  That causes even worse problems than
-// the original glitch.  For example, if we're doing a fade-out, the last
-// couple of updates in the fade might get lost, leaving the output slightly
-// on at the end, when it's supposed to be completely off.
-//
-// The mbed workaround of resetting the cycle counter fixes the lost-update
-// problem, but it causes the constant glitching during fades.  So we need
-// a third way that works around the hardware problem without causing 
-// update glitches.
+// The hardware bug is a case of good intentions gone bad.  The hardware is
+// *supposed* to make it easy for software to avoid glitching during PWM
+// updates, by providing a staging register in front of the real value
+// register.  The software actually writes to the staging register, which
+// holds updates until the end of the cycle, at which point the hardware
+// automatically moves the value from the staging register into the real
+// register.  This ensures that the real register is always updated exactly
+// at a cycle boundary, which in turn ensures that there's no flicker when
+// values are updated.  A great design - except that it doesn't quite work.
+// The problem is that the staging register actually seems to be implemented
+// as a one-element FIFO in "stop when full" mode.  That is, when you write
+// the FIFO, it becomes full.  When the cycle ends and the hardware reads it
+// to move the staged value into the real register, the FIFO becomes empty.
+// But if you try to write the FIFO twice before the hardware reads it and
+// empties it, the second write fails, leaving the first value in the queue.
+// There doesn't seem to be any way to clear the FIFO from software, so you
+// just have to wait for the cycle to end before writing another update.
+// That more or less defeats the purpose of the staging register, whose whole
+// point is to free software from worrying about timing considerations with
+// updates.  It frees us of the need to align our timing on cycle boundaries,
+// but it leaves us with the need to limit writes to once per cycle.
 //
-// Here's my solution: we basically implement our own staging register,
-// using the same principle as the hardware staging register, but hopefully
-// with an implementation that actually works!  First, when we update a PWM 
-// output, we won't actually write the value to the hardware register.
-// Instead, we'll just stash it internally, effectively in our own staging
-// register (but actually just a member variable of this object).  Then
-// we'll periodically transfer these staged updates to the actual hardware 
-// registers, being careful to do this no more than once per PWM cycle.
-// One way to do this would be to use an interrupt handler that fires at
-// the end of the PWM cycle, but that would be fairly complex because we
-// have many (up to 10) PWM channels.  Instead, we'll just use polling:
-// we'll call a routine periodically in our main loop, and we'll transfer
-// updates for all of the channels that have been updated since the last
-// pass.  We can get away with this simple polling approach because the
-// hardware design *partially* works: it does manage to free us from the
-// need to synchronize updates with the exact end of a PWM cycle.  As long
-// as we do no more than one write per cycle, we're golden.  That's easy
-// to accomplish, too: all we need to do is make sure that our polling
-// interval is slightly longer than the PWM period.  That ensures that
-// we can never have two updates during one PWM cycle.  It does mean that
-// we might have zero updates on some cycles, causing a one-cycle delay
-// before an update is actually put into effect, but that shouldn't ever
-// be noticeable since the cycles are so short.  Specifically, we'll use
-// the mbed default 20ms PWM period, and we'll do our update polling 
-// every 25ms.
-class LessGlitchyPwmOut: public PwmOut
-{
-public:
-    LessGlitchyPwmOut(PinName pin) : PwmOut(pin) { }
-    
-    void write(float value)
-    {
-        // Update the counter without resetting the counter.
-        //
-        // NB: this causes problems if there are multiple writes in one
-        // PWM cycle: the first write will be applied and later writes 
-        // during the same cycle will be lost.  Callers must take care
-        // to limit writes to one per cycle.
-        *_pwm.CnV = uint32_t((*_pwm.MOD + 1) * value);
-    }
-};
-
-
-// Collection of PwmOut objects to update on each polling cycle.  The
-// KL25Z has 10 physical PWM channels, so we need at most 10 polled outputs.
+// So here we have our list of PWM outputs that need to be polled for updates.
+// The KL25Z hardware only has 10 PWM channels, so we only need a fixed set
+// of polled items.
 static int numPolledPwm;
 static class LwPwmOut *polledPwm[10];
 
@@ -1235,44 +1271,38 @@
 public:
     LwPwmOut(PinName pin, uint8_t initVal) : p(pin)
     {
-         // set the cycle time to 20ms
-         p.period_ms(20);
-         
-         // add myself to the list of polled outputs for periodic updates
-         if (numPolledPwm < countof(polledPwm))
+        // add myself to the list of polled outputs for periodic updates
+        if (numPolledPwm < countof(polledPwm))
             polledPwm[numPolledPwm++] = this;
-         
-         // set the initial value, and an explicitly different previous value
-         prv = ~initVal;
-         set(initVal);
+
+        // set the initial value
+        set(initVal);
     }
 
     virtual void set(uint8_t val) 
     {
-        // on set, just save the value for a later 'commit' 
+        // save the new value
         this->val = val;
+        
+        // commit it to the hardware
+        commit();
     }
 
     // handle periodic update polling
     void poll()
     {
-        // if the value has changed, commit it
-        if (val != prv)
-        {
-            prv = val;
-            commit(val);
-        }
+        commit();
     }
 
 protected:
-    virtual void commit(uint8_t v)
+    virtual void commit()
     {
         // write the current value to the PWM controller if it's changed
-        p.write(dof_to_pwm[v]);
+        p.glitchFreeWrite(dof_to_pwm[val]);
     }
     
-    LessGlitchyPwmOut p;
-    uint8_t val, prv;
+    NewPwmOut p;
+    uint8_t val;
 };
 
 // Gamma corrected PWM GPIO output.  This works exactly like the regular
@@ -1287,10 +1317,10 @@
     }
     
 protected:
-    virtual void commit(uint8_t v)
+    virtual void commit()
     {
         // write the current value to the PWM controller if it's changed
-        p.write(dof_to_gamma_pwm[v]);
+        p.glitchFreeWrite(dof_to_gamma_pwm[val]);
     }
 };
 
@@ -1896,6 +1926,454 @@
         hc595->update();
 }
 
+// ---------------------------------------------------------------------------
+//
+// IR Remote Control transmitter & receiver
+//
+
+// receiver
+IRReceiver *ir_rx;
+
+// transmitter
+IRTransmitter *ir_tx;
+
+// Mapping from IR commands slots in the configuration to "virtual button"
+// numbers on the IRTransmitter's "virtual remote".  To minimize RAM usage, 
+// we only create virtual buttons on the transmitter object for code slots 
+// that are configured for transmission, which includes slots used for TV
+// ON commands and slots that can be triggered by button presses.  This
+// means that virtual button numbers won't necessarily match the config
+// slot numbers.  This table provides the mapping:
+// IRConfigSlotToVirtualButton[n] = ir_tx virtual button number for 
+// configuration slot n
+uint8_t IRConfigSlotToVirtualButton[MAX_IR_CODES];
+uint8_t IRAdHocSlot;
+
+// IR mode timer.  In normal mode, this is the time since the last
+// command received; we use this to handle commands with timed effects,
+// such as sending a key to the PC.  In learning mode, this is the time
+// since we activated learning mode, which we use to automatically end
+// learning mode if a decodable command isn't received within a reasonable
+// amount of time.
+Timer IRTimer;
+
+// IR Learning Mode.  The PC enters learning mode via special function 65 12.
+// The states are:
+//
+//   0 -> normal operation (not in learning mode)
+//   1 -> learning mode; reading raw codes, no command read yet
+//   2 -> learning mode; command received, awaiting auto-repeat
+//   3 -> learning mode; done, command and repeat mode decoded
+//
+// When we enter learning mode, we reset IRTimer to keep track of how long
+// we've been in the mode.  This allows the mode to time out if no code is
+// received within a reasonable time.
+uint8_t IRLearningMode = 0;
+
+// Learning mode command received.  This stores the first decoded command
+// when in learning mode.  For some protocols, we can't just report the
+// first command we receive, because we need to wait for an auto-repeat to
+// determine what format the remote uses for repeats.  This stores the first
+// command while we await a repeat.  This is necessary for protocols that 
+// have "dittos", since some remotes for such protocols use the dittos and 
+// some don't; the only way to find out is to read a repeat code and see if 
+// it's a ditto or just a repeat of the full code.
+IRCommand learnedIRCode;
+
+// IR comkmand received, as a config slot index, 1..MAX_IR_CODES.
+// When we receive a command that matches one of our programmed commands, 
+// we note the slot here.  We also reset the IR timer so that we know how 
+// long it's been since the command came in.  This lets us handle commands 
+// with timed effects, such as PC key input.  Note that this is a 1-based 
+// index; 0 represents no command.
+uint8_t IRCommandIn = 0;
+
+// "Toggle bit" of last command.  Some IR protocols have a toggle bit
+// that distinguishes an auto-repeating key from a key being pressed
+// several times in a row.  This records the toggle bit of the last
+// command we received.
+uint8_t lastIRToggle = 0;
+
+// Are we in a gap between successive key presses?  When we detect that a 
+// key is being pressed multiple times rather than auto-repeated (which we 
+// can detect via a toggle bit in some protocols), we'll briefly stop sending 
+// the associated key to the PC, so that the PC likewise recognizes the 
+// distinct key press.  
+uint8_t IRKeyGap = false;
+
+// initialize
+void init_IR(Config &cfg, bool &kbKeys)
+{
+    PinName pin;
+    
+    // start the IR timer
+    IRTimer.start();
+    
+    // if there's a transmitter, set it up
+    if ((pin = wirePinName(cfg.IR.emitter)) != NC)
+    {
+        // no virtual buttons yet
+        int nVirtualButtons = 0;
+        memset(IRConfigSlotToVirtualButton, 0xFF, sizeof(IRConfigSlotToVirtualButton));
+        
+        // assign virtual buttons slots for TV ON codes
+        for (int i = 0 ; i < MAX_IR_CODES ; ++i)
+        {
+            if ((cfg.IRCommand[i].flags & IRFlagTVON) != 0)
+                IRConfigSlotToVirtualButton[i] = nVirtualButtons++;
+        }
+            
+        // assign virtual buttons for codes that can be triggered by 
+        // real button inputs
+        for (int i = 0 ; i < MAX_BUTTONS ; ++i)
+        {
+            // get the button
+            ButtonCfg &b = cfg.button[i];
+            
+            // check the unshifted button
+            int c = b.IRCommand - 1;
+            if (c >= 0 && c < MAX_IR_CODES 
+                && IRConfigSlotToVirtualButton[c] == 0xFF)
+                IRConfigSlotToVirtualButton[c] = nVirtualButtons++;
+                
+            // check the shifted button
+            c = b.IRCommand2 - 1;
+            if (c >= 0 && c < MAX_IR_CODES 
+                && IRConfigSlotToVirtualButton[c] == 0xFF)
+                IRConfigSlotToVirtualButton[c] = nVirtualButtons++;
+        }
+        
+        // allocate an additional virtual button for transmitting ad hoc
+        // codes, such as for the "send code" USB API function
+        IRAdHocSlot = nVirtualButtons++;
+            
+        // create the transmitter
+        ir_tx = new IRTransmitter(pin, nVirtualButtons);
+        
+        // program the commands into the virtual button slots
+        for (int i = 0 ; i < MAX_IR_CODES ; ++i)
+        {
+            // if this slot is assigned to a virtual button, program it
+            int vb = IRConfigSlotToVirtualButton[i];
+            if (vb != 0xFF)
+            {
+                IRCommandCfg &cb = cfg.IRCommand[i];
+                uint64_t code = cb.code.lo | (uint64_t(cb.code.hi) << 32);
+                bool dittos = (cb.flags & IRFlagDittos) != 0;
+                ir_tx->programButton(vb, cb.protocol, dittos, code);
+            }
+        }
+    }
+
+    // if there's a receiver, set it up
+    if ((pin = wirePinName(cfg.IR.sensor)) != NC)
+    {
+        // create the receiver
+        ir_rx = new IRReceiver(pin, 32);
+        
+        // connect the transmitter (if any) to the receiver, so that
+        // the receiver can suppress reception of our own transmissions
+        ir_rx->setTransmitter(ir_tx);
+        
+        // enable it
+        ir_rx->enable();
+        
+        // Check the IR command slots to see if any slots are configured
+        // to send a keyboard key on receiving an IR command.  If any are,
+        // tell the caller that we need a USB keyboard interface.
+        for (int i = 0 ; i < MAX_IR_CODES ; ++i)
+        {
+            IRCommandCfg &cb = cfg.IRCommand[i];
+            if (cb.protocol != 0
+                && (cb.keytype == BtnTypeKey || cb.keytype == BtnTypeMedia))
+            {
+                kbKeys = true;
+                break;
+            }
+        }
+    }
+}
+
+// Press or release a button with an assigned IR function.  'cmd'
+// is the command slot number (1..MAX_IR_CODES) assigned to the button.
+void IR_buttonChange(uint8_t cmd, bool pressed)
+{
+    // only proceed if there's an IR transmitter attached
+    if (ir_tx != 0)
+    {
+        // adjust the command slot to a zero-based index
+        int slot = cmd - 1;
+        
+        // press or release the virtual button
+        ir_tx->pushButton(IRConfigSlotToVirtualButton[slot], pressed);
+    }
+}
+
+// Process IR input
+void process_IR(Config &cfg, USBJoystick &js)
+{
+    // if there's no IR receiver attached, there's nothing to do
+    if (ir_rx == 0)
+        return;
+        
+    // Time out any received command
+    if (IRCommandIn != 0)
+    {
+        // Time out inter-key gap mode after 30ms; time out all 
+        // commands after 100ms.
+        uint32_t t = IRTimer.read_us();
+        if (t > 100000)
+            IRCommandIn = 0;
+        else if (t > 30000)
+            IRKeyGap = false;
+    }
+
+    // Check if we're in learning mode
+    if (IRLearningMode != 0)
+    {
+        // Learning mode.  Read raw inputs from the IR sensor and 
+        // forward them to the PC via USB reports, up to the report
+        // limit.
+        const int nmax = USBJoystick::maxRawIR;
+        uint16_t raw[nmax];
+        int n;
+        for (n = 0 ; n < nmax && ir_rx->processOne(raw[n]) ; ++n) ;
+        
+        // if we read any raw samples, report them
+        if (n != 0)
+            js.reportRawIR(n, raw);
+            
+        // check for a command
+        IRCommand c;
+        if (ir_rx->readCommand(c))
+        {
+            // check the current learning state
+            switch (IRLearningMode)
+            {
+            case 1:
+                // Initial state, waiting for the first decoded command.
+                // This is it.
+                learnedIRCode = c;
+                
+                // Check if we need additional information.  If the
+                // protocol supports dittos, we have to wait for a repeat
+                // to see if the remote actually uses the dittos, since
+                // some implementations of such protocols use the dittos
+                // while others just send repeated full codes.  Otherwise,
+                // all we need is the initial code, so we're done.
+                IRLearningMode = (c.hasDittos ? 2 : 3);
+                break;
+                
+            case 2:
+                // Code received, awaiting auto-repeat information.  If
+                // the protocol has dittos, check to see if we got a ditto:
+                //
+                // - If we received a ditto in the same protocol as the
+                //   prior command, the remote uses dittos.
+                //
+                // - If we received a repeat of the prior command (not a
+                //   ditto, but a repeat of the full code), the remote
+                //   doesn't use dittos even though the protocol supports
+                //   them.
+                //
+                // - Otherwise, it's not an auto-repeat at all, so we
+                //   can't decide one way or the other on dittos: start
+                //   over.
+                if (c.proId == learnedIRCode.proId
+                    && c.hasDittos
+                    && c.ditto)
+                {
+                    // success - the remote uses dittos
+                    IRLearningMode = 3;
+                }
+                else if (c.proId == learnedIRCode.proId
+                    && c.hasDittos
+                    && !c.ditto
+                    && c.code == learnedIRCode.code)
+                {
+                    // success - it's a repeat of the last code, so
+                    // the remote doesn't use dittos even though the
+                    // protocol supports them
+                    learnedIRCode.hasDittos = false;
+                    IRLearningMode = 3;
+                }
+                else
+                {
+                    // It's not a ditto and not a full repeat of the
+                    // last code, so it's either a new key, or some kind
+                    // of multi-code key encoding that we don't recognize.
+                    // We can't use this code, so start over.
+                    IRLearningMode = 1;
+                }
+                break;
+            }
+            
+            // If we ended in state 3, we've successfully decoded
+            // the transmission.  Report the decoded data and terminate
+            // learning mode.
+            if (IRLearningMode == 3)
+            {
+                // figure the flags: 
+                //   0x02 -> dittos
+                uint8_t flags = 0;
+                if (learnedIRCode.hasDittos)
+                    flags |= 0x02;
+                    
+                // report the code
+                js.reportIRCode(learnedIRCode.proId, flags, learnedIRCode.code);
+                    
+                // exit learning mode
+                IRLearningMode = 0;
+            }
+        }
+        
+        // time out of IR learning mode if it's been too long
+        if (IRLearningMode != 0 && IRTimer.read_us() > 10000000L)
+        {
+            // report the termination by sending a raw IR report with
+            // zero data elements
+            js.reportRawIR(0, 0);
+            
+            
+            // cancel learning mode
+            IRLearningMode = 0;
+        }
+    }
+    else
+    {
+        // Not in learning mode.  We don't care about the raw signals;
+        // just run them through the protocol decoders.
+        ir_rx->process();
+        
+        // Check for decoded commands.  Keep going until all commands
+        // have been read.
+        IRCommand c;
+        while (ir_rx->readCommand(c))
+        {
+            // We received a decoded command.  Determine if it's a repeat,
+            // and if so, try to determine whether it's an auto-repeat (due
+            // to the remote key being held down) or a distinct new press 
+            // on the same key as last time.  The distinction is significant
+            // because it affects the auto-repeat behavior of the PC key
+            // input.  An auto-repeat represents a key being held down on
+            // the remote, which we want to translate to a (virtual) key 
+            // being held down on the PC keyboard; a distinct key press on
+            // the remote translates to a distinct key press on the PC.
+            //
+            // It can only be a repeat if there's a prior command that
+            // hasn't timed out yet, so start by checking for a previous
+            // command.
+            bool repeat = false, autoRepeat = false;
+            if (IRCommandIn != 0)
+            {
+                // We have a command in progress.  Check to see if the
+                // new command is a repeat of the previous command.  Check
+                // first to see if it's a "ditto", which explicitly represents
+                // an auto-repeat of the last command.
+                IRCommandCfg &cmdcfg = cfg.IRCommand[IRCommandIn - 1];
+                if (c.ditto)
+                {
+                    // We received a ditto.  Dittos are always auto-
+                    // repeats, so it's an auto-repeat as long as the
+                    // ditto is in the same protocol as the last command.
+                    // If the ditto is in a new protocol, the ditto can't
+                    // be for the last command we saw, because a ditto
+                    // never changes protocols from its antecedent.  In
+                    // such a case, we must have missed the antecedent
+                    // command and thus don't know what's being repeated.
+                    repeat = autoRepeat = (c.proId == cmdcfg.protocol);
+                }
+                else
+                {
+                    // It's not a ditto.  The new command is a repeat if
+                    // it matches the protocol and command code of the 
+                    // prior command.
+                    repeat = (c.proId == cmdcfg.protocol 
+                              && uint32_t(c.code) == cmdcfg.code.lo
+                              && uint32_t(c.code >> 32) == cmdcfg.code.hi);
+                              
+                    // If the command is a repeat, try to determine whether
+                    // it's an auto-repeat or a new press on the same key.
+                    // If the protocol uses dittos, it's definitely a new
+                    // key press, because an auto-repeat would have used a
+                    // ditto.  For a protocol that doesn't use dittos, both
+                    // an auto-repeat and a new key press just send the key
+                    // code again, so we can't tell the difference based on
+                    // that alone.  But if the protocol has a toggle bit, we
+                    // can tell by the toggle bit value: a new key press has
+                    // the opposite toggle value as the last key press, while 
+                    // an auto-repeat has the same toggle.  Note that if the
+                    // protocol doesn't use toggle bits, the toggle value
+                    // will always be the same, so we'll simply always treat
+                    // any repeat as an auto-repeat.  Many protocols simply
+                    // provide no way to distinguish the two, so in such
+                    // cases it's consistent with the native implementations
+                    // to treat any repeat as an auto-repeat.
+                    autoRepeat = 
+                        repeat 
+                        && !(cmdcfg.flags & IRFlagDittos)
+                        && c.toggle == lastIRToggle;
+                }
+            }
+            
+            // Check to see if it's a repeat of any kind
+            if (repeat)
+            {
+                // It's a repeat.  If it's not an auto-repeat, it's a
+                // new distinct key press, so we need to send the PC a
+                // momentary gap where we're not sending the same key,
+                // so that the PC also recognizes this as a distinct
+                // key press event.
+                if (!autoRepeat)
+                    IRKeyGap = true;
+                    
+                // restart the key-up timer
+                IRTimer.reset();
+            }
+            else if (c.ditto)
+            {
+                // It's a ditto, but not a repeat of the last command.
+                // But a ditto doesn't contain any information of its own
+                // on the command being repeated, so given that it's not
+                // our last command, we can't infer what command the ditto
+                // is for and thus can't make sense of it.  We have to
+                // simply ignore it and wait for the sender to start with
+                // a full command for a new key press.
+                IRCommandIn = 0;
+            }
+            else
+            {
+                // It's not a repeat, so the last command is no longer
+                // in effect (regardless of whether we find a match for
+                // the new command).
+                IRCommandIn = 0;
+                
+                // Check to see if we recognize the new command, by
+                // searching for a match in our learned code list.
+                for (int i = 0 ; i < MAX_IR_CODES ; ++i)
+                {
+                    // if the protocol and command code from the code
+                    // list both match the input, it's a match
+                    IRCommandCfg &cmdcfg = cfg.IRCommand[i];
+                    if (cmdcfg.protocol == c.proId 
+                        && cmdcfg.code.lo == uint32_t(c.code)
+                        && cmdcfg.code.hi == uint32_t(c.code >> 32))
+                    {
+                        // Found it!  Make this the last command, and 
+                        // remember the starting time.
+                        IRCommandIn = i + 1;
+                        lastIRToggle = c.toggle;
+                        IRTimer.reset();
+                        
+                        // no need to keep searching
+                        break;
+                    }
+                }
+            }
+        }
+    }
+}
+
 
 // ---------------------------------------------------------------------------
 //
@@ -2089,9 +2567,6 @@
 // initialize the button inputs
 void initButtons(Config &cfg, bool &kbKeys)
 {
-    // presume we'll find no keyboard keys
-    kbKeys = false;
-    
     // presume no shift key
     shiftButton.index = -1;
     
@@ -2208,7 +2683,109 @@
      0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0, // D0-DF
      0,  0,  1,  0,  0,  0,  0,  0,  0,  2,  4,  0,  0,  0,  0,  0, // E0-EF
      0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0,  0  // F0-FF
- };
+};
+ 
+// Keyboard key/joystick button state.  processButtons() uses this to 
+// build the set of key presses to report to the PC based on the logical
+// states of the button iputs.
+struct KeyState
+{
+    KeyState()
+    {
+        // zero all members
+        memset(this, 0, sizeof(*this));
+    }
+    
+    // Keyboard media keys currently pressed.  This is a bit vector in
+    // the format used in our USB keyboard reports (see USBJoystick.cpp).
+    uint8_t mediakeys;
+         
+    // Keyboard modifier (shift) keys currently pressed.  This is a bit 
+    // vector in the format used in our USB keyboard reports (see
+    // USBJoystick.cpp).
+    uint8_t modkeys;
+     
+    // Regular keyboard keys currently pressed.  Each element is a USB
+    // key code, or 0 for empty slots.  Note that the USB report format
+    // theoretically allows a flexible size limit, but the Windows KB
+    // drivers have a fixed limit of 6 simultaneous keys (and won't
+    // accept reports with more), so there's no point in making this
+    // flexible; we'll just use the fixed size dictated by Windows.
+    uint8_t keys[7];
+     
+    // number of valid entries in keys[] array
+    int nkeys;
+     
+    // Joystick buttons pressed, as a bit vector.  Bit n (1 << n)
+    // represents joystick button n, n in 0..31, with 0 meaning 
+    // unpressed and 1 meaning pressed.
+    uint32_t js;
+    
+    
+    // Add a key press.  'typ' is the button type code (ButtonTypeXxx),
+    // and 'val' is the value (the meaning of which varies by type code).
+    void addKey(uint8_t typ, uint8_t val)
+    {
+        // add the key according to the type
+        switch (typ)
+        {
+        case BtnTypeJoystick:
+            // joystick button
+            js |= (1 << (val - 1));
+            break;
+            
+        case BtnTypeKey:
+            // Keyboard key.  The USB keyboard report encodes regular
+            // keys and modifier keys separately, so we need to check
+            // which type we have.  Note that past versions mapped the 
+            // Keyboard Volume Up, Keyboard Volume Down, and Keyboard 
+            // Mute keys to the corresponding Media keys.  We no longer
+            // do this; instead, we have the separate BtnTypeMedia for
+            // explicitly using media keys if desired.
+            if (val >= 0xE0 && val <= 0xE7)
+            {
+                // It's a modifier key.  These are represented in the USB 
+                // reports with a bit mask.  We arrange the mask bits in
+                // the same order as the scan codes, so we can figure the
+                // appropriate bit with a simple shift.
+                modkeys |= (1 << (val - 0xE0));
+            }
+            else
+            {
+                // It's a regular key.  Make sure it's not already in the 
+                // list, and that the list isn't full.  If neither of these 
+                // apply, add the key to the key array.
+                if (nkeys < 7)
+                {
+                    bool found = false;
+                    for (int i = 0 ; i < nkeys ; ++i)
+                    {
+                        if (keys[i] == val)
+                        {
+                            found = true;
+                            break;
+                        }
+                    }
+                    if (!found)
+                        keys[nkeys++] = val;
+                }
+            }
+            break;
+
+        case BtnTypeMedia:
+            // Media control key.  The media keys are mapped in the USB
+            // report to bits, whereas the key codes are specified in the
+            // config with their USB usage numbers.  E.g., the config val
+            // for Media Next Track is 0xB5, but we encode this in the USB
+            // report as bit 0x08.  The mediaKeyMap[] table translates
+            // from the USB usage number to the mask bit.  If the key isn't
+            // among the subset we support, the mapped bit will be zero, so
+            // the "|=" will have no effect and the key will be ignored.
+            mediakeys |= mediaKeyMap[val];
+            break;                                
+        }
+    }
+};
 
 
 // Process the button state.  This sets up the joystick, keyboard, and
@@ -2217,16 +2794,8 @@
 // mapped to special device functions (e.g., Night Mode).
 void processButtons(Config &cfg)
 {
-    // start with an empty list of USB key codes
-    uint8_t modkeys = 0;
-    uint8_t keys[7] = { 0, 0, 0, 0, 0, 0, 0 };
-    int nkeys = 0;
-    
-    // clear the joystick buttons
-    uint32_t newjs = 0;
-    
-    // start with no media keys pressed
-    uint8_t mediakeys = 0;
+    // key state
+    KeyState ks;
     
     // calculate the time since the last run
     uint32_t dt = buttonTimer.read_us();
@@ -2275,6 +2844,9 @@
     ButtonState *bs = buttonState;
     for (int i = 0 ; i < nButtons ; ++i, ++bs)
     {
+        // get the config entry for the button
+        ButtonCfg *bc = &cfg.button[bs->cfgIndex];
+
         // Check the button type:
         //   - shift button
         //   - pulsed button
@@ -2355,45 +2927,78 @@
             // not a pulse switch - the logical state is the same as the physical state
             bs->logState = bs->physState;
         }
+        
+        // Determine if we're going to use the shifted version of the
+        // button.  We're using the shifted version if the shift button
+        // is down AND the button has ANY shifted meaning - a key assignment,
+        // a Night Mode toggle assignment, or an IR code.  If the button 
+        // doesn't have any meaning at all in shifted mode, the base version
+        // of the button applies whether or not the shift button is down.
+        //
+        // Note that the test for Night Mode is a bit tricky.  The shifted
+        // version of the button is the Night Mode toggle if the button matches
+        // the Night Mode button index, AND its flags are set with "toggle
+        // mode ON" (bit 0x02 is on) and "switch mode OFF" (bit 0x01 is off).
+        // That means the button flags & 0x03 must equal 0x02.
+        bool useShift = 
+            (shiftButton.state != 0
+             && (bc->typ2 != BtnTypeNone
+                 || bc->IRCommand2 != 0
+                 || (cfg.nightMode.btn == i+1 && (cfg.nightMode.flags & 0x03) == 0x02)));
+                 
+        // If we're using the shift function, and no other button has used
+        // the shift function yet (shift state 1: "shift button is down but
+        // no one has used the shift function yet"), then we've "consumed"
+        // the shift button press (so go to shift state 2: "shift button has
+        // been used by some other button press that has a shifted meaning").
+        if (useShift && shiftButton.state == 1)
+            shiftButton.state = 2;
 
         // carry out any edge effects from buttons changing states
         if (bs->logState != bs->prevLogState)
         {
-            // check for special key transitions
+            // check to see if this is the Night Mode button
             if (cfg.nightMode.btn == i + 1)
             {
-                // Check the switch type in the config flags.  If flag 0x01 is set,
-                // it's a persistent on/off switch, so the night mode state simply
-                // follows the current state of the switch.  Otherwise, it's a 
-                // momentary button, so each button push (i.e., each transition from
-                // logical state OFF to ON) toggles the current night mode state.
+                // Check the switch type in the config flags.  If flag 0x01 is 
+                // set, it's a persistent on/off switch, so the night mode 
+                // state simply tracks the current state of the switch.  
+                // Otherwise, it's a momentary button, so each button push 
+                // (i.e., each transition from logical state OFF to ON) toggles 
+                // the night mode state.
+                //
+                // Note that the "shift" flag (0x02) has no effect in switch
+                // mode.  Shifting only works for toggle mode.
                 if (cfg.nightMode.flags & 0x01)
                 {
-                    // on/off switch - when the button changes state, change
-                    // night mode to match the new state
+                    // It's an on/off switch.  Night mode simply tracks the
+                    // current switch state.
                     setNightMode(bs->logState);
                 }
                 else
                 {
-                    // Momentary switch - toggle the night mode state when the
-                    // physical button is pushed (i.e., when its logical state
-                    // transitions from OFF to ON).  
+                    // It's a momentary toggle switch.  Toggle the night mode 
+                    // state on each distinct press of the button: that is,
+                    // whenever the button's logical state transitions from 
+                    // OFF to ON.
                     //
-                    // In momentary mode, night mode flag 0x02 makes it the
-                    // shifted version of the button.  In this case, only
-                    // proceed if the shift button is pressed.
-                    bool pressed = bs->logState;
+                    // The "shift" flag (0x02) tells us whether night mode is
+                    // assigned to the shifted or unshifted version of the
+                    // button.
+                    bool pressed;
                     if ((cfg.nightMode.flags & 0x02) != 0)
                     {
-                        // if the shift button is pressed but hasn't been used
-                        // as a shift yet, mark it as used, so that it doesn't
-                        // also generate its own key code on release
-                        if (shiftButton.state == 1)
-                            shiftButton.state = 2;
-                            
-                        // if the shift button isn't even pressed
-                        if (shiftButton.state == 0)
-                            pressed = false;
+                        // Shift bit is set - night mode is assigned to the
+                        // shifted version of the button.  This is a Night
+                        // Mode toggle only if the Shift button is pressed.
+                        pressed = (shiftButton.state != 0);
+                    }
+                    else
+                    {
+                        // No shift bit - night mode is assigned to the
+                        // regular unshifted button.  The button press only
+                        // applies if the Shift button is NOT pressed.
+                        pressed = (shiftButton.state == 0);
                     }
                     
                     // if it's pressed (even after considering the shift mode),
@@ -2403,6 +3008,11 @@
                 }
             }
             
+            // press or release IR virtual keys on key state changes
+            uint8_t irc = useShift ? bc->IRCommand2 : bc->IRCommand;
+            if (irc != 0)
+                IR_buttonChange(irc, bs->logState);
+            
             // remember the new state for comparison on the next run
             bs->prevLogState = bs->logState;
         }
@@ -2413,131 +3023,53 @@
         {
             // Get the key type and code.  Start by assuming that we're
             // going to use the normal unshifted meaning.
-            ButtonCfg *bc = &cfg.button[bs->cfgIndex];
-            uint8_t typ = bc->typ;
-            uint8_t val = bc->val;
-            
-            // If the shift button is down, check for a shifted meaning.
-            if (shiftButton.state)
+            uint8_t typ, val;
+            if (useShift)
             {
-                // assume there's no shifted meaning
-                bool useShift = false;
-                
-                // If the button has a shifted meaning, use that.  The
-                // meaning might be a keyboard key or joystick button,
-                // but it could also be as the Night Mode toggle.
-                //
-                // The condition to check if it's the Night Mode toggle
-                // is a little complicated.  First, the easy part: our
-                // button index has to match the Night Mode button index.
-                // Now the hard part: the Night Mode button flags have
-                // to be set to 0x01 OFF and 0x02 ON: toggle mode (not
-                // switch mode, 0x01), and shift mode, 0x02.  So AND the
-                // flags with 0x03 to get these two bits, and check that
-                // the result is 0x02, meaning that only shift mode is on.
-                if (bc->typ2 != BtnTypeNone)
-                {
-                    // there's a shifted key assignment - use it
-                    typ = bc->typ2;
-                    val = bc->val2;
-                    useShift = true;
-                }
-                else if (cfg.nightMode.btn == i+1 
-                         && (cfg.nightMode.flags & 0x03) == 0x02)
-                {
-                    // shift+button = night mode toggle
-                    typ = BtnTypeNone;
-                    val = 0;
-                    useShift = true;
-                }
-                
-                // If there's a shifted meaning, advance the shift
-                // button state from 1 to 2 if applicable.  This signals
-                // that we've "consumed" the shift button press as the
-                // shift button, so it shouldn't generate its own key
-                // code event when released.
-                if (useShift && shiftButton.state == 1)
-                    shiftButton.state = 2;
+                typ = bc->typ2;
+                val = bc->val2;
             }
-            
+            else
+            {
+                typ = bc->typ;
+                val = bc->val;
+            }
+        
             // We've decided on the meaning of the button, so process
             // the keyboard or joystick event.
-            switch (typ)
-            {
-            case BtnTypeJoystick:
-                // joystick button
-                newjs |= (1 << (val - 1));
-                break;
-                
-            case BtnTypeKey:
-                // Keyboard key.  The USB keyboard report encodes regular
-                // keys and modifier keys separately, so we need to check
-                // which type we have.  Note that past versions mapped the 
-                // Keyboard Volume Up, Keyboard Volume Down, and Keyboard 
-                // Mute keys to the corresponding Media keys.  We no longer
-                // do this; instead, we have the separate BtnTypeMedia for
-                // explicitly using media keys if desired.
-                if (val >= 0xE0 && val <= 0xE7)
-                {
-                    // It's a modifier key.  These are represented in the USB 
-                    // reports with a bit mask.  We arrange the mask bits in
-                    // the same order as the scan codes, so we can figure the
-                    // appropriate bit with a simple shift.
-                    modkeys |= (1 << (val - 0xE0));
-                }
-                else
-                {
-                    // It's a regular key.  Make sure it's not already in the 
-                    // list, and that the list isn't full.  If neither of these 
-                    // apply, add the key to the key array.
-                    if (nkeys < 7)
-                    {
-                        bool found = false;
-                        for (int j = 0 ; j < nkeys ; ++j)
-                        {
-                            if (keys[j] == val)
-                            {
-                                found = true;
-                                break;
-                            }
-                        }
-                        if (!found)
-                            keys[nkeys++] = val;
-                    }
-                }
-                break;
-
-            case BtnTypeMedia:
-                // Media control key.  The media keys are mapped in the USB
-                // report to bits, whereas the key codes are specified in the
-                // config with their USB usage numbers.  E.g., the config val
-                // for Media Next Track is 0xB5, but we encode this in the USB
-                // report as bit 0x08.  The mediaKeyMap[] table translates
-                // from the USB usage number to the mask bit.  If the key isn't
-                // among the subset we support, the mapped bit will be zero, so
-                // the "|=" will have no effect and the key will be ignored.
-                mediakeys |= mediaKeyMap[val];
-                break;                                
-            }
+            ks.addKey(typ, val);
         }
     }
-
-    // check for joystick button changes
-    if (jsButtons != newjs)
-        jsButtons = newjs;
-    
-    // Check for changes to the keyboard keys
-    if (kbState.data[0] != modkeys
-        || kbState.nkeys != nkeys
-        || memcmp(keys, &kbState.data[2], 6) != 0)
+    
+    // If an IR input command is in effect, add the IR command's
+    // assigned key, if any.  If we're in an IR key gap, don't include
+    // the IR key.
+    if (IRCommandIn != 0 && !IRKeyGap)
+    {
+        IRCommandCfg &irc = cfg.IRCommand[IRCommandIn - 1];
+        ks.addKey(irc.keytype, irc.keycode);
+    }
+    
+    // We're finished building the new key state.  Update the global
+    // key state variables to reflect the new state. 
+    
+    // set the new joystick buttons (no need to check for changes, as we
+    // report these on every joystick report whether they changed or not)
+    jsButtons = ks.js;
+    
+    // check for keyboard key changes (we only send keyboard reports when
+    // something changes)
+    if (kbState.data[0] != ks.modkeys
+        || kbState.nkeys != ks.nkeys
+        || memcmp(ks.keys, &kbState.data[2], 6) != 0)
     {
         // we have changes - set the change flag and store the new key data
         kbState.changed = true;
-        kbState.data[0] = modkeys;
-        if (nkeys <= 6) {
+        kbState.data[0] = ks.modkeys;
+        if (ks.nkeys <= 6) {
             // 6 or fewer simultaneous keys - report the key codes
-            kbState.nkeys = nkeys;
-            memcpy(&kbState.data[2], keys, 6);
+            kbState.nkeys = ks.nkeys;
+            memcpy(&kbState.data[2], ks.keys, 6);
         }
         else {
             // more than 6 simultaneous keys - report rollover (all '1' key codes)
@@ -2546,11 +3078,13 @@
         }
     }        
     
-    // Check for changes to media keys
-    if (mediaState.data != mediakeys)
+    // check for media key changes (we only send media key reports when
+    // something changes)
+    if (mediaState.data != ks.mediakeys)
     {
+        // we have changes - set the change flag and store the new key data
         mediaState.changed = true;
-        mediaState.data = mediakeys;
+        mediaState.data = ks.mediakeys;
     }
 }
 
@@ -2811,12 +3345,34 @@
 // MMA8451Q.  This class encapsulates an interrupt handler and 
 // automatic calibration.
 //
-// We install an interrupt handler on the accelerometer "data ready" 
-// interrupt to ensure that we fetch each sample immediately when it
-// becomes available.  The accelerometer data rate is fairly high
-// (800 Hz), so it's not practical to keep up with it by polling.
-// Using an interrupt handler lets us respond quickly and read
-// every sample.
+// We collect data at the device's maximum rate of 800kHz (one sample 
+// every 1.25ms).  To keep up with the high data rate, we use the 
+// device's internal FIFO, and drain the FIFO by polling on each 
+// iteration of our main application loop.  In the past, we used an
+// interrupt handler to read the device immediately on the arrival of
+// each sample, but this created too much latency for the IR remote
+// receiver, due to the relatively long time it takes to transfer the
+// accelerometer readings via I2C.  The device's on-board FIFO can
+// store up to 32 samples, which gives us up to about 40ms between
+// polling iterations before the buffer overflows.  Our main loop runs
+// in under 2ms, so we can easily keep the FIFO far from overflowing.
+//
+// The MMA8451Q has three range modes, +/- 2G, 4G, and 8G.  The ADC
+// sample is the same bit width (14 bits) in all modes, so the higher
+// dynamic range modes trade physical precision for range.  For our
+// purposes, precision is more important than range, so we use the
+// +/-2G mode.  Further, our joystick range is calibrated for only
+// +/-1G.  This was unintentional on my part; I didn't look at the
+// MMA8451Q library closely enough to realize it was normalizing to
+// actual "G" units, and assumed that it was normalizing to a -1..+1 
+// scale.  In practice, a +/-1G scale seems perfectly adequate for
+// virtual pinball use, so I'm sticking with that range for now.  But
+// there might be some benefit in renormalizing to a +/-2G range, in
+// that it would allow for higher dynamic range for very hard nudges.
+// Everyone would have to tweak their nudge sensitivity in VP if I
+// made that change, though, so I'm keeping it as is for now; it would
+// be best to make it a config option ("accelerometer high dynamic range") 
+// rather than change it across the board.
 //
 // We automatically calibrate the accelerometer so that it's not
 // necessary to get it exactly level when installing it, and so
@@ -2850,43 +3406,46 @@
 // accelerometer input history item, for gathering calibration data
 struct AccHist
 {
-    AccHist() { x = y = d = 0.0; xtot = ytot = 0.0; cnt = 0; }
-    void set(float x, float y, AccHist *prv)
+    AccHist() { x = y = dsq = 0; xtot = ytot = 0; cnt = 0; }
+    void set(int x, int y, AccHist *prv)
     {
         // save the raw position
         this->x = x;
         this->y = y;
-        this->d = distance(prv);
+        this->dsq = distanceSquared(prv);
     }
     
     // reading for this entry
-    float x, y;
-    
-    // distance from previous entry
-    float d;
+    int x, y;
+    
+    // (distance from previous entry) squared
+    int dsq;
     
     // total and count of samples averaged over this period
-    float xtot, ytot;
+    int xtot, ytot;
     int cnt;
 
-    void clearAvg() { xtot = ytot = 0.0; cnt = 0; }    
-    void addAvg(float x, float y) { xtot += x; ytot += y; ++cnt; }
-    float xAvg() const { return xtot/cnt; }
-    float yAvg() const { return ytot/cnt; }
-    
-    float distance(AccHist *p)
-        { return sqrt(square(p->x - x) + square(p->y - y)); }
+    void clearAvg() { xtot = ytot = 0; cnt = 0; }    
+    void addAvg(int x, int y) { xtot += x; ytot += y; ++cnt; }
+    int xAvg() const { return xtot/cnt; }
+    int yAvg() const { return ytot/cnt; }
+    
+    int distanceSquared(AccHist *p)
+        { return square(p->x - x) + square(p->y - y); }
 };
 
 // accelerometer wrapper class
 class Accel
 {
 public:
-    Accel(PinName sda, PinName scl, int i2cAddr, PinName irqPin)
-        : mma_(sda, scl, i2cAddr), intIn_(irqPin)
+    Accel(PinName sda, PinName scl, int i2cAddr, PinName irqPin, int range)
+        : mma_(sda, scl, i2cAddr)        
     {
         // remember the interrupt pin assignment
         irqPin_ = irqPin;
+        
+        // remember the range
+        range_ = range;
 
         // reset and initialize
         reset();
@@ -2895,138 +3454,130 @@
     void reset()
     {
         // clear the center point
-        cx_ = cy_ = 0.0;
+        cx_ = cy_ = 0;
         
-        // start the calibration timer
+        // start the auto-centering timer
         tCenter_.start();
         iAccPrv_ = nAccPrv_ = 0;
         
         // reset and initialize the MMA8451Q
         mma_.init();
+        
+        // set the range
+        mma_.setRange(
+            range_ == AccelRange4G ? 4 :
+            range_ == AccelRange8G ? 8 :
+            2);
                 
-        // set the initial integrated velocity reading to zero
-        vx_ = vy_ = 0;
-        
-        // set up our accelerometer interrupt handling
-        intIn_.rise(this, &Accel::isr);
-        mma_.setInterruptMode(irqPin_ == PTA14 ? 1 : 2);
+        // set the average accumulators to zero
+        xSum_ = ySum_ = 0;
+        nSum_ = 0;
         
         // read the current registers to clear the data ready flag
         mma_.getAccXYZ(ax_, ay_, az_);
-
-        // start our timers
-        tGet_.start();
-        tInt_.start();
     }
     
-    void disableInterrupts()
+    void poll()
     {
-        mma_.clearInterruptMode();
+        // read samples until we clear the FIFO
+        while (mma_.getFIFOCount() != 0)
+        {
+            int x, y, z;
+            mma_.getAccXYZ(x, y, z);
+            
+            // add the new reading to the running total for averaging
+            xSum_ += (x - cx_);
+            ySum_ += (y - cy_);
+            ++nSum_;
+            
+            // store the updates
+            ax_ = x;
+            ay_ = y;
+            az_ = z;
+        }
     }
-    
+
     void get(int &x, int &y) 
     {
-         // disable interrupts while manipulating the shared data
-         __disable_irq();
-         
-         // read the shared data and store locally for calculations
-         float ax = ax_, ay = ay_;
-         float vx = vx_, vy = vy_;
-         
-         // reset the velocity sum for the next run
-         vx_ = vy_ = 0;
-
-         // get the time since the last get() sample
-         int dtus = tGet_.read_us();
-         tGet_.reset();
-         
-         // done manipulating the shared data
-         __enable_irq();
-         
-         // adjust the readings for the integration time
-         float dt = dtus/1000000.0f;
-         vx /= dt;
-         vy /= dt;
+        // read the shared data and store locally for calculations
+        int ax = ax_, ay = ay_;
+        int xSum = xSum_, ySum = ySum_;
+        int nSum = nSum_;
          
-         // add this sample to the current calibration interval's running total
-         AccHist *p = accPrv_ + iAccPrv_;
-         p->addAvg(ax, ay);
-
-         // check for auto-centering every so often
-         if (tCenter_.read_us() > 1000000)
-         {
-             // add the latest raw sample to the history list
-             AccHist *prv = p;
-             iAccPrv_ = (iAccPrv_ + 1) % maxAccPrv;
-             p = accPrv_ + iAccPrv_;
-             p->set(ax, ay, prv);
-
-             // if we have a full complement, check for stability
-             if (nAccPrv_ >= maxAccPrv)
-             {
-                 // check if we've been stable for all recent samples
-                 static const float accTol = .01f;
-                 AccHist *p0 = accPrv_;
-                 if (p0[0].d < accTol
-                     && p0[1].d < accTol
-                     && p0[2].d < accTol
-                     && p0[3].d < accTol
-                     && p0[4].d < accTol)
-                 {
-                     // Figure the new calibration point as the average of
-                     // the samples over the rest period
-                     cx_ = (p0[0].xAvg() + p0[1].xAvg() + p0[2].xAvg() + p0[3].xAvg() + p0[4].xAvg())/5.0f;
-                     cy_ = (p0[0].yAvg() + p0[1].yAvg() + p0[2].yAvg() + p0[3].yAvg() + p0[4].yAvg())/5.0f;
-                 }
-             }
-             else
-             {
-                // not enough samples yet; just up the count
-                ++nAccPrv_;
-             }
+        // reset the average accumulators for the next run
+        xSum_ = ySum_ = 0;
+        nSum_ = 0;
+
+        // add this sample to the current calibration interval's running total
+        AccHist *p = accPrv_ + iAccPrv_;
+        p->addAvg(ax, ay);
+
+        // check for auto-centering every so often
+        if (tCenter_.read_us() > 1000000)
+        {
+            // add the latest raw sample to the history list
+            AccHist *prv = p;
+            iAccPrv_ = (iAccPrv_ + 1);
+            if (iAccPrv_ >= maxAccPrv)
+               iAccPrv_ = 0;
+            p = accPrv_ + iAccPrv_;
+            p->set(ax, ay, prv);
+
+            // if we have a full complement, check for stability
+            if (nAccPrv_ >= maxAccPrv)
+            {
+                // check if we've been stable for all recent samples
+                static const int accTol = 164*164;  // 1% of range, squared
+                AccHist *p0 = accPrv_;
+                if (p0[0].dsq < accTol
+                    && p0[1].dsq < accTol
+                    && p0[2].dsq < accTol
+                    && p0[3].dsq < accTol
+                    && p0[4].dsq < accTol)
+                {
+                    // Figure the new calibration point as the average of
+                    // the samples over the rest period
+                    cx_ = (p0[0].xAvg() + p0[1].xAvg() + p0[2].xAvg() + p0[3].xAvg() + p0[4].xAvg())/5;
+                    cy_ = (p0[0].yAvg() + p0[1].yAvg() + p0[2].yAvg() + p0[3].yAvg() + p0[4].yAvg())/5;
+                }
+            }
+            else
+            {
+               // not enough samples yet; just up the count
+               ++nAccPrv_;
+            }
              
-             // clear the new item's running totals
-             p->clearAvg();
+            // clear the new item's running totals
+            p->clearAvg();
             
-             // reset the timer
-             tCenter_.reset();
-             
-             // If we haven't seen an interrupt in a while, do an explicit read to
-             // "unstick" the device.  The device can become stuck - which is to say,
-             // it will stop delivering data-ready interrupts - if we fail to service
-             // one data-ready interrupt before the next one occurs.  Reading a sample
-             // will clear up this overrun condition and allow normal interrupt
-             // generation to continue.
-             //
-             // Note that this stuck condition *shouldn't* ever occur, because if only
-             // happens if we're spending a long period with interrupts disabled (in
-             // a critical section or in another interrupt handler), which will likely
-             // cause other, worse problems beyond the sticky accelerometer.  Even so, 
-             // it's easy to detect and correct, so we'll do so for the sake of making 
-             // the system a little more fault-tolerant.
-             if (tInt_.read_us() > 1000000)
-             {
-                float x, y, z;
-                mma_.getAccXYZ(x, y, z);
-             }
-         }
+            // reset the timer
+            tCenter_.reset();
+        }
          
-         // report our integrated velocity reading in x,y
-         x = rawToReport(vx);
-         y = rawToReport(vy);
+        // report our integrated velocity reading in x,y
+        x = rawToReport(xSum/nSum);
+        y = rawToReport(ySum/nSum);
          
 #ifdef DEBUG_PRINTF
-         if (x != 0 || y != 0)        
-             printf("%f %f %d %d %f\r\n", vx, vy, x, y, dt);
+        if (x != 0 || y != 0)        
+            printf("%f %f %d %d %f\r\n", vx, vy, x, y, dt);
 #endif
-     }    
+    }    
          
 private:
     // adjust a raw acceleration figure to a usb report value
-    int rawToReport(float v)
+    int rawToReport(int v)
     {
-        // scale to the joystick report range and round to integer
-        int i = int(round(v*JOYMAX));
+        // Scale to the joystick report range.  The accelerometer
+        // readings use the native 14-bit signed integer representation,
+        // so their scale is 2^13.
+        //
+        // The 1G range is special: it uses the 2G native hardware range,
+        // but rescales the result to a 1G range for the joystick reports.
+        // So for that mode, we divide by 4096 rather than 8192.  All of
+        // the other modes map use the hardware scaling directly.
+        int i = v*JOYMAX;
+        i = (range_ == AccelRange1G ? i/4096 : i/8192);
         
         // if it's near the center, scale it roughly as 20*(i/20)^2,
         // to suppress noise near the rest position
@@ -3038,56 +3589,32 @@
         return (i > 20 || i < -20 ? i : filter[i+20]);
     }
 
-    // interrupt handler
-    void isr()
-    {
-        // Read the axes.  Note that we have to read all three axes
-        // (even though we only really use x and y) in order to clear
-        // the "data ready" status bit in the accelerometer.  The
-        // interrupt only occurs when the "ready" bit transitions from
-        // off to on, so we have to make sure it's off.
-        float x, y, z;
-        mma_.getAccXYZ(x, y, z);
-        
-        // calculate the time since the last interrupt
-        float dt = tInt_.read();
-        tInt_.reset();
-
-        // integrate the time slice from the previous reading to this reading
-        vx_ += (x + ax_ - 2*cx_)*dt/2;
-        vy_ += (y + ay_ - 2*cy_)*dt/2;
-        
-        // store the updates
-        ax_ = x;
-        ay_ = y;
-        az_ = z;
-    }
-    
     // underlying accelerometer object
     MMA8451Q mma_;
     
-    // last raw acceleration readings
-    float ax_, ay_, az_;
-    
-    // integrated velocity reading since last get()
-    float vx_, vy_;
+    // last raw acceleration readings, on the device's signed 14-bit 
+    // scale -8192..+8191
+    int ax_, ay_, az_;
+    
+    // running sum of readings since last get()
+    int xSum_, ySum_;
+    
+    // number of readings since last get()
+    int nSum_;
         
-    // timer for measuring time between get() samples
-    Timer tGet_;
-    
-    // timer for measuring time between interrupts
-    Timer tInt_;
-
     // Calibration reference point for accelerometer.  This is the
     // average reading on the accelerometer when in the neutral position
     // at rest.
-    float cx_, cy_;
-
-    // timer for atuo-centering
+    int cx_, cy_;
+    
+    // range (AccelRangeXxx value, from config.h)
+    uint8_t range_;
+
+    // atuo-centering timer
     Timer tCenter_;
 
     // Auto-centering history.  This is a separate history list that
-    // records results spaced out sparesely over time, so that we can
+    // records results spaced out sparsely over time, so that we can
     // watch for long-lasting periods of rest.  When we observe nearly
     // no motion for an extended period (on the order of 5 seconds), we
     // take this to mean that the cabinet is at rest in its neutral 
@@ -3104,9 +3631,6 @@
     
     // interurupt pin name
     PinName irqPin_;
-    
-    // interrupt router
-    InterruptIn intIn_;
 };
 
 // ---------------------------------------------------------------------------
@@ -3274,25 +3798,37 @@
 //   so we don't want to push the button on a TV that's already on.
 //   
 
-// Current PSU2 state:
+// Current PSU2 power state:
 //   1 -> default: latch was on at last check, or we haven't checked yet
 //   2 -> latch was off at last check, SET pulsed high
 //   3 -> SET pulsed low, ready to check status
 //   4 -> TV timer countdown in progress
 //   5 -> TV relay on
+//   6 -> sending IR signals designed as TV ON signals
 uint8_t psu2_state = 1;
 
 // TV relay state.  The TV relay can be controlled by the power-on
 // timer and directly from the PC (via USB commands), so keep a
 // separate state for each:
-//
 //   0x01 -> turned on by power-on timer
 //   0x02 -> turned on by USB command
 uint8_t tv_relay_state = 0x00;
 const uint8_t TV_RELAY_POWERON = 0x01;
 const uint8_t TV_RELAY_USB     = 0x02;
 
-// TV ON switch relay control
+// TV ON IR command state.  When the main PSU2 power state reaches
+// the IR phase, we use this sub-state counter to send the TV ON
+// IR signals.  We initialize to state 0 when the main state counter
+// reaches the IR step.  In state 0, we start transmitting the first
+// (lowest numbered) IR command slot marked as containing a TV ON
+// code, and advance to state 1.  In state 1, we check to see if
+// the transmitter is still sending; if so, we do nothing, if so
+// we start transmitting the second TV ON code and advance to state
+// 2.  Continue until we run out of TV ON IR codes, at which point
+// we advance to the next main psu2_state step.
+uint8_t tvon_ir_state = 0;
+
+// TV ON switch relay control output pin
 DigitalOut *tv_relay;
 
 // PSU2 power sensing circuit connections
@@ -3313,12 +3849,27 @@
         tv_relay->write(tv_relay_state != 0);
 }
 
-// Timer interrupt
-Ticker tv_ticker;
-float tv_delay_time;
-void TVTimerInt()
+// PSU2 Status update routine.  The main loop calls this from time 
+// to time to update the power sensing state and carry out TV ON 
+// functions.
+Timer powerStatusTimer;
+uint32_t tv_delay_time_us;
+void powerStatusUpdate(Config &cfg)
 {
-    // time since last state change
+    // Only update every 1/4 second or so.  Note that if the PSU2
+    // circuit isn't configured, the initialization routine won't 
+    // start the timer, so it'll always read zero and we'll always 
+    // skip this whole routine.
+    if (powerStatusTimer.read_us() < 250000)
+        return;
+        
+    // reset the update timer for next time
+    powerStatusTimer.reset();
+    
+    // TV ON timer.  We start this timer when we detect a change
+    // in the PSU2 status from OFF to ON.  When the timer reaches
+    // the configured TV ON delay time, and the PSU2 power is still
+    // on, we'll trigger the TV ON relay and send the TV ON IR codes.
     static Timer tv_timer;
 
     // Check our internal state
@@ -3338,6 +3889,7 @@
             // try setting the latch
             psu2_status_set->write(1);
         }
+        powerTimerDiagState = 0;
         break;
         
     case 2:
@@ -3345,6 +3897,7 @@
         // the latch.  Drop the SET signal and go to CHECK state.
         psu2_status_set->write(0);
         psu2_state = 3;
+        powerTimerDiagState = 0;
         break;
         
     case 3:
@@ -3362,7 +3915,6 @@
             
             // start the power timer diagnostic flashes
             powerTimerDiagState = 2;
-            diagLED();
         }
         else
         {
@@ -3375,53 +3927,136 @@
         break;
         
     case 4:
-        // TV timer countdown in progress.  If we've reached the
-        // delay time, pulse the relay.
-        if (tv_timer.read() >= tv_delay_time)
+        // TV timer countdown in progress.  The latch has to stay on during
+        // the countdown; if the latch turns off, PSU2 power must have gone
+        // off again before the countdown finished.
+        if (!psu2_status_sense->read())
+        {
+            // power is off - start a new check cycle
+            psu2_status_set->write(1);
+            psu2_state = 2;
+            break;
+        }
+        
+        // Flash the power time diagnostic every two cycles
+        powerTimerDiagState = (powerTimerDiagState + 1) & 0x03;
+        
+        // if we've reached the delay time, pulse the relay
+        if (tv_timer.read_us() >= tv_delay_time_us)
         {
             // turn on the relay for one timer interval
             tvRelayUpdate(TV_RELAY_POWERON, true);
             psu2_state = 5;
+            
+            // show solid blue on the diagnostic LED while the relay is on
+            powerTimerDiagState = 2;
         }
-        
-        // flash the power time diagnostic every two interrupts
-        powerTimerDiagState = (powerTimerDiagState + 1) & 0x03;
-        diagLED();
         break;
         
     case 5:
         // TV timer relay on.  We pulse this for one interval, so
-        // it's now time to turn it off and return to the default state.
+        // it's now time to turn it off.
         tvRelayUpdate(TV_RELAY_POWERON, false);
+        
+        // Proceed to sending any TV ON IR commands
+        psu2_state = 6;
+        tvon_ir_state = 0;
+    
+        // diagnostic LEDs off for now
+        powerTimerDiagState = 0;
+        break;
+        
+    case 6:        
+        // Sending TV ON IR signals.  Start with the assumption that
+        // we have no IR work to do, in which case we're done with the
+        // whole TV ON sequence.  So by default return to state 1.
         psu2_state = 1;
+        powerTimerDiagState = 0;
         
-        // done with the diagnostic flashes
-        powerTimerDiagState = 0;
-        diagLED();
+        // If we have an IR emitter, check for TV ON IR commands
+        if (ir_tx != 0)
+        {
+            // check to see if the last transmission is still in progress
+            if (ir_tx->isSending())
+            {
+                // We're still sending the last transmission.  Stay in
+                // state 6.
+                psu2_state = 6;
+                powerTimerDiagState = 4;
+                break;
+            }
+                
+            // The last transmission is done, so check for a new one.
+            // Look for the Nth TV ON IR slot, where N is our state
+            // number.
+            for (int i = 0, n = 0 ; i < MAX_IR_CODES ; ++i)
+            {
+                // is this a TV ON command?
+                if ((cfg.IRCommand[i].flags & IRFlagTVON) != 0)
+                {
+                    // It's a TV ON command - check if it's the one we're
+                    // looking for.
+                    if (n == tvon_ir_state)
+                    {
+                        // It's the one.  Start transmitting it by
+                        // pushing its virtual button.
+                        int vb = IRConfigSlotToVirtualButton[i];
+                        ir_tx->pushButton(vb, true);
+                        
+                        // Pushing the button starts transmission, and once
+                        // started, the transmission will run to completion
+                        // even if the button is no longer pushed.  So we
+                        // can immediately un-push the button, since we only
+                        // need to send the code once.
+                        ir_tx->pushButton(vb, false);
+                        
+                        // Advance to the next TV ON IR state, where we'll
+                        // await the end of this transmission and move on to
+                        // the next one.
+                        psu2_state = 6;
+                        tvon_ir_state++;
+                        break;
+                    }
+                    
+                    // it's not ours - count it and keep looking
+                    ++n;
+                }
+            }
+        }
         break;
     }
+    
+    // update the diagnostic LEDs
+    diagLED();
 }
 
-// Start the TV ON checker.  If the status sense circuit is enabled in
-// the configuration, we'll set up the pin connections and start the
-// interrupt handler that periodically checks the status.  Does nothing
-// if any of the pins are configured as NC.
-void startTVTimer(Config &cfg)
+// Start the power status timer.  If the status sense circuit is enabled 
+// in the configuration, we'll set up the pin connections and start the
+// timer for our periodic status checks.  Does nothing if any of the pins 
+// are configured as NC.
+void startPowerStatusTimer(Config &cfg)
 {
     // only start the timer if the pins are configured and the delay
     // time is nonzero
-    if (cfg.TVON.delayTime != 0
-        && cfg.TVON.statusPin != 0xFF 
-        && cfg.TVON.latchPin != 0xFF 
-        && cfg.TVON.relayPin != 0xFF)
+    powerStatusTimer.reset();
+    if (cfg.TVON.statusPin != 0xFF 
+        && cfg.TVON.latchPin != 0xFF)
     {
+        // set up the power sensing circuit connections
         psu2_status_sense = new DigitalIn(wirePinName(cfg.TVON.statusPin));
         psu2_status_set = new DigitalOut(wirePinName(cfg.TVON.latchPin));
-        tv_relay = new DigitalOut(wirePinName(cfg.TVON.relayPin));
-        tv_delay_time = cfg.TVON.delayTime/100.0f;
-    
-        // Set up our time routine to run every 1/4 second.  
-        tv_ticker.attach(&TVTimerInt, 0.25);
+        
+        // if there's a TV ON relay, set up its control pin
+        if (cfg.TVON.relayPin != 0xFF)
+            tv_relay = new DigitalOut(wirePinName(cfg.TVON.relayPin));
+            
+        // Set the TV ON delay time.  We store the time internally in
+        // microseconds, but the configuration stores it in units of
+        // 1/100 second = 10ms = 10000us.
+        tv_delay_time_us = cfg.TVON.delayTime * 10000;;
+    
+        // Start the TV timer
+        powerStatusTimer.start();
     }
 }
 
@@ -3482,6 +4117,18 @@
 //
 NVM nvm;
 
+// Flag: configuration save requested.  The USB command message handler
+// sets this flag when a command is sent requesting the save.  We don't
+// do the save inline in the command handler, but handle it on the next 
+// main loop iteration.
+const uint8_t SAVE_CONFIG_ONLY = 1;
+const uint8_t SAVE_CONFIG_AND_REBOOT = 2;
+uint8_t saveConfigPending = 0;
+
+// If saveConfigPending == SAVE_CONFIG_AND_REBOOT, this specifies the
+// delay time in seconds before rebooting.
+uint8_t saveConfigRebootTime;
+
 // For convenience, a macro for the Config part of the NVM structure
 #define cfg (nvm.d.c)
 
@@ -3499,26 +4146,10 @@
 }
 flash_nvm_memory __attribute__ ((aligned(SECTOR_SIZE))) = { };
 
-// figure the flash address as a pointer along with the number of sectors
-// required to store the structure
-NVM *configFlashAddr(int &addr, int &numSectors)
-{
-    // figure how many flash sectors we span, rounding up to whole sectors
-    numSectors = (sizeof(NVM) + SECTOR_SIZE - 1)/SECTOR_SIZE;
-
-    // figure the address - this is the highest flash address where the
-    // structure will fit with the start aligned on a sector boundary
-    addr = (int)&flash_nvm_memory;
-    
-    // return the address as a pointer
-    return (NVM *)addr;
-}
-
 // figure the flash address as a pointer
 NVM *configFlashAddr()
 {
-    int addr, numSectors;
-    return configFlashAddr(addr, numSectors);
+    return (NVM *)&flash_nvm_memory;
 }
 
 // Load the config from flash.  Returns true if a valid non-default
@@ -3570,8 +4201,7 @@
     waitPlungerIdle();
     
     // get the config block location in the flash memory
-    int addr, sectors;
-    configFlashAddr(addr, sectors);
+    uint32_t addr = uint32_t(configFlashAddr());
     
     // loop until we save it successfully
     for (int i = 0 ; i < 5 ; ++i)
@@ -3588,10 +4218,10 @@
         // verify the data
         if (nvm.verify(addr))
         {
-            // show a diagnostic success flash
-            for (int j = 0 ; j < 3 ; ++j)
+            // show a diagnostic success flash (rapid green)
+            for (int j = 0 ; j < 4 ; ++j)
             {
-                diagLED(0, 1, 1);
+                diagLED(0, 1, 0);
                 wait_us(50000);
                 diagLED(0, 0, 0);
                 wait_us(50000);
@@ -3704,8 +4334,10 @@
 // Turn night mode on or off
 static void setNightMode(bool on)
 {
-    // set the new night mode flag in the noisy output class
-    nightMode = on;
+    // Set the new night mode flag in the noisy output class.  Note
+    // that we use the status report bit flag value 0x02 when on, so
+    // that we can just '|' this into the overall status bits.
+    nightMode = on ? 0x02 : 0x00;
     
     // update the special output pin that shows the night mode state
     int port = int(cfg.nightMode.port) - 1;
@@ -4890,6 +5522,7 @@
 #define if_msg_valid(test)  if (test)
 #define v_byte(var, ofs)    cfg.var = data[ofs]
 #define v_ui16(var, ofs)    cfg.var = wireUI16(data+(ofs))
+#define v_ui32(var, ofs)    cfg.var = wireUI32(data+(ofs))
 #define v_pin(var, ofs)     cfg.var = wirePinName(data[ofs])
 #define v_byte_ro(val, ofs) // ignore read-only variables on SET
 #define v_ui32_ro(val, ofs) // ignore read-only variables on SET
@@ -4901,6 +5534,7 @@
 #undef if_msg_valid
 #undef v_byte
 #undef v_ui16
+#undef v_ui32
 #undef v_pin
 #undef v_byte_ro
 #undef v_ui32_ro
@@ -4911,6 +5545,7 @@
 #define if_msg_valid(test)
 #define v_byte(var, ofs)    data[ofs] = cfg.var
 #define v_ui16(var, ofs)    ui16Wire(data+(ofs), cfg.var)
+#define v_ui32(var, ofs)    ui32Wire(data+(ofs), cfg.var)
 #define v_pin(var, ofs)     pinNameWire(data+(ofs), cfg.var)
 #define v_byte_ro(val, ofs) data[ofs] = (val)
 #define v_ui32_ro(val, ofs) ui32Wire(data+(ofs), val);
@@ -4986,12 +5621,9 @@
                 cfg.psUnitNo = newUnitNo;
                 cfg.plunger.enabled = data[3] & 0x01;
                 
-                // save the configuration
-                saveConfigToFlash();
-                
-                // reboot if necessary
-                if (needReset)
-                    reboot(js);
+                // set the flag to do the save
+                saveConfigPending = needReset ? SAVE_CONFIG_AND_REBOOT : SAVE_CONFIG_ONLY;
+                saveConfigRebootTime = 0;
             }
             break;
             
@@ -5035,13 +5667,10 @@
             break;
             
         case 6:
-            // 6 = Save configuration to flash.
-            saveConfigToFlash();
-            
-            // before disconnecting, pause for the delay time specified in
-            // the parameter byte (in seconds)
-            rebootTime_us = data[2] * 1000000L;
-            rebootTimer.start();
+            // 6 = Save configuration to flash.  Reboot after the delay
+            // time in seconds given in data[2].
+            saveConfigPending = SAVE_CONFIG_AND_REBOOT;
+            saveConfigRebootTime = data[2];
             break;
             
         case 7:
@@ -5091,7 +5720,20 @@
             break;
             
         case 12:
-            // Unused
+            // 12 = Learn IR code.  This enters IR learning mode.  While
+            // in learning mode, we report raw IR signals and the first IR
+            // command decoded through the special IR report format.  IR
+            // learning mode automatically ends after a timeout expires if
+            // no command can be decoded within the time limit.
+            
+            // enter IR learning mode
+            IRLearningMode = 1;
+            
+            // cancel any regular IR input in progress
+            IRCommandIn = 0;
+            
+            // reset and start the learning mode timeout timer
+            IRTimer.reset();
             break;
             
         case 13:
@@ -5245,7 +5887,8 @@
 {
     // say hello to the debug console, in case it's connected
     printf("\r\nPinscape Controller starting\r\n");
-
+    
+    
     // debugging: print memory config info
     //    -> no longer very useful, since we use our own custom malloc/new allocator (see xmalloc() above)
     // {int *a = new int; printf("Stack=%lx, heap=%lx, free=%ld\r\n", (long)&a, (long)a, (long)&a - (long)a);} 
@@ -5313,12 +5956,20 @@
     // start the TLC5940 refresh cycle clock
     if (tlc5940 != 0)
         tlc5940->start();
-        
-    // start the TV timer, if applicable
-    startTVTimer(cfg);
+
+    // 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
+    // USB keyboard interface.
+    bool kbKeys = false;
+
+    // set up the IR remote control emitter & receiver, if present
+    init_IR(cfg, kbKeys);
+
+    // start the power status time, if applicable
+    startPowerStatusTimer(cfg);
 
     // initialize the button input ports
-    bool kbKeys = false;
     initButtons(cfg, kbKeys);
     
     // Create the joystick USB client.  Note that the USB vendor/product ID
@@ -5349,9 +6000,16 @@
             connFlashTimer.reset();
         }
 
+        // If we've been disconnected for more than the reboot timeout,
+        // reboot.  Some PCs won't reconnect if we were left plugged in
+        // during a power cycle on the PC, but fortunately a reboot on
+        // the KL25Z will make the host notice us and trigger a reconnect.
         if (cfg.disconnectRebootTimeout != 0 
             && connTimeoutTimer.read() > cfg.disconnectRebootTimeout)
             reboot(js, false, 0);
+            
+        // update the PSU2 power sensing status
+        powerStatusUpdate(cfg);
     }
     
     // we're now connected to the host
@@ -5418,7 +6076,8 @@
     acTimer.start();
     
     // create the accelerometer object
-    Accel accel(MMA8451_SCL_PIN, MMA8451_SDA_PIN, MMA8451_I2C_ADDRESS, MMA8451_INT_PIN);
+    Accel accel(MMA8451_SCL_PIN, MMA8451_SDA_PIN, MMA8451_I2C_ADDRESS, 
+        MMA8451_INT_PIN, cfg.accelRange);
        
     // last accelerometer report, in joystick units (we report the nudge
     // acceleration via the joystick x & y axes, per the VP convention)
@@ -5442,6 +6101,9 @@
     // start the PWM update polling timer
     polledPwmTimer.start();
     
+    // Timer for configuration change reboots
+    ExtTimer saveConfigRebootTimer;
+    
     // we're all set up - now just loop, processing sensor reports and 
     // host requests
     for (;;)
@@ -5456,7 +6118,7 @@
         LedWizMsg lwm;
         Timer lwt;
         lwt.start();
-        IF_DIAG(int msgCount = 0;)
+        IF_DIAG(int msgCount = 0;) 
         while (js.readLedWizMsg(lwm) && lwt.read_us() < 5000)
         {
             handleInputMsg(lwm, js);
@@ -5472,11 +6134,20 @@
             }
         )
         
+        // process IR input
+        process_IR(cfg, js);
+    
+        // update the PSU2 power sensing status
+        powerStatusUpdate(cfg);
+
         // update flashing LedWiz outputs periodically
         wizPulse();
         
         // update PWM outputs
         pollPwmUpdates();
+        
+        // poll the accelerometer
+        accel.poll();
             
         // collect diagnostic statistics, checkpoint 0
         IF_DIAG(mainLoopIterCheckpt[0] += mainLoopTimer.read_us();)
@@ -5487,7 +6158,7 @@
        
         // collect diagnostic statistics, checkpoint 1
         IF_DIAG(mainLoopIterCheckpt[1] += mainLoopTimer.read_us();)
-
+        
         // check for plunger calibration
         if (calBtn != 0 && !calBtn->read())
         {
@@ -5537,8 +6208,10 @@
             // Otherwise, return to the base state without saving anything.
             // If the button is released before we make it to calibration
             // mode, it simply cancels the attempt.
+            diagLED(1,1,1);
             if (calBtnState == 3 && calBtnTimer.read_us() > 15000000)
             {
+                diagLED(0,0,0);
                 // exit calibration mode
                 calBtnState = 0;
                 plungerReader.setCalMode(false);
@@ -5549,6 +6222,7 @@
             }
             else if (calBtnState != 3)
             {
+                diagLED(0,1,1);
                 // didn't make it to calibration mode - cancel the operation
                 calBtnState = 0;
             }
@@ -5635,10 +6309,12 @@
         bool jsOK = false;
         
         // figure the current status flags for joystick reports
-        uint16_t statusFlags =
-            (cfg.plunger.enabled ? 0x01 : 0x00)
-            | (nightMode ? 0x02 : 0x00)
-            | ((psu2_state & 0x07) << 2);
+        uint16_t statusFlags = 
+            cfg.plunger.enabled             // 0x01
+            | nightMode                     // 0x02
+            | ((psu2_state & 0x07) << 2);   // 0x04 0x08 0x10
+        if (IRLearningMode != 0)
+            statusFlags |= 0x20;
 
         // If it's been long enough since our last USB status report, send
         // the new report.  VP only polls for input in 10ms intervals, so
@@ -5692,7 +6368,7 @@
         
         // If joystick reports are turned off, send a generic status report
         // periodically for the sake of the Windows config tool.
-        if (!cfg.joystickEnabled && jsReportTimer.read_us() > 5000)
+        if (!cfg.joystickEnabled && jsReportTimer.read_us() > 10000UL)
         {
             jsOK = js.updateStatus(statusFlags);
             jsReportTimer.reset();
@@ -5758,8 +6434,23 @@
         }
         
         // if we have a reboot timer pending, check for completion
-        if (rebootTimer.isRunning() && rebootTimer.read_us() > rebootTime_us)
+        if (saveConfigRebootTimer.isRunning() 
+            && saveConfigRebootTimer.read() > saveConfigRebootTime)
             reboot(js);
+            
+        // if a config save is pending, do it now
+        if (saveConfigPending != 0)
+        {
+            // save the configuration
+            saveConfigToFlash();
+            
+            // if desired, reboot after the specified delay
+            if (saveConfigPending == SAVE_CONFIG_AND_REBOOT)
+                saveConfigRebootTimer.start();
+                
+            // the save is no longer pending
+            saveConfigPending = 0;
+        }
         
         // if we're disconnected, initiate a new connection
         if (!connected)
@@ -5809,10 +6500,19 @@
                     diagTimer.reset();
                 }
                 
-                // if the disconnect reboot timeout has expired, reboot
+                // If the disconnect reboot timeout has expired, reboot.
+                // Some PC hosts won't reconnect to a device that's left
+                // plugged in through various events on the PC side, such as 
+                // rebooting Windows, cycling power on the PC, or just a lost
+                // USB connection.  Rebooting the KL25Z seems to be the most
+                // reliable way to get Windows to notice us again after one
+                // of these events and make it reconnect.
                 if (cfg.disconnectRebootTimeout != 0 
                     && reconnTimeoutTimer.read() > cfg.disconnectRebootTimeout)
                     reboot(js, false, 0);
+
+                // update the PSU2 power sensing status
+                powerStatusUpdate(cfg);
             }
             
             // resume the main loop timer