MAIN EVENT

FINAL PROJECT

Kamil Gallardo Toledo - FAB ACADEMY

Fab termi
FINAL PROJECT

THE KNOCKOUT CONCEPT

It consists of a device that measures reaction speed and the force of punches for people interested in boxing. Using a visual cue, users can learn punch combinations while receiving feedback through a virtual interface.

Core Concept

[STEP 01/03]
> Inspiraton

My project idea came about because, during my trips to the gym, I noticed that many people hit the punching bag without proper technique, and that home punching boxes do not encourage the development of correct technique either.

So I came up with the idea of designing a punching bag that encourages proper technique while being user-friendly and fun. First, I thought about games that consist of a memory sequence where different icons light up and you have to follow the sequence.

Memory_game

Core Concept

[STEP 02/03]
System integration

Multi-device addressing via SDA/SCL.

Structure: Includes the design and 3D printing of the frame.

Plan

Control System: Use an ESP32 and its clock to count the time between strikes and turn led sequence.

Electronics Design/Production: Design a PCB for the microcontroller that connects the lights and sensors.

Input Devices: Add sensors so we can track the speed of the strike.

Output Devices: Control the neopixels.

Interface and Application Programming: Build a simple web or mobile interface to showcase the speed of punches and show the combos.

Computer-Aided Design: Design the 3D model of the machine and the internal structure.

First sketch

Idea

The plan is to make a wall-mounted punching bag with four illuminated zones that indicate where to strike, measuring the speed of the punches and featuring preloaded combos. I made a short sketch that I intend to improve soon.


Git account

APRIL 2026

SUN
MON
TUE
WED
THU
FRI
SAT
1
2
3
4
5
6
7 BUY MATERIAL
8 BUY MATERIAL
9 BUY MATERIAL
10
11
12
13
14
15 GATHER MATERIAL
16
17
18
19
20 TEST SENSORS
21 TEST SENSORS
22
23
24
25
26
27 3D MODELING
28 3D MODELING
29
30

MAY 2026

SUN
MON
TUE
WED
THU
FRI
SAT
1
2
3
4
5
6
7 BUY MATERIAL
8 BUY MATERIAL
9 BUY MATERIAL
10
11
12
13
14
15 GATHER MATERIAL
16
17
18
19
20 TEST SENSORS
21 TEST SENSORS
22
23
24
25
26
27 3D MODELING
28 3D MODELING
29
30

JUNE 2026

SUN
MON
TUE
WED
THU
FRI
SAT
1
2
3
4
5
6
7 BUY MATERIAL
8 BUY MATERIAL
9 BUY MATERIAL
10
11
12
13
14
15 GATHER MATERIAL
16
17
18
19
20 TEST SENSORS
21 TEST SENSORS
22
23
24
25
26
27 3D MODELING
28 3D MODELING
29
30

WEEK PROGRESS

During some week tasks I tried to learn about some necesarry things for my final project. Here is the progress I have.

ESP32 TIMER

[STEP 01/02]
> Week 4

For more information you can access to my fourth week Week 4.

My code consists of turning on three LEDs in sequence and starting a timer each time one turns on to measure the time between the LED lighting up and the button being pressed. During the Development of my code I was assisted by ChatGPT to understand the ESP32 internal timer and to be able to register big numbers.

Code

[STEP 02/02]

                            // ---------- Pins ----------
                            const int ledPins[3] = {16, 17, 18};
                            const int btnPins[3] = {13, 12, 14};

                            // ---------- Time ----------
                            hw_timer_t *timer = NULL;
                            volatile unsigned long tiempo = 0; // ms

                            // ---------- Control ----------
                            int status = 0;
                            bool waiting = false;

                            // ---------- Interruption ----------
                            void IRAM_ATTR onTimer() {
                             tiempo++; // 1 ms
                                            }

                            // ---------- Setup ----------
                            void setup() {
                            Serial.begin(9600);

                            // LEDs
                            for (int i = 0; i < 3; i++) {
                            pinMode(ledPins[i], OUTPUT);
                            digitalWrite(ledPins[i], LOW);
                             }

                            // Buttons
                            for (int i = 0; i < 3; i++) {
                            pinMode(btnPins[i], INPUT_PULLUP);
                            }

                            // ---------- Timer ----------
                            timer = timerBegin(1000000);
                            timerAttachInterrupt(timer, &onTimer);
                            timerAlarm(timer, 1000, true, 0);
                            timerStart(timer);

                            iniciateLED();
                                            }

                            // ---------- Loop ----------
                            void loop() {
                            if (waiting) {
                             if (digitalRead(btnPins[status]) == LOW) {
                              waiting = false;
                            Serial.print("LED ");
                            Serial.print(status + 1);
                            Serial.print(" -> Tiempo: ");
                            Serial.print(tiempo);
                            Serial.println(" ms");
                            delay(300);
                            nextLED();
                              }
                            }
                            }

                            // ---------- Functions ----------
                            void iniciateLED() {
                            turnOff();
                            tiempo = 0;
                            digitalWrite(ledPins[status], HIGH);
                            waiting = true;
                            Serial.print("LED ");
                            Serial.print(status + 1);
                            Serial.println(" encendido...");
                            }

                            void nextLED() {
                            status++;
                            // If LED number 3 already ended:
                            if (status >= 3) {
                            waiting = false; // NO MORE BUTTONS LEFT
                            turnOff(); // Turn off LEDs
                            Serial.println("---- Cicle ended ----");
                            return;
                            }

                            iniciateLED();
                            }

                            void turnOff() {
                            for (int i = 0; i < 3; i++) {
                            digitalWrite(ledPins[i], LOW);
                              }
                            }
           

Schematic

> Week 9

For more information you can access to my ninth week Week 9.

Fab

I made this sensor with the help of Adrian Torres documentation, Neil Gershenfeld's examples and Robert Hart's page.

What this sensor does is that its reading increases when we bring the plates closer together, since the distance between the two copper pieces decreases, causing a change in capacitance. The closer the plates are, the greater the capacitance.

Step Response. This board only has the pins needed to connect the step-response sensor to the microcontroller. It consists of two analog pins; one is connected to GND and the other to 3.3V via two 1-megohm resistors. After that, I simply added pins for power.

PCB design

Fab

1. Then I went to the PCB editor and with the Route single track I connected every component.

Calculator tool. Before defining the size, it is important to calculate it using the calculator tool given by KiCad. To do that we first have to go to the start menu and open the Calculator tool.


add the Current (I) and the Temperature rise we are expecting our PCB to have and look fo the result the calculator will give back to us in the right top side. The calculator works by using a formula explained at the bottom.

Fab Fab
Track thickness. To change the track thickness we must go to the top tool section and click on Track use netclass width. Subsequently, select Edit Pre-defined Sizes.
Inside that section we can add tracks sizes by clicking the + symbol located at the bottom of the window, and in the width section we can change the width of the new track we added. Then we'll just have to click Ok.
Fab Fab

MODS

This are the parameters for each process in Mods. If you want to learn more go to Week 8.

Parameters.
The outline width is 2 mm and its layer is Edge.Cuts.
The track’s width is 0.8 mm- 2 mm and its layer is F.Cu.
The Holes layer is User.1.
Drilling - MODS. • Tool width. 0.8 mm
• Speed. 0.5 mm/s
• Origin (x,y,z). (0,0,0)
• Offset number. 1
Cutting - MODS. • Tool width. 0.39 mm
• Speed. 4 mm/s
• Origin (x,y,z). (0,0,0)
•Offset number. 3
Outline - MODS. • Tool width. 2 mm
• Speed. 4 mm/s
• Origin (x,y,z). (0,0,0)
• Offset number. 1

3D view

Fab

XIAO ESP32-C6 - Capacitive Sensing & NeoPixel Control

~ Method: Step response capacitive sensing (TX: D10, RX: A2).
~ Output: Dual WS2812B visual feedback via Pin D3.
~ Logic: Signal mapping and range filtering for proximity detection.


            #include <Adafruit_NeoPixel.h>

            // -------- CONFIGURACIÓN --------
            #define PIN_NEO 3
            #define NUM_PIXELS 2
            Adafruit_NeoPixel pixels(NUM_PIXELS, PIN_NEO, NEO_GRB + NEO_KHZ800);

            long result;
            int analog_pin = A2;
            int tx_pin = D10;

            void setup() {
            pinMode(tx_pin, OUTPUT);
            Serial.begin(115200);
            pixels.begin();
            pixels.clear();
            pixels.show();
            }

            long tx_rx() {
            int read_high, read_low, diff;
            long sum = 0;
            for (int i = 0; i < 100; i++) {
                digitalWrite(tx_pin, HIGH);
                read_high = analogRead(analog_pin);
                delayMicroseconds(100);
                digitalWrite(tx_pin, LOW);
                read_low = analogRead(analog_pin);
                diff = read_high - read_low;
                sum += diff;
            }
            return sum;
            }

            void loop() {
            result = tx_rx();
            long mapped_result = map(result, 15000, 25000, 0, 1024);

            if (mapped_result >= 30000 && mapped_result <= 35000) {
                for(int i=0; i < NUM_PIXELS; i++) {
                pixels.setPixelColor(i, pixels.Color(255, 0, 0));
                }
                pixels.show();
            } else {
                pixels.clear();
                pixels.show();
            }
            delay(50);
            }

Diagram of connections

> Week 11

For more information you can access to my ninth week Week 11.

Fab termi

The diagram shows the wiring of the system powered by the 5V output of the XIAO ESP32-C6. This voltage rail supplies power to the NeoPixels, while all GND connections are tied directly to the XIAO’s GND, creating a common ground that ensures stable operation and proper signal reference across the system.

Additionally, the system includes five push buttons used for input commands. These buttons are connected directly to the XIAO pins from D0 to D4 and use the internal pull-up resistor configuration, meaning each pin reads HIGH by default and switches to LOW when the button is pressed.

From pin D5, a 220 Ω resistor is placed in series with the data line that connects to the input (DIN) of the NeoPixels. This resistor helps protect the data line from voltage spikes and improves signal integrity. The NeoPixels are connected in series, where the data flows from the first LED to the next (DOUT to DIN), allowing the microcontroller to control all LEDs through a single data pin.

XIAO ESP32-C6 - Capacitive Sensing & NeoPixel Control

~ Network: WiFi connection to MQTT Broker (EMQX).
~ System: MQTT publish "Macarena" in the topic xiao/boton



                                #include <WiFi.h>
                                #include <PubSubClient.h>
                                #include <Adafruit_NeoPixel.h>

                                // -------- WIFI --------
                                const char* ssid = "iPhone de Derek";
                                const char* password = "password";

                                // -------- MQTT --------
                                const char* mqttServer = "broker.emqx.io";
                                const int mqttPort = 1883;

                                // -------- PINES --------
                                #define PIN_BOTON D0
                                #define PIN_KAM D1
                                #define PIN_RGB D5
                                #define NUM_LEDS 10
                                #define LED 23

                                // -------- OBJETOS --------
                                WiFiClient esp32Client;
                                PubSubClient mqttClient(esp32Client);
                                Adafruit_NeoPixel pixels(NUM_LEDS, PIN_RGB, NEO_GRB + NEO_KHZ800);

                                // -------- VARIABLES --------
                                int var = 0;
                                String resultS = "";
                                uint32_t pixelHue = 0;
                                bool lastState = HIGH;
                                bool before = HIGH;

                                // -------- WIFI --------
                                void wifiInit() {
                                Serial.print("Conectándose a ");
                                Serial.println(ssid);

                                WiFi.begin(ssid, password);

                                while (WiFi.status() != WL_CONNECTED) {
                                    Serial.print(".");
                                    delay(500);
                                }

                                Serial.println("\nConectado a WiFi");
                                Serial.println(WiFi.localIP());
                                }

                                // -------- CALLBACK MQTT --------
                                void callback(char* topic, byte* payload, unsigned int length) {
                                Serial.print("Mensaje recibido [");
                                Serial.print(topic);
                                Serial.print("] ");

                                char payload_string[length + 1];
                                memcpy(payload_string, payload, length);
                                payload_string[length] = '\0';

                                int resultI = atoi(payload_string);
                                var = resultI;

                                resultS = "";
                                for (int i = 0; i < length; i++) {
                                    resultS += (char)payload[i];
                                }

                                Serial.println(resultS);
                                }

                                // -------- RECONEXIÓN MQTT --------
                                void reconnect() {
                                while (!mqttClient.connected()) {
                                    Serial.print("Intentando MQTT...");

                                    String clientId = "Kamilovich-" + String(random(0xffff), HEX);

                                    if (mqttClient.connect(clientId.c_str())) {
                                    Serial.println("Conectado");
                                    mqttClient.subscribe("fab_test_mine");
                                    } else {
                                    Serial.println(" fallo, reintentando...");
                                    delay(3000);
                                    }
                                }
                                }

                                // -------- SETUP --------
                                void setup() {
                                Serial.begin(115200);

                                pinMode(PIN_BOTON, INPUT_PULLUP);
                                pinMode(PIN_KAM, INPUT_PULLUP);
                                pinMode(LED, OUTPUT);

                                pixels.begin();
                                pixels.setBrightness(50);
                                pixels.show();

                                wifiInit();

                                mqttClient.setServer(mqttServer, mqttPort);
                                mqttClient.setCallback(callback);
                                }

                                // -------- LOOP --------
                                void loop() {
                                if (!mqttClient.connected()) {
                                    reconnect();
                                }

                                mqttClient.loop();

                                apagarkamil();
                                leerBoton();

                                if (var == 0) {
                                    efectoGamer();
                                } 
                                else if (var == 1) {
                                    efectoRespiracion();
                                }
                                }

                                // -------- BOTÓN --------
                                void leerBoton() {
                                bool estado = digitalRead(PIN_BOTON);

                                if (estado == LOW && lastState == HIGH) {
                                    mqttClient.publish("xiao/boton", "Macarena");
                                    Serial.println("Enviado: Macarena");
                                    delay(50);
                                }

                                lastState = estado;
                                }

                                // -------- BOTÓN 2 --------
                                void apagarkamil() {
                                bool como = digitalRead(PIN_KAM);

                                if (como == LOW && before == HIGH) {
                                    mqttClient.publish("xiao/boton", "APAGAOS EN NOMBRE DE LO BUENO Y DE LO HONESTO");
                                    Serial.println("Enviado: APAGAOS EN NOMBRE DE LO BUENO Y DE LO HONESTO");
                                    delay(50);
                                }

                                before = como;
                                }

                                // -------- EFECTO GAMER --------
                                void efectoGamer() {
                                static unsigned long lastUpdate = 0;

                                if (millis() - lastUpdate < 15) return;

                                for (int i = 0; i < pixels.numPixels(); i++) {
                                    int hueOffset = i * (65536 / pixels.numPixels());

                                    pixels.setPixelColor(i, pixels.gamma32(
                                    pixels.ColorHSV(pixelHue + hueOffset)
                                    ));
                                }

                                pixels.show();
                                pixelHue += 256;
                                lastUpdate = millis();
                                }

                                // -------- EFECTO RESPIRACIÓN --------
                                void efectoRespiracion() {
                                static int brillo = 0;
                                static int direccion = 5;

                                brillo += direccion;

                                if (brillo <= 0 || brillo >= 255) {
                                    direccion *= -1;
                                }

                                for (int i = 0; i < pixels.numPixels(); i++) {
                                    pixels.setPixelColor(i, pixels.Color(0, 0, brillo));
                                }

                                pixels.show();
                                delay(20);
                                }

                                

