RELOJ DIGITAL DE CUATRO DÍGITOS CON ESP8266: MULTIPLEXADO Y PUESTA EN HORA CON DOS PULSADORES

 

RELOJ DIGITAL DE CUATRO DÍGITOS CON ESP8266: MULTIPLEXADO Y PUESTA EN HORA CON DOS PULSADORES

1. Introducción

En la entrada anterior, Control de dos semáforos mediante Arduino UNO y ESP8266, vimos que un mismo programa puede controlar un circuito sin importar la placa que se utilice, siempre que se asignen correctamente los pines. Allí el programa solo encendía y apagaba seis LED con retardos fijos.

En esta práctica damos un paso más. Construiremos un reloj digital de 24 horas con cuatro displays de siete segmentos y dos puntos separadores que parpadean cada segundo. Además, con dos pulsadores (B1 y B2) podremos poner el reloj en hora.

Aparecen varias ideas nuevas respecto al semáforo:

  • Multiplexado de displays: cuatro displays comparten las mismas siete líneas de segmentos y se encienden de a uno, tan rápido que el ojo los percibe encendidos a la vez.
  • Medición del tiempo con millis(): el reloj sigue contando aunque el programa esté haciendo otras cosas, algo que delay() no permite.
  • Lectura de pulsadores con antirrebote y una pequeña máquina de estados para el modo de configuración.

Igual que en la práctica anterior, para la simulación en Proteus el ESP8266 (módulo NodeMCU V3) se programa como si fuera un Arduino UNO, seleccionando la placa Arduino Uno en el Arduino IDE y cargando el archivo compilado en el modelo de Proteus. Más adelante veremos qué cambios hay que hacer para armar el circuito real en una protoboard con un ESP8266.

2. Descripción del circuito

El circuito está formado por:

  • Cuatro displays de 7 segmentos de cátodo común. Los segmentos a a g de los cuatro displays están unidos entre sí (las mismas siete líneas llegan a todos). El cátodo común de cada display va a tierra a través de un transistor.
  • Cuatro transistores 2N2222 (Q1 a Q4) con una resistencia de 1 kΩ en la base. Cada transistor funciona como interruptor de un display: si su base recibe un nivel alto, el display correspondiente queda conectado a tierra y puede encenderse.
  • Dos LED (D1 y D2) con una resistencia de 220 Ω (R5) que forman los dos puntos del reloj.
  • Dos pulsadores (B1 y B2) conectados a tierra. Al presionarlos, la entrada correspondiente recibe un nivel bajo.
  • El módulo NodeMCU V3 (ESP8266) que controla todo.

La correspondencia entre transistores y dígitos de la hora es:

TransistorEtiquetaDígito que controla
Q4DhDecena de hora
Q3UhUnidad de hora
Q2DminDecena de minuto
Q1UminUnidad de minuto
(sin transistor)UsegDos puntos (D1 y D2)

Importante: el nombre «Useg» (unidad de segundo) se conserva de una versión anterior del circuito. En este reloj la señal Useg no controla un dígito, sino los dos puntos.



2.1. ¿Por qué multiplexar?

Si cada display tuviera sus propias siete líneas, necesitaríamos 4 × 7 = 28 salidas. Con el multiplexado solo necesitamos 7 líneas de segmentos más 4 líneas de selección de dígito, es decir, 11 salidas.

El principio es el siguiente. En cada instante solo un display está encendido:

  1. Se apagan todos los segmentos.
  2. Se colocan en las líneas de segmentos las formas del número que corresponde al primer display.
  3. Se activa solo el transistor de ese display durante unos 3 milisegundos.
  4. Se desactiva ese transistor y se repite el proceso con el segundo display, luego el tercero y el cuarto.

Cada display se enciende 3 ms de cada 12 ms, es decir, unas 83 veces por segundo. Esa rapidez, junto con la persistencia de la visión del ojo humano, hace que veamos los cuatro números encendidos y estables.

3. Funcionamiento: cómo se pone en hora

Con el reloj en marcha normal, los dos puntos parpadean cada segundo. Los pulsadores funcionan así:

SituaciónPulsadorResultado
Funcionamiento normalB1Entra al modo de configuración. Parpadea la decena de hora (Dh).
Funcionamiento normalB2No hace nada.
Configurando (parpadea un dígito)B2Aumenta en 1 el dígito que parpadea. Al llegar a su máximo vuelve a 0.
ConfigurandoB1Pasa al siguiente dígito: Dh → Uh → Dmin → Umin. Tras Umin, sale del modo de configuración.

Durante la configuración, el reloj se detiene y los dos puntos permanecen encendidos fijos, para que el parpadeo del dígito en edición se distinga bien. Al salir, los segundos vuelven a cero y el reloj reanuda la cuenta. Por eso conviene presionar B1 por última vez en el instante exacto en que se desea comenzar.

Los rangos de cada dígito son:

DígitoRangoObservación
Dh (decena de hora)0 a 2Si Dh pasa a 2 y Uh era mayor que 3, Uh se corrige a 3 (así nunca aparece 24 a 29).
Uh (unidad de hora)0 a 9, o 0 a 3 si Dh = 2El máximo depende de Dh.
Dmin (decena de minuto)0 a 9 (ver nota)Se recomienda limitarla a 0 a 5. Ver la sección 10.
Umin (unidad de minuto)0 a 9

4. Asignación de pines en la simulación (Proteus)

Para la simulación se programa el módulo NodeMCU como un Arduino UNO. En el modelo de Proteus las etiquetas D0 a D9 del módulo responden como los pines digitales 0 a 9 del programa. Los cuatro pines de la fila superior a los que se conectaron Dh, Uh, B1 y B2 responden en el programa como los pines analógicos A0 a A3, usados aquí como entradas y salidas digitales.

Señal del circuitoPin en el programa (Arduino UNO)Función
a9Segmento a
b8Segmento b
c7Segmento c
d6Segmento d
e5Segmento e
f4Segmento f
g3Segmento g
DhA0Base de Q4 (decena de hora)
UhA1Base de Q3 (unidad de hora)
Dmin1Base de Q2 (decena de minuto)
Umin2Base de Q1 (unidad de minuto)
Useg0Dos puntos
B1A2Pulsador de configuración
B2A3Pulsador de incremento

