Low-power solar monitoring node built on ESP32 with modular firmware architecture. Reads voltage (INA3221) and ambient light (LDR), renders live data on an OLED display, and enters Deep Sleep between cycles to minimise power consumption. Part of an embedded systems portfolio targeting real-world IoT applications.
| Component | Interface | Pin / Address |
|---|---|---|
| ESP32 DevKit | — | — |
| OLED SSD1306 (128×64) | I2C | 0x3C |
| INA3221 (3-channel voltage/current sensor) | I2C | 0x40 |
| LDR + 10 kΩ pull-down resistor | ADC | GPIO34 |
| ESP32 Pin | Signal | OLED | INA3221 |
|---|---|---|---|
| GPIO 21 | SDA | SDA | SDA |
| GPIO 22 | SCL | SCL | SCL |
| 3.3 V | VCC | VCC | VCC |
| GND | GND | GND | GND |
3.3V ──── LDR ──── GPIO34 ──── 10kΩ ──── GND
↑
ADC measurement node
Wake from Deep Sleep
│
▼
Init peripherals
(OLED, INA3221, LDR)
│
▼
┌─────────────────┐
│ 5 s wake window│
│ Poll every 300 ms:│
│ · Read INA3221 CH1│
│ · Read LDR ADC │
│ · Update OLED │
│ · Print Serial │
└─────────────────┘
│
▼
OLED display off
│
▼
Deep Sleep 25 s
(RTC memory retained)
│
└──────► (repeat)
Total duty cycle: 5 s active / 25 s sleeping = ~17% duty cycle.
src/
├── config.h — Centralised #defines: pins, I2C addresses, timing constants
├── display.h/.cpp — OLED driver: init, combined data screen, error screen, power-off
├── sensors.h/.cpp — INA3221 driver: init, per-channel bus voltage read
├── ldr.h/.cpp — LDR driver: init, ADC read with clamp, return as 0–100 %
└── main.cpp — Entry point: wake → sense → display → sleep
test/
├── validator.py — Serial port reader; validates voltage and light readings
├── test_solar_node.py — pytest suite: connection, range, light, stability (7 tests)
└── requirements.txt — pyserial, pytest
Each hardware driver is a self-contained .h/.cpp pair. The hardware object is static inside the .cpp file and never exposed beyond that translation unit — all interaction goes through the public API functions.
Deep Sleep — The node reads sensors, updates the display for 5 s, then sleeps for 25 s (30 s total cycle). setup() executes on every wake; loop() is intentionally empty. This eliminates the need for a state machine in the main loop.
Graceful degradation — Every peripheral init function returns bool. If a device is absent, the fault is logged to Serial and the system continues. An OLED-only or sensor-only failure doesn't crash the node.
F() macro for string literals — All fixed strings are stored in Flash rather than copied to SRAM at runtime. On a device with 520 KB SRAM and several KB of string output, this matters.
Encapsulation — Hardware objects are hidden inside their translation units and accessed only through function calls, preventing unintended shared state between drivers.
RTC_DATA_ATTR boot counter — The boot counter is placed in RTC-retained memory so it survives Deep Sleep without a battery-backed RTC chip. Costs 4 bytes of RTC SRAM.
OLED off before sleep — displayOff() clears and blanks the panel before the MCU sleeps, eliminating the ~1–2 mA idle current draw of a lit OLED.
ADC on GPIO34 (ADC1) — ADC2 is shared with the Wi-Fi radio and is unreliable when Wi-Fi is active. GPIO34 belongs to ADC1, which is safe to use regardless of radio state.
=== Solar IoT Node - Stage 3 | Boot #1 ===
[OK] SSD1306 OLED initialized at 0x3C
[OK] INA3221 initialized at 0x40
[DATA] CH1 Bus Voltage: 3.300 V
[DATA] Light: 75.8 %
[DATA] CH1 Bus Voltage: 3.301 V
[DATA] Light: 75.6 %
... (every 300 ms for 5 seconds)
[INFO] Sleeping for 25 seconds...
=== Solar IoT Node - Stage 3 | Boot #2 ===
...
- I2C bus initialised with explicit SDA/SCL pins (GPIO21/22)
- INA3221 reads CH1 bus voltage; result rendered on OLED
- Fault logged to Serial if no I2C device responds
- LDR + 10 kΩ voltage divider on GPIO34 (ADC1 — safe with Wi-Fi)
- ADC value clamped to [0, 4095] before conversion to 0–100 %
- Isolated into its own
ldr.h/ldr.cppmodule
- 30 s duty cycle: 5 s active → 25 s Deep Sleep
- Single OLED frame shows bus voltage, light %, and boot count
- Boot count persisted across sleep cycles via
RTC_DATA_ATTR - Sensors polled every 300 ms while awake
- Connect to Wi-Fi on wake; publish readings via MQTT or HTTP POST
- Log data to a time-series store (InfluxDB, Google Sheets, or similar)
- OTA (over-the-air) firmware updates via
ArduinoOTA
- 6 V solar panel stepped down via LM2596 buck converter
- INA3221 CH1: panel voltage / CH2: battery voltage / CH3: load current
- Fully off-grid — no USB power required
- PlatformIO (VS Code extension or CLI)
- ESP32 DevKit connected via USB
# Clone
git clone https://github.com/guyifergan1/solar-monitor.git
cd solar-monitor
# Open in VS Code — PlatformIO resolves and installs all libraries
code .
# Or upload directly from CLI
pio run --target uploadLibrary dependencies (auto-installed via platformio.ini):
adafruit/Adafruit INA3221 Library @ ^1.0.1adafruit/Adafruit GFX Library @ ^1.11.9adafruit/Adafruit SSD1306 @ ^2.5.9
cd test
pip install -r requirements.txt
pytest test_solar_node.py -vThe suite opens the serial port, reads live output, and verifies:
- Serial connection established within timeout
- Voltage readings in valid range (0–26 V)
- Light readings in valid range (0–100 %)
- Reading stability across 5 consecutive samples
| Area | Detail |
|---|---|
| Embedded C++ | Modular driver design, translation-unit encapsulation |
| Power management | Deep Sleep, OLED power-off, ADC1 vs ADC2 awareness |
| Hardware integration | I2C (multi-device), ADC, voltage divider |
| Firmware architecture | Graceful degradation, F() macro, RTC memory |
| Testing | Python pytest suite against live serial output |
| Toolchain | PlatformIO, library management, multi-platform build |
MIT — see LICENSE.
Guy Ifergan — Electrical Engineering student
GitHub: @guyifergan1