How to Design a Cheap Plant Watering Sensor (Part 4)

This is the fourth part of the meta-tutorial, where I talk about designing a cheap plant watering sensor. If you did not already read the firstsecond and third part please do it now. These parts contain a lot information which lead to this point of the tutorial.

The third part ended with step 18, planing the final firmware. There a decision was made about the language and style of the firmware. This article will focus on the code of the firmware itself.

Step 19: Write a Preliminary Firmware

In order to be able to do some final tests with the prototypes and be able to work on the final PCB, I need a firmware which is is very close to the final one. In the Atmel Studio, I start a new C++ project in a new folder.

The first thing I do is checking the chosen compiler options for the project. Everything looks reasonable, I just add the option --std=c++11 to the C++ compiler options to get the latest language features.

In a section below I will describe all modules I wrote and will point details about the functions. I obviously did not wrote the whole firmware sequentially in that order, instead I use a incremental approach to develop the software:

  1. Create empty frameworks for all modules.
    • Create a header and implementation file for each module with the correct name.
    • Add the header comments, the namespace, #pragma once and the #include for the own header file.
    • At this point, each module should be ready, so I can easily add new functions to each module.
  2. Start with the hardware module.
    • Write the initialisation for the hardware, like CPU speed, port directions and other important stuff.
    • Layout the interface for the hardware module and prepare empty implementation blocks to be filled with code.
    • At each place where code is missing, I write a comment // FIXME!! to be reminded that there is something missing.
  3. Start the logic module.
    • Write the main entry point of the logic.
    • Call this entry point in the main() method of the firmware.
    • Add the hardware initialisation to the logic.

At this point, I have the structure of the firmware prepared as planed. This structure will lead me through the development process.

Connect the Prototype

Next I connect the prototype. In this case I use the Atmel ICE and connect it to the programming header I soldered to the prototype. To check if my firmware is running on the microcontroller, I write a simple “blink” code and start it.

This blink code is not only decoration, it also lets you easily check if the CPU is running at the right speed. Just connect the oscilloscope to the LED output and measure the frequency. Now calculate the number of cycles used in your delay code. You should get near the intended frequency, otherwise you did something wrong in the hardware initialisation.

Incremental Development

From this point, I develop the firmware in incremental steps. I chose an action from the behaviour diagram, e.g. “check battery level” and implement all required functions in all modules for this specific action. In a next step, I test this specific action, if it works as expected. If everything works, I start with the next action until the firmware is complete.

  1. Pick an action from the behaviour diagram.
  2. Add and implement all functions for this specific action.
  3. Test all functions for this action thoroughly.
  4. Repeat

Refactor and Refine

After all functionality is implemented (or at every milestone in your project), go through your code:

  • Check the names of all used functions, variables and namespaces.
    • Do they still make sense?
    • Is there a better, clearer name?
    • If this is the case: rename! (using refactoring tools!).
  • Is the documentation complete?
    • Has every function, variable, structure, enum, enum value a clear and valid documentation?
    • Does the documentation match the actual implementation of the function?
    • If this is not the case: fix!
    • You can use a API documentation tool like doxygen to create API documentation out of your code. You can configure the tool to generate warnings for every undocumented element.
  • Go through all implementations you wrote.
    • Is the code simple and easy to understand?
    • Are complex part properly documented in code?
    • Are there duplicate/similar code segments which can be shared?
    • Did you miss any race condition (overflows, out of bounds, …)?
    • Any constant literals hidden in your implementation? Think if it makes sense to create a constant variable for it to give this value a name. It also may make sense to move it to the configuration to keep an eye on it.

You can compare this with designing the routes on a board. Nobody would just draw the routes to the board once and produce this board unchecked. You start with some initial routing and then start to refine it incrementally, step by step until you are completely happy with the design. This is also a very good habit for writing software.

Clean Up

In the same cycle where you refactor and refine you code, you also should clean up your code:

  • Remove (delete) any unused code segments.
    • Especially commented out code segments – never leave them in your files.
  • Order your includes.
    • Internal ones first, empty line, Libraries, empty lines, System Libraries.
    • If you like, sort them alphabetically.
  • Check the formatting of your code.
    • Check the correct indentation.
    • Check the correct spacing around statements.
    • Make everything uniform, ideally according to your coding guidelines if you have some.

Nobody likes to work in a big mess. A little bit chaos is nice, but if you never find the things you are searching for, it is really annoying. This is also true for messy code. You will spend hours searching for things, which would be obvious, if the code was clean-up and easy to read.

Details About the Code

In the next sections I will explain some details of the code. There is also a complete API documentation with a search box to quickly lookup functions or variables:

Plant Watering Sensor API Documentation

