diff --git a/Software/web-server/config_manager.py b/Software/web-server/config_manager.py index f6520d19..e3fd9ec2 100644 --- a/Software/web-server/config_manager.py +++ b/Software/web-server/config_manager.py @@ -829,6 +829,7 @@ def _is_calibration_field(self, key: str) -> bool: "calibration.", "kAutoCalibration", "_ENCLOSURE_", + "kDAC_setting", ] return any(pattern in key for pattern in calibration_patterns) diff --git a/Software/web-server/configurations.json b/Software/web-server/configurations.json index efd8c8ba..29d64250 100644 --- a/Software/web-server/configurations.json +++ b/Software/web-server/configurations.json @@ -266,7 +266,7 @@ }, "gs_config.strobing.kConnectionBoardVersion": { "category": "System", - "subcategory": "advanced", + "subcategory": "basic", "basicSubcategory": "System", "displayName": "Connection Board Version", "description": "The original Ver. 1.0 board inverts the external shutter signal, so this setting tells the system whether to pre-invert the signal or not.", @@ -281,9 +281,20 @@ "passedVia": "json", "passedTo": "both" }, + "gs_config.strobing.kDAC_setting": { + "category": "Strobing", + "subcategory": "advanced", + "displayName": "Strobe DAC Setting", + "description": "Calibrated DAC value for strobe LED current control. Set by the strobe calibration tool.", + "type": "number", + "min": 0, + "max": 255, + "default": null, + "internal": true + }, "gs_config.system.kEnclosureVersion": { "category": "System", - "subcategory": "advanced", + "subcategory": "basic", "basicSubcategory": "System", "displayName": "Enclosure Version", "description": "The enclosure version (type) can affect various aspects of the system. For example, the distance of the reference ball in the calibration rig, as different enclosures position the cameras at different locations.", diff --git a/Software/web-server/requirements.txt b/Software/web-server/requirements.txt index bea8e344..cf32276e 100644 --- a/Software/web-server/requirements.txt +++ b/Software/web-server/requirements.txt @@ -8,3 +8,5 @@ pillow==11.3.0 pyyaml==6.0.3 aiofiles==25.1.0 websockets==15.0.1 +spidev>=3.6 +gpiozero>=2.0 diff --git a/Software/web-server/server.py b/Software/web-server/server.py index 43cdf7dc..b284b443 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -29,6 +29,7 @@ from managers import ConnectionManager, ShotDataStore from parsers import ShotDataParser from pitrac_manager import PiTracProcessManager +from strobe_calibration_manager import StrobeCalibrationManager from testing_tools_manager import TestingToolsManager logger = logging.getLogger(__name__) @@ -46,6 +47,7 @@ def __init__(self): self.pitrac_manager = PiTracProcessManager(self.config_manager) self.calibration_manager = CalibrationManager(self.config_manager) self.testing_manager = TestingToolsManager(self.config_manager) + self.strobe_calibration_manager = StrobeCalibrationManager(self.config_manager) self.mq_conn: Optional[stomp.Connection] = None self.listener: Optional[ActiveMQListener] = None self.reconnect_task: Optional[asyncio.Task] = None @@ -436,6 +438,62 @@ async def upload_test_image(file: UploadFile = File(...)) -> Dict[str, Any]: logger.error(f"Error uploading test image: {e}") return {"status": "error", "message": str(e)} + # Strobe calibration endpoints + @self.app.get("/api/strobe-calibration/status") + async def strobe_calibration_status() -> Dict[str, Any]: + """Get current strobe calibration status""" + return self.strobe_calibration_manager.get_status() + + @self.app.get("/api/strobe-calibration/settings") + async def strobe_calibration_settings() -> Dict[str, Any]: + """Get saved strobe calibration settings""" + return await self.strobe_calibration_manager.get_saved_settings() + + @self.app.post("/api/strobe-calibration/start") + async def start_strobe_calibration(request: Request) -> Dict[str, Any]: + """Start strobe calibration as a background task""" + body = await request.json() + led_type = body.get("led_type", "v3") + target_current = body.get("target_current") + overwrite = body.get("overwrite", False) + + task = asyncio.create_task( + self.strobe_calibration_manager.start_calibration( + led_type=led_type, + target_current=target_current, + overwrite=overwrite, + ) + ) + self.background_tasks.add(task) + task.add_done_callback(self.background_tasks.discard) + + return {"status": "started"} + + @self.app.post("/api/strobe-calibration/cancel") + async def cancel_strobe_calibration() -> Dict[str, Any]: + """Cancel a running strobe calibration""" + self.strobe_calibration_manager.cancel() + return {"status": "ok"} + + @self.app.get("/api/strobe-calibration/diagnostics") + async def strobe_calibration_diagnostics() -> Dict[str, Any]: + """Read strobe hardware diagnostics""" + return await self.strobe_calibration_manager.read_diagnostics() + + @self.app.post("/api/strobe-calibration/set-dac") + async def strobe_set_dac(request: Request) -> Dict[str, Any]: + """Manually set the DAC value""" + body = await request.json() + value = body.get("value") + if value is None or not isinstance(value, int): + return {"status": "error", "message": "Integer 'value' is required"} + return await self.strobe_calibration_manager.set_dac_manual(value) + + @self.app.get("/api/strobe-calibration/dac-start") + async def strobe_dac_start() -> Dict[str, Any]: + """Get the safe DAC starting value""" + return await self.strobe_calibration_manager.get_dac_start() + @self.app.get("/api/cameras/detect") async def detect_cameras() -> Dict[str, Any]: """Auto-detect connected cameras""" diff --git a/Software/web-server/static/css/calibration.css b/Software/web-server/static/css/calibration.css index 7b0f31a6..53916be9 100644 --- a/Software/web-server/static/css/calibration.css +++ b/Software/web-server/static/css/calibration.css @@ -582,29 +582,121 @@ color: var(--error); } +/* Strobe Calibration */ +.strobe-calibration { + background: var(--bg-card); + border-radius: 12px; + padding: 2rem; + margin-bottom: 2rem; + box-shadow: var(--shadow-md); +} + +.strobe-header { + margin-bottom: 1.5rem; +} + +.strobe-header h3 { + color: var(--text-primary); + margin-bottom: 1rem; +} + +.strobe-status-bar { + display: flex; + gap: 2rem; + background: var(--bg-hover); + padding: 0.75rem 1rem; + border-radius: 6px; + border: 1px solid var(--border-color); +} + +.strobe-controls { + margin-bottom: 1.5rem; +} + +.strobe-control-row { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; +} + +.strobe-label { + color: var(--text-secondary); + font-weight: 600; + min-width: 80px; +} + +.strobe-select { + padding: 0.5rem 1rem; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-hover); + color: var(--text-primary); + font-size: 0.95rem; + cursor: pointer; +} + +.strobe-calibration .progress-bar { + margin-bottom: 0.5rem; +} + +.strobe-calibration h4 { + color: var(--text-primary); + margin-bottom: 1rem; +} + +#strobe-result-area .result-card { + border-width: 2px; + border-style: solid; + border-color: var(--border-color); + transition: border-color 0.3s ease; +} + +#strobe-diagnostics-area { + margin-top: 1.5rem; +} + +#strobe-diagnostics-area h4 { + color: var(--text-primary); + margin-bottom: 1rem; +} + +#strobe-diagnostics-warning { + margin-top: 1rem; +} + /* Mobile Responsive */ @media (max-width: 768px) { .main-content { padding: 1rem; } - + .calibration-wizard { padding: 1rem; } - + .wizard-steps { margin-bottom: 2rem; } - + .step-label { font-size: 0.8rem; } - + .camera-options { grid-template-columns: 1fr; } - + .result-cards { grid-template-columns: 1fr; } + + .strobe-status-bar { + flex-direction: column; + gap: 0.5rem; + } + + .strobe-control-row { + flex-wrap: wrap; + } } \ No newline at end of file diff --git a/Software/web-server/static/js/calibration.js b/Software/web-server/static/js/calibration.js index ec23052f..be282f21 100644 --- a/Software/web-server/static/js/calibration.js +++ b/Software/web-server/static/js/calibration.js @@ -14,7 +14,8 @@ class CalibrationManager { camera1: false, camera2: false }; - this.calibrationResults = {}; // Store results from calibration API + this.calibrationResults = {}; + this.strobePollingTimer = null; this.init(); this.setupPageCleanup(); @@ -25,6 +26,8 @@ class CalibrationManager { await this.loadCalibrationData(); + await this.loadStrobeSettings(); + this.setupEventListeners(); this.startStatusPolling(); @@ -52,6 +55,11 @@ class CalibrationManager { this.statusPollInterval = null; } + if (this.strobePollingTimer) { + clearTimeout(this.strobePollingTimer); + this.strobePollingTimer = null; + } + this.cameraPollIntervals.forEach((intervalId) => { clearInterval(intervalId); }); @@ -634,6 +642,265 @@ class CalibrationManager { } }, 5000); } + + // -- Strobe Calibration -- + + async loadStrobeSettings() { + try { + const [settingsRes, configRes] = await Promise.all([ + fetch('/api/strobe-calibration/settings'), + fetch('/api/config?key=gs_config.strobing.kConnectionBoardVersion') + ]); + + const calBtn = document.getElementById('strobe-calibrate-btn'); + const diagBtn = document.getElementById('strobe-diagnostics-btn'); + const controls = document.getElementById('strobe-controls'); + const warning = document.getElementById('strobe-board-warning'); + + let boardVersion = null; + if (configRes.ok) { + const config = await configRes.json(); + boardVersion = config.data; + } + + const boardEl = document.getElementById('strobe-board-version'); + boardEl.textContent = boardVersion ? 'V' + boardVersion : 'Not set'; + + const isV3 = boardVersion !== null && parseInt(boardVersion) === 3; + if (!isV3) { + calBtn.disabled = true; + diagBtn.disabled = true; + controls.style.opacity = '0.5'; + warning.style.display = 'block'; + } else { + calBtn.disabled = false; + diagBtn.disabled = false; + controls.style.opacity = '1'; + warning.style.display = 'none'; + } + + if (settingsRes.ok) { + const settings = await settingsRes.json(); + const dacEl = document.getElementById('strobe-saved-dac'); + if (settings.dac_setting !== null && settings.dac_setting !== undefined) { + dacEl.textContent = '0x' + parseInt(settings.dac_setting).toString(16).toUpperCase().padStart(2, '0'); + if (isV3) calBtn.textContent = 'Recalibrate'; + } else { + dacEl.textContent = 'Not calibrated'; + if (isV3) calBtn.textContent = 'Calibrate'; + } + } + } catch (error) { + console.error('Error loading strobe settings:', error); + } + } + + async startStrobeCalibration() { + const btn = document.getElementById('strobe-calibrate-btn'); + const cancelBtn = document.getElementById('strobe-cancel-btn'); + const originalText = btn.textContent; + + const ledType = document.getElementById('strobe-led-type').value; + const targetCurrent = ledType === 'v3' ? 10.0 : 9.0; + + try { + btn.disabled = true; + btn.textContent = 'Starting...'; + cancelBtn.style.display = ''; + + // Reset and show progress area, hide stale results + document.getElementById('strobe-progress-area').style.display = 'block'; + document.getElementById('strobe-result-area').style.display = 'none'; + document.getElementById('strobe-progress-fill').style.width = '0%'; + document.getElementById('strobe-progress-fill').style.background = ''; + document.getElementById('strobe-progress-message').textContent = 'Starting...'; + document.getElementById('strobe-state').textContent = 'Running'; + + const response = await fetch('/api/strobe-calibration/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + led_type: ledType, + target_current: targetCurrent, + overwrite: true + }) + }); + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.error || 'Failed to start calibration'); + } + + btn.textContent = 'Calibrating...'; + this.pollStrobeStatus(); + } catch (error) { + console.error('Error starting strobe calibration:', error); + btn.disabled = false; + btn.textContent = originalText; + cancelBtn.style.display = 'none'; + document.getElementById('strobe-state').textContent = 'Failed'; + document.getElementById('strobe-progress-message').textContent = error.message; + } + } + + pollStrobeStatus() { + this.strobePollingTimer = setTimeout(async () => { + try { + const response = await fetch('/api/strobe-calibration/status'); + if (!response.ok) { + this.pollStrobeStatus(); + return; + } + + const status = await response.json(); + const progressFill = document.getElementById('strobe-progress-fill'); + const progressMsg = document.getElementById('strobe-progress-message'); + + if (status.progress !== undefined) { + progressFill.style.width = status.progress + '%'; + } + if (status.message) { + progressMsg.textContent = status.message; + } + + if (status.state === 'complete' || status.state === 'completed') { + this.onStrobeCalibrationDone(status); + } else if (status.state === 'failed' || status.state === 'error') { + this.onStrobeCalibrationFailed(status); + } else if (status.state === 'cancelled') { + this.onStrobeCalibrationCancelled(); + } else { + this.pollStrobeStatus(); + } + } catch (error) { + console.error('Error polling strobe status:', error); + this.pollStrobeStatus(); + } + }, 500); + } + + onStrobeCalibrationDone(status) { + const btn = document.getElementById('strobe-calibrate-btn'); + const cancelBtn = document.getElementById('strobe-cancel-btn'); + btn.disabled = false; + btn.textContent = 'Recalibrate'; + cancelBtn.style.display = 'none'; + + document.getElementById('strobe-progress-fill').style.width = '100%'; + document.getElementById('strobe-state').textContent = 'Complete'; + + // Show results + const resultArea = document.getElementById('strobe-result-area'); + const resultCard = document.getElementById('strobe-result-card'); + resultCard.style.borderColor = 'var(--success)'; + document.getElementById('strobe-result-title').textContent = 'Calibration Successful'; + + if (status.dac_setting !== undefined) { + document.getElementById('strobe-result-dac').textContent = + '0x' + status.dac_setting.toString(16).toUpperCase().padStart(2, '0'); + } + if (status.led_current !== undefined) { + document.getElementById('strobe-result-current').textContent = status.led_current.toFixed(2) + ' A'; + } + if (status.ldo_voltage !== undefined) { + document.getElementById('strobe-result-ldo').textContent = status.ldo_voltage.toFixed(2) + ' V'; + } + + resultArea.style.display = 'block'; + + // Refresh the saved DAC display + this.loadStrobeSettings(); + } + + onStrobeCalibrationFailed(status) { + const cancelBtn = document.getElementById('strobe-cancel-btn'); + cancelBtn.style.display = 'none'; + this.loadStrobeSettings(); + + document.getElementById('strobe-progress-fill').style.width = '100%'; + document.getElementById('strobe-progress-fill').style.background = 'var(--error)'; + document.getElementById('strobe-state').textContent = 'Failed'; + document.getElementById('strobe-progress-message').textContent = + status.message || 'Calibration failed'; + + // Show failure in result area + const resultArea = document.getElementById('strobe-result-area'); + const resultCard = document.getElementById('strobe-result-card'); + resultCard.style.borderColor = 'var(--error)'; + document.getElementById('strobe-result-title').textContent = 'Calibration Failed'; + document.getElementById('strobe-result-dac').textContent = '--'; + document.getElementById('strobe-result-current').textContent = '--'; + document.getElementById('strobe-result-ldo').textContent = '--'; + resultArea.style.display = 'block'; + } + + onStrobeCalibrationCancelled() { + const cancelBtn = document.getElementById('strobe-cancel-btn'); + cancelBtn.style.display = 'none'; + + document.getElementById('strobe-state').textContent = 'Idle'; + document.getElementById('strobe-progress-message').textContent = 'Cancelled by user'; + this.loadStrobeSettings(); + } + + async cancelStrobeCalibration() { + try { + const cancelBtn = document.getElementById('strobe-cancel-btn'); + cancelBtn.disabled = true; + cancelBtn.textContent = 'Cancelling...'; + + await fetch('/api/strobe-calibration/cancel', { method: 'POST' }); + // Polling loop will pick up the cancelled state + } catch (error) { + console.error('Error cancelling strobe calibration:', error); + } + } + + async readStrobeDiagnostics() { + const btn = document.getElementById('strobe-diagnostics-btn'); + const originalText = btn.textContent; + + try { + btn.disabled = true; + btn.textContent = 'Reading...'; + + const response = await fetch('/api/strobe-calibration/diagnostics'); + if (!response.ok) { + throw new Error('Failed to read diagnostics'); + } + + const data = await response.json(); + const grid = document.getElementById('strobe-diagnostics-grid'); + grid.innerHTML = ''; + + const items = [ + { label: 'LDO Voltage', value: data.ldo_voltage != null ? data.ldo_voltage.toFixed(2) + ' V' : '--' }, + { label: 'LED Current', value: data.led_current != null ? data.led_current.toFixed(2) + ' A' : '--' }, + { label: 'ADC CH0 Raw', value: data.adc_ch0_raw != null ? data.adc_ch0_raw : '--' }, + { label: 'ADC CH1 Raw', value: data.adc_ch1_raw != null ? data.adc_ch1_raw : '--' } + ]; + + items.forEach(item => { + this.addCalibrationDataItem(grid, item.label, item.value); + }); + + // Handle warnings + const warningEl = document.getElementById('strobe-diagnostics-warning'); + if (data.warning) { + warningEl.textContent = data.warning; + warningEl.style.display = 'block'; + } else { + warningEl.style.display = 'none'; + } + + document.getElementById('strobe-diagnostics-area').style.display = 'block'; + } catch (error) { + console.error('Error reading strobe diagnostics:', error); + } finally { + btn.disabled = false; + btn.textContent = originalText; + } + } } const calibration = new CalibrationManager(); diff --git a/Software/web-server/strobe_calibration_manager.py b/Software/web-server/strobe_calibration_manager.py new file mode 100644 index 00000000..a50c52c3 --- /dev/null +++ b/Software/web-server/strobe_calibration_manager.py @@ -0,0 +1,494 @@ +"""Strobe Calibration Manager for PiTrac Web Server + +Controls MCP4801 DAC and MCP3202 ADC over SPI1 to calibrate the IR strobe +LED current on the V3 Connector Board. +""" + +import asyncio +import gc +import logging +import os +import time +from typing import Any, Dict, Optional + +logger = logging.getLogger(__name__) + +try: + import spidev +except ImportError: + spidev = None + +try: + os.environ.setdefault('LG_WD', '/tmp') + from gpiozero import DigitalOutputDevice +except ImportError: + DigitalOutputDevice = None + + +class StrobeCalibrationManager: + """Manages strobe LED calibration via SPI hardware on the Connector Board""" + + # SPI bus 1 (auxiliary), CS0 = DAC, CS1 = ADC + SPI_BUS = 1 + SPI_DAC_DEVICE = 0 + SPI_ADC_DEVICE = 1 + SPI_MAX_SPEED_HZ = 1_000_000 + + # DIAG pin gates the strobe LED (BCM numbering) + DIAG_GPIO_PIN = 10 + + # MCP4801 8-bit DAC write command (1x gain, active output) + MCP4801_WRITE_CMD = 0x30 + + # MCP3202 12-bit ADC channel commands (single-ended) + ADC_CH0_CMD = 0x80 # LED current sense + ADC_CH1_CMD = 0xC0 # LDO voltage + + # DAC range + DAC_MIN = 0 + DAC_MAX = 0xFF + + # Safe fallback DAC value if calibration fails + SAFE_DAC_VALUE = 0x96 + + # If ADC CH0 reads above this with strobe off, something is wrong (blown MOSFET, shorted gate driver) + PREFLIGHT_CURRENT_THRESHOLD = 6 + + # LDO voltage bounds + LDO_MIN_V = 4.5 + LDO_MAX_V = 11.0 + + # Target LED currents (amps) + V3_TARGET_CURRENT = 10.0 + LEGACY_TARGET_CURRENT = 9.0 + HARD_CAP_CURRENT = 12.0 + + # Config key for persisting the result + DAC_CONFIG_KEY = "gs_config.strobing.kDAC_setting" + + def __init__(self, config_manager): + self.config_manager = config_manager + + self._spi_dac = None + self._spi_adc = None + self._diag_pin = None + + self._cancel_requested = False + + self.status: Dict[str, Any] = { + "state": "idle", + "progress": 0, + "message": "", + } + + # ------------------------------------------------------------------ + # Hardware lifecycle + # ------------------------------------------------------------------ + + def _open_hardware(self): + if spidev is None: + raise RuntimeError("spidev library not available -- not running on a Raspberry Pi?") + if DigitalOutputDevice is None: + raise RuntimeError("gpiozero library not available -- not running on a Raspberry Pi?") + + self._spi_dac = spidev.SpiDev() + self._spi_dac.open(self.SPI_BUS, self.SPI_DAC_DEVICE) + self._spi_dac.max_speed_hz = self.SPI_MAX_SPEED_HZ + self._spi_dac.mode = 0 + + self._spi_adc = spidev.SpiDev() + self._spi_adc.open(self.SPI_BUS, self.SPI_ADC_DEVICE) + self._spi_adc.max_speed_hz = self.SPI_MAX_SPEED_HZ + self._spi_adc.mode = 0 + + self._diag_pin = DigitalOutputDevice(self.DIAG_GPIO_PIN) + + def _close_hardware(self): + for name, resource in [("diag", self._diag_pin), + ("dac", self._spi_dac), + ("adc", self._spi_adc)]: + if resource is None: + continue + try: + if name == "diag": + resource.off() + time.sleep(0.1) + resource.close() + except Exception: + logger.debug(f"Error closing {name}", exc_info=True) + + self._diag_pin = None + self._spi_dac = None + self._spi_adc = None + + # ------------------------------------------------------------------ + # DAC / ADC primitives + # ------------------------------------------------------------------ + + def _set_dac(self, value: int): + """Write an 8-bit value to the MCP4801 DAC.""" + msb = self.MCP4801_WRITE_CMD | ((value >> 4) & 0x0F) + lsb = (value << 4) & 0xF0 + self._spi_dac.xfer2([msb, lsb]) + + def _read_adc(self, channel_cmd: int) -> int: + """Read a 12-bit value from the MCP3202 ADC.""" + response = self._spi_adc.xfer2([0x01, channel_cmd, 0x00]) + return ((response[1] & 0x0F) << 8) | response[2] + + def get_ldo_voltage(self) -> float: + """Read the LDO gate voltage via ADC CH1 (2k/1k resistor divider).""" + adc_value = self._read_adc(self.ADC_CH1_CMD) + return (3.3 / 4096) * adc_value * 3.0 + + def get_led_current(self) -> float: + """Pulse DIAG, read LED current sense via ADC CH0 (0.1 ohm sense resistor). + + Uses real-time scheduling and GC disable for deterministic timing. + DIAG is always turned off in the finally block. + """ + msg = [0x01, self.ADC_CH0_CMD, 0x00] + spi = self._spi_adc + diag = self._diag_pin + + gc.disable() + try: + try: + param = os.sched_param(os.sched_get_priority_max(os.SCHED_FIFO)) + os.sched_setscheduler(0, os.SCHED_FIFO, param) + except (PermissionError, AttributeError, OSError): + pass + + time.sleep(0) + + try: + diag.on() + response = spi.xfer2(msg) + finally: + diag.off() + try: + os.sched_setscheduler(0, os.SCHED_OTHER, os.sched_param(0)) + except (PermissionError, AttributeError, OSError): + pass + finally: + gc.enable() + + adc_value = ((response[1] & 0x0F) << 8) | response[2] + return (3.3 / 4096) * adc_value * 10.0 + + # ------------------------------------------------------------------ + # Calibration algorithm + # ------------------------------------------------------------------ + + def _find_dac_start(self): + """Sweep DAC 0->255, return last value where LDO stays >= LDO_MIN_V. + + Returns: + (dac_value, ldo_voltage) — dac_value is -1 if even DAC 0 is unsafe. + """ + dac_start = 0 + ldo = 0.0 + + for i in range(self.DAC_MAX + 1): + self._set_dac(i) + time.sleep(0.1) + ldo = self.get_ldo_voltage() + logger.debug(f"DAC={i:#04x}, LDO={ldo:.2f}V") + + if ldo < self.LDO_MIN_V: + dac_start = i - 1 + return dac_start, ldo + + dac_start = i + + return dac_start, ldo + + def _calibrate(self, target_current: float): + """Run full calibration: find safe start, sweep down to target, average. + + Returns: + (success, final_dac, led_current) + """ + # Pre-flight: check for current with strobe off — indicates blown MOSFET or gate driver + idle_adc = self._read_adc(self.ADC_CH0_CMD) + if idle_adc > self.PREFLIGHT_CURRENT_THRESHOLD: + self.status["message"] = f"Current detected with strobe off (ADC CH0={idle_adc}). Likely blown MOSFET or gate driver — check V3 Connector Board." + return False, -1, -1 + + # Phase 1: find safe starting DAC + dac_start, ldo = self._find_dac_start() + + if dac_start < 0: + self.status["message"] = f"DAC value of 0 is below minimum LDO voltage ({self.LDO_MIN_V:.2f}V): {ldo:.2f}V. This indicates a problem with the controller board." + return False, -1, -1 + + logger.debug(f"Calibrating: target={target_current}A, dac_start={dac_start:#04x}") + + # Phase 2: sweep from dac_start downward, looking for target crossing + final_dac = self.DAC_MIN + crossed = False + + for dac in range(dac_start, self.DAC_MIN - 1, -1): + if self._cancel_requested: + logger.info("Calibration cancelled by user") + return False, -1, -1 + + self._set_dac(dac) + time.sleep(0.1) + + steps_done = dac_start - dac + total_steps = dac_start - self.DAC_MIN + 1 + if total_steps > 0: + self.status["progress"] = int(20 + (steps_done / total_steps) * 60) + + ldo = self.get_ldo_voltage() + + if ldo < self.LDO_MIN_V: + logger.debug(f"LDO {ldo:.2f}V below min at DAC={dac:#04x}, skipping") + final_dac = dac + continue + + if ldo > self.LDO_MAX_V: + self.status["message"] = f"LDO voltage ({ldo:.2f}V) above maximum ({self.LDO_MAX_V}V). Stopping calibration, as something is wrong." + return False, -1, -1 + + led_current = self.get_led_current() + logger.debug(f"DAC={dac:#04x}, current={led_current:.2f}A") + + if led_current > self.HARD_CAP_CURRENT: + self.status["message"] = f"LED current ({led_current:.2f}A) exceeds hard cap ({self.HARD_CAP_CURRENT}A). This strongly indicates the LED is shorted." + return False, -1, -1 + + if led_current > target_current: + logger.debug(f"Crossed target at DAC={dac:#04x} ({led_current:.2f}A)") + final_dac = dac + 1 + crossed = True + break + + final_dac = dac + + if not crossed: + self.status["message"] = f"Reached MIN_DAC without reaching target ({target_current}A). This generally indicates a problem." + return False, -1, -1 + if final_dac >= self.DAC_MAX: + self.status["message"] = "MAX_DAC resulted in current above target. This generally indicates a problem." + return False, -1, -1 + + # Phase 3: average readings at the final setting to refine + led_current = 0.0 + n_avg = 10 + + while True: + if self._cancel_requested: + return False, -1, -1 + + self._set_dac(final_dac) + time.sleep(0.1) + + ldo = self.get_ldo_voltage() + if ldo < self.LDO_MIN_V: + final_dac -= 1 + break + + current_sum = 0.0 + for _ in range(n_avg): + current_sum += self.get_led_current() + time.sleep(0.1) + led_current = current_sum / n_avg + + if led_current > target_current: + final_dac += 1 + if final_dac > self.DAC_MAX: + logger.error("Averaging loop exceeded DAC_MAX") + return False, -1, -1 + else: + break + + self.status["progress"] = 90 + logger.debug(f"Calibration result: DAC={final_dac:#04x}, current={led_current:.2f}A") + return True, final_dac, led_current + + # ------------------------------------------------------------------ + # Public async API (called from web server endpoints) + # ------------------------------------------------------------------ + + async def start_calibration(self, led_type: str = "v3", + target_current: Optional[float] = None, + overwrite: bool = False) -> Dict[str, Any]: + """Run full strobe calibration. Blocking I/O is offloaded to a thread.""" + + if self.status.get("state") == "calibrating": + return {"status": "error", "message": "Calibration already in progress"} + + # Validate board version + board_version = self.config_manager.get_config("gs_config.strobing.kConnectionBoardVersion") + if board_version is None or int(board_version) != 3: + msg = f"Board version must be V3 (got {board_version})" + self.status = {"state": "error", "progress": 0, "message": msg} + return {"status": "error", "message": msg} + + # Check for existing calibration + existing = self.config_manager.get_config(self.DAC_CONFIG_KEY) + if existing is not None and not overwrite: + msg = "Calibration already exists. Set overwrite=true to replace." + self.status = {"state": "error", "progress": 0, "message": msg} + return {"status": "error", "message": msg} + + # Resolve target + if target_current is not None: + target = target_current + elif led_type == "legacy": + target = self.LEGACY_TARGET_CURRENT + else: + target = self.V3_TARGET_CURRENT + + self._cancel_requested = False + self.status = {"state": "calibrating", "progress": 0, "message": "Starting calibration"} + + loop = asyncio.get_event_loop() + try: + result = await loop.run_in_executor(None, self._run_calibration_sync, target) + return result + except Exception as e: + logger.error(f"Calibration error: {e}") + self.status = {"state": "error", "progress": 0, "message": str(e)} + return {"status": "error", "message": str(e)} + + def _run_calibration_sync(self, target: float) -> Dict[str, Any]: + """Synchronous calibration wrapper — runs in executor thread.""" + try: + self._open_hardware() + + success, final_dac, led_current = self._calibrate(target) + + if success and final_dac > 0: + self.config_manager.set_config(self.DAC_CONFIG_KEY, final_dac) + self.status = { + "state": "complete", "progress": 100, + "message": f"DAC=0x{final_dac:02X}, current={led_current:.2f}A", + "dac_setting": final_dac, + "led_current": round(led_current, 2), + } + return self.status + elif self._cancel_requested: + self.status = {"state": "cancelled", "progress": 0, + "message": "Calibration cancelled by user"} + return self.status + else: + self._set_dac(self.SAFE_DAC_VALUE) + reason = self.status.get("message", "Calibration failed") + self.status = {"state": "failed", "progress": 0, + "message": f"{reason} DAC set to safe fallback."} + return self.status + + except Exception as e: + logger.error(f"Calibration exception: {e}") + self.status = {"state": "error", "progress": 0, "message": str(e)} + return {"status": "error", "message": str(e)} + finally: + self._close_hardware() + + def cancel(self): + """Request cancellation of a running calibration.""" + self._cancel_requested = True + + def get_status(self) -> Dict[str, Any]: + """Return a snapshot of the current calibration status.""" + return dict(self.status) + + async def read_diagnostics(self) -> Dict[str, Any]: + """Read LDO voltage, LED current, and raw ADC values.""" + loop = asyncio.get_event_loop() + try: + return await loop.run_in_executor(None, self._read_diagnostics_sync) + except Exception as e: + return {"status": "error", "message": str(e)} + + def _read_diagnostics_sync(self) -> Dict[str, Any]: + try: + self._open_hardware() + + ldo = self.get_ldo_voltage() + adc_ch0 = self._read_adc(self.ADC_CH0_CMD) + adc_ch1 = self._read_adc(self.ADC_CH1_CMD) + + result: Dict[str, Any] = { + "ldo_voltage": round(ldo, 2), + "adc_ch0_raw": adc_ch0, + "adc_ch1_raw": adc_ch1, + } + + if ldo >= self.LDO_MIN_V: + led_current = self.get_led_current() + result["led_current"] = round(led_current, 2) + else: + result["led_current"] = None + result["warning"] = f"LDO unsafe ({ldo:.2f}V) — skipped LED current read" + + return result + + except Exception as e: + logger.error(f"Diagnostics error: {e}") + return {"status": "error", "message": str(e)} + finally: + self._close_hardware() + + async def set_dac_manual(self, value: int) -> Dict[str, Any]: + """Set DAC to a specific value and report LDO voltage.""" + if value < self.DAC_MIN or value > self.DAC_MAX: + return {"status": "error", + "message": f"DAC value must be {self.DAC_MIN}-{self.DAC_MAX}"} + + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, self._set_dac_manual_sync, value) + + def _set_dac_manual_sync(self, value: int) -> Dict[str, Any]: + try: + self._open_hardware() + self._set_dac(value) + time.sleep(0.1) + ldo = self.get_ldo_voltage() + + result: Dict[str, Any] = { + "status": "success", + "dac_value": value, + "ldo_voltage": round(ldo, 2), + } + + if ldo < self.LDO_MIN_V: + result["warning"] = f"LDO voltage {ldo:.2f}V is below minimum {self.LDO_MIN_V}V" + + return result + except Exception as e: + return {"status": "error", "message": str(e)} + finally: + self._close_hardware() + + async def get_dac_start(self) -> Dict[str, Any]: + """Run the safe-start sweep and return the boundary DAC value.""" + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, self._get_dac_start_sync) + + def _get_dac_start_sync(self) -> Dict[str, Any]: + try: + self._open_hardware() + dac_start, ldo = self._find_dac_start() + + if dac_start >= 0: + self._set_dac(dac_start) + time.sleep(0.1) + ldo = self.get_ldo_voltage() + + return { + "dac_start": dac_start, + "ldo_voltage": round(ldo, 2), + } + except Exception as e: + return {"status": "error", "message": str(e)} + finally: + self._close_hardware() + + async def get_saved_settings(self) -> Dict[str, Any]: + """Read the saved kDAC_setting from config.""" + value = self.config_manager.get_config(self.DAC_CONFIG_KEY) + return {"dac_setting": value} diff --git a/Software/web-server/templates/calibration.html b/Software/web-server/templates/calibration.html index 71c9a37c..49b891ba 100644 --- a/Software/web-server/templates/calibration.html +++ b/Software/web-server/templates/calibration.html @@ -285,6 +285,88 @@