Introduction: Thermometer That Pushes Arduino to Its Limits

Starting playing with Arduino seems simple enough. You can find all sorts of tutorials, instructables, wiring and code examples for pretty much every sensor, component, or module available. So far so good. But when the time comes for building a more complex device, the troubles start. The tutorials for adding multiple modules to Arduino and then working with them efficiently are very scarce. Therefore, with this instructable, I will try to help with just that. So here comes the Arduino thermometer/hygrometer with a GUI, designed to push Arduino to its limits.

A small confession is in order though. When I started this project, I thought that the limit to it were the number of Arduino GPIOs, which limit the number of modules one can attach to it. I was considering multiple sensors, more than one display, several buttons and other input modules. However, as I later discovered, the real bottleneck proved to be the limited memory (firstly RAM, but Flash comes a close second). So I shrunk down the project quite a lot, but I ended up with a nice (and hopefully someone will share this sentiment with me) and quite feature rich thermometer. The whole journey made me think that the experience should be shared with the community, since Instructables helped me a lot with my first steps in Arduino.

The project is based on an OLED display, a small pot-based joystick, and temperature and humidity sensor (thermometer/hygrometer). It is designed to connect to an external thermometer/hygrometer over the built-in UART. Analog and digital inputs, and digital outputs are used, eeprom, timers, and interrupts. A small programming framework is provided, which lets one add and remove sensors with very little programming.

Step 1: What to Expect

First thing I have to tell you is about myself. I am a programmer by education and profession. I started learning about Arduino a few months ago and I am mostly interested in it from the programming point of view. I don't like most of the available libraries which are made in C style, since Arduino compilers have no problem accepting C++ code, which lets you do more and in a more compact way. C++ is difficult to master and most hobbyists never will, but libraries written in C++ are generally simple enough to use. This is also how I try to program, I hide the complexities in libraries, to make the code that uses them as simple and readable as possible. So the libraries I will provide to in this instructables should be simple enough to use, with the example paving the way. Modifying he libraries, however, might cause some headaches, so beware.

I will be starting the instructable with instructions and (hopefully) helpful hints on how to approach a big Arduino project and will give the links to code afterwards. The target audience for those instructions and hints are makers with some experience in both Arduino and coding. So others beware, this is not another "how to use Arduino for beginners" class. Although everybody can still just copy the hardware and software, but will probably not learn much through the process.

Of course the project will have an immediate goal, and that is to create a thermometer/hygrometer with internal (measurements taken in the room) and external inputs (measurements taken outside the house). But that will not be just any old kind of weather station that is able to display current values, (or the deluxe kind, which also provides the min and max values). Nope, this one will show the graph of how a given quantity evolved through time for the last 24 hours or a close-up of the same, for the last 20 minutes. It will include options to calibrate the sensors and store the calibration variable in EEPROM. And it will have a very basic user interface to navigate through. So let's start.

Step 2: The Hardware

Hardware - the integral part to any Arduino project. Some of the hardware listed below may not be absolutely necessary or may be replaced by similar modules. One should be able to follow this instructable with only the integral hardware at hand.

First the necessities:

  1. Arduino. Uno is a good choice, but pretty much every other flavor of Arduino could be used.
  2. Breadboard and cable ties. To connect everything together of course.
  3. 0.96" OLED display. I love those little things. In contrast to the LCD's often used with Arduinos, this one has a very low power consumption, is very easy on the eyes and has great resolution. It comes in different flavors, mine is I²C controlled, but SPI controlled is just as easy to use (see g82l library for help).
  4. Joystick. I am using the Keyes KY023 Joystick module, which seems to be very popular. It comprises two potentiometers that define the joystick position and one switch. It also has builtin pullup resistor for the switch, which allows it to be directly connected to the Arduino, without any external components.
  5. DHT11 temperature and humidity meter. Possibly the simplest and cheapest digital temperature and humidity meter. It can be a bit quirky to work with and has poor precision on both measured quantities. I am using a module Keyes KY015, which has the DHT11 properly attached to all the required resistors, and can be again connected directly to the Arduino.
  6. [optional] Another Arduino with DHT11 or similar sensor attached. This Arduino drives the sampling of temperature and humidity outside. I haven't got this one fully designed yet, and so far I am using another Arduino with a thermocouple for measuring temperature, connected to the primary Arduino through UART.

Step 3: Software Prerequisites

I want my software to be simple and readable. I tend to encapsulate every bit of functionality in a class and use objects for everything. Such approach is not the most efficient in the number of lines written but it produces clear code and writing is very satisfying. Inside the libraries, I release my constraints a lot and code there is harder on the eyes. This project will also consist of both, libraries and project-specific code.

This project is not meant to be copied verbatim but rather to be extended from, just using and reusing the parts that one requires. The external (outside) sensor device is not even fully described, since I believe it to be a bit off-topic and the instructions are already long enough without it. One could google for a basic tutorial for any temperature sensor and get a good enough base from which to build the external device. The code required for communication, however, is provided here.

On to the actual software prerequisites. First is the Arduino environment, or equivalent IDE. I am using the PlatformIO, since it provides a bit more powerful IDE.

Second come the public libraries:

  1. For driving the OLED: g8l2
  2. For interfacing the Arduino hardware timers: FlexiTimer2.
  3. For using the streaming operator to output strings: Streaming.
  4. Libraries that ship with the Arduino environment: SPI, Wire, EEPROM

Now the libraries provided by me:

  1. PotJoystick: Library for interfacing the small potentiometer-based joystick.
  2. TemperatureSensor_DHT11: For interfacing DHT11 and compatible sensors (DHT12, DHT22). There are other libraries written for this sensor family but I found them hard to use together with other stuff. That's the only reason I wrote my own. Note that it has its share of flaws and is not written for anything else than the AVR processor family.
  3. SimpleExponentialFilter: For filtering noisy values, such as the readings from DHT11. Memory efficient and easy to use filtering. I would advise every builder to learn how the exponential filtering works (I provide the description in one of further steps). It is simple and effective.
  4. MultiTimer: A library that allows bonding multiple interrupt service routines to a single hardware timer. Similar libraries already exist (such as arduino-tasker). I am using my own, because it came to life as a byproduct of another problem I was solving.
  5. StaticLinkedList: This library enables one to automatize global (static) linked lists. This library is used in my other libraries but not directly in the project.
  6. FixedPoint12dot4: Simple 16-bit fixed-point number encapsulation. Fixed point numbers seem a must in Arduino environment because of the lower memory footprint and superior execution times (I provide the description in one of the steps).
  7. GraphLib: A library for plotting graphs on the OLED and other screens.

Step 4: Setting Up User Interface

Before going into the first software part of the project, let me make a short overview of what I am trying to achieve here. Although there does not seem to be that many parts, this project is big enough to be able to cause all kind of memory problems. Therefore, when programming, I was putting memory efficiency above everything else. Secondly, although I tried to make code nicely structured (the opposite of making spaghetti code), I was not quite able to leave all the pasta out. Global variables are a perfect example of that - not something you'd want in well structured code, but proliferated rapidly in my code because I was putting memory efficiency above code structure. But enough of that, let's move on to coding.

OLED driver

The central module is the OLED screen. Everything else can be thought of as just a support for pushing data on that screen. The OLED is driven by the excellent u8g2 library. I've tested also the Adafruit SSD1306 library but found it inferior. U8g2 is easy to use, never caused me any problems, comes with a ton of fonts, and last but not least, is very memory efficient.

A naive approach to driving these OLED screens is having a copy (a cache) of the screen contents in RAM to draw on and then copy it over to the screen in one go. That makes up 128x64 bits of data, which equals 1 kByte - half of the available RAM in Arduino UNO! Such an approach is not acceptable for a complex device that has great demands for memory itself. To alleviate the headache one gets from using OLED screen, u8g2 lib offers a very nice option to only cache one or two lines of data (128 bits = 16 Bytes or 128*2 bits = 32 Bytes of data). Much better! When initializing the u8g2 driver, one declares a new variable like this:

U8G2_SSD1306_128X64_NONAME_F_HW_I2C display(...).

Note the letter F in the second to last block of letters in the variable declaration. This instructs u8g2 lib to cache the screen in full. One can substitute t for 1 (1 line of cache) or for 2 (2 lines of cache). Then, when drawing stuff on screen, one must draw in a loop, repeating the same draw commands over and over:

display.firstPage();
do {
   // put things on display
} while ( display.nextPage() );

And that's it. Less memory is used on the account of slower drawing (everything in the loop is repeated 64x or 32x). But as long as simple things are pushed on screen, the slowdown will not be noticeable.

Graphs

When I use OLEDs, I also always try to use its main advantage over the small LCD or LED matrix displays. What is that you ask, while you are probably thinking of the low power usage, great contrast, etc. I had none of that in mind. I am thinking of its ability to display graphics in contrast to just text. And when one can draw graphics, there is no better data representation than a graph. Why show the current temperature when you can show the graph of temperature through the last 24 hours? Onward to plotting graphs then. I am using a small library GraphLib (see my sources), where I provide two classes, the basic LineGraph and the extended LineGraphYAxis. As the name suggests, the latter is the former, extended by Y axis labels and grid lines.

These classes are used both, as containers for data points, and as handles for plotting line graphs from the data points. Data points are stored as single bytes (signed 8-bit integer for the y value, while x value is implicit) and each graph comprises 48 points, all in an effort to keep the memory usage low. It could go lower still, since I use graphs only 32 pixels high, and would therefore require only 5 bits and not the whole byte for each point, but I am keeping this particular memory optimization on the todo (and only if needed to) stack for future.

So these graphs are just sets of connected points, with the option of appending new points at the right side of the graph (the graph seems to scroll left). If the points are added to the graph periodically, then graph's x axis can be seen as time and the graph shows the evolution of a variable through time very nicely.

User interface (UI)

An important addition to a complex project is a nice user interface, that is, a collection of screens for displaying various bits of information to the user and a way of interaction with those screens and/or those bits of information. I chose not to overdo it graphically, as you will be able to see from the photos. One could add blinking of the parts that can be adjusted and so on. Such details would surely be nice but are also time consuming to program with infinite level of detail to go into. Maybe I will dwell into those details in the next project.

So, the way I imagine a simple Arduino UI, is based around a set of variables. Each is displayed on its own screen and these screens are linked horizontally. Therefore, horizontal joystick movement should switch between screens. Screens are furthermore composed of sub-screens, where different views on the same variable can be selected. Sub-screens are linked vertically, therefore vertical joystick movements switch between sub-screens. The button on the joystick switches the joystick mode from selecting screens and sub-screens to controlling a given variable and vice-versa.

Joystick

This brings us to the last part of the user interface, the almost too good to be true joystick module. Yes, it has its down sides and can hardly be used for analog control of pretty much anything. But it just shines when asked for digital input. This joysticks can be bought for less than half a Dollar (or half a Euro, they are pretty neck-to-neck these days), comprises two rotary potentiometers and a tactile switch. Tilting the joystick in x or y directions rotates the potentiometers, while pushing on the joystick pushes down the switch. Connecting the joystick to the Arduino is very simple, just connect VCC and GND connections, X and Y to analog inputs, and SW to a digital input with a pull-up resistor (You can use the Arduino internal pullup) .

Then the joystick position/tilt can be read with

sw=digitalRead(swPin);
x=analogRead(xPin);
y=analogRead(yPin);

Switch is connected in active low mode (digitalRead will return HIGH when it is not pressed and LOW when it is pressed) and should be debounced. I choose to poll it at a low enough rate, which I believe is considered a valid debouncing method. X and Y positions should be around 512 when the joystick is in center position but don't count on these values to be accurate. When fully tilted, the positions go all the way to 0 and 1023, but again, do not depend your code on those exact numbers.

I provide an option of calibrating the joystick (determining its central position and range of movement), and storing the calibration constants to EEPROM. See PotJoystick library for more detail. This library and the whole approach might be a little more trouble than is worth it, but this way I get the required accuracy if I ever decide that I need joystick for its analog values too.

Joystick control of the UI

Back to the problem of controlling the UI with pot-based (I use pot as short for potentiometer) joystick. Basics are simple, read the joystick position and determine if it is nearer the center or nearer full tilt. If it is former than leave the screen, if it is latter then scroll to the previous or next screen. To do this properly, there are several steps to follow:

  1. Enumerate the UI screens (so that each screen has predecessor and successor defined) and link an integer variable to the joystick. This variable will represent the selected screen.
  2. Know the center position and movement range of your joystick.
  3. Read joystick positions separately for x axis. When joystick position is near the center, do not touch the variable. Let's say 512 is the center position and 512 is the movement range. Then 512/2=256 is half the movement range. If the position is between 512-256=256 and 523+256=768, then do not change the variable value. If it is more than half the movement range away from center (that is 0-256 or 768-1023), then decrease or increase the variable by one.
  4. If the variable was modified in previous step, then wait for the joystick to go back near center (256-768) before allowing the variable to be changed again. Simple and effective.
  5. Throw timers into the game and allow the repeated change of the variable when joystick is kept in tilted position for some time (half a second for example).

The method above only used x axis. Using the same method for y axis gives another dimension of movement, which I use for sub-screen scrolling, but could also be used in other ways. I really believe that this joystick is a perfect companion for a GUI and incredibly more effective than using buttons or even rotary encoders.

The library that implements the method described above is JoystickControlledUI from my sources.

Step 5: Monitored Variables

Finally to the meat of the project, that is to the quantities (or the variables, as we computer geeks like to call them) that we wish to measure or control. Both temperature and relative humidity are continuous variables and are best represented by floats. Although I could get away with the additional RAM usage and processing nightmare that floats bring to Arduino, I chose to use fixed point numbers for representing my quantities.

Fixed point number representation

