Implementación de un Menú de Niveles Múltiples para Pantallas OLED en STM32

  1. Fundamentos del Sistema de Menú y su Implementación Práctica

En el desarrollo de GUI para sistemas embebidos, un menú de niveles múltiples trasciende la simple superposición de interfaces; integra lógica de interacción usuario-máquina, gestión de estados, retroalimentación visual y limitaciones de recursos hardware. Para lograr una experiencia fluida, se mapean las intenciones del usuario (navegación arriba/abajo, selección de ítem, cambio de nivel) a una máquina de estados finitos predecible y controlable, donde las transiciones se completan mediante animaciones progresivas impulsadas por temporizadores de precisión. Esta guía se basa en una plataforma STM32 con pantalla OLED SSD1306, desde el dibujo a nivel de píxel hasta la gestión de estados de alto nivel.

1.1 Sistema de Fuentes y Coordenadas: Bases para la Alineación

Las pantallas OLED, con su contraste alto y resolución limitada (comúnmente 128×64 píxeles), requieren un manejo cuidadoso de fuentes. Usar fuentes de ancho variable (como algunas fuentes TTF escaladas) provoca desalineación horizontal en los ítems del menú, ya que el ancho de los caracteres varía. La solución es emplear fuentes de píxeles de ancho fijo. Por ejemplo, una fuente de 16×16 píxeles para caracteres chinos asigna 16 píxeles de ancho por carácter, mientras que los caracteres ASCII (A-Z, 0-9) típicamente usan 8 píxeles. La altura de línea debe incluir espaciado vertical; se recomienda un intervalo de 20 píxeles por línea (16 para la fuente más 4 de espacio).

Para calcular el ancho total de un texto del menú, se necesita una función que maneje codificaciones como GB2312, donde los caracteres chinos ocupan dos bytes. En lugar de depender de strlen(), que falla con codificaciones multi-byte, se implementa un cálculo basado en tablas de búsqueda.

// Tabla de consulta de ancho de caracteres (ejemplo)
const uint8_t tabla_ancho_caracteres[256] = {
    [0x20 ... 0x7E] = 8,   // Caracteres ASCII imprimibles (espacio a '~')
    [0xA1 ... 0xFE] = 16, // Rango del primer byte para caracteres chinos en GB2312
};

// Función para calcular el ancho en píxeles de una cadena
uint16_t obtenerAnchoTexto(const uint8_t* texto) {
    uint16_t ancho = 0;
    const uint8_t* ptr = texto;
    while (*ptr) {
        if (*ptr >= 0xA1 && *ptr <= 0xFE) { // Inicio de carácter chino (dos bytes)
            ancho += 16;
            ptr += 2; // Saltar el segundo byte
        } else {
            ancho += 8;
            ptr++;
        }
    }
    return ancho;
}

1.2 Dibujado de Rectángulos: Implementación con Esquinas Redondeadas

El controlaodr SSD1306 no soporta relleno por hardware; toda la gráfica se escribe píxel a píxel. La función dibujarRectanguloRedondeado() utiliza algoritmos de Bresenham para arcos y líneas. Los parámetros clave incluyen posición (x, y) de la esquina superior izquierda, dimensiones (ancho, alto) y radio de esquina (que debe cumplir radio_esquina ≤ min(ancho, alto)/2). Para evitar errores de cálculo que causen desbordamientos, se fuerzan restricciones de seguridad.

void dibujarRectanguloRedondeado(uint8_t x, uint8_t y, uint8_t ancho, uint8_t alto, uint8_t radio_esquina) {
    // Ajustar el radio para que no exceda las dimensiones del rectángulo
    uint8_t r = (radio_esquina > ancho/2) ? ancho/2 : radio_esquina;
    r = (r > alto/2) ? alto/2 : r;

    // Dibujar los cuatro cuadrantes de arco (arriba-izquierda, arriba-derecha, etc.)
    dibujarCuadranteCirculo(x + r, y + r, r, 0b0001);
    dibujarCuadranteCirculo(x + ancho - r, y + r, r, 0b0010);
    dibujarCuadranteCirculo(x + ancho - r, y + alto - r, r, 0b0100);
    dibujarCuadranteCirculo(x + r, y + alto - r, r, 0b1000);

    // Dibujar las cuatro líneas rectas (superior, derecha, inferior, izquierda)
    dibujarLineaHorizontal(x + r, y, ancho - 2*r);
    dibujarLineaVertical(x + ancho - 1, y + r, alto - 2*r);
    dibujarLineaHorizontal(x + r, y + alto - 1, ancho - 2*r);
    dibujarLineaVertical(x, y + r, alto - 2*r);
}

