Mike R / Mbed 2 deprecated Pinscape_Controller_V2

Dependencies:   mbed FastIO FastPWM USBDevice

Fork of Pinscape_Controller by Mike R

Files at this revision

API Documentation at this revision

Comitter:
mjr
Date:
Sat Mar 05 00:16:52 2016 +0000
Parent:
51:57eb311faafa
Child:
53:9b2611964afc
Commit message:
New calibration procedure - attempt #1, with separate calibration release sensingi

Changed in this revision

USBJoystick/USBJoystick.cpp Show annotated file Show diff for this revision Revisions of this file
USBJoystick/USBJoystick.h Show annotated file Show diff for this revision Revisions of this file
USBProtocol.h Show annotated file Show diff for this revision Revisions of this file
ccdSensor.h Show annotated file Show diff for this revision Revisions of this file
cfgVarMsgMap.h Show annotated file Show diff for this revision Revisions of this file
config.h Show annotated file Show diff for this revision Revisions of this file
main.cpp Show annotated file Show diff for this revision Revisions of this file
nullSensor.h Show annotated file Show diff for this revision Revisions of this file
plunger.h Show annotated file Show diff for this revision Revisions of this file
potSensor.h Show annotated file Show diff for this revision Revisions of this file
--- a/USBJoystick/USBJoystick.cpp	Tue Mar 01 23:21:45 2016 +0000
+++ b/USBJoystick/USBJoystick.cpp	Sat Mar 05 00:16:52 2016 +0000
@@ -94,7 +94,58 @@
     return writeTO(EP4IN, report.data, report.length, MAX_PACKET_SIZE_EPINT, 100);
 }
  
-bool USBJoystick::updateExposure(int &idx, int npix, const uint8_t *pix)
+bool USBJoystick::sendPlungerStatus(
+    int npix, int edgePos, int dir, uint32_t avgScanTime, uint32_t processingTime)
+{
+    HID_REPORT report;
+    
+    // Set the special status bits to indicate it's an extended
+    // exposure report.
+    put(0, 0x87FF);
+    
+    // start at the second byte
+    int ofs = 2;
+    
+    // write the report subtype (0) to byte 2
+    report.data[ofs++] = 0;
+
+    // write the number of pixels to bytes 3-4
+    put(ofs, uint16_t(npix));
+    ofs += 2;
+    
+    // write the shadow edge position to bytes 5-6
+    put(ofs, uint16_t(edgePos));
+    ofs += 2;
+    
+    // write the flags to byte 7
+    extern bool plungerCalMode;
+    uint8_t flags = 0;
+    if (dir == 1) 
+        flags |= 0x01; 
+    else if (dir == -1)
+        flags |= 0x02;
+    if (plungerCalMode)
+        flags |= 0x04;
+    report.data[ofs++] = flags;
+    
+    // write the average scan time in 10us intervals to bytes 8-10
+    uint32_t t = uint32_t(avgScanTime / 10);
+    report.data[ofs++] = t & 0xff;
+    report.data[ofs++] = (t >> 8) & 0xff;
+    report.data[ofs++] = (t >> 16) & 0xff;
+    
+    // write the processing time to bytes 11-13
+    t = uint32_t(processingTime / 10);
+    report.data[ofs++] = t & 0xff;
+    report.data[ofs++] = (t >> 8) & 0xff;
+    report.data[ofs++] = (t >> 16) & 0xff;
+    
+    // send the report
+    report.length = reportLen;
+    return sendTO(&report, 100);
+}
+
+bool USBJoystick::sendPlungerPix(int &idx, int npix, const uint8_t *pix)
 {
     HID_REPORT report;
     
@@ -107,13 +158,6 @@
     // start at the second byte
     int ofs = 2;
     
-    // in the first report, add the total pixel count as the next two bytes
-    if (idx == 0)
-    {
-        put(ofs, npix);
-        ofs += 2;
-    }
-        
     // now fill out the remaining bytes with exposure values
     report.length = reportLen;
     for ( ; ofs < report.length ; ++ofs)
@@ -123,50 +167,6 @@
     return sendTO(&report, 100);
 }
 
-bool USBJoystick::updateExposureExt(
-    int edgePos, int dir, uint32_t avgScanTime, uint32_t processingTime)
-{
-    HID_REPORT report;
-    
-    // Set the special status bits to indicate it's an extended
-    // exposure report.
-    put(0, 0x87FF);
-    
-    // start at the second byte
-    int ofs = 2;
-    
-    // write the report subtype (0) to byte 2
-    report.data[ofs++] = 0;
-    
-    // write the shadow edge position to bytes 3-4
-    put(ofs, uint16_t(edgePos));
-    ofs += 2;
-    
-    // write the flags to byte 5:
-    //   0x01 -> standard orientation detected (dir == 1)
-    //   0x02 -> reverse orientation detected (dir == -1)
-    uint8_t flags = 0;
-    if (dir == 1) flags |= 0x01;
-    if (dir == -1) flags |= 0x02;
-    report.data[ofs++] = flags;
-    
-    // write the average scan time in 10us intervals to bytes 6-8
-    uint32_t t = uint32_t(avgScanTime / 10);
-    report.data[ofs++] = t & 0xff;
-    report.data[ofs++] = (t >> 8) & 0xff;
-    report.data[ofs++] = (t >> 16) & 0xff;
-    
-    // write the processing time to bytes 9-11
-    t = uint32_t(processingTime / 10);
-    report.data[ofs++] = t & 0xff;
-    report.data[ofs++] = (t >> 8) & 0xff;
-    report.data[ofs++] = (t >> 16) & 0xff;
-    
-    // send the report
-    report.length = reportLen;
-    return sendTO(&report, 100);
-}
-
 
 bool USBJoystick::reportID()
 {
@@ -189,7 +189,30 @@
     return sendTO(&report, 100);
 }
 
