Diy mini weather station with esp32-c3 super mini

Hello readers this project is about Diy mini weather station with esp32-c3 super mini that also shows real time uv readings from the sunlight.
Materials required to build this project
ESP32 C3 super mini board
UV Sensor Module
Jumper Cables
OLED Module
Arduino IDE
3D Printer
PLA Filament
Slicing Software
Tinkercad Design of Frame
I will be using Tinkercad to design the frame for this project, and it is very easy

Arduino Code for UV Weather Station with Live UV Meter
This is the program for our weather station
#include <WiFi.h>
#include <WebServer.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <esp_wifi.h>
#include <time.h>
// ============================================================
// CONFIGURATION
// ============================================================
const char* ssid = "yourid";
const char* password = "123123123";
const char* apiKey = "9a78053936753yadfd737780df2792be(yourapikey)";
const char* city = "Bengaluru";
const char* countryCode = "IN";
#define UV_SENSOR_PIN 0
#define SDA_PIN 8
#define SCL_PIN 9
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
#define YELLOW_BAR_H 16 // top 16px = blue on your display
#define WHITE_TOP 16 // alias — first row of the lower white zone
#define WHITE_H 48 // usable lower rows (64-16)
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
WebServer server(80);
// ── Data ──────────────────────────────────────────────────
float uvIndex = 0;
float temperature = 0;
float humidity = 0;
float pressure = 0;
float windSpeed = 0;
float feelsLike = 0;
String weatherDesc = "---";
// ── Display modes ─────────────────────────────────────────
enum DisplayMode { UV_MODE, WEATHER_MODE, EYES_MODE };
DisplayMode currentMode = UV_MODE;
bool wifiOK = false;
bool serverStarted = false;
uint8_t animTick = 0;
unsigned long lastWeatherUpdate = 0;
unsigned long lastModeSwitch = 0;
unsigned long lastOLEDRefresh = 0;
unsigned long lastWifiCheck = 0;
const unsigned long UV_DURATION = 7000UL;
const unsigned long WEATHER_DURATION = 7000UL;
const unsigned long EYES_DURATION = 5000UL;
const unsigned long WEATHER_INTERVAL = 300000UL;
const unsigned long OLED_REFRESH = 80UL;
const unsigned long WIFI_CHECK_MS = 20000UL;
// ── Eye animation state ───────────────────────────────────
struct Eyes {
float lx, ly, rx, ry;
float tlx, tly, trx, try_;
float blinkL, blinkR;
bool blinking;
unsigned long nextBlink;
uint8_t expression;
unsigned long expressionEnd;
} eyes;
// ── UV helpers ────────────────────────────────────────────
const char* uvLabel(float uv) {
if (uv < 3) return "LOW";
if (uv < 6) return "MODERATE";
if (uv < 8) return "HIGH";
if (uv < 11) return "V.HIGH";
return "EXTREME";
}
// ============================================================
// OLED HELPERS
// ============================================================
void oledHeader(const char* left, const char* right = nullptr) {
display.fillRect(0, 0, SCREEN_WIDTH, YELLOW_BAR_H, SSD1306_WHITE);
display.setTextColor(SSD1306_BLACK);
display.setTextSize(1);
if (!right) {
int x = (SCREEN_WIDTH - (int)strlen(left) * 6) / 2;
display.setCursor(max(0, x), 4);
display.print(left);
} else {
display.setCursor(3, 4);
display.print(left);
display.setCursor(SCREEN_WIDTH - (int)strlen(right) * 6 - 2, 4);
display.print(right);
}
display.setTextColor(SSD1306_WHITE);
}
void hLine(int y) {
display.drawFastHLine(0, y, SCREEN_WIDTH, SSD1306_WHITE);
}
void drawSignalBars(int x, int y, int rssi) {
int level = (rssi >= -50) ? 4 : (rssi >= -65) ? 3 : (rssi >= -75) ? 2 : 1;
for (int i = 0; i < 4; i++) {
int bh = (i + 1) * 2;
int bx = x + i * 4;
int by = y + (8 - bh);
if (i < level) display.fillRect(bx, by, 3, bh, SSD1306_WHITE);
else display.drawRect(bx, by, 3, bh, SSD1306_WHITE);
}
}
// Draw arc from angle a1 to a2 around cx,cy at radius r
void drawArc(int cx, int cy, int r, float a1, float a2, uint16_t col) {
for (float a = a1; a <= a2; a += 0.06f) {
int px = cx + (int)(r * cos(a));
int py = cy + (int)(r * sin(a));
if (px >= 0 && px < 128 && py >= 0 && py < 64)
display.drawPixel(px, py, col);
}
}
// ============================================================
// SCREEN: UV INDEX
// ============================================================
void drawUVScreen() {
struct tm ti; char tbuf[6] = "--:--";
if (getLocalTime(&ti)) strftime(tbuf, sizeof(tbuf), "%H:%M", &ti);
oledHeader("SOLAR UV", tbuf);
// Arc: semicircle, centre at (64, 52), radius 30
int arcCX = 64, arcCY = 52, arcR = 30;
// Background arc
drawArc(arcCX, arcCY, arcR, PI, 2 * PI, SSD1306_WHITE);
drawArc(arcCX, arcCY, arcR - 1, PI, 2 * PI, SSD1306_WHITE);
// Sun position along arc
float uvRatio = constrain(uvIndex / 11.0f, 0.0f, 1.0f);
float sunAngle = PI + uvRatio * PI;
int sunX = arcCX + (int)(arcR * cos(sunAngle));
int sunY = arcCY + (int)(arcR * sin(sunAngle));
// Filled arc up to sun position
for (float a = PI; a <= sunAngle; a += 0.05f) {
for (int dr = -1; dr <= 1; dr++) {
int px = arcCX + (int)((arcR + dr) * cos(a));
int py = arcCY + (int)((arcR + dr) * sin(a));
if (px >= 0 && px < 128 && py >= WHITE_TOP && py < 64)
display.drawPixel(px, py, SSD1306_WHITE);
}
}
// Sun circle at current position
if (sunY >= WHITE_TOP) {
display.fillCircle(sunX, sunY, 4, SSD1306_BLACK);
display.drawCircle(sunX, sunY, 4, SSD1306_WHITE);
display.fillCircle(sunX, sunY, 2, SSD1306_WHITE);
}
// Horizon line
display.drawFastHLine(arcCX - arcR - 2, arcCY, arcR * 2 + 4, SSD1306_WHITE);
// UV value centred above arc — textSize 2
String uvStr = String(uvIndex, 1);
int tw = uvStr.length() * 12;
display.setTextSize(2);
display.setCursor((SCREEN_WIDTH - tw) / 2, 18);
display.print(uvStr);
// Risk pill
const char* lbl = uvLabel(uvIndex);
int pw = strlen(lbl) * 6 + 8;
int px2 = (SCREEN_WIDTH - pw) / 2;
display.fillRoundRect(px2, 54, pw, 10, 3, SSD1306_WHITE);
display.setTextColor(SSD1306_BLACK);
display.setTextSize(1);
display.setCursor(px2 + 4, 56);
display.print(lbl);
display.setTextColor(SSD1306_WHITE);
// Signal bars bottom-right
drawSignalBars(112, 54, WiFi.RSSI());
}
// ============================================================
// SCREEN: WEATHER
// ============================================================
void drawWeatherScreen() {
struct tm ti; char tbuf[9] = "--:--:--";
if (getLocalTime(&ti)) strftime(tbuf, sizeof(tbuf), "%H:%M:%S", &ti);
oledHeader(city, tbuf);
// Temperature
display.setTextSize(2);
display.setCursor(2, 17);
display.print(String(temperature, 1));
display.setTextSize(1);
display.print("\xF8""C");
// Feels like right
char fl[12]; snprintf(fl, sizeof(fl), "FL:%.0f\xF8", feelsLike);
display.setTextSize(1);
display.setCursor(SCREEN_WIDTH - (int)strlen(fl) * 6 - 1, 18);
display.print(fl);
// Humidity right 2nd line
char hh[8]; snprintf(hh, sizeof(hh), "H:%.0f%%", humidity);
display.setCursor(SCREEN_WIDTH - (int)strlen(hh) * 6 - 1, 27);
display.print(hh);
hLine(29);
// Description
String desc = weatherDesc; desc.toUpperCase();
if (desc.length() > 21) desc = desc.substring(0, 20) + "~";
display.setTextSize(1);
display.setCursor(2, 31);
display.print(desc);
hLine(39);
// Wind + Pressure
display.setCursor(2, 41);
display.printf("W:%.1fm/s", windSpeed);
display.setCursor(64, 41);
display.printf("P:%dhPa", (int)pressure);
hLine(49);
// UV mini arc
int mx = 12, my = 62, mr = 7;
drawArc(mx, my, mr, PI, 2 * PI, SSD1306_WHITE);
float uvR2 = constrain(uvIndex / 11.0f, 0, 1);
float sa2 = PI + uvR2 * PI;
for (float a = PI; a <= sa2; a += 0.12f)
display.drawPixel(mx + (int)(mr * cos(a)), my + (int)(mr * sin(a)), SSD1306_WHITE);
// Sun dot on mini arc
int sdx = mx + (int)(mr * cos(sa2));
int sdy = my + (int)(mr * sin(sa2));
if (sdy >= WHITE_TOP) display.fillCircle(sdx, sdy, 1, SSD1306_WHITE);
display.setCursor(22, 52);
display.print(String(uvIndex, 1));
display.setCursor(22, 59);
display.print(uvLabel(uvIndex));
drawSignalBars(110, 53, WiFi.RSSI());
}
// ============================================================
// SCREEN: ANIMATED EYES
// ============================================================
void drawEye(int cx, int cy, int ew, int eh,
float blinkAmt, float pox, float poy,
bool happy, bool surprised, bool sleepy) {
int hw = ew / 2;
int hh = surprised ? eh / 2 + 2 : sleepy ? max(2, (int)(eh / 2 * 0.5f)) : eh / 2;
display.drawRoundRect(cx - hw, cy - hh, ew, hh * 2, min(hw, hh) - 1, SSD1306_WHITE);
if (blinkAmt > 0.01f) {
int lidH = (int)(blinkAmt * hh * 2);
display.fillRect(cx - hw - 1, cy - hh, ew + 2, lidH, SSD1306_BLACK);
display.drawRoundRect(cx - hw, cy - hh, ew, hh * 2, min(hw, hh) - 1, SSD1306_WHITE);
if (!happy) display.drawFastHLine(cx - hw, cy - hh + lidH, ew, SSD1306_WHITE);
}
if (happy && blinkAmt < 0.3f) {
for (int dx = -hw + 1; dx < hw; dx++) {
float t = (float)dx / hw;
int dy = (int)(t * t * hh * 0.7f);
int px2 = cx + dx, py2 = cy + dy;
if (py2 < cy + hh && py2 >= cy - hh)
display.drawPixel(px2, py2, SSD1306_WHITE);
}
}
if (blinkAmt < 0.8f) {
int px2 = constrain(cx + (int)(pox * (hw - 3)), cx - hw + 2, cx + hw - 2);
int py2 = constrain(cy + (int)(poy * (hh - 3)), cy - hh + 2, cy + hh - 2);
int pr = surprised ? 4 : 3;
display.fillCircle(px2, py2, pr, SSD1306_WHITE);
display.drawPixel(px2 - 1, py2 - 1, SSD1306_BLACK);
}
}
void pickExpression() {
static unsigned long lastPick = 0;
if (millis() - lastPick < 2000) return;
lastPick = millis();
if (uvIndex >= 8) {
eyes.expression = 5;
} else {
uint8_t r = random(0, 10);
if (r < 3) eyes.expression = 1;
else if (r < 5) eyes.expression = 2;
else if (r < 6) eyes.expression = 3;
else if (r < 8) eyes.expression = 4;
else eyes.expression = 0;
}
eyes.expressionEnd = millis() + random(1500, 4000);
}
void updatePupils() {
float spd = 0.12f;
eyes.lx += (eyes.tlx - eyes.lx) * spd;
eyes.ly += (eyes.tly - eyes.ly) * spd;
eyes.rx += (eyes.trx - eyes.rx) * spd;
eyes.ry += (eyes.try_ - eyes.ry) * spd;
static unsigned long nextMove = 0;
if (millis() > nextMove) {
nextMove = millis() + random(600, 2000);
float tx = ((float)random(-80, 81)) / 100.0f;
float ty = ((float)random(-60, 61)) / 100.0f;
eyes.tlx = tx; eyes.tly = ty;
if (random(0, 8) == 0) {
eyes.trx = -tx; eyes.try_ = ty;
} else {
eyes.trx = tx + ((float)random(-15, 16)) / 100.0f;
eyes.try_ = ty;
}
}
}
void updateBlink() {
static unsigned long blinkStart = 0;
static bool inBlink = false;
static float blinkPhase = 0;
if (!inBlink && millis() > eyes.nextBlink) {
inBlink = true;
blinkStart = millis();
eyes.blinking = (eyes.expression == 2);
eyes.nextBlink = millis() + random(2000, 5000);
}
if (inBlink) {
unsigned long elapsed = millis() - blinkStart;
if (elapsed < 80) blinkPhase = elapsed / 80.0f;
else if (elapsed < 160) blinkPhase = 1.0f - (elapsed - 80) / 80.0f;
else { blinkPhase = 0; inBlink = false; }
if (eyes.blinking) { eyes.blinkL = blinkPhase; eyes.blinkR = 0; }
else { eyes.blinkL = blinkPhase; eyes.blinkR = blinkPhase; }
}
}
void drawEyesScreen() {
const char* exprNames[] = {
"( . _ . )", "( ^ o ^ )", "( ^_- )", "( o O )", "( -_- )", "( >_< )"
};
oledHeader(exprNames[eyes.expression]);
updatePupils();
updateBlink();
pickExpression();
bool happy = (eyes.expression == 1);
bool wink = (eyes.expression == 2);
bool surprised = (eyes.expression == 3);
bool sleepy = (eyes.expression == 4);
bool uvWarn = (eyes.expression == 5);
int eyeW = surprised ? 22 : 20;
int eyeH = surprised ? 14 : sleepy ? 7 : 12;
int eyeCY = 37;
int lEyeCX = 38, rEyeCX = 90;
drawEye(lEyeCX, eyeCY, eyeW, eyeH,
wink ? 1.0f : eyes.blinkL,
eyes.lx, eyes.ly, happy, surprised, sleepy);
drawEye(rEyeCX, eyeCY, eyeW, eyeH,
eyes.blinkR,
eyes.rx, eyes.ry, happy, surprised, sleepy);
int mouthCX = 64, mouthY = 54;
if (happy) {
drawArc(mouthCX, mouthY, 10, 0.2f, PI - 0.2f, SSD1306_WHITE);
drawArc(mouthCX, mouthY, 9, 0.2f, PI - 0.2f, SSD1306_WHITE);
} else if (uvWarn) {
drawArc(mouthCX, mouthY + 6, 8, PI + 0.2f, 2 * PI - 0.2f, SSD1306_WHITE);
drawArc(mouthCX, mouthY + 6, 7, PI + 0.2f, 2 * PI - 0.2f, SSD1306_WHITE);
display.fillCircle(100, 30, 2, SSD1306_WHITE);
display.drawPixel(100, 27, SSD1306_WHITE);
} else if (surprised) {
display.drawCircle(mouthCX, mouthY, 5, SSD1306_WHITE);
} else if (sleepy) {
display.drawFastHLine(mouthCX - 7, mouthY, 14, SSD1306_WHITE);
display.setTextSize(1);
display.setCursor(100, 26); display.print("z");
display.setCursor(106, 21); display.print("z");
display.setCursor(112, 17); display.print("Z");
} else {
drawArc(mouthCX, mouthY, 6, 0.3f, PI - 0.3f, SSD1306_WHITE);
}
if (uvWarn) {
display.setTextSize(1);
display.setCursor(2, 56);
display.printf("UV:%.1f WEAR SPF!", uvIndex);
}
}
// ============================================================
// SPLASH
// ============================================================
void splashScreen() {
display.clearDisplay();
oledHeader("ROBOHUB V3.0");
display.setTextSize(1);
display.setCursor(2, 19); display.print("UV WEATHER STATION");
display.setCursor(18, 29); display.print("ESP32-C3 MINI");
display.drawRoundRect(28, 38, 16, 10, 4, SSD1306_WHITE);
display.fillCircle(36, 43, 3, SSD1306_WHITE);
display.drawRoundRect(84, 38, 16, 10, 4, SSD1306_WHITE);
display.fillCircle(92, 43, 3, SSD1306_WHITE);
drawArc(64, 56, 7, 0.2f, PI - 0.2f, SSD1306_WHITE);
drawArc(64, 56, 6, 0.2f, PI - 0.2f, SSD1306_WHITE);
display.display();
delay(1500);
display.fillRect(0, 36, 128, 28, SSD1306_BLACK);
display.drawRect(4, 40, 120, 6, SSD1306_WHITE);
display.display();
for (int i = 0; i <= 100; i++) {
int f = (int)(i * 1.16f);
display.fillRect(5, 41, f, 4, SSD1306_WHITE);
display.fillRect(0, 50, 128, 12, SSD1306_BLACK);
display.setCursor(2, 52);
if (i < 25) display.print("Booting...");
else if (i < 55) display.print("WiFi init...");
else if (i < 85) display.print("Connecting...");
else display.print("Almost ready!");
display.setCursor(112, 52); display.printf("%d%%", i);
if (i % 3 == 0) display.display();
delay(10);
}
display.display();
delay(300);
}
// ============================================================
// CONNECTING SCREEN
// ============================================================
void drawConnectingScreen(int attempt, wl_status_t status) {
display.clearDisplay();
oledHeader("CONNECTING...");
display.setTextSize(1);
display.setCursor(2, 18); display.print("SSID: "); display.print(ssid);
display.setCursor(2, 28);
switch (status) {
case WL_DISCONNECTED: display.print("Associating..."); break;
case WL_NO_SSID_AVAIL: display.print("SSID not found"); break;
case WL_CONNECT_FAILED: display.print("Auth failed!"); break;
default: display.printf("Status: %d", status); break;
}
display.setCursor(2, 38); display.printf("Try %d / 40", attempt);
display.drawRect(4, 47, 120, 5, SSD1306_WHITE);
int fill = (int)(attempt / 40.0f * 118);
if (fill > 0) display.fillRect(5, 48, fill, 3, SSD1306_WHITE);
// Nervous eyes
display.drawRoundRect(28, 54, 14, 8, 3, SSD1306_WHITE);
display.fillCircle(35, 58, 2, SSD1306_WHITE);
display.drawRoundRect(86, 54, 14, 8, 3, SSD1306_WHITE);
display.fillCircle(93, 58, 2, SSD1306_WHITE);
display.display();
}
// ============================================================
// CONNECTED SCREEN
// ============================================================
void showConnectedScreen() {
display.clearDisplay();
oledHeader("WIFI CONNECTED");
display.setTextSize(1);
display.setCursor(2, 19); display.print("IP: "); display.print(WiFi.localIP().toString());
hLine(28);
display.setCursor(2, 31); display.printf("RSSI: %d dBm", WiFi.RSSI());
drawSignalBars(100, 30, WiFi.RSSI());
hLine(40);
display.setCursor(2, 43); display.printf("CH: %d 2.4GHz", WiFi.channel());
hLine(52);
// Happy eyes
display.drawRoundRect(24, 54, 16, 9, 4, SSD1306_WHITE);
display.fillCircle(32, 58, 3, SSD1306_WHITE);
display.drawRoundRect(88, 54, 16, 9, 4, SSD1306_WHITE);
display.fillCircle(96, 58, 3, SSD1306_WHITE);
drawArc(64, 60, 5, 0.2f, PI - 0.2f, SSD1306_WHITE);
display.display();
delay(2500);
}
// ============================================================
// FAIL SCREEN
// ============================================================
void showFailScreen() {
display.clearDisplay();
oledHeader("CONNECT FAILED");
display.setTextSize(1);
display.setCursor(2, 19); display.print("- Hotspot: 2.4GHz");
display.setCursor(2, 28); display.print("- SSID/pass correct?");
display.setCursor(2, 37); display.print("- Check serial log");
hLine(46);
display.setCursor(2, 49); display.print("Retrying in 20s...");
// Sad eyes
display.drawRoundRect(24, 54, 16, 9, 4, SSD1306_WHITE);
display.fillCircle(32, 58, 3, SSD1306_WHITE);
display.drawRoundRect(88, 54, 16, 9, 4, SSD1306_WHITE);
display.fillCircle(96, 58, 3, SSD1306_WHITE);
drawArc(64, 63, 5, PI + 0.2f, 2 * PI - 0.2f, SSD1306_WHITE);
display.display();
}
// ============================================================
// NO WIFI SCREEN
// ============================================================
void drawNoWiFiScreen() {
oledHeader("NO CONNECTION");
display.setTextSize(1);
const char* frames[] = {"Searching . ", "Searching .. ", "Searching ..."};
display.setCursor(14, 20); display.print(frames[(animTick / 4) % 3]);
display.setCursor(2, 31); display.print("SSID: "); display.print(ssid);
display.setCursor(2, 42); display.printf("UV: %.1f (offline)", uvIndex);
hLine(51);
display.drawRoundRect(24, 53, 16, 9, 4, SSD1306_WHITE);
display.fillCircle(32, 57, 3, SSD1306_WHITE);
display.drawRoundRect(88, 53, 16, 9, 4, SSD1306_WHITE);
display.fillCircle(96, 57, 3, SSD1306_WHITE);
}
// ============================================================
// OLED DRIVER
// ============================================================
void updateOLED() {
display.clearDisplay();
if (!wifiOK) {
drawNoWiFiScreen();
display.display();
return;
}
unsigned long now = millis();
unsigned long elapsed = now - lastModeSwitch;
bool advance = false;
if (currentMode == UV_MODE && elapsed >= UV_DURATION) advance = true;
if (currentMode == WEATHER_MODE && elapsed >= WEATHER_DURATION) advance = true;
if (currentMode == EYES_MODE && elapsed >= EYES_DURATION) advance = true;
if (advance) {
if (currentMode == UV_MODE) currentMode = WEATHER_MODE;
else if (currentMode == WEATHER_MODE) currentMode = EYES_MODE;
else currentMode = UV_MODE;
lastModeSwitch = now;
}
if (currentMode == UV_MODE) drawUVScreen();
else if (currentMode == WEATHER_MODE) drawWeatherScreen();
else drawEyesScreen();
display.display();
}
// ============================================================
// WIFI CONNECT
// ============================================================
bool connectWiFi() {
Serial.println("\n[WiFi] Starting ESP32-C3 Mini sequence");
WiFi.disconnect(true, true);
delay(500);
WiFi.mode(WIFI_OFF); delay(200);
WiFi.mode(WIFI_STA); delay(500);
esp_wifi_set_max_tx_power(40);
WiFi.setSleep(false);
WiFi.setAutoReconnect(false);
WiFi.begin(ssid, password);
for (int i = 0; i < 40; i++) {
delay(500);
wl_status_t s = WiFi.status();
Serial.printf("[WiFi] attempt %d status=%d\n", i + 1, (int)s);
drawConnectingScreen(i + 1, s);
if (s == WL_CONNECTED) {
Serial.printf("[WiFi] Connected! IP: %s\n", WiFi.localIP().toString().c_str());
return true;
}
if (s == WL_NO_SSID_AVAIL && i > 6) {
WiFi.disconnect(); delay(300); WiFi.begin(ssid, password);
}
if (i == 20) {
WiFi.disconnect(true); delay(500);
WiFi.mode(WIFI_STA); delay(300);
esp_wifi_set_max_tx_power(40);
WiFi.setSleep(false);
WiFi.begin(ssid, password);
}
}
return false;
}
// ============================================================
// SETUP
// ============================================================
void setup() {
Serial.begin(115200);
delay(1000);
Wire.begin(SDA_PIN, SCL_PIN);
pinMode(UV_SENSOR_PIN, INPUT);
analogReadResolution(12);
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println(F("SSD1306 fail")); while (true);
}
display.cp437(true);
randomSeed(analogRead(1));
memset(&eyes, 0, sizeof(eyes));
eyes.nextBlink = millis() + 1500;
eyes.expression = 1;
splashScreen();
wifiOK = connectWiFi();
if (wifiOK) {
configTime(5.5 * 3600, 0, "pool.ntp.org", "time.nist.gov");
server.on("/", handleRoot);
server.on("/data", handleData);
server.begin();
serverStarted = true;
showConnectedScreen();
fetchWeatherData();
lastWeatherUpdate = millis();
} else {
showFailScreen();
}
currentMode = UV_MODE;
lastModeSwitch = millis();
lastOLEDRefresh = millis();
lastWifiCheck = millis();
}
// ============================================================
// LOOP
// ============================================================
void loop() {
if (millis() - lastWifiCheck >= WIFI_CHECK_MS) {
lastWifiCheck = millis();
if (WiFi.status() != WL_CONNECTED) {
wifiOK = false;
if (connectWiFi()) {
wifiOK = true;
if (!serverStarted) {
server.on("/", handleRoot); server.on("/data", handleData);
server.begin(); serverStarted = true;
}
fetchWeatherData(); lastWeatherUpdate = millis();
}
} else { wifiOK = true; }
}
if (wifiOK && serverStarted) server.handleClient();
int raw = analogRead(UV_SENSOR_PIN);
uvIndex = max(0.0f, (raw / 4095.0f) * 3.3f * 10.0f);
if (wifiOK && millis() - lastWeatherUpdate >= WEATHER_INTERVAL) {
fetchWeatherData(); lastWeatherUpdate = millis();
}
if (millis() - lastOLEDRefresh >= OLED_REFRESH) {
animTick++;
updateOLED();
lastOLEDRefresh = millis();
}
}
// ============================================================
// WEATHER FETCH
// ============================================================
void fetchWeatherData() {
if (WiFi.status() != WL_CONNECTED) return;
HTTPClient http;
http.setTimeout(8000);
String url = "http://api.openweathermap.org/data/2.5/weather?q="
+ String(city) + "," + String(countryCode)
+ "&units=metric&appid=" + String(apiKey);
http.begin(url);
if (http.GET() == 200) {
JsonDocument doc;
if (!deserializeJson(doc, http.getString())) {
temperature = doc["main"]["temp"] | 0.0f;
feelsLike = doc["main"]["feels_like"] | 0.0f;
humidity = doc["main"]["humidity"] | 0.0f;
pressure = doc["main"]["pressure"] | 0.0f;
windSpeed = doc["wind"]["speed"] | 0.0f;
weatherDesc = doc["weather"][0]["description"].as<String>();
}
}
http.end();
}
// ============================================================
// WEB — /data
// ============================================================
void handleData() {
struct tm ti; char buf[20] = "--:--:--";
if (getLocalTime(&ti)) strftime(buf, sizeof(buf), "%H:%M:%S", &ti);
JsonDocument doc;
doc["uv"] = serialized(String(uvIndex, 1));
doc["temp"] = serialized(String(temperature, 1));
doc["feel"] = serialized(String(feelsLike, 1));
doc["hum"] = serialized(String(humidity, 0));
doc["wind"] = serialized(String(windSpeed, 1));
doc["pres"] = serialized(String(pressure, 0));
doc["desc"] = weatherDesc;
doc["time"] = buf;
doc["ip"] = WiFi.localIP().toString();
doc["risk"] = uvLabel(uvIndex);
doc["rssi"] = WiFi.RSSI();
String j; serializeJson(doc, j);
server.send(200, "application/json", j);
}
// ============================================================
// WEB — /
// ============================================================
void handleRoot() {
const char* page = R"RAWHTML(
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>RoboHub UV Station</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#060d14;--s1:#0d1f30;
--gold:#ffd632;--white:#e8f0f8;
--dim:rgba(232,240,248,0.45);--dimmer:rgba(232,240,248,0.2);
--safe:#3dffa0;--border:rgba(255,220,50,0.1);
}
body{
background:var(--bg);color:var(--white);
font-family:'DM Sans',sans-serif;min-height:100vh;
display:flex;flex-direction:column;align-items:center;
padding:28px 14px 60px;
background-image:
radial-gradient(ellipse 80% 50% at 50% -10%,rgba(255,214,50,0.07) 0%,transparent 60%),
repeating-linear-gradient(90deg,rgba(255,255,255,0.012) 0,rgba(255,255,255,0.012) 1px,transparent 1px,transparent 52px),
repeating-linear-gradient(0deg,rgba(255,255,255,0.012) 0,rgba(255,255,255,0.012) 1px,transparent 1px,transparent 52px);
}
header{width:100%;max-width:900px;display:flex;justify-content:space-between;align-items:center;margin-bottom:32px;padding-bottom:16px;border-bottom:1px solid var(--border)}
.logo{font-family:'Orbitron',monospace;font-size:12px;font-weight:900;letter-spacing:5px;color:var(--gold)}
.logo span{opacity:.4;font-weight:400}
.hdr-right{display:flex;align-items:center;gap:20px}
.rssi-wrap{display:flex;align-items:flex-end;gap:3px;height:16px}
.rssi-wrap b{width:4px;border-radius:1px;background:var(--dimmer);transition:background .4s}
.rssi-wrap b:nth-child(1){height:5px}
.rssi-wrap b:nth-child(2){height:8px}
.rssi-wrap b:nth-child(3){height:11px}
.rssi-wrap b:nth-child(4){height:16px}
.rssi-wrap b.on{background:var(--safe)}
.live{display:flex;align-items:center;gap:8px;font-size:11px;letter-spacing:3px;color:var(--dim)}
.live::before{content:'';width:7px;height:7px;border-radius:50%;background:var(--safe);box-shadow:0 0 10px var(--safe);animation:pulse 1.8s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.3;transform:scale(.6)}}
.grid{width:100%;max-width:900px;display:grid;grid-template-columns:repeat(auto-fit,minmax(270px,1fr));gap:18px}
.card{background:var(--s1);border:1px solid var(--border);border-radius:22px;padding:26px 24px;position:relative;overflow:hidden;transition:transform .3s,box-shadow .3s}
.card:hover{transform:translateY(-4px);box-shadow:0 24px 52px rgba(0,0,0,.55)}
.card::before{content:'';position:absolute;top:0;left:0;right:0;height:1px;background:linear-gradient(90deg,transparent,var(--gold),transparent);opacity:.4}
.lbl{font-family:'Orbitron',monospace;font-size:10px;font-weight:700;letter-spacing:3px;color:var(--dimmer);margin-bottom:18px;text-transform:uppercase}
.sun-wrap{position:relative;height:160px;display:flex;align-items:center;justify-content:center}
.sun-svg{width:100%;height:160px}
.uv-center{position:absolute;top:50%;left:50%;transform:translate(-50%,-66%);text-align:center}
.uv-big{font-family:'Orbitron',monospace;font-size:64px;font-weight:900;line-height:1;transition:color .6s}
.uv-unit{font-family:'Orbitron',monospace;font-size:12px;color:var(--dimmer);letter-spacing:3px;margin-top:2px}
.risk-pill{display:inline-block;margin-top:14px;padding:7px 20px;border-radius:100px;font-family:'Orbitron',monospace;font-size:11px;font-weight:700;letter-spacing:2px;border:1px solid;transition:all .5s}
.temp-num{font-family:'Orbitron',monospace;font-size:62px;font-weight:900;line-height:1;color:var(--white);letter-spacing:-2px}
.temp-num sup{font-size:22px;vertical-align:super;color:var(--dimmer)}
.w-desc{font-size:13px;color:var(--dim);text-transform:capitalize;margin-top:6px;letter-spacing:1px}
.fl-row{display:flex;gap:8px;align-items:center;margin-top:12px}
.fl-pill{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);border-radius:8px;padding:5px 12px;font-size:12px;color:var(--dim)}
.stat-list{display:flex;flex-direction:column;gap:11px;margin-top:4px}
.stat{display:flex;justify-content:space-between;align-items:center;padding:11px 14px;background:rgba(255,255,255,.04);border-radius:12px;border:1px solid rgba(255,255,255,.055)}
.sn{font-size:11px;letter-spacing:2px;color:var(--dim);text-transform:uppercase}
.sv{font-family:'Orbitron',monospace;font-size:14px;font-weight:700;color:var(--white)}
.footer{grid-column:1/-1;display:flex;justify-content:space-between;align-items:center;padding-top:6px;font-size:11px;color:var(--dimmer);letter-spacing:1px;flex-wrap:wrap;gap:6px}
.footer strong{color:var(--gold)}
@media(max-width:500px){.uv-big{font-size:48px}.temp-num{font-size:46px}}
</style>
</head>
<body>
<header>
<div class="logo">RoboHub <span>/ UV Station</span></div>
<div class="hdr-right">
<div class="rssi-wrap" id="rssiBar"><b></b><b></b><b></b><b></b></div>
<div class="live">LIVE</div>
</div>
</header>
<div class="grid">
<div class="card">
<div class="lbl">Solar UV Index</div>
<div class="sun-wrap">
<svg class="sun-svg" id="sunSvg" viewBox="0 0 260 160" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="uvGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#3dffa0"/>
<stop offset="45%" stop-color="#ffd632"/>
<stop offset="75%" stop-color="#ff8c00"/>
<stop offset="100%" stop-color="#ff4f4f"/>
</linearGradient>
</defs>
<path d="M20,140 A110,110 0 0,1 240,140" fill="none" stroke="rgba(255,255,255,0.08)" stroke-width="6" stroke-linecap="round"/>
<path id="arcFill" d="M20,140 A110,110 0 0,1 240,140" fill="none" stroke="url(#uvGrad)" stroke-width="6" stroke-linecap="round" stroke-dasharray="345" stroke-dashoffset="345"/>
<circle id="sunDot" cx="130" cy="30" r="14" fill="none" stroke="#ffd632" stroke-width="2"/>
<circle id="sunCore" cx="130" cy="30" r="7" fill="#ffd632"/>
<g id="sunRays">
<line x1="130" y1="10" x2="130" y2="4" stroke="#ffd632" stroke-width="2" stroke-linecap="round" opacity=".7"/>
<line x1="130" y1="50" x2="130" y2="56" stroke="#ffd632" stroke-width="2" stroke-linecap="round" opacity=".7"/>
<line x1="110" y1="30" x2="104" y2="30" stroke="#ffd632" stroke-width="2" stroke-linecap="round" opacity=".7"/>
<line x1="150" y1="30" x2="156" y2="30" stroke="#ffd632" stroke-width="2" stroke-linecap="round" opacity=".7"/>
<line x1="116" y1="16" x2="111" y2="11" stroke="#ffd632" stroke-width="2" stroke-linecap="round" opacity=".5"/>
<line x1="144" y1="44" x2="149" y2="49" stroke="#ffd632" stroke-width="2" stroke-linecap="round" opacity=".5"/>
<line x1="116" y1="44" x2="111" y2="49" stroke="#ffd632" stroke-width="2" stroke-linecap="round" opacity=".5"/>
<line x1="144" y1="16" x2="149" y2="11" stroke="#ffd632" stroke-width="2" stroke-linecap="round" opacity=".5"/>
</g>
<text x="16" y="156" fill="rgba(255,255,255,.3)" font-size="10" font-family="monospace" text-anchor="middle">0</text>
<text x="72" y="126" fill="rgba(255,255,255,.3)" font-size="10" font-family="monospace" text-anchor="middle">3</text>
<text x="130" y="114" fill="rgba(255,255,255,.3)" font-size="10" font-family="monospace" text-anchor="middle">6</text>
<text x="188" y="126" fill="rgba(255,255,255,.3)" font-size="10" font-family="monospace" text-anchor="middle">8</text>
<text x="244" y="156" fill="rgba(255,255,255,.3)" font-size="10" font-family="monospace" text-anchor="middle">11</text>
</svg>
<div class="uv-center">
<div class="uv-big" id="uvNum">0.0</div>
<div class="uv-unit">UV INDEX</div>
</div>
</div>
<div style="text-align:center">
<div class="risk-pill" id="riskPill">CALCULATING</div>
</div>
</div>
<div class="card">
<div class="lbl">Temperature</div>
<div class="temp-num" id="temp">--<sup>°C</sup></div>
<div class="w-desc" id="desc">connecting...</div>
<div class="fl-row">
<div class="fl-pill">Feels like <strong id="feel" style="color:var(--white)">--°C</strong></div>
<div class="fl-pill">H: <strong id="hum" style="color:var(--white)">--%</strong></div>
</div>
<div class="stat-list" style="margin-top:18px">
<div class="stat"><span class="sn">Pressure</span><span class="sv" id="pres">-- hPa</span></div>
<div class="stat"><span class="sn">Wind</span><span class="sv" id="wind">-- m/s</span></div>
</div>
</div>
<div class="card">
<div class="lbl">System</div>
<div class="stat-list">
<div class="stat"><span class="sn">Signal</span><span class="sv" id="rssiVal">-- dBm</span></div>
<div class="stat"><span class="sn">Device IP</span><span class="sv" id="ip" style="font-size:11px">---</span></div>
<div class="stat"><span class="sn">Last Sync</span><span class="sv" id="ts" style="font-size:11px">---</span></div>
</div>
</div>
<div class="footer">
<span>UV WEATHER STATION · ESP32-C3 Mini + SSD1306 + OWM</span>
<span>REFRESH <strong>2s</strong></span>
</div>
</div>
<script>
const ARC_LEN=345;
const RC={
LOW: {bg:'rgba(61,255,160,.13)', border:'rgba(61,255,160,.4)', text:'#3dffa0'},
MODERATE: {bg:'rgba(255,214,50,.13)', border:'rgba(255,214,50,.4)', text:'#ffd632'},
HIGH: {bg:'rgba(255,140,0,.15)', border:'rgba(255,140,0,.45)', text:'#ff8c00'},
'V.HIGH': {bg:'rgba(255,100,0,.15)', border:'rgba(255,100,0,.45)', text:'#ff6432'},
EXTREME: {bg:'rgba(255,79,79,.15)', border:'rgba(255,79,79,.45)', text:'#ff4f4f'},
};
let rayAngle=0, lastUV=-1;
const sunLines=document.querySelectorAll('#sunRays line');
const basePts=Array.from(sunLines).map(l=>({
x1:+l.getAttribute('x1'),y1:+l.getAttribute('y1'),
x2:+l.getAttribute('x2'),y2:+l.getAttribute('y2')
}));
function uvToPos(uv){
const ratio=Math.min(uv/11,1);
const angle=Math.PI*(1-ratio);
return{x:130+110*Math.cos(angle), y:140-110*Math.sin(angle)};
}
function animFrame(){
rayAngle+=0.4;
const pos=uvToPos(lastUV<0?0:lastUV);
const dx=pos.x-130, dy=pos.y-30;
const cos=Math.cos(rayAngle*Math.PI/180);
const sin=Math.sin(rayAngle*Math.PI/180);
sunLines.forEach((l,i)=>{
const b=basePts[i];
const rx1=(b.x1-130)*cos-(b.y1-30)*sin+130+dx;
const ry1=(b.x1-130)*sin+(b.y1-30)*cos+30+dy;
const rx2=(b.x2-130)*cos-(b.y2-30)*sin+130+dx;
const ry2=(b.x2-130)*sin+(b.y2-30)*cos+30+dy;
l.setAttribute('x1',rx1.toFixed(1));l.setAttribute('y1',ry1.toFixed(1));
l.setAttribute('x2',rx2.toFixed(1));l.setAttribute('y2',ry2.toFixed(1));
});
requestAnimationFrame(animFrame);
}
animFrame();
function setRSSI(r){
const lvl=r>=-50?4:r>=-60?3:r>=-70?2:1;
document.querySelectorAll('#rssiBar b').forEach((b,i)=>b.classList.toggle('on',i<lvl));
document.getElementById('rssiVal').textContent=r+' dBm';
}
function update(){
fetch('/data').then(r=>r.json()).then(d=>{
const uv=parseFloat(d.uv);
lastUV=uv;
document.getElementById('uvNum').textContent=d.uv;
const c=RC[d.risk]||RC.LOW;
document.getElementById('uvNum').style.color=c.text;
const pill=document.getElementById('riskPill');
pill.textContent=d.risk;
pill.style.background=c.bg;
pill.style.borderColor=c.border;
pill.style.color=c.text;
document.getElementById('arcFill').style.strokeDashoffset=Math.max(0,ARC_LEN*(1-uv/11));
const pos=uvToPos(uv);
document.getElementById('sunDot').setAttribute('cx',pos.x.toFixed(1));
document.getElementById('sunDot').setAttribute('cy',pos.y.toFixed(1));
document.getElementById('sunCore').setAttribute('cx',pos.x.toFixed(1));
document.getElementById('sunCore').setAttribute('cy',pos.y.toFixed(1));
document.getElementById('temp').innerHTML=d.temp+'<sup>°C</sup>';
document.getElementById('desc').textContent=d.desc;
document.getElementById('feel').textContent=d.feel+'°C';
document.getElementById('hum').textContent=d.hum+'%';
document.getElementById('wind').textContent=d.wind+' m/s';
document.getElementById('pres').textContent=d.pres+' hPa';
document.getElementById('ts').textContent=d.time;
document.getElementById('ip').textContent=d.ip;
if(d.rssi)setRSSI(parseInt(d.rssi));
}).catch(()=>{});
}
setInterval(update,2000);
update();
</script>
</body>
</html>
)RAWHTML";
server.send(200,"text/html",page);
}
Diy mini weather station circuit diagram
This is the circuit diagram to make live uv meter with weather station using esp32 c3 super mini.

Assembling Electronics Inside 3D Printed Frame
This is the working circuit that you will get after you upload the program.
Not happy with my circuit?
Get a custom PCB from JLCPCB they have all the PCB services for any type of projects that you could possibly imagine, Use their EasyEDA tool to design PCB.

If you are able to see this screen the wiring is correct.

Visit their site to take advantage of all their services.

Im very happy with their services and they offer fast shipping, First time? Visit them to avail great coupons and offers.
How To Use
Using this is very fun and definitely you will enjoy seeing your project come to life

Just connect to any usb power supply and the other end of USB cable to esp32 c3 board and you will see OLED display welcome message.



