Community project
ConnectOS
ConnectOS is an email client built on the ESP32 with a touchscreen display, bringing mail management to an embedded device. The project combines a 320x480 LCD panel with Wi-Fi connectivity to let users check and send emails directly from the device after configuring mailbox settings through the on-screen interface.
This guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions to get the display board running on USB power and connected to your network. The included firmware handles the display rendering, touch input, and email functionality, so builders can focus on the hardware setup and configuration.
Wiring diagram
Assemble it in 4 steps
1. Keep the display board on USB power
Leave the Connect Gen1 display board connected to USB-C while setting up mail. The mailbox uses Wi-Fi, so the board must remain powered while it connects.
- Use the existing USB-C connection; no new wires or extra mail hardware are needed.
- Do not enter a normal account password; use only the app password or access token issued by your mail provider.
2. Connect the board to Wi-Fi
Open the Wi-Fi app on Connect Gen1, choose your network, enter its password with the on-screen keyboard, and wait until it says it is connected. This gives the Email app a real internet connection.
- A successful Wi-Fi connection must be present before Email can check or send messages.
- Make sure the Wi-Fi password is entered in the Wi-Fi app, not in the Email app.
3. Enter the mailbox settings on the device
Open Email and tap each setting box. Enter your mailbox address, the IMAP server name, the SMTP server name, and the provider-issued app password or access token. Tap Save mailbox when the four settings are complete.
- Most providers use IMAP port 993 and SMTP port 465; ConnectOS uses those secure TLS connections.
- The app password or token is shown as stars after you type it.
- Entering the wrong server name or an ordinary account password can make sign-in fail.
4. Check and send mail
Tap Check inbox to verify that the IMAP mailbox can be reached. To send a message, enter the recipient address, subject, and message text, then tap Send email.
- The app reports connection and sign-in failures instead of pretending a message was sent.
- Check your provider's Sent mail folder or the recipient inbox to confirm final delivery.
- The first version confirms the real mailbox connection and inbox message count; individual message header previews are not installed yet.
Deploy the firmware
#include <Arduino.h>
#include <Wire.h>
#include <esp_heap_caps.h>
#include <lvgl.h>
#include <Arduino_GFX_Library.h>
#include "app_ui.h"
#include "system_clock.h"
// We hate Pedros, but without the R
// JC3248W535C: AXS15231B QSPI LCD in native 320x480 portrait orientation.
constexpr int LCD_CS = 45;
constexpr int LCD_SCLK = 47;
constexpr int LCD_D0 = 21;
constexpr int LCD_D1 = 48;
constexpr int LCD_D2 = 40;
constexpr int LCD_D3 = 39;
constexpr int LCD_BACKLIGHT = 1;
constexpr int TOUCH_SDA = 4;
constexpr int TOUCH_SCL = 8;
constexpr int TOUCH_INT = 3;
constexpr uint8_t TOUCH_ADDRESS = 0x3B;
constexpr uint16_t PORTRAIT_WIDTH = 320;
constexpr uint16_t PORTRAIT_HEIGHT = 480;
constexpr uint16_t SCREEN_WIDTH = PORTRAIT_WIDTH;
constexpr uint16_t SCREEN_HEIGHT = PORTRAIT_HEIGHT;
constexpr uint16_t DRAW_LINES = SCREEN_HEIGHT;
static lv_disp_t *registered_display = nullptr;
Arduino_DataBus *display_bus = new Arduino_ESP32QSPI(
LCD_CS, LCD_SCLK, LCD_D0, LCD_D1, LCD_D2, LCD_D3);
Arduino_GFX *panel = new Arduino_AXS15231B(
display_bus, GFX_NOT_DEFINED, 0, false, SCREEN_WIDTH, SCREEN_HEIGHT);
// The AXS15231B panel needs complete frames. LVGL's single full-screen PSRAM
// buffer is sent directly to the panel; no second canvas framebuffer is used.
static lv_disp_draw_buf_t draw_buffer;
static lv_color_t *buffer_a;
static lv_color_t *buffer_b;
void display_flush(lv_disp_drv_t *driver, const lv_area_t *area, lv_color_t *color_map) {
const int16_t width = area->x2 - area->x1 + 1;
const int16_t height = area->y2 - area->y1 + 1;
// The AXS15231B QSPI panel is reliable only when LVGL supplies one complete
// 320 x 480 frame. Do not use partial rectangles here: they leave stale
// panel data behind and visibly tear the ConnectOS interface.
panel->draw16bitRGBBitmap(area->x1, area->y1,
reinterpret_cast<uint16_t *>(color_map),
width, height);
lv_disp_flush_ready(driver);
}
bool read_touch_point(uint16_t &x, uint16_t &y) {
// AXS15231B uses this vendor command to return one six-byte touch record.
const uint8_t command[11] = {0xB5, 0xAB, 0xA5, 0x5A, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00};
uint8_t data[8];
Wire.beginTransmission(TOUCH_ADDRESS);
Wire.write(command, sizeof(command));
if (Wire.endTransmission() != 0) return false;
if (Wire.requestFrom(TOUCH_ADDRESS, static_cast<uint8_t>(sizeof(data))) != sizeof(data)) return false;
for (uint8_t &value : data) value = Wire.read();
if (data[1] == 0 || data[1] > 1) return false;
x = static_cast<uint16_t>((data[2] & 0x0F) << 8) | data[3];
y = static_cast<uint16_t>((data[4] & 0x0F) << 8) | data[5];
return x < SCREEN_WIDTH && y < SCREEN_HEIGHT;
}
void touch_read(lv_indev_drv_t *driver, lv_indev_data_t *data) {
uint16_t x, y;
if (read_touch_point(x, y)) {
// The first touch after display sleep only wakes the panel. It is not sent
// to LVGL, so a sleeping screen cannot accidentally activate a control.
if (app_ui_note_touch()) {
data->state = LV_INDEV_STATE_REL;
return;
}
// Both ConnectOS and ConnectLynx use the stable native portrait layout.
data->point.x = (PORTRAIT_WIDTH - 1) - x;
data->point.y = (PORTRAIT_HEIGHT - 1) - y;
data->state = LV_INDEV_STATE_PR;
} else {
app_ui_note_no_touch();
data->state = LV_INDEV_STATE_REL;
}
}
void setup() {
Serial.begin(115200);
pinMode(LCD_BACKLIGHT, OUTPUT);
digitalWrite(LCD_BACKLIGHT, HIGH);
pinMode(TOUCH_INT, INPUT_PULLUP);
Wire.begin(TOUCH_SDA, TOUCH_SCL, 400000);
if (!psramFound()) {
Serial.println("PSRAM was not found; the UI requires the board's 8 MB PSRAM.");
}
if (!panel->begin()) {
Serial.println("The display panel could not start.");
while (true) delay(1000);
}
// Display the complete interface upside down. Touch coordinates are mirrored
// below so the on-screen controls still respond where they are drawn.
panel->setRotation(2);
// Draw a plain dark startup frame only while LVGL is being prepared. The
// launcher replaces it immediately after app_ui_init(); it is never a route
// or a persistent loading overlay.
panel->fillScreen(0x0000);
lv_init();
const size_t pixels = SCREEN_WIDTH * DRAW_LINES;
buffer_a = static_cast<lv_color_t *>(heap_caps_malloc(pixels * sizeof(lv_color_t), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
buffer_b = nullptr;
if (!buffer_a) {
Serial.println("Unable to allocate LVGL draw buffers in PSRAM.");
while (true) delay(1000);
}
lv_disp_draw_buf_init(&draw_buffer, buffer_a, buffer_b, pixels);
static lv_disp_drv_t display_driver;
lv_disp_drv_init(&display_driver);
display_driver.hor_res = SCREEN_WIDTH;
display_driver.ver_res = SCREEN_HEIGHT;
display_driver.flush_cb = display_flush;
display_driver.draw_buf = &draw_buffer;
// This LCD needs a complete consistent frame for every redraw. This is less
// aggressive than rectangle-only flushing, but it preserves every screen,
// icon, lock-screen element, and app instead of corrupting the display.
display_driver.full_refresh = 1;
registered_display = lv_disp_drv_register(&display_driver);
static lv_indev_drv_t touch_driver;
lv_indev_drv_init(&touch_driver);
touch_driver.type = LV_INDEV_TYPE_POINTER;
touch_driver.read_cb = touch_read;
lv_indev_drv_register(&touch_driver);
app_ui_init();
// Render the launcher twice before entering the normal loop. This drains the
// initial full-screen invalidation on this QSPI panel instead of leaving the
// temporary hardware frame visible after a reset.
lv_timer_handler();
delay(20);
lv_timer_handler();
Serial.println("ConnectOS home screen mounted.");
}
void loop() {
static uint32_t last_lvgl_tick = millis();
uint32_t now = millis();
lv_tick_inc(now - last_lvgl_tick);
last_lvgl_tick = now;
// LVGL now flushes only invalidated rectangles. Yield briefly so Wi-Fi and
// touch processing keep their time slices while the UI can reach a smooth
// 60 Hz response whenever an app is animating.
lv_timer_handler();
system_clock_service();
app_ui_tick();
delay(1);
}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.