For those of you who did not hear about fixed point (as opposed to floating point) representation of numbers yet I give a short intro here. Fixed point numbers are actually very simple, and their simplest form are the good old integers, such as int, long, long long, short and even char. These have the decimal point fixed (that's where the representation name comes from) to the position just after the least significant digit. This means that there is no digit different than zero to the right of the decimal point (or after the last bit when written in binary).

If one is brave enough to consider fixing a number's decimal point at, say, between bits 4 and 5, instead of after the last bit, then one gets a non integer fixed point number. For example, the number 13 would be written in fixed point representation with 4 binary digits to the right of the decimal point like this: 1101.0000b. Now these four bits do not need to be zero any more. One can write the number 1101.0001b, which equals 13 ¹/₁₆ or 13.0625. 1101.0010b equals 13 ²/₁₆ or 13.125 and so on, up to 1101.1111b, which equals 13 ¹⁵/₁₆ or 13.9375. We can see that fixed point representation takes several (4 in the example above) bits away from the integer representation and employs them as representation of fractions. This way numbers can be represented to precision of several decimal places.

A more concrete example, which is incidentally also used in this project (see library FixedPoint12dot4) uses 16 bit integer type (int16_t) to represent fixed point numbers with 12 bit whole numbers and 4 bit fractions. The FixedPoint12dot4 type can thus represent numbers approximately from -2¹¹ to +2¹¹ (that is from -2048 to +2048) with precision of ¹/₁₆. That number range is more than enough for representing temperatures or percents. The precision of ¹/₁₆ is more than ¹/₁₀ (which equals one decimal place) and is therefore sufficient for presenting numbers with exactly one decimal place. Using this type, the temperatures measured with a digital thermometer can now be stored in precision of 0.1 °C. Sweet! But the thermometer is only precise to 1 °C, which could perhaps leave a bitter aftertaste? Well, not necessarily - read on the next section.

Improved precision of sensors

I am a person with high standards and if I am given the temperature reading to only 1 °C precise, that reading is practically meaningless to me. I am sure I am unable to feel the 0.1 °C temperature difference but I am also quite certain I want to know it, to display it, and then obsess about it! But, more seriously though, even if I were happy with the precision of the sensor, I wouldn't want for the display to jump between 13 and 14, when the number is 13.5. Luckily, these two problems (one pretty much imagined and the other a bit less so) are solvable by a two step procedure. First step is over-sampling and the second is filtering.

So what is over-sampling? First off, please note that I will be giving a bit narrowed down and simplified definition. Over-sampling can be thought of as taking samples (measurements) of a signal or quantity more frequently than necessary. I am dealing with temperature and humidity of ambient air, which are both very docile, and change very slowly over time. Measuring temperature every several minutes would probably capture the whole temperature dynamics accurately enough for everyday use. So if I am sampling the temperature every few seconds instead, I am using over-sampling.

And what is filtering? This is probably a more generally known procedure. Filtering means removing the noise from the signal. But what is noise? One example was already mentioned above - the continuous jumping of the sensor reading between 13 and 14, when in reality the value should be 13.5. But in general, the noise arises from the imperfections of sensors or measurement-taking procedures. For example, the sensor readings are more often than not based on the power supply voltage, and every jitter in the voltage will result in a small jitter in the sensor reading. Filtering employs the power of large numbers and statistics to fight against noise.

So, if one does not believe a single reading of a sensor (because, you know, jitter), one can make multiple readings and average them (probably the simplest known filtering technique) to obtain a less noisy reading. In general, averaging of n values reduces the noise by a factor of √n. I am not going into the detail of why and when this is true, you will have to trust me on it. Translating this to practice, if I were to take 100 temperature readings and average them, I would reduce the noise 10-fold (compared to just reading the temperature once). That's pretty neat! But wait, there's more. You probably guessed it by now - if I started with the jitter of ±1 °C, then by averaging 100 temperature readings into one, I reduced the jitter to 0.1 °C. That is the precision to one decimal place, which I was not even able to get before, when I was just seeing the precision of sensor. Seems great, but has the accuracy increased as well you ask? Glad you asked. Yes, it has. So instead of reading temperature 13, then 14, then 13, then 13, then 14 again, and so on (you get the picture right?), I will read the temperature as 13.5 °C and will also be accurate to 0.1 °C.

As a note to all of you that are more used to Fahrenheit - this helps you too. Probably even more so, since sensors like the DHT11 are made to return temperature in °C with the resolution of 1 °C. Converting Celsius to Fahrenheit gives even worse results than just leaving them in °C. The sensor's resolution measured in F is about 0.5555 F. The reading are thus not even displayable as plain integers! So the increased resolution due to averaging should be very welcome for you.

Exponential filtering

By now you probably wonder why I advocate taking 100 values and averaging them, since that would take a whooping minimum of 100 bytes of RAM. That is 100 B per sensor, and I am not selling you a single sensor here. No, I am not the kind of person that would throw RAM away like that. So this is the place where exponential filtering enters the picture. Exponential filtering only holds 1 sample value of history and is perfect for when memory is scarce. The formula for exponential filter is very simple:

xf = α*xf + (1-α)*x

In the formula, x is a new value, xf is the filtered value, which doubles as the filter history, and α can be thought of as the filtering strength. α is a number between 0 and 1, and is more often than not above 0.9. With every new value, filtered value is updated (that's why it doubles as the filter history), but the new value only influences it very little. If α = 0.9, than the filtered value moves towards the new value for only 0.1 times the difference between new value and previous value. Let's see practical example again. If temperature was exactly 13 for very long, then xf equals 13. Now a new reading shows 14. Send the new reading through the filter: xf = 0.9*13 + 0.1*14 = 13.1. Filtered value goes up to 13.1. "Big deal" thinks the filter, "I don't trust your sensor more than 10%!" That's why the filter isn't getting fooled by random noisy measurement.

Exponential filter isn't quite as good as the averaging, since it does not give all readings the same weight. Using a little math we can see that the weight of the last filtered value is (1-α), of the second to last is α*(1-α), of the third to last is α*α*(1-α), and so on. For α=0.9, that makes weights 0.1, 0.09, 0.081, etc. This is where the filter's name comes from - the weights are dropping exponentially. So the filter is biased towards the later values. This can also be considered a good point, since it should react to changes faster. In any case, I digress too much. Exponential filtering is just the remedy that a typical temperature sensor connected to the Arduino requires. A memory and processing efficient filter that can be used on top of over-sampling. Well, perhaps I have skipped some ugly details, such as setting up the initial filter value, but these can be inspected in the implementation in the included SimpleExponentialFilter library.

Opposed to all the theory I was throwing around above, I am driven by my gut feelings when I implement things and am pressed against defining some pesky constants. With the thermometer/hygrometer sampling, I am dealing with two such constants. First one is the sampling period and the second one is filter's α. I tried to derive the former one first, and I considered the DHT11 properties in the process. DHT11 requires must be given some time for recovery after every reading, and according to datasheets, it should not be read more than once per second. So I chose the sampling period of 10 seconds - a bit above the required 1 second for recovery, but will help keep the reading (which is also slow on DHT11) overhead low. On the other hand, 10 seconds period should result in frequent enough sampling for the over-sampling to work. Now that I determined the sampling period, I considered the α, for which my gut feeling advised me the value 0.95 and I listened to it. It does sound like a good value, doesn't it? Choosing these two constants mean that the sensor will have a few minutes lag for temperature changes, but well, I think the sensor already has a long enough response time (tested by blowing warm air on it) that this additional few minutes won't mean much. You can tweak the filter's reaction time by changing α. Lowering it will make the filter react faster but will also make it more twitchy, while higher values (don't forget the maximum is 1) will make it lazier but also more noise resistant.

Step 6: Adding Variables to Monitor

Now that the basics have been covered, it should be quite straight forward to go on. I am working with 4 variables only: internal temperature, internal relative humidity, external temperature, and external humidity. They are all defined in relatively similar manner. I will demonstrate on the case of internal temperature. First there is:

SensorValue insideTemperature;

Where SensorValue is just FixedPoint12dot4 type, extended with adjustment value. Adjustment value is used to calibrate the sensor, that is, to adjust the sensor's output to match the reference value. But more about that later. This variable of SensorValue type is used to hold the filtered sensor value. It is the value that gets displayed on screen in large font. It is modified in a timer-based object for reading the DHT11 sensor, Dht11Reader.

Then there is the graph variable inside the UserInterface struct (this struct is only instantiated once):

struct UserInterface {
   ...
   LinePlot insideTempGraph
   ...
}

The LinePlot type is defined as:

typedef LineGraphYAxis<U8g2GraphAdapter, 48, uint8_t> LinePlot;

which means that LinePlot is just LineGraphYAxis which is able to use the U8g2 library, and represents graph objects with 48 data points which are all uint8_t (single byte signed integer type). The variables of this type are plotted in the UserInterface's displayGraph function which is called from the showScreen function whenever there is a screen with a graph active, which is in turn called from the loop function of the Arduino platform. The insideTempGraph variable is modified in another timer-based object called GraphDataAdder, where the last value of insideTemperature is added to the graph.

To recap, for the temperature value to reach the display, there is a timer based reading of DHT11, a timer based appending of values to the graph and loop-and-delay based display update.

All other sensor values follow a similar route, but some get to be presented on two graphs. These are the inside temperature and humidity, which can be observed either on a graph that spans 24 hours with resolution of 30 minutes, or on a graph that spans 20 minutes with resolution of 25 seconds. I made the decision to display internal sensor values on a shorter time axis to be able to monitor the progress of airing the room when I open the windows (old house with moisture problems).

Sensor calibration

In my experience, every sensor that I get shows a different temperature. And that difference is up to several °C. Luckily, I've got an analogue thermometer that seems to agree quite well with my aircon setting, and I use that to calibrate other sensors. How do I calibrate them? Well, not entirely correctly, since sensor error is usually a complex function, which I would not like to tackle. Instead I use the most approximate solution to calibration possible - I pretend that my sensors are just off by a couple of degrees and then I offset their reading by the right amount to make them show roughly the same temperature as my thermometer/aircon combo.

As mentioned above, the class SensorValue extends the FixedPoint12dot4 (which is just a value representation) with an adjustment variable. This adjustment is actually the offset used for sensor calibration. The type of variable is int8_t, which means it is an 8-bit signed integer, but do not let that fool you. I am actually using it as fixed point number with 1 bit reserved for fraction. Yes, I was a bit lazy coding this class so I did not make a FixedPoint7dot1, which would possibly be a bit nicer. Instead I opted to know what I was doing with the adjustment value in all parts of code where I use it. I chose to use only one byte to represent the adjustment value because I am trying to save every precious byte of RAM that I can. Yes, sometimes I go to great lengths to make code uglier to gain 1 byte but other times I am completely ok with throwing away tens of bytes for a much lesser effect elsewhere. Do not copy that trait from me, always go for a prettier (and easier to understand) code first and optimize those bytes later and only if you have to.

The values of all adjustment variables are read from an EEPROM-based data structure in the setup function and can be changed through the user interface for every measured quantity separately. If it is changed it gets written back to EEPROM. Calibration of sensors s currently the only sensible use of EEPROM I have encountered so far. But I am grateful for having that EEPROM available, if it weren't for it, I would have to hard-code sensor adjustments into the code, which is just ugly and hard to change.

Step 7: A Few Programming Tricks

My code seems full of little programming gimmicks that could send the average maker running for cover. I will try to explain some of them here.

Defines

Starting at the top of the main source file, there are several defines. I use defines to enable and disable parts of code at compile-time. Using defines ensures that the disabled code will not even get compiled, much less uploaded to the Arduino, and will also not use a single byte of RAM. Enabling or disabling parts of code in this project is done for debugging only. So for example un-commenting the #define DHT11_TEST and recompiling will disable the main program code and enable debugging DHT11 code, which is pretty much just writing every read value to the Serial. But creating such blocks in the process of writing a project and then leaving them in code as conditional blocks is very useful when something goes wrong. If one of your sensors starts to send out garbage, what do you do? Well, start with un-commenting the appropriate #define statement, thus enabling some additional debugging code and disabling other non-related code, and go form there. Debugging in this way makes it easier to track problems to their locations in code.

There are also very useful blocks like this:

#ifdef COMM_TEST
   #define IF_COMM_TEST(PERFORM) do { PERFORM; } while (false)
#else
   #define IF_COMM_TEST(PERFORM) {}
#endif

Such blocks just make writing conditional code blocks it a bit prettier later on. So the code above for example, makes it possible to write something like this:

void setup() {
    IF_COMM_TEST(Serial.println("This will be only shown when COMM_TEST is defined"));
    ...
    IF_COMM_TEST({
        Serial.println("making multiple ");
        Serial.println("statements conditional ");
        Serial.println("is also possible");
    });
}

instead of this:

void setup() {
#ifdef COMM_TEST
    Serial.println("This will be only shown when COMM_TEST is defined");
#endif
    ...
#ifdef COMM_TEST
    Serial.println("making multiple ");
    Serial.println("statements conditional ");
    Serial.println("is also possible");
#endif
}

It might not seem worth the extra code but to me it is much easier on the eyes.

Streaming

Including Streaming.h (From the external Streaming library; I link to it later) in your code will not only make available the c++ way of printing stuff, which is like this:

#include <Streaming.h>
...
int i;
float f;
Serial << "Some text, an int " << i << ", and a float " << f << "\n";

instead of this:

int i;
float f;
Serial.print("Some text, an int ");
Serial.print(i);
Serial.print(", and a float ");
Serial.println(f);

but more importantly also allow the use of F macro in a seamless way. If you haven't heard of F macro before, then a short introduction is in order. When strings are used in Arduino, they are stored in flash and in RAM. Yes, they take up space in both memories. Their values are of course stored in flash, as all literal values are, but are then copied to RAM too and are always there, burning our precious RAM, even when nobody needs them. Luckily the F macro comes to rescue. When strings are declared like this: F("a string here"), they are stored in flash only. Some magic then happens when they are used, which copies them into temporary variables and discards them after use. But strings declared with F macro are not equivalent to normal string. Most importantly, one cannot use them with Serial.print function:

Serial.print(F("This will not compile"));

That's a shame, Arduino could surely be extended to make that possible. But who cares anyway when the so much better:

Serial << F("Luckily, this will compile");

makes compiler perfectly happy. I could also mention that Streaming has no problems printing floating point values and so on. I use Streaming library everywhere now, it really makes the Arduino more usable in many ways.

CharStream

I have defined CharStream to enable Streaming library to print everything that is printable into a char array. So, imagine you have a function that takes text as a char array (that is char[]) and prints it somewhere. That kind of function is hard to use if you want to pass some variable values to it. Perhaps even floating point values, which cannot be printed to char[] with the usual sprintf. Well, for those cases I have CharStream and I use it like this:

#include <Streaming.h>
#include <CharStream.h>

CharStream<20> stream; // create a char[20] variable, which can be used as a destination for << operator
stream << "pi = " << 3.14 << "\n";
functionThatTakesCharArray(stream.str());

So, this little helper is used mostly in combination with Streaming although it also supports print functions. Enough detail, I include the source for you to fiddle with (CharStream.h in sources).

Avoiding delay using interrupt-based timers

There is nothing wrong with the delay function, in fact, it is very useful. But, if I were to handle reading sensors, reading joystick input, drawing to screen, listening to Serial and more using only delay then I would be in serious trouble. If more than one module is to be handled, delay should be replaced by timers and timer-based events.

In this project I use the FlexiTimer2 library for basic timer code on top of which I build MultiTimer library. MultiTimer expansion can be used to create as many ISRs (Interrupt Service Routines) as one may desire instead of only the one that FlexiTimer2 (and all other timer libraries I checked) provides. ISRs are small functions that are triggered on so called interrupts. In our case the hardware timer raises those interrupts, so the ISRs are used to make stuff happen when the timer says so.

MultiTimer library helps me create multiple timers, which occur only once or repeat every n milliseconds. These timers are very simple to use, creating one requires just extending the MultiTimer (for once-off events) or RepeatableMultiTimer (for periodic events). A simple blink program, which would blink the LEDs connected to GPIOS 10 through 13 with different frequencies could look like this:

#include <FlexiTimer2.h>
#include "MultiTimer.h"

// define the timer functionality 
struct MyTimer : RepeatableMultiTimer {
    bool state = false;
    int pin;
    MyTimer (int p, int r) {
        pin = p;   // set the active pin
        setPeriod(r);  // set the 'delay' between two repeats
    }
    
    void onTimer() {
        state = !state;    // invert the state 
        digitalWrite(setpin, state ? HIGH: LOW);  // according to the state variable either write LOW or HIGH to 13 
    } 
} 
// make variables with the previously defined functionality 
MyTimer led0(10, 100), led1(11, 150), led2(11, 200), led3(13, 300);

void setup() { 
    FlexiTimer2::set (1, MultiTimer::isrFunc);   // execute the provided ISR every millisecond 
    FlexiTimer2::start(); 
}

void loop() {}

No more code is required. The struct that is extended from RepeatableMultiTimer will automatically get its own ISR and this ISR will be registered with the timer library.

Avoiding delay using tasks

Interrupt-based timers have one major flaw that is not immediately apparent. The functions that execute inside the interrupt service routine should be as short as possible. Any functions that use interrupts themselves (such as delay, printing on Serial, etc) can also not be called from the interrupts. The usual solution to attaching processing intensive functions or functions that use interrupts themselves on timers is to use tasks. Task processing system is usually composed of a task handler (or executor) and tasks (or jobs) themselves. The handler holds a FIFO queue of tasks to execute and is called inside the Arduino main loop. If its queue is empty it does nothing, but when its queue is not empty, it handles them (pops them from the queue and executes them) one by one. Adding a task tho handler's queue is a simple operation and can be done from an interrupt-based timer. And that's basically it - through the use of handler, the timer is given the ability to execute (albeit indirectly) procedures of almost any length. Do note though, that scheduling every second a procedure that takes 10 seconds to execute is still not a good idea.

MultiTimer library already contains a task system, composed of MultiTasker (the task handler) and base class SimpleTimerTask. SimpleTimerTask is a template class and can be used either on top of the MultiTimer or RepeatableMultiTimer, creating a one-off task or a repeatable task. An example of how to use this task system to print to Serial every 10 seconds:

class TestTask : SimpleTimerTask<RepeatableMultiTimer> { 
    int cnt = 0; 
  public: 
    TestTask (int period) { 
        setPeriod (period * 1000L); 
    } 
    void onExecute() { 
        ++cnt; 
        Serial << F("Time: ") << cnt < '\n'; 
    } 
};

TestTask tt1(10);  // create a task that will be executed every 10 seconds

void setup() { 
    FlexiTimer2::set (1, MultiTimer::isrFunc);   // execute the MultiTimer ISR every millisecond 
    FlexiTimer2::start(); 
    Serial.begin(9600); 
}

void loop() { 
    MultiTasker::run();  // execute the task handler : execute all tasks that are ready 
}

Note that using the tasker will cause somewhat random delay in task execution, unlike when only timers are used, which execute exactly on time. The delay arises because the tasks are executed indirectly and because sometimes multiple tasks may be added to the handler queue at the same time but can execute one at a time only. Still, the MultiTimer and the MutliTasker should provide acceptable results large majority of cases where actions should be taken in regular intervals.

Delay in the loop

You might notice in my project code that I still use delay inside the main loop function. The loop would run just fine without it, but I like to keep it there so that the functions in the loop don't get called too often when there is no task scheduled to execute. There is nothing inherently wrong with calling the functions in the loop more often but it is just not needed. It is simply a waste of processing cycles. Putting Arduino to some low power state would be best, and the delay is currently there to remind me of that. So that's on my TODO list, putting Arduino to sleep when there is nothing to do. And yes, I could use my MultiTimer instead of the delay but it would just mimic the delay functionality. Therefore this is a case when using the delay is the best choice.

Data over Serial

To connect two Arduinos over the UART, one has many choices. Wireless transmission using cheap 433 MHz transmitter and receiver pair, wired options like the RS485, or the ultimate solution of just connecting the RX, TX, GND to the other TX, RX, GND. You can even exchange data with the PC or Raspberry PI over the Serial. May it be any of the listed cases, I use a little trick when using Serial for communication. That is, I use encoded communication so that it can be separated easily from the debugging messages.

I have defined a structure SerialData (see it in the project code, it is a bit too complicated to list here), which takes care of the following logic:

When sending data, start your data packet with a nonsense character, such as '\b' (backspace character). This is surely not going to appear in your debug messages and will make it very easy for the data reader to filter data from debug messages. After the nonsense character just output the data, either in ascii or in binary (faster but a bit trickier) and end it with a newline. Such data will be simple to parse, as can be seen from SerialData struct in the main TemperatureGraph.ino file.

Data in EEPROM

For storing to and loading from EEPROM, I use a similar trick as above. I create structures to be stored in EEPROM. These structures always include all the data to be stored and an additional single character that acts as a header. For storing joystick calibration data for example, I prepend the character 'j' to the data. So when reading the data I also read the header character and test if it equals 'j'. If it does not, then I have never stored data to that EEPROM location yet or I have previously used this the Arduino for something else. In any case, I will know that the data read is invalid and will just initialize the joystick some other way, or even redirect the user to joystick calibration screen (that's what I do actually). I have encoded the logic of adding a header byte in the template class EepromStruct, which is used like this:

struct JoystickEpromStruct : public EepromStruct<'j'> {
    int cx, cy;   // central joystick position
    int range;   // range of joystick movement
};

My structure extends EepromStruct and provides its header value as the template parameter. That is it. The extended structure then provides the variables to be stored in the EEPROM. Loading my structure from EEPROM then looks like this:

JoystickEpromStruct js;
EEPROM.get(joystickEepromAddress, js);
if (js.isValid()) { ... }

the member function isValid() does the comparison of header char with 'j' (the template parameter used before) and returns true if they equal, false otherwise. Storing my struct is similarly easy:

JoystickEpromStruct js;
js.initForWriting();   // this will initialize the header char correctly
js.cx = 0;  // set values to the variables here
EEPROM.put(joystickEepromAddress, js);

Both code snippets assume you also have joystickEepromAddress defined. Having addresses defined somewhere as either '#define's or as 'const int's is advisable, as it is much less error prone than writing it as a number all over the code. If you do the latter you will burn yourself sooner or later by providing wrong (different) addresses. The same is true for the header character value - if it is defined once and used multiple times, then the odds of getting it wrong are near zero. This is why I define it as a template parameter.Once again, the full code is in the TemperatureGraph.ino.

Step 8: Links to Code and Connecting Instructions

You've made it this far, congratulations. Here comes the source code. I have put together a git repository just for this: https://bitbucket.org/gundolf_/thermometergraph. If you don't want to deal with git, bitbucket allows you to download source as a zip file instead. In any case, don't forget to download the required libraries too (g8l2, flexitimer2, streaming). You can probably download these libraries using Arduino IDE's library manager, but I am not sure, since I haven't tried.

Unfortunately, to have it compile without much work in Arduino IDE, I had to put all source files, including my libraries, together in the same directory. The alternative would be to put all the libraries to own directories inside your Arduino's global libraries directory. You are of course free to do so if you grow to fancy any one of them.

So the git repository contains the following:

  • ThermometerGraph.ino (The main project code)
  • CharStream.h
  • FixedPoint12dot4.h
  • U8g2GraphAdapter.h
  • TemperatureSensor_DHT11.h
  • StaticLinkedList.h
  • SimpleExponentialFilter.h
  • PotJoystick.h
  • MultiTimer.h
  • MultiTimer.cpp
  • JoystickControlledUI.h
  • GraphLib.h

There is just a little part of code missing, and that is for the other Arduino, that would be sitting outside somewhere, connected to thermometer & hygrometer module(s), and would be sending the data in. I will leave the data sampling as homework and only give the part that sends the data over Serial for this project to pick up and display:

int16_t tempFF = floor(temp * 16.0 + 0.5); // convert temperature from temp (float or int) to FixedPoint12dot4
int16_t rhFF = floor(rh * 16.0 + 0.5); // convert relative humidity from rh (float or int) to FixedPoint12dot4
// send the data in the format recognised by the TemperatureGraph project
Serial << '\b' << (char)(tempFF) << (char)(tempFF>>8) << (char)(rhFF) << (char)(rhFF>>8) << '\n';

The primary Arduino will be able to pick he properly formated data up with its SerialData structure.

I always thought that Arduino was mostly about hardware. Boy was I wrong. After playing with it for a couple of months, I now see that the fun (and time consuming) part is in coding it. Nevertheless, finally this instructable reached the part where I describe the connections. So here is how to connect it up:

  • Joystick +5V to +5V
    • GND to GND
    • VRx to pin A0
    • VRy to pin A1
    • SW to pin 3
  • DHT11
    • - to GND
    • + (middle pin on KY015) to +5V
    • S to pin 2
  • OLED (there are many models available, mine is I²C with 4 pins, yours may very well be completely different and you will have to connect it differently)
    • GND to GND
    • VCC to +5V
    • SDA to pin A4 (I²C SDA)
    • SCL to pin A5 (I²C SCL)
  • Connect another Arduino over pins 0 (RX) & 1 (TX) directly or over a couple of communication modules. For direct connection connect RX₁ to TX₂ and TX₁ to RX₂. This is how I have it for now, while I am deciding on how to make the Arduinos communicate over the distance of several 10 meters and across several walls.

Step 9: Conclusion

In this instructable, I haven't really tried to provide the full instructions on how to build something from the ground up. I think everybody has their own ideas of what they want to build. I hope I have provided the more important know-how of how to define the building blocks that work well together and allow a large project to take shape. I see a lot of people having trouble of expanding their projects beyond one peripheral, since the available tutorials almost never touch that topic. Controlling multiple peripherals takes a different approach, which is seldom shown in tutorials, while adding the details requires well designed structure and following some additional rules.

To summarize my advice to makers trying to expand their Arduino thermometer (or whatever else you are dealing with) is:

  • Ditch the delay as soon as possible - usually the easiest time to switch to interrupt-based timers is at the very start, with the first blinking LED.
  • Don't write literal constants and magic numbers in the middle of your code, define them all at at some point in the code and then reference them.
  • Use classes/structs a lot. Remember that struct is just a class, with all of its members public by default. This form of structured programming will make your code readable and manageable.
  • Watch out for your code's memory footprint. Writing a lot of debugging output to Serial is great, but use Streaming library and F macro to do it or it will eat your memory away in no time.

And finally, don't just attach a sensor to your Arduino and display it's readings on some display once every few seconds. You've got a CPU that can process 16.000 operations per second. Be creative with it and milk it for all it's worth. The project shared here for example uses about 75% of both flash and RAM available in Arduino UNO, and is probably not doing any useful processing 75% of the time. So there is still room for expansion. Note that RAM is the bottleneck though, since you can not use 100% of it (in the way the Arduino IDE shows RAM usage), since you need to save some for the stack too. And you cannot know how much RAM you require for the stack (learning the number would require a very deep analysis of the program code).

First Time Authors Contest 2016

Participated in the
First Time Authors Contest 2016

Arduino Contest 2016

Participated in the
Arduino Contest 2016