AquaEye - Aquarium/Fishtank monitor and controller (ongoing project)

Hi,
This is my ongoing project.

It is a Aquarium monitoring system. It has a circuit board part, with some sensors (pH, Flux, Water Level and temperature) and the HomeAssistant part, that will show the values and control some equipment connected.

It has a lot of improvements to make. Any suggestions are welcome. I’m not a graphic designer, so all graphics are from internet. Also, I’m quite new on HA programming.

I'll be updating this post until the full project is finished.

Lovelace Dashboard:
aquario

All controls are executed clicking on the corresponding device on the picture. And the state is shown on the device:

  1. The water level is shown with fluid-level and reflects the sensor that’s installed on the aquarium. There’s the possibility to make automatic the refill (I don’t have).

  2. Canister flux is shown as a fan with the value below. I defined some severities so that it will be red/green/orange if it’s too slow.

  3. Clicking on the circulation pump activates it and a fan spins green.

  4. Clicking on the frog (I hava a “bubble frog”), activates the air comprassor connected to a frog in my aquarium. The water on the fliud changes, full with bubbles.

  5. The CO2 shows the estimated level of gas. For this I created a helper to store the last fill and another that has the estimated duration. Clicking on the valve opens/closes the solenoid to liberate the gas. Clicking on the canister, the date is set to today, indicating the REFILL was done and the level is updated.

  6. The light still has the basic on/of behavior, but I intend to reflect the intensity later.

  7. UV light on/off clicking on it.

  8. pH level will reflect the sensor. But I need a more representable gauge. I’m using the canvas because it was the closest I need, but it does not accept percentage to define the size. I will look for another one.

  9. Temperature bar: I 'd like to make it the same size as the picture. And the number n vertical. I will search how can I do that. Still couldn’t find out how .

    Todo:

  • Better light control, reflecting the brightness level, changing the opacity

  • better pH gauge.

  • Better positioning. Still learning how to do this correctly. I’m having issues when accessing in little screens, such as a phone.

  • Controls to turn on devices not in the picture, like heater, or refill CO2.

  • Adjust temperature bar

  • Show other parameters (amnonia, nitrite, nitrate, etc…)

Here are the codes and project details (yes, they can be immproved, but it works):

======================================
AQUAEYE BUILD AND CONFIGURATION

This is the heart of the project. It’s a circuit board based on ESP32 (or ESP8266), with ESPHome firmware and connected with some sensors that returns to HomeAssistant the sensors data.
Actually it has 4 sensors:

substitutions:
  name: "aquaeye-sala"
  friendly_name: "AquaEye Sala"
  dns: "aqyaeye-sala.casa"
  flux_time: "60s"
  ph_time: "60s"
  temp_time: "60s"
  level_time: "60s"


esphome:
  name: ${name}
  friendly_name: ${friendly_name}
  min_version: 2024.11.0
  name_add_mac_suffix: false
  project:
    name: "alexantao.aquaeye"
    version: dev

esp32:
  board: esp32-c3-devkitm-1
  variant: ESP32C3
  #restore_from_flash: true
  framework:
    type: esp-idf

# Enable logging
logger:

# Enable Home Assistant API
api:

# Allow Over-The-Air updates
ota:
- platform: esphome

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  output_power: 8.5dB
  #use_address: ${dns}

globals:
  - id: retorno
    type: float
    initial_value: "0.0"
  - id: freq_fluxo                 # pulses per second per litre/minute of flow
    type: float
    restore_value: yes
    initial_value: "6.6"
  - id: sonic_offset                # Offset do Sensor até a água quando o tanque está cheio (em mm)
    type: int
    restore_value: yes
    initial_value: '23'
  - id: coluna_agua_total          # Medida da coluna de água quando o tanque está cheio (em mm)
    type: int
    restore_value: yes
    initial_value: '420'



####################################################################################
# LUZES
output:
  - platform: ledc
    id: luz_verde
    pin: GPIO10


light:
  - platform: status_led
    name: "Status LED"
    id: esp_status_led
    icon: "mdi:alarm-light"
    pin:
      number: GPIO8
      inverted: true
    restore_mode: ALWAYS_OFF
  - platform: binary
    name: "Luz Verde"
    output: luz_verde

####################################################################################
# TEMPERATURA
one_wire:
  - platform: gpio
    pin: GPIO4

####################################################################################
# SENSORES
sensor:
  #------------------------------------------------------------#
  - platform: homeassistant
    id: ha_freq_fluxo
    entity_id: input_number.freq_fluxo_sala
    on_value:
      then:
        if:
          condition:
              lambda: 'return id(freq_fluxo) != float(x);'
          then:
            - logger.log:
                level: DEBUG
                format: 'Mudança na Freq Fluxo : de %f para %f'
                args: ['id(freq_fluxo)', 'float(x)']
            - globals.set:
                id: freq_fluxo
                value: !lambda 'return float(x);'
  #------------------------------------------------------------#
  - platform: homeassistant
    id: ha_sonic_offset
    entity_id: input_number.sonic_offset_sala
    on_value:
      then:
        if:
          condition:
              lambda: 'return id(sonic_offset) != int(x);'
          then:
            - logger.log:
                level: DEBUG
                format: 'Mudança no Offset Sonico : de %d para %d'
                args: ['id(sonic_offset)', 'int(x)']
            - globals.set:
                id: sonic_offset
                value: !lambda 'return int(x);'
  #------------------------------------------------------------#
  - platform: homeassistant
    id: ha_coluna_agua_total
    entity_id: input_number.coluna_agua_total_sala
    on_value:
      then:
        if:
          condition:
              lambda: 'return id(coluna_agua_total) != int(x);'
          then:
            - logger.log:
                level: DEBUG
                format: 'Mudança na Coluna de Agua : de %d para %d'
                args: ['id(coluna_agua_total)', 'int(x)']
            - globals.set:
                id: sonic_offset
                value: !lambda 'return int(x);'
  #------------------------------------------------------------#
  - platform: wifi_signal
    name: 'Sinal Wifi'
    update_interval: 60s
    accuracy_decimals: 0

  - platform: uptime
    name: "Tempo de Monitoração"
    unit_of_measurement: days
    update_interval: 300s
    accuracy_decimals: 1
    filters:
      - multiply: 0.000011574
  #------------------------------------------------------------#
  - platform: dallas_temp
    name: "Temperatura"
    update_interval: "${temp_time}"
    id: temperatura
    filters:
      - filter_out: nan
      - clamp:
          min_value: 0
  #------------------------------------------------------------#
  - platform: ultrasonic  
    id: distancia_agua
    trigger_pin: GPIO1
    echo_pin: GPIO0
    name: "Distância da Água"
    unit_of_measurement: "m"
    icon: "mdi:waves-arrow-up"
    accuracy_decimals: 2
    update_interval: ${level_time}
    #timeout: 2.0m
    pulse_time: 20us
    filters:
       - filter_out: nan
    on_value:
      - sensor.template.publish:
          id: nivel
          state: !lambda return ( (id(coluna_agua_total) - (id(distancia_agua).state*1000.0 - id(sonic_offset))) * 100 ) / id(coluna_agua_total);
      # x = leitura atual em m
      # Altura Total : 50 cm       AT 
      # Offset do Sensor= 2 cm     OS 
      # Coluna de Agua = 42 cm     CA
      # FALTA = (x*100 - OS) =  (x*100 - 2)  
      # SOBRANDO = (CA - FALTA) = CA - (x*100 - OS)
      # % = (SOBRANDO * 100) / CA

  - platform: template
    name: "Nível da Água"
    id: nivel
    unit_of_measurement: '%'
    accuracy_decimals: 0
    icon: "mdi:water-percent"
    filters:
        - filter_out: nan
        - median: 
            send_first_at: 1
            window_size: 4
            send_every: 2

  #------------------------------------------------------------#
  - platform: pulse_counter
    state_class: total_increasing
    name: "Frequência do Fluxo"          # É o valor retornado pelo sensor, em pulsos/min
    id: freq_fluxo_sala
    pin: GPIO3
    update_interval: ${flux_time}
    icon: "mdi:wave-undercurrent"
    unit_of_measurement: "pul/min"
    accuracy_decimals: 0
    filters:
      - filter_out: nan
      - clamp:
          min_value: 0
    on_value:
      - sensor.template.publish:
          id: fluxo
          state: !lambda return ( (id(freq_fluxo_sala).state / (id(freq_fluxo)*60) ) *60); #Flow pulse: F=(6.68Q)±5% with Q=L/min

  - platform: template
    name: "Fluxo Canister"
    id: fluxo
    unit_of_measurement: 'L/h'
    accuracy_decimals: 1
    icon: "mdi:waves-arrow-right"
    filters:
        - filter_out: nan

 
  #- platform: integration
  #  id: fluxo_atual
  #  device_class: water
  #  state_class: measurement
  #  name: "Fluxo da Filtragem"
  #  unit_of_measurement: 'L/h'
  #  accuracy_decimals: 2
  #  sensor: freq_fluxo_sala
  #  time_unit: min
  #  icon: "mdi:waves-arrow-right"
  #  filters:
  #      - lambda: return ( (x / (id(freq_fluxo)*60) ) *60); #Flow pulse: F=(6.68Q)±5% with Q=L/min

  - platform: integration
    device_class: water
    state_class: total_increasing
    name: "Litros Filtrados"
    unit_of_measurement: 'L'
    accuracy_decimals: 1
    sensor: fluxo
    time_unit: min
    icon: "mdi:cup-water"

  #------------------------------------------------------------#
  - platform: adc
    pin: GPIO2
    id: ph_ads
    name: "pH ads"
    icon: "mdi:flash-triangle-outline"
    update_interval: ${ph_time}
    unit_of_measurement: "mV"
    accuracy_decimals: 3
    filters:
      - median:
          window_size: 6
          send_every: 6
          send_first_at: 2
      #Measured voltage -> Actual pH (buffer solution)
      - calibrate_linear:
          - 0.59 -> 7.0
          - 0.71 -> 4.0
