# Building a tiny Shrek NAS (LILYGO T-Dongle-S3)

> Turning a £10 ESP32 into a tiny wireless file server that never stops playing Shrek

By Zsolt Bizderi · Published 2026-09-04
Canonical: https://ambientnode.uk/building-a-tiny-shrek-nas-lilygo-t-dongle-s3

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.

![](/media/f4ce9032-087f-47df-9964-056c584ff430/signal-2026-07-28-20-24-44-490_002.gif)

## The hardware

The [T-Dongle-S3](https://lilygo.cc/products/t-dongle-s3?srsltid=AfmBOoodlseXhx0QXL9Jsp5Ikrq_bXhAWuGEou6oi9ZtrRBIveC72UGr) 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

![](/media/c5404e4e-a095-4097-a035-3be7f2becc90/591.webp)

## 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.

![image-removebg-preview(6).png](/media/c07af723-a5fa-4b49-9e3e-98e91be268d0/485.webp)

## 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:
![](/media/b4b147df-af54-4c7b-a9c7-e0dc06a255f3/414.webp)
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("&", "&amp;");
  out.replace("<", "&lt;");
  out.replace(">", "&gt;");
  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.
```
