Community project

Stereo FM Radio Dashboard

ESP32
Photo of Stereo FM Radio Dashboard
Generated with AI

Mehmet Murat AKBAS

Published October 22, 2026

Build a full-featured FM radio with a colorful touchscreen dashboard powered by the ESP32. This project combines the Si4703 FM receiver module with a 2.8-inch ILI9341 display, stereo amplifier, and rotary encoder to create an interactive radio tuner that displays station information, supports preset storage, and integrates weather data via WiFi.

The guide provides a complete wiring diagram showing how to connect the display via SPI, the FM radio module via I2C, dual speakers through the PAM8403 amplifier, and the rotary encoder for intuitive frequency and volume control. Assembly steps walk through power distribution, component integration, and initial firmware upload to get your radio on the air.

Wiring diagram

Wiring diagram for Stereo FM Radio Dashboard

Gather all the parts

QtyComponent
1

2.8 inç ILI9341 SPI TFT ekran

Radyo, hava durumu ve tarih-saat bilgilerini renkli gösteren 320 × 240 ekran.

1

Si4703 FM radyo alıcı modülü

FM istasyonlarını ve RDS yayın bilgisini alan, sesi sol ve sağ kanal olarak veren modül.

1

PAM8403 stereo güç yükselteci modülü

Si4703’ün düşük seviyeli sesini iki hoparlörü sürecek seviyeye yükselten 5 V stereo modül.

1

KY-040 Rotary Encoder Module

5-pin incremental rotary encoder breakout with integrated momentary push switch. CLK and DT are the quadrature outputs; SW is the built-in push-button output and should not be modelled as a separate Push Button component.

1

Push Button

Momentary push button switch

1

Sol 4–8 ohm hoparlör

Sol kanal müziğini çalan pasif hoparlör.

1

Sağ 4–8 ohm hoparlör

Sağ kanal müziğini çalan pasif hoparlör.

1

Yaklaşık 75 cm FM tel anten

FM yayınını daha iyi almak için Si4703 anten ucuna bağlanan tek damarlı tel.

Assemble it in 6 steps

1. Ortak güç hatlarını hazırlayın

ESP32-S3 kartın 3V3, 5V ve GND pinlerinden üç ayrı bağlantı hattı çıkarın. TFT ekranın VCC ucunu 3V3 hattına, GND ucunu GND hattına bağlayın (ekran gücü). Si4703 modülünün 3V3 ucunu 3V3 hattına ve GND ucunu GND hattına bağlayın (radyo gücü). PAM8403 modülünün 5V ucunu kartın 5V/VIN hattına, GND ve IN_GND uçlarını GND hattına bağlayın (yükselteç gücü ve ses referansı).

  • Önce USB kablosunu çıkarın; bütün güç bağlantılarını sonradan kontrol etmek daha kolay olur.
  • Si4703 ve ekranın 3,3 V uçlarına 5 V bağlamayın; fazla voltaj modüllere zarar verebilir.

2. Renkli ekranı bağlayın

ILI9341 ekranın SCK ucunu ESP32-S3 GPIO12 pinine bağlayın (ekran saat sinyali). MOSI ucunu GPIO11 pinine bağlayın (ekran görüntü verisi). CS ucunu GPIO10 pinine bağlayın (ekran seçimi). DC ucunu GPIO4 pinine bağlayın (komut veya görüntü verisi seçimi). RST ucunu GPIO5 pinine bağlayın (ekranı başlatma). VCC → 3V3 (power), GND → GND (ground).

  • Ekranın VCC ve GND uçlarını ters bağlamayın; ters güç ekranı bozabilir.

3. FM radyo modülünü bağlayın

Si4703 modülünün SDIO ucunu GPIO8 pinine bağlayın (radyo veri hattı). SCLK ucunu GPIO9 pinine bağlayın (radyo saat hattı). RST ucunu GPIO6 pinine bağlayın (radyo modülünü doğru haberleşme biçiminde başlatır). Si4703 LOUT ucunu PAM8403 L_IN ucuna, ROUT ucunu PAM8403 R_IN ucuna bağlayın (sol ve sağ ses). Si4703 ANT ucuna yaklaşık 75 cm uzunluğunda tek damarlı bir tel bağlayın (FM anteni). 3V3 → 3V3 (power), GND → GND (ground).

  • Anten telini ekrandan ve güç kablolarından olabildiğince uzak tutmak paraziti azaltır.
  • Si4703 modülünüzde güç pinlerinin baskısı farklıysa, mutlaka modül üzerindeki 3V3 ve GND yazılarını izleyin; bazı eski kartlarda yerleri değişebilir.

