ESP32 Based Differential Pressure Monitor

This design uses a MPXV7002DP for the pressure transducer and includes Web Graphing, LCD Display. I will add alarms and email but this is a starting point.

I place the device between the output of the laser and the fan (at our maker space) to take the exhaust air outside. We were having a problem with people not turning on the fan when the laser was operational or leaving the fan on (and sucking the maker space air through the laser) when the laser was turned off. Hopefully, this post will help you understand the project.

Project Overview

This project creates a network-connected differential pressure monitor with:

  • ±2 kPa measurement range
  • 5-minute sampling
  • 5-hour historical graph (60 points)
  • High & Low alarm thresholds
  • Physical zero-calibration button
  • Web-based calibration initiation button
  • 16×2 LCD display
  • WiFi auto configuration portal
  • Email alert via Gmail SMTP – planned
  • EEPROM storage of configuration
  • Connected via USB cable for power

System Features

Measurement

  • Reads analog output from MPXV7002DP
  • Voltage scaled safely for ESP32
  • 64-sample averaging (with the highest and lowest thown out for noise reduction)
  • Calibration offset stored in EEPROM

Web Interface

  • Chart.- line graph
  • Threshold configuration
  • Email address configuration – planned
  • Zero calibration button
  • Alarm status display – planned

LCD Display Interface

  • Line 1: IP Address
  • Line 2: Pressure or ALARM message

Physical Requirements

Main Components

ItemNotes
ESP32 Dev Module3.3V logic
MPXV7002DP Sensor5V supply
16×2 I2C LCDPCF8574 backpack
Push ButtonZero calibration
Logic levelerConvert from 5V to 3.3V

Electrical Design

Image

Sensor Output Scaling

MPXV7002DP sensor output:

  • 0.5V to 4.5V
  • Center = 2.5V (0 kPa)

Wiring Diagram (Simplified)

ComponentESP32 PinNotes
Pressure SensorADC Pin 34Connected via voltage divider
LCD (I2C)SDA (21),
SCL (22)
Needs 5V VCC
Zero ButtonGPIO 25Wired between pin and GND (using internal pull-up).
Logic Leveler Converts 5V signals to and from the LCD I2C
Resistors Voltage divider to get the sensor signal down to 3.3V range

Resistors are actually 20KΩ and 11KΩ, because that is what I had lying around. The graphic has an illustration for a pressure sensor that was already in Fritzing.

Firmware

Required Libraries

Install:

  • WiFiManager
  • WebServer
  • LiquidCrystal_I2C

ESP32 code

Not all the code is included here but it should give you a good idea of what I am doing.

main

#include <Arduino.h>
#include <LiquidCrystal_I2C.h>
#include "secrets.h"
#include <softpause.h>
#include <WiFi.h>
#include "PressureTransducer.h"
#include <WebServer.h>
#include <Preferences.h>
// #include <ESP_Mail_Client.h>

// ===================== PINS & CONSTANTS =====================
#define ADC_PIN 34
#define ZERO_BUTTON 25
#define ALARM_PIN 26

const int HISTORYLENGTH = 60; // Store 60 samples of history at 1 sample per 5 minutes

// ===================== GLOBALS =====================
LiquidCrystal_I2C lcd(0x27, 16, 2);
PressureTransducer transducer(ADC_PIN);
WebServer server(80);

// AsyncWebServer server(80);
Preferences prefs;
// SMTPSession smtp;

float pressureHistory[HISTORYLENGTH];
float calibrationOffset = 0;
float calibrationFanOn = 0;
float fanThreshold = .05;
float highThreshold = 1.0;
float lowThreshold = -1.0;
bool alarmActive = false;
bool emailPending = false;
float lastPressure = 0;
unsigned long lastSampleTime = 0;
unsigned long lastHistoryTime = 0;
float maxPressure = -999.0;  // Track highest pressure since last calibration
float minPressure = 999.0;   // Track lowest pressure since last calibration

// Button hold detection variables
unsigned long buttonPressTime = 0;
bool buttonHeldNotified = false;
const unsigned long BUTTON_HOLD_TIME = 5000; // 5 seconds in milliseconds

// ===================== CORE LOGIC =====================

void saveSettings() {
  prefs.begin("monitor", false);
  prefs.putFloat("calOffset", calibrationOffset);
  prefs.putFloat("fanOn", calibrationFanOn);
  prefs.putFloat("maxP", maxPressure);
  prefs.putFloat("minP", minPressure);
  prefs.end();
}

void loadSettings() {
  prefs.begin("monitor", true);
  calibrationOffset = prefs.getFloat("calOffset", 0.0);
  calibrationFanOn = prefs.getFloat("fanOn", 0.0);
  maxPressure = prefs.getFloat("maxP", -999.0);
  minPressure = prefs.getFloat("minP", 999.0);
  prefs.end();
}

void calibrateSensor()
{
  // Reset min/max on calibration
  maxPressure = -999.0;
  minPressure = 999.0;
  
  // setup up calibdration offset
  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.println("Turn off the fan.");
  lcd.setCursor(0, 1);
  lcd.println("Press the button.");
  softPause(2000); // give user time to read instructions
  while (digitalRead(ZERO_BUTTON) == HIGH)
  {
    // wait for button press
    softPause(100);
  }
  // setup the base pressure
  calibrationOffset = transducer.readPressure();
  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.println("Baseline set:   ");
  lcd.setCursor(0, 1);
  lcd.println(calibrationOffset, 2);
  lcd.print(" kPa   ");
  Serial.println("Baseline: " + String(calibrationOffset) + " kPa");
  softPause(1000);
  calibrationFanOn = 0;
  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.println("Turn on the fan.");
  while (calibrationFanOn == 0)
  {
    lcd.setCursor(0, 1);
    lcd.println("Press the button.");
    while (digitalRead(ZERO_BUTTON) == HIGH)
    {
      // wait for button press
      softPause(100);
    }
    float tempPressure = transducer.readPressure();
    Serial.println("Delta Pressure: " + String(tempPressure+fanThreshold) + " kPa");
    Serial.println("calibrationOffset Pressure: " + String(calibrationOffset) + " kPa");
    if (tempPressure+fanThreshold >= calibrationOffset)
    {
      lcd.setCursor(0, 0);
      lcd.println("Check fan!      ");
    }
    else
    {
      calibrationFanOn = tempPressure;
    }
  }
  saveSettings();
  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.print("IP: " + WiFi.localIP().toString());
  lcd.println(calibrationFanOn, 2);
  lcd.print(" kPa   ");
}

// Web server handlers
void handleRoot() {
  String html = "<html><head><title>Pressure Monitor</title><style>";
  html += "body { font-family: Arial; margin: 20px; }";
  html += "h1 { color: #333; }";
  html += "p { font-size: 18px; }";
  html += "button { padding: 10px 20px; font-size: 16px; background-color: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; }";
  html += "button:hover { background-color: #45a049; }";
  html += ".stats { background-color: #f0f0f0; padding: 15px; border-radius: 4px; margin: 20px 0; }";
  html += ".chart-container { width: 100%; max-width: 800px; margin: 20px auto; }";
  html += "</style></head><body>";
  html += "<h1>Pressure Monitor</h1>";
  html += "<div class='stats'>";
  html += "<p>Current Pressure: <strong id='currentPressure'>" + String(lastPressure, 2) + "</strong> kPa</p>";
  html += "<p>Max Pressure: <strong id='maxPressure'>" + String(maxPressure, 2) + "</strong> kPa</p>";
  html += "<p>Min Pressure: <strong id='minPressure'>" + String(minPressure, 2) + "</strong> kPa</p>";
  html += "<p>Time since power-up: <strong id='uptime'>Calculating...</strong></p>";
  html += "</div>";
  html += "<p>Calibration Offset: <strong id='calOffset'>" + String(calibrationOffset, 2) + "</strong> kPa</p>";
  html += "<p>Fan Threshold: <strong id='fanThreshold'>" + String(calibrationFanOn, 2) + "</strong> kPa</p>";
  html += "</div>"; // close stats
  html += "<div class='chart-container'><canvas id='pressureChart' width='800' height='300'></canvas></div>";
  html += "<form action='/calibrate' method='POST'>";
  html += "<button type='submit'>Calibrate</button>";
  html += "</form>";
  html += "https://cdn.jsdelivr.net/npm/chart.js";
  html += "<script>";
  html += "const ctx = document.getElementById('pressureChart').getContext('2d');";
  html += "const pressureChart = new Chart(ctx, {";
  html += "  type: 'line',";
  html += "  data: {";
  html += "    labels: [],";
  html += "    datasets: [{";
  html += "      label: 'Pressure (kPa)',";
  html += "      data: [],";
  html += "      fill: false,";
  html += "      borderColor: 'rgba(33,150,243,1)',";
  html += "      backgroundColor: 'rgba(33,150,243,0.5)',";
  html += "      tension: 0.2";
  html += "    }]";
  html += "  },";
  html += "  options: {";
  html += "    responsive: true,";
  html += "    scales: {";
  html += "      x: { title: { display: true, text: 'Sample Index (updated every 5 minutes)' } },";
  html += "      y: { title: { display: true, text: 'Pressure (kPa)' } }";
  html += "    }";
  html += "  }";
  html += "});";

  html += "function updateData() {";
  html += "  fetch('/data')";
  html += "    .then(response => response.json())";
  html += "    .then(data => {";
  html += "      document.getElementById('currentPressure').innerText = data.current.toFixed(2);";
  html += "      document.getElementById('maxPressure').innerText = data.max.toFixed(2);";
  html += "      document.getElementById('minPressure').innerText = data.min.toFixed(2);";
  html += "      document.getElementById('uptime').innerText = data.uptime;";
  html += "      document.getElementById('calOffset').innerText = data.calOffset.toFixed(2);";
  html += "      document.getElementById('fanThreshold').innerText = data.fanThreshold.toFixed(2);";
  html += "      const history = data.history || [];";
  html += "      const now = new Date();";
  html += "      pressureChart.data.labels = history.map((_, idx) => {";
  html += "        const minutesAgo = (history.length - 1 - idx) * 5;";
  html += "        const labelTime = new Date(now.getTime() - minutesAgo * 60000);";
  html += "        const hh = String(labelTime.getHours()).padStart(2, '0');";
  html += "        const mm = String(labelTime.getMinutes()).padStart(2, '0');";
  html += "        return `${hh}:${mm}`;";
  html += "      });";
  html += "      pressureChart.data.datasets[0].data = history;";
  html += "      pressureChart.update();";
  html += "    });";
  html += "}";
  html += "updateData();";
  html += "setInterval(updateData, 5000);";
  html += "</script>";
  html += "</body></html>";
  server.send(200, "text/html", html);
}

void handleData() {
  unsigned long uptimeMillis = millis();
  int hours = uptimeMillis / 3600000UL;
  int minutes = (uptimeMillis % 3600000UL) / 60000UL;
  int seconds = (uptimeMillis % 60000UL) / 1000UL;
  String uptimeStr = String(hours) + ":" + (minutes < 10 ? "0" : "") + String(minutes) + ":" + (seconds < 10 ? "0" : "") + String(seconds);
  String json = "{";
  json += "\"current\":" + String(lastPressure, 3) + ",";
  json += "\"max\":" + String(maxPressure, 3) + ",";
  json += "\"min\":" + String(minPressure, 3) + ",";
  json += "\"uptime\":\"" + uptimeStr + "\",";
  json += "\"calOffset\":" + String(calibrationOffset, 3) + ",";
  json += "\"fanThreshold\":" + String(calibrationFanOn, 3) + ",";
  json += "\"history\": [";
  for (int i = 0; i < HISTORYLENGTH; i++) {
    json += String(pressureHistory[i], 3);
    if (i < HISTORYLENGTH - 1) json += ",";
  }
  json += "]";
  json += "}";
  server.send(200, "application/json", json);
}

void handleCalibrate() {
  calibrateSensor();
  String response = "<html><head><title>Calibration Complete</title><style>";
  response += "body { font-family: Arial; margin: 20px; }";
  response += "h1 { color: #4CAF50; }";
  response += "a { display: inline-block; margin-top: 20px; padding: 10px 20px; background-color: #008CBA; color: white; text-decoration: none; border-radius: 4px; }";
  response += "a:hover { background-color: #007399; }";
  response += "</style></head><body>";
  response += "<h1>Calibration Completed</h1>";
  response += "<p>Calibration offset: " + String(calibrationOffset, 2) + " kPa</p>";
  response += "<p>Fan threshold: " + String(calibrationFanOn, 2) + " kPa</p>";
  response += "<p>Min/Max pressures have been reset.</p>";
  response += "<a href='/'>Back to Home</a>";
  response += "</body></html>";
  server.send(200, "text/html", response);
}

void setup() {
  Serial.begin(115200);
  // define devices
  pinMode(ZERO_BUTTON, INPUT_PULLUP);
  //pinMode(ALARM_PIN, OUTPUT);
  lcd.init(); 
  lcd.backlight();
  lcd.setCursor(0,0);
  lcd.print("Startup...");
  //wifi setup
  WiFi.mode(WIFI_STA);
  WiFi.begin(SSID, PASSWORD);
  int loop=0;
  while (WiFi.status() != WL_CONNECTED) {
        lcd.clear();
        lcd.setCursor(0,loop);
        lcd.println("Connecting...");
        softPause(200);
        loop +=1;
        if (loop>1) loop=0;
    }
  // Connected!
  lcd.clear();
  lcd.setCursor(0,0);
  lcd.print("IP: " + WiFi.localIP().toString());
  lcd.setCursor(0,1);
  lcd.print("Connected");
  // setup ADC
  analogReadResolution(12);              // 0–4095
  analogSetPinAttenuation(ADC_PIN , ADC_11db);
  loadSettings();
  if (calibrationOffset == 0.0 && calibrationFanOn == 0.0) {
    // No calibration data, run calibration - this should only happen on first run or if settings are erased, so we can block here until it's done without impacting the user experience
    calibrateSensor();
  } else {
    lcd.clear();
    lcd.setCursor(0,0);
    lcd.print("IP: " + WiFi.localIP().toString());
    lcd.setCursor(0,1);
    lcd.print("Loaded: ");
    lcd.print(calibrationFanOn, 2);
    lcd.print(" kPa");
    softPause(2000);
  }
 
  // Set up web server routes
  server.on("/", handleRoot);
  server.on("/data", handleData);
  server.on("/calibrate", HTTP_POST, handleCalibrate);
  server.begin();
}

const unsigned long SAMPLE_INTERVAL = 1000;    // 1 second
const unsigned long HISTORY_INTERVAL = 300000; // 5 minutes

void loop() {
  server.handleClient();  // Handle web server requests
  
  // Monitor button for 5-second hold to trigger calibration
  bool buttonPressed = (digitalRead(ZERO_BUTTON) == LOW); // Button is pressed when LOW (INPUT_PULLUP)
  
  if (buttonPressed) {
    // Button is currently being held
    if (buttonPressTime == 0) {
      // Button just pressed, record the time
      buttonPressTime = millis();
      buttonHeldNotified = false;
    } else if (!buttonHeldNotified && (millis() - buttonPressTime >= BUTTON_HOLD_TIME)) {
      // Button has been held for 5 seconds
      buttonHeldNotified = true;
      lcd.clear();
      lcd.setCursor(0, 0);
      lcd.println("Calibrating...");
      Serial.println("ZERO_BUTTON held for 5 seconds - starting calibration");
      calibrateSensor();
    }
  } else {
    // Button is not pressed
    buttonPressTime = 0;
    buttonHeldNotified = false;
  }
  
  // take a sample every second 
  if (millis() - lastSampleTime > SAMPLE_INTERVAL) {
     lastSampleTime = millis();
     lastPressure = transducer.readPressure();
     lcd.setCursor(0, 1);
     lcd.println("Last: " + String(lastPressure, 2) + " kPa   ");
     // Track min and max pressure
     if (lastPressure > maxPressure) {
       maxPressure = lastPressure;
       saveSettings();
     }
     if (lastPressure < minPressure) {
       minPressure = lastPressure;
       saveSettings();
     }
   }
   // update history every 5 minutes
   if (millis() - lastHistoryTime > HISTORY_INTERVAL) {
     lastHistoryTime = millis();
     for (int i = 0; i < HISTORYLENGTH - 1; i++) {
       pressureHistory[i] = pressureHistory[i + 1];
     }
     pressureHistory[HISTORYLENGTH - 1] = lastPressure;
   }

}

Pressure transducer class

#include "PressureTransducer.h"
PressureTransducer::PressureTransducer(int pin) : adcPin(pin) {
pinMode(adcPin, INPUT);
}
#define NUMBER_OF_SAMPLES 64
float PressureTransducer::readPressure() {
float samples[NUMBER_OF_SAMPLES];
float minSample = 4095.0;
float maxSample = 0.0;
float sum = 0.0;
for (int i = 0; i < NUMBER_OF_SAMPLES; i++) {
float temp = analogRead(adcPin);
samples[i] = temp;
if (temp < minSample) minSample = temp;
if (temp > maxSample) maxSample = temp;
}
// Sum all except min and max
for (int i = 0; i < NUMBER_OF_SAMPLES; i++) {
if (samples[i] != minSample && samples[i] != maxSample) {
sum += samples[i];
}
}
float avg = sum / (NUMBER_OF_SAMPLES - 2);
float adcVoltage = (avg / 4095.0) * 3.3;
float sensorVoltage = adcVoltage * 1.55;
float pressure_kPa = (sensorVoltage - 2.5);
return pressure_kPa;
}

Web page functions

  1. Page loads with initial values
  2. Chart renders 60 stored samples (gathered every 5 minutes)
  3. Calibration initiation button displayed
  4. Every 5 seconds the web page:
    • Loads data
    • Updates graph

No page refresh required.

Calibration Procedure

There are three ways where a calibration is initiated:

  1. System sees that there is no preference data defined
  2. Web page calibration button pressed
  3. User holds the device Zero button for 5 seconds

When the calibration is initiated, all previous data is lost and the user is asked to:

  1. Turn off fan
  2. Press Zero button
  3. Turn on fan
  4. Press Zero button
  5. System auto-adjusts offset and starts tracking
  6. IP address is displayed in line 0 of the LCD

Housing design

Attaches between the laser smoke exhaust and the exhaust hose to the fan and outside.

Sensor air intake hose is attached to the air flow via Bowden tube – that I had lying around.

Image

When it is all assembled with the LCD housing, it looks like:

Image

Here is a picture of the current software/hardware in production:

Image

Showing the current IP address (so you can look at the web page) and the last pressure reading. Normally, when the fan is on, it is reading between -0.10 and -0.30. If the fan is off, the pressure value is dependent on the difference between the outside barometric pressure and the air pressure inside the maker space.

A gift to myself that expanded my 3D printing skills

Back in November (or so) I saw a product called Print Kits. Each month they send out a kit that includes the electronics for one of their designs. They also offer an add-on filament subscription featuring unique materials. They’re not new to providing this kind of service. According to their website:

“Our lineage goes back pretty far. Before we were Print Kits, we were Alien3D with our “UFO boxes”, and before that, we were MakerBox! We hope we have carried on the legacy of these past experiments and that the love for 3D printing has carried through to us.”

Since I am always looking for ways to know more about 3D printing, I purchased a couple months of their kits as a present to myself for Christmas.

This post was about the first kit. I learned quite a bit about assembling large 3D-printed parts putting it together. Print Kits approached some design and assembly challenges in ways I hadn’t considered before, which made it a great learning experience.

Their business concept is simple: they provide the electronics/hardware and design files; you provide the filament — and the time to print and assemble everything.

I have yet to put together the second kit though — I haven’t had time. I asked them if they would be interested in a project I’d done in the past with the Raspberry Pico. They said they were, so I ported it over to an ESP32 an C++ (Arduino), their preferred microcontroller and reworked the 3D models so they give a better result — the layer lines are less pronounced…

Around this time, I was preparing a class for my local makerspace on using the ESP32. Porting the 8 ball project gave me the push I needed to better understand the platform while creating the class and what turned into a book to go along with it. While I still prefer MicroPython on the Pico from a productivity perspective, it’s nice that the ESP32 supports both environments. I recently did a dry run of the class and have the first official training session scheduled for next month.

If all goes well, you may see my redesigned Magic 8 Ball appear as a Print Kits monthly kit later this year. It’s come a long way since the original 2025 version.

If you’re looking to grow your 3D printing skills, PrintKits has been a worthwhile experience for me. I’ve learned something new every time I’ve worked through one of their kits.

AI facilitating 3D CAD design

I have been using Fusion for about a decade for CAD design and every so often there is something I need to create as part of another design that is just grunt work. Lately, I have been experimenting with using AI to do some of these simple designs.

It is relatively easy to see how AI can generate text from a prompt like:

Document the dimensions for an ESP32 Generic “ESP32 Mini DevKit 4‑Hole” Boards. Mine is 52 mm long.

I gave it my devices dimension since it would help it find the right one, since there are so many variations out there. The response returned is:

Core Board Dimensions
This "V1" footprint is slightly longer and wider than the official Espressif DevKitC, making it more stable for mounting but tighter on a breadboard.
Feature Dimension (Metric) Dimension (Imperial)
Total Length 52.0 mm 2.05 in
Total Width 28.5 mm 1.12 in
Height (with pins) 13.5 mm 0.53 in
PCB Thickness 1.6 mm 0.06 in
Export to Sheets
________________________________________
Mounting Hole Pattern
The four mounting holes are positioned symmetrically in the corners of the PCB. These are critical for 3D-printed enclosures or standoff mounting.
• Hole Diameter: 3.0 mm (standard for M2.5 screws; M3 may be a tight fit).
• Horizontal Center-to-Center: ~24.5 mm.
• Vertical Center-to-Center: ~48.0 mm.
• Edge Offset: Hole centers are typically 2.0 mm from the PCB edges.
________________________________________
Header & Pin Layout
The pins follow a standard DIP layout, but the width is a common pain point for prototyping.
• Pin Pitch: 2.54 mm (0.1 in).
• Row Spacing: 25.4 mm (1.0 in).
Note: Because the row spacing is exactly 1 inch, if you plug this into a standard breadboard, it will cover all the tie-points on both sides. You will need to use two breadboards side-by-side or use jumper wires from underneath.
• Pin Count: 30 pins (15 per side).

That is useful and expected. But what I didn’t expect was what happens if I then prompted the AI tool with:

Create an openscad model of this device.

OpenSCAD is software for creating solid 3D CAD models using a programming language. It is free software and available for Linux/UNIX, Windows and Mac OS X. Unlike most free software for creating 3D models (such as Blender) it does not focus on the artistic aspects of 3D modelling but instead on the CAD aspects.

After that prompt I receive:

Since you have the 52 mm x 28.5 mm variant (often called the DOIT DevKit V1), the OpenSCAD code below creates a realistic "bounding box" model.
This model includes the PCB, the four mounting holes, the ESP32 module, and the micro-USB port. I've used variables at the top so you can tweak the dimensions if you find your specific clone is off by a fraction of a millimeter.
// ESP32 Mini DevKit (52mm 4-Hole Variant)
// Dimensions in mm
$fn = 32;
// --- Dimensions ---
pcb_l = 52.0;
pcb_w = 28.5;
pcb_h = 1.6;
hole_dia = 3.0;
hole_offset = 2.0; // Distance from center of hole to edge
module_l = 25.5;
module_w = 18.0;
module_h = 3.5;
usb_l = 7.5;
usb_w = 8.0;
usb_h = 3.0;
// --- Build ---
union() {
// 1. PCB Base with Mounting Holes
difference() {
color("DarkGreen")
cube([pcb_l, pcb_w, pcb_h], center = true);
// Four Mounting Holes
for (x = [-(pcb_l/2 - hole_offset), (pcb_l/2 - hole_offset)]) {
for (y = [-(pcb_w/2 - hole_offset), (pcb_w/2 - hole_offset)]) {
translate([x, y, -pcb_h])
cylinder(d = hole_dia, h = pcb_h * 3);
}
}
}
// 2. ESP32 Module (The Silver Tin)
translate([-(pcb_l/2 - module_l/2 - 5), 0, (pcb_h/2 + module_h/2)]) {
color("Silver")
cube([module_l, module_w, module_h], center = true);
}
// 3. Micro-USB Port
translate([pcb_l/2 - usb_l/2, 0, pcb_h/2 + usb_h/2]) {
color("LightGray")
cube([usb_l, usb_w, usb_h], center = true);
}
// 4. Pin Headers (Simplified Bounding Box)
color("Black") {
translate([0, 12.7, -5]) // Row 1
cube([pcb_l - 4, 2.54, 10], center = true);
translate([0, -12.7, -5]) // Row 2
cube([pcb_l - 4, 2.54, 10], center = true);
}
}

