Tohum

Del concepto al código: mi camino iterativo hacia un módulo multiplexor con ChatGPT

Ilustración: un multiplexor dirige muchas entradas a una salida, moldeado por un diálogo iterativo

El prompting iterativo con ChatGPT convirtió un problema de hardware de una línea en un módulo multiplexor completo y no bloqueante para el ESP32; este post recorre cómo funcionó, paso a paso. Todo empezó con una restricción sencilla: tenía un ESP32 con un solo pin analógico accesible, pero necesitaba leer 16 canales analógicos. Le expliqué mi problema al modelo ChatGPT o3-mini-high: «Solo tengo un canal, pero necesito leer 16 canales». ChatGPT respondió con varias opciones: ADCs externos, multiplexación y más. Sopesé las alternativas y decidí que un multiplexor analógico, en concreto el CD74HC4067, era el mejor camino. Esa elección abrió una discusión mucho más profunda.

Paso 1: El deseo inicial

Empecé diciendo que quería usar el ESP32 para leer 16 canales analógicos aunque solo tenía una entrada analógica disponible. ChatGPT me dio rápidamente varias opciones: multiplexores analógicos, ADCs externos e incluso reasignar pines si era posible. Al final elegí el multiplexor CD74HC4067. En ese momento aún no había detallado la arquitectura de software que quería ni los requisitos de temporización. Más adelante pedí posibles diseños de arquitectura y un conjunto de requisitos para apoyar mi objetivo.

Paso 2: Diseño de la arquitectura y requisitos

Después pedí a ChatGPT que propusiera un diseño de arquitectura para un módulo de software que se interfacía con el CD74HC4067. Presentó un diseño por capas que separaba la abstracción de hardware, el control del multiplexor, la lectura del ADC y la integración con tareas. La estructura era completa y ordenada, pero noté que faltaban detalles sobre rendimiento en tiempo real y parametrización en tiempo de compilación. Así que pedí que cada parámetro ajustable se definiera como una directiva de preprocesador, y que el diseño incluyera un enfoque de Task/Thread de RTOS con una cola que guardara un timestamp para cada conjunto de 16 lecturas de canal.

Paso 3: Añadiendo RTOS y timestamps

Con la idea ya tomando forma, confirmé que iba a usar el CD74HC4067. Pedí específicamente que se creara una tarea RTOS dedicada para escanear secuencialmente los 16 canales y que cada conjunto completo de lecturas incluyera un timestamp indicando cuándo terminó la lectura. ChatGPT actualizó el diseño en consecuencia, incorporando una capa de gestor de ADC y muestreo, y especificando que el paquete de datos contendría tanto los datos de los canales como el timestamp. En ese punto sentí que el diseño era sólido, pero aún necesitaba cálculos de temporización más específicos.

Paso 4: Cálculos de temporización y modos de muestreo

A continuación pedí un cálculo de cuántos milisegundos tardaría leer los 16 canales. ChatGPT proporcionó varios escenarios de ejemplo basados en distintos tiempos de establecimiento y retardos de conversión del ADC. Me gustó la explicación, pero fui más allá: quería añadir un parámetro (fijado en tiempo de compilación) que pudiera tomar valores como Fast Mode, Moderate Mode o HighRes. Este parámetro ajustaría automáticamente el tiempo de establecimiento del multiplexor y el retardo de conversión del ADC según las cifras del datasheet y mediciones previas. El diseño evolucionó aún más con estos requisitos añadidos.

Paso 5: Implementación completa del módulo

Luego pedí un módulo completo —tanto los archivos .cpp como .h— que incorporara todos los requisitos hasta ese momento, incluidas funciones de prueba unitaria. ChatGPT produjo un módulo detallado, con todos los parámetros ajustables definidos como directivas de preprocesador y valores de temporización reales elegidos a partir de datos del datasheet (por ejemplo, usando un tiempo de conversión de unos 11 µs para el ESP32). Incluso venía con pruebas unitarias. El módulo era completo, pero noté que el código aún usaba algunas esperas bloqueantes (por ejemplo, llamadas como ets_delay_us), lo cual no era aceptable para mis necesidades de tiempo real.

Paso 6: El mandato no bloqueante

