This document describes how the CCIT4080 CL05 PKPD Group2 project is structured, which code runs in each build, and how data and control flow through the system. It covers both the simple TFT clock (default build) and the MAX30102 Health Monitor with LVGL (combination build).
Table of Contents
- Project Overview
- Build System and Entry Points
- Code Flow A: Simple TFT Clock (Default)
- Code Flow B: MAX30102 Health Monitor with LVGL
- Component Deep Dive
- LVGL Integration and Display Pipeline
- Sensor and Algorithm Data Flow
- Event and UI Navigation Flow
- Timing and Loop Structure
- File and Dependency Map
1. Project Overview
- Target hardware: ESP32-S3 (board:
esp32-s3-devkitc-1). - Display: TFT LCD driven by TFT_eSPI (SPI; pins and driver in
User_Setup.h). - Optional: LVGL for GUI, MAX30102 for heart rate and SpO2.
The repo contains:
- Two distinct application entry points selected by PlatformIO env:
- Default env:
src/main.cpp— simple TFT clock (no LVGL, no sensors). - Combination env:
src/Combination/2.4_and_max30102_test/2.4_max30102.cpp— full health monitor (LVGL + MAX30102 + UI). - Shared library code in
lib/ui_manager/: display driver, UI manager, MAX30102 driver, heart-rate and SpO2 algorithms. - Test/component sketches under
src/Test_components/and other envs (display, touch, PPG, gyro, Bluetooth, etc.), each built only when that env is selected.
2. Build System and Entry Points
2.1 PlatformIO Environments (platformio.ini)
| Environment | Source Filter | What Runs |
|---|---|---|
[env] (base) |
(inherited) | Base config: ESP32-S3, Arduino, libs (TFT_eSPI, LVGL, MAX3010x, etc.). |
esp32-s3-devkitc-1 |
+<*> -<Test_components/> |
main.cpp only (and anything under src/ except Test_components/). Combination code is not compiled. |
2_4_and_max30102_test |
+<Combination/2.4_and_max30102_test/> |
Only 2.4_max30102.cpp. main.cpp is not compiled. |
1_69_inch_display_test |
+<Test_components/display_test/1.69_inch_display/> |
1.69" display test. |
2_4_inch_display_test |
+<Test_components/display_test/2.4_inch_display/> |
2.4" display test. |
| … (others) | Various | Button, gyro, PPG, Bluetooth tests. |
So:
- Default “upload” = Code Flow A (simple clock in
main.cpp). - Upload with env
2_4_and_max30102_test= Code Flow B (health monitor in2.4_max30102.cpp).
2.2 Key Build Flags
-DLV_CONF_PATH=${PROJECT_DIR}/lv_conf.h— LVGL uses a project-levellv_conf.h(must exist when building LVGL; some envs may use library default if not set).- Libraries: TFT_eSPI, RAK14014-FT6336U (touch), Adafruit MPU6050, SparkFun MAX3010x, lvgl, devxplained MAX3010x.
3. Code Flow A: Simple TFT Clock (Default)
Entry: src/main.cpp (used when building for esp32-s3-devkitc-1).
3.1 What Gets Compiled
- Only
main.cpp. - No
lib/ui_managerdisplay_driver, ui_manager, MAX30102, or algorithms are used. - No LVGL.
- TFT_eSPI and SPI are used directly.
3.2 Startup (setup())
- Optional:
Serial.begin(115200)(commented out in current code). - Display:
tft.init()— TFT_eSPI init (pins fromUser_Setup.h). - Orientation:
tft.setRotation(1)(landscape). - Clear:
tft.fillScreen(TFT_BLACK). - Text:
tft.setTextSize(1),tft.setTextColor(TFT_YELLOW, TFT_BLACK). - Timing:
targetTime = millis() + 1000for 1 s tick.
Time is taken from compile time via __TIME__ and parsed by conv2d() into hh, mm, ss (no RTC/NTP).
3.3 Main Loop (loop())
- Every 1 s (
targetTime <= millis()): - Advancess; on overflow updatemm, thenhh(24 h rollover). - Redraw digital time (hours:minutes, then seconds) and optional colon blink usingtft.drawNumber()/tft.drawChar(). - No LVGL, no sensors, no other tasks.
So the default code path is: init TFT → loop that only updates a simple digital clock once per second.
4. Code Flow B: MAX30102 Health Monitor with LVGL
Entry: src/Combination/2.4_and_max30102_test/2.4_max30102.cpp (used when building for 2_4_and_max30102_test).
This is the full application: display + LVGL + MAX30102 + HR/SpO2 + multi-screen UI.
4.1 Global Objects (Constructed Before setup())
MAX30102 max30102— I2C driver for MAX30102.HeartRateAlgorithm hrAlgorithm— HR from IR PPG.SpO2Algorithm spo2Algorithm— SpO2 from red/IR ratio.DisplayDriver displayDriver— Wraps TFT_eSPI and registers LVGL flush (and optional brightness); constructor setsg_displayDriver = this.UIManager uiManager— Creates all LVGL screens and widgets; constructor setsUIManager::_instance = this.
Plus LVGL display buffers (buf1, buf2) and timing/state variables (lastSensorRead, lastClockUpdate, lastTempRead, currentTemperature, currentHeartRate, currentSpO2).
4.2 Setup Sequence (Order Matters)
- Serial:
Serial.begin(115200),delay(1000). - I2C:
Wire.begin(I2C_SDA, I2C_SCL)— fromdisplay_config.h(e.g. 21/22). - MAX30102:
max30102.begin(Wire)— detect, reset, FIFO/mode/SpO2/LED config, clear FIFO. On failure, loop with error message. - Display:
displayDriver.init()— SPI,tft.init(), rotation 0, fill black,setSwapBytes(true), touch placeholder. (Backlight init is commented out in currentdisplay_driver.cpp.) - LVGL:
setupLVGL(): -lv_init()- Allocate two DMA-capable buffers:buffer_size = DISPLAY_WIDTH * DISPLAY_HEIGHT / 10(e.g. 7680 pixels each). -display = lv_display_create(DISPLAY_WIDTH, DISPLAY_HEIGHT)-lv_display_set_flush_cb(display, display_flush)-lv_display_set_buffers(display, buf1, buf2, buffer_size, LV_DISPLAY_RENDER_MODE_PARTIAL)- Create pointer input device:lv_indev_set_read_cb(indev, touchpad_read) - UI:
uiManager.init()— create all screens (main, analog clock, digital clock, temperature, heart rate, SpO2, settings), thenlv_scr_load(_mainScreen)and_currentScreen = SCREEN_MAIN. - Time: WiFi (optional), then either manual time (
setManualTime()) or NTP;setenv("TZ", ...),tzset(). Wait up to 10 s fortime(nullptr)to be valid.
After this, the device shows the main menu and is ready for the loop.
4.3 Main Loop (loop())
Each iteration (throttled by delay(5) at the end):
- LVGL:
lv_timer_handler()— input, animations, dirty regions, flush callbacks. - Sensors (every 10 ms):
IfcurrentMillis - lastSensorRead >= SENSOR_READ_INTERVAL(10 ms): -updateSensors():max30102.readFIFO(&red, &ir)→ push tohrAlgorithm.addSample(ir)andspo2Algorithm.addSample(red, ir).- If algorithms are ready:
currentHeartRate = hrAlgorithm.getHeartRate(),currentSpO2 = spo2Algorithm.getSpO2(). lastSensorRead = currentMillis.
- Clock (every 1 s):
IfcurrentMillis - lastClockUpdate >= CLOCK_UPDATE_INTERVAL(1000 ms): -uiManager.updateClock()(updates analog and digital clock widgets). -lastClockUpdate = currentMillis. - Temperature (every 5 s):
IfcurrentMillis - lastTempRead >= TEMP_READ_INTERVAL(5000 ms): -currentTemperature = max30102.readTemperature(), thenuiManager.updateTemperature(currentTemperature). -lastTempRead = currentMillis. - UI data:
uiManager.updateHeartRate(currentHeartRate)anduiManager.updateSpO2(currentSpO2)(update labels if present). - Events:
handleUIEvents()— currently a stub; real handling is via LVGL callbacks. - Throttle:
delay(5).
So in Code Flow B, the only application entry file is 2.4_max30102.cpp; it drives everything through these components and intervals.
5. Component Deep Dive
5.1 Display Driver (lib/ui_manager/display_driver.cpp, .h)
- Role: Bridge between LVGL and TFT_eSPI; optional backlight PWM.
- Global:
TFT_eSPI tft;static DisplayDriver *g_displayDriverset in constructor so C-callable LVGL callbacks can call back into the instance.
init():
SPI.begin()→tft.init()→tft.setRotation(0),tft.fillScreen(TFT_BLACK),tft.setSwapBytes(true).- Touch:
initTouch()(placeholder). - Backlight block (pinMode + setBrightness(80)) is commented out in current file; brightness can still be changed later via
setBrightness()if the pin is configured elsewhere.
flush(lv_display_t *, const lv_area_t *area, uint8_t *px_map):
- Called by LVGL when a region must be sent to the display.
- Computes width/height from
area, then: tft.startWrite()→tft.setAddrWindow(...)→tft.pushColors((uint16_t*)px_map, pixelCount, true)→tft.endWrite().- Periodically logs flush count, average pixels, last flush time, estimated FPS.
- Must call
lv_display_flush_ready(disp)when done so LVGL can reuse the buffer.
setBrightness(uint8_t brightness):
- Maps 0–100 to 0–255 and calls
analogWrite(DISPLAY_BL, pwmValue)(backlight pin fromdisplay_config.h, e.g. 15).
Callbacks (C linkage for LVGL):
display_flush(...)→ ifg_displayDrivernon-null,g_displayDriver->flush(...).touchpad_read(...)— placeholder: setsdata->state = LV_INDEV_STATE_RELEASED(no real touch).
5.2 Display Config (lib/ui_manager/display_config.h)
- Central pin and size defines:
DISPLAY_WIDTH,DISPLAY_HEIGHT,DISPLAY_MOSI,DISPLAY_SCLK,DISPLAY_CS,DISPLAY_DC,DISPLAY_RST,DISPLAY_BL, touch and I2C pins. TFT_eSPI itself is usually configured in its ownUser_Setup.h; this file is used by the ui_manager and combination sketch.
5.3 UI Manager (lib/ui_manager/ui_manager.cpp, .h)
- Role: Create and manage all LVGL screens and widgets; handle navigation and periodic updates (clock, temperature, HR, SpO2).
Screens (enum screen_id_t):
SCREEN_MAIN, SCREEN_CLOCK_ANALOG, SCREEN_CLOCK_DIGITAL, SCREEN_TEMPERATURE, SCREEN_HEART_RATE, SCREEN_SPO2, SCREEN_SETTINGS.
init():
- Creates each screen in turn:
createMainScreen(),createClockAnalogScreen(),createClockDigitalScreen(),createTemperatureScreen(),createHeartRateScreen(),createSpO2Screen(),createSettingsScreen(). - Then
lv_scr_load(_mainScreen)and_currentScreen = SCREEN_MAIN.
Screen creation (pattern):
lv_obj_create(NULL)for the screen; set background.- Add buttons/labels/sliders; for navigation buttons,
lv_obj_add_event_cb(..., mainBtnEventHandler/settingsBtnEventHandler/backBtnEventHandler, LV_EVENT_CLICKED, (void*)screen_id). - Store pointers to widgets that need updates (e.g.
_digitalClockLabel,_tempLabel,_hrLabel,_spo2Label,_brightnessSlider, clock hands).
Event handlers (static, pass _instance):
mainBtnEventHandler/settingsBtnEventHandler/backBtnEventHandler→_instance->showScreen(target).showScreen(screen_id_t)resolves screen pointer from enum, thenlv_scr_load(targetScreen)and_currentScreen = screen.brightnessChangeHandleris commented out in current code; when enabled it would doanalogWrite(DISPLAY_BL, map(lv_slider_get_value(slider), 10, 100, 0, 255)).
Updates (called from combination loop or timers):
updateClock()→updateAnalogClock()+updateDigitalClock()(only if current screen is clock; usestime()andlocaltime()).updateTemperature(float)→ set_tempLabeltext.updateHeartRate(uint8_t)/updateSpO2(uint8_t)→ set_hrLabel/_spo2Label(or "--" when 0).update()→ if on clock screen, callsupdateClock().
So Code Flow B drives all UI updates from 2.4_max30102.cpp’s loop using these methods.
5.4 MAX30102 Driver (lib/ui_manager/MAX30102_Driver.cpp, .h)
- Role: I2C access to MAX30102 (FIFO, mode, SpO2 config, temperature).
- Address: 0xAE (write). Registers: FIFO data/pointers, mode, SpO2 config, LED current, temperature, etc.
begin(Wire):
Check connection, reset(), delay(100), setup() (FIFO, MODE_SPO2, sample rate 100 Hz, LED pulse width 411 µs, ADC range, LED current, clear FIFO).
readFIFO(uint32_t *red, uint32_t *ir):
Read 6 bytes from REG_FIFO_DATA, pack into two 18-bit values, return true/false. Used at 100 Hz in updateSensors().
readTemperature():
Enable temp conversion, delay 100 ms, read integer and fractional registers, return °C. Used every 5 s in Code Flow B.
Other: getWritePointer(), getReadPointer(), getOVFCounter() for debugging; config helpers (setLEDCurrent, etc.).
5.5 Heart Rate Algorithm (lib/ui_manager/HeartRateAlgorithm.cpp, .h)
- Input: IR PPG samples from
readFIFO(one sample per call). - Output: BPM when
isDataReady()and enough peaks detected.
State: Circular buffer of 100 samples; “data ready” after first full lap; peak detection with minimum distance (e.g. 30 samples); store last 10 peak intervals.
addSample(irValue):
Store in buffer; when buffer has at least 3 samples and data ready, run detectPeak(current, prev, next) (current > prev and > next and > average × 1.1). On peak, record interval and update rolling intervals.
getHeartRate():
From average interval and sample rate (100 Hz), BPM = 6000 / avg_interval; clamped to valid range; returns 0 if not enough peaks.
5.6 SpO2 Algorithm (lib/ui_manager/SpO2Algorithm.cpp, .h)
- Input: Red and IR from
readFIFO(one pair per call). - Output: SpO2 % when
isDataReady().
State: Two circular buffers (red, IR), 100 samples each; “data ready” after first full lap.
addSample(red, ir):
Store in both buffers.
getSpO2():
DC = mean of buffer; AC = sqrt(mean of squared differences from mean). Ratio R = (red_AC/red_DC) / (ir_AC/ir_DC). SpO2 = 110 − 25×R (empirical), clamped 0–100. Returns 0 if ratio invalid or not ready.
6. LVGL Integration and Display Pipeline
6.1 When LVGL Is Used
- Only in Code Flow B (combination build). Code Flow A does not link LVGL or the display_driver’s flush.
6.2 Initialization (in setupLVGL() inside 2.4_max30102.cpp)
lv_init().- Two partial buffers (e.g. 7680 pixels each), DMA-capable.
lv_display_create(WIDTH, HEIGHT),lv_display_set_flush_cb(display, display_flush),lv_display_set_buffers(..., LV_DISPLAY_RENDER_MODE_PARTIAL).- Pointer indev with
touchpad_read(placeholder).
6.3 Render → Flush Path
- LVGL marks dirty regions (e.g. button redraw, clock hand move, label change).
lv_timer_handler()renders dirty areas into one of the two buffers.- For each dirty region, LVGL calls
display_flush(disp, area, px_map). display_flush(indisplay_driver.cpp) callsg_displayDriver->flush(disp, area, px_map).DisplayDriver::flushuses TFT_eSPI to sendareafrompx_mapto the display, thenlv_display_flush_ready(disp).
So the code flow for every frame update is: LVGL timer handler → flush callback → DisplayDriver::flush → TFT_eSPI → physical display.
6.4 Touch
touchpad_readis registered but currently always reports “released”; no coordinates. Real touch would require reading from a controller (e.g. FT6336) and fillingdata->pointanddata->state.
7. Sensor and Algorithm Data Flow
Only in Code Flow B:
MAX30102 (I2C)
→ readFIFO(red, ir) every 10 ms
→ hrAlgorithm.addSample(ir)
→ spo2Algorithm.addSample(red, ir)
→ (when ready) currentHeartRate = hrAlgorithm.getHeartRate()
→ (when ready) currentSpO2 = spo2Algorithm.getSpO2()
→ uiManager.updateHeartRate(currentHeartRate)
→ uiManager.updateSpO2(currentSpO2)
Temperature (every 5 s):
MAX30102 readTemperature()
→ currentTemperature
→ uiManager.updateTemperature(currentTemperature)
HR algorithm: IR buffer → peaks → intervals → BPM.
SpO2 algorithm: red/IR buffers → AC/DC → ratio → SpO2 %.
8. Event and UI Navigation Flow
- Source of events: LVGL (button clicks, slider changes) processed in
lv_timer_handler(). - Registration: In UIManager screen creation, e.g.
lv_obj_add_event_cb(clockBtn, mainBtnEventHandler, LV_EVENT_CLICKED, (void*)SCREEN_CLOCK_ANALOG). - Handler: Static method gets
lv_event_get_user_data(e)asscreen_id_t, then_instance->showScreen(screen). - Effect:
lv_scr_load(targetScreen),_currentScreen = screen; next LVGL render draws the new screen and flush sends it to the display.
Settings (brightness, clock format, temp unit) have widgets and handlers defined; brightness handler is commented out in current ui_manager.
9. Timing and Loop Structure
9.1 Code Flow A (main.cpp)
- Loop: No fixed period; only “when 1 s elapsed” then redraw clock. No
delayin the logic (tight loop until next second).
9.2 Code Flow B (2.4_max30102.cpp)
- Loop period: ~5 ms (dominated by
delay(5)). - 10 ms: Sensor read and algorithm update.
- 1 s: Clock UI update.
- 5 s: Temperature read and temperature UI update.
- Every iteration: LVGL handler, HR/SpO2 label updates,
handleUIEvents()stub.
So the code flow in the loop is: LVGL → 10 ms sensor → 1 s clock → 5 s temp → UI updates → delay(5).
10. File and Dependency Map
10.1 Entry Points
| File | Used by env | Depends on |
|---|---|---|
src/main.cpp |
esp32-s3-devkitc-1 |
Arduino, TFT_eSPI, SPI |
src/Combination/2.4_and_max30102_test/2.4_max30102.cpp |
2_4_and_max30102_test |
LVGL, Wire, display_driver, display_config, ui_manager, MAX30102_Driver, HeartRateAlgorithm, SpO2Algorithm |
10.2 Library Components (lib/ui_manager/)
| File | Role |
|---|---|
display_config.h |
Display and I2C pin defines, sizes |
display_driver.h / display_driver.cpp |
TFT init, LVGL flush, optional backlight |
ui_manager.h / ui_manager.cpp |
All screens, widgets, navigation, clock/temp/HR/SpO2 updates |
MAX30102_Driver.h / MAX30102_Driver.cpp |
MAX30102 I2C and FIFO/temp |
HeartRateAlgorithm.h / HeartRateAlgorithm.cpp |
HR from IR buffer and peaks |
SpO2Algorithm.h / SpO2Algorithm.cpp |
SpO2 from red/IR AC-DC ratio |
10.3 Existing Documentation
lib/ui_manager/CODE_EXECUTION_FLOW.md— Detailed MAX30102 + LVGL + UI flow (matches Code Flow B); register-level and timing details.lib/ui_manager/README.md, QUICKSTART.md, SERIAL_LOGGING.md, TFT_eSPI_SETUP.md — Setup, wiring, logging, TFT config.
Summary
- Default build runs only
main.cpp: a simple TFT digital clock with no LVGL and no sensors (Code Flow A). - Combination build (
2_4_and_max30102_test) runs only2.4_max30102.cpp: full health monitor with LVGL, DisplayDriver, UIManager, MAX30102, and HR/SpO2 algorithms (Code Flow B).
Setup order: Serial → I2C → MAX30102 → Display → LVGL (buffers + display + input) → UI (all screens) → time.
Loop: LVGL handler, 10 ms sensors/algorithms, 1 s clock update, 5 s temperature, UI label updates, then delay(5).
Display updates go through LVGL →display_flush→DisplayDriver::flush→ TFT_eSPI → hardware; navigation is event-driven via LVGL callbacks toUIManager::showScreen.
This document and lib/ui_manager/CODE_EXECUTION_FLOW.md together give the full picture of project structure and code flow for both entry points and all major components.