Generador mflash ver. 2.0

Generador mflash ver. 2.0

Fecha: 12 de septiembre de 2026 Plataforma: ESP32 (ESP32-D0WDQ6, dual core, 240 MHz) · ESP-IDF 5.4 · FreeRTOS Ruta: ~/esp/mflashv2 · app mflash · rama master

Generador de señales DDS por DAC con interfaz local (OLED + encoder) y dashboard web por WiFi.


1. Qué es

Firmware para ESP32 que sintetiza señales seno, cuadrada, sierra y PWM mediante DDS (Direct Digital Synthesis) sobre el DAC interno, con frecuencia ajustable de 0.1 Hz a 10 kHz. La señal se genera desde una ISR de timer a 20 kHz, aislada en el core 1. El usuario controla el generador con un encoder rotativo y ve el estado en una OLED SSD1306; en paralelo, un servidor HTTP publica el estado y acepta cambios en formato JSON.


2. Hardware

Elemento Señal GPIO Configuración Notas
Encoder HW-040 CLK GPIO 35 Entrada, FLOATING, ANYEDGE Input-only, pull-up externo obligatorio
Encoder HW-040 DT GPIO 32 Entrada, PULLUP, ANYEDGE Pull-up interno
Encoder HW-040 SW GPIO 33 Entrada, PULLUP, NEGEDGE Activo en LOW
DAC Salida GPIO 25 DAC_CHAN_0 Señal generada
Debug Test GPIO 2 Salida FS/2 = 10 kHz
OLED SSD1306 SCL GPIO 22 I2C_NUM_0, 400 kHz Maestro I2C
OLED SSD1306 SDA GPIO 19 I2C_NUM_0, 400 kHz Maestro I2C
OLED SSD1306 Dirección — 0x3C 7 bits

Por qué GPIO35 no usa pull-up interno: es un pin input-only del ESP32 y el silicio no le ofrece resistencia de pull-up interna. En el código se configura explícitamente como GPIO_FLOATING [1]. Al estar las interrupciones en GPIO_INTR_ANYEDGE, un pin flotante no produce lecturas erráticas: produce una tormenta de interrupciones. Es el punto más frágil de todo el montaje y hay que verificar con osciloscopio que CLK reposa en 3.3 V.


3. Arquitectura

Reparto de núcleos

Núcleo Constante Contenido
Core 1 CORE_SIGNAL ISR del DAC (timer_isr)
Core 0 CORE_APP OLED, UI, servidor web y pila WiFi

La afinidad de la ISR del DAC no depende de los flags de registro, sino del núcleo desde el que se registra la interrupción. Por eso timer_init_hw() se invoca desde una tarea anclada al core 1.

Tareas

Tarea Función Core Prio Stack Misión
— app_main 0 1 — Init y creación de tareas; luego portMAX_DELAY
sig signal_core_init 1 5 4096 Registra el timer en core 1, fija frecuencia, log único, se autodestruye
ui task_ui 0 3 4096 Bloqueada en enc_q; aplica eventos de encoder
OLED task_oled 0 3 4096 Refresco cada 500 ms
web web_task 0 3 6144 Servidor HTTP en puerto 80

ISRs

ISR Fuente Disparo Core Notas
timer_isr TIMG0 / TIMER_0 Alarma a 20 kHz 1 Sin IRAM_ATTR, registrada con flags 0
enc_quad_isr GPIO35 + GPIO32 GPIO_INTR_ANYEDGE 0 Una sola función para ambos pines
enc_btn_isr GPIO33 GPIO_INTR_NEGEDGE 0 Antirrebote 50 ms

Flujo de datos del encoder

enc_quad_isr / enc_btn_isr  --(xQueueSendFromISR)-->  enc_q  --(xQueueReceive)-->  task_ui
                                                                                    |
                                                              menu_advance() / rotary_apply()

Las ISR solo encolan: no tocan el estado del generador ni llaman a nada que no sea IRAM-safe (gpio_get_level, esp_timer_get_time, xQueueSendFromISR).


4. Decisiones de diseño

4.1 ISR del DAC aislada en el core 1

