Diseño a profundidad (ESP-32)
Para el correcto seguimiento de esta sección del libro se espera un conocimiento moderadamente avanzado del lenguaje de programación C así como un conocimiento básico de señales.
Dada la naturaleza de nuestro proyecto, la tarjeta ESP-32 únicamente funciona como una interfaz entre el mundo real y el mundo digital en el cual queremos representar las gráficas del voltaje de nuestro osciloscopio.
En el capítulo de diseño habíamos mencionado un pseudocódigo a partir del cual nos vamos a basar para programar, ahora sí, con código optimizado la tarjeta ESP-32 para obtener la mayor velocidad de muestreo posible así como el comportamiento más homogéneo a lo largo del tiempo que podamos conseguir.
Comencemos con el pseudocódigo original, convirtámoslo en código de Arduino y comencemos a optimizar:
int lectura_ADC_A
int lectura_ADC_B
int pin_ADC_A
int pin_ADC_B
int resta_ADC_AB
inicializa(){
//De este valor dependerá la velocidad de operación de la ESP32
configurar_reloj_ESP32
//De este valor dependerá nuestra velocidad de envío
configurar_tasa_baudios_monitor_serial(máximo_valor_estable)
//De este valor dependerá nuestra calidad de lectura
configurar_resolución_ADC(máximo_valor_estable)
}
ciclo(){
//Leemos los valores de las terminal positiva (A) y la terminal negativa (B)
lectura_ADC_A = leer(pin_ADC_A)
lectura_ADC_B = leer(pin_ADC_B)
//Al valor de la terminal positiva restarle el valor de la terminal negativa
resta_ADC_AB = lectura_ADC_A - lectura_ADC_B
//Mandamos el valor a la computadora
mandar_serial(resta_ADC_AB)
}
Convirtiéndolo en código arduino podemos hacer una traducción casi palabra por palabra:
//El número de pin en que esperaremos leer los valores de voltaje
#define pin_ADC_A 4
#define pin_ADC_B 6
//Variables para guardar la lectura 0->4095
int lectura_ADC_A = 0;
int lectura_ADC_B = 0;
int resta_ADC_A_B = 0;
void setup() {
//Configuramos los ADC a su máxima resolución
//Por defecto es de 12 bits por lo que podemos comentar casi siempre
//analogReadResolution(12);
//Configuramos la velocidad de reloj a la máxima posible
//Generalmente esta velocidad está predeterminada a 240, comentamos
//setCpuFrequencyMhz(240);
//Comenzamos el monitor serial a la máxima velocidad estable
Serial.begin(921600);
}
void loop() {
//Leemos el valor de voltaje presente en los pines A y B
lectura_ADC_A = analogRead(pin_ADC_A);
lectura_ADC_B = analogRead(pin_ADC_B);
//Lectura A - Lectura B
resta_ADC_A_B = lectura_ADC_A - lectura_ADC_B;
Serial.println(resta_ADC_A_B);
}
Ya que está explícito el código, limpiemos un poco. Confie en mi, esto se va a saturar pronto:
#define pin_ADC_A 4
#define pin_ADC_B 6
int lectura_ADC_A = 0;
int lectura_ADC_B = 0;
int resta_ADC_A_B = 0;
void setup() {
Serial.begin(921600);
}
void loop() {
lectura_ADC_A = analogRead(pin_ADC_A);
lectura_ADC_B = analogRead(pin_ADC_B);
resta_ADC_A_B = lectura_ADC_A - lectura_ADC_B;
Serial.println(resta_ADC_A_B);
}
Hay que utilizar la biblioteca oficial de Espressif para poder utilizar el IDE de Arduino y subir el código. Recordar que en la sección de Manual de Usuario "Cómo programarlo" se trató este tema.
Ya preparado esto y subiendo el código a la tarjeta, si hacemos pruebas con nuestro osciloscopio armado (tal y como lo armamos en la fase de manual de usuario) podemos abrir la ventana del monitor serial y visualizar los valores desde el propio IDE de Arduino.
Aquí es donde surge la importancia de las herramientas que habíamos propuesto en la fase de diseño:
Windows y Linux HTerm
Para Mac CoolTerm
Si usamos el IDE de Arduino obtendremos más o menos el siguiente comportamiento:
Pinta bien, si además cambiamos, en vez del monitor serial, usamos el llamado "Serial Plotter" podemos visualizar rápidamente los valores obtenidos desde el EPS-32 en una gráfica.
Ahora, le pediré que confíe en mi cuando le digo, el código está mal y es muy lento. Las pruebas realizadas fueron extensas pero escapan de la fase de diseño y son más bien cuestión de la fase de pruebas.
Para no entrar en demasiado detalle, hay una serie de problemas:
- El código es muy lento por la conversión que realiza la ESP-32 del valor leído del ADC.
- Se lee el valor del ADC en binario
- Se convierte el número a decimal
- Se imprime el número como caracters ASCII en el monitor serial
- Esto supone una conversión adicional de número decimal a número de caracter ASCII lo que implica enviar dos bytes de datos ¡PARA CADA CIFRA DE NUESTRO NÚMERO!
- Ese último punto revela que nuestro código además de ser lento es inconsistente, esto porque los números leídos de una sola cifra (0->9) se imprimirán en un solo caracter lo que significa dos bytes; los números de dos cifras (-9 -> 99) se imprimirán como dos caracteres, lo que significan cuatro bytes. ¡Ni siquiera podemos obtener un comportamiento consistente entre números positivos y negativos. Comparando con el número -4096 (pensando que el voltaje en B es de 4095 y el voltaje en A es de 0) tendríamos que enviar cinco caracteres de la ESP-32 hacia la computadora; en comparación con los valores de un solo caracter (0->9) estaríamos enviando cinco veces más información haciendo de este protocolo terriblemente ineficiente.
Nuevo protocolo de cifrado
Para arreglar este problema, es necesario proponer un nuevo protocolo de cifrado de nuestros números enteros (-4096 -> 4095) de manera que quepan todos en un tamaño constante de bytes para poder reinterpretarlos en la computadora (en el programa de Java).
Hay múltiples posibilidades para lograr esto, sin embargo, aprovechando que el protocolo serial siempre funciona con el envío de bytes, podemos inmediatamente pensar que dos bytes por número es un tamaño suficiente e incluso sobrante.
Pensemos en el valor binario de 4095 (1111_1111_1111) que es un número de doce bits. Ya que estamos intentando meterlo en dos bytes, es muy claro que en efecto podemos enviar este valor:
16 bits 0000_0000 0000_0000
4095 0000_1111 1111_1111
Y tendremos una holgura de 4 bits para enviar información adicional que pueda resultar útil.
Entre la información importante que podríamos querer enviar se encuentra si nuestro número leído es positivo o negativo. Después de todo, estamos trabajando con los valores de dos ADC (terminal positiva y terminal negativa) y estamos realizando una resta de valores de 12 bits. (0 -> 4095) para el positivo (A) y (-0->-4095) para el negativo (B). Esto quiere decir que nuestro rango de posibles valores es en realidad -4096 a 4095, dándonos una extraña resolución de 13 bits a pesar de que nuestro ADC sea de 12 bits.
Esto quiere decir que tendríamos que agregar un bit para enviar el signo:
16 bits 0000_0000 0000_0000
-4096 000s_1111 1111_1111
El siguiente paso se define únicamente por practicidad de la lectura desde el programa de Java. Cuando empezamos a leer el flujo de valores, únicamente empezaremos a leer paquetes de bytes, no sabremos qué número es el inicio y cuál es el fin del número que se está enviando. Podríamos enviar un tercer byte lleno de unos a partir del cual podríamos determinar el fin del número enviado anterior, pero queda claro que aún tenemos tres bits con los cuales podemos enviar esa información.
Podemos definir en nuestro protocolo que si un número inicia con uno será el inicio de un paquete de dos bytes y si inicia con cero será el final del paquete de dos bytes. Por ejemplo, si observáramos lo siguiente:
Sin embargo, dado que nuestro byte menos significativo puede estar lleno de unos, habrá que definir que los dos primeros bits siempre estén vacíos y nosotros los podamos manipular a voluntad para enviar los bits que signifiquen "inicio de número" "fin de número" así como el bit de "número negativo".
0010_1110 //fin de número (lo descartamos)
1001_1011 //inicio de número
0011_1100 //fin de número
1001_1001 //inicio de número
...
Entonces, si el bit con la letra "i" significa "inicio", "f" significa "fin" y "s" es de "signo" podríamos así fácilmente interpretar los números a partir del paquete conformado por los bits señalados con ##_####.
is##_####
fs##_####
is##_####
fs##_####
...
Si definimos que el bit de inicio "i" es 1, el bit de fin "f" es 0, el bit de signo negativo "s" es 1 y el bit de signo positivo "s" es 0, obtendríamos una representación de este estilo:
3094 -> en binario 1100_0001_0110
Visto en paquetes de 6 bits
3094 -> 110000_010110
Lo partimos a la mitad y ponemos los bytes más significativos en el byte de inicio.
Creamos el primer byte a partir del primer paquete de 6 bits
(inicio y positivo) 10_110000
enviamos el primer byte
Creamos el segundo byte a partir del segundo paquete de 6 bits
(final y positivo ) 00_010110
enviamos el segundo byte
Por ejemplo, si las lecturas en el programa de Java fueran:
0001_0110
1011_0000
0001_0110
Podemos descartar el primer byte ya que es un final y no sabemos con qué número inició.
Reordenando el segundo número para darle facilidad visual:
1011_0000 0001_0110
10_110000 00_010110
El primer bit de ambos bytes lo podemos descartar ya que sabemos qué orden tiene el número
0_11000 0_010110
Si el número de inicio es 1 el número será negativo. Si es 0 será positivo.
+ 11000_010110
Convertimos a decimal:
+ (3094) = 3094
¡Funciona!
Ya con esta información, en el programa de Java podremos interpretar cada número con únicamente dos bytes por número. ¡Mucho mejor que el protocolo inicial de cifrado!
Es verdad que tendremos una redundancia con el signo, como no tenemos un uso especial para ese bit y únicamente simplificará el código del cifrado no debemos preocuparnos demasiado por él.
Entonces, ¿cómo quedaría el cifrado en código?
Esta parte ya no es de diseño sino de ingeniería de código. Dada la naturaleza de nuestro algoritmo (en el que vamos a jugar mucho con bits a nivel bajo) es posible que sea un poco difícil de entender qué está pasando, sin embargo, ya con el conocimiento de qué queremos hacer podemos ir paso a paso.
Recordemos que hay que codificar un número binario de dos bytes a un número en nuestra codificación.
Vamos a tener que hacer uso de una serie de mañas de C ya que no tenemos la posibilidad de agregar código ensamblador con el cual hacer estas manipulaciones de bytes.
Comencemos con un ejemplo, el ESP-32 lee el valor en A de 223 y el valor en B de 3359:
Tenemos que conocer el valor de la resta (el cual será negativa en este caso)
223 - 3359 = -3136
Para aquellos con buen ojo se habrán dado cuenta de un detalle que no hemos tocado. El número negativo se representará como un número en su complemento a dos. Yo sé que puede parecer un problema pero no lo es. Es por esto que es tan importante mandar el signo del número desde la ESP-32. Vamos a tratar en el código de Java este tema en concreto.
La representación del número -3136 en binario (aunque parezca raro, justo por el complemento a dos) es la siguiente:
1111_0011 1100_0000
El número que nos interesa es el de los primeros 12 bits y sabemos que el número es negativo y los primeros cuatro unos aparecen únicamente por la representación en 16 bits del número. Sabemos que el número será negativo si y solo si los primeros cuatro bits son todos 1. Esto se debe al llamado "desbordamiento" al momento de hacer restas que vayan por debajo del cero. Lo que vamos a hacer va a ser aprovechar este desbordamiento para encontrar el signo del número que vamos a enviar de la ESP-32 a la computadora.
Entonces, si vemos el número "desbordado" en 16 bits dado que nuestro número es uno de 12 bits, sabremos que el número en cuestión es un número negativo.
if (x > 4096){
//El número es negativo
//Por la cadena de 1111
//1111_xxxx_xxxx_xxxx
}
Sin embargo, ni siquiera es necesario hacer la condicional, podemos diréctamente poner ese 1 como el valor que nos interesa en el número de 16 bits ya cifrado.
Entonces:
- Usaremos dos bytes, vacíos, a esos dos bytes les pondremos los doce bits marcados con xxxx_xxxx_xxxx.
- Agregaremos el signo, que como 1 es nuestro negativo, podemos escribirlo diréctamente de los 1111 desde el desbordamiento.
- Limpiamos la cola de 1111 del primer byte y agregamos los 6 bits más significativos al primer byte del paquete y los menos significativos al segundo byte del paquete
- Etiquetamos con un 1 el primer bit para el primer byte
- Etiquetamos con un 0 el primer bit del segundo byte (cosa que podemos no hacer porque el número inicia siendo puros ceros).
- Imprimimos como bytes (NO COMO TEXTO) de la ESP-32 a la computadora
Paso a pasito, para no perdernos en la manipulación de bits:
//El número de pin en que esperaremos leer los valores de voltaje
#define pin_ADC_A 4
#define pin_ADC_B 15
//Usaremos short que es una estructura de 16 bits
short lectura_ADC_A = 0b0;
short lectura_ADC_B = 0b0;
short resta_ADC = 0b0;
//Estas serán las variables que enviaremos por medio del serial
byte num_codificado_primero = 0b0;
byte num_codificado_segundo = 0b0;
void setup() {
Serial.begin(921600);
}
void loop() {
//Inicializamos los valores de nuestros bytes
//Podemos poner el etiquetado del primero byte de una vez
num_codificado_primero = 0b10000000;
num_codificado_segundo = 0b00000000;
lectura_ADC_A = analogRead(pin_ADC_A);
lectura_ADC_B = analogRead(pin_ADC_B);
resta_ADC = lectura_ADC_A - lectura_ADC_B;
//Recordemos que resta_ADC es un número de 16 bits
//Los números codificados son de un solo byte
//Por esto tenemos que recorrerlo un byte
//Para tener acceso a la cola de 1111
//Aquí estamos sumando el segundo 1, 0100
//Por eso aplicamos una máscara lógica &&
num_codificado_primero += ((resta_ADC >> 8) && 0b01000000);
num_codificado_segundo += ((resta_ADC >> 8) && 0b01000000);
//Aplicando otra máscara lógica sumamos los 6 bits
//Tenemos que recorrer 6 lugares para sumar al primer byte
// ssss_xxxx_xxxx_xxxx -> ssss_xxxx_xxxx_xxxx
// isxx_xxxx -> is_xxxx_xx
num_codificado_primero += ((resta_ADC >> 6) && 0b00111111);
//Tenemos que recorrer 0 lugares para sumar al primer byte
// ssss_xxxx_xxxx_xxxx -> ssss_xxxx_xxyy_yyyy
// isxx_xxxx -> isxx_xxxx
num_codificado_segundo += (resta_ADC && 0b00111111);
Serial.print("Resta:");
Serial.println(resta_ADC);
Serial.print("1ero:");
Serial.println(num_codificado_primero);
Serial.print("2ndo");
Serial.println(num_codificado_segundo);
}
Solo para revisar rápidamente en el monitor serial si se está enviando correctamente nuestro número, podemos usar Serial.print() para revisar los valores enviados. En todos las implementaciones ya funcionales usaremos Serial.write() además ya no imprimiremos las banderas de "Resta, 1ero, 2nd".
Entonces, ¿qué obtenemos?
Si conectamos ambas terminales (positiva y negativa) al mismo voltaje de tierra, vamos a obtener un resultado más o menos como el mostrado. Interpretémoslo:
El primer valor que estamos imprimiendo es la resta de los valores del ADC_A - ADC_B, recordemos que estos son números entre 0 -> 4095 por lo que valores como -31 son en realidad bastante cercanos al cero. Esto es congruente con la lectura que estamos haciendo de la tierra, esperaríamos un valor leído de 0, sin embargo, por ruido generado por el efecto de antena de los cables así como errores en los resistores con los que estamos trabajando también traerá un poco de desviación. (Además recordemos que estamos trabajando con un circuito diseñado con un 1% de error).
Los segundos valores son "1ero" que es el valor del primer byte, en el cual vamos 255 para todos los mostrados en la captura. El tercer valor impreso es "2ndo" que es el segundo byte de nuestro paquete, este oscila entre 97 y 91 en la captura, pero para ser congruentes con la lectura de la "resta" vamos a usar la primera lectura:
Resta: -38
1ero : 255
2ndo : 90
Esta información es suficiente como para saber si nuestra codificación fue correcta, veamos. Tendremos que hacer la traducción de -38 a dos bytes 255_90 o esos dos byte a -38. Considero que es más fácil hacerlo de los dos bytes a -38, en especial por el hecho de que el número es negativo, y eso es algo que tenemos que explicar rápidamente (como decíamos más arriba con el complemento a 2).
255 = 1111_1111
90 = 0101_1010
El orden de los dos bytes es correcto (como vemos por el 1 y 0 en los primeros bits) También sabemos que el número que estamos leyendo es negativo (como vemos en el segundo bit 1 de ambos bytes)
1ero = 11_1111
2ndo = 01_1010
Juntos = 11_1111_01_1010 -> 1111_1101_1010
Este número es 4058 en binario. Sin embargo, hay que recordar que el número es negativo, esto significa que en realidad estamos trabjando con el complemento a 2 del número en una representación de 12 bits.
Si sacamos el complemento a 2 de este número obtendremos el valor real del número pero tendremos que considerarlo negativo:
1111_1101_1010 -> 0000_0010_0101 + 1 -> 0000_0010_0110 = 38
Con su signo menos: -38
Así que, en efecto, hemos enviado correctamente el mensaje. Ya que sabemos que nuestro código es funcional, podemos cambiar el Serial.print() por Serial.write() y obviar los comentarios de "Resta, 1ero, 2ndo":
Código final
//El número de pin en que esperaremos leer los valores de voltaje
#define pin_ADC_A 4
#define pin_ADC_B 15
//Usaremos short que es una estructura de 16 bits
short lectura_ADC_A = 0b0;
short lectura_ADC_B = 0b0;
short resta_ADC = 0b0;
//Estas serán las variables que enviaremos por medio del serial
byte num_codificado_primero = 0b0;
byte num_codificado_segundo = 0b0;
void setup() {
Serial.begin(921600);
}
void loop() {
//Inicializamos los valores de nuestros bytes
//Podemos poner el etiquetado del primero byte de una vez
num_codificado_primero = 0b10000000;
num_codificado_segundo = 0b00000000;
lectura_ADC_A = analogRead(pin_ADC_A);
lectura_ADC_B = analogRead(pin_ADC_B);
resta_ADC = lectura_ADC_A - lectura_ADC_B;
//Recordemos que resta_ADC es un número de 16 bits
//Nosotros trabajamos con dos bytes separados
//Por esto tenemos que recorrer el entero 8 bits
//Así tenemos acceso a la cola de desbordamiento 1111 (ó 0000)
//Aquí estamos sumamos el segundo bit, donde va el signo
//Por eso aplicamos una máscara lógica & 0b0100
num_codificado_primero += ((resta_ADC >> 8) & 0b01000000);
num_codificado_segundo += ((resta_ADC >> 8) & 0b01000000);
//Aplicando otra máscara lógica sumamos los 6 primeros bits
//Tenemos que recorrer 6 lugares para sumar al primer byte
// ssss_xxxx_xxxx_xxxx -> ssss_xxxx_xxxx_xxxx
// isxx_xxxx -> is_xxxx_xx
num_codificado_primero += ((resta_ADC >> 6) & 0b00111111);
//Tenemos que recorrer 0 lugares para sumar al primer byte
// ssss_xxxx_xxxx_xxxx -> ssss_xxxx_xxyy_yyyy
// isxx_xxxx -> isxx_xxxx
num_codificado_segundo += (resta_ADC & 0b00111111);
/*
Serial.print("Resta:");
Serial.println(resta_ADC);
Serial.print("1ero:");
Serial.println(num_codificado_primero);
Serial.print("2ndo:");
Serial.println(num_codificado_segundo);
*/
Serial.write(num_codificado_primero);
Serial.write(num_codificado_segundo);
Si intentamos leer los valores mostrados con el código usando Serial.write() no podremos ver ningún valor con sentido en la terminal de Arduino, sin embargo, en CoolTerm o en HTerm sí podremos ver los valores si usamos el modo de visualización en hexadecimal o en binario.
Veremos algo del siguiente tipo:
Aquí, tal como en el análisis que hicimos previamente, podemos ver que hay muchos bytes del tipo "FF" o sea 255 en decimal, que son casi el mismo byte que interpretamos previamente. Esta aplicación está hecha para visualizar el valor de los bytes y no interpretarlos en el cifrado que nosotros diseñamos. Es por eso que el paso subsecuente es interpretar estos valores a una alta velocidad y graficarlos.
Para esto, exploraremos las posibilidades en la siguiente sección de esta capítulo.
Diseños subsecuentes
Si usted lector se siente suficientemente aventurado como para realizarle cambios a este protocolo de comunicación, si por casualidad contara con una tarjeta con una resolución más alta en su ADC y quisiera aprovecharla, aquí dejaré una seried e recomendaciones las cuales espero le sirvan de algo.
A manera de recapitulación, comenzamos con el código más sencillo posible (el que planteamos en el capítulo de diseño) y lo usamos como plantilla para generar este código nuevo. Es una buena idea comenzar con algo ineficiente y malo como plantilla para optimizar y optimizar hasta llegar a un resultado que uno considere suficientemente bueno.
Dado que estamos trabajando con el análisis de señales le recomiendo enormemente que no le tema a la manipulación de los valores binarios en los bytes. C no es el lenguaje más amable al momento de permitirnos estas manipulaciones pero es suficientemente bueno y claro si uno mantiene notas y comentarios como los fuimos haciendo durante nuestro desarrollo.
En caso de tener una tarjeta de mayor resolución de ADC (pensemos que fuera de 16-bits) lo más probable es que tenga que agregar un tercer byte como bandera de inicio/fin de paquetes de datos. La realidad es que es de suma importancia escribir un protocolo de comunicación claro al momento de leerlo. Como se dio cuenta, al momento de leerlo hay ciertos datos que inmediatamente se pueden descartar porque su significado se puede interpretar solo con verlo sin requerir de manipulación posterior.
Evite el uso de condicionales y codificaciones de terceros, después de todo, nadie conoce mejor la implementación del dispositivo que el propio diseñador y el diseñador tiene que estar dispuesto a optimizar su trabajo hasta las últimas consecuencias, no importa si tiene que descartar la tecnología más usada para encontrar algo que se acople mejor a sus necesidades.