¿Se puede detectar metal enterrado sin comprar un detector caro de tienda? Sí, y además puedes monitorear cada hallazgo desde tu celular. En este proyecto vas a armar un detector de metales por pulso de inducción (PI) usando un ESP32, una bobina de cobre que enrollas tú mismo y un puñado de componentes económicos. Al terminar vas a entender cómo funciona la física de la detección, vas a tener un dashboard web que muestra distancia y profundidad en tiempo real, y vas a saber calibrar la sensibilidad para encontrar desde una moneda hasta un anillo.
Lo interesante de este enfoque es que no necesita sensores especializados ni circuitos industriales: el propio ESP32 genera el pulso, lee el decaimiento con su ADC y, al mismo tiempo, levanta un punto de acceso WiFi con una página web. Todo en una sola placa de doble núcleo.
Concepto: cómo detecta el metal el pulso de inducción
El método de pulso de inducción manda un pulso corto desde el ESP32 hacia la bobina de cobre y luego lo corta de golpe. Al cortarse, el campo magnético alrededor de la bobina colapsa. Cuando no hay metal cerca, ese campo colapsa al instante y el voltaje decae muy rápido. Cuando hay metal cerca, el campo que colapsa induce corrientes de Foucault (eddy currents) dentro del objeto metálico; esas corrientes generan su propio campo secundario que frena el decaimiento y deja un voltaje medible por encima del umbral. El ESP32 mide esa velocidad de decaimiento con su pin ADC y decide si hay un objetivo.
Por qué internamente funciona así: la profundidad se estima con una curva física. La señal cae aproximadamente con el cubo de la distancia, así que el código aplica una raíz cúbica (pow(..., 1.0/3.0)) sobre la diferencia medida para convertirla en centímetros lineales. Por eso el detector se calibra con dos puntos de referencia (0 cm tocando el objeto y 2 cm de separación): con esos dos valores el ESP32 interpola la profundidad hasta un máximo de 15 cm.