En el driver legacy de timer group, la interrupción queda ligada al núcleo que la registra. La tarea de WiFi corre en el core 0 con prioridad 23, así que registrar el timer desde app_main (core 0) ponía la ISR de 20 kHz a competir con WiFi y la pila de red. Registrándolo desde signal_core_init, anclada a CORE_SIGNAL, la ISR queda sola en el core 1.

4.2 Encoder por cuadratura en lugar de antirrebote por tiempo

El diseño anterior sondeaba el encoder desde una tarea con periodo de tick (10 ms con CONFIG_FREERTOS_HZ = 100). A esa velocidad, un giro rápido genera transiciones cada ~1 ms y se pierden detentes. Sustituirlo por un filtro de tiempo era peor: cualquier guarda cercana a los rebotes medidos recorta también transiciones legítimas.

La solución es una máquina de estados de cuadratura completa: se detectan los 4 estados (CLK, DT) y solo se acepta la transición si es un paso válido de la secuencia Gray. Los saltos en que cambian los dos bits son rebote y se descartan por construcción. Además, un rebote que va y vuelve (00→01→00) suma +1 y −1, y se cancela. Resultado: no hace falta antirrebote por tiempo en CLK/DT.

La tabla enc_quad_table[16] está en DRAM_ATTR (accesible con la caché de flash apagada), y ENC_DEBOUNCE_US se eliminó del código.

4.3 ENC_COUNTS_PER_STEP = 2

Un EC11 nominal recorre los 4 estados de cuadratura por detente, lo que sugeriría un divisor de 4. Medido en esta unidad HW-040 concreta: 10 clics producen +50 Hz con paso de 10 Hz, es decir 1 paso por clic. Eso significa que este encoder genera solo 2 transiciones válidas por detente (los detentes caen a mitad de ciclo).

El divisor es un parámetro de la pieza física, no del datasheet genérico. Con 2 se obtiene exactamente 1 paso de menú por clic.

Compromiso asumido: con divisor 2 hay la mitad de holgura frente a transiciones espurias que con 4, y un giro parcial (sin llegar al detente) puede registrar un paso. Si alguna vez aparecen saltos fantasma, volver a 4.

4.4 Sentido de giro invertido en la tabla (deuda documentada)

El sentido real de giro se corrigió invirtiendo el signo de los 16 valores de enc_quad_table respecto a la versión de referencia. Es la forma canónica: la dirección es una convención de signo, y los ceros (transiciones inválidas) permanecen intactos, así que el rechazo de rebote no se ve afectado.

Consecuencia: los eventos EV_ROT_CW y EV_ROT_CCW están cruzados respecto al sentido físico de giro. Hoy es inocuo porque rotary_apply() solo suma o resta 10 Hz y la OLED no muestra dirección. Si algún día se añade un indicador de sentido en pantalla, hay que tenerlo en cuenta. Ver pendiente D.

4.5 task_ui bloqueada en la cola con portMAX_DELAY

Con la tarea bloqueada en xQueueReceive(enc_q, ..., portMAX_DELAY) el consumo en reposo es cero: no hay sondeo, no hace falta vTaskDelay, y el idle task del core 0 dispone de CPU, así que el Task Watchdog no puede dispararse. Los eventos se emiten solo cuando ocurren de verdad.

Esta decisión además elimina el parche temporal vTaskDelay(1) que se había puesto para desbloquear el watchdog. Contexto: vTaskDelay(pdMS_TO_TICKS(5)) con CONFIG_FREERTOS_HZ = 100 vale 0, y vTaskDelay(0) no bloquea, dejando la tarea girando y matando de hambre a IDLE0. Fue la causa del WDT que se veía en la fase de validación.

4.6 ISR del DAC sin ESP_INTR_FLAG_IRAM

timer_isr llama a dac_output_voltage() y gpio_set_level(), que no están en IRAM. Mantener el flag obligaba a que la ISR pudiera ejecutarse con la caché de flash deshabilitada, lo que es un riesgo real de crash intermitente cuando WiFi escribe en flash.

Quitando el flag (y el IRAM_ATTR de la función), el sistema enmascara la interrupción durante esas operaciones, y la ISR nunca corre con la caché apagada. El problema desaparece por construcción.

Coste aceptado: el DAC se queda congelado en el último valor (meseta) durante las escrituras en flash. En este firmware eso solo ocurre en el arranque, así que es una meseta de milisegundos casi teórica. Ver pendiente F y G.

4.7 Mediciones de CPU muestreadas 1 de cada 1000

La métrica de duración de la ISR usaba dos llamadas a esp_timer_get_time() en cada interrupción: 40 000 llamadas por segundo, justo en el núcleo que se aisló para proteger la señal. Además el dato se medía a sí mismo (incluía el coste de las dos llamadas).

Con ISR_MEASURE_DIV = 1000 el coste cae 1000×, el dato se sigue refrescando 20 veces por segundo (de sobra para una OLED a 500 ms) y task_oled y web_task no cambian.

Además isr_duration_us pasó de uint64_t a uint32_t para que la lectura cruzada entre ISR y tareas sea atómica.


5. Configuraciones clave

Constante Valor Significado
FS 20000 Hz Frecuencia de muestreo = frecuencia de la ISR del DAC (periodo 50 µs)
TABLE_SIZE 1024 Entradas de cada tabla DDS
TABLE_BITS 10 log2(TABLE_SIZE)
PHASE_BITS 32 Bits del acumulador de fase
TABLE_SHIFT 22 PHASE_BITS - TABLE_BITS; índice = phase >> 22
amp_q15 32767 Amplitud en formato Q15 (escala de 0 a 1)
divisor timer 80 80 MHz / 80 = 1 MHz de contador; alarma = 1000000 / FS = 50
ENC_QUEUE_LEN 16 Profundidad de enc_q (eventos enc_evt_t)
BTN_DEBOUNCE_US 50000 Antirrebote del pulsador (50 ms)
ENC_COUNTS_PER_STEP 2 Transiciones válidas por paso de menú (medido en este HW-040)
ISR_MEASURE_DIV 1000 Se mide 1 de cada 1000 ISR
CORE_SIGNAL 1 Núcleo de la ISR del DAC
CORE_APP 0 Núcleo de UI/OLED/web
PRIO_APP 3 Prioridad de las tareas de trabajo (WiFi usa 23, idle 0)
WIFI_SSID / WIFI_PASS hardcoded Pendiente: moverlas a NVS o a Kconfig (ver pendiente C)

6. Medidas reales de hardware

Osciloscopio Hantek DS2100, DC, 1 ms/div, GND común con la ESP32, canal 1 en CLK (GPIO35) y canal 2 en DT (GPIO32):

Señal Medida Repeticiones
Tren de rebotes CLK/DT < 600 µs, ambos sentidos Repetidas, siempre estable después
Rebote del pulsador (SW) ~1200 µs —

Conclusión: entre dos transiciones válidas de un detente hay milisegundos, mientras que el rebote se agota en menos de 600 µs. Por eso no hace falta antirrebote por tiempo en CLK/DT: la tabla de cuadratura descarta los saltos inválidos por sí sola, y añadir una guarda temporal solo restaría margen a alta velocidad de giro. En el pulsador sí se mantiene el filtro de 50 ms, con holgura de sobra sobre los 1200 µs medidos.


7. Estado actual

Funciona y está verificado en hardware:

Pruebas superadas:

Versión: mflash.bin ≈ 0xca870 bytes, 21% libre de la partición de 1 MB.


8. Comandos útiles

# Compilar
idf.py build

# Compilar guardando log y filtrando avisos
idf.py build 2>&1 | tee logs/build.log
grep -inE "warning|error:" logs/build.log

# Flashear y abrir monitor
idf.py -p /dev/ttyUSB0 flash monitor

# Solo monitor
idf.py monitor

# Git
git tag -l
git log --oneline --decorate -20
git describe --tags --dirty

# Comprobaciones rápidas del fuente
awk '{n+=gsub(/\{/,"{")-gsub(/\}/,"}")}END{print "balance:",n}' main/mflash.c
grep -c "void app_main" main/mflash.c

Prueba de verificación del encoder (la que valida la cuadratura y el divisor):

  1. Flashear y abrir monitor: debe salir una sola línea de la aplicación.
  2. Con el menú cerrado, pulsar el botón una vez → la OLED muestra CONFIGURACION con > en Freq.
  3. Partiendo de 1000.0 Hz, dar 10 clics en un sentido.
  4. Debe quedar en 1100.0 Hz exactos.