Los pines 0 y 1 de un Arduino UNO real son RX y TX del puerto serie. En la simulación no hay problema porque el programa no usa Serial.

5. Asignación de pines para armar el circuito real con ESP8266

En una protoboard con un NodeMCU real no se pueden usar los números del programa tal cual, por tres motivos:

  • El ESP8266 no tiene los pines D0 a D9 del Arduino UNO. Tiene sus propios GPIO, y la etiqueta impresa en la placa (D0, D1, ...) no coincide con el número GPIO.
  • El pin A0 del ESP8266 es la entrada del conversor analógico: solo sirve como entrada y no puede entregar un nivel alto o bajo. Por eso el programa de la simulación (que usa A0 como salida para Dh) no puede cargarse sin cambios en la placa real.
  • El ESP8266 trabaja a 3,3 V y algunos pines tienen funciones especiales durante el arranque (GPIO0, GPIO2 y GPIO15).

Este circuito necesita 12 salidas digitales y 2 entradas, es decir 14 líneas. El NodeMCU ofrece 13 pines digitales utilizables y el pin analógico A0. La solución que se propone es usar A0 como entrada del pulsador B2 (leyéndolo con analogRead()) y aprovechar todos los demás pines. Esto obliga a usar algunos pines delicados (RX, TX, SD2 y SD3), pero en este circuito resultan seguros: los usamos para las líneas de segmentos, que pasan por una resistencia y un LED y no tienen corriente mientras ningún transistor esté conduciendo.

SeñalPin en la simulación (Arduino UNO)Etiqueta en el NodeMCUGPIOObservación
a9D0GPIO16
b8D4GPIO2Comparte el LED azul de la placa. Es normal que parpadee al arrancar.
c7D8GPIO15Debe estar en nivel bajo al arrancar. Con la resistencia y el LED en serie no hay problema.
d6RXGPIO3No usar Serial en este programa.
e5TXGPIO1Puede parpadear un instante al arrancar. No usar Serial.
f4SD2GPIO9En el Arduino IDE configurar Flash Mode en DIO.
g3SD3GPIO10En el Arduino IDE configurar Flash Mode en DIO.
Dh (base de Q4)A0D5GPIO14
Uh (base de Q3)A1D6GPIO12
Dmin (base de Q2)1D7GPIO13
Umin (base de Q1)2D1GPIO5
Useg (dos puntos)0D2GPIO4
B1A2D3GPIO0Pulsador a GND. Ya tiene resistencia de pull-up en la placa. Se puede usar INPUT_PULLUP.
B2A3A0ADCPulsador a GND con una resistencia externa de 10 kΩ a 3,3 V. Se lee con analogRead().

¿Por qué las bases de los transistores no van en D3 ni en D4? Las resistencias de 1 kΩ de las bases llevarían el pin cerca de 0,7 V en el arranque. GPIO0 y GPIO2 deben estar en nivel alto al encender la placa; si no lo están, el ESP8266 entra en modo de grabación o no arranca. Por eso esas dos líneas se reservaron para un segmento y para el pulsador.

Los cambios exactos que deben hacerse al programa se indican en la sección 9.

5.1. Detalles de hardware para la versión real

  • Resistencias en los segmentos: en el simulador no se necesitan, pero en el circuito real cada línea de segmento debe llevar una resistencia de 220 Ω en serie antes de llegar a los displays. Sin ellas se dañan los LED y el ESP8266.
  • Dos puntos: dos LED rojos en serie necesitan cerca de 3,6 V y el pin del ESP8266 entrega 3,3 V, por lo que no encenderían. En la versión real conecte cada LED con su propia resistencia de 330 Ω, en paralelo entre sí, desde el pin Useg hacia GND.
  • Transistores: con 3,3 V y una resistencia de base de 1 kΩ la corriente de base es de unos 2,6 mA, suficiente para que el 2N2222 conduzca la corriente de los segmentos de un display.
  • Tierra común: las tierras del ESP8266, los transistores y los pulsadores deben unirse.

6. Flujograma general del programa

El siguiente flujograma muestra el programa completo. Los bloques con doble contorno son subrutinas (funciones) que se detallan en flujogramas independientes. Los colores y las letras (Parte A a F) se mantienen en toda la explicación para ubicar cada sección del código.


Figura 1. Flujograma general del programa.

ParteColorQué haceFunción en el código
AAzulConfigura los pinessetup()
BNaranjaLee los pulsadoresleerBotones() y pulsado()
CVerdeHace avanzar la horaactualizarReloj()
DVioletaPrepara lo que se mostraráPrimera mitad de loop()
EVioletaEnciende los displays por turnosCiclo for final de loop(), mostrarNumero() y apagarSegmentos()
FRojoModifica un dígito en la puesta en horaincrementarDigito()

7. Código fuente completo (versión para simulación en Proteus)

Este es el programa que funcionó en el simulador. En el Arduino IDE se selecciona la placa Arduino Uno, se compila, y el archivo .hex generado se carga en las propiedades del módulo NodeMCU de Proteus.

// RELOJ DIGITAL 4 DISPLAYS + DOS PUNTOS + AJUSTE DE HORA
// ESP8266 programado como Arduino UNO - Proteus
// Displays 7 segmentos CATODO COMUN, multiplexados con 2N2222

// ---------- SEGMENTOS ----------
const int SEG_A = 9;
const int SEG_B = 8;
const int SEG_C = 7;
const int SEG_D = 6;
const int SEG_E = 5;
const int SEG_F = 4;
const int SEG_G = 3;

// ---------- DIGITOS (HIGH = encendido) ----------
const int DIG_DH   = A0;  // Decena de hora   (Q4)
const int DIG_UH   = A1;  // Unidad de hora   (Q3)
const int DIG_DMIN = 1;   // Decena de minuto (Q2)
const int DIG_UMIN = 2;   // Unidad de minuto (Q1)
const int DOTS     = 0;   // Dos puntos       (Useg)

// ---------- BOTONES ----------
const int BTN1 = A2;      // B1: entrar / avanzar / salir de configuracion
const int BTN2 = A3;      // B2: incrementar el digito que parpadea