Por último, insistí en que no se usara ninguna espera bloqueante en el código: cada retardo de temporización tenía que ejecutarse de forma no bloqueante con un enfoque de máquina de estados, cediendo el control al planificador en lugar de hacer busy-waiting. ChatGPT revisó el diseño una última vez, convirtiendo la tarea de muestreo en una máquina de estados no bloqueante que comprueba el tiempo transcurrido y cede el control usando vTaskDelay(0) y taskYIELD(). Con eso, el diseño quedó completo y cumplía todos mis requisitos.


Reflexiones sobre la IA como arquitecto de sistemas, desarrollador y desarrollador de hardware

Mirando atrás, el camino de una simple descripción del problema a un módulo completo y no bloqueante me enseñó dónde ayuda de verdad la IA. A través de una conversación iterativa, una idea vaga se convirtió en un conjunto detallado de requisitos y en una implementación completa. La IA puede pasar por alto matices sutiles de hardware o problemas de integración, pero la iteración rápida me permitió dedicar mi tiempo a refinar el diseño en lugar de reinventar cada detalle.

Mi conclusión: la IA puede actuar tanto como arquitecto de sistemas como de desarrollador. Puede sugerir diseños modulares, ayudar a definir requisitos funcionales y no funcionales, y generar código que respeta restricciones de tiempo real no bloqueantes. La supervisión humana sigue siendo esencial para verificar y adaptar la salida de la IA al hardware real. Pero el flujo de trabajo avanza más rápido, dejando más tiempo para pruebas, optimización y resolución creativa de problemas.

Puedes ver los requisitos y el código completo a continuación.


Requisitos funcionales:

Requisitos no funcionales:


Código final

A continuación está el código completo del módulo final:

MultiplexerModule.h

cpp

#ifndef MULTIPLEXER_MODULE_H
#define MULTIPLEXER_MODULE_H

#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/task.h>
#include <stdint.h>

/*
  Module Parameters and Sampling Modes

  All adjustable parameters are defined as preprocessor directives.
  The values below are chosen based on published datasheet data and practical measurements.
*/

// Number of multiplexer channels.
#ifndef NUM_CHANNELS
  #define NUM_CHANNELS 16
#endif

// Sampling mode selection.
typedef enum {
  MODE_FAST,
  MODE_MODERATE,
  MODE_HIGHRES
} SamplingMode_t;

#ifndef SAMPLING_MODE
  #define SAMPLING_MODE MODE_FAST
#endif

// Timing parameters (in microseconds) based on sampling mode.
#if (SAMPLING_MODE == MODE_FAST)
  #define MUX_SETTLE_TIME_US     2    // Minimal settling delay.
  #define ADC_CONVERSION_TIME_US 11   // Measured ~11 µs conversion time.
#elif (SAMPLING_MODE == MODE_MODERATE)
  #define MUX_SETTLE_TIME_US     5
  #define ADC_CONVERSION_TIME_US 22
#elif (SAMPLING_MODE == MODE_HIGHRES)
  #define MUX_SETTLE_TIME_US     20
  #define ADC_CONVERSION_TIME_US 50
#else
  #error "Invalid SAMPLING_MODE selected"
#endif

// Full-scan sampling interval (in milliseconds).
#ifndef SAMPLING_INTERVAL_MS
  #define SAMPLING_INTERVAL_MS 10   // Adjust as required.
#endif

// Queue length for data packages.
#ifndef QUEUE_LENGTH
  #define QUEUE_LENGTH 10
#endif

// Hardware pin assignments (adjust as needed for your wiring).
#ifndef MUX_S0_PIN
  #define MUX_S0_PIN  25
#endif
#ifndef MUX_S1_PIN
  #define MUX_S1_PIN  26
#endif
#ifndef MUX_S2_PIN
  #define MUX_S2_PIN  27
#endif
#ifndef MUX_S3_PIN
  #define MUX_S3_PIN  14
#endif
#ifndef MUX_EN_PIN
  #define MUX_EN_PIN  12   // Optional enable pin (active LOW). If unused, tie low.
#endif
#ifndef MUX_ANALOG_PIN
  #define MUX_ANALOG_PIN  34  // Example: an ADC1 channel on the ESP32.
#endif

// Data package structure that holds a full set of readings with a timestamp.
typedef struct {
  uint16_t channelReadings[NUM_CHANNELS];  // ADC reading for each channel.
  uint32_t timestamp_ms;                   // Timestamp (in ms) when the scan finished.
} DataPackage_t;

