Skip to content

UV Sensor

A weather app gives you the UV index for your postcode. This gives you the UV index where you are actually standing — the patio, the greenhouse, the corner of the garden that only gets four hours of sun and can’t work out why the tomatoes sulk. An LTR390 measures UV and light level, a BME280 adds temperature, humidity and barometric pressure, and a 0.96 in OLED shows the two live numbers so you don’t have to open Home Assistant to read them.

It is one ESPHome config and three I2C modules. No soldering required if you have female-to-female jumpers.

Parts

Some links below are Amazon affiliate links. As an Amazon Associate I earn from qualifying purchases — at no extra cost to you. It helps keep these guides free. Outside the US, they should send you to your own Amazon store; if one doesn't, searching the part number there will find it.

Nothing clear is clear to UV. The clear cover on that junction box, a double-glazed window, a sheet of acrylic — all of them pass visible light happily and cut UV hard. Put the LTR390 behind any of them and the lux reading stays sensible while the UV index collapses to a fraction of the real figure, which is the worst kind of wrong: it looks like it is working.

The sensor needs a hole in the lid and a bead of sealant around it, or it needs to be outside the box entirely on a short lead. If you leave it behind glass, treat the UV number as a relative trend for that one spot and never as an index you would base sunscreen on.

Wiring

Every pin here comes out of bruh-uv-sensor.yaml. Both buses run at 400 kHz.

SignalESP32 GPIOBusGoes to
SDAGPIO5bus_aLTR390 SDA and OLED SDA
SCLGPIO4bus_aLTR390 SCL and OLED SCL
SDAGPIO17bus_bBME280 SDA
SCLGPIO16bus_bBME280 SCL
3V3VCC on all three modules
GNDGND on all three modules

Power all three from 3V3, not from the 5 V pin. All three modules will run from 5 V, and all three will then pull their I2C lines up to 5 V — into an ESP32 whose pins are 3.3 V parts. It works right up until it doesn’t.

Why there are two buses

The README calls it sensor isolation. In practice the split buys you two things. The BME280 gets its own pair of pins, so it can sit on a length of wire well away from the board without dragging the display’s bus out to the same place. And the display and the UV sensor — the two parts that talk constantly — keep a bus to themselves, so a BME280 that stops answering cannot wedge the screen.

There is a wrinkle worth knowing: bus_a is declared with scan: false and bus_b with scan: true. The boot log will therefore list what it found on the BME280’s bus and say nothing at all about the display’s. Flip bus_a to scan: true while you are bringing the board up, and put it back afterwards.

Assembly

  1. Wire bus_a first — the OLED and the LTR390 both onto GPIO5 and GPIO4. Flash the config with the BME280 left off entirely. If the screen lights up and shows a lux figure, the hard half is done.

  2. Add the BME280 on bus_b on a lead long enough to reach wherever it is going to live. Keep it as short as that allows; I2C at 400 kHz is not a long-run bus.

  3. Mount the LTR390 so it looks at open sky, through a hole rather than through the lid. Nothing else in the build cares which way up it sits.

  4. Put the BME280 somewhere with air and shade — not sealed in the same box as the ESP32, and out of the sun. Again, see Calibration.

  5. Close it up last, once every entity has appeared in Home Assistant and the numbers look plausible.

Firmware

Copy bruh-uv-sensor.yaml from the download section below into your ESPHome folder, then handle these before you hit compile.

  1. Put Roboto-Medium.ttf next to the YAML. The font: block names it as a plain relative path, so ESPHome looks for it beside the config and the build fails outright if it isn’t there. Roboto is a free download from Google Fonts. This is the most common first-compile failure on this config.

  2. Fill in secrets.yamlwifi_ssid, wifi_password, ota_password, ap_password and api_key. There is a secrets.yaml.example in the repo with every key these configs use.

  3. Rename it if you are building more than one. There is no substitutions: block here: the device is bruh-uv-sensor-1 and the fallback hotspot is Bruh-Uv-Sensor-1, both written out in full. Edit esphome.name, friendly_name and the AP ssid for each additional node.

  4. Flash over USB the first time. Every flash after that is over WiFi, and captive_portal plus the fallback AP is what gets you back in if it loses the network.

It appears in Home Assistant on its own — ESPHome devices are discovered, so there is nothing to write on the Home Assistant side.

The display

The lambda draws two lines: light level at the top, UV index at the bottom, both in the 15 px Roboto. rotation: 180 is set, so if the screen comes up upside down in your box, that key is the one to change.

Both lines are formatted %.0f, so the screen rounds. A UV index of 5.4 shows as UV: 5. The entity in Home Assistant keeps the decimals — the rounding is on the glass only, and at that font size it is the right call.