4. İki hoparlörü yükseltece bağlayın

Sol hoparlörün iki kablosunu PAM8403 üzerindeki L+ ve L- uçlarına bağlayın (sol ses). Sağ hoparlörün iki kablosunu R+ ve R- uçlarına bağlayın (sağ ses).

  • Hoparlörün L- veya R- ucunu GND’ye bağlamayın; PAM8403 bu iki ucu kendi arasında sürer ve GND bağlantısı yükseltece zarar verebilir.

5. Rotary encoder ve hava durumu butonunu bağlayın

KY-040 encoder kartında VCC → 3V3 (power), GND → GND (ground), CLK → GPIO15 (döndürme sinyali), DT → GPIO16 (döndürme yönü), SW → GPIO17 (encoder basma anahtarı) bağlayın. Ayrı butonun bir bacağını GPIO18 pinine, diğer bacağını GND’ye bağlayın (hava durumu ekranı sinyali).

  • Butonun aynı taraftaki iki bacağını kullanmayın; karşılıklı iki bacağı kullanın. Basınca hiç değişiklik olmuyorsa butonu kart üzerinde 90 derece çevirin.

6. İlk açılışı yapın

Tüm bağlantıları gözle yeniden kontrol edin. Kartı USB ile bilgisayara bağlayın. İlk açılışta telefon veya bilgisayardan StereoRadyo-Ayar adlı geçici Wi-Fi ağına bağlanıp ev ağınızı seçin; bu bağlantı hava durumu ve saat için gereklidir.

  • Encoder’a basmak frekans, ses ve kayıtlı istasyon seçimini sırayla değiştirir. Ayrı buton radyo ile hava durumu ekranı arasında geçiş yapar.

Review all connections

1. Connections between "tft_ili9341" and "ESP32"

Functiontft_ili9341ESP32
powerVCC → ekran beslemesi3V3
groundGND → ekran ortak eksi hattıGND
spiSCK → ekran SPI saat hattıGPIO 12
spiMOSI → ekran SPI görüntü verisiGPIO 11
spiCS → ekran seçme hattıGPIO 10
digitalDC → ekran komut/veri seçimiGPIO 4
digitalRST → ekran sıfırlama hattıGPIO 5

2. Connections between "si4703" and "ESP32"

Functionsi4703ESP32
power3V3 → radyo modülü 3,3 V beslemesi3V3
groundGND → radyo modülü ortak eksi hattıGND
i2cSDIO → radyo I2C veri hattıGPIO 8
i2cSCLK → radyo I2C saat hattıGPIO 9
digitalRST → radyo iki telli mod başlangıç hattıGPIO 6
analogLOUT → PAM8403 stereo güç yükselteci modülü L_INEXT
analogROUT → PAM8403 stereo güç yükselteci modülü R_INEXT
dataANT → Yaklaşık 75 cm FM tel anten ANTEXT

3. Connections between "pam8403" and "ESP32"

Functionpam8403ESP32
power5V → yükselteç 5 V beslemesi5V
groundGND → yükselteç güç eksi hattıGND
groundIN_GND → ses girişleri ortak eksi hattıGND
dataL+ → Sol 4–8 ohm hoparlör +EXT
dataL- → Sol 4–8 ohm hoparlör -EXT
dataR+ → Sağ 4–8 ohm hoparlör +EXT
dataR- → Sağ 4–8 ohm hoparlör -EXT

4. Connections between "encoder" and "ESP32"

FunctionencoderESP32
powerVCC → encoder 3,3 V beslemesi3V3
groundGND → encoder ortak eksi hattıGND
digitalCLK → döndürme darbe hattı AGPIO 15
digitalDT → döndürme yön hattı BGPIO 16
digitalSW → encoder basma anahtarıGPIO 17

5. Connections between "weather_button" and "ESP32"

Functionweather_buttonESP32
groundGND → buton ortak eksi ucuGND
digitalSIGNAL → hava durumu ekranı buton hattıGPIO 18

Deploy the firmware

#include <Arduino.h>
#include <SPI.h>
#include <Wire.h>
#include <WiFi.h>
#include <WiFiManager.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <time.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <U8g2_for_Adafruit_GFX.h>

#define TFT_CS 10
#define TFT_DC 4
#define TFT_RST 5
#define TFT_MOSI 11
#define TFT_SCK 12
#define FM_SDA 8
#define FM_SCL 9
#define FM_RST 6
#define ENC_CLK 15
#define ENC_DT 16
#define ENC_SW 17
#define WEATHER_BUTTON 18

