I wanted a Bluetooth proxy near two Vent-Axia Svara extractors (Pax BLE fans), plus temperature, humidity, and pressure in the same spot. An M5Stack AtomS3 Lite (~£9, ESP32-S3) and Unit ENV IV (~£6; EOL from M5Stack, still sold by resellers) cover both in one ESPHome firmware for about £15. ENV IV is SHT40 + BMP280 on Grove I2C.

Hardware, HA sensors, RAM budget, and sanitized YAML below.

M5Stack AtomS3 Lite and Unit ENV IV sensor with a two-pence coin for scale

The two parts (one firmware)

M5Stack AtomS3 Lite (~£9)

Tiny ESP32-S3 board with Wi-Fi, Bluetooth 5 LE, a button, and four RGB LEDs under the cap.

From esptool when the board is connected over USB:

Chip type:          ESP32-S3 (QFN56) (revision v0.2)
Features:           Wi-Fi, BT 5 (LE), Dual Core + LP Core, 240MHz, Embedded Flash 8MB (GD)
Crystal frequency:  40MHz

In practice that means: enough flash for ESP-IDF + BLE proxy + sensors, dual-core headroom for Wi-Fi/BLE coexistence, and native USB-CDC for first flash (no separate USB-UART chip).

M5Stack Unit ENV IV (~£6)

Grove/STEMMA QT breakout:

  • Sensirion SHT40: temperature and relative humidity (I2C 0x44)
  • Bosch BMP280: barometric pressure (I2C 0x76)

On the AtomS3 Lite the Grove port is SDA = GPIO2, SCL = GPIO1. A short Grove cable is enough; if I2C scan finds nothing, swap the yellow/white data lines (AtomS3 Grove pin order is not always the cable default).

What it does in Home Assistant

Main job: ESPHome Bluetooth proxy so Home Assistant can reach Pax fans that only speak BLE GATT. I use Erik Nordby’s ha-pax_ble; it is maintained and fits Vent-Axia Svara when there is no official HA integration.

Secondary job: publish room environment every five minutes (slow interval keeps the chip cooler and keeps I2C out of BLE’s way).

After OTA to firmware 1.1.1 (ESPHome 2026.7.3), a typical live snapshot:

Check Status
ESPHome integration Loaded (AtomS3 Lite Temp 5e5b70)
Firmware 1.1.1 (ESPHome 2026.7.3)
Connectivity (Status) On
Uptime ~5 min (post-OTA)
Wi-Fi signal -53 dBm (IoT VLAN)
Temperature 25.0 °C
Humidity 47.7 %
Pressure 1017 hPa
Absolute humidity 11.0 g/m³
Dew point 13.2 °C
Altitude (barometric) ~-32 m vs ISA 1013.25 hPa
LED Blue when Wi-Fi up; green/red 10 s flash after each env publish

Pax fans via the proxy

With the Atom on the IoT VLAN and the proxy active, both Svara units stay reachable:

Fan State RPM Temperature
top_bathroom_extractor Trickle ventilation 1000 25.4 °C
tom_bedroom_ensuite_extractor Trickle ventilation 1641 22.9 °C

Home Assistant sees full Pax entities (flow, humidity sensor on the unit, boost mode, silent hours, etc.) through ha-pax_ble. The Atom only forwards BLE; the integration on the HA host runs the Pax logic.

RAM and flash budget

Compiled with ESPHome 2026.7.3, ESP-IDF 5.5.5, project version 1.1.1:

RAM:   [====      ]  42.6% (used 145515 bytes from 341760 bytes)
Flash: [========  ]  75.1% (used 1377767 bytes from 1835008 bytes)

That leaves headroom for three BLE connection_slots (Pax may hold active GATT sessions), Wi-Fi, captive portal fallback, and the LED/env scripts. I stayed at connection_slots: 3: each costs roughly 1 KB RAM, and Wi-Fi proxies rarely need more unless you saturate connections.

LED feedback (v1.1.1)

The four WS2812 LEDs under the button are status-only:

  • Blue: Wi-Fi connected to HA
  • Red pulse: fallback AP / Wi-Fi down
  • Green for 10 s: env reading published and HA API reachable
  • Solid red for 10 s: reading OK but Wi-Fi or API unreachable
  • Short press toggles the LED; long press (~1 s) reboots

Useful when the puck sits on a USB charger away from the desk.

Sanitized ESPHome config

Secrets (wifi_ssid, wifi_password, api_encryption_key, wifi_ap_password) live in secrets.yaml on the HA host, not in git. Example placeholders:

wifi_ssid: "your-ssid"
wifi_password: "your-wifi-password"
api_encryption_key: "generate-with-esphome secrets create"
wifi_ap_password: "fallback-ap-password"

Full public YAML (no credentials):

atoms3-lite-temp.yaml (click to expand)
# Public/sanitized ESPHome config for AtomS3 Lite + Unit ENV IV (SHT40 + BMP280).
# Secrets: copy secrets.yaml.example -> secrets.yaml and fill in locally.
# BLE proxy baseline: https://github.com/esphome/bluetooth-proxies/tree/main/m5stack

substitutions:
  name: atoms3-lite-temp
  friendly_name: AtomS3 Lite Temp
  sea_level_pressure_hpa: "1013.25"
  wifi_ap_ssid: "AtomS3-Lite-Fallback"

esphome:
  name: ${name}
  friendly_name: ${friendly_name}
  min_version: 2025.8.0
  name_add_mac_suffix: true
  project:
    name: homelab.atoms3-ble-proxy
    version: "1.1.1"