Hardware y conexiones
El circuito es sencillo. Un extremo de la resistencia de 200 Ω va al GPIO 26 (pin de pulso); el otro extremo se conecta al ánodo del diodo 1N4007 y a un extremo de la bobina de búsqueda, que enrollas con 25 a 30 vueltas y un diámetro de 7 cm. El cátodo del diodo va al GPIO 33 (pin de lectura del ADC) junto con un extremo del capacitor cerámico de 0.1 nF; el otro extremo del capacitor va al segundo extremo de la bobina y a GND.
Resumen del pinout:
- GPIO 26 → resistencia 200 Ω → bobina + ánodo del diodo (genera el pulso)
- GPIO 33 → cátodo del diodo + capacitor 0.1 nF (lee el voltaje de decaimiento por ADC)
- GPIO 2 → resistencia 220 Ω → LED (indicador de objetivo detectado)
- GPIO 4 → buzzer pasivo (alerta sonora por PWM)
- VIN / 5V → salida OUT+ del TP4056
- GND → OUT- del TP4056 y tierra común
La alimentación viene de una batería 18650 de 3.7 V conectada a los terminales B+ y B- del módulo cargador TP4056; la salida OUT+ va al pin VIN del ESP32 y OUT- al GND. La inductancia de la bobina ronda los 98 µH, valor que se calcula con L = (µ₀ · N² · A) / l, donde N es el número de vueltas y A el área del núcleo.
Dato clave para no frustrarte: el alambre esmaltado trae una capa de aislante invisible. Antes de soldar los extremos, ráspalos con lija o cuchillo cartonero hasta ver el cobre, o la bobina no conducirá y el detector no medirá nada.
Software: cargar y calibrar el código
El firmware está escrito en el IDE de Arduino y aprovecha los dos núcleos del ESP32: el núcleo 1 muestrea la bobina y hace el seguimiento dinámico de la línea base, mientras el núcleo 0 hospeda el servidor web. La variable THRESHOLD controla la sensibilidad, GLITCH_FILTER cuántos ciclos seguidos se necesitan para activar el buzzer, y QUIET_RESYNC_LOOPS cada cuánto se re calibra la línea base cuando no hay metal.
/*
* Dual-Core Transient Decay Metal Detector for ESP32 with Web Server
* RECTIFIED v4.3 - RECOVERY & STUCK-BEEP LOGIC FIX
* - Configured for LOW-LEVEL PASSIVE BUZZER on GPIO 4 using New LEDC API
* - Features Continuous Adaptive Baseline Tracking to prevent stuck-beeping
*/
#include <WiFi.h>
#include <WebServer.h>
#define capPin 33
#define pulsePin 26
#define led 2
#define buz 4
#define THRESHOLD 80
#define GLITCH_FILTER 3
#define QUIET_RESYNC_LOOPS 40
// PWM SETTINGS FOR PASSIVE BUZZER
#define BUZ_FREQ 2500
#define BUZ_RESOLUTION 8
#define TON_DUTY_LOW 128
#define TON_DUTY_OFF 0
// ───────────────────────────────────────────────────────────
//DISTANCE CALIBRATION
// ───────────────────────────────────────────────────────────
const float CAL_DIFF_AT_0CM = 900.0;
const float CAL_DIFF_AT_REF = 140.0;
const float REF_DISTANCE_CM = 2.0;
const float MAX_DISTANCE_CM = 15.0;
const float SMOOTHING_ALPHA = 0.20;
// WI-FI CREDENTIALS
const char* ssid = "ESP32-MetalDetector";
const char* password = "password123";
WebServer server(80);
TaskHandle_t DetectorTask = nullptr;
// Thread-safe shared states
volatile long sumExpect = 0;
volatile long diff = 0;
volatile unsigned long pTime = 0;
volatile long buzPeriod = 0;
volatile int webTargetPresent = 0;
volatile float webDistanceCm = -1;
volatile long lastDiffShared = 0;
portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED;
void applyPulses();
void detectorLoop(void *pvParameters);
void handleRoot();
void handleData();
float diffToDistanceCm(float diffMag);
// HTML & JavaScript Dashboard Interface
const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ESP32 Metal Detector Dashboard</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; background: #121212; color: #e0e0e0; padding: 20px; }
.card { background: #1e1e1e; padding: 20px; border-radius: 12px; box-shadow: 0 4px 10px rgba(0,0,0,0.5); max-width: 450px; margin: 20px auto; border: 1px solid #333; }
h1 { color: #00adb5; font-size: 24px; }
.metric { font-size: 22px; font-weight: bold; margin: 15px 0; }
.near { color: #ff4d4d; }
.mid { color: #ffd700; }
.far { color: #00ff66; }
.none { color: #888; }
.data-box { background: #111; padding: 10px; border-radius: 6px; font-family: monospace; font-size: 16px; margin-top: 10px; border: 1px solid #222; color: #00ff66; }
</style>
</head>
<body>
<div class="card">
<h1>Pulse Induction Metal Detector</h1>
<hr style="border-color:#333;">
<div class="metric">Distance: <span id="distance" class="none">No Target</span></div>
<div class="data-box">
<div>Raw Diff: <span id="diffVal">0</span></div>
<div>Buzzer Period: <span id="buzVal">0</span> ms</div>
</div>
</div>
<script>
setInterval(function() {
fetch('/data')
.then(response => response.json())
.then(data => {
document.getElementById("diffVal").innerText = data.diff;
document.getElementById("buzVal").innerText = data.buzzer;
let distEl = document.getElementById("distance");
if (data.present === 1) {
distEl.innerText = data.cm.toFixed(1) + " cm";
if (data.cm < 2) distEl.className = "near";
else if (data.cm < 6) distEl.className = "mid";
else distEl.className = "far";
} else {
distEl.innerText = "No Target";
distEl.className = "none";
}
})
.catch(err => console.log(err));
}, 150);
</script>
</body>
</html>
)rawliteral";
void setup() {
Serial.begin(115200);
pinMode(pulsePin, OUTPUT);
digitalWrite(pulsePin, LOW);
pinMode(capPin, INPUT);
pinMode(led, OUTPUT);
digitalWrite(led, LOW);
ledcAttach(buz, BUZ_FREQ, BUZ_RESOLUTION);
ledcWrite(buz, TON_DUTY_OFF);
analogReadResolution(10);
WiFi.mode(WIFI_AP);
WiFi.softAP(ssid, password);
server.on("/", handleRoot);
server.on("/data", handleData);
server.begin();
xTaskCreatePinnedToCore(
detectorLoop,
"DetectorTask",
4096,
NULL,
1,
&DetectorTask,
1
);
}
void loop() {
server.handleClient();
delay(2);
}
void handleRoot() {
server.send_P(200, "text/html", index_html);
}
void handleData() {
long diffLocal;
long buzLocal;
int presentLocal;
float cmLocal;
portENTER_CRITICAL(&mux);
diffLocal = lastDiffShared;
buzLocal = buzPeriod;
presentLocal = webTargetPresent;
cmLocal = webDistanceCm;
portEXIT_CRITICAL(&mux);
String json = "{";
json += "\"diff\":" + String(diffLocal) + ",";
json += "\"buzzer\":" + String(buzLocal) + ",";
json += "\"present\":" + String(presentLocal) + ",";
json += "\"cm\":" + String(cmLocal, 1);
json += "}";
server.send(200, "application/json", json);
}
float diffToDistanceCm(float diffMag) {
if (diffMag <= 1) diffMag = 1;
const float r0 = 0.3;
float k = CAL_DIFF_AT_0CM * pow(r0, 3.0);
float kRef = CAL_DIFF_AT_REF * pow(REF_DISTANCE_CM, 3.0);
k = (k + kRef) / 2.0;
float distCm = pow(k / diffMag, 1.0 / 3.0);
if (distCm < 0) distCm = 0;
if (distCm > MAX_DISTANCE_CM) distCm = MAX_DISTANCE_CM;
return distCm;
}
void detectorLoop(void *pvParameters) {
long localSumExpect = 0;
int quietLoops = 0;
int localGlitchCounter = 0;
unsigned long localPTime = 0;
float smoothedMag = 0;
bool smoothInit = false;
for (;;) {
int minval = 1023;
int maxval = 0;
unsigned long sum = 0;
for (int i = 0; i < 256; i++) {
pinMode(capPin, OUTPUT);
digitalWrite(capPin, LOW);
delayMicroseconds(20);
pinMode(capPin, INPUT);
applyPulses();
int val = analogRead(capPin);
if (val < minval) minval = val;
if (val > maxval) maxval = val;
sum += val;
}
sum -= minval;
sum -= maxval;
if (localSumExpect == 0) {
localSumExpect = ((long)sum) << 6;
}
long avgsum = (localSumExpect + 32) >> 6;
long localDiff = (long)sum - avgsum;
bool targetPresent = (labs(localDiff) >= THRESHOLD);
// FIXED TRACKER LOGIC: Continuous baseline adaptation
if (!targetPresent) {
quietLoops++;
// Fast baseline correction when no metal is present
localSumExpect = localSumExpect + ((long)sum - avgsum);
if (quietLoops > QUIET_RESYNC_LOOPS * 16) {
localSumExpect = ((long)sum) << 6;
quietLoops = 0;
}
} else {
quietLoops = 0;
// Ultra-slow tracking when metal is present.
// This prevents baseline lockup and allows the buzzer to turn off immediately when metal is removed.
localSumExpect = localSumExpect + (((long)sum - avgsum) >> 3);
}
if (targetPresent) {
localGlitchCounter++;
} else {
localGlitchCounter = 0;
}
long localBuzPeriod = 1000000;
int localPresent = 0;
float localCm = -1;
if (localGlitchCounter >= GLITCH_FILTER) {
long magnitude = labs(localDiff);
if (magnitude > 0) {
localBuzPeriod = avgsum / (2 * magnitude);
}
if (localBuzPeriod > 300) localBuzPeriod = 300;
unsigned long cTime = millis();
bool buzOn = false;
if (cTime < localPTime + 10) {
buzOn = true;
}
if (cTime > localPTime + (unsigned long)localBuzPeriod) {
buzOn = true;
localPTime = cTime;
}
digitalWrite(led, buzOn ? HIGH : LOW);
ledcWrite(buz, buzOn ? TON_DUTY_LOW : TON_DUTY_OFF);
localPresent = 1;
if (!smoothInit) {
smoothedMag = (float)magnitude;
smoothInit = true;
} else {
smoothedMag = (SMOOTHING_ALPHA * (float)magnitude) + ((1.0 - SMOOTHING_ALPHA) * smoothedMag);
}
localCm = diffToDistanceCm(smoothedMag);
} else {
digitalWrite(led, LOW);
ledcWrite(buz, TON_DUTY_OFF);
localBuzPeriod = 1000000;
localPresent = 0;
localCm = -1;
smoothInit = false;
}
portENTER_CRITICAL(&mux);
sumExpect = localSumExpect;
diff = localDiff;
buzPeriod = localBuzPeriod;
webTargetPresent = localPresent;
webDistanceCm = localCm;
lastDiffShared = localDiff;
pTime = localPTime;
portEXIT_CRITICAL(&mux);
vTaskDelay(pdMS_TO_TICKS(10));
}
}
void applyPulses() {
for (int i = 0; i < 3; i++) {
digitalWrite(pulsePin, HIGH);
delayMicroseconds(6);
digitalWrite(pulsePin, LOW);
delayMicroseconds(6);
}
}
Después de cargar el código, enciende el circuito, conecta tu celular al punto de acceso WiFi ESP32-MetalDetector (clave password123) y abre en el navegador la dirección IP que aparece en el monitor serial. Verás el dashboard con la distancia, la diferencia cruda de señal (Diff) y la profundidad estimada actualizándose cada 150 ms.

Pulso de inducción vs VLF: por qué elegimos PI
Antes de armar, conviene entender por qué este proyecto usa pulso de inducción y no el diseño VLF (muy baja frecuencia) de la mayoría de los detectores comerciales:
- Diseño de bobina: PI usa una sola bobina (transmite y recibe); VLF necesita dos bobinas balanceadas con precisión.
- Suelo mineralizado: PI se comporta excelente y casi no le afectan los minerales; VLF sufre falsos positivos.
- Discriminación de metal: VLF puede identificar el tipo de metal; PI se limita a presencia y profundidad.
- Complejidad: PI es simple e ideal para makers; VLF es más difícil de calibrar.
Para un proyecto casero y preciso sin circuitería de discriminación avanzada, la bobina única PI es la opción correcta.
Carcasa impresa en 3D
El proyecto incluye una carcasa de dos partes diseñada en Onshape (81 mm × 96 mm × 45 mm) con cortes para el interruptor (4 mm), el puerto USB-C de carga (11 mm) y dos pasos de 2 mm para el cable de la bobina. Descarga los STL, ábrelos en Cura o PrusaSlicer con altura de capa 0.2 mm y 20% de relleno, y envía el G-code a tu impresora.


Solución de problemas
- No detecta objetos pequeños: baja el valor de
THRESHOLD(por ejemplo de 80 a 50) para captar cambios de voltaje más pequeños de monedas o anillos. - La bobina no detecta nada: raspa el esmalte de los extremos del alambre antes de soldar.
- Beep constante o glitches: asegura una tierra común sólida entre el GND del ESP32 y el negativo del módulo de batería.
- Verificar la bobina: con un multímetro, la continuidad debe sonar de extremo a extremo y la resistencia total debe ser muy baja (0.5 Ω a 2 Ω).
Variantes y mejoras
Estas ideas no vienen en el proyecto original, pero son extensiones naturales:
- Discriminación básica de metales: registrando la forma del decaimiento (no solo su magnitud) puedes distinguir metales ferrosos de no ferrosos. Guarda varias muestras del ADC tras el corte del pulso y compara la pendiente: los metales ferrosos frenan el decaimiento distinto a los no ferrosos.
- Registro de detecciones por WiFi: en vez de solo mostrar la profundidad, agrega un endpoint
/logque guarde cada detección con marca de tiempo (millis()) en un arreglo, y descárgalo como CSV desde el navegador. Así llevas un historial de dónde encontraste cada objeto. - Alcance con bobina más grande: una bobina de mayor diámetro (10-12 cm) aumenta la profundidad de detección para objetos grandes, a costa de perder sensibilidad con los pequeños. Podrías dejar dos bobinas intercambiables por conector.
Personalización para Chile
Todos los componentes de este proyecto son de electrónica básica y están disponibles en el catálogo de MechatronicStore:
- Placa ESP32 DevKit. controlador principal de doble núcleo
- Resistencias 200 Ω y 220 Ω. pulso de bobina e indicador LED
- Capacitor cerámico 0.1 nF. lectura del decaimiento
- Alambre de cobre esmaltado 0.3 a 0.5 mm. bobina de búsqueda (necesitas ~6.5 m)
- Buzzer pasivo 3.3 V. alerta sonora
- LED 5 mm. indicador visual
- Batería 18650 3.7 V + portapilas 18650. alimentación
- Módulo cargador TP4056. carga por USB-C
- Diodo 1N4007 e interruptor toggle. rectificación y encendido
Si en otro tutorial ves un "detector shield" comercial, no lo necesitas: con el ESP32 DevKit y estos pasivos armas exactamente la misma función a una fracción del precio.
Recursos
- Tutorial original: DIY ESP32 Metal Detector: PI Circuit, Code & Wiring
- Repositorio GitHub: ESP32-Based Metal-Detector System (código fuente y archivos STL de la carcasa)
Versión chilena con componentes en stock local en MechatronicStore.