The Configuration Module

The configuration module has only a header file with constant variable definitions in it. As long all definitions are constants, this will never lead to any linker problems, because these variables are never allocated in memory.


The first part of the file contains definitions to adjust various key parameters of the firmware. Having all of them at this central place makes changes simple. You never have to search for the actual location where this value is tested or used. Also using this structure, you can write a detailed documentation for each definition.

The second part labeled “testing options”, contains flags to enable or disable features of the firmware. I can tailor a custom firmware for testing, just by changing these flags. The compiler will automatically remove not used sections from the code.

const Variables vs. Macro #define

The preprocessor is a powerful tool, but you should really hard try to reduce its use to the absolute minimum. There are several reasons for this:

  • You give your const variables a type, this can be checked by the compiler.
  • The compiler can optimise code with const variables more efficient.
  • You can place variables in custom namespace blocks, this make the names simpler and avoids naming conflicts.

About #pragma once

As all #pragma values, this is not part of the c++ standard. The term #pragma once will make sure, the file will only be included once for each compiler unit. It is meant as a replacement for the old style header guards. Meanwhile all good compilers support this, and there are several reasons to use #pragma once instead of the old style header guards:

  • The compiler knows your intention and can accomplish this in the most efficient way possible.
  • It is safer.
    • This is just a single line.
    • No special macro names needed.
    • No risk of naming conflicts or typos.
    • No risk of a missing #endif at the end.
    • No risk of strange errors because of macro condition nesting problems.

About uint8_t, uint16_t and #include

If you do microcontroller programming, you should be aware of the size of each variable.

  • Avoid using int, because each compiler may define its size differently.
  • Avoid using char instead of uint8_t, because char may be signed or not.
  • You may waste memory, because from the allocated four bytes only two are actually used.
  • In your mind, you always stay aware of the size limitations and the implications of them.

About bool and #include

The  header will provide the bool datatype for C programs. Because this is a C++ program, it should be removed here. 🙂

If you are writing a C program, check if the  header is available, and use the bool type in your program.

The Hardware Module

This module provides an abstraction layer between the rest of the software and the hardware. The API provides functions with speaking names, without the need to understand the actual hardware details behind this calls.


The implementation of these functions is very simple:


The setup() Function

This function is called at the start of the code to do the initial setup of all registers required for the hardware.

The getVoltageValue() Function

Here I get 32 samples from the ADC and calculate the average of it. This is a simple way to reduce the noise from the signal. Each ADC measurement consumes power, so here I had to experiment, how many samples were a good compromise between noise reduction and power consumption.

Software reliability considerations made:

  • If the voltage bridge is not connected to the MCU, the result will be random. The battery warning will blink in 50% of all cases or the test mode will be activated.
  • If the voltage bridge is not connected to VCC, the result will be zero. The battery warning will be always on.
  • If the voltage bridge is not connected to GND, the result will be the maximum 0x3ff, which will always activate the test mode.
  • If the ADC malfunctions, the result will be zero. This will trigger the battery warning.
  • In case the voltage bridge leads to a shortcut, the device will not work at all.
  • If the ADC conversion hangs, and never finishes, the system timer will shutdown the power of the MCU after the configured delay. This will be the only case which will lead to a problem, which can not easily detected and could lead to a dead plant. I noted this for later consideration to generate an error.
  • In all cases but one, the problem will be easy noticeable. No need to write extra code for handling rare cases.

The getOscillatorFrequency() Function

I measure the frequency of the oscillator using the counter of the microcontroller. After the counter is setup and set to zero, I just wait a defined amount of time (~1ms) and read the new counter value. This will be the frequency of the oscillator.

Software reliability considerations made:

  • If the pin of the MCU is not connected, the result will be zero. I noted this case for later consideration to generate an error.
  • If the frequency is faster than 255kHz, the counter will overflow. For this reason I check the overflow flag and return the maximum value. This will always generate a flash, even the flash should be disabled.

The shutdown... Functions

These functions shutdown the counter and ADC to preserve power. This will be done, shortly after the measurements are made.

The Display Module

This is a further abstraction of the display, which are in our case the two LEDs. It helps to name the different styles in which the LEDs can blink.


The implementation is as simple as the interface:


There is not much to tell about this implementation. For the flash, the LED is driven with the full current available (after the resistor). For all other display types, this would be too bright, therefore the LED is dimmed using PWM.

Software reliability considerations made:

  • With the current LED setup, I could make an ADC conversion on the pin to check if both LEDs are connected and are working. I dismissed this idea, because of the additional power consumption. Further, if the LEDs are defect, there is no way to signal this problem. The LEDs are part of the device test, a failure should be detected at this early stage.

The Settings Module

