Detecting Christmas Tree Water Level With an Arduino
Introduction
We've all been there, watering a Christmas tree can be a pain in the ass. In the beginning, you try watering it at the stand directly but generally end up spilling water everywhere as you try to tip a pitcher while lying on the floor under the branches. Then you level up and by a three foot watering funnel thinking how easy it will be to stand and pour only to realize you can't see where the water level is. So you either make a mess by yourself overfilling or enlist a helper to crawl under the tree with a phone light telling you when to stop. Finally, you just give up and decide the tree is dying anyway and stop watering it.
Tech Solution
I decided to build a contraption with an Arduino so that I could water my tree without help through a standing funnel that would let me know when the water level was optimal. My first core requirement was that I wanted different colored LEDs that I could see from a distance to know whether the tree needed to be watered at all. My second core requirement was that I wanted an audible signal to stop watering in case I couldn't see the LED through the branches.
| Tree Water Sensing Prototype |
To make mounting easy, magnets will keep the Arduino firmly attached to the tree stand (mine is steel). To monitor the water levels, a 3D printed bracket can hold the sensor at the tree stand's top edge to maintain a constant distance measurement. Using multiple LED colors can provide a visual indication of water depth from a distance without having to inspect closer. I opted to choose Green for "topped off", Yellow for "enough", Red as "low" and flashing Red as "critically low."
The first prototype tried using dedicated water level sensor to determine when tree needed watering. It turned out the sensor behaved really poorly without giving a gradient type of water depth and more of a "yes water" or "no water". This wasn't conducive to knowing when to stop watering and numerous forums indicated the sensors were prone to corroding anyway and failing to work.
This lead to adapting the other recommendations from various forums that an ultrasonic sensor was best. It did prove to work better on all accounts with an ability to consistently measure distances to the millimeter accuracy. Additionally, being decoupled from direct water contact meant it would not corrode and stop working.
Parts List
Not a whole lot of components are required for the build. Around the end of 2025, they could be acquired from Amazon for roughly $60 with a lot of parts leftover. If you're already a tinkerer, you may already have a lot of these leftover from other projects. Everything centers around an Arduino and a prototype board for mounting the components.
- Arduino Uno R3 - https://www.amazon.com/dp/B01EWOE0UU
- HiLetgo Uno R3 Protoshield Expansion Board - https://www.amazon.com/dp/B00HHYBWPO
- HiLetgo Ultrasonic Sensor - https://www.amazon.com/dp/B00E87VXH0
- Red, Green, Yellow LEDs - https://www.amazon.com/dp/B0CZ3XK1RH
- Piezoelectric Buzzer - https://www.amazon.com/dp/B07VRK7ZPF
- Neodynium Magnets - https://www.amazon.com/dp/B096LYVGPS
- 9V Power Brick - https://www.amazon.com/dp/B06Y1LF8T5
- PLA Filament for 3D Printing a Bracket
Assembly
The prototype breadboard was suitable for developing the project but is certainly not a long term solution. After getting everything to work consistently, it was time to figure out a more permanent build. Various options exist from just stuffing components into a box to having a nice printed circuit board. I chose an option somewhat in the middle by mounting a protoshield expansion "hat" to the Arduino. This options had the advantage of providing pin access to the components without directly soldering the Arduino. It also eliminated jumper wires which were likely to come apart between seasons leaving me wondering every year how to put it back together.
... to be completed ...
Code
Programming an Arduino is fortunately not too hard. In a nutshell, you define some constants and variables, initialize the system in the setup() function, and then handle sensor input and decision logic in the loop() function. Let's walk through the code starting with this first block which defines some constants for referencing the ultrasonic sensor, the LEDs, and the piezoelectric buzzer:
// Define the pins for the sensor
const int echoPin = 10;
const int trigPin = 9;
// Define the pins for the LEDs
const int grnLEDpin = 4;
const int ylwLEDpin = 7;
const int redLEDpin = 8;
// Define the pins for Piezo Buzzer
const int buzzerPin = 2;
// Variables to store readings
float duration, distance;
bool buzzFlag = false;
// Variable delay for power saving
int sensorDelay = 2000;
The ultrasonic sensor uses two pins; setting a trigger pin HIGH activates the device and reading the value of an echo pin allows determining distance. This program uses Pin 10 for reading the distance and Pin 9 for turning the sensor on and off.
LEDs are some of the most straightforward devices for an Arduino to activate. Arduinos can vary brightness via pulse width modulation or just turn them on and off (which is what this version does). This program uses Pin 4 or the Green LED, Pin 7 for the Yellow LED and Pin 8 for the Red LED.
The buzzer is much like an LED where an Arduino simply changes its pin between HIGH and LOW states. This program uses Pin 2 for the piezoelectric buzzer.
Lastly, the program uses a few variables for storing values. The first two variables are typed as floating point to deal with decimals and keep track of the ultrasonic pulse duration and a calculated distance. Then a boolean variable buzzFlag is used as a flag to keep track of whether the buzzer is active. Lastly, an integer variable sensorDelay allows for flexibly changing the amount of time between data samples to potentially reduce power consumption when it doesn't matter.
// Configure Device
void setup() {
// Configure pins for either Input or Output
pinMode(trigPin, OUTPUT);
pinMode(echoPin, INPUT);
pinMode(grnLEDpin, OUTPUT);
pinMode(ylwLEDpin, OUTPUT);
pinMode(redLEDpin, OUTPUT);
pinMode(buzzerPin, OUTPUT);
// Set initial state of the LED and piezo buzzer pins
digitalWrite(grnLEDpin, LOW);
digitalWrite(ylwLEDpin, LOW);
digitalWrite(redLEDpin, LOW);
digitalWrite(buzzerPin, LOW);
// Start serial communication for monitoring
Serial.begin(9600);
Serial.println("[INFO] Water Level Sensor Started");
}
The setup() function is called once when the Arduino powers up and serves to configure the device. This is the place to define pin behavior and initial states for hardware.
The pinMode() function configures the behavior of the specified pin. Each pin defined previously with a constant reference gets set here. The ultrasonic sensor needs the trigger pin as an OUTPUT and the echo pin for reading as an INPUT. Each LED is configured as an OUTPUT as is the piezoelectric buzzer.
The digitalWrite() function is used here to write initial values to the pins. Writing LOW to the LEDs and piezoelectric buzzer ensures the system starts with them in an "off" state.
The last functions Serial.begin() and Serial.println() provide debugging information if the Arduino remains connected via the USB cable to the development IDE. The first sets the serial port up for 9600 baud communications and the latter outputs a string of data via the port.
void loop() {
digitalWrite(trigPin, LOW);
delayMicroseconds(2);
digitalWrite(trigPin, HIGH);
delayMicroseconds(10);
digitalWrite(trigPin, LOW);
duration = pulseIn(echoPin, HIGH);
distance = (duration*.0343)/2;
Serial.print("[INFO] Distance: ");
Serial.println(distance);
The loop() is where the repetition begins for continuously sampling sensor data and making decisions.
The loop begins by activating the ultrasonic sensor, reading a distance value, and performing some math to turn the value into a usable metric. First the sensor is cycled off by setting the trigger pin LOW to ensure a clean reading. The delayMicroseconds() function is used for controlled pauses or allowing for devices an appropriate amount of time to reset or perform a function. After a 2ms delay, the ultrasonic sensor's trigger pin is set HIGH to activate it. It's allowed to measure for 10ms before being turned off again.
The ultrasonic sensor provides a value on the echo pin in the form a "pulse duration" (e.g. how long it took for the sound to reflect from the target. This value is read using the pulseIn() function. Since the speed of sound is 343 meters per second, then distance can be calculated from the pulse duration.
To compute the distance, multiply the pulse duration (in microseconds) by the speed of sound (0.0343 cm/microsecond) and divide by 2 to account for acoustic round trip time. The result of this calculation is saved in the distance variable in centimeters. For debugging purposes, this value is output via the serial port.
// Display an LED -- flashing RED
if (distance > 15) {
sensorDelay = 2000;
digitalWrite(grnLEDpin, LOW);
digitalWrite(ylwLEDpin, LOW);
for (int i=0; i<5; i++) {
digitalWrite(redLEDpin, LOW);
delay (50);
digitalWrite(redLEDpin, HIGH);
delay(100);
}
}
This little block of code will flash the Red LED when the water level is more than 15cm away from the sensor - indicative of extremely low water. Basically the if (condition) {} block just checks the distance variable and only acts if it's greater than 15.
The sensorDelay variable is set to 2000 milliseconds which will be used later to only sample the ultrasonic sensor every two seconds. It could probably be set even larger. After all, when the water level is low, there is no need to keep sampling with precision and reducing the sample rate can save power if using batteries.
The digitalWrite() function is applied to the Green and Yellow LEDs to ensure they are turned off by setting their pins to a LOW state. Lastly, a for (start, condition, iteration) {} loop blinks the Red LED five times by alternating between LOW and HIGH writes to its pin with a short delay mixed in to keep the blink from being too fast.
// Display an LED - RED
if (distance > 10 && distance <= 15) {
sensorDelay = 1500;
digitalWrite(grnLEDpin, LOW);
digitalWrite(ylwLEDpin, LOW);
digitalWrite(redLEDpin, HIGH);
}
This little block of code is much like the its predecessor. It looks for a condition where the water level is farther than 10cm but less than or equal to 15cm. When that condition is met, a slightly quicker sensorDelay is set, the Green and Yellow LEDs are turned off and the Red LED is turned on.
// Display an LED - YELLOW
if (distance > 5 && distance <= 10) {
sensorDelay = 1000;
digitalWrite(grnLEDpin, LOW);
digitalWrite(ylwLEDpin, HIGH);
digitalWrite(redLEDpin, LOW);
}
Much like the others, this little block of code looks for a condition where the water level is farther than 5cm but less than or equal to 10cm. When that condition is met, the sensorDelay is set to 1s, the Green and Red LEDs are turned off and the Yellow LED is turned on.
// Display an LED - GREEN
if (distance <= 5) {
sensorDelay = 250;
digitalWrite(grnLEDpin, HIGH);
digitalWrite(ylwLEDpin, LOW);
digitalWrite(redLEDpin, LOW);
}
This final block of LED logic code looks for a condition where the water level is within 5cm. When that condition is met, the sensorDelay is set to a quarter second (250ms), the Yellow and Red LEDs are turned off and the Green LED is turned on.
// Sound Buzzer
if (distance <= 3) {
if (buzzFlag) {
digitalWrite(buzzerPin, HIGH);
delay(2500); }
digitalWrite(buzzerPin, LOW);
buzzFlag = false;
sensorDelay = 2000;
}
// Reset Buzzer
if (distance >= 5) {
digitalWrite(buzzerPin, LOW);
buzzFlag = true;
}
These two conditional blocks handle the piezoelectric buzzer. If the water is less than 3cm away, the buzzer will sound for 2.5 seconds and then be deactivated. Here is where the buzzFlag variable comes into play. After the buzzer sounds, the flag is set to false so that it won't keep sounding and being annoying.
The second conditional block resets the buzzFlag variable once the water level drops again. This way it can sound audibly again once the water is refilled.
// Add a short delay before the next reading
delay(sensorDelay);
}
The final portion of code in the loop() is a pause based on the sensorDelay variable. As mentioned earlier, longer delays can be used to reduce power consumption when the water levels are particularly low. Shorter delays can be used as the water level rises so that the lights and buzzer can reflect a "nearly full" condition accurately to avoid overfilling the tree stand.
Bracket
It's all well and good to have a working Arduino and all of the components configured. But those parts are not doing much until you figure out getting them attached to the tree stand where they can monitor the water levels. Every tree stand is different, but this TinkerCAD sketch will hold the ultrasonic sensor (.stl file) at the top of my tree stand where it can monitor the distance to the water level.
| TinkerCAD Sketch of a Sensor Bracket |
The ultrasonic sensor can be press fit with the transmitter and receiver modules through the bracket holes. Connect the sensor to the Arduino with a long wire bundle and attach it to the tree stand facing downwards towards the water. Ensure the path to the water is not obstructed by parts of the Christmas tree's trunk. Failure to have a direct path to the water will just produce a constant distance to the obstruction instead of the water.
Conclusion
The project was successful and provided a quick visual cue for when it was okay to be lazy and not water the tree versus when it was necessary. The audible tone from the piezoelectric buzzer was easy to hear and provided a timely warning to stop pouring water. This little gadget will certainly be used for all future Christmas holidays. Finally, no more daily crawls under the tree with a flashlight watching for pending overflow to tell my wife when to stop pouring water.


