ESP32: How to create a Web Server to control a 16×2 LCD Display

ESP32 Tutorial: Web Server for 16×2 LCD Display (4-Bit Parallel)

Abstract

This tutorial details how to interface a standard 16×2 LCD (HD44780) with the ESP32 using the 4-bit parallel mode (without an I²C adapter) and control the displayed text via a simple web server. The ESP32 hosts an HTML form where a user can input two lines of text, which are then sent to the display. This showcases direct GPIO control of a common display module combined with the ESP32’s Wi-Fi capability.

1. Introduction to LCD Display 16x2

Integrating physical displays with Internet-connected devices is a cornerstone of the Internet of Things (IoT). The 16×2 Character LCD (HD44780) remains one of the most common and robust display modules in embedded electronics due to its simplicity and low cost.

This tutorial focuses on an intermediate-level integration, demonstrating how to merge the ESP32’s powerful Wi-Fi capabilities with direct hardware control of the LCD. Instead of relying on the simpler, but often slower, I²C adapter, we utilize the 4-bit parallel mode. While this requires slightly more complex wiring (using 6 dedicated GPIO pins), it provides direct, low-level control and ensures maximum compatibility and performance using the standard LiquidCrystal library.

The final application turns the ESP32 into a Web Server, allowing remote users to instantly change the text displayed on the physical LCD. A user simply navigates to the ESP32’s IP address, enters two lines of text into an HTML form, and submits the data via HTTP POST. This showcases a practical, real-world application of the ESP32’s ability to seamlessly bridge web-based input with physical output devices.

2. Prerequisites and Wiring

2.1 Hardware and Library

  • Hardware: ESP32 Dev Board, 16×2 Character LCD (HD44780), 10 kΩ Potentiometer (for contrast), Breadboard.
  • Library: The standard LiquidCrystal Library is used, which is included in the Arduino IDE.

2.2 ESP32-to-LCD Wiring (4-Bit Mode)

This mode requires 6 data/control lines plus power and ground. The potentiometer controls the contrast (V0).

LCD Pin

Function

Connection

Notes

VSS

Ground

GND

 

VDD

Power

5V (from ESP32 or external)

The LCD often prefers 5V power.

V0

Contrast

Middle pin of 10 kΩ pot

Other pot pins to 5V and GND.

RS

Register Select

GPIO 14

Control pin: High=Data, Low=Instruction.

RW

Read/Write

GND

We only write data, so tie to GND.

E

Enable

GPIO 12

Control pin: Clocks data in/out.

D4

Data Bit 4

GPIO 27

4-bit parallel data lines.

D5

Data Bit 5

GPIO 26

 

D6

Data Bit 6

GPIO 25

 

D7

Data Bit 7

GPIO 33

 

A/LED+

Backlight Anode

5V (or 3.3V)

Often connected via a resistor (e.g., 220Ω).

K/LED-

Backlight Cathode

GND

 

 

3. The ESP32 Arduino Sketch

The web server handles a POST request from the HTML form to extract the two text lines and send them to the LCD using the LiquidCrystal library.

3.1 Libraries and Global Variables

				
					#include <WiFi.h>
#include <WebServer.h>
#include <LiquidCrystal.h>

// Replace with your network credentials
const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";

WebServer server(80);

// Initialize the LiquidCrystal library with the pin numbers
// LiquidCrystal(rs, enable, d4, d5, d6, d7)
LiquidCrystal lcd(14, 12, 27, 26, 25, 33); 

// Global variables to hold the displayed text
String line1 = "ESP32 LCD Server";
String line2 = "Status: Online";

				
			

3.2 LCD Handler Function

This function prints the stored global strings to the LCD display.

				
					void updateLCD() {
  lcd.clear();
  lcd.setCursor(0, 0); // Start of line 1 (Row 0)
  lcd.print(line1);
  lcd.setCursor(0, 1); // Start of line 2 (Row 1)
  lcd.print(line2);
  Serial.print("LCD Updated: Line 1: '");
  Serial.print(line1);
  Serial.print("', Line 2: '");
  Serial.print(line2);
  Serial.println("'");
}

				
			

3.3 Web Server Handlers

