Directly jump to the content and enable reader mode
Article image

have read aloud


Reading time: about 12 min Print version

CIMDIT part 3, the hardware abstraction layer

Part 1 discusses the generic idea, part 2 the electronics. Please make sure to read them to make the most out of this article.

I built the electronics from part 2 and besides one tiny mistake (already fixed in the plans in the git repository) all components work as expected. This was a surprise for me as well.

This article describes the hardware abstraction layer or HAL. This is a piece of software that abstracts the physical nitty gritty things and offers an easy and simple access to the states of the buttons, rotary encoders and the analog values.

Only relevant parts are shown here, please refer to the git repo for the full source.

The Interface

/// constructor
CimditHAL();

/// initialize
void begin();

/// \brief read current state from hardware
/// \param interrupt true if called from an interrupt
void readFromHardware(bool interrupt);

/// \brief has a rotary input changed
uint8_t rotaryChanged();

/// \brief has an analog value changed
uint16_t analogChanged();

/// \brief has a button changed
uint64_t buttonsChanged();

/// \brief get single rotary value
/// \param num number of encoder
/// \retval delta value for the given encoder
int8_t getEncoder(uint8_t num);

/// \brief get analog value
/// \param num number of analog values
/// \retval analog values
uint16_t getAnalog(uint8_t num) const;

/// \brief get state of a single button
/// \param num button number
/// \retval buttons status
bool getButton(uint8_t num) const;

/// global millis value
static uint32_t m_millis;

These are the exported functions from the layer. Basically the usual suspects, a constructor and the begin function.
The readFromHardware is the main acquisition function and it needs to be called as often as possible. It reads the values from the external hardware and stores the information in internal data structures. Access to these is done with the following functions.
First you got the XXXChanged functions that set bits if one of the values has changed. For example, if the first analog value has changed the bit 0 in analogChanged is set (Value = 1), if the second button has been changed (pressed or unpressed) buttonsChanged return 2 (2nd bit set).
After each call to the functions, the internal bits are reset.

If some value has been changed, you need to fetch the actual value, these are the getXXX functions. For the buttons or analog values this is obvious, the encoder returns the number of clicks since the last fetch, clockwise rotation increased the number, counter clockwise decreased the number.

Reading analog values

Let's start with reading the analog values, this is the easiest one.

if (m_millis > m_nextAnalogRead) {  // only scan analog every ANALOG_INTERVAL ms
    m_nextAnalogRead = m_millis + ANALOG_INTERVAL;
    // save old state
    int16_t lastAnalogValue = m_currentAnalogValues[m_analogNum];
    // scan analog value
    m_currentAnalogValues[m_analogNum] = analogRead(ANALOG_IN);
    // set changed bits
    if (lastAnalogValue != m_currentAnalogValues[m_analogNum]) {
      m_analogChanged |= 1 << m_analogNum;
    }
     // set mux value for next round, wrap to 0 at 16
    m_analogNum = (m_analogNum + 1) & 15;
    // set output
    digitalWrite(ANALOG_MUX3, m_analogNum & 8);
    digitalWrite(ANALOG_MUX2, m_analogNum & 4);
    digitalWrite(ANALOG_MUX1, m_analogNum & 2);
    digitalWrite(ANALOG_MUX0, m_analogNum & 1);
  }

The readFromHardware functions stores a global time in m_millis. Calling the millis() function disables interrupts, so it is a good practice to have one global time variable and only call millis() from one central position.
Since we scan the analog values slower than technically possible (who needs several thousand of updates per second), we store the next time to read in m_nextAnalogRead and set the time when we read. This will be repeated for other types as well.
We save the old value and read the new value in and set the internal changed bits if needed. Since we use a multiplexer to map 16 analog values to one physical pin on the micro controller, we need to tell the muxing chip to switch to next input. It takes a moment for the chip to switch, so we do this at the end and give the chip time until we read again in a few milliseconds. Currently this is set to 12ms. The update rate for the analog inputs results in 12ms x 16 values = 192ms or roughly 5 updates/s. Should be fast enough.

Reading rotary inputs

while (interrupt || digitalRead(ROT_INT)) {
  rotChanged = true;
  interrupt = false;
  uint16_t rot = m_rotEncoders.readGPIOAB();
  for (uint8_t i = 0; i < 8; ++i) {
     m_rotChanged |= m_rotaryEncoders[i].update((rot & 1) != 0, (rot & 2) != 0) << i;
   rot >>= 2;
  }
}
// quick return if rotary encoder changed
if (rotChanged) return;

Our rotary encoder IO-chip mcp23017 has an interrupt pin that it can set if one of the inputs changes. We use this to trigger an interrupt on the arduino and set the interrupt parameter when calling the function. The while loop checks the flag and does not read the input, this saves a few clock cycles on the first run. Encoders change fast when turning, so we need to be quick here. When reading the data with readGPIOAB() the internal change pin is reset, so our input pin as well. We use the returned values to optionally advance our encoder counters.
The line looks scary, but is actually not complicated, just optimized. On each iteration we look at the 2 lowest bits of the rot value. In the first iteration those are inputs 1 and 2 of the mcp23017 IC. with (rot & 1) != 0 we check if the bit is set and pass that to the update functions. The same for pin 2. The function return true if the input was changed (a rotation happened) or false if not. To set the right bit we need to shift this 1 or 0 to the right position and add the bit to the existing change flags. shifting left and right is the key here. In the first iteration when i = 0, left shifting with 0 does nothing. on second round i = 1 we set the second bit a.s.o.
As the final step in the loop we modify our result value by right shifting, so that next round we still have to check for the lowest 2 bits. But this time this will be the next input.
If the rotary input has been changed, we bail out of the function to save some cpu cycles. If we saw one change, there is a high change that another will happen very quickly. One click in the encoder results in 4 changes in very short time.

