When working with electronics, a multimeter can tell you the voltage at a particular moment, but it cannot show you how that voltage changes over time. To observe these changes and understand what's happening inside a circuit, we need an oscilloscope. But instead of investing in a dedicated instrument, what if we could build a simple one ourselves using a microcontroller and a few readily available components? That's exactly what we're going to do in this project.

At the heart of this build is the Arduino UNO R4 Minima, paired with a 1.8-inch TFT display, a rotary encoder, and a voltage divider circuit. The oscilloscope can measure positive input voltages from 0 to 10V, display waveforms in real time, and calculate parameters such as peak-to-peak voltage, RMS voltage, and signal frequency. We have also designed a custom 3D-printed enclosure to house the components, making it a compact tool for basic signal testing and experimentation. You can also check out similar Arduino Projects done previously here at Circuit Digest.
This project was sponsored by Arduino, which provided the Arduino UNO R4 Minima board used in its development. We sincerely thank the Arduino team for their support and for helping us bring this project to life.
Working Principle of an Oscilloscope
An oscilloscope works by measuring how an electrical signal changes over time and displaying those changes as a waveform. The vertical axis represents the voltage, while the horizontal axis represents time. This allows us to observe the shape of a signal, identify voltage variations, and measure parameters such as amplitude, frequency, and time period.

In a digital oscilloscope, the input signal is sampled at regular intervals using an analog-to-digital converter (ADC). These samples are converted into digital values and processed to generate the waveform on the display. The quality of the displayed waveform depends on several factors, including the ADC resolution, sampling rate, input voltage range, and frequency of the signal being measured.
We can apply the same principle using a microcontroller. By reading an analog signal through its ADC, storing a series of samples, and plotting those values against time, we can create a basic digital oscilloscope. However, the microcontroller must sample the signal quickly enough to capture its variations accurately.

In our project, the Arduino UNO R4 Minima reads the input voltage through a voltage divider, processes the sampled values, and displays the resulting waveform on a TFT screen. Before building the complete circuit, we can verify this working principle by connecting the Arduino to a voltage divider, applying a test signal, and observing the readings in the Arduino IDE Serial Plotter.
Components Required
Before we start building the DIY oscilloscope, let's gather all the components needed for the project. The setup uses the Arduino UNO R4 Minima as the main controller, along with a TFT display to visualise the waveforms and a rotary encoder to adjust the display parameters. A few resistors are used to build the input voltage divider, while a BNC connector provides a convenient interface for connecting the input signal.
The following table lists the components and their specifications required to build this project.
S.No | Components | Specification | Quantity |
| 1. | Microcontroller | Arduino Uno R4 Minima | 1 |
| 2. | TFT display | 2.0" ILI9225 SPI TFT display | 1 |
| 3. | Rotary Encoder | HW-040(KY-040) | 1 |
| 4. | Resistors | 15 kΩ | 1 |
| 10 kΩ | 1 | ||
| 1 kΩ | 1 | ||
| 5. | BNC Female Connector | Right Angle PCB Mount | 1 |
Circuit Diagram and Connections
The DIY oscilloscope circuit is simple and uses only a few components. The Arduino UNO R4 Minima acts as the main controller, interfacing with the TFT display and rotary encoder. A resistor-based voltage divider scales down the incoming signal before it reaches the Arduino's analogue pin.
The circuit diagram below shows how all the components are connected. Follow the connections carefully while assembling the circuit, paying particular attention to the voltage divider and input signal connections.

The table below shows the wiring of the encoder with Minima
| Encoder Pin | Uno Minima pin |
| CLK | D2 |
| DT | D3 |
| SW | D4 |
| VCC | 5V |
| GND | GND |
The table below shows the wiring of the TFT display with the Minima
| TFT Display Pin | Uno Minima Pin |
| VCC | 5V |
| GND | GND |
| GND | GND |
| CLK | D13 |
| SDA | D11 |
| RS | D6 |
| RST | D7 |
| CS | D8 |
Once the circuit is assembled and tested, let's move on to the enclosure design. We designed a simple 3D-printed enclosure to fit the display, rotary encoder, and other components, with the Arduino and remaining electronics housed inside.
Designing the 3D-Printed Enclosure
We also designed a simple, compact enclosure for the oscilloscope to house all the components in one unit. The TFT display and rotary encoder are mounted on the front, while the Arduino UNO R4 Minima and the rest of the electronics are assembled inside.
We have also provided openings for the USB Type-C port and DC jack for power input, along with a BNC connector to connect the input signal. Below is the 3D model of the enclosure we designed for this project.

After designing the enclosure, we 3D-printed it and assembled all the components inside. The TFT display and rotary encoder are mounted on the front, while the Arduino and the rest of the circuit are housed inside. We also added the BNC connector and power input connections to complete the build.
Here is the final prototype of our DIY oscilloscope.

The above image is the final prototype of our DIY oscilloscope. I've also included the 3D enclosure files in the GitHub repository, so you can find the link below the article and print your own enclosure.
The Arduino sketch handles signal sampling, waveform display, and measurement calculations, along with the rotary encoder controls. Let's take a quick look at how the code captures the input signal, processes the readings, and displays the results on the TFT screen.
The program first initializes the display, input pins, and rotary encoder. It then captures 128 samples from the analog input, calculates the peak-to-peak voltage, RMS voltage, and signal frequency, and draws the waveform on the display. The rotary encoder is used to adjust the voltage scale, time base, and waveform position, while a long press freezes or resumes the display.
void captureFrame()
{
if (paused)
return;
long targetSampleIntervalUs =
timeDivPresetsUs[timeDivIndex] / 16;
long delayUs =
targetSampleIntervalUs - 4;
if (delayUs < 0)
delayUs = 0;
uint16_t minAdc = 1023;
uint16_t maxAdc = 0;
uint32_t sumAdcSquared = 0;
unsigned long startTime = micros();
for (uint8_t i = 0; i < BUF_LEN; i++)
{
uint16_t raw =
analogRead(PIN_SCOPE_INPUT);
sampleBuf[i] =
raw >> 2;
if (raw < minAdc)
minAdc = raw;
if (raw > maxAdc)
maxAdc = raw;
sumAdcSquared +=
(uint32_t)raw * raw;
if (delayUs > 0)
delayMicroseconds(delayUs);
// Poll the inputs inside the capture loop periodically to avoid lag
if (i % 16 == 0)
{
handleInputs();
}
}
unsigned long totalTimeUs =
micros() - startTime;
float actualSampleRateUs =
(float)totalTimeUs / BUF_LEN;
The above function captures 128 samples from the input signal through the Arduino's analog pin. It calculates the minimum and maximum ADC readings and accumulates the squared readings for RMS voltage calculation. The sampling interval is adjusted according to the selected time scale. It also periodically checks user inputs to keep the rotary encoder responsive and measures the actual time taken to capture the samples.
// ----------------------------------
// Frequency Detection
// ----------------------------------
uint8_t minVal =
minAdc >> 2;
uint8_t maxVal =
maxAdc >> 2;
float midAdc =
minVal +
((maxVal - minVal) / 2.0);
float firstCross = -1.0;
float lastCross = -1.0;
uint8_t edgesFound = 0;
for (
uint8_t i = 0;
i < BUF_LEN - 1;
i++
)
{
if (
sampleBuf[i] < midAdc &&
sampleBuf[i + 1] >= midAdc
)
{
float dy =
(float)(
sampleBuf[i + 1] -
sampleBuf[i]
);
float exactCross;
if (dy > 0)
{
exactCross =
(float)i +
(
midAdc -
sampleBuf[i]
)
/ dy;
}
else
{
exactCross = i;
}
if (firstCross < 0)
{
firstCross =
exactCross;
}
lastCross =
exactCross;
edgesFound++;
}
}
if (
edgesFound >= 2 &&
signalVpp > 0.4
)
{
float totalCycles =
edgesFound - 1;
float avgPeriodSamples =
(lastCross - firstCross)
/ totalCycles;
float periodSec =
(
avgPeriodSamples *
actualSampleRateUs
)
/ 1000000.0;
if (periodSec > 0)
{
signalFreq =
1.0 / periodSec;
}
else
{
signalFreq = 0;
}
}
else
{
signalFreq = 0;
}
}
This code calculates the signal frequency by first finding the midpoint between the minimum and maximum ADC values, which acts as the crossing threshold. It then scans the 128 samples to detect rising-edge crossings where the signal moves from below the midpoint to above it. Using linear interpolation, it estimates the exact crossing position between two samples for better accuracy.

