Heat Seeker Robot

Project Overview

An autonomous robot designed to track and follow heat signatures using an M5Stack Thermal Camera 2 Unit. It processes the thermal centroid data via a custom PID controller running on an M5StickC PLUS (ESP32). To move it uses a M5StickC 8-Channel Servo Driver HAT to control two TowerPro SG90 continuous rotation 360 degree servo motors. The chassis and wheels are 3D printed and the tracks are replacement tracks for a Cozmo robot.

thermal.ino

#include <M5Unified.h> 
#include <M5_Thermal2.h>
#include <Wire.h> 
#include "Hat_8Servos.h" 

// --- DEVICE CONFIG ---
#define THERMAL_SDA_PIN 32
#define THERMAL_SCL_PIN 33
#define THERMAL_I2C_SPEED 400000UL
#define THERMAL_I2C_ADDR 0x32
#define SERVOS_SDA_PIN 0
#define SERVOS_SCL_PIN 26
#define SERVOS_I2C_SPEED 100000UL 
#define SERVOS_DEVICE_ADDR 0x36

M5_Thermal2 thermal2;
Hat_8Servos drive; 

// --- SERVO/MOTOR CONFIG ---
uint8_t currentServoStates[8] = {90, 90, 90, 90, 90, 90, 90, 90}; 

bool isForwardMode = false;
bool moveForward = false;
bool targetAquired = false;
bool moveForwardAlertSwitch = false;
int moveForwardAlertCounter = 0;
int moveForwardAlertCounterLimit = 10;

// --- THERMAL & DISPLAY CONFIG ---
#define FIXED_MIN_TEMP 20.0 
#define FIXED_MAX_TEMP 40.0 
#define DISPLAY_SCALE 5
#define THRESHOLD_TEMP 33.0 
#define THERMAL_CENTER_X 16 
#define BASE_SPEED 95      
#define MAX_SPEED_ADJUST 40 
#define TFT_MAGENTA M5.Display.color565(255, 0, 255)

// --- PID CONSTANTS (Requires Tuning!) ---
float Kp = 2.5;  
float Ki = 0.001; 
float Kd = 0.5;   

// --- PID STATE VARIABLES ---
long lastTime = 0;
float error = 0;
float lastError = 0;
float integral = 0;

// =========================================================================
// --- HELPER FUNCTIONS ---
// =========================================================================

void setThermalBus() {
    Wire.end(); 
    Wire.begin(THERMAL_SDA_PIN, THERMAL_SCL_PIN, THERMAL_I2C_SPEED);
    delay(25);
}

void setServoBus() {
    Wire.end();
    Wire.begin(SERVOS_SDA_PIN, SERVOS_SCL_PIN, SERVOS_I2C_SPEED);
    delay(25);
}

void handleLEDAndBuzzer(){
    if(isForwardMode)
    {   
        if(targetAquired){
            if(moveForward){
                thermal2.setLed(64, 0, 0);
                if(!moveForwardAlertSwitch){
                    thermal2.buzzerOn();               
                    moveForwardAlertSwitch = true;
                }    
            }
            else{
                thermal2.setLed(4, 0, 0);
            }
        }
        else{
            thermal2.setLed(0, 4, 0);
        }
    }
    else{
        if(targetAquired){
            thermal2.setLed(4, 0, 0);
        }
        else{
            thermal2.setLed(0, 0, 4);
        }
    }

    if(moveForwardAlertSwitch){
        moveForwardAlertCounter += 1;
    
        if (!(moveForwardAlertCounter > 0 && moveForwardAlertCounter <= moveForwardAlertCounterLimit)){
            thermal2.buzzerOff();
            moveForwardAlertCounter = 0;
            moveForwardAlertSwitch = false;
        }
    }
}