const uint8_t SI4703_ADDR = 0x10;
Adafruit_ILI9341 tft(TFT_CS, TFT_DC, TFT_RST);
U8G2_FOR_ADAFRUIT_GFX u8g2;
uint16_t fmReg[16];
const int presets[] = {885, 945, 1017, 1042, 1065};
const uint8_t PRESET_COUNT = sizeof(presets) / sizeof(presets[0]);
int frequency10 = 1017, volumeLevel = 8, presetIndex = 2;
uint8_t encoderMode = 0; // 0 frekans, 1 ses, 2 hafiza
bool weatherScreen = false, needsFullDraw = true, weatherValid = false;
String stationName = "RDS bekleniyor";
char ps[9] = "        ";
String weatherNow = "Veri bekleniyor", weatherTomorrow = "Veri bekleniyor";
int tempNow = 0, humNow = 0, maxTomorrow = 0, minTomorrow = 0;
int dailyMax[3] = {0}, dailyMin[3] = {0}, dailyCode[3] = {0};
uint32_t lastClockDraw = 0, lastWeatherFetch = 0, lastInput = 0;
int lastClk = HIGH;

// Unifont's Polish Latin set also contains the Turkish Latin characters used here.
void fontSmall() { u8g2.setFont(u8g2_font_unifont_t_polish); }
void fontLarge() { u8g2.setFont(u8g2_font_unifont_t_polish); }
void text(int x, int y, const String &s, uint16_t color) { u8g2.setForegroundColor(color); u8g2.drawUTF8(x, y, s.c_str()); }

void siRead() {
  Wire.requestFrom((int)SI4703_ADDR, 32);
  for (int i = 0x0A; i <= 0x0F && Wire.available(); i++) fmReg[i] = (Wire.read() << 8) | Wire.read();
  for (int i = 0; i <= 0x09 && Wire.available(); i++) fmReg[i] = (Wire.read() << 8) | Wire.read();
}
void siWrite() {
  Wire.beginTransmission(SI4703_ADDR);
  for (int i = 0x02; i <= 0x07; i++) { Wire.write(fmReg[i] >> 8); Wire.write(fmReg[i] & 0xFF); }
  Wire.endTransmission();
}
void setVolume() { siRead(); fmReg[0x05] = (fmReg[0x05] & 0xFFF0) | constrain(volumeLevel, 0, 15); siWrite(); }
void tuneFM(int f) {
  frequency10 = constrain(f, 875, 1080);
  siRead();
  fmReg[0x03] = (fmReg[0x03] & 0xFE00) | (frequency10 - 875) | 0x8000;
  siWrite();
  uint32_t started = millis();
  do { delay(12); siRead(); } while (!(fmReg[0x0A] & 0x4000) && millis() - started < 700);
  siRead(); fmReg[0x03] &= ~0x8000; siWrite();
  stationName = "RDS aranıyor";
  memset(ps, ' ', 8); ps[8] = 0;
}
void initRadio() {
  pinMode(FM_SDA, OUTPUT); digitalWrite(FM_SDA, LOW);
  pinMode(FM_RST, OUTPUT); digitalWrite(FM_RST, LOW); delay(2); digitalWrite(FM_RST, HIGH); delay(5);
  Wire.begin(FM_SDA, FM_SCL); Wire.setClock(100000);
  siRead(); fmReg[0x07] = 0x8100; siWrite(); delay(550);
  siRead();
  fmReg[0x02] = 0x4001;                 // normal calisma
  fmReg[0x04] |= 0x1800;                // RDS ve Avrupa 50 us de-emphasis
  fmReg[0x05] = (fmReg[0x05] & 0xFF00) | 0x0010 | volumeLevel; // 100 kHz aralik
  siWrite(); delay(120);
  tuneFM(frequency10);
}
void readRDS() {
  siRead();
  if (!(fmReg[0x0A] & 0x8000)) return;
  uint16_t blockB = fmReg[0x0D], blockC = fmReg[0x0E], blockD = fmReg[0x0F];
  uint8_t group = (blockB >> 12) & 0x0F, versionB = (blockB >> 11) & 1;
  if (group == 0) {
    uint8_t at = (blockB & 3) * 2;
    ps[at] = char(blockD >> 8); ps[at + 1] = char(blockD & 0xFF);
    String candidate = String(ps); candidate.trim(); if (candidate.length()) stationName = candidate;
  } else if (group == 2) {
    String rt;
    if (versionB) { rt += char(blockD >> 8); rt += char(blockD & 0xFF); }
    else { rt += char(blockC >> 8); rt += char(blockC & 0xFF); rt += char(blockD >> 8); rt += char(blockD & 0xFF); }
    rt.trim(); if (rt.length() > 1) stationName = rt;
  }
}

