This project builds a compact FM radio receiver with an OLED display using an ESP32 microcontroller and Si4702 FM tuner module. The display shows the current station frequency, RDS station name when available, and network-synchronized time. A single BOOT button controls station seeking and volume adjustment, making it a minimal but functional radio interface.
The guide provides a wiring diagram connecting the Si4702 module to the ESP32 via I2C, a complete parts list, and firmware with RDS support for station name display and WiFi time synchronization. Assembly involves connecting power, the FM receiver module, OLED display, and a pushbutton, then uploading the provided code to enable full radio functionality.
Wiring diagram

Gather all the parts
Assemble it in 4 steps
1. Gücü çıkarın
USB kablosunu çıkarın. Dahili OLED için harici ekran veya kablo gerekmez. GPIO5 ve GPIO6 ekrana ayrılmıştır.
2. Radyoyu bağlayın
Modülün yazılarına göre 3.3V → 3V3 ve GND → GND (besleme), SDIO → GPIO0 (veri), SCLK → GPIO1 (saat), RST → GPIO3 (sıfırlama) bağlayın. SEN ile 3V3 arasına 10 kΩ direnç bağlayın. Radyo GPIO1 ve GPIO2 uçları boş kalır.
- Radyo modülüne 5 V vermeyin. Fotoğrafta pin yazıları okunmadığından fiziksel pin sırası belirtilmiyor; modül üzerindeki adları esas alın.
3. Tek BOOT düğmesiyle kontrol
Harici düğmelere gerek yoktur. Eski GPIO4, GPIO10, GPIO20 ve GPIO7 düğme kablolarını USB çıkarılmışken sökün. Kartın BOOT düğmesine kısa basınca ses bir kademe artar; 0,7 saniye basılı tutunca bir kademe azalır; hızlı çift basınca ileri doğru otomatik istasyon aranır. Kısa basış çift basışı ayırt etmek için yaklaşık 0,35 saniye sonra uygulanır. Uzun basış her tutuşta yalnızca bir kademe azaltır. RST yalnızca kartı yeniden başlatır.
- USB takılırken veya RST düğmesine basılırken BOOT düğmesini basılı tutmayın; kart yükleme moduna girebilir.
4. Kulaklık ve yükleme
Kulaklığı radyo jakına takın; kablosu anten görevi görür. USB ile kartı bağlayıp Deploy düğmesine basın. Ekranda frekans, ses ve Türkiye saati görünür. Kayıtlı 2,4 GHz Wi-Fi ağı internete bağlıysa saat otomatik ayarlanır; bağlantı kurulana kadar --:-- görünür. İnternet kesilirse saat kart açık kaldığı sürece ilerler. Wi-Fi bilgileri yazılımda saklanır; projeyi paylaşmadan önce bunları silin. RADYO HATA yazarsa besleme ve radyo bağlantılarını kontrol edin.
- Çıplak hoparlörü kulaklık çıkışına doğrudan bağlamayın.
Review all connections
1. Connections between "radio" and "ESP32"
2. Connections between "sen_pullup" and "ESP32"
Deploy the firmware
#include <Arduino.h>
#include <Wire.h>
#include <U8g2lib.h>
#include <WiFi.h>
#include <time.h>
// Network credentials stay in firmware; never print them.
constexpr char WIFI_SSID[]="TurkTelekom_TP09B0";
constexpr char WIFI_PASSWORD[]="E7DvFUmRWJp9";
char clockText[6]="--:--";
char dateText[11]="--.--.--";
char stationName[9]="";
char psCandidate[9]=" ";
uint8_t psMask=0;
uint16_t stationPI=0;
uint32_t lastRdsPoll=0, rdsResume=0;
void resetRds(){
stationName[0]=0;memcpy(psCandidate," ",9);psMask=0;stationPI=0;
rdsResume=millis()+250;
}
uint32_t lastClockCheck=0, lastWifiRetry=0;
bool timeConfigured=false;
char clockHeader[16]="WIFI BEKLE";
constexpr int SDA_PIN=0, SCL_PIN=1, RESET_PIN=3;
// Manufacturer example: SSD1306 RAM window x=30..101, y=12..51.
U8G2_SSD1306_128X64_NONAME_F_SW_I2C oled(U8G2_R0,6,5,U8X8_PIN_NONE);
// Si4702/03: SEEKTH (05h.15:8), SKSNR (06h.7:4), SKCNT (06h.3:0).
// Higher values reject more weak/noisy seek candidates.
constexpr uint8_t SEEK_RSSI=10, SEEK_SNR=0, SEEK_IMPULSE=0;
uint16_t regs[16];
int freq=875, volume=1;
bool healthy=true;
bool notFound=false;
bool readRadio(){
if(Wire.requestFrom((uint8_t)0x10,(uint8_t)32)!=32) return false;
for(int n=0;n<16;n++){int i=(10+n)%16; regs[i]=(Wire.read()<<8)|Wire.read();}
return true;
}
bool writeRadio(){
Wire.beginTransmission(0x10);
for(int i=2;i<=7;i++){Wire.write(regs[i]>>8);Wire.write(regs[i]&255);}
return Wire.endTransmission()==0;
}
bool waitSTC(bool state, uint32_t timeout=1500){
uint32_t start=millis();
do {if(!readRadio())return false; if(bool(regs[10]&0x4000)==state)return true;delay(2);}while(millis()-start<timeout);
return false;
}
bool tune(){
if(!readRadio())return false;
regs[3]=(regs[3]&0xFC00)|(freq-875)|0x8000;
if(!writeRadio())return false;
bool ok=waitSTC(true);
regs[3]&=~0x8000;
if(!writeRadio())return false;
return waitSTC(false)&&ok;
}
// Let the receiver seek internally instead of tuning every channel in software.
bool seekStation(bool upwards){
notFound=false;
resetRds();
if(!readRadio())return false;
const int previous=freq;
// Wrap at band edges, choose direction, then start hardware seek.
regs[2]=(regs[2]&~0x0700)|(upwards?0x0200:0)|0x0100;
if(!writeRadio())return false;
bool complete=waitSTC(true,15000);
bool failed=bool(regs[10]&0x2000);
int found=875+(regs[11]&0x03FF);
regs[2]&=~0x0100;
if(!writeRadio())return false;
if(!waitSTC(false))return false;
if(!complete){freq=previous;notFound=true;return tune();}
freq=found;
if(failed){notFound=true;freq=previous;return tune();}
return true;
}
// Maker's drawFrame(30,12,72,40): only this RAM window is visible.
// All layout coordinates below are local to the actual 72x40 panel.
constexpr int SCREEN_X=30, SCREEN_Y=12, SCREEN_W=72, SCREEN_H=40;
// Permanent layout within the visible 72x40 pixels; no rotating pages.
void centeredLine(int baseline,const char *text){
int width=oled.getStrWidth(text);
oled.drawStr(SCREEN_X+(SCREEN_W-width)/2,SCREEN_Y+baseline,text);
}
void draw(){
oled.clearBuffer();
oled.setFontMode(1);
oled.setFontPosBaseline();
char s[24];
// Small date/time sits immediately above the unchanged frequency.
oled.setFont(u8g2_font_5x7_tr);
snprintf(s,sizeof(s),"%s %s",dateText,clockText);
centeredLine(26,s);
oled.setFont(u8g2_font_9x15_tr);
snprintf(s,sizeof(s),"%d.%d",freq/10,freq%10);
oled.drawStr(SCREEN_X,SCREEN_Y+39,s);
oled.setFont(u8g2_font_8x13_tr);
snprintf(s,sizeof(s),"%d",volume);
oled.drawStr(SCREEN_X+SCREEN_W-oled.getStrWidth(s)-3,SCREEN_Y+39,s);
oled.drawHLine(SCREEN_X,SCREEN_Y+42,SCREEN_W);
oled.setFont(u8g2_font_4x6_tr);
const char *rds=stationName[0]?stationName:"RDS BEKLE";
if(notFound)rds="BULUNAMADI";
if(!healthy)rds="RADYO HATA";
centeredLine(51,rds);
oled.sendBuffer();
}
// Standard RDS mode: RDSR exposes corrected complete groups.
// Group 0A/0B carries two PS characters in block D, indexed by B bits 1:0.
void pollRds(){
if(!healthy || millis()-lastRdsPoll<30 || int32_t(millis()-rdsResume)<0)return;
lastRdsPoll=millis();
if(!readRadio() || !(regs[10]&0x8000))return;
if((regs[13]>>12)!=0)return;
if(stationPI!=regs[12]){
stationPI=regs[12];psMask=0;memcpy(psCandidate," ",9);
}
int segment=regs[13]&3;
char first=regs[15]>>8,second=regs[15]&255;
// Use printable characters supported by the small display font.
if((uint8_t)first<32 || (uint8_t)first>126)first='?';
if((uint8_t)second<32 || (uint8_t)second>126)second='?';
psCandidate[segment*2]=first;psCandidate[segment*2+1]=second;
psMask|=1<<segment;
if(psMask==15){
if(strcmp(stationName,psCandidate)!=0){memcpy(stationName,psCandidate,9);draw();}
psMask=0;
}
}
struct Button{int pin;bool raw=HIGH,stable=HIGH;uint32_t changed=0;};
Button bootButton{9};
uint32_t pressedAt=0, releasedAt=0;
bool longHandled=false, pendingClick=false;
void changeVolume(int delta){
notFound=false;volume=constrain(volume+delta,0,15);
healthy=readRadio();
if(healthy){regs[5]=(regs[5]&0xFFF0)|volume;healthy=writeRadio();}
draw();
}
void seekForward(){
oled.clearBuffer();oled.setFont(u8g2_font_6x10_tr);oled.setFontPosBaseline();
oled.drawStr(SCREEN_X+9,SCREEN_Y+34,"ARANIYOR");oled.sendBuffer();
healthy=seekStation(true);rdsResume=millis()+250;draw();
}
void pollButton(){
uint32_t now=millis();bool level=digitalRead(bootButton.pin);
if(level!=bootButton.raw){bootButton.raw=level;bootButton.changed=now;}
if(now-bootButton.changed>=30 && bootButton.stable!=bootButton.raw){
bootButton.stable=bootButton.raw;
if(bootButton.stable==LOW){pressedAt=now;longHandled=false;}
else if(!longHandled){
if(pendingClick && now-releasedAt<=350){pendingClick=false;seekForward();}
else {pendingClick=true;releasedAt=now;}
}
}
if(bootButton.stable==LOW && !longHandled && now-pressedAt>=700){
longHandled=true;pendingClick=false;changeVolume(-1);
}
if(pendingClick && bootButton.stable==HIGH && now-releasedAt>350){
pendingClick=false;changeVolume(1);
}
}
void setup(){
pinMode(bootButton.pin,INPUT_PULLUP);
// External 10k SEN pull-up selects 2-wire mode with SDIO low.
pinMode(RESET_PIN,OUTPUT);digitalWrite(RESET_PIN,LOW);
pinMode(SDA_PIN,OUTPUT);digitalWrite(SDA_PIN,LOW);delay(2);
digitalWrite(RESET_PIN,HIGH);delay(2);
Wire.begin(SDA_PIN,SCL_PIN);Wire.setClock(100000);Wire.setTimeOut(100);
healthy=readRadio();
if(healthy){regs[7]=0x8100;healthy=writeRadio();delay(500);}
if(healthy){healthy=readRadio();regs[2]=0x4001;regs[4]|=0x1800;
regs[5]=(uint16_t(SEEK_RSSI)<<8)|0x0010|volume;
regs[6]=(regs[6]&0xF100)|(uint16_t(SEEK_SNR)<<4)|SEEK_IMPULSE;
healthy=writeRadio()&&healthy;delay(110);}
if(healthy){healthy=tune();}
oled.setBusClock(100000);oled.begin();draw();
// Nonblocking network connection: radio remains usable without Wi-Fi.
WiFi.mode(WIFI_STA);WiFi.setAutoReconnect(true);
WiFi.begin(WIFI_SSID,WIFI_PASSWORD);
lastWifiRetry=millis();
}
void loop(){
if(millis()-lastClockCheck>=1000){
lastClockCheck=millis();
char nextHeader[16];
char nextDate[11];strcpy(nextDate,dateText);
if(WiFi.status()!=WL_CONNECTED){
snprintf(nextHeader,sizeof(nextHeader),"WIFI YOK");
if(millis()-lastWifiRetry>=30000){
lastWifiRetry=millis();WiFi.disconnect();WiFi.begin(WIFI_SSID,WIFI_PASSWORD);
}
}else{
if(!timeConfigured){
configTime(3*3600,0,"pool.ntp.org","time.google.com","time.cloudflare.com");
timeConfigured=true;
}
time_t now=time(nullptr);
if(now>1700000000){
struct tm local;localtime_r(&now,&local);
strftime(clockText,sizeof(clockText),"%H:%M",&local);
strftime(nextDate,sizeof(nextDate),"%d.%m.%y",&local);
snprintf(nextHeader,sizeof(nextHeader),"FM %s",clockText);
}else snprintf(nextHeader,sizeof(nextHeader),"SAAT BEKLE");
}
if(strcmp(nextHeader,clockHeader)!=0 || strcmp(nextDate,dateText)!=0){
strcpy(clockHeader,nextHeader);strcpy(dateText,nextDate);draw();
}
}
pollButton();
pollRds();
delay(2);
}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.




