Case Studies
Case Study2026-02-18 · 5 min

SmartRoom — Dual Control Plane on an ESP32 with No Backend

Course brief was to replace Blynk with a custom dashboard. The interesting work showed up at the follow-up question: what about voice? The system ships as one ESP32 running two concurrent MQTT clients against two different brokers, with bidirectional state sync over both planes.

Problem

EEE 405 IoT lab brief: build a room automation system on an ESP32, replace the Blynk control interface with something custom. The class default is a single web dashboard. The stretch was Alexa voice control as a second input — which requires a completely different MQTT broker, a different control plane, and state sync between the two.

Constraint

  • Two weeks from spec to demo.
  • No backend server, no database, no third-party IoT platform mediating state. The system topology had to be: browser ↔ broker ↔ ESP32, end-to-end.
  • Either control plane failing (HiveMQ down, Sinric Pro down) must not take down the other. Relays stay reachable as long as one path is alive.
  • Browser → broker has to work over WebSocket. JS can't open raw MQTT TCP sockets.

Decisions

Two MQTT clients on one ESP32, concurrent

PubSubClient over MQTT/TLS to HiveMQ Cloud on port 8883 (the web dashboard's control plane). SinricPro library running its own WebSocket connection in parallel (Alexa's control plane). The same handler functions that flip the relays publish state updates back to HiveMQ so the dashboard reflects voice changes instantly and vice versa.

Dynamic import for the mqtt npm package

The mqtt package transitively imports Node.js modules (net, tls, fs), which breaks the Next.js build during SSR. The fix is two parts: webpack fallbacks in next.config.js to mark those modules as false client-side, and dynamic await import("mqtt") inside the useEffect instead of a top-level import. Both required for the page to build.

WebSocket over TLS on port 8884, not 1883

HiveMQ Cloud accepts plain MQTT on 8883 for the ESP32's PubSubClient and MQTT-over-WSS on 8884 for the browser. Hard-won knowledge after an hour of “why does my ESP32 work but the browser doesn't.”

SessionStorage, not localStorage

Auth lives in sessionStorage under smartroom_auth. Closing the tab logs you out. The deployment model — anyone with the URL has access during a session, no persistent credentials across browser restarts — matched the access pattern the room actually needed.

Two on-device rules running every 3 seconds

HC-SR04 ultrasonic reads presence: below 120 cm means someone in the room → light on. DHT11/22 reads temperature: above 30°C → fan on. Manual commands from either control plane override the rules until reset. State confirmations publish back so the dashboard is always ground-truth.

Outcome

  • Live deployment at eee-405-grp-1.vercel.app, controlling a real ESP32 with DHT11 + HC-SR04 + relay outputs for light + fan.
  • Alexa voice commands (“Alexa, turn on the smart light”) work end-to-end through Sinric Pro.
  • Dashboard state updates from web commands appear in the web UI; voice commands also appear in the web UI. The two planes are eventually consistent with sub-second lag in normal conditions.
  • Total stack: 1 ESP32 firmware file, 3 Next.js pages, 1 MQTT lib, 1 constants file. No backend.

Lessons

  • Multi-control-surface architecture is the same problem everywhere. Whether it's “dashboard + Alexa” or “web UI + admin API + Slack bot,” the pattern is identical: pick a source of truth, fan out, accept eventual consistency.
  • The browser→MQTT path was 80% of the bug surface. ESP32 firmware came together quickly; getting the mqtt package to play nicely with Next.js SSR + WSS + auth was where the days went.
  • The microcontroller can hold state. No backend doesn't mean no logic. The ESP32 runs the automatic rules, holds manual overrides, and is the only consistent authority on relay state. Both control planes are just RPCs against it.