When makers search for energy robot project ideas, they usually find elementary school science fair dioramas or vague theoretical concepts. In embedded systems, a true "energy robot" is an autonomous platform designed to harvest, manage, and optimize its own power budget in real-time. It doesn't just consume battery; it actively hunts for energy and adjusts its computational and mechanical load to survive.

In this guide, we are building a Solar-Tracking Regenerative Rover. This platform uses an ESP32 to read ambient light gradients, physically tilts a solar array via a servo to maximize photon capture, and dynamically throttles its motor PWM based on the instantaneous harvest-to-consumption ratio. We will use the modern ESP32 Arduino Core 3.x API, avoiding deprecated functions that plague older tutorials.

Power Budget and Component Specifications

The biggest mistake in energy-harvesting robotics is pairing a high-quiescent microcontroller with an inefficient motor driver. The classic L298N motor driver uses bipolar junction transistors (BJTs), dropping roughly 2V across the H-bridge. On a 6V solar system, that is a catastrophic 33% power loss before the motor even spins. Instead, we use the TB6612FNG, which uses MOSFETs and drops only ~0.5V.

Below is the strict power budget for this build. This table dictates our solar panel sizing and battery capacity.

Component Exact Variant Quiescent (mA) Active/Peak (mA) Operating Voltage Max Power (mW)
Microcontroller ESP32-WROOM-32 DevKit V1 10 (deep sleep) 240 (WiFi TX) 3.3V 792
Motor Driver TB6612FNG (Pololu Carrier) 0.1 1200 (stall) 5.0V (VM) 6000
Light Sensor BH1750FVI (I2C) 0.01 0.19 3.3V 0.6
Panel Tilt Servo SG90 Micro (9g) 0 220 5.0V 1100
Drive Motors (x2) N20 Gear Motor (100:1) 0 160 (each) 5.0V 1600

Note: Peak system draw is roughly 8.5W. To sustain this, we use a 6V 3W monocrystalline panel paired with a 3.7V 3000mAh 18650 Li-ion cell and a TP4056 charge controller. The rover must duty-cycle its movement, driving only when the capacitor bank is buffered.

Hardware Bill of Materials and Wiring

Before writing code, map your physical connections. The ESP32-WROOM-32 DevKit V1 is our target board variant due to its built-in USB-to-UART bridge and accessible GPIO breakout. Ensure you are using the 30-pin variant, as the 38-pin variant shifts the I2C default pins on some silkscreens.

Callout Tip: The BH1750FVI sensor requires I2C pull-up resistors. Most cheap breakout boards include 4.7kΩ surface-mount resistors tied to VCC. If you are wiring a raw BH1750 chip, you must add 4.7kΩ pull-ups to GPIO 21 and GPIO 22, or the I2C bus will float and crash the ESP32.
Function ESP32 GPIO Target Module Pin Wiring Notes
I2C Data GPIO 21 BH1750 SDA Keep wire length < 30cm to avoid capacitance issues.
I2C Clock GPIO 22 BH1750 SCL Verify 4.7kΩ pull-up to 3.3V.
Motor A PWM GPIO 16 TB6612 PWMA 1kHz frequency, 8-bit resolution.
Motor A Dir 1 GPIO 17 TB6612 AIN1 Digital HIGH/LOW.
Motor A Dir 2 GPIO 18 TB6612 AIN2 Digital HIGH/LOW.
Servo Tilt PWM GPIO 19 SG90 Signal (Orange) 50Hz frequency, 16-bit resolution for precision.
Driver Logic VCC 3V3 Pin TB6612 VCC Logic level power (NOT motor power).
Driver Motor VM N/A (Battery) TB6612 VM Tied directly to 5V buck converter output.

Complete ESP32 Solar Tracking Code

The following C++ code targets the ESP32-WROOM-32 DevKit V1 using the ESP32 Arduino Core 3.x. Older tutorials rely on ledcSetup() and ledcAttachPin(), which were deprecated and removed in Core 3.0.0. This code uses the modern ledcAttach() API. It also implements raw I2C communication for the BH1750 to eliminate external library dependencies and demonstrate proper bus error handling.

#include <Wire.h>

// --- PIN DEFINITIONS ---
#define I2C_SDA       21
#define I2C_SCL       22
#define MOTOR_PWM_A   16
#define MOTOR_DIR_A1  17
#define MOTOR_DIR_A2  18
#define SERVO_PWM     19

// --- BH1750 I2C CONFIG ---
#define BH1750_ADDR   0x23
#define BH1750_CONT_H 0x10 // Continuously H-Resolution Mode

// --- SYSTEM THRESHOLDS ---
#define LIGHT_THRESHOLD_TRACK 150  // Lux threshold to start tracking
#define LIGHT_THRESHOLD_DRIVE 800  // Lux threshold to allow driving
#define SERVO_MIN_US  500
#define SERVO_MAX_US  2400

uint16_t currentLux = 0;
uint8_t servoAngle = 90;

void setup() {
  Serial.begin(115200);
  delay(500);
  Serial.println("[SYS] Solar Tracking Energy Rover Initializing...");

  // Initialize I2C Bus
  Wire.begin(I2C_SDA, I2C_SCL);
  Wire.setClock(100000); // 100kHz standard mode

  // Initialize Motor Driver Pins
  pinMode(MOTOR_DIR_A1, OUTPUT);
  pinMode(MOTOR_DIR_A2, OUTPUT);
  digitalWrite(MOTOR_DIR_A1, LOW);
  digitalWrite(MOTOR_DIR_A2, LOW);

  // Modern ESP32 Core 3.x LEDC API
  // Motor PWM: 1kHz, 8-bit resolution (0-255)
  ledcAttach(MOTOR_PWM_A, 1000, 8);
  
  // Servo PWM: 50Hz, 16-bit resolution for microsecond precision
  ledcAttach(SERVO_PWM, 50, 16);

  // Initialize BH1750 Sensor
  Wire.beginTransmission(BH1750_ADDR);
  Wire.write(BH1750_CONT_H);
  uint8_t i2cErr = Wire.endTransmission();
  if (i2cErr != 0) {
    Serial.printf("[ERR] BH1750 init failed. I2C Error code: %d\n", i2cErr);
  } else {
    Serial.println("[SYS] BH1750 online. Waiting for first measurement...");
    delay(180); // First measurement takes ~180ms in H-res mode
  }
}

void loop() {
  // 1. Read Light Sensor with Error Handling
  readLightSensor();

  // 2. Adjust Solar Panel Tilt (Simplified 1D Tracking)
  trackSun();

  // 3. Manage Drive Motors based on Energy Budget
  manageDrivePower();

  delay(500); // Sample rate limit
}

void readLightSensor() {
  uint8_t bytesReturned = Wire.requestFrom(BH1750_ADDR, (uint8_t)2);
  
  if (bytesReturned != 2) {
    // This is where the ESP-IDF logs the exact timeout error to the console
    Serial.println("[ERR] I2C Read Failed. Check pull-ups and wiring.");
    currentLux = 0;
    return;
  }

  uint8_t msb = Wire.read();
  uint8_t lsb = Wire.read();
  
  // Raw data is 2 bytes, divide by 1.2 for Lux
  currentLux = ((msb << 8) | lsb) / 1.2;
  Serial.printf("[DATA] Ambient Light: %u Lux\n", currentLux);
}

void trackSun() {
  // Basic sweep logic to find peak light (1D axis)
  // In a full build, you'd use two sensors for X/Y differential tracking
  static bool sweepDirection = true;
  
  if (currentLux < LIGHT_THRESHOLD_TRACK) {
    ledcWrite(MOTOR_PWM_A, 0); // Park motors if dark
    return;
  }

  if (sweepDirection) {
    servoAngle += 2;
    if (servoAngle >= 170) sweepDirection = false;
  } else {
    servoAngle -= 2;
    if (servoAngle <= 10) sweepDirection = true;
  }

  // Map angle to microsecond pulse width, then to 16-bit duty cycle
  uint32_t pulseUs = map(servoAngle, 0, 180, SERVO_MIN_US, SERVO_MAX_US);
  uint32_t duty = (pulseUs * 65535) / 20000; // 20ms period
  ledcWrite(SERVO_PWM, duty);
}

void manageDrivePower() {
  // Only drive if we are harvesting enough energy to sustain the motors
  if (currentLux > LIGHT_THRESHOLD_DRIVE) {
    // Drive forward
    digitalWrite(MOTOR_DIR_A1, HIGH);
    digitalWrite(MOTOR_DIR_A2, LOW);
    
    // Scale speed to available light (max 200/255 duty to prevent brownout)
    uint8_t speed = map(currentLux, LIGHT_THRESHOLD_DRIVE, 2000, 100, 200);
    speed = constrain(speed, 100, 200);
    ledcWrite(MOTOR_PWM_A, speed);
    Serial.printf("[ACT] Driving at PWM: %u\n", speed);
  } else {
    // Stop driving to conserve battery and charge
    digitalWrite(MOTOR_DIR_A1, LOW);
    digitalWrite(MOTOR_DIR_A2, LOW);
    ledcWrite(MOTOR_PWM_A, 0);
  }
}

Debugging: First Three Things to Check When It Fails

When working with energy-harvesting robots, power fluctuations cause phantom software errors. If your ESP32 halts or reboots, look at the serial monitor. The most common failure mode in this specific build is the I2C bus locking up due to voltage sag when the drive motors engage, resulting in this exact error string printed by the underlying ESP-IDF:

[E][Wire.cpp:500] requestFrom(): i2cWriteReadNonStop returned Error 263 (ESP_ERR_TIMEOUT)

If you see this, or if the rover simply freezes, execute these three diagnostic steps in order:

  1. Check the I2C Pull-Up Voltage Source: If your BH1750 breakout board pull-up resistors are tied to the 5V motor rail instead of the 3.3V logic rail, the voltage sag from the motors starting will drag the I2C SDA/SCL lines below the ESP32's logic high threshold (0.75 * VCC). Fix: Ensure pull-ups are tied strictly to the ESP32 3V3 pin.
  2. Measure the TB6612FNG VM Pin Under Load: Use a multimeter to measure the VM pin while the rover is stalled. If your solar panel/battery cannot supply the 1.2A stall current, the voltage will brownout, resetting the ESP32 via the shared ground plane. Fix: Add a 1000µF electrolytic capacitor across the TB6612 VM and GND pins to buffer transient current spikes.
  3. Verify GPIO Strapping Pin Conflicts: GPIO 16 and 17 are safe for PWM, but if you modified the pinout and accidentally used GPIO 0, 2, 5, 12, or 15, you will interfere with the ESP32's boot strapping pins, causing bootloops. Fix: Consult the Espressif Strapping Pin Documentation and reassign your PWM pins.

Extending and Simplifying the Build

This baseline code provides 1D (single-axis) tracking and basic light-threshold driving. Depending on your bench capabilities and budget, you can scale this project up or down.

How to Simplify (For Beginners or Tight Budgets)

  • Drop the Servo: Remove the trackSun() function and mount the solar panel flat. This eliminates the SG90 servo, saving 1.1W of peak power and removing complex PWM microsecond mapping from the code.
  • Use Photoresistors: Swap the BH1750 I2C sensor for two cheap CdS photoresistors wired in a voltage divider to the ESP32's ADC pins (GPIO 34 and 35). You lose absolute Lux calibration, but gain analog differential tracking without I2C bus headaches.

How to Extend (For Advanced Makers)

  • Add MPPT Logic: The TP4056 is a linear charger. For true energy optimization, integrate an I2C-enabled buck-boost MPPT (Maximum Power Point Tracking) IC like the LTC3105. You can read its telemetry via I2C and log actual harvest efficiency to an SD card.
  • Implement 2D Tracking with PID: Mount four BH1750 sensors in a cross pattern with physical dividers (blinders) between them. Use a PID control loop to calculate the X and Y error gradients, driving two servos to keep all four sensors perfectly balanced. Refer to the TB6612FNG datasheet for managing the thermal dissipation when driving two heavy servos simultaneously.
  • Deep Sleep Duty Cycling: Modify the code to use esp_deep_sleep_start(). The rover wakes every 5 minutes via an RTC timer, checks light levels, moves 10 degrees, and goes back to sleep, dropping the quiescent draw from 10mA to roughly 150µA.