Diagram of connections

Fab

This diagram represents the wiring of the system powered by a Seeed Studio XIAO ESP32-C6. The red 3.3V line and black GND line form a common power rail that distributes energy to two WS2812B NeoPixels, an OLED display, and a ESP32-WROOM-32 dev module. The green signal line originates from pin D3, passing through a 220Ω resistor (used to protect the data pin from voltage spikes) before reaching the DIN port of the first NeoPixel; the signal then chains from DOUT to the next pixel's DIN. This setup allows the XIAO to act as the controller of the ESP32 and the Neopixels, managing both local visual feedback.

XIAO ESP32-C6 - Capacitive Sensing & NeoPixel Control

~ Network: WiFi connection to MQTT Broker (EMQX).
~ System: MQTT Callback for "Macarena" mode and Gamer effects.
~ Setup: PIN_BOTON (D0), PIN_RGB (D3), NUM_LEDS (2).


                #include <WiFi.h>
                #include <PubSubClient.h>
                #include <Adafruit_NeoPixel.h>

                // -------- WIFI --------
                const char* ssid     = "iPhone de Derek";
                const char* password = "9414902012";

                // -------- MQTT --------
                const char* mqttServer = "broker.emqx.io";
                const int mqttPort = 1883;

                // -------- PINES --------
                #define PIN_BOTON  D0
                #define PIN_RGB D3
                #define NUM_LEDS 2
                #define LED  23

                // -------- OBJETOS --------
                WiFiClient esp32Client;
                PubSubClient mqttClient(esp32Client);
                Adafruit_NeoPixel pixels(NUM_LEDS, PIN_RGB, NEO_GRB + NEO_KHZ800);

                // -------- VARIABLES --------
                int var = 0;
                String resultS = "";
                uint32_t pixelHue = 0;
                bool lastState = HIGH;
                bool modoMacarena = false; 

                void wifiInit() {
                WiFi.begin(ssid, password);
                while (WiFi.status() != WL_CONNECTED) {
                    delay(500);
                }
                }

                void callback(char* topic, byte* payload, unsigned int length) {
                resultS = "";
                for (int i = 0; i < length; i++) {
                    resultS += (char)payload[i];
                }

                if (String(topic) == "xiao/boton") {
                    if (resultS == "Macarena") {
                    modoMacarena = true; 
                    for (int i = 0; i < NUM_LEDS; i++) {
                        pixels.setPixelColor(i, pixels.Color(255, 0, 0));
                    }
                    pixels.show();
                    } else {
                    modoMacarena = false;
                    }
                }
                var = atoi(resultS.c_str());
                }

                void reconnect() {
                while (!mqttClient.connected()) {
                    String clientId = "Kamilovich-" + String(random(0xffff), HEX);
                    if (mqttClient.connect(clientId.c_str())) {
                    mqttClient.subscribe("xiao/boton");
                    } else {
                    delay(3000);
                    }
                }
                }

                void setup() {
                Serial.begin(115200);
                pinMode(PIN_BOTON, INPUT_PULLUP);
                pinMode(LED, OUTPUT);
                pixels.begin();
                pixels.setBrightness(50);
                wifiInit();
                mqttClient.setServer(mqttServer, mqttPort);
                mqttClient.setCallback(callback);
                }

                void loop() {
                if (!mqttClient.connected()) { reconnect(); }
                mqttClient.loop();
                if (!modoMacarena) { efectoGamer(); }
                leerBoton();
                if (var == 0) { digitalWrite(LED, LOW); } 
                else if (var == 1) { digitalWrite(LED, HIGH); }
                }

                void leerBoton() {
                bool estado = digitalRead(PIN_BOTON);
                if (estado == LOW && lastState == HIGH) {
                    mqttClient.publish("xiao/boton", "Macarena");
                    delay(200);
                }
                lastState = estado;
                }

                void efectoGamer() {
                static unsigned long lastUpdate = 0;
                if (millis() - lastUpdate < 15) return;
                for (int i = 0; i < pixels.numPixels(); i++) {
                    int hueOffset = i * (65536 / pixels.numPixels());
                    pixels.setPixelColor(i, pixels.gamma32(pixels.ColorHSV(pixelHue + hueOffset)));
                }
                pixels.show();
                pixelHue += 256;
                lastUpdate = millis();
                }

Diagram of connections

Fab

This diagram shows the I2C communication setup between an ESP32-WROOM-32 development board and an OLED display. The blue line connects the SDA (Serial Data) pin of the OLED to GPIO 21 on the ESP32, while the yellow line connects the SCL (Serial Clock) pin to GPIO 22. These two pins are the standard hardware I2C default for the ESP32, allowing the microcontroller to send graphical data and text to the screen using a synchronous serial protocol.

XIAO ESP32-C6 - Capacitive Sensing & NeoPixel Control

~ Device: ESP32-WROOM-32 Receiver.
~ Display: SSD1306 OLED via I2C (SDA: 21, SCL: 22).
~ Action: Real-time visualization of messages from topic xiao/boton.


                                #include <WiFi.h>
                                #include <PubSubClient.h>
                                #include <Wire.h>
                                #include <Adafruit_GFX.h>
                                #include <Adafruit_SSD1306.h>

                                // -------- CONFIGURACIÓN OLED --------
                                #define SCREEN_WIDTH 128
                                #define SCREEN_HEIGHT 64
                                #define OLED_RESET -1
                                #define SCREEN_ADDRESS 0x3C
                                Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

                                // -------- WIFI & MQTT --------
                                const char* ssid = "iPhone de Derek";
                                const char* mqttServer = "broker.emqx.io";

                                WiFiClient esp32Client;
                                PubSubClient mqttClient(esp32Client);

                                void actualizarOLED(String mensaje) {
                                display.clearDisplay();
                                display.setCursor(0, 0);
                                display.setTextSize(1); 
                                display.setTextColor(SSD1306_WHITE);
                                display.println(mensaje);
                                display.display();
                                }

                                void callback(char* topic, byte* payload, unsigned int length) {
                                String mensaje = "";
                                for (int i = 0; i < length; i++) { mensaje += (char)payload[i]; }
                                actualizarOLED(mensaje);
                                }

                                void reconnect() {
                                while (!mqttClient.connected()) {
                                    String clientId = "Kamo_Display_" + String(random(0xffff), HEX);
                                    if (mqttClient.connect(clientId.c_str())) {
                                    mqttClient.subscribe("xiao/boton");
                                    } else { delay(3000); }
                                }
                                }

                                void setup() {
                                if(!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) { for(;;); }
                                display.clearDisplay();
                                actualizarOLED("Esperando WiFi...");
                                wifiInit();
                                mqttClient.setServer(mqttServer, 1883);
                                mqttClient.setCallback(callback);
                                }

                                void loop() {
                                if (!mqttClient.connected()) { reconnect(); }
                                mqttClient.loop();
                                }

Diagram of connections

> Week 14

For more information you can access to my fourteenth week Week 14.

Fab

The diagram represents an ESP32 WROOM 32 connected to a step response sensor and an LED. The LED is connected between the GND supply and pin G26, allowing the ESP32 to control when the LED turns on or off. The step response sensor is connected using pin G25 as the analog pin and pin G35 as the digital input pin, while also sharing the ESP32's 3.3V and GND connections.

INTERFACE

~ Network: WiFi connection to MQTT Broker (EMQX).
~ System: MQTT publish "FAB" in the topic KAMILOVSKY



        #include <WiFi.h>
        #include <PubSubClient.h>
        #include <Wire.h>

        // -------- WIFI CONFIG --------
        const char* ssid = "WIFI_CONNECTION";
        const char* password = "PASSWORD";

        // -------- MQTT CONFIG --------
        const char* mqttServer = "broker.emqx.io";
        const int mqttPort = 1883;
        const char* connection = "DEREKOVICH_DEREKOVA";
        const char* subscribeTopic = "KAMILOVSKY";
        const char* publishTopic = "KAMILOVSKY";

        // -------- HARDWARE PINS --------
        int analog_pin = 35;
        int tx_pin = 25;
        const int led_pin = 26;

        long result = 0;

        // -------- CONTROL VARIABLES --------
        hw_timer_t *timer = NULL;
        volatile unsigned long tiempo = 0; 
        bool waiting = false; 
        String modo = "";

        WiFiClient esp32Client;
        PubSubClient mqttClient(esp32Client);

        void IRAM_ATTR onTimer() {
          tiempo++; 
        }

        void wifiInit() {
          Serial.print("Conectándose a ");
          Serial.println(ssid);
          WiFi.begin(ssid, password);
          while (WiFi.status() != WL_CONNECTED) {
              Serial.print(".");
              delay(500);
          }
          Serial.println("\nWiFi conectado");
          Serial.print("IP: ");
          Serial.println(WiFi.localIP());
        }

        void iniciarPrueba() {
          tiempo = 0;
          digitalWrite(led_pin, HIGH);
          waiting = true;
          Serial.println("--- PRUEBA INICIADA ---");
          Serial.println("LED Encendido. Temporizador corriendo...");
        }

        void callback(char* topicCallback, byte* payload, unsigned int length) {
          Serial.print("Mensaje recibido en topic: ");
          Serial.println(topicCallback);
          modo = "";
          for (int i = 0; i < length; i++) {
              modo += (char)payload[i];
          }
          Serial.print("Contenido: ");
          Serial.println(modo);
          
          if (modo == "FAB") {
              Serial.println("Comando FAB recibido. Reactivando sistema...");
              iniciarPrueba();
          } else if (modo == "PARAR") {
              Serial.println("Comando PARAR recibido. Abortando prueba...");
              digitalWrite(led_pin, LOW);
              waiting = false;
          }
        }

        void reconnect() {
          while (!mqttClient.connected()) {
              Serial.print("Conectando a MQTT...");
              String clientId = String(connection) + "-" + String(random(0xffff), HEX);
              if (mqttClient.connect(clientId.c_str())) {
                Serial.println(" conectado");
                mqttClient.subscribe(subscribeTopic);
              } else {
                Serial.print(" fallo, rc=");
                Serial.print(mqttClient.state());
                Serial.println(" reintentando en 3 segundos");
                delay(3000);
              }
          }
        }

        long tx_rx() {
          int read_high, read_low, diff;
          long sum = 0;
          int N_samples = 100;

          for (int i = 0; i < N_samples; i++) {
              digitalWrite(tx_pin, HIGH);
              read_high = analogRead(analog_pin);
              delayMicroseconds(100);
              digitalWrite(tx_pin, LOW);
              read_low = analogRead(analog_pin);
              diff = read_high - read_low;
              sum += diff;
          }
          return sum;
        }

        void leerSensor() {
          result = tx_rx();
          long mapped_result = map(result, 15000, 25000, 0, 1024);

          // Fixed: Evaluating the raw result instead of the 0-1024 mapped output
          if (result >= 4500 && result <= 10000) {
              if (waiting) {
                digitalWrite(led_pin, LOW);
                waiting = false;

                Serial.println("\n>> ¡IMPACTO DETECTADO! <<");
                Serial.print("Tiempo de reacción: ");
                Serial.print(tiempo);
                Serial.println(" ms");

                String tiempoData = String(tiempo);
                bool estado = mqttClient.publish(publishTopic, tiempoData.c_str());

                if (estado) {
                    Serial.println("Tiempo enviado por MQTT exitosamente.");
                }
                Serial.println("Esperando comando 'FAB' vía MQTT para repetir la prueba...");
              }
          }
        }

        void setup() {
          Serial.begin(115200);
          pinMode(tx_pin, OUTPUT);
          pinMode(led_pin, OUTPUT);
          digitalWrite(led_pin, LOW);

          wifiInit();
          mqttClient.setServer(mqttServer, mqttPort);
          mqttClient.setCallback(callback);

          timer = timerBegin(1000000);
          timerAttachInterrupt(timer, &onTimer);
          timerAlarm(timer, 1000, true, 0);
          timerStart(timer);

          iniciarPrueba(); 
        }

        void loop() {
          if (!mqttClient.connected()) {
              reconnect();
          }
          mqttClient.loop();
          leerSensor();
          delay(50);
        }

    