Great response – If I paste that into OpenSCAD I get the following model:

Image

That’s nice but a real device has pins an headers so I prompt it with:

refine the design to show the pins and the header

I can then take that new output into OpenSCAD and get a design that looks like:

Image

That is definitely close enough for my needs. If I was 3D printing it would be good enough to use. What I really would like is to take this mode over in Fusion in a way that I could refine it. So I prompt:

Create a fusion version of the design

The AI then creates a Fusion sketch and response with:

Fusion 360 Construction Guide
If you are modeling this from scratch in Fusion 360, use these parameters for a "Master Sketch" on the XY plane:
1. The Main PCB (Center Rectangle)
Type: Center Point Rectangle
Dimensions:
Extrude: (New Component: "PCB")
2. Mounting Holes (4-Hole Pattern)
Placement: Draw a construction rectangle centered on the PCB.
Diameter: at each corner of the construction rectangle.
Extrude: Cut through the PCB.
3. Pin Header Layout (Critical for Fitment)
To ensure your 3D-printed enclosures or PCB mounts fit, use these offsets:
Row Spacing: (1 inch) center-to-center.
Pin Pitch: (0.1 inch).
Positioning: The 15-pin rows are typically centered, meaning the first pin starts 17.78 mm from the center point of the board ().
________________________________________
Python Script for Fusion 360
You can run this script inside Fusion 360 (Utilities > Add-ins > Scripts) to automate the generation of the base board and the 30 pins.
import adsk.core, adsk.fusion, traceback
def run(context):
ui = None
try:
app = adsk.core.Application.get()
ui = app.userInterface
design = app.activeProduct
rootComp = design.rootComponent
# Parameters
pcb_l = 5.2 # cm
pcb_w = 2.85 # cm
pcb_h = 0.16 # cm
row_space = 2.54 # cm
pin_pitch = 0.254 # cm
# Create PCB
sketches = rootComp.sketches
xyPlane = rootComp.xYConstructionPlane
sketch = sketches.add(xyPlane)
# Center the PCB
lines = sketch.sketchCurves.sketchLines
rect = lines.addTwoPointRectangle(adsk.core.Point3D.create(-pcb_l/2, -pcb_w/2, 0),
adsk.core.Point3D.create(pcb_l/2, pcb_w/2, 0))
prof = sketch.profiles.item(0)
extrudes = rootComp.features.extrudeFeatures
extInput = extrudes.createInput(prof, adsk.fusion.FeatureOperations.NewComponentFeatureOperation)
distance = adsk.core.ValueInput.createByReal(pcb_h)
extInput.setDistanceExtent(False, distance)
extrudes.add(extInput)
except:
if ui:
ui.messageBox('Failed:\n{}'.format(traceback.format_exc()))

When I load this into a Fusion script, create an assembly and run the script I get a model:

Image

That’s nice but not nearly the level of detail that was included in the OpenSCAD design. The sketch that this was extruded from in Fusion didn’t have pins…

Just like with other AI tasks, it needs a bit more prompting to get something useful, so:

In the OpenSCAD design there were pins and mounting holes. The Fusion model does not have those.
Add these details to the fusion model.

The AI responds with:

To get those specific features into Fusion 360, we have to transition from a single block to a parametric assembly. Below is the expanded Python script for the Fusion 360 Script Editor.
This script generates the PCB, cuts the four mounting holes, and iterates through the 30-pin layout to create physical pins.
Fusion 360 Automation Script
1. Open Fusion 360.
2. Go to Utilities > Add-ins > Scripts and Add-ins.
3. Click Create and choose Python. Give it a name like ESP32_Refined.
4. Paste the following code:
import adsk.core, adsk.fusion, traceback
def run(context):
ui = None
try:
app = adsk.core.Application.get()
ui = app.userInterface
design = app.activeProduct
rootComp = design.rootComponent
# Parameters (Fusion uses CM internally)
pcb_l = 5.2
pcb_w = 2.85
pcb_h = 0.16
hole_dia = 0.3
hole_off = 0.2
pin_pitch = 0.254
row_space = 2.54
pin_count = 15
pin_size = 0.064 # 0.64mm standard square pin
# --- 1. Create PCB Body ---
sketches = rootComp.sketches
xyPlane = rootComp.xYConstructionPlane
pcb_sketch = sketches.add(xyPlane)
# Draw PCB Outline
lines = pcb_sketch.sketchCurves.sketchLines
rect = lines.addTwoPointRectangle(adsk.core.Point3D.create(-pcb_l/2, -pcb_w/2, 0),
adsk.core.Point3D.create(pcb_l/2, pcb_w/2, 0))
# Draw 4 Mounting Holes
holes = pcb_sketch.sketchCurves.sketchCircles
offsets = [[(pcb_l/2 - hole_off), (pcb_w/2 - hole_off)],
[-(pcb_l/2 - hole_off), (pcb_w/2 - hole_off)],
[(pcb_l/2 - hole_off), -(pcb_w/2 - hole_off)],
[-(pcb_l/2 - hole_off), -(pcb_w/2 - hole_off)]]
for pos in offsets:
holes.addByCenterRadius(adsk.core.Point3D.create(pos[0], pos[1], 0), hole_dia/2)
# Extrude PCB
prof = pcb_sketch.profiles.item(0) # Fusion handles holes automatically if in same profile
extrudes = rootComp.features.extrudeFeatures
extInput = extrudes.createInput(prof, adsk.fusion.FeatureOperations.NewBodyFeatureOperation)
extInput.setDistanceExtent(False, adsk.core.ValueInput.createByReal(pcb_h))
pcb_body = extrudes.add(extInput)
# --- 2. Create Pins ---
pin_sketch = sketches.add(xyPlane)
start_x = -((pin_count - 1) * pin_pitch) / 2
for i in range(pin_count):
curr_x = start_x + (i * pin_pitch)
# Row 1 and Row 2 pin squares
for y_pos in [row_space/2, -row_space/2]:
lines.addTwoPointRectangle(adsk.core.Point3D.create(curr_x - pin_size/2, y_pos - pin_size/2, 0),
adsk.core.Point3D.create(curr_x + pin_size/2, y_pos + pin_size/2, 0))
# Extrude Pins (Downwards from PCB)
for i in range(pin_sketch.profiles.count):
p_prof = pin_sketch.profiles.item(i)
p_ext_input = extrudes.createInput(p_prof, adsk.fusion.FeatureOperations.NewBodyFeatureOperation)
# Extrude 1cm down
p_ext_input.setDistanceExtent(False, adsk.core.ValueInput.createByReal(-1.0))
extrudes.add(p_ext_input)
except:
if ui:
ui.messageBox('Failed:\n{}'.format(traceback.format_exc()))

When I update my script and run it the following sketch is created in Fusion:

Image

This is a much more useful sketch that I could extrude the pins from… I am sure if I really wanted to continue to work on it I could have convinced the AI tool to create the 3D pins and headers in Fusion — but that was good enough for my needs.

This is more of a demonstration of capability than anything else, and it would have taken me a bit of time to design the same dimensionally correct model manually.

One warning though, not all the AIs are the same. Gemini did a good job of creating the Fusion design without any errors (once I ran the script inside a Fusion assembly). The Microsoft AI (CoPilot) didn’t do nearly as well.

It was interesting to note how much better job OpenSCAD did at the parametric modeling when compared to Fusion script generation. It may not be all that useful yet, but definitely a signpost on the road to the future.

Using the GCA01 on ESP32 running Arduino

A while back I put out a post about using the GC9A01 with a Raspberry Pico

I am putting together a class for our local maker space that includes some model railroaders – they prefer Arduino and C++. I thought I’d take some of my code that was working on the Pico over to the Arduino. Everything was working well, until I got an update in the Arduino environment for the library I was using to control the GC9A01. EVERYTHING stopped working – black screen, no real information – I was back to square one with setting this thing up.