####################################################################################

======================================
HOMEASSISTANT CONFIGURATION

Helpers
There are some temporary helpers, because aquaeye if not fully finished yet (like ph, level, etc…).
Some of these Helpers are connectors for the AquaEye Project and others for the UI on HomeAssistant.

  • input_number.sonic_offset_sala
  • input_number.freq_fluxo_sala
  • input_number.aqua_sala_prof
  • input_number.coluna_agua_total_sala
  • input_boolean.calib_ph
  • input_number.v_calib_mv4
  • input_number.v_calib_mv7
  • input_number.v_calib_mv9
  • input_select.c_calib_ph4
  • input_select.c_calib_ph7
  • input_select.c_calib_ph9_10

Timers
You will need to create a timer to control de time the light will be lit. Mine is:

  • timer.timer_luz_aquario

HA templates.yaml

- sensor:
  - name: "pH do Aquário da Sala"
    unique_id: sala_ph
    unit_of_measurement: "pH"
    device_class: ph
    icon: mdi:ph
    state: "{{ (states('input_select.c_calib_ph9_10')| float) - ( (((states('input_number.v_calib_mv9') | float)-(states('sensor.aquaeye_sala_ph_ads') | float(0)))*((states('input_select.c_calib_ph7')| float)-(states('input_select.c_calib_ph9_10') | float))) / ((states('input_number.v_calib_mv7') | float)-(states('input_number.v_calib_mv9') | float)) ) | round(2) }}"

  - name: "Nível do CO2 da Sala"
    icon: mdi:co2
    unique_id: co2_sala_nivel
    state: >
      {% set abastecimento = states('input_datetime.dia_reabastecimento_co2_sala') | as_datetime | as_local %}
      {% set duracao = states('input_number.duracao_co2_sala') |int  %}
      {% set hoje = now() %}
      {% set dias_passados = (hoje - abastecimento).days |int %}
      {% set nivel = 100 - ((dias_passados * 100) / duracao) |int %}
      {{ nivel | int }}

  - name: "Tempo Restante Luz da Sala"
    icon: mdi:timer-outline
    unique_id: tempo_restante_luz_sala
    value_template: >
      {% set f = state_attr('timer.timer_luz_aquario', 'finishes_at') %}
      {{ '00:00:00' if f == None else
        (as_datetime(f) - now()).total_seconds() | timestamp_custom('%H:%M:%S', false) }}

lovelace panel

Requirements:

  • custom:pool-monitor-card (for now, for the pH gouge)
  • custom:fluid-level-background-card
  • custom:button-card
  • custom:slider-entity-row
  • custom:hui-element
  • custom:bar-card
  • custom:card-templater
type: picture-elements
image: /local/itens/aquario-fundo.png
elements:
  - type: image
    entity: switch.yuri_aquecedor
    title: Aquecedor
    tap_action:
      action: none
    state_image:
      "on": /local/itens/aquario-aquecedor-LIG.png
    style:
      z-index: 1
      left: "0"
      top: "0"
      transform: none
  - type: custom:pool-monitor-card
    display:
      compact: true
    sensors:
      ph:
        - entity: input_number.ph_temporario
    style:
      z-index: 1
      top: 80%
      left: 50%
      width: 40%
  - type: custom:fluid-level-background-card
    entity: sensor.co2_sala_nivel
    background_color: transparent
    show_state: true
    show_icon: false
    full_value: 100
    severity:
      - value: 10
        color: red
      - value: 30
        color: yellow
      - value: 80
        color: rgba(80, 80, 60, 1)
    style:
      z-index: 3
      top: 78%
      left: 10.4%
      width: 6.3%
      height: 60%
    card_mod:
      style: |
        ha-card {
          text-align: center;
          --ha-card-border-color: transparent !important;
          box-shadow: none !important;
          background: none !important;
          border-radius: 10px;
          overflow: hidden;
        }  
        #container, .container {
          width: 100% !important;
          height: 40% !important;
          position: relative !important;
          border-radius: 14px !important;
          margin-left: 0%;
          margin-top: 0%;
          opacity: 0.4;
          overflow: hidden;
        }
    card:
      type: custom:button-card
      entity: sensor.co2_sala_nivel
      title_template: "{{ states('sensor.co2_sala_nivel')|round(0) }} %"
      show_header_toggle: false
      show_name: false
      show_icon: false
      show_title: true
      tap_action:
        action: call-service
        confirmation:
          text: Marcar hoje como dia de recarga ?
        service: input_datetime.set_datetime
        service_data:
          entity_id: input_datetime.dia_reabastecimento_co2_sala
          date: "[[[ return new Date().toLocaleDateString('en-CA') ]]]"
        value: "off"
      card_mod:
        style: |
          ha-card {
           --ha-card-header-font-size: 20px;
             height: 100px !important;
             color: white;
             font-weight: 800;
          }
          .card-header {
           justify-content: center !important;
          }
          .name {
           overflow: unset !important;
            }
  - type: image
    entity: switch.alimentador_sala
    title: Alimentar Peixinhos
    tap_action:
      action: toggle
    state_image:
      "on": /local/itens/feeder.png
      "off": /local/itens/feeder-off.png
      unknown: /local/itens/feeder-off.png
    style:
      z-index: 3
      left: 30%
      top: 25%
      transform: scale(0.2, 0.2) translate(-50%, -50%)
  - type: image
    entity: switch.aeracao_aquario
    title: Aeração do Aquário
    tap_action:
      action: toggle
    state_image:
      "on": /local/itens/sapo-ligado.png
      "off": /local/itens/sapo-desligado.png
    style:
      z-index: 3
      left: 50%
      top: 35%
      transform: scale(0.2, 0.2) translate(-50%, -50%)
  - type: image
    entity: switch.energia_aquario_uv
    title: Luz UV
    tap_action:
      action: toggle
    state_image:
      "on": /local/itens/uv-ligada.png
      "off": /local/itens/uv-desligada.png
    style:
      z-index: 2
      left: "-8%"
      top: "-5%"
      transform: scale(0.1, 0.1) translate(-50%, -50%)
  - type: state-label
    entity: sensor.tempo_restante_luz_da_sala
    style:
      z-index: 3
      left: 35%
      top: 9%
      color: lightgreen
      font-size: 200%
    conditions:
      - entity: sensor.tempo_restante_luz_da_sala
        state_not: "00:00"
  - type: custom:slider-entity-row
    entity: light.luz_do_aquario
    style:
      z-index: 3
      left: 50%
      top: 9%
    card_mod:
      style: |
        ha-card {
         --ha-card-header-font-size: 20px;
           height: 100px !important;
           color: white;
           font-weight: 800;
        }
        .card-header {
         justify-content: center !important;
        }
        .name {
         overflow: unset !important;
        }
  - type: image
    entity: light.luz_do_aquario
    tap_action: none
    hold_action: none
    state_image:
      "on": /local/itens/luz-ligada.png
      "off": /local/itens/luz-desligada.png
    style:
      z-index: 2
      top: "-12%"
      width: 100%
      transform: scale(0.7, 0.7) translate(0%, 0%)
      opacity: ${ vars[0] / 255.0 }
  - type: image
    image: /local/itens/circulação.png
    style:
      z-index: 2
      top: 65%
      left: 80%
      height: 28%
      transform: scale(0.6, 0.6) translate(-50%, -50%)
  - type: image
    entity: switch.aquario_co2
    title: CO2 - Clique na válvula para ligar/desligar
    tap_action:
      action: toggle
    state_image:
      "on": /local/itens/co2-cilindro-ON.png
      "off": /local/itens/co2-cilindro-OFF.png
    style:
      z-index: 2
      top: "-10%"
      left: "-13%"
      transform: scale(0.2, 0.2) translate(-50%, -50%)
  - type: conditional
    conditions:
      - entity: input_boolean.aquaeye_ui_config
        state: "on"
    elements:
      - type: custom:hui-element
        card_type: vertical-stack
        cards:
          - type: horizontal-stack
            cards:
              - type: entities
                title: Sensores
                entities:
                  - entity: input_number.coluna_agua_total_sala
                    name: Coluna de Água
                  - entity: input_number.sonic_offset_sala
                    name: Distância do Sensor
                  - entity: input_number.freq_fluxo_sala
                    name: Fator do Sensor de Fluxo
              - type: entities
                title: Equipamentos
                entities:
                  - entity: input_number.aquario_max_luz
                    name: Iluminação Máx
                  - entity: input_number.aquario_tempo_circulacao
                  - entity: input_number.aquario_tempo_da_aeracao
                  - entity: input_number.fader_luz_sala
          - type: horizontal-stack
            cards:
              - type: entities
                title: pH - Temp Sala / Calibração
                entities:
                  - entity: sensor.aquaeye_sala_ph_ads
                    name: pH ads
                  - entity: sensor.ph_do_aquario_da_sala
                  - entity: sensor.aquaeye_sala_temperatura
                  - entity: input_boolean.calib_ph
              - type: entities
                title: Calibração pH Sala
                entities:
                  - entity: input_select.c_calib_ph4
                  - entity: input_number.v_calib_mv4
                    name: mV4
                  - type: divider
                  - entity: input_select.c_calib_ph7
                  - entity: input_number.v_calib_mv7
                    name: mV7
                  - type: divider
                  - entity: input_select.c_calib_ph9_10
                  - entity: input_number.v_calib_mv9
                    name: mV9-10
                visibility:
                  - condition: state
                    entity: input_boolean.calib_ph
                    state: "on"
        style:
          z-index: 3
          top: 35%
          left: 30%
  - type: custom:button-card
    entity: input_boolean.aquaeye_ui_config
    icon: mdi:tune-vertical
    show_title: false
    show_name: false
    title: Configurações
    tap_action:
      action: toggle
    style:
      z-index: 3
      top: 5%
      left: 5%
      height: 8%
    styles:
      icon:
        - width: 40%
        - color: red
    card_mod:
      style: |
        ha-card {
          text-align: center;
          --ha-card-border-color: transparent !important;
          box-shadow: none !important;
          background: none !important;
          border-radius: 10px;
          overflow: hidden;
        }
  - type: custom:button-card
    entity: switch.circulacao_aquario
    icon: mdi:fan
    show_name: false
    title: Liga/Desliga Circulação
    style:
      z-index: 3
      top: 67.5%
      left: 82%
      height: 8%
    styles:
      icon:
        - width: 40%
    state:
      - value: "on"
        styles:
          icon:
            - animation: rotating 1s linear infinite
            - color: green
      - value: "off"
        styles:
          icon:
            - animation: none
            - color: red
    card_mod:
      style: |
        ha-card {
          text-align: center;
          --ha-card-border-color: transparent !important;
          box-shadow: none !important;
          background: none !important;
          border-radius: 10px;
          overflow: hidden;
        }
  - type: custom:button-card
    entity: input_number.fluxo_temporario
    icon: mdi:fan
    tap_action: none
    tltle: Fluxo do Filtro
    "show_label:": false
    show_name: false
    style:
      z-index: 1
      top: 23%
      left: 74%
      height: 8%
    styles:
      card:
        - font-size: 15px
        - font-weight: bold
      grid:
        - grid-template-areas: "\"i\" \"fluxo\""
        - grid-template-columns: 1fr
      custom_fields:
        fluxo:
          - align-self: middle
          - color: white
      icon:
        - color: |
            [[[
              if (entity.state < 400) return 'red';
              if (entity.state >= 400 && entity.state < 649) return 'orange';
              else return 'green';
            ]]]
        - width: 30%
        - animation: rotating 1s linear infinite
    custom_fields:
      fluxo: |
        [[[
          return `<span>${entity.state}L/h</span>`
        ]]]
    card_mod:
      style: |
        ha-card {
          text-align: center;
          --ha-card-border-color: transparent !important;
          box-shadow: none !important;
          background: none !important;
          border-radius: 10px;
          overflow: hidden;
        }  
  - type: custom:bar-card
    animation: "on"
    bar-card-color: transparent
    bar-card-border-radius: 0
    direction: up
    entity_row: true
    icon: false
    max: 32
    width: 8%
    unit_of_measurement: ºC
    positions:
      icon: "off"
      name: "off"
      value: outside
    style:
      z-index: 1
      top: 38.5%
      left: 17.3%
    severity:
      - color: Red
        from: 0
        to: 24
      - color: Orange
        from: 24.1
        to: 25
      - color: Green
        from: 25.1
        to: 27.9
      - color: Red
        from: 28
        to: 31
    card_mod:
      style: |
        ha-card {
              --ha-card-border-width: 0px;
              vertical-align: middle;
              text-align: center;
        }
        bar-card-value {
          margin-top: auto;
          font-size: 16px;
          font-weight: bold;
          text-shadow: 1px 1px #0005;
          text-orientation: mixed;
          transform-origin: 0 0;
          transform: rotate(270deg);
          
        }
        bar-card-max {
          margin: 0px;
          margin-left: auto;
          margin-top: -20px;
          top: 10px;
        }
    entities:
      - entity: sensor.aquaeye_sala_temperatura
  - type: custom:fluid-level-background-card
    entity: sensor.aquaeye_sala_nivel
    fill_entity: switch.aeracao_aquario
    background_color: transparent
    show_state: true
    show_icon: false
    camera_view: auto
    level_color: rgba(82, 171, 255, 1)
    style:
      z-index: 2
      top: 80%
      left: 65%
      width: 100%
      height: 110%
    card_mod:
      style: |
        ha-card {
          text-align: center;
          --ha-card-border-color: transparent !important;
          box-shadow: none !important;
          background: none !important;
          border-radius: 10px;
          overflow: hidden;
        }  
        #container, .container {
          width: 71% !important;
          height: 40% !important;
          position: relative !important;
          border-radius: 14px !important;
          margin-left: 0%;
          margin-top: 0%;
          opacity: 0.4;
          overflow: hidden;
        }
    card:
      type: custom:card-templater
      card:
        type: entity
        entity: sensor.aquaeye_sala_nivel
        title_template: "{{ states(''sensor.aquaeye_sala_nivel'')|round(0) }} %"
        unit_of_measurement: "%"
        show_header_toggle: false
        show_name: false
        show_icon: false
        show_title: true
        name: " "
        positions:
          value: "off"
        card_mod:
          style: |
            ha-card {
             --ha-card-header-font-size: 20px;
               height: 100px !important;
               color: white;
               font-weight: 800;
            }
            .card-header {
             justify-content: center !important;
            }
            .name {
             overflow: unset !important;
            }


Hope you like…

I did this

Very cool !