The algorithm measures the distance between the first and last crossings and divides it by the number of cycles to calculate the average period. It then converts this period into seconds using the actual sampling interval and calculates the frequency in Hz by taking its reciprocal. Finally, it checks that at least two rising-edge crossings are detected and the signal amplitude is above 0.4 V; otherwise, the frequency is set to 0 Hz to avoid unreliable readings.
Working Demo
Now that we’ve looked at how the circuit works and how the code captures the signal, let’s put it to the test. We’ll connect different input signals, adjust the voltage and time scales, and see how accurately our Arduino oscilloscope can display the waveforms in real time.
For testing, we used a signal generator and tried different types of input signals, including sine waves, PWM signals, and several other waveforms. The oscilloscope was able to capture and display them clearly on the screen. Seeing it work so well with different signals was really satisfying, especially for a DIY build like this!
Limitations and Possible Improvements
One of the main limitations we noticed is the sampling speed. As we increase the input frequency, the waveform starts to break down due to aliasing, because the Arduino cannot sample fast enough to accurately capture the signal. This can be improved using techniques like direct ADC register access and DMA-based sampling, which can increase the sampling speed.
Another limitation is that this oscilloscope can measure only positive voltages within the Arduino's input range of 0–5V. Applying negative voltages directly can damage the board. To measure AC signals, we can add a level-shifting circuit that shifts the waveform into the safe positive voltage range, allowing the Arduino to sample it and the software to reconstruct the original waveform.
These improvements can make the oscilloscope more capable, but even in its current form, it's a useful tool for checking PWM signals, monitoring sensor outputs, and debugging basic circuits on your workbench.
GitHub Repository
This project demonstrates a simple DIY oscilloscope built using a microcontroller and basic electronic components. It provides a practical way to visualise electrical signals and explore embedded systems. Includes project code, circuit connections, and enclosure design files.
Complete Project Code
/*
Arduino Uno R4 Minima Mini Oscilloscope
Display: 2.0" ILI9225 SPI TFT (176x220)
Controls:
D2 - Rotary Encoder CLK
D3 - Rotary Encoder DT
D4 - Rotary Encoder Switch
D5 - Freeze / Pause Button (dedicated)
Display:
D6 - RS / DC
D7 - RST
D8 - CS
D11 - SDA / MOSI
D13 - CLK / SCK
Input:
A0 - Oscilloscope Input
*/
#include <Arduino.h>
#include <SPI.h>
#include "src/src/TFT_22_ILI9225_Local.h"
#include <math.h>
// =====================================================
// DISPLAY PINS
// =====================================================
#define TFT_RST 7
#define TFT_RS 6
#define TFT_CS 8
#define TFT_SDI 11
#define TFT_CLK 13
TFT_22_ILI9225 tft(
TFT_RST,
TFT_RS,
TFT_CS,
TFT_SDI,
TFT_CLK
);
// =====================================================
// PIN DEFINITIONS
// =====================================================
const uint8_t PIN_SCOPE_INPUT = A0;
const uint8_t PIN_ENC_CLK = 2;
const uint8_t PIN_ENC_DT = 3;
const uint8_t PIN_ENC_SW = 4;
const uint8_t PIN_PAUSE_BTN = 5; // Dedicated freeze / pause button
// =====================================================
// DISPLAY ROTATION & SETTINGS
// =====================================================
// 0 = Portrait (0 degrees, default)
// 1 = Landscape (90 degrees rotated right)
// 2 = Reverse Portrait (180 degrees rotated)
// 3 = Reverse Landscape (270 degrees rotated left)
#define DISPLAY_ROTATION 1
#if (DISPLAY_ROTATION == 1) || (DISPLAY_ROTATION == 3)
#define IS_LANDSCAPE true
const int SCREEN_W = 220;
const int SCREEN_H = 176;
const int GRAPH_X = 10;
const int GRAPH_Y = 30;
const int GRAPH_W = 200;
const int GRAPH_H = 100;
const int STATUS_Y = 135;
#else
#define IS_LANDSCAPE false
const int SCREEN_W = 176;
const int SCREEN_H = 220;
const int GRAPH_X = 8;
const int GRAPH_Y = 35;
const int GRAPH_W = 160;
const int GRAPH_H = 130;
const int STATUS_Y = 175;
#endif
#if IS_LANDSCAPE
uint8_t displayRotation = 3; // Default to 3 (Landscape reversed) since 1 was upside down
#else
uint8_t displayRotation = 2; // Default to 2 (Portrait reversed) since 0 was upside down
#endif
// Header
const int HEADER_H = 28;
// =====================================================
// ADC CALIBRATION
// =====================================================
// Adjust these depending on your voltage divider circuit
const float ADC_VREF = 5.0;
const float DIVIDER_RATIO = 0.4;
const float ADC_TO_VIN =
ADC_VREF / 1023.0 / DIVIDER_RATIO;
// =====================================================
// WAVEFORM BUFFER & CANVAS
// =====================================================
const uint8_t BUF_LEN = 128;
uint8_t sampleBuf[BUF_LEN];
uint8_t canvas[5200];
bool paused = false;
// =====================================================
// SIGNAL METRICS
// =====================================================
float signalFreq = 0.0;
float signalVpp = 0.0;
float signalVrms = 0.0;
// =====================================================
// PARAMETER MODES
// =====================================================
enum Mode
{
VOLTS_DIV = 0,
TIME_DIV,
X_POS,
Y_POS,
FREEZE,
SETTINGS,
MODE_COUNT
};
uint8_t currentMode = VOLTS_DIV;
// =====================================================
// VOLTS / DIV SETTINGS
// =====================================================
const float voltsDivPresets[] =
{
0.1,
0.2,
0.5,
1.0,
2.0,
3.0,
5.0,
10.0
};
const uint8_t VOLTS_DIV_COUNT = 8;
int8_t voltsDivIndex = 4; // Default: 2.0 V/div
// =====================================================
// TIME / DIV SETTINGS
// =====================================================
const unsigned int timeDivPresetsUs[] =
{
200,
500,
1000,
2000,
5000,
10000
};
const uint8_t TIME_DIV_COUNT = 6;
int8_t timeDivIndex = 3;
// =====================================================
// POSITION SETTINGS
// =====================================================
int8_t xOffset = 0;
int8_t yOffset = 0;
// =====================================================
// TELEMETRY AND SCREEN TIMING
// =====================================================
unsigned long lastSerialPrintMs = 0;
unsigned long lastRedrawMs = 0;
// =====================================================
// ENCODER INTERRUPT
// =====================================================
volatile int8_t encoderDelta = 0;
void encoderISR()
{
static uint8_t lastState = 0;
static int8_t subSteps = 0;
uint8_t clk = digitalRead(PIN_ENC_CLK);
uint8_t dt = digitalRead(PIN_ENC_DT);
uint8_t currentState = (clk << 1) | dt;
if (currentState != lastState)
{
// Clockwise transitions: 00 -> 10 -> 11 -> 01 -> 00
// Counter-clockwise transitions: 00 -> 01 -> 11 -> 10 -> 00
if ((lastState == 0b00 && currentState == 0b10) ||
(lastState == 0b10 && currentState == 0b11) ||
(lastState == 0b11 && currentState == 0b01) ||
(lastState == 0b01 && currentState == 0b00))
{
subSteps++;
}
else if ((lastState == 0b00 && currentState == 0b01) ||
(lastState == 0b01 && currentState == 0b11) ||
(lastState == 0b11 && currentState == 0b10) ||
(lastState == 0b10 && currentState == 0b00))
{
subSteps--;
}
// A standard detent (click) is 4 state changes.
// If it accumulates to 4 or -4, we register a single step.
if (subSteps >= 4)
{
encoderDelta++;
subSteps = 0;
}
else if (subSteps <= -4)
{
encoderDelta--;
subSteps = 0;
}
lastState = currentState;
}
}
// =====================================================
// BUTTON DEBOUNCE
// =====================================================
bool lastSwState = HIGH;
unsigned long lastSwMs = 0;
// =====================================================
// SERIAL TELEMETRY
// =====================================================
void printSerialTelemetry()
{
Serial.print(F("[MODE]: "));
switch (currentMode)
{
case VOLTS_DIV:
Serial.print(F("VOLTS/DIV"));
break;
case TIME_DIV:
Serial.print(F("TIME/DIV"));
break;
case X_POS:
Serial.print(F("X-POSITION"));
break;
case Y_POS:
Serial.print(F("Y-POSITION"));
break;
case SETTINGS:
Serial.print(F("SETTINGS"));
break;
}
Serial.print(F(" | V/Div: "));
Serial.print(voltsDivPresets[voltsDivIndex], 1);
Serial.print(F("V | T/Div: "));
Serial.print(timeDivPresetsUs[timeDivIndex]);
Serial.print(F("us"));
Serial.print(F(" | X: "));
Serial.print(xOffset);
Serial.print(F(" | Y: "));
Serial.print(yOffset);
Serial.print(F(" | Freq: "));
Serial.print(signalFreq, 1);
Serial.print(F("Hz"));
Serial.print(F(" | Vpp: "));
Serial.print(signalVpp, 2);
Serial.print(F("V"));
Serial.print(F(" | Vrms: "));
Serial.print(signalVrms, 2);
Serial.print(F("V"));
Serial.print(F(" | State: "));
Serial.println(
paused ?
F("*** FROZEN ***") :
F("RUNNING")
);
}
// =====================================================
// HANDLE ENCODER AND BUTTONS
// =====================================================
void handleInputs()
{
noInterrupts();
int8_t delta = encoderDelta;
encoderDelta = 0;
interrupts();
// ----------------------------------
// Rotary Encoder
// ----------------------------------
if (delta != 0)
{
int direction =
(delta > 0) ? 1 : -1;
switch (currentMode)
{
case VOLTS_DIV:
voltsDivIndex =
constrain(
voltsDivIndex + direction,
0,
VOLTS_DIV_COUNT - 1
);
break;
case TIME_DIV:
timeDivIndex =
constrain(
timeDivIndex + direction,
0,
TIME_DIV_COUNT - 1
);
break;
case X_POS:
xOffset =
constrain(
xOffset + direction * 2,
-60,
60
);
break;
case Y_POS:
yOffset =
constrain(
yOffset + direction * 2,
-50,
50
);
break;
case FREEZE:
// Encoder rotation toggles freeze
paused = !paused;
break;
case SETTINGS:
#if IS_LANDSCAPE
displayRotation = (displayRotation == 1) ? 3 : 1;
#else
displayRotation = (displayRotation == 0) ? 2 : 0;
#endif
tft.setOrientation(displayRotation);
tft.clear();
drawStaticUI();
break;
}
printSerialTelemetry();
}
// ----------------------------------
// Encoder Push Button (mode cycle — short press only)
// ----------------------------------
static bool swStable = HIGH;
static bool swPressed = false;
const unsigned long DEBOUNCE_DELAY = 40;
bool swReading = digitalRead(PIN_ENC_SW);
// Debounce the switch reading using global state variables
if (swReading != lastSwState)
{
lastSwMs = millis();
lastSwState = swReading;
}
if ((millis() - lastSwMs) > DEBOUNCE_DELAY)
{
// If the state has changed after the debounce period
if (swReading != swStable)
{
swStable = swReading;
if (swStable == LOW)
{
swPressed = true;
}
else
{
// Button released — cycle mode
if (swPressed)
{
currentMode = (currentMode + 1) % MODE_COUNT;
printSerialTelemetry();
swPressed = false;
}
}
}
}
// ----------------------------------
// Dedicated Pause / Freeze Button (D5)
// ----------------------------------
static bool pauseBtnStable = HIGH;
static bool lastPauseBtnState = HIGH;
static unsigned long lastPauseBtnMs = 0;
bool pauseReading = digitalRead(PIN_PAUSE_BTN);
if (pauseReading != lastPauseBtnState)
{
lastPauseBtnMs = millis();
lastPauseBtnState = pauseReading;
}
if ((millis() - lastPauseBtnMs) > DEBOUNCE_DELAY)
{
if (pauseReading != pauseBtnStable)
{
pauseBtnStable = pauseReading;
if (pauseBtnStable == LOW)
{
// Button pressed — toggle freeze
paused = !paused;
printSerialTelemetry();
}
}
}
}
// =====================================================
// CAPTURE WAVEFORM
// =====================================================
void captureFrame()
{
if (paused)
return;
long targetSampleIntervalUs =
timeDivPresetsUs[timeDivIndex] / 16;
long delayUs =
targetSampleIntervalUs - 4;
if (delayUs < 0)
delayUs = 0;
uint16_t minAdc = 1023;
uint16_t maxAdc = 0;
uint32_t sumAdcSquared = 0;
unsigned long startTime = micros();
for (uint8_t i = 0; i < BUF_LEN; i++)
{
uint16_t raw =
analogRead(PIN_SCOPE_INPUT);
sampleBuf[i] =
raw >> 2;
if (raw < minAdc)
minAdc = raw;
if (raw > maxAdc)
maxAdc = raw;
sumAdcSquared +=
(uint32_t)raw * raw;
if (delayUs > 0)
delayMicroseconds(delayUs);
// Poll the inputs inside the capture loop periodically to avoid lag
if (i % 16 == 0)
{
handleInputs();
}
}
unsigned long totalTimeUs =
micros() - startTime;
float actualSampleRateUs =
(float)totalTimeUs / BUF_LEN;
// ----------------------------------
// Voltage measurements
// ----------------------------------
signalVpp =
(maxAdc - minAdc)
* ADC_TO_VIN;
float meanSquareAdc =
(float)sumAdcSquared / BUF_LEN;
signalVrms =
sqrt(meanSquareAdc)
* ADC_TO_VIN;
// ----------------------------------
// Frequency Detection
// ----------------------------------
uint8_t minVal =
minAdc >> 2;
uint8_t maxVal =
maxAdc >> 2;
float midAdc =
minVal +
((maxVal - minVal) / 2.0);
float firstCross = -1.0;
float lastCross = -1.0;
uint8_t edgesFound = 0;
for (
uint8_t i = 0;
i < BUF_LEN - 1;
i++
)
{
if (
sampleBuf[i] < midAdc &&
sampleBuf[i + 1] >= midAdc
)
{
float dy =
(float)(
sampleBuf[i + 1] -
sampleBuf[i]
);
float exactCross;
if (dy > 0)
{
exactCross =
(float)i +
(
midAdc -
sampleBuf[i]
)
/ dy;
}
else
{
exactCross = i;
}
if (firstCross < 0)
{
firstCross =
exactCross;
}
lastCross =
exactCross;
edgesFound++;
}
}
if (
edgesFound >= 2 &&
signalVpp > 0.4
)
{
float totalCycles =
edgesFound - 1;
float avgPeriodSamples =
(lastCross - firstCross)
/ totalCycles;
float periodSec =
(
avgPeriodSamples *
actualSampleRateUs
)
/ 1000000.0;
if (periodSec > 0)
{
signalFreq =
1.0 / periodSec;
}
else
{
signalFreq = 0;
}
}
else
{
signalFreq = 0;
}
}
// =====================================================
// CANVAS RENDERING UTILITIES
// =====================================================
void setCanvasPixel(int x, int y, uint8_t colorIdx)
{
if (x < 0 || x >= GRAPH_W || y < 0 || y >= GRAPH_H) return;
int pixelIndex = y * GRAPH_W + x;
int byteIndex = pixelIndex / 4;
int shift = (3 - (pixelIndex % 4)) * 2;
canvas[byteIndex] = (canvas[byteIndex] & ~(0b11 << shift)) | (colorIdx << shift);
}
void drawCanvasLine(int x0, int y0, int x1, int y1, uint8_t colorIdx)
{
int dx = abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
int dy = -abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
int err = dx + dy, e2;
while (true) {
setCanvasPixel(x0, y0, colorIdx);
if (x0 == x1 && y0 == y1) break;
e2 = 2 * err;
if (e2 >= dy) { err += dy; x0 += sx; }
if (e2 <= dx) { err += dx; y0 += sy; }
}
}
void drawGridOnCanvas()
{
// Draw border (color 3 = COLOR_WHITE)
for (int x = 0; x < GRAPH_W; x++) {
setCanvasPixel(x, 0, 3);
setCanvasPixel(x, GRAPH_H - 1, 3);
}
for (int y = 0; y < GRAPH_H; y++) {
setCanvasPixel(0, y, 3);
setCanvasPixel(GRAPH_W - 1, y, 3);
}
// Vertical divisions (color 1 = COLOR_DARKGREY)
for (int x = 20; x < GRAPH_W; x += 20)
{
for (int y = 2; y < GRAPH_H; y += 4)
{
setCanvasPixel(x, y, 1);
}
}
// Horizontal divisions (color 1 = COLOR_DARKGREY)
for (int y = 20; y < GRAPH_H; y += 20)
{
for (int x = 2; x < GRAPH_W; x += 4)
{
setCanvasPixel(x, y, 1);
}
}
// Center vertical line
int centerX = GRAPH_W / 2;
for (int y = 1; y < GRAPH_H - 1; y++)
{
setCanvasPixel(centerX, y, 1);
}
// Center horizontal line
int centerY = GRAPH_H / 2;
for (int x = 1; x < GRAPH_W - 1; x++)
{
setCanvasPixel(x, centerY, 1);
}
}
void drawWaveformOnCanvas()
{
float voltsPerDiv = voltsDivPresets[voltsDivIndex];
// Base zero coordinate local to canvas
int baseZeroY = GRAPH_H / 2 + yOffset;
baseZeroY = constrain(baseZeroY, 2, GRAPH_H - 3);
int prevX = -1;
int prevY = 0;
for (uint8_t i = 0; i < BUF_LEN; i++)
{
int rawAdc = sampleBuf[i] << 2;
float vin = rawAdc * ADC_TO_VIN;
// 20 pixels = 1 division
int y = baseZeroY - (int)((vin / voltsPerDiv) * 20.0);
y = constrain(y, 1, GRAPH_H - 2);
int x = map(i, 0, BUF_LEN - 1, 0, GRAPH_W - 1) + xOffset;
x = constrain(x, 1, GRAPH_W - 2);
if (prevX >= 0)
{
drawCanvasLine(prevX, prevY, x, y, 2); // 2 = COLOR_GREEN
}
prevX = x;
prevY = y;
}
}
// =====================================================
// DRAW STATIC UI
// =====================================================
void drawStaticUI()
{
tft.setFont(
Terminal6x8
);
tft.drawText(
5,
5,
"MINI OSCILLOSCOPE",
COLOR_YELLOW
);
tft.drawLine(
0,
28,
SCREEN_W,
28,
COLOR_WHITE
);
}
// =====================================================
// DRAW HEADER
// =====================================================
void drawHeader()
{
tft.setFont(
Terminal6x8
);
char buffer[32];
// VPP
sprintf(
buffer,
"VPP:%-6.2fV",
signalVpp
);
tft.drawText(
5,
17,
buffer,
COLOR_WHITE
);
// VRMS
sprintf(
buffer,
"RMS:%-6.2fV",
signalVrms
);
#if IS_LANDSCAPE
tft.drawText(
75,
17,
buffer,
COLOR_WHITE
);
#else
tft.drawText(
65,
17,
buffer,
COLOR_WHITE
);
#endif
// Frequency
char freqBuf[16];
if (signalFreq >= 1000)
{
sprintf(
freqBuf,
"%.1fkHz",
signalFreq / 1000.0
);
}
else
{
sprintf(
freqBuf,
"%.0fHz",
signalFreq
);
}
sprintf(buffer, "%-8s", freqBuf);
#if IS_LANDSCAPE
tft.drawText(
155,
17,
buffer,
COLOR_CYAN
);
#else
tft.drawText(
125,
17,
buffer,
COLOR_CYAN
);
#endif
}
// =====================================================
// DRAW STATUS BUTTON
// =====================================================
void drawStatusBox(
int x,
int y,
int w,
int h,
bool selected,
const char *text
)
{
uint16_t borderColor =
selected ?
COLOR_YELLOW :
COLOR_WHITE;
uint16_t fillColor =
selected ?
COLOR_BLUE :
COLOR_BLACK;
tft.drawRectangle(
x,
y,
x + w,
y + h,
borderColor
);
tft.fillRectangle(
x + 1,
y + 1,
x + w - 1,
y + h - 1,
fillColor
);
tft.drawText(
x + 3,
y + 4,
text,
selected ?
COLOR_WHITE :
COLOR_CYAN
);
}
// =====================================================
// DRAW SETTINGS BOX (GEAR ICON)
// =====================================================
void drawSettingsBox(
int x,
int y,
int w,
int h,
bool selected
)
{
uint16_t borderColor =
selected ?
COLOR_YELLOW :
COLOR_WHITE;
uint16_t fillColor =
selected ?
COLOR_BLUE :
COLOR_BLACK;
tft.drawRectangle(
x,
y,
x + w,
y + h,
borderColor
);
tft.fillRectangle(
x + 1,
y + 1,
x + w - 1,
y + h - 1,
fillColor
);
int cx = x + w / 2;
int cy = y + h / 2;
uint16_t iconColor = selected ? COLOR_WHITE : COLOR_CYAN;
// Draw gear spokes
tft.drawLine(cx - 5, cy, cx + 5, cy, iconColor);
tft.drawLine(cx, cy - 5, cx, cy + 5, iconColor);
tft.drawLine(cx - 3, cy - 3, cx + 3, cy + 3, iconColor);
tft.drawLine(cx - 3, cy + 3, cx + 3, cy - 3, iconColor);
// Outer gear body
tft.drawCircle(cx, cy, 3, iconColor);
// Hollow center
tft.fillCircle(cx, cy, 2, fillColor);
}
// =====================================================
// DRAW FREEZE STATUS BOX
// =====================================================
void drawFreezeBox(
int x,
int y,
int w,
int h,
bool selected,
bool frozen
)
{
uint16_t borderColor =
selected ? COLOR_YELLOW : COLOR_WHITE;
uint16_t fillColor =
selected ? COLOR_BLUE : COLOR_BLACK;
uint16_t textColor;
if (selected)
textColor = COLOR_WHITE;
else
textColor = frozen ? COLOR_RED : COLOR_GREEN;
tft.drawRectangle(
x, y, x + w, y + h,
borderColor
);
tft.fillRectangle(
x + 1, y + 1, x + w - 1, y + h - 1,
fillColor
);
tft.drawText(
x + 3, y + 4,
frozen ? "FROZEN " : "RUNNING ",
textColor
);
}
// =====================================================
// DRAW STATUS BAR
// =====================================================
void drawStatusBar()
{
char buffer[20];
tft.setFont(
Terminal6x8
);
// -------------------------
// Volts / Div
// -------------------------
sprintf(
buffer,
"%.1fV",
voltsDivPresets[voltsDivIndex]
);
#if IS_LANDSCAPE
drawStatusBox(
5,
STATUS_Y,
48,
16,
currentMode == VOLTS_DIV,
buffer
);
#else
drawStatusBox(
5,
STATUS_Y,
48,
18,
currentMode == VOLTS_DIV,
buffer
);
#endif
// -------------------------
// Time / Div
// -------------------------
if (
timeDivPresetsUs[timeDivIndex] >= 1000
)
{
sprintf(
buffer,
"%dms",
timeDivPresetsUs[timeDivIndex] / 1000
);
}
else
{
sprintf(
buffer,
"%dus",
timeDivPresetsUs[timeDivIndex]
);
}
#if IS_LANDSCAPE
drawStatusBox(
58,
STATUS_Y,
50,
16,
currentMode == TIME_DIV,
buffer
);
#else
drawStatusBox(
60,
STATUS_Y,
50,
18,
currentMode == TIME_DIV,
buffer
);
#endif
// -------------------------
// X Position
// -------------------------
sprintf(
buffer,
"X:%d",
xOffset
);
#if IS_LANDSCAPE
drawStatusBox(
113,
STATUS_Y,
48,
16,
currentMode == X_POS,
buffer
);
#else
drawStatusBox(
117,
STATUS_Y,
52,
18,
currentMode == X_POS,
buffer
);
#endif
// -------------------------
// Y Position
// -------------------------
sprintf(
buffer,
"Y:%d",
yOffset
);
#if IS_LANDSCAPE
drawStatusBox(
166,
STATUS_Y,
48,
16,
currentMode == Y_POS,
buffer
);
#else
drawStatusBox(
5,
198,
55,
18,
currentMode == Y_POS,
buffer
);
#endif
// -------------------------
// Settings Icon Box
// -------------------------
#if IS_LANDSCAPE
drawSettingsBox(
5,
STATUS_Y + 20,
24,
16,
currentMode == SETTINGS
);
#else
drawSettingsBox(
140,
198,
31,
18,
currentMode == SETTINGS
);
#endif
// -------------------------
// Freeze Status Box
// (selectable via encoder — turn to toggle)
// -------------------------
#if IS_LANDSCAPE
drawFreezeBox(
34,
STATUS_Y + 20,
80,
16,
currentMode == FREEZE,
paused
);
#else
drawFreezeBox(
63,
198,
68,
18,
currentMode == FREEZE,
paused
);
#endif
}
void drawUI(bool forceRedraw)
{
// 1. Clear the canvas to black (color index 0)
memset(canvas, 0, sizeof(canvas));
// 2. Draw grid and border on canvas
drawGridOnCanvas();
// 3. Draw waveform on canvas
drawWaveformOnCanvas();
// 4. Push the canvas to the display at GRAPH_X, GRAPH_Y
tft.draw2BitBitmap(
GRAPH_X,
GRAPH_Y,
canvas,
GRAPH_W,
GRAPH_H,
COLOR_BLACK,
COLOR_DARKGREY,
COLOR_GREEN,
COLOR_WHITE
);
// 5. Update header and status bar
static unsigned long lastTextUpdateMs = 0;
if (forceRedraw || (millis() - lastTextUpdateMs >= 300))
{
drawHeader();
drawStatusBar();
lastTextUpdateMs = millis();
}
}
// =====================================================
// SETUP
// =====================================================
void setup()
{
Serial.begin(9600);
delay(200);
// -------------------------
// Input pins
// -------------------------
pinMode(
PIN_ENC_CLK,
INPUT_PULLUP
);
pinMode(
PIN_ENC_DT,
INPUT_PULLUP
);
pinMode(
PIN_ENC_SW,
INPUT_PULLUP
);
pinMode(
PIN_PAUSE_BTN,
INPUT_PULLUP
);
// -------------------------
// ADC
// -------------------------
analogReadResolution(10);
// -------------------------
// Encoder interrupts
// -------------------------
attachInterrupt(
digitalPinToInterrupt(
PIN_ENC_CLK
),
encoderISR,
CHANGE
);
attachInterrupt(
digitalPinToInterrupt(
PIN_ENC_DT
),
encoderISR,
CHANGE
);
// -------------------------
// Initialize TFT
// -------------------------
tft.begin();
tft.setOrientation(displayRotation);
tft.setBackgroundColor(
COLOR_BLACK
);
tft.clear();
drawStaticUI();
Serial.println(
F("--- UNO R4 MINIMA TFT OSCILLOSCOPE READY ---")
);
printSerialTelemetry();
}
// =====================================================
// MAIN LOOP
// =====================================================
void loop()
{
handleInputs();
captureFrame();
// Check if parameters changed, or if 70ms has passed since last redraw
static int8_t lastVoltsDivIndex = -1;
static int8_t lastTimeDivIndex = -1;
static int8_t lastXOffset = 99;
static int8_t lastYOffset = 99;
static uint8_t lastCurrentMode = 99;
static bool lastPaused = false;
bool parameterChanged = (voltsDivIndex != lastVoltsDivIndex ||
timeDivIndex != lastTimeDivIndex ||
xOffset != lastXOffset ||
yOffset != lastYOffset ||
currentMode != lastCurrentMode ||
paused != lastPaused);
unsigned long now = millis();
if (parameterChanged || (!paused && (now - lastRedrawMs >= 70)))
{
drawUI(parameterChanged);
lastRedrawMs = now;
lastVoltsDivIndex = voltsDivIndex;
lastTimeDivIndex = timeDivIndex;
lastXOffset = xOffset;
lastYOffset = yOffset;
lastCurrentMode = currentMode;
lastPaused = paused;
}
// Serial telemetry
if (
millis() -
lastSerialPrintMs >=
1000
)
{
printSerialTelemetry();
lastSerialPrintMs =
millis();
}
}
Add a comment
No account needed. Your comment appears once a moderator has reviewed it.