Table of contents
IntroductionIntroduction to RP2350 and its DVI capabilities
Overview of the BME280 sensor Wiring and setup Arduino programming Initial configuration
Reading sensor data Building the graphical elements The result Possible improvements
Introduction
Have you ever ended up with an unused DVI monitor? Maybe an old PC display, a small portable screen, or even a leftover TV, and wondered if it could be turned into something useful?
With the new Soldered NULA Max RP2350 microcontroller and a simple BME280 environmental sensor, that screen can become a fully functional DVI Weather Station.
The same graphics output technology used in consumer gadgets, kiosks, and digital signage is now accessible on a microcontroller you can power from a USB-C port. Pair it with the BME280, and suddenly any spare DVI display becomes a smart, always-on ambient weather hub for your home or workspace.
In this article, you will:
-
learn how microcontrollers like the RP2350 can output full DVI signals
-
understand what the BME280 measures and why it’s ideal for compact weather stations
-
see how to wire both components with minimal hardware
-
get a complete Arduino sketch for reading sensors and rendering graphics
-
build your own plug-and-play weather dashboard for any HDMI screen
Whether you want a desk companion, a workshop monitor, or a living-room info panel, this project lets you give new life to old displays with modern, open-source hardware.
Introduction to RP2350 and its DVI capabilities
The RP2350 is the newest evolution of Raspberry Pi’s microcontroller lineup - compact, affordable, and designed with makers in mind. Our NULA Max board makes use of all of its features as well as adding an DVI output connector. With it, as well as the right libraries, the chip can generate real, stable video signals directly from its pins, no extra graphics hardware required.
This is made possible thanks to the incredible PicoDVI library. It uses clever PIO (Programmable I/O) and DMA techniques to drive DVI-compatible DVI displays entirely in software.
For Arduino developers, the best part is that the PicoDVI library has been fully adapted for the Arduino ecosystem. Even better, Adafruit maintains a fork that blends PicoDVI with the familiar Adafruit GFX environment. If you've ever used an Inkplate display with Adafruit GFX, you’ll feel right at home drawing shapes, text, icons, or entire UI panels.
With just a few wires and the RP2350 board, you can output a clean digital signal to virtually any DVI screen.
Overview of the BME280 sensor

The BME280 Environmental sensor is an atmospheric sensor that measures three values: temperature, pressure, and humidity. Additionally, elevation can be calculated. It is simple to use since it communicates via I2C and is designed for seamless Qwiic connectivity.
It is extra small, so it can be placed anywhere. This sensor measures everything you need to know about atmospheric conditions, so it is ideal for meteorological station projects!
Wiring and setup
The wiring of the project couldn't be simpler, here are the needed components:
-
Soldered NULA Max RP2350
-
Environmental sensor BME280 breakout
-
Qwiic cable
-
DVI cable
Connect the BME280 via Qwiic cable, and the Monitor via the DVI cable to the NULA Max.
Install the following libraries:
A tutorial on how to install the Soldered NULA Max RP2350 board definition is available here.
Arduino programming
The following examples will go over how each component is initialized, how sensor data is read and how to draw the UI elements onto the display.
Initial configuration
The first step in the project is defining how the RP2350 should output DVI signals. This is done with a display configuration structure. If you're using the Soldered fork of PicoDVI, this step can be skipped because the fork already includes the correct default configuration.
After defining the configuration, a display object is created using the DVIGFX8 class. This selects an 8-bit color mode and a 320×240 resolution at 60 Hz, which works well for lightweight graphics on microcontrollers. The configuration maps the RP2350 pins to the DVI TMDS channels and prepares the library to generate valid video signals.
// Display configuration structure
struct dvi_serialiser_cfg rp2350_soldered_nula_dvi_cfg = {
.pio = DVI_DEFAULT_PIO_INST, // Use default PIO instance
.sm_tmds = {0, 1, 2}, // State machines for TMDS
.pins_tmds = {14, 16, 18}, // TMDS data pins
.pins_clk = 12, // Clock pin
.invert_diffpairs = true // Invert differential pairs
};
DVIGFX8 display(DVI_RES_320x240p60, true, rp2350_soldered_nula_dvi_cfg); // Create display object
BME280 bme280; // Create sensor object
It is also required to input your wi-fi credentials so that the time can be fetched via the NTP server:
const char* ssid = "YOUR_SSID_HERE"; // WiFi SSID
const char* password = "YOUR_PASSWORD_HERE"; // WiFi password
const char* ntpServer1 = "pool.ntp.org"; // Primary NTP server
const char* ntpServer2 = "time.nist.gov"; // Secondary NTP server
const long gmtOffset_sec = 0; // GMT timezone offset
const int daylightOffset_sec = 0; // No daylight saving
const char* timezone = "EST-2";
Inside setup(), several things are initialized:
-
Serial communication (optional but useful for debugging)
-
The BME280 sensor
-
Wi-Fi connection
-
NTP time synchronization
-
DVI display
-
Color palette setup and a simple status message
-
Preparing the data buffers for graphs
void setup()
{
Serial.begin(115200); // Initialize serial communication
// Uncomment the line below if you are debugging, with the line uncommented nothing will show
// On the display before a serial communication is available
//while(!Serial) {} // Wait for serial connection
Serial.println("Starting config");
bme280.begin(); // Initialize sensor
// Connect to WiFi
Serial.println("Connecting to WiFi...");
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED)
{
delay(500);
Serial.print(".");
}
Serial.println("Connected to Wi-Fi!");
// Configuring NTP communication as well as the timezone
configTime(gmtOffset_sec, daylightOffset_sec, ntpServer1, ntpServer2);
setenv("TZ", timezone, 1);
tzset();
Serial.println("Syncing time via NTP...");
delay(1000);
int retries = 0;
while (!time(nullptr) && retries < 20)
{
Serial.print(".");
delay(500);
retries++;
}
time_t now = time(nullptr);
retries = 0;
do
{
now = time(nullptr);
retries++;
delay(200);
} while ((!now || now < 1000000000) && (retries < 30));
Serial.println("Time synced successfully!");
// Initialize display
if (!display.begin())
{
Serial.println("Display init failed!");
while (1)
; // Halt if display fails
}
// Set color palette
uint16_t* pal = display.getPalette();
pal[COLOR_BG] = rgb565(0, 0, 32); // Dark blue background
pal[COLOR_TEMP] = rgb565(255, 64, 64); // Red for temperature
pal[COLOR_HUM] = rgb565(64, 255, 255); // Cyan for humidity
pal[COLOR_PRES] = rgb565(255, 255, 64); // Yellow for pressure
pal[COLOR_TEXT] = rgb565(255, 255, 255); // White for text
display.swap(false, true); // Apply palette to both buffers
display.fillScreen(COLOR_BG); // Clear screen with background color
display.setTextColor(COLOR_TEXT); // Set text color
display.setCursor(10, 100); // Position cursor
display.print("WiFi OK, Time synced"); // Display status message
display.swap(); // Update display
delay(1000);
// Initialize data arrays with zeros
memset(tempData, 0, sizeof(tempData));
memset(humData, 0, sizeof(humData));
memset(presData, 0, sizeof(presData));
}
Reading sensor data
In the main loop, data is sampled once per second. Each new temperature, humidity, and pressure reading is stored in a dedicated circular buffer. These buffers maintain the last 120 data points for the graphs. This structure lets the graphs scroll smoothly across the display and makes it easy to keep track of trends over time.
// Read sensor data
float temperature = bme280.readTemperature();
float humidity = bme280.readHumidity();
float pressure = bme280.readPressure();
// Store data in circular buffer
tempData[indexData] = temperature;
humData[indexData] = humidity;
presData[indexData] = pressure;
indexData = (indexData + 1) % GRAPH_POINTS; // Increment index with wrap-around
// Update min/max values
tempMin = min(tempMin, temperature);
tempMax = max(tempMax, temperature);
humMin = min(humMin, humidity);
humMax = max(humMax, humidity);
presMin = min(presMin, pressure);
presMax = max(presMax, pressure);
Building the graphical elements
Each graph area is rendered with the drawGraph() function. The function:
-
draws a colored background
-
adds a thin border
-
overlays grid lines
-
labels the graph
-
shows the current value, min, and max
-
plots the data line
void drawGraph(int x, int y, int w, int h, float* data, float minVal, float maxVal, uint16_t color,
const char* label, float value)
{
// Draw subtle background for graph area
display.fillRect(x, y, w, h, rgb565(20, 20, 40)); // Dark blue-gray background
// Draw thin border instead of thick rectangle
display.drawRect(x, y, w, h, rgb565(100, 100, 120)); // Subtle border
// Draw grid lines
for (int i = 1; i < 4; i++)
{
int gridY = y + (h * i / 4);
display.drawFastHLine(x + 1, gridY, w - 2, rgb565(40, 40, 60)); // Horizontal grid lines
}
for (int i = 1; i < 5; i++)
{
int gridX = x + (w * i / 5);
display.drawFastVLine(gridX, y + 1, h - 2, rgb565(40, 40, 60)); // Vertical grid lines
}
// Draw axes with slightly brighter lines
display.drawLine(x + 2, y + h - 12, x + w - 2, y + h - 12, rgb565(120, 120, 140)); // X-axis
display.drawLine(x + 12, y + 10, x + 12, y + h - 2, rgb565(120, 120, 140)); // Y-axis
// Display current value in a cleaner way
display.setTextSize(1);
display.setTextColor(color);
display.setCursor(x + 15, y + 4);
display.print(label);
display.print(": ");
display.print(value, 1);
// Add min/max labels on Y-axis
display.setTextColor(rgb565(150, 150, 170));
display.setCursor(x + 2, y + 8);
display.print((int) maxVal);
display.setCursor(x + 2, y + h - 14);
display.print((int) minVal);
// Plot smoother graph lines with optional fill
int baseY = y + h - 12;
int graphHeight = h - 22;
// Draw main graph line (thicker and smoother)
for (int i = 1; i < GRAPH_POINTS; i++)
{
int prevX = x + (i - 1) * (w - 14) / GRAPH_POINTS + 12;
int currX = x + i * (w - 14) / GRAPH_POINTS + 12;
int prevY =
baseY - (int) (((data[(indexData + i - 1) % GRAPH_POINTS] - minVal) / (maxVal - minVal)) *
graphHeight);
int currY =
baseY -
(int) (((data[(indexData + i) % GRAPH_POINTS] - minVal) / (maxVal - minVal)) * graphHeight);
// Draw thicker line for better visibility
display.drawLine(prevX, prevY, currX, currY, color);
// Optional: draw a second slightly offset line for thickness
if (i % 2 == 0)
{
display.drawLine(prevX, prevY - 1, currX, currY - 1, color);
}
}
// Draw current value indicator dot
int latestX = x + (GRAPH_POINTS - 1) * (w - 14) / GRAPH_POINTS + 12;
int latestY =
baseY -
(int) (((data[(indexData + GRAPH_POINTS - 1) % GRAPH_POINTS] - minVal) / (maxVal - minVal)) *
graphHeight);
display.fillCircle(latestX, latestY, 2, rgb565(255, 255, 255)); // White dot for current value
}
There is also a function which draws a table containing the maximum and minimum values recorded of each data type as well as the current time fetched from the NTP server:
void drawTable(int x, int y, int cellW, int cellH)
{
display.setTextSize(1); // Set text size
const char* labels[3] = {"Temp", "Hum", "Pres"}; // Row labels
uint16_t colors[3] = {COLOR_TEMP, COLOR_HUM, COLOR_PRES}; // Row colors
float current[3] = {tempData[(indexData + GRAPH_POINTS - 1) % GRAPH_POINTS],
humData[(indexData + GRAPH_POINTS - 1) % GRAPH_POINTS],
presData[(indexData + GRAPH_POINTS - 1) % GRAPH_POINTS]}; // Current values
float minV[3] = {tempMin, humMin, presMin}; // Minimum values
float maxV[3] = {tempMax, humMax, presMax}; // Maximum values
int cols = 4; // Number of columns
int rows = 4; // Number of rows
// Draw table background
display.fillRect(x, y, cellW * cols, cellH * rows, rgb565(30, 30, 50)); // Dark background
// Draw header row with accent color
const char* colLabels[4] = {"", "Min", "Curr", "Max"}; // Column headers
for (int c = 0; c < cols; c++)
{
int cellX = x + c * cellW;
int cellY = y;
display.fillRect(cellX, cellY, cellW, cellH, rgb565(60, 60, 80)); // Header background
display.drawRect(cellX, cellY, cellW, cellH, rgb565(100, 100, 120)); // Subtle border
display.setTextColor(COLOR_TEXT);
display.setCursor(cellX + 4, cellY + 4); // Slightly more padding
display.print(colLabels[c]); // Print header
}
// Draw data rows with colored labels
for (int r = 0; r < 3; r++)
{
for (int c = 0; c < cols; c++)
{
int cellX = x + c * cellW;
int cellY = y + (r + 1) * cellH;
// Draw cell background and border
display.fillRect(cellX, cellY, cellW, cellH, rgb565(40, 40, 60)); // Cell background
display.drawRect(cellX, cellY, cellW, cellH, rgb565(80, 80, 100)); // Cell border
display.setCursor(cellX + 4, cellY + 4); // Consistent padding
if (c == 0)
{
display.setTextColor(colors[r]); // Use graph colors for labels
display.print(labels[r]); // Print row label
}
else
{
display.setTextColor(COLOR_TEXT); // White for data values
if (c == 1)
display.print(minV[r], 1); // Print minimum value
else if (c == 2)
display.print(current[r], 1); // Print current value
else if (c == 3)
display.print(maxV[r], 1); // Print maximum value
}
}
}
}
The result
When we flash the project to the NULA Max board, we are greeted with a retro design resembling Teletext which updates the sensor readings every second!
*** Note: If you are seeing red lines across the whole screen, go to Tools->Optimise->Optimise more (-O2)
Full code available on GitHub.
Possible improvements
This project was created to demonstrate what the RP2350 can do with its onboard DVI output, so the code intentionally focuses on simplicity rather than visual polish. The user interface is basic, but that is exactly where the fun begins.
We encourage you to take this foundation, modify it, redesign the UI, add features, or completely reinvent the display. Then share your creations with us. Seeing how makers transform simple examples into something uniquely theirs is what makes this community so exciting.