Week 15: Interface and Application Programming

Individual Assignment:

Write an application that interfaces a user with an input &/or output device that you made.

Group Assignment:

Compare as many tool options as possible.


Starting Point: What I Thought I Was Building

At the beginning of this week, the task sounded quite simple: build a parent-facing app for my smart piggy bank.

The idea in my head was very direct:

  • Parent sets a savings goal
  • Kid adds money through the piggy bank
  • App shows how much has been saved

  • It felt like a straightforward “app problem”.


    Where Things Started Getting Unclear

    As soon as I tried to think about implementation, things started breaking apart.

    I realized I didn’t actually know how the system was supposed to behave behind the scenes:

  • Where is the goal stored?
  • Who keeps track of the total amount?
  • Who calculates the percentage?
  • How does the app and device even communicate?

  • At this point, everything existed as one blurred system rather than clearly defined parts.


    Breaking It Down (First Real Step)

    To get some clarity, I split the system into three components:

  • ESP32 (device) → handles physical interaction
  • Mobile App (Kodular) → interface for the parent
  • Cloud layer (later Firebase) → shared data between both

  • This was the first moment the project started to feel like a system instead of just an app.


    Important Design Decision

    One decision that shaped everything after this:

    The app should not perform any calculations.

    So the responsibility became:

    ESP32

  • calculate total
  • calculate percentage
  • handle logic
  • App

  • display values
  • send goal

  • This made the architecture cleaner, but it also meant the ESP32 had to do more than I initially expected.


    Rough Data Flow (At That Time)

    This is how I imagined things would work:

    
    App → sends goal
    ESP32 → reads goal
    User input → adds money
    ESP32 → updates total + percentage
    ESP32 → sends data to cloud
    App → reads and displays
    
    

    At this stage, this was just a conceptual flow — nothing was implemented yet.


    Ground Reality

    Printing process

    Right now, I had:

  • no working app
  • no database
  • no communication between anything

  • Only a rough idea of how things should work.

    So instead of jumping into everything at once, I decided to start from the most controlled part of the system:

    → the button input on the ESP32


    Transition

    Before anything could go to Firebase or the app, I needed one thing to work perfectly:

    a reliable way to convert physical interaction into meaningful input.


    Button Logic - Starting from What I Already Had

    After mapping out the system, I still didn’t have anything functional. So instead of trying to build everything at once, I went back to something I had already made.

    Starting Point: My Previous PCB

    In the PCB production week, I had designed a board with:

  • XIAO ESP32-C6
  • Push button
  • LED
  • At that time, I had already experimented with:

  • button input
  • LED feedback
  • Serial output
  • That setup was stable and something I understood.

    So instead of reinventing everything, I decided to reuse that logic.


    The Shift in Thinking

    At this point, the idea clicked:

    treat the button as a coin input system.

    So I mapped interactions like this:

  • Single press → ₹1 → LED blinks once
  • Double press → ₹2 → LED blinks twice
  • Long press → ₹5 → LED stays on briefly
  • And every interaction should:

  • print to Serial
  • update a running total


    Building on Existing Logic

    I didn’t start from scratch. I reused parts of my earlier working code and extended it to:

  • detect press types (single, double, long)
  • maintain a running total
  • print clean outputs only when an action happens
  • This made the system much more stable from the beginning.


    First Working Behavior

    Printing process

    At this stage:

  • input was reliable
  • feedback was clear (LED + Serial)
  • total was updating correctly
  • This was the first time I had actual data being generated by the system.


    Prompt used on Chatgpt

    I am working with a XIAO ESP32C6 board and I want to build on a button interaction system similar to the simple edge-detection
    code I previously used successfully (where a button press toggled LED behavior reliably). Using that as a reference for stable
    button handling, I now want to extend the logic. The hardware setup is: a push button connected to pin D8 and a yellow LED connected
    to pin D2. I want to use the button to simulate coin inputs with three types of interactions: a single press should represent 1 rupee
    and trigger a single LED blink, a double press should represent 2 rupees and trigger a double blink, and a long press (around 1 second
    or more) should represent 5 rupees and trigger a longer LED glow. The system should use proper edge detection instead of continuous
    reading, include basic debounce handling, and use timing logic (millis) to distinguish between single, double, and long presses without
    conflicts (for example, a long press should not also register as a single press). The code should maintain a running total and print
    updates to the Serial Monitor only when an interaction occurs. 
    

    Code

    // -------- PIN SETUP -------- #define BUTTON_PIN D8 // button input #define LED_PIN D2 // yellow LED (active LOW or HIGH — we'll handle) // -------- TIMING CONFIG -------- const unsigned long debounceDelay = 50; // debounce time const unsigned long longPressTime = 1000; // long press threshold const unsigned long doublePressGap = 400; // max gap for double press // -------- STATE VARIABLES -------- bool buttonState = LOW; // current stable state bool lastButtonReading = LOW; // last raw reading unsigned long lastDebounceTime = 0; unsigned long pressStartTime = 0; unsigned long lastReleaseTime = 0; int pressCount = 0; int totalAmount = 0; // -------- SETUP -------- void setup() { Serial.begin(115200); pinMode(BUTTON_PIN, INPUT_PULLDOWN); // using your proven setup pinMode(LED_PIN, OUTPUT); digitalWrite(LED_PIN, LOW); // LED OFF initially } // -------- LOOP -------- void loop() { int reading = digitalRead(BUTTON_PIN); // -------- DEBOUNCE -------- if (reading != lastButtonReading) { lastDebounceTime = millis(); // reset debounce timer } if ((millis() - lastDebounceTime) > debounceDelay) { if (reading != buttonState) { buttonState = reading; // -------- BUTTON PRESSED -------- if (buttonState == HIGH) { pressStartTime = millis(); // start timing } // -------- BUTTON RELEASED -------- else { unsigned long pressDuration = millis() - pressStartTime; // -------- LONG PRESS -------- if (pressDuration >= longPressTime) { handleLongPress(); // ₹5 pressCount = 0; // reset count (important) } else { // short press → count for single/double pressCount++; lastReleaseTime = millis(); } } } } // -------- SINGLE / DOUBLE DECISION -------- if (pressCount > 0 && (millis() - lastReleaseTime) > doublePressGap) { if (pressCount == 1) { handleSinglePress(); // ₹1 } else if (pressCount == 2) { handleDoublePress(); // ₹2 } pressCount = 0; // reset after handling } lastButtonReading = reading; } // -------- ACTIONS -------- // SINGLE PRESS → ₹1 void handleSinglePress() { totalAmount += 1; Serial.print("Single Press | +1 | Total: "); Serial.println(totalAmount); // single blink digitalWrite(LED_PIN, HIGH); delay(120); digitalWrite(LED_PIN, LOW); } // DOUBLE PRESS → ₹2 void handleDoublePress() { totalAmount += 2; Serial.print("Double Press | +2 | Total: "); Serial.println(totalAmount); // double blink for (int i = 0; i < 2; i++) { digitalWrite(LED_PIN, HIGH); delay(120); digitalWrite(LED_PIN, LOW); delay(120); } } // LONG PRESS → ₹5 void handleLongPress() { totalAmount += 5; Serial.print("Long Press | +5 | Total: "); Serial.println(totalAmount); // long glow digitalWrite(LED_PIN, HIGH); delay(400); digitalWrite(LED_PIN, LOW); }


    Extending It: Introducing a Goal

    Once the total was working, I added a simple constraint:

  • Goal = ₹100 (hardcoded for now)
  • From this, I calculated:

  • percentage = (total / goal) × 100
  • Now the system wasn’t just counting, it had context.


    Adding Milestones

    To make progress visible, I introduced milestone checks:

  • 25%
  • 50%
  • 75%
  • 100%
  • Each milestone triggered a Serial message and LED feedback.

    This made the system feel more interactive and state-aware.


    Prompt used on Chatgpt

    Now that the single, double, and long press logic is working reliably with the button on D8 and the LED on D2, I want to build on
    this same system instead of changing anything. Next, I want to introduce a goal into this flow. So the idea is that the device
    keeps adding values (1, 2, or 5 rupees based on interaction), tracks a running total, and compares it to a fixed goal (for now hardcoded).
    From that, it should calculate percentage progress and check for milestones at 25%, 50%, 75%, and 100%. When it reaches 100%, it should
    trigger a clear feedback moment (like a slightly different LED pattern), and then reset everything back to 0 so the cycle can start again.
    I want to keep the same structure and stability from the previous working code, just extend it to include this goal, percentage, milestone
    tracking, and reset behavior without breaking the interaction logic.
    

    Code

    // -------- PIN SETUP -------- #define BUTTON_PIN D8 // button input (INPUT_PULLDOWN) #define LED_PIN D2 // yellow LED // -------- TIMING CONFIG -------- const unsigned long debounceDelay = 50; const unsigned long longPressTime = 1000; const unsigned long doublePressGap = 400; // -------- SYSTEM STATE -------- int totalAmount = 0; int goal = 100; bool milestone25 = false; bool milestone50 = false; bool milestone75 = false; bool milestone100 = false; // -------- BUTTON STATE -------- bool buttonState = LOW; bool lastButtonReading = LOW; unsigned long lastDebounceTime = 0; unsigned long pressStartTime = 0; unsigned long lastReleaseTime = 0; int pressCount = 0; // -------- SETUP -------- void setup() { Serial.begin(115200); pinMode(BUTTON_PIN, INPUT_PULLDOWN); pinMode(LED_PIN, OUTPUT); digitalWrite(LED_PIN, LOW); // LED OFF initially } // -------- LOOP -------- void loop() { int reading = digitalRead(BUTTON_PIN); // -------- DEBOUNCE -------- if (reading != lastButtonReading) { lastDebounceTime = millis(); } if ((millis() - lastDebounceTime) > debounceDelay) { ``` if (reading != buttonState) { buttonState = reading; // -------- BUTTON PRESSED -------- if (buttonState == HIGH) { pressStartTime = millis(); } // -------- BUTTON RELEASED -------- else { unsigned long pressDuration = millis() - pressStartTime; // -------- LONG PRESS -------- if (pressDuration >= longPressTime) { handleLongPress(); pressCount = 0; } else { pressCount++; lastReleaseTime = millis(); } } } ``` } // -------- SINGLE / DOUBLE -------- if (pressCount > 0 && (millis() - lastReleaseTime) > doublePressGap) { ``` if (pressCount == 1) { handleSinglePress(); } else if (pressCount == 2) { handleDoublePress(); } pressCount = 0; ``` } lastButtonReading = reading; } // -------- ACTIONS -------- // Single press → ₹1 void handleSinglePress() { addAmount(1); blinkLED(1); } // Double press → ₹2 void handleDoublePress() { addAmount(2); blinkLED(2); } // Long press → ₹5 void handleLongPress() { addAmount(5); // longer glow digitalWrite(LED_PIN, HIGH); delay(400); digitalWrite(LED_PIN, LOW); } // -------- CORE LOGIC -------- void addAmount(int value) { // prevent adding after goal already reached if (milestone100) return; totalAmount += value; // clamp to goal if (totalAmount > goal) { totalAmount = goal; } int percentage = (totalAmount * 100) / goal; Serial.print("Added: "); Serial.print(value); Serial.print(" | Total: "); Serial.print(totalAmount); Serial.print(" | "); Serial.print(percentage); Serial.println("%"); checkMilestones(percentage); } // -------- LED HELPER -------- void blinkLED(int times) { for (int i = 0; i < times; i++) { digitalWrite(LED_PIN, HIGH); delay(120); digitalWrite(LED_PIN, LOW); delay(120); } } // -------- RESET SYSTEM -------- void resetSystem() { totalAmount = 0; milestone25 = false; milestone50 = false; milestone75 = false; milestone100 = false; Serial.println("System Reset → Back to 0%"); } // -------- MILESTONE LOGIC -------- void checkMilestones(int percentage) { if (!milestone25 && percentage >= 25) { milestone25 = true; Serial.println("Milestone: 25% reached"); } if (!milestone50 && percentage >= 50) { milestone50 = true; Serial.println("Milestone: 50% reached"); } if (!milestone75 && percentage >= 75) { milestone75 = true; Serial.println("Milestone: 75% reached"); } if (!milestone100 && percentage >= 100) { milestone100 = true; ``` Serial.println("Milestone: Goal reached!"); // -------- CELEBRATION -------- for (int i = 0; i < 3; i++) { digitalWrite(LED_PIN, HIGH); delay(150); digitalWrite(LED_PIN, LOW); delay(150); } delay(500); // pause before reset resetSystem(); // restart cycle ``` } }

    Problem found at this stage: Overflow

    The First Real Bug

    Everything seemed fine, until I crossed the goal.

    Example:

  • Total = 96
  • Add ₹5 → becomes 101 → 101%
  • At this point, I ran into the first issue.


    Issue 1: Overflow

    The system was allowing the total to exceed the goal.

    So instead of stopping at 100, it went beyond:

    Total: 101 | 101%

    This didn’t make sense for the system I was trying to build.

    I wanted the goal to act as a hard boundary, not something that could be exceeded.


    Fix: Clamp to Goal

    I first fixed this by restricting the value:

  • if total > goal → set total = goal
  • So the system would properly stop at 100%.

    Problem found at this stage: Remainder loss

    Issue 2: Remainder Loss (More Subtle Problem)

    After fixing overflow, I noticed another issue.

  • Total = 97
  • Add ₹5
  • Expected behavior:

  • 100 completes the goal
  • remaining ₹2 should carry forward
  • Actual behavior:

  • system reset to 0
  • extra ₹2 was lost


  • Printing process

    So even though overflow was visually fixed, logically it was still incorrect.


    Final Fix: Carry Forward the Remainder

    To solve this properly, I changed the reset logic:

  • calculate overflow = total − goal
  • after goal completion:

  • reset milestones
  • set total = overflow
  • So now:

  • 102 → reset → starts next cycle at 2
  • This made the system behave more like a real piggy bank— no money disappears.


    Prompt used on Chatgpt

    Now that the interaction logic, goal tracking, and reset cycle are working properly, I noticed an issue when the total exceeds the
    goal. For example, if the total is 97 and I add 5, it becomes 102, but earlier the system was resetting to 0 and losing the extra 2.
    I want to fix this so that the overflow is not lost. Instead, when the goal is reached or exceeded, the system should calculate the
    remainder (like 102 → 100 + 2), trigger the goal completion feedback, and then reset while carrying forward the leftover amount into
    the next cycle. The rest of the logic should remain the same—single, double, and long press behavior, milestone tracking, and clean
    serial output. I just want to extend the reset logic so it behaves more realistically and doesn’t discard any value.
    

    Code

    // -------- PIN SETUP -------- #define BUTTON_PIN D8 // button input (INPUT_PULLDOWN) #define LED_PIN D2 // yellow LED // -------- TIMING CONFIG -------- const unsigned long debounceDelay = 50; const unsigned long longPressTime = 1000; const unsigned long doublePressGap = 400; // -------- SYSTEM STATE -------- int totalAmount = 0; int goal = 100; bool milestone25 = false; bool milestone50 = false; bool milestone75 = false; bool milestone100 = false; // -------- BUTTON STATE -------- bool buttonState = LOW; bool lastButtonReading = LOW; unsigned long lastDebounceTime = 0; unsigned long pressStartTime = 0; unsigned long lastReleaseTime = 0; int pressCount = 0; // -------- SETUP -------- void setup() { Serial.begin(115200); pinMode(BUTTON_PIN, INPUT_PULLDOWN); pinMode(LED_PIN, OUTPUT); digitalWrite(LED_PIN, LOW); } // -------- LOOP -------- void loop() { int reading = digitalRead(BUTTON_PIN); // -------- DEBOUNCE -------- if (reading != lastButtonReading) { lastDebounceTime = millis(); } if ((millis() - lastDebounceTime) > debounceDelay) { if (reading != buttonState) { buttonState = reading; // -------- BUTTON PRESSED -------- if (buttonState == HIGH) { pressStartTime = millis(); } // -------- BUTTON RELEASED -------- else { unsigned long pressDuration = millis() - pressStartTime; // -------- LONG PRESS -------- if (pressDuration >= longPressTime) { handleLongPress(); pressCount = 0; } else { pressCount++; lastReleaseTime = millis(); } } } } // -------- SINGLE / DOUBLE -------- if (pressCount > 0 && (millis() - lastReleaseTime) > doublePressGap) { if (pressCount == 1) { handleSinglePress(); } else if (pressCount == 2) { handleDoublePress(); } pressCount = 0; } lastButtonReading = reading; } // -------- ACTIONS -------- // Single press → ₹1 void handleSinglePress() { addAmount(1); blinkLED(1); } // Double press → ₹2 void handleDoublePress() { addAmount(2); blinkLED(2); } // Long press → ₹5 void handleLongPress() { addAmount(5); // long glow digitalWrite(LED_PIN, HIGH); delay(400); digitalWrite(LED_PIN, LOW); } // -------- CORE LOGIC -------- void addAmount(int value) { totalAmount += value; int percentage = (totalAmount * 100) / goal; Serial.print("Added: "); Serial.print(value); Serial.print(" | Total: "); Serial.print(totalAmount); Serial.print(" | "); Serial.print(percentage); Serial.println("%"); // -------- CHECK GOAL WITH REMAINDER -------- if (totalAmount >= goal) { int overflow = totalAmount - goal; // calculate leftover Serial.println("Milestone: Goal reached!"); // celebration pattern for (int i = 0; i < 3; i++) { digitalWrite(LED_PIN, HIGH); delay(150); digitalWrite(LED_PIN, LOW); delay(150); } delay(500); // -------- RESET WITH CARRY FORWARD -------- totalAmount = overflow; milestone25 = false; milestone50 = false; milestone75 = false; milestone100 = false; Serial.print("System Reset → Carry Forward: "); Serial.println(totalAmount); return; // stop further milestone checks } // -------- NORMAL MILESTONE CHECK -------- checkMilestones(percentage); } // -------- LED HELPER -------- void blinkLED(int times) { for (int i = 0; i < times; i++) { digitalWrite(LED_PIN, HIGH); delay(120); digitalWrite(LED_PIN, LOW); delay(120); } } // -------- MILESTONE LOGIC -------- void checkMilestones(int percentage) { if (!milestone25 && percentage >= 25) { milestone25 = true; Serial.println("Milestone: 25% reached"); } if (!milestone50 && percentage >= 50) { milestone50 = true; Serial.println("Milestone: 50% reached"); } if (!milestone75 && percentage >= 75) { milestone75 = true; Serial.println("Milestone: 75% reached"); } }

    Printing process

    Why This Step Mattered

    At this stage, I had:

  • a reliable input system
  • a running total
  • goal-based logic
  • proper handling of edge cases (overflow + remainder)
  • state transitions (milestones + reset)

  • Even though nothing was connected to server or the app yet, this became the core engine of the entire project.


    Transition

    Now that the device could generate structured and reliable data, the next question was:

    how do I get this data out of the ESP32?


    Connecting the System - WiFi and Firebase

    Up to this point, the system could generate data reliably, but it was still confined to the ESP32.

    Every interaction - button press, total update, milestone - was happening internally, visible only through the Serial Monitor.

    The next step was to move that data outside the device.


    Step 1: Getting the ESP32 Online

    I started by testing a basic WiFi connection.

    The goal here wasn’t complexity, it was verification:

  • can the board connect to a network?
  • does it stay connected?
  • can I see a valid IP address?
  • Once the Serial Monitor showed a successful connection and an IP address, it confirmed that the ESP32 was now part of a network, not just a standalone device.

    Wifi Test Code

    // -------- WIFI TEST -------- #include <WiFi.h> const char* ssid = "MY SSID"; const char* password = "PASSWORD"; void setup() { Serial.begin(115200); delay(1000); Serial.println("Connecting to WiFi..."); WiFi.begin(ssid, password); // wait until connected while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println("\nConnected!"); Serial.print("IP Address: "); Serial.println(WiFi.localIP()); } void loop() { // nothing here }


    Integrating WiFi into the Existing System

    After confirming WiFi worked independently, I added it into my existing button logic code.

    Wifi integrated code

    // -------- WIFI -------- #include <WiFi.h> const char* ssid = "MiCho"; const char* password = "Hello1234"; // -------- PIN SETUP -------- #define BUTTON_PIN D8 #define LED_PIN D2 // -------- TIMING CONFIG -------- const unsigned long debounceDelay = 50; const unsigned long longPressTime = 1000; const unsigned long doublePressGap = 400; // -------- SYSTEM STATE -------- int totalAmount = 0; int goal = 100; bool milestone25 = false; bool milestone50 = false; bool milestone75 = false; // -------- BUTTON STATE -------- bool buttonState = LOW; bool lastButtonReading = LOW; unsigned long lastDebounceTime = 0; unsigned long pressStartTime = 0; unsigned long lastReleaseTime = 0; int pressCount = 0; // -------- SETUP -------- void setup() { Serial.begin(115200); pinMode(BUTTON_PIN, INPUT_PULLDOWN); pinMode(LED_PIN, OUTPUT); digitalWrite(LED_PIN, LOW); // -------- WIFI CONNECT -------- Serial.println("Connecting to WiFi..."); WiFi.begin(ssid, password); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println("\nWiFi Connected!"); Serial.print("IP Address: "); Serial.println(WiFi.localIP()); } // -------- LOOP -------- void loop() { int reading = digitalRead(BUTTON_PIN); // -------- DEBOUNCE -------- if (reading != lastButtonReading) { lastDebounceTime = millis(); } if ((millis() - lastDebounceTime) > debounceDelay) { if (reading != buttonState) { buttonState = reading; if (buttonState == HIGH) { pressStartTime = millis(); } else { unsigned long pressDuration = millis() - pressStartTime; if (pressDuration >= longPressTime) { handleLongPress(); pressCount = 0; } else { pressCount++; lastReleaseTime = millis(); } } } } if (pressCount > 0 && (millis() - lastReleaseTime) > doublePressGap) { if (pressCount == 1) { handleSinglePress(); } else if (pressCount == 2) { handleDoublePress(); } pressCount = 0; } lastButtonReading = reading; } // -------- ACTIONS -------- void handleSinglePress() { addAmount(1); blinkLED(1); } void handleDoublePress() { addAmount(2); blinkLED(2); } void handleLongPress() { addAmount(5); digitalWrite(LED_PIN, HIGH); delay(400); digitalWrite(LED_PIN, LOW); } // -------- CORE LOGIC -------- void addAmount(int value) { totalAmount += value; int percentage = (totalAmount * 100) / goal; Serial.print("Added: "); Serial.print(value); Serial.print(" | Total: "); Serial.print(totalAmount); Serial.print(" | "); Serial.print(percentage); Serial.println("%"); // -------- GOAL CHECK WITH REMAINDER -------- if (totalAmount >= goal) { int overflow = totalAmount - goal; Serial.println("Milestone: Goal reached!"); for (int i = 0; i < 3; i++) { digitalWrite(LED_PIN, HIGH); delay(150); digitalWrite(LED_PIN, LOW); delay(150); } delay(500); totalAmount = overflow; milestone25 = false; milestone50 = false; milestone75 = false; Serial.print("System Reset → Carry Forward: "); Serial.println(totalAmount); return; } checkMilestones(percentage); } // -------- LED -------- void blinkLED(int times) { for (int i = 0; i < times; i++) { digitalWrite(LED_PIN, HIGH); delay(120); digitalWrite(LED_PIN, LOW); delay(120); } } // -------- MILESTONES -------- void checkMilestones(int percentage) { if (!milestone25 && percentage >= 25) { milestone25 = true; Serial.println("Milestone: 25% reached"); } if (!milestone50 && percentage >= 50) { milestone50 = true; Serial.println("Milestone: 50% reached"); } if (!milestone75 && percentage >= 75) { milestone75 = true; Serial.println("Milestone: 75% reached"); } }

    Printing process

    Introducing Firebase (First Time Use)

    Now that the device could connect to the internet, I needed a way to store and share data.

    Printing process

    The requirement was specific:

  • the ESP32 should be able to write data
  • the app should be able to read it
  • both should stay in sync
  • This is where Firebase came in.


    What Firebase Is (In This Context)

    Instead of treating Firebase as a generic backend, I approached it as:

    a shared data layer between the device and the app

    It acts like a central state that both sides can access.

    In this system:

  • ESP32 updates values like total, percentage, and last coin
  • the app reads and displays those values
  • the app writes the goal
  • the ESP32 reads that goal and uses it for calculations
  • Everything passes through Firebase.


    Creating the Firebase Project

    Printing process

    I created a new Firebase project for this system.

  • Project name: Smart Piggy
  • Default setup (Spark plan)

  • Setting Up Realtime Database

    Printing process

    From the Firebase console, I moved to:

  • Build → Realtime Database
  • Then configured:

  • Region: Singapore (asia-southeast1)
  • Mode: Test mode

  • Printing process

    Test mode allowed open read/write access, which made initial development easier.


    Getting Configuration Details

    Printing process

    After setting up the database, I needed the credentials required by the ESP32:

  • API Key
  • Database URL
  • These come from adding an app to the Firebase project.

    Printing process

    This configuration is what allows the ESP32 to authenticate and communicate with Firebase.

    Enabling Authentication

    To allow the ESP32 to connect without user login, I enabled:

  • Anonymous Authentication
  • This step was necessary for the device to perform read/write operations programmatically.


    First Test - ESP32 Writes to Firebase

    Before sending real system data, I tested a minimal case:

  • ESP32 sends a simple value (123)
  • Firebase receives and stores it

  • Printing process

    Firebase Connectivity Test (ESP32)

    // -------- WIFI -------- #include <WiFi.h> // -------- FIREBASE -------- #include // helpers (required by the library) #include "addons/TokenHelper.h" #include "addons/RTDBHelper.h" // your WiFi const char* ssid = "MiCho"; const char* password = "Hello1234"; // your Firebase (from config) #define API_KEY "AIzaSyDpefz07KOuYyj0mZ1YtvJkm5g3z5HU0-I" #define DATABASE_URL "https://smart-piggy-effc6-default-rtdb.asia-southeast1.firebasedatabase.app/" // Firebase objects FirebaseData fbdo; FirebaseAuth auth; FirebaseConfig config; void setup() { Serial.begin(115200); // -------- WIFI CONNECT -------- WiFi.begin(ssid, password); Serial.print("Connecting to WiFi"); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println("\nWiFi connected"); Serial.print("IP: "); Serial.println(WiFi.localIP()); // -------- FIREBASE CONFIG -------- config.api_key = API_KEY; config.database_url = DATABASE_URL; // REQUIRED: token handling config.token_status_callback = tokenStatusCallback; // -------- SIGN UP (ANONYMOUS) -------- if (Firebase.signUp(&config, &auth, "", "")) { Serial.println("Firebase signup OK"); } else { Serial.printf("Signup failed: %s\n", config.signer.signupError.message.c_str()); } // -------- START FIREBASE -------- Firebase.begin(&config, &auth); Firebase.reconnectWiFi(true); } void loop() { // only run when Firebase is ready if (Firebase.ready()) { // send test value if (Firebase.RTDB.setInt(&fbdo, "/test/value", 123)) { Serial.println("Sent: 123"); } else { Serial.print("FAILED: "); Serial.println(fbdo.errorReason()); } } delay(5000); }


    Moving from Test Data to System Data

    After confirming communication worked, I replaced the test value with actual values from the system:

  • total
  • percentage
  • percentage
  • These were sent only when a valid interaction occurred.


    Transition

    With Firebase now storing real data, the next step was:

    how to build an interface that reads and displays this data


    Kodular - Turning Data into an Interface

    Printing process Printing process

    Kodular looked very simple and practical at first.

    Printing process

    Two main sections:

  • Designer (felt familiar immediately.)
  • Blocks (… not so much.)

  • Designer - The familiar Part

    I started by placing the components I knew I would need.

    Printing process

    3 labels:

  • total
  • percentage
  • last coin
  • a progress bar
  • (not exactly what I wanted visually, but enough to test)

  • Firebase Database component
  • (invisible, but important)

    This part was smooth. It felt like arranging things on a canvas.

    At this stage, everything looked ready, but nothing actually worked.


    Blocks - Where Everything Changed

    Printing process

    Then I switched to the Blocks section.

    And that’s where everything stopped making sense.

    It looks like a simple grid with draggable tiles, but:

    Printing process

  • I didn’t know what to pick
  • I didn’t know where to place it
  • I didn’t know how anything connects
  • This is where the logic of the app lives, but there was no clear starting point.


    What Made It Difficult

    Kodular works like puzzle pieces:

  • each block is a piece of logic
  • pieces only fit in certain ways
  • and even if they fit, it doesn’t mean they’re correct

  • Printing process

    Sometimes, the hardest part wasn’t just logic, it was finding the right block in the first place

    Sometimes I knew what I wanted to do, but:

  • couldn’t find the block
  • or found it but didn’t know how to use it
  • or connected it and nothing happened

  • Building the Logic (Piece by Piece)

    Receiving the Data

    The data comes through:

    Firebase.GotValue(tag, value)

    At first, this part was confusing.

  • where does this trigger from?
  • what exactly is inside value?

  • Printing process

    It took time to understand that this is the point where Firebase responds.


    Making Sense of the Data

    Initially, I tried to fetch individual values.

    That didn’t work well.

    Then I switched to fetching everything at once.

    tag = ""

    This returns a full dataset.

    From there:

  • value["total"]
  • value["percentage"]
  • value["lastCoin"]

  • // -------- WIFI -------- #include <WiFi.h> // -------- FIREBASE -------- #include <Firebase_ESP_Client.h> #include "addons/TokenHelper.h" #include "addons/RTDBHelper.h" // -------- WIFI -------- const char* ssid = "MiCho"; const char* password = "Hello1234"; // -------- FIREBASE CONFIG -------- #define API_KEY "AIzaSyDpefz07KOuYyj0mZ1YtvJkm5g3z5HU0-I" #define DATABASE_URL "https://smart-piggy-effc6-default-rtdb.asia-southeast1.firebasedatabase.app/" FirebaseData fbdo; FirebaseAuth auth; FirebaseConfig config; // -------- PIN SETUP -------- #define BUTTON_PIN D8 #define LED_PIN D2 // -------- TIMING CONFIG -------- const unsigned long debounceDelay = 50; const unsigned long longPressTime = 1000; const unsigned long doublePressGap = 400; // -------- SYSTEM STATE -------- int totalAmount = 0; int goal = 100; bool milestone25 = false; bool milestone50 = false; bool milestone75 = false; // -------- BUTTON STATE -------- bool buttonState = LOW; bool lastButtonReading = LOW; unsigned long lastDebounceTime = 0; unsigned long pressStartTime = 0; unsigned long lastReleaseTime = 0; int pressCount = 0; // -------- SETUP -------- void setup() { Serial.begin(115200); pinMode(BUTTON_PIN, INPUT_PULLDOWN); pinMode(LED_PIN, OUTPUT); digitalWrite(LED_PIN, LOW); // -------- WIFI -------- Serial.println("Connecting to WiFi..."); WiFi.begin(ssid, password); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println("\nWiFi Connected!"); Serial.print("IP Address: "); Serial.println(WiFi.localIP()); // -------- FIREBASE -------- config.api_key = API_KEY; config.database_url = DATABASE_URL; config.token_status_callback = tokenStatusCallback; Firebase.signUp(&config, &auth, "", ""); Firebase.begin(&config, &auth); Firebase.reconnectWiFi(true); Serial.println("Firebase ready"); } // -------- LOOP -------- void loop() { int reading = digitalRead(BUTTON_PIN); // -------- DEBOUNCE -------- if (reading != lastButtonReading) { lastDebounceTime = millis(); } if ((millis() - lastDebounceTime) > debounceDelay) { if (reading != buttonState) { buttonState = reading; if (buttonState == HIGH) { pressStartTime = millis(); } else { unsigned long pressDuration = millis() - pressStartTime; // LONG PRESS → ₹5 if (pressDuration >= longPressTime) { handleLongPress(); pressCount = 0; } else { pressCount++; lastReleaseTime = millis(); } } } } // SINGLE / DOUBLE PRESS DETECTION if (pressCount > 0 && (millis() - lastReleaseTime) > doublePressGap) { if (pressCount == 1) { handleSinglePress(); } else if (pressCount == 2) { handleDoublePress(); } pressCount = 0; } lastButtonReading = reading; } // -------- ACTIONS -------- void handleSinglePress() { addAmount(1); blinkLED(1); } void handleDoublePress() { addAmount(2); blinkLED(2); } void handleLongPress() { addAmount(5); digitalWrite(LED_PIN, HIGH); delay(400); digitalWrite(LED_PIN, LOW); } // -------- FIREBASE (ATOMIC UPDATE) -------- void sendToFirebase(int total, int percentage, int lastCoin) { if (Firebase.ready()) { FirebaseJson json; json.set("total", total); json.set("percentage", percentage); json.set("lastCoin", lastCoin); // single atomic write Firebase.RTDB.setJSON(&fbdo, "/piggy", &json); Serial.println("Data sent to Firebase (JSON)"); } } // -------- CORE LOGIC -------- void addAmount(int value) { totalAmount += value; int percentage = (totalAmount * 100) / goal; Serial.print("Added: "); Serial.print(value); Serial.print(" | Total: "); Serial.print(totalAmount); Serial.print(" | "); Serial.print(percentage); Serial.println("%"); // single Firebase update sendToFirebase(totalAmount, percentage, value); // -------- GOAL LOGIC -------- if (totalAmount >= goal) { int overflow = totalAmount - goal; Serial.println("Milestone: Goal reached!"); for (int i = 0; i < 3; i++) { digitalWrite(LED_PIN, HIGH); delay(150); digitalWrite(LED_PIN, LOW); delay(150); } delay(500); totalAmount = overflow; milestone25 = false; milestone50 = false; milestone75 = false; Serial.print("System Reset → Carry Forward: "); Serial.println(totalAmount); return; } checkMilestones(percentage); } // -------- LED -------- void blinkLED(int times) { for (int i = 0; i < times; i++) { digitalWrite(LED_PIN, HIGH); delay(120); digitalWrite(LED_PIN, LOW); delay(120); } } // -------- MILESTONES -------- void checkMilestones(int percentage) { if (!milestone25 && percentage >= 25) { milestone25 = true; Serial.println("Milestone: 25% reached"); } if (!milestone50 && percentage >= 50) { milestone50 = true; Serial.println("Milestone: 50% reached"); } if (!milestone75 && percentage >= 75) { milestone75 = true; Serial.println("Milestone: 75% reached"); } }


    Printing process

    Now things finally started making sense.


    Connecting It to the UI

    Once I could access the values:

  • labels were updated
  • progress bar was linked to percentage
  • First Working State

    At this point, the app showed:

  • total
  • total
  • last coin


  • Printing process

    And everything matched what the ESP32 was sending.

    This was the first time I could see the entire system working in one place.


    What This Phase Really Tested

  • figuring out a new system
  • being okay with not understanding things immediately
  • staying with it long enough for it to click

  • Printing process

    I just wanted to finish this before dinner.
    I didn’t want to go to sleep thinking about Kodular blocks.


    Transition

    At this stage, the app could read and display data correctly.

    That felt like a big win.

    But the system was still one-directional.

    The next step was to send data back from the app:

    setting the goal from the interface


    Goal Loop - When Everything Broke Again

    Sending Goal from App

    I added:

  • a textbox → to enter goal
  • a button → to send it


  • Printing process

    Logic:

  • take input
  • store in Firebase under piggy/goal
  • This part seemed to work.

    The value showed up in Firebase.

    Printing process

    The First Problem - Updates Not Consistent

    Even though the goal was updating, it wasn’t consistent everywhere.

  • sometimes ESP32 reflected it
  • sometimes it didn’t
  • app wasn’t reliably updating

  • Printing process

    This is where I realized something was missing.


    The Missing Piece - Clock

    Up until now, the app wasn’t actively checking for updates.

    So I added a Clock component.

  • it runs at intervals
  • requests data from Firebase repeatedly


  • Printing process

    This made a big difference.

    Now:

  • goal updates were consistent
  • app stayed in sync

  • New Problem - Everything Else Broke

    Printing process

    As soon as the Clock started working properly:

  • total stopped updating on the app
  • last coin stopped updating
  • percentage stopped updating
  • Printing process

    So fixing one thing broke three others.


    Trying to Fix That

    I started debugging:

  • checked Firebase values
  • checked block connections
  • adjusted logic
  • Some things improved.

    But then:

  • goal stopped updating properly
  • or Firebase didn’t reflect changes
  • or values were inconsistent again

  • Printing process

    I could see the system almost working, but I couldn’t reach it.


    What Was Actually Happening

    At some point, the system started overwriting values.

  • multiple writes happening
  • inconsistent timing
  • data getting replaced unexpectedly
  • So even though everything was connected, it wasn’t coordinated.


    Reset and Rebuild

    Printing process

    At this point, I stopped trying to patch things.

    I went back to Firebase and:

  • deleted existing data
  • rebuilt the structure manually
  • Then I reconnected everything step-by-step.


    Re-testing the Flow

    I tested in order:

    1.Set goal from app

  • → reflected in Firebase
  • → ESP32 reads correctly
  • 2.Press button

  • → total updates
  • → percentage updates
  • → last coin updates
  • 3.App reflects everything correctly

    This time, everything stayed consistent.


    Key Fixes That Made It Work

    Looking back, a few things fixed the system:

    1.Clock for Controlled Updates

  • app actively checks for data
  • keeps UI in sync
  • 2.Clean Firebase Structure

  • removing conflicting/old values
  • rebuilding from scratch
  • 3.Avoiding Overwrites

  • controlled when data is written
  • avoided unnecessary updates
  • 4.Event-Based Serial Feedback

  • ESP32 sends updates only when interaction happens
  • no continuous unnecessary operations

  • The Moment It Finally Worked

    After all this:

  • goal → updates correctly
  • ESP32 → reads correctly
  • button input → updates values
  • Firebase → stays consistent
  • app → reflects everything in real time

  • Printing process

    This is where I finally reached that “end of the tunnel”.


    Final Working Loop

    App → sets goal
    ↓
    Firebase stores goal
    ↓
    ESP32 reads goal
    ↓
    User interaction
    ↓
    ESP32 updates values
    ↓
    Firebase updated
    ↓
    Clock triggers update
    ↓
    App reflects new values
    

    Printing process

    Prompt used on Chatgpt

    Continue building the Smart Piggy ESP32 system from the previous stage where WiFi and Firebase are already connected and
    the device sends total, percentage, and lastCoin values. Now extend the system into a stable two-way integration with a
    Kodular app that sets a savings goal via Firebase.
    
    Write complete ESP32 (Arduino) code that:
    
    - Reads the goal value from Firebase (`/piggy/goal`) at fixed intervals using millis (non-blocking), not continuous polling
    - Updates the goal variable only when it changes, and prints “Goal updated: X” to Serial only on change
    - Handles Firebase data types robustly (int, float, double, string → convert to int)
    - Maintains responsive button input logic with debounce:
        - Single press = +1
        - Double press = +2
        - Long press = +5
    - Keeps button handling independent of Firebase calls (no blocking delays except short LED feedback)
    - Calculates percentage as `(total * 100) / goal`, with a safeguard for goal = 0
    - Writes data to Firebase WITHOUT overwriting the entire node:
        - Use individual writes (`setInt`) for:
            - `/piggy/total`
            - `/piggy/percentage`
            - `/piggy/lastCoin`
            - `/piggy/goal` (to keep it synced)
    - Uses the Firebase ESP Client library with proper initialization (API key, database URL, anonymous sign-in)
    - Includes Serial debug messages for:
        - WiFi connection
        - Firebase readiness
        - Goal updates (only when changed)
        - Amount added and percentage
        - Firebase write success
    - Uses the following structure in Firebase:
        
        ```
        piggy/
          goal
          total
          percentage
          lastCoin
        ```
        
    - Keeps the loop efficient and stable so that real-time button presses are never delayed by network operations
    

    Code

    // -------- WIFI -------- #include <WiFi.h> // -------- FIREBASE -------- #include <Firebase_ESP_Client.h> #include "addons/TokenHelper.h" #include "addons/RTDBHelper.h" // -------- WIFI -------- const char* ssid = "MiCho"; const char* password = "Hello1234"; // -------- FIREBASE CONFIG -------- #define API_KEY "AIzaSyDpefz07KOuYyj0mZ1YtvJkm5g3z5HU0-I" #define DATABASE_URL "https://smart-piggy-effc6-default-rtdb.asia-southeast1.firebasedatabase.app/" FirebaseData fbdo; FirebaseAuth auth; FirebaseConfig config; // -------- PIN SETUP -------- #define BUTTON_PIN D8 #define LED_PIN D2 // -------- TIMING -------- const unsigned long debounceDelay = 50; const unsigned long longPressTime = 1000; const unsigned long doublePressGap = 400; // -------- FIREBASE TIMING -------- unsigned long lastFirebaseRead = 0; const unsigned long firebaseInterval = 2000; // -------- STATE -------- int totalAmount = 0; int goal = 0; int lastGoal = -1; // -------- BUTTON STATE -------- bool buttonState = LOW; bool lastButtonReading = LOW; unsigned long lastDebounceTime = 0; unsigned long pressStartTime = 0; unsigned long lastReleaseTime = 0; int pressCount = 0; // -------- SETUP -------- void setup() { Serial.begin(115200); pinMode(BUTTON_PIN, INPUT_PULLDOWN); pinMode(LED_PIN, OUTPUT); digitalWrite(LED_PIN, LOW); Serial.println("Connecting to WiFi..."); WiFi.begin(ssid, password); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println("\nWiFi Connected!"); Serial.println(WiFi.localIP()); config.api_key = API_KEY; config.database_url = DATABASE_URL; config.token_status_callback = tokenStatusCallback; Firebase.signUp(&config, &auth, "", ""); Firebase.begin(&config, &auth); Firebase.reconnectWiFi(true); Serial.println("Firebase ready"); } // -------- LOOP -------- void loop() { // -------- READ GOAL (ONLY IF CHANGED) -------- if (Firebase.ready() && millis() - lastFirebaseRead > firebaseInterval) { lastFirebaseRead = millis(); if (Firebase.RTDB.get(&fbdo, "/piggy/goal")) { int newGoal = 0; String type = fbdo.dataType(); if (type == "int") newGoal = fbdo.intData(); else if (type == "float") newGoal = (int)fbdo.floatData(); else if (type == "double") newGoal = (int)fbdo.doubleData(); else if (type == "string") newGoal = fbdo.stringData().toInt(); if (newGoal != lastGoal) { goal = newGoal; lastGoal = newGoal; Serial.print("Goal updated: "); Serial.println(goal); } } } // -------- BUTTON LOGIC -------- int reading = digitalRead(BUTTON_PIN); if (reading != lastButtonReading) { lastDebounceTime = millis(); } if ((millis() - lastDebounceTime) > debounceDelay) { if (reading != buttonState) { buttonState = reading; if (buttonState == HIGH) { pressStartTime = millis(); } else { unsigned long pressDuration = millis() - pressStartTime; if (pressDuration >= longPressTime) { handleLongPress(); pressCount = 0; } else { pressCount++; lastReleaseTime = millis(); } } } } if (pressCount > 0 && (millis() - lastReleaseTime) > doublePressGap) { if (pressCount == 1) { handleSinglePress(); } else if (pressCount == 2) { handleDoublePress(); } pressCount = 0; } lastButtonReading = reading; } // -------- BUTTON ACTIONS -------- void handleSinglePress() { addAmount(1); blinkLED(1); } void handleDoublePress() { addAmount(2); blinkLED(2); } void handleLongPress() { addAmount(5); digitalWrite(LED_PIN, HIGH); delay(400); digitalWrite(LED_PIN, LOW); } // -------- FIREBASE WRITE (SAFE: NO OVERWRITE) -------- void sendToFirebase(int total, int percentage, int lastCoin) { if (Firebase.ready()) { Firebase.RTDB.setInt(&fbdo, "/piggy/total", total); Firebase.RTDB.setInt(&fbdo, "/piggy/percentage", percentage); Firebase.RTDB.setInt(&fbdo, "/piggy/lastCoin", lastCoin); Firebase.RTDB.setInt(&fbdo, "/piggy/goal", goal); // keep goal synced Serial.println("Data updated (safe write)"); } } // -------- CORE LOGIC -------- void addAmount(int value) { totalAmount += value; int percentage = 0; if (goal > 0) { percentage = (totalAmount * 100) / goal; } Serial.print("Added: "); Serial.print(value); Serial.print(" | Total: "); Serial.print(totalAmount); Serial.print(" | "); Serial.print(percentage); Serial.println("%"); sendToFirebase(totalAmount, percentage, value); } // -------- LED -------- void blinkLED(int times) { for (int i = 0; i < times; i++) { digitalWrite(LED_PIN, HIGH); delay(120); digitalWrite(LED_PIN, LOW); delay(120); } }


    Learnings and Insights

    What helped me most was breaking the system into smaller parts and building step by step, button → logic → WiFi → Firebase → app. Making sure each stage worked before moving forward kept things manageable. Serial output was my constant checkpoint, and switching to event-based updates made the system much more stable. When Firebase started behaving inconsistently, resetting it and rebuilding cleanly turned out to be more effective than trying to fix everything.

    The difficult part was understanding how Kodular works, especially the Blocks section. Nothing was intuitive, and figuring out how blocks connect and where to find them took time. The shift to event-driven thinking was also not immediate. Debugging became harder once everything was connected-when something failed, it was unclear whether the issue was in the app, Firebase, or the ESP32. Synchronization issues and value overwriting made this even more confusing.

    What this project really showed me is that a system is not just about individual components working, but about how well they communicate. Real-time systems need controlled data flow, and event-driven logic behaves very differently from linear code. Also, sometimes it’s more efficient to reset and rebuild than to keep debugging a broken state.

    If I were to take this further, I would improve the app interface, especially the visual feedback. The current progress bar doesn't make any sense, I need to do something about it.


    Source Files

  • Press Logic Module

  • WiFi Test Code
  • WiFi Integrated Code

  • Goal Overflow
  • Goal with Remainder
  • Goal Control test

  • Firebase Integration Mock
  • Firebase Data Sync
  • Firebase Connectivity Test

  • Final Integrated Code