Kamil Gallardo Toledo - FAB ACADEMY
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.
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.
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.
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.
During some week tasks I tried to learn about some necesarry things for my final project. Here is the progress I have.
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.
// ---------- 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);
}
}
For more information you can access to my ninth week Week 9.
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.
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.
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.
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
~ 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);
}
For more information you can access to my ninth week Week 11.
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.
~ 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);
}
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.
~ 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();
}
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.
~ 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();
}
For more information you can access to my fourteenth week Week 14.
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.
~ 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);
}
~ 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"><</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">></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">></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">></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>
For more information you can access to my sixteenth week Week 16.
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.
| 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 |
| 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 |
| 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 |
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
~ 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
~ 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.
~ 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.
~ 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.
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.
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.
~ 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.
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.
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.
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.
4. Having secured the bed inside the SMR-20, we have to select the tool for each milling process (Tracks and Borders).
~ 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
~ 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
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.
2. Then we have to click on Cut. A window will open, and we should click Add to add our milling code.
3. Finally we have to click Output and the machine will automatically start to cut.
1. After finishing the cutting, very carefully, we must take out the copper board and remove our PCB.
Capacitive Sensor
~ I simply connected two 1M resistors, each connected to 3.3V and GND, respectively. Then I connected four pins for VCC, GND, the analog output, and the pin that connects to a step response sensor board.
~ 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.
~ 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.
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.
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.
~ 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.
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.
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.
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.
4. Having secured the bed inside the SMR-20, we have to select the tool for each milling process (Tracks and Borders).
~ 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
~ 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
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.
2. Then we have to click on Cut. A window will open, and we should click Add to add our milling code.
3. Finally we have to click Output and the machine will automatically start to cut.
1. After finishing the cutting, very carefully, we must take out the copper board and remove our PCB.
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)
1. To solder, we first need to set up our workspace and gather the necessary tools.
~ Necessary tools:
~ Components:
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.
~ 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);
}
~ 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.
<!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"><</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>
~ 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:
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.
1.First we have to log in Github and then click the top green button labeled as New.
2. Then, we have to name, add a brief description and mark it as public.
3. After creating the repository, we have to click in the button that says Add file and select Create new file. Subsequently, we have to paste our code and name the File index.html, this is because github automatically recognizes that name and set it as the body of the repository. The we have to click Commit Changes.
4. Then we have to go to Settings and in the side-bar select Pages. In Pages we must go to the Branch section and set the set it as main. Finally, we have to copy the URL that Github will generate at the top and we will have our page uploaded.
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.
2. Then, I extruded it 1.5 cm and made roundings of 0.20 cm.
3. After that, I made a plane 0.30 cm from the front face of the piece.
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.
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.
6. After that, I made a plane 0.90 cm from the back face of the piece.
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.
~ 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.
1. First, I should have changed the document units to CGS, but I forgot, so it will be in MMGS. Then I created a center point rectangle of 110 X 100 mm in the front plane.
2. Then, I made round corners of 10 mm of radius and tiny holes of 5 mm of radius for screws. Next, extruded it 5 mm.
3. Then, I made the same figure inside, but with a distance of 3.5 mm from each edge. After that, I extruded it 15 mm.
4. Subsequently, in the lateral face of the piece I made a rectangle of 13 X 15 mm at a distance of 18 mm from the top. Next, I extruded a cut inside.
~ 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.
Assembly
~ RESULTS. I made an Assembly with all the pieces to make sure everything was correct.
~ Slicer: To print, I used Prusa Slicer. I will also use a Prusa MK4S for the 3D printing process.
1. First, open the slicer and import the STL piece.
2. For importing, we have to press File, then import and select import STL.
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.
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.
1. First, open the slicer and import the STL piece.
2. For importing, we have to press File, then import and select import STL.
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.
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.
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.
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.
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.
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.
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.
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.
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.
1. First, I obtained three foam sheets of different densities. One was low-density (purple), which served as a dielectric for the step response sensors; the second was medium-density (blue), to cushion the impact against the assembly; and the third was high-density (pink), to absorb the impact directly.
2. Then I cut them into 17 x 17 cm pieces, and the total thickness of the three layers was also 17 cm. The pink one is 10 cm thick, the purple one is 2 cm thick, and the blue one is 5 cm thick. At first, I thought about using the blue one as the dielectric, but the purple one turned out to be better because the distance between the plates was shorter.
3. Next, for the side pads, stacking them the same way as the center pad wouldn’t work, since they wouldn’t provide a single flat surface for impact. So, I purchased a 17-cm-thick high-density foam. I then measured out a 17 x 17 cm square, cut it out, and then measured 2 cm from the back edge and 6 cm from the right side edge, and drew a line connecting the two.
4. To position the sensors on the side pads, I measured two lines from the center of the impact surface, then drew the cutting lines centered 5 cm from the edge and 2 cm between them.
Cover. To make the lining for the pads, I measured them on a piece of canvas fabric and added an extra centimeter all around for the seam allowance. I sewed the seams by machine. I also added the symbols for week 16 to indicate where to hit.Before closing the seams, I inserted the sensors and left only their cables exposed so I could connect them to the step response boards.
PROGRAMMING.To program my board, I first need to connect it to a power wall outlet via its USB port using a cable with a 5V 3A 15W adapter. Then, I need to connect its UART pins to an FT232RL module, which is a highly popular USB-to-UART bridge integrated circuit. I connect the TX pin on my board to the RX pin on the module and vice versa to establish communication. Once my computer has detected my board, I have to boot it up, and then I can upload the code to it.
SENSOR. My sensors consist of a 5 x 5 cm copper-fiberglass board soldered to a custom-made cable.
3. The test consisted of simulating the impacts; I used a layer with the same thickness as the dielectric in my pads and the same foam that will ultimately be used. The test was designed to verify that the device was functioning properly.
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.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.
1. First, I placed the base and the position footprint together. Then, I attached the walls to join the pieces.
2. I then secured the base of the PADS with M6 screws. This step also adds rigidity to the structure.
3. Then I secured the motherboard case with M3 screws.
4. I then routed the cables through the channels and applied adhesive Velcro tape to the base of the pads and to the pads themselves; this will hold the pads securely in place. Finally, I attached the front panel and closed the structure.