La función dibujarCuadranteCirculo() emplea aritmética entera para minimizar el costo computacional. En pruebas con un STM32F103C8T6 (72 MHz), dibujar un rectángulo redondeado de 16×16 toma aproximadamente 85 μs, adecuado para tasas de refresco de 60 Hz.

1.3 Disposición de Ítems del Menú: Alineación y Espaciado

Un error común es no alinear correctamente la línea base del texto con el borde superior del rectángulo. Para solucionarlo, se calcula un desplazamiento vertical para el texto. Con una altura de fuente de 16 píxeles y una altura de línea de 20 píxeles, el desplazamiento recomendado es DESOFFSET_BASE_TEXTO = (20 - 16)/2 + 2, compensando el descenso de la línea base. Además, se establece un espaciado mínimo entre ítems para evitar amontonamiento; en pantallas de 128×64, una altura de línea de 20 píxeles ofrece buena legibilidad.

La fórmula de disposición para cada ítem del menú es:

#define ALTURA_LINEA 20
#define DESOFFSET_BASE_TEXTO ((ALTURA_LINEA - 16) / 2 + 2)

// Ejemplo de cálculo para el ítem i
Item[i].rectangulo.x = 0;
Item[i].rectangulo.y = i * ALTURA_LINEA;
Item[i].rectangulo.ancho = obtenerAnchoTexto(lista_textos[i]);
Item[i].rectangulo.alto = 16;
Item[i].texto_x = 2; // Desplazamiento horizontal para evitar borde
Item[i].texto_y = Item[i].rectangulo.y + DESOFFSET_BASE_TEXTO;
  1. Máquina de Estados del Menú: De Entrada a Respuesta UI

Un menú multinivel es esencialmente una máquina de estados finitos. El índice de selección actual (indice_foco) es el núcleo, pero para interacciones complejas se requiere un modelo de estados en capas y basado en eventos.

2.1 Arquitectura de Tres Capas de Estados

Los estados se organizan en capas: la capa de página (pagina_actual) identifica la pantalla mostrada (menú principal, ajustes, etc.); la capa de foco (indice_foco) resalta el ítem actual en la página; y la capa de edición (modo_edicion) indica si se está editando un parámetro. Esto desacopla el cambio de página de la selección de ítems, evitando lógica conflictiva.

2.2 Manejo de Teclas con Anti-Rebote y Cola de Eventos

Los botones mecánicos producen rebotes de 10-20 ms. Se implementa un anti-rebote por software usando un temporizador a 1 kHz para muestreo periódico. Los eventos de teclas se almacenan en una cola circular para evitar pérdidas.

// Ejemplo de anti-rebote y cola de eventos
typedef enum { TECLA_ARRIBA, TECLA_ABAJO, TECLA_ENTER, TECLA_ATRAS, TECLA_NINGUNA } evento_tecla_t;
evento_tecola_t cola_teclas[8];
uint8_t cabeza = 0, cola = 0;

void agregarEventoTecla(evento_tecla_t evento) {
    uint8_t siguiente = (cabeza + 1) % 8;
    if (siguiente != cola) {
        cola_teclas[cabeza] = evento;
        cabeza = siguiente;
    }
}

evento_tecla_t obtenerEventoTecla(void) {
    if (cabeza == cola) return TECLA_NINGUNA;
    evento_tecla_t evento = cola_teclas[cola];
    cola = (cola + 1) % 8;
    return evento;
}

En el manejador de interrupciones del temporizador, se leen los pines de los botones y se generan eventos al detectar pulsaciones estables.

2.3 Lógica de Transición de Estados

En el bucle principal, se procesan los eventos de la cola para actualizar la máquina de estados. Por ejemplo, para la página principal, las teclas ARRIBA/ABAJO modifican indice_foco, y ENTER desencadena acciones como cambiar de página o ejecutar funciones.

void procesarPaginaPrincipal(evento_tecla_t evento) {
    switch (evento) {
        case TECLA_ARRIBA:
            if (indice_foco > 0) indice_foco--;
            else indice_foco = NUM_ITEMS_PRINCIPAL - 1; // Ciclo
            break;
        case TECLA_ABAJO:
            indice_foco = (indice_foco + 1) % NUM_ITEMS_PRINCIPAL;
            break;
        case TECLA_ENTER:
            switch (indice_foco) {
                case 0: pagina_actual = PAGINA_AJUSTES; indice_foco = 0; break;
                case 1: alternarRetroiluminacion(); break;
                // Otras acciones
            }
            break;
        case TECLA_ATRAS:
            // Ignorar en página principal
            break;
    }
}
  1. Motor de Animación: Desplazamiento Suave mediante Interpolación

La experiencia fluida se logra con control cinemático. Se usa un modelo de movimiento uniformemente acelerado (ecuaciones SUVAT simplificadas) para actualizar posición y velocidad en cada cuadro.

3.1 Variables de Estado de la Animación

typedef struct {
    int16_t destino_y;      // Coordenada Y objetivo
    int16_t posicion_y;     // Coordenada Y actual
    int16_t velocidad;      // Velocidad actual (píxeles/cuadro)
    int16_t aceleracion;    // Aceleración (píxeles/cuadro²)
    uint8_t en_movimiento;  // Bandera de animación activa
} animacion_t;

animacion_t animacion_menu = {0};

3.2 Implementación del Motor Físico

La posición se actualiza sumando la velocidad; la velocidad se incrementa con la aceleración. Se usa aritmética de punto fijo Q15 (16 bits con signo, 15 bits fraccionarios) para evitar desbordamientos numéricos.

#define ESCALA_Q15 32768

void actualizarAnimacion(void) {
    if (!animacion_menu.en_movimiento) return;

    // Integración de posición
    animacion_menu.posicion_y += (animacion_menu.velocidad >> 15);

    // Detección de error para detener la animación
    int16_t error = animacion_menu.destino_y - animacion_menu.posicion_y;
    if (abs(error) < abs(animacion_menu.velocidad >> 15)) {
        animacion_menu.posicion_y = animacion_menu.destino_y;
        animacion_menu.velocidad = 0;
        animacion_menu.en_movimiento = 0;
        return;
    }

    // Integración de velocidad
    animacion_menu.velocidad += animacion_menu.aceleracion;

    // Límite de velocidad para evitar sobrepaso
    if (animacion_menu.velocidad > (10 * ESCALA_Q15))
        animacion_menu.velocidad = 10 * ESCALA_Q15;
    if (animacion_menu.velocidad < (-10 * ESCALA_Q15))
        animacion_menu.velocidad = -10 * ESCALA_Q15;
}

3.3 Ajuste de Parámetros

La aceleración se calcula siempre en dirección hacia el destino, con un valor absoluto constante. Los parámetros óptimos son: aceleración inicial de ±2 unidades Q15, velocidad máxima de ±10 unidades Q15, y tasa de refresco de 60 Hz. Esto permite movimientos de 100 píxeles en aproximadamente 32 cuadros (533 ms), percibidos como fluidos.

  1. Integración del Sistema de Menú: De Página Única a Sistema Completo

Para extensibilidad, se define una interfaz abstratca para páginas de menú, permitiendo renderización personalizada por página. Esto facilita casos especiales, como menús con pocos ítems.

4.1 Interfaz Abstracta para Páginas de Menú

typedef struct {
    const char* titulo;
    uint8_t num_items;
    const char** textos_items;
    void (*renderizar)(uint8_t indice_foco);
    void (*al_seleccionar)(uint8_t indice);
    void (*al_retroceder)(void);
} pagina_menu_t;

Cada página (p.ej., menú principal, ajustes) implementa sus funciones de renderizado y manejo de eventos, permitiendo diseños como dos rectángulos fijos para menús de dos ítems.

4.2 Optimización de Memoria

En MCUs con memoria limitada, se usan técnicas como codificación GBK para cadenas (2 bytes por carácter chino), almacenamiento de datos de fuente en Flash, y arreglos de punteros a estructuras de menú para reducir uso de RAM.

// Ejemplo de almacenamiento eficiente
const char* textos_principales[] = {"Ajustes", "Brillo", "Acerca de"};
const pagina_menu_t paginas_menu[] = {
    { .titulo = "Menú Principal", .num_items = 3, .textos_items = textos_principales, ... },
    // Otras páginas
};
  1. Experiencias de Depuración en Proyectos Reales

Problemas comunes incluyen bloqueos en escrituras I²C de SSD1306 (solucionables con DMA o SPI), errores de codificación por relojes del sistema descalibrados, conflictos entre animaciones y modos de bajo consumo (manejados con modos Sleep o despertares RTC), y ruido en pantallas táctiles (filtrado con componentes RC). Estos detalles son críticos para que un sistema de menú sea robusto y usable.

Etiquetas: STM32 OLED SSD1306 menú-multinivel máquina-estados

Publicado el 6-28 04:29