Background
Residential gates usually fall into one of two extremes: a guard who waves cars through by eye, or a cloud-connected "smart" gate that stops working the moment the internet or the vendor's server goes down. Neither gives management a trustworthy record of who entered, when, and through which lane. Gatewise was built to sit between those extremes: an embedded controller that makes the open/deny decision on the device itself, plus a lightweight backend that records every event and manages the card list. The physical gate keeps working with no network, and the dashboard catches up whenever the network returns.Problem
- Network dependence: Gates that ask a server "may this card enter?" fail exactly when the link drops, which is when residents are stuck outside.
- No audit trail: Manual gates leave no record of denied attempts, emergency overrides, or card changes.
- Card management friction: Registering a new RFID card usually means typing a UID read from a separate tool.
- Constrained hardware: An ESP32 has only a few hundred KB of RAM. Running HTTPS in the same loop that drives servos and reads RFID can freeze the gate.
- Hosting cost: The system had to run on free tiers only, with no VPS or always-on server.
Solution
Gatewise splits responsibilities along the reliability boundary:- The ESP32 is the source of truth for access decisions. It keeps a local copy of the active card list in RAM and flash (NVS) and decides in-process.
- The backend is the source of truth for the card list and history. It authenticates the device, stores an idempotent event log, and serves the list of active cards.
- The two communicate through a deliberately small pull-based contract (three endpoints). There is no persistent connection, no broker to host, and nothing that can block the gate.
My Role
Solo Full-Stack / Embedded Engineer- Designed the system end to end: hardware wiring and pin map, firmware architecture, API contract, and database schema.
- Wrote the ESP32 firmware (C++/Arduino on FreeRTOS): RFID handling, gate state machines, offline event queue, card cache, and the network task.
- Built the device-facing API (device auth, idempotent event ingestion, card sync) and the admin API with Next.js route handlers.
- Designed the Postgres schema and migrations with Drizzle (constraints, indexes, foreign-key behavior).
- Built the dashboard and landing page, including an interactive 2D gate simulation and a 3D model of the installation.
- Deployed to Vercel and Neon on free tiers and validated the full loop in the Wokwi simulator.
Architecture
Gatewise has three layers: the ESP32 controller at the gate, a Next.js application on Vercel (API and dashboard), and a Neon Postgres database. The ESP32 talks to Vercel over HTTPS. The /api/device/* endpoints are called by the ESP32 with an x-device-key header, while the remaining /api/* endpoints are called by the dashboard with an admin session cookie. Vercel reads and writes Neon Postgres.- The decision path is local. The server is never in the open/deny path; the card lookup happens on the ESP32.
- Two lanes, one controller. Entry and exit each have their own RFID reader, servo, IR sensor, LEDs, and emergency button, driven by the same state machine instantiated twice.
- Pull, not push. The ESP32 POSTs events and pings, and GETs the active card list every 5 minutes. There is no server-to-gate command channel by design (no MQTT or WebSocket), so the device accepts no inbound connections and the hosting needs no persistent process.
- The database owns the card list, but the device may amend it. Changes made at the gate (admin mode) apply instantly on the ESP32 and are reported to the server as card_enrolled / card_revoked events, which the server applies to the cards table. There is no card-write endpoint for devices.
- Dual-core split on the ESP32. loop() runs the real-time work (RFID, servos, IR, LEDs, LCD, buttons). A FreeRTOS task pinned to core 0 owns all networking: event delivery, a 60-second ping, and a 5-minute card sync.
Hardware
- ESP32 DevKit C v4 (1): main controller, WiFi, and NVS storage.
- MFRC522 RFID readers (2): read cards on the entry and exit lanes. Both share one SPI bus and are told apart by their SS/RST pins.
- SG90 servos (2): barrier arms, driven by PWM.
- IR sensors (2): detect a vehicle passing so the barrier closes at the right moment.
- LCD 1602 I2C (1): shows status and the card owner's name.
- Green and red LEDs with 220 Ω resistors (4): granted/denied indicators for each lane.
- Push buttons (2): emergency toggle for each lane.
Technical Challenges
- Keeping the gate real-time while HTTPS runs. A single TLS request can take seconds; inside loop() it would freeze RFID polling and servo logic. Solution: Moved all networking into a dedicated FreeRTOS task pinned to core 0. loop() only appends to an in-RAM queue. Shared state (card list and event queue) is protected by mutexes. The card-list lock is a recursive mutex because the card helpers call each other, and it is held through an RAII guard so every return path releases it.
- Never losing or duplicating an access event across outages. Events must survive WiFi or server downtime, and retries must never create duplicate log rows. Solution: A 50-slot ring queue with an eviction policy (the oldest access event is dropped first; card-change events are preserved). Each event gets a deterministic eventId (<device>-<epoch>-<seq>). The server stores it behind UNIQUE(event_id) with ON CONFLICT DO NOTHING, so retries are idempotent. A permanent 400 response is dropped from the queue so one malformed event cannot block the rest. Delivered events are removed by sequence number rather than index, because the queue shifts while a request is in flight.
- Correct timestamps before the clock is valid. After a cold boot the ESP32 may record events before NTP has synced. Solution: Each event records millis() when created. Once NTP is valid, its real UTC time is reconstructed by subtracting the elapsed time, so offline-era events still carry accurate timestamps. Events are held back until a valid clock exists.
- Two writers to the card list: the gate and the server. Admin mode edits the local card list instantly, while the periodic server sync overwrites it. A naive sync would erase a card that was just enrolled at the gate but not yet reported. Solution: Sync is deferred while any card-change event is still queued, and the condition is re-checked inside the lock before overwriting, which closes the race where an event arrives mid-request. The server applies card events only when the log row is new, so a retried event can never undo a change an admin made on the dashboard in the meantime.
- Two RFID readers sharing one SPI bus. Both MFRC522 modules sit on the same SCK/MOSI/MISO lines. Solution: Distinguish them purely by SS/RST pins, initialize both explicitly, and halt the card (PICC_HaltA / StopCrypto1) right after reading the UID so a card left on the reader is not re-read in a loop.
- Serverless and HTTP database driver limits. Neon's HTTP driver has no interactive transactions, so "check duplicate → apply card change → insert log" cannot be one transaction. Solution: Ordered the steps so a failure in the middle is safe to retry: duplicate check first, then an idempotent card mutation, then the log insert. A re-send simply re-applies the same card change.
- Staying inside free-tier quotas. Neon auto-suspends when idle and Vercel meters invocations. Solution: Device ping is capped at 60 seconds and card sync at 5 minutes. Dashboard polling is 5 seconds and pauses when the tab is hidden, and the unregistered-UID endpoint is polled only while the "Add card" dialog is open.
Features
- Local, offline-capable access control: Decisions come from a cache in RAM mirrored to NVS, so the gate works with no WiFi and survives reboots.
- Independent entry and exit lanes: Each lane has its own reader, barrier, IR sensor, and LEDs. The barrier closes after the IR sensor sees a vehicle pass and clear (with a short delay), or after a timeout if nothing passes.
- Emergency toggle: A button per lane opens the barrier and holds it open (IR and timeout are ignored) until pressed again. It is logged as an emergency event.
- Admin card mode: Tapping the admin card enters admin mode (alternating red/green LEDs, "MODE ADMIN" on the LCD). A new card is enrolled; a known card is revoked. Both are reflected on the dashboard. The mode exits on its own after a period of inactivity.
- Unregistered-card discovery: Denied taps are logged with their UID and surface in the dashboard's "Add card" dialog, so registering a card is "tap it at the gate, click it on screen".
- Authenticated device API: Each device has a key; only a SHA-256 hash is stored, and it is compared in constant time (timingSafeEqual). The raw key is shown once at provisioning and can be rotated without losing logs.
- Device health: Ping updates last_seen. The dashboard shows Online/Offline (online means seen within the last 3 minutes) and the last activity per lane.
- Audit-friendly data model: Deleting a card never rewrites history; old logs show "card deleted" with the UID preserved. Revoking at the gate deactivates the card rather than deleting it.
- Admin dashboard: A filterable, paginated access log (state kept in the URL), card management with optimistic updates, and near-real-time refresh via 5-second polling.
- Interactive demos: A 2D simulation of the gate logic on the landing page and a Three.js model of the installation (loaded only on its own route).
Tech Stack
Embedded / Hardware:- ESP32 (Arduino framework) on FreeRTOS
- MFRC522 (SPI), ESP32Servo, LiquidCrystal_I2C
- Preferences (NVS) for the persistent card cache
- WiFiClientSecure + HTTPClient, ArduinoJson (with streaming filter), NTP time sync
- Wokwi for simulation, PlatformIO-compatible layout
- Next.js 16 route handlers (Node runtime), TypeScript strict
- Zod validation on every external input (body, query, env)
- Drizzle ORM + drizzle-kit migrations
- jose (JWT sessions) + bcryptjs, login rate limiting
- Route protection via proxy.ts, plus a per-handler session check as a second layer
- Neon Postgres (serverless HTTP driver)
- Vercel (Hobby), all on free tiers
- Constraints: UNIQUE(event_id), CHECK on gate/type, ON DELETE SET NULL for card references, indexes on (ts DESC), (gate, ts DESC), and (card_id)
- React, Tailwind CSS, shadcn/ui, SWR, Three.js (3D page only)
Results
- The end-to-end flow is validated in the Wokwi simulator: a registered card opens and closes the barrier via IR, an unknown card is denied and its UID appears in the dashboard, the emergency toggle holds the barrier open, and admin mode enrolls and revokes cards that are reflected in the dashboard.
- The failure mode that matters most is verified: with the server unreachable the gate keeps operating, events queue up, and they are delivered without duplicates once connectivity returns.
- The device endpoints are covered by a 16-case manual test matrix (auth failures, idempotent re-send, card lifecycle events, input validation, future-timestamp rejection).
- Runs entirely on free tiers with no always-on server.
Lessons Learned
- Design the failure mode first. Putting the decision on the device removed the hardest availability problem instead of trying to solve it with retries.
- Idempotency beats exactly-once. An unreliable link plus a unique event ID gives correct data without any distributed coordination.
- Concurrency on a microcontroller is real concurrency. Moving networking off the main loop required thinking about lock scope, lock re-entrancy, and re-checking conditions after acquiring a lock.
- Datasheets have opinions. Input-only pins, missing pull-ups, and strapping pins shaped the pin map before any code existed.
- Be honest in the data model. Keeping history immutable (deactivate, don't delete; preserve the UID on orphaned logs) made the dashboard's behavior simple and trustworthy.
Known Limitations
- The event queue lives in RAM, so a restart before delivery loses pending events (a card change made at the gate could then be overwritten by the next sync).
- HTTPS currently uses setInsecure() for the demo; production should pin a root CA.
- The firmware reads 4-byte UIDs only, although the API accepts 4-, 7-, and 10-byte UIDs.
- Card changes made on the dashboard reach the gate on the next sync (up to 5 minutes).
- Login rate limiting is in-memory per serverless instance.
Future Improvements
- Persistent event queue in NVS or LittleFS so pending events survive a reboot.
- TLS root CA pinning and device key rotation from the dashboard.
- A push channel for card changes (MQTT or long polling) to shorten the 5-minute sync window, if the hosting budget allows.
- OTA firmware updates with signed images and rollback.
- Multi-gate / multi-site support and 7/10-byte UID support in the firmware.
- Camera / ALPR for plate capture, and notifications (Telegram/WhatsApp) on repeated denials or emergency openings.