ESP32 on cheat mode – idea to working prototype in just 1 hour!


When working with electronics, going from an idea to a functioning prototype usually weeks. Designing schematics, sourcing libraries, writing code, debugging, and finally getting something that works can feel like a long and often frustrating journey. But what if you could cut that down to just one hour?

That was the challenge I set for myself recently: build a complete digital delay generator with a touchscreen interface, starting from scratch and finishing with a working prototype in just 60 minutes. Impossible? Not with ChatGPT! A year ago, this kind of rapid development workflow would have been unthinkable. Today, it’s very much possible.


The Project: Digital Delay Generator with Touchscreen Control

The goal was to create a digital delay generator, a device that can generate multiple output signals with precise timing relationships between them. I use such devices at work to trigger various scientific instruments (e.g. oscilloscopes, lasers, cameras etc) and often need the ability to vary the time delay between trigger signals on a nanosecond time scale despite the output waveforms being relatively low frequencies (kHz).

The design requirements were simple on paper:

  • Si5351 I2C clock generator: To enable precises timing.
  • Channel 0 (Master Frequency): Adjustable base frequency with selectable units (kHz, MHz).
  • Channel 1 and Channel 2 (Delays): Adjustable delay relative to Channel 0.
  • On-Screen Plot: A visual representation of the signals, oscilloscope-style.
  • Extra Features: Frequency multipliers and the ability to enable/disable outputs.

The whole interface would be controlled via a cheap yellow display (CYD), with the ESP32 handling the logic and an external clock generator chip producing the signals.


Step 1: Hardware Setup and Verification

The first 15 minutes of the project were spent wiring everything up. This was kept deliberately simple, just an I2C connection to the Si5351 (clock + data), 3.3 V power, and ground between the ESP32 and the clock generator. With no proper connectors at hand, I soldered wires directly to the socket. Messy, yes. Reliable, not really. But it worked.

Before diving into code, I tested each piece of hardware individually:

  • Touchscreen Display: Using the well-documented Random Nerd Tutorials examples, I loaded a simple “Hello World” sketch. The screen lit up, touch coordinates came through.
  • Clock Generator: Using Adafruit’s library, I ran a test script. The oscilloscope confirmed a clean waveform on all three channels.

With both the display and the generator verified, it was time to bring in ChatGPT.


Step 2: AI-Assisted Software Development

Here’s where things got interesting. Rather than manually piecing together code from scratch, I gave ChatGPT a detailed description of what I wanted: the hardware setup, links to the tutorials I used, and even a hand-drawn sketch of the planned user interface.

The first version of code ChatGPT produced was rough, the UI flickered badly, touch didn’t work, and no plots appeared. But the overall layout was correct: three input boxes, up/down buttons, and unit selectors.

From there, I went through several iterations:

  • Fixing button alignment and touch response.
  • Getting the waveform plots to display.
  • Integrating control of the clock generator chip.

What impressed me most was how ChatGPT handled hardware constraints. For example, it immediately flagged that the clock chip couldn’t generate a 1 kHz signal, explaining its real limits (7 kHz to hundreds of MHz). That alone saved me a lot of time.

Not everything was smooth. At one point, ChatGPT suggested switching to the LVGL graphics library. That led to a frustrating rabbit hole before I abandoned it and went back to simpler graphics. Lesson learned: sometimes “good enough” is better than “fancy.”


Step 3: Adding Features and Polishing

By the 45-minute mark, the basics were working: the touchscreen adjusted the master frequency, the oscilloscope confirmed the output, and delay settings shifted the waveforms as expected.

With a bit of time left, I pushed further:

  • Submenu System: Added a new screen with extra options.
  • Frequency Multipliers: Channel 1 and 2 could now run at multiples or fractions of Channel 0.
  • Output Control: A button to instantly enable or disable outputs.

Finally, I tidied up the interface with a dark mode theme. The result looked surprisingly sleek for something assembled so quickly.


The Finished Prototype

At the one-hour mark, the ESP32-based digital delay generator was fully functional:

  • Master Frequency: Adjustable from 10 kHz to 10 MHz (software-limited).
  • Delays: Clearly visible phase shifts on the oscilloscope as delay values were increased.
  • Multipliers: Channels scaled relative to the master frequency.
  • Output Control: On-screen toggle instantly switched waveforms on/off.

The final sketch was about 420 lines of code, something I could never have typed and debugged manually in that time frame! Below I’ve provided the final version of the code for you to have a play around with!

#include <SPI.h>
#include <Wire.h>
#include <TFT_eSPI.h>               // https://github.com/Bodmer/TFT_eSPI
#include <XPT2046_Touchscreen.h>    // https://github.com/PaulStoffregen/XPT2046_Touchscreen
#include <si5351.h>                 // Etherkit Si5351Arduino

/*
  Digital Delay Generator v3
  • CH0: 0.01–10.00 MHz, ◀▶ select digit, ▲▼ adjust
  • CH1/CH2: 0–127 phase steps (¼ VCO cycle each), ▲▼ step ±1
      Actual delay = phase/(4·Fvco) → displayed in ns
  • Options → Frequency Control (M1/M2 multipliers), ◀▶ select digit, ▲▼ adjust
      + Toggle outputs On/Off (with dynamic label & color)
  • Contrast-boosted value text for daylight visibility
*/

#define TS_CS       33
#define TS_IRQ      36
#define TOUCH_MOSI  32
#define TOUCH_MISO  39
#define TOUCH_CLK   25
#define I2C_SDA_PIN 22
#define I2C_SCL_PIN 27

#define TS_MINX   200
#define TS_MINY   200
#define TS_MAXX  3800
#define TS_MAXY  3800
#define Z_THRESH    8

SPIClass            touchscreenSPI(VSPI);
TFT_eSPI            tft = TFT_eSPI();
XPT2046_Touchscreen ts(TS_CS, TS_IRQ);
Si5351              si5351;

// VCO ~900 MHz → one phase LSB = T_VCO/4 ≈ 0.278 ns
static constexpr float Fvco = 25e6f * 36.0f;

// Layout
static const int SCREEN_W=320, SCREEN_H=240;
static const int TITLE_H=20;
static const int ROW_H=28, ROW_SP=8;
static const int BUTTON_W=24, BUTTON_H=ROW_H, BUTTON_SP=6;
static const int UNIT_W=34, X0=10;
static int VBW, rowY[3];
static const int PLOT_X=0, PLOT_Y=TITLE_H + (SCREEN_H-TITLE_H)/2;
static const int PLOT_W=SCREEN_W, PLOT_H=SCREEN_H-PLOT_Y;

// Colours
#define C_BG       TFT_WHITE
#define C_TITLE    TFT_BLACK
#define C_HILITE   TFT_ORANGE
#define C_VALBOX   TFT_DARKGREY
#define C_VALTXT   TFT_WHITE      // improved contrast
#define C_BTN      TFT_LIGHTGREY
#define C_BTN_FG   TFT_BLACK
#define C_AXIS     TFT_BLACK
#define C_CH0      TFT_ORANGE
#define C_CH1      TFT_CYAN
#define C_CH2      TFT_MAGENTA

// State
static float freqMHz=1.00f;
static int   cursorMain[3]={0,0,0};
static uint8_t phase1=0, phase2=0;
static int     d1ns=0, d2ns=0;
static float m1=1.00f, m2=1.00f;
static int   cursorSub[3]={0,1,2};
static bool outputsEnabled = true;
bool inSubMenu=false;

inline bool inRect(int x,int y,int rx,int ry,int rw,int rh){
  return x>=rx && x<rx+rw && y>=ry && y<ry+rh;
}
inline float getHz0(){ return freqMHz*1e6f; }

// ── DRAW FUNCTIONS ────────────────────────────────────────────────────────────

void drawMainTitle(){
  tft.fillRect(0,0,SCREEN_W,TITLE_H,C_BG);
  tft.setTextFont(2); tft.setTextColor(C_TITLE);
  tft.setTextDatum(ML_DATUM);
  tft.drawString("Digital Delay Generator",5,TITLE_H/2,2);
  int bx=SCREEN_W-75, by=1;
  tft.fillRoundRect(bx,by,70,18,4,C_BTN);
  tft.setTextColor(C_BTN_FG);
  tft.setTextDatum(MC_DATUM);
  tft.drawString("Options",bx+35,by+9,2);
}

void drawSubTitle(){
  tft.fillRect(0,0,SCREEN_W,TITLE_H,C_BG);
  tft.setTextFont(2); tft.setTextColor(C_TITLE);
  tft.setTextDatum(MC_DATUM);
  tft.drawString("Frequency Control",SCREEN_W/2,TITLE_H/2,2);
}

void drawMainRow(int idx){
  int y=rowY[idx], vx;
  const int aw=12, ah=6;
  if(idx==0){
    int x0=X0;
    tft.fillRoundRect(x0,y,BUTTON_W,BUTTON_H,4,C_BTN);
    tft.fillTriangle(x0+aw/2,y+14, x0+aw/2+aw/2,y+14-ah/2, x0+aw/2+aw/2,y+14+ah/2,C_BTN_FG);
    int x1=x0+BUTTON_W+BUTTON_SP;
    tft.fillRoundRect(x1,y,BUTTON_W,BUTTON_H,4,C_BTN);
    tft.fillTriangle(x1+BUTTON_W-aw/2,y+14, x1+BUTTON_W-aw/2-aw/2,y+14-ah/2, x1+BUTTON_W-aw/2-aw/2,y+14+ah/2,C_BTN_FG);
    vx=x1+BUTTON_W+BUTTON_SP;
  } else vx=X0;

  tft.fillRoundRect(vx,y,VBW,ROW_H,4,C_VALBOX);
  tft.setTextFont(2);
  tft.setTextColor(C_TITLE,C_VALBOX);
  tft.setTextDatum(ML_DATUM);
  tft.drawString(idx==0?"Ch0":idx==1?"Ch1":"Ch2",vx+5,y+14,2);

  char buf[8];
  if(idx==0)      snprintf(buf,sizeof(buf),"%.2f",freqMHz);
  else if(idx==1) snprintf(buf,sizeof(buf),"%03d",d1ns);
  else            snprintf(buf,sizeof(buf),"%03d",d2ns);

  int len=strlen(buf);
  tft.setTextFont(4);
  int cw=tft.textWidth("0",4), th=tft.fontHeight(4);
  int yC=y+(ROW_H-th)/2;
  int mapIdx=(idx==0?(cursorMain[0]==0?0:cursorMain[0]==1?2:3):-1);

  for(int i=0;i<len;i++){
    int dx=vx+VBW-5-cw*len + i*cw + cw/2;
    tft.setTextColor(i==mapIdx?C_HILITE:C_VALTXT,C_VALBOX);
    tft.setTextDatum(MC_DATUM);
    tft.drawChar(buf[i],dx,yC,4);
  }

  int x2=vx+VBW+BUTTON_SP;
  tft.fillRoundRect(x2,y,BUTTON_W,BUTTON_H,4,C_BTN);
  tft.fillTriangle(x2+BUTTON_W/2,y+6, x2+BUTTON_W/2-aw/2,y+6+ah, x2+BUTTON_W/2+aw/2,y+6+ah,C_BTN_FG);
  int x3=x2+BUTTON_W+BUTTON_SP;
  tft.fillRoundRect(x3,y,BUTTON_W,BUTTON_H,4,C_BTN);
  tft.fillTriangle(x3+BUTTON_W/2,y+ROW_H-6, x3+BUTTON_W/2-aw/2,y+ROW_H-6-ah, x3+BUTTON_W/2+aw/2,y+ROW_H-6-ah,C_BTN_FG);

  int ux=x3+BUTTON_W+BUTTON_SP;
  tft.setTextFont(2);
  tft.setTextColor(C_TITLE);
  tft.drawString(idx==0?"MHz":"ns",ux+UNIT_W/2,y+14,2);
}

void updateControls(){
  drawMainRow(0);
  drawMainRow(1);
  drawMainRow(2);
}

void drawSubRow(int idx){
  int y=rowY[idx], vx=X0;
  const int aw=12, ah=6;

  tft.fillRoundRect(vx,y,VBW,ROW_H,4,C_VALBOX);
  tft.setTextFont(2);
  tft.setTextColor(C_TITLE,C_VALBOX);
  tft.drawString(idx==0?"M1":"M2",vx+5,y+14,2);

  char buf[8]; snprintf(buf,sizeof(buf),"%.2f", idx==0?m1:m2);
  int len=strlen(buf);
  tft.setTextFont(4);
  int cw=tft.textWidth("0",4), th=tft.fontHeight(4);
  int yC=y+(ROW_H-th)/2;
  int mapIdx = idx==0?
    (cursorSub[0]==0?0:cursorSub[0]==1?2:3)
    :(cursorSub[1]==0?0:cursorSub[1]==1?2:3);

  for(int i=0;i<len;i++){
    int dx=vx+VBW-5-cw*len + i*cw + cw/2;
    tft.setTextColor(i==mapIdx?C_HILITE:C_VALTXT,C_VALBOX);
    tft.setTextDatum(MC_DATUM);
    tft.drawChar(buf[i],dx,yC,4);
  }

  int x0=vx;
  tft.fillRoundRect(x0,y,BUTTON_W,BUTTON_H,4,C_BTN);
  tft.fillTriangle(x0+aw/2,y+14, x0+aw/2+aw/2,y+14-ah/2, x0+aw/2+aw/2,y+14+ah/2,C_BTN_FG);
  int x1=x0+BUTTON_W+BUTTON_SP;
  tft.fillRoundRect(x1,y,BUTTON_W,BUTTON_H,4,C_BTN);
  tft.fillTriangle(x1+BUTTON_W-aw/2,y+14, x1+BUTTON_W-aw/2-aw/2,y+14-ah/2, x1+BUTTON_W-aw/2-aw/2,y+14+ah/2,C_BTN_FG);

  int x2=vx+VBW+BUTTON_SP, x3=x2+BUTTON_W+BUTTON_SP;
  tft.fillRoundRect(x2,y,BUTTON_W,BUTTON_H,4,C_BTN);
  tft.fillTriangle(x2+BUTTON_W/2,y+6, x2+BUTTON_W/2-aw/2,y+6+ah, x2+BUTTON_W/2+aw/2,y+6+ah,C_BTN_FG);
  tft.fillRoundRect(x3,y,BUTTON_W,BUTTON_H,4,C_BTN);
  tft.fillTriangle(x3+BUTTON_W/2,y+ROW_H-6, x3+BUTTON_W/2-aw/2,y+ROW_H-6-ah, x3+BUTTON_W/2+aw/2,y+ROW_H-6-ah,C_BTN_FG);
}

void updateSubMenu(){
  drawSubTitle();
  drawSubRow(0);
  drawSubRow(1);

  int by=SCREEN_H-28;
  // Back
  tft.fillRoundRect(5,by,60,24,4,C_BTN);
  tft.setTextFont(2);
  tft.setTextColor(C_BTN_FG);
  tft.drawString("⏎ Back",35,by+12,2);

  // Outputs toggle
  int bw=140, tx=SCREEN_W-bw-5;
  uint16_t bg = outputsEnabled ? TFT_GREEN : TFT_RED;
  tft.fillRoundRect(tx,by,bw,24,4,bg);
  tft.setTextFont(2);
  tft.setTextColor(TFT_WHITE);
  tft.drawString(outputsEnabled?"Output Enabled":"Output Disabled", tx+bw/2, by+12,2);
}