// Public API class declaration.
class MultiplexerModule {
public:
  // Initialize the module: configure hardware, create queue, and start the sampling task.
  static void init();

  // Stop the sampling task and free allocated resources.
  static void stop();

  // Retrieve a data package from the module’s queue (non-blocking).
  static bool getData(DataPackage_t* pkg, TickType_t waitTime = 0);

  // (Optional) Set sampling mode at runtime.
  // NOTE: Dynamic mode changes are not supported because all timing parameters are compile-time.
  static void setSamplingMode(SamplingMode_t mode);

  // Unit test function (compiled only if UNIT_TEST is defined).
  #ifdef UNIT_TEST
  static void runUnitTests();
  #endif

private:
  // Enumeration of states in the non-blocking sampling state machine.
  typedef enum {
    STATE_SELECT_CHANNEL,
    STATE_WAIT_SETTLE,
    STATE_ADC_READ,
    STATE_WAIT_ADC,
    STATE_NEXT_CHANNEL,
    STATE_FULL_SCAN_WAIT
  } SamplingState_t;

  // The non-blocking state-machine-based sampling task.
  static void samplingTask(void* pvParameters);

  // Helper function to set the multiplexer to the specified channel.
  static void selectMuxChannel(uint8_t channel);

  // FreeRTOS queue handle for data packages.
  static QueueHandle_t muxQueue;
  
  // Task handle for the sampling task.
  static TaskHandle_t samplingTaskHandle;

  // Current sampling mode (for documentation; timing parameters remain compile-time).
  static SamplingMode_t currentMode;
};

#endif // MULTIPLEXER_MODULE_H 

MultiplexerModule.cpp

cpp

#include "MultiplexerModule.h"

// Static member definitions.
QueueHandle_t MultiplexerModule::muxQueue = NULL;
TaskHandle_t MultiplexerModule::samplingTaskHandle = NULL;
SamplingMode_t MultiplexerModule::currentMode = SAMPLING_MODE;

// Helper function: set multiplexer control pins based on the channel (0–15).
void MultiplexerModule::selectMuxChannel(uint8_t channel) {
  digitalWrite(MUX_S0_PIN, (channel & 0x01) ? HIGH : LOW);
  digitalWrite(MUX_S1_PIN, (channel & 0x02) ? HIGH : LOW);
  digitalWrite(MUX_S2_PIN, (channel & 0x04) ? HIGH : LOW);
  digitalWrite(MUX_S3_PIN, (channel & 0x08) ? HIGH : LOW);
}

// Non-blocking sampling task implemented as a state machine.
void MultiplexerModule::samplingTask(void* pvParameters) {
  DataPackage_t pkg;
  uint8_t currentChannel = 0;
  SamplingState_t state = STATE_SELECT_CHANNEL;
  uint64_t stateStartTime = esp_timer_get_time();  // in microseconds
  uint64_t fullScanStartTime = esp_timer_get_time();
  uint16_t adcTemp = 0;

  // Run the state machine forever without blocking waits.
  for (;;) {
    uint64_t now = esp_timer_get_time();

    switch (state) {
      case STATE_SELECT_CHANNEL:
        selectMuxChannel(currentChannel);
        digitalWrite(MUX_EN_PIN, LOW);
        stateStartTime = now;
        state = STATE_WAIT_SETTLE;
        break;

      case STATE_WAIT_SETTLE:
        if (now - stateStartTime >= MUX_SETTLE_TIME_US) {
          state = STATE_ADC_READ;
        } else {
          vTaskDelay(0);
        }
        break;

      case STATE_ADC_READ:
        adcTemp = analogRead(MUX_ANALOG_PIN);
        stateStartTime = now;
        state = STATE_WAIT_ADC;
        break;

      case STATE_WAIT_ADC:
        if (now - stateStartTime >= ADC_CONVERSION_TIME_US) {
          pkg.channelReadings[currentChannel] = adcTemp;
          state = STATE_NEXT_CHANNEL;
        } else {
          vTaskDelay(0);
        }
        break;

      case STATE_NEXT_CHANNEL:
        currentChannel++;
        if (currentChannel < NUM_CHANNELS) {
          state = STATE_SELECT_CHANNEL;
        } else {
          pkg.timestamp_ms = (uint32_t)((esp_timer_get_time() - fullScanStartTime) / 1000);
          xQueueSend(muxQueue, &pkg, 0);
          currentChannel = 0;
          fullScanStartTime = esp_timer_get_time();
          state = STATE_FULL_SCAN_WAIT;
        }
        break;

      case STATE_FULL_SCAN_WAIT: {
          uint32_t elapsed_ms = (uint32_t)((esp_timer_get_time() - fullScanStartTime) / 1000);
          if (elapsed_ms >= SAMPLING_INTERVAL_MS) {
            state = STATE_SELECT_CHANNEL;
          } else {
            vTaskDelay(0);
          }
        }
        break;

      default:
        state = STATE_SELECT_CHANNEL;
        break;
    }
    taskYIELD();
  }
}

