Build Guide · Draft

Custom Controller for
Broan B210E75RT AI ERV

Full clean-room reverse engineering of the VTouch RS-485 protocol, followed by a staged replacement with an ESP32-S3 RS-485 master running an MQTT broker, a web dashboard, and drop-in VTouch-emulator panels — all without the ERV knowing anything changed.

Broan B210E75RT ESP32-S3 RS-485 · Modbus RTU MQTT WT32-SC01 Plus

What You End Up With

Controller Node

ESP32-S3 as the only RS-485 master. Talks directly to the ERV, hosts MQTT broker + web UI.

VTouch Emulator Bridge

Second ESP32-S3 intercepts the original VTouch, translates its commands to MQTT, and feeds it fabricated responses so it still works normally.

Replacement Panels

WT32-SC01 Plus touchscreens that look and feel like the VTouch but run your firmware. Multiple panels possible.

Web Dashboard

Hosted on the controller at http://erv.local. Live state, controls, audit log, config — from any browser on the LAN.

Your Hardware: Broan B210E75RT

Unit Specs (Relevant to This Build)

ModelBroan B210E75RT
CFM206 CFM max (top-port mount)
Core typeHRV (Heat Recovery Ventilator)
Controller port4-wire terminal block — A, B, GND, +12 V DC
ProtocolRS-485, Modbus RTU (VTouch confirmed)
Baud rate9600 8N1 (confirm during capture phase)
Slave address0x01 (assumed — verify from capture)
VTouch powerDrawn from ERV 12 V line

VTouch Terminal Wiring

ERV terminal block (inside unit):
  T1 = RS-485 A (non-inverting)
  T2 = RS-485 B (inverting)
  T3 = GND
  T4 = +12 V DC (VTouch power)

VTouch cable = 4-conductor, ~22 AWG
Max stated run: 100 ft / 30 m
Confirm pin labels by tracing from ERV terminal block to VTouch connector before cutting any wires.

Master Parts List

Everything needed across all stages. Buy Stage 0–1 parts first — later stages depend on what you learn from capture.

Stage 0–1 · Tools & RE Capture Hardware

PartPurposeWhere to BuyCost
Seeed XIAO ESP32-S3 (with PSRAM) VTouch bridge / RE capture board. Two free UARTs, tiny footprint, 3.3 V logic. Seeed Studio · Amazon $8–10
2× MAX3485 breakout (3.3 V RS-485) One for VTouch side, one for ERV side. Must be 3.3 V compatible — DO NOT use MAX485 (5 V only). Amazon · AliExpress $5–8 (pair)
Perfboard / protoboard Temporary build. A half-size breadboard also works for capture phase. Any electronics supplier $2–4
Dupont jumper wire set (M/F, M/M) Connecting ESP32 ↔ RS-485 transceivers during capture phase. Amazon $5–8
4-wire terminal breakout or screw terminal strip Tap into the VTouch cable without cutting. Use a T-tap or wire-splice connector. Home Depot / Amazon $3–6
USB-C cable + laptop Flash firmware, monitor serial output during capture. On hand —
MQTT Explorer (desktop app) Visualize captured frames in real time over Wi-Fi during the RE session. mqtt-explorer.com (free) Free
Multimeter Verify RS-485 line voltages before connecting the bridge. On hand —

Stage 0–1 subtotal: ~$20–30

Stage 2–3 · Controller ESP32-S3 (RS-485 Master + MQTT Broker + Web Server)

PartPurposeWhere to BuyCost
Adafruit Feather ESP32-S3 (4 MB Flash, 2 MB PSRAM) Main controller. Dual UART, enough RAM for broker + web server simultaneously. PSRAM gives headroom for WebSocket clients + audit log. Adafruit #5477 · Amazon $17–20
1× MAX3485 breakout Controller's RS-485 interface to ERV. One transceiver only needed here. Same as above $3–5
120 Ω termination resistor (1/4 W) RS-485 line termination at the controller end if cable run is long (> 10 ft). Parts bin / Amazon (resistor kit) <$1
5 V DC power supply (USB-C or 5.5/2.1 mm barrel, 1 A min) Power the controller node from the same panel box or nearby outlet. Or derive from ERV 12 V via a buck module. Amazon $6–10
Mini buck converter (12 V → 5 V, 1 A) Optional: power controller from ERV 12 V terminal. Use an LM2596 or MP1584 module. Amazon $5–8 (5-pack)
Small project enclosure (Hammond 1591 or similar) Mount controller near ERV or in panel. ABS plastic is fine. Amazon · Digikey $6–12

Stage 2–3 subtotal: ~$35–55

Stage 4 · Tier 3 Replacement Panels (per panel — buy 1 or 2)

