Ryan Malloy 613611d37a 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
2026-02-02 21:24:29 -07:00

268 lines
7.6 KiB
JavaScript

// 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();
}