Interface

Fab

Smart Boxing UI - Web Interface Implementation

~ Framework: Responsive mobile UI with dark theme, Flatpickr integration, and Paho MQTT WebSockets.
~ Broker Gateway: WSS Secure Protocol on port 8084 (broker.emqx.io).
~ Telemetry Sync: Binds execution commands and raw milliseconds parsing to dashboard metrics.


    <!DOCTYPE html>
    <html lang="es">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Entrenamiento de Boxeo Inteligente</title>
                
        <!-- CDN IMPORTS -->
        <script src="https://cdnjs.cloudflare.com/ajax/libs/paho-mqtt/1.0.1/mqttws31.min.js" type="text/javascript"></script>
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
        <script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
        <script src="https://npmcdn.com/flatpickr/dist/l10n/es.js"></script>
              
        <style>
            :root {
                --bg-color: #121418;
                --card-bg: #1e2126;
                --text-color: #ffffff;
                --accent-yellow: #e4e61c;
                --icon-grey: #3f454f;
                --border-radius: 20px;
            }

            body {
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
                background-color: var(--bg-color);
                color: var(--text-color);
                margin: 0;
                padding: 20px;
                display: flex;
                justify-content: center;
            }

            .phone-container {
                width: 100%;
                max-width: 380px;
                display: flex;
                flex-direction: column;
                gap: 15px;
            }

            header {
                display: flex;
                justify-content: space-between;
                align-items: center;
                padding: 5px 0;
            }

            .header-icon {
                width: 40px;
                height: 40px;
                background-color: var(--card-bg);
                border-radius: 50%;
                display: flex;
                justify-content: center;
                align-items: center;
                color: var(--icon-grey);
            }

            header h1 {
                font-size: 18px;
                margin: 0;
                font-weight: 500;
            }

            .tabs-container {
                display: flex;
                background-color: var(--card-bg);
                border-radius: 30px;
                padding: 5px;
                gap: 5px;
            }

            .tab-btn {
                flex: 1;
                background: none;
                border: none;
                color: #888;
                padding: 12px;
                font-size: 14px;
                font-weight: bold;
                border-radius: 25px;
                cursor: pointer;
                transition: all 0.3s ease;
            }

            .tab-btn.active {
                background-color: #ffffff;
                color: #000000;
            }

            .view-content {
                display: none;
                flex-direction: column;
                gap: 15px;
            }

            .view-content.active {
                display: flex;
            }

            .card {
                background-color: var(--card-bg);
                border-radius: var(--border-radius);
                padding: 20px;
            }

            h2.section-title {
                font-size: 16px;
                font-weight: 600;
                margin-top: 0;
                margin-bottom: 15px;
                color: var(--accent-yellow);
            }

            .combo-card {
                display: flex;
                align-items: center;
                background-color: var(--card-bg);
                border-radius: var(--border-radius);
                padding: 15px;
                gap: 15px;
                border: 2px solid transparent;
                transition: border 0.2s ease;
                cursor: pointer;
            }

            .combo-card.selected {
                border: 2px solid var(--accent-yellow);
            }

            .combo-img-placeholder {
                width: 65px;
                height: 65px;
                background-color: var(--icon-grey);
                border-radius: 12px;
                display: flex;
                justify-content: center;
                align-items: center;
                font-size: 24px;
            }

            .combo-info { flex: 1; }
            .combo-title { font-size: 16px; font-weight: bold; margin-bottom: 4px; }
            .combo-sequence { font-size: 13px; color: var(--accent-yellow); font-weight: 500; }
            
            .combo-action-btn {
                width: 45px;
                height: 45px;
                background-color: #ffffff;
                border-radius: 50%;
                border: none;
                display: flex;
                justify-content: center;
                align-items: center;
                font-size: 18px;
                font-weight: bold;
                color: #000;
                cursor: pointer;
            }

            .flatpickr-calendar { background: transparent !important; box-shadow: none !important; border: none !important; color: white !important; width: 100% !important; }
            .flatpickr-day.selected { background: var(--accent-yellow) !important; color: black !important; }
            .flatpickr-day { color: white !important; }
                        
            .results-container { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
            .result-item { text-align: center; }
            .result-value { font-size: 36px; font-weight: bold; }
            .result-unit { font-size: 14px; color: #888; }
                        
            .max-force-card { background-color: var(--accent-yellow); color: black; border-radius: var(--border-radius); padding: 15px; display: flex; flex-direction: column; justify-content: center; }
            .max-force-card .result-value { color: black; }

            .controls { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px; }
            .btn { padding: 15px; border: none; border-radius: 12px; font-weight: bold; font-size: 16px; cursor: pointer; }
            .btn-start { background-color: #2ecc71; color: white; }
            .btn-stop { background-color: #e74c3c; color: white; }
            .selected-status { font-size: 14px; color: #aaa; margin-bottom: 10px; font-style: italic; }
        </style>
    </head>
    <body>
        
        <div class="phone-container">
            <header>
                <div class="header-icon">&lt;</div>
                <h1>Entrenamiento</h1>
                <div class="header-icon">⚙️</div>
            </header>

            <div class="tabs-container">
                <button class="tab-btn active" onclick="switchTab('rendimiento')">Rendimiento</button>
                <button class="tab-btn" onclick="switchTab('combos')">Combos</button>
            </div>

            <!-- VIEW: RENDIMIENTO -->
            <div id="view-rendimiento" class="view-content active">
                <div class="card">
                    <h2 class="section-title">Historial de Sesiones</h2>
                    <input type="text" id="calendarInline" style="display:none;">
                </div>

                <div class="card">
                    <h2 class="section-title">Datos del Último Golpe</h2>
                    <div class="selected-status" id="currentComboLabel">Combo seleccionado: Ninguno</div>
                        
                    <div class="results-container">
                        <div class="result-item">
                            <div class="result-unit">Reacción</div>
                            <div class="result-value" id="mqttTime">0.00</div>
                            <div class="result-unit">segundos</div>
                        </div>
                        <div class="max-force-card result-item">
                            <div class="result-unit" style="color: rgba(0,0,0,0.6); font-weight:bold;">Fuerza</div>
                            <div class="result-value" id="mqttForce">0</div>
                            <div class="result-unit" style="color: rgba(0,0,0,0.6);">unidades</div>
                        </div>
                    </div>
                </div>

                <div class="controls">
                    <button class="btn btn-start" onclick="startSession()">Empezar</button>
                    <button class="btn btn-stop" onclick="stopSession()">Detener</button>
                </div>
            </div>

            <!-- VIEW: COMBOS -->
            <div id="view-combos" class="view-content">
                <h2 class="section-title" style="margin-left: 5px;">Selecciona tu rutina</h2>
                    
                <div class="combo-card" id="card-combo1" onclick="selectCombo('XCS', 'Combo 1', 'card-combo1')">
                    <div class="combo-img-placeholder">🥊</div>
                    <div class="combo-info">
                        <div class="combo-title">Combo 1</div> 
                        <div class="combo-sequence">Secuencia: Izq - Cen - Der</div>
                    </div>
                    <button class="combo-action-btn">&gt;</button>
                </div>

                <div class="combo-card" id="card-combo2" onclick="selectCombo('CCX', 'Combo 2', 'card-combo2')">
                    <div class="combo-img-placeholder"></div>
                    <div class="combo-info">
                        <div class="combo-title">Combo 2</div>
                        <div class="combo-sequence">Secuencia: Cen - Cen - Izq</div>
                    </div>
                    <button class="combo-action-btn">&gt;</button>
                </div>

                <div class="combo-card" id="card-combo3" onclick="selectCombo('FAB', 'Combo PRUEBA', 'card-combo3')">
                    <div class="combo-img-placeholder">💡</div>
                    <div class="combo-info">
                        <div class="combo-title">Combo PRUEBA</div>
                        <div class="combo-sequence">Activa el ESP32 (LED)</div>
                    </div>
                    <button class="combo-action-btn">&gt;</button>
                </div>
            </div>
        </div>

        <script>
            let selectedComboValue = ""; 
            let selectedComboName = "";

            function switchTab(tabName) {
                document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
                document.querySelectorAll('.view-content').forEach(view => view.classList.remove('active'));

                if(tabName === 'rendimiento') {
                    document.querySelectorAll('.tab-btn')[0].classList.add('active');
                    document.getElementById('view-rendimiento').classList.add('active');
                } else {
                    document.querySelectorAll('.tab-btn')[1].classList.add('active');
                    document.getElementById('view-combos').classList.add('active');
                }
            }

            function selectCombo(value, name, cardId) {
                selectedComboValue = value; 
                selectedComboName = name;
                    
                document.querySelectorAll('.combo-card').forEach(card => card.classList.remove('selected'));
                document.getElementById(cardId).classList.add('selected');
                    
                document.getElementById('currentComboLabel').innerText = `Combo activo: ${name}`;
                setTimeout(() => switchTab('rendimiento'), 250);
            }

            flatpickr("#calendarInline", { inline: true, locale: "es", defaultDate: "today" });

            const MQTT_HOST = "broker.emqx.io"; 
            const MQTT_PORT = 8084; 
            const MQTT_CLIENT_ID = "web_ui_" + parseInt(Math.random() * 1000, 10);
            const TOPIC_KAMILOVSKY = "KAMILOVSKY"; 

            let client = new Paho.MQTT.Client(MQTT_HOST, MQTT_PORT, MQTT_CLIENT_ID);
            client.onConnectionLost = onConnectionLost;
            client.onMessageArrived = onMessageArrived;

            client.connect({
                useSSL: true, 
                timeout: 5,
                onSuccess: onConnect,
                onFailure: (e) => console.error("Error MQTT:", e.errorMessage)
            });
                
            function onConnect() {
                console.log("Conectado a MQTT. Suscribiendo a " + TOPIC_KAMILOVSKY);
                client.subscribe(TOPIC_KAMILOVSKY);
            }

            function onConnectionLost(responseObject) {
                if (responseObject.errorCode !== 0) console.warn("Conexión perdida. Recarga la página.");
            }

            function onMessageArrived(message) {
                let payload = message.payloadString;
                console.log("Mensaje MQTT recibido: " + payload);
                    
                // Lógica corregida a PARAR para evitar el bug del LED
                if (payload === "FAB" || payload === "XCS" || payload === "CCX" || payload === "PARAR") return;

                let tiempoMilisegundos = parseInt(payload);

                if (!isNaN(tiempoMilisegundos)) {
                    let tiempoSegundos = tiempoMilisegundos / 1000;
                    document.getElementById('mqttTime').innerText = tiempoSegundos.toFixed(2);
                        
                    document.getElementById('mqttTime').style.color = "var(--accent-yellow)";
                    setTimeout(() => { document.getElementById('mqttTime').style.color = "white"; }, 500);
                }
            }

            function startSession() {
                if (!selectedComboValue) {
                    alert("Selecciona una rutina en la pestaña 'Combos'.");
                    return;
                }
                    
                let message = new Paho.MQTT.Message(selectedComboValue);
                message.destinationName = TOPIC_KAMILOVSKY;
                client.send(message);
                console.log(`Comando [${selectedComboValue}] enviado.`);
            }

            function stopSession() {
                // Comando actualizado a PARAR
                let message = new Paho.MQTT.Message("PARAR");
                message.destinationName = TOPIC_KAMILOVSKY;
                client.send(message);
                console.log("Comando PARAR enviado.");
            }
        </script>
    </body>
    </html>

Diagram of connections

> Week 16

For more information you can access to my sixteenth week Week 16.

Fab termi Fab termi
Fab termi Fab termi

The process involved using Inkscape and the Inkstitch extension to create the design. The steps are as follows: first, select the image, then vectorize it, edit it, and open the Inkstitch extension. In the extension, select the type of embroidery and finally export it as a .pes file.

Then, use the Brother NS1850L and follow the machine's instructions for threading. Next, insert the USB drive, open the file, and start embroidering.

COMPONENTS

PCB

Material Description Quantity Unit price Source
ESP32 WROOM-32 SMD Dual Core 32Bits 4 MB It is a 32-bit dual-core microcontroller with Wi-Fi and Bluetooth capabilities; it is an SMD surface-mount device with 32 pins. 1 $6.46 UNIT ELECTRONICS
AMS1117 3.3v regulator It is a low-dropout linear voltage regulator. 1 - IBERO
1 USB Type A It serves as a standard “host” connection to transfer data and power. 1 $0.69 -
10μF smd Capacitors It is an electronic component that temporarily stores electrical energy in an electric field. 3 - IBERO
100nF smd Capacitors It is an electronic component that temporarily stores electrical energy in an electric field. 4 $1.21 -
10k Ω resistor It a passive electrical component that restricts or limits the flow of electrical current in a circuit. 1 - IBERO
2k Ω resistor It a passive electrical component that restricts or limits the flow of electrical current in a circuit. 1 - IBERO
4.7k Ω resistor It a passive electrical component that restricts or limits the flow of electrical current in a circuit. 3 - IBERO
0 Ω resistor It is essentially a wire link packaged to look like a standard resistor. 1 - -
1M Ω resistor It a passive electrical component that restricts or limits the flow of electrical current in a circuit. 6 - IBERO
Blue LED LED stands for Light-Emitting Diode. It is a highly efficient electronic component made of semiconductor materials that converts electricity directly into light. 1 - IBERO
Pinheaders A pin header is a common electrical connector consisting of one or more rows of metal pins mounted on a plastic base. They are primarily used as bridges on printed circuit boards (PCBs) to route power, data, and signals, or to connect components to external wiring and other boards. 39 - -
Ft232rl 3.3v 5v It is a highly popular USB-to-UART bridge integrated circuit (IC) manufactured by FTDI (Future Technology Devices International). 1 $4.55 Mercado Libre
Soku Charger Usb 5v 3a 15w It is a usb charger. 1 $10.21 Mercado Libre
Usb to Usb cable 1.7 meters It is a usb to usb cable. 1 $4.55 Mercado Libre

Cases

Material Description Quantity Unit price Source
PLA A biodegradable thermoplastic derived from renewable resources like corn starch. It's the standard for desktop printing due to its minimal thermal expansion. 1 $20 Inova Market

Integration

Material Description Quantity Unit price Source
3mm-thick MDF board MDF (Medium-Density Fiberboard) is an engineered wood product made by breaking down hardwood and softwood residuals into fine fibers, combining them with wax and resin, and pressing them into flat, dense sheets under high heat and pressure. 2 $9.51 HOME DEPOT
Canvas fabric snippet is a highly durable, heavy-duty plain-weave fabric traditionally made from cotton or linen. 2 $2.88 -
Foam These are foams of different densities used for the pads. 3 $11.82 -
Adhesive Velcro These are two strips of Velcro that stick to the wall. 1 $8.31 Amazon
The total is $80.19.

Boxing Reaction Interface System

Electronic Design & Production

Design

Schematic

Fab

Power

Fab

Power

~ To power my circuit, I’ll use a 5V, 3A wall charger, so I connected it to my board via a USB Type-A cable, using only the VCC and GND pins. I intended to use a Schottky diode to protect my circuit; however, my professor Oliver told me that it would significantly reduce my voltage and that the regulator would be sufficient, so I won’t use it when soldering and will instead place a 0-ohm resistor. Next, I installed an AMS1117 regulator from 5V to 3.3V, placing two 10uF capacitors on the input and output. Then, on the 3.3V output, I placed an LED connected to a 2000Ω resistor to provide a visual indication that it is powering correctly. Finally, I added 3 5V pins and 3 3.3V pins to power my NEOPIXELS and sensors, respectively, and I also added 6 ground pins for the same purpose. The 0-ohm resistor is there to facilitate the connection in the design.

ESP32 WROOM 32

Fab

ESP32 WROOM 32

~ The ESP32 WROOM 32 is connected to 3.3V via two capacitors in parallel—one 10uF and the other 100nF—to prevent voltage spikes that could impair the chip's functionality. I installed two buttons, Boot and Enable; both are connected to the ESP32 and to GND via a 100nF capacitors. The capacitor has this value to ensure precise signal transmission. The Enable pin is also connected to a 10k resistor and a 100nF capacitor to GND.

PINS

Fab

PINS

~ The pins are for the three analog sensors, which require four connections (VCC, GND, Input, and Output). Then there are three for the Neopixels (VCC, GND, VIN, VOUT). I also included pins for UART communication so I can program my board, and I left pins for MOSI.

PCB Design

~ Calculator tool. Before defining the size, it is important to calculate it using the calculator tool given by KiCad. To do that we first have to go to the start menu and open the Calculator tool.
Then add the Current (I) and the Temperature rise we are expecting our PCB to have and look for the result the calculator will give back to us in the right top side. The calculator works by using a formula explained at the bottom.

Fab Fab

~ Track thickness. To change the track thickness we must go to the top tool section and click on Track use netclass width. Subsequently, select Edit Pre-defined Sizes.
Inside that section we can add tracks sizes by clicking the + symbol located at the bottom of the window, and in the width section we can change the width of the new track we added. Then we'll just have to click Ok.

Fab Fab

Design

After using KiCad’s width calculation tool, 0.8 mm trace width was selected. I placed the USB at the edge of the board so it would be easier to connect it, then I tried to place all the pins that will be connected to external components as close as posible so I can have more order and I also left the signal space free so it won't be interrfered. The enable and boot buttons are close so I can program it easier.

~ Parameters.

> The outline width is 2 mm and its layer is Edge.Cuts.

> The track’s width is 0.8 mm and its layer is F.Cu.

Results

Fab

Export as SVG

Fab Fab

1. First, I changed the border line width to 2 mm because of the tool that will be used later.

2. Then I went to the top left corner and pressed file, after that I selected Fabrication Outputs.

3. In the fabrication outputs we have to select Gerbers. In Gerbers we have to change the plot format to SVG.

4. Having changed the format to SVG, we have to click on Fit page to board to keep the meassures the way they are in the dessign and select the Output Directory.

Fab Fab

~ Output Directory To change the Output Directory we have to click on the folder symbol located at the top and select the place in our computer where we want to save our document.

Fab

5. Then, we have to select the layers we want to plot.

6. Finally, we have to click on plot and go to the folder where our files are located.

Before Cutting

Fab Fab

1. First, we have to paste the tape in the back of the copper board.

2. Then, we have to paste the copper board to the Sacrifice Bed.

~ Materials:

> Copper Board.

> Double-sided tape.

> Sacrifice Bed. Is an MDF board designed to prevent the SMR-20 from being damaged in the event that the tool drills too deep.

Fab

3. Subsequently, we have to place the bed inside the SMR-20. In my case, in my lab, our SMR-20 has a fitting to secure the sacrifice table with screws.

Before Cutting (Tools)

4. Having secured the bed inside the SMR-20, we have to select the tool for each milling process (Tracks and Borders).

Fab Fab

~ Cutting tool. This tool is specifically designed for traces, as its point is sharp and very thin. The Speed of use can be higher but we must be careful about its deep.

~ Cutting - MODS.

> Tool width: 0.39 mm

> Speed: 4 mm/s

> Origin (x,y,z): (0,0,0)

> Offset number: 3

Fab Fab

~ Outline tool. This tool is can be used for the border cutting because of its width, it can also be used for perforation, but the diameter of them will be bigger.

~ Outline - MODS.

> Tool width: 2 mm

> Speed: 4 mm/s

> Origin (x,y,z): (0,0,0)

> Offset number: 1

Cutting

Fab

1. We have to connect our computer to the SMR-20 and open VPANEL.

~ VPANEL Overview:

> X-Y Controls: Move the tool in the X-Y axis.

> Z Controls: Move the tool in the Z axis.

> Cursor Step and Shortcuts: The Step Cursor determines the speed at which the tool moves along all axes; Continue is the smoothest and x1 is the slowest setting, since each click corresponds to a single motor movement. The Shortcuts are to automatically move to an already saved point (Origin Point).

> Speed and Spindle: Is to set the speed and to turn on or turn off the the spin of the tool.

> Set Coordinates (Origin Point): By clicking the XY or the Z button we can set the Origin Point.

> Process Controls: Cut is for adding our code and start the process. Pause allows you to pause the process and resume it from where it left off. Stop stops the process.

Cutting Setup

Fab

2. Then we have to click on Cut. A window will open, and we should click Add to add our milling code.

Fab

3. Finally we have to click Output and the machine will automatically start to cut.

After Cutting

Fab

1. After finishing the cutting, very carefully, we must take out the copper board and remove our PCB.

Roland SRM-20

The tool used for cutting the boards is the Roland SRM-20. Is a compact CNC desktop milling machine used for prototyping, PCB milling, and small mechanical parts. It removes material using rotating cutting tools.

> Work area: 203.2 × 152.4 × 60.5 mm
> Table size: 232.2 × 156.6 mm
> Spindle speed: 3,000 – 7,000 rpm
> Feed rate: 6 – 1800 mm/min
> Mechanical resolution: ~0.000998 mm/step
> Max workpiece weight: 2 kg
> Control interface: USB (RML-1 or NC code)

Fab

Soldering

Fab

1. To solder, we first need to set up our workspace and gather the necessary tools.

~ Necessary tools:

  • Soldering station
  • Flux
  • Soldering Tin
  • Desoldering mesh
  • Silicone Tablecloth

~ Components:

  • Pinheaders
  • 3 10μF smd Capacitors
  • 4 100nF smd Capacitors
  • 1 AMS1117
  • 1 USB TYPE A
  • 39 Pinheaders
  • 1 ESP32 WROOM 32
  • 2 Buttons
  • 2 0Ω resistors
  • 1 10kΩ resistor
  • 1 2kΩ resistor
  • 6 1MΩ resistors
  • 1 LED

Soldering

Fab

2. First, we need to apply flux to our PCB so that the solder adheres better.

3. Then, we have to turn on the soldering station and set the temperature. To solder tin it is recomendable to place the temperature above 300 °C (572 °F). I will use 404 °C (760 °F) because that works good with my materials.

4. To solder, we have to place the soldering iron over the copper board and heat it up, then we have to place the Tin on the surface and wait until it melts. It is important to place the Tin on the copper surface and not on the soldering iron because the melted Tin flows toward hot surfaces. If we place it on the soldering iron, it won't adhere easily to the copper surface because it will be cooler.

Results

Main board

Fab

Step Response

Fab

ESP32, Inputs & Outputs Code

B.R.I.S. Code

ESP32, Inputs & Outputs Code

~ Firmware Overview. This is the main code for the microcontroller. It handles the Wi-Fi connection, subscribing to the MQTT broker, reading the capacitive sensors calibrated for the resistance of the foam rubber, and individually controlling the three NeoPixel strips to visually indicate the target to the user.


#include <WiFi.h>
#include <PubSubClient.h>
#include <Adafruit_NeoPixel.h>

// ======================================================
// -------------------- WIFI ----------------------------
// ======================================================
const char* ssid = "INTERNET";
const char* password = "PASSWORD";

// ======================================================
// -------------------- MQTT ----------------------------
// ======================================================
const char* mqttServer = "broker.emqx.io";
const int mqttPort = 1883;
const char* connection = "DEREKOVICH_DEREKOVA";
const char* subscribeTopic = "KAMILOVSKY";
const char* publishTopic = "KAMILOVSKY";

// ======================================================
// -------------------- SENSORES & NEOPIXELS ------------
// ======================================================
const int analog_pins[3] = {35, 34, 32}; 
const int tx_pins[3] = {33, 26, 25};     
const int led_pins[3] = {27, 14, 12}; 

const int NUMPIXELS = 11; // 11 NeoPixels per wire

// 3 Sensors and 3 Wires
Adafruit_NeoPixel tiraX(NUMPIXELS, led_pins[0], NEO_GRB + NEO_KHZ800); // Left
Adafruit_NeoPixel tiraO(NUMPIXELS, led_pins[1], NEO_GRB + NEO_KHZ800); // Center
Adafruit_NeoPixel tiraS(NUMPIXELS, led_pins[2], NEO_GRB + NEO_KHZ800); // Right

// Arrange with pointers
Adafruit_NeoPixel* tiras[3] = {&tiraX, &tiraO, &tiraS};

// ======================================================
// -------------------- TIME & COMBOS -----------------
// ======================================================
hw_timer_t *timer = NULL;
volatile unsigned long tiempo = 0; // ms
bool waiting = false; 

String secuenciaActual = "";
int pasoActual = -1;           
unsigned long tiempos[10];     
int sensorActivo = -1;         

// --- Values for the lecture ---
int UMBRAL_X = 2000;  // Left Sensor  (X)
int UMBRAL_O = 4050;  // Central Sensor(O)
int UMBRAL_S = 700;   // Right Sensor (S)

WiFiClient esp32Client;
PubSubClient mqttClient(esp32Client);

// ======================================================
// -------------------- INTERRUPTION TIMER --------------
// ======================================================
void IRAM_ATTR onTimer() {
  tiempo++; 
}

void wifiInit() {
  Serial.print("Conectándose a ");
  Serial.println(ssid);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    Serial.print(".");
    delay(500);
  }
  Serial.println("\nWiFi conectado");
}

int getSensorIndex(char c) {
  if (c == 'X' || c == 'x') return 0;
  if (c == 'O' || c == 'o') return 1;
  if (c == 'S' || c == 's') return 2;
  return -1; 
}

// ======================================================
// -------------------- LIGHTS ----------------
// ======================================================
void encenderTira(int index) {
  uint32_t color;
  
  // Colors for each sensor
  if (index == 0) {
    color = tiras[index]->Color(0, 0, 255);       // X - BLUE
  } else if (index == 1) {
    color = tiras[index]->Color(255, 0, 0);       // O - RED
  } else if (index == 2) {
    color = tiras[index]->Color(150, 0, 255);     // S - PURPLE
  }

  // Turn the corresponding light
  for(int i = 0; i < NUMPIXELS; i++) {
    tiras[index]->setPixelColor(i, color);
  }
  tiras[index]->show();
}

void apagarTira(int index) {
  tiras[index]->clear();
  tiras[index]->show();
}

// ======================================================
// -------------------- START COMBO STEPS ----------
// ======================================================
void iniciarPasoActual() {
  if (pasoActual >= secuenciaActual.length()) return; 
  
  char letra = secuenciaActual.charAt(pasoActual);
  sensorActivo = getSensorIndex(letra);
  
  if (sensorActivo != -1) {
    tiempo = 0;
    encenderTira(sensorActivo); // <-- Next NeoPixel
    delay(100); 
    waiting = true;
    
    Serial.print("\n--- GOLPE ");
    Serial.print(pasoActual + 1);
    Serial.print("/");
    Serial.print(secuenciaActual.length());
    Serial.print(" | SENSOR ACTIVO: ");
    Serial.print(letra);
    Serial.println(" (Esperando impacto) ---");
  } else {
    Serial.print("Letra inválida '");
    Serial.print(letra);
    Serial.println("' en combo. Saltando...");
    pasoActual++;
    iniciarPasoActual();
  }
}

// ======================================================
// -------------------- CALLBACK MQTT ----
// ======================================================
void callback(char* topicCallback, byte* payload, unsigned int length) {
  String modo = "";
  for (int i = 0; i < length; i++) {
    modo += (char)payload[i];
  }
  modo.toUpperCase(); 
  modo.trim(); 
  
  if (modo.length() == 0) return;

  // 1. SINCRONIZATION WITH STOP BUTTON
  if (modo == "PARAR") {
    Serial.println("\n[MQTT] Comando PARAR recibido. Deteniendo rutina...");
    waiting = false;
    sensorActivo = -1;
    secuenciaActual = "";
    pasoActual = -1;
    for (int i = 0; i < 3; i++) {
      apagarTira(i); // <-- Next NeoPixel
    }
    return;
  }

  // 2. FILTER
  char primerChar = modo.charAt(0);
  if (primerChar != 'X' && primerChar != 'O' && primerChar != 'S') {
    return; 
  }
  
  // 3. PROCESS NEW COMBO
  Serial.println("\n==================================");
  Serial.print("NUEVO COMBO RECIBIDO DESDE WEB: ");
  Serial.println(modo);
  Serial.println("==================================");
  
  if (sensorActivo != -1) {
    apagarTira(sensorActivo); // <-- Next NeoPixel
  }

  secuenciaActual = modo;
  pasoActual = 0;
  iniciarPasoActual();
}

void reconnect() {
  while (!mqttClient.connected()) {
    Serial.print("Conectando a MQTT...");
    String clientId = String(connection) + "-" + String(random(0xffff), HEX);
    if (mqttClient.connect(clientId.c_str())) {
      Serial.println(" conectado");
      mqttClient.subscribe(subscribeTopic);
    } else {
      delay(3000);
    }
  }
}

// ======================================================
// -------------------- CAPACITIVE LECTURE --------------
// ======================================================
long tx_rx(int tx, int rx) {
  int read_high, read_low, diff;
  long sum = 0;
  const int N_samples = 100;

  for (int i = 0; i < N_samples; i++) {
    digitalWrite(tx, HIGH);
    delayMicroseconds(50); 
    read_high = analogRead(rx);
    
    digitalWrite(tx, LOW);
    delayMicroseconds(50); 
    read_low = analogRead(rx);
    
    diff = read_high - read_low;
    sum += diff;
  }
  return sum;
}

// ======================================================
// -------------------- READ ACTIVE SENSOR --------------
// ======================================================
void leerSensor() {
  if (!waiting || sensorActivo == -1) return; 
  
  for (int i = 0; i < 3; i++) {
    if (i != sensorActivo) {
      digitalWrite(tx_pins[i], LOW); 
    }
  }

  delayMicroseconds(500); 

  long result = tx_rx(tx_pins[sensorActivo], analog_pins[sensorActivo]);
  bool impactoDetectado = false; 

  // --- LECTURE---
  Serial.print("Crudo: "); Serial.println(result); 
  // -------------------------------------------

  // --- Independent sensor parameters ---
  if (sensorActivo == 0 && result > UMBRAL_X) {
    impactoDetectado = true;
  } 
  else if (sensorActivo == 1 && result > UMBRAL_O) {
    impactoDetectado = true;
  } 
  else if (sensorActivo == 2 && result > UMBRAL_S) {
    impactoDetectado = true;
  }

  // If an impact is detected the flow continue 
  if (impactoDetectado) {
    
    apagarTira(sensorActivo); 
    waiting = false;
    tiempos[pasoActual] = tiempo;

    Serial.print(">> ¡IMPACTO DETECTADO! Tiempo parcial: ");
    Serial.print(tiempo);
    Serial.println(" ms");

    pasoActual++;

    if (pasoActual >= secuenciaActual.length()) {
      unsigned long suma = 0;
      for(int i = 0; i < secuenciaActual.length(); i++) {
        suma += tiempos[i];
      }
      unsigned long promedio = suma / secuenciaActual.length();

      Serial.println("\n==================================");
      Serial.print("COMBO COMPLETADO. PROMEDIO ENVIADO: ");
      Serial.print(promedio);
      Serial.println(" ms");
      Serial.println("==================================");

      String promedioData = String(promedio);
      mqttClient.publish(publishTopic, promedioData.c_str());

      sensorActivo = -1; 
      secuenciaActual = "";
    } else {
      delay(400); 
      iniciarPasoActual();
    }
  }
}

// ======================================================
// -------------------- SETUP ---------------------------
// ======================================================
void setup() {
  Serial.begin(115200);

  // Inicializar pines TX y Analog
  for (int i = 0; i < 3; i++) {
    pinMode(tx_pins[i], OUTPUT);
    digitalWrite(tx_pins[i], LOW);
    pinMode(analog_pins[i], INPUT);
  }

  // Inicializar las 3 tiras NeoPixel
  for (int i = 0; i < 3; i++) {
    tiras[i]->begin();
    tiras[i]->setBrightness(15); // Low intensity to avoid voltaje dropdowns
    apagarTira(i);               // Arrancan apagadas
  }

  wifiInit();

  mqttClient.setServer(mqttServer, mqttPort);
  mqttClient.setCallback(callback);

  timer = timerBegin(1000000);
  timerAttachInterrupt(timer, &onTimer);
  timerAlarm(timer, 1000, true, 0);
  timerStart(timer);
  
  Serial.println("\nSincronización Completa con NeoPixels. Esperando interacción desde la Web...");
}

void loop() {
  if (!mqttClient.connected()) {
    reconnect();
  }
  mqttClient.loop();

  leerSensor();

  delay(20); 
}
        

ESP32, Inputs & Outputs - Code

~ 1. Library Imports and Network Credentials:
The code begins by importing three essential libraries to establish its runtime framework: WiFi.h manages the ESP32’s wireless local area network architecture, PubSubClient.h implements the MQTT communication engine for message queuing, and Adafruit_NeoPixel.h drives the addressable digital LED arrays. Immediately following these imports, the network constants are declared. The character pointers ssid ("INTERNET") and password ("PASSWORD") hold the authentication keys required to securely bind the microcontroller to the local internet router.

~ 2. MQTT Server Identity and Routing Registers:
The second block configures the Internet of Things (IoT) network pathways. The constants mqttServer ("broker.emqx.io") and mqttPort (1883) target the remote cloud messaging gateway. The unique identity register connection ("DEREKOVICH_DEREKOVA") identifies this specific hardware unit on the server. To streamline the bidirectional data flow and ensure unified communication synchronization with the web user interface, both subscribeTopic and publishTopic point to the identical string, "KAMILOVSKY".

~ 3. Triple Proximity Matrix and NeoPixel Strip Construction:
This structural segment instantiates the physical hardware mapping arrays and objects. The integer arrays analog_pins[3] (35, 34, 32) and tx_pins[3] (33, 26, 25) define the explicit receiver and transmitter lines for the Left (X), Center (O), and Right (S) touch sensor pads. The array led_pins[3] (27, 14, 12) defines the data lanes for the indicators, while NUMPIXELS sets a length of 11 LEDs per target. Three independent objects—tiraX, tiraO, and tiraS—are compiled using the NEO_GRB + NEO_KHZ800 bitmask and aggregated into the pointer array tiras[3] to allow for unified indexing across loops.

~ 4. Timing Registers, Sequence Queues, and Foam Calibration Thresholds:
This section manages variables dedicated to tracking athletic performance and physical sensor tuning. A hardware-level pointer hw_timer_t *timer establishes the precision stopwatch engine, while the volatile unsigned long variable tiempo hosts the running millisecond count. The boolean flag waiting dictates if the pad is actively listening for an impact. The character array strings and indexing arrays secuenciaActual, pasoActual, tiempos[10], and sensorActivo handle the active routine layout. Crucially, three independent variables—UMBRAL_X (2000), UMBRAL_O (4050), and UMBRAL_S (700)—store individual raw thresholds to calibrate each sensor pad independently, compensating for structural variances in the foam/dielectric material over each sensor.

~ 5. Internet Core Clients and Hardware Clock Interruption:
Before generating custom logic, the network routing objects are declared. The instance esp32Client is spawned from the WiFiClient class to handle underlying TCP/IP protocol tasks, which is subsequently nested straight inside the mqttClient constructor of the PubSubClient class. Below these declarations, the Interrupt Service Routine (ISR) function void IRAM_ATTR onTimer() is written. Flagged with the IRAM_ATTR macro, this function is saved directly inside internal fast instruction RAM to bypass flash-memory reading latency. It acts as the precision clock engine, incrementing tiempo by exactly 1 every single millisecond.

~ 6. Network Gateway Initialization and Index Mapping:
The network onboarding and parsing utility functions follow next. The function void wifiInit() initiates local wireless authentication by executing WiFi.begin(ssid, password) and running a blocking while loop that prints tracking dots every 500ms until WiFi.status() confirms a successful connection. Beneath it, the translation function int getSensorIndex(char c) interprets incoming letter commands. It parses an inbound character c and maps 'X' to index 0, 'O' to index 1, and 'S' to index 2, providing a clean numerical translation tool to reference the hardware pointer arrays later.

~ 7. Dynamic NeoPixel Chromatic Controllers:
The lighting feedback system is managed by two dedicated display functions. The custom function void encenderTira(int index) handles target illumination based on the currently active target. It uses a conditional sequence to assign unique high-visibility colors to each target: index 0 (X) lights up deep Blue, index 1 (O) lights up pure Red, and index 2 (S) triggers a bright Purple. A nested for loop sweeps through all 11 slots defined by NUMPIXELS, pushing the target hex color code into the array with setPixelColor() and rendering it instantly using tiras[index]->show(). Conversely, void apagarTira(int index) utilizes .clear() and .show() to instantly black out the targeted strip once a hit is registered.

~ 8. Routine Stepping Mechanics:
The function void iniciarPasoActual() functions as the automated drill commander for the active boxing routine. It validates that pasoActual has not overshot the total string sequence length of secuenciaActual. It extracts the targeted letter character using .charAt(), resolves its physical array position using getSensorIndex(), and loads it into sensorActivo. If the resolution is valid, it zeroes out the precision tiempo tracking register, illuminates the target using encenderTira(), introduces a brief 100ms settling pause, flips the waiting flag to true, and logs a detailed status message out to the Serial console.

~ 9. Inbound Packet Arbitration and Echo Filtering:
The data ingestion function void callback(char* topicCallback, byte* payload, unsigned int length) captures incoming data streams sent from the web dashboard. It loops through the raw byte array packet (payload) to recompile a clean local String called modo, forcing it to uppercase and stripping trailing whitespace. If modo matches the explicit string "PARAR", it immediately aborts the active workout, drops waiting to false, resets indices, and forces a total blackout across all 3 pads. If the string is not a stop command, it performs a critical echo-filtering check: if the first character does not match a valid target key ('X', 'O', or 'S'), it silently drops the packet. Valid new routines overwrite secuenciaActual, reset pasoActual to 0, and call iniciarPasoActual().

~ 10. Persistent Network Integration Loop:
The recovery function void reconnect() maintains a continuous link to the remote messaging server. It wraps its operations within a blocking while loop that executes whenever mqttClient.connected() returns false. It constructs a localized identity string, clientId, combining the baseline connection name with a randomized four-digit hexadecimal token to guarantee no duplicate connection clashes occur on the public EMQX server. Upon a successful validation from mqttClient.connect(), it automatically registers a fresh subscription to subscribeTopic to resume listening for incoming athlete selections.

~ 11. Cross-Talk Filtered Differential Measurement:
The performance math function long tx_rx(int tx, int rx) is the technical foundation of the touch-sensing logic. It operates a closed for loop to capture a dense pool of 100 consecutive read entries (N_samples) to filter out ambient electromagnetic interference. In each pass, it drives the designated transmitter pin (tx) high, pauses 50 microseconds, reads the raw voltage level via analogRead(rx) into read_high, drives the transmitter pin low, pauses another 50 microseconds, and captures read_low. The net difference (diff) between these states is added to a running sum, providing a highly stable, noise-insulated capacitive index.

~ 12. Independent Calibration Logic and Analytical Transit:
The processing hub void leerSensor() continuously assesses the physical impact pads. If waiting is false or sensorActivo equals -1, it drops out early to conserve processing cycles. To eliminate cross-talk noise between adjacent targets, it loops through all transmitter lanes, forcing non-active pins to an absolute LOW ground state. It passes the current target pins into tx_rx(), storing the output inside result. It then passes result through independent conditional filters matching the sensorActivo index: index 0 tests against UMBRAL_X, index 1 tests against UMBRAL_O, and index 2 tests against UMBRAL_S.

When a raw signal spike breaks past its dedicated threshold register, an impact is confirmed, and the code runs a rapid finalization sequence: It blacks out the target via apagarTira() and sets waiting to false. It locks the exact millisecond timeline from tiempo into the tiempos[pasoActual] data ledger. It increments pasoActual. If the final step of the string is completed, it sums up all individual entry logs using a localized loop, divides the total by the combo length to compute an overall session average (promedio), and broadcasts this performance metric directly to the web dashboard using mqttClient.publish(). If steps remain, it inserts a 400ms physical recovery delay and fires the next stage via iniciarPasoActual().

~ 13. Pre-Flight Configuration Routine:
The initialization function void setup() schedules the startup tasks when power is applied to the ESP32. It initializes the standard hardware terminal line at 115200 baud and executes a for loop across the sensor arrays, applying explicit pinMode() output/input controls and driving the transmitters low. Another loop runs across the pointer array to invoke .begin(), lock down setBrightness(15) to safeguard the power supply against heavy current spikes, and clear the pixel states. Finally, it binds the server configurations using setServer() and setCallback(), structures the millisecond time base via timerBegin(1000000), hooks up the clock ISR execution pointer with timerAttachInterrupt(), registers an automated reload alarm interval via timerAlarm(), and launches the clock engine.

~ 14. Continuous Execution Engine:
The main loop function void loop() drives the firmware indefinitely. On every pass, it validates server connectivity with an if statement, routing execution into reconnect() if the network link has fractured. It cycles the underlying background processes of the MQTT client using mqttClient.loop() to ingest incoming wireless message arrays, and immediately invokes leerSensor() to poll the active target pad for physical impacts. A short 20ms delay() caps the bottom of the loop to protect the ESP32 from core saturation, maintaining a responsive balance between sensor processing and network maintenance.


Interface

B.R.I.S. INTERFACE

Fab

If you want to take a look at the interface click here B.R.I.S.

B.R.I.S. Interface - Full Source Code


    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Intelligent Boxing</title>
        
        <!-- CDN IMPORTS -->
        <script src="https://cdnjs.cloudflare.com/ajax/libs/paho-mqtt/1.0.1/mqttws31.min.js" type="text/javascript"></script>
        
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
        <script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
        <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>

        <style>
            :root {
                --bg-color: #121418;
                --card-bg: #1e2126;
                --text-color: #ffffff;
                --accent-yellow: #e4e61c;
                --icon-grey: #3f454f;
                --border-radius: 20px;
            }

            body {
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
                background-color: var(--bg-color);
                color: var(--text-color);
                margin: 0;
                padding: 20px;
                display: flex;
                justify-content: center;
            }

            .phone-container {
                width: 100%;
                max-width: 380px;
                display: flex;
                flex-direction: column;
                gap: 15px;
            }

            header {
                display: flex;
                justify-content: space-between;
                align-items: center;
                padding: 5px 0;
            }
            .header-icon {
                width: 40px;
                height: 40px;
                background-color: var(--card-bg);
                border-radius: 50%;
                display: flex;
                justify-content: center;
                align-items: center;
                color: var(--icon-grey);
            }
            header h1 {
                font-size: 18px;
                margin: 0;
                font-weight: 500;
            }

            .tabs-container {
                display: flex;
                background-color: var(--card-bg);
                border-radius: 30px;
                padding: 5px;
                gap: 3px;
            }
            .tab-btn {
                flex: 1;
                background: none;
                border: none;
                color: #888;
                padding: 10px 5px;
                font-size: 13px;
                font-weight: bold;
                border-radius: 25px;
                cursor: pointer;
                transition: all 0.3s ease;
                text-align: center;
            }
            .tab-btn.active {
                background-color: #ffffff;
                color: #000000;
            }

            .view-content {
                display: none;
                flex-direction: column;
                gap: 15px;
            }
            .view-content.active {
                display: flex;
            }

            .card {
                background-color: var(--card-bg);
                border-radius: var(--border-radius);
                padding: 20px;
            }
            h2.section-title {
                font-size: 16px;
                font-weight: 600;
                margin-top: 0;
                margin-bottom: 15px;
                color: var(--accent-yellow);
            }

            .builder-box {
                border: 1px dashed var(--accent-yellow);
                background-color: rgba(228, 230, 28, 0.03);
            }
            .btn-toggle-builder {
                width: 100%;
                padding: 10px;
                background-color: #2d3139;
                color: white;
                border: 1px solid var(--icon-grey);
                border-radius: 12px;
                font-weight: bold;
                cursor: pointer;
                margin-bottom: 12px;
                transition: all 0.2s;
            }
            .btn-toggle-builder.active {
                background-color: var(--accent-yellow);
                color: black;
                border-color: var(--accent-yellow);
            }
            .slots-container {
                display: grid;
                grid-template-columns: 1fr 1fr 1fr;
                gap: 8px;
                margin-bottom: 12px;
            }
            .circuit-slot {
                background-color: #121418;
                border: 1px solid #3f454f;
                border-radius: 10px;
                padding: 8px;
                text-align: center;
                font-size: 11px;
                color: #777;
                text-transform: uppercase;
            }
            .circuit-slot.filled {
                border-color: var(--accent-yellow);
                color: white;
                font-weight: bold;
            }

            .combo-card {
                display: flex;
                align-items: center;
                background-color: var(--card-bg);
                border-radius: var(--border-radius);
                padding: 15px;
                gap: 15px;
                border: 2px solid transparent;
                transition: border 0.2s ease;
                cursor: pointer;
            }
            .combo-card.selected {
                border: 2px solid var(--accent-yellow);
            }
            .combo-img-placeholder {
                width: 50px;
                height: 50px;
                background-color: var(--icon-grey);
                border-radius: 12px;
                display: flex;
                justify-content: center;
                align-items: center;
                font-size: 20px;
            }
            .combo-info { flex: 1; }
            .combo-title { font-size: 15px; font-weight: bold; margin-bottom: 4px; }
            .combo-sequence { font-size: 12px; color: var(--accent-yellow); font-weight: 500; }
            .combo-action-btn {
                width: 35px;
                height: 35px;
                background-color: #ffffff;
                border-radius: 50%;
                border: none;
                display: flex;
                justify-content: center;
                align-items: center;
                font-size: 14px;
                font-weight: bold;
                color: #000;
            }

            .flatpickr-calendar { background: 
            transparent !important; box-shadow: none !important; border: none !important; color: white !important; width: 100% !important; }
            .flatpickr-day.selected { background: var(--accent-yellow) !important; color: black !important; }
            .flatpickr-day { color: white !important; }
            
            .results-container { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
            .result-item { text-align: center; }
            .result-value { font-size: 36px; font-weight: bold; }
            .result-unit { font-size: 14px; color: #888; }
            
            .max-force-card { background-color: 
            var(--accent-yellow); color: black; border-radius: var(--border-radius); 
            padding: 15px; display: flex; flex-direction: column; justify-content: center; }
            .max-force-card .result-value { color: black; }

            .controls { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px; }
            .btn { padding: 15px; border: none; border-radius: 12px; font-weight: bold; font-size: 16px; cursor: pointer; }
            .btn-start { background-color: #2ecc71; color: white; }
            .btn-stop { background-color: #e74c3c; color: white; }
            .selected-status { font-size: 14px; color: #aaa; margin-bottom: 10px; font-style: italic; }

            .history-list { max-height: 220px; overflow-y: auto; margin-top: 10px; padding-right: 5px; }
            .history-item { display: 
            flex; justify-content: space-between; align-items: center; font-size: 12px; padding: 10px 0; border-bottom: 1px solid #2d3139; }
            .history-item:last-child { border: none; }
            .history-tag { color: var(--accent-yellow); font-size: 14px; font-weight: bold; }
            
            .score-pill {
                background-color: #3f454f;
                color: #fff;
                padding: 3px 8px;
                border-radius: 10px;
                font-size: 10px;
                font-weight: bold;
                margin-top: 4px;
                display: inline-block;
            }
            
            .chart-container {
                width: 100%;
                height: 180px;
                margin-bottom: 10px;
            }
        </style>
    </head>
    <body>

    <div class="phone-container">
        
        <header>
            <div class="header-icon">&lt;</div>
            <h1>Session</h1>
            <div class="header-icon">⚙️</div>
        </header>

        <div class="tabs-container">
            <button id="btn-tab-rendimiento" class="tab-btn active" onclick="switchTab('rendimiento')">Performance</button>
            <button id="btn-tab-combos" class="tab-btn" onclick="switchTab('combos')">Combos</button>
            <button id="btn-tab-historial" class="tab-btn" onclick="switchTab('historial')">History</button>
        </div>

        <div id="view-rendimiento" class="view-content active">
            <div class="card">
                <h2 class="section-title">Current Strike</h2>
                <div class="selected-status" id="currentComboLabel">Selected Routine: None (Select from the tabs)</div>
                
                <div class="results-container">
                    <div class="result-item">
                        <div class="result-unit">Reaction Time</div>
                        <div class="result-value" id="mqttTime">0.00</div>
                        <div class="result-unit">Seconds</div>
                    </div>
                    <div class="max-force-card result-item">
                        <div class="result-unit" style="color: rgba(0,0,0,0.6); font-weight:bold;">Combo Score</div>
                        <div class="result-value" id="mqttScore">0</div>
                        <div class="result-unit" style="color: rgba(0,0,0,0.6);">Points</div>
                    </div>
                </div>
            </div>

            <div class="controls">
                <button class="btn btn-start" onclick="startSession()">Start</button>
                <button class="btn btn-stop" onclick="stopSession()">Stop / Save</button>
            </div>
        </div>

        <div id="view-combos" class="view-content">
            <div class="card builder-box">
                <h2 class="section-title" style="margin-bottom:8px;">Custom Circuit Builder</h2>
                <button class="btn-toggle-builder" id="builderToggleBtn" onclick="toggleBuilderMode()">➕ Create Custom Circuit</button>
                <div class="slots-container">
                    <div class="circuit-slot" id="slot-0">Slot 1</div>
                    <div class="circuit-slot" id="slot-1">Slot 2</div>
                    <div class="circuit-slot" id="slot-2">Slot 3</div>
                </div>
                <div style="display:flex; gap:10px;">
                    <button onclick="clearCustomCircuit()" style="flex:1; padding:8px; border:none; background:#444; color:white; 
                        border-radius:8px; font-weight:bold; cursor:pointer;">Clear</button>
                    <button id="btnLoadCircuit" onclick="loadCustomCircuit()" style="flex:2; padding:8px;
    border:none; background:var(--accent-yellow); color:black; border-radius:8px; font-weight:bold; cursor:pointer; 
    display:none;">   
    Load & Play Circuit</button>
                </div>
            </div>

            <h2 class="section-title" style="margin-left: 5px; margin-top:5px;">Select your routine</h2>
            
            <div class="combo-card" id="card-combo-xos" onclick="handleComboSelection('XOS', 'Left-Center-Right', 'card-combo-xos')">
                <div class="combo-img-placeholder">🥊</div>
                <div class="combo-info">
                    <div class="combo-title">Left - Center - Right</div>
                    <div class="combo-sequence">Sequence: X - O - S</div>
                </div>
                <button class="combo-action-btn">></button>
            </div>

            <div class="combo-card" id="card-combo-xso" onclick="handleComboSelection('XSO', 'Left-Right-Center', 'card-combo-xso')">
                <div class="combo-img-placeholder">🥊</div>
                <div class="combo-info">
                    <div class="combo-title">Left - Right - Center</div>
                    <div class="combo-sequence">Sequence: X - S - O</div>
                </div>
                <button class="combo-action-btn">></button>
            </div>

            <div class="combo-card" id="card-combo-sox" onclick="handleComboSelection('SOX', 'Right-Center-Left', 'card-combo-sox')">
                <div class="combo-img-placeholder">🥊</div>
                <div class="combo-info">
                    <div class="combo-title">Right - Center - Left</div>
                    <div class="combo-sequence">Sequence: S - O - X</div>
                </div>
                <button class="combo-action-btn">></button>
            </div>

            <div class="combo-card" id="card-combo-sxs" onclick="handleComboSelection('SXS', 'Right-Left-Right', 'card-combo-sxs')">
                <div class="combo-img-placeholder">🥊</div>
                <div class="combo-info">
                    <div class="combo-title">Right - Left - Right</div>
                    <div class="combo-sequence">Sequence: S - X - S</div>
                </div>
                <button class="combo-action-btn">></button>
            </div>

            <div class="combo-card" id="card-combo-osx" onclick="handleComboSelection('OSX', 'Center-Right-Left', 'card-combo-osx')">
                <div class="combo-img-placeholder">🥊</div>
                <div class="combo-info">
                    <div class="combo-title">Center - Right - Left</div>
                    <div class="combo-sequence">Sequence: O - S - X</div>
                </div>
                <button class="combo-action-btn">></button>
            </div>

            <div class="combo-card" id="card-combo-oso" onclick="handleComboSelection('OSO', 'Center-Right-Center', 'card-combo-oso')">
                <div class="combo-img-placeholder">🥊</div>
                <div class="combo-info">
                    <div class="combo-title">Center - Right - Center</div>
                    <div class="combo-sequence">Sequence: O - S - O</div>
                </div>
                <button class="combo-action-btn">></button>
            </div>

            <div class="combo-card" id="card-combo-xsx" onclick="handleComboSelection('XSX', 'Left-Right-Left', 'card-combo-xsx')">
                <div class="combo-img-placeholder">🥊</div>
                <div class="combo-info">
                    <div class="combo-title">Left - Right - Left</div>
                    <div class="combo-sequence">Sequence: X - S - X</div>
                </div>
                <button class="combo-action-btn">></button>
            </div>

            <div class="combo-card" id="card-combo-sos" onclick="handleComboSelection('SOS', 'Right-Center-Right', 'card-combo-sos')">
                <div class="combo-img-placeholder">🥊</div>
                <div class="combo-info">
                    <div class="combo-title">Right - Center - Right</div>
                    <div class="combo-sequence">Sequence: S - O - S</div>
                </div>
                <button class="combo-action-btn">></button>
            </div>

            <div class="combo-card" id="card-combo-oxo" onclick="handleComboSelection('OXO', 'Center-Left-Center', 'card-combo-oxo')">
                <div class="combo-img-placeholder">🥊</div>
                <div class="combo-info">
                    <div class="combo-title">Center - Left - Center</div>
                    <div class="combo-sequence">Sequence: O - X - O</div>
                </div>
                <button class="combo-action-btn">></button>
            </div>
        </div>

        <div id="view-historial" class="view-content">
            <div class="card">
                <h2 class="section-title">Long-Term Progress</h2>
                <div style="font-size: 11px; color: #888; margin-bottom: 5px; text-align: center;">Average Session Reaction Time (s)</div>
                <div class="chart-container">
                    <canvas id="progressChart"></canvas>
                </div>
            </div>

            <div class="card">
                <h2 class="section-title">Session History</h2>
                <input type="text" id="calendarInline" style="display:none;">
                <div class="history-list" id="storageHistory"></div>
            </div>
        </div>

    </div>

    <script>
        // Variables Globales
        let comboQueue = [];
        let currentQueueIndex = 0;
        let currentRoutineName = "";
        let isPlaylistMode = false;
        let builderModeActive = false;
        let selectedCustomCombos = [];
        let progressChart = null; 

        // Session tracking variables
        let sessionActive = false;
        let currentSessionTimes = [];
        let currentSessionScores = [];

        // TABS LOGIC
        function switchTab(tabName) {
            document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
            document.querySelectorAll('.view-content').forEach(view => view.classList.remove('active'));

            document.getElementById(`btn-tab-${tabName}`).classList.add('active');
            document.getElementById(`view-${tabName}`).classList.add('active');

            if(tabName === 'historial') {
                updateHistoryUI();
            }
        }

        // COMBO SELECTION & BUILDER LOGIC
        function handleComboSelection(value, name, cardId) {
            if (builderModeActive) {
                if (selectedCustomCombos.length >= 3) {
                    alert("Maximum of 3 combos reached for the custom circuit!");
                    return;
                }
                selectedCustomCombos.push({ value: value, name: name });
                updateBuilderSlotsUI();
            } else {
                currentRoutineName = name;
                comboQueue = [value];
                isPlaylistMode = false;
                currentQueueIndex = 0;

                document.querySelectorAll('.combo-card').forEach(card => card.classList.remove('selected'));
                document.getElementById(cardId).classList.add('selected');
                
                document.getElementById('currentComboLabel').innerText = `Active: ${name} (${value})`;
                setTimeout(() => switchTab('rendimiento'), 250);
            }
        }

        function toggleBuilderMode() {
            builderModeActive = !builderModeActive;
            const btn = document.getElementById('builderToggleBtn');
            if (builderModeActive) {
                btn.innerText = "Selecting Combos... (Tap below)";
                btn.classList.add('active');
                document.querySelectorAll('.combo-card').forEach(card => card.classList.remove('selected'));
            } else {
                btn.innerText = "➕ Create Custom Circuit";
                btn.classList.remove('active');
            }
        }

        function clearCustomCircuit() {
            selectedCustomCombos = [];
            updateBuilderSlotsUI();
        }

        function updateBuilderSlotsUI() {
            for (let i = 0; i < 3; i++) {
                const slot = document.getElementById(`slot-${i}`);
                if (selectedCustomCombos[i]) {
                    slot.innerText = selectedCustomCombos[i].name + ` (${selectedCustomCombos[i].value})`;
                    slot.classList.add('filled');
                } else {
                    slot.innerText = `Slot ${i+1}`;
                    slot.classList.remove('filled');
                }
            }
            document.getElementById('btnLoadCircuit').style.display = selectedCustomCombos.length > 0 ? "block" : "none";
        }

        function loadCustomCircuit() {
            if (selectedCustomCombos.length === 0) return;
            
            comboQueue = selectedCustomCombos.map(item => item.value);
            currentRoutineName = "Custom Circuit";
            isPlaylistMode = true;
            currentQueueIndex = 0;

            document.getElementById('currentComboLabel').innerText = `Active: Custom Circuit [${comboQueue.length} Stages]`;
            
            builderModeActive = false;
            const btn = document.getElementById('builderToggleBtn');
            btn.innerText = "➕ Create Custom Circuit";
            btn.classList.remove('active');

            switchTab('rendimiento');
        }

        flatpickr("#calendarInline", { inline: true, defaultDate: "today" });
        
        // MQTT CONFIGURATION
        const MQTT_HOST = "broker.emqx.io"; 
        const MQTT_PORT = 8084; 
        const MQTT_CLIENT_ID = "boxing_interface_" + parseInt(Math.random() * 1000, 10);
        const TOPIC_KAMILOVSKY = "KAMILOVSKY"; 

        let client = new Paho.MQTT.Client(MQTT_HOST, MQTT_PORT, MQTT_CLIENT_ID);
        client.onConnectionLost = onConnectionLost;
        client.onMessageArrived = onMessageArrived;

        const connectOptions = {
            useSSL: true, 
            timeout: 5,
            keepAliveInterval: 30,
            onSuccess: onConnect,
            onFailure: (err) => console.error("MQTT Connection error: ", err.errorMessage)
        };

        client.connect(connectOptions);

        function onConnect() {
            console.log("Connected to broker via WSS!");
            client.subscribe(TOPIC_KAMILOVSKY);
        }

        function onConnectionLost(responseObject) {
            if (responseObject.errorCode !== 0) {
                setTimeout(() => client.connect(connectOptions), 3000);
            }
        }

        // DATA RECEPTION
        function onMessageArrived(message) {
            let payload = message.payloadString.trim();
            if (isNaN(payload)) return; 

            let promedioMilisegundos = parseInt(payload);
            if (!isNaN(promedioMilisegundos)) {
                let promedioSegundos = promedioMilisegundos / 1000;
                let comboScore = Math.max(0, Math.round(1000 - (promedioSegundos * 200)));

                // Update live view
                document.getElementById('mqttTime').innerText = promedioSegundos.toFixed(2);
                document.getElementById('mqttScore').innerText = comboScore;
                
                document.getElementById('mqttTime').style.color = "var(--accent-yellow)";
                setTimeout(() => { document.getElementById('mqttTime').style.color = "white"; }, 600);

                // Save data temporarily to active session
                if (sessionActive) {
                    currentSessionTimes.push(promedioSegundos);
                    currentSessionScores.push(comboScore);
                }

                // Continue circuit if in playlist mode
                if (isPlaylistMode) {
                    currentQueueIndex++;
                    if (currentQueueIndex < comboQueue.length) {
                        setTimeout(() => {
                            sendMqttCommand(comboQueue[currentQueueIndex]);
                        }, 1200);
                    } else {
                        document.getElementById('currentComboLabel').innerText = "🎉 Custom Circuit Finished!";
                        isPlaylistMode = false;
                        currentQueueIndex = 0;
                        finalizeSession(); // Auto-save when circuit ends
                    }
                }
            }
        }

        function sendMqttCommand(payloadStr) {
            let message = new Paho.MQTT.Message(payloadStr);
            message.destinationName = TOPIC_KAMILOVSKY;
            client.send(message);
        }

        function startSession() {
            if (comboQueue.length === 0) {
                alert("⚠️ Choose a single combo or load a Custom Circuit first.");
                return;
            }
            
            sessionActive = true;
            currentSessionTimes = [];
            currentSessionScores = [];
            currentQueueIndex = 0; 
            
            sendMqttCommand(comboQueue[0]);
        }

        function stopSession() {
            sendMqttCommand("PARAR");
            document.getElementById('currentComboLabel').innerText = "Session Stopped. Saving to history...";
            isPlaylistMode = false;
            currentQueueIndex = 0;
            
            finalizeSession();
        }

        // Calculate averages and save session
        function finalizeSession() {
            if (!sessionActive || currentSessionTimes.length === 0) {
                sessionActive = false;
                return; 
            }

            let totalTime = currentSessionTimes.reduce((a, b) => a + b, 0);
            let avgTime = (totalTime / currentSessionTimes.length).toFixed(2);
            
            let totalScore = currentSessionScores.reduce((a, b) => a + b, 0);

            saveSessionToHistory(currentRoutineName, avgTime, totalScore, currentSessionTimes.length);
            sessionActive = false;
        }

        // LOCALSTORAGE & CHART UI
        function saveSessionToHistory(routine, avgTime, totalScore, punchCount) {
            let currentData = JSON.parse(localStorage.getItem("boxing_sessions")) || [];
            let now = new Date();
            let dateStamp = now.toLocaleDateString([], {month: 'short', day: 'numeric'});
            let timeStamp = now.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
            
            let newRecord = {
                routine: routine,
                avgTime: avgTime,
                score: totalScore,
                strikes: punchCount,
                dateLabel: `${dateStamp} ${timeStamp}`
            };
            
            currentData.unshift(newRecord); 
            if (currentData.length > 30) currentData.pop(); // Keep last 30 sessions
            
            localStorage.setItem("boxing_sessions", JSON.stringify(currentData));
            updateHistoryUI();
        }

        function updateHistoryUI() {
            let currentData = JSON.parse(localStorage.getItem("boxing_sessions")) || [];
            let container = document.getElementById("storageHistory");
            
            if (currentData.length === 0) {
                container.innerHTML = `<div style="color:#666; font-size:12px; text-align:center; padding:10px;
                    ">No complete sessions recorded yet. Start a session and press Stop to save.</div>`;
                return;
            }
            
            // Render session list
            container.innerHTML = currentData.map(item => `
                <div class="history-item">
                    <div style="display:flex; flex-direction:column; gap:2px;">
                        <span>[${item.dateLabel}] <strong>${item.routine}</strong></span>
                        <span style="font-size: 10px; color: #888;">Strikes Thrown: ${item.strikes}</span>
                        <span class="score-pill">Total Score: ${item.score}</span>
                    </div>
                    <div style="text-align:right;">
                        <span class="history-tag">${item.avgTime}s</span>
                        <div style="font-size: 10px; color: #888;">Avg React Time</div>
                    </div>
                </div>
            `).join('');

            renderProgressChart(currentData);
        }

        function renderProgressChart(data) {
            const ctx = document.getElementById('progressChart').getContext('2d');
            
            // Reverse data for chart to go from oldest to newest
            const chartData = [...data].reverse();
            
            const labels = chartData.map((d, index) => `S${index + 1}`); 
            const avgTimes = chartData.map(d => parseFloat(d.avgTime));

            if (progressChart) {
                progressChart.data.labels = labels;
                progressChart.data.datasets[0].data = avgTimes;
                progressChart.update();
            } else {
                progressChart = new Chart(ctx, {
                    type: 'line',
                    data: {
                        labels: labels,
                        datasets: [{
                            label: 'Avg Reaction Time (s)',
                            data: avgTimes,
                            borderColor: '#e4e61c',
                            backgroundColor: 'rgba(228, 230, 28, 0.1)',
                            borderWidth: 2,
                            pointBackgroundColor: '#fff',
                            pointRadius: 4,
                            tension: 0.3,
                            fill: true
                        }]
                    },
                    options: {
                        responsive: true,
                        maintainAspectRatio: false,
                        scales: {
                            y: {
                                beginAtZero: false,
                                grid: { color: '#2d3139' },
                                ticks: { color: '#888', font: { size: 10 } }
                            },
                            x: {
                                grid: { display: false },
                                ticks: { color: '#888', font: { size: 10 } }
                            }
                        },
                        plugins: {
                            legend: { display: false },
                            tooltip: {
                                callbacks: {
                                    title: function(context) {
                                        return chartData[context[0].dataIndex].dateLabel;
                                    },
                                    label: function(context) {
                                        return ` Avg Time: ${context.raw}s`;
                                    }
                                }
                            }
                        }
                    }
                });
            }
        }

        // Initialize UI
        updateHistoryUI();
    </script>

    </body>
    </html>
        

B.R.I.S. Interface - FULL CODE

~ The External Foundation (Libraries):
To handle complex tasks without slowing down, the system imports three highly specialized external software libraries through global content delivery networks. First, the Paho MQTT library (mqttws31.min.js) functions as the system's internet translator, allowing a standard web browser to send and receive high-speed wireless messages to and from the boxing pads. Second, the Flatpickr library provides the internal logic for an interactive, on-screen training calendar. Finally, the Chart.js library provides the graphic engine that converts raw numerical logs into clean, dynamic visual line graphs.

~ The App Appearance (CSS Layout):
The visual framework uses advanced styling rules to transform a standard web page into what looks and feels like a native mobile phone application. By defining color blueprints inside the :root selector, the interface utilizes a dark theme controlled by --bg-color (#121418), --card-bg (#1e2126), and a vibrant neon highlight called --accent-yellow (#e4e61c), which guarantees the text remains readable even when an athlete is sweating and moving fast. The container class .phone-container constrains the layout to a maximum width of 380 pixels, while specific stylistic structural pieces like .combo-card (for routines), .builder-box (for the custom editor), and .score-pill (for achievement tags) organize the layout into clean, separate blocks.

~ The Brain's Memory (Global Variables):
Before running any actions, the program sets up its active memory banks using global variables to keep track of the training state. The array variable comboQueue and the numerical counter currentQueueIndex work together like a music playlist, storing which combinations are up next and which one is currently active. The true/false flags isPlaylistMode, builderModeActive, and sessionActive act as strict digital switches, letting the app know if it should be saving custom selections, running an active drill, or processing a live routine. To capture performance data safely, currentSessionTimes and currentSessionScores operate as temporary ledger sheets that hold every individual punch metric before they are permanently filed away.

~ Navigating the App (Tabs Logic):
The app is built as a single-page interface, meaning it never forces the user to wait for a new page to load. Instead, the function switchTab(tabName) handles all navigation by instantly modifying the visibility of the screen. It searches the entire document for any elements tagged with .tab-btn or .view-content and strips away their .active styling properties to make them invisible. It then instantly applies the .active property to the specific tab button and viewport container that the user clicked (such as btn-tab-rendimiento, btn-tab-combos, or btn-tab-historial). If a user clicks over to the History tab, this function automatically runs updateHistoryUI() behind the scenes so the graph is fully loaded before the view fades in.

~ The Custom Routine Creator (Builder Functions):
The system features an interactive "Circuit Builder" section so fighters can design their own custom workouts. The function toggleBuilderMode() toggles the builderModeActive flag on and off, changing the text of the #builderToggleBtn to let the user know they are actively editing. While active, clicking any workout card diverts into handleComboSelection(), which halts immediate playback and instead pushes that combo's ID and name into the selectedCustomCombos array, capping the limit at 3 items. The function updateBuilderSlotsUI() monitors this array and updates the physical slots (#slot-0, #slot-1, #slot-2) on screen from empty placeholders to filled indicators, while clearCustomCircuit() resets the array entirely. Finally, loadCustomCircuit() locks in the custom routine, converts the selections into raw command arrays within comboQueue, turns on isPlaylistMode, and automatically pushes the athlete to the performance screen.

~ Establishing the Wireless Link (MQTT Core):
Internet communication is managed by binding variables to a public server located at MQTT_HOST ("broker.emqx.io") using the secured web-sockets port MQTT_PORT (8084). The app generates a unique tracking name via MQTT_CLIENT_ID combined with a random number generator to prevent server conflicts, and hooks up the communication client via new Paho.MQTT.Client(). It attaches internal listeners called onConnectionLost and onMessageArrived to handle server events. Through an initialization object named connectOptions, it forces the connection to stay alive via keepAliveInterval: 30 and protects the data stream using useSSL: true before calling onConnect() to officially subscribe to the machine's primary data channel, TOPIC_KAMILOVSKY. If the signal drops, onConnectionLost() catches the error and schedules an automatic reconnection attempt every 3 seconds.

~ Ingesting and Gamifying Punch Data (Message Parsing):
When the boxing gear registers an impact, it broadcasts a data packet over the internet which is caught immediately by onMessageArrived(message). This function extracts the raw text via message.payloadString, trims off empty spaces, and ignores the message if it isn't a pure number. It takes the incoming millisecond number and divides it by 1000 to compute the clean decimal value promedioSegundos (seconds). To turn training into a game, it passes that time through a math formula:

Score = 1000 - (Seconds × 200)

This value is handled via Math.max(0, ...) to prevent negative scores, producing an integer called comboScore where lightning-fast reactions yield massive point values. The function updates the text inside the #mqttTime and #mqttScore layout blocks, briefly flashes the text color to yellow using a short setTimeout trigger to signal a live hit, and pushes the data into the active session arrays if sessionActive is turned on.

~ Automation and Hardware Controls (Session Lifecycle):
Controlling the physical boxing target is managed through a cluster of specialized operational commands. The function sendMqttCommand(payloadStr) is the fundamental shipping container; it wraps any text instruction into a formal Paho.MQTT.Message and fires it off to the broker. When a user presses the green button, startSession() checks if a routine is loaded, wipes out old metrics, sets sessionActive to true, resets the indexing queue, and deploys the first target code across the web. If the user hits the red button, stopSession() acts as an immediate override, broadcasting the explicit shutdown command "PARAR" to clear the target's LEDs and sensors, sets isPlaylistMode to false, and invokes finalizeSession(). If the user is running a multi-stage custom circuit, the message arrival function handles the automation: it automatically increases currentQueueIndex and utilizes a 1.2-second setTimeout delay to give the fighter time to reset their stance before triggering the next stage automatically.

~ Saving Progress to the Hard Drive (LocalStorage):
To prevent your data from vanishing when you close the browser window, the app contains an integrated automated filing system. When a workout ends, finalizeSession() checks for valid data, uses a JavaScript .reduce() array loop to sum up all reaction times, and divides that sum by the array length to establish a true session average (avgTime). It then hands this over to saveSessionToHistory(), which uses JSON.parse to pull down the historic file folder called "boxing_sessions" from the browser's permanent localStorage. It grabs the current date and clock time using toLocaleDateString and toLocaleTimeString, packages everything into a compact dataset object named newRecord, and uses .unshift() to slide it onto the absolute top of the history stack. To keep the app running fast, a defensive .pop() command removes anything past the 30th slot before saving the updated list back to the browser's hard drive.

~ Displaying the Historical Graphs (UI Render):
The final segment of the application reads the browser's memory bank and displays it to the public as beautiful charts and logs. The function updateHistoryUI() loops through the local history array and uses an automated template literal conversion (.map().join('')) to instantly construct fresh HTML cards complete with date stamps, routine names, target strikes, and grey background .score-pill counters, instantly dumping them into the #storageHistory list display. Finally, renderProgressChart(data) finds the #progressChart element on the HTML canvas and uses a spread operator code [...data].reverse() to flip the layout chronologically from oldest to newest. If the line graph already exists, it updates the internal points smoothly using progressChart.update(); otherwise, it builds a fully custom, responsive, neon-yellow line graph from scratch, configuring visual grids, custom tooltips, and font parameters to showcase the boxer's increasing hand speed over weeks of training.


Integration & Packaging

Part Design & 3D Printing

NEOPIXELS case

Fab Fab

1. First, I changed the document units to CGS and created a center point rectangle of 1.7 X 18 cm in the front plane.

NEOPIXELS case

Fab Fab

2. Then, I extruded it 1.5 cm and made roundings of 0.20 cm.

NEOPIXELS case

Fab

3. After that, I made a plane 0.30 cm from the front face of the piece.

Fab Fab

4. And in that plane made a rectangle with a distance of 0.30 cm from each edge. Then, I extruded a cut of 0.90 cm inside.

NEOPIXELS case

Fab Fab

5. Subsequently, in the lateral face of the piece I made a rectangle of 1.10 X 0.50 cm at a distance of 0.30 cm from each edge. Next, I extruded a cut inside. I also added roundings in the corners so it could look better.

Fab

NEOPIXELS case

Fab

6. After that, I made a plane 0.90 cm from the back face of the piece.

Fab Fab

7. Then I added a rectangle to create a tab where I could later insert a transparent sheet. Then, I extruded a cut of 0.10 cm inside and outside.

Results

Fab

~ Export. We have to export our piece as an STL so we can print it. For that, we must go to File, then look for Save as and save it as STL.

Printing

~ Slicer: To print, I used Prusa Slicer. I will also use a Prusa MK4S for the 3D printing process.

AIDE

NEOPIXELS case

Fab

1. First, open the slicer and import the STL piece.

NEOPIXELS case

Fab
Fab

2. For importing, we have to press File, then import and select import STL.

NEOPIXELS case

Fab
Fab

3. After that, we have to set the printing parameters. Being honest, I just placed my piece on the center, changed the material to PLA and also changed the infill to 5%.

4. Then, we just have to press the button at the bottom labeled as SLICE.

Fab

5. Having sliced our piece, we can see the way it will be printed and, by pressing the button below, we can save it in the Prusa USB.

Results

Fab

System Integration

After week 15, I made changes to the structure to improve the assembly’s usability and durability, and also, to have a packaging for my project. Among the most significant changes are a reduction in the horizontal size, reduction in the space between the pads, engraving on the front panel, and fine-tuning of the screw sizes and spacing between components.

Position footprint

Fab Fab
Fab Fab

PIECE. First, I changed the document units to CGS and created a center point rectangle of 62 X 36 cm in the front plane. Then, I made fillets of 5 cm of diameter and extruded it 3 mm. Next, I traced the outlines of the parts I’ll be placing in the assembly, which are: 3 Neopixel casings, 3 pads, and the ESP32 casing. My pads will measure 17 x 17 cm, spaced 1 cm apart, with a 2 cm distance from their respective Neopixels and a 1 cm vertical distance from the center pad to the ESP32 housing. Then I extruded the cut. After that, I made small conduits of 6 mm to keep the wires from my components tidy. They all go to the ESP32. Finally, I made small joints around mi piece.

BASE

Fab Fab
Fab Fab

PIECE. First, I changed the document units to CGS and created a center point rectangle of 62 X 36 cm in the front plane. Then, I made fillets of 5 cm of diameter and extruded it 3 mm. After, I made small holes located in the corners of the Pads and the ESP32 case, to attach them to the board. Finally, I made small joints around my piece.

Fab Fab

PAD BASE. Also create the bases for the pads; this is to provide them with better support. They measure 17 x 17 cm, with a 1-cm fillet, and have the same 6-mm holes 1 cm from the side and top edges.

WALLS

Fab Fab
Fab Fab

PIECE. The lateral wall design consists of 5 teeth at the top and bottom. The top teeth measure 4.02 × 0.3 cm and the bottom teeth 4.02 × 0.6 cm, taking into account the kerf and the fact that the bottom must fit into two layers 0.3 cm thick. I then extruded it by 0.3 cm. The design of the top and bottom walls consists of 9 teeth on each side. The top teeth measure 4.2 x 0.3 cm and the bottom teeth measure 4.2 x 0.6 cm, taking into account the kerf and the fact that the bottom section must fit into two layers, each 0.3 cm thick. I then extruded it by 0.3 cm.

FRONT FACE

Fab Fab
Fab Fab

PIECE. First, I changed the document units to CGS and created a center point rectangle of 62 X 36 cm in the front plane. Then, I made fillets of 5 cm of diameter and extruded it 3 mm. Next, I traced the outlines of the parts placed , which are: 3 Neopixel casings and 3 pads. My pads will measure 17 x 17 cm, spaced 1 cm apart, with a 2 cm distance from their respective Neopixels. In the space below, I wrote the name of the prject, which is B.R.I.S.

ASSEMBLY

Fab Fab

Fabrication

Laser cutting

Fab

1. To cut we have to use the CFL_CMA1390T laser cutting machine, turn it on and place our material inside. For more information about the laser machine, go to my week 3.

Laser cutting

Fab Fab
Fab Fab

2. Then we set the parameters and then cut.

cut. Max. Power(%): 65, Min. Power(%): 60 and Work Speed (mm/s): 20.

Engrave. Max. Power(%): 25, Min. Power(%): 20 and Work Speed (mm/s): 200.

Laser cutting

Results

Fab
Cables & Attaching

Cables

Fab Fab
Fab

Cables.To route the wires, I had created a 3D model of my circuit boards to get an idea of how I was going to arrange them. Unfortunately, as the project progressed, I realized there was a lot of noise affecting my sensor readings, so I had to use some foam to space the wires apart and minimize the noise as much as possible; however, this solution didn’t give me a very clean result.

Attaching

Fab Fab

Attaching.To attach my pads, I cut the adhesive Velcro I had and placed it on the back of the pads, then attached the other side of the Velcro to the pads themselves. The result surprised me—it held quite firmly thanks to the excellent adhesive on the Velcro. I also used it to attach B.R.I.S. to the wall, and it worked just as well; it stayed firmly in place.

Final Thoughts

The development of B.R.I.S. was a challenge from multiple perspectives, including electronics production, mechanical design, system integration, and pad fabrication. The successful completion of this project was made possible largely through the knowledge acquired week by week throughout the course, as well as the support, guidance, and collaboration of my instructors and fellow students.

Personally, I consider the final result to be highly satisfactory because it fulfills the objectives that were originally proposed. The system is capable of detecting impacts, communicating data through MQTT, displaying information through a user interface, and providing visual feedback using Neopixels. Although the project achieved its primary goals, there are several aspects I would like to improve in future iterations. In particular, I would like to enhance the step-response sensors to obtain cleaner readings with less interference and greater reliability. I would also like to reinforce the structure with internal supports to better withstand repeated impacts and improve the overall durability of the device.

Another feature I would like to implement is impact force measurement. Unfortunately, I was unable to fully integrate this functionality due to challenges related to MQTT message transmission and system integration. Despite this limitation, the remaining components of the project performed reliably and met expectations.

I would like to express my sincere gratitude to my instructor Aristarco for his continuous support and guidance throughout the development of the project. I would also like to thank Oliver and the rest of my instructors for sharing their knowledge and helping me overcome technical challenges during the course. Finally, I would like to thank my classmates for their collaboration, encouragement, and presence throughout this learning experience.

B.R.I.S. is not only my final project; it is also a reflection of everything I learned during Fab Academy and a demonstration of the skills, knowledge, and confidence I developed throughout the program. It represents both the culmination of months of work and the foundation for future improvements and new ideas.

Files