This library has multiple .h files that need to be defined to get the display working. I’d forgotten most of what I had figured out the first time and didn’t write any of it down. This time I thought I’d write down the information for the TFT_eSPI library that drives this little device. Hopefully, it will help others out. I’ll update this post as I find things missing… Hopefully, the documentation doesn’t get mangled too much by the process of pasting it into here.

TFT_eSPI –Developer Cheat Sheet

Setup

The setup for this library is complicated. The first time you install it, the basic information will be loaded into the documents/Arduino/libraries/TFT_eSPI. There are a number of steps required to get it working…

1) Look under User_Setups for the one that is applicable for your display. In my case it is:

      Setup200_GC9A01.h

      Edit this file for the lines you plan to use:

      In my case it was:

      #define TFT_MOSI 23 // In some display driver board, it might be written as "SDA" and so on.
      #define TFT_SCLK 18  // make sure the clock and mosi are not reversed
      #define TFT_CS   22  // Chip select control pin
      #define TFT_DC   27  // Data Command control pin
      #define TFT_RST  33  // Reset pin
      #define TFT_BL   -1  // LED back-light

        2) Go back to the TFT_eSPI folder and look for:
        User_Setup_Select.h

        Edit this file so that the only setup that is uncommented is the one for your display.

        //#include <User_Setups/Setup138_Pico_Explorer_Base_RP2040_ST7789.h> // Setup file for Pico Explorer Base by Pimoroni for RP2040 (ST7789 on SPI bus with 240x240 TFT)
        #include <User_Setups/Setup200_GC9A01.h>           // Setup file for ESP32 and GC9A01 240 x 240 TFT
        //#include <User_Setups/Setup201_WT32_SC01.h>        // Setup file for ESP32 based WT32_SC01 from Seeed

        3)Also go to the User_Setup.h file and ensure that the correct driver is uncommented and the default one is commented out.

        Create your setup() routine and make it look like:

        void setup() {
          Serial.begin(115200);
          delay(500);
          Serial.println("\nBooting...");
          tft.init();
          Serial.println("TFT init done");
          Serial.print("SDA/MOSI: "); Serial.println(TFT_MOSI);
          Serial.print("SCLK: "); Serial.println(TFT_SCLK);
          Serial.print("CS:   "); Serial.println(TFT_CS);
          Serial.print("DC:   "); Serial.println(TFT_DC);
          Serial.print("RST:  "); Serial.println(TFT_RST);
          Serial.println("prerotation");
          tft.setRotation(0);
        }

        Ensure that the various data lines are where you expect them to be.

        Diagnosing problems

        Frequency setup

        One area that can cause issues is if the speed is set too high. In your setup.h file look at the SPI frequency if you are getting strange screen behavior.

        #define SPI_FREQUENCY  40000000

        Recommended guidance:

        • 40 MHz → works on most ESP32 + short wires
        • 27 MHz → safer for long wires
        • 20 MHz → very stable fallback

        Symptoms of SPI being too fast:

        • Random colored pixels
        • Screen tearing
        • Occasional lockups

        Memory Usage & ESP32 Constraints (Very Important)

        RAM vs PSRAM

        • Sprites allocate RAM, not flash
        • Large sprites can crash ESP32 silently

        Rule of thumb:

        240 × 240 × 2 bytes ≈ 112 KB

        Recommendations:

        • Prefer partial sprites for UI elements
        • Use setColorDepth(8) when possible
        • Check free heap:

        Serial.println(ESP.getFreeHeap());

        Initalization issues

        White Screen / Black Screen

        Checklist:

        • Wrong setup file selected
        • CS/DC swapped or other wiring issues
        • SPI clock too high
        • Backlight pin floating
        • Missing tft.init()

        No backlight

        • Connection of 3v3 and GND – there should be some light

        Initialization

        • TFT_eSPI tft; // Create display object.
        • init() / begin() – Initialize hardware and controller.
        • setRotation(r) – Set screen orientation (r: 0–3 or 4–7 mirrored).
        • width() – Return current dimensions after rotation (returns int16_t or int32_t).
        • height() – Current dimensions after rotation.

        Core Drawing

        Pixels & Lines

        • drawPixel(int x, int y, uint color) – Plot single pixel
        • readPixel(int x, int y) – Read color of pixel at position (returns uint).
        • drawLine(int x0,int y0,int x1,int y1,uint color) – Draw straight line between two points
        • drawFastHLine(int x, int y, int width, uint color) – Fast horizontal line
        • drawFastVLine(int x, int y,int height, uint color) – Fastvertical line

        Color operations

        • color565(r, g, b) – Convert 8-bit RGB to 16-bit RGB565 (returns uint).
        • alphaBlend(alpha, fg, bg) – Blend two colors with alpha (0-255) (returns uint).

        Rectangles

        • drawRect(int32_t x, int32_t y, int32_t w, int32_t h, uint32_t color) — Draw 1-pixel outline rectangle.
        • fillRect(int32_t x, int32_t y, int32_t w, int32_t h, uint32_t color) — Fill solid rectangle (no border).
        • drawRoundRect(int32_t x, int32_t y, int32_t w, int32_t h, int32_t r, uint32_t color) — Draw outline of rectangle with rounded corners (1-pixel thick border).
        • fillRoundRect(int32_t x, int32_t y, int32_t w, int32_t h, int32_t r, uint32_t color) — Fill rectangle with rounded corners.

        Circles & Ellipses

        • drawCircle(x,y,r,color) — Draw circle outline
        • fillCircle(x,y,r,color) — Draw circle filled
        • drawEllipse(x,y,rx,ry,color) — Draw ellipse outline
        • fillEllipse(x,y,rx,ry,color) — Draw ellipse filled

        Triangles

        • drawTriangle(x1,y1,x2,y2,x3,y3,color) — Draw triangle outline
        • fillTriangle(x1,y1,x2,y2,x3,y3,color) — Draw triangle filled

        Advanced Drawing

        • drawBezier(x0,y0,x1,y1,x2,y2,color) – Draw quadratic Bezier curve
        • drawBezier(x0,y0,x1,y1,x2,y2,x3,y3,color) – Draw cubic Bezier curve
        • drawArc(x,y,r,startAngle,endAngle,color) – Draw arc (non-smooth version)
        • drawPolygon(x[],y[],n,color) – Draw polygon from array of points

        Text Rendering

        • setTextFont(uint font) – selects one of the compile-time bitmap fonts built into TFT_eSPI.

        Typical fonts:

        1. Font 1 – classic Adafruit GFX 5×7
        2. Font 2 – 16-pixel high
        3. Font 4 – 26-pixel high
        4. Font 6 – 48-pixel high
        5. Font 7 – 7-segment style
        6. Font 8 – large numeric font

        These fonts are fixed or semi-fixed width bitmap fonts stored in flash (PROGMEM).

        • drawChar(int x,int y,uint unicode, uint color,uint bg,uint size) – Draw single character
        • setTextColor(fg [, bg]) – Set text foreground (and optional background) color
        • setTextSize(n) –  Set text magnification multiplier
        • setTextDatum(datum) – Set text reference/alignment point
          (TL_DATUM – top left, MC_DATUM – middle center, BR_DATUM – bottom right, etc.)
        • setCursor(int x,int y) – Set text cursor position
        • setTextPadding(int width) – Set fixed width for text background erasure
        • write(char c) – Print single character at cursor
        • print() / println() – Arduino-style printing at cursor position (various overloads: String, int, float, etc.).

        Fonts

        Loading Fonts

        • loadFont(String font_name, [fs]) – Load a font file from SPIFFS/LittleFS or SD card
          • font_name: Path to font file (e.g., “/fonts/NotoSansBold15.vlw”)
          • fs: Optional filesystem pointer (SPIFFS, LittleFS, SD, etc.)
          • Returns: true if successful, false if failed
        • unloadFont() – Free memory used by current font and revert to built-in font

        Font Properties & Metrics

        • getTextWidth(String string) – Get pixel width of string in current font. Returns uint
        • getTextHeight(String string) – Get pixel height of string. Returns uint
        • fontHeight() – Get height of current font. returns uint
        • textWidth(string) – Alias for getTextWidth(). Returns uint
        • textFont(int n) – Switch between built-in GLCD fonts (1-8) or custom loaded fonts

        Viewports (Clipping)

        • setViewport(x,y,w,h [, datum]) – Define clipping region
        • resetViewport() — Clear viewport / disable clipping
        • checkViewport(x,y,w,h) – Check if area is inside viewport
        • frameViewport(color, thickness) – Draw frame around viewport

        Useful for UI panels, scrolling regions, and widgets.

        Address Window & Pixel Streaming

        These functions are low-level, high-performance tools for directly writing pixel data to the TFT controller. They bypass higher-level drawing commands (like fillRect or drawPixel) and are essential when you need maximum speed, such as:

        • Blitting large images or bitmaps from arrays/buffers
        • Streaming video frames or camera data
        • Implementing custom graphics engines (e.g., LVGL, game rendering)
        • Updating only small changed regions of the screen efficiently

        The workflow is almost always:

        1. Call setAddrWindow (or the internal setWindow) to define the rectangular area on the screen where pixels will be written.
        2. Then repeatedly call pixel-pushing functions (pushColor, pushColors, pushBlock, etc.) to send color data — the controller automatically advances the write position inside that window (row-major order: left-to-right, top-to-bottom).

        Do not interleave other drawing commands between setAddrWindow and your pixel pushes unless you call end_tft_write() / begin_tft_write() or know what you’re doing — this can corrupt the window.

        • setAddrWindow(x,y,w,h) — Set pixel write window
        • pushColor(color) — Push single RGB565 color to current window
        • pushColors(ptr, len [, swap]) — Push array of colors
        • pushBlock(color, len) — Push repeated color block
        • pushPixels(ptr, len) — Push raw pixel data

        Images & Bitmaps

        • setSwapBytes(bool) – enable byte swapping for RGB565 arrays
        • drawBitmap(x,y,bitmap,w,h,fg [, bg])  — Draw 1-bit monochrome bitmap
        • drawXBitmap(x,y,bitmap,w,h,fg [, bg]) — Draw XBM format bitmap
        • pushImage(x,y,w,h,data [, transparent]) —Draw RGB565 image array
        • pushMaskedImage(x,y,w,h,img,mask) — Draw image with per-pixel mask

        Smooth (Anti‑Aliased) Graphics

        • drawSmoothArc(x,y,r,ir,start,end,fg,bg,roundEnds) — Draw anti-aliased arc
        • drawSmoothCircle(x,y,r,fg,bg) — Draw anti-aliased circle outline
        • fillSmoothCircle(x,y,r,color,bg) — Draw anti-aliased circle filled
        • drawWideLine(ax,ay,bx,by,width,fg,bg) — Draw thick anti-aliased line
        • drawWedgeLine(ax,ay,bx,by,aw,bw,fg,bg) – — Draw tapered wedge line

        Great for gauges, dials, and UI polish.

        Sprite-Specific Functions (TFT_eSprite)

        • createSprite(width, height) – Allocate sprite in RAM
        • deleteSprite() – Free sprite memory
        • pushSprite(x, y) – Draw sprite to screen
        • pushSprite(x, y, transparentColor) – Draw with transparency
        • setColorDepth(depth) – Set 8-bit or 16-bit color (8 saves RAM)
        • setBitmapColor(fg, bg) – For 1-bit sprites

        Screen Operations

        • fillScreen(color) — Fill entire screen
        • invertDisplay(bool) — Invert screen colors
        • readRect(x,y,w,h,buffer) — Read rectangle to buffer
        • pushRect(x,y,w,h,buffer) — Write buffer to rectangle
        • getRotation() – Returns current screen rotation (0-3)
        • getCursorX() / getCursorY() – Get current text cursor position

        Pivot & Rotation Helpers

        Used for rotated sprites and rotated drawing.

        • setPivot(x,y) — Set pivot point for rotated drawing
        • getPivotX() / getPivotY()— Get current pivot coordinates

        Color Constants (RGB565)

        • TFT_BLACK, TFT_WHITE, TFT_RED, TFT_GREEN, TFT_BLUE
        • TFT_YELLOW, TFT_CYAN, TFT_MAGENTA
        • TFT_ORANGE, TFT_PINK, TFT_BROWN, TFT_GOLD
        • TFT_SILVER, TFT_SKYBLUE, TFT_VIOLET
        • TFT_TRANSPARENT (sprite transparency)

        Text Datum Reference

        setTextDatum() — the primary use

        DatumMeaning
        TL_DATUMTop‑left
        TC_DATUMTop‑center
        TR_DATUMTop‑right
        ML_DATUMMiddle‑left
        MC_DATUMMiddle‑center
        MR_DATUMMiddle‑right
        BL_DATUMBottom‑left
        BC_DATUMBottom‑center
        BR_DATUMBottom‑right
        L_BASELINE
        C_BASELINE
        R_BASELINE
        Baseline‑aligned

        This is an example of the function that consumes the datum constants:

        tft.setTextDatum(MC_DATUM);
        tft.drawString("Hello", 120, 160);

        With MC_DATUM, the text is centered on (120,160).

        Without a datum, the library assumes top‑left.

         drawString() and drawCentreString()

        drawString() uses the currently selected datum:

        tft.setTextDatum(BR_DATUM);
        tft.drawString("OK", 200, 200);

        Now (200,200) is the bottom‑right corner of the text.

        drawCentreString() internally uses TC_DATUM or MC_DATUM depending on the variant.

         Sprite text drawing (TFT_eSprite)

        Sprites use the same datum system:

        sprite.setTextDatum(CC_DATUM);
        sprite.drawString("Speed", sprite.width()/2, sprite.height()/2);

        This is extremely common for UI widgets, gauges, and labels.

        Typical Setup Snippet

        #include <TFT_eSPI.h>
        TFT_eSPI tft = TFT_eSPI();
        void setup() {
          tft.init();
          tft.setRotation(1);
          tft.fillScreen(TFT_BLACK);
          tft.setTextColor(TFT_WHITE, TFT_BLACK);
          tft.setTextSize(2);
          tft.drawString("Hello TFT!", 10, 10);
        }

        Complete Font Example

        #include <TFT_eSPI.h>
        #include <SPIFFS.h>
        TFT_eSPI tft = TFT_eSPI();
        void setup() {
          Serial.begin(115200);
          if (!SPIFFS.begin()) {
            Serial.println("SPIFFS failed!");
            return;
          }
          tft.init();
          tft.setRotation(1);
          tft.fillScreen(TFT_BLACK);
          // Load custom font
          if (tft.loadFont("/fonts/NotoSansBold20", SPIFFS)) {
            tft.setTextColor(TFT_YELLOW);
            tft.setTextDatum(MC_DATUM);
            tft.drawString("Custom Font!", tft.width()/2, tft.height()/2);
            // Get font metrics
            Serial.print("Font height: "); Serial.println(tft.fontHeight());
            Serial.print("Text width: "); Serial.println(tft.textWidth("Custom Font!"));
            tft.unloadFont();  // Free memory
          }
        }
        void loop() {}

        Sprite Example

        TFT_eSprite sprite = TFT_eSprite(&tft);
        void createUIElement() {
          sprite.createSprite(100, 50);  // Create sprite in RAM
          sprite.fillSprite(TFT_BLUE);
          sprite.drawRect(0, 0, 100, 50, TFT_WHITE);
          sprite.setTextColor(TFT_WHITE);
          sprite.setTextDatum(MC_DATUM);
          sprite.drawString("Button", 50, 25);
          sprite.pushSprite(10, 10);  // Draw to screen at (10,10)
          sprite.deleteSprite();      // Free memory
        }

        Address Window & Pixel Streaming Examples

        Fill a rectangular area with one color (fast solid rectangle)

        tft.setAddrWindow(50, 50, 100, 80);          // 100×80 area starting at (50,50)
        tft.pushBlock(TFT_BLUE, 100UL * 80);         // Fill all 8000 pixels blue

        Draw a bitmap from array (e.g. 240×240 full-screen image in PROGMEM)

        #include <MyImage.h>  // const uint16_t myImage[] PROGMEM = { ... };
        tft.setAddrWindow(0, 0, tft.width(), tft.height());
        tft.pushColors((uint16_t*)myImage, tft.width() * tft.height(), true);

        Streaming partial updates (e.g. progress bar)

        uint32_t barWidth = map(value, 0, 100, 0, 200);
        tft.setAddrWindow(20, 100, barWidth, 30);
        tft.pushBlock(TFT_GREEN, barWidth * 30UL);
        tft.setAddrWindow(20 + barWidth, 100, 200 - barWidth, 30);
        tft.pushBlock(TFT_DARKGREY, (200 - barWidth) * 30UL);

        Manual pixel-by-pixel (slow — only for small areas or debugging)

        tft.setAddrWindow(120, 160, 1, 1);  // Single pixel
        tft.pushColor(TFT_YELLOW);

        3D printed giant fireworks simulation

        For Christmas I received a mystery box from PrintKits from my wife. The concept really pulled me in: they send the electronics, and you 3D print the enclosures. As someone who grew up building Heathkit ham radio gear, this felt like a modern nod to my roots.

        The kit that came far exceeded my expectations as a fireworks simulation:

        Image

        That GIF is of the fireworks display laying on my garage floor (with the lights off) in test mode. It stands about 4 feet high. The instructions for the kit can be found at:

        https://printkits.com/blogs/news/digital-fireworks

        The Build Details:

        • Printing: It’s a marathon build—it took over a full spool of filament of various colors and quite a bit of time.
        • Electronics: It runs on an ESP32 microcontroller. It was my first time using one, so it was fun learning more about it once the assembly was done.
        • The “Oops”: One LED strip segment was DOA, but luckily I had a spare half-meter in my workshop.

        I only wish I had it assembled by New Years Eve, so I could have had it on my front porch. There is always the 4th of July – or Guy Fawkes night.

        If you have a 3D printer and interested in electronics (and aren’t afraid of a soldering iron) you should look at some of their kits.

        3D model generation – exploration and observations

        I was talking to a fellow I used to work with at EDS and HP the other day. We both are into 3D printing and model creation. He mentioned that he was using OpenSCAD and Gemini to create models. OpenSCAD is a parametric CAD design tool. I thought I’d give it a try.

        First, I gave it just a text prompt:

        "Design a 3D print for a battery holder that will hold 3 AA batteries (side by side) where I can add spring terminals to connect to power. Create it as OpenSCAD code"

        I got back something that was not all that useful, but I could see what it was going for, so this time I gave it a picture of what I wanted (that I pulled off of Amazon) as well as text description and I received back this OpenSCAD design (rendered in 3D builder):

        Image

        That was nice, but I use Fusion as my CAD tool and it supports parametric design. I then asked Gemini to generate the design in Python for Fusion. It ended up creating a design (after a few iterations) that looks like:

        Image

        The python code created the two sketches as well as the body. Overall, it was interesting  (and frustratingly close to useful) to see the start CAD generation with AI.

        Autodesk has an AI extension for Fusion but at $50 a month, it is a bit steep for a hobbyist. What I have shown here was all done with free tools. I probably could design the same thing quicker by hand but doing this did provide some ideas on how to approach the problem.

        Finally moving away from Microsoft Outlook

        Image

        After using the fat client version of Microsoft Outlook for decades, the ‘new’ Outlook has finally made me pull the plug. I use many local rules to sort my email into folders so only the unknown or important stuff sits in my inbox. This has never worked as well as it could, since it only works on my PC not my phone.

        With the new Outlook, it forces all the filters to be on the server side. That means I need to rebuild my filters on GMail. Heck, if I am going to do that I might as well just recreate the filters on Gmail for all my email accounts and bail out of Outlook altogether.

        That’s what I’ve done.

        I now have a folder for each email inbox that has unsorted mail, and a folder for each category of sorted mail that works across all my email accounts. Sometimes the sorts are by incoming email domain, other times it is by subject line… and it should even work that way from my phone.

        Too bad I use OneDrive so much, otherwise I’d move all my office file work off MS office.

        Naturally, I had to have Gemini AI create a graphic for the move.

        Taking a design from Lightburn over to TinkerCAD to be 3D printed

        Image

        I rarely use TinkerCAD, but this is an ideal example of where it can be useful. In the earlier post a design was created in Lightburn to cut out of wood. We can easily take that design and 3D print it. Here is an overview of the process:

        1. From within Lightburn, Export the model into a Scalable Vector Graphics (SVG).
        2. Open TinkerCAD (www.tinkercad.com)
        3. Create a new 3D design and name it as desired.
        4.  Import the SVG model, using the button in the upper right
        Image
        • A dialog will be displayed — use the defaults:
        Image

         Click on Import.

        5. Once it has been imported, the default height is 10. If you want a different height, you’ll need to left click on the model and bring up the model’s attributes and adjust them as desired.

        Image

        6. Click on Export in the upper left of the interface and select .STL as the output format.

        Now you are ready to go!

        Custom Ornament Creation Using Lightburn

        Image

        Each holiday season for the last decade or so, I have created a custom ornament. This year I decided to be an over achiever and create a custom ornament for everyone in the family.

        This post will walk through the process

        1. Determine the word or name you would like to use as the foundation for the ornament.
        2. Open Lightburn
        3. Enter the word in a script font, like: Script MT Bold, Benalla, Brush Script. There are thousands of script fonts out there.
        4. Scale the word so it is about half the size you desire for the ornament
        5. Right click on the word and select Convert to Path. This allows the text to be treated as a graphic shape.
        Image
        1. Now that the letters are a path, you can move them as needed, so they touch each other. You can also create shapes to help attach orphaned letters, letter parts or just to strengthen the graphic. Make sure any shapes you add are closed shapes. If you make new shapes (see the blue and red design elements below), put them on a separate layer to make them easier to work with.
        Image

        Don’t worry if the shapes or letters overlap, we can take care of that later.

        You can also drop into node editing mode to access capabilities like:

        • I – inserts a node
        • D – deletes a node
        • B – breaks a shape at a node
        • D – smooth and existing node
        • S – convert a line to a curve (so you can add nodes
          and adjust the curve)
        1. Use Boolean Union (you can only merge two graphic elements at a time) or Weld (which can handle many elements at a time, but it can leave internal structures sometimes?!??)
        Image
        1. Now that everything in the design is attached to each other, we use Circular Array to rotate the design. Adjust the Center X and Y values as needed to create the overlap graphic desired.
        Image
        1. Add any additional strengthening or ornamentation as desired
        Image
        1. Once you have it arranged, merge the designs together again (using Weld).
        2. Validate the scaling of the design.

        It should be ready to cut.

        Fine tuning your 3D printer bed using Aluminum foil and Fluidd

        I purchased a new printer earlier this summer (Creality K2+) and it has been working well, though  recently I’ve had some problems with some larger prints. Fortunately, the printer has a web interface called Fluidd.

        One of the capabilities in Fluidd will display a bed grid showing how even (or not) it is. This is located in the Tuning command listed on the left side of the interface.

        When I ran my initial analysis I saw the following:

        Image

        Any time the range is greater than 1 mm, intervention is required. I was actually shocked the printer was able to do this well as it had, with the left side being so far off.

        Fortunately, the K2+ has a few knobs on the bottom of the bed allowing for macro adjustments. After I tuned it up a bit, my bed mesh looks like:

        Image

        That’s a significant improvement.

        One trick that I have seen discussed online is to use Aluminum foil to fill in various areas to get it even flatter. I saw that some folks had written programs to do this, but rather than use theirs I started from scratch and attempted some Vibe coding. It was a bit frustrating though, since there were quite a few errors injected along the way, but I think it’s working.

        I wrote a small Python program to facilitate the analysis. Its user interface looks like:

        Image

        Where you input:

        • the mesh data (from Fluidd)
          1. inside Fluidd -> console run: BED_MESH_OUTPUT
          2. edit the result into a CSV file
        • the folder where you would like a PDF generated of the output
        • the thickness of the AL foil
        • the dimensions of your printer

        You click on the Generate Maps button and the screen will be update with a preview of the foil layers that will be required to level out the bed:

        Image

        You can also view a PDF of the results. Here is the first page of what was generated:

        Image

        The program looks like:

        import tkinter as tk
        from tkinter import filedialog, messagebox
        import numpy as np
        import matplotlib.pyplot as plt
        from matplotlib.backends.backend_pdf import PdfPages
        from PIL import Image, ImageTk
        import os
        import csv
        import json
        import subprocess
        import platform
        # --- CORE FUNCTIONS ---
        def load_mesh(file_path):
        if file_path.endswith(".csv"):
        with open(file_path, newline='') as f:
        reader = csv.reader(f)
        mesh = np.array([[float(cell) for cell in row] for row in reader])
        elif file_path.endswith(".json"):
        with open(file_path) as f:
        data = json.load(f)
        mesh = np.array(data["mesh"])
        else:
        raise ValueError("Unsupported file format. Use .csv or .json")
        return mesh
        def generate_images(mesh, thresholds, output_dir, bed_width, bed_height):
        os.makedirs(output_dir, exist_ok=True)
        for i, threshold in enumerate(thresholds):
        mask = mesh <= -threshold
        fig, ax = plt.subplots()
        cell_width = bed_width / (mesh.shape[1] - 1)
        cell_height = bed_height / (mesh.shape[0] - 1)
        extent = [0, bed_width, bed_height, 0] # [x_min, x_max, y_min, y_max]
        c = ax.imshow(mask, cmap='gray', interpolation='none', extent=extent)
        ax.set_title(f"Layer {i+1}: Tape for ≤ {-threshold:.2f} mm")
        x_ticks = np.linspace(0, bed_width, mesh.shape[1])
        y_ticks = np.linspace(0, bed_height, mesh.shape[0])
        ax.set_xticks(x_ticks)
        ax.set_yticks(y_ticks)
        ax.set_xticklabels([f"{x:.1f}" for x in x_ticks])
        ax.set_yticklabels([f"{y:.1f}" for y in y_ticks])
        ax.set_xlabel("X Position (mm)")
        ax.set_ylabel("Y Position (mm)")
        ax.grid(True, color='lightgray', linewidth=0.5)
        #plt.colorbar(c, ax=ax, label='Tape Needed')
        fig.text(0.5, 0.00, "White = height needed, Black = no foil needed", fontsize=9, va='bottom', ha='center')
        plt.savefig(f"{output_dir}/layer_{i+1}.png")
        plt.close()
        def generate_pdf_summary(output_dir, num_layers, layer_thickness, max_depth):
        pdf_path = os.path.join(output_dir, "foil_map_summary.pdf")
        with PdfPages(pdf_path) as pdf:
        for i in range(num_layers-1):
        img_path = os.path.join(output_dir, f"layer_{i+1}.png")
        img = Image.open(img_path)
        fig, ax = plt.subplots(figsize=(6, 6))
        if i == 0:
        # Add header text
        fig.text(0.5, 0.97, f"Foil thickness: {layer_thickness:.2f} mm, Max compensation: {max_depth:.2f} mm", ha='center', fontsize=8)
        fig.text(0.5, 0.95, "Each image layer corresponds to a threshold of bed deviation (in millimeters).", ha='center', fontsize=8, weight='bold')
        fig.text(0.5, 0.93, "The darker areas in each layer indicate where the bed is lower than that threshold,", ha='center', fontsize=8, weight='bold')
        fig.text(0.5, 0.91, "meaning foil should be added there to raise it.", ha='center', fontsize=8, weight='bold')
        fig.text(0.1, 0.89, f"Layer 1 (e.g., ≤ {layer_thickness:.2f} mm): Apply foil to all white squares.", ha='left', fontsize=8)
        fig.text(0.1, 0.87, f"Layer 2 (≤ {layer_thickness*2:.2f} mm): Apply a second layer of foil only to squares that are white in this image.", ha='left', fontsize=8)
        fig.text(0.1, 0.85, f"Layer 3 (≤ {layer_thickness*3:.2f} mm): Add a third layer where the squares are white.", ha='left', fontsize=8)
        fig.text(0.1, 0.83, "...", ha='left', fontsize=12)
        ax.imshow(img)
        ax.axis('off')
        ax.set_title(f"Layer {i+1}")
        pdf.savefig(fig)
        plt.close(fig)
        return pdf_path
        def open_pdf(path):
        try:
        if platform.system() == "Windows":
        os.startfile(path)
        elif platform.system() == "Darwin":
        subprocess.run(["open", path])
        else:
        subprocess.run(["xdg-open", path])
        except Exception as e:
        messagebox.showerror("Error", f"Could not open PDF:\n{e}")
        # --- GUI SETUP ---
        def run_gui():
        def browse_file():
        path = filedialog.askopenfilename(filetypes=[("CSV or JSON", "*.csv *.json")])
        file_entry.delete(0, tk.END)
        file_entry.insert(0, path)
        def browse_output():
        path = filedialog.askdirectory()
        output_entry.delete(0, tk.END)
        output_entry.insert(0, path)
        def run_generator():
        try:
        mesh_file = file_entry.get()
        output_dir = output_entry.get()
        #thresholds = [float(t.strip()) for t in threshold_entry.get().split(",")]
        layer_thickness = float(layer_thickness_entry.get())
        mesh = load_mesh(mesh_file)
        max_depth = -np.min(mesh)
        thresholds = np.arange(layer_thickness, max_depth + layer_thickness, layer_thickness)
        mesh = load_mesh(mesh_file)
        bed_width = float(bed_width_entry.get())
        bed_height = float(bed_height_entry.get())
        generate_images(mesh, thresholds, output_dir, bed_width, bed_height)
        # Clear old previews
        for label in preview_labels:
        label.destroy()
        preview_labels.clear()
        # Load and display thumbnails
        for i in range(len(thresholds)-1):
        img_path = os.path.join(output_dir, f"layer_{i+1}.png")
        img = Image.open(img_path).resize((120, 120))
        tk_img = ImageTk.PhotoImage(img)
        label = tk.Label(preview_frame, image=tk_img)
        label.image = tk_img # Keep reference
        label.grid(row=0, column=i, padx=5)
        preview_labels.append(label)
        # Generate PDF
        pdf_path_var.set(generate_pdf_summary(output_dir, len(thresholds), layer_thickness, max_depth))
        view_pdf_btn.config(state=tk.NORMAL)
        messagebox.showinfo("Success", f"Generated {len(thresholds)-1} layer images and PDF summary.")
        except Exception as e:
        messagebox.showerror("Error", str(e))
        def view_pdf():
        path = pdf_path_var.get()
        if path:
        open_pdf(path)
        else:
        messagebox.showwarning("No PDF", "No PDF has been generated yet.")
        root = tk.Tk()
        root.title("Foil Tape Map Generator")
        tk.Label(root, text="Mesh File (.csv or .json):").grid(row=0, column=0, sticky="w")
        file_entry = tk.Entry(root, width=40)
        file_entry.grid(row=0, column=1)
        tk.Button(root, text="Browse", command=browse_file).grid(row=0, column=2)
        tk.Label(root, text="Output Folder:").grid(row=1, column=0, sticky="w")
        output_entry = tk.Entry(root, width=40)
        output_entry.grid(row=1, column=1)
        tk.Button(root, text="Browse", command=browse_output).grid(row=1, column=2)
        tk.Label(root, text="Foil Layer Thickness (mm):").grid(row=2, column=0, sticky="w")
        layer_thickness_entry = tk.Entry(root, width=10)
        layer_thickness_entry.insert(0, "0.02")
        layer_thickness_entry.grid(row=2, column=1, sticky="w")
        # add the printer dimensions
        tk.Label(root, text="Bed Width (mm):").grid(row=3, column=0, sticky="w")
        bed_width_entry = tk.Entry(root, width=10)
        bed_width_entry.insert(0, "350")
        bed_width_entry.grid(row=3, column=1, sticky="w")
        tk.Label(root, text="Bed Height (mm):").grid(row=4, column=0, sticky="w")
        bed_height_entry = tk.Entry(root, width=10)
        bed_height_entry.insert(0, "350")
        bed_height_entry.grid(row=4, column=1, sticky="w")
        tk.Button(root, text="Generate Maps", command=run_generator).grid(row=3, column=1, pady=10)
        # View PDF button
        view_pdf_btn = tk.Button(root, text="View PDF Summary", command=view_pdf, state=tk.DISABLED)
        view_pdf_btn.grid(row=3, column=2)
        # Preview frame
        preview_frame = tk.LabelFrame(root, text="Preview", padx=10, pady=10)
        preview_frame.grid(row=5, column=0, columnspan=3, pady=10)
        preview_labels = []
        pdf_path = pdf_path_var = tk.StringVar()
        root.mainloop()
        if __name__ == "__main__":
        run_gui()