← Índice del curso

Robótica y Automatización · Tema 46

Proyecto: construcción y firmware modular

Con el diseño del tema 45 en la mano, toca construir. La regla de este tema es una sola: nada se conecta ni se programa sin probar antes lo anterior. El robot se arma por etapas, y el firmware se escribe como módulos pequeños, cada uno con una función clara y una prueba, que un planificador de tareas hace trabajar juntos.

01 · Punto de partida

Del diseño al robot

El tema 45 dejó cuatro cosas: requisitos medibles, decisiones de hardware con sus cuentas, un mapa de 18 pines y un plan de pruebas. Acá se materializan. Hay dos tentaciones que conviene evitar. La primera es armar todo y recién después enchufar la batería: si algo falla, no hay forma de saber qué. La segunda es escribir un único programa de 500 líneas que mezcla sensores, motores y decisiones: cuando falla, tampoco.

La alternativa es construir en capas y probar cada capa antes de sumar la siguiente, tanto en el hardware como en el código. Cada etapa del montaje tiene su prueba, y cada módulo del firmware se prueba solo.

El código de este tema se compiló y probó en una computadora con pines, reloj y sensores simulados, para comprobar la lógica de cada módulo antes de que llegue a la placa. Esa prueba encontró un error real en el planificador, que se cuenta en la sección 08. Conviene que hagas lo mismo con tus cambios.

02 · Construir sin sorpresas

Montaje por etapas

Cada etapa agrega un grupo de componentes y termina con una prueba con criterio de aprobación. Si la prueba falla, el problema está en lo último que se agregó.

Etapas del montaje y su prueba.
EtapaQué se montaPrueba antes de seguir
1. MecánicaChasis, motores con encoder, ruedas, rueda loca, soporte de la bateríaLas ruedas giran libres; el robot apoya firme y sin inclinarse; los ejes están alineados
2. PotenciaBatería, interruptor, fusible, driver, motores. Sin el ArduinoCon una fuente de laboratorio limitada a 0,5 A y 7,4 V: sin cortocircuito, motores en vacío consumen poco, giran en ambos sentidos al excitar las entradas del driver
3. LógicaArduino alimentado por VIN, regulador, LED, botón, divisor de la batería5 V en la placa; el autodiagnóstico del tema 45 muestra la batería correcta y el botón
4. Motores con controlSeñales PWM y de sentido del Arduino al driverEl módulo de motores: gira adelante con +0,5 y atrás con −0,5 en las dos ruedas
5. EncodersAlimentación y canales A y B de cada encoderUna vuelta de rueda a mano suma 1440 cuentas (±1 %) con signo positivo hacia delante
6. LíneaArreglo de cinco sensores, 8 cm adelante del eje y a pocos milímetros del pisoValores altos sobre el blanco, bajos sobre el negro, en los cinco; calibración correcta
7. ObstáculosUltrasónico al frente, centradoUna caja a 20 cm se lee 20 cm (±1 cm); sin obstáculo, no hay eco

Antes de conectar la batería por primera vez

  1. Polaridad y cortocircuitos. Con el tester en continuidad, verificá que no hay conducción entre el positivo y la masa de la batería (con el interruptor cerrado), y que el negro es realmente masa en todos los conectores.
  2. Fusible. Uno rápido de unos 3 A en el positivo de la batería: el pico calculado en el tema 45 era de 2,59 A, y un cortocircuito debe cortar antes de que se caliente un cable.
  3. Fuente limitada. La primera vez, alimentá con una fuente de laboratorio con límite de corriente (0,5 A). Si no tenés, medí con el tester en serie: sin carga, el consumo en reposo debe ser de unas decenas de miliamperios.
  4. Un solo punto de masa. Masas de la batería, el driver y el Arduino unidas en un punto común, con cables cortos; los motores no comparten el cable de masa fino de las señales.
  5. Desacople. Un capacitor electrolítico (cientos de µF) en la entrada de potencia del driver y uno cerámico de 100 nF en los bornes de cada motor, para amortiguar los arranques y el ruido de las escobillas (verificá los valores con tu driver).
  6. Cables ordenados. Los de potencia, separados de los de señal; los de los encoders, cortos y retorcidos; y todos fijados al chasis para que no los atrapen las ruedas.
  7. Etiquetas. Marcá cada conector con su función y su pin. Sirve el mapa de la tabla del tema 45.

Dos cuidados mecánicos que se pagan caro después. La altura del arreglo de línea al piso debe ser fija y pequeña (unos pocos milímetros): si varía, la calibración deja de valer. Y el peso debe quedar repartido, con la batería baja y cerca del eje, para que las ruedas motrices no patinen y la rueda loca no se despegue en las frenadas.

03 · Módulos con límites claros

Estructura del firmware

El firmware se divide en archivos, uno por dispositivo, más un programa principal. En el entorno de Arduino cada archivo .h va en la carpeta del sketch y se incluye desde robot.ino.

Archivos del firmware y sus dependenciasEl programa principal robot.ino incluye cinco módulos de dispositivo: motores, encoders, línea, distancia y batería. Todos los módulos dependen sólo de config.h, que contiene pines, medidas y umbrales. Los módulos no se incluyen entre sí. robot.inoplanificador y estadosmotores.horden → PWMencoders.hcuadratura 4xlinea.hposicióndistancia.hmediana de 3bateria.hfiltro y umbralconfig.hpines, medidas, umbrales

Las reglas que sostienen esta estructura:

  • Un módulo, un dispositivo. motores.h sólo sabe de motores y linea.h de la línea. Ninguno incluye a otro.
  • Interfaz pequeña. Cada módulo ofrece unas pocas funciones (motorSet, encVelocidad, lineaLeer) y algunas variables de resultado (lin_pos, dis_cm). El programa principal no toca sus detalles.
  • Los datos de hardware viven en un solo lugar. Cambiar un pin o el radio de rueda es editar config.h, no buscar números por todo el código.
  • Los módulos no bloquean. Ninguno usa delay() en funcionamiento. Las únicas esperas son la calibración inicial y los 6 ms máximos del ultrasónico, y ambas están documentadas.
  • La decisión está arriba. Qué hace el robot ante cada situación es del programa principal y de los estados; los módulos no deciden nada.

Es la misma separación en capas que se usó en el brazo del tema 24: cada parte se puede probar sin las demás, y un error se localiza por capas. El archivo de configuración:

// config.h - Pines, medidas y umbrales del robot. Es el único archivo que se toca al cambiar de hardware.
#ifndef CONFIG_H
#define CONFIG_H

// Pines (presupuesto del tema 45: 18 de 18)
const byte PIN_ENC_IZQ_A = 2, PIN_ENC_DER_A = 3, PIN_ENC_IZQ_B = 4, PIN_ENC_DER_B = 9;
const byte PIN_PWM_IZQ = 5, PIN_PWM_DER = 6, PIN_DIR_IZQ = 7, PIN_DIR_DER = 8;
const byte PIN_TRIG = 10, PIN_ECHO = 11, PIN_BOTON = 12, PIN_LED = 13;
const byte PIN_LINEA[5] = {A0, A1, A2, A3, A4};
const byte PIN_BATERIA = A5;

// Mecánica (tema 27)
const float RADIO_RUEDA_CM = 3.25;
const int CPR = 1440;                                         // cuentas por vuelta de rueda, decodificación 4x
const float CM_POR_CUENTA = 2.0 * PI * RADIO_RUEDA_CM / CPR;  // 0,01418 cm

// Motores
const byte PWM_MIN = 60;                // PWM al que el motor apenas empieza a girar (medilo, ver calibración)
const float ZONA_MUERTA_U = 0.02;       // órdenes menores a esto se tratan como cero

// Línea
const byte N_LINEA = 5;
const float X_SENSOR_MM[N_LINEA] = {-20, -10, 0, 10, 20};   // posición de cada sensor respecto del centro
const float UMBRAL_LINEA = 0.3;         // oscuridad total mínima para decir que hay línea

// Batería (divisor 10 kΩ + 10 kΩ)
const float VREF = 5.0, DIVISOR = 2.0;
const float V_BAT_MIN = 6.4;

// Tiempos (ms) y umbrales de seguridad
const unsigned long TS_CONTROL_MS = 20, TS_DISTANCIA_MS = 60, TS_TELEMETRIA_MS = 100, TS_BATERIA_MS = 100;
const unsigned long T_LINEA_PERDIDA_MS = 1000, T_BATERIA_BAJA_MS = 1000;
const float DIST_OBSTACULO_CM = 20.0;

#endif

Los valores de PWM_MIN, X_SENSOR_MM y el signo de los encoders dependen de tu robot: son parte de la calibración, no constantes universales.

04 · Mover con una orden

Driver de motores

Para el resto del programa un motor es una orden u entre −1 (atrás a fondo) y +1 (adelante a fondo). El driver traduce esa orden a un ancho de PWM y un pin de sentido. Hay dos detalles de hardware que el módulo esconde:

  • Zona muerta. Un motor real no gira con PWM bajos: la fricción y el reductor exigen un mínimo. Si u = 0,05 se tradujera en un PWM de 13 sobre 255, el motor zumbaría sin moverse. El driver mapea el rango útil de u a PWM entre PWM_MIN y 255, y trata como cero lo que está por debajo de ZONA_MUERTA_U.
  • Sentido y montaje. Los motores de un robot diferencial están enfrentados, y uno gira al revés que el otro con la misma polaridad. La constante MOT_INVERTIDO lo corrige en un lugar.
// motores.h - Capa de dispositivo: un motor se maneja con una orden u entre -1 (atrás a fondo) y +1 (adelante a fondo).
#ifndef MOTORES_H
#define MOTORES_H

const byte MOT_PWM[2] = {PIN_PWM_IZQ, PIN_PWM_DER};
const byte MOT_DIR[2] = {PIN_DIR_IZQ, PIN_DIR_DER};
const bool MOT_INVERTIDO[2] = {false, false};     // true si el motor quedó montado al revés

// Convierte la orden en PWM: cero por debajo de la zona muerta; si no, desde PWM_MIN hasta 255.
byte pwmDesdeOrden(float u) {
  float a = fabs(u);
  if (a < ZONA_MUERTA_U) return 0;
  if (a > 1.0) a = 1.0;
  return (byte)(PWM_MIN + (255 - PWM_MIN) * a + 0.5);
}

void motorSet(byte lado, float u) {
  bool adelante = (u >= 0) != MOT_INVERTIDO[lado];
  digitalWrite(MOT_DIR[lado], adelante ? HIGH : LOW);
  analogWrite(MOT_PWM[lado], pwmDesdeOrden(u));
}

void motorParar() { motorSet(0, 0); motorSet(1, 0); }

void motorBegin() {
  for (byte i = 0; i < 2; i++) { pinMode(MOT_PWM[i], OUTPUT); pinMode(MOT_DIR[i], OUTPUT); }
  motorParar();
}

#endif
Orden y PWM resultante con PWM_MIN = 60 (probado con el módulo real).
Orden uPWMComentario
0,00 y 0,010Dentro de la zona muerta
0,0264El umbral: el motor arranca apenas
0,1080
0,25109
0,50158La mitad de la orden no es la mitad del PWM
0,75206
1,00 y 1,50255Saturado
−0,50158Mismo PWM, con el pin de sentido invertido

Medir PWM_MIN

  1. Con el robot apoyado en el piso (con su peso real), ordená PWM crecientes de 5 en 5 cada medio segundo.
  2. Anotá el valor en el que la rueda empieza a girar de forma continua: ese es PWM_MIN. Repetilo con cada motor y usá el mayor.
  3. Verificá que las dos ruedas giran hacia delante con una orden positiva; si una gira al revés, cambiá su MOT_INVERTIDO.

Una limitación: analogWrite en D5 y D6 del UNO genera PWM de unos 980 Hz, audible como un zumbido; es normal, y el control de velocidad del tema 18 lo compensa con el lazo cerrado. Otra: este mapeo compensa la zona muerta, pero no linealiza el motor; lo hace el lazo de velocidad en el tema 47.

05 · Medir el giro

Driver de encoders

Un encoder en cuadratura entrega dos señales, A y B, desfasadas un cuarto de ciclo. El orden en que cambian indica el sentido; contar cada cambio de cada canal da una resolución cuatro veces mayor que la de los pulsos de un solo canal. Con un encoder de 360 pulsos por vuelta de rueda, eso son las 1440 cuentas del tema 27.

La lógica es una tabla. El estado de los dos canales es un número de 2 bits; cada cambio de estado suma +1, −1 o 0, según la secuencia: 00 → 01 → 11 → 10 → 00 es adelante, la inversa es atrás, y un salto de dos bits a la vez (imposible si no se perdió un pulso) no suma. Esa propiedad filtra además parte del ruido.

// encoders.h - Cuenta en cuadratura 4x con interrupciones: el canal A en INT0/INT1 y el B en interrupciones de cambio de pin.
#ifndef ENCODERS_H
#define ENCODERS_H

// Índice = (estado anterior << 2) | estado nuevo, con estado = (A << 1) | B. +1 adelante, -1 atrás, 0 sin cambio o salto inválido.
const int8_t TABLA_Q[16] = {0, +1, -1, 0, -1, 0, 0, +1, +1, 0, 0, -1, 0, -1, +1, 0};

volatile long enc_cuentas[2] = {0, 0};
volatile byte enc_estado[2] = {0, 0};
const int8_t ENC_SIGNO[2] = {+1, +1};               // -1 si el encoder de esa rueda cuenta al revés

inline void encActualizar(byte lado, byte pinA, byte pinB) {
  byte nuevo = (digitalRead(pinA) << 1) | digitalRead(pinB);
  enc_cuentas[lado] += ENC_SIGNO[lado] * TABLA_Q[(enc_estado[lado] << 2) | nuevo];
  enc_estado[lado] = nuevo;
}
void isrEncIzq() { encActualizar(0, PIN_ENC_IZQ_A, PIN_ENC_IZQ_B); }
void isrEncDer() { encActualizar(1, PIN_ENC_DER_A, PIN_ENC_DER_B); }
ISR(PCINT2_vect) { isrEncIzq(); }                   // D4: canal B izquierdo
ISR(PCINT0_vect) { isrEncDer(); }                   // D9: canal B derecho

void encBegin() {
  pinMode(PIN_ENC_IZQ_A, INPUT_PULLUP); pinMode(PIN_ENC_IZQ_B, INPUT_PULLUP);
  pinMode(PIN_ENC_DER_A, INPUT_PULLUP); pinMode(PIN_ENC_DER_B, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(PIN_ENC_IZQ_A), isrEncIzq, CHANGE);
  attachInterrupt(digitalPinToInterrupt(PIN_ENC_DER_A), isrEncDer, CHANGE);
  PCICR |= (1 << PCIE2) | (1 << PCIE0);
  PCMSK2 |= (1 << PCINT20);                         // D4
  PCMSK0 |= (1 << PCINT1);                          // D9
}

long encLeer(byte lado) {                           // lectura atómica: un long se copia en varios pasos
  noInterrupts();
  long c = enc_cuentas[lado];
  interrupts();
  return c;
}

// Velocidad de la rueda (cm/s) desde la última llamada; dt es el tiempo transcurrido en segundos.
float encVelocidad(byte lado, float dt) {
  static long previa[2] = {0, 0};
  long c = encLeer(lado);
  float v = (c - previa[lado]) * CM_POR_CUENTA / dt;
  previa[lado] = c;
  return v;
}

#endif

Detalles que importan:

  • Las interrupciones. El UNO sólo tiene interrupciones externas en D2 y D3: ahí van los canales A. Los canales B (D4 y D9) usan interrupciones de cambio de pin, que se habilitan en PCICR y en la máscara de cada grupo. Sólo se habilitan esos dos pines: nunca el del botón.
  • Lectura atómica. Un long de 4 bytes se copia en varias instrucciones; si una interrupción lo cambia a la mitad, se lee un valor corrupto. Por eso encLeer apaga las interrupciones un instante.
  • Costo. A 30 cm/s ocurren unas 2116 interrupciones por segundo por rueda. La estimación del tema 45 (5 µs cada una) supone el código con acceso directo a los puertos; con digitalRead cada una puede costar el doble. Medilo con micros(): incluso a 10 µs son un 4 % de la CPU.
  • El signo. Si al avanzar una rueda su cuenta baja, cambiá su ENC_SIGNO.

Se comprobó con una rueda simulada: una vuelta hacia delante suma 1440 cuentas; retroceder 400 deja 1040; y el salto inválido no suma. Cada cuenta vale 0,01418 cm y 100 cm de recorrido son 7052 cuentas.

La velocidad: resolución contra retardo

La velocidad se calcula como cuentas por tiempo de la ventana. Como el encoder sólo ve cuentas enteras, la velocidad medida varía de ventana en ventana aunque la real sea constante. Con la rueda a 30 cm/s:

Velocidad medida con la rueda a 30 cm/s (200 ventanas consecutivas).
VentanaRango medidoResoluciónError máximoRetardo medio
10 ms29,78 a 31,20 cm/s1,418 cm/s4,0 %5 ms
20 ms29,78 a 30,49 cm/s0,709 cm/s1,6 %10 ms
50 ms29,78 a 30,06 cm/s0,284 cm/s0,7 %25 ms
100 ms29,92 a 30,06 cm/s0,142 cm/s0,3 %50 ms

El promedio siempre es 30,00: la medición no está sesgada, sólo discretizada. La ventana de 20 ms (el período del lazo de control) es un buen compromiso: 1,6 % de error y 10 ms de retardo. Una ventana de 100 ms da una medida casi perfecta, pero con 50 ms de retardo medio; en un lazo de 20 ms eso desestabiliza (tema 41). Es el mismo compromiso entre ruido y retardo que el filtrado del tema 44.

06 · Ver la pista

Sensores de línea

Cada sensor mide cuánta luz infrarroja vuelve del piso: mucha sobre el blanco, poca sobre el negro. Pero dos sensores nominalmente iguales dan valores distintos, y la luz ambiente los cambia. Por eso hay dos pasos: calibrar y estimar la posición.

  • Calibración. Durante unos segundos al arrancar, se pasa el arreglo sobre la línea y el blanco; para cada sensor se guarda el máximo (blanco) y el mínimo (negro). Es la única función bloqueante del módulo, y está bien: el robot está quieto y en manos del operador.
  • Oscuridad normalizada. Cada lectura se convierte a un número entre 0 (blanco) y 1 (negro) con los valores de su propio sensor. Así los cinco quedan en la misma escala.
  • Posición. Es el promedio de las posiciones de los sensores ponderado por su oscuridad, en milímetros respecto del centro. Tiene resolución mucho mejor que 10 mm, el paso entre sensores.
  • Visibilidad. Si la oscuridad total es menor que UMBRAL_LINEA, no hay línea: se marca y se conserva la última posición.
// linea.h - Arreglo de 5 sensores reflectivos. Valor alto = blanco, valor bajo = negro (si es al revés, invertí la cuenta).
#ifndef LINEA_H
#define LINEA_H

int lin_blanco[N_LINEA], lin_negro[N_LINEA];       // calibración de cada sensor
float lin_pos = 0;                                 // posición de la línea respecto del centro (mm, + = a la derecha)
bool lin_visible = false;

// Mover el robot a mano sobre blanco y negro durante la calibración: se guarda el máximo y el mínimo de cada sensor.
void lineaCalibrar(unsigned long duracionMs) {
  for (byte i = 0; i < N_LINEA; i++) { lin_blanco[i] = 0; lin_negro[i] = 1023; }
  unsigned long t0 = millis();
  while (millis() - t0 < duracionMs) {
    for (byte i = 0; i < N_LINEA; i++) {
      int v = analogRead(PIN_LINEA[i]);
      if (v > lin_blanco[i]) lin_blanco[i] = v;
      if (v < lin_negro[i]) lin_negro[i] = v;
    }
  }
}

// Posición por promedio ponderado de la "oscuridad" (0 = blanco, 1 = negro) de cada sensor.
void lineaLeer() {
  float suma = 0, ponderada = 0;
  for (byte i = 0; i < N_LINEA; i++) {
    float rango = lin_blanco[i] - lin_negro[i];
    float d = rango > 20 ? (lin_blanco[i] - analogRead(PIN_LINEA[i])) / rango : 0;   // un sensor sin rango no aporta
    d = constrain(d, 0, 1);
    suma += d;
    ponderada += d * X_SENSOR_MM[i];
  }
  lin_visible = suma > UMBRAL_LINEA;
  if (lin_visible) lin_pos = ponderada / suma;      // si no hay línea, conserva la última posición
}

#endif

Se probó el módulo con sensores simulados (una línea de 19 mm y un punto de lectura de 8 mm por sensor, con valores de blanco y negro distintos en cada uno). La calibración con un barrido recupera exactamente los valores de cada sensor. El error de la posición estimada:

Posición de la línea: error de la estimación en milímetros.
Línea enCon calibraciónCon valores nominales
0 mm0,000,05
2 mm0,670,65
5 mm0,00−0,36
10 mm0,00−0,72
15 mm0,00−0,59
20 mm−3,05−4,05
25 mm−5,00−6,67
fuera del arreglo (80 mm)no visible

Lecciones de la tabla. Dentro de ±15 mm la estimación es buena (menos de 1 mm de error con calibración). La calibración mejora la precisión, aunque con estos sensores parecidos la diferencia es chica; con sensores más dispares sería grande. Y cerca del borde (20 mm y más) la estimación se aplasta, porque el sensor de afuera ya no existe: el control debe corregir antes, y el requisito F1 (desvío máximo de 15 mm) cae justo en la zona confiable. Un error de 0,67 mm a 2 mm no es un defecto: es la no linealidad del promedio ponderado entre dos sensores.

07 · Obstáculos y energía

Distancia y batería

Los dos módulos son cortos, pero cada uno tiene una decisión de diseño detrás.

Ultrasónico con mediana

El HC-SR04 da de vez en cuando lecturas absurdas: un eco falso, una reflexión, una pared lejana. Una sola de ellas, tomada como verdadera, detendría el robot sin motivo. La defensa es la mediana de las últimas tres mediciones: un valor aislado queda siempre en un extremo y se descarta. Sin eco, el módulo asume «lejos» (400 cm), y la medición bloquea como máximo 6 ms.

// distancia.h - Ultrasónico HC-SR04 con filtro de mediana de las últimas tres mediciones.
#ifndef DISTANCIA_H
#define DISTANCIA_H

float dis_hist[3] = {400, 400, 400};               // cm; arranca en "lejos"
float dis_cm = 400;

float distanciaMedirUna() {                        // bloquea hasta 6 ms; devuelve -1 si no hay eco
  digitalWrite(PIN_TRIG, LOW);  delayMicroseconds(2);
  digitalWrite(PIN_TRIG, HIGH); delayMicroseconds(10);
  digitalWrite(PIN_TRIG, LOW);
  unsigned long us = pulseIn(PIN_ECHO, HIGH, 6000UL);
  return us == 0 ? -1.0 : us * 0.01715;
}

float mediana3(float a, float b, float c) {
  return max(min(a, b), min(max(a, b), c));
}

void distanciaActualizar() {
  float d = distanciaMedirUna();
  if (d < 0) d = 400;                               // sin eco = nada a la vista
  dis_hist[2] = dis_hist[1]; dis_hist[1] = dis_hist[0]; dis_hist[0] = d;
  dis_cm = mediana3(dis_hist[0], dis_hist[1], dis_hist[2]);   // un eco falso aislado no pasa el filtro
}

#endif

Probado con una secuencia de 50 cm con un eco falso de 8 cm: el filtro devuelve siempre 50 cm. Pero tiene un costo, que el diseño del tema 45 no había considerado:

  • Al arrancar, el historial está vacío (400 cm) y la primera salida vale 400.
  • Cuando aparece un obstáculo real (de 50 a 30 cm en la prueba), la mediana no lo deja pasar hasta la segunda medición: se necesitan dos de tres valores. Con mediciones cada 60 ms, la detección llega hasta 120 ms tarde, más los 10 ms de proceso.

Con 130 ms de retardo a 0,30 m/s el robot recorre 3,9 cm antes de reaccionar (contra 2,1 cm sin filtro). Sumado a los 4,5 cm de frenado, son 8,4 cm: detectando a 20 cm, el margen baja de 13,4 cm a 11,6 cm. Sigue cumpliendo F3 (al menos 10 cm), pero con menos holgura: filtrar cuesta tiempo, y se paga con distancia.

Batería con filtro y umbral sostenido

Al arrancar los motores, la tensión de la batería se hunde un instante. Si el robot se detuviera ante cualquier lectura bajo 6,4 V, se pararía cada vez que acelera. Dos defensas: un filtro exponencial (cada ciclo se acerca un 10 % a la lectura nueva) y exigir que la tensión filtrada permanezca baja durante un segundo.

// bateria.h - Tensión de la batería, suavizada, y detección de nivel bajo sostenido.
#ifndef BATERIA_H
#define BATERIA_H

float bat_v = 0;
unsigned long bat_bajaDesde = 0;
bool bat_bajaActiva = false;

void bateriaActualizar() {
  float v = analogRead(PIN_BATERIA) * VREF / 1023.0 * DIVISOR;
  bat_v = bat_v == 0 ? v : bat_v + 0.1 * (v - bat_v);        // filtro exponencial: tarda unos 10 ciclos
  if (bat_v < V_BAT_MIN) {
    if (!bat_bajaActiva) { bat_bajaActiva = true; bat_bajaDesde = millis(); }
  } else {
    bat_bajaActiva = false;
  }
}

bool bateriaBaja() {                               // baja de forma sostenida durante T_BATERIA_BAJA_MS
  return bat_bajaActiva && millis() - bat_bajaDesde >= T_BATERIA_BAJA_MS;
}

#endif

Si la batería baja de golpe de 7,4 a 6,0 V, el filtro cruza 6,4 V a los 1200 ms y la falla se declara a los 2200 ms. Con un filtro más rápido (20 %) serían 600 ms y 1600 ms. Es el mismo compromiso: un filtro lento ignora los hundimientos breves pero tarda en avisar. Para una batería, que se agota en decenas de minutos, 2 s de demora son irrelevantes.

08 · Hacerlo andar junto

Planificador y estados mínimos

El programa principal reparte el tiempo con un planificador cooperativo: una tabla de tareas con su período y su próximo instante. En cada vuelta de loop() se ejecutan las que ya les toca. No hay delay(): la placa nunca está dormida y el reloj de millis() manda (tema 37).

Tareas del programa principal.
TareaPeríodoQué hace
Control20 msLee el botón, mide velocidades, lee la línea, supervisa y ordena los motores
Distancia60 msMide el ultrasónico (bloquea hasta 6 ms)
Batería100 msLee y filtra la tensión
Telemetría100 msEnvía estado, posición, velocidades, distancia y tensión; maneja el LED

El orden de la tabla importa: si dos tareas coinciden en la misma vuelta, la distancia (que bloquea) se ejecuta después del control, que así no se retrasa. Y las tareas son de período fijo: el próximo instante se calcula sumando el período al anterior, no al instante actual, para que un retraso puntual no se acumule.

Estados mínimos: ESPERA (inicial, motores en cero), MARCHA y FALLA. Con ellos el programa ya cumple los requisitos de seguridad del tema 45: arranca parado y espera el botón (S4); el botón detiene (S1); perder la línea durante 1 s (S2) o tener la batería baja durante 1 s (S3) lleva a FALLA, con los motores en cero y el LED titilando. El seguimiento de línea de esta versión es sólo proporcional y sirve de prueba de integración; el PID, la evasión y los subestados llegan en el tema 47.

// robot.ino - Programa principal: planificador de tareas y estados mínimos (espera, marcha, falla).
// Los módulos sólo se comunican por sus funciones públicas; aquí se decide cuándo se llama a cada una.
#include "config.h"
#include "motores.h"
#include "encoders.h"
#include "linea.h"
#include "distancia.h"
#include "bateria.h"

enum Estado { ESPERA, MARCHA, FALLA };
Estado estado = ESPERA;
float velIzq = 0, velDer = 0;                       // velocidades medidas (cm/s)
bool lineaPerdidaActiva = false;
unsigned long lineaPerdidaDesde = 0;

const float VEL_BASE = 0.45;                        // orden base en marcha (prueba de integración)
const float KP_LINEA = 0.02;                        // orden por mm de error (20 mm -> 0,4)

void pasarA(Estado nuevo) {
  estado = nuevo;
  if (nuevo != MARCHA) motorParar();                // fuera de marcha, los motores siempre quedan en cero
  lineaPerdidaActiva = false;
}

bool botonEvento() {                                // flanco de pulsación con antirrebote de 50 ms
  static bool previo = false;
  static unsigned long ultimo = 0;
  bool pulsado = digitalRead(PIN_BOTON) == LOW;
  bool evento = pulsado && !previo && millis() - ultimo > 50;
  if (pulsado != previo) ultimo = millis();
  previo = pulsado;
  return evento;
}

void tareaControl() {                               // cada 20 ms
  if (botonEvento()) pasarA(estado == ESPERA ? MARCHA : ESPERA);   // arranca desde ESPERA; en MARCHA (S1) o FALLA vuelve a ESPERA
  float dt = TS_CONTROL_MS / 1000.0;
  velIzq = encVelocidad(0, dt);
  velDer = encVelocidad(1, dt);
  lineaLeer();
  if (estado != MARCHA) return;

  if (!lin_visible) {                               // S2: línea perdida durante más de 1 s
    if (!lineaPerdidaActiva) { lineaPerdidaActiva = true; lineaPerdidaDesde = millis(); }
    if (millis() - lineaPerdidaDesde >= T_LINEA_PERDIDA_MS) { pasarA(FALLA); return; }
  } else {
    lineaPerdidaActiva = false;
  }
  if (bateriaBaja()) { pasarA(FALLA); return; }     // S3
  float giro = KP_LINEA * lin_pos;                  // línea a la derecha (+): la rueda izquierda va más rápido
  motorSet(0, VEL_BASE + giro);
  motorSet(1, VEL_BASE - giro);
}

void tareaDistancia() { distanciaActualizar(); }    // cada 60 ms, justo después del control para no retrasarlo
void tareaBateria() { bateriaActualizar(); }        // cada 100 ms

void tareaTelemetria() {                            // cada 100 ms
  Serial.print((int)estado); Serial.print(',');
  Serial.print(lin_pos, 1); Serial.print(',');
  Serial.print(velIzq, 1); Serial.print(',');
  Serial.print(velDer, 1); Serial.print(',');
  Serial.print(dis_cm, 0); Serial.print(',');
  Serial.println(bat_v, 2);
  digitalWrite(PIN_LED, estado == MARCHA ? HIGH : (estado == FALLA ? (millis() / 250) % 2 : LOW));
}

struct Tarea { unsigned long periodo; unsigned long proxima; void (*funcion)(); };
Tarea tareas[] = {                                  // el orden importa: dentro de una vuelta, primero el control
  {TS_CONTROL_MS, 0, tareaControl},
  {TS_DISTANCIA_MS, 0, tareaDistancia},
  {TS_BATERIA_MS, 0, tareaBateria},
  {TS_TELEMETRIA_MS, 0, tareaTelemetria},
};

void setup() {
  Serial.begin(115200);
  pinMode(PIN_LED, OUTPUT);
  pinMode(PIN_BOTON, INPUT_PULLUP);
  pinMode(PIN_TRIG, OUTPUT);
  pinMode(PIN_ECHO, INPUT);
  motorBegin();                                     // lo primero: motores en cero
  encBegin();
  lineaCalibrar(3000);                              // 3 s: pasá el arreglo sobre la línea y el blanco
  bateriaActualizar();
  for (auto &t : tareas) t.proxima = millis();      // la calibración tardó: sin esto, las tareas se ejecutarían en ráfaga
}

void loop() {
  unsigned long ahora = millis();
  for (auto &t : tareas) {
    if ((long)(ahora - t.proxima) >= 0) {
      t.proxima += t.periodo;                       // período fijo, sin acumular retrasos
      t.funcion();
    }
  }
}

Se ejecutó este programa completo contra el simulador de pines y reloj. Resultados:

Comportamiento del programa principal en la simulación.
SituaciónResultado
Ejecuciones en 2 s (control, distancia, batería, telemetría)100, 34, 20 y 20
Robot recién encendidoESPERA, PWM = 0 en ambos motores (S4)
Pulso del botón con la línea centradaMARCHA, PWM = 148 y 148 (orden 0,45)
Pulso del botón en marcha (S1)ESPERA, PWM = 0; en el peor caso tarda 25 ms
Línea perdida (S2)FALLA a los 1010 ms, PWM = 0
Pulso del botón en FALLAVuelve a ESPERA (reinicio)
Batería baja de golpe a 6,0 V (S3)FALLA a los 2220 ms

La primera versión de este programa tenía un error que la simulación mostró enseguida. La calibración dura 3 s, y las tareas arrancaban con el «próximo instante» en cero. Al empezar el loop(), todas parecían atrasadas 3 s y se ejecutaban en ráfaga: el control corría unas 150 veces seguidas, con las velocidades y los motores calculados sobre tiempos falsos. La corrección es la línea que sincroniza proxima con millis() al final de setup(). Un error así, en el robot, se vería como un arranque errático sin causa aparente; en la simulación, en el contador de ejecuciones.

También hubo que ajustar el botón: la primera versión llevaba de FALLA a MARCHA, y el diseño decía que el botón en falla reinicia a ESPERA (después de resolver la causa, el operador vuelve a pulsar para arrancar). Salir de una falla directamente a marchar es justo lo que no debe pasar.

09 · Antes de integrar

Pruebas de módulo

Cada módulo se aprueba solo, con un programa de prueba corto, antes de integrarlo. Son los de nivel «módulo» del plan del tema 45, con criterios concretos:

Pruebas de módulo y criterios de aprobación.
IdMóduloProcedimientoCriterio
M1MotoresRobot levantado; ordenar +0,5 y −0,5 a cada motorGira hacia delante con +, hacia atrás con −; PWM 158 en ambos
M2Zona muertaRobot en el piso; subir el PWM de 5 en 5PWM_MIN medido y anotado para cada motor
M3EncodersUna vuelta de rueda a mano, hacia delante1440 cuentas ±1 % (±14), positivas; igual en la otra rueda
M4Odometría linealEmpujar el robot 100 cm marcadosUnas 7052 cuentas por rueda; la diferencia entre ruedas es pequeña
M5LíneaCalibrar y deslizar el arreglo de lado sobre la líneaLa posición varía de −15 a +15 mm de forma continua y en el sentido correcto; fuera del arreglo, lin_visible es falso
M6UltrasónicoUna caja a 10, 20 y 40 cmLecturas dentro de ±1 cm; sin obstáculo, 400 cm
M7BateríaComparar con un tester en tres tensionesDiferencia menor a 0,05 V
M8TiemposMedir con micros() el período real del control durante 1 minuto20 ms con una variación de ±2 ms (requisito C3)

M4 sale de una cuenta que sirve para calibrar: 100 cm son 100 / 0,014181 = 7052 cuentas. Si el robot cuenta 7000 en 100 cm reales, el radio efectivo de la rueda no es 3,25 cm sino 3,274 cm (el neumático blando, el desgaste, el diámetro real); se corrige RADIO_RUEDA_CM y se repite, como se vio en el tema 27.

10 · Exploración interactiva

Laboratorios de construcción

1. Qué ve el arreglo de línea

Mové la línea (19 mm de ancho) bajo el arreglo de cinco sensores. El círculo de cada sensor se aclara con el blanco y se oscurece con el negro; el número es su lectura. El triángulo naranja es la posición que estima el módulo.

Arreglo de cinco sensores sobre una líneaCinco círculos que representan los sensores, con la lectura de cada uno, y un rectángulo negro de 19 milímetros que es la línea. Un triángulo marca la posición estimada.
Lecturas S1 a S5—
Posición estimada—
Error—
¿Línea visible?—

Mové la línea.

Probá con 10 mm y calibración (error nulo), y después sin calibración (−0,72 mm). Llevá la línea a 20 y a 25 mm para ver cómo se aplasta la estimación en el borde, y a 30 mm para ver cómo se pierde.

2. Qué mide el encoder

La rueda va a velocidad constante, pero el encoder sólo cuenta pulsos enteros. El laboratorio muestra el rango de la velocidad medida durante 200 ventanas consecutivas.

Resolución de una cuenta—
Velocidad medida (cm/s)—
Error máximo—
Retardo medio—

Mové los controles.

Con 30 cm/s y 20 ms se obtienen los valores de la tabla (29,78 a 30,49). Bajá la velocidad a 5 cm/s: la misma ventana de 20 ms ya produce un error de más del 10 %, porque una cuenta es una fracción grande de lo que se mide. Para velocidades bajas hace falta una ventana mayor, o medir el tiempo entre cuentas en lugar de contarlas.

11 · Código ejecutable

El firmware en Python

El mismo cálculo de los módulos reescrito en Python: la conversión de orden a PWM, la estimación de posición de la línea, la velocidad cuantizada, el filtro de mediana y el filtro de la batería. Sirve para entender cada módulo sin placa y para probar cambios de constantes. Las salidas coinciden con las que dio el código C++ en el simulador de la computadora.

Probalo en el navegador. Python se descarga con Pyodide la primera vez y requiere conexión. El código es editable y no controla ningún equipo real.

from math import pi

# Constantes del firmware (las mismas de config.h)
R, CPR = 3.25, 1440
CM_POR_CUENTA = 2 * pi * R / CPR
PWM_MIN, ZONA_MUERTA = 60, 0.02
X = [-20, -10, 0, 10, 20]                       # posición de cada sensor de línea (mm)
BLANCO_REAL = [780, 820, 800, 760, 840]         # cada sensor es un poco distinto
NEGRO_REAL = [110, 130, 120, 100, 140]

def pwm_desde_orden(u):
    """Igual que pwmDesdeOrden(): cero en la zona muerta; si no, de PWM_MIN a 255."""
    a = abs(u)
    if a < ZONA_MUERTA:
        return 0
    return int(PWM_MIN + (255 - PWM_MIN) * min(a, 1.0) + 0.5)

def lecturas(c_mm, ancho=19.0, spot=8.0):
    """Lectura de cada sensor para una línea de 19 mm centrada en c_mm (cada sensor mira un punto de 8 mm)."""
    out = []
    for i, x in enumerate(X):
        solape = max(0.0, min(c_mm + ancho / 2, x + spot / 2) - max(c_mm - ancho / 2, x - spot / 2))
        out.append(int(BLANCO_REAL[i] - solape / spot * (BLANCO_REAL[i] - NEGRO_REAL[i]) + 0.5))
    return out