// ---------- LIMITES ----------
const int MAX_DH   = 2;   // Decena de hora: 0..2 (permite 20-23)
const int MAX_DMIN = 9;   // Decena de minuto: 0..9 (usa 5 para solo 0..59)

const int segPins[7] = {SEG_A, SEG_B, SEG_C, SEG_D, SEG_E, SEG_F, SEG_G};
const int digPins[4] = {DIG_DH, DIG_UH, DIG_DMIN, DIG_UMIN};

// Tabla 0-9, bits: gfedcba
const byte numeros[10] = {
  0x3F, 0x06, 0x5B, 0x4F, 0x66,
  0x6D, 0x7D, 0x07, 0x7F, 0x6F
};

// ---------- HORA ----------
int horas    = 12;
int minutos  = 0;
int segundos = 0;
unsigned long ultimoSegundo = 0;

// ---------- MODO CONFIGURACION ----------
// 0 = normal, 1 = Dh, 2 = Uh, 3 = Dmin, 4 = Umin
int modo = 0;

// ---------- ANTIREBOTE ----------
bool b1Previo = HIGH, b2Previo = HIGH;
unsigned long b1Tiempo = 0, b2Tiempo = 0;
const unsigned long REBOTE = 30;

void setup() {
  for (int i = 0; i < 7; i++) {
    pinMode(segPins[i], OUTPUT);
    digitalWrite(segPins[i], LOW);
  }
  for (int i = 0; i < 4; i++) {
    pinMode(digPins[i], OUTPUT);
    digitalWrite(digPins[i], LOW);
  }
  pinMode(DOTS, OUTPUT);
  digitalWrite(DOTS, LOW);

  pinMode(BTN1, INPUT_PULLUP);
  pinMode(BTN2, INPUT_PULLUP);
}

void mostrarNumero(int n) {
  byte patron = numeros[n];
  for (int i = 0; i < 7; i++) {
    digitalWrite(segPins[i], (patron >> i) & 1);
  }
}

void apagarSegmentos() {
  for (int i = 0; i < 7; i++) digitalWrite(segPins[i], LOW);
}

// Devuelve true una sola vez por cada pulsacion (flanco a LOW)
bool pulsado(int pin, bool &previo, unsigned long &tiempo) {
  bool lectura = digitalRead(pin);
  bool evento = false;
  if (lectura != previo && (millis() - tiempo) > REBOTE) {
    tiempo = millis();
    previo = lectura;
    if (lectura == LOW) evento = true;
  }
  return evento;
}

void actualizarReloj() {
  if (modo != 0) return;               // En configuracion el reloj se detiene
  if (millis() - ultimoSegundo >= 1000) {
    ultimoSegundo += 1000;
    segundos++;
    if (segundos >= 60) {
      segundos = 0;
      minutos++;
      if (minutos >= 60) {
        minutos = 0;
        horas++;
        if (horas >= 24) horas = 0;
      }
    }
  }
}

void incrementarDigito() {
  int dh   = horas / 10;
  int uh   = horas % 10;
  int dmin = minutos / 10;
  int umin = minutos % 10;

  switch (modo) {
    case 1:  // Decena de hora
      dh = (dh + 1) % (MAX_DH + 1);
      if (dh == 2 && uh > 3) uh = 3;   // evita 24-29
      break;
    case 2: { // Unidad de hora
      int maxUh = (dh == 2) ? 3 : 9;
      uh = (uh + 1) % (maxUh + 1);
      break;
    }
    case 3:  // Decena de minuto
      dmin = (dmin + 1) % (MAX_DMIN + 1);
      break;
    case 4:  // Unidad de minuto
      umin = (umin + 1) % 10;
      break;
  }
  horas   = dh * 10 + uh;
  minutos = dmin * 10 + umin;
}

void leerBotones() {
  if (pulsado(BTN1, b1Previo, b1Tiempo)) {
    modo++;
    if (modo > 4) {                    // Sale de configuracion
      modo = 0;
      segundos = 0;
      ultimoSegundo = millis();        // El reloj reinicia desde :00
    }
  }
  if (pulsado(BTN2, b2Previo, b2Tiempo)) {
    if (modo != 0) incrementarDigito();
  }
}

void loop() {
  leerBotones();
  actualizarReloj();

  int digitos[4] = {
    horas / 10,
    horas % 10,
    minutos / 10,
    minutos % 10
  };

  // Dos puntos: parpadean cada segundo en modo normal, fijos en configuracion
  if (modo == 0) digitalWrite(DOTS, (segundos % 2 == 0) ? HIGH : LOW);
  else           digitalWrite(DOTS, HIGH);

  // Parpadeo del digito en edicion (~300 ms encendido / 300 ms apagado)
  bool parpadeoApagado = (modo != 0) && ((millis() / 300) % 2 == 1);

  // Multiplexado
  for (int i = 0; i < 4; i++) {
    apagarSegmentos();
    bool apagarEste = parpadeoApagado && (i == modo - 1);
    if (!apagarEste) {
      mostrarNumero(digitos[i]);
      digitalWrite(digPins[i], HIGH);
    }
    delay(3);
    digitalWrite(digPins[i], LOW);
  }
}

8. Explicación del código, parte por parte

8.1. Declaración de pines y constantes

Esta sección está al principio del programa y define a qué pin va conectada cada señal.

const int SEG_A = 9;
const int SEG_B = 8;
const int SEG_C = 7;
const int SEG_D = 6;
const int SEG_E = 5;
const int SEG_F = 4;
const int SEG_G = 3;
  • const: indica que el valor no cambiará mientras el programa se ejecuta. Es una buena práctica para los pines porque evita modificarlos por error.
  • int: tipo de dato entero.
  • SEG_A = 9;: crea un nombre (SEG_A) para el número 9. Cuando más adelante el programa dice SEG_A, en realidad se refiere al pin 9, donde está conectado el segmento a.

La ventaja es que si cambia el cableado, solo hay que modificar este número en un lugar y no en todo el programa. Esto es justamente lo que se hará al pasar al ESP8266 real.