uint16_t getColor(float temp, float min_val, float max_val) {
    temp = constrain(temp, min_val, max_val);
    float normalizedTemp = (max_val - min_val) > 0 ? (temp - min_val) / (max_val - min_val) : 0;
    byte r, g, b;

    if (normalizedTemp < 0.25) {
        r = 0; g = (byte)(255 * (normalizedTemp / 0.25)); b = 255;
    } else if (normalizedTemp < 0.5) {
        r = 0; g = 255; b = (byte)(255 * (1 - (normalizedTemp - 0.25) / 0.25));
    } else if (normalizedTemp < 0.75) {
        r = (byte)(255 * ((normalizedTemp - 0.5) / 0.25)); g = 255; b = 0;
    } else {
        r = 255; g = (byte)(255 * (1 - (normalizedTemp - 0.75) / 0.25)); b = 0;
    }
    return M5.Display.color565(r, g, b);
}

float computePID(int current_centroid_x) {
    long now = millis();
    float timeChange = (float)(now - lastTime);
    error = (float)current_centroid_x - (float)THERMAL_CENTER_X;
    float proportional = Kp * error;
    integral += (error * timeChange);
    integral = constrain(integral, -2000, 2000); 
    float integral_term = Ki * integral;
    float derivative = (error - lastError) / timeChange;
    float derivative_term = Kd * derivative;
    float output = proportional + integral_term + derivative_term;
    lastError = error;
    lastTime = now;
    return constrain(output, -MAX_SPEED_ADJUST, MAX_SPEED_ADJUST);
}


// =========================================================================
// --- SETUP ---
// =========================================================================

void setup() {
    Serial.begin(115200);
    delay(500); 
    M5.begin();

    // Display Setup
    if (M5.Display.width() < M5.Display.height()) {
        M5.Display.setRotation(M5.Display.getRotation() ^ 1);
    }
    M5.Ex_I2C.begin(); // Not strictly needed, but included for completeness

    // 1. Thermal Init
    Serial.println("-> Initializing Thermal Unit...");
    setThermalBus(); 
    if (!thermal2.begin(&Wire, THERMAL_I2C_ADDR)) {
         Serial.println("❌ Thermal2 init failed!");
    }
    thermal2.setRefreshRate(6);
    thermal2.setNoiseFilterLevel(8);
    thermal2.alarmOff(); // Keep control logic simple

    thermal2.ledOn();
    thermal2.buzzerOff();

    thermal2.setBuzzer(300, 128);
    thermal2.setLed(0, 0, 4);

    // 2. Servo Init
    Serial.println("-> Initializing Servo Hat...");
    drive.begin(&Wire, SERVOS_SDA_PIN, SERVOS_SCL_PIN, SERVOS_DEVICE_ADDR);
    setServoBus(); 
    drive.enableServoPower(1); 
    drive.setServoAnglesFromArray(currentServoStates); 

    Serial.println("*** PID Hotspot Follower Ready ***");
    lastTime = millis();
}

// =========================================================================
// --- MAIN LOOP ---
// =========================================================================

void loop() {
    M5.update();
    M5.Display.startWrite(); // Begin fast display updates

    int centroid_x_px = -1; // Horizontal centroid (PID Input)
    int centroid_y_px = -1; // Vertical centroid (For display only)
    static int frame_count = 0;

    // 1. --- Switch Bus and Read Thermal Data (Input) ---
    setThermalBus(); 
    
    if (thermal2.btnWasClicked()) {
        isForwardMode = !isForwardMode;
    }

    handleLEDAndBuzzer();

    if (thermal2.update()) {
        frame_count++;
        auto temp_data = thermal2.getTemperatureData();
        targetAquired = false;
        
        // **Centroid Calculation Variables**
        int hot_pixels_count = 0;
        long sum_x_pixels = 0;
        long sum_y_pixels = 0; // Needed for display

        // **Thermal Visualization and Centroid Calculation Loop**
        for (int idx = 0; idx < 384; ++idx) {
            int y = idx >> 4;
            int x = ((idx & 15) << 1) + ((y & 1) != temp_data.getSubPage());
            float pixel_temp = temp_data.getPixelTemperature(idx);
            
            // Centroid Logic: Check Threshold and Accumulate
            if (pixel_temp > THRESHOLD_TEMP) {
                sum_x_pixels += x;
                sum_y_pixels += y;
                hot_pixels_count++;
            }

            // Draw the thermal image pixel
            uint16_t color = getColor(pixel_temp, FIXED_MIN_TEMP, FIXED_MAX_TEMP);
            M5.Display.fillRect(70 + x * DISPLAY_SCALE, 8 + y * DISPLAY_SCALE, DISPLAY_SCALE, DISPLAY_SCALE, color);
        }

        // Final Centroid Calculation
        if (hot_pixels_count > 0) {
            centroid_x_px = sum_x_pixels / hot_pixels_count;
            centroid_y_px = sum_y_pixels / hot_pixels_count; 
            targetAquired = true;
        }

        // --- DISPLAY Centroid and Stats (Your original code) ---      
        M5.Display.setCursor(0, 100);
        if (hot_pixels_count > 0) {
            // Draw tracking dot
            int draw_x = 70 + centroid_x_px * DISPLAY_SCALE + (DISPLAY_SCALE / 2);
            int draw_y = 8 + centroid_y_px * DISPLAY_SCALE + (DISPLAY_SCALE / 2);
            M5.Display.fillCircle(draw_x, draw_y, 2, TFT_MAGENTA); 
            M5.Display.printf("Cen(X,Y): %02d,%02d\n", centroid_x_px, centroid_y_px);
        } else {
            M5.Display.printf("Cen(X,Y): --,--\n");
        }

        // Display text stats
        static int prev_sec = ~0u;
        int sec = millis() / 1000;
        if (prev_sec != sec) {
            prev_sec = sec;
            M5.Display.setCursor(0, 8);
            M5.Display.printf("avg: %5.2f\n", temp_data.getAverageTemperature());
            M5.Display.printf("med: %5.2f\n", temp_data.getMedianTemperature());
            M5.Display.printf("high:%5.2f\n", temp_data.getHighestTemperature());
            M5.Display.printf("low: %5.2f\n", temp_data.getLowestTemperature());
            M5.Display.printf("fps: %02d\n", frame_count);
            M5.Display.printf("TH:%5.1fC\n", THRESHOLD_TEMP); 
            frame_count = 0;
        }
    } 
    
    // --- PID Control (Using calculated centroid_x_px) ---
    int right_motor_command = 90;
    int left_motor_command = 90;

    if (centroid_x_px != -1) {
        float pid_adjustment = computePID(centroid_x_px);
        right_motor_command = constrain((int)(BASE_SPEED + pid_adjustment), 0, 180);
        left_motor_command = constrain((int)(BASE_SPEED - pid_adjustment), 0, 180);
    } 

    if (right_motor_command < 100 && right_motor_command > 80 && left_motor_command < 100 && left_motor_command > 80){
        moveForward = true;
    }
    else{
        moveForward = false;
    }

    // 2. --- Switch Bus and Apply Array Motor Commands (Output) ---
    if (moveForward){
        // Serial.println("Dead Zone");
        if(isForwardMode && targetAquired > 0){
            currentServoStates[0] = 135;
            currentServoStates[4] = 45;
        }
        else{
            currentServoStates[0] = 90;
            currentServoStates[4] = 90;
        }
    }
    else{
        // Serial.println("Tracking");
        currentServoStates[0] = 180 - (uint8_t)right_motor_command;
        currentServoStates[4] = (uint8_t)left_motor_command;
    }

    setServoBus(); 
    drive.setServoAnglesFromArray(currentServoStates);
    
    // 3. --- Final Display Update and Loop Control ---
    M5.Display.setCursor(0, 0);
    M5.Display.printf("btn: %02x\n", thermal2.getButtonRawState());
    M5.Display.endWrite(); // Finish fast display updates

    // Optional: Add PID status to display for tuning
    M5.Display.setCursor(0, 140);
    M5.Display.printf("Kp:%.1f E:%.1f A:%.1f", Kp, error, (float)right_motor_command - 90.0);

    // Minor delay to stabilize the loop frequency
    delay(10); 
}