def posicion(lect, blanco, negro, umbral=0.3):
    """Igual que lineaLeer(): promedio ponderado de la oscuridad. Devuelve None si no hay línea."""
    suma = pond = 0.0
    for i, v in enumerate(lect):
        rango = blanco[i] - negro[i]
        d = min(1.0, max(0.0, (blanco[i] - v) / rango)) if rango > 20 else 0.0
        suma += d
        pond += d * X[i]
    return pond / suma if suma > umbral else None

def velocidad_medida(v_cm_s, ventana, n=200):
    """Velocidad que mide el encoder en n ventanas consecutivas: sólo ve cuentas enteras."""
    fase, med = 0.0, []
    for _ in range(n):
        fase += v_cm_s / CM_POR_CUENTA * ventana
        cuentas = int(fase)
        fase -= cuentas
        med.append(cuentas * CM_POR_CUENTA / ventana)
    return min(med), max(med), sum(med) / n

def mediana3(a, b, c):
    return max(min(a, b), min(max(a, b), c))

def filtro_distancia(medidas):
    """Mediana de las últimas tres mediciones (arranca en 'lejos', 400 cm)."""
    hist, salida = [400.0, 400.0, 400.0], []
    for m in medidas:
        hist = [m] + hist[:2]
        salida.append(mediana3(*hist))
    return salida

def deteccion_bateria(v_ini=7.4, v_nuevo=6.0, v_min=6.4, alfa=0.1, ciclo_ms=100, sostenido_ms=1000):
    """Tiempos (ms) en que el filtro cruza el umbral y en que se declara la falla sostenida."""
    v, t, cruce = v_ini, 0, None
    while t < 20000:
        t += ciclo_ms
        v += alfa * (v_nuevo - v)
        if v < v_min and cruce is None:
            cruce = t
        if cruce is not None and t - cruce >= sostenido_ms:
            return cruce, t
    return cruce, None

if __name__ == "__main__":
    print("Orden -> PWM (PWM_MIN = 60, zona muerta 0,02):")
    for u in (0.0, 0.01, 0.02, 0.1, 0.25, 0.5, 0.75, 1.0, -0.5, 1.5):
        print(f"  u = {u:5.2f} -> PWM {pwm_desde_orden(u):3d}")

    print(f"\nEncoder: {CM_POR_CUENTA:.5f} cm por cuenta, una vuelta = {CM_POR_CUENTA * CPR:.2f} cm, "
          f"100 cm = {100 / CM_POR_CUENTA:.0f} cuentas")
    print("Velocidad medida con v = 30 cm/s (200 ventanas):")
    for ventana in (0.01, 0.02, 0.05, 0.10):
        lo, hi, media = velocidad_medida(30.0, ventana)
        print(f"  ventana {ventana * 1000:3.0f} ms: de {lo:5.2f} a {hi:5.2f} cm/s (media {media:5.2f}), "
              f"resolución {CM_POR_CUENTA / ventana:.3f} cm/s, retardo medio {ventana * 500:.0f} ms")

    print("\nPosición de la línea (error en mm):")
    print("   c(mm)  calibrado  error | sin calibrar  error")
    for c in (0, 2, 5, 10, 15, 20, 25, -10):
        lect = lecturas(c)
        p1 = posicion(lect, BLANCO_REAL, NEGRO_REAL)
        p2 = posicion(lect, [800] * 5, [120] * 5)
        print(f"  {c:6.1f}  {p1:9.2f}  {p1 - c:5.2f} | {p2:12.2f}  {p2 - c:5.2f}")
    print(f"  línea fuera del arreglo (c = 80 mm): {posicion(lecturas(80), BLANCO_REAL, NEGRO_REAL)}")

    print("\nFiltro de mediana de la distancia (cm):")
    entrada = [50, 50, 50, 8, 50, 50, 30, 30, 30]
    for m, f in zip(entrada, filtro_distancia(entrada)):
        print(f"  medida {m:5.1f} -> filtrada {f:5.1f}")

    cruce, falla = deteccion_bateria()
    print(f"\nBatería: de 7,4 a 6,0 V de golpe -> el filtro cruza 6,4 V a los {cruce} ms y la falla se declara a los {falla} ms")
    cruce2, falla2 = deteccion_bateria(alfa=0.2)
    print(f"         con un filtro más rápido (alfa 0,2): {cruce2} ms y {falla2} ms")

    v = 0.30
    for filtro, retardo in (("sin filtro", 0.060 + 0.010), ("con mediana de 3", 2 * 0.060 + 0.010)):
        reaccion, freno = v * retardo * 100, v * v / (2 * 1.0) * 100
        print(f"Frenado a {v:.2f} m/s {filtro}: reacción {reaccion:.1f} cm + freno {freno:.1f} cm; margen {20 - reaccion - freno:.1f} cm")

La salida repite las tablas del texto: la conversión a PWM, la velocidad medida con cuatro ventanas, el error de posición con y sin calibración, el filtro de mediana, la detección de batería baja y el frenado con y sin filtro de distancia.

Probá cambiar PWM_MIN a 80 y ver cómo se desplazan los PWM, o poner alfa=0.05 en el filtro de la batería para comprobar cuánto tarda en avisar.

12 · Diagnóstico

Cuando algo no funciona

Síntomas frecuentes en la construcción y su causa probable.
SíntomaCausa probableQué hacer
El Arduino se reinicia al arrancar los motoresCaída de tensión por el arranque; masas mal unidasRevisar el capacitor del driver, el cableado de masa y la batería; nunca alimentar los motores desde el 5 V del Arduino
Una rueda gira al revésMotor o driver con la polaridad invertidaCambiar MOT_INVERTIDO de esa rueda
Una rueda zumba y no gira a baja ordenPWM_MIN demasiado bajoMedirlo de nuevo con el robot en el piso
El encoder cuenta al revés, o cuenta de másCanales A y B cruzados; ruido en los cablesInvertir ENC_SIGNO; acortar y retorcer los cables; revisar la masa
La cuenta por vuelta es la mitad o la cuarta parteSe cuenta un solo canal o un solo flancoVerificar las interrupciones de los dos canales
La posición de la línea salta al cambiar la luzLa calibración se hizo con otra iluminaciónCalibrar en el lugar de uso; proteger los sensores de la luz exterior
El robot se detiene sin obstáculoEco falso del ultrasónico; reflexión en el piso o en una paredRevisar la inclinación del sensor y mirar las lecturas crudas
El control corre a ráfagas al empezarTareas con el próximo instante sin sincronizarAsignar millis() a proxima al final de setup()
La telemetría hace irregular el controlMucho texto por ciclo; Serial bloquea con el búfer llenoAcortar el mensaje o bajar la frecuencia; medir el período del control
«Primero lo escribo todo y después pruebo»

Si algo falla, no se sabe en qué módulo. Un módulo probado solo es una pieza en la que se puede confiar cuando se integra.

«Los pines y las constantes pueden estar donde me quede cómodo»

Se multiplican los lugares donde tocar, y se pierde la consistencia. Un cambio de hardware debe ser un cambio de un archivo.

«delay() es más simple que un planificador»

Mientras espera, la placa no lee el botón ni vigila la batería. Los requisitos de seguridad dependen de que el programa nunca deje de mirar.

«Si funcionó una vez, el módulo está bien»

Se prueba con el borde de los casos: línea fuera del arreglo, sin eco, batería en el umbral, encoder hacia atrás. Es donde el código real suele fallar.

«Filtrar siempre mejora la medida»

Quita ruido y agrega retardo. La mediana de tres lecturas del ultrasónico descarta ecos falsos, pero atrasa la detección hasta 120 ms: hay que repasar el presupuesto de frenado.

«El simulador reemplaza a la prueba en el robot»

Verifica la lógica, no el hardware: ruido, cableado, tiempos reales y rozamientos solo aparecen en el robot. Las dos pruebas se complementan.

13 · Práctica guiada

Predecí antes de comprobar

1. Calibrar el radio

Empujás el robot 100 cm marcados y una rueda cuenta 7000. ¿Cuánto esperabas y qué radio efectivo tiene la rueda?

Ver solución

Esperabas 100 / 0,014181 = 7052 cuentas. El radio efectivo es 3,25 · 7052 / 7000 = 3,274 cm. Se corrige RADIO_RUEDA_CM y se repite la prueba.

2. Orden a PWM

Con PWM_MIN = 60, ¿qué PWM sale para u = 0,3? ¿Y para u = 0,019?

Ver solución

Para 0,3: 60 + 195 · 0,3 + 0,5 = 119. Para 0,019: está bajo la zona muerta (0,02), así que el PWM es 0.

3. Giro proporcional

La línea está 10 mm a la derecha. Con VEL_BASE = 0,45 y KP_LINEA = 0,02, ¿qué órdenes y qué PWM reciben las ruedas? ¿Hacia dónde gira el robot?

Ver solución

El giro es 0,02 · 10 = 0,2. Rueda izquierda: 0,45 + 0,2 = 0,65, PWM 187. Rueda derecha: 0,45 − 0,2 = 0,25, PWM 109. La izquierda va más rápido, así que el robot gira hacia la derecha, hacia la línea.

4. Ventana de velocidad

Para el lazo de velocidad a 30 cm/s, ¿conviene una ventana de 50 ms? Compará con 20 ms.

Ver solución

La resolución mejora de 0,709 a 0,284 cm/s y el error máximo baja de 1,6 % a 0,7 %, pero el retardo medio sube de 10 a 25 ms, mayor que el período del lazo de 20 ms. En un lazo de control ese retardo cuesta amortiguamiento (tema 41). Conviene la de 20 ms, y filtrar después si hace falta.

5. Costo de la mediana

Con el filtro de mediana de tres, ¿cuánto vale la distancia de reacción a 0,30 m/s y qué margen queda si se detecta a 20 cm y se frena con 1,0 m/s²?

Ver solución

El retardo máximo es 2 · 60 + 10 = 130 ms: 0,30 · 0,130 = 3,9 cm. Frenado: 4,5 cm. Total: 8,4 cm; margen: 20 − 8,4 = 11,6 cm. Cumple F3, con menos holgura que sin filtro (13,4 cm).

6. Filtro de la batería

Con un filtro que se acerca un 20 % por ciclo de 100 ms, ¿cuándo se detecta una caída brusca de 7,4 a 6,0 V? ¿Por qué no conviene un filtro aún más rápido?

Ver solución

La tensión filtrada cruza 6,4 V a los 600 ms y la falla sostenida se declara a los 1600 ms. Un filtro todavía más rápido deja pasar los hundimientos de tensión al arrancar los motores y detendría al robot en cada aceleración.

7. Una ráfaga al arrancar

Un planificador cuyas tareas arrancan con proxima = 0 comienza el loop() tras una calibración de 3 s. ¿Cuántas veces seguidas se ejecuta la tarea de control de 20 ms?

Ver solución

Parece atrasada 3 s: 3000 / 20 = 150 ejecuciones consecutivas más la normal, hasta alcanzar al reloj, sin esperar entre una y otra. Se evita con t.proxima = millis() al final de setup().

14 · Síntesis

Ideas para recordar

  • El robot se monta por etapas, cada una con una prueba de aprobación; antes de conectar la batería, se revisan polaridad, cortocircuitos, fusible, masas y desacople.
  • El firmware se divide en un módulo por dispositivo, con interfaz pequeña, sin dependencias entre ellos y con los datos de hardware concentrados en config.h.
  • El driver de motores traduce una orden entre −1 y 1 a PWM y sentido, compensando la zona muerta con PWM_MIN.
  • El encoder en cuadratura 4x usa una tabla de transiciones; se lee de forma atómica y la velocidad es cuentas por ventana, con resolución limitada por el retardo.
  • La posición de la línea es un promedio ponderado de la oscuridad normalizada de cada sensor; es buena en el centro del arreglo y se aplasta cerca del borde.
  • Cada filtro quita ruido a cambio de retardo: la mediana del ultrasónico y el filtro de la batería se justifican con cuentas, no con intuición.
  • Un planificador cooperativo de período fijo reparte el tiempo sin delay(); las tareas bloqueantes van después del control, y sus instantes se sincronizan al terminar setup().
  • Los estados ESPERA, MARCHA y FALLA ya cumplen los requisitos de seguridad: arrancar parado, botón de parada, línea perdida y batería baja.
  • Probar cada módulo solo, en el robot y en simulación, localiza los errores por capas.

¿Listo para el tema 47?

Antes de avanzar, aprobá las pruebas M1 a M8 con tu robot y anotá los valores medidos: PWM_MIN, cuentas por vuelta y por 100 cm, calibración de la línea y período real del control. Con eso, el programa de este tema debería seguir una línea recta con un movimiento aceptable, aunque oscile.

En el próximo tema integramos la navegación, la evasión y los estados: reemplazamos el control proporcional por los lazos PID de línea y de velocidad, y construimos la máquina de estados que rodea el obstáculo y vuelve a la línea.