esp32:
  variant: esp32s3
  framework:
    type: esp-idf

logger:
  baud_rate: 0
  level: INFO

api:
  encryption:
    key: !secret api_encryption_key

ota:
  - platform: esphome
    id: ota_esphome

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  ap:
    ssid: ${wifi_ap_ssid}
    password: !secret wifi_ap_password
  on_connect:
    - script.execute: led_wifi_connected
  on_disconnect:
    - script.execute: led_wifi_ap

captive_portal:

esp32_ble_tracker:

bluetooth_proxy:
  active: true
  cache_services: true
  connection_slots: 3

i2c:
  sda: GPIO2
  scl: GPIO1
  scan: true
  id: bus_grove

light:
  - platform: esp32_rmt_led_strip
    rgb_order: GRB
    pin: GPIO35
    num_leds: 4
    chipset: ws2812
    id: atom_led
    name: "${friendly_name} LED"
    restore_mode: RESTORE_DEFAULT_OFF
    effects:
      - pulse:
          name: Pulse
          transition_length: 1s

script:
  - id: led_wifi_connected
    then:
      - light.turn_on:
          id: atom_led
          brightness: 30%
          red: 0%
          green: 0%
          blue: 100%
  - id: led_wifi_ap
    then:
      - light.turn_on:
          id: atom_led
          brightness: 30%
          red: 100%
          green: 40%
          blue: 0%
          effect: Pulse
  - id: led_update_sent
    mode: restart
    then:
      - light.turn_on:
          id: atom_led
          brightness: 30%
          red: 0%
          green: 100%
          blue: 0%
      - delay: 10s
      - if:
          condition:
            wifi.connected:
          then:
            - script.execute: led_wifi_connected
          else:
            - script.execute: led_wifi_ap
  - id: led_update_failed
    mode: restart
    then:
      - light.turn_on:
          id: atom_led
          brightness: 30%
          red: 100%
          green: 0%
          blue: 0%
      - delay: 10s
      - if:
          condition:
            wifi.connected:
          then:
            - script.execute: led_wifi_connected
          else:
            - script.execute: led_wifi_ap

sensor:
  - platform: sht4x
    i2c_id: bus_grove
    address: 0x44
    update_interval: 300s
    precision: High
    heater_max_duty: 0.0
    temperature:
      name: "${friendly_name} Temperature"
      id: sht40_temperature
    humidity:
      name: "${friendly_name} Humidity"
      id: sht40_humidity

  - platform: bmp280_i2c
    i2c_id: bus_grove
    address: 0x76
    update_interval: 300s
    iir_filter: 4x
    temperature:
      id: bmp280_temperature
      internal: true
    pressure:
      name: "${friendly_name} Pressure"
      id: bmp280_pressure
      on_value:
        then:
          - if:
              condition:
                and:
                  - wifi.connected:
                  - api.connected:
              then:
                - script.execute: led_update_sent
              else:
                - script.execute: led_update_failed

  - platform: absolute_humidity
    name: "${friendly_name} Absolute Humidity"
    temperature: sht40_temperature
    humidity: sht40_humidity

  - platform: template
    name: "${friendly_name} Dew Point"
    unit_of_measurement: "°C"
    device_class: temperature
    icon: mdi:thermometer-alert
    update_interval: 300s
    lambda: |-
      return (243.5 * (log(id(sht40_humidity).state / 100.0) +
        ((17.67 * id(sht40_temperature).state) /
        (243.5 + id(sht40_temperature).state)))) /
        (17.67 - log(id(sht40_humidity).state / 100.0) -
        ((17.67 * id(sht40_temperature).state) /
        (243.5 + id(sht40_temperature).state)));

  - platform: template
    name: "${friendly_name} Altitude"
    unit_of_measurement: "m"
    icon: mdi:image-filter-hdr
    update_interval: 300s
    lambda: |-
      const float sea_level = ${sea_level_pressure_hpa};
      return ((id(sht40_temperature).state + 273.15) / 0.0065) *
        (powf(sea_level / id(bmp280_pressure).state, 0.190234) - 1);

  - platform: wifi_signal
    name: "${friendly_name} WiFi Signal"
    update_interval: 60s

  - platform: uptime
    name: "${friendly_name} Uptime"
    update_interval: 60s

binary_sensor:
  - platform: status
    name: "${friendly_name} Status"

  - platform: gpio
    name: "${friendly_name} Button"
    id: atom_button
    pin:
      number: GPIO41
      inverted: true
      mode:
        input: true
        pullup: true
    filters:
      - delayed_on_off: 50ms
    on_click:
      - min_length: 50ms
        max_length: 800ms
        then:
          - light.toggle: atom_led
      - min_length: 1000ms
        max_length: 5000ms
        then:
          - button.press: device_restart

button:
  - platform: restart
    name: "${friendly_name} Restart"
    id: device_restart
    internal: true

  - platform: safe_mode
    name: "${friendly_name} Safe Mode Boot"
    id: button_safe_mode

  - platform: factory_reset
    name: "${friendly_name} Factory Reset"
    id: factory_reset_btn

Config outline:

  • esp-idf on ESP32-S3 (not Arduino; lower RAM for BLE + Wi-Fi)
  • bluetooth_proxy with active: true, cache_services: true, connection_slots: 3
  • sht4x + bmp280_i2c on Grove I2C, update_interval: 300s
  • Absolute humidity, dew point, and altitude templates
  • wifi_signal and uptime sensors for placement debugging
  • Captive portal plus a named fallback AP for recovery

Official BLE proxy starting point: esphome/bluetooth-proxies (M5Stack Atom S3).