I recently picked up a LILYGO T-Dongle-S3 because I wanted to build a USB drive with extra features. It's a USB-stick-shaped ESP32-S3 dev board with a tiny 0.96" screen, a microSD slot, an RGB LED, and native USB.
Three core functionalities:
- Plug it into a PC and it appears as a normal USB flash drive.
- Plug it into a USB charger and it becomes a tiny NAS. It broadcasts its own WiFi network, you connect from your phone, and browse/upload files from a simple web based file browser.
- Most importantly, the screen loops the entire Shrek movie on loop when the device is powered on.

The hardware
The T-Dongle-S3 squeezes a lot of hardware into a device no bigger than a USB stick:
- ESP32-S3, dual-core, WiFi + Bluetooth 5, native USB
- 0.96" ST7735 LCD, 80×160
- microSD slot
- A push button

How it works
- USB mode: When plugged into a computer, the ESP32-S3 exposes the microSD card as a standard USB Mass Storage device. This is done using the ESP-IDF SDMMC and FATFS drivers, which provide the raw sector level access required for USB MSC.
- WiFi mode: The firmware always broadcasts an access point (
ShrekNAS/ShrekNAS). Any device that connects is DNS hijacked, with every request redirected to the web based file browser, including connectivity check URLs used by Android, iOS, and Windows. Most phones automatically display the "Sign in to network" captive portal prompt, which opens the file browser without the user needing to enter an address. I used this method in previous projects as it does not require you to recall / find the IP of the device. - The screen: I pre-converted the first Shrek movie into raw RGB565 frames and stored on the SD card. The firmware streams them to the display in a loop, so the ESP does not need to decode on the fly, just read pixel data off the card frame by frame.
- Both WiFi and direct USB interfaces share the same SD card, so don't actively write to it from a plugged-in PC and the web browser at the same time.

The firmware
Select the LilyGo T-Display-S3 (16M Flash) board in Arduino IDE. The only external library used is GFX Library for Arduino. The standard Arduino board settings are:
Flash the board first, but do not insert the SD card yet as it needs to be formatted and the video plays off of the card. The contents of the drive can be read over WiFi, so ensure you change the dummy WiFi password:
#include <Arduino_GFX_Library.h>
#include <WiFi.h>
#include <WebServer.h>
#include <DNSServer.h>
#include "USB.h"
#include "USBMSC.h"
extern "C" {
#include "esp_vfs_fat.h"
#include "sdmmc_cmd.h"
#include "driver/sdmmc_host.h"
}
#include <dirent.h>
#include <sys/stat.h>
#include <stdio.h>
#define TFT_CLK 5
#define TFT_MOSI 3
#define TFT_DC 2
#define TFT_CS 4
#define TFT_RST 1
#define TFT_BL 38
Arduino_DataBus *bus = new Arduino_HWSPI(TFT_DC, TFT_CS, TFT_CLK, TFT_MOSI, -1);
Arduino_GFX *gfx = new Arduino_ST7735(bus, TFT_RST, 0,
true, 80, 160, 26, 1,
0, 0, true);
#define SD_CLK 12
#define SD_CMD 16
#define SD_D0 14
#define SD_D1 17
#define SD_D2 21
#define SD_D3 18
#define MOUNT_POINT "/sdcard"
sdmmc_card_t *card = nullptr;
bool initSdCard() {
sdmmc_host_t host = SDMMC_HOST_DEFAULT();
host.max_freq_khz = SDMMC_FREQ_HIGHSPEED;
sdmmc_slot_config_t slot_config = SDMMC_SLOT_CONFIG_DEFAULT();
slot_config.clk = (gpio_num_t)SD_CLK;
slot_config.cmd = (gpio_num_t)SD_CMD;
slot_config.d0 = (gpio_num_t)SD_D0;
slot_config.d1 = (gpio_num_t)SD_D1;
slot_config.d2 = (gpio_num_t)SD_D2;
slot_config.d3 = (gpio_num_t)SD_D3;
slot_config.width = 4;
slot_config.flags |= SDMMC_SLOT_FLAG_INTERNAL_PULLUP;
esp_vfs_fat_sdmmc_mount_config_t mount_config = {};
mount_config.format_if_mount_failed = false;
mount_config.max_files = 8;
mount_config.allocation_unit_size = 16 * 1024;
esp_err_t ret = esp_vfs_fat_sdmmc_mount(MOUNT_POINT, &host, &slot_config, &mount_config, &card);
return ret == ESP_OK;
}
#define AP_SSID "ShrekNAS"
#define AP_PASSWORD "ShrekNAS"
USBMSC msc;
static int32_t onRead(uint32_t lba, uint32_t offset, void *buffer, uint32_t bufsize) {
if (!card) return -1;
uint32_t count = bufsize / card->csd.sector_size;
return sdmmc_read_sectors(card, buffer, lba, count) == ESP_OK ? bufsize : -1;
}
static int32_t onWrite(uint32_t lba, uint32_t offset, uint8_t *buffer, uint32_t bufsize) {
if (!card) return -1;
uint32_t count = bufsize / card->csd.sector_size;
return sdmmc_write_sectors(card, buffer, lba, count) == ESP_OK ? bufsize : -1;
}
static bool onStartStop(uint8_t power_condition, bool start, bool load_eject) { return true; }
WebServer server(80);
DNSServer dnsServer;
const byte DNS_PORT = 53;
IPAddress apIP(192, 168, 4, 1);
bool nasActive = false;
String htmlEscape(const String &s) {
String out = s;
out.replace("&", "&");
out.replace("<", "<");
out.replace(">", ">");
return out;
}
String fsPath(const String &webPath) {
String p = String(MOUNT_POINT) + webPath;
if (p.endsWith("/") && p.length() > strlen(MOUNT_POINT) + 1) {
p.remove(p.length() - 1);
}
return p;
}
void handleRoot() {
String webPath = server.hasArg("dir") ? server.arg("dir") : "/";
String realPath = fsPath(webPath);
DIR *dir = opendir(realPath.c_str());
String html = "<html><head><title>ShrekNAS</title></head><body>";
html += "<h2>ShrekNAS: " + htmlEscape(webPath) + "</h2>";
html += "<form method='POST' action='/upload' enctype='multipart/form-data'>";
html += "<input type='hidden' name='dir' value='" + htmlEscape(webPath) + "'>";
html += "<input type='file' name='file'><input type='submit' value='Upload'></form>";
html += "<form method='POST' action='/mkdir'>";
html += "<input type='hidden' name='dir' value='" + htmlEscape(webPath) + "'>";
html += "<input type='text' name='name' placeholder='New folder name'>";
html += "<input type='submit' value='Create folder'></form><hr><ul>";
if (dir) {
struct dirent *entry;
while ((entry = readdir(dir)) != nullptr) {
String name = entry->d_name;
if (name == "." || name == "..") continue;
String childWeb = webPath;
if (!childWeb.endsWith("/")) childWeb += "/";
childWeb += name;
String childReal = realPath + "/" + name;
struct stat st;
stat(childReal.c_str(), &st);
if (S_ISDIR(st.st_mode)) {
html += "<li>[DIR] <a href='/?dir=" + childWeb + "'>" + htmlEscape(name) + "</a></li>";
} else {
html += "<li>
<a href='/download?file=" + childWeb + "'>" + htmlEscape(name) +
"</a>
(" + String((unsigned long)st.st\_size) + " bytes)";
}
}
closedir(dir);
} else {
html += "<em>could not open directory</em>";
}
html += "";
server.send(200, "text/html", html);
}
void handleDownload() {
if (!server.hasArg("file")) { server.send(400, "text/plain", "Missing file"); return; }
String realPath = fsPath(server.arg("file"));
FILE \*f = fopen(realPath.c\_str(), "rb");
if (!f) { server.send(404, "text/plain", "Not found"); return; }
fseek(f, 0, SEEK\_END);
long size = ftell(f);
fseek(f, 0, SEEK\_SET);
server.setContentLength(size);
server.send(200, "application/octet-stream", "");
uint8\_t buf[1024];
size\_t n;
while ((n = fread(buf, 1, sizeof(buf), f)) > 0) {
server.sendContent((const char \*)buf, n);
}
fclose(f);
}
FILE \*uploadFile = nullptr;
void handleUpload() {
HTTPUpload &upload = server.upload();
String webDir = server.hasArg("dir") ? server.arg("dir") : "/";
if (!webDir.endsWith("/")) webDir += "/";
String realPath = fsPath(webDir) + upload.filename;
if (upload.status == UPLOAD\_FILE\_START) {
uploadFile = fopen(realPath.c\_str(), "wb");
} else if (upload.status == UPLOAD\_FILE\_WRITE) {
if (uploadFile) fwrite(upload.buf, 1, upload.currentSize, uploadFile);
} else if (upload.status == UPLOAD\_FILE\_END) {
if (uploadFile) { fclose(uploadFile); uploadFile = nullptr; }
}
}
void handleUploadDone() {
server.sendHeader("Location", "/");
server.send(303);
}
void handleMkdir() {
String webDir = server.hasArg("dir") ? server.arg("dir") : "/";
String name = server.hasArg("name") ? server.arg("name") : "";
if (!webDir.endsWith("/")) webDir += "/";
name.trim();
if (name.length() > 0) {
String realPath = fsPath(webDir) + name;
mkdir(realPath.c\_str(), 0777);
}
server.sendHeader("Location", "/?dir=" + webDir);
server.send(303);
}
void handleCaptivePortal() {
server.sendHeader("Location", "http://192.168.4.1/", true);
server.send(302, "text/plain", "");
}
bool tryStartNas() {
WiFi.mode(WIFI\_AP);
WiFi.softAP(AP\_SSID, AP\_PASSWORD);
delay(200);
dnsServer.start(DNS\_PORT, "\*", apIP);
server.on("/", handleRoot);
server.on("/download", handleDownload);
server.on("/upload", HTTP\_POST, handleUploadDone, handleUpload);
server.on("/mkdir", HTTP\_POST, handleMkdir);
server.on("/generate\_204", handleCaptivePortal);
server.on("/gen\_204", handleCaptivePortal);
server.on("/hotspot-detect.html", handleCaptivePortal);
server.on("/library/test/success.html", handleCaptivePortal);
server.on("/ncsi.txt", handleCaptivePortal);
server.onNotFound(handleCaptivePortal);
server.begin();
return true;
}
uint16\_t frameW = 0, frameH = 0;
uint32\_t frameCount = 0;
uint16\_t fps = 10;
uint32\_t frameBytes = 0;
uint8\_t \*frameBuf = nullptr;
FILE \*shrekFile = nullptr;
bool openShrek() {
shrekFile = fopen(MOUNT\_POINT "/shrek.raw", "rb");
if (!shrekFile) return false;
uint8\_t header[12];
if (fread(header, 1, 12, shrekFile) != 12) return false;
frameW = header\[0\] \| \(header\[1\] << 8\);
frameH = header\[2\] \| \(header\[3\] << 8\);
frameCount = header\[4\] \| \(header\[5\] << 8\) \| \(header\[6\] << 16\) \| \(\(uint32\_t\)header\[7\] << 24\);
fps = header\[8\] \| \(header\[9\] << 8\);
frameBytes = (uint32\_t)frameW \* frameH \* 2;
frameBuf = (uint8\_t \*)malloc(frameBytes);
Serial.printf("shrek.raw header: %ux%u, %u frames, %u fps\\n", frameW, frameH, frameCount, fps);
return frameBuf != nullptr && frameCount > 0;
}
void playShrekFrame() {
size\_t n = fread(frameBuf, 1, frameBytes, shrekFile);
if (n < frameBytes) {
fseek(shrekFile, 12, SEEK\_SET);
fread(frameBuf, 1, frameBytes, shrekFile);
}
gfx->draw16bitRGBBitmap(0, 0, (uint16\_t \*)frameBuf, frameW, frameH);
}
void setup() {
Serial.begin(115200);
gfx->begin();
gfx->invertDisplay(false);
gfx->fillScreen(BLACK);
pinMode(TFT\_BL, OUTPUT);
digitalWrite(TFT\_BL, LOW);
if (!initSdCard()) {
Serial.println("SD card mount failed");
}
if (card) {
msc.vendorID("LILYGO");
msc.productID("ShrekNAS");
msc.productRevision("1.0");
msc.onRead(onRead);
msc.onWrite(onWrite);
msc.onStartStop(onStartStop);
msc.mediaPresent(true);
msc.begin(card->csd.capacity, card->csd.sector\_size);
}
USB.begin();
nasActive = tryStartNas();
if (!openShrek()) {
Serial.println("Could not open /shrek.raw");
}
}
void loop() {
if (nasActive) {
dnsServer.processNextRequest();
server.handleClient();
}
static uint32\_t lastFrame = 0;
uint32\_t frameDelay = 1000 / (fps ? fps : 10);
if (frameBuf && millis() - lastFrame >= frameDelay) {
lastFrame = millis();
playShrekFrame();
}
}
Video Encoding
Now that the firmware's flashed, we will need to get it to play the movie. The board's internal flash isn't big enough for a full movie, but as this doubles as a USB drive/NAS, we use the SD card itself for storing the video file.
Use an external SD reader to format USB drive as FAT32/exFAT. As mentioned above, the screen does not decode the video on the fly, so the movie needs to be pre-converted into raw frames and cropped to the display's native 80x160.
Run the Python converter below against your video file with python convert_shrek.py shrek.mp4 --fps 8, then copy shrek.raw to the root of the FAT32/exFAT SD card. Obviously, source your copy of Shrek yourself.
import argparse
import struct
import sys
import cv2
import numpy as np
SCREEN\_W = 80
SCREEN\_H = 160
def rgb888\_to\_rgb565(frame\_rgb: np.ndarray) -> np.ndarray:
r = (frame\_rgb[:, :, 0] >> 3).astype(np.uint16)
g = (frame\_rgb[:, :, 1] >> 2).astype(np.uint16)
b = (frame\_rgb[:, :, 2] >> 3).astype(np.uint16)
rgb565 = \(r << 11\) \| \(g << 5\) \| b
return rgb565.astype("<u2")
def resize\_and\_crop(frame\_bgr: np.ndarray, target\_w: int, target\_h: int) -> np.ndarray:
h, w = frame\_bgr.shape[:2]
scale = max(target\_w / w, target\_h / h)
new\_w, new\_h = int(w \* scale + 0.5), int(h \* scale + 0.5)
resized = cv2.resize(frame\_bgr, (new\_w, new\_h), interpolation=cv2.INTER\_AREA)
x0 = (new_w - target_w) // 2 y0 = (new_h - target_h) // 2 cropped = resized[y0:y0 + target_h, x0:x0 + target_w] return cropped
def main():
parser = argparse.ArgumentParser(description="Convert a video file to T-Dongle-S3 raw frame format")
parser.add\_argument("input", help="Path to your source video file")
parser.add\_argument("--output", default="shrek.raw", help="Output file (default: shrek.raw)")
parser.add\_argument("--seconds", type=float, default=None,
help="How many seconds to use (default: entire remaining runtime)")
parser.add\_argument("--fps", type=int, default=8, help="Target playback FPS (lower = smaller file)")
parser.add\_argument("--start", type=float, default=0.0, help="Start offset in seconds")
parser.add\_argument("--yes", action="store\_true", help="Skip the confirmation prompt")
args = parser.parse\_args()
cap = cv2.VideoCapture(args.input) if not cap.isOpened(): print(f"Could not open {args.input}", file=sys.stderr) sys.exit(1)
src_fps = cap.get(cv2.CAP_PROP_FPS) or 30 total_src_frames = cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0 src_duration = total_src_frames / src_fps if src_fps else 0
duration = args.seconds if args.seconds is not None else max(0, src_duration - args.start) frame_count_wanted = int(duration * args.fps) step = max(1, round(src_fps / args.fps))
est_bytes = 12 + frame_count_wanted * SCREEN_W * SCREEN_H * 2 print(f"Source: {src_duration/60:.1f} min @ {src_fps:.1f} fps") print(f"Converting: {duration/60:.1f} min starting at {args.start:.0f}s, at {args.fps} fps") print(f"Estimated output size: {est_bytes / (1024*1024):.1f} MB (~{frame_count_wanted} frames)")
if not args.yes: resp = input("Proceed? [y/N] ").strip().lower() if resp != "y": print("Cancelled.") sys.exit(0)
start_frame = int(args.start * src_fps) cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
with open(args.output, "wb") as f: f.write(b"\x00" * 12)
written = 0
read_idx = 0
last_report = 0
while written < frame_count_wanted:
ret, frame_bgr = cap.read()
if not ret:
break
if read_idx % step == 0:
cropped = resize_and_crop(frame_bgr, SCREEN_W, SCREEN_H)
rgb = cv2.cvtColor(cropped, cv2.COLOR_BGR2RGB)
f.write(rgb888_to_rgb565(rgb).tobytes())
written += 1
if written - last_report >= 500:
print(f" ...{written}/{frame_count_wanted} frames")
last_report = written
read_idx += 1
f.seek(0)
f.write(struct.pack("<HHIH", SCREEN_W, SCREEN_H, written, args.fps))
cap.release()
if written == 0: print("No frames extracted — check the input file/offsets.", file=sys.stderr) sys.exit(1)
size_mb = (12 + written * SCREEN_W * SCREEN_H * 2) / (1024 * 1024) print(f"Wrote {written} frames ({size_mb:.1f} MB) to {args.output}")
if **name** == "**main**":
main()
Once the encoded video is on the SD card, insert the SD card into the dongle and bob's your uncle. ```