const int DIG_DH   = A0;  // Decena de hora   (Q4)
const int DIG_UH   = A1;  // Unidad de hora   (Q3)
const int DIG_DMIN = 1;   // Decena de minuto (Q2)
const int DIG_UMIN = 2;   // Unidad de minuto (Q1)
const int DOTS     = 0;   // Dos puntos       (Useg)

Son los pines que controlan las bases de los transistores (uno por dígito) y el pin de los dos puntos. Un nivel HIGH en la base del transistor lo hace conducir y enciende ese display. A0 y A1 son nombres que Arduino reconoce como pines analógicos, pero aquí se usan como salidas digitales.

const int BTN1 = A2;
const int BTN2 = A3;

const int MAX_DH   = 2;
const int MAX_DMIN = 9;

BTN1 y BTN2 son los pines de los pulsadores B1 y B2. MAX_DH y MAX_DMIN son el valor máximo que puede tomar la decena de hora y la decena de minuto en la puesta en hora.

8.2. Arreglos de pines y tabla de números

const int segPins[7] = {SEG_A, SEG_B, SEG_C, SEG_D, SEG_E, SEG_F, SEG_G};
const int digPins[4] = {DIG_DH, DIG_UH, DIG_DMIN, DIG_UMIN};

Un arreglo es una lista de valores a los que se accede por su posición. Se empieza a contar desde 0:

  • segPins[0] es SEG_A, segPins[1] es SEG_B ... segPins[6] es SEG_G.
  • digPins[0] es la decena de hora, digPins[1] la unidad de hora, digPins[2] la decena de minuto y digPins[3] la unidad de minuto.

Gracias a los arreglos se pueden recorrer los siete segmentos o los cuatro dígitos con un ciclo for en vez de escribir siete o cuatro líneas casi iguales. Además, el orden de digPins es el mismo orden de izquierda a derecha en que aparecen los dígitos del reloj.

const byte numeros[10] = {
  0x3F, 0x06, 0x5B, 0x4F, 0x66,
  0x6D, 0x7D, 0x07, 0x7F, 0x6F
};

Esta es la tabla que dice qué segmentos deben encenderse para dibujar cada número. Cada valor es un número de un byte escrito en hexadecimal (por eso empieza por 0x). Lo importante es verlo en binario: cada bit representa un segmento.

El bit 0 (el de más a la derecha) es el segmento a, el bit 1 es b, y así hasta el bit 6, que es g. El bit 7 no se usa. Por eso se anota «gfedcba»: es el orden de los bits de izquierda a derecha.

NúmeroValor hexadecimalBinario (g f e d c b a)Segmentos encendidos
00x3F0 1 1 1 1 1 1a, b, c, d, e, f
10x060 0 0 0 1 1 0b, c
20x5B1 0 1 1 0 1 1a, b, d, e, g
30x4F1 0 0 1 1 1 1a, b, c, d, g
40x661 1 0 0 1 1 0b, c, f, g
50x6D1 1 0 1 1 0 1a, c, d, f, g
60x7D1 1 1 1 1 0 1a, c, d, e, f, g
70x070 0 0 0 1 1 1a, b, c
80x7F1 1 1 1 1 1 1todos
90x6F1 1 0 1 1 1 1a, b, c, d, f, g

Como la posición 0 de la tabla corresponde al número 0, la posición 7 al número 7, etc., para obtener el patrón del número n basta escribir numeros[n]. Por ejemplo, numeros[1] vale 0x06.

8.3. Variables de la hora, del modo y del antirrebote

int horas    = 12;
int minutos  = 0;
int segundos = 0;
unsigned long ultimoSegundo = 0;
  • horas, minutos y segundos guardan la hora actual. Se inicializa en 12:00:00. Cambie estos valores si desea otra hora de arranque.
  • unsigned long ultimoSegundo guarda el instante (en milisegundos desde que se encendió el sistema) en que se contó el último segundo. Es de tipo unsigned long porque millis() devuelve números muy grandes que no caben en un int.
int modo = 0;

modo es la «memoria» de la máquina de estados:

modoSignificado
0Funcionamiento normal
1Se edita Dh (parpadea la decena de hora)
2Se edita Uh
3Se edita Dmin
4Se edita Umin
bool b1Previo = HIGH, b2Previo = HIGH;
unsigned long b1Tiempo = 0, b2Tiempo = 0;
const unsigned long REBOTE = 30;

Son los datos que necesita el antirrebote. b1Previo y b2Previo recuerdan el estado anterior de cada pulsador (HIGH significa suelto, porque se usa pull-up). b1Tiempo y b2Tiempo recuerdan cuándo ocurrió el último cambio, y REBOTE es el tiempo mínimo (30 ms) que debe pasar para aceptar un nuevo cambio.

8.4. Parte A: la función setup()


Figura 2. Parte A del flujograma general: setup().

setup() se ejecuta una sola vez, al encender el sistema o al iniciar la simulación.

void setup() {
  for (int i = 0; i < 7; i++) {
    pinMode(segPins[i], OUTPUT);
    digitalWrite(segPins[i], LOW);
  }
  • for (int i = 0; i < 7; i++): repite lo que está entre llaves siete veces, con i valiendo 0, 1, 2, ..., 6. Es decir, recorre los siete segmentos.
  • pinMode(segPins[i], OUTPUT);: configura el pin del segmento número i como salida.
  • digitalWrite(segPins[i], LOW);: lo deja inicialmente en nivel bajo, es decir, el segmento apagado.
  for (int i = 0; i < 4; i++) {
    pinMode(digPins[i], OUTPUT);
    digitalWrite(digPins[i], LOW);
  }

Lo mismo para los cuatro pines de los transistores. En LOW los transistores no conducen, por lo que ningún display está encendido al iniciar.

  pinMode(DOTS, OUTPUT);
  digitalWrite(DOTS, LOW);

  pinMode(BTN1, INPUT_PULLUP);
  pinMode(BTN2, INPUT_PULLUP);
}
  • El pin de los dos puntos se configura como salida y se apaga.
  • INPUT_PULLUP configura un pin como entrada con una resistencia interna que lo conecta a nivel alto. Así, con el pulsador suelto el pin lee HIGH y al presionarlo (como está conectado a tierra) lee LOW. No se necesita resistencia externa en el circuito.

