17. Building an I2C-based Digital Thermometer

Introduction

I’ll present a project that simply measures the temperature by reading a sensor. Afterwards, we’ll expand on it by introducing memory storage (Project 2 and time-stamped readings (Project 3).

The Theory

How I2C is different from SPI

SPI has 4 wires: MISO, MOSI, CS/SS, SCK. I2C only has 2.

By giving each device an address, we can drop the SS line. By forcing all communication to occur on one wire, we drop MISO/MOSI. This means communication is half-duplex and bidirectional.

Voila! Just like that we’ve arrived at 2 wires – SDA/SCK. Contrast this with 4 wires for SPI.

How I2C Works

SDA can only toggle when SCK is low EXCEPT for START/STOP conditions.

Data is only read when SCK is high.

Every I2C transaction begins with a 7-bit address.

Since SDA is bidirectional, we attach an 8th bit, the R/W bit. This conveys the direction of communication flow. If its set, the Master is receiving (or READING) data. If pulled low, Master is WRITING data.

If the Master or Slave receives data, they must either ACK or NACK. This is a 9th-bit sent after every byte. SPI doesn’t have this error detection. Therefore, an I2C data transaction is always 9 bits long.

START/STOP conditions are generated exclusively by the master. Thus, the slave is unable to initiate or terminate conversation. It’s response is received via ACK/NACK.

Bits are transmitted on SDA when SCL is low (except for START/STOP conditions)
Data is only read when SCK is high.
A complete I2C transaction
  1. Master generates a START condition (Red)
  2. Master sends 7-bit slave address along with an extra bit at the end (R/W bit). (Green)
  3. Slave transmits an ACK by pulling SDA low (Blue)
  4. Master responds by transmitting a data byte (Yellow)
  5. Slave responds with ACK/NACK. (Pink)
  6. If NACK is submitted, Master responds by generating a STOP condition (Teal)
A complete I2C transaction

Project 1 – I2C Thermometer

This project will read the temperature from a LM75 and print it to the serial terminal every 3 seconds.

Wait? What’s a LM75

Aah. Good question. The information about I2C at the beginning wasn’t a waste of time because you see, the LM75 (and many many other sensors) communicates via I2C.

Description

The LM75 is a digital temperature sensor. It has an integrated Sigma-Delta ADC and I2C interface. This means we avoid any messy signal acquisition and filtering. We obtain the 9-bit temperature by politely asking it via I2C with an accuracy of ±2°C.

We can set it with a programmable limit with hysteresis via over-temperature output (O.S). In this configuration, the OS pin is set when a certain temperature threshold is reached.

Pinout
Functional block diagram

For it’s 7-bit address, the first 4 (MSB) are hardwired into the silicon. The last 3 can be configured by connecting to A0, A1, A2 pins.

Upto (2^3) 8 LM75’s can be connected on the I2C bus

The temperature data is read from the Temperature (T_OS) Set Point and (T_HYST) Set Point registers. The temperature from 125°C to -55°C is mapped to 0FAh – 192h. The MSB represents the sign.

The LM75 outputs a 10-bit temperature reading
Register Structure

AI cooked

The LM75 has 4 registers

Hardware Connections

The schematic is located in my GitHub repository.

Highlights

  • A0, A1, A2 is grounded, setting last 3 address bits to 000.
  • 10kΩ pullup resistors on I2C bus (as recommended by datasheet, page 18)

The Software

This project consists of an I2C driver and main.c. Full source code available in my GitHub repository. Thanks to the hardware I2C/TWI, the software is quite sparse. The hardware is doing the majority of the heavy lifting. The I2C driver implements the register access to facilitate I2C.

NOTE: Pay attention to the MakeFile. Some files are imported from folders with specific file locations.

At the beginning of main.c, I extract the pointers of the 4 registers and address for read/write operation from the datasheet.

An infinite while loop where the read operation is performed via I2C. The steps in A complete I2C transaction are implemented in code. The result is then printed to serial terminal via print_uint8().

The information below contains the address of the LM75

The first 4 digits 1001 is hard-wired to the LM75. The other 3 is determined by the connections to A0, A1, A2. Since their all grounded, the address this is 000. That is 4 + 3 = 7 bits. This is a standard I2C 7-bit address.

#define LM75_ADDRESS_W          0b10010000  
#define LM75_ADDRESS_R          0b10010001 

The last bit, LSB is R/W bit. A Read = 1, Write = 0. Recall that an I2C address is 7-bits. Last bit is R/W bit. When we want to send data we send 1001000. When we want to read data we send 10010001.

The Showcase

You can see when I bring fire close to the sensor, it’s readings increase (as it should).

Project 2 – Logging Thermometer

Now that I’ve validated the LM75 temperature sensor works, let’s add a 24LC256 EEPROM IC to develop a temperature logger.

NOTE: The 24LC256 was covered in extensive detail in 16. Working with the 24LC256 EEPROM IC.

Hardware Connections

2 devices are connected to the I2C bus.

The schematic is available in my GitHub repository.

The Software

NOTE: The I2C and 25LC256 driver from previous projects are re-used. Hooray for code re-usability. Full source code available at the GitHub repository.

On power-up, a configuration menu appears and waits for input. The user can interact with the logger from here, but if no selection is made within MENU_DELAY (5 seconds), the menu times out and the program begins logging temperature readings to the EEPROM.

The top of main.c sets up the groundwork: pointers to the four registers and the read/write address are pulled from the datasheet, followed by the 25LC256 macros. The menu is then printed, and a switch/case captures and processes whatever the user selects.

KeyOptionWhat it does
<Shorten sample delaySubtracts 5 s from the reading interval. Guarded by seconds_delay >= 10, so shortest reachable interval is 5 s.
>Lengthen sample delayAdds 5 s, up to 65 000 s (~18 hours).
rReset delay to 60 sSelf-explanatory
pPrint log over serialWalks EEPROM from MEMORY_START (byte 4) to the current write pointer in 7-byte strides, decoding each record to YYYY-MM-DD HH:MM:SS, XX.X degrees.
eErase memoryClears the whole 32 KB chip (~3 s), then re-seeds the two config words: write pointer back to MEMORY_START.
sStart logging(cut off in the screenshot)

The Showcase

TimestampDescription
0:00 – 0:08Testing invalid inputs
0:07 – 0:11Reset delay time
0:11 – 0:16Shorten delay time
0:16 – 0:22Increase delay time
0:22 – 0:23Reset delay time
0:24 – 0:38Clearing EEPROM
0:39 – 0:47Commencing logging

Project 3 – Logging Thermometer with time-stamped readings

I left the temperature logger logging overnight. It accrued 248 readings. However, without a timestamp, the readings are meaningless. I can’t interpret this. Let’s add a DS3231 RTC IC and time-stamp each sensor reading.

Here are the new additions:

  1. The readings are dumped into a .csv file, dump_log.py
  2. A Python script plots it, plot_log.py

Hardware Connections

We have 3 devices connected on the I2C bus.

The schematic is available in my GitHub repository.

Circuit prototyped on a breadboard

The Software

The source code is expanded with the addition of the DS3231 driver and two scripts. Full source code available at the GitHub repository.

Hardware Definitions

The top of main.c establishes the I²C groundwork: the LM75’s 7-bit address shifted into read (0x91) and write (0x90) forms, and its four internal register pointers — temperature, config, THYST, and TOS — taken from the datasheet. The 24LC256 macros follow, defining an address map where the first four bytes hold two persistent 16-bit config words (the next-write pointer and the sampling interval), with log records beginning at MEMORY_START.

The Record Structure

log_record_t defines one timestamped reading: six calendar/clock bytes matching rtc_time_t, plus a single packed temperature byte. The struct is compiled with -fpack-struct so it lays out as exactly seven contiguous bytes, which is what makes byte-by-byte serialization to EEPROM safe.

Temperature packing is worth noting: the LM75’s 9-bit reading is compressed into one byte with the integer degrees in bits 7:1 and the 0.5 °C flag in bit 0.

Helper Layer

UART printing helpers handle unpadded and zero-padded decimal output, with print_timestamp() assembling YYYY-MM-DD HH:MM:SS (century hardcoded as 20xx, since the DS3231 stores only a two-digit year). eeprom_read_record() and eeprom_write_record() walk the struct byte-by-byte to and from a given address.

Setting the Clock

set_clock() prompts for each field in turn via prompt_field(), populating an rtc_time_t instance t — year, month, date, hour, minute, second — and passes it to ds3231_set_time(). This only needs running once; the module’s coin cell holds time through power cycles.

The Menu

Largely unchanged from the earlier version, with one addition: t to set the clock. The existing options handle adjusting the sample interval, erasing memory, and dumping the log over serial.

Logging Loop

Each iteration reloads the write pointer from EEPROM, reads the current time via ds3231_read_time() into a fresh rec, then performs the LM75 read — write the pointer register, repeated START, read two bytes with an ACK then a NACK. The two bytes are packed into rec.temp, the record is printed and written to EEPROM, and the pointer advances only if a full seven bytes still fit before the chip’s end. The LED then toggles once per second through the inter-reading delay as a heartbeat.

The Showcase

After I run make plot, it executes the Python script, generating a beautiful time-stamped log.

NOTE: The dip at the bottom at 11:45 – 12:00 is deliberate as I transported the circuit into the garage. Afterwards, I transported it into the room and cranked up the heater. This explains the subsequent rise in readings.

A plot of time-stamped sensor readings