// Current active configuration for display scaling let activeConfig = { desiredRate: 10000, sample_rate: 10000, atten: 3, // 11dB Default bit_width: 12, test_hz: 100, trigger: 2048, invert: false }; // Accumulator for virtual low sample rates let lowRateState = { accMin: 4096, accMax: 0, accSum: 0, accCount: 0, progress: 0.0, targetCount: 1.0 }; // Approximate full-scale voltages for ESP32C6/ESP32 ADC attenuations // 0dB: ~950mV, 2.5dB: ~1250mV, 6dB: ~1750mV, 11dB: ~3100mV+ (use 3.3V) const ATTEN_TO_MAX_V = [0.95, 1.25, 1.75, 3.3]; // Data buffer const countPoints = 4000; /** @type Array */ let dataBuffer = new Array(countPoints).fill(0); /** @type HTMLCanvas */ const canvas = document.getElementById('adcChart'); const ctx = canvas.getContext('2d'); const statusEl = document.getElementById('status'); const deltaPanel = document.getElementById('deltaPanel'); const triggerLevel = document.getElementById('triggerLevel'); function triggerColor() { activeConfig.trigger = triggerLevel.value; const value = (triggerLevel.value - triggerLevel.min) / (triggerLevel.max - triggerLevel.min) * 100; triggerLevel.style.background = triggerLevel.invert ? `linear-gradient(to bottom, #00000070 0%, #ff020270 ${value}%, #1302ff70 ${value}%, #00000070 100%)` : `linear-gradient(to bottom, #00000070 0%, #1302ff70 ${value}%, #ff020270 ${value}%, #00000070 100%)` } triggerLevel.addEventListener('input', triggerColor); triggerLevel.addEventListener('mousedown', function () { this.downValue = this.value; }); triggerLevel.addEventListener('mouseup', function () { if (this.downValue == this.value) { this.invert = !this.invert; activeConfig.invert = this.invert; triggerColor(); this.dispatchEvent(new Event("change")); } delete this.downValue; }); triggerColor(); // Resize canvas & data function resize() { canvas.width = canvas.offsetWidth; canvas.height = canvas.offsetHeight; } window.addEventListener('resize', resize); resize(); let lastMousePosition = { x: null, y: null }; let isFrozen = false; // Track freeze state canvas.addEventListener('click', () => isFrozen = !isFrozen); let viewTransform = { scale: 1, offsetX: 0, offsetY: 0 }; // Helper to get total time (width of buffer in ms) function getTotalTimeMs() { let msPerPoint; // If we are in low-rate mode (peak detect), we emit 1 point (object) // for every 'targetCount' samples of the hardware rate (which is 1kHz). if (activeConfig.desiredRate < 1000) { msPerPoint = lowRateState.targetCount; } else { // Normal mode: 1 point = 1 sample msPerPoint = 1000.0 / activeConfig.desiredRate; } // The total time displayed is simply the time-per-pixel * number-of-pixels // assuming 1 pixel = 1 point at scale 1.0. return msPerPoint * canvas.width; } // Helper to get max voltage function getMaxVoltage() { return ATTEN_TO_MAX_V[activeConfig.atten] || 3.3; } // Coordinate Transforms function XtoTime(px) { const totalTime = getTotalTimeMs(); // px = (t / totalTime * width) * scale + offsetX // t = ((px - offsetX) / scale) * (totalTime / width) if (canvas.width === 0) return 0; return ((px - viewTransform.offsetX) / viewTransform.scale) * (totalTime / canvas.width); } function TimeToX(t) { const totalTime = getTotalTimeMs(); if (totalTime === 0) return 0; const xp = (t / totalTime) * canvas.width; return xp * viewTransform.scale + viewTransform.offsetX; } function YtoVolts(py) { const maxV = getMaxVoltage(); // sy = yp * scale + offsetY // yp = (sy - offsetY) / scale // yp = h * (1 - v/maxV) // v = maxV * (1 - yp/h) if (canvas.height === 0) return 0; const yp = (py - viewTransform.offsetY) / viewTransform.scale; return maxV * (1 - yp / canvas.height); } function VoltsToY(v) { const maxV = getMaxVoltage(); const yp = canvas.height * (1 - v / maxV); return yp * viewTransform.scale + viewTransform.offsetY; } canvas.addEventListener('wheel', function (e) { e.preventDefault(); if (e.deltaX > 0) { } else if (e.deltaX < 0) { } const zoomFactor = 1.1; const direction = e.deltaY < 0 ? 1 : -1; const factor = direction > 0 ? zoomFactor : 1 / zoomFactor; let newScale = viewTransform.scale * factor; if (newScale < 1.001) { // Snap to 100% and reset position newScale = 1.0; viewTransform.offsetX = 0; viewTransform.offsetY = 0; } else if (newScale > 50) { return; // Max limit } else { // Zoom centered on mouse const mx = e.offsetX; const my = e.offsetY; viewTransform.offsetX = mx - (mx - viewTransform.offsetX) * factor; viewTransform.offsetY = my - (my - viewTransform.offsetY) * factor; } viewTransform.scale = newScale; if (newScale < 1.001) { statusEl.textContent = 'Connected via WebSocket'; } else { statusEl.textContent = 'Scaled to ' + newScale.toFixed(2) + 'x'; } draw(); // Update info if frozen to show correct values under cursor if (isFrozen) { updateInfo({ offsetX: e.offsetX, offsetY: e.offsetY, pageX: e.pageX, pageY: e.pageY }); } }); let referencePosition = null; // Store the reference position for deltas // Helper function to schedule WebSocket reconnection function scheduleReconnect() { statusEl.textContent = 'Disconnected. Retrying in 2s...'; statusEl.style.color = '#ef4444'; reconnectTimeout = setTimeout(connect, 2000); } // Helper function to draw crosshairs function drawCrosshairs(x, y, color) { ctx.setLineDash([5, 5]); ctx.strokeStyle = color; ctx.lineWidth = 1; // Draw vertical line ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke(); // Draw horizontal line ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke(); ctx.setLineDash([]); } // Refactor duplicated code to use helper functions function updateInfo(event) { // Use raw coordinates or event helpers const voltage = YtoVolts(event.offsetY); const timeOffset = XtoTime(event.offsetX); let info = `
${voltage.toFixed(3)}V, ${timeOffset.toFixed(2)}ms
`; // Store the last mouse position lastMousePosition.x = event.offsetX; lastMousePosition.y = event.offsetY; // Update delta panel position and content if frozen if (isFrozen && referencePosition) { // Delta uses World Coordinates now const deltaVoltage = Math.abs(referencePosition.v - voltage); const deltaTime = Math.abs(referencePosition.t - timeOffset); info += `
ΔV ${deltaVoltage.toFixed(3)}V, ΔT ${deltaTime.toFixed(2)}ms (${(1000 / deltaTime).toFixed(2)} Hz)
`; } deltaPanel.style.left = `${event.pageX + 10}px`; deltaPanel.style.top = `${event.pageY + 10}px`; deltaPanel.innerHTML = info; // Force redraw when frozen if (isFrozen) { draw(); } } canvas.addEventListener('mousemove', updateInfo); canvas.addEventListener('mouseenter', () => deltaPanel.style.display = 'block'); canvas.addEventListener('mouseleave', () => deltaPanel.style.display = 'none'); canvas.addEventListener('click', (event) => { if (isFrozen) { // Set reference position in World Coordinates referencePosition = { t: XtoTime(event.offsetX), v: YtoVolts(event.offsetY) }; } }); // WebSocket let ws; let reconnectTimeout; function connect() { loadStoredConfig(); clearTimeout(reconnectTimeout); const btn = document.getElementById('reconnectBtn'); if (btn) btn.style.display = 'none'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/signal`; // For local testing without ESP hardware, uncomment next line: // const wsUrl = 'ws://localhost:8080/signal'; ws = new WebSocket(wsUrl); ws.binaryType = 'arraybuffer'; ws.onopen = () => { statusEl.textContent = 'Connected via WebSocket'; statusEl.style.color = '#4ade80'; ws.send("hello"); }; ws.onclose = () => { scheduleReconnect(); }; ws.onmessage = (event) => { try { const arr = new Uint16Array(event.data); if (arr?.length) { processData(arr); } } catch (e) { console.error('Parse error:', e); } }; } // Animation loop function animationLoop() { if (!isFrozen) { draw(); } requestAnimationFrame(animationLoop); } // Start the loop requestAnimationFrame(animationLoop); function processData(/** @type Uint16Array */newData) { if (isFrozen) { return; // Skip updating the buffer when frozen } // Recalculate target count based on ratio // If virtual rate < 1000, we forced hardware to 1000 if (activeConfig.desiredRate < 1000) { lowRateState.targetCount = activeConfig.sample_rate / activeConfig.desiredRate; } else { lowRateState.targetCount = 1; } if (lowRateState.targetCount <= 1) { // Passthrough mode pushToBuffer(newData); } else { // Accumulation mode (Peak Detect) with Fractional Resampling let pointsToPush = []; let idx = 0; // We add 1.0 "samples worth" of progress for each input sample. // When progress >= targetCount, we have enough input density to emit an output point. // We subtract targetCount (rather than reset to 0) to preserve fractional error (dither/phase). for (const val of newData) { if (val < lowRateState.accMin) lowRateState.accMin = val; if (val > lowRateState.accMax) lowRateState.accMax = val; lowRateState.accSum += val; lowRateState.accCount++; lowRateState.progress += 1.0; if (lowRateState.progress >= lowRateState.targetCount) { // Push object with min/max/avg const avg = lowRateState.accCount > 0 ? (lowRateState.accSum / lowRateState.accCount) : val; pointsToPush.push({ min: lowRateState.accMin, max: lowRateState.accMax, avg: avg }); // Reset Min/Max/Sum for next window lowRateState.accMin = 4096; lowRateState.accMax = 0; lowRateState.accSum = 0; lowRateState.accCount = 0; // Subtract one full window's worth of progress lowRateState.progress -= lowRateState.targetCount; } } if (pointsToPush.length > 0) { pushToBuffer(pointsToPush); } } } function pushToBuffer(/** @type Array */ newItems) { if (newItems.length >= countPoints) { dataBuffer = Array.from(newItems.slice(-countPoints)); } else { dataBuffer.splice(0, newItems.length); dataBuffer.push(...newItems); } } // Nice Number Generator function niceNum(range, round) { const exponent = Math.floor(Math.log10(range)); const fraction = range / Math.pow(10, exponent); let niceFraction; if (round) { if (fraction < 1.5) niceFraction = 1; else if (fraction < 3) niceFraction = 2; else if (fraction < 7) niceFraction = 5; else niceFraction = 10; } else { if (fraction <= 1) niceFraction = 1; else if (fraction <= 2) niceFraction = 2; else if (fraction <= 5) niceFraction = 5; else niceFraction = 10; } return niceFraction * Math.pow(10, exponent); } function calculateNiceTicks(min, max, maxTicks) { const range = niceNum(max - min, false); const tickSpacing = niceNum(range / (maxTicks - 1), true); const niceMin = Math.floor(min / tickSpacing) * tickSpacing; const niceMax = Math.ceil(max / tickSpacing) * tickSpacing; const ticks = []; for (let t = niceMin; t <= niceMax + 0.00001; t += tickSpacing) { ticks.push(t); } return ticks; } function drawGrid(w, h) { ctx.strokeStyle = '#333'; ctx.lineWidth = 1; ctx.fillStyle = '#fff'; ctx.font = '15px monospace'; // Helper to draw text with lozenge background const drawLabel = (text, x, y, align) => { ctx.save(); const paddingX = 6; const paddingY = 3; const fontSize = 13; // Set font to measure correctly ctx.font = `${fontSize}px monospace`; const metrics = ctx.measureText(text); const textWidth = metrics.width; // Calculate box position // We align the text at (x,y) with specified 'align' // y is baseline. Visual center of 15px font is roughly y - 4 const boxHeight = fontSize + paddingY * 2; const boxWidth = textWidth + paddingX * 2; let boxX; if (align === 'left') boxX = x; else if (align === 'center') boxX = x - textWidth / 2; else if (align === 'right') boxX = x - textWidth; // Adjust for padding and visual centering boxX -= paddingX; const boxY = (y - 4) - boxHeight / 2; // Center box around text visual center // Draw semi-transparent lozenge ctx.fillStyle = 'rgba(255, 255, 255, 0.55)'; ctx.beginPath(); ctx.roundRect(boxX, boxY, boxWidth, boxHeight, 8); ctx.fill(); // Draw text ctx.fillStyle = 'black'; ctx.textAlign = align; ctx.fillText(text, x, y); ctx.restore(); }; // Determine Visible Voltage Range const minV = YtoVolts(h); // Bottom of screen (normally 0 if unzoomed, or higher/lower if zoomed/panned) const maxV = YtoVolts(0); // Top of screen // Calculate handy ticks in the visible range const ticks = calculateNiceTicks(minV, maxV, 8); for (let val of ticks) { const y = VoltsToY(val); // Skip if out of bounds (with a bit of margin) if (y < -20 || y > h + 20) continue; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); drawLabel(val.toFixed(2) + 'V', 5, y + 4, 'left'); } // Determine Visible Time Range const minT = XtoTime(0); const maxT = XtoTime(w); // Create ticks for time // Re-use logic or simple logic const tTicks = calculateNiceTicks(minT, maxT, 8); for (let t of tTicks) { const x = TimeToX(t); if (x < -50 || x > w + 50) continue; let timeStr; if (Math.abs(t) >= 1000) { timeStr = (t / 1000).toFixed(2) + 's'; } else { timeStr = t.toFixed(1) + 'ms'; } ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); drawLabel(timeStr, x, h - 5, 'center'); } } function draw() { const w = canvas.width; const h = canvas.height; ctx.clearRect(0, 0, w, h); // Always draw the last waveform data ctx.beginPath(); ctx.strokeStyle = '#4ade80'; ctx.lineWidth = 2; const maxAdcVal = 4096; // 12-bit fixed scale // Trigger values let drawIdx = dataBuffer.length - w; if (drawIdx < 0) drawIdx = 0; else { const triggerVal = (4096 - (parseInt(triggerLevel.value) || 2048)); // Helper to extract value for trigger (handles numbers and avg objects) const getVal = (i) => { const v = dataBuffer[i]; return (typeof v === 'object' && v !== null) ? v.avg : v; }; if (triggerLevel.invert) { while (drawIdx >= 0) { // Look for falling edge if (getVal(drawIdx) < triggerVal && getVal(drawIdx + 1) > triggerVal) { break; } drawIdx -= 1; } } else { while (drawIdx >= 0) { // Look for rising edge if (getVal(drawIdx) > triggerVal && getVal(drawIdx + 1) < triggerVal) { break; } drawIdx -= 1; } } if (drawIdx < 0) { drawIdx = dataBuffer.length - w; } } // NOTE: dataBuffer index 'i' maps to x pixel in initial scale. // sx = i * scale + offsetX // sy : VoltsToY(val_in_volts) Or simpler: // yp = h - (val / maxAdcVal * h) // sy = yp * scale + offsetY // Pass 1: Draw Min/Max ranges for downsampled data ctx.lineWidth = 1; ctx.strokeStyle = '#2b7044'; // Dark green ctx.beginPath(); dataBuffer.slice(drawIdx).forEach((val, i) => { // Check if it's an object (downsampled) if (typeof val === 'object' && val !== null) { const sx = i * viewTransform.scale + viewTransform.offsetX; const yMin = VoltsToY(YtoVolts(h - (val.min / maxAdcVal * h))); // Convert to Y pixels // Note: simplified Y calculation: const yTop = h - (val.max / maxAdcVal * h) * viewTransform.scale + viewTransform.offsetY; // Wait, simple logic: // val -> Y pixel: // yp_raw = h - (raw / maxAdcVal * h) // yp_screen = yp_raw * scale + offsetY const rawYMin = h - (val.min / maxAdcVal * h); const rawYMax = h - (val.max / maxAdcVal * h); const screenYMin = rawYMin * viewTransform.scale + viewTransform.offsetY; const screenYMax = rawYMax * viewTransform.scale + viewTransform.offsetY; ctx.moveTo(sx, screenYMin); ctx.lineTo(sx, screenYMax); } }); ctx.stroke(); // Pass 2: Draw Main Trace (Avg or raw value) ctx.lineWidth = 2; ctx.strokeStyle = '#4ade80'; // Bright green ctx.beginPath(); dataBuffer.slice(drawIdx).forEach((val, i) => { const sx = i * viewTransform.scale + viewTransform.offsetX; let rawVal; if (typeof val === 'object' && val !== null) { rawVal = val.avg; } else { rawVal = val; } const yp = h - (rawVal / maxAdcVal * h); const sy = yp * viewTransform.scale + viewTransform.offsetY; if (i === 0) ctx.moveTo(sx, sy); else ctx.lineTo(sx, sy); }); ctx.stroke(); // Draw Background Grid drawGrid(w, h); // Draw Crosshairs if mouse is over the canvas if (lastMousePosition.x !== null && lastMousePosition.y !== null) { drawCrosshairs(lastMousePosition.x, lastMousePosition.y, '#4ade80'); } // Draw reference crosshairs and deltas if frozen if (isFrozen && referencePosition) { // Convert World Reference to Screen const refX = TimeToX(referencePosition.t); const refY = VoltsToY(referencePosition.v); drawCrosshairs(refX, refY, '#eab308'); } } function setParams() { const desiredRate = parseInt(document.getElementById('sampleRate').value); const hardwareRate = desiredRate < 1000 ? 1000 : desiredRate; const payload = { sample_rate: hardwareRate, bit_width: parseInt(document.getElementById('bitWidth').value), atten: parseInt(document.getElementById('atten').value), test_hz: parseInt(document.getElementById('testHz').value) }; fetch('/params', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }).then(res => { if (res.ok) { lowRateState.accMin = 4096; lowRateState.accMax = 0; lowRateState.accSum = 0; lowRateState.accCount = 0; // Update active config activeConfig = { ...payload, desiredRate, trigger: parseInt(triggerLevel.value) || 2048 }; // Save to localStorage localStorage.setItem('esp32_adc_config', JSON.stringify(activeConfig)); } else { alert('Error updating configuration'); } }).catch(err => alert('Network error: ' + err)); }; // Load config from localStorage on startup function loadStoredConfig() { const stored = localStorage.getItem('esp32_adc_config'); if (stored) { try { const cfg = JSON.parse(stored); if (cfg.desiredRate) document.getElementById('sampleRate').value = cfg.desiredRate; if (cfg.bit_width) document.getElementById('bitWidth').value = cfg.bit_width; if (cfg.atten !== undefined) document.getElementById('atten').value = cfg.atten; if (cfg.test_hz) document.getElementById('testHz').value = cfg.test_hz; if (cfg.invert) triggerLevel.invert = Boolean(cfg.invert); if (cfg.trigger) triggerLevel.value = cfg.trigger; triggerColor(); setParams(); } catch (e) { console.error("Failed to load config", e); } } } document.getElementById('reconnectBtn').addEventListener('click', connect); document.querySelectorAll('#sampleRate, #bitWidth, #atten, #testHz').forEach(input => input.addEventListener('change', setParams)); triggerLevel.addEventListener('change', () => localStorage.setItem('esp32_adc_config', JSON.stringify(activeConfig))); document.getElementById('resetBtn').addEventListener('click', () => { localStorage.clear(); window.location.reload(); }); document.getElementById('powerOff').addEventListener('click', () => window.location.href = "/poweroff"); function setupWifiListeners() { const modal = document.getElementById('wifiModal'); const btn = document.getElementById('wifiBtn'); const closeBtn = document.getElementById('closeWifi'); const saveBtn = document.getElementById('saveWifi'); if (btn) btn.onclick = () => { modal.style.display = "flex"; document.getElementById('wifiSsid').focus(); }; if (closeBtn) closeBtn.onclick = () => modal.style.display = "none"; if (saveBtn) saveBtn.onclick = () => { const ssid = document.getElementById('wifiSsid').value; const pass = document.getElementById('wifiPass').value; if (!ssid) { alert("SSID is required"); return; } saveBtn.innerText = "Saving..."; fetch('/api/save_wifi', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ssid, password: pass }) }) .then(res => res.text()) .then(text => { alert(text); window.location.reload(); }) .catch(err => { alert("Error: " + err); saveBtn.innerText = "Save"; }); }; } setupWifiListeners(); setParams(); connect();