Reading key matrix

The key matrix is similar to the analog value function:

// scan key matrix
  if (m_millis > m_nextKeyScan) {  // only scan analog every KEYSCAN_INTERVAL ms
    m_nextKeyScan = m_millis + KEYSCAN_INTERVAL;
    // save old state
    uint8_t lastButtons = m_currentButtons[m_keyRow];
   // read column bits
    m_currentButtons[m_keyRow] = m_keyMatrix.readGPIO(1);
    // set changed bits
    m_buttonsChanged[m_keyRow] |= lastButtons ^ m_currentButtons[m_keyRow];
    // set row bit wrap 8 to 0
    m_keyRow = (m_keyRow + 1) & 7;
    m_keyMatrix.writeGPIO(0xff ^ (1 << m_keyRow), 0);
  }

Here we do the check for the interval, save the button values. In this case, we save a full row (8 inputs). After this, we read the button state in.
The buttons changed is a bit harder to read since it works on the full 8 bit at the same time. The magic here is the xor function ^. If both bits of the compared bits are the same, the result of the xor operation is 0, so no change.
Similar to the analog multiplexer, we finally select the next row. Since the rows are normally high and only low for the current active row, we quickly create this by xor again. first we set the bit we want to set 0 by shifting 1 over how many times we need. Xor with all bits set 1 (0xff) results in all bits high except our one. Again, its takes a little bit of time, so we switch the row at the end so that the next time we can read directly.
Reading one row at time every 24ms x 8 rows = 192ms, the same 5 updates per second rate.

The rotary encoder logic

bool CimditRotary::update(bool a, bool b) {
if (a == m_a) return false;  // no change
  if (a && !m_a) {  // rising bit a
    if (b) {
      ++m_delta;
    } else {
      --m_delta;
    }
  }
  m_a = a;
return a

The functionality here is simple, if a did not change, return false. We only do work if a goes from false to true (a rising edge).
if such a rising edge is detected, we look at the status of b. if it is positive, increase the counter, else decrease it. There are many explanations about how and why this works, no need to do this here again. Important is just the fact that we return true if we changed the counters, since the first if catches the a unchanged case, we can just return a. If it went from true to false, no change could happen, if it changed from false to true, we always modify the counter.

Glue and testing code

To do a quick testing, we just hard coded the first 4 analog values to the first 4 joystick axis and map the first 32 buttons from the key matrix to the joystick as well. That way we can quickly validate if the inputs seem reasonable. The rotary counter are just printed to serial for now.

/// main loop
void loop() {
  hal.readFromHardware(outstandingRotInterrupt);
  if (outstandingRotInterrupt) {
    outstandingRotInterrupt = false;
    return;
  }
  if (hal.m_millis < nextUpdate && hal.m_millis > ROLLOVER_INTERVAL) return;
  nextUpdate = hal.m_millis + UPDATE_INTERVAL;
  uint8_t rotaryChanged = hal.rotaryChanged();
  if (rotaryChanged) {
    for (uint8_t i = 0; i < 7; ++i) {
      if (rotaryChanged & 1) {
        // temporary debug output
        Serial.print(F("rot "));
        Serial.print(i);
        Serial.print(F(" => "));
        Serial.println(hal.getEncoder(i));
        // end of temporary debug output
      }
      rotaryChanged >>= 1;
    }
  }
  uint16_t analogChanged = hal.analogChanged();
  if (analogChanged) {
    for (uint8_t i = 0; i < 4; ++i) {
      if (analogChanged & 1) {
        uint16_t analog = hal.getAnalog(i);
        // temporary debug output
        int16_t analogMapped16 = map(analog, 0, 1024, -32768, 32767);
        switch(i) {
          case 0:
          Gamepad.xAxis(analogMapped16);
          break;
          case 1:
          Gamepad.yAxis(analogMapped16);
          break;
          case 2:
         Gamepad.zAxis(analogMapped16>>8);
          break;
          case 3:
          Gamepad.rxAxis(analogMapped16);
          break;
          default:
          break;
        }
        Serial.print(F("analog "));
        Serial.print(i);
        Serial.print(F(": "));
        Serial.println(analogMapped16);
        // end of temporary debug output
      }
      analogChanged >>= 1;
    }
    Gamepad.write();
  }

  uint64_t buttonsChanged = hal.buttonsChanged();
  if (buttonsChanged) {
    for (uint8_t i = 0; i < 64; ++i) {
      if (buttonsChanged & 0xFF) { // quickcheck for 8 bits at a time
        if (buttonsChanged & 1) {
          // temporary debug output
          Serial.print(F("button "));
          Serial.print(i);
          Serial.print(F(" "));
          bool pressed = hal.getButton(i);
          Serial.println(pressed);
          if (i<32) {
            if (pressed)
              Gamepad.press(i+1);
            else
              Gamepad.release(i+1);
          }
          // end of temporary debug output
        }
        buttonsChanged >>= 1;
      } else {
        buttonsChanged >>= 8;
        i += 8;
      }
    }
  }
}

Again, these are just parts of the code, see the repo full the full source.

This concludes the hardware abstraction layer, next article will create the profiles and custom mappings to make it extra crazy.

0 comments

Our algorithm thinks, these articles are relevant: