• The ultimate wireless smartphone control VFO ESP32 and Si5351

QC

The ultimate wireless smartphone control VFO ESP32 and Si5351

A professional-looking VFO for your radio projects without spending a fortune on touchscreen, rotary encoder or complex hardware.

Devices and components

MUST ESP32 DevKit v1

SI5351 CLOCK GENERATION MODULE

Materials and tools

Soldering iron kit

Software and tools

Arduino IDE

Project description

Code Vfo

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(); }




Note: Content and images are from: https://projecthub.arduino.cc/, with some modifications.
If you want it removed due to copyright reasons, please leave a comment. Thank you.
I want to share this article more widely so that everyone knows about Arduino and your project.

SendData

Điều khiển trạng thái qua Firebase Trạng thái hiện tại: Đang tải... ĐỔI TRẠNG THÁI