Make a Temperature Logger Using EEPROM and LCD Using MCC | Embedded C Programming - Part 37
Published
Hello again!
In this tutorial we’re continuing consideration of the MSSP module of the PIC18F14K50 MCU, but this time we will configure it in the SPI mode. Again, this is not a tutorial about the SPI interface itself but how to implement a SPI interface using C in the PIC family. So if you are not familiar with SPI at all, please read some basics about it, for example, here.
This tutorial keeps the good tradition of gradually increasing the complexity and the length of the program. The current task we will be tackling is the following:
- Create a temperature logger which will read the temperature from the DS18B20 sensor (which we considered in detail in Tutorial 13) every minute.
- Save the value in an external EEPROM chip, the AT25010.
- Display the temperature graph in a Nokia5110 LCD. The last two devices are connected via the SPI bus.
The EEPROM chip AT25010 is located on the Universal board which I’ve mentioned in several previous tutorials, for example, here. The Nokia5110 LCD is called this because it was used in the eponymous cell phone. It became quite popular because of its convenience and simplicity of use, low price ($3-$4), and reasonable resolution of 84x48 pixels. It’s not part of the Universal board, unfortunately, but I’ll show you how to install it there easily. A long time ago I used to buy these displays in a phone spare parts shop but now omnipresent Chinese manufacturers sell the modules with this display, a built-in backlight, and convenient pin connector (Figure 1).
The color of the board and the pin order may differ but it’s always the same LCD with the same connection, so you can purchase any version, and it will be just fine.
As usual, first of all let’s get familiar with the MSSP module used in this tutorial. We have already considered it previously but now we will use it in SPI mode which has certain differences.
Microchip PIC Master synchronous serial port (MSSP) module in SPI mode
As I mentioned in the previous tutorial, this module provides a hardware implementation of both the I2C and SPI interfaces. Also, it uses the same pins for them so you can’t use both interfaces at once, which is unfortunate.
As for SPI mode of the MSSP module, it supports both Master and Slave modes. In Master mode, the MSSP module controls only MISO, MOSI and SCK pins, so you can use any pin for the SS function and control it with your firmware. In Slave mode, the SS pin is controlled by the module, as a low level on this pin prepares the module for communication and connects the MISO pin to the bus (when SS is high, the MISO pin is in the high-Z state).
The MSSP module supports all four SPI modes but for some reason, instead of the familiar PHA and POL settings, it has different names of the corresponding bits and even different states, which we will talk about a bit later.
In this tutorial, we will use the MSSP module in the SPI Master mode.
The waveforms of the SPI signals in Master mode depending on the settings are shown in Figure 2 (taken from the PIC18F14K50 datasheet).
For some reason, even pins of the SPI module are named strangely (Microchip being Microchip?). And actually, even their functioning is a bit different. While in normal SPI, the pin MISO always stands for “Master In - Slave Out”, so in Master mode, this pin receives data from the Slave device. When in Slave mode, this pin outputs data to the Master. And when you want two devices to communicate you need to connect the eponymous pins - MISO to MISO, MOSI to MOSI, and SCK to SCK.
Here the pins are called SDI (Serial Data In), SDO (Serial Data Out), and SCK (Serial Clock). So the SDI pin in all modes is data input, and SDO in all modes is data output. And if you want to connect two PIC microcontrollers together, you need to cross connect the SDI and SDO pins, like Rx and Tx pins in the UART interface.
The data sheet says the following important things about the SPI pins:
- SDO must have the corresponding TRIS bit cleared.
- SDI is automatically controlled by the SPI module.
- SCK (Master mode) must have the corresponding TRIS bit cleared.
- SCK (Slave mode) must have the corresponding TRIS bit set.
- SS must have the corresponding TRIS bit set.
The line “SDI is automatically controlled by the SPI module” cost me two days of thorough checking and rechecking why my MCU refuses to receive any data even though they are present on the SDI pin. So this statement is only half true, and I will reveal to you the second half.
SDI pin is merged with the RB4 pin, which also can act as the ADC input AN10.
So for proper operation, you must disable the analog buffer for this pin by clearing the ANS10 bit of the ANSELH register, otherwise your MCU will not receive any data
(I’m not sure who to reach out to at Microchip to ask for this to be clarified in the documentation). For sure, with the MCC configurator you can do this by just clearing the corresponding checkbox in the pins configuration (which I will note again when we come to the configuration part of this tutorial), not even knowing anything about these bits and registers.
And, I believe this is enough information about the MSSP module in the SPI mode. If you want to know more, please don’t hesitate to refer to Chapter 15.2 of the PIC18F14K50 data sheet.
Now let’s briefly consider the EEPROM chip and the LCD we are going to use in this project.
AT25010 EEPROM chip
The only reason to use this memory chip is that I wanted to put something which can demonstrate the SPI bus with the Universal board, and this chip was the cheapest and the most universal solution as you can both write into it and read from it. Actually, I2C EEPROM chips are cheaper and more widespread but again, we already have two I2C devices on the board, so I needed something different even though it’s a bit more expensive.
The AT25010 chip is an external EEPROM which consists of 1 kbit of non-volatile memory with an endurance of at least 1 million write cycles and 100 year data retention. 1 kbit is just 128 bytes (note the *bit* instead of *byte* for 1 kbit) which is not that much. Indeed, the PIC18F14K50 MCU has an inbuilt EEPROM memory with a volume of 256 bytes but we will consider it later in some of the next tutorials.
This memory chip can work with a supply voltage from 2.7 to 5.5V, and have the clock rate up to 3 MHz. It supports SPI modes 0 and 3. Also, it supports the protection of the memory writing using a dedicated pin or a special command.
This chip is produced in the PDIP-8 or SOIC-8 packages. The pinout of the chip is shown in Figure 3 (taken from the datasheet).
The functions of the pins are as follows:
- CS is the Chip Select pin, which in terms of SPI bus is the slave select pin (SS).
- SO is the Serial Output pin, which acts as MISO pin of the SPI bus.
- WP is the Write Protection pin. It should be held high when you want to write something into memory. If it’s low, the writing operation is prohibited.
- GND is the 0V power pin.
- SI is the Serial Input pin, which acts as MOSI pin of the SPI bus.
- SCK is the Clock pin, which finally matches the standard SPI terms.
- HOLD pin is used in conjunction with the CS pin to select the AT25010. When the device is selected and a serial sequence is underway, HOLD can be used to pause the serial communication with the master device without resetting the serial sequence. To pause, the HOLD pin must be brought low while the SCK pin is low. To resume serial communication, the HOLD pin is brought high while the SCK pin is low (SCK may still toggle during HOLD). Inputs to the SI pin will be ignored while the SO pin is in the high impedance state.
- VCC is the supply voltage power pin.
When you communicate with the AT25010 chip, you need to let it know what you want to do. For this there are so-called instructions. An instruction is the byte which is sent to the chip first after the CS pin goes low. Let’s consider which instructions are available (table 2).
Table 2 - AT25010 Instruction
Instruction name | Instruction format | Description |
WREN | 0000 X110 | Write Enable. After the device power-up, the write is disabled so you need to implement this instruction prior to any writing operations. |
WRDI | 0000 X100 | Write Disable. Implementation of this instruction will disable all writing operations, so you can’t accidentally change the content of the memory. This instruction works independently from the WP pin. |
RDSR | 0000 X101 | Read Status Register. The format of this register will be considered later. |
WRSR | 0000 X001 | Write Status Register. |
READ | 0000 A011 | Read data from the memory. |
WRITE | 0000 A010 | Write data to the memory. |
‘X’ in the commands means “doesn’t matter”, so this bit can be either ‘0’ or ‘1’.
‘A’ stands for the “address”. This bit is the higher bit of the address of the device which has more than 256 bytes of memory. In our case this bit should be set as ‘0’.
The format of the Status Register is the following:
- bits #7 - #4 are not used.
- bits #3 and #2 - BP1 and BP0 (Block write Protect). These bits allow the protection of some part of the memory from further writing, and thus this part becomes read-only. The correspondence between these bits and the protected addresses is shown in Table 3.
Table 3 - Block Write Protect Bits
Level | Status Register Bits | Memory Addresses Protected | |
BP1 | BP0 | ||
0 | 0 | 0 | None |
1 (¼) | 0 | 1 | 0x60-0x7F |
2 (½) | 1 | 0 | 0x40-0x7F |
3 (All) | 1 | 1 | 0x00-0x7F |
- bit #1 - WEN (Write Enable). This bit indicates the state of the write enable. If it’s ‘1’ then the writing to the memory is enabled, and if it’s ‘0’ the writing is disabled.
- bit #0 - RDY (Ready). This bit indicates if the device is ready (RDY = ‘0’) or if the internal write cycle is in progress (RDY = ‘1’).
We will use this register to check if the device is ready for the read or write operations.
So the basic write sequence will be the following:
- Check if the device is ready (RDY = ‘0’);
- Send the WREN command to enable writing;
- Write some data using the WRITE command;
- Send the WRDI command to protect the device from the unintended writes.
The basic read sequence will be the following:
- Check if the device is ready;
- Read the data using the READ command.
And that’s everything we need to know to start working with the AT25010 chip. Let’s now consider the Nokia5110 LCD.
How to Interface with the Nokia5110 LCD
Like the OLED display we considered in the previous tutorial, this LCD is also a graphic display though its resolution is a bit higher - 84 x 48 pixels.
The Nokia5110 LCD uses the PCD8544 driver about which you can read in the corresponding datasheet. This driver is somewhat simpler than the SSD1306 used in the OLED display but still its full description might take some time.
The PCD8544 driver uses the SPI interface to communicate with the MCU. The communication is in one direction - from MCU to the driver, as it doesn't even have the serial output pin.
In this tutorial I will use the same approach as with SSD1306 - provide you the library to work with it, and if you want to know why it is done in this or that way, please refer to the datasheet.
Actually, here we will use the same library as we used in the previous tutorial for the OLED display, just change the interface functions and the initialization part. Everything else will work just fine, and this is a big advantage of this library.
So in this tutorial, like in the previous one, I will provide you the source files of this library, explain its main functions, and show the parts of the code which directly work with the hardware of the MCU.
Now, let’s briefly consider the LCD module and its pinout (Figure 1). As I said, the layout of the pinouts of different modules can be different but all of them have the following pins:
- VCC and GND to which you should apply the supply voltage. In different sources there is different information about the level of this voltage but 3.3V is found everywhere so it’s safe to use it.
- BL is the backlight positive voltage pin. If you want the backlight to be turned on, apply 3.3V to this pin, otherwise you may leave it unconnected.
- RST is the reset pin which resets the internal logic of the LCD driver. The short negative pulse should be applied to this pin after power up to initialize the display’s driver properly.
- CE is the Chip Enable pin, which plays the role of the SS pin of the SPI interface.
- DIN is the Data Input pin, which is a MOSI pin of the SPI interface.
- CLK is the Clock pin, which is an SCK pin of the SPI interface.
- DC is the Data/Command pin in addition to the SPI pins. When this pin is low, the data which comes in via the SPI interface is treated as a Command, and if it’s high the data is considered as Data to display.
I will provide more information about the display work when we consider the code of the library, and for now let’s consider the schematic diagram of the device.
Schematic Diagram of our Temperature Datalogger
Schematic diagram with the regular parts is presented in Figure 4 and with the Universal board in Figure 5.
So, this schematic diagram consists of the PIC18F14K50 MCU (DD1), the DS18B20 temperature sensor (DD2) with its pull-up resistor R1, AT25010 chip (DD3), Nokia5110 LCD (X2) and three buttons (S1 - S3) which we will use to navigate the device menu. I specifically didn’t put the pin numbers of the LCD module because, as I mentioned earlier, there can be different pinouts of it.
The DS18B20 sensor will be used to measure the temperature to be logged. We are already familiar with it from Tutorial 12, so I will not stop on its description here. We even connected it to the same pin as in Tutorial 12, so this part of the program code will be also transferred without any changes.
As for the AT25010 chip, we will not use the hardware write protection and hold features, so we tie the WP and HOLD pins to the VCC. Speaking of which, in this circuit, the LCD’s logic is 3.3V, so we need to supply the whole schematic with 3.3V. All other parts can work normally with this voltage as well.
The SCK, SI, and SO pins of the memory chip are connected to the SCK (RB6), SDO (RC7), and SDI (RB4) pins of the MCU, respectively. These MCU pins are shared with the MSSP module in the SPI mode. As for the CS pin of the AT25010 chip, it’s connected to the RC3 pin which will be used as a regular GPIO.
As the LCD is connected via the SPI interface as well and it only receives the data from the MCU, we need to connect the display’s pins DIN and CLK to the same SDO and SCK pins of the MCU to which the memory chip is connected. It’s normal practice to do this because, unless the device is selected by pulling its CS pin low, it doesn’t listen to the SPI bus, and its MISO pin (or its analog) is in the high-Z state, so it doesn’t affect the bus. RST, CE, and DC pins of the LCD can be connected to any free pin of the MCU as they will be used as regular GPIOs. In our case, the RC0, RC2, and RC1 pins are used, respectively. The VCC and the BL pins of the LCD are connected to the VCC powering the LCD itself and the backlight. If you don’t want to use the backlight for some reason, you can leave the BL pin unconnected, as it will not affect the display operation.
Buttons S1-S3 are connected to the pins of ports RA and RB because we can enable internal pull-up resistors on them.
Now, let’s consider the schematics diagram based on the Universal board (Figure 5). If you don’t have one and are not going to get it, you may skip this section.
In this schematic, apart from the MCU DD1, the debugger X1 and the Universal board J2, J6 and J7 (positions are taken from the board itself), we have only the Nokia5110 LCD (X3) and a couple of resistors R1 and R2. Each part on the board is connected to these two 80-pin connectors, and marked correspondingly.
The DS18B20 chip requires only one pin to connect to the MCU and it is marked as “DS18B20”. This is the DQ pin of the chip. The pull-up resistor is already mounted on the board. The chip is internally powered with 5V.
As our MCU is powered with 3.3V, one may ask if there will be any problem with the different supply voltage. Well, with the DS18B20 - no. The 1-wire bus uses open-drain outputs and a logical ‘1’ is formed with the pull-up resistor. So even if it pulls the voltage level to 5V, the spare voltage will drop on this resistor, not damaging the low-voltage MCU.
The AT25010 chip, which is also mounted on the board, has the pins “SPI MISO”, “SPI MOSI”, “SPI CS” and “SPI SCK” on the J7 connector. This chip is also internally powered with 5V. And there the problem may appear with the MISO pin. With the other pins there will be no problem, as 3.3V which comes from the MCU will be recognized as logical ‘1’ even if the chip voltage is 5V. But when the signal of 5V goes from the memory chip to the MCU, which is powered with 3.3V, it’s better to lower the voltage of this signal to reduce the load of the protection diode on the MCU pin RB4. A good solution for this scenario, in general, is to use a voltage shifter but in our particular situation, a regular voltage divider R1-R2 will be enough.
It divides the voltage that comes from the SO pin of the AT25010 chip, so that the voltage on the SDI pin of the MCU is 5V * 2 kOhm / (2 kOhm + 1 kOhm) = 3.33 V.
The Nokia5110 LCD connector is inserted into the Arduino socket (J2 connector). All of its pins are not connected internally, so you can use them as you need. The appearance of the board with the inserted LCD is shown in Figure 6.
As you may notice, the LCD is upside down, but we will fix this with the software.
The buttons used in this project are marked with a red rectangle in Figure 6. The S4 and S6 buttons are used to navigate the menu and the temperature graph, and the S5 button is used as Enter/Select/Return, or simply, as an action button. Don’t forget to connect the second end of the buttons to the ground according to Figure 5.
And that’s everything about the device schematic diagram. Again, it’s up to you which diagram to use depending on the resources you have.
Configuration of the datalogger project using MCC
Let’s create a new project called “Temp_logger_MCC” (as usual, you can call it whatever you want).
Here we will need to add and configure the Timer1 module which will be used to produce 100 ms intervals, and the MSSP module configured in SPI mode. Even though I told you in the previous tutorial that we will use the special MCC services to work with the digital interfaces, we will skip the one for the SPI mode. It’s really, really raw and moreover, I couldn’t run the program with it as it always gave some error. I hope this problem will be fixed in the new MCC versions but for now we will use the regular SPI peripheral driver which is quite simple itself.
Let’s run the MCC plugin, change the MCU package, open the System Module page, and set the Internal Clock as 16MHz_HF. Please note that in this program we will use the 16 MHz MCU frequency, not 32 MHz as usual. This is done to be able to produce 100 ms intervals by the timer, as with the higher frequency even with the maximum prescaler value we can’t reach such a low timer period. Also (as usual), don’t forget to remove the check from the “Low-voltage programming enable” field (Figure 7).
Then we need to go to the Device resources tab on the left part of the screen, expand the drop-down list “MSSP", and click on the green plus at “MSSP1” the same as we did in the previous tutorial (Figure 8).
Also, we need to expand the “Timer” list and add the “TMR1” component.
After adding all the parts, your Resource Management window should look like in Figure 9.
Now, let’s open the MSSP1 window and configure the corresponding module according to Figure 10.
Actually, there is only one option we need to change. But let’s briefly consider all of them as they differ from the I2C mode:
- “Interrupt Driven” allows us to perform SPI communication based on the interrupts which are called after each byte exchange completes. As our program code will work consecutively, we don’t need this option but if you want you can freely check this box. Anyway, the operation will be hidden inside the API functions.
- “Serial Protocol”. As I mentioned, the MSSP module supports both I2C and SPI protocols. Here we need to use the SPI protocol, so if I2C is selected by default, please change it to SPI.
- “Mode” selects the operation mode of the module: “Master” or “Slave”. Depending on this and the previous parameters the rest of the options will differ. We will consider only the “Master” mode.
- “SPI Mode”. As I already mentioned, there are four SPI modes (see Figure 2) depending on the idle signal level and the sampling SCK front. When you connect some device via the SPI bus you need to check in its datasheet which SPI mode it supports. In our case, both the EEPROM chip and the LCD support the “SPI Mode 0”, thus we choose this option.
- “Input Data Sampled At” selects whether the input data will be sampled at the middle of the bit interval or at the end of it (see Figure 2). For SPI Mode 0 we need to select the “Middle” option.
- “Clock Source Selection” selects the SCK source and thus its frequency. Your options for setting the SCK frequency are quite limited. You can only divide the main frequency by 4, 16, or 64. If you need to set the frequency more precisely, you need to use Timer2. Here, we use the option Fosc/64 which gives us the SCK frequency as 16 MHz / 64 = 250 kHz (you can see this value underneath this option in Figure 10). We could use the higher frequency as both EEPROM and LCD support them but we’re not in rush, so 250 kHz is fine.
- “Enable SPI Interrupt”. You should set this parameter if you have set the “Interrupt Driven” option, or if you want to generate the interrupts from the SPI events.
As you can see, despite the complexity of the MSSP module, the number of available parameters is quite limited, and configuration is quite easy.
Now, let’s open and configure the “TMR1” driver (Figure 11).
This timer has been considered in detail in Tutorial 15. Here, we need the timer to generate the interrupts each 100 ms. To achieve such a big period, we need to set the biggest prescaler of 1:8, then we will be able to set the Timer period of 100 ms. Also, we need to enable the timer interrupt and set the callback function rate as 1 (see Figure 11).
If you read both non-MCC and MCC-based tutorials, you may notice that in Tutorial 36 we used the ECCP module in the special compare mode to generate the interrupts. Unfortunately, this mode is not available in the MCC configurator, and as we decided to try to use only the MCC wherever it’s possible, in this tutorial we will use just Timer1 itself.
Now, let’s switch to the Pin Module and Pin Manager and configure all required pins according to the schematic diagram shown in Figure 4 or 5 (Figure 12).
As you can see, pins RB4, RB6, and RC7 are used by the MSSP1 module and represent the SDI, SCK, and SDO pins of the SPI bus, respectively. Buttons S1 (ENTER), S2 (DOWN) and S3 (UP) are connected to pins RA5, RA4, and RB5 respectively. We need to configure these pins as inputs with the pull-up resistors, as we don’t use the external ones. Also, don’t forget to disable the analog buffers on these pins by unchecking the corresponding checkbox in the Analog column. Pin RB7 is connected to the DQ pin of the DS18B20 chip, and operates as a 1-wire IO. Initially, it’s configured as an input without a pull-up resistor to leave the bus idle. LCD pins RST, DC and CE (RC0, RC1, and RC2, respectively) and the EEPROM CS pin (RC3) are all configured as outputs. Additionally, the LCD_CE and EEPROM_CS pins should be high at startup so none of the slave devices is selected initially.
Don’t forget to clear the checkbox at the SDI1 pin in the Analog column, otherwise the SPI receiver will not work. Actually, it’s good to disable the analog buffers on all pins that are not used as an ADC or analog comparator input.
Also, don’t forget to enable the pull-up resistors for ports A and B (bit nRABPU of the INTCON2 register) (Figure 13). If you don’t do that, the MCC will show the corresponding warning.
Finally, let’s switch to the Interrupt Module tab and make sure that the TMR1 interrupt is enabled (Figure 14).
There is no need to enable the priorities of the interrupts as only one of them is active.
Now everything is configured, and we can click the “Generate” button and switch to writing code.
Program code description for our Temperature Datalogger
In this project, the same as in the previous one, we need to copy the display library files provided at the beginning and end of this tutorial which consist of the following:
- “Nokia_5110_MCC.h” - the header file which consists of the headers of the display-related functions.
- “Nokia_5110_MCC.c” - the source file with the implementation of the display-related functions. Here, we will make some changes to work with the PIC’s MSSP module.
- “glcdfont.c” contains the array in which there is a 5x7 pixels font generator for all the ASCII characters from 0 to 255.
The first two files are very similar to the “Adafruit_SSD1306_MCC.h” and ”Adafruit_SSD1306_MCC.c” files used in the previous tutorial. At least the functions and their meaning will be the same. The only difference in the names is that the prefix of the functions has been changed from “ssd1306_” to “nokia5110_” to indicate that they are used with another display.
The “glcdfont.c” file is used without any changes at all.
After all of this, your project should look like Figure 15.
Let’s start considering the program with the LCD files. As I mentioned, we will consider just the part of them which we will need to change, and which we will use in our program. But feel free to discover these files by yourself if you are curious how they work. The content of the “Nokia_5110_MCC.h” file is listed below:
#define BLACK 0
#define WHITE 1
#define NOKIA5110_LCDWIDTH 84
#define NOKIA5110_LCDHEIGHT 48
extern int8_t nokia5110_getCursorY(void);
extern int8_t nokia5110_getCursorX(void);
extern void nokia5110_init(void);
extern void nokia5110_begin(void);
extern void nokia5110_command(uint8_t c);
extern void nokia5110_data(uint8_t c);
extern void nokia5110_clearDisplay(void);
extern void nokia5110_display(void);
extern uint8_t nokia5110_getRotation(void);
extern void
nokia5110_setCursor(uint8_t x, uint8_t y),
nokia5110_setTextColor(uint8_t c),
nokia5110_setTextBgColor(uint8_t c, uint8_t bg),
nokia5110_setTextSize(uint8_t s),
nokia5110_setTextWrap(uint8_t w),
nokia5110_setRotation(uint8_t r),
nokia5110_drawPixel(int8_t x, int8_t y, uint8_t color),
nokia5110_drawLine(int8_t x0, int8_t y0, int8_t x1, int8_t y1, uint8_t color),
nokia5110_fillRect(int8_t x, int8_t y, int8_t w, int8_t h, uint8_t color),
nokia5110_drawFastVLine(int8_t x, int8_t y, int8_t h, uint8_t color),
nokia5110_drawFastHLine(int8_t x, int8_t y, int8_t w, uint8_t color),
nokia5110_drawRect(int8_t x, int8_t y, int8_t w, int8_t h, uint8_t color),
nokia5110_write(const char *c),
nokia5110_write_number(uint8_t num),
nokia5110_drawChar(int8_t x, int8_t y, unsigned char c, uint8_t color,uint8_t bg, uint8_t size),
nokia5110_drawBitmap(int8_t x, int8_t y, uint8_t *bitmap, int8_t w, int8_t h, uint8_t color),
nokia5110_fillScreen(uint8_t color),
nokia5110_drawCircle(int8_t x0, int8_t y0, int8_t r, uint8_t color),
nokia5110_drawCircleHelper(int8_t x0, int8_t y0, int8_t r, uint8_t cornername, uint8_t color),
nokia5110_fillCircle(int8_t x0, int8_t y0, int8_t r, uint8_t color),
nokia5110_fillCircleHelper(int8_t x0, int8_t y0, int8_t r, uint8_t cornername, int8_t delta, uint8_t color),
nokia5110_drawTriangle(int8_t x0, int8_t y0, int8_t x1, int8_t y1, int8_t x2, int8_t y2, uint8_t color),
nokia5110_fillTriangle(int8_t x0, int8_t y0, int8_t x1, int8_t y1, int8_t x2, int8_t y2, uint8_t color),
nokia5110_drawRoundRect(int8_t x0, int8_t y0, int8_t w, int8_t h, int8_t radius, uint8_t color),
nokia5110_fillRoundRect(int8_t x0, int8_t y0, int8_t w, int8_t h, int8_t radius, uint8_t color);
As I said, this file is very similar to the “Adafruit_SSD1306_MCC.h” from the previous tutorial, so here I will just mention the differences, and for more information please refer to Tutorial 35.
The first thing that catches your eye is that the prefix of the functions has changed from ssd1306_ to nokia5110_. The second thing you may notice is that we replaced all 16-bit variables with 8-bit ones. This is done, first, because the coordinates can fit into one byte anyway, and second, because we are constrained with our RAM in this project.
In lines 23 and 24 we set the resolution of the LCD which is 84 x 48 pixels. Thus, to store the display buffer we need 84 * 48 / 8 = 504 bytes. The RAM volume of the PIC18F14K50 MCU is just 768 bytes, so there are just 264 bytes left for all other uses. And we will still need some buffer to store the temperature measurements, and also we will have some global and local variables. Looking ahead, the total RAM consumption of this project is 727 bytes, or 95%, so saving some memory by reducing the variables’ size sounds like a good idea.
In our project, we will show just the numbers at times, so it would be good to have a lite function that parses the number and sends it to the LCD. That’s why the only new function has been added to the library called nokia5110_write_number. This is a very specific function which displays one- two- or three-digit numbers on the LCD, which is very convenient to show the graph legend.
The description of the display functions (lines 42-80) can be found in Tutorial 35, as I already mentioned, so I’ll skip it here and switch to the “Nokia5110_MCC.c” file:
#include <stdlib.h>
#include <string.h>
#include "glcdfont.c"
#include "mcc_generated_files/mcc.h"
#include "Nokia_5110_MCC.h"
void nokia5110_command (uint8_t c)
{
LCD_DC_SetLow(); //Set DC pin low to send the command
LCD_CE_SetLow(); //Set the CE pin low to select the LCD
SPI1_ExchangeByte(c); //Send the command
LCD_CE_SetHigh(); //Set the CE pin high to end the transmission
}
void nokia5110_data (uint8_t c)
{
LCD_DC_SetHigh(); //Set DC pin high to send the data
LCD_CE_SetLow(); //Set the CE pin low to select the LCD
SPI1_ExchangeByte(c); //Send the command
LCD_CE_SetHigh(); //Set the CE pin high to end the transmission
}
void nokia5110_begin(void)
{
LCD_RST_SetLow(); //Set RST pin low to initialize the LCD's logic
LCD_CE_SetHigh(); //Set the CE pin high to deselect the LCD
__delay_ms(1); //Delay for 1 ms for the Reset pulse
LCD_RST_SetHigh(); //End the Reset pulse and return to the working mode
nokia5110_command (0x21); //Extended command set
nokia5110_command (0x06); //Set temperature coefficient
nokia5110_command (0x13); //Set bias system
nokia5110_command (0xF8); //Set contrast
nokia5110_command (0x20); //Standard command set
nokia5110_command (0x0C); //Normal mode
}
void nokia5110_write_number(uint8_t num) {
if (num >= 100)
{
nokia5110_drawChar(nokia5110_cursor_x, nokia5110_cursor_y, num / 100 + 0x30, nokia5110_textcolor, nokia5110_textbgcolor, nokia5110_textsize);
nokia5110_cursor_x += nokia5110_textsize*6;
}
if (num >= 10)
{
nokia5110_drawChar(nokia5110_cursor_x, nokia5110_cursor_y, (num % 100) / 10 + 0x30, nokia5110_textcolor, nokia5110_textbgcolor, nokia5110_textsize);
nokia5110_cursor_x += nokia5110_textsize*6;
}
nokia5110_drawChar(nokia5110_cursor_x, nokia5110_cursor_y, num % 10 + 0x30, nokia5110_textcolor, nokia5110_textbgcolor, nokia5110_textsize);
nokia5110_cursor_x += nokia5110_textsize*6;
}
void nokia5110_display(void) {
uint8_t y = 0;
for (uint8_t i = 0; i < NOKIA5110_LCDHEIGHT/8; i ++)
{
nokia5110_command(0x80); //Set the LCD column as 0
nokia5110_command(0x40 + y); //Set the LCD row
for (uint8_t j = 0; j < NOKIA5110_LCDWIDTH; j ++) //Send the whole row
{
nokia5110_data(nokia5110_buffer[i * NOKIA5110_LCDWIDTH + j]);
}
y ++; //Increment the row
}
}
First, we include the required header files (lines 18-22).
In lines 38-44 and in lines 46-52, there are nokia5110_command, and nokia5110_data functions, respectively. Like for the SSD1306 driver, they are very similar. The only difference between them is that in the nokia5110_command function we set the DC pin of the LCD low (line 40), letting it know that the following byte should be treated as a command, and in the nokia5110_data function the DC pins is set high (line 48), indicating that the following byte is data. Everything else is the same.
First we pull the CE pin down (line 41 or 49), then we send one byte via the SPI interface using the MCC generated function SPI1_ExchangeByte (line 42 or 50), and finally pull the CE pin up to deselect the LCD (line 43 or 51). The SPI1_ExchangeByte function both sends the byte to the SPI bus and receives it from the SPI bus simultaneously. The byte to send is transferred to the function as its single argument, and the received byte is the value returned by the function. In lines 42 and 50 we don’t expect to receive any bytes, so we don’t assign the returned value to any variable.
The nokia5110_begin function (lines 54-66) is much shorter than the corresponding function of the SSD1306 driver, as the PCD8544 driver is much simpler.
At the beginning of this function we pull the RTS pin down to start the reset pulse (line 56). Then we set the CE pin high to deselect the LCD, and disconnect it from the SPI bus (line 57). Next, we perform the 1 ms delay (line 58) after which we end the reset pulse and pull the RST pin high (line 59). After that, the hardware initialization is completed. Then we need to send a bunch of commands to initialize the logic of the LCD driver (lines 60-65). For more information about them please refer to the PCD8544 data sheet.
The new function nokia5110_write_number is located in lines 297-310. It displays a number with up to three digits in it. If the upper digits are 0, they are not displayed. So first we check if the number is greater or equal to 100 (line 298), which means that we have the third digit. If so, we display a single character using the nokia5110_drawChar function (line 300). The character that is shown is num / 100 + 0x30.
The “num / 100” represents the hundreds of the number, and adding the 0x30 converts the number into the corresponding ASCII character.
Then we shift the cursor by 6 positions to the right as each character has the width of 6 (line 301).
Then we do the same with the tens: check if the number is greater or equal than 10 (line 303), show the character which corresponds to tens (line 305) which is (num % 100) / 10 + 0x30 and shift the cursor (line 306).
Finally we show the character that corresponds to units (line 308) which is num % 10 + 0x30 and shift the cursor (line 309) in case we want to write something right after this number.
We also need to change the nokia5110_display functions which send the display buffer from the MCU to the LCD itself. Like with SSD1306, the buffer is organized as a set of bytes, each bit of which represents the state of some pixel on the display. If this bit is 1, then the pixel is turned on, otherwise it’s turned off. These bytes are sent to the display row-wise. So you first fill the first eight rows (each byte holds the information about the eight bits), then you fill the next eight rows and so on and so forth.
We first start the loop to send all the rows. As the height of the display is NOKIA5110_LCDHEIGHT pixels (which is 48) in our case, and the buffer is organized bytewise, we have the NOKIA5110_LCDHEIGHT/8 actual rows to send (line 314). First we send the 0x80 command which sets the LCD column as 0 to start from the beginning of the row (line 316). Then we send the 0x40 command with the addition of the row number (line 317). After that we send NOKIA5110_LCDWIDTH (which is 84) bytes of the display buffer (lines 318-321) which represent the whole row. Please pay attention
in line 320 we use the nokia5110_data function as we are sending the data here.
In line 322, we increment the row number which is used in line 317. And that’s actually everything we need to change in the library. The rest of the functions remain the same.
And now let’s finally consider the “main.c” file in which the main logic of our program is implemented.
#include "mcc_generated_files/mcc.h"
#include "Nokia_5110_MCC.h"
#include <stdio.h>
uint8_t temp; //Temperature received from the DS18B20 sensor
uint8_t data[2]; //Array to read two bytes from the scratchpad
uint16_t tick = 0; //Number of timer ticks (every 100 ms)
const char menu[7][15] = {"Start record ","Resume record ","Show results ","Clear results ","Back to menu ","Clear complete","Memory is full"};
int8_t menu_pos = 0; //Selected menu point
uint8_t menu_mode = 1; //If it is 1 then menu is shown, otherwise the graph is shown
uint8_t addr_write; //EEPROM address to write
uint8_t addr_read; //EEPROM address to read
uint8_t temp_ready; //Indicates that the temperature has been measured
uint8_t temp_array[70]; //Array of temperature to show on LCD
uint8_t temp_max, temp_min; //Max and min values of the temperature;
//=============1-wire functions======================
//---------1-wire bus reset-------------------------------
uint8_t ow_reset(void)
{
uint8_t presence; //Presence flag
OW_BUS_SetDigitalOutput();//Configure OW_BUS as output (set bus low)
__delay_us(480); //Set bus low for 480 us
OW_BUS_SetDigitalInput();//Configure OW_BUS as input (set bus high)
__delay_us(70); //Waiting for the presence signal for 70us
if(OW_BUS_GetValue() == 0) //If bus is low then the device is present
presence = 1; //Set presence flag high
else //Otherwise
presence = 0; //Set presence flag low
__delay_us(410); //And wait for 410 us
return (presence); //Return the presence flag
}
//---------Writing one bit to the device------------------------
void ow_write_bit (uint8_t bit)
{
OW_BUS_SetDigitalOutput();//Configure OW_BUS as output (set bus low)
if (bit) //If we send 1
{
__delay_us(6); //Perform 6 us delay
OW_BUS_SetDigitalInput();//Configure OW_BUS as input (set bus high)
__delay_us(64); //Perform 64 us delay
}
else //If we send 0
{
__delay_us(60); //Perform 60 us delay
OW_BUS_SetDigitalInput();//Configure OW_BUS as input (set bus high)
__delay_us(10); //Perform 10 us delay
}
}
//--------Reading one bit from the device-------------------------
uint8_t ow_read_bit (void)
{
uint8_t bit; //Value to be returned
OW_BUS_SetDigitalOutput();//Configure OW_BUS as output (set bus low)
__delay_us(6); //Perform 6 us delay
OW_BUS_SetDigitalInput();//Configure OW_BUS as input (set bus high)
__delay_us(9); //Perform 9 us delay
bit = OW_BUS_GetValue();//Save the state of the bus in the bit variable
__delay_us(55); //Perform 55 us delay
return (bit);
}
//---------Writing one byte to the device------------------------
void ow_write_byte (uint8_t byte)
{
for (uint8_t i = 0; i < 8; i++) //Loop to transmit all bits LSB first
{
ow_write_bit (byte & 0x01);//Send the bit
byte >>= 1; //Shift the byte at one bit to the right
}
}
//---------Reading one byte from the device-----------------------
uint8_t ow_read_byte (void)
{
uint8_t byte = 0; //Value to be returned
for (uint8_t i = 0; i< 8; i++)//Loop to read the whole byte
{
byte >>= 1; //Shift the byte at one bit to the right
if (ow_read_bit()) //If 1 has been read
byte |= 0x80; //Then add it to the byte
}
return (byte);
}
//============DS18B20 sensor functions=============================
//-----------Command to start the temperature conversion-----------
void convert_t (void)
{
if (ow_reset()) //If sensor is detected
{
ow_write_byte(0xCC);//Issue "Skip ROM" command
ow_write_byte(0x44);//Issue "Convert T" command
}
}
//---Reading and calculating the temperature from the ds18s20 sensor-------
uint8_t read_temp (void)
{
int16_t tmp; //Calculated temperature
if (ow_reset()) //If sensor is detected
{
ow_write_byte (0xCC);//Issue "Skip ROM" command
ow_write_byte (0xBE);//Issue "Read Scratchpad" command
for (uint8_t i = 0; i < 2 ; i++)//Loop for reading 2 bytes from scratchpad
data[i] = ow_read_byte();//Writing the received byte into the data array
tmp = data[0] + (data[1] << 8);//Calculating the temperature
tmp /= 16; //Convert positive temperature into C
}
else //If sensor was not detected
tmp = 254; //Then return the value that sensor can't have
return ((int8_t)tmp); //And return the temperature value
}
//-------------------------------------------------------------------------
void timer1_callback (void) //Interrupt subroutine
{
if (tick == 590) //If tick is 590 (every minute minus one second)
convert_t(); //Start temperature conversion
if (tick == 600) //In 1 second after the conversion start
{
temp = read_temp(); //Read the temperature value
tick = 0; //Clear the tick value
temp_ready = 1; //Set the temp_ready flag
}
tick ++; //Increment the tick number
}
//-------------------------------------------------------------------------
void write_enable_eeprom (void)
{
EEPROM_CS_SetLow(); //Set CS pin low
SPI1_ExchangeByte(0x06); //Write enable command;
EEPROM_CS_SetHigh(); //Set CS pin high
}
void write_disable_eeprom (void)
{
EEPROM_CS_SetLow(); //Set CS pin low
SPI1_ExchangeByte(0x04); //Write disable command;
EEPROM_CS_SetHigh(); //Set CS pin high
}
uint8_t check_busy_eeprom (void)
{
uint8_t status = 0xFF;
EEPROM_CS_SetLow(); //Set CS pin low
SPI1_ExchangeByte(0x05); //Read status register command;
status = SPI1_ExchangeByte(0x00); //Read the register
EEPROM_CS_SetHigh(); //Set CS pin high
return status & 0x01; //Return the state of the RDY bit
}
void write_eeprom (uint8_t addr, uint8_t data)
{
while (check_busy_eeprom()); //Check while EEPROM is busy
write_enable_eeprom(); //Enable writing to EEPROM
EEPROM_CS_SetLow(); //Set CS pin low
SPI1_ExchangeByte(0x02); //Write data to memory command;
SPI1_ExchangeByte(addr); //Send the address
SPI1_ExchangeByte(data); //Send the data
EEPROM_CS_SetHigh(); //Set CS pin high
write_disable_eeprom(); //Disable writing to EEPROM
}
void read_eeprom (uint8_t addr, uint8_t *data, uint8_t len)
{
while (check_busy_eeprom()); //Check while EEPROM is busy
EEPROM_CS_SetLow(); //Set CS pin low
SPI1_ExchangeByte(0x03); //Read data from memory command;
SPI1_ExchangeByte(addr); //Send the address
for (uint8_t i = 0; i < len; i ++)
data[i] = SPI1_ExchangeByte(0x00); //Read the data
EEPROM_CS_SetHigh(); //Set CS pin high
}
//-------------------------------------------------------------------------
void show_menu (void)
{
nokia5110_clearDisplay(); //Clear the display buffer
for (uint8_t i = 0; i < 4; i ++) //Show all 4 menu points
{
if (i == menu_pos) //If current point is selected
nokia5110_setTextBgColor(BLACK, WHITE); //Set background color white and text color black
else
nokia5110_setTextBgColor(WHITE, BLACK); //Set background color black and text color white
nokia5110_setCursor(0, i * 8); //Set the cursor position to the next line
nokia5110_write(menu[i]); //Write the menu point
}
nokia5110_display(); //Update the display
}
void show_graph (void)
{
temp_min = temp_array[0]; //Set temp_min as the first element of the temp_array
temp_max = temp_array[0]; //Set temp_min as the first element of the temp_array
uint8_t end_addr; //Indicates the end address in the array to be considered
if ((addr_write - addr_read) < 70) //If the difference between the start and end addresses is less than 70
end_addr = addr_write - addr_read; //Then we set the end_addr as their difference
else //Otherwise
end_addr = 70; //We set end_addr as 70
for (uint8_t i = 1; i < end_addr; i ++) //Loop to find the minimum and maximum temperatures in the array
{
if (temp_min > temp_array[i])
temp_min = temp_array[i];
if (temp_max < temp_array[i])
temp_max = temp_array[i];
}
nokia5110_fillRect(0, 0, 84, 48, BLACK);//Fill the black rectangle to clear the screen
nokia5110_setTextBgColor(WHITE, BLACK); //Set background color black and text color white
nokia5110_drawFastVLine(14, 0, 30, WHITE); //Draw the Y axis
nokia5110_drawFastHLine(14, 30, 70, WHITE); //Draw the X axis
for (uint8_t i = 0; i < 4; i ++) //Vertical grid and legend
{
for (uint8_t j = 0; j < 30; j += 3) //Draw dashed grid lines
{
if (i) nokia5110_drawPixel(i * 20 + 14, j, WHITE);//Grid lines
}
if (addr_read + i * 20 <= 120) //We show numbers only if they are less or equal than 120, as it's the max value
{
nokia5110_setCursor(i * 20 + 8, 32); //Numbers position
nokia5110_write_number(addr_read + i * 20); //write legend numbers
}
}
for (uint8_t i = 0; i < 3; i ++) //Horizontal grid and legend
{
for (uint8_t j = 14; j < 84; j += 3) //Draw dashed grid lines
if (i < 2) nokia5110_drawPixel(j, i * 15, WHITE); //Grid lines
}
if (temp_min == temp_max) //If minimum and maximum temperatures are the same
{
nokia5110_setCursor(0, 12); //We show only one temperature value
nokia5110_write_number(temp_min); //At the central grid line
}
else //Otherwise
{
nokia5110_setCursor(0, 0); //We show the max temperature
nokia5110_write_number(temp_max); //at the top grid line
nokia5110_setCursor(0, 24); //and the min temperature
nokia5110_write_number(temp_min); //at the bottom grid line
}
for (uint8_t i = 1; i < end_addr; i ++) //Draw the graph itself
{
if (temp_min == temp_max) //If temperature is all the same
nokia5110_drawLine(i + 13, 15, i + 14, 15, WHITE);//We just draw the straight line in the middle
else //Otherwise we connect the neighbor dots with lines
nokia5110_drawLine(i + 13, ((temp_max - temp_array[i - 1]) * 30) / (temp_max - temp_min),
i + 14, ((temp_max - temp_array[i]) * 30) / (temp_max - temp_min), WHITE);
}
nokia5110_setTextBgColor(BLACK, WHITE); //Set background color black and text color white
nokia5110_setCursor(0, 40); //Set cursor to the bottom line
nokia5110_write(menu[4]); //Write the "Stop record" text
nokia5110_display(); //Update the display
}
void main(void)
{
SYSTEM_Initialize(); // Initialize the device
TMR1_SetInterruptHandler(timer1_callback); //Set the timer1 callback function
SPI1_Open(SPI1_DEFAULT); //Initialize the SPI interface
INTERRUPT_GlobalInterruptEnable(); //Enable global interrupts
INTERRUPT_PeripheralInterruptEnable(); //Enable peripheral interrupts
nokia5110_init(); //Initialize the display variables
nokia5110_begin(); //Initialize the display itself
nokia5110_setTextColor(WHITE); //Set text color visible
nokia5110_setTextSize(1); //Set text size
nokia5110_setRotation(2); //Rotate the display by 180 degrees
nokia5110_clearDisplay(); //Clear the display buffer
show_menu();
while (1) //Main loop of the program
{
if (BUT_ENTER_GetValue() == 0) //If the Enter button is pressed
{
__delay_ms(20); //Then perform the debounce delay
if (BUT_ENTER_GetValue() == 0) //If after the delay button is still pressed
{
while (BUT_ENTER_GetValue() == 0); //Then wait while button is pressed
__delay_ms(20); //After button has been released, perform another delay
if (BUT_ENTER_GetValue() == 1) //If the button is released after the delay
{
if (menu_mode) //If we are in the menu mode
{
switch (menu_pos) //Then the action depends on the menu position
{
case 0: //START RECORD
menu_mode = 0; //Leave menu mode
addr_write = 0; //Reset the EEPROM write address
addr_read = 0; //Reset the EEPROM read address
temp_ready = 0; //Reset the temp_ready flag
TMR1_Reload(); //Clear Timer 1
tick = 590; //Implement the first measure immediately
INTERRUPT_GlobalInterruptEnable(); //Enable all unmasked interrupts
show_graph();
break;
case 1: //RESUME RECORD
menu_mode = 0; //Leave menu mode
temp_ready = 0; //Reset the temp_ready flag
read_eeprom (127, temp_array, 1);//Read the last recorded address
addr_write = temp_array[0];
TMR1_Reload(); //Clear Timer 1
tick = 590; //Implement the first measure immediately
INTERRUPT_GlobalInterruptEnable(); //Enable all unmasked interrupts
addr_read = 0; //Reset the EEPROM read address
while (addr_write - addr_read > 40) //We need to find the last address aliquote to 40
addr_read += 40; //from which to show the graph
read_eeprom (addr_read, temp_array, 70); //Read the data from EEPROM
show_graph();
break;
case 2: //SHOW RESULTS
menu_mode = 0; //Leave menu mode
addr_read = 0; //Reset the EEPROM read address
read_eeprom (127, temp_array, 1);//Read the last recorded address
addr_write = temp_array[0]; //And save it into the addr_write variable
read_eeprom (addr_read, temp_array, 70); //Read the data from EEPROM
show_graph();
break;
case 3: //CLEAR RESULTS
addr_write = 0; //Reset the EEPROM write address
write_eeprom (127, 0); //Save the EEPROM write address
nokia5110_setCursor(0, 40);//Set cursor to the bottom line
nokia5110_write(menu[5]); //Write the "Clear complete" text
nokia5110_display(); //Update the menu
__delay_ms(1000); //Delay for 1 second
show_menu(); //Show normal menu
break;
}
}
else //If we are in the graph display mode
{
menu_mode = 1; //Enter menu mode
INTERRUPT_GlobalInterruptDisable(); //Disable all unmasked interrupts
show_menu(); //Show normal menu
}
}
}
}
if (BUT_DOWN_GetValue() == 0) //If the Down button is pressed
{
__delay_ms(20); //Then perform the debounce delay
if (BUT_DOWN_GetValue() == 0) //If after the delay button is still pressed
{
while (BUT_DOWN_GetValue() == 0); //Then wait while button is pressed
__delay_ms(20); //After button has been released, perform another delay
if (BUT_DOWN_GetValue() == 1) //If the button is released after the delay
{
if (menu_mode) //In menu mode
{
if (menu_pos < 3) //If menu_pos is not the last one
menu_pos ++; //we increment menu_pos
show_menu(); //and show the menu
}
else //In graph mode
{
if (addr_write > (addr_read + 40)) //If there are more data to display to the right
{
addr_read += 40; //we increment the read_addr
read_eeprom (addr_read, temp_array, 70); //and read the data from EEPROM
show_graph(); //Then show the graph
}
}
}
}
}
if (BUT_UP_GetValue() == 0) //If the Up button is pressed
{
__delay_ms(20); //Then perform the debounce delay
if (BUT_UP_GetValue() == 0) //If after the delay button is still pressed
{
while (BUT_UP_GetValue() == 0); //Then wait while button is pressed
__delay_ms(20); //After button has been released, perform another delay
if (BUT_UP_GetValue() == 1) //If the button is released after the delay
{
if (menu_mode) //In menu mode
{
if (menu_pos > 0) //If menu_pos is not the first one
menu_pos --; //we decrement menu_pos
show_menu(); //and show the menu
}
else //In graph mode
{
if (addr_read > 0) //If there are more data to display to the left
{
addr_read -= 40; //we decrement the read_addr
read_eeprom (addr_read, temp_array, 70); //and read the data from EEPROM
show_graph(); //Then show the graph
}
}
}
}
}
if (temp_ready) //When new temperature is ready (every one minute)
{
temp_ready = 0; //We clear the temp_ready flag
if (addr_write >= 120) //If we reached the last memory cell
{
INTERRUPT_GlobalInterruptEnable(); //Disable all unmasked interrupts
nokia5110_setCursor(0, 40); //Set cursor to the bottom line
nokia5110_write(menu[6]); //Write the "Memory is full" text
nokia5110_display(); //Update the display
}
else //If there is still some free memory
{
write_eeprom(addr_write, temp); //Write the temperature into EEPROM
temp_array[addr_write - addr_read] = temp; //Write the temperature into the temp_array
addr_write ++; //Increment the address to write the next time
write_eeprom(127, addr_write);//Send the current address in the EEPROM
if (addr_write - addr_read > 70) //If the graph is full
{
addr_read += 20; //we increment the read_addr
read_eeprom (addr_read, temp_array, 70); //and read the data from EEPROM to show the next part of graph
}
show_graph(); //Show the graph
}
}
}
}
This program, even by our growing standards, is just enormous. And as much as I’d like to calm you down and say “Don’t worry, there are a lot of repeating parts, so it’s not that bad”, I can’t. I’m sorry but there are really few parts that are similar, so this is just a very big program.
In line 1, we, as usual, include the “mcc_generated_files/mcc.h” file to use the MCC-generated variables, functions, and macros. In line 2, we include the “Nokia_5110_MCC.h” file, which consists of the LCD-related functions we considered above. And in line 3, we include the “stdio.h” file which I just forgot to remove from the previous project. Unfortunately, here we can’t use the sprintf function because it requires some heap, and as you remember, we’re lacking RAM.
In lines 5-15 we declare some global variables:
- temp (line 5) represents the temperature received from the DS18B20 sensor. Note that the type of the variable is uint8_t so we will get only the integer temperature value. This is done because the EEPROM volume is not very large, and in order to store more individual numbers, we reduce their data size.
- data (line 6) is the array of two bytes which we read from the DS18B20 scratchpad.
- tick (line 7) is the number of timer ticks since the last MCU reset. This value will be incremented every 100 ms, and it will be used to distinguish the moments when we need to make another temperature measurement.
- menu (line 8) is a constant char array which consists of the menu item and some messages that are shown in the device. Marking this array as const puts its content into the Flash memory of the MCU, releasing some more RAM bytes. The first four elements “Start record”, “Resume record”, “Show results” and “Clear results” form the main menu of the device, and we will talk about it later. “Back to menu” message is shown when the temperature graph is shown. “Clear complete” is shown for a while after the EEPROM memory is cleared. And “Memory is full” appears when 120 values have been written into the EEPROM.
- menu_pos (line 9) is the number of the selected menu item (0 - 3).
- menu_mode (line 10) indicates if the device is in the menu mode (when it is 1) or if it shows the temperature graph (when it is 0).
- addr_write (line 11) is the EEPROM memory address into which the next value will be written.
- addr_read (line 12) is the EEPROM address from which the reading of the temperature values to be displayed in the graph will be started.
- temp_ready (line 13) is the flag that indicates that the new temperature value has been measured and is ready to be saved in the EEPROM and displayed in the graph.
- temp_array (line 14) is the array of the temperature values that are shown in the graph at the moment. The size of this array is 70. Even though the width of the display is 84 dots, we need some space to fit the graph legend and the left axis.
- temp_max and temp_min (line 15) are the maximum and minimum values of the temp_array. They are used to scale the graph and to write the legend properly.
This time, let’s break the familiar order of program consideration and do it from the very beginning to the end without skipping any functions.
In lines 17-115 there are 1-wire and DS18B20-specific functions. They are almost the same as in Tutorial 13, so I’ll skip their explanation here - we still have more than 300 lines of code to consider, after all! The only difference is that in the current program, we measure the temperature as integer values without the fractional part, so we don’t need to multiply the result by 10 in line 110, and in line 114, we cast the tmp value as a uint8_t type. By the way, as the returned type of the function read_temp is uint8_t, this means that we expect to have only positive values, so keep it in mind if you decide to use this logger.
In lines 118-129 there is the timer1_callback function which is invoked every time Timer1 overflows. When this happens, we start the temperature measure cycle. If the tick value is 590, which means that we’re on the 59’th second from the beginning (line 120) we start the temperature conversion (line 121) which as you know, takes about 1 second. So when the tick value reaches 600 (line 122) which means that we need to read and save the temperature value, it is already measured and ready to be read, which we do in line 124. After that, we set the tick value as 0 to start the new counting cycle (line 125). In line 126, we set the temp_ready flag which will be processed in the main loop of the program (lines 399 - 422). When this flag is set, as I mentioned, this will start saving the new data into the EEPROM and adding it to the graph. In line 128, we increment the tick value.
In lines 131-176 there are functions to operate with the EEPROM memory. These functions implement the commands according to Table 2. In all functions, any operation with the SPI bus starts with pulling the EEPROM_CS pin low. After completing the SPI transfer, this pin is set high. Let’s consider these functions in more detail.
The names of write_enable_eeprom (lines 131-136) and write_disable_eeprom (lines 138-143) functions speak for themselves.
In the write_enable_eeprom function we just send the WREN command 0x06 to the EEPROM (line 134) and in the write_disable_eeprom function we send the WRDI command 0x04 (line 141).
The check_busy_eeprom function (lines 145-153) checks if the EEPROM is busy by reading the Status Register and checking the RDY bit. So in this function, we first send the RDSR command 0x05 (line 149) then read the value of the Status Register by starting the new transfer and reading its result (line 150). Please note that in this case the value that you send to the device doesn’t matter and can be random, so 0x00 is not the worst option. After that we release the device by pulling the EEPROM_CS pin high (line 151) and return the value of the LSB of the status register, which is RDY (line 152).
The write_eeprom function (lines 155-165) writes the single byte ‘data’ into the specified address ‘addr’ of the EEPROM memory array. As we write the data into memory relatively rarely (only 1 time per minute) we will enable the write right before writing the data, and then disable the writing afterwards to prevent any unintended writes into the memory. In this function we first check while the memory is busy (line 157). Then we enable EEPROM writing (line 158). After that we send the WRITE COMMAND 0x02 (line 160) followed by the address to which to write the data (line 161) and the data itself (line 162). After that, we disable EEPROM writing (line 164).
The read_eeprom function (lines 167-176) reads the specified number of bytes (dictated by the variable len) starting from the address addr into the data array. In our application, we will need to read several consecutive bytes at once, that’s why this function is implemented in this way. Like in the previous function, we first wait while the memory is busy (line 169). Then we send the READ command (line 171) followed by the address from which the reading needs to be started (line 172). The AT25010 chip has an internal address counter which is incremented after every read, so there is no need to send the address every time, we only need to specify the start address. In lines 173-174 we read len bytes from the EEPROM and write them into the data array.
In lines 178-191, there is the show_menu function which displays the main menu of the program with highlighting the selected item with the text inversion. I will show the photos of the different modes in the next section. This function is quite simple. First, we clear the LCD buffer (line 180). Then we show all four menu items from the menu array (loop in lines 181-189). If the current item is selected (if menu_pos equals i) (line 183) then we invert the colors showing the white text on black background (line 184). Here may be some confusion. The BLACK macro means that the dot is not turned on, and the WHITE macro means that the dot is turned on. For an OLED display this makes more sense but for LCD, when the dot is turned on, it’s actually black, so keep in mind that these macros mean the opposite, or just swap them so as to not confuse yourself.
When the current menu item is not the one that is selected, it’s shown as usual - black text on white background (line 186).
In line 187, we set the cursor on the first position of the next row. Each menu item is 8 pixels in height, that’s why we multiply i by 8 in the y coordinate. After that we write the corresponding menu item (line 188). Please note that every one of them uses exactly 14 positions, and if the text is shorter, the rest is filled with the whitespaces. This is done to make it look good when the item is selected. In this case the whole row will be black, not only just the text.
When we write everything in the display buffer we send it to the LCD with the nokia5110_display function (line 190).
The show_graph function (lines 193-254) is much more complicated. As follows from its name, it can show the temperature graph which will also be shown in the next section. This graph needs to have axes, the grid, the legend, the graph itself, and the notifying text “Return to menu”. Let’s now see how this all is done. The coordinates of the lines and texts present in this function were selected by me in an empirical way to make the graph look the best (in my oh-so-humble opinion).
Before we start drawing, we do some preparations with the temp_array array. In it, we need to find the number of elements to display, the minimum and the maximum temperatures. The min and max search algorithm is very standard. We first assign the first element of the array to the temp_min and temp_max variables (lines 195 and 196). Then we need to find the last valid element in the array. The thing is that we always read 70 bytes from the EEPROM, no matter if all the memory cells are valid. There are two variables which help us to find the last element - addr_write and addr_read. The first one is the address of the cell into which the next data will be written and the second one is the address from which we start reading these 70 bytes. In line 197, we declare the end_addr variable which is the address of this last element in the temp_array we need to take into account. If (write_addr - read_addr) is smaller than 70, this means that the graph is not full yet, and so the end_addr should be assigned with this difference: addr_write - addr_read (lines 198-199). Otherwise the array is full, and the end_addr should be set as 70 (lines 200-201). In lines 202-208, there is a search of the minimum and maximum temperature within the elements range from 0 to end_addr.
In line 209, we draw the filled rectangle of the whole display size. I don’t know why but, for some reason, after implementation of the nokia5110_clearDisplay, the menu stayed in the background, so I decided to clear the display in this peculiar way, which works well. In line 210, we set the normal drawing mode - black dots on white background. In lines 211 and 212 we draw the Y and X axes, respectively. As I said before, the coordinates were selected in an empirical way.
In lines 213-224, we draw a vertical grid and write a legend under the X axis. The grid lines shouldn’t be very dense, neither should they be solid, otherwise the graph will be lost in them. The distance of 20 pixels between the lines looks good enough. To make the grid line dashed, we draw it as dots in proportion one dot - two empty spots. This is done in the loop in lines 215-218.
In lines 219-223, we write the legend. The numbers start with the addr_read value and are incremented by 20, as the distance between the grid lines is 20 pixels (line 222). If the number is greater than 120 (which is the maximum number of temperature values stored in the EEPROM) we don’t write them (line 219).
In lines 225-229, we draw the horizontal grid lines with the distance between them of 15 pixels.
As for the legend, there are two options. If the min and max temperatures match, which means that temperature hasn’t changed during the measurement interval, we will draw just the horizontal line in the center of the graph, and the legend will consist of just this single temperature value written opposite to the central grid line. This is implemented in lines 230-234.
If the min and max temperatures are different, we write the values only of these two temperatures, leaving the central grid line unmarked (lines 235-241). This is done because if the difference between the min and max temperatures is odd, we can’t divide it by 2 without a modulo, and also we can’t fit the value with the fractional part into the legend spot, so it’s better to leave it blank.
In lines 242-249 we draw the temperature graph. There are also two options. If the temperature is stable during the measurement period, which means temp_min = temp_max (line 244), we just draw the horizontal line in the middle of the reference plane (line 245). Please note, that in both cases (whether temperature changes or not) we draw the graph in the same way - using lines that connect the current and the next dots. In this way the graph will look solid.
Let’s consider in details what is written in lines 247-248:
nokia5110_drawLine(i + 13, ((temp_max - temp_array[i - 1]) * 30) / (temp_max - temp_min),
i + 14, ((temp_max - temp_array[i]) * 30) / (temp_max - temp_min), WHITE);
Everything’s clear with the X coordinates, we just start the graph from the 14th position where the Y axis is located. As for the Y coordinates, the formula represents the linear interpolation of the temperature between the temp_min and temp_max. The temp_min value corresponds to the Y coordinate of 30, and the temp_max value corresponds to the Y coordinate of 0.
Thus the equation will be the following:
(temp_array[i] - temp_max) / (temp_min - temp_max) = (Y - 0) / (30 - 0).
From which we get:
Y = (temp_array[i] - temp_max) / (temp_min - temp_max) / 30
Or, swapping the values in both numerator and denominator, we finally get:
Y = (temp_max - temp_array[i]) / (temp_max - temp_min) / 30
This is this formula that we see in the program in lines 247-248.
The last thing we need to do is to write the text “Return to menu” (line 252) at the bottom of the screen (line 251). To indicate that this is an active text, not just some message, we will also draw it in white letters on black background (line 250).
Finally, we send the display buffer to the LCD (line 253).
And, at last we got to the main function of the program (lines 257-424) which is very long but not very complex (actually, the same as the rest of the program).
The initialization part occupies lines 259-273. In line 259, there is the MCC-generated function which initializes all the hardware modules of the MCU.
In line 261, we invoke the TMR1_SetInterruptHandler function which sets the callback function which will be called when the Timer1 overflow interrupt occurs. This callback function is called timer1_callback and has been considered earlier in this tutorial.
In line 262, we invoke the SPI1_Open function with the SPI_DEFAULT argument passed to it. Frankly, I had to spend some time to understand that I needed to add this line and what to pass into it. We already got used to the peripheral modules initialization implementation inside the SYSTEM_Initialize function, and thus we don’t need to care about it. But for some reason, the MSSP module in SPI mode is initialized in the inactive state, and nothing happens when you call the SPI1_ExchangeByte function. After thorough checking, I finally found that the SPI1_Open function needs to be additionally called to activate the SPI module. The SPI_DEFAULT is the default configuration of the MSSP module registers according to the settings we made in the MCC configurator (Figure 10). This is not obvious at all and I’m tired of being surprised by Microchip.
In line 264, we enable global interrupts, and in line 265, we enable peripheral interrupts.
In lines 267-272, we initialize the LCD. The initialization is very similar to the one of the SSD1306 OLED display. First, we initialize the variables of the library (line 267), then we initialize the MCU pins connected to the LCD, and its driver (line 268). Next, we set the text color as WHITE (but it’s black in fact, as you remember) (line 269). In line 270, we set text size as 1 which is the minimum possible one with the character size 8 x 6 pixels. In line 271, we rotate the screen by 180 degrees. I did it just because I inserted the LCD in this way (see Figure 6). If you don’t use it upside down, you can skip this line.
In line 272, we clear the display buffer which, as I just realized, is unnecessary because in the next line we call the show_menu function which starts with the nokia5110_clearDisplay as well.
And with this, the initialization part is over.
In the main loop of the program (lines 275-423) there are four main parts - processing the button S1 “Enter” (lines 277-341), button S2 “Down” (lines 343-369), button S3 “Up” (lines 371-397), and the flag temp_ready (lines 399-422). Let’s consider them one by one. The buttons are processed with the standard blocking algorithm, so we will consider only the payload of these parts.
When you press the “Enter” button S1, its action depends on the mode - Menu or Graph, and in the first case - on the selected menu item.
So if the current mode is menu (line 286), we check the menu_pos variable to implement one of the four actions using the switch-case statement (line 288).
If menu_pos is 0 (line 290) this means that the selected action is “Start record”. In this case we need to do the following:
- Set the menu_mode variable as 0 to switch to the temperature graph mode (line 291).
- Reset the addr_write variable to start recording from the first address of the memory (line 292). Please note that we don’t need to actually erase the whole EEPROM memory, because when we reset the addr_write variable, we can no longer find the previous end of the array, as now it’s 0. Also, this approach prolongs the memory life, because the number of write cycles is not unlimited.
- Reset the addr_read variable to start reading from the very beginning (line 293).
- Reset the temp_ready flag in case it has been left set after the last measurement (line 294).
- Clear the Timer1 counting register TMR1 with the initial value so that all intervals between measurements are equal (line 295).
- Set the tick variable as 590 to implement the first measurement immediately (line 296). Really, if it’s 590, then when the interrupt occurs, the condition in line 120 is met, and the convert_t command will be implemented at once.
- Enable global interrupts (line 297) after which the interrupt subroutine will start implementing, and the temperature measurement and logging will begin.
- Invoke the show_graph function to immediately switch from menu to the temperature graph mode (line 298).
The “Resume record” action (if menu_pos is 1, line 300) is very similar to the “Start record” but it has certain differences.
First, we don’t start recording from the beginning, we start adding new values to the end of the memory. So we somehow need to know the value of the addr_write variable. If the MCU hasn’t been reset, there is no problem with this, as we have the actual value of addr_write. But when we select this item after power-up, the addr_write is 0. To solve this problem I save the value of addr_write into the last address of the EEPROM memory after each measurement (line 414). But I should warn you - this is a bad approach. When we do it like this, we exhaust the resource of this cell much quicker than the resource of all other cells, and after some time there will be rubbish in this cell instead of the real value. The endurance of this chip is 1 million writes at least, so in our application this number will not be reached very soon. But in other applications, you need to think of something else to make the cells’ usage more steady and even.
So, here we read the last cell of the EEPROM memory into the temp_array (line 303) and then copy the first element of this array into the addr_write variable (line 304).
Another difference from the previous mode is in the lines 308-310. Here we want to display the end of the graph, as new values will be added there. So we increment the addr_read value by 40 until the difference between the addr_write and addr_read is greater than 40 (lines 309-310). The step of 40 has been selected to show some previous values if they exist, and then add new values to them.
In line 311, we read 70 bytes from the EEPROM starting with the addr_read. As I already mentioned, we always read 70 bytes, and then check which of them we need in lines 198-201.
Other than that, this item is the same as the previous one.
In the next item “Show Results” (if menu_pos is 2, line 314), we don’t need to enable the global interrupts because we don’t implement new measurements. Also, in this mode, we start showing the graph from the very beginning, that’s why we just reset the addr_read variable (line 316). But we still need to know how much data we already have, so we read the addr_write value from the EEPROM last address the same as in the previous item (lines 317-318).
In the last item, “Clear Results” (if menu_pos is 3, line 322), we actually just clear the addr_write variable (line 323) and write it into the last address of the EEPROM (line 324). This action happens invisibly to a user, so some notification is needed to show that the action is done. That’s why we set the cursor to the last line (line 325), write the text “Clear complete” there (line 326), show it in the LCD (line 327), then in a second (line 328) we return to the normal menu (line 329). If needed, you may increase this time of showing the notification but be aware that as we use a regular delay here, no other actions can be performed during this.
If we are in the graph mode (line 333), then pressing on this button returns to the menu mode (line 335), disables the global interrupts (line 336), as in this mode we don’t perform any measurements, and displays the menu (line 337).
Actions of the “Up” and “Down” buttons are very similar. They also are different in menu and graph mode, like in the previous button. When we press the “Down” button in the menu mode (line 352) we increment the menu_pos value (line 355) if it’s not the maximum one, which is 3 (line 354), after that we show the menu (line 356).
When we press this button in the graph mode (line 358) we read the next 40 values from the EEPROM (lines 362, 363) and show the updated graph (line 364) in case the last address addr_write is greater than (addr_read + 40) (line 360).
Actions done when the “Up” button is pressed, are opposite. In the menu mode (line 380) we decrement the menu_pos value (line 383) if it’s greater than 0 (line 382), after that, we show the menu (line 384). In the graph mode, (line 386) we read the previous 40 values from the EEPROM (lines 390, 391), if addr_read is greater than 0 (line 388), and show the updated graph (line 392).
That’s everything about the buttons. Let’s now see what happens when the temperature is measured and the temp_ready flag is set in the Timer1 callback function (in line 126).
First, we reset the temp_ready flag to prepare for a new measurement (line 401). If we reach the maximum address, which is 120 (line 402) we need to stop writing. Even though the EEPROM has 128 cells, I decided to stop at the address of 120 to record data for exactly two hours, with a sample every minute. So, to stop measurement, we disable global interrupts (line 404). To let the user know that no more measurements will be performed, we go to the last line of the LCD (line 405), write the text “Memory is full” there (line 406), and update the LCD (line 407).
If there are still some free cells (line 409), we save the recently measured temperature into the EEPROM (line 411) and add it to the temp_array (line 412). Then we increment the addr_write (line 413) and save it into the last address of the EEPROM (line 414).
If the graph is fully filled, which means all 70 values are shown in it already (line 415) then we need to shift it to the left. This time I decided that shifting by 20 pixels would be good. To do this, we increment addr_read by 20 (line 417) and read the next values from the EEPROM (line 418). After all the manipulations we update the graph on the screen (line 420).
And that’s finally it!
Testing of the temperature logger
Let’s now assemble the device according to Figure 4 or Figure 5, compile and build the project and flash it into the PIC18F14K50 MCU. Here is one trick you should know. When you press the Build button , you will most likely have an error (Figure 16).
This happens because the button is not called “Build” actually but “Build for debugging main project” which you can see if you hold the cursor over it. As I said, we’re lacking RAM space in this project as it’s 95% full. And “building for debugging” seems to require additional memory which we don’t have. To overcome this problem, press on the black triangle at the button and select the point “Build Main Project” from the drop-down list (Figure 17).
After that, the project will compile without any issues (if you made everything right, at least).
If everything is correct, after flashing the MCU, you should see the following menu (Figure 18).
When you press the “Up” and “Down” buttons you should see that the menu item is changing. When you select “Start Record” you should see that the graph starts from the very beginning, and a new value is added every minute to it. After some time you may see something like this (Figure 19).
As you can see, it shows the change of the temperature during an hour here. It’s not very convenient that if the current temperature matches the minimum one, the graph overlaps the X axis, and you actually don’t know where it stops. So as homework I suggest you change the axes lines to be dashed as well, like grid lines, but more dense. If you press the “Enter” button, you will return to the menu mode. If you press the “Up” or “Down” button, you will navigate the graph. For example, this is how the last chunk of the graph looks here (Figure 20).
Here you can see that during the last few minutes temperature was stable, so it’s shown as a horizontal line in the middle, and there is only one legend value at this line. So our program works as intended.
And that’s all! In this tutorial, we have learned about the MSSP module of the PIC18F14K50 MCU in SPI mode. We have connected the Nokia5110 LCD and the AT25010 EEPROM to the MCU using the SPI bus and implemented a temperature logger with a simple HMI interface using the menu and the buttons.
Here’s the project file for this tutorial:
Get the latest tools and tutorials, fresh from the toaster.