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

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.

Before
After

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.

Have since added 2 more tanks!