Community project

Portable ESP32 Laptop

Raspberry Pi Pico
Photo of Portable ESP32 Laptop
Generated with AI

Sven Celikovic

Published September 12, 2026

This project turns a Raspberry Pi Pico into a portable text editor with a 3.3-inch touchscreen display and compact keyboard. The device runs on a rechargeable LiPo battery managed by a charger module and boost converter, making it truly mobile for quick note-taking or writing on the go.

Builders will receive a complete wiring diagram showing how to connect the ILI9341 display, M5Stack keyboard, and power management components to the Pico, along with a full parts list, Arduino firmware for text editing and touch input, and step-by-step assembly instructions including proper power sequencing and logic level shifting for 5V-tolerant signals.

Wiring diagram

Wiring diagram for Portable ESP32 Laptop
Loading diagram…

Gather all the parts

QtyComponent
1

ILI9341 TFT Touchscreen

2.8 inch

240x320 SPI TFT display using the ILI9341 LCD controller with an XPT2046 resistive touch controller sharing the SPI bus

1

M5Stack CardKB2

50-key mini keyboard

M5Stack CardKB2 full QWERTY mini keyboard unit based on ESP32-C61, communicating over I2C at default address 0x5F. Powered from 5V via HY2.0-4P Grove connector (GND, 5V, SDA/UART_RX/G26, SCL/UART_TX/G25). Key value is readable over I2C by reading register 0x00 (single keypress byte); additional registers expose modifier/special keys. For simple firmware, the Arduino Wire library is enough. Select I2C mode with Fn + Sym + 1.

1

LiPo 3.7V 1000mAh Battery

1000 mAh

Single-cell LiPo pack, nominal 3.7 V, 1000 mAh. Default rechargeable choice for portable ESP32 / Pico projects. Pair with a TP4056 charger for safe USB recharging.

1

TP4056 Li-Ion/LiPo charger module with protection

protected 6-pad

TP4056 single-cell Li-Ion/LiPo linear charger module, 5V USB input, 1A charge current (programmable). Common variants ship with DW01 protection. Pair with battery_lipo_storage for the cell.

1

Boost Converter

set to 5.0 V

Small adjustable MT3608-style DC-DC boost converter module for stepping a lower DC input up to a higher rail such as 5V, 9V, or 12V. It is a power-path module with VIN/VOUT terminals, not a GPIO peripheral.

1

BSS138 Logic Level Shifter

4-channel 3.3 V ↔ 5 V

Common BSS138 MOSFET-based 4-channel bidirectional logic level converter module. Good for I2C, UART, and slow digital signals between 3.3V and 5V domains; not a strong high-speed SPI/LED data buffer.

Assemble it in 5 steps

1. Prepare the rechargeable power section

With the battery disconnected, connect battery_1 positive (+) to charger_1 B+ and battery_1 negative (−) to charger_1 B−. Connect charger_1 OUT+ to boost_1 VIN+ and charger_1 OUT− to boost_1 VIN−. Use a USB cable only on charger_1 IN+ and IN− when charging.

  • Set the boost converter output to exactly 5.0 V with a meter before connecting anything else.
  • Do not connect the battery directly to the Raspberry Pi Pico 2 W or the screen — incorrect battery voltage can damage them.

2. Power the Pico, screen, keyboard, and level shifter

Connect boost_1 VOUT+ to the Pico 5 V / VBUS power pin and to keyboard_1 VCC. Connect boost_1 VOUT− to every GND pin: the Pico, tft_1, keyboard_1, and level_shifter_1. Connect the Pico 3V3 pin to tft_1 VCC and TFT_BL, and to level_shifter_1 LV. Connect boost_1 VOUT+ to level_shifter_1 HV.

  • All ground wires must meet; they give every signal a common reference.
  • Make sure VCC and GND are not swapped on the screen or level-shifter — swapped power can damage them.

3. Wire the screen to the Pico

Connect tft_1 MOSI to Pico GP19 (screen data), MISO to GP16 (screen return data), SCK to GP18 (screen timing), TFT_CS to GP17 (screen select), TFT_DC to GP6 (screen command/data), TFT_RST to GP7 (screen reset), and TOUCH_CS to GP8 (touch select).

  • Keep these screen wires short and check each printed label before powering up.
  • Do not move the screen's VCC wire to the 5 V boost output; this display is powered from the Pico's 3.3 V pin.

4. Put the keyboard signals through the level shifter

