My son has been working hard on his cubing skills, and he showed me some competition videos that use a timing device. I set out to make one, using a ESP32 dev module because i got loads of those, and they have native touch pins. I added a common 0.96” OLED (SSD1306 driver over SPI), and a piezo buzzer. To make it portable, i powered it with a 18650 Lithium cell, with a TP4056 charger, and a small solar panel connected to the USB side of the charger.

OK, we usually don’t start with a solved cube, this was just a test run!

It works as follows:

  • a small switch powers on the ESP32
  • the OLED shows the current record time, in MM:SS:ss (hundredths of sec)
  • it waits for a double touch, shows ready.. while double touch
  • starts the timer as soon as the double touch is released
  • stops the timer as soon as double touch again
  • if the time is below the stored record time, ask to save or not, using left/right touch
  • go back to showing the current record time, waiting for double touch to start again
  • to reset the stored record time, touch both pads while resetting the ESP32 (or power off/on)

HARDWARE

The touch wires are stainless steel, connected straight to GPIO pins. Read more about the ESP32’s touchRead() here. I ended up increasing the threshold to 40. The touch values depend on how you power the ESP32; when connected to a laptop over USB i got a range of 10-50 while battery powered i got 30-50. I assume the GND reference is different from when powered by battery. Capacitive touch is a bit of magic.

Above shows the soldered final version; but i started on a breadboard first, with USB power.

To make it more robust, i soldered 2x 19-pin female headers to perf board, and a 7-pin female header for the OLED. The wiring is very simple, as detailed in the sketch below.

Unfortunately these dev boards have a very lousy LDO to convert the USB 5V to 3.3V (the AMS1117), this is actually not a LOW dropout voltage regulator, as it has a typical dropout of 1.1V, much too high to use it with a Lithium battery. I added a MCP1826 with a typical dropout of 0.25V, and 2x 10uF decoupling caps.

When put in the sunlight, the small 6V solar panel manages to charge the battery a little, according to the red LED on the TP4056 charger.

CODE

The sketch below has a boolean test_touch, when set to true the OLED will show the touchRead() values for both pads, so you can find which threshold will work for you. Remember to test on battery power if that is your goal. Change back to false and upload again to be able to use the timer.

To store the record time (called PB in the sketch), i used the ESP32’s preferences function, as EEPROM is depricated.

Each loop represents 1 timing event; the sketch waits for user actions with a while statement checking the 2 touch pads. The touching(pin) function returns true/false based on the defined threshold.

The OLED needs the Adafruit_SSD1306 library.

/**************************************************************************

  CUBE TIMER v0.2

  hardware:
  > ESP32 dev module
  > SSD1306 OLED over SPI
  > 2 touch pads
  > buzzer

  to reset the PB/record, touch both pads at startup

  Tom Tobback
  BuffaloLabs June 2020

 **************************************************************************/

#include <SPI.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Preferences.h>  // non volatile storage, better than eeprom
Preferences preferences;

#define SCREEN_WIDTH 128 // OLED display width, in pixels
#define SCREEN_HEIGHT 64 // OLED display height, in pixels

// Declaration for SSD1306 display connected using software SPI (default case):
#define OLED_MOSI   19
#define OLED_CLK   18
#define OLED_DC    22
#define OLED_CS    23
#define OLED_RESET 21
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT,
                         OLED_MOSI, OLED_CLK, OLED_DC, OLED_RESET, OLED_CS);

const boolean test_touch = false;
const int touch_threshold = 40;
const int pad_right = 13;  // right
const int pad_left = 12;  // left

unsigned long start_ts;
unsigned long stop_ts;
unsigned long pb;      // stored record time / personal best

///////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////

