diff --git a/firmware/.gitignore b/firmware/.gitignore
new file mode 100644
index 0000000..e3f5efb
--- /dev/null
+++ b/firmware/.gitignore
@@ -0,0 +1,7 @@
+.pio/
+.vscode/
+*.o
+*.obj
+*.elf
+*.bin
+*.map
diff --git a/firmware/Makefile b/firmware/Makefile
new file mode 100644
index 0000000..cf8d380
--- /dev/null
+++ b/firmware/Makefile
@@ -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
diff --git a/firmware/data/app.js b/firmware/data/app.js
new file mode 100644
index 0000000..49719b5
--- /dev/null
+++ b/firmware/data/app.js
@@ -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();
+}
diff --git a/firmware/data/favicon.svg b/firmware/data/favicon.svg
new file mode 100644
index 0000000..c9eb869
--- /dev/null
+++ b/firmware/data/favicon.svg
@@ -0,0 +1,28 @@
+
diff --git a/firmware/data/index.html b/firmware/data/index.html
new file mode 100644
index 0000000..9897091
--- /dev/null
+++ b/firmware/data/index.html
@@ -0,0 +1,150 @@
+
+
+
+
+
+ HMC472A Attenuator
+
+
+
+
+
+
+
+
+
+
+
+ 0.0
+ dB
+
+
+ Step: 0 / 63
+
+
+
+
+
+
+
+
+ Presets
+
+
+
+
+
+
+
+
+
+
+
+
+ Control Pins
+
+
+
+ HIGH (pass)
+
+
+ LOW (attenuate)
+
+
+
+
+
+
+ Sweep
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/firmware/data/style.css b/firmware/data/style.css
new file mode 100644
index 0000000..cefea8b
--- /dev/null
+++ b/firmware/data/style.css
@@ -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;
+ }
+}
diff --git a/firmware/include/config.h b/firmware/include/config.h
new file mode 100644
index 0000000..5346eef
--- /dev/null
+++ b/firmware/include/config.h
@@ -0,0 +1,60 @@
+#pragma once
+
+#include
+
+// --- 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
diff --git a/firmware/platformio.ini b/firmware/platformio.ini
new file mode 100644
index 0000000..ea593c6
--- /dev/null
+++ b/firmware/platformio.ini
@@ -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
diff --git a/firmware/src/attenuator.cpp b/firmware/src/attenuator.cpp
new file mode 100644
index 0000000..531f845
--- /dev/null
+++ b/firmware/src/attenuator.cpp
@@ -0,0 +1,124 @@
+#include "attenuator.h"
+#include
+
+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(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;
+}
diff --git a/firmware/src/attenuator.h b/firmware/src/attenuator.h
new file mode 100644
index 0000000..eaf71ff
--- /dev/null
+++ b/firmware/src/attenuator.h
@@ -0,0 +1,57 @@
+#pragma once
+
+#include
+#include
+#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();
+};
diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp
new file mode 100644
index 0000000..b285c77
--- /dev/null
+++ b/firmware/src/main.cpp
@@ -0,0 +1,233 @@
+#include
+#include
+#include
+#include
+#include
+
+#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
+}
diff --git a/firmware/src/web_server.cpp b/firmware/src/web_server.cpp
new file mode 100644
index 0000000..6457a91
--- /dev/null
+++ b/firmware/src/web_server.cpp
@@ -0,0 +1,245 @@
+#include "web_server.h"
+
+#include
+#include
+#include
+#include
+#include
+
+#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();
+ for (int i = 0; i < 6; i++) {
+ bits.add(pAtten->getBit(i));
+ }
+
+ JsonObject pins = doc["pins"].to();
+ const char* pinNames[] = {"V1", "V2", "V3", "V4", "V5", "V6"};
+ for (int i = 0; i < 6; i++) {
+ JsonObject pin = pins[pinNames[i]].to();
+ 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();
+ 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()) {
+ pAtten->setDB(doc["attenuation_db"].as());
+ } else if (doc["step"].is()) {
+ pAtten->setStep(doc["step"].as());
+ } else if (doc["bits"].is()) {
+ JsonArray arr = doc["bits"].as();
+ if (arr.size() == 6) {
+ uint8_t bits[6];
+ for (int i = 0; i < 6; i++) {
+ bits[i] = arr[i].as();
+ }
+ 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();
+ 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()) {
+ up = strcmp(doc["direction"].as(), "down") != 0;
+ }
+ if (doc["dwell_ms"].is()) {
+ dwellMs = doc["dwell_ms"].as();
+ }
+ }
+ }
+
+ 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);
+}
diff --git a/firmware/src/web_server.h b/firmware/src/web_server.h
new file mode 100644
index 0000000..6385306
--- /dev/null
+++ b/firmware/src/web_server.h
@@ -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);