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:
Ryan Malloy 2026-02-02 21:24:29 -07:00
parent bd49570ea0
commit 613611d37a
13 changed files with 1634 additions and 0 deletions

7
firmware/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
.pio/
.vscode/
*.o
*.obj
*.elf
*.bin
*.map

21
firmware/Makefile Normal file
View 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
View 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
View 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
View 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 • DC3.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
View 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
View 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
View 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
View 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
View 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 V1V6 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 (031.5, 0.5 steps). Returns actual value after clamping/rounding. */
float setDB(float db);
/** Set attenuation as step value (063). 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 (063) */
uint8_t getStep() const;
/** Get single bit state (0 or 1) for pin index 05 */
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 05 */
bool getGPIOState(uint8_t index) const;
private:
uint8_t _step; // Current step 063
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
View 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
View 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);
}

View 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);