Reusing LCD Display Panels From Old 3D Printers - 2025.11.20
Most of us have one or two old 3D printers gathering dust in the shed or under the bed. These often have nice retro LCD display panels with a rotary encoder, a beeper and an SD card reader/writer. Some also have Reset/Stop and Kill push-buttons. The LCDs are usually 128 x 64 pixel graphics displays, or sometimes 4 lines x 20 character text displays.
This post describes how to connect and use these old display panels with (Arduino-style) microcontrollers. Here you'll find adapter circuit diagrams and C++ code for the text and graphical displays, rotary encoder and SD card reader/writer.
The microcontroller boards from old 3D printers are also really useful. These have the EXP display panel connectors, high-current MOSET PWM-controlled outputs for the heaters, up to 5 stepper motor controllers, analogue temperature sensor inputs, fan control outputs, and digital inputs for proximity sensors and end-stop switches. They can have 8, 16 or 32-bit processors, and can be programmed using the Arduino libraries via the ICSP connector. In fact, the easiest way to re-use the display panel is to use it with the original controller board. But debugging is a big problem. Some modules give access to a Serial port, and some do not, but that's it. I'll cover some of these boards in later posts.
It's often possible to buy an old 3D printer for a few $, just for the parts. You get a display panel, controller board, power supply, several stepper motors, lead screws, bearings, drive belts, aluminium extrusions, end stop switches, bits of wire and string, clouds of dust, and lots more really interesting stuff which you can use to create an enormous mess on the living room carpet.
EXP1, EXP2 and EXP3 Connectors
The display panels have two or three 'EXP' connectors. Most panels have the EXP1 and EXP2 connectors, some have an additional EXP3 connector, and some have only EXP3 (but only if the panel does not have an SD card). Each connector has 10 pins as 2 rows of 5, with a locating slot on one side.
One or two ribbon cables are used to connect the panel to the controller board. Either EXP1 and EXP2, or EXP3 alone.
The signals on these connectors are always the same, but sometimes the numbering or the connector polarity is reversed or mirrored. This causes a lot of confusion (and probably a lot of damaged hardware) because some boards have pins numbered 1..10, on other boards the same pins are numbered 10..1. To make things worse, the socket's locating slot is sometimes on different sides and does not relate to the pin numbering! And some of the ribbon cables have the locating lugs on different sides at each end!
EXP1 is for the graphical display with an SPI interface (usually software SPI), or the character/text display with a 4-bit parallel interface, encoder push-button and beeper. EXP2 is for the SD Card (hardware SPI for speed), encoder pins and optional Reset and Kill push-buttons. EXP3 is for the graphical display with a (software) SPI interface, encoder and beeper. If the SD card is not present, then only EXP3 is needed.
To solve the pin numbering problem, the best solution is to find the GND pin on each connector and reference all the other pins from the GND pin. The pin numbers and locating slot are not reliable because these vary between boards. I stuck a bit of RED tape on each connector to mark the GND pin so the ribbon cables are always connected correctly. A more robust dot of paint or toenail varnish would be better.
Note that the red wire on the ribbon cable may be pin 1 or pin 10. Without knowing the GND connection you can never be sure.
RepRap Smart Adapter
The 'RepRap Arduino Mega Pololu Shield' (RAMPS) used a 'Smart Adapter' for the EXP1 and EXP2 connectors, so you may have one or of these adapters lying around. I did not find a use for them, and the separate 6-pin SPI connector for the SD card is not aligned on a 2.54" matrix. (Why do they always do that? I hate it. Is it just to stop us re-using them with 2.54" breadboards?)
Here's the back of the adapter. Pin 1 has the square outline, which matches the Pin 1 'V' marker on the socket. So the pin numbering on the adapter is correct! Wow!
RepRap Discount Smart Controller LCD 4 lines x 20 characters
The first RepRap displays were simple 4x20 text displays. The LCD module uses a 4-bit parallel interface with function select (RS), read/write (RW) and enable (EN) signals. That's a lot of I/Os, but RW is connected to VCC so only writes are supported. It has 8 user-programmable characters, but the remaining characters are hard-wired and many are Chinese (looks more like Klingon to me). It does not support the old DOS character set (CP437).
The GND pins are indicated above. Mark the GND pin with a dot on or near the connector, so you always connect the cables the right way round. You can check them with a meter using the GND connection of the rotary encoder or Stop button.
Note that Pin 9 is GND - on some other boards it's Pin 2.
RepRap Discount Full Graphic Smart Controller 128x64 pixels
The second generation of panels had a 128x64 bit graphics display. This can support any character set (if the font bitmaps are available), and can display any other bitmap too. The Mattlabs code (see below) provides full support for almost any font, and for 8, 16 or 32-bit bitmaps. Bitmap fonts can be created from any TrueType font using the Adafruit 'fontconvert.exe' application. The standard 5x8 DOS font 'Code Page 437' is often used because it uses less memory.
The controller uses SPI to communicate with the display: CS, SID (MOSI) and SCLK. These signals are connected to some of the text LCD pins on EXP1: LCDRS=CS, LCDE=SID, LCD4=SCLK. The other LCD pins are unused (LCD5, LCD6, LCD7). We must use the software SPI for display communications if the SD card is used, because that uses the hardware SPI. But some MCUs have more than one hardware SPI channel...
The GND pins are indicated. Mark the GND pins with a dot on or near the connector, so you always connect the cables the right way round. You can check them with a meter using the GND connection on the rotary encoder (bottom left in the right-hand picture).
Note that GND is Pin 2 according to the silkscreen numbering on the board, but on the circuit diagram below it is Pin 9.
Creality Ender 3 12864ZW-10
This panel introduced the EXP3 connector, because the panel did not have the SD card slot or the Stop (RESET) button. You can use only EXP3 instead of EXP1 and EXP2. You don't need EXP1 and EXP2, they are just for compatibility with controller boards that don't have EXP3.
The rotary encoder is mounted directly on the LCD board. The other panels use a main board for the encoder, SD card and connectors, and a separate LCD module which is soldered onto the main board.
It has a slightly better quality display than the RepRap Discount model, with sharper characters and better contrast.
I was unable to find a schematic for this panel, or for the 'Creality 3D V2.5.2 Controller' board which used it, but the EXP connections are standard EXCEPT for the socket's locating slot position which is ON THE WRONG SIDE! And I think the EXP3 connector should have been rotated 180° so the GND pin is in the same position as the GND pin on EXP1 and EXP2.
Again, the GND pins are indicated above. Mark the GND pins with a dot on or near the connector, so you always connect the cables the right way round. You can check them with a meter using the GND connection on the rotary encoder.
Bigtreetech Mini12864 V2.0
This is a more recent board, with a very nice (but smaller) display and an excellent quality PCB (the V1.0 version is not so good). If you don't have an old panel, then buy one of these! The V1.0 version can be found for about CHF10.-, the superior V2.0 version (shown below) is about CHF20.-
Note that the GND pins are on the top left of the connectors. The locating slots are also on the top. Pin 1 is marked with the white triangle pointer so GND is on Pin 9, which is correct! (I think.)
Adapter Hardware
Prototypes were made with 'Arduino Prototype Shield' boards, which you can buy on ebay etc. for a couple of $ each. Don't buy them in Switzerland or from Arduino - they are up to 10x more expensive!
Some prototype shields do not have connections for the I2C's SDA and SCL on pins 31 and 32, marked with the red square below. Prototype shields without these cannot be used with boards that do not connect SDA and SCL to A4 and A5, like the Nucleo boards that I use.
Below is the prototype for the 4x20 character LCD version, with the PCF8574 I2C I/O expander (no sarcastic comments about my soldering, please :-) The graphics display version has EXP3 and doesn't need the PCF8574.
Note the red dots to mark the GND connections - these are essential to ensure the ribbon cables are connected the right way round.
For the wiring, I always use wire-wrap wire with Kynar sleeving (not PVC!). Kynar is not affected by heat from the soldering iron, whereas PVC behaves like heat-shrink sleeving when it gets hot. Slightly thicker wire was used for the GND and +5V, but that's probably not necessary.
If you wire up your own prototype, use the the underside view of the EXP connectors which you will find below.
The Reset (or Stop) button can be connected directly to the MCU's reset pin, so pressing it resets the MCU. Alternatively, it can be connected to a general-purpose input which can be polled or generate an interrupt when pressed. The Kill button (if present) can also be connected to an input. If connected to inputs, both these buttons should be de-bounced by the software, for an example see Debouncer.h below.
The SD_DET signal (SD card detected) can be omitted. A function call will determine if a card is in the slot, but that's a bit slower.
3.3V Controllers
These old LCD panels all run on 5V, but can be used safely with 3.3V controllers if the I2C and optional BTN pull-up resistors on the adapter boards are to 3.3V instead of 5V. The encoder, Reset and Kill buttons, and the optional SD_DET signal will need pinMode(xxx, INPUT_PULLUP) or 10K..100K pull-ups, and the I2C SDA and SCL may need 4K7..10K pull-ups.
The SD card runs on 3.3V. A 74HC40450 chip is used on the board to drop the voltage from 5V to 3.3V. The SD card output signals are 3.3V, so you don't need to do anything if you're using a 3.3V microcontroller.
128x64 Graphics Display Adapter
This circuit has all three EXP connectors, if you care to wire them up. Either EXP1 and EXP2 can be wired, or just EXP3 if connecting a 'Creality Ender 3 12864ZW-10' or compatible panel (so you can leave EXP1 and EXP2 out and wire up only EXP3). If the panel has all three connectors, then connect either EXP1 AND EXP2, or ONLY EXP3. There is no need to connect all three connectors.
The 10K..100K pull-up resistors, if fitted, must be to the voltage of your MCU, either 3.3V or 5V.
4x20 Text Display Adapter
This is the circuit for panels with the 4x20 character display. These do not have the EXP3 connector, and do not use SPI for the LCD. Instead they use the 4-bit parallel interface. To save pins, the prototype uses a PCF8574 I2C I/O Expander chip, so the LCD can be controlled via the 2-pin I2C interface (SCL and SDA). If using a 3.3V controller, the PCF8574 also converts 3.3V I2C to 5V signals.
The BEEPER is on the PCF8574 output P3, which is used as the BACKLIGHT by the LcdDisplayI2C.h library. Just call backlightOn(true/false) to turn on/off the beeper.
If your MCU board does not have I2C pull-up resistors, then you must add them (R1 and R2, 4K7..10K), to 3.3V or 5V depending on the microcontroller's voltage.
If you can't use pinMode(..., INPUT_PULLUP), you will need 10K..100K pull-ups on the BTN_xxx and SD_DET signals, and the optional RESET and KILL push-buttons.
I also added a 10K pull-up resistor R3 on the BEEPER output, because the BEEPER misbehaved without it. Not sure why. Shouldn't that be a pull-down resistor?
There's more details about LCD text displays and the PCF8574 in this post: I2C LCD Character Display.
Underside view of the EXP connectors - for wiring
When making a prototype it's very easy to wire the connectors the wrong way round, so use this underside view of the connectors.
(*) Maybe EXP3 should be rotated 180° so the GND pins are all in the same position.
Mattlabs C++ Code
Each class is in a single .h #include file which contains both the class definition and the code. There's no need to have separate .cpp and .h files if the class is referenced from only one source file. But you can easily create separate .cpp and .h files if you need them. Copy/paste the code into your own files.
The code has been tested on a Nucleo STM32 board and an Arduino Zero. UNOs can't be used for this project because they don't have enough memory, especially if fonts and bitmaps are used.
Instead of writing pages of function definition help or using obfuscated comment-generated help, you can read the comments in the self-documenting source code. There is no need for separate help texts if the comments in the code are sufficient.
There is no menu handling (yet). For menus, you could use the 'u8g2' library. But this does not support the rotary encoder, it is quite big and may be overkill. I'll be writing a simple menu handler soon, designed to use only the rotary encoder. The Marlin firmware for RepRap 3D printers uses a subset of the 'u8g2' library, so take a look at that. See the links in the References section below.
128x64 Graphics Display
The 128x64 display is used only in graphics mode. Text mode is designed for 16x16 Chinese characters and it's not much good for anything else, so text mode has not been implemented.
All x/y coordinates are in pixels. The horizontal axis 'x' is 0..127, the vertical axis 'y' is 0..63. Coordinate x,y = 0,0 is the top left of the display. x,y = 127,63 is the bottom right corner. 'x' is always the first coordinate.
When displaying text, the x,y coordinate is the bottom left of each character. Use the fontHeight, fontAscender and fontDescender values for positioning text vertically. Don't use these for the CP437 font.
For horizontal positioning, the drawXXX() methods always return the next 'x' position, because each character may have a different width.
To use the built-in 5x7 CP437 font, add #define LCD12864_CP437 before including Lcd12864.h. This needs more memory for the font bitmap, so exclude it if you don't need it. The CP437 font has a fixed width of 5 pixels and a height of 8. Use drawCP437Char() and drawCP437Text() for the CP437 font. You can use the larger Adafruit fonts at the same time.
Larger fonts are based on Adafruit's GFX library font format, but I've written some new code which is smaller and faster. Use setFont(font*) , drawChar() and drawText() for the Adafruit-style fonts.
Or you can use the 'Adafruit Font Converter' application 'fontconvert.exe', which you can download below or build yourself. This app creates the C code for the bitmap font (.h file) from a TrueType font file (.ttf).
All drawing methods write to a display buffer in memory. They do not write directly to the display. Use the 'write to graphics data ram' methods writeGDram() or writeGDramRectangle() to copy the buffer to the display. As an optimization you only need to write the rectangle that's changed, but this is not managed automatically by the code (yet).
Drawing into the buffer and writing it to the display can take up to 200 milliseconds. This can have serious effects on the timing of loop(). If possible, it's best to only update the rectangular display area that has changed by calling writeGDramRectangle().
The display update could be made much faster by replacing digitalWrite() with a faster 'direct IO' version, depending on your microcontroller. See the example for the 74HC595 shift register chip, and the References section at the end of the page.
For even more exciting details please read the comments in the code.
#pragma once
// LCD character display driver with PCF8754 I2C interface
// Copyright (C) muman.ch, 2025.11.15
// info@muman.ch
// https://muman.ch/muman/index.htm?muman-reusing-3d-printer-displays.htm
#include <Wire.h>
#ifdef DEBUG
#define I2C_ASSERT(b) if (!(b)) { Serial.println("ASSERT Failed!"); }
#else
#define I2C_ASSERT(b)
#endif
class LcdDisplayI2C
{
// Control bits, usually bits 0..3, see I2C adapter circuit diagram
enum CTRL {
NONE = 0,
RS = 0x01, // P4 RS Register select: 0=Instruction register (for write),
// Busy register(for read); 1=Data register(for read and write)
RW = 0x02, // P5 RW Read/Write: 0=Write mode; 1=Read mode
EN = 0x04, // P6 EN Enable
BL = 0x08 // P7 BL Backlight: 1=on; 0=off (also controlled by jumper)
};
// Saved values for Display on/off control command, D C B bits
// all three bits are written by the command, so we must keep track of the current values
enum DISP {
DISPON = 0x04, // D display on/off
CURSOR = 0x02, // C cursor on/off
BLINK = 0x01 // B cursor blink on/off
};
TwoWire* wire;
int i2cAdds; // I2C slave address
int rows; // 1, 2, 4
int columns; // 8, 16, 20, 40
CTRL backlight; // bit P3 is the backlight, this holds the P3 value
DISP dispCtrl; // current value of LCD's display control register
int* rowOffset; // address offsets to the start of each LCD line (DDRAM memory addresses)
public:
enum ENTRYMODE {
SH = 0x01, // display shift: 0=not shifted; 1=display shifted (ID 1=shift left, ID 0=shift right)
ID = 0x02 // cursor movement (DDRAM and CGRAM address): 1=increment; 0=decrement
};
bool begin(TwoWire* twoWire, int i2cAddress, int rows, int columns);
void backlightOn(bool backlightOn);
void clearDisplay();
void displayOn(bool displayOn);
void cursorUnderline(bool show);
void cursorBlink(bool show);
void cursorHome();
void cursorPos(int row, int column);
void entryModeSet(ENTRYMODE mode);
void putChar(char ch, int row, int column);
void putString(const char* s, int row, int column);
void putChar(char ch);
void putString(const char* s);
//void putLines(char* lines[]);
void writeCGRAM(int charIndex, byte charData[8]);
protected:
bool writeCommand(int data);
bool writeData(int data);
bool writeByte(CTRL ctrl, int data);
bool writeNibble(CTRL ctrl, int data);
bool write(byte b);
//void delayMicroseconds(uint microseconds);
};
bool LcdDisplayI2C::begin(TwoWire* twoWire, int i2cAddress, int rows, int columns)
{
I2C_ASSERT(rows == 1 || rows == 2 || rows == 4);
I2C_ASSERT(columns == 8 || columns == 16 || columns == 20 || columns == 40);
wire = twoWire;
i2cAdds = i2cAddress;
this->rows = rows;
this->columns = columns;
// for 4-line displays, the offset for the last 2 lines depends on characters-per-line
rowOffset = new int[4] { 0x00, 0x40, columns, 0x40 + columns };
// the lcd starts in 8-bit mode
// send several "set 4-bit mode" commands (0x03) with mysterious delays in between
// see LCD documentation
// function set: RS=0; RW=0; 4-bit data=0011
writeNibble(CTRL::NONE, 0);
delay(40);
writeNibble(CTRL::NONE, 0x03);
delay(5);
writeNibble(CTRL::NONE, 0x03);
delay(5);
writeNibble(CTRL::NONE, 0x03);
delay(5);
// set 4-bit mode
writeNibble(CTRL::NONE, 0x02);
delay(1);
// Funtion Set
// configure the LCD for number of lines and font size
// this cannot be changed later
// command = 001dnf00
// d : 1=8-bit, 0=4-bit data
// n : 1=2 lines, 0=1 lines
// f : font size 1=5x11, 0=5x8
// for example:
// n f lines size
// 0 0 1 5x8
// 0 1 1 5x11
// 1 x 2 5x8 <- we use this one
if (!writeCommand(0x28))
return false; // lcd not present or not responding
// Entry Mode Set
// address increments, cursor moves right
entryModeSet(ENTRYMODE::ID);
// set defaults, LCD has no hardware reset
displayOn(true);
clearDisplay();
cursorHome();
cursorBlink(false);
cursorUnderline(false);
backlightOn(true);
return true;
}
// Note: You can't see the display when the backlight is off!
// but this could be used to flash the display
// the backlight on my module draws 10mA
void LcdDisplayI2C::backlightOn(bool backlightOn)
{
// save the bit state, it's written on every command (PCF8574 output P3)
backlight = backlightOn ? CTRL::BL : CTRL::NONE;
// just set/clear the bit
write((byte)backlight);
}
// Clears the display by filling it with spaces
// the cursor position remains unchanged
void LcdDisplayI2C::clearDisplay()
{
writeCommand(0x01);
// command takes 1.53ms
// use delay() because we can't poll the BUSY flag BF
delay(2);
}
// All characters are hidden when the display is off, but nothing else is changed
// this can be used to flash the text on the display
void LcdDisplayI2C::displayOn(bool displayOn)
{
int ctrl = dispCtrl;
if (displayOn)
ctrl |= DISP::DISPON;
else
ctrl &= ~DISP::DISPON;
// save the bit state, it's written on every command
dispCtrl = (DISP)ctrl;
writeCommand(0x08 | ctrl);
}
// Show or hide the static underline cursor '_'
void LcdDisplayI2C::cursorUnderline(bool show)
{
int ctrl = dispCtrl;
if (show)
ctrl |= DISP::CURSOR;
else
ctrl &= ~DISP::CURSOR;
dispCtrl = (DISP)ctrl;
writeCommand(0x08 | ctrl);
}
// Show or hide the blinking box cursor '█'
void LcdDisplayI2C::cursorBlink(bool show)
{
int ctrl = (int)dispCtrl;
if (show)
ctrl |= DISP::BLINK;
else
ctrl &= ~DISP::BLINK;
dispCtrl = (DISP)ctrl;
writeCommand(0x08 | ctrl);
}
// Move cursor to 0, 0
void LcdDisplayI2C::cursorHome()
{
writeCommand(0x02);
// command takes 1.53ms
delay(2);
}
void LcdDisplayI2C::cursorPos(int row, int column)
{
I2C_ASSERT((uint)row < rows && (uint)column < columns);
writeCommand(0x80 | (rowOffset[row] + column));
}
// Entry Mode Set, sets display shift and cursor movement direction
// ENTRYMODE.SH : Bit 0 : Display shift: 0=not shifted; 1=display shifted
// SH=1: ID=1 shift left, ID=0 shift right
// ENTRYMODE.ID : Bit 1 : Cursor movement (DDRAM and CGRAM address):
// 1=increment; 0=decrement
// Note: "Shift mode" means that BOTH lines are shifted, which is probably
// not what you want! I can't think of any use for this strange feature.
// You can do something far better just by re-writing the entire line with
// putString() or putLines().
void LcdDisplayI2C::entryModeSet(ENTRYMODE mode)
{
I2C_ASSERT(((int)mode & 0xfc) == 0);
// command = 000001is
writeCommand(0x04 | (int)mode);
}
// Writes a single character to the given cursor location
// and moves the cursor to the next position
// does not wrap at end of line
void LcdDisplayI2C::putChar(char ch, int row, int column)
{
cursorPos(row, column);
writeData(ch);
}
// Writes a string at the given location
// and moves the cursor the next position
void LcdDisplayI2C::putString(const char* s, int row, int column)
{
cursorPos(row, column);
putString(s);
}
// Writes a single character to the current cursor location
// and moves the cursor to the next position
void LcdDisplayI2C::putChar(char ch)
{
writeData(ch);
}
// Writes a string at the current cursor location
// and moves the cursor to the next position
void LcdDisplayI2C::putString(const char* s)
{
for (int i = 0; s[i] != 0; i++) {
writeData(s[i]);
}
}
/*
// Writes the entire display in one shot
// lines[] array lengths must match the display rows/columns
// the final cursor position is one character beyond the end of the display
void LcdDisplayI2C::putLines(char* lines[])
{
int row = 0;
foreach(char* s in lines) {
I2C_ASSERT(s.Length == columns);
cursorPos(row++, 0);
putString(s);
}
}
*/
// Write programmable character in "character generater RAM" (CGRAM)
// character is assumed to be 5x8: 5-bit data width (0x00..0x1f) x 8 rows
// NOTE: After calling this, you must set the cursor position (DDRAM address)
void LcdDisplayI2C::writeCGRAM(int charIndex, byte charData[8])
{
//TODO check this, 8 rows per character? max. 8 or 16 characters? 5 bits per row?
// set CGRAM address
writeCommand(0x40 | (charIndex * 8));
// write the bitmap, 8 x 5-bit values
for (int i = 0; i < 8; ++i) {
writeData(charData[i] & 0x1f);
}
}
// Internal methods
// "There is madness in his methods" - Liamwil Peareshakes
// Write a command (instruction), RS=0, RW=0
// data = 8-bits, sent as two nibbles, ms-nibble first
bool LcdDisplayI2C::writeCommand(int data)
{
return writeByte(CTRL::NONE, data);
}
bool LcdDisplayI2C::writeData(int data)
{
return writeByte(CTRL::RS, data);
}
bool LcdDisplayI2C::writeByte(CTRL ctrl, int data)
{
return writeNibble(ctrl, data >> 4) && writeNibble(ctrl, data);
}
// Writes 4 bits of data and the control bits (BL RW RS),
// toggles the EN bit and handles the timing.
// The LCD runs in 4-bit mode. To write a byte, this method is called
// twice, first for the MS nibble and again for the LS nibble.
bool LcdDisplayI2C::writeNibble(CTRL ctrl, int data)
{
// get data in bits 7..4
data = (data << 4) & 0xf0;
// merge in the backlight and ctrl values
int txbyte = (int)backlight | (int)ctrl | data;
// set up the data, EN=0
if (!write((byte)txbyte))
return false;
delayMicroseconds(1);
// set EN
if (!write((byte)(txbyte | (int)CTRL::EN)))
return false;
delayMicroseconds(1);
// clear EN
if (!write((byte)txbyte))
return false;
// >37us settling time
delayMicroseconds(40);
return true;
}
// Returns false if failed (LCD not present, no response, ...)
bool LcdDisplayI2C::write(byte b)
{
wire->beginTransmission(i2cAdds);
wire->write(b);
return wire->endTransmission() == 0;
}
/*
// Delay the given number of microseconds
void LcdDisplayI2C::delayMicroseconds(uint microseconds)
{
unsigned long t = micros();
while ((micros() - t) < microseconds) {
yield();
}
}
*/
Rotary Encoder and Encoder Push Button
This code should work with all rotary encoders, with the BTN_ENC, BTN_EN1 and BTN_EN2 pins connected to individual digital inputs. Use pinMode(..., INPUT_PULLUP) if available, or add 10K..100K pull-up resistors to 3.3V or 5V according to your MCU's voltage.
#pragma once
// Rotary encoder
// (on 12866ZW-10 display from old Creality CR-10 V3 printer
// with Creality 3D V2.5.2 mother board)
// Copyright (C) matt@muman.ch, 2025.03.19
/*
This should work for all rotary encoders, with optional push-button.
NOTES
=====
Double-click, long-push and hold-down actions of the push-button
are not implemented because these actions are not "intuitive".
(If you haven't read the 2000 page manual then you won't know about
these non-standard actions. User interfaces should be standard,
obvious and very easy to use.)
// Rotary encoder, on LCD display panel
#define BTN_ENC D6
#define BTN_EN1 D7
#define BTN_EN2 D8
On the Creality board, the ENC signals cannot generate interrupts,
so they must be polled from loop() or by a timer interrupt (say)
every 10ms.
Only call getState() when a menu is active. There is no need to poll
the encoder if it's not active.
POLLED EXAMPLE
==============
#include "RotaryEncoder.h"
RotaryEncoder encoder(BTN_EN1, BTN_EN2, BTN_ENC);
void loop()
{
...
if (menuActive) {
long encoderPosition;
bool buttonPressed;
bool encoderPositionChanged = encoder.getState(&encoderPosition, &buttonPressed);
if (encoderPositionChanged) {
Serial.println(encoderPosition);
...
}
if (buttonPressed) {
Serial.println("button pressed");
...
}
}
...
}
TIMER INTERRUPT EXAMPLE
=======================
//https://github.com/Naguissa/uTimerLib
#include <uTimerLib.h>
#include "RotaryEncoder.h"
RotaryEncoder encoder(BTN_EN1, BTN_EN2, BTN_ENC);
void timerInterrupt()
{
encoder.timerInterrupt();
}
void setup()
{
...
// start the timer interrupt for checking the encoder every 500uS
//TODO in practice, only enable the interrupt when the menu is active
TimerLib.setInterval_us(timerInterrupt, 500);
}
void loop()
{
//TODO enable the timer interrupt only when the menu is active
if (menuActive) {
long encoderPosition;
bool buttonPressed;
bool encoderPositionChanged = encoder.getStateTimer(&encoderPosition, &buttonPressed);
if (encoderPositionChanged) {
Serial.println(encoderPosition);
...
}
if (buttonPressed) {
Serial.println("button pressed");
...
}
}
}
For reference, the Marlin code for the encoder is here:
https://github.com/MarlinFirmware/Marlin/blob/bugfix-2.1.x/Marlin/src/lcd/e3v2/common/encoder.cpp
https://github.com/MarlinFirmware/Marlin/blob/bugfix-2.1.x/Marlin/src/lcd/marlinui.cpp#L1434
See encoderReceiveAnalyze() and get_encoder_delta().
*/
class RotaryEncoder
{
private:
int buttonPin, enc1pin, enc2pin;
// for encoder position
long count = 0;
long lastPosition = 0;
int lastState = 0;
// for button debouncing
bool lastButtonState = false;
bool latchedState = false;
int debounceCounter = 0;
// for timer interrupt
long timerEncoderPos;
bool timerEncoderPosChanged;
bool timerButtonPressed;
// Table for Gray code decoding
const int8_t decodeTable[16] =
{
0, -1, 1, 0,
1, 0, 0, -1,
-1, 0, 0, 1,
0, 1, -1, 0
};
public:
// Number of calls for the push-button debounce delay
// this depends on speed of getState() calls and the quality of the push-button
// e.g. if called every 1ms, a count of 10 gives a 10ms debounce delay
const int debounceCount = 10;
RotaryEncoder(int pinEnc1, int pinEnc2, int pinButton = 0);
bool getState(long* position, bool* buttonPressed);
void reset();
void timerInterrupt();
bool getStateTimer(long* position, bool* buttonPressed);
};
/// Constructor
RotaryEncoder::RotaryEncoder(int pinEnc1, int pinEnc2, int pinButton)
{
enc1pin = pinEnc1;
enc2pin = pinEnc2;
buttonPin = pinButton;
pinMode(pinEnc1, INPUT_PULLUP);
pinMode(pinEnc2, INPUT_PULLUP);
if (pinButton > 0)
pinMode(pinButton, INPUT_PULLUP);
}
void RotaryEncoder::reset()
{
noInterrupts();
lastPosition = 0;
count = 0;
interrupts();
}
/// For timer handling, call timerInterrupt() from a (say) 10ms timer interrupt
/// then call getStateTimer() regularly from a loop. When using the timer, the
/// states are latched until read.
void RotaryEncoder::timerInterrupt()
{
bool pressed = false;
if (getState(&timerEncoderPos, &pressed))
timerEncoderPosChanged = true;
if (pressed)
timerButtonPressed = true;
}
/// Call this to get the results from the timerInterrupt handler
/// States are latched until read.
bool RotaryEncoder::getStateTimer(long* position, bool* buttonPressed)
{
noInterrupts();
*position = timerEncoderPos;
*buttonPressed = timerButtonPressed;
bool posChanged = timerEncoderPosChanged;
// changes are latched until they are read
timerButtonPressed = false;
timerEncoderPosChanged = false;
interrupts();
return posChanged;
}
/// If not using a timer interrupt, call this from loop() at least every 10ms.
/// It returns 'true' if the encoder position has changed, so you don't have
/// to check for changes yourself.
/// It also detects the debounced push-button presses. 'buttonPresssed' is set
/// high just ONCE when the button is pressed. On subsequent calls it is low,
/// even if the button is still depressed. See NOTE in file description.
bool RotaryEncoder::getState(long* position, bool* buttonPressed)
{
*position = lastPosition;
*buttonPressed = false;
// button state, debounce is needed
// we cannot use millis() here because this may be called from an interrupt
// so for timing we must use a 'debounceCounter'
// optional button state is active low, 0 = button pressed
if (buttonPin > 0) {
bool buttonState = !digitalRead(buttonPin);
// button state has changed, restart the debounce counter
if (buttonState != lastButtonState) {
lastButtonState = buttonState;
debounceCounter = debounceCount;
}
// button pressed
if (buttonState) {
// it has just been pressed, register it as pressed immediately
// and start the release debounce counter
if (!latchedState) {
latchedState = true;
*buttonPressed = true;
debounceCounter = debounceCount;
}
}
// button was pressed and now it's released
// it must be released for the debounce count before registering it as released
else if (latchedState) {
if (debounceCounter > 0)
--debounceCounter;
if (debounceCounter == 0) {
latchedState = false;
*buttonPressed = false;
}
}
}
// encoder state
bool en1 = digitalRead(enc1pin);
bool en2 = digitalRead(enc2pin);
int newState = en1 + (en2 << 1);
if (newState == lastState)
return false; // encoder state unchanged
int chg = decodeTable[newState + (lastState << 2)];
lastState = newState;
if (chg == 0)
return false;
count += chg;
long newPosition = count >> 2;
if (newPosition == lastPosition)
return false;
lastPosition = newPosition;
*position = newPosition;
return true; // encoder state has changed
}
SD Card
The official Arduino SD card library is fine. The class below is just a wrapper class that provides the example test() method and a work-around fro the Arduino Zero. It can be modified to add data logging methods, load/save settings, etc. Format the SD card on your PC before using it.
The SD card needs the hardware SPI for speed, so it can't be used at the same time as the ICSP connector (for Atmel-ICE etc.) if you're using ICSP to program the 3D printer controller card.
The on-board LED is usually connected to the SPI clock pin 13 (SCK), so you can't use this LED if you're using SPI.
Using SPI on pins 11/12/13 of the Arduino Zero (updated 2025.11.24)
On the Arduino Zero (SAMD21 MCU), the default SPI channel is wired to the ICSP connector, not to the standard (old) SPI pins 11/12/13. This means that some sketches or libraries which use SPI will not run on the Zero,and some of the SPI shields will not work. The Nucleo-64 STM32 boards use the standard SPI pins, so this problem does not occur.
It also means that the circuits shown above will not work with the Zero (and some other Arduinos) unless you define a new hardware SPI channel which uses pins 11, 12, and 13 - or change the circuit to use the ICSP pins (recommended). Most prototype shields have the ICSP pins, but you usually have to add the female pin header socket. Another problem, on some prototype boards the ICSP connector is wired to pins 11/12/13. You must cut those tracks on the shield board if you want to use ICSP and pins 11/12/13 on an Arduino Zero/Leonardo or similar!
On the Zero, you could define a new SERCOM channel, but the Arduino SD library does not fully support this. I found a workaround by patching out one line of code in the Arduino SD library.
In the code below (SDCard.h), if it's an Arduino Zero _VARIANT_ARDUINO_ZERO_, then a new SPI channel is created (sercom1) which uses pins 11, 12, and 13. The new channel is assigned to the standard 'SPI', and it needs pinPeripheral(..., PIO_SERCOM) calls to make it work.
// if INPUT_PULLUP is not supported, add a 10..100K pull-up to SD_DET
pinMode(sdDetPin, INPUT_PULLUP);
}
#ifdef _VARIANT_ARDUINO_ZERO_
// override SPI so we can use the standard SPI pins 11/12/13
// (the Arduino Zero has the default SPI on the ICSP connector)
mySPI.begin();
SPI = mySPI;
// assign pins 11, 12, 13 to SERCOM functionality
pinPeripheral(11, PIO_SERCOM);
pinPeripheral(12, PIO_SERCOM);
pinPeripheral(13, PIO_SERCOM);
#endif
}
This allows the SD library to use the new 'sercom1' SPI channel on the standard pins. However, the SD library code keeps re-initializing it which [seems to] override the pinPeripheral(..., PIO_SERCOM) settings. I'm probably not understanding what's going on, and maybe the SD library has a better way to do this, but I didn't find it. Please let me know if there is better/correct way to do this, maybe with USE_SPI_LIB or something - but that didn't work for me.
My hack "solution" is to patch out the offending line which re-initializes SPI. Then everything works beautifully.
Find the SD library file Sd2Card.cpp (C:\Users\<user-name>\Documents\Arduino\libraries\SD\src\utility\Sd2Card.cpp) and patch out the line SDCARD_SPI.begin(). Line 283 in my version of the library.
Libraries which use SPI and I2C should always have the channel's initialization done outside the library code, and a pointer to the HardwareSPI or TwoWire object (for example) passed to the library. This is much more flexible, and allows the channel to be shared by other devices.
This code is not finished, and may have other problems, but it should be enough to get started. I'll update it when I develop a real application.
#pragma once
// A wrapper for the Arduino SD Card Library
// muman.ch, 2025.11.21
/*
For details see
https://muman.ch/muman/index.htm?muman-reusing-3d-printer-displays.htm
There's a BIG problem with SPI on the Arduino Zero - it does not use the standard
SPI pins 11/12/13, so most SPI libraries and shields do not work.
But there's a solution...
https://muman.ch/muman/index.htm?muman-reusing-3d-printer-displays.htm#muman-spi-on-the-arduino-zero
Arduino SD card library
https://github.com/arduino-libraries/SD
The SD Library is itself a wrapper for the SDFat library by Bill Greiman
https://github.com/greiman/SdFat
// SAMD21 stuff
https://learn.adafruit.com/using-atsamd21-sercom-to-add-more-spi-i2c-serial-ports/overview
https://forum.arduino.cc/t/clear-explanation-about-how-sd-cards-works-and-how-sd-library-works/1176912/10
NOTES
The SD Card uses the hardware SPI, so it CANNOT be used at the same time as the
ICSP Atmel-ICE.
The built-in LED is on the same output pin as the SPI SCLK signal, so you can't use
the LED and SPI at the same time.
*/
#include <SPI.h>
#include "wiring_private.h" // for pinPeripheral() function
//problem 'extern SDClass SD;' is publicly defined, not good for class inheritance
#include <SD.h>
// New SPI channel for the Arduino Zero
#ifdef _VARIANT_ARDUINO_ZERO_
SPIClassSAMD mySPI(&sercom1, 12, 13, 11, SPI_PAD_0_SCK_1, SERCOM_RX_PAD_3);
#endif
class SDCard : public SDClass
{
private:
uint csPin;
uint sdDetPin;
public:
void begin(uint chipSelectPin, uint sdDetectPin = 0);
bool begin();
// Check the SD_DET signal, if wired
bool cardInSlot() { return sdDetPin ? digitalRead(sdDetPin) == 0 : false; }
bool test();
};
// Initialise SPI
void SDCard::begin(uint chipSelectPin, uint sdDetectPin)
{
csPin = chipSelectPin;
sdDetPin = sdDetectPin;
if (sdDetPin) {
// if INPUT_PULLUP is not supported, add a 10..100K pull-up resistor
pinMode(sdDetPin, INPUT_PULLUP);
}
#ifdef _VARIANT_ARDUINO_ZERO_
// override SPI so we can use the standard SPI pins 11/12/13
// (the Arduino Zero has the default SPI on the ICSP connector)
mySPI.begin();
SPI = mySPI;
// assign pins 11, 12, 13 to SERCOM functionality
pinPeripheral(11, PIO_SERCOM);
pinPeripheral(12, PIO_SERCOM);
pinPeripheral(13, PIO_SERCOM);
#endif
}
// Initialize the SD card
// call whenever a new card is inserted
bool SDCard::begin()
{
return SDClass::begin(csPin);
}
// this is patched out for release version
#ifdef DEBUG
bool SDCard::test()
{
// from examples
// https://docs.arduino.cc/learn/programming/sd-guide/
Sd2Card card;
SdVolume volume;
SdFile root;
Serial.println("\nInitializing SD card...");
// use the initialization code from the utility libraries
// since we're just testing if the card is working
if (!card.init(SPI_HALF_SPEED, csPin)) {
Serial.println("Initialization failed. Things to check:");
Serial.println("* Are you wearing stripey pyjamas?");
Serial.println("* Is a card inserted?");
Serial.println("* Did you patch out the line in Sd2card.cpp if using an Arduino Zero?");
Serial.println("* Is your wiring correct?");
return false;
}
Serial.println("Card present");
// print the type of card
Serial.println();
Serial.print("Card type: ");
switch (card.type()) {
case SD_CARD_TYPE_SD1:
Serial.println("SD1");
break;
case SD_CARD_TYPE_SD2:
Serial.println("SD2");
break;
case SD_CARD_TYPE_SDHC:
Serial.println("SDHC");
break;
default:
Serial.println("Unknown");
}
// try to open the 'volume'/'partition' - it should be FAT16 or FAT32
if (!volume.init(card)) {
Serial.println("Could not find FAT16/FAT32 partition. Is the card formatted?");
return false;
}
Serial.print("Clusters: ");
Serial.println(volume.clusterCount());
Serial.print("Blocks x Cluster: ");
Serial.println(volume.blocksPerCluster());
Serial.print("Total Blocks: ");
Serial.println(volume.blocksPerCluster() * volume.clusterCount());
Serial.println();
// print the type and size of the first FAT-type volume
uint32_t volumesize;
Serial.print("Volume type is: FAT");
Serial.println(volume.fatType(), DEC);
volumesize = volume.blocksPerCluster(); // clusters are collections of blocks
volumesize *= volume.clusterCount(); // we'll have a lot of clusters
volumesize /= 2; // SD card blocks are always 512 bytes (2 blocks are 1KB)
Serial.print("Volume size (KB): ");
Serial.println(volumesize);
Serial.print("Volume size (MB): ");
volumesize /= 1024;
Serial.println(volumesize);
Serial.print("Volume size (GB): ");
Serial.println((float)volumesize / 1024.0);
Serial.println("\nFiles found on the card (name, date and size in bytes): ");
// SdFat library
root.openRoot(volume);
// list all files in the card with date and size
root.ls(LS_R | LS_DATE | LS_SIZE);
root.close();
Serial.println("");
Serial.flush();
return true;
}
#endif
Push-button Debouncer
If the Reset or Kill buttons (normally open, GND when pressed) are connected to inputs, they will need 10K pull-ups to 3.3V or 5V, or use pinMode(..., INPUT_PULLUP) if available. The inputs must be debounced by the software to prevent multiple activations. Code like this can be used.
#pragma once
// Switch Debouncer
// Copyright (C) muman.ch, 2025.03.21
/*
Use this for push buttons or end-stop switches.
Microswitches must ALWAYS be debounced! Infrared speed sensors may not need it
if they use a comparator with hysterests, like an LM393.
The code registers the switch activation (closing) immediately, which is great
for emergency switches. It is only the decativation (opening) of the switch
that is delayed by the debounceCount.
It works with active high or active low switches, accoring to 'activeState'.
The 'debounceCount' is the number of calls for which the state must be unchanged
before it registers as inactive. For example, if called every 2mS, use '5' for a
10mS debounce delay.
bool getState(bool* newState) returns true if *newState has changed, false if
unchanged, so you don't have to check for state changes yourself.
*/
/// Use this for push buttons or end-stop switches.
class DebouncedSwitch
{
private:
int pin;
int debCount;
bool actState;
bool firstCall;
int debCounter;
bool lastState;
bool latchedState;
public:
DebouncedSwitch(int switchPin, int debounceCount, bool activeState);
bool getState(bool* newState);
};
// Constructicator
DebouncedSwitch::DebouncedSwitch(int switchPin, int debounceCount, bool activeState)
{
pin = switchPin;
pinMode(pin, INPUT); // or INPUT_PULLUP to save a resistor
debCount = debounceCount;
actState = activeState;
firstCall = true;
lastState = false;
latchedState = false;
}
/// Returns true if *newState has changed, false if unchanged
bool DebouncedSwitch::getState(bool* newState)
{
*newState = lastState;
bool state = digitalRead(pin);
if (!actState) // active low
state = !state;
// register current state on first call
if (firstCall) {
firstCall = false;
lastState = !state;
latchedState = lastState;
}
// state has changed
if (state != lastState) {
// restart the debounce counter
debCounter = debCount;
lastState = state;
}
// switch closed
if (state) {
// if it has just been closed, register it as closed immediately
// and start the release debounce counter
if (!latchedState) {
latchedState = true;
debCounter = debCount;
*newState = true; // switch active
return true; // state has changed
}
}
// switch was closed and now it's open
// it must be open for the debounceCount before registering it as open
else if (latchedState) {
if (debCounter > 0)
--debCounter;
if (debCounter <= 0) {
latchedState = false;
*newState = false; // switch inactive
return true; // state has changed
}
}
return false; // state unchanged
}
Or here's a version that uses the hardware timer directly, intuitively called 'Debouncer2'... Debouncer2.h
Example Sketches
Here are two sketches. One for the graphical 128x64 display and one for the 2x20 character display.
// 3D Printer Display Panel Demo
// for 128x64 graphics panel with rotary encoder and SD card
// muman.ch, 2025.11.22
//
// For details see
// https://muman.ch/muman/index.htm?muman-reusing-3d-printer-displays.htm
// Libraries used
#include <SD.h>
#include <Wire.h>
#include <SPI.h>
// Enable debug features
#define DEBUG
// Digital outputs used by the panel
#define LCD_CS 2 // } software SPI for 128x64 LCD
#define LCD_SCK 3 // }
#define LCD_MOSI 4 // }
#define BEEPER 5
#define BTN_ENC 6 // Encoder Push Button
#define BTN_EN1 7 // Encoder 1
#define BTN_EN2 8 // Encoder 2
#define SD_CSEL 10 // SPI SD chip select
#define SD_DET 9 // SD detect, active low
// Example bitmaps
// (you can probably create better designs)
// using 'const' replaces PROGMEM, but maybe not on all platforms
// the bitmap max. width is 8, 16 or 32
// the height can be up to 64 rows
const byte bmp8[] =
{
0b00001000,
0b00011100,
0b00111110,
0b01111111,
0b00111110,
0b00011100,
0b00001000
};
const ushort bmp16[] =
{
0b1000000000010010,
0b0100000000100010,
0b0010000001000010,
0b0001000010000010,
0b0000100100000010,
0b0000011000000010,
0b0000011000000010,
0b0000100100000010,
0b0001000010000010,
0b0010000001000010,
0b0100000000100010,
0b1000000000010010
};
const ulong bmp32[12] =
{
// 12345678901234567890123456789012
0b10000000000100101000000000010010,
0b01000000001000100100000000100010,
0b00100000010000100010000001000010,
0b00010000100000100001000010000010,
0b00001001000000100000100100000010,
0b00000110000000100000011000000010,
0b00000110000000100000011000000010,
0b00001001000000100000100100000010,
0b00010000100000100001000010000010,
0b00100000010000100010000001000010,
0b01000000001000100100000000100010,
0b10000000000100101000000000010010
};
// An Adafruit font
// see https://github.com/adafruit/Adafruit-GFX-Library/tree/master/Fonts
#define FONT_NAME FreeSans9pt7b
#include "FreeSans9pt7b.h"
// 128x64 LCD
#define LCD12864_CP437 // enable the CP437 5x7 font
#include "Lcd12864.h"
Lcd12864 lcd;
// SD card
#include "SDCard.h"
SDCard sdcard;
// Rotary encoder
// just to confuse you, this uses a constructor instead of begin()
#include "RotaryEncoder.h"
RotaryEncoder encoder(BTN_EN1, BTN_EN2, BTN_ENC);
void setup()
{
// for debug output
Serial.begin(115200);
// the beeper is on an output, make sure it's off
pinMode(BEEPER, OUTPUT);
digitalWrite(BEEPER, 0);
delay(1000);
// configure the software SPI
lcd.begin(LCD_SCK, LCD_MOSI, LCD_CS);
// select the large font
lcd.setFont(&FONT_NAME);
// draw some shapes
lcd.drawRectangle(0, 0, lcd.xPixels - 1, lcd.yPixels - 1);
// add some text
lcd.drawCP437Text("Hello, cruel world", 10, 6);
const char* s = "Large Font";
uint swidth, sheight;
lcd.getTextBounds(s, &swidth, &sheight);
uint sx = (lcd.xPixels - swidth) / 2;
uint sy = sheight + 13;
lcd.drawText("Large Font", sx, sy);
// draw a bitmap
lcd.drawBitmap32(bmp32, 8, sy + 12, 32, sizeof(bmp32) / sizeof(ulong));
// draw a circle
lcd.drawCircle(110, 47, 10);
// update the display's graphics data ram
lcd.writeGDram();
// SD card, patch this out if the SD card is not present
sdcard.begin(SD_CSEL, SD_DET);
sdcard.test();
#if 0
if (sdcard.begin()) {
// SD card read file example
// change 'this'filename' to the name of a text file on the SD card
const char* filename = "PANGOLIN.GCO";
int lineCounter = 100;
byte buf[100];
Serial.println(filename);
File file = sdcard.open(filename);
while (1) {
int n = file.readBytes(buf, sizeof(buf));
if (n <= 0)
break;
for (int i = 0; i < n; ++i) {
char ch = (char)(buf[i]);
Serial.print(ch);
if (ch == '\n')
Serial.print('\r');
if (lineCounter-- == 0)
break;
}
Serial.flush();
if (n < sizeof(buf) || lineCounter == 0)
break;
}
file.close();
Serial.println();
}
#endif
}
long tlast = 0;
long tbutton = 0;
int lastSdDet = -1;
void loop()
{
bool sdDet = !digitalRead(SD_DET);
if (sdDet != lastSdDet) {
lastSdDet = sdDet;
//TODO SD card inserted/removed
}
// poll the rotary encoder every millisecond
// steps will be missed if it's polled too slowly,
// depending on the MCU speed and loop timing
// normally a hardware timer should be used
ulong tnow = micros();
if ((tnow - tlast) >= 1000) {
tlast = tnow;
long position;
bool buttonPressed;
if (encoder.getState(&position, &buttonPressed)) {
// display the new rotary position
char buf[32];
sprintf(buf, "%ld", position);
uint width = 36;
uint height = 8;
uint x = lcd.xPixels / 2;
uint y = 45;
// clear the background before drawing the text
lcd.clearRectangle(x, y, width, height);
// the text is drawn by inverting the background
lcd.drawCP437Text(buf, x, y);
// update only the rectangle that has changed
lcd.writeGDramRectangle(x, y, width, height);
}
// turn on beeper when button is pressed
if (buttonPressed) {
digitalWrite(BEEPER, true);
tbutton = tnow;
}
}
// turn off button pressed beeper after a few milliseconds
if (tbutton != 0) {
if ((tnow - tbutton) > 20000) {
digitalWrite(BEEPER, false);
tbutton = 0;
}
}
}
When you run the 128x64 example, you should see the display shown below. A rectangle is drawn around the edge, then a line of small 5x7 CP437 text followed by a 'Large Font' text using an Adafruit GFX 'FreeSans9pt7b' font. Bottom left is a 32-bit bitmap. Bottom right is a circle. Between these is the rotary encoder position, which is updated when the rotor is turned (only the modified area of the display is refreshed). The encoder value should decrease when turned anti-clockwise, and increase when turned clockwise. If it's the other way round, just swap the BTN_EN1 and BTN_EN2 pins in the lcd.begin() call - the polarity does vary between board models. Pressing the encoder button sounds the beeper for a few milliseconds.
// 3D Printer Display Panel Demo
// for the 4x20 character display panel with rotary encoder and SD card
// muman.ch, 2025.11.19
//
// For details see
// https://muman.ch/muman/index.htm?muman-reusing-3d-printer-displays.htm
#include <Wire.h>
#include <SPI.h>
// Enable debug features
#define DEBUG
// Digital outputs used by the panel
// remove the 'D' for Arduinos
#define BTN_ENC D6 // Encoder Push Button
#define BTN_EN1 D7 // Encoder 1
#define BTN_EN2 D8 // Encoder 2
#define SD_CSEL D10 // SPI SD chip select
#define SD_DET D9 // SD detect, active low
// the beeper is connected to the backlight output
#define beeperOn backlightOn
// 4x20 character display
#include "LcdDisplayI2C.h"
LcdDisplayI2C lcd;
// SD card
#include "SDCard.h"
SDCard sdcard;
// Rotary encoder
// just to confuse you, this uses a constructor instead of begin()
#include "RotaryEncoder.h"
RotaryEncoder encoder(BTN_EN1, BTN_EN2, BTN_ENC);
// Character bitmaps 5x8 for programmable characters 0..7
// Box drawing characters
const byte ch0[8] =
{
0b00000,
0b00000,
0b00000,
0b00111,
0b00100,
0b00100,
0b00100,
0b00100
};
const byte ch1[8] =
{
0b00000,
0b00000,
0b00000,
0b11100,
0b00100,
0b00100,
0b00100,
0b00100
};
const byte ch2[8] =
{
0b00100,
0b00100,
0b00100,
0b00111,
0b00000,
0b00000,
0b00000,
0b00000
};
const byte ch3[8] =
{
0b00100,
0b00100,
0b00100,
0b11100,
0b00000,
0b00000,
0b00000,
0b00000
};
const byte ch4[8] =
{
0b00000,
0b00000,
0b00000,
0b11111,
0b00000,
0b00000,
0b00000,
0b00000
};
const byte ch5[8] =
{
0b00100,
0b00100,
0b00100,
0b00100,
0b00100,
0b00100,
0b00100,
0b00100
};
const byte ch6[8] =
{
0b00100,
0b00100,
0b00100,
0b11111,
0b00000,
0b00000,
0b00000,
0b00000
};
const byte ch7[8] =
{
0b00000,
0b00000,
0b00000,
0b11111,
0b00100,
0b00100,
0b00100,
0b00100
};
void setup()
{
// for debug output
Serial.begin(115200);
// configure I2C and lcd display
// the PCF8574 address pins A3..0 are all connected to GND,
// so the I2C address is 0x20
Wire.begin();
Wire.setClock(100000);
Wire.setTimeout(200);
lcd.begin(&Wire, 0x20, 4, 20);
// the beeper is on the backlight output of the I2C I/O expander
lcd.beeperOn(false); // backlightOn()
delay(1000);
// display some texts
lcd.putString("Hello, World", 0, 0);
lcd.putString("Box Chars: ", 1, 0);
lcd.putString("Encoder Pos: 0", 3, 0);
// program the Character Generator RAM
// for character codes 0..7 with 5x8 bit box characters
lcd.writeCGRam(0, ch0);
lcd.writeCGRam(1, ch1);
lcd.writeCGRam(2, ch2);
lcd.writeCGRam(3, ch3);
lcd.writeCGRam(4, ch4);
lcd.writeCGRam(5, ch5);
lcd.writeCGRam(6, ch6);
lcd.writeCGRam(7, ch7);
// display the programmed CG RAM box characters 0x00..0x07
// NOTE: character code 0x00 can't be used with putString()
// because 0x00 (NUL) is the string terminator character
lcd.putChar(0x00, 1, 11);
lcd.putString("\x01\x02\x03\x04\x05\x06\x07", 1, 12);
// SD card test
// patch this out if the SD card is not present
sdcard.begin(SD_CSEL);
pinMode(SD_DET, INPUT_PULLUP);
sdcard.test();
#if 0
// SD card read file example
if (sdcard.begin(SD_CSEL)) {
// change 'this'filename' to the name of a text file on the SD card
const char* filename = "PANGOLIN.GCO";
Serial.println(filename);
File file = sdcard.open(filename);
while (1) {
int n = file.readBytes(buf, sizeof(buf));
if (n <= 0)
break;
for (int i = 0; i < n; ++i) {
char ch = (char)(buf[i]);
Serial.print(ch);
if (ch == '\n')
Serial.print('\r');
}
Serial.flush();
if (n < sizeof(buf))
break;
}
file.close();
}
#endif
}
ulong tlast1 = 0;
ulong tlast2 = 0;
byte ch = 0;
void loop()
{
// poll the rotary encoder every millisecond
// steps will be missed if it's polled too slowly,
// depending on the MCU speed and loop timing
// normally a hardware timer should be used
ulong tnow = micros();
if ((tnow - tlast1) >= 1000) {
tlast1 = tnow;
long position;
bool buttonPressed;
if (encoder.getState(&position, &buttonPressed)) {
// display the new rotary position
char buf[32];
sprintf(buf, "%ld ", position);
lcd.putString(buf, 3, 13);
}
if (buttonPressed) {
//TODO button pressed (already debounced)
}
}
// display entire character set, 20 characters at a time
// update every 2 seconds
if ((tnow - tlast2) >= 2000000) {
tlast2 = tnow;
lcd.cursorPos(2, 0);
for (int i = 0; i < 20; ++i) {
lcd.putChar(ch++);
if (ch == 0)
break;
}
}
}
This is what you should see when you run the 4x20 example. There are 8 user-programmable characters which are programmed as box-drawing characters in the example. These are displayed on line 2. Line 3 shows the entire character set, characters 0x00..0xFF, 20 at a time with a 2 second delay between updates. Line 4 shows the rotary encoder position. Turn anti-clockwise to decrease, clockwise to increase. Pressing the encoder button sounds the beeper for a few milliseconds.
Adafruit Font Converter
This Adafruit application converts any TrueType font file (.ttf) to an Adafruit font bitmap file (.h) with a given point size. It is the Adafruit 'fontconvert' project, which I built for Windows. It is a console application which you must run from the Windows command prompt (cmd).
It works very well for sizes down to 8pt, but below that the characters might look rather strange. For small 5x8 characters I recommend the familiar CP437 font.
Run fontconvert.exe from the cmd prompt, providing the .ttf file path and the point size, e.g.
This will output the C code for the font bitmaps (the .h include file) to the console, from where you can copy/paste it into a fontname.h file and add it to your project or sketch. You may need to modify the font and array names because it uses the ttf file name, not the font name. Piping the output directly to a file (using '> filename.h') does not seem to work on Windows. But that could be something to do with my build, I'll try to fix that later.
Windows will do an anti-virus scan (the website's service provider does that too). But as usual, Windows won't like it because it's not digitally signed, so press 'More Info' and 'Run Anyway'.
I could not find any other downloads of Windows versions of this application, so either use this one, or build it yourself from the source code in github.
Code Page 437 DOS Font
This font is supported by the Mattlabs library, see fontCP437.h and the drawCP437Char() and drawCP437Text() methods. https://en.wikipedia.org/wiki/Code_page_437
RepRap means "REProduce RAPidly", meaning "rapid prototyping machine"
This was one of the first 3D printer designs, on which most of the current designs are based https://reprap.org/wiki/RepRap
The 'u8g2' menu library
This seems to be popular, but it does not use the rotary encoder. I think it could be modified to use rotate-left as the Up Arrow key, rotate-right as Down Arrow key, and the button press as Enter. The Reset and Kill buttons (if present) could be used for Left and Right. Or you could add you own very cheap touch pad, see TTP229 Touch Pad. https://github.com/olikraus/u8g2
The Marlin RepRap 3D printer firmware
This has code for the displays, rotary encoder, menus, etc. It can be a useful reference. https://github.com/MarlinFirmware/Marlin
Faster digital I/O
This will speed up the graphics writing routines. I'll do an update for this later.