PartPurposeWhere to BuyCost
WT32-SC01 Plus (3.5" 480×320 cap. touch, ESP32-S3) Recommended. All-in-one ESP32-S3 + 3.5" IPS capacitive touchscreen. LVGL runs out of the box. Proven community support. Acts as a wall-panel client on the MQTT network. AliExpress (search WT32-SC01 Plus) · Amazon $32–38
Alternative: Waveshare ESP32-S3-Touch-LCD-2.8" Smaller 2.8" 320×240 cap. touch, slightly lower cost. Less screen real estate for the dashboard but easier to fit in a standard mud ring. Waveshare · Amazon $28–34
3D-printed Decora-style faceplate Flush-mount the WT32-SC01 Plus behind a standard single-gang Decora opening. Print in white PLA or paint to match. Print yourself (Bambu / Prusa / JLCPCB) — Thingiverse has WT32-SC01 Plus frames $2–5 filament
Single-gang old-work electrical box Mount in drywall without opening the wall. Route USB-C cable through knockout. Home Depot / Lowes $3–5
In-wall USB-C outlet (5 V 2 A) Power the panel from inside the wall cavity. Or piggyback from a nearby outlet using low-voltage cable. Amazon $12–18
Short USB-C cable (6–12 in angled) Panel power inside the wall cavity. Angled plug keeps the unit flat. Amazon $6–9

Per panel subtotal: ~$55–75

Minimum Build (1 panel)

~$110–160

Stages 0–4, one WT32-SC01 Plus

Two Panels

~$165–235

Duplicate the wall panel for two locations

With Original VTouch Kept

Add ~$0

VTouch stays in circuit as emulated client

Final System Architecture

┌──────────────────┐    ┌──────────────────────┐    ┌─────────────────────────┐
│  Original VTouch │    │  WT32-SC01 Plus #1    │    │  WT32-SC01 Plus #2      │
│  (still works,   │    │  (Replacement panel   │    │  (2nd location,         │
│   thinks it's    │    │   runs your firmware) │    │   optional)             │
│   a real master) │    └──────────┬───────────┘    └────────────┬────────────┘
└────────┬─────────┘               │ Wi-Fi MQTT                  │ Wi-Fi MQTT
         │ RS-485                  └──────────────┬──────────────┘
         │                                        │
┌────────▼─────────┐    Wi-Fi MQTT   ┌────────────▼────────────────────────────┐
│  ESP32-S3 #2     │◄───────────────►│  ESP32-S3 #1  (CONTROLLER)             │
│  VTouch Bridge   │                 │  ┌─────────────────────────────────────┐│
│  - Intercepts    │                 │  │ MQTT Broker  (uMQTTBroker/Mosquitto)││
│    VTouch RS-485 │                 │  │ RS-485 Master → ERV                 ││
│  - Feeds VTouch  │                 │  │ ESPAsyncWebServer → http://erv.local ││
│    fake responses│                 │  │ LittleFS → static dashboard UI      ││
│    from MQTT state                 │  └─────────────────────────────────────┘│
└──────────────────┘                 └────────────────────┬────────────────────┘
                                                          │ RS-485 (Modbus RTU)
                                                ┌─────────▼──────────┐
                                                │  Broan B210E75RT   │
                                                │  ERV Unit          │
                                                └────────────────────┘

Only One RS-485 Master

ESP32-S3 #1 is the sole Modbus master. Nothing else writes to the ERV bus directly.

VTouch Sees No Change

The bridge responds to the VTouch's Modbus reads/writes from a cached state mirror. VTouch thinks it's still the master.

All Clients in Sync

Controller publishes authoritative state to erv/state (retained). Every client — VTouch bridge, panels, browser — stays synchronized.

Build Stages at a Glance

0

Setup

Toolchain, libraries, test blink on both ESP32-S3 boards

1

Passive Capture

Bridge listens between VTouch & ERV. Map every register.

2

Active Proxy

Bridge forwards frames. Controller begins sending commands directly.

3

Full Takeover

Controller is sole RS-485 master. Bridge emulates ERV responses to VTouch.

4

Replacement Panels

WT32-SC01 Plus panels installed. VTouch optionally retained or removed.

0

Stage 0 — Development Environment Setup

Est. time: 1–2 hours · Do this before touching the ERV.

0.1 — Install Toolchain

  1. Install VS Code + PlatformIO extension. This is the recommended toolchain for this build — better library management than Arduino IDE and supports both boards.
  2. Or use Arduino IDE 2.x with the ESP32 board package by Espressif (version 2.x or 3.x) installed via Boards Manager. URL: https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json
  3. Install required libraries (PlatformIO: add to platformio.ini · Arduino IDE: Library Manager):
    • knolleary/PubSubClient — MQTT client
    • me-no-dev/ESPAsyncWebServer + me-no-dev/AsyncTCP — web server + WebSocket
    • olehs/UMODBUS or eModbus/eModbus — Modbus RTU master (Stage 2+)
    • ArduinoJson — JSON serialization
    • ESPmDNS — built-in with ESP32 package
  4. For Stage 4 (WT32-SC01 Plus panels): install LVGL 8.x and the WT32-SC01 Plus board definitions.

0.2 — Flash Test Both Boards

Before touching the ERV, verify both ESP32-S3 boards enumerate over USB and accept a basic blink sketch. Identify serial port assignments on your machine.

Board: "XIAO_ESP32S3"  (for capture bridge board)
Board: "Adafruit Feather ESP32-S3"  (for controller board)
Upload speed: 921600
USB CDC on Boot: Enabled  ← important for Serial monitor on S3

0.3 — Wire the Capture Bridge (Bench Test First)

Build and test the bridge on the bench before connecting it to the ERV. Use two 3.3 V MAX3485 breakouts and the XIAO ESP32-S3.

XIAO ESP32-S3 GPIO pins used:
  GPIO4  = UART1_RX  (from MAX3485 #1 RO — VTouch side)
  GPIO5  = UART1_TX  (to MAX3485 #1 DI  — VTouch side)
  GPIO6  = DIR1      (DE/RE control for MAX3485 #1)

  GPIO7  = UART2_RX  (from MAX3485 #2 RO — ERV side)
  GPIO8  = UART2_TX  (to MAX3485 #2 DI  — ERV side)
  GPIO9  = DIR2      (DE/RE control for MAX3485 #2)

MAX3485 wiring (each transceiver):
  VCC → 3.3 V
  GND → GND
  A   → RS-485 A terminal
  B   → RS-485 B terminal
  RO  → ESP32-S3 RX pin
  DI  → ESP32-S3 TX pin
  DE+RE tied together → DIR GPIO  (tie low for receive-only in Stage 1)
Stage 1 is listen-only. Tie DE low and RE low on BOTH transceivers during capture phase. The bridge cannot transmit — it can only observe. This is the "clean room" constraint that prevents accidental ERV commands during RE.
1

Stage 1 — Passive Capture & Protocol Mapping

Est. time: 2–4 hours · VTouch remains in circuit. Bridge is read-only.

1.1 — Physical Install of the Bridge

  1. Power down the ERV at the breaker.
  2. Open the ERV service panel. Locate the VTouch terminal block (A, B, GND, +12 V).
  3. Use T-tap wire connectors or a 4-way terminal splitter to insert the bridge in series between the ERV terminal and the VTouch cable — do not cut the VTouch cable.
  4. Connect bridge wiring:
    ERV terminal ─── [T-tap A] ──┬── VTouch cable A
                                  └── MAX3485 #2 (ERV side) A
    
    ERV terminal ─── [T-tap B] ──┬── VTouch cable B
                                  └── MAX3485 #2 (ERV side) B
    
    VTouch cable A ──────────────── MAX3485 #1 (VTouch side) A
    VTouch cable B ──────────────── MAX3485 #1 (VTouch side) B
    ERV GND ─────────────────────── Bridge GND
    ERV +12 V ──── Buck 12→5 V ──── Bridge USB-C / 5 V rail
  5. Power up the ERV. VTouch should function normally. The bridge is invisible — it only listens.

1.2 — Stage 1 Firmware: Passive Capture

// platformio.ini board: seeed_xiao_esp32s3
// Both UARTs receive-only. DE=LOW, RE=LOW on both MAX3485 boards.

#include <WiFi.h>
#include <PubSubClient.h>

#define UART_BAUD   9600
#define RX_VTOUCH   4     // UART1 RX (VTouch side)
#define TX_VTOUCH   5     // UART1 TX (unused in Stage 1)
#define DIR_VTOUCH  6     // DE/RE — keep LOW
#define RX_ERV      7     // UART2 RX (ERV side)
#define TX_ERV      8     // UART2 TX (unused in Stage 1)
#define DIR_ERV     9     // DE/RE — keep LOW

HardwareSerial vtSerial(1);   // UART1 — VTouch side
HardwareSerial ervSerial(2);  // UART2 — ERV side

const char* WIFI_SSID = "YOUR_SSID";
const char* WIFI_PASS = "YOUR_PASSWORD";
const char* MQTT_HOST = "192.168.1.XXX"; // controller ESP32 IP (temp broker)

WiFiClient   net;
PubSubClient mqtt(net);

uint8_t vtBuf[64], ervBuf[64];
uint8_t vtLen = 0, ervLen = 0;
uint32_t vtLast = 0, ervLast = 0;
const uint32_t FRAME_GAP_MS = 5; // 3.5 char times @ 9600 baud ≈ 4 ms

void setup() {
  Serial.begin(115200);
  pinMode(DIR_VTOUCH, OUTPUT); digitalWrite(DIR_VTOUCH, LOW);
  pinMode(DIR_ERV,    OUTPUT); digitalWrite(DIR_ERV, LOW);
  vtSerial.begin(UART_BAUD, SERIAL_8N1, RX_VTOUCH, TX_VTOUCH);
  ervSerial.begin(UART_BAUD, SERIAL_8N1, RX_ERV, TX_ERV);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  while (WiFi.status() != WL_CONNECTED) delay(200);
  mqtt.setServer(MQTT_HOST, 1883);
  mqtt.connect("erv-bridge");
}

void publishFrame(const char* topic, uint8_t* buf, uint8_t len, uint32_t ts) {
  char json[200];
  char hex[100] = {};
  for (int i = 0; i < len; i++) sprintf(hex + i*3, "%02X ", buf[i]);
  snprintf(json, sizeof(json),
    "{\"ts\":%lu,\"hex\":\"%s\",\"len\":%d}", ts, hex, len);
  mqtt.publish(topic, json);
  Serial.println(json); // also to serial monitor
}

void drainUart(HardwareSerial& s, uint8_t* buf, uint8_t& len,
               uint32_t& lastMs, const char* topic) {
  while (s.available()) {
    buf[len++] = s.read();
    lastMs = millis();
  }
  if (len > 0 && (millis() - lastMs) > FRAME_GAP_MS) {
    publishFrame(topic, buf, len, lastMs);
    len = 0;
  }
}

void loop() {
  if (!mqtt.connected()) mqtt.connect("erv-bridge");
  mqtt.loop();
  drainUart(vtSerial,  vtBuf,  vtLen,  vtLast,  "erv/raw/vt");
  drainUart(ervSerial, ervBuf, ervLen, ervLast, "erv/raw/erv");
}

Flash this, open MQTT Explorer pointed at the broker, then operate the VTouch through every function.

1.3 — Capture Session Checklist

Run through each action multiple times. Let the unit idle between each group to capture poll cycles clearly. Log to a file in MQTT Explorer (File → Export).

  • ☐ Idle for 3 minutes (captures poll cadence)
  • ☐ Off → Low fan speed
  • ☐ Low → Medium fan speed
  • ☐ Medium → High fan speed
  • ☐ High → Off
  • ☐ Activate Boost mode (note timer)
  • ☐ Cancel Boost manually
  • ☐ Let Boost timer expire naturally
  • ☐ View supply + exhaust temp display
  • ☐ View humidity readout
  • ☐ Check filter status screen
  • ☐ Power cycle VTouch — capture init sequence

1.4 — Decoding Modbus RTU Frames

All frames follow standard Modbus RTU framing. Decode each captured hex string:

── Read Request (VTouch → ERV) ──
[Addr][FC  ][RegHi][RegLo][CntHi][CntLo][CRC_Lo][CRC_Hi]
  01    03    00     01     00     06      XX       XX
FC 03 = Read Holding Registers
Reg 0x0001, count 6 → reading 6 registers starting at 0x0001

── Read Response (ERV → VTouch) ──
[Addr][FC  ][ByteCnt][Data × N bytes ][CRC_Lo][CRC_Hi]
  01    03     0C     [12 bytes / 6 regs]  XX     XX

── Write Request (VTouch → ERV) ──
[Addr][FC  ][RegHi][RegLo][ValHi][ValLo][CRC_Lo][CRC_Hi]
  01    06    00     01     00     03      XX       XX
FC 06 = Write Single Register
Reg 0x0001, value 0x0003 → set mode to High (or index 3)

── Write Response (ERV echoes request) ──
  01    06    00     01     00     03      XX       XX
Values like 0x02AC (684) on a temperature register likely mean 68.4 °F (°F × 10). Values like 0x0034 (52) on a humidity register are direct percentage. Document each register as you confirm it.

1.5 — Build Your Register Map

Fill this in from your capture session. Mark each row as field-verified with a date once confirmed.

Register FC Description Values / Scale Status
0x0001R/WOperating mode0=Off 1=Low 2=Med 3=High 4=Boostinferred
0x0002RFan speed actualRPM or index — TBD from captureTBD
0x0003RSupply air temp°F × 10inferred
0x0004RExhaust air temp°F × 10inferred
0x0005RRelative humidity% (0–100)inferred
0x0006RFilter status0=OK 1=Replaceinferred
0x0007R/WBoost timerMinutes remaininginferred
0x0008RDefrost active0/1 booleanTBD
0x0009–0x001F?Unknown — log raw values—TBD

Also record: confirmed baud rate, slave address, poll interval (ms between VTouch reads), ERV response time (ms).

2

Stage 2 — Controller Build & Active Proxy

Est. time: 4–8 hours · Build the controller ESP32-S3. Bridge begins forwarding.

2.1 — Wire the Controller (ESP32-S3 #1)

Adafruit Feather ESP32-S3 GPIO assignments:
  GPIO17 = UART1_RX  ← MAX3485 RO (ERV RS-485 A/B)
  GPIO18 = UART1_TX  → MAX3485 DI
  GPIO15 = DIR_ERV   → MAX3485 DE+RE tied (HIGH = transmit, LOW = receive)

MAX3485 → ERV terminal block:
  A → ERV T1
  B → ERV T2
  GND → ERV T3

Power: 5 V USB-C or 12V ERV → buck → 5 V → USB-C input
In Stage 2, the VTouch bridge is ALSO connected to the ERV bus (the capture wiring from Stage 1 is still in place). The controller will be a second Modbus master. In Stage 2 only send commands when you know the VTouch is not mid-poll. Stage 3 resolves this by removing the VTouch from the real ERV bus.

2.2 — Controller Firmware: MQTT Broker + RS-485 Master

// Controller ESP32-S3 #1 — Stage 2 sketch outline
// Libraries: PubSubClient, ESPAsyncWebServer, eModbus, ArduinoJson, ESPmDNS

// ── MQTT Broker ──────────────────────────────────
// Use uMQTTBroker (https://github.com/martin-ger/uMQTTBroker)
// Add to lib_deps: martin-ger/uMQTTBroker
#include <uMQTTBroker.h>
// Call uMQTT_startBroker(1883); in setup() after WiFi connect
// This ESP32 IS the broker — no external broker needed.

// ── RS-485 Modbus RTU Master ─────────────────────
// eModbus library: https://github.com/eModbus/eModbus
#include <ModbusClientRTU.h>
ModbusClientRTU MB(Serial1, DIR_ERV_PIN);
// MB.begin(9600); // baud confirmed from Stage 1

// ── Poll Loop (every 5 s) ──────────────────────
void pollERV() {
  // FC03: read 8 registers from 0x0001
  MB.addRequest(TOKEN_POLL, ERV_ADDR, READ_HOLD_REGISTER, 0x0001, 8);
}

// ── On Modbus Response ─────────────────────────
void onData(ModbusMessage response, uint32_t token) {
  // Parse registers into state struct
  // Publish to erv/state (retained=true)
  // Push via WebSocket: ws.textAll(stateJson)
}

// ── On MQTT Command ────────────────────────────
void onCommandReceived(const char* topic, const char* payload) {
  // Parse JSON → command + params
  // Validate (check register bounds, enum values)
  // Queue for next available RS-485 slot
  // Publish ACK to erv/command/ack/{client_id}
}

// ── Arbitration (simple FIFO, priority, TTL) ──
// See queue section below

2.3 — Upgrade Bridge to Active Proxy

In Stage 2 the bridge still passes VTouch commands through to the ERV, but it also translates them to MQTT so the controller is aware.

VTouch READ  → Bridge forwards bytes verbatim to ERV side
             ← ERV response forwarded back to VTouch
             → Decoded frame published to erv/audit (info only)

VTouch WRITE → Bridge:
  1. Forwards to ERV (executes on real unit)
  2. Publishes decoded command to erv/command/request
  3. Forwards ERV's WRITE echo back to VTouch
This is the validation phase. Watch the controller's MQTT subscription to erv/command/request and confirm it is decoding VTouch write frames correctly before proceeding to Stage 3.

2.4 — Web Server + Dashboard (Set Up Now)

Stand the web server up in Stage 2 so you can validate state from a browser while the VTouch is still in control.

// In controller firmware setup():
#include <ESPAsyncWebServer.h>
#include <LittleFS.h>
#include <ESPmDNS.h>

AsyncWebServer server(80);
AsyncWebSocket ws("/ws/state");

void setup() {
  // ...WiFi, broker, Modbus...
  MDNS.begin("erv"); // → http://erv.local
  LittleFS.begin();
  server.serveStatic("/", LittleFS, "/www/").setDefaultFile("index.html");
  server.addHandler(&ws);
  server.on("/api/state", HTTP_GET, [](AsyncWebServerRequest* req){
    req->send(200, "application/json", buildStateJson());
  });
  server.on("/api/command", HTTP_POST, /* parse + enqueue */);
  server.begin();
}

Upload your dashboard HTML/JS to data/www/index.html in the PlatformIO project — it gets written to LittleFS via pio run --target uploadfs.

3

Stage 3 — Controller Takeover & VTouch Emulation

Est. time: 2–4 hours · Controller becomes sole RS-485 master. Bridge feeds VTouch fabricated responses.

3.1 — Rewire the Bridge

Remove the bridge's tap on the ERV side. The controller is now the only device on the ERV RS-485 bus. The bridge only talks to the VTouch.

Before Stage 3:
  VTouch ── Bridge (pass-through) ── ERV

After Stage 3:
  VTouch ── Bridge (VTouch emulator)
                                      Controller ── ERV

The bridge now has a single MAX3485 (VTouch side only). It no longer has physical access to the ERV bus.

3.2 — Bridge Firmware: VTouch Emulator

The bridge now acts as a fake ERV slave to the VTouch. It answers all of VTouch's Modbus reads from a local state cache that is kept in sync with erv/state via MQTT. VTouch writes are translated to MQTT commands and sent to the real controller.

// Bridge ESP32-S3 #2 — Stage 3: VTouch emulator
// Only one UART now (VTouch side). DE/RE controlled for half-duplex.

// ── State cache (mirror of erv/state) ──────────
struct ERVState {
  uint8_t  mode;          // 0–4
  uint16_t supply_temp;   // °F × 10
  uint16_t exhaust_temp;  // °F × 10
  uint8_t  humidity;      // %
  uint8_t  filter_status; // 0=ok 1=replace
  uint16_t boost_remaining;
  uint8_t  defrost;
} cache;

// ── MQTT: subscribe to erv/state, update cache ──
void onMqttState(const char* payload) {
  // Parse JSON → update cache fields
}

// ── Modbus slave responder ────────────────────
// Listen on VTouch serial for requests
// When VTouch sends FC03 (read), reply with cache values
// When VTouch sends FC06 (write), publish to erv/command/request
//   then reply with Modbus write echo (confirm to VTouch immediately)

void handleVTouchRequest(uint8_t* req, uint8_t len) {
  uint8_t fc  = req[1];
  uint16_t reg = (req[2] << 8) | req[3];

  if (fc == 0x03) {          // READ — answer from cache
    uint8_t resp[64];
    uint8_t cnt = buildReadResponse(reg, req[4]<<8|req[5], cache, resp);
    sendRS485(resp, cnt);
  } else if (fc == 0x06) {   // WRITE — forward via MQTT
    uint16_t val = (req[4] << 8) | req[5];
    publishCommand(reg, val);
    // Echo write confirmation back to VTouch immediately
    uint8_t echo[8];
    memcpy(echo, req, 6);
    appendCRC(echo, 6);
    sendRS485(echo, 8);
  }
}
The VTouch poll interval is ~5 seconds. The controller also polls the ERV every 5 seconds and publishes to erv/state. The bridge's cache will be at most 5–10 seconds stale — completely imperceptible to the VTouch.

3.3 — Command Arbitration on the Controller

All commands — from VTouch bridge, replacement panels, and web UI — arrive on erv/command/request. The controller serializes them before issuing RS-485 writes.

Arbitration Rules

  1. Lower priority number wins (0 = highest)
  2. Same priority → later timestamp wins
  3. Invalid register/value → reject outright
  4. Superseded commands get status: superseded ACK

Queue Params

  • FIFO depth: 5 entries
  • Per-entry TTL: 10 seconds
  • One RS-485 transaction at a time
  • Expired entries → status: expired ACK

3.4 — Smoke Test Checklist

  • ☐ VTouch shows correct mode, temps, humidity (sourced from bridge cache)
  • ☐ Pressing a mode button on VTouch → command appears in MQTT → ERV changes mode → controller publishes new state → bridge cache updates → VTouch confirms on next poll
  • ☐ Browser at http://erv.local shows matching state in real time
  • ☐ Command sent from web UI changes ERV mode → VTouch reflects update within one poll cycle
  • ☐ Boost from VTouch, cancelled from web UI — both sides reflect correctly
  • ☐ Power cycle controller — VTouch remains functional (bridge queues reads, reconnects to broker)
4

Stage 4 — Replacement Wall Panels (WT32-SC01 Plus)

Est. time: 4–8 hours per panel · VTouch optionally retained or decommissioned.

4.1 — Why WT32-SC01 Plus

  • Integrated: ESP32-S3 + 3.5" 480×320 IPS + capacitive touch, all in one PCB
  • LVGL ready: Works with LVGL 8.x out of the box, extensive community examples
  • Well supported: Arduino + ESP-IDF, active GitHub repo from Wireless-Tag
  • Right size: 3.5" gives a genuinely usable dashboard — 320×240 (2.8") can feel cramped
  • Mountable: 3D-printed Decora faceplate files exist for it; fits standard single-gang
  • Powered by USB-C: route a short cable through the wall cavity from a nearby outlet or in-wall USB adapter

4.2 — Physical Installation

  1. Cut a single-gang opening in drywall at the desired location (where VTouch was, or new location).
  2. Install an old-work single-gang electrical box. Run USB-C power cable through a low-voltage opening or the knockout.
  3. If replacing the original VTouch location: the existing VTouch cable (A/B/GND/12V) can either be capped (if bridge emulator handles the VTouch) or repurposed for panel power if convenient.
  4. 3D print the Decora-size faceplate for the WT32-SC01 Plus. Secure the board to the faceplate with M2 standoffs (or hot-glue for prototype).
  5. Connect USB-C, fold into the box, snap the faceplate to the Decora plate, and screw to the single-gang box.
The WT32-SC01 Plus draws about 300–400 mA at 5 V when the display is on full brightness. A 1 A in-wall USB adapter is sufficient. Dim the display to 30–40% in the firmware to reduce power and prevent image retention.

4.3 — Panel Firmware (LVGL + MQTT Client)

// WT32-SC01 Plus — platformio.ini
// board: esp32s3box  (or use WT32-SC01-PLUS custom board def)
// lib_deps:
//   lvgl/lvgl@^8.3.0
//   knolleary/PubSubClient
//   ArduinoJson

// ── LVGL UI layout ───────────────────────────
// Screen: 480 × 320 px
// Top bar (40px): mode label + connection dot
// Center (180px): large current mode pill buttons
//   [ OFF ] [ LOW ] [ MED ] [ HIGH ] [ BOOST ]
// Sensor row (60px): supply °F | exhaust °F | humidity %
// Bottom bar (40px): boost timer countdown (hidden when not in boost)
//                    + settings gear icon (opens drawer)

// ── Mode buttons (LVGL lv_btnmatrix or lv_btn grid) ──
static const char* MODE_LABELS[] = { "OFF", "LOW", "MED", "HIGH", "BOOST" };
// On press: publish to erv/command/request, show pending spinner

// ── State sync via MQTT ───────────────────────
void onMqttState(const char* payload) {
  // Parse JSON → update LVGL labels + active button highlight
  // lv_label_set_text(temp_supply_lbl, "68.4°F");
  // lv_obj_add_state(mode_btn[mode], LV_STATE_CHECKED);
}

// ── Offline indicator ─────────────────────────
// If MQTT disconnects > 10 s: overlay red banner "Reconnecting…"

// ── Settings drawer ───────────────────────────
// Swipe up from bottom bar to reveal:
//   Poll interval slider, display brightness slider,
//   broker IP display, firmware version

4.4 — UI Sketch (480 × 320)

┌────────────────────────────────────────────────────────────────┐
│  ● ERV  ·  High Speed          🌡 Supply: 68.4°F   [●] online  │  ← top bar
├────────────────────────────────────────────────────────────────┤
│                                                                │
│   ┌──────┐  ┌──────┐  ┌──────┐  ┌ ──── ┐  ┌──────┐          │
│   │  OFF │  │  LOW │  │  MED │  │ HIGH │  │BOOST │          │  ← mode pills
│   └──────┘  └──────┘  └──────┘  └──────┘  └──────┘          │
│                           ↑ active = filled                   │
├────────────────────────────────────────────────────────────────┤
│  Supply: 68.4°F    Exhaust: 71.2°F    Humidity: 52%           │  ← sensor row
├────────────────────────────────────────────────────────────────┤
│  [Boost: 18 min remaining ████████░░░░░░░░░░░░]         ⚙    │  ← boost bar
└────────────────────────────────────────────────────────────────┘

4.5 — Multi-Panel Setup

Each additional WT32-SC01 Plus is an independent MQTT client. Add one per location — upstairs hall, kitchen, wherever you want VTouch-like control. All panels stay in sync via erv/state retained topic.

  • Each panel has its own client_id (e.g. panel-kitchen, panel-hallway)
  • Use captive-portal first-boot config to set Wi-Fi credentials without reflashing
  • OTA update all panels from a single binary via ArduinoOTA or custom HTTP endpoint
  • If you keep the original VTouch: its commands arrive via the bridge with client_id: vttouch-bridge and participate in normal arbitration — same priority as any other client

MQTT Topic Schema

TopicDirectionRetainedDescription
erv/command/requestAny client → ControllerNoCommand payload from any client (panel, web, bridge)
erv/command/ack/{client_id}Controller → ClientNoPer-client ACK: accepted / rejected / superseded / expired
erv/stateController → AllYesAuthoritative ERV state snapshot — clients sync on reconnect
erv/auditController → AllNoAppend-only event log (command + outcome + RS-485 raw)
erv/errorsController → AllNoBus faults, rejected commands, Modbus errors
erv/raw/vtBridge → ControllerNoStage 1 only — raw hex frames from VTouch (RE capture)
erv/raw/ervBridge → ControllerNoStage 1 only — raw hex frames from ERV (RE capture)
erv/config/clients/{client_id}Admin → ControllerYesPer-client config: read-only flag, priority override
erv/controller/statusController → AllYesHeartbeat + uptime + broker client count

Command Request

{
  "client_id": "panel-kitchen",
  "request_id": "a3f9c1",
  "timestamp": 1749500000,
  "command": "set_mode",
  "params": {
    "mode": "boost",
    "duration_minutes": 20
  },
  "priority": 1
}

ERV State (retained)

{
  "timestamp": 1749500005,
  "mode": "boost",
  "fan_speed": 3,
  "supply_temp_f": 68.4,
  "exhaust_temp_f": 71.2,
  "humidity_pct": 52,
  "boost_remaining_min": 19,
  "filter_status": "ok",
  "defrost_active": false,
  "bus_ok": true,
  "last_command_source": "panel-kitchen"
}

Web Dashboard (Hosted on Controller)

Access at http://erv.local from any browser on your LAN. Static files served from LittleFS, live state pushed via WebSocket — no polling needed.

REST Endpoints

  • GET /api/state — current state JSON
  • POST /api/command — send a command
  • GET /api/audit?limit=50 — recent log
  • GET /api/clients — online client list
  • GET /api/registers — raw register dump

Dashboard Panels

  • Live status: mode, speed, temps, humidity, filter
  • Control: mode buttons + boost duration picker
  • Audit log: last 50 commands with outcome + source
  • Clients: online panels, last seen, broker stats
  • Admin: poll interval, read-only toggles, CSV export

WebSocket Live Update (Browser JS)

const ws = new WebSocket(`ws://${location.host}/ws/state`);
ws.onmessage = (e) => {
  const state = JSON.parse(e.data);
  document.getElementById('mode').textContent    = state.mode;
  document.getElementById('supply').textContent  = state.supply_temp_f + '°F';
  document.getElementById('exhaust').textContent = state.exhaust_temp_f + '°F';
  document.getElementById('humidity').textContent = state.humidity_pct + '%';
  document.getElementById('boost-bar').style.display =
    state.mode === 'boost' ? 'block' : 'none';
};

// Send command from web UI:
async function sendCommand(mode) {
  await fetch('/api/command', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      client_id: 'web-ui',
      request_id: crypto.randomUUID().slice(0,8),
      timestamp: Math.floor(Date.now()/1000),
      command: 'set_mode',
      params: { mode },
      priority: 2
    })
  });
}

OTA Updates & First-Boot Configuration

First-Boot Captive Portal (Panels)

So you never need to reflash a wall panel just to change Wi-Fi credentials. Use WiFiManager:

  1. On first boot (or if saved credentials fail), panel broadcasts AP: ERV-Panel-Setup
  2. Connect from phone → captive portal opens
  3. Enter Wi-Fi SSID, password, controller IP, client name
  4. Panel saves to NVS, reboots, connects to broker

OTA via ArduinoOTA

All ESP32-S3 nodes (controller + bridge + panels) should include OTA support so you never need physical USB access after initial flash.

#include <ArduinoOTA.h>
// In setup():
ArduinoOTA.setHostname("erv-controller");
ArduinoOTA.setPassword("your-ota-pass");
ArduinoOTA.begin();
// In loop():
ArduinoOTA.handle();

Use PlatformIO upload_protocol = espota for seamless OTA deploys.

Complete Build Order

  1. 01 Order Stage 0–1 parts (XIAO ESP32-S3 + 2× MAX3485 breakouts)
  2. 02 Set up toolchain, flash test boards, verify USB enumeration
  3. 03 Wire capture bridge (two MAX3485 transceivers, DE/RE tied low)
  4. 04 Flash Stage 1 firmware — passive capture mode
  5. 05 Run full VTouch capture session, export MQTT log
  6. 06 Decode frames, fill in register map, confirm baud + slave addr
  7. 07 Order Stage 2–3 parts (Feather ESP32-S3 controller + single MAX3485)
  8. 08 Wire and flash controller — MQTT broker + Modbus RTU master
  9. 09 Upgrade bridge to active-proxy mode (Stage 2), validate command flow
  10. 10 Rewire bridge as VTouch emulator (Stage 3) — controller takes over RS-485
  11. 11 Run Stage 3 smoke test checklist — all clients in sync
  12. 12 Order WT32-SC01 Plus panel(s) + mounting hardware
  13. 13 Flash LVGL + MQTT client firmware on panel(s), test on bench
  14. 14 3D print Decora faceplate, install in-wall, run USB-C power
  15. 15 Mount panel(s), verify all clients sync. Optionally decommission original VTouch.

Open Questions to Resolve in Stage 1

  • Confirmed baud rate: 9600 is assumed — verify from capture. Some Broan units use 19200.
  • Slave address: 0x01 is the Modbus default — verify from first byte of every VTouch request.
  • VTouch poll interval: Measure inter-request gap from MQTT timestamps. Expected ~5 s.
  • All writable registers: Mode (0x0001) and boost timer (0x0007) are expected. Are there registers for fan speed independent of mode? Verify.
  • Defrost behavior: Does the ERV reject writes during defrost cycle? Capture if a defrost runs during the session.
  • VTouch init sequence: Does it send any special frame on power-up that the emulator must replicate? Capture a VTouch power cycle.
  • Broker memory: Test uMQTTBroker under load (3 clients + WebSocket + REST) on Feather ESP32-S3. Upgrade to Pi Zero 2 W as broker if RAM is tight.