Si saltara 4 pasos por clic, subir ENC_COUNTS_PER_STEP; si saltara uno cada dos clics, bajarlo a 1. Si el sentido saliera invertido, invertir de nuevo los signos de la tabla.


9. Historial de fases y tags

Fase Tag Commit Contenido
Baseline — — Estado sucio: antes-limpieza-ssd1306-dirty. Un único proveedor de ssd1306_init en components/ssd1306
Fases 1 + 2 fase2-ok — Un solo log de arranque; ISR del DAC en core 1; tareas de trabajo en core 0 a prioridad 3; corregido el WDT de IDLE0
Fase 3 fase3-ok 9640758 Encoder por cuadratura: ISR en CLK y DT a cualquier flanco, tabla enc_quad_table[16] en DRAM, 1 clic = 1 paso
Fase 4 (punto 1) fase4-ok 325d4e2 ISR del DAC sin ESP_INTR_FLAG_IRAM; ENC_COUNTS_PER_STEP 4 → 2
Fase 4 (punto 2) fase4-punto2-ok e8085bc Medición de duración de ISR muestreada 1 de cada 1000

antes-limpieza-ssd1306 es la cadena que devolvía git describe en el baseline, no un tag creado a propósito.


10. Pendientes conocidos

Nota: los que en su día fueron los dos pendientes de prioridad alta y media —el ESP_INTR_FLAG_IRAM conviviendo con dac_output_voltage()/gpio_set_level(), y las dos llamadas a esp_timer_get_time() por interrupción— quedaron resueltos en la Fase 4 (puntos 1 y 2). Se documentan abajo como cerrados para que no se reabran por error.

# Pendiente Prioridad Estado
A ESP_INTR_FLAG_IRAM + dac_output_voltage()/gpio_set_level() en timer_isr ALTA Resuelto en Fase 4 punto 1
B Dos esp_timer_get_time() por interrupción a 20 kHz MEDIA Resuelto en Fase 4 punto 2
C Credenciales WiFi hardcoded en el fuente BAJA Abierto
D EV_ROT_CW/EV_ROT_CCW cruzados respecto al sentido físico BAJA Abierto (documentado)
E Reconexión WiFi automática ante caída del AP BAJA Abierto
F Los 2 warnings de deprecación (driver/dac.h, driver/timer.h [1]) MEDIA Abierto
G Restricción latente: no hacer escrituras en flash en runtime MEDIA Abierto

C — Credenciales WiFi. WIFI_SSID y WIFI_PASS están en el fuente. Moverlas a NVS o a Kconfig. Ojo: el repositorio queda publicado con las credenciales en el historial de git.

D — Sentido de giro. Opciones: dejar la deuda documentada, o renombrar los eventos intercambiándolos en enc_quad_isr para que EV_ROT_CW coincida con el sentido físico real. Renombrar en la ISR es más limpio que volver a tocar la tabla.

E — Reconexión WiFi. Hoy hay esp_wifi_connect() al arrancar, pero no hay manejador de eventos que reconecte si el AP cae o se reinicia.

F — Warnings de deprecación. Son los únicos avisos del build. La migración interesante es driver/dac_continuous.h con DMA: generaría la onda por hardware desde un buffer, eliminando la ISR entera, el aislamiento de núcleo, el coste de CPU, la meseta durante escrituras en flash y el warning del DAC, todo a la vez. Es el diseño natural para 1 kHz con 20 kHz de muestreo. Requiere rehacer esa parte con la API del driver; no es un parche. Para el timer, driver/gptimer.h sustituye al driver legacy y permite fijar la afinidad de núcleo de forma explícita.

G — Escrituras en flash en runtime. Consecuencia directa de la decisión 4.6: hoy la meseta del DAC durante una escritura en flash es aceptable porque solo ocurre en el arranque. Si algún día se añade OTA o persistencia en NVS en caliente, escribe en flash durante segundos y congelaría la señal. En ese momento hay que volver a ESP_INTR_FLAG_IRAM y escribir el DAC por registro directo (RTC_IO_PAD_DAC1_REG y GPIO.out_w1ts/out_w1tc), asumiendo la dependencia de registros internos. Decidir esto antes de meter OTA.


11. Notas de mantenimiento