From 613611d37a144c9dbef2fff822a19b6eb1677383 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Mon, 2 Feb 2026 21:24:29 -0700 Subject: [PATCH] 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 --- firmware/.gitignore | 7 + firmware/Makefile | 21 ++ firmware/data/app.js | 267 +++++++++++++++++++++++ firmware/data/favicon.svg | 28 +++ firmware/data/index.html | 150 +++++++++++++ firmware/data/style.css | 419 ++++++++++++++++++++++++++++++++++++ firmware/include/config.h | 60 ++++++ firmware/platformio.ini | 14 ++ firmware/src/attenuator.cpp | 124 +++++++++++ firmware/src/attenuator.h | 57 +++++ firmware/src/main.cpp | 233 ++++++++++++++++++++ firmware/src/web_server.cpp | 245 +++++++++++++++++++++ firmware/src/web_server.h | 9 + 13 files changed, 1634 insertions(+) create mode 100644 firmware/.gitignore create mode 100644 firmware/Makefile create mode 100644 firmware/data/app.js create mode 100644 firmware/data/favicon.svg create mode 100644 firmware/data/index.html create mode 100644 firmware/data/style.css create mode 100644 firmware/include/config.h create mode 100644 firmware/platformio.ini create mode 100644 firmware/src/attenuator.cpp create mode 100644 firmware/src/attenuator.h create mode 100644 firmware/src/main.cpp create mode 100644 firmware/src/web_server.cpp create mode 100644 firmware/src/web_server.h 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 + + + + +
+
+

+ + + + HMC472A +

+

6-Bit RF Attenuator • DC–3.8 GHz

+
+ +
+ +
+
+ 0.0 + dB +
+
+ Step: 0 / 63 +
+
+ + +
+ +
+ 0 dB + 31.5 dB +
+
+ + +
+

Presets

+
+ + + + + + +
+
+ + +
+

Control Pins

+
+
+
+
V1
+
16 dB
+
+
+
+
V2
+
8 dB
+
+
+
+
V3
+
4 dB
+
+
+
+
V4
+
2 dB
+
+
+
+
V5
+
1 dB
+
+
+
+
V6
+
0.5 dB
+
+
+
+ + HIGH (pass) + + + LOW (attenuate) + +
+
+ + +
+

Sweep

+
+ + + +
+
+ +
+
+
+
+ +
+
+ attenuator.local + ---.---.---.--- + -- dBm + v-- +
+
+ +
+
+
+ + + + 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);