DEV Community

Cover image for IoT Architectures Under Pressure: Smart Thermostat, Hardware (Part 7)
Adriano Repetti
Adriano Repetti

Posted on • Edited on

IoT Architectures Under Pressure: Smart Thermostat, Hardware (Part 7)

In previous posts, we developed the firmware for a cost-effective smart thermostat, embracing a firmware-less approach and then refined the original design. We started the development of a C# basic reference implementation for the Hub.

Now, we turn our attention to the hardware, demonstrating that a smart device doesn’t have to be more expensive (whether in design, development, production, or purchase) than compared to a traditional one.

Disclaimer: The code and electronic circuit designs provided in this post are intended for illustrative purposes only. They should not be considered best practices nor assumed to comply with industry safety standards or regulations. Before implementing any design, always refer to official documentation, safety guidelines, and certified standards applicable to your region or industry. The author assumes no responsibility for damages, malfunctions, or risks arising from the use of this information.

Hardware

In this example we assume I2C communication with the hub (you might need a bus buffer like NXP P2B96 for long distances).

The entire device is built around an Atmel ATtiny85 microcontroller, a Texas Instruments LM35 temperature sensor and a Sanyou SRD relay. Other variations are surely possible with minimal changes.

Smart thermostat schematic

With this design, the estimated cost for all components is around 8 USD (€7) but it's way less than that if you buy in bulk.

This is not a production-ready design but if you're going to build something then remember:

  • C1, C2, R4 and R5 should be as close as possible to U1.
  • C4 and R1 should be near U2.
  • U2 should have its tiny heat sink and be as far as possible from other heat sources (ideally near the side of the PCB).
  • If you are really using a long I2C line then, as first step, you could change R4 and R5 to 10 kΩ and add a TVS diode like Texas Instruments TPD4E1B06.

Power supply is outside the scope of this post but a super simple design (from a 24V DC input you might get from the furnace) could be:

12V to 24V power supply

Software

Let's see the firmware, again we are going to keep it simple (refer to Atmel's Application Notes for a full I2C slave implementation in C) or jump to the C++ implementation (which is somewhat complete):

C Implementation

#include <avr/io.h>
#include <avr/interrupt.h>
#include <util/delay.h>
#include <stdint.h>

#define SLAVE_ADDRESS 0x50

#define NUMBER_OF_SAMPLES 6

#define FURNACE_ON_COMMAND '1'
#define FURNACE_OFF_COMMAND '0'
#define READ_TEMPERATURE_COMMAND 'r'

#define STATE_WAIT_ADDRESS    0
#define STATE_RECEIVE_COMMAND 1
#define STATE_TRANSMIT_DATA   2

#define MAX(a, b) ((a) > (b) ? (a) : (b))
#define MIN(a, b) ((a) < (b) ? (a) : (b))

volatile uint8_t received_command = 0;
volatile uint8_t i2c_state = STATE_WAIT_ADDRESS;

// Reads a single temperature sample from LM35
uint16_t read_raw_temperature(void) {
    // Select ADC channel, start conversion and waits for it to complete
    ADMUX = (1 << REFS0) | (1 << MUX1);

    ADCSRA |= (1 << ADSC);
    while (ADCSRA & (1 << ADSC))
        ;

    // Convert ADC reading to °C (LM35 calibration: 10mV/°C)
    return (5 * ADC * 100) / 1024;
}

// Reads multiple temperatures to reduce the noise
uint8_t read_temperature(void) {
    uint16_t sum = 0, min = UINT16_MAX, max = UINT16_MIN;

    for (uint8_t i = 0; i < NUMBER_OF_SAMPLES; ++i) {
        uint16_t sample = read_raw_temperature();
        sum += sample;
        min = MIN(min, sample);
        max = MAX(max, sample);
        _delay_ms(1);
    }

    // Compute average excluding min/max (reduces noise)
    uint16_t temperature = (sum - min - max) / (NUMBER_OF_SAMPLES - 2);

    return (uint8_t)(temperature >> 2);
}

void init_adc(void) {
    ADCSRA = (1 << ADEN)  // Enable ADC
           | (1 << ADPS2) // Set ADC prescaler to 128 (stable readings)
           | (1 << ADPS1)
           | (1 << ADPS0);
}

void init_i2c(void) {
    DDRB &= ~((1 << PB0) | (1 << PB2)); // Configure SDA & SCL as inputs
    USIDR = 0;
    USISR = (1 << USIOIF);  // Reset USI overflow interrupt flag
    USICR = (1 << USISIE)  // Enable USI start condition interrupt
          | (1 << USIWM1) | (1 << USIWM0) // Enable two-wire mode
          | (1 << USICS1); // Set clock source
}

ISR(usi_start_condition_handler) {
    USISR = (1 << USISIF) | (1 << USIOIF);
    i2c_state = STATE_WAIT_ADDRESS;
}