Connect Pico GP4 to level_shifter_1 LV1 and Pico GP5 to level_shifter_1 LV2. Connect keyboard_1 SDA to the matching high-side channel HV1 and keyboard_1 SCL to the matching high-side channel HV2. The level-shifter board labels may use HV1/HV2 rather than one shared HV1-HV4 label.

  • Keep SDA paired with LV1/HV1 and SCL paired with LV2/HV2; do not cross the two channels.
  • Do not connect the keyboard SDA or SCL pins directly to the Pico. The keyboard's 5 V signal level can damage the Pico's 3.3 V pins.

5. Check before first power-up

Check that the battery polarity is correct, the boost output is 5.0 V, the screen is on 3.3 V, and every module has a GND wire. Then plug the Pico into USB for its first test.

  • If the screen stays blank, disconnect power first and recheck the six screen signal wires one at a time.
  • Never charge the battery through the boost converter output; charge it only through the TP4056 module's USB input.

Review all connections

1. Connections between "tft_1" and "Raspberry Pi Pico"

Functiontft_1Raspberry Pi Pico
powerVCC3V3
groundGNDGND
powerTFT_BL3V3
spiMOSIGPIO 19
spiMISOGPIO 16
spiSCKGPIO 18
digitalTFT_CSGPIO 17
digitalTFT_DCGPIO 6
digitalTFT_RSTGPIO 7
digitalTOUCH_CSGPIO 8

2. Connections between "keyboard_1" and "Raspberry Pi Pico"

Functionkeyboard_1Raspberry Pi Pico
powerVCC → Boost Converter VOUT+EXT
groundGNDGND
i2cSDA → BSS138 Logic Level Shifter HV1-HV4EXT
i2cSCL → BSS138 Logic Level Shifter HV1-HV4EXT

3. Connections between "battery_1" and "Raspberry Pi Pico"

Functionbattery_1Raspberry Pi Pico
power+V → TP4056 Li-Ion/LiPo charger module with protection B+EXT
groundGND → TP4056 Li-Ion/LiPo charger module with protection B-EXT

4. Connections between "charger_1" and "Raspberry Pi Pico"

Functioncharger_1Raspberry Pi Pico
powerIN+ → USB charging cable 5V positiveEXT
groundIN- → USB charging cable groundEXT
powerOUT+ → Boost Converter VIN+EXT
groundOUT- → Boost Converter VIN-EXT

5. Connections between "boost_1" and "Raspberry Pi Pico"

Functionboost_1Raspberry Pi Pico
powerVOUT+VIN
groundVOUT-GND

6. Connections between "level_shifter_1" and "Raspberry Pi Pico"

Functionlevel_shifter_1Raspberry Pi Pico
powerLV3V3
powerHVVIN
groundGNDGND
digitalLV1GPIO 4
digitalLV2GPIO 5

Deploy the firmware

#include <Arduino.h>
#include <SPI.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <XPT2046_Touchscreen.h>


// Forward declarations
void drawLine(uint8_t line);
void drawCursor();
void redrawNote();
void clearNote();
void newLine();
void appendCharacter(char key);
char readKeyboard();

constexpr uint8_t TFT_CS_PIN = 17;
constexpr uint8_t TFT_DC_PIN = 6;
constexpr uint8_t TFT_RST_PIN = 7;
constexpr uint8_t TOUCH_CS_PIN = 8;
constexpr uint8_t TFT_MOSI_PIN = 19;
constexpr uint8_t TFT_MISO_PIN = 16;
constexpr uint8_t TFT_SCK_PIN = 18;
constexpr uint8_t KEYBOARD_SDA_PIN = 4;
constexpr uint8_t KEYBOARD_SCL_PIN = 5;
constexpr uint8_t KEYBOARD_ADDRESS = 0x5F;

constexpr int16_t SCREEN_W = 320;
constexpr int16_t SCREEN_H = 240;
constexpr int16_t HEADER_H = 26;
constexpr int16_t FOOTER_H = 18;
constexpr int16_t TEXT_X = 8;
constexpr int16_t TEXT_Y = 36;
constexpr int16_t CHAR_W = 6;
constexpr int16_t LINE_H = 10;
constexpr uint8_t MAX_COLS = 50;
constexpr uint8_t MAX_LINES = 18;
constexpr uint16_t HEADER_COLOR = 0x000F;
constexpr uint16_t FOOTER_COLOR = 0x7BEF;

