Bathroom Fan Monitoring in Home Assistant: XIAO ESP32-C3 Bluetooth Proxy + Fresh Intellivent Sky

Setting up a XIAO ESP32-C3 as a Home Assistant Bluetooth proxy, adding Fresh Intellivent Sky fans over BLE, and a crash that looked like bad range but wasn't.

July 8, 2026 Updated 8/27/2026 Verified on HA 2026.8
Bathroom Fan Monitoring in Home Assistant: XIAO ESP32-C3 Bluetooth Proxy + Fresh Intellivent Sky

Two Fresh Intellivent Sky bathroom fans, both broadcasting Bluetooth Low Energy, both further from the Home Assistant host than its onboard radio reliably reaches. The fix wasn’t a new HA server or a mesh of repeaters — it was a $5 microcontroller and about ten minutes with a browser. This post covers the Bluetooth proxy setup, adding the fans, and a bug that looked exactly like a hardware problem and wasn’t.

The hardware: Seeed Studio XIAO ESP32-C3

The XIAO ESP32-C3 is a thumbnail-sized board — WiFi, Bluetooth 5, and a USB-C port, nothing else you need to think about. I plugged it into a USB power brick in the hallway between the two bathrooms and never touched the wiring again.

Seeed Studio XIAO ESP32-C3 board close-up, showing the USB-C port and external antenna connector

Flashing it is not a project in the way “flashing firmware” usually implies. ESPHome’s web-based project installer has a ready-made Bluetooth Proxy firmware image — plug the board into your computer over USB, open the installer in Chrome or Edge, pick the board and the Bluetooth Proxy project, and it flashes over WebSerial. No YAML to write, no ESPHome dashboard to run locally. Connect it to WiFi during the guided setup and it shows up in Home Assistant’s discovery within a minute.

Once it’s added as an ESPHome device, it registers itself as a Bluetooth adapter under Settings → Devices & Services → Bluetooth. Home Assistant automatically prefers proxies over its own onboard adapter for any device closer to a proxy, with no configuration on your end.

Why a proxy instead of just more range

Home Assistant’s host Bluetooth radio covers roughly one room, maybe two with clear line of sight. Everything past that is a dead zone for any BLE-only integration — no Zigbee-style mesh, no repeater logic, just “in range” or “not in range.” A single $5 ESP32 board fixes an entire dead zone at once, and you can drop more of them anywhere you have USB power; each one just adds coverage, no pairing or per-device configuration required on the proxy side.

I run this alongside a Google Find My setup that also depends on Bluetooth — the FAQ there specifically calls out an ESP32 BLE proxy as the right tool when you need a reading in seconds rather than “whenever a phone walks past.”

Adding the Fresh Intellivent Sky fans

Fresh’s Intellivent Sky is a humidity-sensing bathroom extractor fan with its own Bluetooth interface, controlled normally through the Fresh SmartLink app. Home Assistant has no official integration for it — the one that works is a community project, angoyd/freshintelliventHacs, installed via HACS as a custom repository.

Fresh Intellivent Sky bathroom fan installed in a ceiling diffuser

With the proxy in place, setup is genuinely just discovery: once the fan’s BLE advertisements reach a proxy in range, Home Assistant offers it under Settings → Devices & Services → Discovered with fresh_intellivent_sky as the source. Confirm it, and it creates a device with five sensors:

  • sensor.<name>_humidity
  • sensor.<name>_temperature
  • sensor.<name>_current_speed
  • sensor.<name>_mode (human-readable — Off, Trickle, Boost, etc.)
  • sensor.<name>_mode_raw (the numeric code behind it)

That’s read-only. There’s no fan.*, switch.*, or select.* entity — nothing in Home Assistant can change the fan’s speed or trigger a boost. Automations built on this integration can notify you to go boost the fan manually; they can’t do it for you. That’s a real limitation worth knowing before you plan automations around it.

Update 2026-08-26: not the whole story anymore — see the update section at the bottom of this post.

Humidity automations that actually work with read-only data

Two fans, five sensors each, no control — but humidity and its rate of change are still enough to build something useful. The pattern I landed on:

  • A Derivative Helper (Settings → Devices & Services → Helpers, no YAML) on each humidity sensor, giving a %/min value — a sustained rise past roughly 3.3%/min flags “someone’s in the shower” faster than a fixed humidity threshold ever could.
  • A sustained-threshold automation for genuine mold risk: humidity above 70% for 45+ minutes, independent of rate.

Both just push a notification — see the read-only limitation above — but the rate-of-change trigger catches a shower starting within a couple of minutes, well before a fixed “humidity above 65%” threshold would fire.

The bug that looked like a range problem

A few days after setup, one fan’s five sensors all went unavailable while the other kept reporting normally. The obvious assumption is signal — proxy placement, BLE range, maybe the fan needs re-pairing. I checked the proxy’s coverage first and found nothing wrong with it.

The actual cause was in the integration’s config entry, not the radio. Home Assistant tracks each integration instance’s setup state — one of the possible values is failed_unload, which occurs when an integration’s own reload/unload handler throws mid-teardown. In practice it doesn’t self-recover: only a full restart clears it. The error log showed exactly that: an options-update listener triggered a reload, the unload half failed, and the entry was left dead. The identical, unaffected fan’s config entry, by contrast, sat at a normal loaded state the entire time.

Nothing about this is diagnosable from entity state alone — unavailable looks identical whether the cause is “integration crashed” or “device is out of range.” The tell is in Settings → Devices & Services, on the integration entry itself, or in the Home Assistant log around the time the entity went stale. A full restart cleared the stuck entry immediately; every sensor was reporting again within two minutes.

alias: "Bathroom: Fan Sensor Stale Alert"
description: >
  Notifies if a bathroom fan's sensors go and stay unavailable for over an
  hour — includes a one-tap Restart HA action button rather than
  restarting automatically, since staleness can also mean the fan is
  genuinely out of range or powered off, where a restart wouldn't help.
mode: parallel
trigger:
  - platform: state
    entity_id: sensor.example_fan_mode
    to: "unavailable"
    for:
      hours: 1
action:
  - action: notify.mobile_app_pixel_10
    data:
      title: "📡 Fan sensor offline"
      message: "Unavailable for over an hour — likely a crashed integration entry, not range."
      data:
        actions:
          - action: restart_ha_bathroom_fan
            title: "Restart HA"

The action button fires a second, tiny automation that listens for that specific mobile_app_notification_action event and calls homeassistant.restart — deliberately not automatic, since a genuinely out-of-range fan would just get a useless restart on a loop instead of the actual fix.

Styling it into the dashboard

Both fans’ cards use the same frosted-glass card_mod constants as the rest of the dashboard — a humidity trend chart, temperature/mode/speed tiles, and a compact status tile that only renders when the fan is active or humidity is climbing, so an idle bathroom doesn’t take up permanent dashboard space. The formerly-broken fan’s card also carries a small “Connectivity: Unavailable” fallback tile, driven by the same mode sensor going unavailable — it’s gone the moment the sensors start reporting again, no manual dashboard edit needed either way.

Update: switching to a repo with write access

Seven weeks later, someone from Spittie.dk — a Danish Home Assistant enthusiast site — reached out and pointed me at a repo I hadn’t found myself: Spit68/Fresh-Intellivent-Sky. angoyd/freshintelliventHacs is still strictly read-only, as described above, no matter how many times you reload it; this one is a different animal entirely, installed the same way through HACS.

That changes the entity picture substantially. Instead of the five read-only sensors, each fan now gets boost and pause buttons (with duration and RPM), a constant-speed switch, per-mode RPM settings, night mode and silent hours with start/end times, plus sensitivity selects for humidity, VOC, and light detection. That’s a real gain — automations can now actually boost the fan instead of just reminding you to.

