| Capability | Source | Used For |
|---|---|---|
| ERV intake & exhaust temp, CFM, power, filter life | RS-485 → ESP32 → MQTT | Logging, efficiency calc, filter alerts |
| Per-room indoor temp + humidity | Aqara Hub V3 → MQTT | Comfort baseline, humidity triggers |
| Current outdoor temp + humidity | Open-Meteo (current hour) | ERV decision — is outdoor air worth bringing in? |
| 48-hour weather forecast | Open-Meteo (hourly) | Pre-emptive ERV scheduling before weather changes |
| ERV fan mode + speed control | MQTT → ESP32 → RS-485 | Automated + manual override via web UI |
| Web dashboard | FastAPI served locally | All sensor data, charts, manual controls, automation status |
| SQLite historical log | Python MQTT subscriber | Trend analysis, efficiency over time, Phase 2 baseline |
| Phase | What to Expect |
|---|---|
| Phase 1A — Hardware Assembly | Buck converter calibration, MAX485 wiring, RS-485 line checks. |
| Phase 1B — ESPHome Firmware | Flash ESP32-S3, validate MQTT publishing, verify ERV sensor reads. |
| Phase 1C — MQTT Broker + Python Backend | Mosquitto, SQLite logging, and the FastAPI dashboard backbone. |
| Phase 1D — Aqara Indoor Sensor Integration | Bridge Aqara sensors into MQTT and confirm room-level data flow. |
| Phase 1E — Open-Meteo Weather Integration | Forecast polling, outdoor conditions, and scheduler inputs. |
| Phase 1F — ERV Automation Rules | Define the decision logic and validate automated ERV actions. |
| Draft Document | |
| Multi-Controller for a Single ERV | Single RS-485 master + multiple clients with logged command routing. |
| ESP32-S3 | MAX485 | Notes | |
|---|---|---|---|
| 5V | → | VCC | Red wire |
| GND | → | GND | Black wire |
| GPIO17 (TX) | → | DI | Green wire |
| GPIO18 (RX) | → | RO | Blue wire |
| GPIO16 | → | DE + RE (bridged) | Purple — tie DE and RE together first |
# Python 3.9+ required pip install esphome # secrets.yaml — same folder as the ERV yaml, never commit to git wifi_ssid: "YourSSID" wifi_password: "YourWiFiPassword" ota_password: "choose-a-password" mqtt_user: "erv_monitor" mqtt_password: "your-mqtt-password"
substitutions: device_name: broan-erv-1 # broan-erv-2 for second unit mqtt_prefix: "erv/unit1" # erv/unit2 for second unit wifi: manual_ip: static_ip: 192.168.1.200 # .201 for second unit gateway: 192.168.1.1 mqtt: broker: 192.168.1.XXX # your Mosquitto broker IP
# Plug in via USB-C then: esphome run broan_erv_esphome.yaml # Watch for these in the logs: [I] WiFi connected, IP: 192.168.1.200 [I] MQTT connected [I] Broan ERV: received packet # If you see alignment/unknown command errors → swap D+/D− wires # Power-cycle the ERV (breaker) after first ESP32 connection
apt install mosquitto mosquitto-clients # Create authenticated user: mosquitto_passwd -c /etc/mosquitto/passwd erv_monitor # /etc/mosquitto/conf.d/erv.conf listener 1883 allow_anonymous false password_file /etc/mosquitto/passwd systemctl restart mosquitto && systemctl enable mosquitto # Verify data is flowing from ESP32: mosquitto_sub -h localhost -u erv_monitor -P yourpassword -t "erv/#" -v # Should see: erv/unit1/sensor/intake_temperature 68.4
mkdir -p /opt/erv_monitor && cd /opt/erv_monitor pip install fastapi uvicorn paho-mqtt aiosqlite python-dotenv httpx # Copy erv_monitor.py and .env.example, then: cp .env.example .env nano .env # set MQTT_BROKER, credentials, DB_PATH mkdir -p /var/lib/erv_monitor python erv_monitor.py # Dashboard available at: http://your-server-ip:8080
# /etc/systemd/system/erv-monitor.service
[Unit]
Description=ERV Monitor + Automation
After=network.target mosquitto.service
[Service]
WorkingDirectory=/opt/erv_monitor
ExecStart=/usr/bin/python3 erv_monitor.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
systemctl daemon-reload && systemctl enable --now erv-monitor
Install Home Assistant OS in its own LXC or VM. Add the official Aqara integration (Settings → Integrations → Aqara). Enable the MQTT integration in HA pointed at your Mosquitto broker. HA republishes every Aqara sensor automatically.
# HA MQTT topic pattern for Aqara sensors: homeassistant/sensor/aqara_living_room_temp/state homeassistant/sensor/aqara_bedroom_humidity/state # Add these topic patterns to ERV_UNITS in erv_monitor.py
# pip install python-aqara (or use requests directly) # Aqara developer.aqara.com — create app, get credentials import httpx, asyncio async def poll_aqara(mqtt_client): # POST to Aqara OpenAPI every 60 seconds headers = {"Authorization": f"Bearer {ACCESS_TOKEN}"} r = await client.post( "https://open-usa.aqara.com/v3.0/open/api", json={"intent": "query.resource.value", "data": [{"subjectId": DEVICE_ID, "resourceIds": ["4.1.85", "0.1.85"]}]} # 4.1.85 = temperature, 0.1.85 = humidity ) for sensor in r.json()["result"]: mqtt_client.publish( f"aqara/{sensor['subjectId']}/{sensor['resourceId']}", sensor["value"] ) await asyncio.sleep(60)
# Requires: SONOFF Zigbee 3.0 USB Dongle Plus (~$20) # Plug into server. Install Zigbee2MQTT alongside Mosquitto. # Aqara sensors re-pair to Z2M directly. # WARNING: test one sensor first — pairing behavior varies. # Z2M publishes natively to your broker: zigbee2mqtt/living_room_sensor # → {"temperature":68.4,"humidity":42,"battery":87}
| Location | What it tells the automation engine |
|---|---|
| Main living floor — central | Primary comfort reference and humidity trigger source |
| Upstairs hallway / landing | Heat stratification — how much warmth has risen |
| Basement / mechanical room | Whole-home thermal mass baseline — slow to change |
| Near ERV supply-air outlet | Temperature of air the ERV is delivering — is it helping? |
import httpx # Eastern PA coordinates — replace with yours FORECAST_URL = ( "https://api.open-meteo.com/v1/forecast" "?latitude=40.XX&longitude=-75.XX" "&hourly=temperature_2m,relativehumidity_2m," "dewpoint_2m,precipitation_probability,windspeed_10m" "&forecast_days=2" "&temperature_unit=fahrenheit&windspeed_unit=mph" "&timezone=America/New_York" ) async def fetch_forecast() -> dict: async with httpx.AsyncClient() as client: r = await client.get(FORECAST_URL, timeout=10) h = r.json()["hourly"] return { "outdoor_temp_f": h["temperature_2m"][0], "outdoor_humidity": h["relativehumidity_2m"][0], "dewpoint_f": h["dewpoint_2m"][0], "precip_prob_now": h["precipitation_probability"][0], "temp_in_2h": h["temperature_2m"][2], "temp_in_4h": h["temperature_2m"][4], "temp_in_6h": h["temperature_2m"][6], "temp_in_12h": h["temperature_2m"][12], "temp_in_24h": h["temperature_2m"][24], "humidity_in_4h": h["relativehumidity_2m"][4], "temp_min_12h": min(h["temperature_2m"][:12]), "temp_max_12h": max(h["temperature_2m"][:12]), } async def weather_poll_loop(): while True: weather = await fetch_forecast() # Store in db, publish to MQTT, feed into ERV decision engine await record_weather(weather) await erv_decision_engine(weather) await asyncio.sleep(900) # poll every 15 minutes
| Priority | Condition | Action | Reason |
|---|---|---|---|
| 1 — Safety | outdoor_temp < 10°F | Min | Extreme cold — prevent heat loss and frosting |
| 2 — Humidity | indoor_rh > 55% | Max | Indoor humidity too high regardless of outdoor conditions |
| 3 — Forecast | temp_in_4h < outdoor_temp − 20°F | Max → then Min | Cold front incoming — ventilate while cheap, close down before it hits |
| 4 — Moisture | outdoor_rh > indoor_rh + 20% | Min | Outdoor air much more humid — bringing it in worsens indoor conditions |
| 5 — Ideal | 45°F < outdoor_temp < 75°F AND outdoor_rh < 65% | Max | Best possible outdoor air — ventilate aggressively, energy cost near zero |
| 6 — Cold | 10°F < outdoor_temp < 45°F | Med | Maintain air quality without excessive heat exchange load |
| 7 — Default | all other conditions | Med | Baseline ventilation |
async def erv_decision_engine(weather: dict): indoor_rh = await get_avg_indoor_humidity() # from Aqara outdoor_f = weather["outdoor_temp_f"] outdoor_rh = weather["outdoor_humidity"] temp_in_4h = weather["temp_in_4h"] # Evaluate rules in priority order if outdoor_f < 10: mode, reason = "Min", "extreme cold" elif indoor_rh > 55: mode, reason = "Max", "high indoor humidity" elif temp_in_4h < outdoor_f - 20: mode, reason = "Max", "cold front in 4h — pre-ventilate" # Schedule a switch to Min in 3.5 hours asyncio.create_task(delayed_mode_set("Min", delay_minutes=210)) elif outdoor_rh > indoor_rh + 20: mode, reason = "Min", "outdoor more humid than indoor" elif 45 < outdoor_f < 75 and outdoor_rh < 65: mode, reason = "Max", "ideal outdoor conditions" elif outdoor_f < 45: mode, reason = "Med", "cold — balanced ventilation" else: mode, reason = "Med", "default" for unit_id in ERV_UNITS: await set_erv_mode(unit_id, mode, reason)
| Section | Content |
|---|---|
| ERV Status | Per-unit: current mode, fan speed, intake temp, exhaust temp, CFM, power draw, filter life remaining |
| Indoor Conditions | Per-room temp and humidity from Aqara sensors. Highlights any room above 55% RH or below comfort range. |
| Outdoor / Weather | Current outdoor temp + humidity (Open-Meteo), 12-hour forecast sparkline, next weather event flag |
| Automation Status | Current active rule, reason for current ERV mode, last 10 decisions with timestamps |
| Manual Controls | Override fan mode per unit (Standby / Min / Med / Max / Turbo). Override expires after 2 hours, automation resumes. |
| Charts | 24-hour history for indoor temps, humidity, ERV power draw, and outdoor temp overlaid |
| Alerts | Filter life < 14 days, humidity spike, ERV offline, sensor dropout |
| Endpoint | Method | Description |
|---|---|---|
GET / | GET | Web dashboard HTML |
/api/state/{unit_id} | GET | Current snapshot all metrics |
/api/weather | GET | Current conditions + 12h forecast |
/api/indoor | GET | All Aqara sensor current values |
/api/command/{unit}/fan_mode | POST | Set ERV mode — triggers manual override flag |
/api/automation/pause | POST | Pause automation for N minutes |
/api/history/{unit}/{metric} | GET | Time series data for charts |
| Symptom | Cause | Fix |
|---|---|---|
| Alignment errors / unknown command spam | D+/D− reversed | Swap the two RS-485 wires at the ERV terminal block |
| ESP connects to WiFi but no ERV data | ERV not restarted after connect | Power-cycle the ERV (flip its breaker) |
| No MQTT messages at broker | Wrong broker IP or credentials | Verify with mosquitto_sub test, check secrets.yaml |
| Aqara sensors not appearing in MQTT | Bridge not configured | Confirm HA MQTT integration is enabled and pointing at your broker |
| Open-Meteo returning wrong temps | Wrong coordinates | Verify lat/lon in FORECAST_URL — use decimal degrees |
| Buck converter warm / ESP resets | Output voltage too high | Re-measure output with multimeter, readjust to 5.0–5.2V |