work in progress
Dependencies: FastAnalogIn FastIO USBDevice mbed FastPWM SimpleDMA
Fork of Pinscape_Controller by
main.cpp
- Committer:
- mjr
- Date:
- 2014-07-27
- Revision:
- 5:a70c0bce770d
- Parent:
- 4:02c7cd7b2183
- Child:
- 6:cc35eb643e8f
File content as of revision 5:a70c0bce770d:
/* Copyright 2014 M J Roberts, MIT License * * Permission is hereby granted, free of charge, to any person obtaining a copy of this software * and associated documentation files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, copy, modify, merge, publish, * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all copies or * substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ // // Pinscape Controller // // "Pinscape" is the name of my custom-built virtual pinball cabinet. I wrote this // software to perform a number of tasks that I needed for my cabinet. It runs on a // Freescale KL25Z microcontroller, which is a small and inexpensive device that // attaches to the host PC via USB and can interface with numerous types of external // hardware. // // I designed the software and hardware in this project especially for Pinscape, but // it uses standard interfaces in Windows and Visual Pinball, so it should be // readily usable in anyone else's VP-based cabinet. I've tried to document the // hardware in enough detail for anyone else to duplicate the entire project, and // the full software is open source. // // The controller provides the following functions. It should be possible to use // any subet of the features without using all of them. External hardware for any // particular function can simply be omitted if that feature isn't needed. // // - Nudge sensing via the KL25Z's on-board accelerometer. Nudge accelerations are // processed into a physics model of a rolling ball, and changes to the ball's // motion are sent to the host computer via the joystick interface. This is designed // especially to work with Visuall Pinball's nudge handling to produce realistic // on-screen results in VP. By doing some physics modeling right on the device, // rather than sending raw accelerometer data to VP, we can produce better results // using our awareness of the real physical parameters of a pinball cabinet. // VP's nudge handling has to be more generic, so it can't make the same sorts // of assumptions that we can about the dynamics of a real cabinet. // // The nudge data reports are compatible with the built-in Windows USB joystick // drivers and with VP's own joystick input scheme, so the nudge sensing is almost // plug-and-play. There are no Windiows drivers to install, and the only VP work // needed is to customize a few global preference settings. // // - Plunger position sensing via an attached TAOS TSL 1410R CCD linear array sensor. // The sensor must be wired to a particular set of I/O ports on the KL25Z, and must // be positioned adjacent to the plunger with proper lighting. The physical and // electronic installation details are desribed in the project documentation. We read // the CCD to determine how far back the plunger is pulled, and report this to Visual // Pinball via the joystick interface. As with the nudge data, this is all nearly // plug-and-play, in that it works with the default Windows USB drivers and works // with the existing VP handling for analog plunger input. A few VP settings are // needed to tell VP to allow the plunger. // // Unfortunately, analog plungers are not well supported by individual tables, // so some work is required for each table to give it proper support. I've tried // to reduce this to a recipe and document it in the project documentation. // // - In addition to the CCD sensor, a button should be attached (also described in // the project documentation) to activate calibration mode for the plunger. When // calibration mode is activated, the software reads the plunger position for about // 10 seconds when to note the limits of travel, and uses these limits to ensure // accurate reports to VP that properly report the actual position of the physical // plunger. The calibration is stored in non-volatile memory on the KL25Z, so it's // only necessary to calibrate once - the calibration will survive power cycling // and reboots of the PC. It's only necessary to recalibrate if the CCD sensor or // the plunger are removed and reinstalled, since the relative alignment of the // parts could cahnge slightly when reinstalling. // // - LedWiz emulation. The KL25Z can appear to the PC as an LedWiz device, and will // accept and process LedWiz commands from the host. The software can turn digital // output ports on and off, and can set varying PWM intensitiy levels on a subset // of ports. (The KL25Z can only provide 6 PWM ports. Intensity level settings on // other ports is ignored, so non-PWM ports can only be used for simple on/off // devices such as contactors and solenoids.) The KL25Z can only supply 4mA on its // output ports, so external hardware is required to take advantage of the LedWiz // emulation. Many different hardware designs are possible, but there's a simple // reference design in the documentation that uses a Darlington array IC to // increase the output from each port to 500mA (the same level as the LedWiz), // plus an extended design that adds an optocoupler and MOSFET to provide very // high power handling, up to about 45A or 150W, with voltages up to 100V. // That will handle just about any DC device directly (wtihout relays or other // amplifiers), and switches fast enough to support PWM devices. // // The device can report any desired LedWiz unit number to the host, which makes // it possible to use the LedWiz emulation on a machine that also has one or more // actual LedWiz devices intalled. The LedWiz design allows for up to 16 units // to be installed in one machine - each one is invidually addressable by its // distinct unit number. // // The LedWiz emulation features are of course optional. There's no need to // build any of the external port hardware (or attach anything to the output // ports at all) if the LedWiz features aren't needed. Most people won't have // any use for the LedWiz features. I built them mostly as a learning exercise, // but with a slight practical need for a handful of extra ports (I'm using the // cutting-edge 10-contactor setup, so my real LedWiz is full!). #include "mbed.h" #include "USBJoystick.h" #include "MMA8451Q.h" #include "tsl1410r.h" #include "FreescaleIAP.h" #include "crc32.h" // --------------------------------------------------------------------------- // // Configuration details // // Our USB device vendor ID, product ID, and version. // We use the vendor ID for the LedWiz, so that the PC-side software can // identify us as capable of performing LedWiz commands. The LedWiz uses // a product ID value from 0xF0 to 0xFF; the last four bits identify the // unit number (e.g., product ID 0xF7 means unit #7). This allows multiple // LedWiz units to be installed in a single PC; the software on the PC side // uses the unit number to route commands to the devices attached to each // unit. On the real LedWiz, the unit number must be set in the firmware // at the factory; it's not configurable by the end user. Most LedWiz's // ship with the unit number set to 0, but the vendor will set different // unit numbers if requested at the time of purchase. So if you have a // single LedWiz already installed in your cabinet, and you didn't ask for // a non-default unit number, your existing LedWiz will be unit 0. // // We use unit #7 by default. There doesn't seem to be a requirement that // unit numbers be contiguous (DirectOutput Framework and other software // seem happy to have units 0 and 7 installed, without 1-6 existing). // Marking this unit as #7 should work for almost everybody out of the box; // the most common case seems to be to have a single LedWiz installed, and // it's probably extremely rare to more than two. const uint16_t USB_VENDOR_ID = 0xFAFA; const uint16_t USB_PRODUCT_ID = 0x00F7; const uint16_t USB_VERSION_NO = 0x0004; // On-board RGB LED elements - we use these for diagnostic displays. DigitalOut ledR(LED1), ledG(LED2), ledB(LED3); // calibration button - switch input and LED output DigitalIn calBtn(PTE29); DigitalOut calBtnLed(PTE23); // I2C address of the accelerometer (this is a constant of the KL25Z) const int MMA8451_I2C_ADDRESS = (0x1d<<1); // SCL and SDA pins for the accelerometer (constant for the KL25Z) #define MMA8451_SCL_PIN PTE25 #define MMA8451_SDA_PIN PTE24 // Digital in pin to use for the accelerometer interrupt. For the KL25Z, // this can be either PTA14 or PTA15, since those are the pins physically // wired on this board to the MMA8451 interrupt controller. #define MMA8451_INT_PIN PTA15 // --------------------------------------------------------------------------- // // LedWiz emulation // static int pbaIdx = 0; // on/off state for each LedWiz output static uint8_t wizOn[32]; // profile (brightness/blink) state for each LedWiz output static uint8_t wizVal[32] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; static float wizState(int idx) { if (wizOn[idx]) { // on - map profile brightness state to PWM level uint8_t val = wizVal[idx]; if (val >= 1 && val <= 48) return 1.0 - val/48.0; else if (val >= 129 && val <= 132) return 0.0; else return 1.0; } else { // off return 1.0; } } static void updateWizOuts() { ledR = wizState(0); ledG = wizState(1); ledB = wizState(2); } // --------------------------------------------------------------------------- // // Non-volatile memory (NVM) // // Structure defining our NVM storage layout. We store a small // amount of persistent data in flash memory to retain calibration // data when powered off. struct NVM { // checksum - we use this to determine if the flash record // has been initialized uint32_t checksum; // signature value static const uint32_t SIGNATURE = 0x4D4A522A; static const uint16_t VERSION = 0x0002; // stored data (excluding the checksum) struct { // signature and version - further verification that we have valid // initialized data uint32_t sig; uint16_t vsn; // direction - 0 means unknown, 1 means bright end is pixel 0, 2 means reversed uint8_t dir; // plunger calibration min and max int plungerMin; int plungerMax; } d; }; // --------------------------------------------------------------------------- // // Customization joystick subbclass // class MyUSBJoystick: public USBJoystick { public: MyUSBJoystick(uint16_t vendor_id, uint16_t product_id, uint16_t product_release) : USBJoystick(vendor_id, product_id, product_release, true) { suspended_ = false; } // are we connected? int isConnected() { return configured(); } // Are we in suspend mode? int isSuspended() const { return suspended_; } protected: virtual void suspendStateChanged(unsigned int suspended) { suspended_ = suspended; } // are we suspended? int suspended_; }; // --------------------------------------------------------------------------- // // Accelerometer (MMA8451Q) // // The MMA8451Q is the KL25Z's on-board 3-axis accelerometer. // // This is a custom wrapper for the library code to interface to the // MMA8451Q. This class encapsulates an interrupt handler and some // special data processing to produce more realistic results in // Visual Pinball. // // We install an interrupt handler on the accelerometer "data ready" // interrupt in order to ensure that we fetch each sample immediately // when it becomes available. Since our main program loop is busy // reading the CCD virtually all of the time, it wouldn't be practical // to keep up with the accelerometer data stream by polling. // // Visual Pinball is nominally designed to accept raw accelerometer // data as nudge input, but in practice, this doesn't produce // very realistic results. VP simply applies accelerations from a // physical accelerometer directly to its modeled ball(s), but the // data stream coming from a real accelerometer isn't as clean as // an idealized physics simulation. The problem seems to be that the // accelerometer samples capture instantaneous accelerations, not // integrated acceleration over time. In other words, adding samples // over time doesn't accurately reflect the actual net acceleration // experienced. The longer the sampling period, the greater the // divergence between the sum of a series of samples and the actual // net acceleration. The effect in VP is to leave the ball with // an unrealistically high residual velocity over the course of a // nudge event. // // This is where our custom data processing comes into play. Rather // than sending raw accelerometer samples, we apply the samples to // our own virtual model ball. What we send VP is the accelerations // experienced by the ball in our model, not the actual accelerations // we read from the MMA8451Q. Now, that might seem like an unnecessary // middleman, because VP is just going to apply the accelerations to // its own model ball. But it's a useful middleman: what we can do // in our model that VP can't do in its model is take into account // our special knowledge of the physical cabinet configuration. VP // has to work generically with any sort of nudge input device, but // we can make assumptions about what kind of physical environment // we're operating in. // // The key assumption we make about our physical environment is that // accelerations from nudges should net out to zero over intervals on // the order of a couple of seconds. Nudging a pinball cabinet makes // the cabinet accelerate briefly in the nudge direction, then rebound, // then re-rebound, and so on until the swaying motion damps out and // the table returns roughly to rest. The table doesn't actually go // anywhere in these transactions, so the net acceleration experienced // is zero by the time the motion has damped out. The damping time // depends on the degree of force of the nudge, but is a second or // two in most cases. // // We can't just assume that all motion and/or acceleration must stop // in a second or two, though. For one thing, the player can nudge // the table repeatedly for long periods. (Doing this too aggressivly // will trigger a tilt, so there are limits, but a skillful player // can keep nudging a table almost continuously without tilting it.) // For another, a player could actually pick up one end of the table // for an extended period, applying a continuous acceleration the // whole time. // // The strategy we use to cope with these possibilities is to model a // ball, rather like VP does, but with damping that scales with the // current speed. We'll choose a damping function that will bring // the ball to rest from any reasonable speed within a second or two // if there are no ongoing accelerations. The damping function must // also be weak enough that new accelerations dominate - that is, // the damping function must not be so strong that it cancels out // ongoing physical acceleration input, such as when the player // lifts one end of the table and holds it up for a while. // // What we report to VP is the acceleration experienced by our model // ball between samples. Our model ball starts at rest, and our damping // function ensures that when it's in motion, it will return to rest in // a short time in the absence of further physical accelerations. The // sum or our reports to VP from a rest state to a subsequent rest state // will thus necessarily equal exactly zero. This will ensure that we // don't leave VP's model ball with any residual velocity after an // isolated nudge. // // We do one more bit of data processing: automatic calibration. When // we observe the accelerometer input staying constant (within a noise // window) for a few seconds continously, we'll assume that the cabinet // is at rest. It's safe to assume that the accelerometer isn't // installed in such a way that it's perfectly level, so at the // cabinet's neutral rest position, we can expect to read non-zero // accelerations on the x and y axes from the component along that // axis of the Earth's gravity. By watching for constant acceleration // values over time, we can infer the reseting position of the device // and take that as our zero point. By doing this continuously, we // don't have to assume that the machine is perfectly motionless when // initially powered on - we'll organically find the zero point as soon // as the machine is undisturbed for a few moments. We'll also deal // gracefully with situations where the machine is jolted so much in // the course of play that its position is changed slightly. The result // should be to make the zeroing process reliable and completely // transparent to the user. // // point structure struct FPoint { float x, y; FPoint() { } FPoint(float x, float y) { this->x = x; this->y = y; } void set(float x, float y) { this->x = x; this->y = y; } void zero() { this->x = this->y = 0; } FPoint &operator=(FPoint &pt) { this->x = pt.x; this->y = pt.y; return *this; } FPoint &operator-=(FPoint &pt) { this->x -= pt.x; this->y -= pt.y; return *this; } FPoint &operator+=(FPoint &pt) { this->x += pt.x; this->y += pt.y; return *this; } FPoint &operator*=(float f) { this->x *= f; this->y *= f; return *this; } FPoint &operator/=(float f) { this->x /= f; this->y /= f; return *this; } float magnitude() const { return sqrt(x*x + y*y); } float distance(FPoint &b) { float dx = x - b.x; float dy = y - b.y; return sqrt(dx*dx + dy*dy); } }; // accelerometer wrapper class class Accel { public: Accel(PinName sda, PinName scl, int i2cAddr, PinName irqPin) : mma_(sda, scl, i2cAddr), intIn_(irqPin) { // remember the interrupt pin assignment irqPin_ = irqPin; // reset and initialize reset(); } void reset() { // assume initially that the device is perfectly level center_.zero(); tCenter_.start(); iAccPrv_ = nAccPrv_ = 0; // reset and initialize the MMA8451Q mma_.init(); // set the initial ball velocity to zero v_.zero(); // set the initial raw acceleration reading to zero araw_.zero(); vsum_.zero(); // enable the interrupt mma_.setInterruptMode(irqPin_ == PTA14 ? 1 : 2); // set up the interrupt handler intIn_.rise(this, &Accel::isr); // read the current registers to clear the data ready flag float z; mma_.getAccXYZ(araw_.x, araw_.y, z); // start our timers tGet_.start(); tInt_.start(); tRest_.start(); } void get(float &x, float &y, float &rx, float &ry) { // disable interrupts while manipulating the shared data __disable_irq(); // read the shared data and store locally for calculations FPoint vsum = vsum_, araw = araw_; // reset the velocity sum vsum_.zero(); // get the time since the last get() sample float dt = tGet_.read_us()/1.0e6; tGet_.reset(); // done manipulating the shared data __enable_irq(); // check for auto-centering every so often if (tCenter_.read_ms() > 1000) { // add the latest raw sample to the history list accPrv_[iAccPrv_] = araw_; // commit the history entry iAccPrv_ = (iAccPrv_ + 1) % maxAccPrv; // 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 = .005; if (accPrv_[0].distance(accPrv_[1]) < accTol && accPrv_[0].distance(accPrv_[2]) < accTol && accPrv_[0].distance(accPrv_[3]) < accTol && accPrv_[0].distance(accPrv_[4]) < accTol) { // figure the new center as the average of these samples center_.set( (accPrv_[0].x + accPrv_[1].x + accPrv_[2].x + accPrv_[3].x + accPrv_[4].x)/5.0, (accPrv_[0].y + accPrv_[1].y + accPrv_[2].y + accPrv_[3].y + accPrv_[4].y)/5.0); } } else { // not enough samples yet; just up the count ++nAccPrv_; } // reset the timer tCenter_.reset(); } // Calculate the velocity vector for the model ball. Start // with the accumulated velocity from the accelerations since // the last reading. FPoint dv = vsum; // remember the previous velocity of the model ball FPoint vprv = v_; // If we have residual motion, check for damping. // // The dmaping we model here isn't friction - we leave that sort of // detail to the pinball simulator on the PC. Instead, our form of // damping is just an attempt to compensate for measurement errors // from the accelerometer. During a nudge event, we should see a // series of accelerations back and forth, as the table sways in // response to the push, rebounds from the sway, rebounds from the // rebound, etc. We know that in reality, the table itself doesn't // actually go anywhere - it just sways, and when the swaying stops, // it ends up where it started. If we use the accelerometer input // to do dead reckoning on the location of the table, we know that // it has to end up where it started. This means that the series of // position changes over the course of the event should cancel out - // the displacements should add up to zero. to model friction and other forces // on the ball. Instead, the damping we apply is to compensate for // measurement errors in the accelerometer. During a nudge event, // a real pinball cabinet typically ends up at the same place it // started - it sways in response to the nudge, but the swaying // quickly damps out and leaves the table unmoved. You don't // typically apply enough force to actually pick up the cabinet // and move it, or slide it across the floor - and doing so would // trigger a tilt, in which case the ball goes out of play and we // don't really have to worry about how realistically it behaves // in response to the acceleration. if (vprv.magnitude() != 0) { // The model ball is moving. If the current motion has been // going on for long enough, apply damping. We wait a short // time before we apply damping to allow small continuous // accelerations (from tiling the table) to get the ball // rolling. if (tRest_.read_ms() > 100) { } } else { // the model ball is at rest; if the instantaneous acceleration // is also near zero, reset the rest timer if (dv.magnitude() < 0.025) tRest_.reset(); } // If the current velocity change is near zero, damp the ball's // velocity. The idea is that the total series of accelerations // from a nudge should net to zero, since a nudge doesn't // actually move the table anywhere. // // Ideally, this wouldn't be necessary, because the raw // accelerometer readings should organically add up to zero over // the course of a nudge. In practice, the accelerometer isn't // perfect; it can only sample so fast, so it can't capture every // instantaneous change; and each reading has some small measurement // error, which becomes significant when many readings are added // together. The damping is an attempt to reconcile the imperfect // measurements with what how expect the real physical system to // behave - we know what the outcome of an event should be, so we // adjust our measurements to get the expected outcome. // // If the ball's velocity is large at this point, assume that this // wasn't a nudge event at all, but a sustained inclination - as // though the player picked up one end of the table and held it // up for a while, to accelerate the ball down the sloped table. // In this case just reset the velocity to zero without doing // any damping, so that we don't pass through any deceleration // to the pinball simulation. In this case we want to leave it // to the pinball simulation to do its own modeling of friction // or bouncing to decelerate the ball. Our correction is only // realistic for brief events that naturally net out to neutral // accelerations. if (dv.magnitude() < .025) { // check the ball's speed if (v_.magnitude() < .25) { // apply the damping FPoint damp(damping(v_.x), damping(v_.y)); dv -= damp; ledB = 0; } else { // the ball is going too fast - simply reset it v_ = dv; vprv = dv; ledB = 1; } } else ledB = 1; // apply the velocity change for this interval v_ += dv; // return the acceleration since the last update (change in velocity // over time) in x,y dv /= dt; x = (v_.x - vprv.x) / dt; y = (v_.y - vprv.y) / dt; // report the calibrated instantaneous acceleration in rx,ry rx = araw.x - center_.x; ry = araw.y - center_.y; } private: // velocity damping function float damping(float v) { // scale to -2048..2048 range, and get the absolute value float a = fabs(v*2048.0); // damp out small velocities immediately if (a < 20) return v; // calculate the cube root of the scaled value float r = exp(log(a)/3.0); // rescale r /= 2048.0; // apply the sign and return the result return (v < 0 ? -r : r); } // 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); // store the raw results araw_.set(x, y); zraw_ = z; // calculate the time since the last interrupt float dt = tInt_.read_us()/1.0e6; tInt_.reset(); // Add the velocity to the running total. First, calibrate the // raw acceleration to our centerpoint, then multiply by the time // since the last sample to get the velocity resulting from // applying this acceleration for the sample time. FPoint rdt((x - center_.x)*dt, (y - center_.y)*dt); vsum_ += rdt; } // underlying accelerometer object MMA8451Q mma_; // last raw acceleration readings FPoint araw_; float zraw_; // total velocity change since the last get() sample FPoint vsum_; // current modeled ball velocity FPoint v_; // timer for measuring time between get() samples Timer tGet_; // timer for measuring time between interrupts Timer tInt_; // time since last rest Timer tRest_; // calibrated center point - this is the position where we observe // constant input for a few seconds, telling us the orientation of // the accelerometer device when at rest FPoint center_; // timer for atuo-centering Timer tCenter_; // recent accelerometer readings, for auto centering int iAccPrv_, nAccPrv_; static const int maxAccPrv = 5; FPoint accPrv_[maxAccPrv]; // interurupt pin name PinName irqPin_; // interrupt router InterruptIn intIn_; }; // --------------------------------------------------------------------------- // // Clear the I2C bus for the MMA8451!. This seems necessary some of the time // for reasons that aren't clear to me. Doing a hard power cycle has the same // effect, but when we do a soft reset, the hardware sometimes seems to leave // the MMA's SDA line stuck low. Forcing a series of 9 clock pulses through // the SCL line is supposed to clear this conidtion. // void clear_i2c() { // assume a general-purpose output pin to the I2C clock DigitalOut scl(MMA8451_SCL_PIN); DigitalIn sda(MMA8451_SDA_PIN); // clock the SCL 9 times for (int i = 0 ; i < 9 ; ++i) { scl = 1; wait_us(20); scl = 0; wait_us(20); } } // --------------------------------------------------------------------------- // // Main program loop. This is invoked on startup and runs forever. Our // main work is to read our devices (the accelerometer and the CCD), process // the readings into nudge and plunger position data, and send the results // to the host computer via the USB joystick interface. We also monitor // the USB connection for incoming LedWiz commands and process those into // port outputs. // int main(void) { // turn off our on-board indicator LED ledR = 1; ledG = 1; ledB = 1; // clear the I2C bus for the accelerometer clear_i2c(); // Create the joystick USB client MyUSBJoystick js(USB_VENDOR_ID, USB_PRODUCT_ID, USB_VERSION_NO); // set up a flash memory controller FreescaleIAP iap; // use the last sector of flash for our non-volatile memory structure int flash_addr = (iap.flash_size() - SECTOR_SIZE); NVM *flash = (NVM *)flash_addr; NVM cfg; // check for valid flash bool flash_valid = (flash->d.sig == flash->SIGNATURE && flash->d.vsn == flash->VERSION && flash->checksum == CRC32(&flash->d, sizeof(flash->d))); // Number of pixels we read from the sensor on each frame. This can be // less than the physical pixel count if desired; we'll read every nth // piexl if so. E.g., with a 1280-pixel physical sensor, if npix is 320, // we'll read every 4th pixel. It takes time to read each pixel, so the // fewer pixels we read, the higher the refresh rate we can achieve. // It's therefore better not to read more pixels than we have to. // // VP seems to have an internal resolution in the 8-bit range, so there's // no apparent benefit to reading more than 128-256 pixels when using VP. // Empirically, 160 pixels seems about right. The overall travel of a // standard pinball plunger is about 3", so 160 pixels gives us resolution // of about 1/50". This seems to take full advantage of VP's modeling // ability, and is probably also more precise than a human player's // perception of the plunger position. const int npix = 160; // if the flash is valid, load it; otherwise initialize to defaults if (flash_valid) { memcpy(&cfg, flash, sizeof(cfg)); printf("Flash restored: plunger min=%d, max=%d\r\n", cfg.d.plungerMin, cfg.d.plungerMax); } else { printf("Factory reset\r\n"); cfg.d.sig = cfg.SIGNATURE; cfg.d.vsn = cfg.VERSION; cfg.d.plungerMin = 0; cfg.d.plungerMax = npix; } // plunger calibration button debounce timer Timer calBtnTimer; calBtnTimer.start(); int calBtnDownTime = 0; int calBtnLit = false; // Calibration button state: // 0 = not pushed // 1 = pushed, not yet debounced // 2 = pushed, debounced, waiting for hold time // 3 = pushed, hold time completed - in calibration mode int calBtnState = 0; // set up a timer for our heartbeat indicator Timer hbTimer; hbTimer.start(); int hb = 0; uint16_t hbcnt = 0; // set a timer for accelerometer auto-centering Timer acTimer; acTimer.start(); // create the accelerometer object Accel accel(MMA8451_SCL_PIN, MMA8451_SDA_PIN, MMA8451_I2C_ADDRESS, MMA8451_INT_PIN); // create the CCD array object TSL1410R ccd(PTE20, PTE21, PTB0); // last accelerometer report, in mouse coordinates int x = 127, y = 127, z = 0; // start the first CCD integration cycle ccd.clear(); // we're all set up - now just loop, processing sensor reports and // host requests for (;;) { // Look for an incoming report. Continue processing input as // long as there's anything pending - this ensures that we // handle input in as timely a fashion as possible by deferring // output tasks as long as there's input to process. HID_REPORT report; while (js.readNB(&report) && report.length == 8) { uint8_t *data = report.data; if (data[0] == 64) { // LWZ-SBA - first four bytes are bit-packed on/off flags // for the outputs; 5th byte is the pulse speed (0-7) //printf("LWZ-SBA %02x %02x %02x %02x ; %02x\r\n", // data[1], data[2], data[3], data[4], data[5]); // update all on/off states for (int i = 0, bit = 1, ri = 1 ; i < 32 ; ++i, bit <<= 1) { if (bit == 0x100) { bit = 1; ++ri; } wizOn[i] = ((data[ri] & bit) != 0); } // update the physical outputs updateWizOuts(); // reset the PBA counter pbaIdx = 0; } else { // LWZ-PBA - full state dump; each byte is one output // in the current bank. pbaIdx keeps track of the bank; // this is incremented implicitly by each PBA message. //printf("LWZ-PBA[%d] %02x %02x %02x %02x %02x %02x %02x %02x\r\n", // pbaIdx, data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7]); // update all output profile settings for (int i = 0 ; i < 8 ; ++i) wizVal[pbaIdx + i] = data[i]; // update the physical LED state if this is the last bank if (pbaIdx == 24) updateWizOuts(); // advance to the next bank pbaIdx = (pbaIdx + 8) & 31; } } // check for plunger calibration if (!calBtn) { // check the state switch (calBtnState) { case 0: // button not yet pushed - start debouncing calBtnTimer.reset(); calBtnDownTime = calBtnTimer.read_ms(); calBtnState = 1; break; case 1: // pushed, not yet debounced - if the debounce time has // passed, start the hold period if (calBtnTimer.read_ms() - calBtnDownTime > 50) calBtnState = 2; break; case 2: // in the hold period - if the button has been held down // for the entire hold period, move to calibration mode if (calBtnTimer.read_ms() - calBtnDownTime > 2050) { // enter calibration mode calBtnState = 3; // reset the calibration limits cfg.d.plungerMax = 0; cfg.d.plungerMin = npix; } break; case 3: // Already in calibration mode - pushing the button in this // state doesn't change the current state, but we won't leave // this state as long as it's held down. We can simply do // nothing here. break; } } else { // Button released. If we're in calibration mode, and // the calibration time has elapsed, end the calibration // and save the results to flash. // // 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. if (calBtnState == 3 && calBtnTimer.read_ms() - calBtnDownTime > 17500) { // exit calibration mode calBtnState = 0; // Save the current configuration state to flash, so that it // will be preserved through power off. Update the checksum // first so that we recognize the flash record as valid. cfg.checksum = CRC32(&cfg.d, sizeof(cfg.d)); iap.erase_sector(flash_addr); iap.program_flash(flash_addr, &cfg, sizeof(cfg)); // the flash state is now valid flash_valid = true; } else if (calBtnState != 3) { // didn't make it to calibration mode - cancel the operation calBtnState = 0; } } // light/flash the calibration button light, if applicable int newCalBtnLit = calBtnLit; switch (calBtnState) { case 2: // in the hold period - flash the light newCalBtnLit = (((calBtnTimer.read_ms() - calBtnDownTime)/250) & 1); break; case 3: // calibration mode - show steady on newCalBtnLit = true; break; default: // not calibrating/holding - show steady off newCalBtnLit = false; break; } // light or flash the external calibration button LED, and // do the same with the on-board blue LED if (calBtnLit != newCalBtnLit) { calBtnLit = newCalBtnLit; if (calBtnLit) { calBtnLed = 1; ledR = 1; ledG = 1; ledB = 1; } else { calBtnLed = 0; ledR = 1; ledG = 1; ledB = 0; } } // read the plunger sensor int znew = z; uint16_t pix[npix]; ccd.read(pix, npix); // get the average brightness at each end of the sensor long avg1 = (long(pix[0]) + long(pix[1]) + long(pix[2]) + long(pix[3]) + long(pix[4]))/5; long avg2 = (long(pix[npix-1]) + long(pix[npix-2]) + long(pix[npix-3]) + long(pix[npix-4]) + long(pix[npix-5]))/5; // figure the midpoint in the brightness; multiply by 3 so that we can // compare sums of three pixels at a time to smooth out noise long midpt = (avg1 + avg2)/2 * 3; // Work from the bright end to the dark end. VP interprets the // Z axis value as the amount the plunger is pulled: the minimum // is the rest position, the maximum is fully pulled. So we // essentially want to report how much of the sensor is lit, // since this increases as the plunger is pulled back. int si = 1, di = 1; if (avg1 < avg2) si = npix - 2, di = -1; // scan for the midpoint uint16_t *pixp = pix + si; for (int n = 1 ; n < npix - 1 ; ++n, pixp += di) { // if we've crossed the midpoint, report this position if (long(pixp[-1]) + long(pixp[0]) + long(pixp[1]) < midpt) { // note the new position int pos = n; // if the bright end and dark end don't differ by enough, skip this // reading entirely - we must have an overexposed or underexposed frame if (labs(avg1 - avg2) < 0x3333) break; // Calibrate, or apply calibration, depending on the mode. // In either case, normalize to a 0-127 range. VP appears to // ignore negative Z axis values. if (calBtnState == 3) { // calibrating - note if we're expanding the calibration envelope if (pos < cfg.d.plungerMin) cfg.d.plungerMin = pos; if (pos > cfg.d.plungerMax) cfg.d.plungerMax = pos; // normalize to the full physical range while calibrating znew = int(float(pos)/npix * 127); } else { // running normally - normalize to the calibration range if (pos < cfg.d.plungerMin) pos = cfg.d.plungerMin; if (pos > cfg.d.plungerMax) pos = cfg.d.plungerMax; znew = int(float(pos - cfg.d.plungerMin) / (cfg.d.plungerMax - cfg.d.plungerMin + 1) * 127); } // done break; } } // read the accelerometer float xa, ya, rxa, rya; accel.get(xa, ya, rxa, rya); // confine the accelerometer results to the unit interval if (xa < -1.0) xa = -1.0; if (xa > 1.0) xa = 1.0; if (ya < -1.0) ya = -1.0; if (ya > 1.0) ya = 1.0; // scale to our -127..127 reporting range int xnew = int(127 * xa); int ynew = int(127 * ya); // store the updated joystick coordinates x = xnew; y = ynew; z = znew; // Send the status report. It doesn't really matter what // coordinate system we use, since Visual Pinball has config // options for rotations and axis reversals, but reversing y // at the device level seems to produce the most intuitive // results for the Windows joystick control panel view, which // is an easy way to check that the device is working. // // $$$ button updates are for diagnostics, so we can see that the // device is sending data properly if the accelerometer gets stuck js.update(x, -y, z, int(rxa*127), int(rya*127), hb ? 0x5500 : 0xAA00); // show a heartbeat flash in blue every so often if not in // calibration mode if (calBtnState < 2 && hbTimer.read_ms() > 1000) { if (js.isSuspended() || !js.isConnected()) { // suspended - turn off the LED ledR = 1; ledG = 1; ledB = 1; // show a status flash every so often if (hbcnt % 3 == 0) { // disconnected = red flash; suspended = red-red for (int n = js.isConnected() ? 1 : 2 ; n > 0 ; --n) { ledR = 0; wait(0.05); ledR = 1; wait(0.25); } } } else if (flash_valid) { // connected, NVM valid - flash blue/green hb = !hb; ledR = 1; ledG = (hb ? 0 : 1); ledB = (hb ? 1 : 0); } else { // connected, factory reset - flash yellow/green hb = !hb; //ledR = (hb ? 0 : 1); //ledG = 0; ledB = 1; } // reset the heartbeat timer hbTimer.reset(); ++hbcnt; } } }