ISR(usi_handler) {
    uint8_t received = USIDR;

    switch (i2c_state) {
        case STATE_WAIT_ADDRESS:
            handle_address_received(received);
            break;

        case STATE_RECEIVE_COMMAND:
            handle_command_received(received);
            break;

        case STATE_TRANSMIT_DATA:
            handle_data_transmit();
            break;

        default:
            i2c_state = STATE_WAIT_ADDRESS;
            break;
    }
}

void reset_interrupt_flag(void) {
    USISR = (1 << USIOIF); 
}

void handle_address_received(uint8_t received) {
    uint8_t address = received >> 1;
    uint8_t rw = received & 0x01;

    if (address == SLAVE_ADDRESS)
        i2c_state = rw == 0 ? STATE_RECEIVE_COMMAND : STATE_TRANSMIT_DATA;

    reset_interrupt_flag();
}

void handle_command_received(uint8_t command) {
    received_command = command;

    if (received_command == FURNACE_ON_COMMAND)
        PORTB |= (1 << PB1);  // Turn furnace ON
    else if (received_command == FURNACE_OFF_COMMAND) {
        PORTB &= ~(1 << PB1); // Turn furnace OFF

    i2c_state = STATE_WAIT_ADDRESS;
    reset_interrupt_flag();
}

void handle_data_transmit(void) {
    if (received_command == READ_TEMPERATURE_COMMAND)
        USIDR = readTemperature();
    else
        USIDIR = 0;

    i2c_state = STATE_WAIT_ADDRESS;
    reset_interrupt_flag();
}

int main(void) {
    // Set PB1 as output for furnace control and ensure it's off
    DDRB |= (1 << PB1);
    PORTB &= ~(1 << PB1);

    init_adc();
    init_i2c();
    sei();

    while (1) {
        _delay_ms(1000);
    }

    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Alternative C++ Implementation

The code is pretty straightforward and if you are comfortable using some C++(ish) and the TinyWireS library then it becomes incredibly short:

#include <TinyWireS.h>

#define SLAVE_ADDRESS 0x50     // I2C address for this device
#define RELAY_PIN 1            // Relay Control Pin
#define LM35_PIN A2            // Analog pin for temperature sensor
#define NUMBER_OF_MEASURES 6   // Number of temperature samples (>= 3)
#define READING_DELAY 1000     // Delay between each temperature reading

#define FURNACE_ON_COMMAND '1'
#define FURNACE_OFF_COMMAND '0'
#define READ_TEMPERATURE_COMMAND 'r'

float lastKnownTemperature = 0;

void setup() {
    TinyWireS.begin(SLAVE_ADDRESS);
    TinyWireS.onRequest(sendTemperature);
    TinyWireS.onReceive(handleCommand);

    pinMode(RELAY_PIN, OUTPUT);
    digitalWrite(RELAY_PIN, LOW);
}

void loop() {
    lastKnownTemperature = readTemperature();
    delay(READING_DELAY);
}

void handleCommand(int numBytes) {
    if (TinyWireS.available()) {
        char command = TinyWireS.read();

        if (command == FURNACE_ON_COMMAND) {
            digitalWrite(RELAY_PIN, HIGH);
        } else if (command == FURNACE_OFF_COMMAND) {
            digitalWrite(RELAY_PIN, LOW);
        }
        // Let's assume that any read request is preceded
        // by a read command, it makes things simpler.
    }
}

void sendTemperature() {
    TinyWireS.write((int8_t)lastKnownTemperature);
}

float readTemperature() {
    float minimum = FLT_MAX;
    float maximum = FLT_MIN;
    float sum = 0;

    for (int i = 0; i < NUMBER_OF_MEASURES; i++) {
        float temp = analogRead(LM35_PIN) * (5.0 / 1023.0) * 100.0;

        if (temp < minimum) minimum = temp;
        if (temp > maximum) maximum = temp;

        sum += temp;

        delay(10);
    }

    sum -= (minimum + maximum);
    return sum / (NUMBER_OF_MEASURES - 2);
}
Enter fullscreen mode Exit fullscreen mode

For completeness (assuming your're not using an IDE), a low speed fuses setup you might use in this case:

avrdude -c usbasp -p t85 -U lfuse:w:0x62:m -U hfuse:w:0xDF:m -U efuse:w:0xFF:m
Enter fullscreen mode Exit fullscreen mode

And to compile and upload your firmware:

avr-gcc -mmcu=attiny85 -Os -c thermostat.cpp -o thermostat.o
avr-gcc -mmcu=attiny85 -o thermostat.elf thermostat.o
avr-objcopy -O ihex thermostat.elf thermostat.hex
avrdude -c usbasp -p t85 -U flash:w:thermostat.hex:i
Enter fullscreen mode Exit fullscreen mode

Conclusions

From this minimal implementation, we can draw a few conclusions:

  • Our hardware is incredibly simple (and thus cost-effective), even simpler than a traditional non-smart thermostat. Not only is it simple, but it is also highly similar to a traditional implementation, meaning transition costs are negligible.
  • Our firmware is as streamlined as possible (both in our device and in the hub) eliminating the complexities often associated with connected devices (security, UI, compatibility, support, cloud infrastructure, etc.).

Developing a smart device can be both a sustainable and profitable option, benefiting vendors and customers alike.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.