// Public initialization: configure pins, create the queue, and start the sampling task.
void MultiplexerModule::init() {
  pinMode(MUX_S0_PIN, OUTPUT);
  pinMode(MUX_S1_PIN, OUTPUT);
  pinMode(MUX_S2_PIN, OUTPUT);
  pinMode(MUX_S3_PIN, OUTPUT);
  pinMode(MUX_EN_PIN, OUTPUT);
  digitalWrite(MUX_EN_PIN, LOW);
  pinMode(MUX_ANALOG_PIN, INPUT);

  muxQueue = xQueueCreate(QUEUE_LENGTH, sizeof(DataPackage_t));
  if (muxQueue == NULL) {
    Serial.println("Error: Could not create multiplexer queue.");
    return;
  }

  BaseType_t result = xTaskCreatePinnedToCore(
    samplingTask,
    "MuxSamplingTask",
    4096,
    NULL,
    1,
    &samplingTaskHandle,
    1
  );
  if (result != pdPASS) {
    Serial.println("Error: Could not create sampling task.");
  }
}

// Public stop function.
void MultiplexerModule::stop() {
  if (samplingTaskHandle != NULL) {
    vTaskDelete(samplingTaskHandle);
    samplingTaskHandle = NULL;
  }
  if (muxQueue != NULL) {
    vQueueDelete(muxQueue);
    muxQueue = NULL;
  }
}

// Retrieve a data package from the queue (non-blocking).
bool MultiplexerModule::getData(DataPackage_t* pkg, TickType_t waitTime) {
  if (muxQueue == NULL) return false;
  return (xQueueReceive(muxQueue, pkg, waitTime) == pdPASS);
}

// Public function to set the sampling mode (runtime changes are not supported).
void MultiplexerModule::setSamplingMode(SamplingMode_t mode) {
  Serial.println("Runtime mode change not supported; set SAMPLING_MODE at compile time.");
}

#ifdef UNIT_TEST
// Unit tests.
void MultiplexerModule::runUnitTests() {
  Serial.println("Running unit tests for MultiplexerModule...");
  for (uint8_t ch = 0; ch < NUM_CHANNELS; ch++) {
    bool b0 = (ch & 0x01) != 0;
    bool b1 = (ch & 0x02) != 0;
    bool b2 = (ch & 0x04) != 0;
    bool b3 = (ch & 0x08) != 0;
    Serial.print("Channel ");
    Serial.print(ch);
    Serial.print(" expects S0=");
    Serial.print(b0 ? "HIGH" : "LOW");
    Serial.print(", S1=");
    Serial.print(b1 ? "HIGH" : "LOW");
    Serial.print(", S2=");
    Serial.print(b2 ? "HIGH" : "LOW");
    Serial.print(", S3=");
    Serial.println(b3 ? "HIGH" : "LOW");
  }
  uint32_t expected_us = NUM_CHANNELS * (MUX_SETTLE_TIME_US + ADC_CONVERSION_TIME_US);
  Serial.print("Expected full-scan time (µs): ");
  Serial.println(expected_us);
  if (sizeof(DataPackage_t) != (NUM_CHANNELS * sizeof(uint16_t) + sizeof(uint32_t))) {
    Serial.println("Error: DataPackage_t size mismatch.");
  } else {
    Serial.println("DataPackage_t size verified.");
  }
  Serial.println("Unit tests completed.");
}
#endif  // UNIT_TEST