Adafruit_ILI9341 tft(TFT_CS_PIN, TFT_DC_PIN, TFT_RST_PIN);
XPT2046_Touchscreen touch(TOUCH_CS_PIN);
char noteLines[MAX_LINES][MAX_COLS + 1];
uint8_t cursorLine = 0;
uint8_t cursorCol = 0;
unsigned long lastKeyPoll = 0;
unsigned long lastTouchPoll = 0;

void drawLine(uint8_t line) {
  const int16_t y = TEXT_Y + line * LINE_H;
  tft.fillRect(0, y, SCREEN_W, LINE_H, ILI9341_BLACK);
  tft.setTextColor(ILI9341_GREEN, ILI9341_BLACK);
  tft.setTextSize(1);
  tft.setCursor(TEXT_X, y + 1);
  tft.print(noteLines[line]);
}

void drawCursor() {
  const int16_t x = TEXT_X + cursorCol * CHAR_W;
  const int16_t y = TEXT_Y + cursorLine * LINE_H;
  tft.drawFastVLine(x, y, LINE_H - 1, ILI9341_YELLOW);
}

void redrawNote() {
  tft.fillRect(0, HEADER_H, SCREEN_W, SCREEN_H - HEADER_H - FOOTER_H, ILI9341_BLACK);
  for (uint8_t line = 0; line < MAX_LINES; line++) drawLine(line);
  drawCursor();
}

void clearNote() {
  for (uint8_t line = 0; line < MAX_LINES; line++) noteLines[line][0] = '\0';
  cursorLine = 0;
  cursorCol = 0;
  redrawNote();
}

void newLine() {
  if (cursorLine < MAX_LINES - 1) {
    cursorLine++;
    cursorCol = 0;
    drawCursor();
  }
}

void appendCharacter(char key) {
  if (key == '\r' || key == '\n') {
    newLine();
    return;
  }
  if (key == 8 || key == 127) {
    if (cursorCol > 0) {
      cursorCol--;
      noteLines[cursorLine][cursorCol] = '\0';
      drawLine(cursorLine);
      drawCursor();
    }
    return;
  }
  if (key < 32 || key > 126) return;
  if (cursorCol >= MAX_COLS) newLine();
  if (cursorCol < MAX_COLS) {
    noteLines[cursorLine][cursorCol++] = key;
    noteLines[cursorLine][cursorCol] = '\0';
    drawLine(cursorLine);
    drawCursor();
  }
}

char readKeyboard() {
  Wire.beginTransmission(KEYBOARD_ADDRESS);
  if (Wire.endTransmission() != 0) return 0;
  if (Wire.requestFrom((int)KEYBOARD_ADDRESS, 1) != 1) return 0;
  return (char)Wire.read();
}

void setup() {
#if defined(ARDUINO_ARCH_RP2040)
  // Route the Pico 2 W hardware buses to the pins used by this build.
  SPI.setSCK(TFT_SCK_PIN);
  SPI.setTX(TFT_MOSI_PIN);
  SPI.setRX(TFT_MISO_PIN);
#endif
  SPI.begin();
  Wire.begin();
  Wire.setClock(100000);

  tft.begin();
  tft.setRotation(1);
  touch.begin();
  touch.setRotation(1);
  tft.fillScreen(ILI9341_BLACK);
  tft.fillRect(0, 0, SCREEN_W, HEADER_H, HEADER_COLOR);
  tft.setTextColor(ILI9341_WHITE, HEADER_COLOR);
  tft.setTextSize(2);
  tft.setCursor(8, 5);
  tft.print("Pocket Writer");
  tft.fillRect(0, SCREEN_H - FOOTER_H, SCREEN_W, FOOTER_H, FOOTER_COLOR);
  tft.setTextColor(ILI9341_WHITE, FOOTER_COLOR);
  tft.setTextSize(1);
  tft.setCursor(8, SCREEN_H - FOOTER_H + 5);
  tft.print("Type on keyboard | Touch screen to clear");
  clearNote();
}

void loop() {
  const unsigned long now = millis();
  if (now - lastKeyPoll >= 25) {
    lastKeyPoll = now;
    const char key = readKeyboard();
    if (key != 0) appendCharacter(key);
  }
  if (now - lastTouchPoll >= 50) {
    lastTouchPoll = now;
    if (touch.touched()) {
      clearNote();
      delay(200);
    }
  }
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace, diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik