Cómo hacer que tus periféricos binarios parpadeen (casi) sin tu intervención: una clase en C++

Cómo hacer que tus periféricos binarios parpadeen (casi) sin tu intervención: una clase en C++

(Read this article in english here.)

Introducción

Todos hemos diseñado sistemas en los cuales tenemos varios periféricos binarios (LEDs, relevadores, búzers, etc) que deben activarse y desactivarse de manera independiente y bajo condiciones diferentes. 

Por ejemplo, un circuito con dos LEDs, uno rojo y uno azul, un relevador y un búzer. 

  • El LED rojo debe parpadear de manera continua con un tiempo de 500ms encendido, y un tiempo de 500ms apagado.
  • El LED azul debe parpadear con un tiempo de 50ms encendido y un tiempo de 2950ms apagado, durante 10 ciclos cada vez que llega un dato al puerto serial.
  • El relevador debe activarse por una vez durante 10 segundos cuando el algoritmo lo indique.
  • Y en caso de alguna falla del sistema, el búzer deberá activarse durante 1000ms y desactivarse durante 4000ms y repetir el ciclo hasta que el usuario presione un cierto botón.

Si esta temporización ya es en sí misma compleja, imagina que la tienes que implementar dentro del (horrible) súper loop de Arduino, ¡las cosas se ponen peor!

¿Lo podemos resolver? Por supuesto que sí utilizando una clase de C++, la cual tomará el control del periférico una vez que el objeto ha sido configurado.

En varios proyectos que he estado realizando en Arduino (revisa mi blog para que los conozcas) me encontré con el problema recién descrito, y pretender controlar cada periférico binario por separado hubiera resultado en un desastre.

La mejor solución es utilizar un sistema operativo en tiempo real, como FreeRTOS, y sacar provecho de los timers por software que éste incluye. Sin embargo, son pocos los usuarios de Arduino que lo conocen y lo usan, y yo quería una solución al alcance de la mayoría de los entusiastas electrónicos, y fue así que se me ocurrió escribir una clase simple en C++ fácil de usar y reutilizable entre diferentes plataformas.

Con esta clase podrás:

  • Establecer la polaridad del periférico binario, es decir si es activo en alto o en bajo.
  • Programar al periférico binario para que se active y desactive una sóla vez.
  • Programar al periférico binario para que se active y desactive una cierta cantidad de ciclos, con tiempos de encendido y apagado independientes. Y siempre podrás detenerlo bajo el control de una instrucción.
  • Programar al periférico binario para que se active y desactive en un ciclo infinito, con tiempos de encendido y apagado independientes. Y siempre podrás detenerlo bajo el control de una instrucción.
  • Controlar diversos periféricos de manera independiente, ¡aún dentro del súper-loop de Arduino!

Sigue leyendo para platicarte más sobre esta versátil clase.

¿Cómo funciona?

El corazón de la clase que te voy a presentar es una máquina de estados finita (MEF) sencilla (dos estados) cuyo comportamiento depende de la forma en que hayas programado al periférico. Una vez hecha la programación y haber habilitado al periférico, la MEF se encargará del resto, permitiendo que tu programa haga trabajo útil y que no se preocupe por los detalles.

La siguiente imagen muestra una representación de la MEF como diagrama de flujo:

MEF como diagrama de flujo.

Y este es el código actual de la MEF:

   void Blink::state_machine()
   {
      if( this->running )
      {
         switch( this->state )
         {
            case 0:
               --this->ticks;
               if( this->ticks == 0 )
               {
                  write( this->pin, static_cast<uint8_t>(this->polarity) ^ PIN_LOW );

                  if( this->mode == eMode::REPETITIVE or this->mode == eMode::FOREVER )
                  {
                     this->ticks = this->ticks_offMOD;
                     this->state = 1;
                  }
                  else
                  {
                     this->running = false;
                  }
               }
               break;

            case 1:
               --this->ticks;
               if( this->ticks == 0 )
               {
                  if( this->mode == eMode::REPETITIVE )
                  {
                     --this->times;
                     if( this->times == 0 )
                     {
                        this->running = false;
                     }
                  }

                  if( this->running )
                  {
                     this->state = 0;
                     this->ticks = this->ticks_onMOD;

                     write( this->pin, static_cast<uint8_t>(this->polarity) ^ PIN_HIGH );
                  }
               }
               break;
         }
      }
   }

NOTA: Debo aclararte que las MEFs (es una por periférico) deben llamarse a intervalos regulares y constantes de tiempo. Si tu sistema cuenta con una interrupción (ISR) por tiempo (de algún timer del microcontrolador), entonces ahí puedes colocar las MEFs; si estás usando el horrible súper-loop de Arduino, deberás colocar las MEFs como te lo voy a indicar más adelante.

Como un valor agregado de esta clase, y en general, de la ingeniería de software, deberás comenzar a pensar en la programación dirigida por eventos (event-driven programming), lo cual es una técnica muy útil y mucho mejor de lo que suena, y que afortunadamente no interferirá con tu forma habitual de programar.

Y una buena noticia para que dejes de preocuparte: tú NO tienes que programar las MEFs, éstas ya es parte de la propia clase; sólo tienes que colocarlas en el lugar adecuado y llamarlas en el momento adecuado. 

MEFs en una ISR

Por ejemplo, si tienes acceso an una ISR de tiempo de tu microcontrolador podrías hacer lo siguiente:

ISR( Timer1 )
{
   lcd_backlight.state_machine();
   led13.state_machine();
   buzzer.state_machine();
}

Observa que la ISR (hipotética) está controlando 3 periféricos binarios a través de la MEF de cada uno. Así de simple es.

MEFs en el súper-loop de Arduino

Si estás usando Arduino, entonces deberás colocar un tick y llamar a las MEFs cada vez:

void loop()
{
   static unsigned long last_time = 0;
   unsigned long now = millis();
   if( ( now - last_time >= ( SYSTEM_TICK ) ) )
   {
      last_time = now;
      led13.state_machine();      
      buzzer.state_machine();      
      relay.state_machine();
   }
}

Más adelante te mostraré un ejemplo completo utilizando esta técnica.

Una vez que conocimos el corazón de la clase vamos a ver sus métodos públicos:

class Blink
{
public:
   enum class eMode: uint8_t { ONCE, REPETITIVE, FOREVER };
   enum class ePolarity: uint8_t { ACTIVE_HIGH, ACTIVE_LOW };

   Blink();

   Blink& operator=(Blink&) = delete;
   Blink(Blink&) = delete;

   void begin( uint8_t pin );
   void set( eMode mode, uint16_t ticks_on, uint16_t ticks_off = 0, uint16_t times = 1);
   void start();
   void stop();
   void state_machine();
   bool is_running();
   static Output_fn write;

private:
   // private members are not relevant in this moment
};

Cómo se usa

1 Vinculando el software con el hardware

La clase Blink es independiente de cualquier plataforma y microcontrolador, por lo que es necesario inyectarle una función que active y desactive al periférico asociado. Esta función es única para todo el sistema (en otras palabras, compartida por todos los objetos), ya que estamos suponiendo que todos los periféricos se activan y desactivan de la misma forma.

1. La función que tienes que inyectar debe tener la siguiente firma, la cual es la misma que la de la función digitalWrite() del Arduino UNO:

void digitalWrite( uint8_t pin, uint8_t pin_state );

NOTA: La firma de la función digitalWrite() del Arduino Nano Every es diferente en los tipos de los argumentos y en el ejemplo te muestro cómo solucionarlo (ya que usé esa tarjeta en lugar de la UNO para este artículo). Y esta solución te servirá para cualquier otro micro y plataforma.

2. Una vez que ya tienes tu función para activar y desactivar los periféricos binarios, es momento de realizar la vinculación. A nivel global (fuera de cualquier función) escribe la siguiente instrucción:

Output_fn Blink::write = digitalWrite;

En sus entrañas la clase Blink activa y desactiva al periférico indicado a través de una llamada al método estático write().

NOTA: Los símbolos Output_fn y write deben estar completamente cualificados:

fjrg76::Output_fn fjrg76::Blink::write = digitalWrite;

fjrg76 es el espacio de nombres al que pertenece la clase (con fines de organización). La expresión se simplifica si utilizas las conocidas como using-declarations de C++ (es la forma que utilicé en el ejemplo completo que encontrarás más adelante):

using fjrg76::Blink;
using fjrg76::Output_fn;

Output_fn Blink::write = digitalWrite;

// we also use the using-declarations to declare the objects (more on this later)
Blink led13;
Blink buzzer;
Blink relay;

2 Construyendo los objetos

La creación de los objetos requiere dos pasos siguiendo la filosofía de Arduino (para objetos relacionados con el hardware): 

  1. Crear objetos “vacíos”.
  2. Configurar el hardware para dichos objetos.

¿Recuerdas alguna vez haber declarado a los objeto Serial, Wire o SPI? No es importante ya que el sistema lo hace, sin embargo, cuando quieres utilizar alguno de ellos entonces sí que tienes que llamar a su método .begin() (normalmente en la función setup()) para configurar el respectivo hardware. 

Nosotros haremos lo mismo, aunque por supuesto puedes hacerlo de la manera que más te guste y convenga al sistema que estés desarrollando.

Si has leído mis otros artículos ya sabrás que no soy fanático de las variables globales, pero en el caso del hardware podemos romper las reglas, ya que a veces no hay alternativa. Y hoy es una de esas ocasiones.

Para el ejemplo vamos a suponer un sistema con 3 periféricos binarios: un LED, un relevador, y un búzer, cada uno controlado de manera independiente. El LED es activo en alto, mientras que el relevador y el búzer son activos en nivel bajo.

Setup: Amarillo:led13; Verde:Búzer; Rojo:Relevador.

1. Los objetos “vacíos” son creados con alcance global (es decir, como variables globales):

#include "Blink.hpp" // don't forget to include the header!

// don't also forget that we're using the using-declarations as above:
Blink led13;
Blink buzzer;
Blink relay;

En este punto ningún objeto tiene asociado un periférico (el hardware). Esto lo resolveremos en el siguiente paso.

2. Ahora vamos a asociar cada objeto con un pin del microcontrolador. Esto lo haremos dentro de la función setup() de Arduino, o en cualquier lugar antes de entrar al loop principal del programa (todos los programas embebidos tienen un loop principal) usando a la función .begin():

void setup() 
{
   pinMode( 13, OUTPUT ); // on-board LED       (active high)
   pinMode( 2, OUTPUT );  // simulates a buzzer (active low)
   pinMode( 3, OUTPUT );  // simulates a relay  (active low)

   digitalWrite( 13, LOW );
   digitalWrite( 2, HIGH );
   digitalWrite( 3, HIGH );
   // objects binding:

   led13.begin( 13 );
   // ACTIVE_HIGH by default

   buzzer.begin( 2, Blink::ePolarity::ACTIVE_LOW );
   relay.begin( 3, Blink::ePolarity::ACTIVE_LOW );
 
   // more initialization here . . .
}

El método .begin() toma 2 argumentos:

  1. Un número de pin. Este argumento es obligatorio.
  2. La polaridad del pin. Sirve para indicarle a la clase si el pin se activa con un valor lógico 1 (activo en alto) o con un valor lógico 0 (activo en bajo). Este argumento es opcional; la polaridad por defecto es activo en alto.

Y eso es todo en cuanto a la construcción de los objetos.

IMPORTANTE: Estoy suponiendo que una vez que hemos creado la relación objeto-hardware, ésta no cambiará durante la ejecución del programa (y no hay razón para que lo haga).

3 Instalando las MEFs

Lo siguiente es colocar las MEFs de cada objeto en el lugar indicado, es decir, en una ISR o en un pseudo tick dentro del súper-loop de Arduino.

Para el caso de una ISR de tiempo ya indiqué cómo, por lo que ahora te voy a indicar la forma de hacerlo dentro del súper-loop de Arduino.

Lo primero por hacer es tener una base de tiempo regular y constante, la cual obtenemos con la función millis(), y luego instalar ahí las MEFs:

#define SYSTEM_TICK 10 // in milliseconds
#define MS_TO_TICKS( x ) ( (x) / ( SYSTEM_TICK ) )
void loop()
{
   static unsigned long last_time = 0;
   unsigned long now = millis();
   if( ( now - last_time >= ( SYSTEM_TICK ) ) ) // <- Pseudo tick
   {
      last_time = now;

      led13.state_machine();
      buzzer.state_machine();
      relay.state_machine();
   }

   // Regular Arduino's code:
}

La constante SYSTEM_TICK establece cuántos milisegundos tendrá cada tick (o si se me permite el término, la interrupción) en tu sistema dirigido por tiempo. Este valor tú lo fijas en base a tus necesidades; valores típicos podrían ser: 10ms, 50ms, 100ms. No te recomiendo 1ms en Arduino debido a que el resto del código podría utilizar más de eso y comenzar a desplazar el tiempo. No es malo, pero tampoco bueno. Para el ejemplo de hoy el valor 10ms es perfecto (en otro de mis proyectos lo tengo en 5ms, aquí).

He incluído una fórmula de conversión de milisegundos a ticks del sistema, MS_TO_TICKS(), ya que:

  • Nosotros los humanos nos entendemos mejor en milisegundos, y
  • No tendrás mayores problemas si en algún momento quieres cambiar la base de tiempo.

La variable last_time está marcada como static para que guarde su valor entre llamadas.

Cuando la expresión if( ( now - last_time >= ( SYSTEM_TICK ) ) ) se evalúa como verdadero, indica que ha llegado un tick y que es momento de actualizar las MEFs. Te dije que era muy simple… más o menos, ahora vienen las malas noticias.

Nada es gratis en la vida

Para que esto funcione es necesario que no utilices funciones que se bloqueen, tal como la famosa delay(). delay() acapara la CPU mientras el tiempo transcurre y no permite que ningún otro código realice trabajo útil; esto es, un delay(1000) significaría que las MEFs no serán actualizadas por un lapso de 1s; un delay(10000) significaría que las MEFs no serán actualizadas por un lapso de 10s.

En este artículo te muestro una forma de convertir tus programas a programación dirigida por eventos (tiempo). No me odies, todo es culpa del súper-loop de Arduino. 

Una ISR de tiempo no tiene este problema, ya que precisamente es una interrupción que tomará el control de la CPU y actualizará las MEFs sin importar si alguna función se hubiese apropiado la CPU por mucho tiempo.

4 Configurando y utilizando a los objetos

El siguiente paso es controlar a nuestros periféricos binarios utilizando los métodos de la clase Blink.

1 Ejemplo simple

Quiero comenzar fácil y para ello voy a utilizar al objeto led13 y modificaré la función setup() un poco:

void setup() 
{
   // same as before

   led13.set( Blink::eMode::FOREVER, MS_TO_TICKS( 100 ), MS_TO_TICKS( 900) );
   led13.start();
}

El método .set() toma 4 argumentos:

  1. El modo. Aquí establecemos en cuál de los 3 modos va a trabajar el periférico: 
    1. Blink::eMode::ONCE: Se activa una vez.
    2. Blink::eMode::REPETITIVE: El ciclo se repite n veces.
    3. Blink::eMode::FOREVER: El ciclo se repite de manera indefinida.
  2. El número de ticks que el periférico estará en nivel activo. Te recomiendo utilizar la macro MS_TO_TICKS() para convertir milisegundos a ticks.
  3. El número de ticks que el periférico estará en nivel bajo. Te recomiendo utilizar la macro MS_TO_TICKS() para convertir milisegundos a ticks. No se utiliza en el modo Blink::eMode::ONCE.
  4. El número de repeticiones para el ciclo activo/inactivo (ON/OFF) en el modo Blink::eMode::REPETITIVE. Es optativo y no se utiliza en los otros dos modos (es decir, no tienes que darle un valor en los otros modos, como lo muestra el ejemplo).

En el ejemplo anterior el método .set() ha configurado al objeto led13 para que parpadee por siempre, con un tiempo activo (en estado ON) de 100ms y un tiempo inactivo (en estado OFF) de 900ms.

Después de la configuración es necesario llamar al método .start() para que el ciclo comience. Dicho ciclo lo puedes detener en cualquier momento llamando al método .stop(). Ambas funciones no tienen argumentos y no devuelven nada.

2 Ejemplo más complicado

Este ejemplo es una extensión del anterior agregando los otros dos objetos, buzzer y relay. Se complica un poco, pero no te pierdas en los detalles. Lo que quiero que observes es que los periféricos se controlan de forma independiente:

void setup() 
{
   // same as before . . .

   led13.set( Blink::eMode::FOREVER, MS_TO_TICKS( 100 ), MS_TO_TICKS( 900 ) );
   led13.start();

   buzzer.set( Blink::eMode::REPETITIVE, MS_TO_TICKS( 100 ), MS_TO_TICKS( 100 ), 10 );

   relay.set( Blink::eMode::ONCE, MS_TO_TICKS( 2000 ) );

   // more initializations ...
}

El ciclo del objeto buzzer es de 10 repeticiones, con tiempos 100ms/100ms (ON/OFF) cada 5 segundos (como se muestra a continuación). El objeto relay sólo se activa una vez por 2 segundos cada 7 segundos:

Nota que los objetos buzzer y relay no tienen la instrucción de inicio (método .start()). Éste será llamado en el súper-loop a partir de una temporización compleja.

void loop()
{
   static unsigned long last_time = 0;
   unsigned long now = millis();
   if( ( now - last_time >= ( SYSTEM_TICK ) ) )
   {
      last_time = now;

      led13.state_machine();
      buzzer.state_machine();
      relay.state_machine();


      static uint16_t seconds = MS_TO_TICKS( 1000 );
      --seconds;
      if( seconds == 0 )
      {
         seconds = MS_TO_TICKS( 1000 );


         static uint8_t counter0 = 5; 
         --counter0;
         if( counter0 == 0 )
         {
            counter0 = 5;

            buzzer.start(); //<- Starts the cycle for the buzzer object
         }


         static uint8_t counter1 = 7; 
         --counter1;
         if( counter1 == 0 )
         {
            counter1 = 7;

            relay.start(); //<- Starts the cycle for the relay object
         }
      }
   }


   // Your other Arduino's code:
   
}

5 Usando una función diferente a digitalWrite() del UNO

Como lo mencioné, la función interna a la clase y que interactúa con el hardware utiliza la siguiente firma, la cual la hice coincidir con la de Arduino UNO:

void digitalWrite( uint8_t pin, uint8_t pin_state );

Pero cuando probé la clase con una tarjeta Arduino Nano Every (la cual usa al chip ATMEGA4809) me dí cuenta que no compilaba debido a que la firma de dicha función es diferente a la “genérica”:

Así que tuve que escribir una función adaptadora, my_digitalWrite():

void my_digitalWrite( uint8_t pin, uint8_t pin_state )
{
   digitalWrite( pin, pin_state ); // from any Arduino
}

Y es esta función la que inyecto cuando vinculo el hardware con el software en el ejemplo completo:

Output_fn Blink::write = my_digitalWrite;

Este truco también te sirve cuando vas a usar una plataforma diferente, como las de ST o NXP:

void my_digitalWrite( uint8_t pin, uint8_t pin_state )
{
   Chip_GPIO_SetPinState(LPC_GPIO_PORT, 0, pin, pin_state ); // NXP style
}

NOTA: Habrá que batallar un poco cuando los periféricos que quieras controlar pertenezcan a puertos diferentes al puerto 0 (en el código anterior). Si tienes problemas, házmelo saber en los comentarios; todo se soluciona con una tabla de conversión.

Arduino Nano Every board.

Ejemplo completo

Aquí está enlace a un repositorio Git donde podrás descargar el ejemplo completo, el cual incluye:

  1. El módulo Blink en dos archivos: Blink.hpp y Blink.cpp. No olvides incluir el archivo de encabezado Blink.hpp en cada archivo en el que uses a la clase. Y no olvides tampoco enlazar contra el archivo Blink.cpp cuando ejecutes la instrucción de compilación. Pero no te preocupes, Arduino y muchas otras IDEs ya lo hacen por tí.
  2. El archivo blinking_class.ino el cual es el código de ejemplo en el que basé este artículo y que te muestra todo lo que hoy platicamos. Si usas un sistema diferente a Arduino deberás portar el ejemplo a tu plataforma (es muy sencillo).

El ejemplo está listo para ejecutarse en cualquier Arduino, pero con cambios mínimos podrás utilizarlo en cualquier otra plataforma.

Un ejemplo real del uso de esta clase lo puedes encontrar en este proyecto Cisterna-Tinaco. Utilicé una primera versión de la clase, pero el resultado es el mismo. Te sorprenderás de todo lo que puedes hacer con esta clase. En dicho proyecto busca a los objetos lcd_backlight, led13 y buzzer.

Conclusiones

En varios proyectos en los que estoy trabajando esta clase me sacó de muchos problemas, y de manera afortunada, me ha obligado a escribir código en Arduino utilizando la técnica programación dirigida por eventos, con la cual no estoy peleado pero no había querido utilizar en esta plataforma (esa es mi forma de programar en otras plataformas).

Con lo que te mostré en este artículo es muy fácil que controles dos o más periféricos binarios sin desviarte mucho de la forma en que estás acostumbrado a programar. Y cuando veas los resultados no te la vas a creer.

¿Cuál sería el siguiente paso? Como lo mencioné en la introducción, la mejor solución es utilizar un sistema operativo, pero muchas veces su uso no se justifica o simplemente está más allá de nuestras posibilidades técnicas. Así que antes de tomar esta decisión tan importante considera que si tu sistema te lo permite, entonces inserta las MEFs en una interrupción (ISR) de tiempo.

Si quieres saber más sobre la programación dirigida por eventos temporales en Arduino, revisa este artículo.

Si quieres saber más sobre la utilización del sistema operativo FreeRTOS con Arduino, revisa estos artículos:


Fiverr LinkedIn YouTube Instagram Facebook


¿Ya conoces mi curso gratuito de Arduino en tiempo real, utilizando el Arduino UNO o el Arduino Due y FreeRTOS?


¿Te gustaría ser de los primeros en recibir las actualizaciones a mi blog? ¡Suscríbete, es gratis!


Fco. Javier Rodríguez
Escrito por:

Fco. Javier Rodríguez

Soy Ingeniero Electrónico con 20+ años de experiencia en el diseño y desarrollo de productos electrónicos de consumo y a medida, y 12+ años como profesor. Egresado de la UNAM, también tengo el grado de Maestro en Ingeniería por la misma universidad. Mi perfil completo lo puede encontrar en: https://www.linkedin.com/in/fjrg76-dot-com/

Ver todas las entradas

3 COMENTARIOS