8.5. Funciones de la pantalla: mostrarNumero() y apagarSegmentos()

void mostrarNumero(int n) {
  byte patron = numeros[n];
  for (int i = 0; i < 7; i++) {
    digitalWrite(segPins[i], (patron >> i) & 1);
  }
}

Esta función recibe un número n (de 0 a 9) y pone en las siete líneas de segmentos el dibujo de ese número. No enciende ningún display por sí sola: solo prepara las líneas de segmentos. Quien enciende el display es el transistor.

  • byte patron = numeros[n];: busca en la tabla el patrón del número.
  • (patron >> i): desplaza los bits del patrón i posiciones a la derecha, de modo que el bit que interesa queda en la última posición.
  • & 1: se queda solo con ese último bit (un 0 o un 1).
  • digitalWrite(segPins[i], ...): escribe ese 0 o 1 en el pin del segmento. El 1 equivale a HIGH, y el 0 a LOW.

Ejemplo con el número 2 (0x5B = 1011011 en binario). Cuando i vale 0, el bit 0 es 1, entonces el segmento a se enciende. Con i = 1, el bit 1 es 1 y se enciende b. Con i = 2, el bit 2 es 0 y c queda apagado. Así sucesivamente: los segmentos a, b, d, e y g encendidos forman el número 2.

void apagarSegmentos() {
  for (int i = 0; i < 7; i++) digitalWrite(segPins[i], LOW);
}

Pone las siete líneas de segmentos en LOW. Se usa antes de cambiar de dígito para evitar que el dibujo del número anterior se vea en el display siguiente (el efecto «fantasma»).

8.6. Parte B: lectura de pulsadores con antirrebote, función pulsado()

Un pulsador mecánico no pasa limpiamente de suelto a presionado: durante unos milisegundos «rebota» y genera varios cambios rápidos de nivel. Si el programa los contara todos, una sola pulsación podría interpretarse como varias. Esta función los filtra, y además informa una única vez por cada pulsación, sin repetirse mientras el botón se mantenga presionado.


Figura 3. Detalle de la Parte B: función pulsado() con antirrebote.

bool pulsado(int pin, bool &previo, unsigned long &tiempo) {
  bool lectura = digitalRead(pin);
  bool evento = false;
  if (lectura != previo && (millis() - tiempo) > REBOTE) {
    tiempo = millis();
    previo = lectura;
    if (lectura == LOW) evento = true;
  }
  return evento;
}
  • bool pulsado(...): es una función que devuelve un valor lógico, verdadero (true) o falso (false). Verdadero significa «ocurrió una nueva pulsación».
  • int pin: número del pin que se va a leer (BTN1 o BTN2).
  • bool &previo y unsigned long &tiempo: el símbolo & significa que la función recibe la variable original y no una copia. Gracias a eso, cuando la función modifica previo y tiempo, los cambios se guardan en b1Previo y b1Tiempo (o en las de B2).
  • bool lectura = digitalRead(pin);: lee el estado actual del pulsador (HIGH suelto, LOW presionado).
  • bool evento = false;: por defecto se asume que no hubo pulsación nueva.
  • if (lectura != previo && (millis() - tiempo) > REBOTE): pregunta dos cosas a la vez. ¿La lectura es distinta de la anterior? Y ¿pasaron más de 30 ms desde el último cambio aceptado? Solo si ambas son ciertas se acepta el cambio. millis() devuelve los milisegundos transcurridos desde el inicio.
  • tiempo = millis(); y previo = lectura;: se guardan el instante del cambio y el nuevo estado.
  • if (lectura == LOW) evento = true;: solo se informa pulsación cuando el cambio fue a LOW, es decir, al presionar. Cuando el botón se suelta, el cambio es a HIGH y no se informa nada.
  • return evento;: entrega el resultado a quien llamó a la función.

8.7. Parte B: función leerBotones()


Figura 4. Parte B del flujograma general: leerBotones().

void leerBotones() {
  if (pulsado(BTN1, b1Previo, b1Tiempo)) {
    modo++;
    if (modo > 4) {
      modo = 0;
      segundos = 0;
      ultimoSegundo = millis();
    }
  }
  • if (pulsado(BTN1, b1Previo, b1Tiempo)): ejecuta lo que sigue solo si B1 acaba de ser presionado.
  • modo++;: suma 1 a modo. Esta es la acción principal de B1: 0 pasa a 1 (entra a configuración y parpadea Dh), 1 pasa a 2 (parpadea Uh), y así hasta 4.
  • if (modo > 4): si se superó el último dígito, se sale de la configuración. Se vuelve a modo = 0, los segundos se ponen en 0 y ultimoSegundo se actualiza con el instante actual, para que el reloj reanude su cuenta desde cero de forma limpia.
  if (pulsado(BTN2, b2Previo, b2Tiempo)) {
    if (modo != 0) incrementarDigito();
  }
}
  • Si B2 fue presionado, solo se hace algo cuando modo != 0, es decir, cuando se está configurando. Así B2 no tiene ningún efecto en el funcionamiento normal.
  • incrementarDigito() aumenta el dígito que está parpadeando (Parte F).

8.8. Parte C: función actualizarReloj()


Figura 5. Parte C del flujograma general: actualizarReloj().

void actualizarReloj() {
  if (modo != 0) return;
  if (millis() - ultimoSegundo >= 1000) {
    ultimoSegundo += 1000;
    segundos++;
  • if (modo != 0) return;: si se está configurando, la función termina inmediatamente. Por eso el reloj se detiene mientras se pone en hora.
  • if (millis() - ultimoSegundo >= 1000): calcula cuánto tiempo pasó desde el último segundo contado. Si pasó 1000 ms o más, corresponde sumar un segundo.
  • ultimoSegundo += 1000;: avanza el «último segundo contado» exactamente 1000 ms. Se suma 1000 y no se copia millis() para no perder el pequeño retraso acumulado en cada vuelta. Así el reloj no se atrasa.
  • segundos++;: suma un segundo.

Este método reemplaza al delay(1000). Mientras delay() detiene todo el programa, aquí el programa nunca se detiene a esperar: solo consulta la hora de millis() y sigue con sus otras tareas (leer pulsadores y refrescar los displays).

    if (segundos >= 60) {
      segundos = 0;
      minutos++;
      if (minutos >= 60) {
        minutos = 0;
        horas++;
        if (horas >= 24) horas = 0;
      }
    }
  }
}

