Dal concetto al codice: il mio percorso iterativo verso un modulo di multiplexing con ChatGPT
Il prompting iterativo con ChatGPT ha trasformato un problema hardware di una riga in un modulo multiplexer completo e non bloccante per ESP32 — questo post racconta come ha funzionato, passo dopo passo. Tutto è partito da un vincolo semplice: avevo un ESP32 con un solo pin analogico accessibile, ma dovevo leggere 16 canali analogici. Ho spiegato la mia situazione al modello ChatGPT o3-mini-high: “Ho un solo canale, ma devo leggerne 16.” ChatGPT ha risposto con diverse opzioni — ADC esterni, multiplexing e altro. Ho valutato le scelte e deciso che un multiplexer analogico, nello specifico il CD74HC4067, era la strada migliore. Quella scelta ha aperto una discussione molto più profonda.
Passo 1: Il desiderio iniziale
Ho iniziato dichiarando che volevo usare l’ESP32 per leggere 16 canali analogici pur avendo un solo ingresso analogico disponibile. ChatGPT ha fornito rapidamente diverse opzioni: multiplexer analogici, ADC esterni e persino la riassegnazione dei pin, se possibile. Alla fine ho scelto il multiplexer CD74HC4067. A quel punto non avevo ancora dettagliato l’architettura software desiderata né i requisiti di timing. Più avanti ho chiesto possibili design architetturali e un insieme di requisiti a supporto del mio obiettivo.
Passo 2: Design architetturale e requisiti
Ho quindi chiesto a ChatGPT di proporre un design architetturale per un modulo software che si interfacciasse con il CD74HC4067. Ha presentato un design a strati che separava astrazione hardware, controllo del multiplexer, lettura dell’ADC e integrazione dei task. La struttura era completa e organizzata, ma ho notato che mancavano dettagli sulle prestazioni real-time e sulla parametrizzazione a compile time. Ho quindi richiesto che ogni parametro regolabile fosse definito come direttiva del preprocessore, e che il design includesse un approccio basato su RTOS Task/Thread con una coda contenente un timestamp per ogni set di 16 letture di canale.
Passo 3: Aggiungere RTOS e timestamp
Con l’idea ormai ben delineata, ho confermato che avrei usato il CD74HC4067. Ho chiesto specificamente che venisse creato un RTOS task dedicato per scansionare in sequenza tutti i 16 canali e che ogni set completo di letture includesse un timestamp indicante il momento in cui la lettura era terminata. ChatGPT ha aggiornato il design di conseguenza, incorporando uno strato di gestione dell’ADC e del campionamento e specificando che il pacchetto dati avrebbe contenuto sia i dati dei canali sia il timestamp. A quel punto il design mi sembrava solido, ma servivano calcoli di timing più specifici.
Passo 4: Calcoli di timing e modalità di campionamento
Poi ho chiesto un calcolo di quanti millisecondi sarebbero serviti per leggere tutti i 16 canali. ChatGPT ha fornito diversi scenari di esempio basati su diversi ritardi di settling e di conversione dell’ADC. La spiegazione mi piaceva, ma ho spinto oltre: volevo aggiungere un parametro (impostato a compile time) che potesse assumere valori come Fast Mode, Moderate Mode o HighRes. Questo parametro avrebbe regolato automaticamente il settling time del multiplexer e il ritardo di conversione dell’ADC in base ai dati del datasheet e a misurazioni precedenti. Il design si è evoluto ulteriormente con questi requisiti aggiuntivi.
Passo 5: Implementazione completa del modulo
Ho quindi richiesto un modulo completo — sia i file .cpp che .h — che incorporasse tutti i requisiti fino a quel punto, incluse le funzioni di unit test. ChatGPT ha prodotto un modulo dettagliato, con tutti i parametri regolabili definiti come direttive del preprocessore e valori di timing reali scelti in base ai dati del datasheet (per esempio, usando un tempo di conversione di circa 11 µs per l’ESP32). Includeva persino gli unit test. Il modulo era completo, ma ho notato che il codice usava ancora alcune attese bloccanti (per esempio chiamate come ets_delay_us), inaccettabili per le mie esigenze real-time.
Passo 6: L’obbligo del non bloccante
Infine, ho insistito che non venisse usata alcuna attesa bloccante in nessun punto del codice: ogni ritardo di timing doveva essere gestito in modo non bloccante con un approccio a state machine, cedendo il controllo allo scheduler invece di fare busy-waiting. ChatGPT ha rivisto il design un’ultima volta, convertendo il task di campionamento in una state machine non bloccante che verifica il tempo trascorso e cede il controllo usando vTaskDelay(0) e taskYIELD(). Con questo, il design era completo e soddisfaceva tutti i miei requisiti.
Riflessioni sull’AI come system architect, developer e hardware developer
Guardando indietro, il percorso da una semplice descrizione del problema a un modulo completo e non bloccante mi ha insegnato dove l’AI aiuta davvero. Attraverso la conversazione iterativa, un’idea vaga è diventata un insieme dettagliato di requisiti e un’implementazione completa. L’AI può perdere sfumature hardware sottili o problemi di integrazione, ma l’iterazione rapida mi ha permesso di dedicare il mio tempo a rifinire il design invece di reinventare ogni dettaglio.
La mia conclusione: l’AI può agire sia come system architect sia come developer. Può suggerire design modulari, aiutare a definire requisiti funzionali e non funzionali e generare codice che rispetta vincoli real-time e non bloccanti. La supervisione umana resta essenziale per verificare e adattare l’output dell’AI all’hardware reale. Ma il workflow si muove più velocemente, lasciando più tempo per test, ottimizzazione e problem solving creativo.
Qui sotto trovate i requisiti e il codice completo.
Requisiti funzionali:
-
Inizializzazione hardware: il modulo deve inizializzare tutti i pin digitali dell’ESP32 per il controllo del multiplexer CD74HC4067 e configurare correttamente l’ADC.
-
Selezione del canale del multiplexer: il modulo deve consentire la selezione di qualsiasi canale (0–15) tramite uscite digitali.
-
Lettura ADC: il modulo deve eseguire conversioni ADC dall’uscita del multiplexer.
-
Task RTOS per il campionamento: un task FreeRTOS dedicato e non bloccante deve campionare in sequenza tutti i 16 canali.
-
Pacchettizzazione dei dati: dopo una scansione completa, il modulo deve impacchettare le 16 letture ADC insieme a un timestamp (in millisecondi) in un pacchetto dati.
-
Integrazione con la coda: ogni pacchetto dati deve essere pubblicato su una coda FreeRTOS per l’elaborazione asincrona.
-
Parametrizzazione: ogni parametro regolabile (intervallo di campionamento, ritardo di conversione ADC, ritardo di settling del multiplexer, lunghezza della coda, assegnazione dei pin, ecc.) deve essere definito come direttiva del preprocessore.
-
Parametro di modalità di campionamento: il modulo deve supportare un parametro selezionabile a compile time (MODE_FAST, MODE_MODERATE o MODE_HIGHRES) che regola le impostazioni di timing (come settling time e ritardo di conversione ADC) in base ai dati del datasheet e a misurazioni pratiche.
-
Funzionamento non bloccante: tutti i ritardi di timing devono essere implementati in modo non bloccante (con un design a state machine che cede il controllo allo scheduler) senza alcuna attesa bloccante.
-
Gestione degli errori: il modulo deve rilevare e segnalare errori (come fallimenti di inizializzazione hardware e overflow della coda).
Requisiti non funzionali:
-
Prestazioni real-time: il task di campionamento deve operare con jitter minimo e gli deve essere assegnata una priorità sufficiente.
-
Modularità e manutenibilità: il design deve separare controllo hardware, logica di campionamento e interfaccia RTOS per facilitare la manutenzione.
-
Efficienza: il modulo deve conservare cicli di CPU evitando operazioni bloccanti.
-
Scalabilità: il design deve consentire miglioramenti futuri (per esempio filtraggio o averaging aggiuntivi) con modifiche minime.
-
Testabilità: la funzionalità del modulo deve essere verificabile tramite unit test.
Codice finale
Di seguito il codice completo del modulo finale:
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