Keyboard firmware: trackball and tutorial

This is a reimplementation of the trackball firmware, which hopefully makes the mouse a bit more comfortable to use. It features:

  • A simple interrupt counter coupled with a low-pass filter to smooth out the (x,y) position;
  • An exponential “scaling” function to make slow movements be more precise and keep fast movements of the cursor;
  • Less code: debouncer.ino, glider.ino and ratemeter.ino were removed, since they aren’t used anymore (because I couldn’t figure out how they work);
  • The mouse wheel was reimplemented and decoupled from the cursor.

It took me some time to figure out how to compile it, so I also added a small tutorial on how to build the firmware from source with Arduino CLI. Please check it out and build your own (I’ll provide some binaries after I’m fairly certain I won’t brick anyone’s keyboards).

This version of the firmware is forked from ClockworkPi’s repository, so it doesn’t have the features @yatli added, but it should be straightforward to merge the modifications.

About the trackball and the keyboard

DISCLAIMER: most of the next links are to PDF files.

The trackball is a Panasonic EVQWJN mounted with the 4 suggested AN48841B Hall Sensors and a switch (it may also feature a RGB LED, but does not). This seems to be a high quality trackball, and was even cited as an example in Apple’s Mighty Mouse patent, although it was discontinued by Panasonic in 2015.

The keyboard’s microcontroller is the STM32F103R8 (ARM Cortex M3 32-bit) or one of its variants (please visually check your version). This microcontroller specifically features an application note regarding Hall sensors (see AN4013, page 35).

The ideal implementation

The application note explains how to use a Hall-effect sensor to estimate the position of a rotating motor (which could be adapted to the trackball’s case) and the suggested implementation is via the builtin timers (TIMx, in which x = 1,2,3,4). There’s even a code example on the Arduino core the keyboard uses.

When I first found this, I was going to drop my implementation and start from scratch using the timers, but there’s a problem:

// trackball (in keys_io_map.h)
#define HO1 PC8
#define HO2 PC9
#define HO3 PC10
#define HO4 PC11
#define KEY0 PC12

The pins the trackball is connected to (see CON8B in the keyboard and the trackball schematics) can’t be remapped to the hardware timers. At least not all of them:

  • PC8 (or LEFT_PIN) is TIM1_CH1
  • PC9 (or UP_PIN) is TIM3_CH4
  • PC10 (or RIGHT_PIN) is USART3_TX
  • PC11 (or DOWN_PIN) is USART3_RX (or ADC1_EXTI/ADC2_EXTI)

For an easier visualization, you can use the STM32CubeMX tool to check the pins.

The current implementation

The way the trackball encoder is implemented right now is via the external interrupt (EXTI) feature of the STM32 (since all pins can be used for EXTI):

  • An interrupt is attached to each pin of the trackball (HO1, HO2, HO3 and HO4), which correspond to a specific direction:
// Run interrupt function when corresponding PIN changes its value
attachInterrupt(LEFT_PIN, &interrupt<PIN_LEFT>, ExtIntTriggerMode::CHANGE);
attachInterrupt(RIGHT_PIN, &interrupt<PIN_RIGHT>, ExtIntTriggerMode::CHANGE);
attachInterrupt(UP_PIN, &interrupt<PIN_UP>, ExtIntTriggerMode::CHANGE);
attachInterrupt(DOWN_PIN, &interrupt<PIN_DOWN>, ExtIntTriggerMode::CHANGE);
  • When the ball rolls to a certain direction, it moves a corresponding wheel, which magnetically activates the Hall-effect sensor. This generates an alternating pulse (11 of these per full rotation) which triggers the interrupt function every time the pulse changes its value:
template <TrackballPin PIN>
static void interrupt()
{
    // Count the number of times the trackball rolls towards a certain
    // direction (when the corresponding PIN changes its value).
    // This part of the code should be minimal, so that the next
    // interrupts are not blocked from happening.
    direction_counter[PIN] += 1;
}
  • After some unspecified amount of time, interrupts are disabled temporarily, the movement vector (x,y) is calculated and the counters are reset:
// Stop interrupts from happening. Don't forget to re-enable them!
noInterrupts();

// Calculate x and y positions
float x = direction_counter[PIN_RIGHT] - direction_counter[PIN_LEFT];
float y = direction_counter[PIN_DOWN] - direction_counter[PIN_UP];

// Clear counters
std::fill(std::begin(direction_counter), std::end(direction_counter), 0);

// Re-enable interrupts (Mouse.move needs interrupts)
interrupts();
  • Both positions are scaled and filtered, then the mouse is moved
// Non-linear scaling
// SENSITIVITY * sign(x) * exp(abs(x) / sqrt(SENSITIVITY))
x = position_scale(x);
y = position_scale(y);

// Filter x and y
x = iir_filt(&iir_x, x);
y = iir_filt(&iir_y, y);

// Move mouse
while ((int)x != 0 || (int)y != 0)
{
    // Only 8bit values are allowed,
    // so clamp and execute move() multiple times
    int8_t x_byte = clamp<int8_t>(x);
    int8_t y_byte = clamp<int8_t>(y);

    // Move mouse with values in the range [-128, 127]
    dv->Mouse->move(x_byte, y_byte, 0);

    // Decrement the original value, stop if done
    x -= x_byte;
    y -= y_byte;
}

This implementation is slower than the timer solution (but I can’t be sure how much slower) and causes the measurements of (x,y) to be imprecise, resulting in unusual movements. For example, when trying to scroll diagonally, the mouse will most often move like a staircase. For this reason, a low-pass filter was added as an attempt to smooth out the movement, even though it adds some delay.

Suggestion

For the next iteration of the keyboard’s PCB, please try to change the pins of the trackball so that the timers of the STM32 may be used.

4 Likes

Thank you for the detailed write-up! When I first started to mod the firmware I’ve got a bad keyboard with a missing hall sensor. No LEFT INTERRUPT for me. Clockwork arranged a new keyboard for me, and during the wait time I paid no attention to trackball at all :smiley:

11 pulses per round is pretty low, and the task loop runs fast. This means if we clear the counters each time the movement data is collected and sent, we have a “hungry task loop” which doesn’t have precise speed.

The purpose of RateMeter is exactly this. It opens up a maximum 1000ms window between interrupts, and the task loop can “tick” the timer forward (or causing it to expire the 1000ms window, if there’s no interrupt). So in the end, you get a better measurement of raw speed right in the ISR. It then pushes the rate (how many interrupts are there in the past 1000ms?) into the glider which is sorta low pass filter, with its own cutoff.

I agree that the code should be moved out of ISR to make sure it does not skip any events. Maybe you can also keep track of the timestamps of the past events to calculate speed? Currently it’s missing because the information is reduced into a single counter.

Also, you can eliminate the interrupt disable/enable with a lock-free programming technique. The task sets a clear flag without actually clearing/resetting. The ISR detects the flag and does the work.

ISR() {
  if (clearFlag) {
    // Clear counters
    std::fill(std::begin(direction_counter), std::end(direction_counter), 0);
    clearFlag = false;
  }
  ...
}

task() {
  if (clearFlag) {
    // ISR hasn't run since our last visit. nothing to do...
    return;
  }
  // Calculate x and y positions
  float x = direction_counter[PIN_RIGHT] - direction_counter[PIN_LEFT];
  float y = direction_counter[PIN_DOWN] - direction_counter[PIN_UP];
  clearFlag = true; // lock free!
}

!! Note, this technique does have some gotchas. The compiler may reorder the write to clearFlag before reading x and y. If unsure, use atomic<bool> and take advantage of the C++ memory model specs.

2 Likes

Thanks, I think I understand the logic a bit better now. Maybe this would be better implemented by properly setting the sampling frequency of trackball_task()? I think this can be done with hardware timer…

Initially I going to compute the timestamps with millis() and use it to tweak the speed as well, but when I did some data collection I noticed the elapsed times between interrupts were all nearly identical (something like 8ms). If the time differences are ~uniform, than they become just a constant scaling, so I just dropped them. This also makes the filter design easier, because it assumes a constant sampling time.

It’s possible I did the calculation wrong or even that millis() is not accurate enough (it either reads 8ms or 32ms when sending mouse events). I’m not sure, but this is something to investigate further.

Oh! I haven’t thought of that, I remember watching a tutorial on atomic variables applied to real time audio processing, which is not very different from this case. I’ll try that, thanks!

Amazing write up!

Very helpful for me as I’m planning to replace the trackball with a salvaged Thinkpad trackpoint.

I’m building on top of this Thinkpad trackpoint research Trackpoint · joric/jorne Wiki · GitHub

3 Likes