This module is responsible to read and store the set-point values from EEPROM. It is an abstraction layer over the used permanent memory. It also encapsulates the required checks, to make sure the read memory is valid.


The interface splits the task of reading/writing the memory and to get and set the values from the read memory. This is for the sake of extensibility. If I would like to store an additional value in the settings at a later point, I just can add two new functions to the interface and internally extend the data structure.


To implement the data storage for this AVR based system, I simply declare a data structure Data with all required values. There is a “magic”, which is a four byte value to verify the memory segment and also a CRC-16 checksum.

In the flash memory, I create a default record, which is used if there is a problem reading the values from the EEPROM.

The read() Function

The data structure is therefore allocated in SRAM, flash and EEPROM memory. First, the EEPROM data is copied into the SRAM. Here the magic and checksum is verified. If there is any problem, the version from the flash memory is copied into SRAM.

The write() Function

This function is very simple, because the data structure is already in SRAM. Just the checksum has to be calculated for the new values. After this step, the SRAM is copied to the EEPROM storage using the update_... function, which only updates changed bytes.

Software reliability considerations made:

  • If the EEPROM is uninitialised, the magic will not match and the default values from flash is used.
  • If there is corruption in the EEPROM, the checksum will in almost all cases be wrong and the default value is used. Memory corruption over time usually happens only bit wise. Single bits flip from 0 to 1, or the other way around. This bit flips are well covered by the CRC-16 checksum.
  • If there is corruption in the SRAM, wrong values are stored in the EEPROM. At the next cycle, the EEPROM verification fails and default values are used.
  • If there is corruption in the flash memory, there is no solution. In the worst case a random set-point value is read. Either the indicator will always or never flash. Because the default value is read from flash memory, writing a new set-point will in some cases solve the problem.
  • If the magic in the flash memory is damaged, after writing a new set-point, the new damaged magic is used as reference for the verification, which will succeed.

The Tool Module

This simple module contains only two timing functions.


The implementation is very simple:


I actually just wrap around the _delay_Loop_X() functions from the AVR library. For other platforms I can simple replace them with other ways to get the required delays.

The Logic Module

This is the module which defines the actual behaviour of the firmware. The interface contains only the main entry point.


The stop() method is not used and will be removed. The implementation includes all previously defined modules.


The getUserAction() and configurationMode() Function

If the push button is pressed at the start of the logic. The configuration mode is activated. As detailed in the behaviour diagram in the previous part of this article, depending on the duration of the press, another action is selected.

Software reliability considerations made:

  • Debouncing of the input signal is luckily no problem. The system timer only activates if there is a long enough pulse. Until the check for the push button is reached, the push button input should have a clear state.
  • Release of the button is the only condition to check. If bouncing occurs at this point, it does not matter anymore.


The testMode() Function

This is a special mode entered if the supply voltage is above 4V. The test setup will start the MCU with 4V and drop this voltage after 200ms down to 3V, which will activate the test mode.

In this mode, both LEDs flash briefly, then the LEDs indicate different oscillator frequencies. They can be easily tested by enclosing the sensor foot with one hand.

  • It is a LED function check for both colours.
  • The oscillator is checked if it responds in the expected range.
  • The button is checked afterwards at regular 3V, testing if the configuration mode can be entered.


The main() Function

Here you can see the behaviour diagram written down in code. This code should be simple to understand. There are a few aspects which may no be clear.

This statements, like if (Configuration::cTestLoopEnabled) enable or disable certain code segments which are required for testing. The compiler will remove these sections if they are not used.

There is a test loop do { ... } while (Configuration::cTestLoopEnabled); around the main functionality. This loop is used if the prototype is tested on a lab power supply with activated debugger. The power has to stay on for the debugger and this loop allows repeatedly reads. I will explain this in the next step of the tutorial.

The pgm_read_byte(cFirmwareSignature) is only placed in the code to keep the firmware signature in the image, otherwise the optimiser would remove it. It has actually a minimal impact on the code size – the impart of the signature text is much larger. 🙂

As you can see, there is also a logger module in the code. I will not describe it here in detail. This module stores the last number of measurements in EEPROM, so you can use the prototype in a real environment. After use, you remove the battery and read out the EEPROM using the debugger.

Step 20: Tests with the Preliminary Firmware

With the new firmware I can do some important tests before I start designing the final board for the device:

  • Verify all values of the used components.
  • Make usability tests to see if the user interactions make sense as planed.
  • Test the stability of the device on various battery voltage levels.

Verify the Values

I was lucky with this project. All used components worked as planed and there was no need to replace one or experiment with new values. This is obviously not always the case. Often you have to create many different prototype boards with different components, until you find a combination which will work in the perfect range for the firmware.

Think about the oscillator: If the generated frequency would have some logarithmic curve, and go up in the 1MHz range, you could write complicated code to measure this or change the circuit until the values are well measurable.

Make Usability Tests

Tese are very important tests, especially if a device is operated by a human being. Often you have some way to operate a device in mind, but if you give it to someone else, they have different expectations and can not intuitively operate it.

  • Create prototypes with the release version of the firmware and give the device to strangers. Briefly explain what they should do and watch how to handle your device. Even better, if possible, record the usage on video – so you can analyse it later.
    • If you have to start explaining the usage of the device, please stop. It is a strong indicator you have to improve the usage. Go back to the design of the usage, improve things and make new tests.
    • Always test with at least five different people. Ideally with different background. Testing you device with five electrical engineers will bring no new insights.
  • Use the device in the environment where it should be – take it out of your lab!

I let different people disable the flash and update the set-point. I also asked, what they think about the LED signals. In parallel I put various prototypes in the pots of various plants and checked how they behave.

What I learned:

  • The usage is no problem and easy understand.
  • The button is sometimes a little bit hard to press. Pressed with a finger, the tactile feedback is not great.
  • People often touch various places of the sensor when updating the set-point. This always slightly influences the measurement. For that reason I added a correction value to the measurement to compensate for this. I also noted to especially mention this problem in the manual.

Problems I found:

I found a serious problem with the circuit. I forgot to place a resistor between the push button and the input pin on the microcontrollers – this was planed, but I actually forgot about it. This problem did not show up in the breadboard prototype and at the begin not in the prototype board version.

If the microcontroller has no power on VCC, but get the full power on the pin for the push button input, the whole chip is powered from there with some small voltage drop. As long the battery is full, between 2.8V and 3.0V, the problem is not obvious. The microcontroller starts working just a few milliseconds before the system timer enables the full power trough the MOSFET.


This additional resistor fixes the problem.


On the prototype, I cut the route, remove the solder mask from both ends and soldered a resistor on it.


Make Stability Tests

If you have a battery powered device and use a power regulator, you can work the specified voltage or no voltage at all. To keep things as cheap as possible, the sensor has no power regulator and will work with the whole range of the voltage between 2V-3.1V. For practical reasons, the sensor will indicate a low battery if the voltage drops below 2.4V.

I looked into the data sheets of many different CR2032 batteries. Most have a significant voltage drop down to 2.4V at the end of life of the battery. There need to be enough power left to signal the low battery for a few days, until it get noticed.

For the tests I use a precise lab power supply. I use the RND 320-KA300SD which is a own brand from Distrelec. For the low price, it provides a very precise and stable power source. It also has memory banks to store different currents and voltages.


I connect the prototype to the debugger and to the power source.


In the firmware, I enable the test loop. Now I set a special breakpoint with an action. I explained this method already in step 13, where I tested the voltage divider. Now I am able to work with different materials and voltages and get an instant voltage and oscillator value.

First I collected all voltage values for the tested prototype. This values may vary from prototype to prototype, but I liked to have them in a table to compare them while testing. It could be, for some unknown reason, the voltage values change.


The ADC in the microcontroller produces a almost perfect linear result.

Next I measure the oscillator values in a glass of water, in just watered soil, in fresh soil, in dry sand and in the air. This tests I do at voltage levels between 3.1 and 2.0 volts.


As you can see, the oscillator is very stable over the whole voltage range. The measurements of the frequency are only precise in a tolerance range of ±2. There is a slightly visible shift, probably caused from my movements around the sensor.

To visualise the differences, I create a table with the differences in relation to the measurement of 2.8V, which is the average operating voltage.


Now the drift is visible. In the diagram above, it looks larger as it actually is. As you can see, the maximum drift between 3.1 and 2.4 volts is ±4. The difference between wet and dry soil is somewhere around 45. This will have an effect, but not in a range that it will be noticeable or even kill a plant.


The second diagram is with the same values, but with a linear trend line. An adjustment function should be possible, looking at this diagram, but I fear there is not enough space in the flash memory to implement it.

I will have to repeat the same tests with the final board to see if there is any difference.

What I Learned:

  • The ADC conversions are nice an linear.
  • The oscillator drift is smaller as I thought.
  • The oscillator drift is also very linear, it could be easily fixed.


Thank you for reading this article! I published this part very shortly after the last one. It was already prepared and almost completed. I hope it was insightful as the last parts and will help you with your own projects.

Read part 5 of this series.

In a few days I will publish the fifth part, which will focus on the design of the final board and work of the final bill of materials for the project.

If you have questions, miss some information or just have any feedback, feel free to add a comment below.

Have fun!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.