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

  1. Project Overview
  2. Build System and Entry Points
  3. Code Flow A: Simple TFT Clock (Default)
  4. Code Flow B: MAX30102 Health Monitor with LVGL
  5. Component Deep Dive
  6. LVGL Integration and Display Pipeline
  7. Sensor and Algorithm Data Flow
  8. Event and UI Navigation Flow
  9. Timing and Loop Structure
  10. 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 in 2.4_max30102.cpp).

2.2 Key Build Flags

  • -DLV_CONF_PATH=${PROJECT_DIR}/lv_conf.h — LVGL uses a project-level lv_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_manager display_driver, ui_manager, MAX30102, or algorithms are used.
  • No LVGL.
  • TFT_eSPI and SPI are used directly.

3.2 Startup (setup())

  1. Optional: Serial.begin(115200) (commented out in current code).
  2. Display: tft.init() — TFT_eSPI init (pins from User_Setup.h).
  3. Orientation: tft.setRotation(1) (landscape).
  4. Clear: tft.fillScreen(TFT_BLACK).
  5. Text: tft.setTextSize(1), tft.setTextColor(TFT_YELLOW, TFT_BLACK).
  6. Timing: targetTime = millis() + 1000 for 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())

  1. Every 1 s (targetTime <= millis()): - Advance ss; on overflow update mm, then hh (24 h rollover). - Redraw digital time (hours:minutes, then seconds) and optional colon blink using tft.drawNumber() / tft.drawChar().
  2. 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 sets g_displayDriver = this.
  • UIManager uiManager — Creates all LVGL screens and widgets; constructor sets UIManager::_instance = this.

Plus LVGL display buffers (buf1, buf2) and timing/state variables (lastSensorRead, lastClockUpdate, lastTempRead, currentTemperature, currentHeartRate, currentSpO2).

4.2 Setup Sequence (Order Matters)

  1. Serial: Serial.begin(115200), delay(1000).
  2. I2C: Wire.begin(I2C_SDA, I2C_SCL) — from display_config.h (e.g. 21/22).
  3. MAX30102: max30102.begin(Wire) — detect, reset, FIFO/mode/SpO2/LED config, clear FIFO. On failure, loop with error message.
  4. Display: displayDriver.init() — SPI, tft.init(), rotation 0, fill black, setSwapBytes(true), touch placeholder. (Backlight init is commented out in current display_driver.cpp.)
  5. 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)
  6. UI: uiManager.init() — create all screens (main, analog clock, digital clock, temperature, heart rate, SpO2, settings), then lv_scr_load(_mainScreen) and _currentScreen = SCREEN_MAIN.
  7. Time: WiFi (optional), then either manual time (setManualTime()) or NTP; setenv("TZ", ...), tzset(). Wait up to 10 s for time(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):

  1. LVGL: lv_timer_handler() — input, animations, dirty regions, flush callbacks.
  2. Sensors (every 10 ms):
    If currentMillis - lastSensorRead >= SENSOR_READ_INTERVAL (10 ms): - updateSensors():
    • max30102.readFIFO(&red, &ir) → push to hrAlgorithm.addSample(ir) and spo2Algorithm.addSample(red, ir).
    • If algorithms are ready: currentHeartRate = hrAlgorithm.getHeartRate(), currentSpO2 = spo2Algorithm.getSpO2().
    • lastSensorRead = currentMillis.
  3. Clock (every 1 s):
    If currentMillis - lastClockUpdate >= CLOCK_UPDATE_INTERVAL (1000 ms): - uiManager.updateClock() (updates analog and digital clock widgets). - lastClockUpdate = currentMillis.
  4. Temperature (every 5 s):
    If currentMillis - lastTempRead >= TEMP_READ_INTERVAL (5000 ms): - currentTemperature = max30102.readTemperature(), then uiManager.updateTemperature(currentTemperature). - lastTempRead = currentMillis.
  5. UI data: uiManager.updateHeartRate(currentHeartRate) and uiManager.updateSpO2(currentSpO2) (update labels if present).
  6. Events: handleUIEvents() — currently a stub; real handling is via LVGL callbacks.
  7. 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_displayDriver set 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 from display_config.h, e.g. 15).

Callbacks (C linkage for LVGL):

  • display_flush(...) → if g_displayDriver non-null, g_displayDriver->flush(...).
  • touchpad_read(...) — placeholder: sets data->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 own User_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, then lv_scr_load(targetScreen) and _currentScreen = screen.
  • brightnessChangeHandler is commented out in current code; when enabled it would do analogWrite(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; uses time() and localtime()).
  • updateTemperature(float) → set _tempLabel text.
  • updateHeartRate(uint8_t) / updateSpO2(uint8_t) → set _hrLabel / _spo2Label (or "--" when 0).
  • update() → if on clock screen, calls updateClock().

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

  1. LVGL marks dirty regions (e.g. button redraw, clock hand move, label change).
  2. lv_timer_handler() renders dirty areas into one of the two buffers.
  3. For each dirty region, LVGL calls display_flush(disp, area, px_map).
  4. display_flush (in display_driver.cpp) calls g_displayDriver->flush(disp, area, px_map).
  5. DisplayDriver::flush uses TFT_eSPI to send area from px_map to the display, then lv_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_read is registered but currently always reports “released”; no coordinates. Real touch would require reading from a controller (e.g. FT6336) and filling data->point and data->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) as screen_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 delay in 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 only 2.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_flushDisplayDriver::flush → TFT_eSPI → hardware; navigation is event-driven via LVGL callbacks to UIManager::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.