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ó.
| Etapa | Qué se monta | Prueba antes de seguir |
|---|---|---|
| 1. Mecánica | Chasis, motores con encoder, ruedas, rueda loca, soporte de la batería | Las ruedas giran libres; el robot apoya firme y sin inclinarse; los ejes están alineados |
| 2. Potencia | Batería, interruptor, fusible, driver, motores. Sin el Arduino | Con 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ógica | Arduino alimentado por VIN, regulador, LED, botón, divisor de la batería | 5 V en la placa; el autodiagnóstico del tema 45 muestra la batería correcta y el botón |
| 4. Motores con control | Señales PWM y de sentido del Arduino al driver | El módulo de motores: gira adelante con +0,5 y atrás con −0,5 en las dos ruedas |
| 5. Encoders | Alimentación y canales A y B de cada encoder | Una vuelta de rueda a mano suma 1440 cuentas (±1 %) con signo positivo hacia delante |
| 6. Línea | Arreglo de cinco sensores, 8 cm adelante del eje y a pocos milímetros del piso | Valores altos sobre el blanco, bajos sobre el negro, en los cinco; calibración correcta |
| 7. Obstáculos | Ultrasónico al frente, centrado | Una 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
- 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.
- 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.
- 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.
- 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.
- 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).
- 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.
- 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.
Las reglas que sostienen esta estructura:
- Un módulo, un dispositivo.
motores.hsólo sabe de motores ylinea.hde 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,05se tradujera en un PWM de 13 sobre 255, el motor zumbaría sin moverse. El driver mapea el rango útil deua PWM entrePWM_MINy 255, y trata como cero lo que está por debajo deZONA_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_INVERTIDOlo 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 u | PWM | Comentario |
|---|---|---|
| 0,00 y 0,01 | 0 | Dentro de la zona muerta |
| 0,02 | 64 | El umbral: el motor arranca apenas |
| 0,10 | 80 | |
| 0,25 | 109 | |
| 0,50 | 158 | La mitad de la orden no es la mitad del PWM |
| 0,75 | 206 | |
| 1,00 y 1,50 | 255 | Saturado |
| −0,50 | 158 | Mismo PWM, con el pin de sentido invertido |
Medir PWM_MIN
- Con el robot apoyado en el piso (con su peso real), ordená PWM crecientes de 5 en 5 cada medio segundo.
- 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. - 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
PCICRy en la máscara de cada grupo. Sólo se habilitan esos dos pines: nunca el del botón. - Lectura atómica. Un
longde 4 bytes se copia en varias instrucciones; si una interrupción lo cambia a la mitad, se lee un valor corrupto. Por esoencLeerapaga 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
digitalReadcada una puede costar el doble. Medilo conmicros(): 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:
| Ventana | Rango medido | Resolución | Error máximo | Retardo medio |
|---|---|---|---|---|
| 10 ms | 29,78 a 31,20 cm/s | 1,418 cm/s | 4,0 % | 5 ms |
| 20 ms | 29,78 a 30,49 cm/s | 0,709 cm/s | 1,6 % | 10 ms |
| 50 ms | 29,78 a 30,06 cm/s | 0,284 cm/s | 0,7 % | 25 ms |
| 100 ms | 29,92 a 30,06 cm/s | 0,142 cm/s | 0,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:
| Línea en | Con calibración | Con valores nominales |
|---|---|---|
| 0 mm | 0,00 | 0,05 |
| 2 mm | 0,67 | 0,65 |
| 5 mm | 0,00 | −0,36 |
| 10 mm | 0,00 | −0,72 |
| 15 mm | 0,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).
| Tarea | Período | Qué hace |
|---|---|---|
| Control | 20 ms | Lee el botón, mide velocidades, lee la línea, supervisa y ordena los motores |
| Distancia | 60 ms | Mide el ultrasónico (bloquea hasta 6 ms) |
| Batería | 100 ms | Lee y filtra la tensión |
| Telemetría | 100 ms | Enví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:
| Situación | Resultado |
|---|---|
| Ejecuciones en 2 s (control, distancia, batería, telemetría) | 100, 34, 20 y 20 |
| Robot recién encendido | ESPERA, PWM = 0 en ambos motores (S4) |
| Pulso del botón con la línea centrada | MARCHA, 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 FALLA | Vuelve 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:
| Id | Módulo | Procedimiento | Criterio |
|---|---|---|---|
| M1 | Motores | Robot levantado; ordenar +0,5 y −0,5 a cada motor | Gira hacia delante con +, hacia atrás con −; PWM 158 en ambos |
| M2 | Zona muerta | Robot en el piso; subir el PWM de 5 en 5 | PWM_MIN medido y anotado para cada motor |
| M3 | Encoders | Una vuelta de rueda a mano, hacia delante | 1440 cuentas ±1 % (±14), positivas; igual en la otra rueda |
| M4 | Odometría lineal | Empujar el robot 100 cm marcados | Unas 7052 cuentas por rueda; la diferencia entre ruedas es pequeña |
| M5 | Línea | Calibrar y deslizar el arreglo de lado sobre la línea | La posición varía de −15 a +15 mm de forma continua y en el sentido correcto; fuera del arreglo, lin_visible es falso |
| M6 | Ultrasónico | Una caja a 10, 20 y 40 cm | Lecturas dentro de ±1 cm; sin obstáculo, 400 cm |
| M7 | Batería | Comparar con un tester en tres tensiones | Diferencia menor a 0,05 V |
| M8 | Tiempos | Medir con micros() el período real del control durante 1 minuto | 20 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.
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.
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íntoma | Causa probable | Qué hacer |
|---|---|---|
| El Arduino se reinicia al arrancar los motores | Caída de tensión por el arranque; masas mal unidas | Revisar 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és | Motor o driver con la polaridad invertida | Cambiar MOT_INVERTIDO de esa rueda |
| Una rueda zumba y no gira a baja orden | PWM_MIN demasiado bajo | Medirlo de nuevo con el robot en el piso |
| El encoder cuenta al revés, o cuenta de más | Canales A y B cruzados; ruido en los cables | Invertir ENC_SIGNO; acortar y retorcer los cables; revisar la masa |
| La cuenta por vuelta es la mitad o la cuarta parte | Se cuenta un solo canal o un solo flanco | Verificar las interrupciones de los dos canales |
| La posición de la línea salta al cambiar la luz | La calibración se hizo con otra iluminación | Calibrar en el lugar de uso; proteger los sensores de la luz exterior |
| El robot se detiene sin obstáculo | Eco falso del ultrasónico; reflexión en el piso o en una pared | Revisar la inclinación del sensor y mirar las lecturas crudas |
| El control corre a ráfagas al empezar | Tareas con el próximo instante sin sincronizar | Asignar millis() a proxima al final de setup() |
| La telemetría hace irregular el control | Mucho texto por ciclo; Serial bloquea con el búfer lleno | Acortar 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 terminarsetup(). - 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.