Tohum

Do Conceito ao Código: A Minha Jornada Iterativa para um Módulo de Multiplexagem com o ChatGPT

Ilustração: um multiplexador direciona muitas entradas para uma saída, moldado por um diálogo iterativo

O prompting iterativo com o ChatGPT transformou um problema de hardware de uma linha num módulo multiplexor completo e não bloqueante para o ESP32 — este artigo mostra como isso funcionou, passo a passo. Começou com uma restrição simples: eu tinha um ESP32 com apenas um pino analógico acessível, mas precisava de ler 16 canais analógicos. Expliquei o meu problema ao modelo ChatGPT o3-mini-high: “Só tenho um canal, mas preciso de ler 16 canais.” O ChatGPT respondeu com várias opções — ADCs externos, multiplexagem e mais. Considerei as escolhas e decidi que um multiplexor analógico, especificamente o CD74HC4067, era o melhor caminho. Essa escolha abriu uma discussão muito mais profunda.

Passo 1: O Desejo Inicial

Comecei por afirmar que queria usar o ESP32 para ler 16 canais analógicos, embora só tivesse uma entrada analógica disponível. O ChatGPT forneceu rapidamente várias opções: multiplexores analógicos, ADCs externos e até a reatribuição de pinos, se possível. No fim, escolhi o multiplexor CD74HC4067. Nessa altura, ainda não tinha detalhado a arquitetura de software pretendida nem quaisquer requisitos de timing. Mais tarde, pedi possíveis desenhos de arquitetura e um conjunto de requisitos para suportar o meu objetivo.

Passo 2: Desenho da Arquitetura e Requisitos

Pedi então ao ChatGPT que propusesse um desenho de arquitetura para um módulo de software que fizesse a interface com o CD74HC4067. Apresentou um desenho em camadas que separava a abstração de hardware, o controlo do multiplexor, a leitura do ADC e a integração de tarefas. A estrutura era completa e organizada, mas reparei que faltavam detalhes sobre desempenho em tempo real e parametrização em tempo de compilação. Por isso, pedi que cada parâmetro ajustável fosse definido como uma diretiva de pré-processador e que o desenho incluísse uma abordagem de RTOS Task/Thread com uma queue para guardar um timestamp para cada conjunto de 16 leituras de canais.

Passo 3: Adicionar RTOS e Timestamps

Com a ideia agora a tomar forma claramente, confirmei que ia usar o CD74HC4067. Pedi especificamente que fosse criada uma tarefa RTOS dedicada para varrer sequencialmente os 16 canais e que cada conjunto completo de leituras incluísse um timestamp a indicar quando a leitura terminou. O ChatGPT atualizou o desenho em conformidade, incorporando uma camada de gestor de ADC e amostragem e especificando que o pacote de dados conteria tanto os dados dos canais como o timestamp. Nesse momento, senti que o desenho era sólido, mas ainda precisava de cálculos de timing mais específicos.

Passo 4: Cálculos de Timing e Modos de Amostragem

Em seguida, pedi um cálculo de quantos milissegundos demoraria a ler os 16 canais. O ChatGPT forneceu vários cenários de exemplo baseados em diferentes tempos de estabilização e conversão do ADC. Gostei da explicação, mas fui mais longe: queria adicionar um parâmetro (definido em tempo de compilação) que pudesse tomar valores como Fast Mode, Moderate Mode ou HighRes. Este parâmetro ajustaria automaticamente o tempo de estabilização do multiplexor e o atraso de conversão do ADC de acordo com os valores do datasheet e medições anteriores. O desenho evoluiu ainda mais com estes requisitos adicionados.

Passo 5: Implementação Completa do Módulo

Depois pedi um módulo completo — ficheiros .cpp e .h — que incorporasse todos os requisitos até aqui, incluindo funções de teste unitário. O ChatGPT produziu um módulo detalhado, com todos os parâmetros ajustáveis definidos como diretivas de pré-processador e valores de timing reais escolhidos com base nos dados do datasheet (por exemplo, usando um tempo de conversão de cerca de 11 µs para o ESP32). Até veio com testes unitários. O módulo era completo, mas reparei que o código ainda usava algumas esperas bloqueantes (por exemplo, chamadas como ets_delay_us), o que não era aceitável para as minhas necessidades de tempo real.

Passo 6: O Mandato Não Bloqueante

Por fim, insisti que não fossem usadas esperas bloqueantes em nenhuma parte do código: cada atraso de timing tinha de correr de forma não bloqueante usando uma abordagem de máquina de estados, com o código a ceder ao scheduler em vez de fazer busy-waiting. O ChatGPT reviu o desenho uma última vez, convertendo a tarefa de amostragem numa máquina de estados não bloqueante que verifica o tempo decorrido e cede o controlo usando vTaskDelay(0) e taskYIELD(). Com isso, o desenho ficou completo e cumpriu todos os meus requisitos.


Reflexões sobre a IA como Arquiteto de Sistemas, Programador e Desenvolvedor de Hardware

Olhando para trás, o caminho de uma simples descrição de problema até um módulo completo e não bloqueante ensinou-me onde a IA realmente ajuda. Através de conversação iterativa, uma ideia vaga tornou-se um conjunto detalhado de requisitos e uma implementação completa. A IA pode falhar nuances subtis de hardware ou problemas de integração, mas a iteração rápida permitiu-me gastar o meu tempo a refinar o desenho em vez de reinventar cada detalhe.

A minha conclusão: a IA pode atuar tanto como arquiteto de sistemas como programador. Pode sugerir desenhos modulares, ajudar a definir requisitos funcionais e não funcionais e gerar código que respeita restrições de tempo real e não bloqueantes. A supervisão humana continua a ser essencial para verificar e adaptar o resultado da IA ao hardware do mundo real. Mas o fluxo de trabalho avança mais depressa, deixando mais tempo para testes, otimização e resolução criativa de problemas.

Pode ver os requisitos e o código completo abaixo.


Requisitos Funcionais:

Requisitos Não Funcionais:


Código Final

Abaixo está o código completo do 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