Aqua is an aquarium controller I built using Modulo to monitor and automate my coral reef aquarium. It controls lights, pumps, temperature, and logs data to a server!

This guide goes through everything you need to build Aqua yourself. The concepts presented here are applicable to lots of other projects too.

 

The controller assembled and installed in my aquarium cabinet.
The display shows the current water temperature.

 

A clownfish and toadstool coral living in the aquarium.

 

A blue zoanthid coral colony.

 

Aqua’s key features include:

  • Data logging: Important information like water level and temperature are continuously logged to a server and display as interactive graphs in a web browser.
  • Temperature Control: The controller senses the water temperature and turns on a heater when it gets too cold.
  • Water Level Control: As the water level in the aquarium drops due to evaporation, the controller pumps in new purified water from a reservoir. It also limits the amount of water pumped per hour to avoid a catastrophe if the switch gets stuck.
  • Lighting Control: My aquarium has beneficial algae growing in a sump (a secondary tank under the aquarium). The controller switches a light above the sump at fixed times of day or when the knob on the controller is pressed.
  • Pumps: The aquarium has a number of water pumps. For instance, the “return pump” pumps water that has drained into the sump back to the aquarium. Various filtration devices also have their own pumps. These pumps are normally on, but Aqua can switch them off for maintenance.
  • Information Display: Aqua shows critical information on the Display Modulo, such as the time of day, water temperature, and state of each pump, light, and heater. Everything can be controlled using the buttons on the display.


Physical Construction

Aqua’s physical construction is straightforward and only took a few minutes. I decided to use a Particle Photon so Aqua could log data to a server. The Photon and Modulos are then attached to the Modulo Particle Base, which I mounted to the inside door of the aquarium cabinet.

A lot going on under the aquarium! Aqua is mounted to the door on the right.

The Display Modulo shows important information at a glance. It’s also used to view and modify various settings. The Knob Modulo is used as a switch, turning a light under the aquarium cabinet on and off when it’s pressed.

A float switch connected to a Blank Slate Modulo senses the aquarium’s water level. I soldered a spring terminal connector between ground and pin 4 on the Blank Slate, making it easy to connect the switch’s cable.

 

The lever at the end of the float switch moves when the water level changes.
It contains a reed switch to keep the electrical bits isolated from the water.

Finally, a Temperature Probe Modulo is used to sense the tank’s water temperature. It simply plugs into an expansion jack with the probe end dangling in the aquarium water.

Relay Box

The controller needs to switch a number of AC devices like lights, pumps, and a heater. To do that I used a Relay Box from ReefAngel, makers of an open source diy-friendly aquarium controller system.


The ReefAngel Relay Box.

 

If you don’t want to use the Reef Angel box, another good option for controlling AC loads is the PowerSwitch Tail, though you’ll need to buy one for each outlet that you want to switch.

The relay box can be connected to the controller in one of two ways. The first is through a DB-15 connector, which has a pin for each relay. The pins could be connected to pins on the Photon, or to I/O pins on a Blank Slate Modulo.

The second option is to use the Reef Angel “Relay Expansion Module” which can control the relay box over the same protocol (I2C) that Modulo uses. I used this method since I already had the relay expansion module lying around. Even though it’s I2C, ReefAngel devices use USB connectors so I cut apart a USB cable and soldered it to a Modulo extension cable to connect the two systems. Red, black, and white wires connect to matching colors. The green wire from the USB cable connects to the yellow wire in the modulo cable.

I had to connect a Reef Angel “Expansion Hub” between Modulo and the Relay Expansion Module in order for it to work. This is likely due to different voltage levels between the two devices, but I didn’t dig any deeper to find out if that’s actually the case.

Software

The code is fairly straightforward, but with so many features it can still be overwhelming. Lets break it down and look at one feature at a time.

Temperature

The controller can both sense the aquarium water temperature and turn on a heater when the temperature is too low. The temperature is shown on the display. The display’s center button manually changes the target temperature.

When operating automatically, the heater has hysteresis. That means the heater will stay in its current state until the measured temperature changes by a certain margin. Hysteresis keeps the heater from turning on and off rapidly due to minor temperature fluctuations.

// An enum for tri-state outlets, which can be explicitly on,
// explicitly off, or manually controlled.
enum TriState {
    TriStateOff,
    TriStateOn,
    TriStateAuto
};

TriState heaterMode = TriStateAuto;
uint8_t targetTemperature = 80;
bool heaterOn = false;

void updateHeater() {
    // Include hysteresis when updating the heater based
    // on the current temperature. This means that the temperature
    // must be a certain amount over the set point to turn on,
    // and a certain amount under the set point to turn off. This
    // prevents rapid cycling due to noise on the tempearture input.
    float heaterHysteresis = .25;
    if (heaterMode == TriStateAuto) {
        if (tempProbe.getTemperatureF() >
            targetTemperature+heaterHysteresis) {
            heaterOn = false;
        }
        if (tempProbe.getTemperatureF() <
            targetTemperature-heaterHysteresis) {
            heaterOn = true;
        }
    } else {
        heaterOn = (heaterMode == TriStateOn);
    }
    refreshDisplay();
}

We use Modulo events whenever possible, which make it possible for a function to be called when an input changes. For instance, we need to update the heater state when the temperature changes.

void onTemperatureChanged(TemperatureProbeModulo &t) {
    updateHeater();
}
void setup() {
    ...
    tempProbe.setTemperatureChangeCallback(onTemperatureChanged);
    ...
}

Water Level

The top-off pump pumps purified tap water from a reservoir to the aquarium in order to replace water lost by evaporation. Like the heater, the top-off pump can be turned on, turned off, or set to operate manually. On my aquarium, running the top-off pump for just 2 minutes every hour is more than enough to replace evaporated water, so I set that as a limit to avoid pumping in too much fresh water if the switch gets stuck.

bool topOffPump = false;
bool topOffSwitch = false;

void updateTopOff() {  
    // Monitor the top off switch (on the blank slate's I/O pin 4) 
    blankSlate.setPullup(4, true); // Enable pullup on pin 4
    topOffSwitch = blankSlate.getDigitalInput(4);
    
    if (topOffMode == TriStateAuto) {
        // Limit the top off pump so that it can run at most 2 minutes
        // at the beginning of every hour. This prevents flooding if
        // the switch gets stuck.
        if (Time.minute() < 2) {
            topOffPump = !topOffSwitch;
        } else {
            topOffPump = false;
        }
    } else {
        topOffPump = topOffMode == TriStateOn;
    }
    
    refreshDisplay();
}
void loop() {
    ...
    // Check the top off switch and toggle the top-off pump if necessary
    updateTopOff();
    ...
}

Time and Lighting

The sump light turns on and off at certain times of day. The Photon gets the current time of day from a server, but it needs to know the timezone. My timezone is UTC-8 (Pacific Standard Time)

bool sumpLightOn = false;
// The timestamp from the last time the loop() function ran.
uint32_t previousTimestamp = 0;
void setup() {
    ...
    // Set the timezone to UTC-8 (for Pacific Standard Time)
    // Change this if you live in a different timezone.
    Time.zone(-8);
    // Initialize the previousTimestamp
    previousTimestamp = Time.now();
    ...
}

Each time the main loop function runs, it checks the time to see if the current minute has changed, if it has then it runs the “onMinutesChanged” function.

void updateTimers() {
    // First get the new unix timestamp
    int32_t newTimestamp = Time.now();
    
    // Convert the new and old timestamps to minutes
    int32_t newMinutes = (newTimestamp/60) % (60*24);
    int32_t oldMinutes = (previousTimestamp/60) % (60*24);
    
    if (newMinutes != oldMinutes) {
        onMinutesChanged(Time.hour()*60 + Time.minute());
    }
    
    previousTimestamp = newTimestamp;
}

In the onMinutesChanged function, it checks to see if the new minute matches the on or off time for the light. If it does, then it switches the light accordingly. The display is also refreshed in case it is displaying the current time.

void onMinutesChanged(int32_t minutes) {
    if (minutes == sumpLightOnTime) {
        sumpLightOn = true;
    }
    if (minutes == sumpLightOffTime) {
        sumpLightOn = false;
    }
    
    refreshDisplay();
}

It’s common to turn the light and off manually, for instance when doing maintenance. We use a knob Modulo as a handy light switch by registering an event callback function that toggles the light’s state.

void onKnobButtonPressed(KnobModulo &knob) {
    sumpLightOn = !sumpLightOn;
    refreshDisplay();
}
void setup() {
    ...
    knob.setButtonPressCallback(onKnobButtonPressed);
    ...
}

Display

The display shows different information based on the current page. The left and right buttons on the display change the current page. The center button does different things based on the current page. For instance, when showing the temperature the center button changes the target temperature. When showing the sump light, the center button toggles the sump light on and off


// An enum for the various pages that we can show on the display
enum Page {
    PageTime,
    PageTemperature,
    PageHeater,
    PageSumpLight,
    PageReturnPump,
    PageCarbonFilter,
    PageSkimmer,
    PageTopOff,
    NumPages
};

Page page = PageTime;

void onButtonPressed(DisplayModulo &display, int button) {
    switch(button) {
        case 0:
            page = Page((int(page)-1+NumPages) % NumPages);
            break;
        case 1:
            switch (page) {
                case PageTemperature:
                    if (targetTemperature >= 84) {
                        targetTemperature = 72;
                    } else {
                        targetTemperature++;
                    }
                    updateHeater();
                    break;
                case PageSumpLight:
                    sumpLightOn = !sumpLightOn;
                    break;
                case PageReturnPump:
                    returnPumpOn = !returnPumpOn;
                    break;
                case PageCarbonFilter:
                    carbonFilterOn = !carbonFilterOn;
                    break;
                case PageSkimmer:
                    skimmerOn = !skimmerOn;
                    break;
                case PageTopOff:
                    topOffMode = TriState((int(topOffMode)+1) % 3);
                    updateTopOff();
                    break;
                case PageHeater:
                    heaterMode = TriState((int(heaterMode)+1) % 3);
                    updateHeater();
                    break;
            }
            break;
        case 2:
            page = Page((int(page)+1) % NumPages);
            break;
    }
    
    refreshDisplay();
}
void setup() {
    ...
    display.setButtonPressCallback(onButtonPressed);
    ...
}

The code for drawing the display is long, but fairly straightfoward.

void refreshDisplay() {
    display.clear();
    
    switch(page) {
        case PageTime:
            display.println("  Current Time");
            display.println();
            display.setTextSize(2);
            display.setCursor(6, 20);
            display.print(Time.format(Time.now(), "%I:%M%P"));
            break;
        case PageTemperature:
            refreshTemperatureDisplay(display);
            break;
        case PageHeater:
            refreshHeaterDisplay(display);
            break;
        case PageTopOff:
            display.println("    Top Off");
            display.println();
            display.setTextSize(2);
            switch (topOffMode) {
                case TriStateOff:
                    display.println("Off");
                    break;
                case TriStateOn:
                    display.println("On");
                    break;
                case TriStateAuto:
                    display.println("Auto");
                    break;
            }
            display.setTextSize(1);
            display.println();
            display.print("Switch: ");
            display.println(topOffSwitch ? "High" : "Low");
            display.print("Pump: ");
            display.println(topOffPump ? "On" : "Off");
            break;
        case PageSumpLight:
            display.println("   Sump Light");
            display.println();
            display.setTextSize(2);
            display.println(sumpLightOn ? "On" : "Off");     
            
            display.setTextSize(1);
            display.println();
            display.print(" On at: ");
            display.print((sumpLightOnTime / 60) % 12);
            display.print(":");
            display.print((sumpLightOnTime % 60) / 10, 2);
            display.print(sumpLightOnTime % 10, 2);
            display.println((sumpLightOnTime/60 >= 12) ? "pm" : "am");
         
            display.print("Off at: ");
            display.print((sumpLightOffTime / 60) % 12);
            display.print(":");
            display.print((sumpLightOffTime % 60) / 10, 2);
            display.print(sumpLightOffTime % 10, 2);
            display.println((sumpLightOffTime/60 >= 12) ? "pm" : "am");
            
            break;
        case PageReturnPump:
            display.println("   Return Pump");
            display.println();
            display.setTextSize(2);
            display.print(returnPumpOn ? "On" : "Off");
            break;
        case PageCarbonFilter:
            display.println("  Carbon Filter");
            display.println();
            display.setTextSize(2);
            display.print(carbonFilterOn ? "On" : "Off");
            break;
        case PageSkimmer:
            display.println("    Skimmer");
            display.println();
            display.setTextSize(2);
            display.print(skimmerOn ? "On" : "Off");
            break;
    }
    
    display.refresh();
    
    if (sumpLightOn) {
        knob.setColor(1,1,0);
    } else {
        knob.setColor(0,0,.2);
    }
}

void refreshTemperatureDisplay(DisplayModulo &d) {
    d.println("  Temperature");
    d.setTextSize(2);
    d.setCursor((96-6*6*2)/2, 20);
    if (fabs(tempProbe.getTemperatureF()-targetTemperature) < 2) {
        d.setTextColor(0,1,0);
    } else if (tempProbe.getTemperatureF() < targetTemperature) {
        d.setTextColor(0,0,1);
    } else {
        d.setTextColor(1,0,0);
    }
    
    d.print(tempProbe.getTemperatureF(), 1);
    d.println("\xF7" "F");
    
    d.setTextColor(1,1,1);
    d.setTextSize(1);
    d.println();
    d.print("Target: ");
    d.print(targetTemperature, 1);
    d.println("\xF7" "F");
    d.print("Heater: ");
    d.print(heaterOn ? "On" : "Off"); 
}

void refreshHeaterDisplay(DisplayModulo &d) {
    display.println("     Heater ");
    display.println();
    display.setTextSize(2);
    switch (heaterMode) {
        case TriStateOff:
            display.println("Off");
            break;
        case TriStateOn:
            display.println("On");
            break;
        case TriStateAuto:
            display.println("Auto");
            break;
    }
  
    d.setTextColor(1,1,1);
    d.setTextSize(1);
    d.println();
    d.print("Target: ");
    d.print(targetTemperature, 1);
    d.println("\xF7" "F");
    d.print("Heater: ");
    d.print(heaterOn ? "On" : "Off"); 
}

Online graphs

Certain data, such as the current water temperature and state of the heater, top-off switch, and top-off pump is logged to a server and display in graph using librato.com.

Graphs from librato showing temperature and on/off cycles for the heater and top-off pump.

 

In addition to this code, there are a few additional steps you’ll need to go through to set up librato, but particle.io has a great tutorial that will get you going.

One that’s set up though, our code just needs to publish spark events for each metric once every 5 seconds.

#define publish_delay 5000
unsigned int lastPublish = 0;

void updatePublish() {
    unsigned long now = millis();
    if ((now - lastPublish) < publish_delay) {
        // it hasn't been 10 seconds yet...
        return;
    }
    Spark.publish("librato_temperature", String(tempProbe.getTemperatureF()), 60, PRIVATE);
    Spark.publish("librato_heater", String(heaterOn), 60, PRIVATE);
    Spark.publish("librato_topOffPump", String(topOffPump), 60, PRIVATE);
    Spark.publish("librato_topOffSwitch", String(topOffSwitch), 60, PRIVATE);
    lastPublish = now;
}

void loop() {
    ...
    // Publish graph data
    updatePublish();
    ...
}

Outlets

To control the outlets, all we need to do is write a single byte over I2C to the device at address 0x38. Each bit controls a different outlet, with a 0 for on and a 1 for off.

void updateOutlets() {
    uint8_t outlets =
        (!heaterOn << 0) |
        (!returnPumpOn << 1) |
        (!carbonFilterOn << 2) |
        (!skimmerOn << 3) |
        (1 << 4) | // Unused
        (1 << 5) | // Unused
        (!sumpLightOn << 6) |
        (!topOffPump << 7);
        
    Serial.println(outlets, BIN);
    
    Wire.beginTransmission(0x38);
    Wire.write(outlets);
    Wire.endTransmission();
}
void loop() {
    ...
    // Update the outlets based on the current state
    updateOutlets();
    ...
}

Putting it all together

You can download the complete source code for Aqua and order the Modulos you need from the modulo store. To run the code, upload it on build.particle.io and import the Modulo library. If you’re new to Modulo, you might want to check out the docs first.

Have any feedback or questions about this tutorial? Email hello@modulo.co or contact @modulolabs on Facebook and Twitter.

Buy the modulos used in this project