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.
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)
| Model | Broan B210E75RT |
| CFM | 206 CFM max (top-port mount) |
| Core type | HRV (Heat Recovery Ventilator) |
| Controller port | 4-wire terminal block — A, B, GND, +12 V DC |
| Protocol | RS-485, Modbus RTU (VTouch confirmed) |
| Baud rate | 9600 8N1 (confirm during capture phase) |
| Slave address | 0x01 (assumed — verify from capture) |
| VTouch power | Drawn 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
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
| Part | Purpose | Where to Buy | Cost |
|---|---|---|---|
| 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)
| Part | Purpose | Where to Buy | Cost |
|---|---|---|---|
| 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)
| Part | Purpose | Where to Buy | Cost |
|---|---|---|---|
| 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
Setup
Toolchain, libraries, test blink on both ESP32-S3 boards
Passive Capture
Bridge listens between VTouch & ERV. Map every register.
Active Proxy
Bridge forwards frames. Controller begins sending commands directly.
Full Takeover
Controller is sole RS-485 master. Bridge emulates ERV responses to VTouch.
Replacement Panels
WT32-SC01 Plus panels installed. VTouch optionally retained or removed.
Stage 0 — Development Environment Setup
Est. time: 1–2 hours · Do this before touching the ERV.
0.1 — Install Toolchain
- Install VS Code + PlatformIO extension. This is the recommended toolchain for this build — better library management than Arduino IDE and supports both boards.
- 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 - Install required libraries (PlatformIO: add to
platformio.ini· Arduino IDE: Library Manager):knolleary/PubSubClient— MQTT clientme-no-dev/ESPAsyncWebServer+me-no-dev/AsyncTCP— web server + WebSocketolehs/UMODBUSoreModbus/eModbus— Modbus RTU master (Stage 2+)ArduinoJson— JSON serializationESPmDNS— built-in with ESP32 package
- 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 — Passive Capture & Protocol Mapping
Est. time: 2–4 hours · VTouch remains in circuit. Bridge is read-only.
1.1 — Physical Install of the Bridge
- Power down the ERV at the breaker.
- Open the ERV service panel. Locate the VTouch terminal block (A, B, GND, +12 V).
- 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.
-
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 - 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
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 |
|---|---|---|---|---|
| 0x0001 | R/W | Operating mode | 0=Off 1=Low 2=Med 3=High 4=Boost | inferred |
| 0x0002 | R | Fan speed actual | RPM or index — TBD from capture | TBD |
| 0x0003 | R | Supply air temp | °F × 10 | inferred |
| 0x0004 | R | Exhaust air temp | °F × 10 | inferred |
| 0x0005 | R | Relative humidity | % (0–100) | inferred |
| 0x0006 | R | Filter status | 0=OK 1=Replace | inferred |
| 0x0007 | R/W | Boost timer | Minutes remaining | inferred |
| 0x0008 | R | Defrost active | 0/1 boolean | TBD |
| 0x0009–0x001F | ? | Unknown — log raw values | — | TBD |
Also record: confirmed baud rate, slave address, poll interval (ms between VTouch reads), ERV response time (ms).
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
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
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.
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);
}
}
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
- Lower
prioritynumber wins (0 = highest) - Same priority → later timestamp wins
- Invalid register/value → reject outright
- Superseded commands get
status: supersededACK
Queue Params
- FIFO depth: 5 entries
- Per-entry TTL: 10 seconds
- One RS-485 transaction at a time
- Expired entries →
status: expiredACK
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.localshows 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)
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
- Cut a single-gang opening in drywall at the desired location (where VTouch was, or new location).
- Install an old-work single-gang electrical box. Run USB-C power cable through a low-voltage opening or the knockout.
- 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.
- 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).
- Connect USB-C, fold into the box, snap the faceplate to the Decora plate, and screw to the single-gang box.
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
ArduinoOTAor custom HTTP endpoint - If you keep the original VTouch: its commands arrive via the bridge with
client_id: vttouch-bridgeand participate in normal arbitration — same priority as any other client
MQTT Topic Schema
| Topic | Direction | Retained | Description |
|---|---|---|---|
erv/command/request | Any client → Controller | No | Command payload from any client (panel, web, bridge) |
erv/command/ack/{client_id} | Controller → Client | No | Per-client ACK: accepted / rejected / superseded / expired |
erv/state | Controller → All | Yes | Authoritative ERV state snapshot — clients sync on reconnect |
erv/audit | Controller → All | No | Append-only event log (command + outcome + RS-485 raw) |
erv/errors | Controller → All | No | Bus faults, rejected commands, Modbus errors |
erv/raw/vt | Bridge → Controller | No | Stage 1 only — raw hex frames from VTouch (RE capture) |
erv/raw/erv | Bridge → Controller | No | Stage 1 only — raw hex frames from ERV (RE capture) |
erv/config/clients/{client_id} | Admin → Controller | Yes | Per-client config: read-only flag, priority override |
erv/controller/status | Controller → All | Yes | Heartbeat + 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 JSONPOST /api/command— send a commandGET /api/audit?limit=50— recent logGET /api/clients— online client listGET /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:
- On first boot (or if saved credentials fail), panel broadcasts AP:
ERV-Panel-Setup - Connect from phone → captive portal opens
- Enter Wi-Fi SSID, password, controller IP, client name
- 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
- 01 Order Stage 0–1 parts (XIAO ESP32-S3 + 2× MAX3485 breakouts)
- 02 Set up toolchain, flash test boards, verify USB enumeration
- 03 Wire capture bridge (two MAX3485 transceivers, DE/RE tied low)
- 04 Flash Stage 1 firmware — passive capture mode
- 05 Run full VTouch capture session, export MQTT log
- 06 Decode frames, fill in register map, confirm baud + slave addr
- 07 Order Stage 2–3 parts (Feather ESP32-S3 controller + single MAX3485)
- 08 Wire and flash controller — MQTT broker + Modbus RTU master
- 09 Upgrade bridge to active-proxy mode (Stage 2), validate command flow
- 10 Rewire bridge as VTouch emulator (Stage 3) — controller takes over RS-485
- 11 Run Stage 3 smoke test checklist — all clients in sync
- 12 Order WT32-SC01 Plus panel(s) + mounting hardware
- 13 Flash LVGL + MQTT client firmware on panel(s), test on bench
- 14 3D print Decora faceplate, install in-wall, run USB-C power
- 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.