The handleFormPost() is the core function that retrieves the user’s input from the HTTP request and calls updateLCD().

				
					// Handler for the root web page (HTML form)
void handleRoot() {
  server.send(200, "text/html", getHtmlPage());
}

// Handler for the POST request when the user submits the form
void handleFormPost() {
  // Check if both fields exist in the POST request arguments
  if (server.hasArg("line_1") && server.hasArg("line_2")) {
    // Retrieve and store the new strings (limit to 16 characters for the display)
    line1 = server.arg("line_1").substring(0, 16);
    line2 = server.arg("line_2").substring(0, 16);
    
    // Update the physical LCD display immediately
    updateLCD();
    
    // Redirect back to the main page to show the updated status
    server.sendHeader("Location", "/");
    server.send(303); // HTTP 303: See Other (redirect)
  } else {
    server.send(400, "text/plain", "Invalid request. Missing line arguments.");
  }
}

				
			

3.4 HTML Generation

The HTML includes a form with two input fields that submit data to the /submit endpoint using the POST method.

				
					String getHtmlPage() {
  String html = R"rawliteral(
<!DOCTYPE HTML>
<html>
<head>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    /* New Color Scheme based on user request:
     * Accent Color: #00f5a0 (Vibrant Green)
     * Background Color: #121212 (Dark Gray/Black)
     * Text Color: #d1d5db (Light Gray)
     */
    :root {
      --bg-color: #121212;
      --accent-color: #00f5a0;
      --text-color: #d1d5db;
      --container-bg: #1e1e1e; /* Slightly lighter dark gray for container depth */
    }

    body {
      font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
      text-align: center;
      margin-top: 50px;
      background-color: var(--bg-color);
      color: var(--text-color);
      display: flex;
      flex-direction: column;
      align-items: center;
    }

    /* Logo Image Styling */
    .logo {
        width: 250px; /* Adjust size as needed */
        height: auto;
        margin-bottom: 25px;
    }
   
    .container {
      background-color: var(--container-bg);
      padding: 30px;
      border-radius: 10px;
      /* Green glow box shadow */
      box-shadow: 0 0 20px rgba(0, 245, 160, 0.4);
      display: inline-block;
      min-width: 400px;
    }

    h1 {
      color: var(--accent-color);
      font-weight: 300;
      margin-bottom: 25px;
    }

    p {
        color: var(--text-color);
    }

    form {
      display: flex;
      flex-direction: column;
      align-items: center;
    }

    label {
      margin-top: 15px;
      font-weight: bold;
      color: var(--text-color);
      align-self: flex-start;
      margin-left: 50px;
    }

    input[type="text"] {
      width: 300px;
      padding: 10px;
      margin-top: 5px;
      border: 1px solid var(--accent-color);
      border-radius: 5px;
      box-sizing: border-box;
      background-color: #333333; /* Darker input background */
      color: var(--text-color);
    }
   
    input[type="text"]:focus {
        border-color: var(--accent-color);
        box-shadow: 0 0 8px rgba(0, 245, 160, 0.6);
        outline: none;
    }

    input[type="submit"] {
      background-color: var(--accent-color);
      color: var(--bg-color); /* Dark text on bright button */
      padding: 12px 25px;
      border: none;
      border-radius: 5px;
      cursor: pointer;
      margin-top: 30px;
      font-size: 1.1em;
      font-weight: bold;
      transition: background-color 0.3s, transform 0.2s;
    }

    input[type="submit"]:hover {
      background-color: #00c781; /* Slightly darker accent on hover */
      transform: translateY(-2px);
    }

    .display-box {
      border: 2px solid var(--accent-color);
      padding: 15px;
      margin: 20px auto;
      background-color: #333333; /* Darker background for the display box */
      border-radius: 5px;
      width: 300px;
      text-align: left;
    }
   
    .display-box strong {
        color: var(--accent-color);
    }
   
    .ip-address {
        margin-top: 30px;
        color: #999999;
        font-size: 0.9em;
    }
  </style>
  <title>ESP32 LCD Controller</title>
</head>
<body>
  <img decoding="async" class="logo" src="https://raw.githubusercontent.com/hackerembedded/STM32/main/Logo%20Hacker%20Embedded.png" alt="Hacker Embedded Logo">
 
  <div class="container">
    <h1>LCD Web Interface</h1>
    <p>Current Text on Display:</p>
    <div class="display-box">
        <strong>Line 1:</strong> <span style="color:var(--text-color);">LINE_1_CURRENT</span><br>
        <strong>Line 2:</strong> <span style="color:var(--text-color);">LINE_2_CURRENT</span>
    </div>

    <form method="POST" action="/submit">
      <label for="line_1">Line 1 (Max 16 Chars):</label>
      <input type="text" id="line_1" name="line_1" maxlength="16" value="LINE_1_CURRENT" required>
     
      <label for="line_2">Line 2 (Max 16 Chars):</label>
      <input type="text" id="line_2" name="line_2" maxlength="16" value="LINE_2_CURRENT" required>
     
      <input type="submit" value="Update LCD">
    </form>
    <p class="ip-address">ESP32 IP: IP_ADDRESS</p>
  </div>
</body>
</html>
)rawliteral";
  // Replace placeholders with current data
  html.replace("LINE_1_CURRENT", line1);
  html.replace("LINE_2_CURRENT", line2);
  html.replace("IP_ADDRESS", WiFi.localIP().toString());

  return html;
}

				
			

3.5 Setup and Loop

				
					void setup() {
  Serial.begin(115200);
  
  // 1. Setup LCD
  lcd.begin(16, 2); // Initialize the 16x2 display
  updateLCD();      // Print the initial message

  // 2. Connect to Wi-Fi
  WiFi.begin(ssid, password);
  Serial.print("Connecting to WiFi...");
  while (WiFi.status() != WL_CONNECTED) {
    delay(1000);
    Serial.print(".");
  }
  Serial.println("\nWiFi connected.");
  Serial.print("Web Server IP: ");
  Serial.println(WiFi.localIP());

  // 3. Setup Web Server Routes
  server.on("/", HTTP_GET, handleRoot);
  server.on("/submit", HTTP_POST, handleFormPost);
  server.begin();
}

void loop() {
  // Must continuously handle incoming web requests
  server.handleClient();
}

				
			

4. Source Code

The entire code for an easier copy and paste:

				
					#include <WiFi.h>
#include <WebServer.h>
#include <LiquidCrystal.h>

// Replace with your network credentials
const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";

WebServer server(80);

// Initialize the LiquidCrystal library with the pin numbers
// LiquidCrystal(rs, enable, d4, d5, d6, d7)
LiquidCrystal lcd(14, 12, 27, 26, 25, 33);

// Global variables to hold the displayed text
String line1 = "ESP32 LCD Server";
String line2 = "Status: Online";
void updateLCD() {
  lcd.clear();
  lcd.setCursor(0, 0); // Start of line 1 (Row 0)
  lcd.print(line1);
  lcd.setCursor(0, 1); // Start of line 2 (Row 1)
  lcd.print(line2);
  Serial.print("LCD Updated: Line 1: '");
  Serial.print(line1);
  Serial.print("', Line 2: '");
  Serial.print(line2);
  Serial.println("'");
}
// Handler for the root web page (HTML form)
void handleRoot() {
  server.send(200, "text/html", getHtmlPage());
}

// Handler for the POST request when the user submits the form
void handleFormPost() {
  // Check if both fields exist in the POST request arguments
  if (server.hasArg("line_1") && server.hasArg("line_2")) {
    // Retrieve and store the new strings (limit to 16 characters for the display)
    line1 = server.arg("line_1").substring(0, 16);
    line2 = server.arg("line_2").substring(0, 16);
   
    // Update the physical LCD display immediately
    updateLCD();
   
    // Redirect back to the main page to show the updated status
    server.sendHeader("Location", "/");
    server.send(303); // HTTP 303: See Other (redirect)
  } else {
    server.send(400, "text/plain", "Invalid request. Missing line arguments.");
  }
}

String getHtmlPage() {
  String html = R"rawliteral(
<!DOCTYPE HTML>
<html>
<head>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    /* New Color Scheme based on user request:
     * Accent Color: #00f5a0 (Vibrant Green)
     * Background Color: #121212 (Dark Gray/Black)
     * Text Color: #d1d5db (Light Gray)
     */
    :root {
      --bg-color: #121212;
      --accent-color: #00f5a0;
      --text-color: #d1d5db;
      --container-bg: #1e1e1e; /* Slightly lighter dark gray for container depth */
    }

    body {
      font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
      text-align: center;
      margin-top: 50px;
      background-color: var(--bg-color);
      color: var(--text-color);
      display: flex;
      flex-direction: column;
      align-items: center;
    }

    /* Logo Image Styling */
    .logo {
        width: 250px; /* Adjust size as needed */
        height: auto;
        margin-bottom: 25px;
    }
   
    .container {
      background-color: var(--container-bg);
      padding: 30px;
      border-radius: 10px;
      /* Green glow box shadow */
      box-shadow: 0 0 20px rgba(0, 245, 160, 0.4);
      display: inline-block;
      min-width: 400px;
    }

    h1 {
      color: var(--accent-color);
      font-weight: 300;
      margin-bottom: 25px;
    }

    p {
        color: var(--text-color);
    }

    form {
      display: flex;
      flex-direction: column;
      align-items: center;
    }

    label {
      margin-top: 15px;
      font-weight: bold;
      color: var(--text-color);
      align-self: flex-start;
      margin-left: 50px;
    }

    input[type="text"] {
      width: 300px;
      padding: 10px;
      margin-top: 5px;
      border: 1px solid var(--accent-color);
      border-radius: 5px;
      box-sizing: border-box;
      background-color: #333333; /* Darker input background */
      color: var(--text-color);
    }
   
    input[type="text"]:focus {
        border-color: var(--accent-color);
        box-shadow: 0 0 8px rgba(0, 245, 160, 0.6);
        outline: none;
    }

    input[type="submit"] {
      background-color: var(--accent-color);
      color: var(--bg-color); /* Dark text on bright button */
      padding: 12px 25px;
      border: none;
      border-radius: 5px;
      cursor: pointer;
      margin-top: 30px;
      font-size: 1.1em;
      font-weight: bold;
      transition: background-color 0.3s, transform 0.2s;
    }

    input[type="submit"]:hover {
      background-color: #00c781; /* Slightly darker accent on hover */
      transform: translateY(-2px);
    }

    .display-box {
      border: 2px solid var(--accent-color);
      padding: 15px;
      margin: 20px auto;
      background-color: #333333; /* Darker background for the display box */
      border-radius: 5px;
      width: 300px;
      text-align: left;
    }
   
    .display-box strong {
        color: var(--accent-color);
    }
   
    .ip-address {
        margin-top: 30px;
        color: #999999;
        font-size: 0.9em;
    }
  </style>
  <title>ESP32 LCD Controller</title>
</head>
<body>
  <img decoding="async" class="logo" src="https://raw.githubusercontent.com/hackerembedded/STM32/main/Logo%20Hacker%20Embedded.png" alt="Hacker Embedded Logo">
 
  <div class="container">
    <h1>LCD Web Interface</h1>
    <p>Current Text on Display:</p>
    <div class="display-box">
        <strong>Line 1:</strong> <span style="color:var(--text-color);">LINE_1_CURRENT</span><br>
        <strong>Line 2:</strong> <span style="color:var(--text-color);">LINE_2_CURRENT</span>
    </div>

    <form method="POST" action="/submit">
      <label for="line_1">Line 1 (Max 16 Chars):</label>
      <input type="text" id="line_1" name="line_1" maxlength="16" value="LINE_1_CURRENT" required>
     
      <label for="line_2">Line 2 (Max 16 Chars):</label>
      <input type="text" id="line_2" name="line_2" maxlength="16" value="LINE_2_CURRENT" required>
     
      <input type="submit" value="Update LCD">
    </form>
    <p class="ip-address">ESP32 IP: IP_ADDRESS</p>
  </div>
</body>
</html>
)rawliteral";
  // Replace placeholders with current data
  html.replace("LINE_1_CURRENT", line1);
  html.replace("LINE_2_CURRENT", line2);
  html.replace("IP_ADDRESS", WiFi.localIP().toString());

  return html;
}

