Updated on 2022-12-09
Building a button library seems simple until it isn't.
Initially, I just needed a simple button that reported presses and releases, and the button libraries I looked at seemed like overkill, so I made a simple button library to take care of this very simple facility.
Eventually, I wanted more functionality in the button, like the ability to be attached to an interrupt, and the ability to make a button multifunction depending on how you press it, so I began to make a more full featured counterpart to the first button.
Oh boy, I started to realize why the button libraries I had seen were so complicated. The code to manage the timing of the multifunction presses gets tricky fast, and being able to handle things on an interrupt requires a significant increase in complexity as well. Still, I thought I could do better than what was out there, with a better approach to button handling in code than what I had seen.
My extended button is still relatively minimalistic, and I'll probably add to it later, but it fulfills the requirements of my existing projects and beyond thus far. The regular button is still useful for scenarios where immediate feedback is required and the extra functionality is not necessary.
You'll need VS Code with PlatformIO installed.
You need a TTGO T-Display v1.
You can use different hardware, but you'll have to modify your platformio.ini and pin configuration in config.hpp accordingly.
Once you've added a library dependency to htcw_button and you've included <htcw_button.hpp> into your project, you'll have button<> and button_ex<> available under the arduino namespace.
The first thing to do is instantiate the templates, and then take those concrete types and instantiate them:
// configure the buttons
using button_1_t = button_ex<PIN_BUTTON_1, 10, true, true>;
using button_2_t = button<PIN_BUTTON_2, 10, true>;
static button_1_t button_1;
static button_2_t button_2;
You can see we've declared two buttons. The first one is an extended button and the second one is a regular button. This project is configured for the TTGO so the pins are defined as 35 and 0, open high. They both have a debounce of 10ms and the extended button is configured to be interrupt driven.
Next, you'll need to hook the appropriate callbacks. In this case, we're going to hook all the callbacks available for button_1 (click and long click) and the lone callback available for button_2.
button_1.on_click([](int clicks, void* state){
Serial.print("1 - on click: ");
Serial.println(clicks);
});
button_1.on_long_click([](void* state){
Serial.println("1 - on long click");
});
button_2.callback([](bool pressed, void* state){
Serial.print("2- ");
Serial.println(pressed?"pressed":"released");
});
We just used flat lambdas that dump to the serial port here. Note that they cannot capture, for performance reasons. Use the state argument instead for passing values.
In order for the callbacks to fire, you'll need to pump the buttons in any loops where you want it to function, like we do in loop():
// pump all our objects
button_1.update();
button_2.update();
When the buttons are manipulated, you'll see corresponding messages dumped to the serial port.
Well, that was easy! What's going on behind it all?
We'll explore the simpler button first.
There really are only a few significant parts, so well cover them.
First, initialization:
bool initialize() {
if (m_pressed == -1) {
m_last_change_ms = 0;
if (open_high) {
pinMode(pin, INPUT_PULLUP);
} else {
pinMode(pin, INPUT_PULLDOWN);
}
m_pressed = raw_pressed();
}
return m_pressed != -1;
}
Basically, what we're doing here is checking for an uninitialized button (m_pressed == -1) and then we initialize the members, and establish the pin mode for the button depending on whether it is open high or not. Finally, we set pressed to the current value of the button - pressed or not. It returns true if initialization is successful, which in this case it always will be.
Now, the pump, where we process clicks:
void update() {
bool pressed = raw_pressed();
if (pressed != m_pressed) {
uint32_t ms = millis();
if (ms - m_last_change_ms >= debounce_ms) {
if (m_callback != nullptr) {
m_callback(pressed, m_state);
}
m_pressed = pressed;
m_last_change_ms = ms;
}
}
}
What we're doing here is getting the underlying value of the button (raw_pressed()) and if it's different than the last value we recorded, and more than debounce_ms has passed the callback is fired if it was configured. Note you can use it without the callback just by using update() and pressed(). Once the callback is fired, the pressed value and last update time are updated.
This button is significantly more complicated. In the previous button, the update routine gathered the button clicks, and reported them. In this button, they are two separate routines.
Furthermore, this button keeps a buffer of button state changes with an associated timestamp. As button clicks are registered, this gets filled, and as button events get reported, it gets emptied.
Finally, when we go to report, we use a state machine to parse the button events we stored and produce the appropriate callbacks.
Wow. What's the big deal?
First of all, this button may be signaled via an interrupt which means whenever the button is pressed or released, the MCU's CPU stops whatever else it was doing and handles the button event change. This means an interrupt enabled button will gather presses even as update() cannot be called, like in the middle of refreshing an e-paper display. update() must still be called to actually fire events. We can't fire callbacks inside the interrupt routine because the code in the callback may do something not safe for an interrupt, and it would mean your callbacks would have to be in RAM for the entire life of the application.
Next, using a state machine allows us to do complicated analysis of the button, like counting the time between releases to allow for multiple clicks to be fired off on one event, or counting the time between press and release for a long press, as well as providing room to expand it later for other types of clicks.
Here's our interrupt routine which gathers button presses and releases along with the associated timestamp.
#ifdef ESP32
IRAM_ATTR
#endif
static void process_change(void* instance) {
type* this_ptr = (type*)instance;
uint32_t ms = millis();
bool pressed = this_ptr->raw_pressed();
if (pressed != this_ptr->m_pressed) {
if (ms - this_ptr->m_last_change_ms >= debounce_ms) {
if(!this_ptr->m_events.full()) {
this_ptr->m_events.put({ms,pressed});
this_ptr->m_pressed = pressed;
this_ptr->m_last_change_ms = ms;
}
}
}
}
You can see once you squint past the this_ptr stuff that this is a lot like our update() routine from the old button, except we're putting events into an m_events member. The pointer stuff is just because this is a static member, so we have to pass the class instance as a generic state argument "instance", and then reconstitute the class member access from there.
Now onto the significantly more complicated part - the event processing:
void update() {
if(!initialize()) {
return;
}
if(!use_interrupt) {
process_change(this);
}
if(m_pressed==1) {
return;
}
if(m_last_change_ms!=0 &&
!m_events.empty() &&
millis()-m_last_change_ms>= double_click_ms) {
event_entry_t ev;
uint32_t press_ms=0;
int state = 0;
int clicks = 0;
int longp = 0;
int done = 0;
while(!done) {
switch(state) {
case 0:
if(!m_events.get(&ev)) {
done = true;
break;
}
if(ev.state==1) {
// pressed
state = 1;
break;
} else {
// released
while(ev.state!=1) {
if(!m_events.get(&ev)) {
done = true;
break;
}
// pressed
state = 1;
}
break;
}
case 1: // press state
++clicks;
press_ms = ev.ms;
while(ev.state!=0) {
if(!m_events.get(&ev)) {
done = true;
break;
}
state = 2;
}
break;
case 2: // release state
longp = !!(m_on_long_click &&
ev.ms-press_ms>=long_click_ms);
if(!m_events.get(&ev)) {
// flush the clicks
if(m_on_click) {
if(clicks>longp) {
m_on_click(clicks-longp,m_on_click_state);
}
}
if(longp) {
m_on_long_click(m_on_long_click_state);
}
done = true;
break;
}
state = 1;
break;
}
}
}
}
What a doozy. This took me a while. Basically on release, we start up a state machine to process all the events we previously captured. We only do so if double_click_ms has elapsed since the last time the button was released so that we have time to gather multiple clicks. Then we start off at state zero, processing the first event, and then moving to state 1 (press) or state 2 (release) depending on the event. From there, we basically bounce back and forth, incrementing the clicks (press) or firing the events (release).
There's not much to it other than what we've covered above. Happy coding!