This project lets you control an Adafruit SI5351 clock generator directly from a web page running in your browser. You can:
- Set output frequencies on channels CLK0, CLK1, and CLK2 (8 kHz–22 MHz).
- See the actual frequency achieved (with PLL rounding error) displayed live.
- Turn all outputs on or off with a single click.
It uses the Arduino’s USB-Serial interface together with the Web Serial API in Chrome/Edge to exchange simple text commands.

Hardware Requirements
Any Arduino compatible board with native USB-Serial (Uno, Nano 33 IoT, Leonardo, etc.)
• Adafruit SI5351 breakout (connected via I²C pins SDA/SCL)
• USB cable to your computer
Arduino Sketch
/*
Si5351 “integer‐first” frequency setter, using only DIV = 4,6,8
- Tries integer‐only multisynth divisors {4,6,8} for zero error
- Falls back to a fractional‐N search if no perfect integer solution
- Serial commands at 115200 baud:
• "off\n" / "on\n"
• "<freq>,<chan>\n" (chan = 0,1,2)
*/
#include <Wire.h>
#include <Adafruit_SI5351.h>
Adafruit_SI5351 clockgen;
// Crystal reference and VCO limits
#define XTAL_FREQ 25000000UL
#define VCO_MIN 600000000UL
#define VCO_MAX 900000000UL
// R-divider tables
static const uint8_t R_DIV_ENUM[8] = {
SI5351_R_DIV_1, SI5351_R_DIV_2, SI5351_R_DIV_4, SI5351_R_DIV_8,
SI5351_R_DIV_16,SI5351_R_DIV_32,SI5351_R_DIV_64,SI5351_R_DIV_128
};
static const uint8_t R_DIV_SHIFT[8] = {0,1,2,3,4,5,6,7};
// Only the three integer divisors supported:
static const uint8_t INT_DIVS[] = {
SI5351_MULTISYNTH_DIV_4,
SI5351_MULTISYNTH_DIV_6,
SI5351_MULTISYNTH_DIV_8
};
// Greatest common divisor
unsigned long gcdul(unsigned long a, unsigned long b) {
while (b) {
unsigned long t = b;
b = a % b;
a = t;
}
return a;
}
// Try perfect integer solution (zero error)
bool tryIntegerOnly(unsigned long target, uint8_t chan) {
for (auto DIV : INT_DIVS) {
unsigned long vco = target * DIV;
if (vco < VCO_MIN || vco > VCO_MAX) continue;
if (vco % XTAL_FREQ) continue; // not integer multiple
unsigned long N = vco / XTAL_FREQ;
if (N < 15 || N > 90) continue; // PLL range
clockgen.setupPLLInt(SI5351_PLL_A, N);
clockgen.setupMultisynthInt(chan, SI5351_PLL_A, DIV);
clockgen.enableOutputs(true);
return true;
}
return false;
}
// Set channel to closest frequency; returns error in Hz
long set_freq(unsigned long target, uint8_t chan) {
// 1) Integer‐only fast path
if (tryIntegerOnly(target, chan)) {
Serial.print("CLK"); Serial.print(chan);
Serial.print(" SET → "); Serial.print(target);
Serial.println(" Hz ERR 0");
return 0;
}
// 2) Fractional search
long best_err = 0x7FFFFFFF;
uint8_t best_N=0, best_r=0;
unsigned long best_ip=0, best_num=0, best_den=1;
for (uint8_t N = 15; N <= 90; N++) {
unsigned long vco = XTAL_FREQ * N;
if (vco < VCO_MIN || vco > VCO_MAX) continue;
for (uint8_t i = 0; i < 8; i++) {
double ms_target = double(target) * (1UL<<R_DIV_SHIFT[i]);
double D = double(vco) / ms_target;
if (D < 4.0 || D > 1800.0) continue;
unsigned long ip = floor(D);
unsigned long den = 1000000UL;
unsigned long num = round((D - ip) * den);
unsigned long g = gcdul(num, den);
if (g>1) { num/=g; den/=g; }
double fms = double(vco) / (ip + double(num)/den);
double fout = fms / (1UL<<R_DIV_SHIFT[i]);
long err = lround(fout - double(target));
if (labs(err) < labs(best_err)) {
best_err = err;
best_N = N;
best_r = i;
best_ip = ip;
best_num = num;
best_den = den;
if (best_err == 0) break;
}
}
if (best_err == 0) break;
}
// Program fractional solution
clockgen.setupPLLInt(SI5351_PLL_A, best_N);
clockgen.setupMultisynth(chan,
SI5351_PLL_A,
best_ip,
best_num,
best_den);
clockgen.setupRdiv(chan, R_DIV_ENUM[best_r]);
clockgen.enableOutputs(true);
unsigned long actual = (best_err >= 0)
? target + best_err
: target - (unsigned long)(-best_err);
Serial.print("CLK"); Serial.print(chan);
Serial.print(" SET → "); Serial.print(actual);
Serial.print(" Hz ERR "); Serial.println(best_err);
return best_err;
}
void setup() {
Serial.begin(115200);
while (!Serial);
if (clockgen.begin() != ERROR_NONE) {
Serial.println("INIT ERROR");
while (1);
}
clockgen.enableOutputs(false);
Serial.println("READY");
}
void loop() {
static String cmd;
while (Serial.available()) {
char c = Serial.read();
if (c=='\r') continue;
if (c=='\n') {
cmd.trim();
if (cmd.equalsIgnoreCase("off")) clockgen.enableOutputs(false), Serial.println("ALL OFF");
else if (cmd.equalsIgnoreCase("on")) clockgen.enableOutputs(true), Serial.println("ALL ON");
else {
int comma = cmd.indexOf(',');
if (comma>0) {
unsigned long f = cmd.substring(0,comma).toInt();
uint8_t ch = cmd.substring(comma+1).toInt();
set_freq(f,ch);
}
}
cmd="";
} else cmd+=c;
}
}
Key Points:
- The sketch reads newline-terminated commands:
•off/ontoggles all outputs.
•<frequency>,<channel>sets that channel’s freq (Hz). - After programming, it prints back a line like:
CLK2 SET → 1 234 560 Hz ERR -7
HTML + JavaScript Interface
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>Si5351 Real‐Freq Display</title>
<style>
* { box-sizing:border-box; margin:0; padding:0 }
body {
font-family:"Segoe UI",sans-serif;
background:#f4f6f8; color:#333;
padding:2rem;
display:flex; flex-direction:column; align-items:center;
}
h1 { margin-bottom:1rem }
#connect, #global-toggle {
background:#0078d7; color:#fff; border:none; border-radius:4px;
padding:.6rem 1rem; font-size:1rem; cursor:pointer;
margin-bottom:1rem; width:220px; transition:background .2s;
}
#connect:disabled,
#global-toggle.off { background:#aaa; cursor:default; }
#connect:hover:not(:disabled),
#global-toggle.on:hover { background:#005a9e; }
.channels {
display:flex; flex-direction:column; gap:1.5rem;
width:100%; max-width:400px; margin:0 auto;
}
.channel {
background:#fff; border-radius:8px;
box-shadow:0 2px 6px rgba(0,0,0,0.1);
padding:1rem; display:flex; flex-direction:column; gap:.5rem;
}
.channel h2 { text-align:center; font-size:1.2rem }
.freq-display {
min-height:1.2em; text-align:center;
color:#28a745; font-size:1rem;
}
.controls {
display:flex; align-items:center; gap:.5rem;
}
.controls input[type=range] {
flex:1; height:6px; -webkit-appearance:none;
background:#ddd; border-radius:3px;
}
.controls input[type=range]::-webkit-slider-thumb {
width:18px; height:18px; border-radius:50%;
background:#0078d7; cursor:pointer; transition:background .2s;
}
.controls input[type=range]::-webkit-slider-thumb:hover {
background:#005a9e;
}
.controls input[type=text] {
width:110px; padding:.4rem;
border:1px solid #ccc; border-radius:4px;
text-align:right;
}
.unit { margin-left:.2rem }
</style>
</head>
<body>
<h1>Si5351 Real‐Freq Control</h1>
<button id="connect">Connect to Arduino</button>
<button id="global-toggle" class="off" disabled>Turn All On</button>
<div class="channels">
<div class="channel" data-chan="0">
<h2>CLK0</h2>
<div class="freq-display">—</div>
<div class="controls">
<input type="range" class="rr" min="8000" max="22000000" step="1000">
<input type="text" class="nn">
<span class="unit">Hz</span>
</div>
</div>
<div class="channel" data-chan="1">
<h2>CLK1</h2>
<div class="freq-display">—</div>
<div class="controls">
<input type="range" class="rr" min="8000" max="22000000" step="1000">
<input type="text" class="nn">
<span class="unit">Hz</span>
</div>
</div>
<div class="channel" data-chan="2">
<h2>CLK2</h2>
<div class="freq-display">—</div>
<div class="controls">
<input type="range" class="rr" min="8000" max="22000000" step="1000">
<input type="text" class="nn">
<span class="unit">Hz</span>
</div>
</div>
</div>
<script>
let writer, reader;
const fmt = n => n.toString().replace(/\B(?=(\d{3})+(?!\d))/g,' ');
const throttle = (fn, wait=100) => {
let last=0;
return (...args) => {
const now=Date.now();
if(now-last>wait){ last=now; fn(...args); }
};
};
function saveFreq(ch, hz){ localStorage.setItem('clk'+ch, hz); }
function loadFreq(ch, def){
const v = localStorage.getItem('clk'+ch);
return v ? parseInt(v) : def;
}
document.getElementById('connect').onclick = async () => {
const port = await navigator.serial.requestPort();
await port.open({ baudRate:115200 });
writer = port.writable.getWriter();
reader = port.readable
.pipeThrough(new TextDecoderStream())
.getReader();
document.getElementById('connect').disabled = true;
document.getElementById('global-toggle').disabled = false;
readLoop();
};
async function readLoop() {
let buf='';
while(true){
const { value, done } = await reader.read();
if(done) break;
buf += value;
let idx;
while((idx = buf.indexOf('\n')) >= 0) {
parseLine(buf.slice(0, idx).trim());
buf = buf.slice(idx+1);
}
}
}
function parseLine(line) {
const m = line.match(/^CLK(\d)\s+SET.*→\s*([\d ]+)\s*Hz/);
if(!m) return;
const ch = m[1], val = parseInt(m[2].replace(/ /g,''));
const panel = document.querySelector(`.channel[data-chan="${ch}"]`);
panel.querySelector('.freq-display').textContent = fmt(val)+' Hz';
panel.querySelector('.rr').value = val;
panel.querySelector('.nn').value = fmt(val);
saveFreq(ch, val);
}
const gt = document.getElementById('global-toggle');
gt.onclick = () => {
const isOn = gt.classList.toggle('off');
gt.classList.toggle('on', isOn);
gt.textContent = isOn ? 'Turn All Off' : 'Turn All On';
if(writer) {
writer.write(new TextEncoder().encode((isOn?'on\n':'off\n')));
if(isOn){
document.querySelectorAll('.channel').forEach(ch => {
const chan = +ch.dataset.chan;
const v = +ch.querySelector('.rr').value;
writer.write(new TextEncoder().encode(`${v},${chan}\n`));
});
}
}
};
document.querySelectorAll('.channel').forEach(ch => {
const chan = +ch.dataset.chan;
const range = ch.querySelector('.rr');
const input = ch.querySelector('.nn');
const disp = ch.querySelector('.freq-display');
const mid = ((+range.min + +range.max) >> 1);
const init = loadFreq(chan, mid);
range.value = init;
input.value = fmt(init);
disp.textContent = fmt(init)+' Hz';
const sendTh = throttle(v => {
if(writer) writer.write(new TextEncoder().encode(`${v},${chan}\n`));
saveFreq(chan, v);
}, 100);
range.oninput = () => {
const v = +range.value;
input.value = fmt(v);
disp.textContent = fmt(v)+' Hz';
sendTh(v);
};
input.onblur = () => {
let raw = input.value.replace(/\D/g,'') || range.min;
let v = +raw;
v = Math.max(v, +range.min);
v = Math.min(v, +range.max);
range.value = v;
input.value = fmt(v);
disp.textContent = fmt(v)+' Hz';
if(writer) writer.write(new TextEncoder().encode(`${v},${chan}\n`));
saveFreq(chan, v);
};
input.onkeydown = e => { if(e.key==='Enter') input.blur(); };
});
</script>
</body>
</html>
</body>
</html>
How to Use
- Serve the HTML locally, over HTTPS or localhost.
- Upload the Arduino sketch.
- Baud rate: 115 200
- Open the Serial Monitor to confirm you see
READY.
- Open the web page in Chrome/Edge.
You’ll see three sliders and a “Connect to Arduino” button. - Click “Connect to Arduino”.
- Grant the site permission to access your Arduino’s serial port.
- The “Connect” button grays out and “Turn All On” becomes enabled.
- Move any slider or edit the textbox.
- The page sends
"<freq>,<chan>\n"to set that channel. - When the Arduino replies, the “Actual” frequency is displayed.
- The page sends
- Global On/Off.
- Click “Turn All On” to enable outputs on all three channels at their current values.
- Click again (“Turn All Off”) to disable all outputs.