void drawPlot(){
  tft.fillRect(PLOT_X,PLOT_Y,PLOT_W,PLOT_H,C_BG);
  tft.drawRect(PLOT_X,PLOT_Y,PLOT_W,PLOT_H,C_AXIS);
  float f0=getHz0();
  float freqs[3]={f0,f0*m1,f0*m2};
  uint16_t cols[3]={C_CH0,C_CH1,C_CH2};
  int bandY[3]={PLOT_Y+PLOT_H/6,PLOT_Y+PLOT_H/2,PLOT_Y+5*PLOT_H/6};
  float delays[3]={0.0f,d1ns*1e-9f,d2ns*1e-9f};

  for(int ch=0;ch<3;ch++){
    float T=1.0f/freqs[ch], halfPx=(PLOT_W/2.0f)/(ch==0?1.0f:(ch==1?m1:m2));
    float ofs=constrain((delays[ch]/T)*halfPx,0,halfPx);
    int yC=bandY[ch], yH=yC-6, yL=yC+6;
    for(int cyc=0;cyc<2;cyc++){
      float bx=PLOT_X+cyc*2*halfPx, mx=bx+halfPx;
      tft.drawLine(bx+ofs,yL,bx+ofs,yH,cols[ch]);
      tft.drawLine(bx+ofs,yH,mx+ofs,yH,cols[ch]);
      tft.drawLine(mx+ofs,yH,mx+ofs,yL,cols[ch]);
      tft.drawLine(mx+ofs,yL,bx+2*halfPx+ofs,yL,cols[ch]);
    }
  }
  tft.setTextFont(2); tft.setTextColor(C_TITLE);
  tft.setTextDatum(MC_DATUM);
  tft.drawString("Δ1:"+String(d1ns)+"ns",SCREEN_W/4,PLOT_Y+PLOT_H-10,2);
  tft.drawString("Δ2:"+String(d2ns)+"ns",3*SCREEN_W/4,PLOT_Y+PLOT_H-10,2);
}

void applySi5351(){
  uint64_t h0=(uint64_t)(getHz0()*SI5351_FREQ_MULT);
  si5351.set_freq(h0,SI5351_CLK0);
  si5351.set_freq(h0*m1,SI5351_CLK1);
  si5351.set_freq(h0*m2,SI5351_CLK2);

  si5351.set_phase(SI5351_CLK1,phase1);
  si5351.set_phase(SI5351_CLK2,phase2);
  si5351.pll_reset(SI5351_PLLA);
  si5351.pll_reset(SI5351_PLLB);

  d1ns = int(round(phase1 * 1e9f / (4.0f * Fvco)));
  d2ns = int(round(phase2 * 1e9f / (4.0f * Fvco)));

  si5351.output_enable(SI5351_CLK0, outputsEnabled);
  si5351.output_enable(SI5351_CLK1, outputsEnabled);
  si5351.output_enable(SI5351_CLK2, outputsEnabled);
}

void setup(){
  Serial.begin(115200);
  Wire.begin(I2C_SDA_PIN,I2C_SCL_PIN);
  SPI.begin();
  touchscreenSPI.begin(TOUCH_CLK,TOUCH_MISO,TOUCH_MOSI,TS_CS);
  tft.begin(); tft.setRotation(1);
  ts.begin(touchscreenSPI); ts.setRotation(1);

  if(!si5351.init(SI5351_CRYSTAL_LOAD_8PF,0,0)){
    Serial.println("SI5351 not detected!"); while(1) yield();
  }

  VBW=SCREEN_W-2*X0-3*(BUTTON_W+BUTTON_SP)-UNIT_W;
  drawMainTitle();
  rowY[0]=TITLE_H+ROW_SP;
  rowY[1]=rowY[0]+ROW_H+ROW_SP;
  rowY[2]=rowY[1]+ROW_H+ROW_SP;
  updateControls();
  drawPlot();
  applySi5351();
}

