Add ESP32-S2 firmware for HMC472A attenuator control
PlatformIO/Arduino firmware for WEMOS/LOLIN S2 Mini: - 6-bit GPIO control (GPIOs 1-6) with glitch-free register writes - REST API: /status, /set, /config, /sweep endpoints - Web UI with PCB green theme, slider, presets, pin visualization - NVS persistence of attenuation setting across power cycles - Sweep mode for automated attenuation stepping - mDNS (attenuator.local), OTA updates, watchdog
This commit is contained in:
parent
bd49570ea0
commit
613611d37a
7
firmware/.gitignore
vendored
Normal file
7
firmware/.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
.pio/
|
||||
.vscode/
|
||||
*.o
|
||||
*.obj
|
||||
*.elf
|
||||
*.bin
|
||||
*.map
|
||||
21
firmware/Makefile
Normal file
21
firmware/Makefile
Normal file
@ -0,0 +1,21 @@
|
||||
.PHONY: build upload uploadfs monitor clean ota
|
||||
|
||||
build:
|
||||
pio run
|
||||
|
||||
upload:
|
||||
pio run -t upload
|
||||
|
||||
uploadfs:
|
||||
pio run -t uploadfs
|
||||
|
||||
monitor:
|
||||
pio device monitor
|
||||
|
||||
flash: upload uploadfs
|
||||
|
||||
clean:
|
||||
pio run -t clean
|
||||
|
||||
ota:
|
||||
pio run -t upload --upload-port attenuator.local
|
||||
267
firmware/data/app.js
Normal file
267
firmware/data/app.js
Normal file
@ -0,0 +1,267 @@
|
||||
// HMC472A Attenuator Web UI
|
||||
|
||||
const API_BASE = ''; // Same origin
|
||||
let pollInterval = null;
|
||||
let sweepRunning = false;
|
||||
|
||||
// DOM elements
|
||||
const dbValue = document.getElementById('db-value');
|
||||
const stepValue = document.getElementById('step-value');
|
||||
const dbSlider = document.getElementById('db-slider');
|
||||
const presetBtns = document.querySelectorAll('.preset-btn');
|
||||
const pins = {
|
||||
v1: document.getElementById('pin-v1'),
|
||||
v2: document.getElementById('pin-v2'),
|
||||
v3: document.getElementById('pin-v3'),
|
||||
v4: document.getElementById('pin-v4'),
|
||||
v5: document.getElementById('pin-v5'),
|
||||
v6: document.getElementById('pin-v6')
|
||||
};
|
||||
const sweepUpBtn = document.getElementById('sweep-up');
|
||||
const sweepDownBtn = document.getElementById('sweep-down');
|
||||
const sweepStopBtn = document.getElementById('sweep-stop');
|
||||
const sweepDwellInput = document.getElementById('sweep-dwell');
|
||||
const sweepStatus = document.getElementById('sweep-status');
|
||||
const hostname = document.getElementById('hostname');
|
||||
const ipAddress = document.getElementById('ip-address');
|
||||
const rssi = document.getElementById('rssi');
|
||||
const version = document.getElementById('version');
|
||||
const otaBtn = document.getElementById('ota-btn');
|
||||
|
||||
// --- API Functions ---
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/status`);
|
||||
if (!response.ok) throw new Error('Failed to fetch status');
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Status fetch error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function setAttenuation(db) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/set`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ attenuation_db: db })
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to set attenuation');
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Set attenuation error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function startSweep(direction) {
|
||||
try {
|
||||
const dwellMs = parseInt(sweepDwellInput.value) || 500;
|
||||
const response = await fetch(`${API_BASE}/sweep`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ direction, dwell_ms: dwellMs })
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to start sweep');
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Start sweep error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopSweep() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/sweep/stop`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to stop sweep');
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Stop sweep error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function enableOTA() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/ota`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to enable OTA');
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Enable OTA error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchConfig() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/config`);
|
||||
if (!response.ok) throw new Error('Failed to fetch config');
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Config fetch error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- UI Update Functions ---
|
||||
|
||||
function updateUI(status) {
|
||||
if (!status) return;
|
||||
|
||||
// Update display
|
||||
dbValue.textContent = status.attenuation_db.toFixed(1);
|
||||
stepValue.textContent = status.step;
|
||||
dbSlider.value = status.step;
|
||||
|
||||
// Update pin indicators
|
||||
const pinKeys = ['V1', 'V2', 'V3', 'V4', 'V5', 'V6'];
|
||||
pinKeys.forEach((key, i) => {
|
||||
const pinEl = pins[key.toLowerCase()];
|
||||
const pinData = status.pins[key];
|
||||
if (pinEl && pinData) {
|
||||
if (pinData.state === 'LOW') {
|
||||
pinEl.classList.add('low');
|
||||
} else {
|
||||
pinEl.classList.remove('low');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update preset button states
|
||||
presetBtns.forEach(btn => {
|
||||
const presetDb = parseFloat(btn.dataset.db);
|
||||
if (Math.abs(status.attenuation_db - presetDb) < 0.01) {
|
||||
btn.classList.add('active');
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Update sweep status
|
||||
if (status.sweep) {
|
||||
sweepRunning = status.sweep.running;
|
||||
if (sweepRunning) {
|
||||
sweepStatus.textContent = `Sweeping ${status.sweep.direction}...`;
|
||||
sweepUpBtn.classList.toggle('active', status.sweep.direction === 'up');
|
||||
sweepDownBtn.classList.toggle('active', status.sweep.direction === 'down');
|
||||
} else {
|
||||
sweepStatus.textContent = '';
|
||||
sweepUpBtn.classList.remove('active');
|
||||
sweepDownBtn.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
// Update footer status
|
||||
rssi.textContent = `${status.wifi_rssi} dBm`;
|
||||
version.textContent = `v${status.version}`;
|
||||
}
|
||||
|
||||
function updateConfig(config) {
|
||||
if (!config) return;
|
||||
|
||||
hostname.textContent = `${config.hostname}.local`;
|
||||
ipAddress.textContent = config.ip;
|
||||
version.textContent = `v${config.version}`;
|
||||
|
||||
if (config.ota_enabled) {
|
||||
otaBtn.textContent = 'OTA Enabled';
|
||||
otaBtn.classList.add('enabled');
|
||||
otaBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Event Handlers ---
|
||||
|
||||
dbSlider.addEventListener('input', async () => {
|
||||
const step = parseInt(dbSlider.value);
|
||||
const db = step * 0.5;
|
||||
|
||||
// Optimistic UI update
|
||||
dbValue.textContent = db.toFixed(1);
|
||||
stepValue.textContent = step;
|
||||
|
||||
// Send to API (debounced by slider behavior)
|
||||
const status = await setAttenuation(db);
|
||||
if (status) updateUI(status);
|
||||
});
|
||||
|
||||
presetBtns.forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const db = parseFloat(btn.dataset.db);
|
||||
const status = await setAttenuation(db);
|
||||
if (status) updateUI(status);
|
||||
});
|
||||
});
|
||||
|
||||
sweepUpBtn.addEventListener('click', async () => {
|
||||
if (sweepRunning) return;
|
||||
await startSweep('up');
|
||||
startPolling(100); // Poll faster during sweep
|
||||
});
|
||||
|
||||
sweepDownBtn.addEventListener('click', async () => {
|
||||
if (sweepRunning) return;
|
||||
await startSweep('down');
|
||||
startPolling(100);
|
||||
});
|
||||
|
||||
sweepStopBtn.addEventListener('click', async () => {
|
||||
await stopSweep();
|
||||
startPolling(1000); // Return to normal polling
|
||||
});
|
||||
|
||||
otaBtn.addEventListener('click', async () => {
|
||||
const result = await enableOTA();
|
||||
if (result) {
|
||||
otaBtn.textContent = 'OTA Enabled';
|
||||
otaBtn.classList.add('enabled');
|
||||
otaBtn.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Polling ---
|
||||
|
||||
function startPolling(intervalMs = 1000) {
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
pollInterval = setInterval(async () => {
|
||||
const status = await fetchStatus();
|
||||
if (status) {
|
||||
updateUI(status);
|
||||
// Slow down polling if sweep stopped
|
||||
if (!status.sweep?.running && intervalMs < 1000) {
|
||||
startPolling(1000);
|
||||
}
|
||||
}
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
// --- Initialization ---
|
||||
|
||||
async function init() {
|
||||
// Fetch initial status and config
|
||||
const [status, config] = await Promise.all([
|
||||
fetchStatus(),
|
||||
fetchConfig()
|
||||
]);
|
||||
|
||||
if (status) updateUI(status);
|
||||
if (config) updateConfig(config);
|
||||
|
||||
// Start background polling
|
||||
startPolling(1000);
|
||||
}
|
||||
|
||||
// Start when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
28
firmware/data/favicon.svg
Normal file
28
firmware/data/favicon.svg
Normal file
@ -0,0 +1,28 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<defs>
|
||||
<linearGradient id="pcb" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#3aaa62"/>
|
||||
<stop offset="100%" style="stop-color:#2d8a4e"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Background -->
|
||||
<rect width="32" height="32" rx="4" fill="#131009"/>
|
||||
<!-- Attenuator symbol: signal with decreasing amplitude -->
|
||||
<path d="M4 16 L8 10 L12 22 L16 12 L20 20 L24 14 L28 16"
|
||||
stroke="url(#pcb)"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"/>
|
||||
<!-- Attenuation arrow -->
|
||||
<path d="M6 24 L26 24"
|
||||
stroke="#dbb960"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"/>
|
||||
<path d="M22 21 L26 24 L22 27"
|
||||
stroke="#dbb960"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 930 B |
150
firmware/data/index.html
Normal file
150
firmware/data/index.html
Normal file
@ -0,0 +1,150 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HMC472A Attenuator</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
||||
</svg>
|
||||
HMC472A
|
||||
</h1>
|
||||
<p class="subtitle">6-Bit RF Attenuator • DC–3.8 GHz</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Attenuation Display -->
|
||||
<section class="display-section">
|
||||
<div class="db-display">
|
||||
<span id="db-value">0.0</span>
|
||||
<span class="db-unit">dB</span>
|
||||
</div>
|
||||
<div class="step-display">
|
||||
Step: <span id="step-value">0</span> / 63
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Slider Control -->
|
||||
<section class="slider-section">
|
||||
<input type="range" id="db-slider" min="0" max="63" step="1" value="0">
|
||||
<div class="slider-labels">
|
||||
<span>0 dB</span>
|
||||
<span>31.5 dB</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Preset Buttons -->
|
||||
<section class="presets-section">
|
||||
<h2>Presets</h2>
|
||||
<div class="preset-buttons">
|
||||
<button class="preset-btn" data-db="0">0 dB</button>
|
||||
<button class="preset-btn" data-db="3">3 dB</button>
|
||||
<button class="preset-btn" data-db="6">6 dB</button>
|
||||
<button class="preset-btn" data-db="10">10 dB</button>
|
||||
<button class="preset-btn" data-db="20">20 dB</button>
|
||||
<button class="preset-btn" data-db="31.5">31.5 dB</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pin Visualization -->
|
||||
<section class="pins-section">
|
||||
<h2>Control Pins</h2>
|
||||
<div class="pin-grid">
|
||||
<div class="pin" id="pin-v1" data-pin="V1">
|
||||
<div class="pin-indicator"></div>
|
||||
<div class="pin-label">V1</div>
|
||||
<div class="pin-db">16 dB</div>
|
||||
</div>
|
||||
<div class="pin" id="pin-v2" data-pin="V2">
|
||||
<div class="pin-indicator"></div>
|
||||
<div class="pin-label">V2</div>
|
||||
<div class="pin-db">8 dB</div>
|
||||
</div>
|
||||
<div class="pin" id="pin-v3" data-pin="V3">
|
||||
<div class="pin-indicator"></div>
|
||||
<div class="pin-label">V3</div>
|
||||
<div class="pin-db">4 dB</div>
|
||||
</div>
|
||||
<div class="pin" id="pin-v4" data-pin="V4">
|
||||
<div class="pin-indicator"></div>
|
||||
<div class="pin-label">V4</div>
|
||||
<div class="pin-db">2 dB</div>
|
||||
</div>
|
||||
<div class="pin" id="pin-v5" data-pin="V5">
|
||||
<div class="pin-indicator"></div>
|
||||
<div class="pin-label">V5</div>
|
||||
<div class="pin-db">1 dB</div>
|
||||
</div>
|
||||
<div class="pin" id="pin-v6" data-pin="V6">
|
||||
<div class="pin-indicator"></div>
|
||||
<div class="pin-label">V6</div>
|
||||
<div class="pin-db">0.5 dB</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pin-legend">
|
||||
<span class="legend-item">
|
||||
<span class="legend-dot high"></span> HIGH (pass)
|
||||
</span>
|
||||
<span class="legend-item">
|
||||
<span class="legend-dot low"></span> LOW (attenuate)
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sweep Controls -->
|
||||
<section class="sweep-section">
|
||||
<h2>Sweep</h2>
|
||||
<div class="sweep-controls">
|
||||
<button id="sweep-up" class="sweep-btn" title="Sweep Up">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m18 15-6-6-6 6"/>
|
||||
</svg>
|
||||
Up
|
||||
</button>
|
||||
<button id="sweep-down" class="sweep-btn" title="Sweep Down">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m6 9 6 6 6-6"/>
|
||||
</svg>
|
||||
Down
|
||||
</button>
|
||||
<button id="sweep-stop" class="sweep-btn stop" title="Stop Sweep">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2"/>
|
||||
</svg>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
<div class="sweep-config">
|
||||
<label>
|
||||
Dwell:
|
||||
<input type="number" id="sweep-dwell" min="10" max="10000" value="500" step="10">
|
||||
ms
|
||||
</label>
|
||||
</div>
|
||||
<div id="sweep-status" class="sweep-status"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="status-bar">
|
||||
<span id="hostname">attenuator.local</span>
|
||||
<span id="ip-address">---.---.---.---</span>
|
||||
<span id="rssi">-- dBm</span>
|
||||
<span id="version">v--</span>
|
||||
</div>
|
||||
<div class="ota-section">
|
||||
<button id="ota-btn" class="ota-btn">Enable OTA</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
419
firmware/data/style.css
Normal file
419
firmware/data/style.css
Normal file
@ -0,0 +1,419 @@
|
||||
/* HMC472A Attenuator Web UI - PCB Green Theme */
|
||||
|
||||
:root {
|
||||
--bg: #131009;
|
||||
--bg-elevated: #1a1610;
|
||||
--bg-card: #221e17;
|
||||
--accent-green: #3aaa62;
|
||||
--accent-green-dim: #2d8a4e;
|
||||
--accent-green-glow: rgba(58, 170, 98, 0.3);
|
||||
--accent-gold: #dbb960;
|
||||
--accent-gold-dim: #b39a4d;
|
||||
--accent-gold-glow: rgba(219, 185, 96, 0.3);
|
||||
--text: #e8e2d6;
|
||||
--text-dim: #9a9488;
|
||||
--text-muted: #6a645a;
|
||||
--border: #3a352c;
|
||||
--error: #d64545;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', monospace;
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
text-align: center;
|
||||
padding: 1.5rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-green);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
header h1 .icon {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
section {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* dB Display */
|
||||
.display-section {
|
||||
text-align: center;
|
||||
background: var(--bg);
|
||||
border-color: var(--accent-green-dim);
|
||||
}
|
||||
|
||||
.db-display {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 3.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-green);
|
||||
text-shadow: 0 0 20px var(--accent-green-glow);
|
||||
line-height: 1;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.db-display .db-unit {
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-dim);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.step-display {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Slider */
|
||||
.slider-section {
|
||||
padding: 1.25rem 1rem;
|
||||
}
|
||||
|
||||
#db-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: var(--bg);
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#db-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--accent-green);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 10px var(--accent-green-glow);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
#db-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
#db-slider::-moz-range-thumb {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--accent-green);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 10px var(--accent-green-glow);
|
||||
}
|
||||
|
||||
.slider-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Preset Buttons */
|
||||
.preset-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.preset-btn:hover {
|
||||
background: var(--bg-elevated);
|
||||
border-color: var(--accent-green-dim);
|
||||
}
|
||||
|
||||
.preset-btn:active,
|
||||
.preset-btn.active {
|
||||
background: var(--accent-green-dim);
|
||||
border-color: var(--accent-green);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
/* Pin Visualization */
|
||||
.pin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pin {
|
||||
text-align: center;
|
||||
padding: 0.5rem 0.25rem;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.pin-indicator {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 0.375rem;
|
||||
background: var(--accent-green);
|
||||
box-shadow: 0 0 8px var(--accent-green-glow);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.pin.low .pin-indicator {
|
||||
background: var(--accent-gold);
|
||||
box-shadow: 0 0 8px var(--accent-gold-glow);
|
||||
}
|
||||
|
||||
.pin-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.pin-db {
|
||||
font-size: 0.625rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.pin-legend {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.legend-dot.high {
|
||||
background: var(--accent-green);
|
||||
}
|
||||
|
||||
.legend-dot.low {
|
||||
background: var(--accent-gold);
|
||||
}
|
||||
|
||||
/* Sweep Controls */
|
||||
.sweep-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.sweep-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.875rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.sweep-btn .icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.sweep-btn:hover {
|
||||
background: var(--bg-elevated);
|
||||
border-color: var(--accent-green-dim);
|
||||
}
|
||||
|
||||
.sweep-btn.active {
|
||||
background: var(--accent-green-dim);
|
||||
border-color: var(--accent-green);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.sweep-btn.stop:hover {
|
||||
border-color: var(--error);
|
||||
}
|
||||
|
||||
.sweep-config {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sweep-config label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.sweep-config input {
|
||||
width: 80px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sweep-config input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-green-dim);
|
||||
}
|
||||
|
||||
.sweep-status {
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--accent-gold);
|
||||
min-height: 1.25rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
padding: 1rem 0;
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.75rem 1.5rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ota-section {
|
||||
text-align: center;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.ota-btn {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.ota-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.ota-btn.enabled {
|
||||
color: var(--accent-green);
|
||||
border-color: var(--accent-green-dim);
|
||||
}
|
||||
|
||||
/* Icons */
|
||||
.icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 400px) {
|
||||
.preset-buttons {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.pin-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.db-display {
|
||||
font-size: 2.75rem;
|
||||
}
|
||||
}
|
||||
60
firmware/include/config.h
Normal file
60
firmware/include/config.h
Normal file
@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
// --- Firmware Version ---
|
||||
#define FW_VERSION "2026-02-02"
|
||||
#define FW_HOSTNAME "attenuator"
|
||||
|
||||
// --- WiFi Credentials ---
|
||||
// Override via build_flags or edit directly
|
||||
#ifndef WIFI_SSID
|
||||
#define WIFI_SSID "your-ssid"
|
||||
#endif
|
||||
#ifndef WIFI_PASS
|
||||
#define WIFI_PASS "your-password"
|
||||
#endif
|
||||
|
||||
#define WIFI_TIMEOUT_MS 15000
|
||||
|
||||
// --- HMC472A Control Pins (active-low) ---
|
||||
// GPIOs 1-6: contiguous block in lower 32-bit register
|
||||
// Enables glitch-free simultaneous writes via GPIO.out_w1ts/w1tc
|
||||
static constexpr uint8_t PIN_V1 = 1; // 16 dB (MSB)
|
||||
static constexpr uint8_t PIN_V2 = 2; // 8 dB
|
||||
static constexpr uint8_t PIN_V3 = 3; // 4 dB
|
||||
static constexpr uint8_t PIN_V4 = 4; // 2 dB
|
||||
static constexpr uint8_t PIN_V5 = 5; // 1 dB
|
||||
static constexpr uint8_t PIN_V6 = 6; // 0.5 dB (LSB)
|
||||
|
||||
static constexpr uint8_t ATTEN_PINS[6] = {PIN_V1, PIN_V2, PIN_V3, PIN_V4, PIN_V5, PIN_V6};
|
||||
static constexpr float ATTEN_DB[6] = {16.0f, 8.0f, 4.0f, 2.0f, 1.0f, 0.5f};
|
||||
|
||||
// Bitmask covering all 6 control pins in the GPIO register
|
||||
static constexpr uint32_t ATTEN_PIN_MASK = (1 << PIN_V1) | (1 << PIN_V2) | (1 << PIN_V3) |
|
||||
(1 << PIN_V4) | (1 << PIN_V5) | (1 << PIN_V6);
|
||||
|
||||
// --- Status LED ---
|
||||
static constexpr uint8_t PIN_LED = 15; // Built-in LED, active HIGH
|
||||
|
||||
// --- Attenuator Limits ---
|
||||
static constexpr float DB_MIN = 0.0f;
|
||||
static constexpr float DB_MAX = 31.5f;
|
||||
static constexpr float DB_STEP = 0.5f;
|
||||
static constexpr uint8_t STEP_MIN = 0;
|
||||
static constexpr uint8_t STEP_MAX = 63;
|
||||
|
||||
// --- Sweep Defaults ---
|
||||
static constexpr uint32_t SWEEP_DWELL_MS_DEFAULT = 500;
|
||||
static constexpr uint32_t SWEEP_DWELL_MS_MIN = 10;
|
||||
static constexpr uint32_t SWEEP_DWELL_MS_MAX = 10000;
|
||||
|
||||
// --- NVS ---
|
||||
#define NVS_NAMESPACE "atten"
|
||||
#define NVS_KEY_STEP "step"
|
||||
|
||||
// --- Watchdog ---
|
||||
#define WDT_TIMEOUT_S 120
|
||||
|
||||
// --- Web Server ---
|
||||
#define WEB_PORT 80
|
||||
14
firmware/platformio.ini
Normal file
14
firmware/platformio.ini
Normal file
@ -0,0 +1,14 @@
|
||||
[env:lolin_s2_mini]
|
||||
platform = espressif32
|
||||
board = lolin_s2_mini
|
||||
framework = arduino
|
||||
lib_deps =
|
||||
mathieucarbou/ESPAsyncWebServer @ ^3.6.0
|
||||
bblanchon/ArduinoJson @ ^7.3.0
|
||||
monitor_speed = 115200
|
||||
board_build.filesystem = littlefs
|
||||
build_flags =
|
||||
-DCORE_DEBUG_LEVEL=3
|
||||
-DBOARD_HAS_PSRAM=1
|
||||
-DARDUINO_USB_MODE=1
|
||||
-DARDUINO_USB_CDC_ON_BOOT=1
|
||||
124
firmware/src/attenuator.cpp
Normal file
124
firmware/src/attenuator.cpp
Normal file
@ -0,0 +1,124 @@
|
||||
#include "attenuator.h"
|
||||
#include <soc/gpio_struct.h>
|
||||
|
||||
Attenuator::Attenuator() : _step(0) {}
|
||||
|
||||
void Attenuator::begin() {
|
||||
// Configure all 6 pins as outputs
|
||||
for (uint8_t i = 0; i < 6; i++) {
|
||||
pinMode(ATTEN_PINS[i], OUTPUT);
|
||||
}
|
||||
|
||||
// Restore last setting from NVS
|
||||
_step = loadFromNVS();
|
||||
applyToGPIO();
|
||||
|
||||
Serial.printf("[Attenuator] Initialized, restored step=%u (%.1f dB)\n", _step, getDB());
|
||||
}
|
||||
|
||||
float Attenuator::setDB(float db) {
|
||||
// Clamp to valid range
|
||||
if (db < DB_MIN) db = DB_MIN;
|
||||
if (db > DB_MAX) db = DB_MAX;
|
||||
|
||||
// Convert to step: each step is 0.5 dB
|
||||
// Round to nearest step
|
||||
uint8_t step = static_cast<uint8_t>(db / DB_STEP + 0.5f);
|
||||
return setStep(step) * DB_STEP;
|
||||
}
|
||||
|
||||
uint8_t Attenuator::setStep(uint8_t step) {
|
||||
// Clamp to valid range
|
||||
if (step > STEP_MAX) step = STEP_MAX;
|
||||
|
||||
_step = step;
|
||||
applyToGPIO();
|
||||
saveToNVS();
|
||||
|
||||
Serial.printf("[Attenuator] Set step=%u (%.1f dB)\n", _step, getDB());
|
||||
return _step;
|
||||
}
|
||||
|
||||
uint8_t Attenuator::setBits(const uint8_t bits[6]) {
|
||||
// Convert bit array to step value
|
||||
// bits[0] = V1 (16 dB, weight 32), bits[5] = V6 (0.5 dB, weight 1)
|
||||
uint8_t step = 0;
|
||||
for (uint8_t i = 0; i < 6; i++) {
|
||||
if (bits[i]) {
|
||||
step |= (1 << (5 - i));
|
||||
}
|
||||
}
|
||||
return setStep(step);
|
||||
}
|
||||
|
||||
float Attenuator::getDB() const {
|
||||
return _step * DB_STEP;
|
||||
}
|
||||
|
||||
uint8_t Attenuator::getStep() const {
|
||||
return _step;
|
||||
}
|
||||
|
||||
uint8_t Attenuator::getBit(uint8_t index) const {
|
||||
if (index >= 6) return 0;
|
||||
// Bit order: index 0 = V1 (MSB, weight 32), index 5 = V6 (LSB, weight 1)
|
||||
return (_step >> (5 - index)) & 0x01;
|
||||
}
|
||||
|
||||
void Attenuator::getBits(uint8_t bits[6]) const {
|
||||
for (uint8_t i = 0; i < 6; i++) {
|
||||
bits[i] = getBit(i);
|
||||
}
|
||||
}
|
||||
|
||||
bool Attenuator::getGPIOState(uint8_t index) const {
|
||||
if (index >= 6) return HIGH;
|
||||
// Active-low: bit=1 → GPIO LOW, bit=0 → GPIO HIGH
|
||||
return getBit(index) ? LOW : HIGH;
|
||||
}
|
||||
|
||||
void Attenuator::applyToGPIO() {
|
||||
// Calculate which pins should be HIGH (bit=0, not attenuating)
|
||||
// and which should be LOW (bit=1, attenuating)
|
||||
//
|
||||
// Active-low logic: step bit set → GPIO LOW → attenuation engaged
|
||||
//
|
||||
// Using register writes for glitch-free simultaneous update:
|
||||
// GPIO.out_w1ts = pins to set HIGH (write-1-to-set)
|
||||
// GPIO.out_w1tc = pins to set LOW (write-1-to-clear)
|
||||
|
||||
uint32_t pins_high = 0; // Bits to set HIGH
|
||||
uint32_t pins_low = 0; // Bits to set LOW
|
||||
|
||||
for (uint8_t i = 0; i < 6; i++) {
|
||||
uint32_t pin_bit = 1 << ATTEN_PINS[i];
|
||||
if (getBit(i)) {
|
||||
// Step bit is 1 → engage attenuation → GPIO LOW
|
||||
pins_low |= pin_bit;
|
||||
} else {
|
||||
// Step bit is 0 → pass signal → GPIO HIGH
|
||||
pins_high |= pin_bit;
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic-ish register writes (as atomic as we can get without disabling interrupts)
|
||||
// Clear first, then set — ensures no glitch to opposite state
|
||||
GPIO.out_w1tc = pins_low;
|
||||
GPIO.out_w1ts = pins_high;
|
||||
}
|
||||
|
||||
void Attenuator::saveToNVS() {
|
||||
_prefs.begin(NVS_NAMESPACE, false); // false = read-write
|
||||
_prefs.putUChar(NVS_KEY_STEP, _step);
|
||||
_prefs.end();
|
||||
}
|
||||
|
||||
uint8_t Attenuator::loadFromNVS() {
|
||||
_prefs.begin(NVS_NAMESPACE, true); // true = read-only
|
||||
uint8_t step = _prefs.getUChar(NVS_KEY_STEP, 0); // default 0 = no attenuation
|
||||
_prefs.end();
|
||||
|
||||
// Validate loaded value
|
||||
if (step > STEP_MAX) step = 0;
|
||||
return step;
|
||||
}
|
||||
57
firmware/src/attenuator.h
Normal file
57
firmware/src/attenuator.h
Normal file
@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Preferences.h>
|
||||
#include "config.h"
|
||||
|
||||
/**
|
||||
* HMC472A Attenuator Controller
|
||||
*
|
||||
* Controls 6 GPIO pins connected to V1–V6 of the HMC472A.
|
||||
* Active-low logic: bit=1 in step → GPIO LOW → attenuation engaged.
|
||||
* Uses ESP32 register writes for glitch-free multi-bit transitions.
|
||||
*/
|
||||
class Attenuator {
|
||||
public:
|
||||
Attenuator();
|
||||
|
||||
/** Initialize GPIOs and restore last setting from NVS */
|
||||
void begin();
|
||||
|
||||
/** Set attenuation in dB (0–31.5, 0.5 steps). Returns actual value after clamping/rounding. */
|
||||
float setDB(float db);
|
||||
|
||||
/** Set attenuation as step value (0–63). Returns actual step after clamping. */
|
||||
uint8_t setStep(uint8_t step);
|
||||
|
||||
/** Set attenuation from 6-bit array [V1, V2, V3, V4, V5, V6]. Returns step value. */
|
||||
uint8_t setBits(const uint8_t bits[6]);
|
||||
|
||||
/** Get current attenuation in dB */
|
||||
float getDB() const;
|
||||
|
||||
/** Get current step value (0–63) */
|
||||
uint8_t getStep() const;
|
||||
|
||||
/** Get single bit state (0 or 1) for pin index 0–5 */
|
||||
uint8_t getBit(uint8_t index) const;
|
||||
|
||||
/** Get all 6 bits as array */
|
||||
void getBits(uint8_t bits[6]) const;
|
||||
|
||||
/** Get the actual GPIO state (HIGH or LOW) for pin index 0–5 */
|
||||
bool getGPIOState(uint8_t index) const;
|
||||
|
||||
private:
|
||||
uint8_t _step; // Current step 0–63
|
||||
Preferences _prefs; // NVS handle
|
||||
|
||||
/** Apply current _step to GPIO pins using register writes */
|
||||
void applyToGPIO();
|
||||
|
||||
/** Save current _step to NVS */
|
||||
void saveToNVS();
|
||||
|
||||
/** Load _step from NVS (returns default if not found) */
|
||||
uint8_t loadFromNVS();
|
||||
};
|
||||
233
firmware/src/main.cpp
Normal file
233
firmware/src/main.cpp
Normal file
@ -0,0 +1,233 @@
|
||||
#include <Arduino.h>
|
||||
#include <WiFi.h>
|
||||
#include <ESPmDNS.h>
|
||||
#include <esp_task_wdt.h>
|
||||
#include <ArduinoOTA.h>
|
||||
|
||||
#include "config.h"
|
||||
#include "attenuator.h"
|
||||
#include "web_server.h"
|
||||
|
||||
// --- Global instances ---
|
||||
Attenuator attenuator;
|
||||
|
||||
// --- LED State Machine ---
|
||||
enum class LEDState {
|
||||
Off, // Headless mode (no WiFi)
|
||||
SlowBlink, // Connecting to WiFi
|
||||
Solid, // Connected
|
||||
FastBlink // Sweeping
|
||||
};
|
||||
|
||||
static LEDState ledState = LEDState::Off;
|
||||
static uint32_t lastLedToggle = 0;
|
||||
static bool ledOn = false;
|
||||
|
||||
void setLEDState(LEDState state) {
|
||||
ledState = state;
|
||||
if (state == LEDState::Off) {
|
||||
digitalWrite(PIN_LED, LOW);
|
||||
ledOn = false;
|
||||
} else if (state == LEDState::Solid) {
|
||||
digitalWrite(PIN_LED, HIGH);
|
||||
ledOn = true;
|
||||
}
|
||||
}
|
||||
|
||||
void updateLED() {
|
||||
uint32_t now = millis();
|
||||
uint32_t interval = 0;
|
||||
|
||||
switch (ledState) {
|
||||
case LEDState::SlowBlink:
|
||||
interval = 500;
|
||||
break;
|
||||
case LEDState::FastBlink:
|
||||
interval = 100;
|
||||
break;
|
||||
default:
|
||||
return; // Off or Solid — nothing to toggle
|
||||
}
|
||||
|
||||
if (now - lastLedToggle >= interval) {
|
||||
lastLedToggle = now;
|
||||
ledOn = !ledOn;
|
||||
digitalWrite(PIN_LED, ledOn ? HIGH : LOW);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Sweep Mode ---
|
||||
static bool sweepRunning = false;
|
||||
static int8_t sweepDirection = 1; // 1 = up, -1 = down
|
||||
static uint32_t sweepDwellMs = SWEEP_DWELL_MS_DEFAULT;
|
||||
static uint32_t lastSweepStep = 0;
|
||||
|
||||
void startSweep(bool up, uint32_t dwellMs) {
|
||||
sweepDirection = up ? 1 : -1;
|
||||
sweepDwellMs = constrain(dwellMs, SWEEP_DWELL_MS_MIN, SWEEP_DWELL_MS_MAX);
|
||||
sweepRunning = true;
|
||||
lastSweepStep = millis();
|
||||
setLEDState(LEDState::FastBlink);
|
||||
Serial.printf("[Sweep] Started, direction=%s, dwell=%u ms\n",
|
||||
up ? "up" : "down", sweepDwellMs);
|
||||
}
|
||||
|
||||
void stopSweep() {
|
||||
sweepRunning = false;
|
||||
setLEDState(LEDState::Solid);
|
||||
Serial.println("[Sweep] Stopped");
|
||||
}
|
||||
|
||||
bool isSweeping() {
|
||||
return sweepRunning;
|
||||
}
|
||||
|
||||
int8_t getSweepDirection() {
|
||||
return sweepDirection;
|
||||
}
|
||||
|
||||
uint32_t getSweepDwellMs() {
|
||||
return sweepDwellMs;
|
||||
}
|
||||
|
||||
void updateSweep() {
|
||||
if (!sweepRunning) return;
|
||||
|
||||
uint32_t now = millis();
|
||||
if (now - lastSweepStep < sweepDwellMs) return;
|
||||
lastSweepStep = now;
|
||||
|
||||
int newStep = attenuator.getStep() + sweepDirection;
|
||||
|
||||
// Wrap around at boundaries
|
||||
if (newStep > STEP_MAX) {
|
||||
newStep = 0;
|
||||
} else if (newStep < 0) {
|
||||
newStep = STEP_MAX;
|
||||
}
|
||||
|
||||
attenuator.setStep(newStep);
|
||||
}
|
||||
|
||||
// --- WiFi Connection ---
|
||||
bool connectWiFi() {
|
||||
Serial.printf("[WiFi] Connecting to %s...\n", WIFI_SSID);
|
||||
setLEDState(LEDState::SlowBlink);
|
||||
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.setHostname(FW_HOSTNAME);
|
||||
WiFi.begin(WIFI_SSID, WIFI_PASS);
|
||||
|
||||
uint32_t startAttempt = millis();
|
||||
while (WiFi.status() != WL_CONNECTED) {
|
||||
updateLED();
|
||||
delay(100);
|
||||
esp_task_wdt_reset();
|
||||
|
||||
if (millis() - startAttempt > WIFI_TIMEOUT_MS) {
|
||||
Serial.println("[WiFi] Connection timeout, continuing without network");
|
||||
setLEDState(LEDState::Off);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Serial.printf("[WiFi] Connected! IP: %s\n", WiFi.localIP().toString().c_str());
|
||||
setLEDState(LEDState::Solid);
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- mDNS Setup ---
|
||||
void setupMDNS() {
|
||||
if (MDNS.begin(FW_HOSTNAME)) {
|
||||
MDNS.addService("http", "tcp", WEB_PORT);
|
||||
Serial.printf("[mDNS] Registered as %s.local\n", FW_HOSTNAME);
|
||||
} else {
|
||||
Serial.println("[mDNS] Failed to start");
|
||||
}
|
||||
}
|
||||
|
||||
// --- OTA Setup ---
|
||||
static bool otaEnabled = false;
|
||||
|
||||
void enableOTA() {
|
||||
if (otaEnabled) return;
|
||||
|
||||
ArduinoOTA.setHostname(FW_HOSTNAME);
|
||||
ArduinoOTA.onStart([]() {
|
||||
stopSweep();
|
||||
setLEDState(LEDState::FastBlink);
|
||||
Serial.println("[OTA] Update starting...");
|
||||
});
|
||||
ArduinoOTA.onEnd([]() {
|
||||
Serial.println("[OTA] Update complete, rebooting...");
|
||||
});
|
||||
ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) {
|
||||
Serial.printf("[OTA] Progress: %u%%\r", (progress / (total / 100)));
|
||||
});
|
||||
ArduinoOTA.onError([](ota_error_t error) {
|
||||
Serial.printf("[OTA] Error %u: ", error);
|
||||
if (error == OTA_AUTH_ERROR) Serial.println("Auth failed");
|
||||
else if (error == OTA_BEGIN_ERROR) Serial.println("Begin failed");
|
||||
else if (error == OTA_CONNECT_ERROR) Serial.println("Connect failed");
|
||||
else if (error == OTA_RECEIVE_ERROR) Serial.println("Receive failed");
|
||||
else if (error == OTA_END_ERROR) Serial.println("End failed");
|
||||
setLEDState(LEDState::Solid);
|
||||
});
|
||||
ArduinoOTA.begin();
|
||||
otaEnabled = true;
|
||||
Serial.println("[OTA] Enabled");
|
||||
}
|
||||
|
||||
bool isOTAEnabled() {
|
||||
return otaEnabled;
|
||||
}
|
||||
|
||||
// --- Setup ---
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(1000); // Allow USB CDC to initialize
|
||||
|
||||
Serial.println();
|
||||
Serial.println("================================");
|
||||
Serial.printf("HMC472A Attenuator Controller %s\n", FW_VERSION);
|
||||
Serial.println("================================");
|
||||
|
||||
// Initialize LED
|
||||
pinMode(PIN_LED, OUTPUT);
|
||||
setLEDState(LEDState::Off);
|
||||
|
||||
// Initialize watchdog
|
||||
esp_task_wdt_init(WDT_TIMEOUT_S, true);
|
||||
esp_task_wdt_add(NULL);
|
||||
Serial.printf("[WDT] Initialized, timeout=%d s\n", WDT_TIMEOUT_S);
|
||||
|
||||
// Initialize attenuator (restores from NVS)
|
||||
attenuator.begin();
|
||||
|
||||
// Connect to WiFi
|
||||
bool wifiConnected = connectWiFi();
|
||||
|
||||
if (wifiConnected) {
|
||||
// Start mDNS
|
||||
setupMDNS();
|
||||
|
||||
// Start web server
|
||||
setupWebServer(attenuator);
|
||||
}
|
||||
|
||||
Serial.println("[Setup] Complete");
|
||||
Serial.println();
|
||||
}
|
||||
|
||||
// --- Main Loop ---
|
||||
void loop() {
|
||||
esp_task_wdt_reset();
|
||||
updateLED();
|
||||
updateSweep();
|
||||
|
||||
if (otaEnabled) {
|
||||
ArduinoOTA.handle();
|
||||
}
|
||||
|
||||
delay(1); // Yield to other tasks
|
||||
}
|
||||
245
firmware/src/web_server.cpp
Normal file
245
firmware/src/web_server.cpp
Normal file
@ -0,0 +1,245 @@
|
||||
#include "web_server.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <WiFi.h>
|
||||
#include <ESPAsyncWebServer.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <LittleFS.h>
|
||||
|
||||
#include "config.h"
|
||||
|
||||
// External functions from main.cpp
|
||||
extern void startSweep(bool up, uint32_t dwellMs);
|
||||
extern void stopSweep();
|
||||
extern bool isSweeping();
|
||||
extern int8_t getSweepDirection();
|
||||
extern uint32_t getSweepDwellMs();
|
||||
extern void enableOTA();
|
||||
extern bool isOTAEnabled();
|
||||
|
||||
static AsyncWebServer server(WEB_PORT);
|
||||
static Attenuator* pAtten = nullptr;
|
||||
|
||||
// --- CORS Headers ---
|
||||
static void addCorsHeaders(AsyncWebServerResponse* response) {
|
||||
response->addHeader("Access-Control-Allow-Origin", "*");
|
||||
response->addHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
response->addHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
}
|
||||
|
||||
// --- GET /status ---
|
||||
static void handleStatus(AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
|
||||
doc["attenuation_db"] = pAtten->getDB();
|
||||
doc["step"] = pAtten->getStep();
|
||||
|
||||
JsonArray bits = doc["bits"].to<JsonArray>();
|
||||
for (int i = 0; i < 6; i++) {
|
||||
bits.add(pAtten->getBit(i));
|
||||
}
|
||||
|
||||
JsonObject pins = doc["pins"].to<JsonObject>();
|
||||
const char* pinNames[] = {"V1", "V2", "V3", "V4", "V5", "V6"};
|
||||
for (int i = 0; i < 6; i++) {
|
||||
JsonObject pin = pins[pinNames[i]].to<JsonObject>();
|
||||
pin["gpio"] = ATTEN_PINS[i];
|
||||
pin["state"] = pAtten->getGPIOState(i) ? "HIGH" : "LOW";
|
||||
pin["db"] = ATTEN_DB[i];
|
||||
}
|
||||
|
||||
doc["wifi_rssi"] = WiFi.RSSI();
|
||||
doc["uptime_s"] = millis() / 1000;
|
||||
doc["version"] = FW_VERSION;
|
||||
|
||||
// Sweep status
|
||||
JsonObject sweep = doc["sweep"].to<JsonObject>();
|
||||
sweep["running"] = isSweeping();
|
||||
sweep["direction"] = getSweepDirection() > 0 ? "up" : "down";
|
||||
sweep["dwell_ms"] = getSweepDwellMs();
|
||||
|
||||
String output;
|
||||
serializeJson(doc, output);
|
||||
|
||||
AsyncWebServerResponse* response = request->beginResponse(200, "application/json", output);
|
||||
addCorsHeaders(response);
|
||||
request->send(response);
|
||||
}
|
||||
|
||||
// --- POST /set ---
|
||||
static void handleSet(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) {
|
||||
JsonDocument doc;
|
||||
DeserializationError error = deserializeJson(doc, data, len);
|
||||
|
||||
if (error) {
|
||||
AsyncWebServerResponse* response = request->beginResponse(400, "application/json",
|
||||
"{\"error\":\"Invalid JSON\"}");
|
||||
addCorsHeaders(response);
|
||||
request->send(response);
|
||||
return;
|
||||
}
|
||||
|
||||
// Accept any of: attenuation_db, step, or bits
|
||||
if (doc["attenuation_db"].is<float>()) {
|
||||
pAtten->setDB(doc["attenuation_db"].as<float>());
|
||||
} else if (doc["step"].is<int>()) {
|
||||
pAtten->setStep(doc["step"].as<int>());
|
||||
} else if (doc["bits"].is<JsonArray>()) {
|
||||
JsonArray arr = doc["bits"].as<JsonArray>();
|
||||
if (arr.size() == 6) {
|
||||
uint8_t bits[6];
|
||||
for (int i = 0; i < 6; i++) {
|
||||
bits[i] = arr[i].as<uint8_t>();
|
||||
}
|
||||
pAtten->setBits(bits);
|
||||
}
|
||||
} else {
|
||||
AsyncWebServerResponse* response = request->beginResponse(400, "application/json",
|
||||
"{\"error\":\"Must provide attenuation_db, step, or bits\"}");
|
||||
addCorsHeaders(response);
|
||||
request->send(response);
|
||||
return;
|
||||
}
|
||||
|
||||
// Return new status
|
||||
handleStatus(request);
|
||||
}
|
||||
|
||||
// --- GET /config ---
|
||||
static void handleConfig(AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
|
||||
doc["version"] = FW_VERSION;
|
||||
doc["hostname"] = FW_HOSTNAME;
|
||||
doc["db_min"] = DB_MIN;
|
||||
doc["db_max"] = DB_MAX;
|
||||
doc["db_step"] = DB_STEP;
|
||||
doc["step_min"] = STEP_MIN;
|
||||
doc["step_max"] = STEP_MAX;
|
||||
|
||||
JsonObject gpio = doc["gpio"].to<JsonObject>();
|
||||
const char* pinNames[] = {"V1", "V2", "V3", "V4", "V5", "V6"};
|
||||
for (int i = 0; i < 6; i++) {
|
||||
gpio[pinNames[i]] = ATTEN_PINS[i];
|
||||
}
|
||||
|
||||
doc["ip"] = WiFi.localIP().toString();
|
||||
doc["mac"] = WiFi.macAddress();
|
||||
doc["ota_enabled"] = isOTAEnabled();
|
||||
|
||||
String output;
|
||||
serializeJson(doc, output);
|
||||
|
||||
AsyncWebServerResponse* response = request->beginResponse(200, "application/json", output);
|
||||
addCorsHeaders(response);
|
||||
request->send(response);
|
||||
}
|
||||
|
||||
// --- POST /sweep ---
|
||||
static void handleSweepStart(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) {
|
||||
JsonDocument doc;
|
||||
bool up = true;
|
||||
uint32_t dwellMs = SWEEP_DWELL_MS_DEFAULT;
|
||||
|
||||
if (len > 0) {
|
||||
DeserializationError error = deserializeJson(doc, data, len);
|
||||
if (!error) {
|
||||
if (doc["direction"].is<const char*>()) {
|
||||
up = strcmp(doc["direction"].as<const char*>(), "down") != 0;
|
||||
}
|
||||
if (doc["dwell_ms"].is<int>()) {
|
||||
dwellMs = doc["dwell_ms"].as<int>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startSweep(up, dwellMs);
|
||||
|
||||
AsyncWebServerResponse* response = request->beginResponse(200, "application/json",
|
||||
"{\"status\":\"sweep started\"}");
|
||||
addCorsHeaders(response);
|
||||
request->send(response);
|
||||
}
|
||||
|
||||
// --- GET /sweep ---
|
||||
static void handleSweepStatus(AsyncWebServerRequest* request) {
|
||||
JsonDocument doc;
|
||||
doc["running"] = isSweeping();
|
||||
doc["direction"] = getSweepDirection() > 0 ? "up" : "down";
|
||||
doc["dwell_ms"] = getSweepDwellMs();
|
||||
doc["current_step"] = pAtten->getStep();
|
||||
doc["current_db"] = pAtten->getDB();
|
||||
|
||||
String output;
|
||||
serializeJson(doc, output);
|
||||
|
||||
AsyncWebServerResponse* response = request->beginResponse(200, "application/json", output);
|
||||
addCorsHeaders(response);
|
||||
request->send(response);
|
||||
}
|
||||
|
||||
// --- POST /sweep/stop ---
|
||||
static void handleSweepStop(AsyncWebServerRequest* request) {
|
||||
stopSweep();
|
||||
|
||||
AsyncWebServerResponse* response = request->beginResponse(200, "application/json",
|
||||
"{\"status\":\"sweep stopped\"}");
|
||||
addCorsHeaders(response);
|
||||
request->send(response);
|
||||
}
|
||||
|
||||
// --- POST /ota ---
|
||||
static void handleOTAEnable(AsyncWebServerRequest* request) {
|
||||
enableOTA();
|
||||
|
||||
AsyncWebServerResponse* response = request->beginResponse(200, "application/json",
|
||||
"{\"status\":\"OTA enabled\"}");
|
||||
addCorsHeaders(response);
|
||||
request->send(response);
|
||||
}
|
||||
|
||||
// --- Setup ---
|
||||
void setupWebServer(Attenuator& atten) {
|
||||
pAtten = &atten;
|
||||
|
||||
// Initialize LittleFS
|
||||
if (!LittleFS.begin(true)) {
|
||||
Serial.println("[WebServer] LittleFS mount failed!");
|
||||
} else {
|
||||
Serial.println("[WebServer] LittleFS mounted");
|
||||
}
|
||||
|
||||
// CORS preflight handler for all routes
|
||||
server.on("/*", HTTP_OPTIONS, [](AsyncWebServerRequest* request) {
|
||||
AsyncWebServerResponse* response = request->beginResponse(204);
|
||||
addCorsHeaders(response);
|
||||
request->send(response);
|
||||
});
|
||||
|
||||
// API routes
|
||||
server.on("/status", HTTP_GET, handleStatus);
|
||||
server.on("/config", HTTP_GET, handleConfig);
|
||||
server.on("/sweep", HTTP_GET, handleSweepStatus);
|
||||
server.on("/sweep/stop", HTTP_POST, handleSweepStop);
|
||||
server.on("/ota", HTTP_POST, handleOTAEnable);
|
||||
|
||||
// Routes with body parsing
|
||||
server.on("/set", HTTP_POST, [](AsyncWebServerRequest* request) {},
|
||||
NULL, handleSet);
|
||||
server.on("/sweep", HTTP_POST, [](AsyncWebServerRequest* request) {},
|
||||
NULL, handleSweepStart);
|
||||
|
||||
// Static files from LittleFS (index.html, style.css, app.js, favicon.svg)
|
||||
server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html");
|
||||
|
||||
// 404 handler
|
||||
server.onNotFound([](AsyncWebServerRequest* request) {
|
||||
AsyncWebServerResponse* response = request->beginResponse(404, "application/json",
|
||||
"{\"error\":\"Not found\"}");
|
||||
addCorsHeaders(response);
|
||||
request->send(response);
|
||||
});
|
||||
|
||||
server.begin();
|
||||
Serial.printf("[WebServer] Started on port %d\n", WEB_PORT);
|
||||
}
|
||||
9
firmware/src/web_server.h
Normal file
9
firmware/src/web_server.h
Normal file
@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include "attenuator.h"
|
||||
|
||||
/**
|
||||
* Initialize and start the async web server.
|
||||
* Sets up REST API routes, CORS headers, and static file serving from LittleFS.
|
||||
*/
|
||||
void setupWebServer(Attenuator& atten);
|
||||
Loading…
x
Reference in New Issue
Block a user