String weatherLabel(int code) {
  if (code == 0) return "Açık";
  if (code <= 2) return "Az bulutlu";
  if (code == 3) return "Bulutlu";
  if (code <= 48) return "Sisli";
  if (code <= 57) return "Çiseleme";
  if (code <= 67) return "Yağmurlu";
  if (code <= 77) return "Karlı";
  if (code <= 82) return "Sağanak";
  return "Fırtınalı";
}
void getWeather() {
  if (WiFi.status() != WL_CONNECTED) { weatherNow = "Wi-Fi bağlı değil"; weatherTomorrow = "Hava verisi alınamadı"; weatherValid = false; return; }
  HTTPClient http;
  const char *url = "https://api.open-meteo.com/v1/forecast?latitude=41.0082&longitude=28.9784&current=temperature_2m,relative_humidity_2m,weather_code&daily=weather_code,temperature_2m_max,temperature_2m_min&timezone=Europe%2FIstanbul&forecast_days=3";
  http.begin(url); int code = http.GET();
  if (code == HTTP_CODE_OK) {
    JsonDocument doc; DeserializationError err = deserializeJson(doc, http.getStream());
    if (!err) {
      tempNow = lround(doc["current"]["temperature_2m"].as<float>());
      humNow = doc["current"]["relative_humidity_2m"].as<float>();
      weatherNow = weatherLabel(doc["current"]["weather_code"].as<int>());
      for (int i = 0; i < 3; i++) { dailyCode[i] = doc["daily"]["weather_code"][i]; dailyMax[i] = lround(doc["daily"]["temperature_2m_max"][i].as<float>()); dailyMin[i] = lround(doc["daily"]["temperature_2m_min"][i].as<float>()); }
      weatherTomorrow = weatherLabel(dailyCode[1]); maxTomorrow = dailyMax[1]; minTomorrow = dailyMin[1]; weatherValid = true;
    }
  }
  http.end(); lastWeatherFetch = millis(); needsFullDraw = true;
}
void drawWeatherGraphic(int x, int y, int code) {
  uint16_t sun = ILI9341_YELLOW, cloud = ILI9341_LIGHTGREY, rain = ILI9341_CYAN;
  if (code <= 2) { tft.fillCircle(x + 26, y + 25, 17, sun); for (int a = 0; a < 8; a++) { float r = a * PI / 4; tft.drawLine(x + 26 + cos(r) * 23, y + 25 + sin(r) * 23, x + 26 + cos(r) * 30, y + 25 + sin(r) * 30, sun); } }
  if (code >= 1) { tft.fillCircle(x + 25, y + 37, 15, cloud); tft.fillCircle(x + 42, y + 35, 19, cloud); tft.fillRect(x + 13, y + 37, 48, 16, cloud); }
  if (code >= 51 && code <= 82) for (int i = 0; i < 4; i++) tft.drawLine(x + 16 + i * 12, y + 59, x + 11 + i * 12, y + 70, rain);
  if (code >= 95) { tft.drawLine(x + 38, y + 49, x + 27, y + 68, ILI9341_YELLOW); tft.drawLine(x + 27, y + 68, x + 36, y + 68, ILI9341_YELLOW); }
}
void drawHeader(const String &title) {
  tft.fillScreen(ILI9341_NAVY); tft.fillRect(0, 0, 320, 34, ILI9341_DARKCYAN); fontLarge(); text(9, 24, title, ILI9341_WHITE);
}
void drawClock() {
  struct tm tm; if (!getLocalTime(&tm, 5)) return;
  char b[32]; strftime(b, sizeof(b), "%d.%m.%Y  %H:%M", &tm);
  tft.fillRect(126, 7, 188, 23, ILI9341_DARKCYAN); fontSmall(); text(128, 24, String(b), ILI9341_WHITE);
}
void drawRadio() {
  drawHeader("STEREO FM");
  tft.drawRoundRect(8, 45, 304, 91, 8, ILI9341_CYAN);
  fontLarge(); text(23, 77, "Frekans", ILI9341_LIGHTGREY);
  tft.setTextColor(ILI9341_YELLOW); tft.setTextSize(4); tft.setCursor(43, 125); tft.printf("%d.%d", frequency10 / 10, frequency10 % 10);
  tft.setTextSize(1); fontSmall(); text(205, 123, "MHz", ILI9341_YELLOW);
  tft.fillRoundRect(8, 146, 304, 41, 7, ILI9341_BLACK); fontSmall(); text(16, 172, "RDS: " + stationName, ILI9341_GREEN);
  tft.drawRoundRect(8, 199, 304, 31, 6, ILI9341_DARKGREY);
  String mode = encoderMode == 0 ? "Frekans" : (encoderMode == 1 ? "Ses" : "Kayıtlı");
  text(17, 221, "Encoder: " + mode + "   Ses: " + String(volumeLevel) + "/15", ILI9341_WHITE);
  drawClock();
}
void drawWeather() {
  drawHeader("İSTANBUL HAVA DURUMU");
  fontSmall(); text(10, 58, "Bugün", ILI9341_WHITE); drawWeatherGraphic(15, 65, weatherValid ? dailyCode[0] : 3);
  text(96, 88, weatherNow, ILI9341_WHITE); text(96, 112, String(tempNow) + "°C   Nem: %" + String(humNow), ILI9341_YELLOW);
  tft.drawFastHLine(8, 141, 304, ILI9341_DARKGREY);
  text(10, 165, "Yarın", ILI9341_WHITE); drawWeatherGraphic(15, 170, weatherValid ? dailyCode[1] : 3);
  text(96, 191, weatherTomorrow, ILI9341_WHITE); text(96, 216, "En düşük " + String(minTomorrow) + "°C   En yüksek " + String(maxTomorrow) + "°C", ILI9341_YELLOW);
  for (int i = 0; i < 3; i++) { int x = 215 + i * 34; int y = 229 - (dailyMax[i] + 10) * 2; tft.fillCircle(x, constrain(y, 150, 225), 3, ILI9341_RED); if (i) tft.drawLine(x - 34, constrain(229 - (dailyMax[i - 1] + 10) * 2, 150, 225), x, constrain(y, 150, 225), ILI9341_RED); }
  drawClock();
}
void drawScreen() { if (weatherScreen) drawWeather(); else drawRadio(); needsFullDraw = false; }