void loop(){
  if(!(ts.tirqTouched()&&ts.touched())) return;
  TS_Point p=ts.getPoint(); if(p.z<Z_THRESH) return;
  int x=map(p.x,TS_MINX,TS_MAXX,0,SCREEN_W),
      y=map(p.y,TS_MINY,TS_MAXY,0,SCREEN_H);
  bool redraw=false;

  if(!inSubMenu){
    int bx=SCREEN_W-75,by=1;
    if(inRect(x,y,bx,by,70,18)){
      inSubMenu=true;
      tft.fillScreen(C_BG);
      drawSubTitle();
      updateSubMenu();
      return;
    }
    for(int i=0;i<3;i++){
      int y0=rowY[i];
      int vx=(i==0?X0+2*(BUTTON_W+BUTTON_SP):X0);
      int x2=vx+VBW+BUTTON_SP, x3=x2+BUTTON_W+BUTTON_SP;
      if(inRect(x,y,x2,y0,BUTTON_W,BUTTON_H)){
        if(i==0){
          float st=(cursorMain[0]==0?1:cursorMain[0]==1?0.1:0.01);
          freqMHz=constrain(freqMHz+st,0.01f,10.00f);
        } else if(i==1){ if(phase1<127) phase1++; }
          else{ if(phase2<127) phase2++; }
        redraw=true; break;
      }
      if(inRect(x,y,x3,y0,BUTTON_W,BUTTON_H)){
        if(i==0){
          float st=(cursorMain[0]==0?1:cursorMain[0]==1?0.1:0.01);
          freqMHz=constrain(freqMHz-st,0.01f,10.00f);
        } else if(i==1){ if(phase1>0) phase1--; }
          else{ if(phase2>0) phase2--; }
        redraw=true; break;
      }
      if(i==0){
        int x0=X0, x1=x0+BUTTON_W+BUTTON_SP;
        if(inRect(x,y,x0,y0,BUTTON_W,BUTTON_H)){
          cursorMain[0]=max(0,cursorMain[0]-1); redraw=true; break;
        }
        if(inRect(x,y,x1,y0,BUTTON_W,BUTTON_H)){
          cursorMain[0]=min(2,cursorMain[0]+1); redraw=true; break;
        }
      }
    }
    if(redraw){
      applySi5351();
      updateControls();
      drawPlot();
    }
  } else {
    int by=SCREEN_H-28;
    if(inRect(x,y,5,by,60,24)){
      inSubMenu=false;
      tft.fillScreen(C_BG);
      drawMainTitle();
      updateControls();
      drawPlot();
      applySi5351();
      return;
    }
    int bw=140, tx=SCREEN_W-bw-5;
    if(inRect(x,y,tx,by,bw,24)){
      outputsEnabled = !outputsEnabled;
      redraw=true;
    }
    for(int i=0;i<2;i++){
      int y0=rowY[i];
      int x0=X0, x1=x0+BUTTON_W+BUTTON_SP;
      int vx=X0;
      int x2=vx+VBW+BUTTON_SP, x3=x2+BUTTON_W+BUTTON_SP;
      if(inRect(x,y,x0,y0,BUTTON_W,BUTTON_H)){
        cursorSub[i]=max(0,cursorSub[i]-1); redraw=true; break;
      }
      if(inRect(x,y,x1,y0,BUTTON_W,BUTTON_H)){
        cursorSub[i]=min(2,cursorSub[i]+1); redraw=true; break;
      }
      if(inRect(x,y,x2,y0,BUTTON_W,BUTTON_H)){
        float st=(cursorSub[i]==0?1:cursorSub[i]==1?0.1:0.01);
        if(i==0) m1=constrain(m1+st,0.01f,10.00f);
        else     m2=constrain(m2+st,0.01f,10.00f);
        redraw=true; break;
      }
      if(inRect(x,y,x3,y0,BUTTON_W,BUTTON_H)){
        float st=(cursorSub[i]==0?1:cursorSub[i]==1?0.1:0.01);
        if(i==0) m1=constrain(m1-st,0.01f,10.00f);
        else     m2=constrain(m2-st,0.01f,10.00f);
        redraw=true; break;
      }
    }
    if(redraw){
      tft.fillRect(0,TITLE_H,SCREEN_W,SCREEN_H-TITLE_H,C_BG);
      drawSubTitle();
      updateSubMenu();
      applySi5351();
    }
  }

  while(ts.touched()) delay(10);
}

Summary

This project wasn’t really about building a digital delay generator, it was about exploring a new workflow. The combination of ESP32 hardware, a simple touchscreen, and AI-assisted coding allowed me to go from an idea to a working prototype in just an hour.

The end result is close enough to a marketable product that, with a little more refinement, it could be commercialised. More importantly, it shows how dramatically prototyping has changed. If you’re an engineer, hobbyist, or student, I’d strongly encourage you to try this workflow yourself. It’s fast, fun, and incredibly versatile!