Building Embedded Systems with Arduino
The automatic fish feeder was one of my first complete embedded builds: an Arduino, a DS3231 real-time clock, a continuous-rotation servo as the gate, three ultrasonic sensors watching the food hopper, and an I2C LCD. Nothing in it is exotic. What made it worth writing about is the sketch history. The repository still has every step from the first “does the servo move” test to the final firmware, and the mistakes are visible in the diffs.
Circuits that survive contact with a breadboard
The first thing I learned is that a wiring diagram is a hypothesis. The EasyEDA schematic says the
LCD lives on the I2C bus, but it does not say at which address, and a display at the wrong address
prints nothing and reports nothing. So the repository starts with a small I2C scanner that walks
addresses 1 to 126 and prints whichever one answers. The address that ended up at the top of every
later sketch is 0x27, with a note to change it if the backpack differs. It is worth running the
scanner before writing anything that talks to a new I2C part.
The ultrasonic sensors were the other lesson. An HC-SR04 that gets no echo does not return an
error; it returns a very large number. The firmware treats anything over a ceiling as OVERLOAD
and refuses to compute a fullness percentage from it, rather than showing 0% and making it look
like the hopper is empty. The fullness figure itself is a plain map() from “3 cm from the sensor”
to “full” and “tank depth” to “empty”, clamped with constrain() so a noisy reading cannot push it
past 100.
The three-sensor design also changed under test. The early sketches average all three readings. The final one still reads all three, but only trusts the third. I did not write down why at the time, and I suspect it was cross-talk: three sensors pinging into the same small hopper hear each other’s echoes, and the average was worse than the best single reading. The honest lesson is that redundancy only helps if the redundant sensors fail independently, and mine did not.
Timing and scheduling without an RTOS
The feeding schedule is two fixed times a day, six in the morning and six in the evening. The very first timer sketch does the obvious thing:
if (now.hour() == 6 && now.minute() == 0 && now.second() == 0) {
servo.write(90);
delay(30000);
servo.write(0);
}
That has two problems, and both matter on a real bench. The delay(30000) freezes the whole
board for thirty seconds, so the clock display stops and the hopper sensors stop reading. Worse,
the check requires second() == 0 exactly, and the loop already sleeps for a second at the end,
so it is easy to wake up at second 1 and miss the window entirely. The next revision added
triggeredMorning and triggeredEvening flags so the feed fires once per minute window instead of
once per exact second, which fixed the missed feeds, but the thirty-second freeze was still there.
The final firmware drops delay() from the feeding path completely. The loop reads the clock every
pass, and a small handleFeeding() function compares the current second against a few constants:
at second 0 it pulses the servo one way, a second later it stops it, at second 5 it pulses the
other way, and a second later it stops again. The gate is open for five seconds, the display and
sensors keep running throughout, and there is no state to reset because the clock does the
sequencing. It is a state machine where the RTC is the state variable, which is about the simplest
scheduler you can build.
Two details in that path are worth knowing if you copy it. The servo is a continuous-rotation
type, so write(90) means “stop”, not “go to ninety degrees”, and the two pulse directions are
write(30) and write(330). The Servo library clamps that second value to 180, which happens to
be what I wanted, but I should have written 180. And the RTC is set with
rtc.adjust(DateTime(F(__DATE__), F(__TIME__))), which stamps the clock with the compile time.
That line is meant to run once and then be commented out. Left in, every reboot resets the clock
to the moment the sketch was last compiled, and the feeder is suddenly running on the wrong day.
The comment above that line in the sketch exists for exactly that reason.
Debugging without a debugger
There is no step debugger on an Uno, so the repository is organised around the only tools you
have: serial output and isolation. Every subsystem got its own sketch before anything was
combined. LCD_TEST proved the display, Servo_Test and Servo_Gate proved the gate could open
and close on a timer, RTC proved the clock, ULTRASONIC_TEST and then Measure_Fullness proved
the hopper reading. Only then did LCD_RTC combine two parts, LCD_RTC_Servo three, and the
Final_Project sketches all of them. When something broke in the combined firmware, I already
knew which parts worked on their own, so the bug had to be in the seams.
Serial output stayed in the final firmware on purpose. Every loop prints the time, all three raw
distances, the computed fullness, and the status word. When the LCD shows OVERLOAD you can open
the monitor and see which sensor produced the bad reading, rather than guessing. On a board with
two kilobytes of RAM, a few Serial.print calls are the cheapest instrumentation you will ever
get, and the cost of removing them is that the next bug becomes invisible.
If I built it again I would change one thing in the main loop. Each pass calls pulseIn() three
times, once per sensor, and pulseIn() blocks for up to a second when there is no echo. Three
missing echoes means a three-second stall, which is the same class of problem as the original
delay(30000), just smaller. The fix is the optional timeout argument to pulseIn(), set to the
round-trip time of the furthest distance you care about. It is one number, and it turns a sensor
that can hang the board into one that simply reports “nothing there”.
That is the pattern I have carried into every board since: find the address before you write to it, treat “no reading” as its own state, never block the loop, and keep the serial prints.