void setup() {
  Serial.begin(115200);
 
  // 1. Setup LCD
  lcd.begin(16, 2); // Initialize the 16x2 display
  updateLCD();      // Print the initial message

  // 2. Connect to Wi-Fi
  WiFi.begin(ssid, password);
  Serial.print("Connecting to WiFi...");
  while (WiFi.status() != WL_CONNECTED) {
    delay(1000);
    Serial.print(".");
  }
  Serial.println("\nWiFi connected.");
  Serial.print("Web Server IP: ");
  Serial.println(WiFi.localIP());

  // 3. Setup Web Server Routes
  server.on("/", HTTP_GET, handleRoot);
  server.on("/submit", HTTP_POST, handleFormPost);
  server.begin();
}

void loop() {
  // Must continuously handle incoming web requests
  server.handleClient();
}

				
			

4.1 Code Explanation and Logic Breakdown

The code combines three main elements: the LiquidCrystal library for hardware control, the WebServer for networking, and a POST handler to synchronize the two.

4.1.1 LCD and Library Initialization

The LiquidCrystal library manages the complex timing and data/instruction sequencing required by the HD44780 controller, simplifying the process into simple print commands.

  • Initialization: The LiquidCrystal lcd(...) object is instantiated in global scope, mapping the library’s required control and data lines to the chosen ESP32 GPIO pins:

				
					// LiquidCrystal(rs, enable, d4, d5, d6, d7)
LiquidCrystal lcd(14, 12, 27, 26, 25, 33);
				
			
  • Setup: In setup(), the lcd.begin(16, 2); function initializes the display geometry (16 columns, 2 rows).

  • Text Storage: Two global String variables (line1 and line2) hold the current text, ensuring the display is always refreshed from a known state.

4.1.2 The LCD Update Function (updateLCD)

This helper function encapsulates the display logic, making it easy to call whenever the text needs changing.

				
					void updateLCD() {
    lcd.clear();
    lcd.setCursor(0, 0); // Row 0
    lcd.print(line1);
    lcd.setCursor(0, 1); // Row 1
    lcd.print(line2);
    // ... (Serial output for debugging)
}
				
			
  • lcd.clear(): Wipes the display clean.

  • lcd.setCursor(col, row): Positions the cursor to the starting column (0) of the desired row (0 or 1).

  • lcd.print(String): Writes the current text data to the display.

4.1.3 The Web POST Handler (handleFormPost)

This is the core communication handler that processes the user’s input from the browser.

				
					void handleFormPost() {
    if (server.hasArg("line_1") && server.hasArg("line_2")) {
        line1 = server.arg("line_1").substring(0, 16);
        line2 = server.arg("line_2").substring(0, 16);
        updateLCD();
        server.sendHeader("Location", "/");
        server.send(303); // Redirect
    } else { /* ... error handling ... */ }
}
				
			
  • Argument Retrieval: server.arg("field_name") retrieves the data submitted from the HTML form’s named input fields (line_1, line_2).

  • Safety Truncation: The .substring(0, 16) method is used to guarantee that the received strings do not exceed the physical 16-character limit of the LCD, preventing text overflow and display issues.

  • Display Update: updateLCD() is called immediately to synchronize the physical display with the new data.

  • Redirection: server.send(303) coupled with the Location header is a standard HTTP technique that tells the browser to redirect back to the root page (/) after a successful POST request. This ensures the user sees the updated content and prevents form resubmission errors.

5. Hands-On Lab Recap

You have successfully integrated direct GPIO control of the 16×2 LCD with the ESP32’s web server:

  • The LiquidCrystal Library is used for low-level control of the display via 6 dedicated GPIO pins.
  • The web page features an HTML form that sends user input via an HTTP POST request to the /submit
  • The handleFormPost() function retrieves the form data using arg(), updates the global text variables, and calls updateLCD() to refresh the physical display.
  • The substring(0, 16) limit ensures that the user’s input does not overflow the 16-character width of the display.

Leave a Comment

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

Scroll to Top