Writing
Embedded2026-02-15 · 8 min

Dual Control Plane on a $4 ESP32: HiveMQ + Sinric Pro Without a Backend

EEE 405 IoT lab brief: take a Blynk-controlled room automation system and replace the control interface. Stretch goal: add Alexa voice control. Hard constraint: no backend server, no database, ships in two weeks. Here's how the architecture landed.

The brief, reframed

Course requirement was “replace Blynk with a custom dashboard.” That's a tame ask. The interesting part of the problem only shows up when you ask a follow-up: what about voice? That single addition changes the entire architecture, because Alexa needs a different MQTT broker (Sinric Pro's WebSocket relay), runs on a different control plane, and has to stay in sync with the web dashboard's state.

The end state is one ESP32 running two concurrent MQTT clients against two different brokers, both able to control the same relays, with bidirectional state sync over both planes.

Anti-goalNo backend server. No database. No third-party IoT platform doing state reconciliation in the middle. If a service goes down, the relays keep working from the other plane. The full system is: browser ↔ HiveMQ ↔ ESP32 ↔ relays + sensors, plus Alexa ↔ Sinric Pro ↔ ESP32 in parallel.

The architecture

Alexa ──WebSocket── Sinric Pro ──WebSocket── ESP32 ──MQTT/TLS── HiveMQ Cloud ──MQTT/WSS── Browser

Two MQTT clients on the ESP32:

  • PubSubClient over MQTT/TLS on port 8883 to HiveMQ Cloud. Web dashboard publishes commands on home/light and home/fan; ESP32 publishes sensor readings on home/temp, home/humid, home/distance, and state confirmations on home/state/light and home/state/fan.
  • SinricPro over WebSocket. Alexa device commands arrive as onPowerState callbacks. The same handler functions that flip the relay also publish state back to HiveMQ so the web dashboard reflects voice changes instantly.

The browser side has its own headaches

MQTT-from-a-browser sounds like it should be straightforward. It is not. Three things bit me:

1. Port 8884, not 1883

HiveMQ Cloud accepts plain MQTT/TCP on port 8883 and MQTT over WebSocket with TLS on port 8884. Browsers can only talk WebSocket — TCP sockets aren't exposed to JS. The broker URL has to be:

brokerUrl: "wss://YOUR_CLUSTER.s1.eu.hivemq.cloud:8884/mqtt"

2. Dynamic import or your build dies

The mqtt npm package transitively references Node.js modules — net, tls, fs. Importing it at module level breaks the Next.js build with “module not found” errors during SSR. The fix has two parts:

// 1. next.config.js — webpack fallbacks
module.exports = {
  webpack: (config, { isServer }) => {
    if (!isServer) {
      config.resolve.fallback = {
        ...config.resolve.fallback,
        net: false, tls: false, fs: false,
      };
    }
    return config;
  },
};

// 2. In the hook — dynamic import inside useEffect
useEffect(() => {
  let client;
  (async () => {
    const mqtt = await import("mqtt");
    client = mqtt.connect(MQTT_CONFIG.brokerUrl, {
      username: MQTT_CONFIG.username,
      password: MQTT_CONFIG.password,
      clientId: MQTT_CONFIG.clientId,
    });
    // ... subscribe + message handlers
  })();
  return () => client?.end();
}, []);

3. Session storage, not local storage

The dashboard is auth-guarded on the client. Session lives in sessionStorage under a single key. Local storage would have persisted credentials across tab close, which the room access model didn't want. This is the kind of decision that looks trivial in code review and matters a lot in deployment.

The autonomous control loop on the ESP32

Manual control via the dashboard or Alexa is one half. The other is two automatic rules running every 3 seconds on the microcontroller:

  • HC-SR04 ultrasonic reads room occupancy. Below 120 cm of clearance — someone's in the room — light comes on.
  • DHT11/22 reads temperature. Above 30°C, fan comes on.

Manual commands from either control plane take precedence and override the rules until reset. The state confirmations publish back to HiveMQ so the dashboard always shows ground truth, regardless of who issued the command.

Why this matters for an FDE

The brief was a course project. The pattern generalizes to almost every customer integration I've been near since: multiple control surfaces driving the same physical or logical state, with no single coordinator. Slack-bot + dashboard + cron-job, all touching the same Postgres table. Web UI + admin API + Salesforce sync, all driving the same record. The trick is the same every time: pick a source of truth for state, fan out from there, and accept that any client can be stale momentarily.

What I'd build next

  • [HomeKit alongside Alexa — three control planes on the ESP32. Memory budget is the constraint; profile what's actually costing flash.]
  • [Offline rule execution log — when WiFi drops, log rule firings to flash; flush on reconnect.]
  • [OTA firmware update from the dashboard with rollback if the new firmware doesn't check in within 60 seconds.]