The cost: no calibrated humidity percentage. The new repo exposes a humidity_sensor_raw sensor, but it’s disabled by default, and I checked the source for a hidden conversion formula — there isn’t one. sky_sensors.py unpacks the BLE status characteristic directly:

values = unpack("<2B5H3B", data)
self.humidity_raw = values[2]

That’s a raw, unscaled 16-bit number straight from the device’s own register — no division, no reference calibration, nothing translating it to a percentage. The two humidity automations described above couldn’t just carry over 1:1 — or so I thought, until I looked one level down. The old angoyd integration doesn’t parse BLE data itself; it leans on a separate library, pyfreshintellivent. That library’s sensors.py reads the exact same characteristic (528b80e8-…, literally named “Sensor data” in the protocol docs) at the exact same byte offset as humidity_raw above — and converts it:

if values[2] != 0:
    self.humidity = round((log(values[2] / 10) * 10), 1)

A natural log, not a linear scale. Nothing Fresh ever published themselves — just something another developer worked out and has run in production for years. I tested it against the raw numbers I already had: 858 → 41.3%, ~2800-3150 → 39.5-58%. Both land squarely in normal bathroom humidity territory, not the kind of result you get from feeding numbers into an arbitrary formula. Built it as two template sensors in Home Assistant, and put both automations back on their original thresholds — 70% for 45 minutes on mold risk, 3.33%/min on the rapid-rise one — just pointed at a different source entity than before.

Two more things worth knowing if you swap the repo behind an integration that’s already wired into your dashboard and automations: reinstalling the Bluetooth proxy itself triggered a brief, self-healing drop in the ESPHome API connection (around 20 minutes) that looked like yet another permanent outage but wasn’t — and both automations and dashboard cards had hardcoded entity IDs from the old integration that needed fixing one at a time. The dashboard cards, in particular, turned up a bug that had been sitting there unnoticed for a while: the two bathroom cards were actually showing each other’s data, because the card title and the entity mapping had gotten swapped somewhere along the way.

Update: the formula’s reliability is still unresolved

After publishing this, I heard from the developer maintaining Spit68/Fresh-Intellivent-Sky. Turns out he’d already put serious work into exactly this problem — tried the LaStrada log formula above, several others, opened up a SKY unit to inspect the actual humidity sensor chip, worked from its datasheet, and even fed an AI a pile of raw-value/reference-humidity pairs. None of it produced a formula solid enough to ship as a real percentage sensor, which is exactly why humidity_sensor_raw stays disabled by default in his repo instead of a guess dressed up as data — credit for holding that line rather than shipping something that looks right and isn’t.

The data point that matters most: two SKY units side by side, in presumably similar humidity, can report raw values as different as 100 vs. 300. That’s not a linear offset you calibrate away — it points to the raw register not being on a consistent scale across units at all.

That reframes what I actually validated above. The log formula produced plausible numbers for my specific fan, and my two automations still work because they threshold consistently against that one fan’s own baseline over time — not because the formula is a proven, general raw-to-percentage conversion. If you’re replicating this for your own SKY units, treat the resulting number as a relative trend indicator, not a calibrated percentage, until you’ve checked it against a real hygrometer in your own bathroom. He’s not done chasing this either — new comparison sensors are on the way on his end — so if a real formula ever surfaces, I’ll update this post.

One thing to know

If you’re extending Home Assistant’s Bluetooth range for anything other than a single device — Govee sensors, other BLE trackers, more of these fans — the proxy itself doesn’t care what it’s proxying. One XIAO ESP32-C3 per dead zone covers everything in range, not just the device you set it up for. It’s the same logic as running a LoRa gateway for a mailbox sensor 200 metres out — cheap, purpose-built radio hardware solves range problems that no amount of Home Assistant configuration can.