Son tres condiciones encadenadas, como las ruedas de un odómetro:

  • Si los segundos llegan a 60, se ponen en 0 y se suma un minuto.
  • Si los minutos llegan a 60, se ponen en 0 y se suma una hora.
  • Si las horas llegan a 24, vuelven a 0. Después de 23:59:59 sigue 00:00:00.

8.9. Parte F: función incrementarDigito()


Figura 6. Parte F del flujograma general: incrementarDigito().

void incrementarDigito() {
  int dh   = horas / 10;
  int uh   = horas % 10;
  int dmin = minutos / 10;
  int umin = minutos % 10;

Primero la función separa la hora en sus cuatro dígitos, usando dos operaciones:

  • / 10 es la división entera: da la decena. Por ejemplo, 17 / 10 es 1.
  • % 10 es el resto de la división (módulo): da la unidad. Por ejemplo, 17 % 10 es 7.

Con las 17:45, quedan dh = 1, uh = 7, dmin = 4 y umin = 5.

  switch (modo) {
    case 1:
      dh = (dh + 1) % (MAX_DH + 1);
      if (dh == 2 && uh > 3) uh = 3;
      break;

switch (modo) elige qué bloque ejecutar según el valor de modo. Cada case es una opción, y break hace que termine el switch sin seguir con las otras opciones.

Caso 1 (se edita Dh): dh = (dh + 1) % (MAX_DH + 1) suma 1 y vuelve a 0 al pasarse del máximo. Con MAX_DH = 2 el cálculo es % 3, y los valores sucesivos son 0, 1, 2, 0, 1, 2... Si el nuevo Dh es 2 y la unidad de hora era mayor que 3 (por ejemplo, 19 pasaría a 29), se corrige uh a 3 para que la hora siga siendo válida (23).

    case 2: {
      int maxUh = (dh == 2) ? 3 : 9;
      uh = (uh + 1) % (maxUh + 1);
      break;
    }

Caso 2 (se edita Uh): el máximo de Uh depende de Dh. (dh == 2) ? 3 : 9 es una condición abreviada que significa «si dh es 2, el máximo es 3; si no, es 9». Luego se suma 1 con el mismo recurso del módulo. Las llaves { } del caso permiten declarar la variable local maxUh dentro de un case.

    case 3:
      dmin = (dmin + 1) % (MAX_DMIN + 1);
      break;
    case 4:
      umin = (umin + 1) % 10;
      break;
  }

Casos 3 y 4: lo mismo para la decena y la unidad de minuto. La decena usa el máximo configurado en MAX_DMIN, y la unidad siempre va de 0 a 9 (% 10).

  horas   = dh * 10 + uh;
  minutos = dmin * 10 + umin;
}

Finalmente se recomponen las horas y los minutos a partir de los dígitos ya modificados. Por ejemplo, con dh = 2 y uh = 1, horas pasa a valer 21.

8.10. Partes D y E: la función loop()

loop() se ejecuta continuamente. En cada vuelta (que dura unos 12 ms) hace seis cosas, en este orden:

void loop() {
  leerBotones();
  actualizarReloj();

1) Revisa los pulsadores (Parte B) y 2) actualiza la hora si corresponde (Parte C).

  int digitos[4] = {
    horas / 10,
    horas % 10,
    minutos / 10,
    minutos % 10
  };

3) Separa la hora actual en cuatro dígitos y los guarda en un arreglo. Este arreglo está ordenado igual que digPins: digitos[0] es la decena de hora y se muestra en el display de digPins[0], etc.


Figura 7. Partes D y E del flujograma general: dos puntos, parpadeo y multiplexado.

  if (modo == 0) digitalWrite(DOTS, (segundos % 2 == 0) ? HIGH : LOW);
  else           digitalWrite(DOTS, HIGH);

4) Controla los dos puntos. Si se está en funcionamiento normal (modo == 0), se encienden cuando los segundos son pares (segundos % 2 == 0) y se apagan cuando son impares: parpadean una vez por segundo. En configuración quedan encendidos fijos.

  bool parpadeoApagado = (modo != 0) && ((millis() / 300) % 2 == 1);

5) Calcula si el dígito en edición debe verse apagado en este instante. La variable es verdadera solo si se está configurando (modo != 0) y si (millis() / 300) % 2 es 1. Esa segunda parte vale 0 durante 300 ms, luego 1 durante 300 ms, y así sucesivamente, lo que produce el parpadeo. Todavía no se apaga nada: solo se guarda el resultado.

  for (int i = 0; i < 4; i++) {
    apagarSegmentos();
    bool apagarEste = parpadeoApagado && (i == modo - 1);
    if (!apagarEste) {
      mostrarNumero(digitos[i]);
      digitalWrite(digPins[i], HIGH);
    }
    delay(3);
    digitalWrite(digPins[i], LOW);
  }
}

6) Es el multiplexado. El ciclo recorre los cuatro dígitos (i = 0, 1, 2, 3), y para cada uno:

  • apagarSegmentos();: limpia las líneas de segmentos, para que no quede el dibujo del dígito anterior.
  • bool apagarEste = parpadeoApagado && (i == modo - 1);: este dígito debe quedar apagado solo si estamos en fase de apagado del parpadeo y si es el dígito que se está editando. La relación es modo - 1 porque el modo 1 corresponde a la posición 0 (Dh), el modo 2 a la posición 1 (Uh), y así. En funcionamiento normal modo - 1 vale -1 y ningún i coincide, y además parpadeoApagado es falso.
  • if (!apagarEste): el signo ! significa «no». Si este dígito no debe apagarse, se dibuja su número con mostrarNumero(digitos[i]) y se activa su transistor con digitalWrite(digPins[i], HIGH). Si debe apagarse, simplemente no se enciende su transistor y el display queda en blanco.
  • delay(3);: mantiene ese display encendido 3 ms.
  • digitalWrite(digPins[i], LOW);: desactiva el transistor y pasa al siguiente dígito.