-bool USBJoystick::reportConfig(int numOutputs, int unitNo, int plungerZero, int plungerMax, bool configured)
+bool USBJoystick::reportConfigVar(const uint8_t *data)
+{
+    HID_REPORT report;
+
+    // initially fill the report with zeros
+    memset(report.data, 0, sizeof(report.data));
+    
+    // Set the special status bits to indicate that it's a config 
+    // variable report
+    uint16_t s = 0x9800;
+    put(0, s);
+    
+    // Copy the variable data (7 bytes, starting with the variable ID)
+    memcpy(report.data + 2, data, 7);
+    
+    // send the report
+    report.length = reportLen;
+    return sendTO(&report, 100);
+}
+
+bool USBJoystick::reportConfig(
+    int numOutputs, int unitNo, 
+    int plungerZero, int plungerMax, int plungerRlsTime,
+    bool configured)
 {
     HID_REPORT report;
 
@@ -209,10 +232,11 @@
     // write the plunger zero and max values
     put(6, plungerZero);
     put(8, plungerMax);
+    report.data[10] = uint8_t(plungerRlsTime);
     
     // write the status bits: 
     //  0x01  -> configuration loaded
-    report.data[10] = (configured ? 0x01 : 0x00);
+    report.data[11] = (configured ? 0x01 : 0x00);
     
     // send the report
     report.length = reportLen;
--- a/USBJoystick/USBJoystick.h	Tue Mar 01 23:21:45 2016 +0000
+++ b/USBJoystick/USBJoystick.h	Sat Mar 05 00:16:52 2016 +0000
@@ -213,6 +213,17 @@
          bool updateStatus(uint32_t stat);
          
          /**
+         * Write the plunger status report.
+         *
+         * @param npix number of pixels in the sensor (0 for non-imaging sensors)
+         * @param edgePos the pixel position of the detected edge in this image, or -1 if none detected
+         * @param dir sensor orientation (1 = standard, -1 = reversed, 0 = unknown)
+         * @param avgScanTime average sensor scan time in microseconds
+         * @param processingTime time in microseconds to process the current frame
+         */
+         bool sendPlungerStatus(int npix, int edgePos, int dir, uint32_t avgScanTime, uint32_t processingTime);
+         
+         /**
          * Write an exposure report.  We'll fill out a report with as many pixels as
          * will fit in the packet, send the report, and update the index to the next
          * pixel to send.  The caller should call this repeatedly to send reports for
@@ -222,18 +233,7 @@
          * @param npix number of pixels in the overall array
          * @param pix pixel array
          */
-         bool updateExposure(int &idx, int npix, const uint8_t *pix);
-         
-         /**
-         * Write the special extended exposure report with additional data about the 
-         * scan.
-         *
-         * @param edgePos the pixel position of the detected edge in this image, or -1 if none detected
-         * @param dir detected sensor orientation: 1 for standard, -1 for reversed, 0 for unknown
-         * @param avgScanTime average sensor scan time in microseconds
-         * @param processingTime time in microseconds to process the current frame
-         */
-         bool updateExposureExt(int edgePos, int dir, uint32_t avgScanTime, uint32_t processingTime);
+         bool sendPlungerPix(int &idx, int npix, const uint8_t *pix);
          
          /**
          * Write a configuration report.
@@ -242,9 +242,19 @@
          * @param unitNo the device unit number
          * @param plungerZero plunger zero calibration point
          * @param plungerMax plunger max calibration point
+         * @param plungerRlsTime measured plunger release time, in milliseconds
          * @param configured true if a configuration has been saved to flash from the host
          */
-         bool reportConfig(int numOutputs, int unitNo, int plungerZero, int plungerMax, bool configured);
+         bool reportConfig(int numOutputs, int unitNo, 
+            int plungerZero, int plungerMax, int plunterRlsTime, 
+            bool configured);
+            
+         /**
+         * Write a configuration variable query report.
+         *
+         * @param data the 7-byte data variable buffer, starting with the variable ID byte
+         */
+         bool reportConfigVar(const uint8_t *data);
          
          /**
          * Write a device ID report.
--- a/USBProtocol.h	Tue Mar 01 23:21:45 2016 +0000
+++ b/USBProtocol.h	Sat Mar 05 00:16:52 2016 +0000
@@ -55,18 +55,66 @@
 // as an opaque vendor-defined value, so the joystick interface on the
 // Windows side simply ignores it.)
 //
-// 2A. Plunger sensor pixel dump
-// Software on the PC can request a full read of the pixels from the plunger 
-// image sensor (if an imaging sensor type is being used) by sending custom 
-// protocol message 65 3 (see below).  Normally, the pixels from the image
-// sensor are read and processed on the controller device without being sent
-// to the PC; the PC only receives the plunger position reading obtained from
-// analyzing the image data.  For debugging and setup purposes, software on
-// the host can use this special report to obtain the full image pixel array.
-// The image sensors we use have too many pixels to fit into one report, so 
-// we have to send a series of reports to transmit the full image.  We send
-// as many reports as necessary to transmit the full image.  Each report
-// looks like this:
+// 2A. Plunger sensor status report
+// Software on the PC can request a detailed status report from the plunger
+// sensor.  The status information is meant as an aid to installing and
+// adjusting the sensor device for proper performance.  For imaging sensor
+// types, the status report includes a complete current image snapshot
+// (an array of all of the pixels the sensor is currently imaging).  For
+// all sensor types, it includes the current plunger position registered
+// on the sensor, and some timing information.
+//
+// To request the sensor status, the host sends custom protocol message 65 3
+// (see below).  The device replies with a message in this format:
+//
+//    bytes 0:1 = 0x87FF
+//    byte  2   = 0 -> first (currently only) status report packet
+//                (additional packets could be added in the future if
+//                more fields need to be added)
+//    bytes 3:4 = number of pixels to be sent in following messages, as
+//                an unsigned 16-bit little-endian integer.  This is 0 if 
+//                the sensor isn't an imaging type.
+//    bytes 5:6 = current plunger position registered on the sensor.
+//                For imaging sensors, this is the pixel position, so it's
+//                scaled from 0 to number of pixels - 1.  For non-imaging
+//                sensors, this uses the generic joystick scale 0..4095.
+//                The special value 0xFFFF means that the position couldn't
+//                be determined,
+//    byte  7   = bit flags: 
+//                   0x01 = normal orientation detected
+//                   0x02 = reversed orientation detected
+//                   0x04 = calibration mode is active (no pixel packets
+//                          are sent for this reading)
+//    bytes 8:9:10 = average time for each sensor read, in 10us units.
+//                This is the average time it takes to complete the I/O
+//                operation to read the sensor, to obtain the raw sensor
+//                data for instantaneous plunger position reading.  For 
+//                an imaging sensor, this is the time it takes for the 
+//                sensor to capture the image and transfer it to the
+//                microcontroller.  For an analog sensor (e.g., an LVDT
+//                or potentiometer), it's the time to complete an ADC
+//                sample.
+//    bytes 11:12:13 = time it took to process the current frame, in 10us 
+//                units.  This is the software processing time that was
+//                needed to analyze the raw data read from the sensor.
+//                This is typically only non-zero for imaging sensors,
+//                where it reflects the time required to scan the pixel
+//                array to find the indicated plunger position.  The time
+//                is usually zero or negligible for analog sensor types, 
+//                since the only "analysis" is a multiplication to rescale 
+//                the ADC sample.
+//
+// If the sensor is an imaging sensor type, this will be followed by a
+// series of pixel messages.  The imaging sensor types have too many pixels
+// to send in a single USB transaction, so the device breaks up the array
+// into as many packets as needed and sends them in sequence.  For non-
+// imaging sensors, the "number of pixels" field in the lead packet is
+// zero, so obviously no pixel packets will follow.  If the "calibration
+// active" bit in the flags byte is set, no pixel packets are sent even
+// if the sensor is an imaging type, since the transmission time for the
+// pixels would intefere with the calibration process.  If pixels are sent,
+// they're sent in order starting at the first pixel.  The format of each 
+// pixel packet is:
 //
 //    bytes 0:1 = 11-bit index, with high 5 bits set to 10000.  For 
 //                example, 0x8004 (encoded little endian as 0x04 0x80) 
@@ -77,31 +125,13 @@
 //    bytes 3   = brightness of pixel at index+1
 //    etc for the rest of the packet
 //
-// The pixel dump also sends a special final report, after all of the
-// pixel messages, with the "index" field set to 0x7FF (11 bits of 1's).  
-// This report packs special fields instead of pixels.  There are two
-// subtypes, sent in sequence:
-//
-//  Subtype 0:
-//    bytes 0:1 = 0x87FF (pixel report flags + index 0x7FF)
-//    byte 2    = 0x00 -> special report subtype 0
-//    bytes 3:4 = pixel position of detected shadow edge in this image,
-//                or 0xFFFF if no edge was found in this image.  For
-//                raw pixel reports, no edge will be detected because
-//                we don't look for one.
-//    byte 5    = flags: 
-//                   0x01 = normal orientation detected
-//                   0x02 = reversed orientation detected
-//    bytes 6:7:8 = average time for a sensor scan, in 10us units
-//    byte 9:10:11 = time for processing this image, in 10us units
-//
-//  Subtype 1:
-//    bytes 0:1 = 0x87FF
-//    byte 2    = 0x01 -> special report subtype 1
-//    bytes 3:4 = calibration zero point, in pixels (16-bit little-endian)
-//    bytes 5:6 = calibration maximum point, in pixels
-//    bytes 7:8 = calibration minimum point, in pixels
-//    byte 9    = calibrated release time, in milliseconds
+// Note that we currently only support one-dimensional imaging sensors
+// (i.e., pixel arrays that are 1 pixel wide).  The report format doesn't
+// have any provision for a two-dimensional layout.  The KL25Z probably
+// isn't powerful enough to do real-time image analysis on a 2D image
+// anyway, so it's unlikely that we'd be able to make 2D sensors work at
+// all, but if we ever add such a thing we'll have to upgrade the report 
+// format here accordingly.
 // 
 //
 // 2B. Configuration query.
@@ -114,7 +144,8 @@
 //    bytes 2:3 = total number of outputs, little endian
 //    bytes 6:7 = plunger calibration zero point, little endian
 //    bytes 8:9 = plunger calibration maximum point, little endian
-//    byte  10   = bit flags: 
+//    byte  10  = plunger calibration release time, in milliseconds
+//    byte  11  = bit flags: 
 //                 0x01 -> configuration loaded; 0 in this bit means that
 //                         the firmware has been loaded but no configuration
 //                         has been sent from the host
@@ -124,14 +155,26 @@
 // This is requested by sending custom protocol message 65 7 (see below).
 // In response, the device sends one report to the host using this format:
 //
-//    bytes 0:1 = 0x9000.  This has bit pattern 10010 in the high 5
-//                bits, which distinguishes this special report from other 
-//                report types.
+//    bytes 0:1 = 0x9000.  This has bit pattern 10010 in the high 5 bits
+//                to distinguish this from other report types.
 //    bytes 2-11 = Unique CPU ID.  This is the ID stored in the CPU at the
 //                factory, guaranteed to be unique across Kinetis devices.
 //                This can be used by the host to distinguish devices when
 //                two or more controllers are attached.
 //
+// 2D. Configuration variable query.
+// This is requested by sending custom protocol message 65 9 (see below).
+// In response, the device sends one report to the host using this format:
+//
+//   bytes 0:1 = 0x9800.  This has bit pattern 10011 in the high 5 bits
+//               to distinguish this from other report types.
+//   byte  2   = Variable ID.  This is the same variable ID sent in the
+//               query message, to relate the reply to the request.
+//   bytes 3-8 = Current value of the variable, in the format for the
+//               individual variable type.  The variable formats are
+//               described in the CONFIGURATION VARIABLES section below.
+//
+//
 // WHY WE USE THIS HACKY APPROACH TO DIFFERENT REPORT TYPES
 //
 // The HID report system was specifically designed to provide a clean,
@@ -294,6 +337,12 @@
 //             engage night mode, 0 to disengage night mode.  (This mode isn't stored
 //             persistently; night mode is disengaged after a reset or power cycle.)
 //
+//        9 -> Query configuration variable.  The second byte is the config variable
+//             number (see the CONFIGURATION VARIABLES section below).  For the array
+//             variables (button assignments, output ports), the third byte is the
+//             array index.  The device replies with a configuration variable report
+//             (see above) with the current setting for the requested variable.
+//
 // 66  -> Set configuration variable.  The second byte of the message is the config
 //        variable number, and the remaining bytes give the new value for the variable.
 //        The value format is specific to each variable; see the list below for details.
@@ -527,7 +576,27 @@
 //       timeout period.  Bytes 3 give the new reboot timeout in seconds.  Setting this
 //       to 0 disables the reboot timeout.
 //
-
+// 15 -> Plunger calibration.  In most cases, the calibration is set internally by the
+//       device by running the calibration procedure.  However, it's sometimes useful
+//       for the host to be able to get and set the calibration, such as to back up
+//       the device settings on the PC, or to save and restore the current settings
+//       when installing a software update.
+//
+//         bytes 3:4 = rest position (unsigned 16-bit little-endian)
+//         bytes 5:6 = maximum retraction point (unsigned 16-bit little-endian)
+//         byte  7   = measured plunger release travel time in milliseconds
+//
+// 16 -> Expansion board configuration.  This doesn't affect the controller behavior
+//       directly; the individual options related to the expansion boards (such as 
+//       the TLC5940 and 74HC595 setup) still need to be set separately.  This is
+//       stored so that the PC config UI can store and recover the information to
+//       present in the UI.  For the "classic" KL25Z-only configuration, simply set 
+//       all of the fields to zero.
+//
+//         byte 3 = number of main interface boards
+//         byte 4 = number of MOSFET power boards
+//         byte 5 = number of chime boards
+//
 
 
 // --- PIN NUMBER MAPPINGS ---
--- a/ccdSensor.h	Tue Mar 01 23:21:45 2016 +0000
+++ b/ccdSensor.h	Sat Mar 05 00:16:52 2016 +0000
@@ -55,6 +55,9 @@
         int pixpos;
         if (process(pix, n, pixpos, 0))
         {            
+            // run the position through the anti-jitter filter
+            filter(pixpos);
+
             // Normalize to the 16-bit range.  Our reading from the 
             // sensor is a pixel position, 0..n-1.  To rescale to the
             // normalized range, figure pixpos*65535/(n-1).
@@ -76,24 +79,6 @@
     // of the edge and return true; otherwise we return false.  The 'pos'
     // value returned, if any, is adjusted for sensor orientation so that
     // it reflects the logical plunger position.
-    //
-    // 'visMode' is the visualization mode.  If non-zero, we replace the
-    // pixels in the 'pix' array with a new version for visual presentation
-    // to the user, as an aid to setup and debugging.  The visualization
-    // modes are:
-    //
-    //   0 = No visualization
-    //   1 = High contrast: we set each pixel to white or black according
-    //       to whether it's brighter or dimmer than the midpoint brightness 
-    //       we use to seek the shadow edge.  This mode makes the edge 
-    //       positions visually apparent.
-    //   2 = Edge mode: we set all pixels to white except for detected edges,
-    //       which we set to black.
-    //
-    // The 'pix' array is overwritten with the processed pixels.  If visMode
-    // is 0, this reflects only the basic preprocessing we do in an edge
-    // scan, such as noise reduction.  For other visualization modes, the
-    // pixels are replaced by the visualization results.
     bool process(uint8_t *pix, int &n, int &pos, int visMode)
     {
         // Get the levels at each end
@@ -194,455 +179,75 @@
         // no edge found
         return false;
     }
-
-
-#if 0
-    bool process3(uint8_t *pix, int &n, int &pos, int visMode)
-    {
-        // First, reduce the pixel array resolution to 1/4 of the 
-        // native sensor resolution.  The native 400 dpi is higher
-        // than we need for good results, so we can afford to cut 
-        // this down a bit.  Reducing the resolution  gives us
-        // a little simplistic noise reduction (by averaging adjacent
-        // pixels), and it speeds up the rest of the edge finder by
-        // making the data set smaller.
-        //
-        // While we're scanning, collect the brightness range of the
-        // reduced pixel set.
-        register int src, dst;
-        int lo = pix[0], hi = pix[0];
-        for (src = 0, dst = 0 ; src < n ; )
+    
+    // Filter a result through the jitter reducer.  We tend to have some
+    // very slight jitter - by a pixel or two - even when the plunger is
+    // stationary.  This happens due to analog noise.  In the theoretical
+    // ideal, analog noise wouldn't be a factor for this sensor design,
+    // in that we'd have enough contrast between the bright and dark
+    // regions that there'd be no ambiguity as to where the shadow edge
+    // falls.  But in the real system, the shadow edge isn't perfectly
+    // sharp on the scale of our pixels, so the edge isn't an ideal
+    // digital 0-1 discontinuity but rather a ramp of gray levels over
+    // a few pixels.  Our edge detector picks the pixel where we cross
+    // the midpoint brightness threshold.  The exact midpoint can vary
+    // a little from frame to frame due to exposure length variations,
+    // light source variations, other stray light sources in the cabinet, 
+    // ADC error, sensor pixel noise, and electrical noise.  As the 
+    // midpoint varies, the pixel that qualifies as the edge position 
+    // can move by a pixel or two from one from to the next, even 
+    // though the physical shadow isn't moving.  This all adds up to
+    // some slight jitter in the final position reading.
+    //
+    // To reduce the jitter, we keep a short history of recent readings.
+    // When we see a new reading that's close to the whole string of
+    // recent readings, we peg the new reading to the consensus of the
+    // recent history.  This smooths out these small variations without
+    // affecting response time or resolution.
+    void filter(int &pos)
+    {        
+        // check to see if it's close to all of the history elements
+        const int dpos = 1;
+        bool isClose = true;
+        long sum = 0;
+        for (int i = 0 ; i < countof(hist) ; ++i)
         {
-            // compute the average of this pixel group
-            int p = (int(pix[src++]) + pix[src++] + pix[src++] + pix[src++]) / 4;
-            
-            // note if it's the new high or low point
-            if (p > hi)
-                hi = p;
-            else if (p < lo)
-                lo = p;
-            
-            // Store the result back into the original array.  Note
-            // that there's no risk of overwriting anything we still
-            // need, since the pixel set is shrinking, so the write 
-            // pointer is always behind the read pointer.
-            pix[dst++] = p;
-        }
-        
-        // set the new array size
-        n = dst;
-
-        // figure the midpoint brightness
-        int mid = (hi + lo)/2;
-        
-        // Look at the first few pixels on the left and right sides
-        // to try to detect the sensor orientation. 
-        int left = pix[0] + pix[1] + pix[2] + pix[3];
-        int right = pix[n-1] + pix[n-2] + pix[n-3] + pix[n-4];
-        if (left > right + 40)
-        {
-            // left side is brighter - standard orientation
-            dir = 1;
-        }
-        else if (right > left + 40)
-        {
-            // right side is brighter - reversed orientation
-            dir = -1;
-        }
-        
-        // scan for edges according to the direction
-        bool found = false;
-        if (dir == 0)
-        {
-        }
-        else
-        {
-            // scan from the bright end to the dark end
-            int stop;
-            if (dir == 1)
-            {
-                src = 0;
-                stop = n;
-            }
-            else
+            int ipos = hist[i];
+            sum += ipos;
+            if (pos > ipos + dpos || pos < ipos - dpos)
             {
-                src = n - 1;
-                stop = -1;
-            }
-
-            // scan through the pixels
-            for ( ; src != stop ; src += dir)
-            {
-                // if this pixel is darker than the midpoint, we might 
-                // have an edge
-                if (pix[src] < mid)
-                {
-                    // make sure it's not just noise by checking the next
-                    // few to make sure they're also darker
-                    if (dir > 0)
-                        dst = src + 10 > n ? n : src + 10;
-                    else
-                        dst = src - 10 < 0 ? -1 : src - 10;
-                    int i, nok;
-                    for (nok = 0, i = src ; i != dst ; i += dir)
-                    {
-                        if (pix[i] < mid)
-                            ++nok;
-                    }
-                    if (nok > 6)
-                    {
-                        // we have a winner
-                        pos = src;
-                        found = true;
-                        break;
-                    }
-                }
-            }
-        }
-
-        // return the result
-        return found;        
-    }
-#endif
-
-#if 0
-    bool process2(uint8_t *pix, int n, int &pos, int visMode)
-    {
-        // find the high and low brightness levels, and sum
-        // all pixels (for the running averages)
-        register int i;
-        long sum = 0;
-        int lo = 255, hi = 0;
-        for (i = 0 ; i < n ; ++i)
-        {
-            int p = pix[i];
-            sum += p;
-            if (p > hi) hi = p;
-            if (p < lo) lo = p;
-        }
-        
-        // Figure the midpoint brightness
-        int mid = (lo + hi)/2;
-        
-        // Scan for edges.  An edge is where adjacent pixels are
-        // on opposite sides of the brightness midpoint.  For each
-        // edge, we'll compute the "steepness" as the difference
-        // between the average brightness on each side.  We'll
-        // keep only the steepest edge.
-        register int bestSteepness = -1;
-        register int bestPos = -1;
-        register int sumLeft = 0;
-        register int prv = pix[0], nxt = pix[1];
-        for (i = 1 ; i < n ; prv = nxt, nxt = pix[++i])
-        {
-            // figure the new sums left and right of the i:i+1 boundary
-            sumLeft += prv;
-            
-            // if this is an edge, check if it's the best edge
-            if (((mid - prv) & 0x80) ^ ((mid - nxt) & 0x80))
-            {
-                // compute the steepness
-                int steepness = sumLeft/i - (sum - sumLeft)/(n-i);
-                if (steepness > bestSteepness)
-                {
-                    bestPos = i;
-                    bestSteepness = steepness;
-                }
+                isClose = false;
+                break;
             }
         }
         
-        // if we found a position, return it
-        if (bestPos >= 0)
+        // check if we're close to all recent readings
+        if (isClose)
         {
-            pos = bestPos;
-            return true;
+            // We're close, so just stick to the average of recent
+            // readings.  Note that we don't add the new reading to
+            // the history in this case.  If the edge is about halfway
+            // between two pixels, the history will be about 50/50 on
+            // an ongoing basis, so if just kept adding samples we'd
+            // still jitter (just at a slightly reduced rate).  By
+            // stalling the history when it looks like we're stationary,
+            // we'll just pick one of the pixels and stay there as long
+            // as the plunger stays where it is.
+            pos = sum/countof(hist);
         }
         else
         {
-            return false;
-        }
-    }
-#endif
-
-#if 0
-    bool process1(uint8_t *pix, int n, int &pos, int visMode)
-    {
-        // presume failure
-        bool ret = false;
-        
-        // apply noise reduction
-        noiseReduction(pix, n);
-        
-        // make a histogram of brightness values
-        uint8_t hist[256];
-        memset(hist, 0, sizeof(hist));
-        for (int i = 0 ; i < n ; ++i)
-        {
-            // get this pixel brightness, and count it in the histogram,
-            // stopping if we hit the maximum count of 255
-            int b = pix[i];
-            if (hist[b] < 255)
-                ++hist[b];
-        }
-        
-        // Find the high and low bounds.  To avoid counting outliers that
-        // might be noise, we'll scan in from each end of the brightness 
-        // range until we find a few pixels at or outside that level.
-        int cnt, lo, hi;
-        const int mincnt = 10;
-        for (cnt = 0, lo = 0 ; lo < 255 ; ++lo)
-        {
-            cnt += hist[lo];
-            if (cnt >= mincnt)
-                break;
-        }
-        for (cnt = 0, hi = 255 ; hi >= 0 ; --hi)
-        {
-            cnt += hist[hi];
-            if (cnt >= mincnt)
-                break;
-        }
-        
-        // figure the inferred midpoint brightness level
-        uint8_t m = uint8_t((int(lo) + int(hi))/2);
-        
-        // Try finding an edge with the inferred brightness range
-        if (findEdge(pix, n, m, pos, false))
-        {
-            // Found it!  This image has sufficient contrast to find
-            // an edge, so save the midpoint brightness for next time in
-            // case the next image isn't as clear.
-            midpt[midptIdx] = m;
-            midptIdx = (midptIdx + 1) % countof(midpt);
-            
-            // Infer the sensor orientation.  If pixels at the bottom 
-            // of the array are brighter than pixels at the top, it's in the
-            // standard orientation, otherwise it's the reverse orientation.
-            int a = int(pix[0]) + int(pix[1]) + int(pix[2]);
-            int b = int(pix[n-1]) + int(pix[n-2]) + int(pix[n-3]);
-            dir = (a > b ? 1 : -1);
-            
-            // if we're in the reversed orientation, mirror the position
-            if (dir < 0)
-                pos = n-1 - pos;
-                
-            // success
-            ret = true;
-        }
-        else
-        {
-            // We didn't find a clear edge using the inferred exposure
-            // level.  This might be because the image is entirely in or out
-            // of shadow, with the plunger's shadow's edge out of the frame.
-            // Figure the average of the recent history of successful frames
-            // so that we can check to see if we have a low-contrast image
-            // that's entirely above or below the recent midpoints.
-            int avg = 0;
-            for (int i = 0 ; i < countof(midpt) ; avg += midpt[i++]) ;
-            avg /= countof(midpt);
-            
-            // count how many we have above and below the midpoint
-            int nBelow = 0, nAbove = 0;
-            for (int i = 0 ; i < avg ; nBelow += hist[i++]) ;
-            for (int i = avg + 1 ; i < 255 ; nAbove += hist[i++]) ;
-            
-            // check if we're mostly above or below (we don't require *all*,
-            // to allow for some pixel noise remaining)
-            if (nBelow < 50)
-            {
-                // everything's bright -> we're in full light -> fully retracted
-                pos = n - 1;
-                ret = true;
-            }
-            else if (nAbove < 50)
-            {
-                // everything's dark -> we're in full shadow -> fully forward
-                pos = 0;
-                ret = true;
-            }
-            
-            // for visualization purposes, use the previous average as the midpoint
-            m = avg;
-        }
-        
-        // If desired, apply the visualization mode to the pixels
-        switch (visMode)
-        {
-        case 2:
-            // High contrast mode.  Peg each pixel to the white or black according
-            // to which side of the midpoint it's on.
-            for (int i = 0 ; i < n ; ++i)
-                pix[i] = (pix[i] < m ? 0 : 255);
-            break;
-            
-        case 3:
-            // Edge mode.  Re-run the edge analysis in visualization mode.
-            {
-                int dummy;
-                findEdge(pix, n, m, dummy, true);
-            }
-            break;
-        }
-        
-        // return the result
-        return ret;
-    }
-    
-    // Apply noise reduction to the pixel array.  We use a simple rank
-    // selection median filter, which is fast and seems to produce pretty
-    // good results with data from this sensor type.  The filter looks at
-    // a small window around each pixel; if a given pixel is the outlier
-    // within its window (i.e., it has the maximum or minimum brightness 
-    // of all the pixels in the window), we replace it with the median
-    // brightness of the pixels in the window.  This works particularly
-    // well with the structure of the image we expect to capture, since
-    // the image should have stretches of roughly uniform brightness -
-    // part fully exposed and part in the plunger's shadow.  Spiky
-    // variations in isolated pixels are almost guaranteed to be noise.
-    void noiseReduction(uint8_t *pix, int n)
-    {
-        // set up a rolling window of pixels
-        uint8_t w[7] = { pix[0], pix[1], pix[2], pix[3], pix[4], pix[5], pix[6] };
-        int a = 0;
-        
-        // run through the pixels
-        for (int i = 0 ; i < n ; ++i)
-        {
-            // set up a sorting array for the current window
-            uint8_t tmp[7] = { w[0], w[1], w[2], w[3], w[4], w[5], w[6] };
-            
-            // sort it (using a Bose-Nelson sorting network for N=7)
-#define SWAP(x, y) { \
-                const int a = tmp[x], b = tmp[y]; \
-                if (a > b) tmp[x] = b, tmp[y] = a; \
-            }
-            SWAP(1, 2);
-            SWAP(0, 2);
-            SWAP(0, 1);
-            SWAP(3, 4);
-            SWAP(5, 6);
-            SWAP(3, 5);
-            SWAP(4, 6);
-            SWAP(4, 5);
-            SWAP(0, 4);
-            SWAP(0, 3);
-            SWAP(1, 5);
-            SWAP(2, 6);
-            SWAP(2, 5);
-            SWAP(1, 3);
-            SWAP(2, 4);
-            SWAP(2, 3);            
-
-            // if the current pixel is at one of the extremes, replace it
-            // with the median, otherwise leave it unchanged
-            if (pix[i] == tmp[0] || pix[i] == tmp[6])
-                pix[i] = tmp[3];
-                
-            // update our rolling window, if we're not at the start or
-            // end of the overall pixel array
-            if (i >= 3 && i < n-4)
-            {
-                w[a] = pix[i+4];
-                a = (a + 1) % 7;
-            }
+            // This isn't near enough to the recent stationary position,
+            // so keep the new reading exactly as it is, and add it to the
+            // history.
+            hist[histIdx++] = pos;
+            histIdx %= countof(hist);
         }
     }
     
-    // Find an edge in the image.  'm' is the midpoint brightness level
-    // in the array.  On success, fills in 'pos' with the pixel position
-    // of the edge and returns true.  Returns false if no clear, unique 
-    // edge can be detected.
-    //
-    // If 'vis' is true, we'll update the pixel array with a visualization
-    // of the edges, for display in the config tool.
-    bool findEdge(uint8_t *pix, int n, uint8_t m, int &pos, bool vis)
-    {
-        // Scan for edges.  An edge is a transition where two adajacent
-        // pixels are on opposite sides of the brightness midpoint.
-        int nEdges = 0;
-        int edgePos = 0;
-        uint8_t prv = pix[0], nxt = pix[1];
-        for (int i = 1 ; i < n-1 ; prv = nxt, nxt = pix[++i])
-        {
-            // presume we'll show a non-edge (white) pixel in the visualization
-            uint8_t vispix = 255;
-            
-            // if the two are on opposite sides of the midpoint, we have
-            // an edge
-            if ((prv < m && nxt > m) || (prv > m && nxt < m))
-            {
-                // count the edge and note its position
-                ++nEdges;
-                edgePos = i;
-                
-                // color edges black in the visualization
-                vispix = 0;
-            }
-            
-            // if in visualization mode, substitute the visualization pixel
-            if (vis)
-                pix[i] = vispix;
-        }
-        
-        // check for a unique edge
-        if (nEdges == 1)
-        {
-            // Successfully found an edge - presume we'll return the raw
-            // value we just found
-            pos = edgePos;
-
-            // Filtering to the signal to reduce jitter.  We sometimes see
-            // the detected position jitter around by a pixel or two when
-            // the plunger is stationary; the filtering is meant to reduce
-            // or (ideally) eliminate it.  The jitter happens because the
-            // exactly pixel position of the edge can be a little ambiguous.
-            // The shadow is usually a little fuzzy and spans more than one
-            // pixel on the sensor, so our algorithm picks out the edge in
-            // each frame according to relative brightness from pixel to
-            // pixel.  The exact relative brightnesses can vary a bit,
-            // though, due to variations in exposure time, light source
-            // uniformity, other stray light sources in the cabinet, pixel
-            // noise in the sensor, ADC error, etc.  
-            //
-            // To filter the jitter, we'll look through the recent history
-            // to see if the recent samples are within a couple of pixels
-            // of each other.  If so, we'll take an average and substitute
-            // that for our current reading.
-            bool allClose = true;
-            long sum = 0;
-            for (int i = 0 ; i < countof(hist) ; ++i)
-            {
-                // if this one isn't close enough, they're not all close
-                if (abs(hist[i] - edgePos) > 2)
-                {
-                    allClose = false;
-                    break;
-                }
-                
-                // count it in the sum
-                sum += hist[i];
-            }
-            if (allClose)
-            [
-                // they're all close by - replace this reading with the
-                // average of nearby pixels
-                pos = int(sum / countof(hist));
-            }
-            
-            // indicate success
-            return true;
-        }
-        else
-        {
-            // failure
-            return false;
-        }
-    }
-#endif
-    
-    // Send an exposure report to the joystick interface.
+    // Send a status report to the joystick interface.
     // See plunger.h for details on the flags and visualization modes.
-    virtual void sendExposureReport(USBJoystick &js, uint8_t flags, uint8_t visMode)
+    virtual void sendStatusReport(USBJoystick &js, uint8_t flags, uint8_t visMode)
     {
         // start a capture
         ccd.startCapture();
@@ -652,23 +257,20 @@
         int n;
         uint32_t t;
         ccd.getPix(pix, n, t);
+
+        // start a timer to measure the processing time
+        Timer pt;
+        pt.start();
+
+        // process the pixels and read the position
+        int pos;
+        if (process(pix, n, pos, visMode))
+            filter(pos);
+        else
+            pos = 0xFFFF;
         
-        // Apply processing if desired.  For visualization mode 0, apply no
-        // processing at all.  For all others it through the pixel processor.
-        int pos = 0xffff;
-        uint32_t processTime = 0;
-        if (visMode != 0)
-        {
-            // count the processing time
-            Timer pt;
-            pt.start();
-
-            // do the processing
-            process(pix, n, pos, visMode);
-            
-            // note the processing time
-            processTime = pt.read_us();
-        }
+        // note the processing time
+        uint32_t processTime = pt.read_us();
         
         // if a low-res scan is desired, reduce to a subset of pixels
         if (flags & 0x01)
@@ -681,68 +283,39 @@
             int src, dst;
             for (src = dst = 0 ; dst < lowResPix ; ++dst)
             {
-                // Combine these pixels - the best way to do this differs
-                // by visualization mode...
+                // average this block of pixels
                 int a = 0;
-                switch (visMode)
-                {
-                case 0:
-                case 1:
-                    // Raw or noise-reduced pixels.  This mode shows basically
-                    // a regular picture, so reduce the resolution by averaging
-                    // the grouped pixels.
-                    for (int j = 0 ; j < group ; ++j)
-                        a += pix[src++];
+                for (int j = 0 ; j < group ; ++j)
+                    a += pix[src++];
                         
-                    // we have the sum, so get the average
-                    a /= group;
-                    break;
-                    
-                case 2:
-                    // High contrast mode.  To retain the high contrast, take a
-                    // majority vote of the pixels.  Start by counting the white
-                    // pixels.
-                    for (int j = 0 ; j < group ; ++j)
-                        a += (pix[src++] > 127);
-                        
-                    // If half or more are white, make the combined pixel white;
-                    // otherwise make it black.
-                    a = (a >= n/2 ? 255 : 0);
-                    break;
-                    
-                case 3:
-                    // Edge mode.  Edges are shown as black.  To retain every
-                    // detected edge in the result image, show the combined pixel
-                    // as an edge if ANY pixel within the group is an edge.
-                    a = 255;
-                    for (int j = 0 ; j < group ; ++j)
-                    {
-                        if (pix[src++] < 127)
-                            a = 0;
-                    }
-                    break;
-                }
-                    
+                // we have the sum, so get the average
+                a /= group;
+
                 // store the down-res'd pixel in the array
                 pix[dst] = uint8_t(a);
             }
             
-            // update the pixel count to the number we stored
-            n = dst;
-            
-            // if we have a valid position, rescale it to the reduced pixel count
-            if (pos != 0xffff)
-                pos = pos / group;
+            // rescale the position for the reduced resolution
+            if (pos != 0xFFFF)
+                pos = pos * (lowResPix-1) / (n-1);
+
+            // update the pixel count to the reduced array size
+            n = lowResPix;
         }
         
-        // send reports for all pixels
-        int idx = 0;
-        while (idx < n)
-            js.updateExposure(idx, n, pix);
+        // send the sensor status report report
+        js.sendPlungerStatus(n, pos, dir, ccd.getAvgScanTime(), processTime);
+        
+        // If we're not in calibration mode, send the pixels
+        extern bool plungerCalMode;
+        if (!plungerCalMode)
+        {
+            // send the pixels in report-sized chunks until we get them all
+            int idx = 0;
+            while (idx < n)
+                js.sendPlungerPix(idx, n, pix);
+        }
             
-        // send a special final report with additional data
-        js.updateExposureExt(pos, dir, ccd.getAvgScanTime(), processTime);
-        
         // It takes us a while to send all of the pixels, since we have
         // to break them up into many USB reports.  This delay means that
         // the sensor has been sitting there integrating for much longer
@@ -754,6 +327,9 @@
         ccd.startCapture();
     }
     
+    // get the average sensor scan time
+    virtual uint32_t getAvgScanTime() { return ccd.getAvgScanTime(); }
+    
 protected:
     // Sensor orientation.  +1 means that the "tip" end - which is always
     // the brighter end in our images - is at the 0th pixel in the array.
@@ -772,7 +348,7 @@
 
     // History of recent position readings.  We keep a short history of
     // readings so that we can apply some filtering to the data.
-    uint16_t hist[10];
+    uint16_t hist[8];
     int histIdx;    
     
     // History of midpoint brightness levels for the last few successful
--- a/cfgVarMsgMap.h	Tue Mar 01 23:21:45 2016 +0000
+++ b/cfgVarMsgMap.h	Sat Mar 05 00:16:52 2016 +0000
@@ -156,6 +156,20 @@
         // Disconnect reboot timeout
         v_byte(disconnectRebootTimeout, 2);
         break;
+        
+    case 15:
+        // plunger calibration
+        v_ui16(plunger.cal.zero, 2);
+        v_ui16(plunger.cal.max, 4);
+        v_byte(plunger.cal.tRelease, 6);
+        break;
+        
+    case 16:
+        // expansion board configuration
+        v_byte(expan.nMain, 2);
+        v_byte(expan.nPower, 3);
+        v_byte(expan.nChime, 4);
+        break;
     }
 }
 
--- a/config.h	Tue Mar 01 23:21:45 2016 +0000
+++ b/config.h	Sat Mar 05 00:16:52 2016 +0000
@@ -20,7 +20,7 @@
 // $$$ TESTING CONFIGURATIONS
 #define TEST_CONFIG_EXPAN     0
 #define TEST_CONFIG_CAB       1
-#define TEST_KEEP_PRINTF      1
+#define TEST_KEEP_PRINTF      0
 
 
 #ifndef CONFIG_H
@@ -145,6 +145,11 @@
         // assume standard orientation, with USB ports toward front of cabinet
         orientation = OrientationFront;
 
+        // assume a basic setup with no expansion boards
+        expan.nMain = 0;
+        expan.nPower = 0;
+        expan.nChime = 0;
+
         // assume no plunger is attached
         plunger.enabled = false;
         plunger.sensorType = PlungerType_None;
@@ -394,6 +399,16 @@
     char orientation;
     
     
+    // --- EXPANSION BOARDS ---
+    struct
+    {
+        int nMain;      // number of main interface boards (usually 1 max)
+        int nPower;     // number of MOSFET power boards
+        int nChime;     // number of chime boards
+        
+    } expan;
+    
+    
     // --- PLUNGER CONFIGURATION ---
     struct
     {
@@ -471,6 +486,9 @@
             uint16_t min;
             uint16_t zero;
             uint16_t max;
+            
+            // Measured release time, in milliseconds.
+            uint8_t tRelease;
     
             // Reset the plunger calibration
             void setDefaults()
@@ -479,6 +497,7 @@
                 min = 0;                  // assume we can go all the way forward...
                 max = 0xffff;             // ...and all the way back
                 zero = max/6;             // the rest position is usually around 1/2" back = 1/6 of total travel
+                tRelease = 65;            // standard 65ms release time
             }
             
             // Begin calibration.  This sets each limit to the worst
@@ -491,6 +510,7 @@
                 min = 0;                  // we don't calibrate the maximum forward position, so keep this at zero
                 zero = 0xffff;            // set the zero position all the way back
                 max = 0;                  // set the retracted position all the way forward
+                tRelease = 65;            // revert to a default release time
             }
 
         } cal;
--- a/main.cpp	Tue Mar 01 23:21:45 2016 +0000
+++ b/main.cpp	Sat Mar 05 00:16:52 2016 +0000
@@ -2308,6 +2308,9 @@
     }
 }
 
+// Global plunger calibration mode flag
+bool plungerCalMode;
+
 // Plunger reader
 //
 // This class encapsulates our plunger data processing.  At the simplest
@@ -2368,9 +2371,6 @@
 
         // no history yet
         histIdx = 0;
-        
-        // not in calibration mode
-        cal = false;
     }
 
     // Collect a reading from the plunger sensor.  The main loop calls
@@ -2384,21 +2384,9 @@
         if (plungerSensor->read(r))
         {
             // if in calibration mode, apply it to the calibration
-            if (cal)
+            if (plungerCalMode)
             {
-                // if it's outside of the current calibration bounds,
-                // expand the bounds
-                if (r.pos < cfg.plunger.cal.min)
-                    cfg.plunger.cal.min = r.pos;
-                if (r.pos < cfg.plunger.cal.zero)
-                    cfg.plunger.cal.zero = r.pos;
-                if (r.pos > cfg.plunger.cal.max)
-                    cfg.plunger.cal.max = r.pos;
-                    
-                // As long as we're in calibration mode, return the raw
-                // sensor position as the joystick value, adjusted to the
-                // JOYMAX scale.
-                z = int16_t((long(r.pos) * JOYMAX)/65535);
+                readForCal(r);
                 return;
             }
             
@@ -2419,7 +2407,7 @@
             // bounds-check the calibration data
             checkCalBounds(r.pos);
 
-            // calibrate and rescale the value
+            // Apply the calibration and rescale to the joystick range.
             r.pos = int(
                 (long(r.pos - cfg.plunger.cal.zero) * JOYMAX)
                 / (cfg.plunger.cal.max - cfg.plunger.cal.zero));
@@ -2638,20 +2626,195 @@
     uint32_t getTimestamp() const { return nthHist(0).t; }
 
     // Set calibration mode on or off
-    void calMode(bool f) 
+    void setCalMode(bool f) 
     {
-        // if entering calibration mode, reset the saved calibration data
-        if (f && !cal)
+        // check to see if we're entering calibration mode
+        if (f && !plungerCalMode)
+        {
+            // reset the calibration in the configuration
             cfg.plunger.cal.begin();
-
+            
+            // start in state 0 (waiting to settle)
+            calState = 0;
+            calZeroPosSum = 0;
+            calZeroPosN = 0;
+            calRlsTimeSum = 0;
+            calRlsTimeN = 0;
+            
+            // set the initial zero point to the current position
+            PlungerReading r;
+            if (plungerSensor->read(r))
+            {
+                // got a reading - use it as the initial zero point
+                cfg.plunger.cal.zero = r.pos;
+                
+                // use it as the starting point for the settling watch
+                f1 = r;
+            }
+            else
+            {
+                // no reading available - use the default 1/6 position
+                cfg.plunger.cal.zero = 0xffff/6;
+                
+                // we don't have a starting point for the setting watch
+                f1.pos = -65535;
+                f1.t = 0;
+            }
+        }
+            
         // remember the new mode
-        cal = f; 
+        plungerCalMode = f; 
     }
     
     // is a firing event in progress?
     bool isFiring() { return firing > 3; }
 
 private:
+    // Read the sensor in calibration mode
+    void readForCal(PlungerReading r)
+    {
+        // if it's outside of the current calibration bounds,
+        // expand the bounds
+        if (r.pos < cfg.plunger.cal.min)
+            cfg.plunger.cal.min = r.pos;
+        if (r.pos > cfg.plunger.cal.max)
+            cfg.plunger.cal.max = r.pos;
+                    
+        // While we're in calibration mode, report the raw sensor
+        // position as the joystick value, adjusted to the JOYMAX scale.
+        z = int16_t((long(r.pos) * JOYMAX)/65535);
+        
+        // for the release monitoring, take readings at least 2ms apart
+        if (uint32_t(r.t - f2.t) < 2000UL)
+            return;
+        
+        // Check our state
+        switch (calState)
+        {
+        case 0:
+            // We're waiting for the position to settle.  Check to see if
+            // we've been at the recent settling position long enough.
+            // Consider 1/50" (about 0.5mm) close enough to count as stable, 
+            // to allow for some slight sensor noise from reading to reading.
+            if (abs(r.pos - f1.pos) > 65535/3/50)
+            {
+                // too far away - set the new starting point
+                f1 = r;
+            }
+            else if (uint32_t(r.t - f1.t) > 100000)
+            {
+                // We've been stationary long enough to count as settled.
+                // Wwitch to "at rest" state.
+                calState = 1;
+                
+                // collect the new zero point for our average
+                calZeroPosSum += r.pos;
+                calZeroPosN += 1;
+                
+                // use the new average as the zero point
+                cfg.plunger.cal.zero = uint16_t(calZeroPosSum / calZeroPosN);
+                
+                // remember the current position in f1 to detect when we start
+                // moving again
+                f1 = r;
+            }
+            break;
+            
+        case 1:
+            // At rest.  We remain in this state until we see the plunger
+            // retract more than about 1/2".
+            if (r.pos - f1.pos > 65535/6)
+            {
+                // switch to state 2 - retracting
+                calState = 2;
+                
+                // use f1 as the max so far
+                f1 = r;
+            }
+            break;
+            
+        case 2:
+            // Away from rest position.  Note the maximum point so far in f1,
+            // and monitor for release motions.
+            if (r.pos >= f1.pos)
+            {
+                // moving back - note the new max point on this run
+                f1 = r;
+            }
+            else
+            {
+                // moving forward - switch to possible release mode
+                calState = 3;
+            }
+            break;
+            
+        case 3:
+            // Possible release.  We have to move forward on each new
+            // reading, relative to two readings ago, to stay in release 
+            // mode.
+            if (r.pos >= f3r.pos)
+            {
+                // not moving forward - switch back to retract mode
+                calState = 2;
+                f1 = r;
+            }
+            else if (r.pos <= cfg.plunger.cal.zero)
+            {
+                // Crossed the zero point.  Figure the release time.  If
+                // it's within a reasonable range, add it to the average.
+                // We'll ignore outliers on the assumption that they
+                // don't reflect actual release motions.  
+                int dt = uint32_t(r.t - f1.t)/1000;
+                if (dt < 250 && dt > 25)
+                {
+                    // count it in the average
+                    calRlsTimeSum += dt;
+                    calRlsTimeN += 1;
+                    
+                    // store the new average in the configuration
+                    cfg.plunger.cal.tRelease = uint8_t(calRlsTimeSum / calRlsTimeN);
+                    cfg.plunger.cal.tRelease = dt; // $$$
+                }
+                
+                // release done - switch to "waiting to settle" mode
+                calState = 0;
+                f1 = r;
+            }
+            break;
+        }
+        
+        // f2 is always the immediately previous reading in cal mode, 
+        // and f3r is the one before that
+        f3r = f2;
+        f2 = r;
+    }
+
+    // Calibration state.  During calibration mode, we watch for release
+    // events, to measure the time it takes to complete the release
+    // motion; and we watch for the plunger to come to reset after a
+    // release, to gather statistics on the rest position.
+    //   0 = waiting to settle
+    //   1 = at rest
+    //   2 = retracting
+    //   3 = possibly releasing
+    uint8_t calState;
+    
+    // Calibration zero point statistics.
+    // During calibration mode, we collect data on the rest position (the 
+    // zero point) by watching for the plunger to come to rest after each 
+    // release.  We average these rest positions to get the calibrated 
+    // zero point.  We use the average because the real physical plunger 
+    // itself doesn't come to rest at exactly the same spot every time, 
+    // largely due to friction in the mechanism.  To calculate the average,
+    // we keep a sum of the readings and a count of samples.
+    long calZeroPosSum;
+    int calZeroPosN;
+    
+    // Calibration release time statistics.
+    // During calibration, we collect an average for the release time.
+    long calRlsTimeSum;
+    int calRlsTimeN;
+
     // set a firing mode
     inline void firingMode(int m) 
     {
@@ -2817,9 +2980,6 @@
     // freely.
     PlungerReading f3r;
     
-    // flag: we're in calibration mode
-    bool cal;
-    
     // next Z value to report to the joystick interface (in joystick 
     // distance units)
     int z;
@@ -3125,9 +3285,9 @@
     
 // Pixel dump mode - the host requested a dump of image sensor pixels
 // (helpful for installing and setting up the sensor and light source)
-bool reportPix = false;
-uint8_t reportPixFlags;    // pixel report flag bits (see ccdSensor.h)
-uint8_t reportPixVisMode;  // pixel report visualization mode (not currently used)
+bool reportPlungerStat = false;
+uint8_t reportStatFlags;    // pixel report flag bits (see ccdSensor.h)
+uint8_t reportStatVisMode;  // pixel report visualization mode (not currently used)
 
 
 // ---------------------------------------------------------------------------
@@ -3298,17 +3458,17 @@
             
             // enter calibration mode
             calBtnState = 3;
-            plungerReader.calMode(true);
+            plungerReader.setCalMode(true);
             calBtnTimer.reset();
             break;
             
         case 3:
-            // 3 = pixel dump
+            // 3 = plunger sensor status report
             //     data[2] = flag bits
             //     data[3] = visualization mode
-            reportPix = true;
-            reportPixFlags = data[2];
-            reportPixVisMode = data[3];
+            reportPlungerStat = true;
+            reportStatFlags = data[2];
+            reportStatVisMode = data[3];
             
             // show purple until we finish sending the report
             diagLED(1, 0, 1);
@@ -3320,7 +3480,7 @@
             js.reportConfig(
                 numOutputs, 
                 cfg.psUnitNo - 1,   // report 0-15 range for unit number (we store 1-16 internally)
-                cfg.plunger.cal.zero, cfg.plunger.cal.max,
+                cfg.plunger.cal.zero, cfg.plunger.cal.max, cfg.plunger.cal.tRelease,
                 nvm.valid());
             break;
             
@@ -3351,6 +3511,24 @@
             //     data[2] = 1 to engage, 0 to disengage
             setNightMode(data[2]);
             break;
+            
+        case 9:
+            // 9 = Config variable query.
+            //     data[2] = config var ID
+            //     data[3] = array index (for array vars: button assignments, output ports)
+            {
+                // set up the reply buffer with the variable ID data
+                uint8_t reply[8];
+                reply[1] = data[2];
+                reply[2] = data[3];
+                
+                // query the value
+                configVarGet(reply);
+                
+                // send the reply
+                js.reportConfigVar(reply + 1);
+            }
+            break;
         }
     }
     else if (data[0] == 66)
@@ -3626,7 +3804,7 @@
                     calBtnTimer.reset();
                     
                     // begin the plunger calibration limits
-                    plungerReader.calMode(true);
+                    plungerReader.setCalMode(true);
                 }
                 break;
                 
@@ -3650,7 +3828,7 @@
             {
                 // exit calibration mode
                 calBtnState = 0;
-                plungerReader.calMode(false);
+                plungerReader.setCalMode(false);
                 
                 // save the updated configuration
                 cfg.plunger.cal.calibrated = 1;
@@ -3777,14 +3955,14 @@
             jsReportTimer.reset();
         }
 
-        // If we're in pixel dump mode, report all pixel exposure values
-        if (reportPix)
+        // If we're in sensor status mode, report all pixel exposure values
+        if (reportPlungerStat)
         {
             // send the report            
-            plungerSensor->sendExposureReport(js, reportPixFlags, reportPixVisMode);
+            plungerSensor->sendStatusReport(js, reportStatFlags, reportStatVisMode);
 
             // we have satisfied this request
-            reportPix = false;
+            reportPlungerStat = false;
         }
         
         // If joystick reports are turned off, send a generic status report
--- a/nullSensor.h	Tue Mar 01 23:21:45 2016 +0000
+++ b/nullSensor.h	Sat Mar 05 00:16:52 2016 +0000
@@ -15,6 +15,7 @@
     
     virtual void init() { }
     virtual bool read(PlungerReading &r) { return false; }
+    virtual uint32_t getAvgScanTime() { return 0; }
 };
 
 #endif /* NULLSENSOR_H */
--- a/plunger.h	Tue Mar 01 23:21:45 2016 +0000
+++ b/plunger.h	Sat Mar 05 00:16:52 2016 +0000
@@ -72,29 +72,69 @@
     // that it wasn't possible to take a valid reading.
     virtual bool read(PlungerReading &r) = 0;
     
-    // Send an exposure report to the host, via the joystick interface.  This
-    // is for image sensors, and can be omitted by other sensor types.  For
-    // image sensors, this takes one exposure and sends all pixels to the host
-    // through special joystick reports.  This is used by tools on the host PC
-    // to let the user view the low-level sensor pixel data, which can be
-    // helpful during installation to adjust the sensor positioning and light
-    // source.
+    // Send a sensor status report to the host, via the joystick interface.
+    // This provides some common information for all sensor types, and also
+    // includes a full image snapshot of the current sensor pixels for
+    // imaging sensor types.
+    //
+    // The default implementation here sends the common information
+    // packet, with the pixel size set to 0.
+    //
+    // 'flags' is a combination of bit flags:
+    //   0x01  -> low-res scan (default is high res scan)
     //
-    // Flag bits:
-    //   0x01  -> low res scan (default is high res scan)
+    // Low-res scan mode means that the sensor should send a scaled-down
+    // image, at a reduced size determined by the sensor subtype.  The
+    // default if this flag isn't set is to send the full image, at the
+    // sensor's native pixel size.  The low-res version is a reduced size
+    // image in the normal sense of scaling down a photo image, keeping the
+    // image intact but at reduced resolution.  Note that low-res mode
+    // doesn't affect the ongoing sensor operation at all.  It only applies
+    // to this single pixel report.  The purpose is simply to reduce the USB 
+    // transmission time for the image, to allow for a faster frame rate for 
+    // displaying the sensor image in real time on the PC.  For a high-res
+    // sensor like the TSL1410R, sending the full pixel array by USB takes 
+    // so long that the frame rate is way below regular video rates.
     //
-    // Visualization modes:
-    //   0  -> raw pixels
-    //   1  -> processed pixels (noise reduction, etc)
-    //   2  -> exaggerated contrast mode
-    //   3  -> edge visualization
-    //
-    // If processed mode is selected, the sensor should apply any pixel
-    // processing it normally does when taking a plunger position reading,
-    // such as exposure correction, noise reduction, etc.  In raw mode, we
-    // simply send the pixels as read from the sensor.  Both modes are useful
-    // in setting up the physical sensor.
-    virtual void sendExposureReport(class USBJoystick &js, uint8_t flags, uint8_t visMode) { }
+    // 'visMode' is the visualization mode.  This is currently unused.  (In
+    // a preliminary design, the CCD sensor was going to pre-process the pixels
+    // through some filters, such as noise reduction and contrast enhnacement,
+    // before detecting the edge.  'visMode' was meant to select how much of
+    // this processing to apply to the pixels transmitted to the host, to allow
+    // the user to see each stage of the processing from raw sensor pixels to
+    // fully processed pixels.  But the filtering process proved to be too slow, 
+    // so in the end we removed it and now just do the edge detection directly
+    // on the raw pixels.  This makes the visualization mode unnecessary.
+    // However, we're keeping the parameter in case it becomes useful in the
+    // future.  Note that this could be used for special displays that don't 
+    // reflect actual pre-processing that would be done in normal edge
+    // detection, but instead visualize the internals of the algorithm, as
+    // a debugging or optimization tool.)
+    virtual void sendStatusReport(class USBJoystick &js, uint8_t flags, uint8_t visMode)
+    {
+        // read the current position
+        int pos = 0xFFFF;
+        PlungerReading r;
+        if (read(r))
+        {
+            // success - scale it to 0..4095 (the generic scale used
+            // for non-imaging sensors)
+            pos = int(r.pos*4095L / 65535L);
+        }
+        
+        // Send the common status information, indicating 0 pixels, standard
+        // sensor orientation, and zero processing time.  Non-imaging sensors 
+        // usually don't have any way to detect the orientation, so they have 
+        // to rely on being installed in a pre-determined direction.  Non-
+        // imaging sensors usually have negligible analysis time (the only
+        // "analysis" is usually nothing more than a multiply to rescale an 
+        // ADC sample), so there's no point in collecting actual timing data; 
+        // just report zero.
+        js.sendPlungerStatus(0, pos, 1, getAvgScanTime(), 0);
+    }
+    
+    // Get the average sensor scan time in microseconds
+    virtual uint32_t getAvgScanTime() = 0;
         
 protected:
 };
--- a/potSensor.h	Tue Mar 01 23:21:45 2016 +0000
+++ b/potSensor.h	Sat Mar 05 00:16:52 2016 +0000
@@ -55,17 +55,25 @@
             + uint32_t(pot.read_u16())
             ) / 5U);
             
-        // Get the ending time of the sample, and figure the indicated
+        // Get the elapsed time of the sample, and figure the indicated
         // sample time as the midpoint between the start and end times.
         // (Note that the timer might overflow the uint32_t between t0 
         // and now, in which case it will appear that now < t0.  The
         // calculation will always work out right anyway, because it's 
         // effectively performed mod 2^32-1.)
-        r.t = t0 + (timer.read_us() - t0)/2;        
+        uint32_t dt = timer.read_us() - t0;
+        r.t = t0 + dt/2;
+        
+        // add the current sample to our timing statistics
+        totScanTime += dt;
+        nScans += 1;
             
         // success
         return true;
     }
+    
+    // figure the average scan time in microseconds
+    virtual uint32_t getAvgScanTime() { return uint32_t(totScanTime/nScans); }
         
 private:
     // analog input for the pot wiper
@@ -73,4 +81,8 @@
     
     // timer for input timestamps
     Timer timer;
+    
+    // total sensor scan time in microseconds, and number of scans completed
+    long long totScanTime;
+    int nScans;
 };