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
268 lines
7.6 KiB
JavaScript
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();
|
|
}
|