Al terminar los cuatro dígitos, loop() vuelve a empezar. Cada vuelta dura unos 12 ms (4 × 3 ms), por lo que los pulsadores se revisan unas 80 veces por segundo, más que suficiente para que respondan sin retraso perceptible.

9. Cambios para el ESP8266 real

Para el circuito armado en protoboard con el NodeMCU V3 y el Arduino IDE (con el paquete de placas ESP8266 instalado y la placa «NodeMCU 1.0 (ESP-12E Module)» seleccionada), se modifican solo las constantes de pines y la lectura del pulsador B2. La lógica restante del programa no cambia.

Cambio 1. Reemplazar los bloques de pines por los números del NodeMCU de la tabla de la sección 5. En el paquete ESP8266 existen los nombres D0, D1, ..., D8, que se corresponden con las etiquetas impresas en la placa. Para RX, TX, SD2 y SD3 se usan los números GPIO directamente:

// ---------- SEGMENTOS ----------
const int SEG_A = D0;   // GPIO16
const int SEG_B = D4;   // GPIO2
const int SEG_C = D8;   // GPIO15
const int SEG_D = 3;    // GPIO3  (RX)
const int SEG_E = 1;    // GPIO1  (TX)
const int SEG_F = 9;    // GPIO9  (SD2)
const int SEG_G = 10;   // GPIO10 (SD3)

// ---------- DIGITOS (HIGH = encendido) ----------
const int DIG_DH   = D5;  // GPIO14
const int DIG_UH   = D6;  // GPIO12
const int DIG_DMIN = D7;  // GPIO13
const int DIG_UMIN = D1;  // GPIO5
const int DOTS     = D2;  // GPIO4

// ---------- BOTONES ----------
const int BTN1 = D3;      // GPIO0, pulsador a GND
const int BTN2 = A0;      // entrada analogica, pulsador a GND con 10k a 3,3 V

Cambio 2. El pin A0 no admite INPUT_PULLUP ni digitalRead(). Se agrega una pequeña función que lee un pulsador, sea digital o analógico, y se la usa dentro de pulsado():

// Devuelve HIGH (suelto) o LOW (presionado) para cualquier pulsador
bool leerBoton(int pin) {
  if (pin == A0) return analogRead(A0) > 512;
  return digitalRead(pin);
}

Si el pin es A0, lee el valor analógico (0 a 1023). Con el pulsador suelto, la resistencia de 10 kΩ lleva el pin cerca de 3,3 V y la lectura es alta (mayor que 512, se devuelve true, equivalente a HIGH). Al presionarlo, el pin queda en 0 V (se devuelve false, equivalente a LOW). Así el resto del código sigue pensando que «LOW significa presionado».

Esta función debe escribirse antes de pulsado(), y dentro de pulsado() se cambia una sola línea:

bool lectura = leerBoton(pin);   // antes: digitalRead(pin)

Cambio 3. En setup(), la línea pinMode(BTN2, INPUT_PULLUP); se elimina (A0 no la necesita). La línea de BTN1 se deja igual.

Cambio 4. En el circuito real, cada línea de segmento lleva una resistencia de 220 Ω, y los dos puntos se conectan como se describe en la sección 5.1.

Para cargar el programa: seleccione el puerto del NodeMCU, verifique que el Flash Mode (menú Herramientas) esté en DIO, y pulse Subir. Durante la carga puede ser necesario desconectar momentáneamente los cables de los segmentos d y e (RX y TX) si observa errores de comunicación.

10. Observaciones y problemas frecuentes

  • Rango de la decena de minutos: con MAX_DMIN = 9, B2 permite escribir minutos de 60 a 99, que no existen. Si se deja así, el reloj los mostrará hasta el siguiente cambio de minuto y entonces pasará a 00 y sumará una hora. Para evitarlo, use MAX_DMIN = 5.
  • Se ve un solo dígito o dígitos desordenados: revise que cada transistor esté conectado a su pin y que el orden de digPins coincida con el orden de los displays.
  • Segmentos fantasma o brillo desigual: modifique el valor de delay(3) entre 2 y 5 ms. Con valores mayores aparece parpadeo; con valores menores el brillo disminuye.
  • Un solo pulsador responde: en la versión real verifique que B2 en A0 tenga su resistencia de 10 kΩ a 3,3 V.
  • El reloj se adelanta o atrasa: el reloj se basa en el oscilador del microcontrolador y no tiene el cristal de precisión de un reloj real. Es normal una deriva de algunos segundos por día. Para mayor precisión se debería agregar un módulo RTC (por ejemplo, el DS3231).
  • El reloj pierde la hora al apagarse: se reinicia a 12:00 cada vez que se energiza, porque no hay batería ni memoria para guardar la hora.

11. Conclusiones

Este reloj muestra cómo un microcontrolador reemplaza a un conjunto de circuitos integrados (contadores, decodificadores y osciladores) por un programa. Los conceptos clave fueron el multiplexado, que permite controlar cuatro displays con solo once salidas, y la medición del tiempo con millis(), que evita detener el programa y deja libres los tiempos para leer los botones y refrescar la pantalla.

La máquina de estados de la puesta en hora, formada solo por la variable modo, permite que un mismo botón haga cosas distintas según el momento. Esta misma idea es la base de muchos menús y sistemas de control.

Finalmente, la práctica muestra una vez más que lo difícil de pasar de la simulación a la realidad está en el hardware: asignación de pines, niveles de 3,3 V, resistencias limitadoras y pines con funciones especiales de arranque. La lógica del programa se mantiene, y lo que cambia es la forma de conectar el mundo real.

// RELOJ DIGITAL 4 DISPLAYS + DOS PUNTOS + AJUSTE DE HORA
// ESP8266 programado como Arduino UNO - Proteus
// Displays 7 segmentos CATODO COMUN, multiplexados con 2N2222

// ---------- SEGMENTOS ----------
const int SEG_A = 9;
const int SEG_B = 8;
const int SEG_C = 7;
const int SEG_D = 6;
const int SEG_E = 5;
const int SEG_F = 4;
const int SEG_G = 3;

// ---------- DIGITOS (HIGH = encendido) ----------
const int DIG_DH   = A0;  // Decena de hora   (Q4)
const int DIG_UH   = A1;  // Unidad de hora   (Q3)
const int DIG_DMIN = 1;   // Decena de minuto (Q2)
const int DIG_UMIN = 2;   // Unidad de minuto (Q1)
const int DOTS     = 0;   // Dos puntos       (Useg)

// ---------- BOTONES ----------
const int BTN1 = A2;      // B1: entrar / avanzar / salir de configuracion
const int BTN2 = A3;      // B2: incrementar el digito que parpadea

// ---------- LIMITES ----------
const int MAX_DH   = 2;   // Decena de hora: 0..2 (permite 20-23)
const int MAX_DMIN = 9;   // Decena de minuto: 0..9 (usa 5 para solo 0..59)

const int segPins[7] = {SEG_A, SEG_B, SEG_C, SEG_D, SEG_E, SEG_F, SEG_G};
const int digPins[4] = {DIG_DH, DIG_UH, DIG_DMIN, DIG_UMIN};

// Tabla 0-9, bits: gfedcba
const byte numeros[10] = {
  0x3F, 0x06, 0x5B, 0x4F, 0x66,
  0x6D, 0x7D, 0x07, 0x7F, 0x6F
};

// ---------- HORA ----------
int horas    = 12;
int minutos  = 0;
int segundos = 0;
unsigned long ultimoSegundo = 0;

// ---------- MODO CONFIGURACION ----------
// 0 = normal, 1 = Dh, 2 = Uh, 3 = Dmin, 4 = Umin
int modo = 0;

// ---------- ANTIREBOTE ----------
bool b1Previo = HIGH, b2Previo = HIGH;
unsigned long b1Tiempo = 0, b2Tiempo = 0;
const unsigned long REBOTE = 30;

void setup() {
  for (int i = 0; i < 7; i++) {
    pinMode(segPins[i], OUTPUT);
    digitalWrite(segPins[i], LOW);
  }
  for (int i = 0; i < 4; i++) {
    pinMode(digPins[i], OUTPUT);
    digitalWrite(digPins[i], LOW);
  }
  pinMode(DOTS, OUTPUT);
  digitalWrite(DOTS, LOW);

  pinMode(BTN1, INPUT_PULLUP);
  pinMode(BTN2, INPUT_PULLUP);
}

void mostrarNumero(int n) {
  byte patron = numeros[n];
  for (int i = 0; i < 7; i++) {
    digitalWrite(segPins[i], (patron >> i) & 1);
  }
}

void apagarSegmentos() {
  for (int i = 0; i < 7; i++) digitalWrite(segPins[i], LOW);
}

// Devuelve true una sola vez por cada pulsacion (flanco a LOW)
bool pulsado(int pin, bool &previo, unsigned long &tiempo) {
  bool lectura = digitalRead(pin);
  bool evento = false;
  if (lectura != previo && (millis() - tiempo) > REBOTE) {
    tiempo = millis();
    previo = lectura;
    if (lectura == LOW) evento = true;
  }
  return evento;
}

void actualizarReloj() {
  if (modo != 0) return;               // En configuracion el reloj se detiene
  if (millis() - ultimoSegundo >= 1000) {
    ultimoSegundo += 1000;
    segundos++;
    if (segundos >= 60) {
      segundos = 0;
      minutos++;
      if (minutos >= 60) {
        minutos = 0;
        horas++;
        if (horas >= 24) horas = 0;
      }
    }
  }
}

void incrementarDigito() {
  int dh   = horas / 10;
  int uh   = horas % 10;
  int dmin = minutos / 10;
  int umin = minutos % 10;

  switch (modo) {
    case 1:  // Decena de hora
      dh = (dh + 1) % (MAX_DH + 1);
      if (dh == 2 && uh > 3) uh = 3;   // evita 24-29
      break;
    case 2: { // Unidad de hora
      int maxUh = (dh == 2) ? 3 : 9;
      uh = (uh + 1) % (maxUh + 1);
      break;
    }
    case 3:  // Decena de minuto
      dmin = (dmin + 1) % (MAX_DMIN + 1);
      break;
    case 4:  // Unidad de minuto
      umin = (umin + 1) % 10;
      break;
  }
  horas   = dh * 10 + uh;
  minutos = dmin * 10 + umin;
}

void leerBotones() {
  if (pulsado(BTN1, b1Previo, b1Tiempo)) {
    modo++;
    if (modo > 4) {                    // Sale de configuracion
      modo = 0;
      segundos = 0;
      ultimoSegundo = millis();        // El reloj reinicia desde :00
    }
  }
  if (pulsado(BTN2, b2Previo, b2Tiempo)) {
    if (modo != 0) incrementarDigito();
  }
}

void loop() {
  leerBotones();
  actualizarReloj();

  int digitos[4] = {
    horas / 10,
    horas % 10,
    minutos / 10,
    minutos % 10
  };

  // Dos puntos: parpadean cada segundo en modo normal, fijos en configuracion
  if (modo == 0) digitalWrite(DOTS, (segundos % 2 == 0) ? HIGH : LOW);
  else           digitalWrite(DOTS, HIGH);

  // Parpadeo del digito en edicion (~300 ms encendido / 300 ms apagado)
  bool parpadeoApagado = (modo != 0) && ((millis() / 300) % 2 == 1);

  // Multiplexado
  for (int i = 0; i < 4; i++) {
    apagarSegmentos();
    bool apagarEste = parpadeoApagado && (i == modo - 1);
    if (!apagarEste) {
      mostrarNumero(digitos[i]);
      digitalWrite(digPins[i], HIGH);
    }
    delay(3);
    digitalWrite(digPins[i], LOW);
  }
}

Comentarios

Entradas populares de este blog

Semáforo Esquinero de Dos Vías con Temporizadores NE555

Control secuencial de un motor paso a paso 28BYJ-48 mediante una máquina de estados de 2 bits

Contador binario ascendente y descendente utilizando el reloj como bit menos significativo