Building a Local Smart Home Hub: Unifying HRV, Zigbee Sensors and Air Quality
I’ve just wrapped up a major upgrade to my local smart home control system, which integrates a NovingAIR PHANTOM Wireless 160 heat recovery ventilation (HRV) unit, an 8-in-1 Tuya air quality monitor, and external Zigbee temperature sensors into a unified automation hub.
The idea is simple: monitor indoor air quality and thermal dynamics in real time, factor in outdoor weather conditions via Zigbee, and automatically control ventilation speeds with built-in lockout timers to keep the space healthy and quiet—all from a custom mobile dashboard.
My responsive React Native dashboard visualizing live sensor telemetry, thermal flows, and manual control overrides.
Why I built it and the new additions
It started with a common problem: waking up in a stuffy bedroom. I had a standalone NovingAIR PHANTOM unit and an 8-in-1 Air Quality Monitor, but they didn’t talk to each other.
Since my initial setup, I’ve expanded the architecture significantly:
- Added Zigbee Temperature Integration: Integrated indoor and outdoor Zigbee sensors to dynamically calculate thermal efficiency. If the outdoor air is closer to my target temperature ($22^\circ\text{C}$) than the indoor air, the system shifts flow modes to leverage direct outdoor air exchange.
- Refined Automation & Lockout Timers: To prevent the fan from “chattering” or bouncing speeds when CO2 hovers near thresholds, I implemented intelligent speed-tier rules paired with a strict 30-minute minimum hold timer. This ensures the unit remains whisper-quiet at night while reserving emergency speeds (Speed 3 during the day, Speed 2 at night) strictly for heavy spikes.
The Hardware and Architecture
The project relies on a robust local network stack:
- 8-in-1 Air Quality Monitor: A Tuya-based sensor (Protocol 3.5) reporting CO2, temperature, humidity, PM1.0, PM2.5, PM10, TVOC, and HCHO.
- Zigbee Sensors: Wireless indoor and outdoor temperature sensors polled securely via cloud/gateway integration.
- Phantom IR Blaster: A Tuya device (Protocol 3.3) sending infrared signals to the NovingAIR unit.
The architecture is divided into two layers:
- The Backend (FastAPI): A Python server handling continuous background polling, thermal comparisons, state saving, and asynchronous task loops.
- The Frontend (React Native/Expo): A cross-platform mobile application providing a sleek UI to view live data and manually override HRV modes.
The complete source code for the project is available on GitHub.
The Power of Automation
The real magic of this setup is the adaptive automation loop. The FastAPI backend continuously evaluates air quality metrics against healthy indoor thresholds:
| Metric | Good | Moderate | Unhealthy |
|---|---|---|---|
| CO2 | <800 ppm | 800-1200 | >1200 |
| Temp | 18-24°C | 15-28°C | <15 or >28 |
| Humidity | 30-50% | 25-60% | <25 or >60% |
| PM1.0 | <12 µg/m³ | 12-35 | 35-55 | >55 |
| PM2.5 | <12 µg/m³ | 12-35 | 35-55 | >55 |
| PM10 | <12 µg/m³ | 12-35 | 35-55 | >55 |
| TVOC | <0.3 mg/m³ | 0.3-1.0 | >1.0 |
| HCHO | <0.05 mg/m³ | 0.05-0.1 | >0.1 |
Quiet-First Speed Logic with 30-Minute Lockout
To ensure the bedroom stays quiet at night, the script evaluates time-of-night windows, applies a quiet baseline (Speed 1 at night, Speed 2 during the day), caps nighttime emergencies at Speed 2 (reserving Speed 3 for the day), and strictly locks all speed changes for at least 30 minutes to eliminate annoying oscillations:
def _get_target_speed(co2: int) -> int:
current_hour = datetime.now(ZoneInfo("Europe/Bucharest")).hour
is_night = current_hour >= NIGHT_START_HOUR or current_hour < NIGHT_END_HOUR
# If CO2 is extremely high, use Speed 3 (override for heavy pollution/crowds)
if co2 > CO2_HIGH_THRESHOLD:
return 2 if is_night else 3
# If CO2 is moderate, keep it quiet: Speed 1 at night, Speed 2 during the day
if co2 > CO2_LOW_THRESHOLD:
return 1 if is_night else 2
return 1
Combined with our strict 30-minute lockout timer logic inside the polling task:
# 5. Speed Lockout Evaluation (strict 30-minute minimum hold timer for ALL speeds)
if state_dict.get("speed") != target_speed:
now = datetime.now(ZoneInfo("Europe/Bucharest"))
speed_change_allowed = True
if last_speed_change_time is not None:
if (now - last_speed_change_time) < timedelta(minutes=30):
speed_change_allowed = False
if speed_change_allowed:
logging.info(f"Speed changed -> {target_speed} (CO2: {co2})")
state_dict["speed"] = target_speed
last_speed_change_time = now
# Fire IR command...
else:
logging.info(f"Speed change to {target_speed} deferred (locked for 30 min window).")
Overcoming Frontend Hurdles
Building the frontend wasn’t without its quirks. I chose Expo and React Native to get up and running quickly on an Android device, navigating classic mobile development challenges like local network binding, asset configuration, and peer-dependency lockouts with React 19. Sideloading the latest builds kept the Metro Bundler humming seamlessly.
Conclusion
This project has evolved into a comprehensive, self-contained local hub running 24/7 on an Ubuntu machine. By integrating Zigbee telemetry and debounce lockout rules, the system balances high air quality with complete acoustic peace.
It’s a powerful testament to what open-source home automation can achieve when you take full control of your hardware and data workflows.