void setup() {

  Serial.begin(115200);

  if (!display.begin(SSD1306_SWITCHCAPVCC)) {
    Serial.println(F("SSD1306 allocation failed"));
    while (1);
  }

  tone(5, 500);
  delay(100);
  tone(5, 1000);
  delay(100);
  tone(5, 0);

  Serial.println("CUBE TIMER v0.2");
  preferences.begin("cube", false); // namespace, false for R/W
  pb = preferences.getUInt("pb", 0);
  preferences.end();
  Serial.print("PB stored (ms): ");
  Serial.println(pb);

  if (touching(pad_right) && touching(pad_left)) {    // if both touch at startup, reset PB
    Serial.println("both touches at startup, resetting PB to 0");
    pb = 0;
    preferences.begin("cube", false); // namespace, false for R/W
    preferences.putUInt("pb", pb);
    preferences.end();
    display.clearDisplay();
    display.setTextSize(2);             // Normal 1:1 pixel scale
    display.setTextColor(SSD1306_WHITE);        // Draw white text
    display.setCursor(0, 0);            // Start at top-left corner
    display.println("CUBE TIMER");
    display.setCursor(0, 40);            // Start at top-left corner
    display.println("PB reset");
    display.display();
    Serial.println("wait for release");
    while ((touching(pad_right) || touching(pad_left))); // wait for release
  }

  displayWaiting();
  //delay(3000);

  // test touch sensors
  while (test_touch) {
    display.clearDisplay();
    display.setTextSize(2);             // Normal 1:1 pixel scale
    display.setTextColor(SSD1306_WHITE);        // Draw white text
    display.setCursor(0, 0);            // Start at top-left corner
    display.println(touchRead(pad_right));
    display.println(touchRead(pad_left));
    display.display();
  }
}

///////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////

void loop() {

  // WAIT for double touch
  Serial.println("wait for double touch");
  while (!(touching(pad_right) && touching(pad_left)));  // wait for double touch to start the process

  // TOUCH double to start
  tone(5, 1000);
  displayReady();

  // RELEASE double to start timer
  Serial.println("wait for release");
  while ((touching(pad_right) || touching(pad_left))); // wait for release
  tone(5, 0);
  start_ts = millis();

  // TIMER running
  Serial.println("timer started");
  display.setTextSize(2);             // Normal 1:1 pixel scale
  while (!(touching(pad_right) && touching(pad_left))) {   // wait for double touch
    displayRunning();
  }

  // TOUCH double to stop timer
  stop_ts = millis();
  Serial.println("timer stopped");
  if (stop_ts - start_ts < pb || pb == 0) {     // NEW PB? needs confirmation by touch
    for (int i = 0; i < 10; i++) {
      tone(5, 4000);
      delay(40);
      tone(5, 0);
      delay(40);
    }
    displayPBconfirm();
    Serial.println("wait for release");
    while ((touching(pad_right) || touching(pad_left))); // wait for release
    delay(500);
    Serial.println("wait for left or right touch");
    while (!(touching(pad_right) || touching(pad_left))); // wait for touch left or right
    Serial.println("touched left or right");
    if (touching(pad_right)) {   // save PB
      pb = stop_ts - start_ts;
      preferences.begin("cube", false); // namespace, false for R/W
      preferences.putUInt("pb", pb);
      preferences.end();
      Serial.println("PB saved");
      tone(5, 4000);
      delay(100);
      tone(5, 0);
    } else {                     // don't save PB
      Serial.println("PB not saved");
    }
    displayWaiting();
  } else {
    tone(5, 2000);                   // NO NEW PB
    display.setCursor(0, 40);
    display.setTextSize(1);             // Normal 1:1 pixel scale
    display.println("STOPPED");
    display.setCursor(0, 50);            // Start at top-left corner
    display.setTextSize(2);             // Normal 1:1 pixel scale
    unsigned long seconds_spent = pb / 1000;
    unsigned long minutes_spent = seconds_spent / 60;
    if (minutes_spent < 10) display.print("0");
    display.print(minutes_spent);
    display.print(":");
    if (seconds_spent % 60 < 10) display.print("0");
    display.print(seconds_spent % 60);
    display.print(":");
    if (pb % 1000 < 100) display.print("0");  // only display hundreds, not thousands
    display.print((pb % 1000) / 10);
    display.print(" R");
    display.display();
  }

  // RELEASE
  delay(1000); // to make sure
  tone(5, 0);
  Serial.println("wait for release");
  while ((touching(pad_right) || touching(pad_left))); // wait for double release
  delay(1000); // to make sure

  Serial.println();
}

///////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////////

boolean touching(int pin) {
  int touch_val = 0;
  int samples = 10;
  for (int i = 0; i < samples; i++) touch_val += touchRead(pin);
  touch_val /= samples;
  if (touch_val < touch_threshold) return true;
  else return false;
}

//////////////////////////////////////////////////////////////////////////////////

void tone(byte pin, int freq) {
  ledcSetup(0, 2000, 8); // setup beeper
  ledcAttachPin(pin, 0); // attach beeper
  ledcWriteTone(0, freq); // play tone
}

//////////////////////////////////////////////////////////////////////////////////

void displayWaiting() {
  display.clearDisplay();
  display.setTextSize(2);             // Normal 1:1 pixel scale
  display.setTextColor(SSD1306_WHITE);        // Draw white text
  display.setCursor(0, 0);            // Start at top-left corner
  display.println("CUBE TIMER");
  display.setTextSize(1);             // Normal 1:1 pixel scale
  display.setCursor(0, 25);            // Start at top-left corner
  display.println("touch & release");
  display.println("to start...");
  display.setCursor(0, 50);            // Start at top-left corner
  display.setTextSize(2);             // Normal 1:1 pixel scale
  unsigned long seconds_spent = pb / 1000;
  unsigned long minutes_spent = seconds_spent / 60;
  if (minutes_spent < 10) display.print("0");
  display.print(minutes_spent);
  display.print(":");
  if (seconds_spent % 60 < 10) display.print("0");
  display.print(seconds_spent % 60);
  display.print(":");
  if (pb % 1000 < 100) display.print("0");  // only display hundreds, not thousands
  display.print((pb % 1000) / 10);
  display.print(" R");
  display.display();
}

//////////////////////////////////////////////////////////////////////////////////

void displayReady() {
  display.clearDisplay();
  display.setCursor(0, 0);            // Start at top-left corner
  display.setTextSize(2);             // Normal 1:1 pixel scale
  display.println("CUBE TIMER");
  display.setCursor(0, 20);            // Start at top-left corner
  display.println("00:00:00");
  display.setCursor(0, 40);            // Start at top-left corner
  display.println("READY...");
  display.display();
}

//////////////////////////////////////////////////////////////////////////////////

void displayRunning() {
  display.clearDisplay();
  display.setCursor(0, 0);            // Start at top-left corner
  display.println("CUBE TIMER");
  display.setCursor(0, 20);            // Start at top-left corner
  unsigned long millis_spent = millis() - start_ts;
  unsigned long seconds_spent = millis_spent / 1000;
  unsigned long minutes_spent = seconds_spent / 60;
  if (minutes_spent < 10) display.print("0");
  display.print(minutes_spent);
  display.print(":");
  if (seconds_spent % 60 < 10) display.print("0");
  display.print(seconds_spent % 60);
  display.print(":");
  if (millis_spent % 1000 < 100) display.print("0");  // only display hundreds, not thousands
  display.print((millis_spent % 1000) / 10);
  display.display();
}

//////////////////////////////////////////////////////////////////////////////////

void displayPBconfirm() {
  display.clearDisplay();
  display.setTextSize(2);             // Normal 1:1 pixel scale
  display.setCursor(0, 0);            // Start at top-left corner
  display.print("NEW RECORD");
  display.setCursor(0, 20);            // Start at top-left corner
  unsigned long millis_spent = stop_ts - start_ts;
  unsigned long seconds_spent = millis_spent / 1000;
  unsigned long minutes_spent = seconds_spent / 60;
  if (minutes_spent < 10) display.print("0");
  display.print(minutes_spent);
  display.print(":");
  if (seconds_spent % 60 < 10) display.print("0");
  display.print(seconds_spent % 60);
  display.print(":");
  if (millis_spent % 1000 < 100) display.print("0");  // only display hundreds, not thousands
  display.print((millis_spent % 1000) / 10);
  display.setCursor(30, 40);            // Start at top-left corner
  display.print("save?");
  display.setTextSize(1);             // Normal 1:1 pixel scale
  display.setCursor(0, 50);
  display.print("NO");
  display.setCursor(100, 50);
  display.print("YES");
  display.display();
}

Leave a Reply

Your email address will not be published. Required fields are marked *