The Ultimate Smartphone VFO ESP32 & Si5351 Wireless Control
A professional-looking VFO for your radio projects without spending a fortune on touchscreen, rotary encoder or complex hardware.
Devices & Components
1
DOIT ESP32 DevKit v1
1
SI5351 CLOCK GEN MODULE
Hardware & Tools
1
Soldering Iron Kit
Software & Tools
Arduino IDE
Project description
Code
Code Vfo
cpp
...
1// by mircemk May, 2026 2 3#include <WiFi.h> 4#include <WebServer.h> 5#include <si5351.h> 6#include <Wire.h> 7 8Si5351 si5351; 9unsigned long frequency = 7000000; 10const char* ssid = "Si5351_VFO_Final_Complete"; 11const char* password = "vfo12345678"; 12 13WebServer server(80); 14 15const char VFO_HTML[] PROGMEM = R"rawliteral( 16<!DOCTYPE html><html><head> 17<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"> 18<style> 19 :root { 20 /* ТУКА СЕ МЕНУВААТ БОИТЕ - ВЕРЗИЈА V2.4 */ 21 --panel-bg: #E0AB07; 22 --inner-bezel:#E0AB07 //#56748F; 23 --lcd-bg: #0077c2; 24 --btn-band: #7f0000; 25 --btn-step: #27ae60; 26 --btn-mode: #2980b9; 27 --btn-mem: #8e44ad; 28 --gold-border: #f1c40f; 29 --text-color: #ecf0f1; 30 } 31 32 * { -webkit-tap-highlight-color: transparent; box-sizing: border-box; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; } 33 body { background: #000; margin: 0; padding: 0; display: flex; justify-content: center; font-family: 'Arial Black', sans-serif; color: var(--text-color); overflow: hidden; } 34 .vfo-main-frame { background: var(--panel-bg); width: 100%; max-width: 400px; height: 100vh; display: flex; flex-direction: column; align-items: center; border-left: 2px solid #444; border-right: 2px solid #111; } 35 36 .bezel-display { background: var(--inner-bezel); width: 92%; margin-top: 15px; padding: 10px; border-radius: 8px; box-shadow: inset 4px 4px 10px #000, 2px 2px 5px rgba(255,255,255,0.1); position: relative; } 37 38 .fs-zone { position: absolute; left: 0; top: 0; width: 30%; height: 100%; z-index: 10; cursor: pointer; } 39 .mem-zone { position: absolute; left: 30%; top: 0; width: 40%; height: 100%; z-index: 10; cursor: pointer; } 40 .reset-zone { position: absolute; right: 0; top: 0; width: 30%; height: 100%; z-index: 10; cursor: pointer; } 41 42 .display { background: var(--lcd-bg); border: 4px solid #111; padding: 10px; box-shadow: inset 0 0 25px #000; height: 125px; display: flex; flex-direction: column; justify-content: space-between; position: relative; transition: background 0.2s; } 43 .display.mem-active { background: #e67e22; } 44 .display.reset-flash { background: #e74c3c; } 45 46 .display-info { display: flex; justify-content: space-between; font-size: 14px; color: rgba(255,255,255,0.9); font-family: Arial, sans-serif; } 47 #f-display { font-size: 55px; font-weight: 900; margin: 0; text-align: right; text-shadow: 2px 2px 4px #000; letter-spacing: -1px; line-height: 1; } 48 49 .display-footer { display: flex; align-items: center; border-top: 1px solid rgba(255,255,255,0.2); padding-top: 5px; margin-bottom: 4px; } 50 #mode-label, .sig-text { font-size: 15px; font-weight: bold; } 51 52 .s-meter-container { display: flex; align-items: center; gap: 6px; flex-grow: 1; justify-content: flex-end; margin-left: 25px; } 53 .s-grid { display: flex; gap: 1px; height: 10px; width: 115px; background: rgba(0,0,0,0.3); border: 1px solid #111; } 54 .s-seg { flex: 1; background: #222; } 55 .s-on { background: #ffffff; box-shadow: 0 0 6px #ffffff; } 56 57 .bezel-knob { background: var(--inner-bezel); width: 270px; height: 270px; margin: 25px 0; border-radius: 50%; box-shadow: inset 3px 3px 10px #000, 2px 2px 5px rgba(255,255,255,0.05); display: flex; justify-content: center; align-items: center; } 58 #knob { width: 240px; height: 240px; background: conic-gradient(from 0deg, #333, #777 25%, #333 50%, #777 75%, #333); border-radius: 50%; border: 12px solid #1a1a1a; position: relative; will-change: transform; cursor: pointer; box-shadow: 5px 10px 20px #000; } 59 #knob::after { content: ''; position: absolute; top: 25px; left: 50%; transform: translateX(-50%); width: 24px; height: 24px; background: #111; border-radius: 50%; box-shadow: inset 2px 2px 5px #000; } 60 61 .controls-container { width: 94%; display: flex; flex-direction: column; } 62 .grid { display: grid; gap: 6px; width: 100%; grid-template-columns: repeat(4, 1fr); } 63 .group-margin { margin-bottom: 12px; } 64 65 .btn { border: 3px solid var(--gold-border); border-radius: 8px; color: #fff; font-weight: 900; font-size: 15px; padding: 11px 0; text-align: center; cursor: pointer; box-shadow: 3px 5px 8px #000; text-transform: uppercase; transition: transform 0.05s; } 66 .btn:active { transform: translateY(2px); box-shadow: 1px 2px 4px #000; } 67 68 .b-band { background: var(--btn-band); } 69 .b-step { background: var(--btn-step); font-size: 20px; padding: 12px; grid-column: span 4; } 70 .b-mode { background: var(--btn-mode); } 71 .b-mem { background: var(--btn-mem); border-color: #555; font-size: 13px; } 72 73 .signature { color: #555; font-size: 16px; margin-top: 20px; text-align: center; width: 100%; padding-bottom: 15px; font-weight: normal; } 74</style> 75</head><body> 76 <div class="vfo-main-frame"> 77 <div class="bezel-display"> 78 <div class="fs-zone" onclick="toggleFS()"></div> 79 <div class="mem-zone" onclick="startMem()"></div> 80 <div class="reset-zone" onclick="clearAllMem()"></div> 81 82 <div class="display" id="main-display"> 83 <div class="display-info"><span id="band-label">40M HAM</span><span id="step-label">100Hz</span></div> 84 <h1 id="f-display">07.000.000</h1> 85 <div class="display-footer"> 86 <span id="mode-label">USB</span> 87 <div class="s-meter-container"> 88 <span class="sig-text">Sig:</span> 89 <div class="s-grid" id="s-grid"></div> 90 </div> 91 </div> 92 </div> 93 </div> 94 <div class="bezel-knob"><div id="knob"></div></div> 95 <div class="controls-container"> 96 <div class="grid group-margin"> 97 <div class="btn b-band" onclick="setBand(531000, 'MW')">MW</div> 98 <div class="btn b-band" onclick="setBand(1810000, '160M')">160</div> 99 <div class="btn b-band" onclick="setBand(3500000, '80M')">80</div> 100 <div class="btn b-band" onclick="setBand(7000000, '40M')">40</div> 101 <div class="btn b-band" onclick="setBand(14000000, '20M')">20</div> 102 <div class="btn b-band" onclick="setBand(18068000, '17M')">17</div> 103 <div class="btn b-band" onclick="setBand(21000000, '15M')">15</div> 104 <div class="btn b-band" onclick="setBand(24890000, '12M')">12</div> 105 </div> 106 <div class="grid group-margin"> 107 <div class="btn b-step" id="step-btn" onclick="nextStep()">STEP: 100Hz</div> 108 </div> 109 <div class="grid"> 110 <div class="btn b-mode" onclick="setMode('AM')">AM</div> 111 <div class="btn b-mode" onclick="setMode('USB')">USB</div> 112 <div class="btn b-mode" onclick="setMode('LSB')">LSB</div> 113 <div class="btn b-mode" onclick="setMode('FM')">FM</div> 114 <div class="btn b-mem" id="m1" onclick="handleMem(1)">M1</div> 115 <div class="btn b-mem" id="m2" onclick="handleMem(2)">M2</div> 116 <div class="btn b-mem" id="m3" onclick="handleMem(3)">M3</div> 117 <div class="btn b-mem" id="m4" onclick="handleMem(4)">M4</div> 118 </div> 119 </div> 120 <div class="signature">Si5351 VFO by mircemk</div> 121 </div> 122 123 <script> 124 var freq = 7000000; 125 var curMode = "USB"; var lastAngle = 0; var curRot = 0; var isDrag = false; var lastSent = 0; 126 var steps = [10, 100, 1000, 5000, 10000, 100000]; 127 var stepLabels = ["10Hz", "100Hz", "1KHz", "5KHz", "10KHz", "100KHz"]; 128 var stepIdx = 1; 129 var isMemMode = false; 130 131 function loadSavedMem() { 132 for(let i=1; i<=4; i++){ 133 let saved = localStorage.getItem('vfo_m'+i); 134 if(saved) document.getElementById('m'+i).innerText = (saved/1000000).toFixed(3); 135 else document.getElementById('m'+i).innerText = "M"+i; 136 } 137 } 138 139 function clearAllMem() { 140 for(let i=1; i<=4; i++) localStorage.removeItem('vfo_m'+i); 141 loadSavedMem(); 142 let d = document.getElementById('main-display'); 143 d.classList.add('reset-flash'); 144 setTimeout(() => d.classList.remove('reset-flash'), 300); 145 } 146 147 function startMem() { 148 isMemMode = true; 149 document.getElementById('main-display').classList.add('mem-active'); 150 document.querySelectorAll('.b-mem').forEach(b => b.classList.add('save-ready')); 151 } 152 153 function handleMem(id) { 154 if(isMemMode) { 155 localStorage.setItem('vfo_m'+id, freq); 156 document.getElementById('m'+id).innerText = (freq/1000000).toFixed(3); 157 isMemMode = false; 158 document.getElementById('main-display').classList.remove('mem-active'); 159 document.querySelectorAll('.b-mem').forEach(b => b.classList.remove('save-ready')); 160 } else { 161 let saved = localStorage.getItem('vfo_m'+id); 162 if(saved) { freq = parseInt(saved); updateUI(); sendFreq(); } 163 } 164 } 165 166 function toggleFS() { 167 var d = document.documentElement; 168 if(!document.fullscreenElement) d.requestFullscreen().catch(e=>{}); 169 else document.exitFullscreen(); 170 } 171 172 function updateBandLabel() { 173 let b = document.getElementById('band-label'); 174 // ПРЕЗЕМЕНИ ОПСЕЗИ ОД V1.9 175 if (freq >= 1810000 && freq <= 2000000) b.innerText = "160M HAM"; 176 else if (freq >= 3500000 && freq <= 3800000) b.innerText = "80M HAM"; 177 else if (freq >= 7000000 && freq <= 7200000) b.innerText = "40M HAM"; 178 else if (freq >= 14000000 && freq <= 14350000) b.innerText = "20M HAM"; 179 else if (freq >= 18068000 && freq <= 18168000) b.innerText = "17M HAM"; 180 else if (freq >= 21000000 && freq <= 21450000) b.innerText = "15M HAM"; 181 else if (freq >= 24890000 && freq <= 24990000) b.innerText = "12M HAM"; 182 else if (freq >= 28000000 && freq <= 29700000) b.innerText = "10M HAM"; 183 else if (freq >= 531000 && freq <= 1602000) b.innerText = "MW BROADCAST"; 184 else if (freq >= 5900000 && freq <= 6200000) b.innerText = "49M BROADCAST"; 185 else if (freq >= 7200001 && freq <= 7450000) b.innerText = "41M BROADCAST"; 186 else if (freq >= 9400000 && freq <= 9900000) b.innerText = "31M BROADCAST"; 187 else if (freq >= 11600000 && freq <= 12100000) b.innerText = "25M BROADCAST"; 188 else if (freq >= 15100000 && freq <= 15830000) b.innerText = "19M BROADCAST"; 189 else b.innerText = "GEN"; 190 } 191 192 function updateUI() { 193 document.getElementById('f-display').innerText = Number(freq).toLocaleString('de-DE').replace(/,/g, '.'); 194 document.getElementById('mode-label').innerText = curMode; 195 updateBandLabel(); 196 } 197 198 function setBand(f, n) { freq = f; updateUI(); sendFreq(); } 199 function setMode(m) { curMode = m; updateUI(); } 200 function nextStep() { 201 stepIdx = (stepIdx + 1) % steps.length; 202 document.getElementById('step-btn').innerText = "STEP: " + stepLabels[stepIdx]; 203 document.getElementById('step-label').innerText = stepLabels[stepIdx]; 204 } 205 function sendFreq() { 206 let now = Date.now(); 207 if (now - lastSent > 50) { fetch('/set?f=' + freq); lastSent = now; } 208 } 209 function getAngle(x, y) { 210 let r = document.getElementById('knob').getBoundingClientRect(); 211 return Math.atan2(y - (r.top + r.height/2), x - (r.left + r.width/2)) * 180 / Math.PI; 212 } 213 function move(e) { 214 if (!isDrag) return; 215 let ev = e.touches ? e.touches[0] : e; 216 let ang = getAngle(ev.clientX, ev.clientY); 217 let d = ang - lastAngle; 218 if (d > 180) d -= 360; if (d < -180) d += 360; 219 curRot += d; 220 freq += Math.round(d) * (steps[stepIdx] / 10); 221 if (freq < 100000) freq = 100000; 222 updateUI(); 223 document.getElementById('knob').style.transform = 'rotate(' + curRot + 'deg)'; 224 sendFreq(); 225 lastAngle = ang; 226 } 227 let knob = document.getElementById('knob'); 228 knob.addEventListener('mousedown', function(e) { isDrag = true; lastAngle = getAngle(e.clientX, e.clientY); }); 229 knob.addEventListener('touchstart', function(e) { isDrag = true; lastAngle = getAngle(e.touches[0].clientX, e.touches[0].clientY); e.preventDefault(); }, {passive: false}); 230 window.addEventListener('mouseup', () => isDrag = false); 231 window.addEventListener('touchend', () => isDrag = false); 232 window.addEventListener('mousemove', move); 233 window.addEventListener('touchmove', move, {passive: false}); 234 235 setInterval(() => { 236 fetch('/getS').then(r => r.text()).then(v => { 237 let segs = document.querySelectorAll('.s-seg'); 238 let act = Math.floor((v/100)*20); 239 segs.forEach((s,i) => { if(i<act) s.classList.add('s-on'); else s.classList.remove('s-on'); }); 240 }); 241 }, 250); 242 243 loadSavedMem(); 244 updateUI(); 245 </script> 246</body></html> 247)rawliteral"; 248 249void updateFrequency(unsigned long f) { si5351.set_freq(f * 100ULL, SI5351_CLK0); } 250 251void setup() { 252 Serial.begin(115200); Wire.begin(21, 22); 253 pinMode(32, INPUT); 254 analogReadResolution(12); 255 analogSetAttenuation(ADC_6db); 256 WiFi.mode(WIFI_AP); WiFi.softAP(ssid, password); 257 si5351.init(SI5351_CRYSTAL_LOAD_8PF, 0, 0); 258 updateFrequency(frequency); 259 server.on("/", []() { server.send(200, "text/html", VFO_HTML); }); 260 server.on("/set", []() { if (server.hasArg("f")) { frequency = server.arg("f").toInt(); si5351.set_freq(frequency * 100ULL, SI5351_CLK0); server.send(200, "text/plain", "OK"); } }); 261 server.on("/getS", []() { int val = analogRead(32); int percent = map(val, 0, 1200, 0, 100); if(percent > 100) percent = 100; server.send(200, "text/plain", String(percent)); }); 262 server.begin(); 263} 264void loop() { server.handleClient(); }
Documentation
Schematic
Schematic
Schematic.jpg

Comments
Only logged in users can leave comments