An offline blog that runs on an ESP32-S3. The board boots a WiFi access point, serves a blog with a captive portal, and stores everything as raw blocks on a microSD card. No internet connection required -- just connect to its WiFi and your browser opens the blog.
Built and tested on MicroPython 1.28 (ESP32-S3 N16R8).
- Broadcasts a WiFi AP (SSID: "免费WIFI欢迎使用", open by default -- change in
config.py) - DNS server catches all domain lookups and points them at the device (captive portal)
- Serves a blog with posts, comments, pagination
- Has an admin panel behind password login for creating/editing/deleting posts
- Stores all data via raw block writes to the SPI-connected microSD card (block 100)
- Templates auto-create on internal Flash (
/www/) fromdefaults.pyon first boot
| microSD | ESP32-S3 |
|---|---|
| CS | GPIO 15 |
| SCK | GPIO 18 |
| MOSI | GPIO 21 |
| MISO | GPIO 19 |
注意: 初始设计使用 GPIO 5(CS)和 GPIO 23(MOSI),但实际模块的 MOSI 引脚与部分开发板不兼容,最终改用 GPIO 21。如果你的 SD 模块 MISO 持续读到 0x00,先检查 MISO 是否短路到 GND。 | VCC | 3.3V | | GND | GND |
The dev server mocks the SD card with a local directory so you can work on templates and routes without flashing.
cd D:\esp32
pip install aiohttp
python dev.py
Open http://localhost:8080. Admin password is admin123 (change it in config.py).
Flash MicroPython 1.28 firmware. You need the SPIRAM_OCT variant so the 8 MB PSRAM is available:
esptool.py --chip esp32s3 --port COMx erase_flash
esptool.py --chip esp32s3 --port COMx --baud 460800 write_flash -z 0x0 ESP32_GENERIC_S3-SPIRAM_OCT-20250703-v1.28.0.bin
If your board does not have octal PSRAM, use the standard variant:
esptool.py --chip esp32s3 --port COMx --baud 460800 write_flash -z 0x0 ESP32_GENERIC_S3-16MiB-FLASH.bin
Copy the files to the board with mpremote. You do not need to upload the www/ directory -- templates are auto-created on first boot from defaults.py:
mpremote connect COMx cp config.py boot.py storage.py main.py microdot.py sdcard.py defaults.py memcheck.py :
mpremote connect COMx soft_reset
After reset the AP should appear as "免费WIFI欢迎使用" (or whatever you set in config.py). Connect your phone or laptop to it, open any URL, and the captive portal redirects you to the blog.
To run the memory diagnostic tool:
mpremote connect COMx run memcheck.py
esp32/
├── main.py # Entry point: AP, DNS captive portal, Microdot routes (~650 lines)
├── boot.py # Early boot: mount SD card, check PSRAM
├── config.py # All tunable constants (SSID, password, pinout)
├── storage.py # Raw block read/write, atomic writes, CRUD wrappers
├── microdot.py # Inline async web framework (simplified fork of Microdot)
├── sdcard.py # SPI SD card driver from MicroPython-lib
├── defaults.py # Default HTML/CSS templates (written to Flash /www/ on first boot, then unloaded)
├── memcheck.py # PSRAM & heap diagnostic tool (run with mpremote)
├── dev.py # PC dev server -- mocks hardware so you can work without an ESP32
├── www/ # Frontend template sources (for reference; NOT uploaded to device)
│ ├── base.html
│ ├── home.html
│ ├── post.html
│ ├── login.html
│ ├── admin.html
│ └── style.css
├── dev_sd/ # Fake SD directory used by dev.py
└── docs/
└── TUTORIAL.md # Walkthrough: HTTP, routing, template rendering, CRUD
Templates live on the device at internal Flash /www/, auto-created by defaults.py on first boot. This avoids the complexity of writing them to the SD card VFS (which was unreliable with large SD cards -- see below).
Data is stored via raw block writes to the microSD card, starting at block 100. The VFS FAT32 file approach was abandoned because MemoryError crashes occurred on 16 GB cards during file writes.
The atomic write pattern works like this:
- JSON-encode the data, measure byte length.
- Send
CMD24(write single block) for each 512-byte block needed. - Poll the data-response token to confirm the card accepted the write.
- After all blocks are written, read back block 100 and verify the first bytes match.
- Retry up to 3 times with
CMD0card reset on failure.
A VFS file copy (/sd/blog_data.json) is also written as a best-effort convenience (for editing on a PC by pulling the SD card), but the raw blocks are the source of truth.
{
"posts": [
{
"id": 1,
"title": "Hello",
"content": "First post content.",
"time": "2026-07-01T12:00:00"
}
],
"comments": [
{
"id": 1,
"post_id": 1,
"nickname": "reader",
"content": "Nice post.",
"time": "2026-07-01T12:30:00"
}
],
"next_post_id": 2,
"next_comment_id": 1
}See docs/TUTORIAL.md for a step-by-step walkthrough covering HTTP basics, routing, template rendering, and building the CRUD endpoints from scratch.
The device handles 2-5 concurrent users without issues. With the SPIRAM_OCT firmware, the full 8 MB PSRAM is available, so heap memory is no longer a tight bottleneck. Admin password is plaintext in config.py -- acceptable for a trusted LAN audience. The raw block region is reserved at 4 blocks (2048 bytes), which comfortably holds a few hundred posts and comments.
Several hard-to-diagnose issues were found and fixed during development. They are documented here in case they help others working on similar MicroPython + ESP32-S3 projects.
json.dumps() 返回 Unicode 字符串,中文的 Unicode 码点(如 "欢" = 27426)超出 255。ord() 返回码点 → 写入 bytearray 时被静默截断到低 8 位 → JSON 数据中出现 "(0x22)和非法 UTF-8 字节(0xCE 等)→ UTF-8 解码失败 → 持久化数据损坏。修复:先 .encode("utf-8") 得到字节数组再写入。
The sdcard.py driver's readblocks() does not release CS after a single-block read. This left the card in a locked state, causing subsequent CMD24 writes to fail silently. The fix: always call sd.cs(1); sd.spi.write(b'\xff') immediately after every readblocks() call.
After sending the 516-byte data frame (start token + 512 data + CRC), the card responds with a data-response token. The correct response is 0x05 (bits 0101 in the status field), but many cheap SD cards also return 0x00 (busy) before settling. The original code only checked for exact 0x05. The fix: accept any byte where the lower nibble is 0x05 (i.e. 0x05, 0x25, 0x45, etc.) and also accept 0x00 as a valid "card is busy, data was accepted" signal.
The home.html template had a typo: {{% endif %}} instead of {{% endif %}}. Wait -- that looks the same? It was actually {{% endif %} missing a } -- specifically, one {% endif %} tag in the pagination block was written as {% endif % (missing the closing }), which broke the template regex and silently swallowed content after the pagination section. The fix: corrected the typo in defaults.py.