The repaint is hooked to the light reading: on_value on the LTR390’s light sensor calls component.update: my_display, and a delta: 1 filter sits above it, so a lux reading that hasn’t moved a whole lux never gets that far. If you would rather the screen ticked on a fixed cadence, add an update_interval: to the display: block and drop the on_value.

What you get in Home Assistant

From the LTR390, every 10 seconds: UV index and light in lux, plus UV sensor counts and light sensor counts, which are the raw values the other two are computed from. The raw pair is worth having while you calibrate and worth hiding afterwards.

From the BME280, every 30 seconds: temperature (16× oversampled), humidity and pressure.

Nothing in this config carries a calibration offset, which is deliberate. The numbers you get are what your parts measured, not somebody else’s corrections baked into a file you copied.

Calibration

The BME280 will read hot if you seal it in with the ESP32. A WiFi ESP32 in a closed plastic box is a small heater, and the BME280 is a small, fast thermometer. Put it on a lead, outside the box or at least at the far end of it, shaded, with air moving past. This is the entire reason bus_b exists as its own pair of pins, and it is worth using.

Check the UV index against a forecast, at solar noon, on a cloudless day. The figure the LTR390 reports is computed from raw counts and assumes an unobstructed view of the sky. If it reads consistently low against your local published index, something is over the sensor — glass, a lid, a soffit, or dust on the hole you cut for it.

The lux figure is a real lux figure, not a percentage, so it can be checked against the light meter in a phone app. If the two disagree wildly, make sure the sensor isn’t shadowed by the OLED or by its own wiring.

Troubleshooting

The build fails before it flashes anything. Roboto-Medium.ttf is not beside the YAML. It is not bundled with ESPHome.

The screen is dark and no UV entities appear. Both live on bus_a, and bus_a has scan: false, so the boot log won’t tell you a thing about it. Set scan: true, reflash, and read the log — you get either two addresses or none, and none means GPIO5 and GPIO4 are swapped or unpowered.

Temperature, humidity and pressure are missing. bus_b does scan, so the boot log lists what answered. A BME280 clone strapped to 0x77 instead of 0x76 shows up there immediately; change the address: line under the bme280_i2c platform to match.

The screen says nan. No reading has landed yet. The LTR390 updates every 10 seconds, so give it that long after boot. If it is still nan a minute later it is the bus, not the display.

Readings drop out once the BME280 is on a long lead. 400 kHz down a metre of jumper wire is asking a lot. Change frequency: 400kHz to 100kHz on bus_b only — the display’s bus can stay fast.

UV reads near zero all day. You are behind glass. See the caution under Parts.

Files & downloads

ESPHome configuration

Copy this into your ESPHome directory and adjust the substitutions at the top. Secrets are referenced by name — see secrets.yaml.example .

bruh-uv-sensor.yaml 103 lines
bruh-uv-sensor.yaml
# BRUH UV Sensor
# ────────────────────────────────────────────────────────────
# UV light sensor with environmental monitoring.
# Features LTR390 UV sensor, BME280 environmental sensor,
# and OLED display for real-time readings.
#
# Hardware:
# - Board: ESP32
# - Sensor: LTR390 (UV index and light level)
# - Sensor: BME280 (temperature, pressure, humidity)
# - Display: SSD1306 OLED 128x64
# ────────────────────────────────────────────────────────────
esphome:
name: bruh-uv-sensor-1
friendly_name: "BRUH UV Sensor"
esp32:
board: esp32dev
framework:
type: arduino
logger:
api:
encryption:
key: !secret api_key
ota:
password: !secret ota_password
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ap:
ssid: "Bruh-Uv-Sensor-1"
password: !secret ap_password
captive_portal:
i2c:
- id: bus_a
sda: GPIO5
scl: GPIO4
scan: false
frequency: 400kHz
- id: bus_b
sda: GPIO17
scl: GPIO16
scan: true
frequency: 400kHz
sensor:
- platform: ltr390
update_interval: 10s
i2c_id: bus_a
uv_index:
name: "UV index"
id: uv_index
uv:
name: "UV sensor counts"
id: uv_counts
filters:
- delta: 1
light:
name: "light"
id: light
filters:
- delta: 1
on_value:
then:
- component.update: my_display
ambient_light:
name: "light sensor counts"
id: light_counts
- platform: bme280_i2c
i2c_id: bus_b
temperature:
name: "temperature"
oversampling: 16x
pressure:
name: "pressure"
humidity:
name: "humidity"
address: 0x76
update_interval: 30s
display:
- platform: ssd1306_i2c
id: my_display
i2c_id: bus_a
model: "SSD1306 128x64"
rotation: 180
address: 0x3C
lambda: |-
it.printf(5, 5, id(font1), "light: %.0f lx", id(light).state);
it.printf(5, 40, id(font1), "UV: %.0f", id(uv_index).state);
font:
- file: 'Roboto-Medium.ttf'
id: font1
size: 15

View on GitHub · Download raw

Every file for this project on GitHub →