AtomS3 Lite + ENV IV: ESPHome BLE proxy and room sensors in one box
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.

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 (I2C0x44) - Bosch
BMP280: barometric pressure (I2C0x76)
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-idfon ESP32-S3 (not Arduino; lower RAM for BLE + Wi-Fi)bluetooth_proxywithactive: true,cache_services: true,connection_slots: 3sht4x+bmp280_i2con Grove I2C,update_interval: 300s- Absolute humidity, dew point, and altitude templates
wifi_signalanduptimesensors for placement debugging- Captive portal plus a named fallback AP for recovery
Official BLE proxy starting point: esphome/bluetooth-proxies (M5Stack Atom S3).
Links
- Pax BLE for Home Assistant: github.com/eriknn/ha-pax_ble
- ESPHome Bluetooth proxies: github.com/esphome/bluetooth-proxies
- M5Stack AtomS3 Lite: docs.m5stack.com
- M5Stack Unit ENV IV: docs.m5stack.com