I’ve always had an interest in aquariums and have considered getting a ‘real’ one ever since I had a very small one with a few goldfish as a small child. Fast-forward to now: my wife and I decided to get a 10-gallon tank as a Christmas present for our 2 kiddos. Immediately, I knew I wanted to control and keep tabs on it as part of my Home Assistant setup, using ESPHome of course. Things like lights (both for visual effect and for growing plants) and temperature seemed obvious, but beyond that I wasn’t sure what types of data would be useful or attainable/justifiable (as well as what types of sensors were reasonably priced for something that’s simply a hobby). After spending a bit of time doing some general research on the interwebs (as well as brainstorming), I came up with the following parameters I felt COULD be viable to either control or measure:
- Automatic feeder
- Water filter/pump control
- Water temperature measurement and control (heating and cooling)
- Visual lighting
- Grow lights
- Water leaks
- Water level
- Total Dissolved Solids
- Total Suspended Solids / Turbidity
- Power draw
- Water filter (know when it needs to be changed)
- Camera
- PH of the water
Automatic Feeder
Useful/handy in general, but of specific need for when we are out of town. Started off testing out a couple 3D printed designs I found that seemed like they could work. Ultimately none of these worked all that well or consistently (even after my own modifications). Decided to buy a cheap timer-style one that’s simply a rotating cylinder and see if I could easily control it myself; couldn’t have gone better as it was trivial to directly control it (as well as monitor it’s operation), was inexpensive and has worked VERY consistently. It also had a limit switch that engages on each rotation of the feeder, allowing me to confirm that it actually ran (handy when we’re away from home).
Water filter/pump control
Originally didn’t plan to control this, but I ended up with an extra relay/outlet. Has come in super-handy when doing maintenance on the aquarium; push a button on my phone and the pump and heater both shutoff.
Water temperature measurement and control (heater and cooling fan)
Measuring temperature was obvious. Controlling the temperature wasn’t necessary (the heater does this itself), but it seemed ‘fun’ and potentially useful. Ultimately found that the temperature drifted quite a bit when just using the heater, so controlling it actually made more sense than I originally assumed. Generally cooling isn’t needed (and is all but certainly not in my climate), but possibly could be and simply consists of using a fan, so why not include it? I included pinouts for adding a 12V PC fan if it ever proves useful.
Visual lighting
Pretty obvious as a want/need and individually addressable LEDs (Neopixels) are easy.
Grow lights
In our case, having actual plants was a large part of things, so a grow light was a must. A 12V LED strip was used. I originally tried a 5V strip, but I kept getting some flicker no matter what electrical modifications I made (my suspicion is that the WiFi on the board was causing noise).
Water leaks
I’ve previously installed leak-detection cables in multiple locations of our house and found them to be super-handy. It seemed obvious to put one near/under the aquarium to alert us to any leaks that might not be immediately obvious. Additionally, this initial tank is located in a bathroom so it made sense to also include additional leak sensors to capture other potential sources of leaks.
Water level
There are multiple types of sensors you could use to measure the water level but I decided early-on to try using a ToF sensor. Wasn’t sure how well this would actually work, but I’ve found it to work ‘well-enough’ to let me know when I should start preparing some water as well as when the water level gets low enough that it NEEDS to have some added (so the pump can stay primed).
Total Dissolved Solids
Measure all the stuff that dissolves into the water but you can’t actually see. SLIGHTLY more expensive sensor ($10-15) and honestly haven’t decided if it’s actually all that useful, but it has been interesting to see the values change during various maintenance tasks.
Total Suspended Solids / Turbidity
Fairly cheap sensor (I believe they are just the same sensors used in dishwashers to tell how ‘dirty’ the water is). Literally just measures how much light the water blocks. Works well-enough, but I’ve found that it needs to be cleaned periodically as the slight biofilm that slowly builds up skews the readings over time. Also, not really sure if it’s actually useful, as I can ‘see’ if the water is dirty and needs replacing and the regular cleaning kind of cancels out the benefit of measuring it. The sensor itself isn’t really designed well for general use (it’s incredibly easy to get water in places where it shouldn’t be). I ended up designing and 3D printing an adapter piece to solve these short-comings.
Power draw
Completely not necessary, but since I am controlling the heater (and the pump and lights) it was easy to manually measure the power draw of each and setup a template sensor to estimate the power draw based on the current operation.
Overflow of the filter (so it needs to be changed)
This was a late-add as I wasn’t privy to how aquarium pumps/filters work. At least with ours, there is a small spillover area that gets utilized when the filter needs to be replaced (the filter backs up, raising the water level in the reservoir until it eventually reaches the spillover level). Essentially a leak detector (2 wires used as a touch-sensor) was placed in this spillover area to alert when water reaches that level.
Camera
This was something that I didn’t originally plan on, but on a whim I added one (originally used an old cellphone) and found it surprisingly fun to check on periodically. I already had a handful of ESP32-CAMs lying around that I had previously bought but found pretty unimpressive. But for this use-case, having a mediocre image at ~1fps is fine, if not ideal. I made sure that the final PCB design included pinouts for 5V and GND so I could easily wire up and power a camera off the main board without an extra power supply. I also designed and 3D printed a simple enclosure for it and used an extra suction cup mount I had from the bulb thermometer that came with the aquarium.
PH of the water
I’ve gone back-and-forth on this sensor numerous times since I started this project. Ultimately, I decided not to purchase or include it in my current aquarium, but I did include pinouts for a potential future addition (most likely in an additional/bigger tank). The big issues are that these sensors are relatively expensive (even the cheap ones are pricier than I’d like), they have to be replaced periodically (on the order of every year or sooner) and I haven’t found that measuring the PH is actually useful or something I care about (at least in my case/aquarium).
Once I had a general plan, next came the actual implementation. Before we even setup the aquarium, I purchased and setup/tested/calibrated most of the sensors as well as the ESPHome code. Because we were giving the aquarium to our kiddos on Christmas and planned to spend the day setting it up, I really wanted to be able to quickly and easily setup the sensors, etc. without wasting any time (let’s be honest, adding all of my ‘stuff’ was my Christmas present but I didn’t want it to take away from family-time on the day of). One thing that I didn’t initially plan on, but that worked quite well as I began setting things up in my office, is to use the water filter/pump reservoir as a convenient location for all of the water sensors (Temp, TSS & TDS). This led me to design a replacement cover to more easily route the wires.

The aquarium originally came with a cover that completely covered the tank. I initially planned to use this and just add my own lights, but ultimately decided to leave the top of the aquarium completely open (both for plants as well as being able to see things from the top). This led to me designing and 3D printing a light mount which housed the Neopixels, grow light LEDs and ToF sensor for water level. Pretty straight-forward, but also quite happy with how it turned out and how it integrated into the entire aquarium.

A few years back I got a small hobby CNC machine and I’ve been using it to mill my own PCBs ever since. I’ve gotten pretty good at the process and it’s fantastic if I want a PCB ‘now’ as well as if it’s a one-off, but in this case, I immediately saw the potential for us getting additional aquariums in the future and I was in no rush to finish up the project. This led me to having the PCBs made for me (this is what I used to do prior to having the CNC). Because I was having several of them made, I purposefully left things in a ‘temporary’ state for a while so I could test things out for an extended period and really nail down a final design (both for this and potential future aquariums). I also had the benefit of an already existing cabinet directly below the aquarium, so I could hide the rat’s nest of cables and components while it existed in it’s temporary state.
I also decided early in the PCB design process that I wanted to use literal plug-and-play methods to connect things. I’ve typically used screw terminals in the past, and while they work fine for many projects, in this case I could foresee situations where I might need to disconnect/reconnect things periodically. I’ve found phone-plugs to work well before and is what I utilized in this project, ultimately getting some 6P6C connectors (I’ve used 4P4C for other projects) so that I could reduce the total amount of wiring as well.
Unfortunately, after I received the PCB began wiring things up I found that the TSS & Turbidity sensors we’re giving me wonky readings. Ultimately realized I’d made a pretty silly mistake during the design, I failed to actually connect these analog outputs to their appropriate GPIO pins on the ESP (I took a lot of effort making sure this was the case, but clearly I missed these two). Soldering a couple of wires onto the back of the PCB fixed this mistake.


On the ‘code’ side of things, it was really quite simple and straight-forward, thanks to ESPHome. Outside of playing with some filtering of sensor values to reduce noise and variations, I just added what was needed for a given sensor and moved on. Nearly all of the useable GPIOs got used up, and simply managing these was probably the most complicated part. My current (and mostly finished) ESPHome YAML is below:
esphome:
name: aquarium-upstairs-bath
friendly_name: Aquarium Upstairs Bath
esp32:
board: esp32dev
framework:
type: arduino
# Enable logging
logger:
# Enable Home Assistant API
api:
reboot_timeout: 0s
encryption:
key: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
ota:
- platform: esphome
password: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
ssid: "Aquarium-Upstairs-Bath"
password: !secret wifi_password
status_led:
pin:
number: GPIO2
button:
- platform: restart
name: Restart
- platform: safe_mode
name: Restart (Safe Mode)
- platform: template
name: Feeding Time!
id: feeding_time
icon: mdi:shaker
on_press:
- script.execute: feeder
script:
- id: feeder
mode: single
then:
- switch.turn_on: feeder_motor
- wait_until:
condition:
binary_sensor.is_off: feeder_limit_switch
timeout: 10s
# If the limit switch is actually on, then the timeout occured and send an error event
- if:
condition:
binary_sensor.is_on: feeder_limit_switch
then:
- homeassistant.event:
event: esphome.aquarium_upstairs_bath_feeder
data:
message: The Feeder is Stuck! Error 1
- logger.log: "The Feeder is Stuck! Error 1"
- script.stop: feeder
- wait_until:
condition:
binary_sensor.is_on: feeder_limit_switch
timeout: 45s
# If the limit switch is actually off, then the timeout occured and send an error event
- if:
condition:
binary_sensor.is_off: feeder_limit_switch
then:
- homeassistant.event:
event: esphome.aquarium_upstairs_bath_feeder
data:
message: The Feeder is Stuck! Error 2
- logger.log: "The Feeder is Stuck! Error 2"
- script.stop: feeder
- delay: 2s
- switch.turn_off: feeder_motor
- homeassistant.event:
event: esphome.aquarium_upstairs_bath_feeder
data:
message: The Feeder Finished!
- logger.log: "The Feeder Finished!"
debug:
update_interval: 60s
text_sensor:
- platform: wifi_info
ip_address:
name: IP Address
- platform: debug
device:
name: "Device Info"
disabled_by_default: true
reset_reason:
name: "Reset Reason"
disabled_by_default: true
sensor:
- platform: wifi_signal
name: WiFi Strength
update_interval: 60s
disabled_by_default: true
- platform: uptime
name: Uptime
disabled_by_default: true
# TSS & TDS Sensors
- platform: adc
pin: GPIO34
name: Turbidity Sensor Raw
id: turbidity_sensor_raw
update_interval: 1s
attenuation: auto
internal: true
# Volts to TSS Reading
- platform: template
name: Turbidity Sensor
id: turbidity_sensor
accuracy_decimals: 0
update_interval: 1s
lambda: 'return (id(turbidity_sensor_raw).state);'
unit_of_measurement: 'NTU'
state_class: measurement
icon: mdi:water-opacity
filters:
- throttle: 15s
- calibrate_linear:
datapoints:
- 0.08 -> 1000 # Completely blocked 0.08V (with 3.3V power). While this scenario is ~4,000, seems like the sensor probably maxes out somewhere lower
- 1.77 -> 0 # Open Air 1.54V, clean water 1.77 (with 3.3V power). Call this 0
- sliding_window_moving_average:
window_size: 240 # 15s throttle (4x60=240=60min)
send_every: 20 # 15s throttle (4x5=20=5min)
send_first_at: 4 # 15s throttle (4x1=4=1min)
- clamp:
min_value: 0
max_value: 4000
ignore_out_of_range: false
# TDS requires temperature compensation
- platform: adc
pin: GPIO36
name: TDS Sensor Raw
id: tds_sensor_raw
update_interval: 1s
attenuation: auto
accuracy_decimals: 3
internal: true
filters:
- offset: -0.083 # When at '0', it still shows 0.08V
- clamp:
min_value: 0
ignore_out_of_range: false
# Temperature Compensated Voltage
- platform: template
name: TDS TCV
id: tds_tcv
device_class: voltage
unit_of_measurement: V
accuracy_decimals: 3
lambda: 'return ((id(tds_sensor_raw).state) / (1 + (0.02 * ((id(water_temperature).state) - 25.0))));' #This assumes the temperature is in Celsius. Not 100% sure whether the value is stored in C or F internally in ESPHome.
update_interval: 15s
internal: true
# Temperature Compensated TDS
- platform: template
name: TDS Sensor
id: tds_sensor
unit_of_measurement: 'PPM'
icon: mdi:water-opacity
accuracy_decimals: 0
state_class: measurement
update_interval: 1s
lambda: 'return (133.42*(id(tds_tcv).state)*(id(tds_tcv).state)*(id(tds_tcv).state) - 255.86*(id(tds_tcv).state)*(id(tds_tcv).state) + 857.39*(id(tds_tcv).state))*0.5;'
filters:
- throttle: 15s
- sliding_window_moving_average:
window_size: 240 # 15s throttle (4x60=240=60min)
send_every: 20 # 15s throttle (4x5=20=5min)
send_first_at: 4 # 15s throttle (4x1=4=1min)
- clamp:
min_value: 0
ignore_out_of_range: false
# ToF Sensor
- platform: vl53l0x
name: VL53L0x Distance
id: vl53l0x_distance
device_class: distance
unit_of_measurement: m
update_interval: 5s
address: 0x29
long_range: false
timeout: 10ms
internal: true
# Water Level
- platform: template
name: Water Level
id: water_level
accuracy_decimals: 0
update_interval: 1s
lambda: 'return (id(vl53l0x_distance).state);'
unit_of_measurement: '%'
state_class: measurement
icon: mdi:waves-arrow-up
filters:
- throttle: 60s
# Switched to median to filter out random extreme values
- median:
window_size: 720 # 60s throttle (1x60*12=720=12 hours)
send_every: 15 # 60s throttle (1x15=15=15 minutes)
send_first_at: 5 # 60s throttle (1x5=5=5 minutes)
- calibrate_linear:
datapoints:
- 0.17 -> 0 # Rough placeholder
- 0.095 -> 100 # Oscillated between 0.09 & 0.1
- round_to_multiple_of: 10
- clamp:
min_value: 0
max_value: 100
ignore_out_of_range: false
# Temperature Sensor
- platform: dallas_temp
one_wire_id: dallas_hub
name: Water Temperature
id: water_temperature
icon: mdi:water-thermometer
update_interval: 15s
#Used a power meter to get the power draw of the pump and heater
# Pump=2.7W, Heater=52W, ESPs(including camera)=2.8W, Grow Light(100%)=7.8W, Neopixels(100%)=6W
- platform: template
name: Power
unit_of_measurement: "W"
device_class: "power"
lambda: return (2.8 + ((id(filter_pump).state)*2.7) + ((id(heater).state)*52) + (id(grow_light).current_values.is_on()*id(grow_light).current_values.get_brightness()*7.8) + (id(neopixel_lights).current_values.is_on()*id(neopixel_lights).current_values.get_brightness()*6) );
update_interval: 1s
# Dallas Hub for Temp sensor
one_wire:
- platform: gpio
pin: GPIO04
id: dallas_hub
# For VL52L0X ToF Sensor
i2c:
sda: GPIO26
scl: GPIO27
scan: true
id: bus_a
# Leak Detection Cables
esp32_touch:
# setup_mode: true
binary_sensor:
- platform: esp32_touch
name: Filter Overflow
device_class: moisture
pin: GPIO33
threshold: 30 # For the water furnace, water heater, sink and washing machine cable I see values always above 60 when dry and always zero when wet
filters:
- delayed_off: 5s
# Leak Detection Cables
- platform: esp32_touch
name: Aquarium Water Leak Sensor
device_class: moisture
pin: GPIO13
threshold: 30 # For the water furnace, water heater, sink and washing machine cable I see values always above 60 when dry and always zero when wet
filters:
- delayed_off: 5s
- platform: esp32_touch
name: Sink Water Leak Sensor
device_class: moisture
pin: GPIO14
threshold: 30 # For the water furnace, water heater, sink and washing machine cable I see values always above 60 when dry and always zero when wet
filters:
- delayed_off: 5s
- platform: esp32_touch
name: Washing Machine Water Leak Sensor
device_class: moisture
pin: GPIO15
threshold: 30 # For the water furnace, water heater, sink and washing machine cable I see values always above 60 when dry and always zero when wet
filters:
- delayed_off: 5s
# Feeder Limit Switch
- platform: gpio
pin:
number: GPIO32
inverted: true
mode:
input: true
pullup: true
name: Feeder Limit Switch
id: feeder_limit_switch
switch:
# Heater Relay
- platform: gpio
pin: GPIO18
name: Heater
id: heater
device_class: switch
icon: mdi:sun-thermometer
restore_mode: ALWAYS_OFF
# Filter Pump
- platform: gpio
pin: GPIO25
name: Filter Pump
id: filter_pump
device_class: switch
icon: mdi:pump
restore_mode: ALWAYS_ON
# disabled_by_default: true
# Feeder Motor
- platform: gpio
pin: GPIO21
name: Feeder Motor
id: feeder_motor
restore_mode: ALWAYS_OFF
icon: mdi:engine
#Cooling Fan
output:
- platform: ledc
id: fan_speed_output
pin: GPIO19
frequency: 25000 Hz # 25KHz is standard PC fan frequency, minimises buzzing
channel: 0
- platform: ledc
id: grow_light_output
pin: GPIO22
channel: 4
fan:
- platform: speed
output: fan_speed_output
name: Cooling Fan
id: cooling_fan
icon: mdi:snowflake-thermometer
disabled_by_default: true
light:
- platform: monochromatic
name: Grow Light
id: grow_light
output: grow_light_output
icon: mdi:weather-sunny
restore_mode: RESTORE_DEFAULT_OFF
- platform: neopixelbus
name: Neopixel Lights
id: neopixel_lights
type: GRBW
variant: SK6812
pin: GPIO23
num_leds: 24
icon: mdi:wall-sconce-flat
restore_mode: RESTORE_DEFAULT_OFF
climate:
- platform: thermostat
name: Thermostat
id: water_thermostat
sensor: water_temperature
visual:
min_temperature: 65 °F
max_temperature: 85 °F
temperature_step:
target_temperature: 0.5 °F
current_temperature: 0.1 °F
min_heating_off_time: 300s
min_heating_run_time: 120s
min_idle_time: 300s
heat_deadband: 0.5 °F
heat_overrun: 0.5 °F
heat_action:
- switch.turn_on: heater
idle_action:
- switch.turn_off: heater
default_preset: Tropical Fish
on_boot_restore_from: memory
preset:
- name: Tropical Fish
mode: HEAT
default_target_temperature_low: 77 °F
on_control:
- if:
condition:
- lambda: |-
return (id(water_thermostat).mode != CLIMATE_MODE_OFF);
then:
- switch.turn_off: filter_pump
else:
- switch.turn_on: filter_pump
Everything gets pulled into Home Assistant and most of the data lives on a dedicated dashboard. I automate a few things like turning the lights (visual and grow) on/off throughout the day, alerting me to potential issues (temperature variations, water leak, etc.) and actionable notifications to feed the fish.



Ben Brooks