bool pressed(int pin) {
  static uint32_t lastPress[2] = {0, 0}; int slot = (pin == ENC_SW) ? 0 : 1;
  if (digitalRead(pin) == LOW && millis() - lastPress[slot] > 250) { lastPress[slot] = millis(); return true; }
  return false;
}
void handleInput() {
  int c = digitalRead(ENC_CLK);
  if (lastClk == HIGH && c == LOW && millis() - lastInput > 3) {
    int d = (digitalRead(ENC_DT) == HIGH) ? 1 : -1;
    if (encoderMode == 0) tuneFM(frequency10 + d); else if (encoderMode == 1) { volumeLevel = constrain(volumeLevel + d, 0, 15); setVolume(); } else { presetIndex = (presetIndex + d + PRESET_COUNT) % PRESET_COUNT; tuneFM(presets[presetIndex]); }
    lastInput = millis(); needsFullDraw = true;
  }
  lastClk = c;
  if (pressed(ENC_SW)) { encoderMode = (encoderMode + 1) % 3; needsFullDraw = true; }
  if (pressed(WEATHER_BUTTON)) { weatherScreen = !weatherScreen; needsFullDraw = true; if (weatherScreen && millis() - lastWeatherFetch > 60000) getWeather(); }
}
void setup() {
  pinMode(ENC_CLK, INPUT_PULLUP); pinMode(ENC_DT, INPUT_PULLUP); pinMode(ENC_SW, INPUT_PULLUP); pinMode(WEATHER_BUTTON, INPUT_PULLUP);
  SPI.begin(TFT_SCK, -1, TFT_MOSI, TFT_CS); tft.begin(); tft.setRotation(1); u8g2.begin(tft); tft.fillScreen(ILI9341_NAVY);
  fontSmall(); text(24, 110, "Stereo radyo başlatılıyor...", ILI9341_WHITE);
  WiFiManager wm; wm.setConfigPortalTimeout(180); wm.autoConnect("StereoRadyo-Ayar");
  configTime(3 * 3600, 0, "pool.ntp.org", "time.google.com");
  initRadio(); getWeather(); needsFullDraw = true;
}
void loop() {
  handleInput(); readRDS();
  if (millis() - lastWeatherFetch > 900000) getWeather();
  if (needsFullDraw) drawScreen();
  if (millis() - lastClockDraw > 1000) { drawClock(); lastClockDraw = millis(); }
  delay(5);
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik