This guide walks through loading custom firmware onto an ESP32-based shortwave radio receiver. The project transforms a radio module into a versatile multi-band receiver capable of tuning FM, AM, longwave, and numerous shortwave bands including amateur radio allocations. Builders will receive a complete wiring diagram, parts list, and step-by-step assembly instructions to get the radio operational.
The firmware leverages the SI4735 radio chip and adds a custom display interface with band selection, frequency tuning via rotary encoder, and persistent storage of user preferences. Assembly involves keeping the radio module mounted, preparing a USB connection for programming, and navigating the onboard menus to select bands and frequencies. No soldering experience required—this is a straightforward integration project.
Wiring diagram

Assemble it in 3 steps
1. Mantener la radio montada
No añadir cables ni cambiar las conexiones internas. Usar la antena y el altavoz que ya tiene la radio.
- Antes de sustituir el firmware guarda una copia del original siguiendo la documentación de recuperación del ATS Mini.
2. Preparar la conexión USB
Conectar la radio al ordenador con un cable USB de datos y poner el interruptor en encendido.
- Empezar con volumen bajo para proteger el oÃdo.
3. Usar los menús y las bandas
Girar la rueda cambia la frecuencia. Pulsar brevemente alterna entre frecuencia y volumen. Mantener pulsado durante al menos 0,7 segundos y soltar abre o cierra el menú. En el menú, girar selecciona una opción; pulsar permite ajustar su valor y pulsar de nuevo confirma.
- En Banda están FM, onda larga, media, corta, bandas de radiodifusión, radioaficionados y CB. En Modo se elige AM, LSB o USB fuera de FM.
- BFO fino ajusta la voz en SSB en pasos de 10 Hz. La carga inicial del parche SSB tarda unos instantes.
- HUD ofrece Panel, Instrumentos y Contraste. RSSI indica intensidad en dBuV y SNR calidad en dB; ambos muestran cifras grandes y medidores independientes. Tema ofrece trece paletas, incluida Space con colores del firmware original.
- Region selecciona Global, R1 Europa/Africa, R2 Americas, R3 Asia/Oceania, Japon, Brasil u OIRT historica. Cambia los limites de FM, onda media y ventanas de 80m/40m. Las bandas de onda corta comunes siguen disponibles en cada region.
- Global permite FM 64-108 MHz y AM/SSB 150-30000 kHz mediante AM total. Incluye ventanas de hora patron, CB USA por canales y 11m de 25 a 30 MHz.
- AMP abre un submenú con Amplificador (encendido/apagado), Volumen (0-63) y Volver. Pulsar una opción permite editarla; girar cambia el valor y pulsar confirma. Volumen es el nivel de audio del receptor, no una ganancia independiente del amplificador.
- HUD → Waterfall muestra una cascada por barrido de RSSI. Pulsar pausa para escuchar la frecuencia indicada; pulsar otra vez reanuda. Girar cambia la frecuencia central o el canal CB. Mantener pulsado abre el menú y detiene el barrido. El audio queda silenciado durante el barrido; cada fila tarda varios segundos. CB recorre sus 40 canales en orden de canal.
- Hay diez memorias. Elegir Memoria y después Guardar memoria o Cargar memoria. Los ajustes se guardan automáticamente tras dos segundos sin cambios.
- Los perfiles regionales son ventanas de escucha generales, no una lista de todas las asignaciones nacionales. CB USA mantiene su plan de 40 canales en todos los perfiles; no representa la CB local de cada pais. Las ventanas de hora patron no garantizan una emisora activa.
- Solo recepción. No recibe aviación VHF, DAB ni frecuencias superiores a 108 MHz. Los rangos de radioaficionados son ventanas de escucha y no autorizaciones de transmisión.
- La cobertura de sintonÃa no garantiza recepción. En onda larga y corta depende de la antena y de las condiciones locales.
- El parche SSB incluido tiene una restricción de redistribución comercial indicada por su autor.
Deploy the firmware
#include <Arduino.h>
#include <math.h>
#include <Wire.h>
#include <SI4735.h>
#include <Preferences.h>
#include "Display.h"
#include "SSBPatch.h"
LGFX screen;
LGFX_Sprite ui(&screen);
SI4735 radio;
Preferences prefs;
constexpr int POWER=15, AMP=10, MUTE=3, RESET=16, SDA_PIN=18, SCL_PIN=17;
constexpr int BL=38, ENC_A=2, ENC_B=1, BUTTON=21, BATTERY_PIN=4;
struct Band {const char* name; uint16_t lo,hi,initial;};
Band bands[]={
{"FM completa",6400,10800,9950},{"Onda larga",150,519,198},
{"Onda media",520,1710,1000},{"OC completa",1711,30000,7100},
{"OC 120m",2300,2495,2400},{"OC 90m",3200,3400,3300},
{"OC 75m",3900,4000,3950},{"OC 60m",4750,5060,5000},
{"OC 49m",5900,6200,6000},{"OC 41m",7200,7450,7300},
{"OC 31m",9400,9900,9600},{"OC 25m",11600,12100,11800},
{"OC 22m",13570,13870,13600},{"OC 19m",15100,15830,15300},
{"OC 16m",17480,17900,17600},{"OC 15m",18900,19020,18950},
{"OC 13m",21450,21850,21600},{"Radio OC 11m",25670,26100,25800},
{"Afic. 160m",1800,2000,1900},{"Afic. 80m",3500,4000,3700},
{"Afic. 60m",5250,5450,5357},{"Afic. 40m",7000,7300,7100},
{"Afic. 30m",10100,10150,10120},{"Afic. 20m",14000,14350,14200},
{"Afic. 17m",18068,18168,18100},{"Afic. 15m",21000,21450,21200},
{"Afic. 12m",24890,24990,24920},{"CB USA",26965,27405,27205},
{"Afic. 10m",28000,29700,28500},{"11m amplia",25000,30000,27000},{"AM total",150,30000,1000},
{"Hora 2.5 MHz",2495,2505,2500},{"Hora 5 MHz",4995,5005,5000},
{"Hora 10 MHz",9995,10005,10000},{"Hora 15 MHz",14995,15005,15000},
{"Hora 20 MHz",19995,20005,20000},{"Hora 25 MHz",24995,25005,25000},
// Append only: existing saved band IDs must never change.
{"HF completa",3000,30000,7100},
{"HF 3-5 MHz",3000,5000,3500},{"HF 5-7 MHz",5000,7000,5500},
{"HF 7-9 MHz",7000,9000,7500},{"HF 9-11 MHz",9000,11000,10000},
{"HF 11-13 MHz",11000,13000,12000},{"HF 13-15 MHz",13000,15000,14000},
{"HF 15-17 MHz",15000,17000,16000},{"HF 17-19 MHz",17000,19000,18000},
{"HF 19-21 MHz",19000,21000,20000},{"HF 21-23 MHz",21000,23000,22000},
{"HF 23-25 MHz",23000,25000,24000},{"HF 25-27 MHz",25000,27000,26000},
{"HF 27-30 MHz",27000,30000,28000},
{"Afic. 75m / amplio",3800,4000,3900},
{"160m CW",1800,2000,1810},{"160m Digital",1800,1810,1805},
{"160m LSB / SSTV",1843,2000,1910},{"160m QRP CW",1810,1810,1810},
{"160m QRP LSB",1910,1910,1910},{"160m Balizas",1995,2000,1998},
{"80m RTTY / Datos",3570,3600,3580},{"80m DX LSB",3790,3800,3795},
{"80m SSTV LSB",3845,3845,3845},{"80m Llamada AM",3885,3885,3885},
{"60m USB / continuo",5250,5450,5357},
{"40m RTTY / Datos",7080,7125,7100},{"40m SSTV LSB",7171,7171,7171},
{"40m Llamada AM",7290,7290,7290},
{"30m RTTY",10130,10140,10135},{"30m Packet",10140,10150,10145},
{"20m RTTY",14070,14095,14080},{"20m Packet",14095,14112,14105},
{"20m Balizas",14100,14100,14100},{"20m SSTV USB",14230,14230,14230},
{"20m Llamada AM",14286,14286,14286},
{"17m RTTY",18100,18105,18102},{"17m Packet",18105,18110,18108},
{"15m RTTY / Datos",21070,21110,21080},{"15m SSTV USB",21340,21340,21340},
{"12m RTTY",24920,24925,24922},{"12m Packet",24925,24930,24928},
{"10m CW",28000,28070,28030},{"10m RTTY",28070,28150,28100},
{"10m Balizas",28200,28300,28250},{"10m Voz USB",28300,29300,28500},
{"10m AM",29000,29200,29100}
};
constexpr int BAND_COUNT=sizeof(bands)/sizeof(bands[0]);
constexpr uint8_t CB_BAND=27;
// FCC 47 CFR 95.963: channel order is not strictly frequency order.
const uint16_t cbFrequencies[]={
26965,26975,26985,27005,27015,27025,27035,27055,27065,27075,
27085,27105,27115,27125,27135,27155,27165,27175,27185,27205,
27215,27225,27255,27235,27245,27265,27275,27285,27295,27305,
27315,27325,27335,27345,27355,27365,27375,27385,27395,27405
};
int cbChannel(uint16_t frequency){
int best=0;
for(int i=1;i<40;i++)
if(abs(int(frequency)-int(cbFrequencies[i]))<abs(int(frequency)-int(cbFrequencies[best])))best=i;
return best;
}
// Menu folders use original IDs so saved frequencies and memories stay valid.
struct BandFolder {const char* name; const char* ids;};
const BandFolder folders[]={
{"FM", "0"}, {"Onda larga", "1"}, {"Onda media", "2"},
{"OC / Radiodifusion", "4,5,6,7,8,9,10,11,12,13,14,15,16,17"},
{"CB / 11 metros", "27,29"},
{"Aficionados / 160m", "18,52,53,54,55,56,57"},
{"Aficionados / 80-75m", "19,51,58,59,60,61"},
{"Aficionados / 60m", "20,62"},
{"Aficionados / 40m", "21,63,64,65"},
{"Aficionados / 30m", "22,66,67"},
{"Aficionados / 20m", "23,68,69,70,71,72"},
{"Aficionados / 17m", "24,73,74"},
{"Aficionados / 15m", "25,75,76"},
{"Aficionados / 12m", "26,77,78"},
{"Aficionados / 10m", "28,79,80,81,82,83"},
{"Senales horarias", "31,32,33,34,35,36"},
{"HF / Tramos continuos", "37,38,39,40,41,42,43,44,45,46,47,48,49,50"},
{"Cobertura completa", "3,30"}
};
constexpr int FOLDER_COUNT=sizeof(folders)/sizeof(folders[0]);
int folder=0,folderPosition=0;
bool folderOpen=false;
int folderBand(int f,int position){
const char* p=folders[f].ids;
for(int i=0;i<position;i++){p=strchr(p,',');if(!p)return -1;p++;}
return atoi(p);
}
int folderSize(int f){int n=1;for(const char* p=folders[f].ids;*p;p++)if(*p==',')n++;return n;}
void locateBandFolder(int id){
for(int f=0;f<FOLDER_COUNT;f++)for(int i=0;i<folderSize(f);i++)
if(folderBand(f,i)==id){folder=f;folderPosition=i;return;}
}
const char* hudNames[]={"Panel","Instrumentos","Contraste","Barras LED","Doble dial","Minimal","Consola","Escaner","Tarjetas","Ecualizador","Agujas","Telemetria","Waterfall"};
constexpr int HUD_COUNT=sizeof(hudNames)/sizeof(hudNames[0]);
const char* modes[]={"AM","LSB","USB"};
const uint16_t amSteps[]={1,5,9,10,50,100};
const uint16_t fmSteps[]={1,5,10,20,50,100};
const char* amFilters[]={"6","4","3","2","1","1.8","2.5"};
const char* ssbFilters[]={"1.2","2.2","3","4","0.5","1"};
const char* items[]={"Banda","Modo","Paso","Volumen","Filtro AM/SSB","AGC automatico","Atenuacion","BFO fino","HUD","Silencio","Memoria","Guardar memoria","Cargar memoria","Volver","Tema","Region","AMP"};
constexpr int ITEM_COUNT=sizeof(items)/sizeof(items[0]);
struct Memory {uint16_t frequency; uint8_t band,mode;};
Memory memories[10]={};
uint16_t frequencies[BAND_COUNT];
uint8_t band=0, mode=0, volume=12, stepIndex=2, amFilter=3, ssbFilter=1, attenuation=0, hud=0, slot=0;
const char* regionNames[]={"Global / libre","R1 Europa / Africa","R2 Americas","R3 Asia / Oceania","Japon","Brasil","OIRT historica"};
const char* themeNames[]={"Oceano","Ambar","Bosque","Claro","Violeta","Rojo nocturno","Grafito","Retro verde","Atardecer","Hielo","Rosa","Arena","Space"};
constexpr int THEME_COUNT=sizeof(themeNames)/sizeof(themeNames[0]);
struct Palette {uint16_t background,panel,accent,text,dim;};
const Palette palettes[]={
{0x0841,0x10E3,0x07FF,0xFFFF,0xAD55},
{0x0800,0x18C1,0xFD20,0xFFFF,0xAD55},
{0x0020,0x0922,0x07E8,0xFFFF,0xAD55},
{0xEF7D,0xD69A,0x025F,0x0000,0x4208},
{0x1003,0x2086,0xB3FF,0xFFFF,0xBD77},
{0x0800,0x2000,0xF800,0xFC10,0xB208},
{0x1082,0x2945,0xFFE0,0xFFFF,0xBDF7},
{0x0000,0x0120,0x07E0,0x87F0,0x4D69},
{0x1803,0x3086,0xFC60,0xFF7B,0xCD36},
{0xEFFF,0xC71F,0x0396,0x0028,0x52CA},
{0x1808,0x308E,0xFB5F,0xFFFF,0xCD79},
{0xF735,0xDE50,0x8300,0x2080,0x6B08},
// Space: official ATS Mini colors, adapted to the existing HUD roles.
{0x0004,0x105B,0x3FE0,0xBEDF,0xD69A}
};
static_assert(sizeof(palettes)/sizeof(palettes[0])==THEME_COUNT,"Cada tema necesita una paleta");
uint8_t theme=0,region=0;
void applyRegion(){
const uint16_t fmLo[]={6400,8750,8800,8750,7600,7600,6590};
const uint16_t fmHi[]={10800,10800,10800,10800,9500,10800,7400};
bands[0].lo=fmLo[region];bands[0].hi=fmHi[region];
bands[2].lo=region==2?530:region==0?520:531;
bands[2].hi=region==2||region==0?1710:1602;
// Region presets are listening guides, not national transmit permissions.
bands[18].lo=region==1?1810:1800;
bands[19].hi=region==1?3800:(region==3||region==4)?3900:4000;
bands[6].lo=region==1?3950:3900;
bands[9].lo=region==2?7300:7200;
bands[9].hi=region==2?7400:7450;
bands[21].hi=region==1||region==3?7200:7300;
// Expanded segments follow the US listening plan supplied by the user.
// Keep national authorization separate from receiver coverage.
for(int i=0;i<BAND_COUNT;i++)frequencies[i]=constrain(frequencies[i],bands[i].lo,bands[i].hi);
}
int16_t bfo=0;
bool ready=false,dirty=true,menu=false,editing=false,volumeMode=false,agc=true,muted=false,patchLoaded=false;
bool ampEnabled=true,ampMenu=false,ampEditing=false;
int ampItem=0;
uint16_t step();
// Keep every CB channel; fewer sampled points make other bands faster.
// Interpolate only for display: intermediate pixels are not extra measurements.
constexpr int WF_BINS=40, WF_SCAN_BINS=16, WF_ROWS=48;
uint8_t waterfall[WF_ROWS][WF_BINS]={};
uint8_t sweepLine[WF_BINS]={};
int sweepBin=0;
bool waterfallPaused=false,sweepActive=false,sweepWaiting=false;
uint8_t waterfallHead=0;
uint16_t waterfallColors[81];
bool settingsAvailable=false;
uint32_t sweepAt=0;
uint16_t sweepLow=0,sweepHigh=0;
int sweepCount(){return band==CB_BAND?40:min(WF_SCAN_BINS,int(bands[band].hi-bands[band].lo)+1);}
bool waterfallView(){return hud==HUD_COUNT-1;}
void stopSweep(){
if(sweepActive&&ready){radio.setFrequency(frequencies[band]);digitalWrite(AMP,ampEnabled?HIGH:LOW);}
sweepActive=false;sweepWaiting=false;sweepBin=0;
}
void clearWaterfall(){stopSweep();memset(waterfall,0,sizeof(waterfall));memset(sweepLine,0,sizeof(sweepLine));waterfallHead=0;sweepLow=bands[band].lo;sweepHigh=bands[band].hi;dirty=true;}
uint16_t sweepFrequency(int bin){
if(band==CB_BAND)return cbFrequencies[bin];
int count=sweepCount();
return count==1?sweepLow:sweepLow+(uint32_t(sweepHigh-sweepLow)*bin)/(count-1);
}
void pollWaterfall(uint32_t now){
if(!ready||!waterfallView()||menu||waterfallPaused){stopSweep();return;}
if(!sweepActive){
// Overview of the ENTIRE selected band; tuning no longer zooms the plot.
// Select a narrower band in the menu when looking for narrow signals.
sweepLow=bands[band].lo;sweepHigh=bands[band].hi;sweepActive=true;
digitalWrite(AMP,LOW);
}
if(!sweepWaiting){radio.setFrequency(sweepFrequency(sweepBin));sweepAt=millis();sweepWaiting=true;return;}
if(now-sweepAt<(band==0?10u:12u))return;
radio.getCurrentReceivedSignalQuality(0);
sweepLine[sweepBin]=radio.getCurrentRSSI();
sweepWaiting=false;
int count=sweepCount();
if(++sweepBin==count){
waterfallHead=(waterfallHead+WF_ROWS-1)%WF_ROWS;
memcpy(waterfall[waterfallHead],sweepLine,WF_BINS);sweepBin=0;dirty=true;
}
}
int item=0,encoderState=0,quarters=0;
bool buttonRaw=true,buttonStable=true;
uint32_t buttonChange=0,pressedAt=0,lastSignal=0,saveAt=0;
uint8_t rssi=0,snr=0;
uint32_t lastBattery=0,lastDraw=0;
int batteryMillivolts=0,batteryLevel=-1;
bool externalPower=false;
// ATS Mini reference Battery.cpp: GPIO4, raw ADC correction 1.702.
// The monitor reads the shared supply: USB voltage is NOT battery charge.
void pollBattery(){
uint32_t sum=0;
for(int i=0;i<10;i++)sum+=analogRead(BATTERY_PIN);
int measured=int(sum*1.702f/10.0f);
batteryMillivolts=batteryMillivolts?(batteryMillivolts*3+measured)/4:measured;
bool usb=externalPower?batteryMillivolts>4250:batteryMillivolts>4350;
const int thresholds[]={3680,3780,3880};
int level=batteryLevel;
if(level<0){level=0;while(level<3&&batteryMillivolts>=thresholds[level])level++;}
else {while(level<3&&batteryMillivolts>thresholds[level]+20)level++;
while(level>0&&batteryMillivolts<thresholds[level-1]-20)level--;}
static int shownVoltage=-1;
int rounded=(batteryMillivolts+25)/50*50;
if(level!=batteryLevel||usb!=externalPower||rounded!=shownVoltage)dirty=true;
batteryLevel=level;externalPower=usb;shownVoltage=rounded;
}
String rdsName, rdsText;
bool rdsSync=false,stereo=false;
uint32_t lastRds=0;
void resetRds(){clearWaterfall();rssi=0;snr=0;stereo=false;rdsName="";rdsText="";rdsSync=false;if(band==0&&ready){radio.getRdsStatus(0,1,0);radio.RdsInit();}dirty=true;}
String notice;
uint32_t noticeUntil=0;
uint16_t step(){return band==0?fmSteps[stepIndex]:amSteps[stepIndex];}
void changed(){dirty=true;saveAt=millis()+2000;}
void message(const char* text){notice=text;noticeUntil=millis()+2200;dirty=true;}
void save(){
if(!settingsAvailable){saveAt=0;return;}
prefs.putUChar("band",band);prefs.putUChar("mode",mode);prefs.putUChar("vol",volume);
prefs.putUChar("step",stepIndex);prefs.putUChar("af",amFilter);prefs.putUChar("sf",ssbFilter);
prefs.putUChar("att",attenuation);prefs.putBool("agc",agc);prefs.putUChar("hud",hud);
prefs.putBool("amp",ampEnabled);prefs.putUChar("theme",theme);prefs.putUChar("region",region);prefs.putShort("bfo",bfo);prefs.putBytes("freq",frequencies,sizeof(frequencies));saveAt=0;
}
void audioSettings(){
digitalWrite(AMP,ready&&Enabled&&!sweepActive?HIGH:LOW);
digitalWrite(MUTE,muted?HIGH:LOW);
radio.setVolume(muted?0:volume);
radio.setAutomaticGainControl(agc?0:1,attenuation);
if(band!=0){
if(mode==0)radio.setBandwidth(amFilter,1);
else {radio.setSSBAudioBandwidth(ssbFilter);radio.setSSBBfo(bfo);}
}
}
void selectBand(){
if(!ready)return;
stopSweep();
digitalWrite(AMP,LOW);
if(band==CB_BAND)frequencies[band]=cbFrequencies[cbChannel(frequencies[band])];
const Band &b=bands[band];
if(band==0){radio.setFM(b.lo,b.hi,frequencies[band],step());patchLoaded=false;radio.setRdsConfig(1,2,2,2,2);}
else if(mode==0){radio.setAM(b.lo,b.hi,frequencies[band],step());patchLoaded=false;}
else{
if(!patchLoaded){
screen.fillScreen(0);screen.setTextColor(0xFFFF,0);screen.setTextSize(2);screen.setCursor(12,65);screen.print("Cargando SSB...");
radio.loadPatch(ssbPatch,sizeof(ssbPatch),ssbFilter);patchLoaded=true;
radio.setSSBConfig(ssbFilter,1,0,1,0,1);
}
radio.setSSB(b.lo,b.hi,frequencies[band],step(),mode);
}
resetRds();audioSettings();delay(50);digitalWrite(AMP,ampEnabled?HIGH:LOW);changed();
}
String value(int n){
switch(n){
case 0:return bands[band].name;
case 1:return band==0?"FM":modes[mode];
case 2:if(band==CB_BAND)return "1 canal (1-40)";return String(band==0?step()*10:step())+(band==0?" kHz":" kHz");
case 3:return String(volume)+"/63";
case 4:return band==0?"Solo AM/SSB":String(mode?ssbFilters[ssbFilter]:amFilters[amFilter])+" kHz";
case 5:return agc?"SI":"NO";
case 6:return agc?"Auto":String(attenuation);
case 7:return band&&mode?String(bfo)+" Hz":"Solo SSB";
case 8:return hudNames[hud];
case 14:return themeNames[theme];
case 15:return regionNames[region];
case 16:return ampEnabled?"Encendido / ajustar":"Apagado / ajustar";
case 9:return muted?"SI":"NO";
case 10:return String(slot+1)+(memories[slot].frequency?" ocupada":" vacia");
default:return "Pulsar";
}
}
String frequencyText(uint16_t f){
char text[20];
if(band==0)snprintf(text,sizeof(text),"%u.%02u",f/100,f%100);
else snprintf(text,sizeof(text),"%u.%03u",f/1000,f%1000);
return String(text);
}
void label(int x,int y,const String &text,uint16_t color,uint8_t size=1){
ui.setTextSize(size);ui.setTextColor(color);ui.setCursor(x,y);ui.print(text);
}
// All three views share a fixed RDS area and never redraw unless data changes.
void draw(){
const Palette &colors=palettes[theme];
uint16_t bg=colors.background,panel=colors.panel,accent=colors.accent;
uint16_t white=colors.text,dim=colors.dim;
ui.fillScreen(bg);ui.fillRect(0,0,320,20,panel);
label(8,6,menu?"ATS / CONFIGURACION":String(bands[band].name).substring(0,22),accent);
// Antenna mast and four live strength bars; no signal means empty bars.
ui.drawLine(148,4,148,16,accent);
ui.drawLine(143,4,148,9,accent);ui.drawLine(153,4,148,9,accent);
ui.drawFastHLine(145,16,7,accent);
int signalBars=ready?constrain((int(rssi)+19)/20,0,4):0;
for(int k=0;k<4;k++){int h=4+k*3;ui.fillRect(158+k*6,17-h,4,h,k<signalBars?accent:dim);}
label(188,6,band==0?(stereo?"ST":"MONO"):modes[mode],white);
uint16_t batteryColor=!externalPower&&batteryLevel==0?0xF800:accent;
ui.drawRoundRect(222,3,26,14,2,batteryColor);ui.fillRect(248,7,2,6,batteryColor);
if(externalPower){
ui.drawLine(237,5,230,10,accent);ui.drawLine(230,10,238,10,accent);ui.drawLine(238,10,232,15,accent);
label(257,6,"USB",white);
}else{
for(int k=0;k<=batteryLevel;k++)ui.fillRect(225+k*5,6,4,8,batteryColor);
char volts[10];snprintf(volts,sizeof(volts),"%.2fV",((batteryMillivolts+25)/50*50)/1000.0f);
label(257,6,volts,white);
}
if(!ready){label(12,60,"Radio no detectada",white);ui.pushSprite(0,0);return;}
if(menu){
if(ampMenu){
label(12,27,"AMP / SALIDA DE AUDIO",accent);
const char* names[]={"Amplificador","Volumen","Volver"};
for(int n=0;n<3;n++){
int y=43+n*35;
ui.fillRoundRect(8,y,299,32,6,panel);
if(n==ampItem)ui.fillRect(8,y,4,32,accent);
label(19,y+5,String(ampEditing&&n==ampItem?"> ":"")+names[n],n==ampItem?accent:white);
label(19,y+19,n==0?(ampEnabled?"Encendido":"Apagado"):n==1?String(volume)+" / 63 (nivel de audio)":"Pulsar para regresar",dim);
}
}else if(editing&&item!=0){
label(12,29,"EDITAR AJUSTE",dim);
label(12,52,items[item],white,2);
ui.fillRoundRect(10,80,300,48,8,panel);
label(20,95,value(item),accent,2);
label(12,139,"Girar: cambiar / Pulsar: aceptar",dim);
}else{
bool list=editing&&item==0;
int pos=list?(folderOpen?folderPosition:folder):item;
int count=list?(folderOpen?folderSize(folder)+1:FOLDER_COUNT+1):ITEM_COUNT;
label(10,27,list?(folderOpen?folders[folder].name:"BANDAS / CARPETAS"):"CONFIGURACION",dim);
label(264,27,String(pos+1)+"/"+String(count),accent);
int start=constrain(pos-1,0,max(0,count-3));
for(int row=0;row<min(3,count);row++){
int n=start+row,y=43+row*35;
bool selected=n==pos;
ui.fillRoundRect(8,y,299,32,6,selected?bg:panel);
if(selected)ui.fillRoundRect(8,y,4,32,2,accent);
if(list){
if(!folderOpen || n==folderSize(folder)){
bool back=n==(folderOpen?folderSize(folder):FOLDER_COUNT);
label(19,y+5,back?"< Volver":folders[n].name,selected?accent:white);
label(19,y+19,back?"Pulsar para regresar":String(folderSize(n))+" bandas / segmentos",dim);
continue;
}
int idx=folderBand(folder,n);label(19,y+5,bands[idx].name,selected?accent:white);
char range[40];if(idx==0)snprintf(range,sizeof(range),"%.2f - %.2f MHz",bands[idx].lo/100.0,bands[idx].hi/100.0);
else snprintf(range,sizeof(range),"%.3f - %.3f MHz",bands[idx].lo/1000.0,bands[idx].hi/1000.0);
label(19,y+19,range,dim);
}else{label(19,y+5,items[n],selected?accent:white);label(19,y+19,value(n),dim);}
}
ui.fillRect(313,43,3,102,panel);ui.fillRect(313,43+pos*94/(count-1),3,8,accent);
}
}else if(waterfallView()){
label(8,24,frequencyText(frequencies[band])+" MHz",accent,2);
label(202,27,waterfallPaused?"PAUSA / AUDIO":"BARRIDO / MUDO",white);
int count=sweepCount();
label(8,44,band==CB_BAND?"CH 1 / 26.965":frequencyText(sweepLow),dim);
label(222,44,band==CB_BAND?"40 / 27.405":frequencyText(sweepHigh),dim);
for(int row=0;row<WF_ROWS;row++)for(int x=0;x<304;x++){
int v;
if(band==CB_BAND){v=waterfall[(waterfallHead+row)%WF_ROWS][x*count/304];}
else{
// Smooth narrow colour pixels instead of magnified 9-pixel blocks.
int position=x*(count-1),bin=position/303,fraction=position%303;
int next=min(bin+1,count-1);
v=(int(waterfall[(waterfallHead+row)%WF_ROWS][bin])*(303-fraction)+int(waterfall[(waterfallHead+row)%WF_ROWS][next])*fraction)/303;
}
v=constrain(v,0,80);
uint16_t c=waterfallColors[v];
ui.drawFastVLine(8+x,56+row*2,2,c);
}
int marker=band==CB_BAND?cbChannel(frequencies[band])*304/40:
sweepHigh>sweepLow?int(uint32_t(frequencies[band]-sweepLow)*303/(sweepHigh-sweepLow)):152;
ui.drawFastVLine(8+constrain(marker,0,303),54,98,white);
label(8,147,band==CB_BAND?"CANAL "+String(cbChannel(frequencies[band])+1):"RSSI por frecuencia / tiempo abajo",accent);
}else{
String freq=frequencyText(frequencies[band]);
String head=band==CB_BAND?"CANAL "+String(cbChannel(frequencies[band])+1)+" / 40":band==0?(rdsName.length()?rdsName:"FM / RDS"):String(mode?"RECEPCION SSB":"RECEPCION AM");
label(10,25,head,dim);
bool centered=hud==4||hud==5||hud==8||hud==10;
int font=(hud==2||hud==5)?4:3;
int fx=centered?(320-int(freq.length())*6*font)/2:10;
if(hud==6)ui.drawRect(6,37,308,38,accent);
if(hud==7){for(int k=0;k<16;k++)ui.drawFastVLine(8+k*20,68,k%4==0?7:3,dim);}
if(hud==8)ui.fillRoundRect(6,37,308,38,6,panel);
label(fx,40,freq,accent,font);label(274,58,"MHz",dim);
for(int n=0;n<2;n++){
int x=8+n*156;int reading=n?snr:rssi,maximum=n?50:80;
int level=constrain(reading*100/maximum,0,100);
if(hud!=2&&hud!=5&&hud!=6)ui.fillRoundRect(x,78,148,49,6,panel);
if(hud==6||hud==11)ui.drawRect(x,78,148,49,dim);
label(x+7,82,n?"SNR / CALIDAD":"RSSI / INTENSIDAD",dim);
label(x+7,96,String(reading),white,2);
label(x+57,105,n?"dB":"dBuV",accent);
if(hud==1){for(int k=0;k<10;k++)ui.fillRect(x+89+(k%5)*10,94+(k/5)*11,7,8,level>k*10?accent:bg);}
if(hud==4||hud==10){
int cx=x+114,cy=119;
for(int k=0;k<=10;k++){
float angle=3.14159265f+k*3.14159265f/10;
ui.drawLine(cx+int(cosf(angle)*22),cy+int(sinf(angle)*22),cx+int(cosf(angle)*26),cy+int(sinf(angle)*26),dim);
}
float angle=3.14159265f+level*3.14159265f/100;
ui.drawLine(cx,cy,cx+int(cosf(angle)*22),cy+int(sinf(angle)*22),accent);
}
if(hud==9){for(int k=0;k<8;k++){int h=3+k*3;ui.fillRect(x+85+k*7,117-h,5,h,level>k*12?accent:bg);}}
if(hud==11)label(x+96,101,String(level)+"%",accent);
ui.fillRect(x+7,120,134,5,bg);
if(hud==3||hud==7||hud==9){
for(int k=0;k<20;k++)ui.fillRect(x+7+k*7,120,5,5,level>k*5?accent:dim);
}else if(hud==10){
ui.drawFastHLine(x+7,122,134,dim);ui.fillCircle(x+7+level*132/100,122,2,accent);
}else ui.fillRect(x+7,120,level*134/100,5,accent);
}
if(band==0){
label(10,131,rdsSync?"RDS "+rdsText.substring(0,47):"RDS: esperando datos de emisora",dim);
if(rdsText.length()>47)label(10,143,rdsText.substring(47,64),dim);
}else{
label(10,132,band==CB_BAND?"CB USA / 40 canales":String("PASO ")+String(step())+" kHz / "+(agc?"AGC":"MANUAL"),dim);
label(10,144,mode?"BFO "+String(bfo)+" Hz":String("FILTRO ")+amFilters[amFilter]+" kHz",dim);
}
label(250,144,muted?"MUTE":!ampEnabled?"AMP OFF":"V "+String(volume),accent);
}
ui.fillRect(0,157,320,13,panel);
label(8,160,notice.length()?notice:menu?"Pulsar: aceptar / Mantener: salir":waterfallView()?"Pulsar: pausa/audio / Rueda: sintonia":volumeMode?"RUEDA: volumen / Mantener: menu":"RUEDA: sintonia / Mantener: menu",dim);
ui.pushSprite(0,0);
}
String cleanRds(const char* text,size_t maxLen){
String out;if(!text)return out;out.reserve(maxLen);
for(size_t i=0;i<maxLen&&text[i]&&text[i]!='\r';i++){unsigned char c=text[i];out+=(c>=32&&c<127)?char(c):' ';}
out.trim();return out;
}
void pollRds(){
radio.getRdsStatus();
bool sync=radio.getRdsSync();
if(sync!=rdsSync){rdsSync=sync;if(!menu)dirty=true;}
if(!sync||!radio.getRdsReceived())return;
String name=cleanRds(radio.getRdsStationName(),8);
String text=cleanRds(radio.getRdsText2A(),64);
if(name.length()&&name!=rdsName){rdsName=name;if(!menu)dirty=true;}
if(text.length()&&text!=rdsText){rdsText=text;if(!menu)dirty=true;}
}
int wrap(int v,int count){return (v%count+count)%count;}
void adjust(int d){
switch(item){
case 0:
if(folderOpen)folderPosition=wrap(folderPosition+d,folderSize(folder)+1);
else folder=wrap(folder+d,FOLDER_COUNT+1);
dirty=true;return;
case 1:if(band){mode=wrap(mode+d,3);selectBand();}else message("FM: modo fijo");break;
case 2:if(band==CB_BAND)message("CB: paso fijo por canal");else {stepIndex=wrap(stepIndex+d,6);radio.setFrequencyStep(step());}break;
case 3:volume=constrain(int(volume)+d,0,63);audioSettings();break;
case 4:if(band){if(mode)ssbFilter=wrap(ssbFilter+d,6);else amFilter=wrap(amFilter+d,7);audioSettings();}break;
case 5:agc=!agc;audioSettings();break;
case 6:attenuation=constrain(int(attenuation)+d,0,26);audioSettings();break;
case 7:if(band&&mode){bfo=constrain(int(bfo)+d*10,-16000,16000);radio.setSSBBfo(bfo);}break;
case 8:stopSweep();hud=wrap(hud+d,HUD_COUNT);clearWaterfall();waterfallPaused=false;break;
case 14:theme=wrap(theme+d,THEME_COUNT);break;
case 15:region=wrap(region+d,7);applyRegion();stepIndex=band==0?3:region==2?3:2;selectBand();break;
case 9:muted=!muted;audioSettings();break;
case 10:slot=wrap(slot+d,10);break;
}
changed();
}
void click(){
if(!menu&&waterfallView()){waterfallPaused=!waterfallPaused;stopSweep();dirty=true;return;}
if(!menu){volumeMode=!volumeMode;dirty=true;return;}
if(ampMenu){
if(ampItem==2){ampMenu=false;ampEditing=false;}
else ampEditing=!ampEditing;
dirty=true;return;
}
if(item==16){ampMenu=true;ampItem=0;ampEditing=false;dirty=true;return;}
if(editing&&item==0){
if(!folderOpen){
if(folder==FOLDER_COUNT){editing=false;locateBandFolder(band);}
else{folderOpen=true;folderPosition=0;}
}else if(folderPosition==folderSize(folder)){folderOpen=false;}
else{
stopSweep();band=folderBand(folder,folderPosition);
if(band>=52){const char* name=bands[band].name;mode=strstr(name,"AM")?0:strstr(name,"LSB")?1:2;bfo=0;stepIndex=0;}
if(band==2&®ion)stepIndex=region==2?3:2;
selectBand();editing=false;menu=false;
}
dirty=true;return;
}
if(editing){editing=false;dirty=true;return;}
if(item==13){menu=false;dirty=true;return;}
if(item==11){memories[slot]={frequencies[band],band,mode};if(settingsAvailable&&prefs.putBytes("mem",memories,sizeof(memories))==sizeof(memories))message("Memoria guardada");else message("Error al guardar memoria");}
else if(item==12){
Memory m=memories[slot];
if(!m.frequency)message("Memoria vacia");
else {stopSweep();band=m.band;mode=m.mode;bfo=0;frequencies[band]=m.frequency;selectBand();message("Memoria cargada");}
}else{editing=true;if(item==0){locateBandFolder(band);folderOpen=false;}}
dirty=true;
}
void setup(){
Serial.begin(115200);
analogReadResolution(12);
analogSetPinAttenuation(BATTERY_PIN,ADC_11db);
pollBattery();
pinMode(AMP,OUTPUT);digitalWrite(AMP,LOW);
pinMode(MUTE,OUTPUT);digitalWrite(MUTE,LOW);
pinMode(POWER,OUTPUT);digitalWrite(POWER,HIGH);delay(100);
Wire.begin(SDA_PIN,SCL_PIN);
pinMode(BL,OUTPUT);digitalWrite(BL,LOW);
screen.init();screen.setRotation(3);screen.fillScreen(0);digitalWrite(BL,HIGH);
ui.setColorDepth(16);
if(!ui.createSprite(320,170)){
screen.setTextColor(0xFFFF,0);screen.setCursor(8,50);screen.print("Error: memoria de pantalla");
Serial.println("No se pudo reservar el buffer de pantalla; radio desactivada.");
while(true)delay(1000);
}
ui.setTextWrap(false);
for(int v=0;v<=80;v++)waterfallColors[v]=v<20?screen.color565(0,0,v*10):v<40?screen.color565(0,(v-20)*12,200):v<60?screen.color565((v-40)*12,240,200-(v-40)*10):screen.color565(240,240-(v-60)*10,0);
rdsName.reserve(8);rdsText.reserve(64);notice.reserve(64);
pinMode(ENC_A,INPUT_PULLUP);pinMode(ENC_B,INPUT_PULLUP);pinMode(BUTTON,INPUT_PULLUP);
encoderState=(digitalRead(ENC_A)<<1)|digitalRead(ENC_B);
settingsAvailable=prefs.begin("ats-custom",false);
if(!settingsAvailable)Serial.println("Ajustes no disponibles: se usaran valores iniciales.");
ampEnabled=prefs.getBool("amp",true);
for(int i=0;i<BAND_COUNT;i++)frequencies[i]=bands[i].initial;
size_t savedFreqBytes=prefs.getBytesLength("freq");
if(savedFreqBytes && savedFreqBytes<=sizeof(frequencies) && savedFreqBytes%sizeof(uint16_t)==0)
prefs.getBytes("freq",frequencies,savedFreqBytes);
for(int i=0;i<BAND_COUNT;i++)frequencies[i]=constrain(frequencies[i],bands[i].lo,bands[i].hi);
band=constrain(prefs.getUChar("band",0),0,BAND_COUNT-1);mode=constrain(prefs.getUChar("mode",0),0,2);
volume=constrain(prefs.getUChar("vol",12),0,63);stepIndex=constrain(prefs.getUChar("step",2),0,5);
amFilter=constrain(prefs.getUChar("af",3),0,6);ssbFilter=constrain(prefs.getUChar("sf",1),0,5);
attenuation=constrain(prefs.getUChar("att",0),0,26);agc=prefs.getBool("agc",true);hud=constrain(prefs.getUChar("hud",0),0,HUD_COUNT-1);
theme=constrain(prefs.getUChar("theme",0),0,THEME_COUNT-1);region=constrain(prefs.getUChar("region",0),0,6);applyRegion();
bfo=constrain(prefs.getShort("bfo",0),-16000,16000);
if(prefs.getBytesLength("mem")==sizeof(memories))prefs.getBytes("mem",memories,sizeof(memories));
for(auto &m:memories)if(m.band>=BAND_COUNT||m.mode>2||m.frequency<bands[m.band].lo||m.frequency>bands[m.band].hi)m={0,0,0};
clearWaterfall();
ready=radio.getDeviceI2CAddress(RESET)!=0;
if(ready){radio.setup(RESET,0);selectBand();}else Serial.println("No se detecta SI4732; amplificador desactivado.");
draw();dirty=false;
}
void loop(){
static const int8_t transitions[]={0,-1,1,0,1,0,0,-1,-1,0,0,1,0,1,-1,0};
int state=(digitalRead(ENC_A)<<1)|digitalRead(ENC_B);
if(state!=encoderState){quarters+=transitions[(encoderState<<2)|state];encoderState=state;}
if(abs(quarters)>=4){
int d=quarters>0?1:-1;quarters=0;
if(ready){
if(menu){if(ampMenu){
if(ampEditing){if(ampItem==0)ampEnabled=!ampEnabled;else if(ampItem==1)volume=constrain(int(volume)+d,0,63);audioSettings();changed();}
else{ampItem=wrap(ampItem+d,3);dirty=true;}
}else if(editing)adjust(d);else{item=wrap(item+d,ITEM_COUNT);dirty=true;}}
else if(volumeMode){volume=constrain(int(volume)+d,0,63);audioSettings();changed();}
else{stopSweep();int next;
if(band==CB_BAND)next=cbFrequencies[wrap(cbChannel(frequencies[band])+d,40)];
else{next=int(frequencies[band])+d*step();if(next>bands[band].hi)next=bands[band].lo;if(next<bands[band].lo)next=bands[band].hi;}frequencies[band]=next;radio.setFrequency(next);resetRds();changed();}
}
}
uint32_t now=millis();bool raw=digitalRead(BUTTON);
if(raw!=buttonRaw){buttonRaw=raw;buttonChange=now;}
if(now-buttonChange>=30&&raw!=buttonStable){
buttonStable=raw;if(!raw)pressedAt=now;
else if(ready){if(now-pressedAt>=700){stopSweep();menu=!menu;ampMenu=false;ampEditing=false;editing=false;volumeMode=false;dirty=true;}else click();}
}
pollWaterfall(now);
if(ready&&!sweepActive&&now-lastSignal>=100){lastSignal=now;radio.getCurrentReceivedSignalQuality(0);uint8_t r=radio.getCurrentRSSI(),s=radio.getCurrentSNR();bool st=band==0&&radio.getCurrentPilot();if(r!=rssi||s!=snr||st!=stereo){rssi=r;snr=s;stereo=st;if(!menu)dirty=true;}}
if(ready&&!sweepActive&&band==0&&now-lastRds>=80){lastRds=now;pollRds();}
if(saveAt&&int32_t(now-saveAt)>=0)save();
if(notice.length()&&int32_t(now-noticeUntil)>=0){notice="";dirty=true;}
if(now-lastBattery>=2000){lastBattery=now;pollBattery();}
if(dirty&&now-lastDraw>=50){draw();dirty=false;lastDraw=now;}
delay(1);
}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.




