Introduction
Intermediate Tutorial.
Text messaging didn’t always happen on glass touchscreens. Early mobile phones relied on a simple 12-button numeric keypad and a technique known as multi-tap entry — repeatedly pressing a number key to cycle through its associated letters. While primitive by today’s standards, this method was efficient, responsive, and ran on extremely limited hardware.
In this tutorial, we recreate that classic T9-style experience using an Arduino Uno, a 4×3 matrix keypad, and a 128×64 SSD1306 OLED display.
But this project is more than a nostalgic text demo.
It demonstrates how to build a responsive, non-blocking user interface on a microcontroller using:
- Cooperative multitasking with
millis() - State-based input handling
- Matrix keypad scanning
- Timed character preview and commit logic
- Safe fixed-length C-style buffers
- OLED partial redraw techniques to reduce flicker
Unlike delay-based examples, this design allows the keypad, display, audio feedback, and status indicators to operate concurrently without blocking the main loop. The result is a smooth and responsive embedded UI system — similar in structure to real-world consumer devices.
By the end of this tutorial, you will understand:
- How a matrix keypad is scanned
- How multi-tap character cycling works
- How to implement inactivity timeouts
- How to detect double-press events
- How to design a clean, non-blocking embedded application
Whether you’re learning about embedded interfaces, improving your millis() timing skills, or simply revisiting classic phone texting, this project provides a practical and expandable foundation for menu systems, logging interfaces, or compact input devices.
//
//================================================^================================================
// 4X3 T9 Keypad Text Writer
//================================================^================================================
//
// URL
//
// LarryD
//
// Version YY/MM/DD Comments
// ======= ======== ========================================================================
// 1.00 26/02/19 Running code.
// 1.02 26/02/21 Added key previewing.
//
//
// Intermediate tutorial:
//
// OVERVIEW
// ---------------------
// This sketch converts a standard 4x3 “touch-tone” matrix keypad into a classic
// multi-tap (T9-style) text entry interface similar to early mobile phones.
//
// Characters are selected by repeatedly pressing a numeric key within a timing window.
// The selected ASCII character is committed automatically after a timeout or manually
// using the '#' key. Completed messages are transmitted via Serial and displayed on
// an SSD1306 OLED (Address = 0x3C).
//
// The design uses a fully non-blocking architecture (millis()-based timing) allowing
// concurrent management of keypad scanning, OLED updates, audio feedback, and status
// indicators without delays.
//
// FEATURES
// ---------------------
// • Multi-tap alphanumeric entry (0-9)
// • Automatic character commit after inactivity timeout
// • Manual commit and transmit using '#'
// • Double-tap '#' to clear entire buffer
// • Backspace using '*'
// • Audible keypress feedback
// • Live OLED preview of cycling characters
// • Serial transmission of completed message
// • Heartbeat LED verifies non-blocking execution
// • Fixed-length C-style buffer (no heap fragmentation)
// • Safe overflow handling
//
// TEXT ENTRY RULES
//---------------------
// 0-9 → Multi-tap alphanumeric entry
// # → Single press = Send message
// Double press = Clear buffer
// * → Backspace last committed character
//
// TIMING PARAMETERS
// ---------------------
// Character timeout : 800 ms
// Double '#' detection : 400 ms
// OLED refresh interval : 250 ms
// Message display timeout : 2000 ms
// Heartbeat LED interval : 500 ms
//
// DESIGN NOTES
// ---------------------
// • Cooperative multitasking using millis()
// • State-machine style input handling
// • Area-based OLED redraw for reduced flicker
// • No dynamic memory allocation (String avoided)
// • Easily expandable for menus, logging, SMS emulation, etc.
//
// Notes:
// ---------------------
// Although the example emulates legacy phone-style texting, the architecture is
// directly transferable to:
// • Menu-driven embedded interfaces
// • Configuration panels
// • Serial command entry systems
// • Logging consoles
// • Low-resource UI front ends
//
// 4X3 Keypad connections
// ---------------------
// R1 → D11
// R2 → D10
// R3 → D9
// R4 → D8
// C1 → D7
// C2 → D6
// C3 → D5
//
//
//
//
#include <Wire.h>
#include <Keypad.h>
#include <Adafruit_SSD1306.h>
//======================================================================== <-------<<<<<<< D e b u g
//These macros are used for debug purposes.
//Comment next line to stop printing to the Serial Monitor.
//
#define DEBUG
//Debug Macros.
//--------------------
#ifdef DEBUG
#define SERIALBEGIN(...) Serial.begin(__VA_ARGS__)
#define DPRINT(...) Serial.print(__VA_ARGS__)
#define DPRINTLN(...) Serial.println(__VA_ARGS__)
#define DPRINTF(...) Serial.print(F(__VA_ARGS__)) //for text only, using the F macro
#define DPRINTLNF(...) Serial.println(F(__VA_ARGS__)) //for text only, using the F macro plus new line
//--------------------
#else
#define SERIALBEGIN(...)
#define DPRINT(...)
#define DPRINTLN(...)
#define DPRINTF(...)
#define DPRINTLNF(...)
#endif
//========================================================================
//Keypad Layout
//--------------------
const byte ROWS = 4;
const byte COLS = 3;
char keys[ROWS][COLS] =
{
{'1', '2', '3'},
{'4', '5', '6'},
{'7', '8', '9'},
{'*', '0', '#'}
};
//Row and column #s 1 2 3 4
byte rowPins[ROWS] = {11, 10, 9, 8};
byte colPins[COLS] = { 7, 6, 5};
Keypad keypad = Keypad(makeKeymap(keys), rowPins, colPins, ROWS, COLS);
//Typical T9 keypad.
//https://europe1.discourse-cdn.com/arduino/original/4X/e/c/4/ec46a48da407aa96d9645d284557898a5ed59006.png
//Keypad T9Table
//For a "T9 phone" keypad.
//--------------------
const char *T9Table[] =
//Change as needed
{
// Key/Index
"0_", // 0 Two presses gives a space. For visual effect we use an underscore.
"1.-+", // 1
"2ABC", // 2
"3DEF", // 3
"4GHI", // 4
"5JKL", // 5
"6MNO", // 6
"7PQRS", // 7 Five presses gives us an S.
"8TUV", // 8
"9WXYZ" // 9
};
//Miscellaneous
//--------------------
#define ENABLED true
#define DISABLED false
bool previewFlag = DISABLED;
bool messageFlag = DISABLED;
char lastKey = NO_KEY;
int charCycleIndex = 0;
//GPIOs
//--------------------
const byte piezoSpeaker = 4;
const byte heartbeatLED = 13;
//GPIOs 5 through 11 are used for the 4X3 keypad.
//Text message stuff.
//--------------------
int bufferIndex = 0;
//10 chars + null
const int MAX_Buffer = 11;
char messageBuffer[MAX_Buffer];
//OLED stuff.
//--------------------
byte SCREEN_WIDTH = 128; //OLED display width, in pixels
byte SCREEN_HEIGHT = 64; //OLED display height, in pixels
const byte OLEDaddress = 0x3C;
//SSD1306 display:
//Size 1 is 5X7 pixels therefore, 6X8 to account for spacing, 21 characters per line.
//Size 2 is 10X14 pixels therefore, 11X15 to account for spacing, 10 characters per line.
//Size 4 is 20X28 pixels therefore, 21X29 to account for spacing, 5 characters per line.
//Declaration for an SSD1306 display connected to I2C (SDA, SCL pins).
//
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);
//Timing Stuff
//--------------------
unsigned long heartbeatTime;
const unsigned long heartbeatInterval = 500ul; //Indicates if the sketch is blocking.
unsigned long oledTime;
const unsigned long oledInterval = 250ul; //How often the display is updated.
unsigned long messageTime;
const unsigned long messageInterval = 2000ul; //The time a message is on the display.
unsigned long lastPressTime;
const unsigned long timeoutInterval = 800ul; //Timeout detection to finalizing the character.
unsigned long lastHashTime;
const unsigned long doubleHashInterval = 400ul; //Double hash tap detection time-out.
// s e t u p ( )
//================================================^================================================
//
void setup()
{
SERIALBEGIN(115200);
DPRINTLNF("Buttons 0-9 AlphaNumeric, # Send, Double # Clear, * Backspace");
pinMode(heartbeatLED, OUTPUT);
pinMode(piezoSpeaker, OUTPUT);
//Clear message buffer at powerup.
memset(messageBuffer, 0, MAX_Buffer);
//========================
//Start up the OLED.
if (!display.begin(SSD1306_SWITCHCAPVCC, OLEDaddress))
{
DPRINTLNF("SSD1306 allocation failed");
while (1);
}
//Display.setTextColor(textColor, backgroundColor).
display.setTextColor(WHITE, BLACK);
//Clear the complete SSD1306 display.
display.clearDisplay();
display.setTextSize(1);
display.setCursor(0, 0);
// Character # Positions
//Size 1 000000000111111111122
//21 char 123456789012345678901
//Example ....T9 4X3 Texting...
display.print(" T9 4X3 Texting ");
//Use size 2 text for the rest of this sketch.
display.setTextSize(2);
} //END of setup()
// l o o p ( )
//================================================^================================================
//
void loop()
{
//======================================================================== T I M E R heartbeatLED
//Is it time to toggle the heartbeat LED ?
//
if (millis() - heartbeatTime >= heartbeatInterval)
{
//Restart this TIMER.
heartbeatTime = millis();
//Gives us a rough indication if the code is non-blocking.
//Toggle the heartbeat LED.
digitalWrite(heartbeatLED, !digitalRead(heartbeatLED));
}
//======================================================================== T I M E R message
//If enabled, is it time to erase the message ?
//
if (messageFlag == ENABLED && millis() - messageTime >= messageInterval)
{
//We are done with this TIMER.
messageFlag = DISABLED;
//To clear an area of the screen:
//display.fillRect(x, y, width, height, color):
//
//Clear from top left corner from 0, 16 pixels down, to right side at SCREEN_WIDTH, and 32 pixels tall.
//display.fillRect(0, 16, display.width(), 32, BLACK);
//Clear this text area.
//i.e. Using .setTextSize(2), two lines of text are cleared.
display.fillRect(0, 16, display.width(), 32, BLACK);
}
//======================================================================== T I M E R oled
//This code is responsible for updating screen changes made in the other
//parts of this sketch.
//Is it time to update the display ?
//
if (millis() - oledTime >= oledInterval)
{
//Restart this TIMER.
oledTime = millis();
//Clear one line of text.
display.fillRect(0, 16, SCREEN_WIDTH, 16, BLACK);
display.setCursor(0, 16);
display.print(messageBuffer);
//Do we preview the last pressed key character ?
if (previewFlag == ENABLED)
{
//If a there was a key press, preview that key.
if (lastKey != NO_KEY)
{
//Generate the T9Table index for the previewed key.
byte previewKeyNum = lastKey - '0';
//Add this preview character to what's displayed.
display.print(T9Table[previewKeyNum][charCycleIndex]);
}
}
display.display();
}
//======================================================================== T I M E R hash
//When lastHashTime equals 0, this TIMER is disabled.
//If this TIMER is in timing mode, has the TIMER expired ?
if (lastHashTime > 0 && (millis() - lastHashTime >= doubleHashInterval))
{
//We are finished with this TIMER.
lastHashTime = 0;
//Is there something to send ?
if (bufferIndex > 0)
{
DPRINT(F("\nBuffer SENT: "));
DPRINTLN(messageBuffer);
memset(messageBuffer, 0, MAX_Buffer);
bufferIndex = 0;
display.setCursor(0, 32);
// 123456789A
display.print("Buff Sent ");
//Start the message TIMER
messageFlag = ENABLED;
messageTime = millis();
//Get ready for our next buffer filling.
previewFlag = DISABLED;
lastKey = NO_KEY;
}
}
//======================================================================== T I M E R lastPress
//When a key was pressed, has this key TIMER expired ?
//
if (lastKey != NO_KEY && (millis() - lastPressTime >= timeoutInterval))
{
//We are finished previewing characters.
//Back to normal display updating.
previewFlag = DISABLED;
//Update the buffer.
finalizeCharacter();
}
//======================================================================== Scan for a key press
//This code runs at full loop() speed.
//Check for a T9 matrix key press.
//
char currentKey = keypad.getKey();
//Is there a key being pressed now ?
if (currentKey != NO_KEY)
{
//Service this key press.
handleInput(currentKey);
}
//================================================
// Other non blocking code goes here
//================================================
} //END of loop()
// h a n d l e I n p u t ( )
//================================================^================================================
//
void handleInput(char key)
{
//While we display a screen message, we don't handle key presses.
if (messageFlag == ENABLED)
{
return;
}
//Proceeding as normal.
tone(piezoSpeaker, 3500, 100);
//Service the key being pressed.
switch (key)
{
//==================
case '#':
{
//If pressed again within interval, this is a double tap on the '#' (i.e. clear buffer).
if (lastHashTime != 0 && millis() - lastHashTime < doubleHashInterval)
{
//Clear the buffer.
memset(messageBuffer, 0, MAX_Buffer);
//Get ready for the next buffer filling.
bufferIndex = 0;
lastKey = NO_KEY;
//A zero indicates the hash TIMER is disabled.
lastHashTime = 0;
DPRINTLNF("\nBuffer CLEARED");
display.setCursor(0, 32);
// 123456789A
display.print("Buff Clear");
//Start the message TIMER.
messageFlag = ENABLED;
messageTime = millis();
}
else
{
//This is the first tap on the keypad '#'.
//When lastHashTime is 0 this TIMER is disabled.
//Start this TIMER so we can check to see if there is
//a second '#' press before the TIMER expires.
lastHashTime = millis();
}
}
break;
//==================
case '*':
{
if (previewFlag == ENABLED)
{
previewFlag = DISABLED;
lastKey = NO_KEY;
charCycleIndex = 0;
}
else
{
finalizeCharacter();
}
//Go back to the previous index, i.e. we are backspacing our buffer.
//Are we at index zero ?
if (bufferIndex > 0)
{
//Step back one element.
bufferIndex--;
//Readjust our NUL '\0' character.
messageBuffer[bufferIndex] = '\0';
DPRINTLNF("\nBackspace our Buffer: ");
DPRINTLN(messageBuffer);
}
}
break;
//==================
case '0' ... '9':
{
//Generate the T9Table index from the key being pressed/selected.
byte localKeyNum = key - '0';
//Check to see if user is cycling the same key.
if (key == lastKey && (millis() - lastPressTime < timeoutInterval))
{
//Advance to the next character in this key's string.
charCycleIndex++;
//Did we finish with the character defined for this key.
if (T9Table[localKeyNum][charCycleIndex] == '\0')
{
//Wrap back to the start of the key's string.
charCycleIndex = 0;
}
}
//This "is" a new key that's being pressed.
else
{
//New key pressed, update buffer.
finalizeCharacter();
//Get ready for the next key press cycle.
lastKey = key;
//Start at the beginning of the key string.
charCycleIndex = 0;
}
//If buffer is full, do not allow a preview, clear the buffer and display error message.
if (bufferIndex >= MAX_Buffer - 1)
{
previewFlag = DISABLED;
DPRINTLNF("\nERROR, Buffer Full");
//Initialize the upcoming new buffer entry.
lastKey = NO_KEY;
charCycleIndex = 0;
bufferIndex = 0;
messageBuffer[bufferIndex] = '\0';
display.setCursor(0, 32);
display.print("Buff Full ");
messageFlag = ENABLED;
messageTime = millis();
return;
}
//Enable the previewing of characters.
previewFlag = ENABLED;
//Restart this TIMER
lastPressTime = millis();
}
break;
//==================
default:
{
//Optionally we could have:
//if (key >= '0' && key <= '9')
//Same above 0-9 code.
}
break;
} //END of switch/case
} //END of handleInput()
// f i n a l i z e C h a r a c t e r ( )
//================================================^================================================
//
void finalizeCharacter()
{
if (lastKey != NO_KEY)
{
//Generate the T9Table index from the key being pressed.
byte localKeyNum = lastKey - '0';
//Finalize on this character.
char finalChar = T9Table[localKeyNum][charCycleIndex];
//Update the buffer.
messageBuffer[bufferIndex] = finalChar;
bufferIndex++;
//Add the string terminator.
messageBuffer[bufferIndex] = '\0'; //Null terminator
DPRINT(finalChar);
//Initialize for the next key buffer cycle.
lastKey = NO_KEY;
charCycleIndex = 0;
}
} //END of finalizeCharacter()
// E N D
//================================================^================================================
//
EDIT:
- Corrected some comments.

