AutomationShield
Arduino library and MATLAB/Simulink API for the AutomationShield Arduino expansion boards for control engineering educa…
A public website running entirely on a single ESP32 with 520 KB of RAM. Every page, every sensor reading, every guestbook entry is served by the microcontroller itself. There is no backend server.
Live at helloesp.com
Because it's fun to see how far a $10 microcontroller can go. The last version ran until 2023 on an ESP32 behind a Cloudflare tunnel. It eventually went offline and the domain lapsed.
Years later I came back to it. The internet had gotten heavier in the meantime: more of everything, most of it wasteful. This whole website weighs less than a single phone wallpaper. I wanted to see what a single small chip could still do against all that. The domain was available again, which felt like permission.
This time it stays up. If it goes down, I fix it. That's the point now.
The ESP32 holds a persistent outbound WebSocket to a Cloudflare Worker. When a browser hits helloesp.com, the Worker relays the request over that socket and streams the response back. The ESP never accepts an inbound TCP connection from the internet, only from the LAN.
Browser ──HTTPS──▶ Cloudflare Worker ──WSS──▶ ESP32 (on your home network)
▲ │
└────── response ────────┘
Responses larger than a single WebSocket frame are chunked and base64-encoded. Admin endpoints return 404 through the relay and are only reachable on the LAN. A second shared secret (HMAC) can be enabled so a leaked Worker secret alone cannot impersonate the device.
The same WebSocket also carries live push events the other direction: every 15 seconds the device pushes sensor stats, and every tracked public request triggers a console event. The Worker fans these out to connected browsers via Server-Sent Events (/_stream), so the homepage ticks in real time without polling.
Without the Worker, the site still runs on LAN via mDNS at http://helloesp.local.
| Part | Qty | ~Price | Source | Role |
|---|---|---|---|---|
| Inland ESP32-WROOM-32D | 1 | $10 | Microcenter | MCU, 520 KB RAM, dual-core Xtensa. Different silkscreen layout from the DOIT V1, but builds under esp32doit-devkit-v1 since the firmware addresses pins by GPIO number |
| BME280 breakout | 1 | $7 | Amazon | Temperature, humidity, barometric pressure |
| CCS811 breakout | 1 | $14 | Amazon | eCO₂, VOC |
| Adafruit DS3231 + CR1220 cell | 1 | $14 | Adafruit | Battery-backed real-time clock, accurate timekeeping across reboots |
| SSD1306 128×64 OLED | 1 | $3 | Amazon | Rotating info pages with burn-in shift |
| Adafruit MicroSD breakout | 1 | $8 | Adafruit | SPI SD card adapter |
| Good quality Micro SD card (FAT32, ≤32 GB) | 1 | $15 | anywhere | Filesystem: HTML, images, logs, config |
| 5mm LEDs + 220Ω resistors | 2 each | <$1 | Microcenter | Status LED (visit blink) + notification LED (pending guestbook) |
| Full-size premium breadboard | 1 | $5 | Adafruit | Build substrate; see "build gotchas" below |
| Solid-core hookup wire | 1 | $17 | Adafruit | Cut-to-length connections between modules |
Total parts: ~$95 as actually built (mix of Amazon, Microcenter, Adafruit). Going all-clones with no Adafruit breakouts gets you closer to $35. Clone-shop the DOIT V1 itself with the most care, since some clones ship undersized USB-UART chips or unstable AMS1117 LDOs.
Power: runs from any 1 A USB-A phone charger. Total draw averages well under 1 W, with brief spikes during WiFi association and SD writes.
My setup: USB-C brick → USB-C-to-micro-USB cable → smart plug → UPS. The smart plug gives remote power-cycling for when something wedges, and (with a Shelly Gen 2+ plug) feeds live wattage to the homepage. The UPS makes grid blips invisible. Both are optional, but both are part of why "this stays up" is a real claim and not an aspiration.
| Signal | ESP32 Pin |
|---|---|
| I²C SDA (BME280, CCS811, DS3231, OLED) | GPIO 21 |
| I²C SCL (BME280, CCS811, DS3231, OLED) | GPIO 22 |
| SD CS | GPIO 5 |
| SD MOSI | GPIO 23 |
| SD MISO | GPIO 19 |
| SD SCK | GPIO 18 |
| Status LED | GPIO 33 |
| Notification LED | GPIO 32 |
| CCS811 WAKE | GND |
I²C addresses: BME280 0x76, CCS811 0x5A, DS3231 0x68, SSD1306 0x3C.
DS3231 is optional. If absent, the firmware falls back to NTP-only timekeeping with a brief 1970-until-NTP window after each reboot; with it, the system clock is pre-seeded from the RTC at boot and writes back on every NTP sync.
A few things that aren't obvious until you ship one of these and watch it fail:
These are all lessons from this build. A PCB respin would fix most of them at the layout level, but with the gotchas above applied the breadboard build is solid; whether to ever PCB this is an open question.
Frontend
/history/console live feed: last 50 public requests with country flags, updated instantly via SSE/chronicle: a daily auto-generated diary the chip writes about itself, plus weekly, monthly, and quarterly reflection entries that aggregate prior periods. Around 30 observation detectors fold sensor extremes, visitor counts, weather, calendar events (equinoxes / first-of-month / season transitions), sensor-retire events, owner-defined personal anchors (birthdays, framing day), and self-aware-chip moments (curator absence, tinkering noticed, long-uptime milestones) into one of several narrative templates per cadence. Daily entries seal at chip-local midnight; weekly on Mondays; monthly on the 1st; quarterly at quarter starts. Permalinks for every entry, RSS feed, owner-curated notes, public today-in-progress preview card on the index, year/month accordion archive with template-id filter chips (milestones / records / anomalies / busy / quiet / reflections), keyboard navigation (←/→) on detail views, and per-entry share button.Firmware
tmp → bak → rename)Edge
Cache-Control: max-age headers so repeat visitors hit the edge instead of the chipworker_exclusive mode: LAN visitors to public pages (homepage, guestbook, etc.) get redirected to the public site so they go through the Worker's edge cache rather than hitting the chip directly. /admin stays direct on LAN. Eliminates LAN-burst-vs-WS-write collisions@cf/meta/m2m100-1.2b for primary translation, @cf/meta/llama-3.2-1b-instruct for source-language detection on diacritic-less Latin text and as a final fallback when m2m100 produces nothing usable. Per-(message-id, target-lang, text-hash) cached in DO storage so each unique pair costs at most one neuron set/status.svg (and /status-wide.svg)state/do-snapshot/YYYY-MM-DD.json (30-day retention) so non-chronicle DO state (snake leaderboards + season archives + replays, caches) is recoverable independently if DO is ever wiped/history cards, lifetime cost + CO₂ totals on homepage and /about (when grid rates are configured), admin observability panel + self-test entry/changelog.rss, /guestbook.rss, /chronicle.rss), sitemap.xml, robots.txt, .well-known/security.txtLive badges you can drop into your own README, blog, or status page. All variants pull from the Worker's cache, so embedding them puts zero load on the ESP no matter how many pages link them. Edge-cached 60 seconds. Keep working (showing "offline") even when the device is unreachable.
Compact badges (shields.io-compatible layout, 20px tall):
Wide stat card (340×78, multi-line mini-dashboard):
https://helloesp.com/status-wide.svg
Embedding
Markdown:
[](https://helloesp.com)
HTML:
<a href="https://helloesp.com"> <img src="https://helloesp.com/status.svg" alt="HelloESP status"> </a>
State colors (applies to both compact and wide):
| Color | Meaning |
|---|---|
| Blue | Live: device online, heard from within 45s |
| Gray | Offline or stale: no stats received in >2 minutes, or WS socket is gone |
| Orange | Maintenance: owner-declared downtime window |
Accessibility: every badge includes <title> text and role="img", so screen readers announce the state rather than saying "image." The aria-label reflects the current value ("HelloESP: up 5d 3h" etc.).
Cache policy: Cache-Control: public, max-age=60. Most embedders don't need sub-minute freshness; plan accordingly if you do.
git clone https://github.com/Tech1k/helloesp.git cd helloesp pio run -t upload
Requires PlatformIO.
Upgrading from before firmware 1.4: the boot-time CSV migrations were stripped in 1.4 to free flash. If your SD card has guestbook data from before April 23, 2026, flash firmware 1.3 first to run the v1→v3 migrations, then upgrade. The chip checks at boot and halts with an OLED message if it detects stale data, so you'll know if you skipped this step.
Format a FAT32 SD card (≤32 GB) and copy the contents of data/ to the root. The data/ folder is not flashed. It lives on the SD card.
Rename config.example.txt to config.txt on the SD card and fill in:
wifi_ssid=YOUR_SSID
wifi_pass=YOUR_PASSWORD
admin_user=admin
admin_pass=your-admin-password
timezone=MST7MDT,M3.2.0,M11.1.0
Timezone is a POSIX TZ string. Common US examples are listed in the file. Leave worker_url, worker_key, and device_key blank to run LAN-only.
Optional worker_exclusive=true redirects LAN public-page hits (/, /guestbook, etc.) to the Worker so they go through CF's edge cache. /admin always serves direct on LAN. Default off; flip to true only if your Worker is set up and you want LAN visitors to share the same cache layer as public visitors.
# Generate a shared secret; don't pick one by hand openssl rand -hex 32
The same value goes in two places:
worker_key in the SD config.txtWORKER_SECRETThen deploy:
cd worker wrangler deploy wrangler secret put WORKER_SECRET # paste the value from openssl
Add worker_url to config.txt (e.g. helloesp.com). The ESP will connect on next boot.
Adds a second secret so a leaked WORKER_SECRET alone cannot impersonate the device. On every reconnect, the Worker sends a random nonce and the ESP signs it with the device key.
openssl rand -hex 32
Set the same value as device_key in config.txt and:
wrangler secret put HMAC_SECRET
If either side is unset, HMAC is disabled and auth falls back to WORKER_SECRET only.
Configuring SMTP2GO once unlocks every alert channel the Worker uses:
DEADMAN_HOURS (default 6) + recovery email when it comes backwrangler secret put SMTP2GO_KEY wrangler secret put NOTIFY_EMAIL wrangler secret put NOTIFY_FROM # optional, e.g. "HelloESP <no-reply@yourdomain>" wrangler secret put DEADMAN_HOURS # optional, default 6, accepts fractional hours
The ESP never blocks on outbound HTTPS; all email sending lives on the Worker. If any required secret is unset, the corresponding alerts silently no-op.
Full SD snapshots go to Cloudflare R2 once a day at 4 AM local. Excludes config.txt, *.tmp/*.bak files, and /logs. Each backup lives at state/YYYY-MM-DD/ with a sha256 manifest; state/latest.json is the atomic commit pointer.
wrangler r2 bucket create helloesp-backup wrangler deploy
The binding is declared in wrangler.toml. If the binding isn't present, the Worker falls back to emailing the bundle as an attachment (if SMTP2GO is configured) so you don't silently lose backups.
Rotation (Grandfather-Father-Son). Keeps 7 daily + 4 weekly (Sundays) + 12 monthly (1st of month) + every Jan 1 forever. Anything outside those rules older than 8 days is pruned. A prefix guard refuses to delete anything not matching state/YYYY-MM-DD/.
Storage math. ~1.4 MB per snapshot × ~23 retained snapshots ≈ 32 MB/year. Well under R2's 10 GB free tier.
Alerting. No email on success. A single email fires if a backup fails (throttled 1/hr) or if no successful backup has been committed for >48 h (once per day).
data/ folder from this repo to the SD root. HTML/CSS/JS/images are deploy-recreatable and not in the backup.state/latest.json for the most recent snapshot date.state/{date}/, preserving subdirectories (e.g. stats/weekly/2026-W16.json → /stats/weekly/2026-W16.json on SD).config.txt from your own records. Secrets aren't in the backup on purpose.For single-file fixes, download one object from R2 and drop it in via the admin file manager. No full restore needed.
config.txt is in .gitignore. Do not commit it. Treat WORKER_SECRET and device_key like passwords./admin, /admin/*, /_upload, /_ota, /guestbook/pending, /guestbook/moderate) return 404 to any request arriving through the Worker. They are only reachable from the LAN.If you're thinking about building your own, here's what to expect:
The full stack is documented above: firmware, hardware, Worker, optional R2 backups, optional email alerts. Everything you need to run your own is here.
Got one running? Open a PR or issue with the link. I'll list notable builds in this section as they come in. Curious to see what people do with it.
src/main.cpp Firmware (Arduino framework)
data/ SD card contents: HTML, CSS, JS, images, favicon SVG
data/*.html.gz Pre-gzipped HTML (auto-generated, see below)
worker/worker.js Cloudflare Worker + Durable Object (relay, SSE, weather, badges)
worker/wrangler.toml Worker config
scripts/gzip_assets.py PlatformIO pre-build step that gzips data/*.html
platformio.ini Build config + cppcheck flags
.github/workflows/ CI: PlatformIO build, CodeQL, Lighthouse
.github/dependabot.yml Dependency update automation (GitHub Actions)
.github/ISSUE_TEMPLATE/ Issue chooser (security policy + discussions)
.github/assets/ README hero image
SECURITY.md Security disclosure policy
HTML files under data/ are pre-gzipped before upload. scripts/gzip_assets.py
runs automatically on every pio run (wired via extra_scripts in
platformio.ini) and produces .html.gz companions next to each source.
The firmware's beginResponseGzipOrRaw() helper checks for a .gz version on
SD and serves it with Content-Encoding: gzip when the client accepts it.
Falls back transparently to the uncompressed file if no .gz exists.
Why this matters: a 90+ KB uncompressed index.html served directly
over LAN can saturate LWIP's pbuf pool mid-stream. Under that pressure,
colliding writes on the outbound WebSocket to Cloudflare fail with EAGAIN,
and DNS lookups (which share the same pool) also start failing. Gzipping
drops wire size ~4× and keeps the pool clear.
When you edit an .html file, pio run regenerates the .gz automatically.
Upload both versions to the SD card (the file manager in the admin panel
handles this fine).
Bug reports and feature questions → Issues. For press or anything else → hello@tech1k.com.
MIT. See LICENSE.
more like this
Arduino library and MATLAB/Simulink API for the AutomationShield Arduino expansion boards for control engineering educa…
search projects, people, and tags