Du concept au code : mon parcours itératif vers un module de multiplexage avec ChatGPT
Le prompting itératif avec ChatGPT a transformé un problème matériel d’une ligne en un module multiplexeur complet et non bloquant pour l’ESP32 — cet article montre comment, étape par étape. Tout a commencé avec une contrainte simple : j’avais un ESP32 avec une seule broche analogique accessible, mais je devais lire 16 canaux analogiques. J’ai expliqué ma situation au modèle ChatGPT o3-mini-high : « Je n’ai qu’un seul canal, mais je dois lire 16 canaux. » ChatGPT a répondu avec plusieurs options — ADC externes, multiplexage, et plus encore. J’ai examiné les choix et décidé qu’un multiplexeur analogique, plus précisément le CD74HC4067, était la meilleure voie. Ce choix a ouvert une discussion beaucoup plus profonde.
Étape 1 : Le souhait initial
J’ai commencé en déclarant que je voulais utiliser l’ESP32 pour lire 16 canaux analogiques alors que je n’avais qu’une seule entrée analogique disponible. ChatGPT a rapidement fourni plusieurs options : multiplexeurs analogiques, ADC externes, et même une réaffectation des broches si possible. Finalement, j’ai choisi le multiplexeur CD74HC4067. À ce stade, je n’avais pas encore détaillé l’architecture logicielle souhaitée ni les exigences de timing. Plus tard, j’ai demandé des conceptions d’architecture possibles et un ensemble d’exigences pour soutenir mon objectif.
Étape 2 : Conception d’architecture et exigences
J’ai ensuite demandé à ChatGPT de proposer une conception d’architecture pour un module logiciel qui s’interfacerait avec le CD74HC4067. Il a présenté une conception en couches séparant l’abstraction matérielle, le contrôle du multiplexeur, la lecture de l’ADC et l’intégration des tâches. La structure était complète et organisée, mais j’ai remarqué qu’il manquait des détails sur les performances temps réel et la paramétrisation à la compilation. J’ai donc demandé que chaque paramètre ajustable soit défini comme une directive de préprocesseur, et que la conception inclue une approche RTOS Task/Thread avec une file d’attente contenant un timestamp pour chaque ensemble de 16 lectures de canaux.
Étape 3 : Ajout du RTOS et des timestamps
L’idée prenant maintenant clairement forme, j’ai confirmé que j’allais utiliser le CD74HC4067. J’ai spécifiquement demandé qu’une tâche RTOS dédiée soit créée pour scanner séquentiellement les 16 canaux et que chaque ensemble complet de lectures inclue un timestamp indiquant quand la lecture était terminée. ChatGPT a mis à jour la conception en conséquence, en incorporant une couche de gestion ADC & échantillonnage et en précisant que le paquet de données contiendrait à la fois les données des canaux et le timestamp. À ce stade, je trouvais la conception solide mais elle avait encore besoin de calculs de timing plus précis.
Étape 4 : Calculs de timing et modes d’échantillonnage
Ensuite, j’ai demandé un calcul du nombre de millisecondes nécessaires pour lire les 16 canaux. ChatGPT a fourni plusieurs scénarios d’exemple basés sur différents délais de stabilisation et de conversion ADC. L’explication me plaisait, mais j’ai poussé plus loin : je voulais ajouter un paramètre (défini à la compilation) qui pourrait prendre des valeurs comme Fast Mode, Moderate Mode ou HighRes. Ce paramètre ajusterait automatiquement le temps de stabilisation du multiplexeur et le délai de conversion ADC selon les chiffres de la datasheet et des mesures antérieures. La conception a encore évolué avec ces exigences ajoutées.
Étape 5 : Implémentation complète du module
J’ai alors demandé un module complet — fichiers .cpp et .h — intégrant toutes les exigences jusqu’ici, y compris des fonctions de test unitaire. ChatGPT a produit un module détaillé, avec tous les paramètres ajustables définis comme directives de préprocesseur et des valeurs de timing réelles choisies d’après les données de la datasheet (par exemple, un temps de conversion d’environ 11 µs pour l’ESP32). Il était même livré avec des tests unitaires. Le module était complet, mais j’ai remarqué que le code utilisait encore des attentes bloquantes (par exemple, des appels comme ets_delay_us), ce qui n’était pas acceptable pour mes besoins temps réel.
Étape 6 : L’exigence non bloquante
Finalement, j’ai insisté pour qu’aucune attente bloquante ne soit utilisée nulle part dans le code : chaque délai de timing devait s’exécuter de manière non bloquante avec une approche par machine à états, le code rendant la main au scheduler plutôt que de faire du busy-waiting. ChatGPT a révisé la conception une dernière fois, convertissant la tâche d’échantillonnage en une machine à états non bloquante qui vérifie le temps écoulé et cède le contrôle avec vTaskDelay(0) et taskYIELD(). Avec cela, la conception était complète et répondait à toutes mes exigences.
Réflexions sur l’IA comme architecte système, développeur et développeur matériel
Avec le recul, le chemin d’un simple énoncé de problème à un module complet et non bloquant m’a appris où l’IA aide réellement. Par la conversation itérative, une idée vague est devenue un ensemble détaillé d’exigences et une implémentation complète. L’IA peut manquer des nuances matérielles subtiles ou des problèmes d’intégration, mais l’itération rapide m’a permis de consacrer mon temps à affiner la conception plutôt qu’à réinventer chaque détail.
Ce que j’en retiens : l’IA peut agir à la fois comme architecte système et comme développeur. Elle peut suggérer des conceptions modulaires, aider à définir des exigences fonctionnelles et non fonctionnelles, et générer du code qui respecte des contraintes temps réel non bloquantes. La supervision humaine reste essentielle pour vérifier et adapter la sortie de l’IA au matériel réel. Mais le workflow avance plus vite, laissant plus de temps pour les tests, l’optimisation et la résolution créative de problèmes.
Vous pouvez voir les exigences et le code complet ci-dessous.
Exigences fonctionnelles :
-
Initialisation matérielle : le module doit initialiser toutes les broches numériques de l’ESP32 pour contrôler le multiplexeur CD74HC4067 et configurer correctement l’ADC.
-
Sélection de canal du multiplexeur : le module doit permettre la sélection de n’importe quel canal (0–15) via des sorties numériques.
-
Lecture ADC : le module doit effectuer des conversions ADC depuis la sortie du multiplexeur.
-
Tâche RTOS pour l’échantillonnage : une tâche FreeRTOS dédiée et non bloquante doit échantillonner séquentiellement les 16 canaux.
-
Empaquetage des données : après un scan complet, le module doit empaqueter les 16 lectures ADC avec un timestamp (en millisecondes) dans un paquet de données.
-
Intégration de file d’attente : chaque paquet de données doit être posté dans une file FreeRTOS pour un traitement asynchrone.
-
Paramétrisation : chaque paramètre ajustable (intervalle d’échantillonnage, délai de conversion ADC, délai de stabilisation du multiplexeur, longueur de file, affectations des broches, etc.) doit être défini comme une directive de préprocesseur.
-
Paramètre de mode d’échantillonnage : le module doit supporter un paramètre sélectionnable à la compilation (MODE_FAST, MODE_MODERATE ou MODE_HIGHRES) qui ajuste les réglages de timing (comme le temps de stabilisation et le délai de conversion ADC) d’après les données de la datasheet et des mesures pratiques.
-
Fonctionnement non bloquant : tous les délais de timing doivent être implémentés de manière non bloquante (avec une conception par machine à états et en rendant la main au scheduler) sans aucune attente bloquante.
-
Gestion des erreurs : le module doit détecter et signaler les erreurs (comme les échecs d’initialisation matérielle et les débordements de file).
Exigences non fonctionnelles :
-
Performance temps réel : la tâche d’échantillonnage doit fonctionner avec un jitter minimal et se voir attribuer une priorité suffisante.
-
Modularité & maintenabilité : la conception doit séparer le contrôle matériel, la logique d’échantillonnage et l’interfaçage RTOS pour faciliter la maintenance.
-
Efficacité : le module doit économiser les cycles CPU en évitant les opérations bloquantes.
-
Scalabilité : la conception doit permettre des améliorations futures (p. ex. filtrage ou moyennage supplémentaires) avec des changements minimaux.
-
Testabilité : la fonctionnalité du module doit être vérifiable par des tests unitaires.
Code final
Voici le code complet du module 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