From c8e66e430b22c444183d92b56859a40a60993df4 Mon Sep 17 00:00:00 2001 From: Pete Attayek Date: Thu, 19 Mar 2026 07:21:58 -0400 Subject: [PATCH 01/28] Add clibrate_strobe_output.py script to web-server folder --- .../web-server/calibrate_strobe_output.py | 651 ++++++++++++++++++ 1 file changed, 651 insertions(+) create mode 100644 Software/web-server/calibrate_strobe_output.py diff --git a/Software/web-server/calibrate_strobe_output.py b/Software/web-server/calibrate_strobe_output.py new file mode 100644 index 00000000..1d14bcd7 --- /dev/null +++ b/Software/web-server/calibrate_strobe_output.py @@ -0,0 +1,651 @@ +#!/usr/bin/env python3 +""" +SPDX-License-Identifier: GPL-2.0-only */ +Copyright (C) 2022-2025, Verdant Consultants, LLC. + +PiTrac Controller Board Strobe Output Calibration Module + +Adjusts the strobe output to a default (or selected) current output by sending a range of values to the digital +potentiometer on the board (via SPI) and then repeatedly checking the ADC to see if the desired output has been +reached (as the LED current iteratively goes down as up DAC output goes down). + +WARNING ------ This code has not been tested against actual hardware yet, so it could still be harmful + to whatever hardware you are running on. Caveat emptor! + Please run this step by step in a debugger to make sure that it is sending appropriate + data to the Controller Board. + +""" +import spidev +# Will need to run +# sudo apt install python3-rpi.gpio +# sudo apt install python3-lgpio +from gpiozero import LED +import time +import sys +import argparse +import os +import gc + + +import logging + +# We expect to be running this utility in the standard directory where PiTrac is installed, and where there +# should be the config.manager.py and configurations.json files. If running somewhere else, the user will need to either put +# a copy of those files there, or else set the PiTrac home directory appropriately. +# That directory is typically at: /usr/lib/pitrac/web-server +DEFAULT_PITRAC_PATH = "/usr/lib/pitrac/web-server" +sys.path.append(DEFAULT_PITRAC_PATH) + +from config_manager import ConfigurationManager + + +logger = logging.getLogger(__name__) + + +class StrobeOutputCalibrator: + + # The final DAC setting and corresponding ADC output will be saved to the user_settings.json file, + # so we can read them later if needed. These are the search keys for those values in the JSON file. + DAC_SETTING_JSON_PATH = "gs_config.strobing.kDAC_setting" + + # These are the more-or-less standard SPI bus and device numbers for the Raspberry Pi. + SPI_BUS = 0 + SPI_DAC_DEVICE = 0 # DAC is on CS0 + SPI_ADC_DEVICE = 1 # ADC is on CS1 + + # Max speed for the ADC is 1.1 MHz and DAC is 20 MHz + SPI_MAX_SPEED_HZ = 1000000 # 1 MHz + + # This is the pin that we will use to toggle the strobe output on and off through the DIAG pin + # on the Connector Board. The strobe needs to be on for the ADC to read the LED current, + # so we will toggle this pin on just before reading the ADC, and then toggle it off just after. + # Note - this corresponds to the BCM pin number, not the physical pin number. + # So this is physical pin 38 on the Raspberry Pi header. + DIAG_GPIO_PIN = 20 + + # This is the maximum safe strobe current for the V3 LED + DEFAULT_TARGET_LED_CURRENT_SETTING = 7.0 # amps + + + # We should NEVER go below this LDO voltage + ABSOLUTE_LOWEST_LDO_VOLTAGE = 4.5 # volts + ABSOLUTE_HIGHEST_LDO_VOLTAGE = 11 # volts + + MAX_DAC_SETTING = 0xFF # 255 # It's an 8-bit DAC, so max value is 2^8 - 1 + MIN_DAC_SETTING = 0 # Note - + + # This value is just a guess for now and works on 1 board. It should be high enough so that the LDO voltage in all devices + # (even accounting for variations) will be low enough not to hurt our standard strobe, but high enough to be above the minimum LDO voltage. + # This settins will only be used to set the DAC to a known, safe level if there's some failure + # in the calibration process. + PRESUMED_SAFE_DAC_SETTING = 0x96 # 150 + + # For the MCP4801 commands, see: https://ww1.microchip.com/downloads/en/DeviceDoc/22244B.pdf + MCP4801_BASE_WRITE_CMD_SET_OUTPUT = 0b00110000 # Standard (1x) gain (bit 13) (Vout=Vref*D/4096), VREF buffered, active, no shutdown, D4-D7 are 0 + + # For the MCP3202 commands, see: https://www.google.com/aclk?sa=L&ai=DChsSEwj3ncmJqIKTAxWQK60GHXKrGJQYACICCAEQABoCcHY&ae=2&co=1&ase=2&gclid=CjwKCAiAh5XNBhAAEiwA_Bu8Fae9UWeNq0dHjoQ9N4wHFrRnEvETyzrYVl5xmGvUyNR0uFNVP5IlQRoCaAcQAvD_BwE&cid=CAASWeRo2UUWEQuONJENmHLbquopI4y29-vAFb0frx7hQM6bjuRo_TZk1PIihnFuJO8jpNwTGiEFOnOQWgmJHHFTivCGeaaKvgUk8x67yLoRpN_bIoGgiweiu6Vk&cce=2&category=acrcp_v1_71&sig=AOD64_3LJYqvTzD3ozkW0pkVDNgovi6vvA&q&nis=4&adurl&ved=2ahUKEwi1w8KJqIKTAxXKFDQIHVsXEX8Q0Qx6BAgPEAE + MCP3202_READ_CH0_SINGLE_ENDED_CMD = 0 | 0x80 # Channel 0, single-ended - LED Current Sense Resistor Voltage + MCP3202_READ_CH1_SINGLE_ENDED_CMD = 0 | 0xc0 # Channel 1, single-ended - LDO Gate Voltage + + # Will be set when the class is initialized + spi_dac = None + spi_adc = None + + diag_pin = None + + def __init__(self): + + self.config_manager = ConfigurationManager() + + + def setup_spi_channels(self): + logger.debug(f"Setting up SPI channels...") + + success = False + + try: + # DAC Setup + self.spi_dac = spidev.SpiDev() + self.spi_dac.open(self.SPI_BUS, self.SPI_DAC_DEVICE) + + # Set SPI speed + self.spi_dac.max_speed_hz = self.SPI_MAX_SPEED_HZ + + # Set SPI mode common modes are 0 or 3) + self.spi_dac.mode = 0b00 # Mode 0 + + + # ADC Setup + self.spi_adc = spidev.SpiDev() + self.spi_adc.open(self.SPI_BUS, self.SPI_ADC_DEVICE) + + # Set SPI speed + self.spi_adc.max_speed_hz = self.SPI_MAX_SPEED_HZ + + # Set SPI mode common modes are 0 or 3) + self.spi_adc.mode = 0b00 # Mode 0 + + + # Signal that all went well with the SPI setup + success = True + + except Exception as e: + logger.error(f"An error occurred when setting up the SPI connections: {e}") + success = False + + return success + + + def close_spi(self): + if self.spi_dac is not None: + self.spi_dac.close() # Always close the SPI connection when done + if self.spi_adc is not None: + self.spi_adc.close() + + def open_gpio_system(self): + logger.debug(f"Setting up GPIO pin {self.DIAG_GPIO_PIN}...") + + success = False + + try: + self.diag_pin = LED(self.DIAG_GPIO_PIN) # Use Broadcom pin-numbering scheme + + # Signal that all went well with the SPI setup + success = True + + except Exception as e: + logger.error(f"An error occurred when setting up the GPIO system: {e}") + success = False + + return success + + def close_gpio_system(self): + # Cleanup all GPIO pins to their default state + if self.diag_pin is not None: + self.diag_pin.close() + + def get_ADC_value_CH0(self): + # Start bit is always the first byte, then the channel and mode bits are combined into the second byte, + # and the third byte is just a timing placeholder for the response, because we need the last 2 of 3 bytes of response, + # but our command is only 2 bytes + message_to_send = [0x01, self.MCP3202_READ_CH0_SINGLE_ENDED_CMD, 0x00] + logger.debug(f"Message to send to ADC (to get value): {[format(b, '02x') for b in message_to_send]}") + + response_bytes = self.spi_adc.xfer2(message_to_send) + + # The result is 12-bits. The first byte returned is just random - the MISO line is null + # when the command is sent, so nothing was really sent. + # The second byte contains the top 4 bits (masked with 0x0F as some bits may be null) + # The third byte contains the least-significant 8 bits + + # Put the top 4 bits and lower 8 bits together to get the full 12-bit ADC value + adc_value = (response_bytes[1] & 0x0F) << 8 | response_bytes[2] + + return adc_value + + def get_ADC_value_CH1(self): + # Start bit is always the first byte, then the channel and mode bits are combined into the second byte, + # and the third byte is just a timing placeholder for the response, because we need the last 2 of 3 bytes of response, + # but our command is only 2 bytes + message_to_send = [0x01, self.MCP3202_READ_CH1_SINGLE_ENDED_CMD, 0x00] + logger.debug(f"Message to send to ADC (to get value): {[format(b, '02x') for b in message_to_send]}") + + response_bytes = self.spi_adc.xfer2(message_to_send) + + # The result is 12-bits. The first byte returned is just random - the MISO line is null + # when the command is sent, so nothing was really sent. + # The second byte contains the top 4 bits (masked with 0x0F as some bits may be null) + # The third byte contains the least-significant 8 bits + + # Put the top 4 bits and lower 8 bits together to get the full 12-bit ADC value + adc_value = (response_bytes[1] & 0x0F) << 8 | response_bytes[2] + + return adc_value + + + def get_LDO_voltage(self): + # We need to measure LDO voltage to make sure we are safe to raise the DIAG pin to high + adc_value = self.get_ADC_value_CH1() + + # *3 because of the resistor divider made up of 2k top and 1k bottom, so (1 / (2 + 1)) scaling factor + LDO_voltage = (3.3 / 4096) * adc_value * 3 # Convert ADC value to voltage + return LDO_voltage + + def get_LED_current(self): + # We need to turn on the strobe output through the DIAG pin before we read the ADC, + # because a valid LED current sense voltage is only present when the strobe is on. + # and then turn it right back off again. + message_to_send = [0x01, self.MCP3202_READ_CH0_SINGLE_ENDED_CMD, 0x00] + logger.debug(f"Message to send to ADC (to get value): {[format(b, '02x') for b in message_to_send]}") + + spi = self.spi_adc + diag = self.diag_pin + + # --- PREPARE FOR CRITICAL TIMING --- + + # Disable Python's random memory cleaning + gc.disable() + + # Grab the highest possible Real-Time OS Priority + try: + param = os.sched_param(os.sched_get_priority_max(os.SCHED_FIFO)) + os.sched_setscheduler(0, os.SCHED_FIFO, param) + except (PermissionError, AttributeError): + logger.debug(f"WARNING: sudo permissions not established or OS scheduling priority not supported.") + pass # Fails if not running as root/sudo or on Windows + + # Yield CPU to get a fresh, full time-slice from Linux + time.sleep(0) + + + try: + # --- BEGIN DETERMINISTIC HARDWARE BLOCK --- + diag.on() + response_bytes = spi.xfer2(message_to_send) + diag.off() + # --- END DETERMINISTIC HARDWARE BLOCK --- + + finally: + # --- RETURN TO NORMAL OS BEHAVIOR --- + + # Give up real-time priority (return to normal scheduler) + try: + param = os.sched_param(0) + os.sched_setscheduler(0, os.SCHED_OTHER, param) + except (PermissionError, AttributeError): + pass + + # Turn memory management back on + gc.enable() + + adc_value = (response_bytes[1] & 0x0F) << 8 | response_bytes[2] + LED_current = (3.3 / 4096) * adc_value * 10 # Convert ADC value to current + return LED_current + + def turn_diag_pin_off(self): + logger.debug(f"turn_diag_pin_off") + if self.diag_pin is not None: + self.diag_pin.off() + + + def short_pause(self): + time.sleep(0.1) + + + def set_DAC(self, value): + msb_data = self.MCP4801_BASE_WRITE_CMD_SET_OUTPUT | ((value >> 4) & 0x0F) # Get the top 4 bits of the value and combine with the command + + lsb_data = (value << 4) & 0xF0 # Get the bottom 4 bits of the value into the top 4 bits of the second byte (the bottom 4 bits of the second byte + # are ignored by the DAC, so it doesn't matter what we put there) + + message_to_send = [msb_data, lsb_data] # Get the pot value + + logger.debug(f"\nset_DAC: Message to send to DAC: {[format(b, '02x') for b in message_to_send]}") + # We don't use the response + response = self.spi_dac.xfer2(message_to_send) + + + def get_calibration_settings_from_json(self): + + current_DAC_output_value = -1 + current_DAC_setting = self.config_manager.get_config(self.DAC_SETTING_JSON_PATH) + if current_DAC_setting is None: + logger.debug(f"Current DAC Setting: ") + else: + logger.debug(f"Current DAC Setting: {current_DAC_setting}") + current_DAC_output_value = int(current_DAC_setting) + + return current_DAC_output_value + + + def set_DAC_to_safest_level(self): + logger.error(f"Calibration failed. Setting DAC voltage to highest level of {self.PRESUMED_SAFE_DAC_SETTING} (presumed-safe strobe level) for safety.") + self.set_DAC(self.PRESUMED_SAFE_DAC_SETTING) + + + def json_file_has_calibration_settings(self): + logger.debug(f"Checking whether json_file_has_calibration_settings") + current_DAC_setting = self.config_manager.get_config(self.DAC_SETTING_JSON_PATH) + if current_DAC_setting is None: + return False + else: + return True + + def find_DAC_start_setting(self): + DAC_max_setting = self.MAX_DAC_SETTING + 1 + for i in range(DAC_max_setting): + DAC_start_setting = i + # set DAC value + self.set_DAC(i) + # wait for DAC value to take effect + self.short_pause() + # check the LDO voltage + LDO_voltage = self.get_LDO_voltage() + logger.debug(f"DAC Value: {format(i, '02x')}, Computed LDO voltage (from ADC): {format(LDO_voltage, '0.2f')}" ) + # if LDO voltage drops below ABSOLUTE_LOWEST_LDO_VOLTAGE then break the loop + if LDO_voltage < self.ABSOLUTE_LOWEST_LDO_VOLTAGE: + # set starting DAC value to lowest voltage above the absolute minimum + DAC_start_setting = i - 1 + break + + return DAC_start_setting, LDO_voltage + + + def calibrate_board(self, target_LED_current): + + # find the minimum safe LDO voltage to supply the MCP1407 gate driver + DAC_start_setting, LDO_voltage = self.find_DAC_start_setting() + + # If even a DAC value of 0 was below the ABSOLUTE_LOWEST_LDO_VOLTAGE then fail calibration + if DAC_start_setting < 0: + logger.debug(f"DAC value of 0 is below minimum LDO voltage ({format(self.ABSOLUTE_LOWEST_LDO_VOLTAGE, '0.2f')}): {format(LDO_voltage, '0.2f')}") + return False, -1, -1 + + + logger.debug(f"calibrate_board called with target_LED_current = {target_LED_current}, DAC_start_setting = 0x{format(DAC_start_setting, '02x')}") + + # Now, starting at the max DAC value (0xFF) + # we will iteratively decrease the DAC setting until we get to + # the desired ADC output (or just under it) + + current_DAC_setting = DAC_start_setting + final_DAC_setting = self.MIN_DAC_SETTING + + # just picking a number that we should always be above at the start of the loop, + # so that we can save the first reading as the best one so far even if it's not + # above the target + max_LED_current_so_far = 0.0 + + # We will start at the max DAC setting and then count down while + # looking for the point where the corresponding LED current goes just above the target_LED_current, + # then increase 1 value to ensure we are <= target_LED_current. + + logger.debug(f"calibrate_board starting loop. Desired output is {target_LED_current}") + + # Stop immediately if we ever have an error + while (current_DAC_setting >= self.MIN_DAC_SETTING): + + self.set_DAC(current_DAC_setting) + + # Wait a moment for the setting to take effect + self.short_pause() + + # check the LDO voltage to ensure that we are within the safe bounds + LDO_voltage = self.get_LDO_voltage() + # if we are below the ABSOLUTE_LOWEST_LDO_VOLTAGE, it is unsafe to pulse the DIAG pin. Decrease DAC value and continue + if LDO_voltage < self.ABSOLUTE_LOWEST_LDO_VOLTAGE: + logger.debug(f"Measured LDO_voltage ({LDO_voltage}) was below ABSOLUTE_LOWEST_LDO_VOLTAGE of {self.ABSOLUTE_LOWEST_LDO_VOLTAGE} volts. Trying next DAC value.") + # Continue counting down + final_DAC_setting = current_DAC_setting + current_DAC_setting -= 1 + continue + + # if we are above the ABSOLUTE_HIGHEST_LDO_VOLTAGE, then we have to stop and fail the calibration + if LDO_voltage > self.ABSOLUTE_HIGHEST_LDO_VOLTAGE: + logger.debug(f"Measured LDO_voltage ({LDO_voltage}) was above ABSOLUTE_HIGHEST_LDO_VOLTAGE of {self.ABSOLUTE_HIGHEST_LDO_VOLTAGE} volts. Stopping calibration, as something is wrong.") + return False, -1, -1 + + # Note reading the LED current also pulses the strobe through the DIAG pin, + # which is necessary to get a valid reading, but also means that we are toggling + # the strobe on and off repeatedly during this calibration process, which is not ideal. + # But we need to do it in order to get accurate LED current readings. + LED_current = self.get_LED_current() + logger.debug(f"current_DAC_setting: {format(current_DAC_setting, '02x')}, LED_current: {LED_current}") + + # As we are slowly increasing the LED current, have we reached our desired set-point for the LED current yet? + if LED_current > target_LED_current: + logger.debug(f" ---> Reached above the target_LED_current ({target_LED_current}). LED_current is: {LED_current}. Stopping calibration here...") + final_DAC_setting = current_DAC_setting + 1 # Step back to the last setting that was just before we reached our target + break + + # We have not yet reached the target. TBD - This is a little redundant, maybe change + # Keep track of where we were. + if LED_current > max_LED_current_so_far: + + # Save the current output as the best one so far, even if it's not over the target, + # because we want to get as close as possible without going over + max_LED_current_so_far = LED_current + + # Continue counting down + final_DAC_setting = current_DAC_setting + current_DAC_setting -= 1 + + # There are a couple of possible edge cases here. And either of them indicate that something probably went wrong somewhere even if we + # thought we had a success. + # If so, err on the safe side and consider this a failure + if current_DAC_setting <= self.MIN_DAC_SETTING: + logger.debug(f"Reached MIN_DAC_SETTING ({self.MIN_DAC_SETTING}) without ever reaching target_LED_current ({target_LED_current}). This generally indicates a problem. Failing calibration.") + return False, -1, -1 + if current_DAC_setting >= self.MAX_DAC_SETTING: + logger.debug(f"The MAX_DAC_SETTING resulted in an LED current above the target. This generally indicates a problem. Failing calibration.") + return False, -1, -1 + + # Now, using the best DAC setting we found, average the output voltage a few times to + # get a more accurate reading of the output voltage at that setting + # take an average of n pulses + n = 10 + while True: + self.set_DAC(final_DAC_setting) + self.short_pause() + + # check if LDO voltage is above the minimum + LDO_voltage = self.get_LDO_voltage() + if LDO_voltage < self.ABSOLUTE_LOWEST_LDO_VOLTAGE: + # Fallback to the last known good measurement + final_DAC_setting -= 1 + break + + # Take an average of n pulses + LED_current_sum = 0 + for _ in range(n): + LED_current_sum += self.get_LED_current() + self.short_pause() + LED_current = LED_current_sum / n + + if LED_current > target_LED_current: + # Current is still slightly too high, step the DAC setting + final_DAC_setting += 1 + else: + # We are at or below the target current, we're done + break + + logger.debug(f"calibrate_board -- final_DAC_setting: {format(final_DAC_setting, '02x')}, LED_current: {LED_current}") + + return True, final_DAC_setting, LED_current + + + def cleanup_for_exit(self): + self.turn_diag_pin_off() + self.short_pause() + self.close_spi() + self.close_gpio_system() + + # ----------------------- + + +def main(): + + success = True + + # The calibrator class does all of the work here + calibrator = StrobeOutputCalibrator() + + parser = argparse.ArgumentParser(description="PiTrac Controller Board Strobe Output Calibrator. This tool iteratively adjusts the board's DAC in order to find the right setting for the desired LED current for the strobe LED circuit.\nWARNING - Setting the LDO voltage below 4.5v can break your Control Board.") + parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") + parser.add_argument("-q", "--quiet", action="store_true", help="Quiet output") + parser.add_argument("-w", "--overwrite", action="store_true", help="Overwrites any existing strobe setting in user_settings.json") + parser.add_argument("--target_output", default=0,type=float, help="Set target LED current output (in volts) (ADVANCED)") + parser.add_argument("--ignore", action="store_true", help="Attempt calibration even if the Controller Board version is not 3.0 (ADVANCED)") + action_group = parser.add_mutually_exclusive_group() + action_group.add_argument("-p", "--print_settings", action="store_true", help="Print the current DAC setting and last ADC measurement from the user_settings.json file") + action_group.add_argument("-a0", "--read_ADC_CH0", action="store_true", help="Measure and print the current (12-bit) ADC CH0 measurement from the Connector Board") + action_group.add_argument("-a1", "--read_ADC_CH1", action="store_true", help="Measure and print the current (12-bit) ADC CH1 measurement from the Connector Board)") + action_group.add_argument("-c", "--read_LED_current", action="store_true", help="Compute and print the current LED current (based on the ADC CH0 measurement) from the Connector Board") + action_group.add_argument("-l", "--read_LDO_voltage", action="store_true", help="Compute and print the current LDO value (based on the ADC CH1 measurement) from the Connector Board") + action_group.add_argument("--DAC_setting", default=None,type=int, help="Set the DAC input to a specific value. Value is 8 bits long (ADVANCED).") + action_group.add_argument("--get_DAC_start", action="store_true", help="Check LDO voltage while sweeping DAC values to find lowest safe value") + + args = parser.parse_args() + + if args.verbose: + logging.basicConfig(level=logging.DEBUG) + elif args.quiet: + logging.basicConfig(level=logging.WARNING) + else: + logging.basicConfig(level=logging.INFO) + + + logger.debug(f"Calibrator initialized") + + if calibrator.setup_spi_channels() == False: + logger.error(f"SPI initialization failed. Cannot proceed with calibration.") + return 1 + + if calibrator.open_gpio_system() == False: + logger.error(f"GPIO initialization failed. Cannot proceed with calibration.") + calibrator.close_gpio_system() + return 1 + + try: + + # Process other options + + if args.read_ADC_CH0: + ADC_response = calibrator.get_ADC_value_CH0() + logger.info(f"Value read from ADC: {format(ADC_response, '02x')}" ) + + elif args.read_ADC_CH1: + ADC_response = calibrator.get_ADC_value_CH1() + logger.info(f"Value read from ADC: {format(ADC_response, '02x')}" ) + + elif args.read_LDO_voltage: + LDO_Voltage = calibrator.get_LDO_voltage() + logger.info(f"Computed LDO voltage (from ADC): {format(LDO_Voltage, '0.2f')}" ) + + elif args.read_LED_current: + + # ensure that the LDO voltage is within safe bounds before pulsing the DIAG pin + LDO_Voltage = calibrator.get_LDO_voltage() + if LDO_Voltage > calibrator.ABSOLUTE_LOWEST_LDO_VOLTAGE: + LED_current = calibrator.get_LED_current() + logger.info(f"Computed LED current (from ADC): {format(LED_current, '0.2f')}" ) + else: + logger.warning(f"LDO voltage is below minimum value, cannot safely pulse DIAG pin to get LED current. LDO Voltage: {format(LDO_Voltage, '0.2f')}") + return 1 + + elif args.DAC_setting is not None: + desired_DAC_setting = args.DAC_setting + + # Check if desired DAC setting is within the allowable bounds of MIN_DAC_SETTING and MAX_DAC_SETTING + if desired_DAC_setting > calibrator.MAX_DAC_SETTING: + logger.warning(f"Maximum allowable DAC setting is: {format(calibrator.MAX_DAC_SETTING, '02x')}" ) + return 1 + if desired_DAC_setting < calibrator.MIN_DAC_SETTING: + logger.warning(f"Minimum allowable DAC setting is: {format(calibrator.MIN_DAC_SETTING, '02x')}" ) + return 1 + + # Set the DAC value + calibrator.set_DAC(desired_DAC_setting) + # Wait a moment for the setting to take effect + calibrator.short_pause() + + # check the LDO voltage + LDO_voltage = calibrator.get_LDO_voltage() + logger.warning(f"DAC is set to: {format(desired_DAC_setting, '02x')}" ) + if LDO_voltage < calibrator.ABSOLUTE_LOWEST_LDO_VOLTAGE: + logger.warning(f"LDO voltage is below minimum value. This is VERY DANGEROUS. If this is unintentional, run --get_DAC_start to find the minimum safe DAC value.") + + elif args.print_settings: + DAC_value = calibrator.get_calibration_settings_from_json() + + if DAC_value < 0: + logger.debug(f"Current DAC Setting: ") + else: + logger.info(f"DAC value from user settings: {format(DAC_value, '02x')}" ) + + elif args.get_DAC_start: + # sweep DAC values from low to high + DAC_start_setting, LDO_voltage = calibrator.find_DAC_start_setting() + + # If even a DAC value of 0 was below the ABSOLUTE_LOWEST_LDO_VOLTAGE then fail calibration + if DAC_start_setting < 0: + logger.warning(f"DAC value of 0 is below minimum LDO voltage ({format(calibrator.ABSOLUTE_LOWEST_LDO_VOLTAGE, '0.2f')}): {format(LDO_voltage, '0.2f')}\nThis indicates a problem with the controller board") + else: + + calibrator.set_DAC(DAC_start_setting) + + # Wait a moment for the setting to take effect + calibrator.short_pause() + + # check the LDO voltage + LDO_voltage = calibrator.get_LDO_voltage() + + logger.info(f"DAC_start_setting = 0x{format(DAC_start_setting, '02x')}. LDO_voltage = {format(LDO_voltage, '0.2f')}") + + else: + # Default calibration behavior - iteratively find the closest setting for the DAC that will get the desired ADC reading (but not under) + logger.info(f"Calibrating PiTrac Control Board. This may take a minute or two. Please wait..." ) + + if calibrator.json_file_has_calibration_settings() and not args.overwrite: + logger.error(f"Calibration settings already exist in user_settings.json. Use the --overwrite flag to overwrite them.") + return 1 + + control_board_version = calibrator.config_manager.get_config("gs_config.strobing.kConnectionBoardVersion") + logger.debug(f"control_board_version = {control_board_version}") + if control_board_version is None: + control_board_version = "0" + + control_board_version_value = int(control_board_version) + + # This calibration function is only relevant for the Version 3.x Control Board + if control_board_version_value != 3 and not args.ignore: + logger.error(f"The controller board is the wrong version ({control_board_version_value}) for this calibration utility. Must be using a Verison 3 board.") + return 1 + + target_LED_current = calibrator.DEFAULT_TARGET_LED_CURRENT_SETTING + + if (args.target_output > 0.0): + target_LED_current = args.target_output + + logger.debug(f"target_LED_current = {target_LED_current}") + + + # Perform the actual calibration here + success, final_DAC_setting, LED_current = calibrator.calibrate_board(target_LED_current) + + + if success and final_DAC_setting > 0: + logger.info(f"Calibration successful. Final DAC setting: {format(final_DAC_setting, '02x')}, corresponding LED current: {format(LED_current, '0.2f')}") + # Save the final DAC setting and corresponding ADC output to the user_settings.json file for later reference + calibrator.config_manager.set_config(calibrator.DAC_SETTING_JSON_PATH, final_DAC_setting) + else: + logger.info(f"Calibration failed.") + calibrator.set_DAC_to_safest_level() + + + calibrator.short_pause() + + logger.debug(f"Calibration operation completed." ) + + except KeyboardInterrupt: + print("\nCtrl+C pressed. Performing cleanup...") + # Add your cleanup code here (e.g., closing files, releasing resources) + calibrator.cleanup_for_exit() + + except Exception as e: + logger.debug(f"An error occurred: {e}") + calibrator.set_DAC_to_safest_level() + + return 1 # Failure + + finally: + calibrator.cleanup_for_exit() + + if success: + return 0 + else: + return 1 + + +if __name__ == "__main__": + sys.exit(main()) From 67b5cf07b9d4186f9d600ddc31a54728c225f5e5 Mon Sep 17 00:00:00 2001 From: Pete Attayek Date: Thu, 19 Mar 2026 11:56:06 -0400 Subject: [PATCH 02/28] Set current limits for both V3 LED and Old 100W LED --- Software/web-server/calibrate_strobe_output.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Software/web-server/calibrate_strobe_output.py b/Software/web-server/calibrate_strobe_output.py index 1d14bcd7..8b1d1666 100644 --- a/Software/web-server/calibrate_strobe_output.py +++ b/Software/web-server/calibrate_strobe_output.py @@ -64,7 +64,9 @@ class StrobeOutputCalibrator: DIAG_GPIO_PIN = 20 # This is the maximum safe strobe current for the V3 LED - DEFAULT_TARGET_LED_CURRENT_SETTING = 7.0 # amps + V3_TARGET_LED_CURRENT_SETTING = 10.0 # amps + # This is the maximum safe strobe current for the old 100W LED + OLD_TARGET_LED_CURRENT_SETTING = 7.0 # amps # We should NEVER go below this LDO voltage @@ -471,6 +473,7 @@ def main(): calibrator = StrobeOutputCalibrator() parser = argparse.ArgumentParser(description="PiTrac Controller Board Strobe Output Calibrator. This tool iteratively adjusts the board's DAC in order to find the right setting for the desired LED current for the strobe LED circuit.\nWARNING - Setting the LDO voltage below 4.5v can break your Control Board.") + parser.add_argument("-o", "--old_LED", action="store_true", help="PiTrac is using old 100W LED. Default behavior is V3 LED") parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") parser.add_argument("-q", "--quiet", action="store_true", help="Quiet output") parser.add_argument("-w", "--overwrite", action="store_true", help="Overwrites any existing strobe setting in user_settings.json") @@ -602,10 +605,13 @@ def main(): logger.error(f"The controller board is the wrong version ({control_board_version_value}) for this calibration utility. Must be using a Verison 3 board.") return 1 - target_LED_current = calibrator.DEFAULT_TARGET_LED_CURRENT_SETTING - if (args.target_output > 0.0): target_LED_current = args.target_output + elif (args.old_LED): + target_LED_current = calibrator.OLD_TARGET_LED_CURRENT_SETTING + else: + target_LED_current = calibrator.V3_TARGET_LED_CURRENT_SETTING + logger.debug(f"target_LED_current = {target_LED_current}") From 5ebf706bcdd2c65a6db521cff695a4d55b4520c0 Mon Sep 17 00:00:00 2001 From: Pete Attayek Date: Thu, 19 Mar 2026 15:03:16 -0400 Subject: [PATCH 03/28] Update PCB Assembly documentation with connection guide for V3 --- .../PiTrac_V3_Connection_Guide.svg | 1 + docs/hardware/pcb-assembly.md | 27 ++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 docs/assets/images/enclosure_assembly/PiTrac_V3_Connection_Guide.svg diff --git a/docs/assets/images/enclosure_assembly/PiTrac_V3_Connection_Guide.svg b/docs/assets/images/enclosure_assembly/PiTrac_V3_Connection_Guide.svg new file mode 100644 index 00000000..458a8234 --- /dev/null +++ b/docs/assets/images/enclosure_assembly/PiTrac_V3_Connection_Guide.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/hardware/pcb-assembly.md b/docs/hardware/pcb-assembly.md index 938f97c5..5f2f5416 100644 --- a/docs/hardware/pcb-assembly.md +++ b/docs/hardware/pcb-assembly.md @@ -217,7 +217,7 @@ After assembly, must run current calibration before you will be able to capture ### Pi GPIO -- **J3:** 8-pin GPIO header for control signals (see assembly guide for pinout) +- **J3:** 8-pin GPIO header for control signals (see below for Connection Guide for Raspberry Pi 5) ### LED Output @@ -228,6 +228,31 @@ After assembly, must run current calibration before you will be able to capture - **J6 (USB-A):** Originally for LED strip, but Pi5 has USB 2.0 ports already - this is redundant +## Connection Guide for Raspberry Pi 5 + +| V3 Connector Board | Raspberry Pi 5 | +|----------|----------| +| GND | GND (Pin 39) | +| DIAG | GPIO 20 (Pin 38) | +| CS0 | GPIO 8 (Pin 24) | +| MOSI | GPIO 10 (Pin 19) | +| MISO | GPIO 9 (Pin 21) | +| CLK | GPIO 11 (Pin 23) | +| CS1 | GPIO 7 (Pin 26) | +| V3P3 | 3V3 (Pin 1) | + +| Global Shutter Camera 2 | Raspberry Pi 5 | +|----------|----------| +| Trig+ | GPIO 25 (Pin 22) | +| Trig- | GND (Pin 20) | + +| V3 Connector Board | V3 IRLED Board | +|----------|----------| +| VIR+ | VIR+ | +| VIR- | VIR- | + + ![PiTrac V3 Connection Guide]({{ '/assets/images/hardware/PiTrac_V3_Connection_Guide.svg' | relative_url }}) + ## Configuring PiTrac You'll need to tell PiTrac which version of the Connection Board you are using. This is done in the Configuration screen in the UI. From f31c82b841b3bbd8ad94f6a5730a65968d6d94ea Mon Sep 17 00:00:00 2001 From: Pete Attayek Date: Thu, 19 Mar 2026 15:13:20 -0400 Subject: [PATCH 04/28] Update assembly instructions to only say 1 rpi instead of 1-2 --- docs/hardware/parts-list.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/hardware/parts-list.md b/docs/hardware/parts-list.md index b04f2810..cb2bfcf6 100644 --- a/docs/hardware/parts-list.md +++ b/docs/hardware/parts-list.md @@ -18,10 +18,10 @@ This document provides a comprehensive list of all components needed to build a | Quantity | Hardware | Purpose | Link | |----------|----------|---------|------| -| 1-2 | Raspberry Pi 5 (8 GB recommended) | Main embedded computer | https://vilros.com/products/raspberry-pi-5?variant=40065551302750 -| 1-2 | Active Cooler Kit | Required to keep temps low and timing consistent | https://a.co/d/dsl7saU -| 1-2 | MicroSD card (64 GB recommended) | For RPi5 filesystem | https://www.amazon.com/Amazon-Basics-microSDXC-Memory-Adapter/dp/B08TJTB8XS -| 1-2 | 1ft USB-C to USB-C Cable | For powering RPi5 from connector board | https://www.amazon.com/Anker-Charging-MacBook-Samsung-Nintendo/dp/B09H2DMR4K +| 1 | Raspberry Pi 5 (8 GB recommended) | Main embedded computer | https://vilros.com/products/raspberry-pi-5?variant=40065551302750 +| 1 | Active Cooler Kit | Required to keep temps low and timing consistent | https://a.co/d/dsl7saU +| 1 | MicroSD card (64 GB recommended) | For RPi5 filesystem | https://www.amazon.com/Amazon-Basics-microSDXC-Memory-Adapter/dp/B08TJTB8XS +| 1 | 1ft USB-C to USB-C Cable | For powering RPi5 from connector board | https://www.amazon.com/Anker-Charging-MacBook-Samsung-Nintendo/dp/B09H2DMR4K ## Camera and Lighting Hardware From c6071519a9b09f677b13513fb5d24d4b07f462b4 Mon Sep 17 00:00:00 2001 From: Pete Attayek Date: Fri, 20 Mar 2026 08:55:27 -0400 Subject: [PATCH 05/28] Change ADC and DAC to use SPI1 instead of SPI0. Change DIAG pin to SPI0 MOSI pin (GPIO 10) --- Software/web-server/calibrate_strobe_output.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/Software/web-server/calibrate_strobe_output.py b/Software/web-server/calibrate_strobe_output.py index 8b1d1666..3ce2803c 100644 --- a/Software/web-server/calibrate_strobe_output.py +++ b/Software/web-server/calibrate_strobe_output.py @@ -16,9 +16,6 @@ """ import spidev -# Will need to run -# sudo apt install python3-rpi.gpio -# sudo apt install python3-lgpio from gpiozero import LED import time import sys @@ -48,8 +45,8 @@ class StrobeOutputCalibrator: # so we can read them later if needed. These are the search keys for those values in the JSON file. DAC_SETTING_JSON_PATH = "gs_config.strobing.kDAC_setting" - # These are the more-or-less standard SPI bus and device numbers for the Raspberry Pi. - SPI_BUS = 0 + # SPI bus 1 (SPI1/auxiliary SPI) and device numbers for the Raspberry Pi. + SPI_BUS = 1 SPI_DAC_DEVICE = 0 # DAC is on CS0 SPI_ADC_DEVICE = 1 # ADC is on CS1 @@ -59,9 +56,9 @@ class StrobeOutputCalibrator: # This is the pin that we will use to toggle the strobe output on and off through the DIAG pin # on the Connector Board. The strobe needs to be on for the ADC to read the LED current, # so we will toggle this pin on just before reading the ADC, and then toggle it off just after. - # Note - this corresponds to the BCM pin number, not the physical pin number. - # So this is physical pin 38 on the Raspberry Pi header. - DIAG_GPIO_PIN = 20 + # Note - this corresponds to the BCM pin number, not the physical pin number. + # So this is physical pin 19 on the Raspberry Pi header. + DIAG_GPIO_PIN = 10 # This is the maximum safe strobe current for the V3 LED V3_TARGET_LED_CURRENT_SETTING = 10.0 # amps From 25d5f812c864837e4cd1d82b4ed305679605a8f7 Mon Sep 17 00:00:00 2001 From: Pete Attayek Date: Fri, 20 Mar 2026 11:02:55 -0400 Subject: [PATCH 06/28] Cleanup calibrate_strobe_output.py script --- .../web-server/calibrate_strobe_output.py | 263 +++++++++--------- 1 file changed, 126 insertions(+), 137 deletions(-) diff --git a/Software/web-server/calibrate_strobe_output.py b/Software/web-server/calibrate_strobe_output.py index 3ce2803c..d8bbb123 100644 --- a/Software/web-server/calibrate_strobe_output.py +++ b/Software/web-server/calibrate_strobe_output.py @@ -5,9 +5,9 @@ PiTrac Controller Board Strobe Output Calibration Module -Adjusts the strobe output to a default (or selected) current output by sending a range of values to the digital -potentiometer on the board (via SPI) and then repeatedly checking the ADC to see if the desired output has been -reached (as the LED current iteratively goes down as up DAC output goes down). +Adjusts the strobe output to a default (or selected) current output by sending a range of values to the DAC +on the board (via SPI) and then repeatedly checking the ADC to see if the desired output has been +reached (as the LED current iteratively goes down as the DAC output goes down). WARNING ------ This code has not been tested against actual hardware yet, so it could still be harmful to whatever hardware you are running on. Caveat emptor! @@ -71,7 +71,7 @@ class StrobeOutputCalibrator: ABSOLUTE_HIGHEST_LDO_VOLTAGE = 11 # volts MAX_DAC_SETTING = 0xFF # 255 # It's an 8-bit DAC, so max value is 2^8 - 1 - MIN_DAC_SETTING = 0 # Note - + MIN_DAC_SETTING = 0 # This value is just a guess for now and works on 1 board. It should be high enough so that the LDO voltage in all devices # (even accounting for variations) will be low enough not to hurt our standard strobe, but high enough to be above the minimum LDO voltage. @@ -82,7 +82,7 @@ class StrobeOutputCalibrator: # For the MCP4801 commands, see: https://ww1.microchip.com/downloads/en/DeviceDoc/22244B.pdf MCP4801_BASE_WRITE_CMD_SET_OUTPUT = 0b00110000 # Standard (1x) gain (bit 13) (Vout=Vref*D/4096), VREF buffered, active, no shutdown, D4-D7 are 0 - # For the MCP3202 commands, see: https://www.google.com/aclk?sa=L&ai=DChsSEwj3ncmJqIKTAxWQK60GHXKrGJQYACICCAEQABoCcHY&ae=2&co=1&ase=2&gclid=CjwKCAiAh5XNBhAAEiwA_Bu8Fae9UWeNq0dHjoQ9N4wHFrRnEvETyzrYVl5xmGvUyNR0uFNVP5IlQRoCaAcQAvD_BwE&cid=CAASWeRo2UUWEQuONJENmHLbquopI4y29-vAFb0frx7hQM6bjuRo_TZk1PIihnFuJO8jpNwTGiEFOnOQWgmJHHFTivCGeaaKvgUk8x67yLoRpN_bIoGgiweiu6Vk&cce=2&category=acrcp_v1_71&sig=AOD64_3LJYqvTzD3ozkW0pkVDNgovi6vvA&q&nis=4&adurl&ved=2ahUKEwi1w8KJqIKTAxXKFDQIHVsXEX8Q0Qx6BAgPEAE + # For the MCP3202 commands, see: https://ww1.microchip.com/downloads/en/DeviceDoc/21034F.pdf MCP3202_READ_CH0_SINGLE_ENDED_CMD = 0 | 0x80 # Channel 0, single-ended - LED Current Sense Resistor Voltage MCP3202_READ_CH1_SINGLE_ENDED_CMD = 0 | 0xc0 # Channel 1, single-ended - LDO Gate Voltage @@ -110,7 +110,7 @@ def setup_spi_channels(self): # Set SPI speed self.spi_dac.max_speed_hz = self.SPI_MAX_SPEED_HZ - # Set SPI mode common modes are 0 or 3) + # Set SPI mode (common modes are 0 or 3) self.spi_dac.mode = 0b00 # Mode 0 @@ -121,7 +121,7 @@ def setup_spi_channels(self): # Set SPI speed self.spi_adc.max_speed_hz = self.SPI_MAX_SPEED_HZ - # Set SPI mode common modes are 0 or 3) + # Set SPI mode (common modes are 0 or 3) self.spi_adc.mode = 0b00 # Mode 0 @@ -163,43 +163,30 @@ def close_gpio_system(self): if self.diag_pin is not None: self.diag_pin.close() - def get_ADC_value_CH0(self): - # Start bit is always the first byte, then the channel and mode bits are combined into the second byte, + def _get_ADC_value(self, channel_cmd): + # Start bit is always the first byte, then the channel and mode bits are combined into the second byte, # and the third byte is just a timing placeholder for the response, because we need the last 2 of 3 bytes of response, # but our command is only 2 bytes - message_to_send = [0x01, self.MCP3202_READ_CH0_SINGLE_ENDED_CMD, 0x00] + message_to_send = [0x01, channel_cmd, 0x00] logger.debug(f"Message to send to ADC (to get value): {[format(b, '02x') for b in message_to_send]}") response_bytes = self.spi_adc.xfer2(message_to_send) - # The result is 12-bits. The first byte returned is just random - the MISO line is null - # when the command is sent, so nothing was really sent. + # The result is 12-bits. The first byte returned is just random - the MISO line is null + # when the command is sent, so nothing was really sent. # The second byte contains the top 4 bits (masked with 0x0F as some bits may be null) # The third byte contains the least-significant 8 bits - + # Put the top 4 bits and lower 8 bits together to get the full 12-bit ADC value adc_value = (response_bytes[1] & 0x0F) << 8 | response_bytes[2] return adc_value - def get_ADC_value_CH1(self): - # Start bit is always the first byte, then the channel and mode bits are combined into the second byte, - # and the third byte is just a timing placeholder for the response, because we need the last 2 of 3 bytes of response, - # but our command is only 2 bytes - message_to_send = [0x01, self.MCP3202_READ_CH1_SINGLE_ENDED_CMD, 0x00] - logger.debug(f"Message to send to ADC (to get value): {[format(b, '02x') for b in message_to_send]}") - - response_bytes = self.spi_adc.xfer2(message_to_send) - - # The result is 12-bits. The first byte returned is just random - the MISO line is null - # when the command is sent, so nothing was really sent. - # The second byte contains the top 4 bits (masked with 0x0F as some bits may be null) - # The third byte contains the least-significant 8 bits - - # Put the top 4 bits and lower 8 bits together to get the full 12-bit ADC value - adc_value = (response_bytes[1] & 0x0F) << 8 | response_bytes[2] + def get_ADC_value_CH0(self): + return self._get_ADC_value(self.MCP3202_READ_CH0_SINGLE_ENDED_CMD) - return adc_value + def get_ADC_value_CH1(self): + return self._get_ADC_value(self.MCP3202_READ_CH1_SINGLE_ENDED_CMD) def get_LDO_voltage(self): @@ -241,19 +228,21 @@ def get_LED_current(self): # --- BEGIN DETERMINISTIC HARDWARE BLOCK --- diag.on() response_bytes = spi.xfer2(message_to_send) - diag.off() # --- END DETERMINISTIC HARDWARE BLOCK --- finally: # --- RETURN TO NORMAL OS BEHAVIOR --- - + + # Always turn off DIAG first — leaving it HIGH means the strobe stays on + diag.off() + # Give up real-time priority (return to normal scheduler) try: param = os.sched_param(0) os.sched_setscheduler(0, os.SCHED_OTHER, param) except (PermissionError, AttributeError): pass - + # Turn memory management back on gc.enable() @@ -332,125 +321,127 @@ def find_DAC_start_setting(self): def calibrate_board(self, target_LED_current): - # find the minimum safe LDO voltage to supply the MCP1407 gate driver - DAC_start_setting, LDO_voltage = self.find_DAC_start_setting() - - # If even a DAC value of 0 was below the ABSOLUTE_LOWEST_LDO_VOLTAGE then fail calibration - if DAC_start_setting < 0: - logger.debug(f"DAC value of 0 is below minimum LDO voltage ({format(self.ABSOLUTE_LOWEST_LDO_VOLTAGE, '0.2f')}): {format(LDO_voltage, '0.2f')}") - return False, -1, -1 + # find the minimum safe LDO voltage to supply the MCP1407 gate driver + DAC_start_setting, LDO_voltage = self.find_DAC_start_setting() + # If even a DAC value of 0 was below the ABSOLUTE_LOWEST_LDO_VOLTAGE then fail calibration + if DAC_start_setting < 0: + logger.debug(f"DAC value of 0 is below minimum LDO voltage ({format(self.ABSOLUTE_LOWEST_LDO_VOLTAGE, '0.2f')}): {format(LDO_voltage, '0.2f')}") + return False, -1, -1 - logger.debug(f"calibrate_board called with target_LED_current = {target_LED_current}, DAC_start_setting = 0x{format(DAC_start_setting, '02x')}") + logger.debug(f"calibrate_board called with target_LED_current = {target_LED_current}, DAC_start_setting = 0x{format(DAC_start_setting, '02x')}") - # Now, starting at the max DAC value (0xFF) - # we will iteratively decrease the DAC setting until we get to - # the desired ADC output (or just under it) + # Now, starting at the max DAC value (0xFF) + # we will iteratively decrease the DAC setting until we get to + # the desired ADC output (or just under it) - current_DAC_setting = DAC_start_setting - final_DAC_setting = self.MIN_DAC_SETTING + current_DAC_setting = DAC_start_setting + final_DAC_setting = self.MIN_DAC_SETTING - # just picking a number that we should always be above at the start of the loop, - # so that we can save the first reading as the best one so far even if it's not - # above the target - max_LED_current_so_far = 0.0 + # just picking a number that we should always be above at the start of the loop, + # so that we can save the first reading as the best one so far even if it's not + # above the target + max_LED_current_so_far = 0.0 - # We will start at the max DAC setting and then count down while - # looking for the point where the corresponding LED current goes just above the target_LED_current, - # then increase 1 value to ensure we are <= target_LED_current. + # We will start at the max DAC setting and then count down while + # looking for the point where the corresponding LED current goes just above the target_LED_current, + # then increase 1 value to ensure we are <= target_LED_current. - logger.debug(f"calibrate_board starting loop. Desired output is {target_LED_current}") + logger.debug(f"calibrate_board starting loop. Desired output is {target_LED_current}") - # Stop immediately if we ever have an error - while (current_DAC_setting >= self.MIN_DAC_SETTING): - - self.set_DAC(current_DAC_setting) - - # Wait a moment for the setting to take effect - self.short_pause() - - # check the LDO voltage to ensure that we are within the safe bounds - LDO_voltage = self.get_LDO_voltage() - # if we are below the ABSOLUTE_LOWEST_LDO_VOLTAGE, it is unsafe to pulse the DIAG pin. Decrease DAC value and continue - if LDO_voltage < self.ABSOLUTE_LOWEST_LDO_VOLTAGE: - logger.debug(f"Measured LDO_voltage ({LDO_voltage}) was below ABSOLUTE_LOWEST_LDO_VOLTAGE of {self.ABSOLUTE_LOWEST_LDO_VOLTAGE} volts. Trying next DAC value.") - # Continue counting down - final_DAC_setting = current_DAC_setting - current_DAC_setting -= 1 - continue - - # if we are above the ABSOLUTE_HIGHEST_LDO_VOLTAGE, then we have to stop and fail the calibration - if LDO_voltage > self.ABSOLUTE_HIGHEST_LDO_VOLTAGE: - logger.debug(f"Measured LDO_voltage ({LDO_voltage}) was above ABSOLUTE_HIGHEST_LDO_VOLTAGE of {self.ABSOLUTE_HIGHEST_LDO_VOLTAGE} volts. Stopping calibration, as something is wrong.") - return False, -1, -1 - - # Note reading the LED current also pulses the strobe through the DIAG pin, - # which is necessary to get a valid reading, but also means that we are toggling - # the strobe on and off repeatedly during this calibration process, which is not ideal. - # But we need to do it in order to get accurate LED current readings. - LED_current = self.get_LED_current() - logger.debug(f"current_DAC_setting: {format(current_DAC_setting, '02x')}, LED_current: {LED_current}") - - # As we are slowly increasing the LED current, have we reached our desired set-point for the LED current yet? - if LED_current > target_LED_current: - logger.debug(f" ---> Reached above the target_LED_current ({target_LED_current}). LED_current is: {LED_current}. Stopping calibration here...") - final_DAC_setting = current_DAC_setting + 1 # Step back to the last setting that was just before we reached our target - break + # Stop immediately if we ever have an error + while (current_DAC_setting >= self.MIN_DAC_SETTING): - # We have not yet reached the target. TBD - This is a little redundant, maybe change - # Keep track of where we were. - if LED_current > max_LED_current_so_far: + self.set_DAC(current_DAC_setting) - # Save the current output as the best one so far, even if it's not over the target, - # because we want to get as close as possible without going over - max_LED_current_so_far = LED_current + # Wait a moment for the setting to take effect + self.short_pause() + # check the LDO voltage to ensure that we are within the safe bounds + LDO_voltage = self.get_LDO_voltage() + # if we are below the ABSOLUTE_LOWEST_LDO_VOLTAGE, it is unsafe to pulse the DIAG pin. Decrease DAC value and continue + if LDO_voltage < self.ABSOLUTE_LOWEST_LDO_VOLTAGE: + logger.debug(f"Measured LDO_voltage ({LDO_voltage}) was below ABSOLUTE_LOWEST_LDO_VOLTAGE of {self.ABSOLUTE_LOWEST_LDO_VOLTAGE} volts. Trying next DAC value.") # Continue counting down final_DAC_setting = current_DAC_setting current_DAC_setting -= 1 + continue - # There are a couple of possible edge cases here. And either of them indicate that something probably went wrong somewhere even if we - # thought we had a success. - # If so, err on the safe side and consider this a failure - if current_DAC_setting <= self.MIN_DAC_SETTING: - logger.debug(f"Reached MIN_DAC_SETTING ({self.MIN_DAC_SETTING}) without ever reaching target_LED_current ({target_LED_current}). This generally indicates a problem. Failing calibration.") - return False, -1, -1 - if current_DAC_setting >= self.MAX_DAC_SETTING: - logger.debug(f"The MAX_DAC_SETTING resulted in an LED current above the target. This generally indicates a problem. Failing calibration.") + # if we are above the ABSOLUTE_HIGHEST_LDO_VOLTAGE, then we have to stop and fail the calibration + if LDO_voltage > self.ABSOLUTE_HIGHEST_LDO_VOLTAGE: + logger.debug(f"Measured LDO_voltage ({LDO_voltage}) was above ABSOLUTE_HIGHEST_LDO_VOLTAGE of {self.ABSOLUTE_HIGHEST_LDO_VOLTAGE} volts. Stopping calibration, as something is wrong.") return False, -1, -1 - # Now, using the best DAC setting we found, average the output voltage a few times to - # get a more accurate reading of the output voltage at that setting - # take an average of n pulses - n = 10 - while True: - self.set_DAC(final_DAC_setting) + # Note reading the LED current also pulses the strobe through the DIAG pin, + # which is necessary to get a valid reading, but also means that we are toggling + # the strobe on and off repeatedly during this calibration process, which is not ideal. + # But we need to do it in order to get accurate LED current readings. + LED_current = self.get_LED_current() + logger.debug(f"current_DAC_setting: {format(current_DAC_setting, '02x')}, LED_current: {LED_current}") + + # As we are slowly increasing the LED current, have we reached our desired set-point for the LED current yet? + if LED_current > target_LED_current: + logger.debug(f" ---> Reached above the target_LED_current ({target_LED_current}). LED_current is: {LED_current}. Stopping calibration here...") + final_DAC_setting = current_DAC_setting + 1 # Step back to the last setting that was just before we reached our target + break + + # We have not yet reached the target. TBD - This is a little redundant, maybe change + # Keep track of where we were. + if LED_current > max_LED_current_so_far: + + # Save the current output as the best one so far, even if it's not over the target, + # because we want to get as close as possible without going over + max_LED_current_so_far = LED_current + + # Continue counting down + final_DAC_setting = current_DAC_setting + current_DAC_setting -= 1 + + # There are a couple of possible edge cases here. And either of them indicate that something probably went wrong somewhere even if we + # thought we had a success. + # If so, err on the safe side and consider this a failure + if current_DAC_setting <= self.MIN_DAC_SETTING: + logger.debug(f"Reached MIN_DAC_SETTING ({self.MIN_DAC_SETTING}) without ever reaching target_LED_current ({target_LED_current}). This generally indicates a problem. Failing calibration.") + return False, -1, -1 + if current_DAC_setting >= self.MAX_DAC_SETTING: + logger.debug(f"The MAX_DAC_SETTING resulted in an LED current above the target. This generally indicates a problem. Failing calibration.") + return False, -1, -1 + + # Now, using the best DAC setting we found, average the output voltage a few times to + # get a more accurate reading of the output voltage at that setting + # take an average of n pulses + n = 10 + while True: + self.set_DAC(final_DAC_setting) + self.short_pause() + + # check if LDO voltage is above the minimum + LDO_voltage = self.get_LDO_voltage() + if LDO_voltage < self.ABSOLUTE_LOWEST_LDO_VOLTAGE: + # Fallback to the last known good measurement + final_DAC_setting -= 1 + break + + # Take an average of n pulses + LED_current_sum = 0 + for _ in range(n): + LED_current_sum += self.get_LED_current() self.short_pause() - - # check if LDO voltage is above the minimum - LDO_voltage = self.get_LDO_voltage() - if LDO_voltage < self.ABSOLUTE_LOWEST_LDO_VOLTAGE: - # Fallback to the last known good measurement - final_DAC_setting -= 1 - break + LED_current = LED_current_sum / n - # Take an average of n pulses - LED_current_sum = 0 - for _ in range(n): - LED_current_sum += self.get_LED_current() - self.short_pause() - LED_current = LED_current_sum / n - - if LED_current > target_LED_current: - # Current is still slightly too high, step the DAC setting - final_DAC_setting += 1 - else: - # We are at or below the target current, we're done - break + if LED_current > target_LED_current: + # Current is still slightly too high, step the DAC setting + final_DAC_setting += 1 + if final_DAC_setting > self.MAX_DAC_SETTING: + logger.error(f"Averaging loop exceeded MAX_DAC_SETTING ({self.MAX_DAC_SETTING}) without reaching target current. Failing calibration.") + return False, -1, -1 + else: + # We are at or below the target current, we're done + break - logger.debug(f"calibrate_board -- final_DAC_setting: {format(final_DAC_setting, '02x')}, LED_current: {LED_current}") + logger.debug(f"calibrate_board -- final_DAC_setting: {format(final_DAC_setting, '02x')}, LED_current: {LED_current}") - return True, final_DAC_setting, LED_current + return True, final_DAC_setting, LED_current def cleanup_for_exit(self): @@ -474,7 +465,7 @@ def main(): parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") parser.add_argument("-q", "--quiet", action="store_true", help="Quiet output") parser.add_argument("-w", "--overwrite", action="store_true", help="Overwrites any existing strobe setting in user_settings.json") - parser.add_argument("--target_output", default=0,type=float, help="Set target LED current output (in volts) (ADVANCED)") + parser.add_argument("--target_output", default=0,type=float, help="Set target LED current output (in amps) (ADVANCED)") parser.add_argument("--ignore", action="store_true", help="Attempt calibration even if the Controller Board version is not 3.0 (ADVANCED)") action_group = parser.add_mutually_exclusive_group() action_group.add_argument("-p", "--print_settings", action="store_true", help="Print the current DAC setting and last ADC measurement from the user_settings.json file") @@ -497,11 +488,11 @@ def main(): logger.debug(f"Calibrator initialized") - if calibrator.setup_spi_channels() == False: + if not calibrator.setup_spi_channels(): logger.error(f"SPI initialization failed. Cannot proceed with calibration.") return 1 - if calibrator.open_gpio_system() == False: + if not calibrator.open_gpio_system(): logger.error(f"GPIO initialization failed. Cannot proceed with calibration.") calibrator.close_gpio_system() return 1 @@ -632,11 +623,9 @@ def main(): except KeyboardInterrupt: print("\nCtrl+C pressed. Performing cleanup...") - # Add your cleanup code here (e.g., closing files, releasing resources) - calibrator.cleanup_for_exit() except Exception as e: - logger.debug(f"An error occurred: {e}") + logger.error(f"An error occurred: {e}") calibrator.set_DAC_to_safest_level() return 1 # Failure From 9028d16ec452e37bad6d8b3c26651aff9dc49013 Mon Sep 17 00:00:00 2001 From: Pete Attayek Date: Fri, 20 Mar 2026 18:40:46 -0400 Subject: [PATCH 07/28] change old led current to 9A --- Software/web-server/calibrate_strobe_output.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Software/web-server/calibrate_strobe_output.py b/Software/web-server/calibrate_strobe_output.py index d8bbb123..6aeef664 100644 --- a/Software/web-server/calibrate_strobe_output.py +++ b/Software/web-server/calibrate_strobe_output.py @@ -63,7 +63,7 @@ class StrobeOutputCalibrator: # This is the maximum safe strobe current for the V3 LED V3_TARGET_LED_CURRENT_SETTING = 10.0 # amps # This is the maximum safe strobe current for the old 100W LED - OLD_TARGET_LED_CURRENT_SETTING = 7.0 # amps + OLD_TARGET_LED_CURRENT_SETTING = 9.0 # amps # We should NEVER go below this LDO voltage From 8e7b717d45f1ee70d77e6d95034317b805b1438f Mon Sep 17 00:00:00 2001 From: Pete Attayek Date: Fri, 20 Mar 2026 18:41:39 -0400 Subject: [PATCH 08/28] Add installation files for spi1 setup and Python runtime dependencies --- packaging/build.sh | 2 +- packaging/scripts/configure-cameras.sh | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packaging/build.sh b/packaging/build.sh index 1d61539f..b951271b 100755 --- a/packaging/build.sh +++ b/packaging/build.sh @@ -146,7 +146,7 @@ build_dev() { done # Python runtime dependencies for CLI tool - for pkg in python3 python3-pip python3-yaml python3-opencv python3-numpy; do + for pkg in python3 python3-pip python3-yaml python3-opencv python3-numpy python3-lgpio python3-rpi-lgpio python3-gpiozero; do if ! dpkg -l | grep -q "^ii $pkg"; then missing_deps+=("$pkg") fi diff --git a/packaging/scripts/configure-cameras.sh b/packaging/scripts/configure-cameras.sh index 0422461e..be3f9608 100755 --- a/packaging/scripts/configure-cameras.sh +++ b/packaging/scripts/configure-cameras.sh @@ -148,10 +148,9 @@ configure_boot_config() { log_info " Slot 2 type: ${slot2_type:-none}" log_info " Has InnoMaker: $has_innomaker" - # Skip if no cameras detected + # Issue warning if no cameras detected, but continue to configure base system parameters if [[ "$num_cameras" -eq 0 ]]; then - log_warn "No cameras detected, skipping config.txt configuration" - return 0 + log_warn "No cameras detected. Camera-specific overlays will be skipped, but base system parameters will be configured." fi backup_config_txt "$config_path" @@ -188,6 +187,13 @@ dtparam=spi=on" log_info " dtparam=spi=on already exists, skipping" fi + if ! grep -q "^dtoverlay=spi1-2cs" "$config_path"; then + config_block="$config_block +dtoverlay=spi1-2cs" + else + log_info " dtoverlay=spi1-2cs already exists, skipping" + fi + if ! grep -q "^force_turbo=" "$config_path"; then config_block="$config_block force_turbo=1" From 0eeab428c9c523a6853c14478f26c6a947b89dc7 Mon Sep 17 00:00:00 2001 From: Pete Attayek Date: Fri, 20 Mar 2026 21:05:32 -0400 Subject: [PATCH 09/28] Update wiring diagram and documentation --- .../PiTrac_V3_Connection_Guide.svg | 2 +- docs/hardware/pcb-assembly.md | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/assets/images/enclosure_assembly/PiTrac_V3_Connection_Guide.svg b/docs/assets/images/enclosure_assembly/PiTrac_V3_Connection_Guide.svg index 458a8234..e26cf64a 100644 --- a/docs/assets/images/enclosure_assembly/PiTrac_V3_Connection_Guide.svg +++ b/docs/assets/images/enclosure_assembly/PiTrac_V3_Connection_Guide.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/docs/hardware/pcb-assembly.md b/docs/hardware/pcb-assembly.md index 5f2f5416..2f444e8b 100644 --- a/docs/hardware/pcb-assembly.md +++ b/docs/hardware/pcb-assembly.md @@ -233,12 +233,12 @@ After assembly, must run current calibration before you will be able to capture | V3 Connector Board | Raspberry Pi 5 | |----------|----------| | GND | GND (Pin 39) | -| DIAG | GPIO 20 (Pin 38) | -| CS0 | GPIO 8 (Pin 24) | -| MOSI | GPIO 10 (Pin 19) | -| MISO | GPIO 9 (Pin 21) | -| CLK | GPIO 11 (Pin 23) | -| CS1 | GPIO 7 (Pin 26) | +| DIAG | GPIO 10 (Pin 19) | +| CS0 | GPIO 18 (Pin 12) | +| MOSI | GPIO 20 (Pin 38) | +| MISO | GPIO 19 (Pin 35) | +| CLK | GPIO 21 (Pin 40) | +| CS1 | GPIO 17 (Pin 11) | | V3P3 | 3V3 (Pin 1) | | Global Shutter Camera 2 | Raspberry Pi 5 | From e9eb3d56b598510936cabd89dcfa550a084426bd Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 21 Mar 2026 12:56:24 -0400 Subject: [PATCH 10/28] make kDAC_setting internal-only, not passed to C++ processes --- Software/web-server/configurations.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Software/web-server/configurations.json b/Software/web-server/configurations.json index efd8c8ba..45c026fd 100644 --- a/Software/web-server/configurations.json +++ b/Software/web-server/configurations.json @@ -281,6 +281,17 @@ "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", From b253925fb44875aa545aa3dabc5174a485679246 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 21 Mar 2026 13:04:02 -0400 Subject: [PATCH 11/28] route kDAC_setting to calibration_data.json instead of user_settings --- Software/web-server/config_manager.py | 1 + 1 file changed, 1 insertion(+) 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) From 76f469f3c6fe7499ea545db8a1562af1260c7837 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 21 Mar 2026 13:11:04 -0400 Subject: [PATCH 12/28] strobe calibration manager skeleton with hardware open/close --- .../web-server/strobe_calibration_manager.py | 109 ++++++++++++++++ .../tests/test_strobe_calibration_manager.py | 119 ++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 Software/web-server/strobe_calibration_manager.py create mode 100644 Software/web-server/tests/test_strobe_calibration_manager.py diff --git a/Software/web-server/strobe_calibration_manager.py b/Software/web-server/strobe_calibration_manager.py new file mode 100644 index 00000000..c9b2d36c --- /dev/null +++ b/Software/web-server/strobe_calibration_manager.py @@ -0,0 +1,109 @@ +"""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: + from gpiozero import LED +except ImportError: + LED = 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 + + # 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 LED 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 = LED(self.DIAG_GPIO_PIN) + + def _close_hardware(self): + if self._diag_pin is not None: + self._diag_pin.off() + self._diag_pin.close() + if self._spi_dac is not None: + self._spi_dac.close() + if self._spi_adc is not None: + self._spi_adc.close() diff --git a/Software/web-server/tests/test_strobe_calibration_manager.py b/Software/web-server/tests/test_strobe_calibration_manager.py new file mode 100644 index 00000000..53260198 --- /dev/null +++ b/Software/web-server/tests/test_strobe_calibration_manager.py @@ -0,0 +1,119 @@ +"""Tests for StrobeCalibrationManager""" + +import asyncio +import gc +import os +import time +import pytest +from unittest.mock import Mock, MagicMock, patch, PropertyMock + + +# --------------------------------------------------------------------------- +# Increment 1: Skeleton + hardware open/close +# --------------------------------------------------------------------------- + +class TestStrobeCalibrationInit: + """Initialization and default state""" + + def test_init_stores_config_manager(self): + from strobe_calibration_manager import StrobeCalibrationManager + + cm = Mock() + mgr = StrobeCalibrationManager(cm) + assert mgr.config_manager is cm + + def test_init_status_is_idle(self): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + assert mgr.status["state"] == "idle" + assert mgr.status["progress"] == 0 + assert mgr.status["message"] == "" + + def test_init_hardware_refs_are_none(self): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + assert mgr._spi_dac is None + assert mgr._spi_adc is None + assert mgr._diag_pin is None + + def test_init_cancel_flag_false(self): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + assert mgr._cancel_requested is False + + +class TestOpenHardware: + """_open_hardware sets up SPI and GPIO""" + + @patch("strobe_calibration_manager.spidev") + @patch("strobe_calibration_manager.LED") + def test_open_creates_spi_and_gpio(self, mock_led_cls, mock_spidev_mod): + from strobe_calibration_manager import StrobeCalibrationManager + + mock_dac = MagicMock() + mock_adc = MagicMock() + mock_spidev_mod.SpiDev.side_effect = [mock_dac, mock_adc] + + mgr = StrobeCalibrationManager(Mock()) + mgr._open_hardware() + + assert mgr._spi_dac is mock_dac + mock_dac.open.assert_called_once_with(1, 0) + assert mock_dac.max_speed_hz == 1_000_000 + assert mock_dac.mode == 0 + + assert mgr._spi_adc is mock_adc + mock_adc.open.assert_called_once_with(1, 1) + assert mock_adc.max_speed_hz == 1_000_000 + assert mock_adc.mode == 0 + + mock_led_cls.assert_called_once_with(10) + assert mgr._diag_pin is mock_led_cls.return_value + + @patch("strobe_calibration_manager.spidev", None) + def test_open_raises_when_spidev_missing(self): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + with pytest.raises(RuntimeError, match="spidev"): + mgr._open_hardware() + + +class TestCloseHardware: + """_close_hardware tears down SPI and GPIO safely""" + + def test_close_calls_close_on_all(self): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + mgr._spi_dac = MagicMock() + mgr._spi_adc = MagicMock() + mgr._diag_pin = MagicMock() + + mgr._close_hardware() + + mgr._spi_dac.close.assert_called_once() + mgr._spi_adc.close.assert_called_once() + mgr._diag_pin.close.assert_called_once() + + def test_close_tolerates_none_refs(self): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + # all refs are None by default -- should not raise + mgr._close_hardware() + + def test_close_turns_diag_off_first(self): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + pin = MagicMock() + mgr._diag_pin = pin + + mgr._close_hardware() + # off() should be called before close() + pin.off.assert_called_once() + pin.close.assert_called_once() From 683d5c42b57b6499018ddf2353643c4383b55251 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 21 Mar 2026 13:22:19 -0400 Subject: [PATCH 13/28] add calibration algorithm (_find_dac_start, _calibrate) + fix macOS test compat --- .../web-server/strobe_calibration_manager.py | 184 ++++++++ .../tests/test_strobe_calibration_manager.py | 446 ++++++++++++++++++ 2 files changed, 630 insertions(+) diff --git a/Software/web-server/strobe_calibration_manager.py b/Software/web-server/strobe_calibration_manager.py index c9b2d36c..151395e0 100644 --- a/Software/web-server/strobe_calibration_manager.py +++ b/Software/web-server/strobe_calibration_manager.py @@ -107,3 +107,187 @@ def _close_hardware(self): self._spi_dac.close() if self._spi_adc is not None: self._spi_adc.close() + + # ------------------------------------------------------------------ + # 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: + 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: + param = os.sched_param(0) + os.sched_setscheduler(0, os.SCHED_OTHER, param) + except (PermissionError, AttributeError, OSError): + pass + 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) + """ + # Phase 1: find safe starting DAC + dac_start, ldo = self._find_dac_start() + + if dac_start < 0: + logger.debug(f"DAC 0 already below LDO minimum ({ldo:.2f}V)") + 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 + current_dac = dac_start + final_dac = self.DAC_MIN + total_steps = dac_start - self.DAC_MIN + 1 + + while current_dac >= self.DAC_MIN: + if self._cancel_requested: + logger.info("Calibration cancelled by user") + return False, -1, -1 + + self._set_dac(current_dac) + time.sleep(0.1) + + # Update progress for UI polling + steps_done = dac_start - current_dac + 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={current_dac:#04x}, skipping") + final_dac = current_dac + current_dac -= 1 + continue + + if ldo > self.LDO_MAX_V: + logger.debug(f"LDO {ldo:.2f}V above max — something is wrong") + return False, -1, -1 + + led_current = self.get_led_current() + logger.debug(f"DAC={current_dac:#04x}, current={led_current:.2f}A") + + # Hard safety cap (bug fix over original script) + if led_current > self.HARD_CAP_CURRENT: + logger.error(f"LED current {led_current:.2f}A exceeds hard cap {self.HARD_CAP_CURRENT}A") + return False, -1, -1 + + if led_current > target_current: + logger.debug(f"Crossed target at DAC={current_dac:#04x} ({led_current:.2f}A)") + final_dac = current_dac + 1 + break + + final_dac = current_dac + current_dac -= 1 + + # Edge cases — sweep ran off either end + if current_dac < self.DAC_MIN: + logger.debug(f"Reached DAC_MIN without crossing target") + return False, -1, -1 + if current_dac >= self.DAC_MAX: + logger.debug(f"DAC_MAX still above target — hardware 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 diff --git a/Software/web-server/tests/test_strobe_calibration_manager.py b/Software/web-server/tests/test_strobe_calibration_manager.py index 53260198..04f31587 100644 --- a/Software/web-server/tests/test_strobe_calibration_manager.py +++ b/Software/web-server/tests/test_strobe_calibration_manager.py @@ -117,3 +117,449 @@ def test_close_turns_diag_off_first(self): # off() should be called before close() pin.off.assert_called_once() pin.close.assert_called_once() + + +# --------------------------------------------------------------------------- +# Increment 2: DAC/ADC primitives +# --------------------------------------------------------------------------- + +class TestSetDac: + """_set_dac encodes value into MCP4801 protocol and sends via SPI""" + + def test_set_dac_zero(self): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + mgr._spi_dac = MagicMock() + + mgr._set_dac(0) + mgr._spi_dac.xfer2.assert_called_once_with([0x30, 0x00]) + + def test_set_dac_max(self): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + mgr._spi_dac = MagicMock() + + mgr._set_dac(0xFF) + # 0x30 | (0xFF >> 4 & 0x0F) = 0x30 | 0x0F = 0x3F + # (0xFF << 4) & 0xF0 = 0xF0 + mgr._spi_dac.xfer2.assert_called_once_with([0x3F, 0xF0]) + + def test_set_dac_midrange(self): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + mgr._spi_dac = MagicMock() + + # 0x96 = 150 + # msb: 0x30 | (0x96 >> 4 & 0x0F) = 0x30 | 0x09 = 0x39 + # lsb: (0x96 << 4) & 0xF0 = 0x60 + mgr._set_dac(0x96) + mgr._spi_dac.xfer2.assert_called_once_with([0x39, 0x60]) + + +class TestReadAdc: + """_read_adc sends command and parses 12-bit response""" + + def test_read_adc_parses_12bit(self): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + mgr._spi_adc = MagicMock() + # response: first byte ignored, upper nibble in byte 1, full byte 2 + # 0x0A << 8 | 0xBC = 0xABC = 2748 + mgr._spi_adc.xfer2.return_value = [0x00, 0x0A, 0xBC] + + result = mgr._read_adc(0x80) + mgr._spi_adc.xfer2.assert_called_once_with([0x01, 0x80, 0x00]) + assert result == 2748 + + def test_read_adc_masks_upper_nibble(self): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + mgr._spi_adc = MagicMock() + # byte 1 has extra bits above nibble -- should be masked + mgr._spi_adc.xfer2.return_value = [0xFF, 0xFF, 0xFF] + + result = mgr._read_adc(0xC0) + # (0xFF & 0x0F) << 8 | 0xFF = 0x0FFF = 4095 + assert result == 4095 + + def test_read_adc_zero(self): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + mgr._spi_adc = MagicMock() + mgr._spi_adc.xfer2.return_value = [0x00, 0x00, 0x00] + + assert mgr._read_adc(0x80) == 0 + + +class TestGetLdoVoltage: + """get_ldo_voltage reads CH1 and applies resistor divider formula""" + + def test_ldo_voltage_calculation(self): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + mgr._spi_adc = MagicMock() + # pick adc value that gives a nice voltage + # LDO = (3.3 / 4096) * adc * 3.0 + # for adc = 2048: (3.3/4096)*2048*3 = 4.95 + mgr._spi_adc.xfer2.return_value = [0x00, 0x08, 0x00] # 2048 + + voltage = mgr.get_ldo_voltage() + assert abs(voltage - 4.95) < 0.01 + + def test_ldo_voltage_zero(self): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + mgr._spi_adc = MagicMock() + mgr._spi_adc.xfer2.return_value = [0x00, 0x00, 0x00] + + assert mgr.get_ldo_voltage() == 0.0 + + +class TestGetLedCurrent: + """get_led_current pulses DIAG, reads CH0, converts to amps""" + + def test_led_current_calculation(self): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + mgr._spi_adc = MagicMock() + mgr._diag_pin = MagicMock() + # LED current = (3.3 / 4096) * adc * 10.0 + # for adc = 1240: (3.3/4096)*1240*10 = ~9.98 + mgr._spi_adc.xfer2.return_value = [0x00, 0x04, 0xD8] # 0x04D8 = 1240 + + with patch("strobe_calibration_manager.gc"), \ + patch("strobe_calibration_manager.time.sleep"): + current = mgr.get_led_current() + + expected = (3.3 / 4096) * 1240 * 10.0 + assert abs(current - expected) < 0.01 + + def test_led_current_always_turns_diag_off(self): + """Even if SPI read raises, DIAG must be turned off""" + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + mgr._spi_adc = MagicMock() + mgr._spi_adc.xfer2.side_effect = RuntimeError("SPI failure") + mgr._diag_pin = MagicMock() + + with patch("strobe_calibration_manager.gc"), \ + patch("strobe_calibration_manager.time.sleep"): + with pytest.raises(RuntimeError): + mgr.get_led_current() + + mgr._diag_pin.off.assert_called_once() + + def test_led_current_pulses_diag_on_then_off(self): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + mgr._spi_adc = MagicMock() + mgr._spi_adc.xfer2.return_value = [0x00, 0x04, 0x00] + mgr._diag_pin = MagicMock() + + call_order = [] + mgr._diag_pin.on.side_effect = lambda: call_order.append("on") + mgr._diag_pin.off.side_effect = lambda: call_order.append("off") + + with patch("strobe_calibration_manager.gc"), \ + patch("strobe_calibration_manager.time.sleep"): + mgr.get_led_current() + + assert call_order == ["on", "off"] + + +# --------------------------------------------------------------------------- +# Increment 3: Calibration algorithm +# --------------------------------------------------------------------------- + +def _make_mgr_with_hw(): + """Helper: create a manager with mocked hardware attached.""" + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + mgr._spi_dac = MagicMock() + mgr._spi_adc = MagicMock() + mgr._diag_pin = MagicMock() + return mgr + + +class TestFindDacStart: + """_find_dac_start sweeps DAC 0->255, returns last value where LDO >= 4.5V""" + + @patch("strobe_calibration_manager.time.sleep") + def test_finds_boundary(self, _sleep): + mgr = _make_mgr_with_hw() + + # LDO drops below 4.5V at DAC=5, so start should be 4 + voltages = [8.0, 7.5, 6.5, 5.5, 4.8, 4.3] + call_idx = [0] + + def fake_ldo(): + v = voltages[min(call_idx[0], len(voltages) - 1)] + call_idx[0] += 1 + return v + + mgr.get_ldo_voltage = fake_ldo + + dac_start, ldo = mgr._find_dac_start() + assert dac_start == 4 + + @patch("strobe_calibration_manager.time.sleep") + def test_dac_zero_already_unsafe(self, _sleep): + mgr = _make_mgr_with_hw() + + mgr.get_ldo_voltage = lambda: 3.0 # always below 4.5V + + dac_start, ldo = mgr._find_dac_start() + assert dac_start == -1 + + @patch("strobe_calibration_manager.time.sleep") + def test_never_drops_returns_255(self, _sleep): + mgr = _make_mgr_with_hw() + + mgr.get_ldo_voltage = lambda: 9.0 # always safe + + dac_start, ldo = mgr._find_dac_start() + assert dac_start == 255 + + @patch("strobe_calibration_manager.time.sleep") + def test_sets_dac_at_each_step(self, _sleep): + mgr = _make_mgr_with_hw() + + calls = [] + mgr._set_dac = lambda v: calls.append(v) + mgr.get_ldo_voltage = lambda: 3.0 # immediately unsafe + + mgr._find_dac_start() + # Should have set DAC=0, then LDO was bad, so start=-1 + assert calls[0] == 0 + + +class TestCalibrate: + """_calibrate runs all 3 phases: find_start, main sweep, averaging""" + + @patch("strobe_calibration_manager.time.sleep") + def test_succeeds_with_realistic_data(self, _sleep): + mgr = _make_mgr_with_hw() + + # Phase 1: LDO drops at DAC=100 so start=99 + phase1_voltages = [9.0] * 100 + [4.0] + phase1_idx = [0] + + def phase1_ldo(): + v = phase1_voltages[min(phase1_idx[0], len(phase1_voltages) - 1)] + phase1_idx[0] += 1 + return v + + # Phase 2: sweep DAC from 99 down, LED current rises as DAC decreases. + # Current crosses 10A (V3 target) at DAC=50 + phase2_ldo = [7.0] # always safe during phase 2 + phase2_currents = {} + for d in range(100): + # current goes from ~5A at DAC=99 up to ~12A at DAC=0 + phase2_currents[d] = 5.0 + (99 - d) * (7.0 / 99) + + # Phase 3: averaging returns just under target + avg_current = [9.8] + + # Wire up the mock methods + phase = [1] + ldo_call = [0] + current_call = [0] + current_dac_val = [99] + + def smart_set_dac(v): + current_dac_val[0] = v + + def smart_ldo(): + if phase[0] == 1: + return phase1_ldo() + return phase2_ldo[0] + + def smart_current(): + return phase2_currents.get(current_dac_val[0], 5.0) + + def smart_avg_current(): + return avg_current[0] + + # Patch _find_dac_start to return a known start + mgr._find_dac_start = lambda: (99, 7.0) + mgr._set_dac = smart_set_dac + mgr.get_ldo_voltage = lambda: 7.0 # always safe + mgr.get_led_current = smart_current + phase[0] = 2 + + success, final_dac, current = mgr._calibrate(10.0) + assert success is True + assert final_dac > 0 + assert final_dac <= 99 + + @patch("strobe_calibration_manager.time.sleep") + def test_fails_when_dac_zero_unsafe(self, _sleep): + mgr = _make_mgr_with_hw() + + mgr._find_dac_start = lambda: (-1, 3.0) + + success, dac, current = mgr._calibrate(10.0) + assert success is False + assert dac == -1 + + @patch("strobe_calibration_manager.time.sleep") + def test_fails_when_ldo_too_high(self, _sleep): + mgr = _make_mgr_with_hw() + + mgr._find_dac_start = lambda: (200, 7.0) + mgr._set_dac = lambda v: None + mgr.get_ldo_voltage = lambda: 12.0 # above LDO_MAX_V of 11.0 + + success, dac, current = mgr._calibrate(10.0) + assert success is False + + @patch("strobe_calibration_manager.time.sleep") + def test_fails_when_min_dac_reached(self, _sleep): + mgr = _make_mgr_with_hw() + + mgr._find_dac_start = lambda: (10, 7.0) + mgr._set_dac = lambda v: None + mgr.get_ldo_voltage = lambda: 7.0 + mgr.get_led_current = lambda: 5.0 # never reaches target + + success, dac, current = mgr._calibrate(10.0) + assert success is False + + @patch("strobe_calibration_manager.time.sleep") + def test_cancel_during_main_sweep(self, _sleep): + mgr = _make_mgr_with_hw() + + mgr._find_dac_start = lambda: (200, 7.0) + mgr._set_dac = lambda v: None + mgr.get_ldo_voltage = lambda: 7.0 + mgr.get_led_current = lambda: 5.0 + + # Cancel after a few iterations + counter = [0] + original_set_dac = mgr._set_dac + def counting_set_dac(v): + counter[0] += 1 + if counter[0] > 5: + mgr._cancel_requested = True + original_set_dac(v) + mgr._set_dac = counting_set_dac + + success, dac, current = mgr._calibrate(10.0) + assert success is False + + @patch("strobe_calibration_manager.time.sleep") + def test_hard_cap_rejects_excessive_current(self, _sleep): + """If LED current ever exceeds HARD_CAP_CURRENT, calibration should fail""" + mgr = _make_mgr_with_hw() + + mgr._find_dac_start = lambda: (50, 7.0) + mgr._set_dac = lambda v: None + mgr.get_ldo_voltage = lambda: 7.0 + mgr.get_led_current = lambda: 13.0 # above 12A hard cap + + success, dac, current = mgr._calibrate(10.0) + assert success is False + + @patch("strobe_calibration_manager.time.sleep") + def test_skips_ldo_below_min_during_sweep(self, _sleep): + """When LDO drops below min during sweep, that step is skipped""" + mgr = _make_mgr_with_hw() + + mgr._find_dac_start = lambda: (5, 7.0) + dac_calls = [] + mgr._set_dac = lambda v: dac_calls.append(v) + + # LDO goes below min at DAC=4, then back up for DAC=3..0 + ldo_values = {5: 7.0, 4: 4.0, 3: 7.0, 2: 7.0, 1: 7.0, 0: 7.0} + mgr.get_ldo_voltage = lambda: ldo_values.get(dac_calls[-1] if dac_calls else 5, 7.0) + mgr.get_led_current = lambda: 5.0 # never reaches target + + success, dac, current = mgr._calibrate(10.0) + # Should have continued past the low LDO step + assert success is False # reaches MIN_DAC + assert 4 in dac_calls # did try DAC=4 + + @patch("strobe_calibration_manager.time.sleep") + def test_averaging_steps_up_when_still_over_target(self, _sleep): + """Phase 3: if average current still above target, increment DAC""" + mgr = _make_mgr_with_hw() + + dac_val = [0] + + def track_dac(v): + dac_val[0] = v + + mgr._find_dac_start = lambda: (50, 7.0) + mgr._set_dac = track_dac + mgr.get_ldo_voltage = lambda: 7.0 + + def smart_current(): + d = dac_val[0] + # Sweep phase: crosses target at DAC=25 (current > 10A) + if d <= 25: + return 10.5 + # Averaging phase at DAC=26: first time over target, second time under + if d == 26: + # Return slightly above target so it steps up to 27 + smart_current._avg_call_count = getattr(smart_current, '_avg_call_count', 0) + 1 + if smart_current._avg_call_count <= 10: + return 10.1 + return 9.8 + if d == 27: + return 9.8 + return 5.0 + + mgr.get_led_current = smart_current + + success, final_dac, current = mgr._calibrate(10.0) + assert success is True + # Should have stepped up from 26 to 27 during averaging + assert final_dac == 27 + + @patch("strobe_calibration_manager.time.sleep") + def test_updates_status_progress(self, _sleep): + """Status dict should be updated during calibration sweep""" + mgr = _make_mgr_with_hw() + + mgr._find_dac_start = lambda: (10, 7.0) + mgr._set_dac = lambda v: None + mgr.get_ldo_voltage = lambda: 7.0 + + # Have it cross the target at DAC=5 + current_dac = [10] + original_set = mgr._set_dac + def tracking_set(v): + current_dac[0] = v + mgr._set_dac = tracking_set + + def current_fn(): + if current_dac[0] <= 5: + return 10.5 # above target + return 5.0 + mgr.get_led_current = current_fn + + progress_values = [] + original_status = mgr.status + class StatusProxy(dict): + def __setitem__(self, k, v): + super().__setitem__(k, v) + if k == "progress": + progress_values.append(v) + proxy = StatusProxy(original_status) + mgr.status = proxy + + mgr._calibrate(10.0) + # Should have updated progress at least once + assert len(progress_values) > 0 From 6e3d083f6d55293b8e3bd484b2c7537451edf1c2 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 21 Mar 2026 13:24:36 -0400 Subject: [PATCH 14/28] add async public API for strobe calibration (start, cancel, diagnostics, manual DAC, etc) --- .../web-server/strobe_calibration_manager.py | 173 ++++++++ .../tests/test_strobe_calibration_manager.py | 407 ++++++++++++++++++ 2 files changed, 580 insertions(+) diff --git a/Software/web-server/strobe_calibration_manager.py b/Software/web-server/strobe_calibration_manager.py index 151395e0..269a714c 100644 --- a/Software/web-server/strobe_calibration_manager.py +++ b/Software/web-server/strobe_calibration_manager.py @@ -291,3 +291,176 @@ def _calibrate(self, target_current: float): 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.""" + + # Validate board version + board_version = self.config_manager.get_config("gs_config.strobing.kConnectionBoardVersion") + if board_version is None or int(board_version) != 3: + return {"status": "error", + "message": f"Board version must be V3 (got {board_version})"} + + # Check for existing calibration + existing = self.config_manager.get_config(self.DAC_CONFIG_KEY) + if existing is not None and not overwrite: + return {"status": "error", + "message": "Calibration already exists. Set overwrite=true to replace."} + + # 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={final_dac:#04x}, current={led_current:.2f}A"} + return {"status": "success", "dac_setting": final_dac, + "led_current": round(led_current, 2)} + else: + self._set_dac(self.SAFE_DAC_VALUE) + self.status = {"state": "failed", "progress": 0, + "message": "Calibration failed — DAC set to safe fallback"} + return {"status": "failed", + "message": "Calibration failed. DAC set to safe fallback."} + + 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/tests/test_strobe_calibration_manager.py b/Software/web-server/tests/test_strobe_calibration_manager.py index 04f31587..b607e1ab 100644 --- a/Software/web-server/tests/test_strobe_calibration_manager.py +++ b/Software/web-server/tests/test_strobe_calibration_manager.py @@ -563,3 +563,410 @@ def __setitem__(self, k, v): mgr._calibrate(10.0) # Should have updated progress at least once assert len(progress_values) > 0 + + +# --------------------------------------------------------------------------- +# Increment 4: Async public API +# --------------------------------------------------------------------------- + +class TestStartCalibration: + """start_calibration validates inputs, checks board version, runs _calibrate""" + + @pytest.mark.asyncio + @patch("strobe_calibration_manager.time.sleep") + async def test_rejects_non_v3_board(self, _sleep): + from strobe_calibration_manager import StrobeCalibrationManager + + cm = Mock() + cm.get_config.side_effect = lambda key=None: { + "gs_config.strobing.kConnectionBoardVersion": "2", + "gs_config.strobing.kDAC_setting": None, + }.get(key) + + mgr = StrobeCalibrationManager(cm) + + result = await mgr.start_calibration(led_type="v3") + assert result["status"] == "error" + assert "version" in result["message"].lower() or "V3" in result["message"] + + @pytest.mark.asyncio + @patch("strobe_calibration_manager.time.sleep") + async def test_rejects_overwrite_without_flag(self, _sleep): + from strobe_calibration_manager import StrobeCalibrationManager + + cm = Mock() + cm.get_config.side_effect = lambda key=None: { + "gs_config.strobing.kConnectionBoardVersion": "3", + "gs_config.strobing.kDAC_setting": 150, + }.get(key) + + mgr = StrobeCalibrationManager(cm) + + result = await mgr.start_calibration(led_type="v3", overwrite=False) + assert result["status"] == "error" + assert "exist" in result["message"].lower() or "overwrite" in result["message"].lower() + + @pytest.mark.asyncio + @patch("strobe_calibration_manager.time.sleep") + async def test_allows_overwrite_with_flag(self, _sleep): + from strobe_calibration_manager import StrobeCalibrationManager + + cm = Mock() + cm.get_config.side_effect = lambda key=None: { + "gs_config.strobing.kConnectionBoardVersion": "3", + "gs_config.strobing.kDAC_setting": 150, + }.get(key) + cm.set_config.return_value = (True, "ok", False) + + mgr = StrobeCalibrationManager(cm) + + # Mock out the hardware and calibration + mgr._open_hardware = Mock() + mgr._close_hardware = Mock() + mgr._calibrate = Mock(return_value=(True, 0x80, 9.5)) + + result = await mgr.start_calibration(led_type="v3", overwrite=True) + assert result["status"] == "success" + + @pytest.mark.asyncio + @patch("strobe_calibration_manager.time.sleep") + async def test_uses_v3_target_current(self, _sleep): + from strobe_calibration_manager import StrobeCalibrationManager + + cm = Mock() + cm.get_config.side_effect = lambda key=None: { + "gs_config.strobing.kConnectionBoardVersion": "3", + "gs_config.strobing.kDAC_setting": None, + }.get(key) + cm.set_config.return_value = (True, "ok", False) + + mgr = StrobeCalibrationManager(cm) + mgr._open_hardware = Mock() + mgr._close_hardware = Mock() + + captured_target = [] + def fake_calibrate(target): + captured_target.append(target) + return (True, 0x80, 9.5) + mgr._calibrate = fake_calibrate + + await mgr.start_calibration(led_type="v3") + assert captured_target[0] == 10.0 + + @pytest.mark.asyncio + @patch("strobe_calibration_manager.time.sleep") + async def test_uses_legacy_target_current(self, _sleep): + from strobe_calibration_manager import StrobeCalibrationManager + + cm = Mock() + cm.get_config.side_effect = lambda key=None: { + "gs_config.strobing.kConnectionBoardVersion": "3", + "gs_config.strobing.kDAC_setting": None, + }.get(key) + cm.set_config.return_value = (True, "ok", False) + + mgr = StrobeCalibrationManager(cm) + mgr._open_hardware = Mock() + mgr._close_hardware = Mock() + + captured_target = [] + def fake_calibrate(target): + captured_target.append(target) + return (True, 0x80, 8.5) + mgr._calibrate = fake_calibrate + + await mgr.start_calibration(led_type="legacy") + assert captured_target[0] == 9.0 + + @pytest.mark.asyncio + @patch("strobe_calibration_manager.time.sleep") + async def test_uses_custom_target(self, _sleep): + from strobe_calibration_manager import StrobeCalibrationManager + + cm = Mock() + cm.get_config.side_effect = lambda key=None: { + "gs_config.strobing.kConnectionBoardVersion": "3", + "gs_config.strobing.kDAC_setting": None, + }.get(key) + cm.set_config.return_value = (True, "ok", False) + + mgr = StrobeCalibrationManager(cm) + mgr._open_hardware = Mock() + mgr._close_hardware = Mock() + + captured_target = [] + def fake_calibrate(target): + captured_target.append(target) + return (True, 0x80, 7.5) + mgr._calibrate = fake_calibrate + + await mgr.start_calibration(led_type="v3", target_current=7.5) + assert captured_target[0] == 7.5 + + @pytest.mark.asyncio + @patch("strobe_calibration_manager.time.sleep") + async def test_saves_result_on_success(self, _sleep): + from strobe_calibration_manager import StrobeCalibrationManager + + cm = Mock() + cm.get_config.side_effect = lambda key=None: { + "gs_config.strobing.kConnectionBoardVersion": "3", + "gs_config.strobing.kDAC_setting": None, + }.get(key) + cm.set_config.return_value = (True, "ok", False) + + mgr = StrobeCalibrationManager(cm) + mgr._open_hardware = Mock() + mgr._close_hardware = Mock() + mgr._calibrate = Mock(return_value=(True, 0x80, 9.5)) + + await mgr.start_calibration(led_type="v3") + cm.set_config.assert_called_once_with("gs_config.strobing.kDAC_setting", 0x80) + + @pytest.mark.asyncio + @patch("strobe_calibration_manager.time.sleep") + async def test_sets_safe_dac_on_failure(self, _sleep): + from strobe_calibration_manager import StrobeCalibrationManager + + cm = Mock() + cm.get_config.side_effect = lambda key=None: { + "gs_config.strobing.kConnectionBoardVersion": "3", + "gs_config.strobing.kDAC_setting": None, + }.get(key) + + mgr = StrobeCalibrationManager(cm) + mgr._open_hardware = Mock() + mgr._close_hardware = Mock() + mgr._calibrate = Mock(return_value=(False, -1, -1)) + dac_calls = [] + mgr._set_dac = lambda v: dac_calls.append(v) + + result = await mgr.start_calibration(led_type="v3") + assert result["status"] == "failed" + assert 0x96 in dac_calls + + @pytest.mark.asyncio + @patch("strobe_calibration_manager.time.sleep") + async def test_always_closes_hardware(self, _sleep): + from strobe_calibration_manager import StrobeCalibrationManager + + cm = Mock() + cm.get_config.side_effect = lambda key=None: { + "gs_config.strobing.kConnectionBoardVersion": "3", + "gs_config.strobing.kDAC_setting": None, + }.get(key) + + mgr = StrobeCalibrationManager(cm) + mgr._open_hardware = Mock() + mgr._close_hardware = Mock() + mgr._calibrate = Mock(side_effect=RuntimeError("kaboom")) + + result = await mgr.start_calibration(led_type="v3") + assert result["status"] == "error" + mgr._close_hardware.assert_called_once() + + @pytest.mark.asyncio + @patch("strobe_calibration_manager.time.sleep") + async def test_status_transitions(self, _sleep): + from strobe_calibration_manager import StrobeCalibrationManager + + cm = Mock() + cm.get_config.side_effect = lambda key=None: { + "gs_config.strobing.kConnectionBoardVersion": "3", + "gs_config.strobing.kDAC_setting": None, + }.get(key) + cm.set_config.return_value = (True, "ok", False) + + mgr = StrobeCalibrationManager(cm) + mgr._open_hardware = Mock() + mgr._close_hardware = Mock() + mgr._calibrate = Mock(return_value=(True, 0x80, 9.5)) + + assert mgr.status["state"] == "idle" + await mgr.start_calibration(led_type="v3") + assert mgr.status["state"] == "complete" + assert mgr.status["progress"] == 100 + + +class TestCancel: + """cancel sets the flag and resets status""" + + def test_cancel_sets_flag(self): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + mgr.status["state"] = "calibrating" + mgr.cancel() + assert mgr._cancel_requested is True + + def test_cancel_when_idle(self): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + mgr.cancel() + # should not error, just a no-op + assert mgr._cancel_requested is True + + +class TestGetStatus: + """get_status returns a copy of the status dict""" + + def test_returns_status_copy(self): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + mgr.status["state"] = "calibrating" + mgr.status["progress"] = 42 + mgr.status["message"] = "sweeping" + + result = mgr.get_status() + assert result["state"] == "calibrating" + assert result["progress"] == 42 + assert result["message"] == "sweeping" + # should be a copy + result["state"] = "mutated" + assert mgr.status["state"] == "calibrating" + + +class TestReadDiagnostics: + """read_diagnostics bundles LDO + current + raw ADC reads""" + + @pytest.mark.asyncio + async def test_returns_all_readings(self): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + mgr._open_hardware = Mock() + mgr._close_hardware = Mock() + mgr.get_ldo_voltage = Mock(return_value=7.5) + mgr.get_led_current = Mock(return_value=9.2) + mgr._read_adc = Mock(side_effect=[1234, 2345]) + + result = await mgr.read_diagnostics() + + assert result["ldo_voltage"] == 7.5 + assert result["led_current"] == 9.2 + assert result["adc_ch0_raw"] == 1234 + assert result["adc_ch1_raw"] == 2345 + + @pytest.mark.asyncio + async def test_skips_current_when_ldo_unsafe(self): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + mgr._open_hardware = Mock() + mgr._close_hardware = Mock() + mgr.get_ldo_voltage = Mock(return_value=3.0) + mgr._read_adc = Mock(side_effect=[100, 200]) + + result = await mgr.read_diagnostics() + + assert result["ldo_voltage"] == 3.0 + assert result["led_current"] is None + assert "unsafe" in result.get("warning", "").lower() + + @pytest.mark.asyncio + async def test_closes_hardware_on_error(self): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + mgr._open_hardware = Mock() + mgr._close_hardware = Mock() + mgr.get_ldo_voltage = Mock(side_effect=RuntimeError("SPI gone")) + + result = await mgr.read_diagnostics() + assert result["status"] == "error" + mgr._close_hardware.assert_called_once() + + +class TestSetDacManual: + """set_dac_manual validates range and returns LDO check""" + + @pytest.mark.asyncio + async def test_rejects_out_of_range(self): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + + result = await mgr.set_dac_manual(256) + assert result["status"] == "error" + + result = await mgr.set_dac_manual(-1) + assert result["status"] == "error" + + @pytest.mark.asyncio + @patch("strobe_calibration_manager.time.sleep") + async def test_sets_dac_and_checks_ldo(self, _sleep): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + mgr._open_hardware = Mock() + mgr._close_hardware = Mock() + dac_calls = [] + mgr._set_dac = lambda v: dac_calls.append(v) + mgr.get_ldo_voltage = Mock(return_value=7.5) + + result = await mgr.set_dac_manual(0x80) + assert result["status"] == "success" + assert 0x80 in dac_calls + assert result["ldo_voltage"] == 7.5 + + @pytest.mark.asyncio + @patch("strobe_calibration_manager.time.sleep") + async def test_warns_when_ldo_below_min(self, _sleep): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + mgr._open_hardware = Mock() + mgr._close_hardware = Mock() + mgr._set_dac = lambda v: None + mgr.get_ldo_voltage = Mock(return_value=3.5) + + result = await mgr.set_dac_manual(0x10) + assert "warning" in result + + +class TestGetDacStart: + """get_dac_start runs the safe-start sweep""" + + @pytest.mark.asyncio + @patch("strobe_calibration_manager.time.sleep") + async def test_returns_start_and_ldo(self, _sleep): + from strobe_calibration_manager import StrobeCalibrationManager + + mgr = StrobeCalibrationManager(Mock()) + mgr._open_hardware = Mock() + mgr._close_hardware = Mock() + mgr._find_dac_start = Mock(return_value=(99, 7.0)) + mgr._set_dac = Mock() + mgr.get_ldo_voltage = Mock(return_value=7.0) + + result = await mgr.get_dac_start() + assert result["dac_start"] == 99 + assert result["ldo_voltage"] == 7.0 + + +class TestGetSavedSettings: + """get_saved_settings reads kDAC_setting from config""" + + @pytest.mark.asyncio + async def test_returns_saved_value(self): + from strobe_calibration_manager import StrobeCalibrationManager + + cm = Mock() + cm.get_config.return_value = 150 + + mgr = StrobeCalibrationManager(cm) + result = await mgr.get_saved_settings() + assert result["dac_setting"] == 150 + + @pytest.mark.asyncio + async def test_returns_none_when_unset(self): + from strobe_calibration_manager import StrobeCalibrationManager + + cm = Mock() + cm.get_config.return_value = None + + mgr = StrobeCalibrationManager(cm) + result = await mgr.get_saved_settings() + assert result["dac_setting"] is None From 9e0225fa578e945c8e33ee87457cfe97dd02f0d5 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 21 Mar 2026 13:29:23 -0400 Subject: [PATCH 15/28] wire strobe calibration API endpoints into server.py --- Software/web-server/server.py | 58 ++++++ .../tests/test_strobe_calibration_api.py | 168 ++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 Software/web-server/tests/test_strobe_calibration_api.py 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/tests/test_strobe_calibration_api.py b/Software/web-server/tests/test_strobe_calibration_api.py new file mode 100644 index 00000000..c0a05116 --- /dev/null +++ b/Software/web-server/tests/test_strobe_calibration_api.py @@ -0,0 +1,168 @@ +"""Tests for strobe calibration API endpoints""" + +import pytest +from unittest.mock import Mock, AsyncMock, patch + + +@pytest.mark.unit +class TestStrobeCalibrationAPI: + """Test suite for strobe calibration API endpoints""" + + @pytest.fixture + def mock_strobe_manager(self): + """Create a mock strobe calibration manager""" + manager = Mock() + manager.get_status = Mock( + return_value={"state": "idle", "progress": 0, "message": ""} + ) + manager.get_saved_settings = AsyncMock( + return_value={"dac_setting": None} + ) + manager.start_calibration = AsyncMock( + return_value={"status": "success", "dac_setting": 150, "led_current": 9.85} + ) + manager.cancel = Mock() + manager.read_diagnostics = AsyncMock( + return_value={ + "ldo_voltage": 7.42, + "adc_ch0_raw": 1234, + "adc_ch1_raw": 2048, + "led_current": 9.5, + } + ) + manager.set_dac_manual = AsyncMock( + return_value={"status": "success", "dac_value": 128, "ldo_voltage": 6.8} + ) + manager.get_dac_start = AsyncMock( + return_value={"dac_start": 200, "ldo_voltage": 5.1} + ) + return manager + + def test_get_status(self, client, server_instance, mock_strobe_manager): + """Status endpoint returns expected shape""" + server_instance.strobe_calibration_manager = mock_strobe_manager + + response = client.get("/api/strobe-calibration/status") + assert response.status_code == 200 + + data = response.json() + assert "state" in data + assert "progress" in data + assert "message" in data + assert data["state"] == "idle" + + def test_get_settings(self, client, server_instance, mock_strobe_manager): + """Settings endpoint returns saved DAC value""" + server_instance.strobe_calibration_manager = mock_strobe_manager + + response = client.get("/api/strobe-calibration/settings") + assert response.status_code == 200 + + data = response.json() + assert "dac_setting" in data + + def test_start_calibration(self, client, server_instance, mock_strobe_manager): + """Start endpoint accepts parameters and returns immediately""" + server_instance.strobe_calibration_manager = mock_strobe_manager + + response = client.post( + "/api/strobe-calibration/start", + json={"led_type": "v3", "target_current": 10.0, "overwrite": True}, + ) + assert response.status_code == 200 + + data = response.json() + assert data["status"] == "started" + + def test_start_calibration_defaults(self, client, server_instance, mock_strobe_manager): + """Start endpoint works with empty body (all defaults)""" + server_instance.strobe_calibration_manager = mock_strobe_manager + + response = client.post("/api/strobe-calibration/start", json={}) + assert response.status_code == 200 + assert response.json()["status"] == "started" + + def test_cancel(self, client, server_instance, mock_strobe_manager): + """Cancel endpoint calls manager.cancel and returns ok""" + server_instance.strobe_calibration_manager = mock_strobe_manager + + response = client.post("/api/strobe-calibration/cancel") + assert response.status_code == 200 + + data = response.json() + assert data["status"] == "ok" + mock_strobe_manager.cancel.assert_called_once() + + def test_get_diagnostics(self, client, server_instance, mock_strobe_manager): + """Diagnostics endpoint returns hardware readings""" + server_instance.strobe_calibration_manager = mock_strobe_manager + + response = client.get("/api/strobe-calibration/diagnostics") + assert response.status_code == 200 + + data = response.json() + assert "ldo_voltage" in data + assert "led_current" in data + + def test_set_dac_valid(self, client, server_instance, mock_strobe_manager): + """Set-dac endpoint accepts a valid integer""" + server_instance.strobe_calibration_manager = mock_strobe_manager + + response = client.post( + "/api/strobe-calibration/set-dac", json={"value": 128} + ) + assert response.status_code == 200 + + data = response.json() + assert data["status"] == "success" + mock_strobe_manager.set_dac_manual.assert_called_once_with(128) + + def test_set_dac_missing_value(self, client, server_instance, mock_strobe_manager): + """Set-dac rejects request with no value""" + server_instance.strobe_calibration_manager = mock_strobe_manager + + response = client.post("/api/strobe-calibration/set-dac", json={}) + assert response.status_code == 200 + + data = response.json() + assert data["status"] == "error" + assert "value" in data["message"].lower() + + def test_set_dac_non_integer(self, client, server_instance, mock_strobe_manager): + """Set-dac rejects a non-integer value""" + server_instance.strobe_calibration_manager = mock_strobe_manager + + response = client.post( + "/api/strobe-calibration/set-dac", json={"value": "abc"} + ) + assert response.status_code == 200 + + data = response.json() + assert data["status"] == "error" + + def test_get_dac_start(self, client, server_instance, mock_strobe_manager): + """Dac-start endpoint returns boundary value""" + server_instance.strobe_calibration_manager = mock_strobe_manager + + response = client.get("/api/strobe-calibration/dac-start") + assert response.status_code == 200 + + data = response.json() + assert "dac_start" in data + assert "ldo_voltage" in data + + def test_status_during_calibration(self, client, server_instance, mock_strobe_manager): + """Status reflects in-progress calibration""" + mock_strobe_manager.get_status.return_value = { + "state": "calibrating", + "progress": 45, + "message": "Sweeping DAC", + } + server_instance.strobe_calibration_manager = mock_strobe_manager + + response = client.get("/api/strobe-calibration/status") + assert response.status_code == 200 + + data = response.json() + assert data["state"] == "calibrating" + assert data["progress"] == 45 From 365dfa09a9e67bfed9ece42485d30752852f483d Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 21 Mar 2026 13:34:56 -0400 Subject: [PATCH 16/28] add strobe calibration UI to calibration page --- .../web-server/static/css/calibration.css | 102 ++++++- Software/web-server/static/js/calibration.js | 248 ++++++++++++++++++ .../web-server/templates/calibration.html | 76 ++++++ 3 files changed, 421 insertions(+), 5 deletions(-) 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..557dc40b 100644 --- a/Software/web-server/static/js/calibration.js +++ b/Software/web-server/static/js/calibration.js @@ -25,6 +25,8 @@ class CalibrationManager { await this.loadCalibrationData(); + await this.loadStrobeSettings(); + this.setupEventListeners(); this.startStatusPolling(); @@ -52,6 +54,11 @@ class CalibrationManager { this.statusPollInterval = null; } + if (this.strobePollingTimer) { + clearTimeout(this.strobePollingTimer); + this.strobePollingTimer = null; + } + this.cameraPollIntervals.forEach((intervalId) => { clearInterval(intervalId); }); @@ -634,6 +641,247 @@ class CalibrationManager { } }, 5000); } + + // -- Strobe Calibration -- + + async loadStrobeSettings() { + try { + const [settingsRes, configRes] = await Promise.all([ + fetch('/api/strobe-calibration/settings'), + fetch('/api/config') + ]); + + 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' + settings.dac_setting.toString(16).toUpperCase().padStart(4, '0'); + } else { + dacEl.textContent = 'Not calibrated'; + } + } + + if (configRes.ok) { + const config = await configRes.json(); + const boardEl = document.getElementById('strobe-board-version'); + const boardVersion = config.hardware?.board_version || config.system?.board_version; + boardEl.textContent = boardVersion || 'Unknown'; + } + } 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 = 'Calibrate'; + 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(4, '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 btn = document.getElementById('strobe-calibrate-btn'); + const cancelBtn = document.getElementById('strobe-cancel-btn'); + btn.disabled = false; + btn.textContent = 'Calibrate'; + cancelBtn.style.display = 'none'; + + 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 btn = document.getElementById('strobe-calibrate-btn'); + const cancelBtn = document.getElementById('strobe-cancel-btn'); + btn.disabled = false; + btn.textContent = 'Calibrate'; + cancelBtn.style.display = 'none'; + + document.getElementById('strobe-state').textContent = 'Idle'; + document.getElementById('strobe-progress-message').textContent = 'Cancelled by user'; + } + + 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 !== undefined ? data.ldo_voltage.toFixed(3) + ' V' : '--' }, + { label: 'LED Current', value: data.led_current !== undefined ? data.led_current.toFixed(3) + ' A' : '--' }, + { label: 'ADC CH0 Raw', value: data.adc_ch0_raw !== undefined ? data.adc_ch0_raw.toString() : '--' }, + { label: 'ADC CH1 Raw', value: data.adc_ch1_raw !== undefined ? data.adc_ch1_raw.toString() : '--' } + ]; + + 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/templates/calibration.html b/Software/web-server/templates/calibration.html index 71c9a37c..d3e24e9e 100644 --- a/Software/web-server/templates/calibration.html +++ b/Software/web-server/templates/calibration.html @@ -285,6 +285,82 @@

Next Steps:

+
+
+

Strobe Calibration

+
+
+ Board: + Loading... +
+
+ Saved DAC: + Loading... +
+
+ State: + Idle +
+
+
+ +
+
+ + +
+
+ + + +
+
+ + + + + + +
+

Current Calibration Data

From 1e435770645f18cdf23255158a0dc1e01905ea66 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 21 Mar 2026 13:46:12 -0400 Subject: [PATCH 17/28] use DigitalOutputDevice instead of LED, harden close and gc handling --- .../web-server/strobe_calibration_manager.py | 59 +++++++++++-------- .../tests/test_strobe_calibration_manager.py | 20 ++++--- 2 files changed, 47 insertions(+), 32 deletions(-) diff --git a/Software/web-server/strobe_calibration_manager.py b/Software/web-server/strobe_calibration_manager.py index 269a714c..70d54f02 100644 --- a/Software/web-server/strobe_calibration_manager.py +++ b/Software/web-server/strobe_calibration_manager.py @@ -19,9 +19,9 @@ spidev = None try: - from gpiozero import LED + from gpiozero import DigitalOutputDevice except ImportError: - LED = None + DigitalOutputDevice = None class StrobeCalibrationManager: @@ -84,7 +84,7 @@ def __init__(self, config_manager): def _open_hardware(self): if spidev is None: raise RuntimeError("spidev library not available -- not running on a Raspberry Pi?") - if LED is None: + if DigitalOutputDevice is None: raise RuntimeError("gpiozero library not available -- not running on a Raspberry Pi?") self._spi_dac = spidev.SpiDev() @@ -97,16 +97,24 @@ def _open_hardware(self): self._spi_adc.max_speed_hz = self.SPI_MAX_SPEED_HZ self._spi_adc.mode = 0 - self._diag_pin = LED(self.DIAG_GPIO_PIN) + self._diag_pin = DigitalOutputDevice(self.DIAG_GPIO_PIN) def _close_hardware(self): - if self._diag_pin is not None: - self._diag_pin.off() - self._diag_pin.close() - if self._spi_dac is not None: - self._spi_dac.close() - if self._spi_adc is not None: - self._spi_adc.close() + 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() + 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 @@ -140,23 +148,24 @@ def get_led_current(self) -> float: gc.disable() 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: - param = os.sched_param(0) - os.sched_setscheduler(0, os.SCHED_OTHER, param) + 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] diff --git a/Software/web-server/tests/test_strobe_calibration_manager.py b/Software/web-server/tests/test_strobe_calibration_manager.py index b607e1ab..34a7dedd 100644 --- a/Software/web-server/tests/test_strobe_calibration_manager.py +++ b/Software/web-server/tests/test_strobe_calibration_manager.py @@ -49,7 +49,7 @@ class TestOpenHardware: """_open_hardware sets up SPI and GPIO""" @patch("strobe_calibration_manager.spidev") - @patch("strobe_calibration_manager.LED") + @patch("strobe_calibration_manager.DigitalOutputDevice") def test_open_creates_spi_and_gpio(self, mock_led_cls, mock_spidev_mod): from strobe_calibration_manager import StrobeCalibrationManager @@ -89,15 +89,21 @@ def test_close_calls_close_on_all(self): from strobe_calibration_manager import StrobeCalibrationManager mgr = StrobeCalibrationManager(Mock()) - mgr._spi_dac = MagicMock() - mgr._spi_adc = MagicMock() - mgr._diag_pin = MagicMock() + mock_dac = MagicMock() + mock_adc = MagicMock() + mock_diag = MagicMock() + mgr._spi_dac = mock_dac + mgr._spi_adc = mock_adc + mgr._diag_pin = mock_diag mgr._close_hardware() - mgr._spi_dac.close.assert_called_once() - mgr._spi_adc.close.assert_called_once() - mgr._diag_pin.close.assert_called_once() + mock_dac.close.assert_called_once() + mock_adc.close.assert_called_once() + mock_diag.close.assert_called_once() + assert mgr._spi_dac is None + assert mgr._spi_adc is None + assert mgr._diag_pin is None def test_close_tolerates_none_refs(self): from strobe_calibration_manager import StrobeCalibrationManager From 839c7b0aedfe84faa6c33ff088a243ee240c598c Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 21 Mar 2026 13:46:47 -0400 Subject: [PATCH 18/28] remove standalone CLI calibration script, replaced by web UI --- .../web-server/calibrate_strobe_output.py | 643 ------------------ 1 file changed, 643 deletions(-) delete mode 100644 Software/web-server/calibrate_strobe_output.py diff --git a/Software/web-server/calibrate_strobe_output.py b/Software/web-server/calibrate_strobe_output.py deleted file mode 100644 index 6aeef664..00000000 --- a/Software/web-server/calibrate_strobe_output.py +++ /dev/null @@ -1,643 +0,0 @@ -#!/usr/bin/env python3 -""" -SPDX-License-Identifier: GPL-2.0-only */ -Copyright (C) 2022-2025, Verdant Consultants, LLC. - -PiTrac Controller Board Strobe Output Calibration Module - -Adjusts the strobe output to a default (or selected) current output by sending a range of values to the DAC -on the board (via SPI) and then repeatedly checking the ADC to see if the desired output has been -reached (as the LED current iteratively goes down as the DAC output goes down). - -WARNING ------ This code has not been tested against actual hardware yet, so it could still be harmful - to whatever hardware you are running on. Caveat emptor! - Please run this step by step in a debugger to make sure that it is sending appropriate - data to the Controller Board. - -""" -import spidev -from gpiozero import LED -import time -import sys -import argparse -import os -import gc - - -import logging - -# We expect to be running this utility in the standard directory where PiTrac is installed, and where there -# should be the config.manager.py and configurations.json files. If running somewhere else, the user will need to either put -# a copy of those files there, or else set the PiTrac home directory appropriately. -# That directory is typically at: /usr/lib/pitrac/web-server -DEFAULT_PITRAC_PATH = "/usr/lib/pitrac/web-server" -sys.path.append(DEFAULT_PITRAC_PATH) - -from config_manager import ConfigurationManager - - -logger = logging.getLogger(__name__) - - -class StrobeOutputCalibrator: - - # The final DAC setting and corresponding ADC output will be saved to the user_settings.json file, - # so we can read them later if needed. These are the search keys for those values in the JSON file. - DAC_SETTING_JSON_PATH = "gs_config.strobing.kDAC_setting" - - # SPI bus 1 (SPI1/auxiliary SPI) and device numbers for the Raspberry Pi. - SPI_BUS = 1 - SPI_DAC_DEVICE = 0 # DAC is on CS0 - SPI_ADC_DEVICE = 1 # ADC is on CS1 - - # Max speed for the ADC is 1.1 MHz and DAC is 20 MHz - SPI_MAX_SPEED_HZ = 1000000 # 1 MHz - - # This is the pin that we will use to toggle the strobe output on and off through the DIAG pin - # on the Connector Board. The strobe needs to be on for the ADC to read the LED current, - # so we will toggle this pin on just before reading the ADC, and then toggle it off just after. - # Note - this corresponds to the BCM pin number, not the physical pin number. - # So this is physical pin 19 on the Raspberry Pi header. - DIAG_GPIO_PIN = 10 - - # This is the maximum safe strobe current for the V3 LED - V3_TARGET_LED_CURRENT_SETTING = 10.0 # amps - # This is the maximum safe strobe current for the old 100W LED - OLD_TARGET_LED_CURRENT_SETTING = 9.0 # amps - - - # We should NEVER go below this LDO voltage - ABSOLUTE_LOWEST_LDO_VOLTAGE = 4.5 # volts - ABSOLUTE_HIGHEST_LDO_VOLTAGE = 11 # volts - - MAX_DAC_SETTING = 0xFF # 255 # It's an 8-bit DAC, so max value is 2^8 - 1 - MIN_DAC_SETTING = 0 - - # This value is just a guess for now and works on 1 board. It should be high enough so that the LDO voltage in all devices - # (even accounting for variations) will be low enough not to hurt our standard strobe, but high enough to be above the minimum LDO voltage. - # This settins will only be used to set the DAC to a known, safe level if there's some failure - # in the calibration process. - PRESUMED_SAFE_DAC_SETTING = 0x96 # 150 - - # For the MCP4801 commands, see: https://ww1.microchip.com/downloads/en/DeviceDoc/22244B.pdf - MCP4801_BASE_WRITE_CMD_SET_OUTPUT = 0b00110000 # Standard (1x) gain (bit 13) (Vout=Vref*D/4096), VREF buffered, active, no shutdown, D4-D7 are 0 - - # For the MCP3202 commands, see: https://ww1.microchip.com/downloads/en/DeviceDoc/21034F.pdf - MCP3202_READ_CH0_SINGLE_ENDED_CMD = 0 | 0x80 # Channel 0, single-ended - LED Current Sense Resistor Voltage - MCP3202_READ_CH1_SINGLE_ENDED_CMD = 0 | 0xc0 # Channel 1, single-ended - LDO Gate Voltage - - # Will be set when the class is initialized - spi_dac = None - spi_adc = None - - diag_pin = None - - def __init__(self): - - self.config_manager = ConfigurationManager() - - - def setup_spi_channels(self): - logger.debug(f"Setting up SPI channels...") - - success = False - - try: - # DAC Setup - self.spi_dac = spidev.SpiDev() - self.spi_dac.open(self.SPI_BUS, self.SPI_DAC_DEVICE) - - # Set SPI speed - self.spi_dac.max_speed_hz = self.SPI_MAX_SPEED_HZ - - # Set SPI mode (common modes are 0 or 3) - self.spi_dac.mode = 0b00 # Mode 0 - - - # ADC Setup - self.spi_adc = spidev.SpiDev() - self.spi_adc.open(self.SPI_BUS, self.SPI_ADC_DEVICE) - - # Set SPI speed - self.spi_adc.max_speed_hz = self.SPI_MAX_SPEED_HZ - - # Set SPI mode (common modes are 0 or 3) - self.spi_adc.mode = 0b00 # Mode 0 - - - # Signal that all went well with the SPI setup - success = True - - except Exception as e: - logger.error(f"An error occurred when setting up the SPI connections: {e}") - success = False - - return success - - - def close_spi(self): - if self.spi_dac is not None: - self.spi_dac.close() # Always close the SPI connection when done - if self.spi_adc is not None: - self.spi_adc.close() - - def open_gpio_system(self): - logger.debug(f"Setting up GPIO pin {self.DIAG_GPIO_PIN}...") - - success = False - - try: - self.diag_pin = LED(self.DIAG_GPIO_PIN) # Use Broadcom pin-numbering scheme - - # Signal that all went well with the SPI setup - success = True - - except Exception as e: - logger.error(f"An error occurred when setting up the GPIO system: {e}") - success = False - - return success - - def close_gpio_system(self): - # Cleanup all GPIO pins to their default state - if self.diag_pin is not None: - self.diag_pin.close() - - def _get_ADC_value(self, channel_cmd): - # Start bit is always the first byte, then the channel and mode bits are combined into the second byte, - # and the third byte is just a timing placeholder for the response, because we need the last 2 of 3 bytes of response, - # but our command is only 2 bytes - message_to_send = [0x01, channel_cmd, 0x00] - logger.debug(f"Message to send to ADC (to get value): {[format(b, '02x') for b in message_to_send]}") - - response_bytes = self.spi_adc.xfer2(message_to_send) - - # The result is 12-bits. The first byte returned is just random - the MISO line is null - # when the command is sent, so nothing was really sent. - # The second byte contains the top 4 bits (masked with 0x0F as some bits may be null) - # The third byte contains the least-significant 8 bits - - # Put the top 4 bits and lower 8 bits together to get the full 12-bit ADC value - adc_value = (response_bytes[1] & 0x0F) << 8 | response_bytes[2] - - return adc_value - - def get_ADC_value_CH0(self): - return self._get_ADC_value(self.MCP3202_READ_CH0_SINGLE_ENDED_CMD) - - def get_ADC_value_CH1(self): - return self._get_ADC_value(self.MCP3202_READ_CH1_SINGLE_ENDED_CMD) - - - def get_LDO_voltage(self): - # We need to measure LDO voltage to make sure we are safe to raise the DIAG pin to high - adc_value = self.get_ADC_value_CH1() - - # *3 because of the resistor divider made up of 2k top and 1k bottom, so (1 / (2 + 1)) scaling factor - LDO_voltage = (3.3 / 4096) * adc_value * 3 # Convert ADC value to voltage - return LDO_voltage - - def get_LED_current(self): - # We need to turn on the strobe output through the DIAG pin before we read the ADC, - # because a valid LED current sense voltage is only present when the strobe is on. - # and then turn it right back off again. - message_to_send = [0x01, self.MCP3202_READ_CH0_SINGLE_ENDED_CMD, 0x00] - logger.debug(f"Message to send to ADC (to get value): {[format(b, '02x') for b in message_to_send]}") - - spi = self.spi_adc - diag = self.diag_pin - - # --- PREPARE FOR CRITICAL TIMING --- - - # Disable Python's random memory cleaning - gc.disable() - - # Grab the highest possible Real-Time OS Priority - try: - param = os.sched_param(os.sched_get_priority_max(os.SCHED_FIFO)) - os.sched_setscheduler(0, os.SCHED_FIFO, param) - except (PermissionError, AttributeError): - logger.debug(f"WARNING: sudo permissions not established or OS scheduling priority not supported.") - pass # Fails if not running as root/sudo or on Windows - - # Yield CPU to get a fresh, full time-slice from Linux - time.sleep(0) - - - try: - # --- BEGIN DETERMINISTIC HARDWARE BLOCK --- - diag.on() - response_bytes = spi.xfer2(message_to_send) - # --- END DETERMINISTIC HARDWARE BLOCK --- - - finally: - # --- RETURN TO NORMAL OS BEHAVIOR --- - - # Always turn off DIAG first — leaving it HIGH means the strobe stays on - diag.off() - - # Give up real-time priority (return to normal scheduler) - try: - param = os.sched_param(0) - os.sched_setscheduler(0, os.SCHED_OTHER, param) - except (PermissionError, AttributeError): - pass - - # Turn memory management back on - gc.enable() - - adc_value = (response_bytes[1] & 0x0F) << 8 | response_bytes[2] - LED_current = (3.3 / 4096) * adc_value * 10 # Convert ADC value to current - return LED_current - - def turn_diag_pin_off(self): - logger.debug(f"turn_diag_pin_off") - if self.diag_pin is not None: - self.diag_pin.off() - - - def short_pause(self): - time.sleep(0.1) - - - def set_DAC(self, value): - msb_data = self.MCP4801_BASE_WRITE_CMD_SET_OUTPUT | ((value >> 4) & 0x0F) # Get the top 4 bits of the value and combine with the command - - lsb_data = (value << 4) & 0xF0 # Get the bottom 4 bits of the value into the top 4 bits of the second byte (the bottom 4 bits of the second byte - # are ignored by the DAC, so it doesn't matter what we put there) - - message_to_send = [msb_data, lsb_data] # Get the pot value - - logger.debug(f"\nset_DAC: Message to send to DAC: {[format(b, '02x') for b in message_to_send]}") - # We don't use the response - response = self.spi_dac.xfer2(message_to_send) - - - def get_calibration_settings_from_json(self): - - current_DAC_output_value = -1 - current_DAC_setting = self.config_manager.get_config(self.DAC_SETTING_JSON_PATH) - if current_DAC_setting is None: - logger.debug(f"Current DAC Setting: ") - else: - logger.debug(f"Current DAC Setting: {current_DAC_setting}") - current_DAC_output_value = int(current_DAC_setting) - - return current_DAC_output_value - - - def set_DAC_to_safest_level(self): - logger.error(f"Calibration failed. Setting DAC voltage to highest level of {self.PRESUMED_SAFE_DAC_SETTING} (presumed-safe strobe level) for safety.") - self.set_DAC(self.PRESUMED_SAFE_DAC_SETTING) - - - def json_file_has_calibration_settings(self): - logger.debug(f"Checking whether json_file_has_calibration_settings") - current_DAC_setting = self.config_manager.get_config(self.DAC_SETTING_JSON_PATH) - if current_DAC_setting is None: - return False - else: - return True - - def find_DAC_start_setting(self): - DAC_max_setting = self.MAX_DAC_SETTING + 1 - for i in range(DAC_max_setting): - DAC_start_setting = i - # set DAC value - self.set_DAC(i) - # wait for DAC value to take effect - self.short_pause() - # check the LDO voltage - LDO_voltage = self.get_LDO_voltage() - logger.debug(f"DAC Value: {format(i, '02x')}, Computed LDO voltage (from ADC): {format(LDO_voltage, '0.2f')}" ) - # if LDO voltage drops below ABSOLUTE_LOWEST_LDO_VOLTAGE then break the loop - if LDO_voltage < self.ABSOLUTE_LOWEST_LDO_VOLTAGE: - # set starting DAC value to lowest voltage above the absolute minimum - DAC_start_setting = i - 1 - break - - return DAC_start_setting, LDO_voltage - - - def calibrate_board(self, target_LED_current): - - # find the minimum safe LDO voltage to supply the MCP1407 gate driver - DAC_start_setting, LDO_voltage = self.find_DAC_start_setting() - - # If even a DAC value of 0 was below the ABSOLUTE_LOWEST_LDO_VOLTAGE then fail calibration - if DAC_start_setting < 0: - logger.debug(f"DAC value of 0 is below minimum LDO voltage ({format(self.ABSOLUTE_LOWEST_LDO_VOLTAGE, '0.2f')}): {format(LDO_voltage, '0.2f')}") - return False, -1, -1 - - logger.debug(f"calibrate_board called with target_LED_current = {target_LED_current}, DAC_start_setting = 0x{format(DAC_start_setting, '02x')}") - - # Now, starting at the max DAC value (0xFF) - # we will iteratively decrease the DAC setting until we get to - # the desired ADC output (or just under it) - - current_DAC_setting = DAC_start_setting - final_DAC_setting = self.MIN_DAC_SETTING - - # just picking a number that we should always be above at the start of the loop, - # so that we can save the first reading as the best one so far even if it's not - # above the target - max_LED_current_so_far = 0.0 - - # We will start at the max DAC setting and then count down while - # looking for the point where the corresponding LED current goes just above the target_LED_current, - # then increase 1 value to ensure we are <= target_LED_current. - - logger.debug(f"calibrate_board starting loop. Desired output is {target_LED_current}") - - # Stop immediately if we ever have an error - while (current_DAC_setting >= self.MIN_DAC_SETTING): - - self.set_DAC(current_DAC_setting) - - # Wait a moment for the setting to take effect - self.short_pause() - - # check the LDO voltage to ensure that we are within the safe bounds - LDO_voltage = self.get_LDO_voltage() - # if we are below the ABSOLUTE_LOWEST_LDO_VOLTAGE, it is unsafe to pulse the DIAG pin. Decrease DAC value and continue - if LDO_voltage < self.ABSOLUTE_LOWEST_LDO_VOLTAGE: - logger.debug(f"Measured LDO_voltage ({LDO_voltage}) was below ABSOLUTE_LOWEST_LDO_VOLTAGE of {self.ABSOLUTE_LOWEST_LDO_VOLTAGE} volts. Trying next DAC value.") - # Continue counting down - final_DAC_setting = current_DAC_setting - current_DAC_setting -= 1 - continue - - # if we are above the ABSOLUTE_HIGHEST_LDO_VOLTAGE, then we have to stop and fail the calibration - if LDO_voltage > self.ABSOLUTE_HIGHEST_LDO_VOLTAGE: - logger.debug(f"Measured LDO_voltage ({LDO_voltage}) was above ABSOLUTE_HIGHEST_LDO_VOLTAGE of {self.ABSOLUTE_HIGHEST_LDO_VOLTAGE} volts. Stopping calibration, as something is wrong.") - return False, -1, -1 - - # Note reading the LED current also pulses the strobe through the DIAG pin, - # which is necessary to get a valid reading, but also means that we are toggling - # the strobe on and off repeatedly during this calibration process, which is not ideal. - # But we need to do it in order to get accurate LED current readings. - LED_current = self.get_LED_current() - logger.debug(f"current_DAC_setting: {format(current_DAC_setting, '02x')}, LED_current: {LED_current}") - - # As we are slowly increasing the LED current, have we reached our desired set-point for the LED current yet? - if LED_current > target_LED_current: - logger.debug(f" ---> Reached above the target_LED_current ({target_LED_current}). LED_current is: {LED_current}. Stopping calibration here...") - final_DAC_setting = current_DAC_setting + 1 # Step back to the last setting that was just before we reached our target - break - - # We have not yet reached the target. TBD - This is a little redundant, maybe change - # Keep track of where we were. - if LED_current > max_LED_current_so_far: - - # Save the current output as the best one so far, even if it's not over the target, - # because we want to get as close as possible without going over - max_LED_current_so_far = LED_current - - # Continue counting down - final_DAC_setting = current_DAC_setting - current_DAC_setting -= 1 - - # There are a couple of possible edge cases here. And either of them indicate that something probably went wrong somewhere even if we - # thought we had a success. - # If so, err on the safe side and consider this a failure - if current_DAC_setting <= self.MIN_DAC_SETTING: - logger.debug(f"Reached MIN_DAC_SETTING ({self.MIN_DAC_SETTING}) without ever reaching target_LED_current ({target_LED_current}). This generally indicates a problem. Failing calibration.") - return False, -1, -1 - if current_DAC_setting >= self.MAX_DAC_SETTING: - logger.debug(f"The MAX_DAC_SETTING resulted in an LED current above the target. This generally indicates a problem. Failing calibration.") - return False, -1, -1 - - # Now, using the best DAC setting we found, average the output voltage a few times to - # get a more accurate reading of the output voltage at that setting - # take an average of n pulses - n = 10 - while True: - self.set_DAC(final_DAC_setting) - self.short_pause() - - # check if LDO voltage is above the minimum - LDO_voltage = self.get_LDO_voltage() - if LDO_voltage < self.ABSOLUTE_LOWEST_LDO_VOLTAGE: - # Fallback to the last known good measurement - final_DAC_setting -= 1 - break - - # Take an average of n pulses - LED_current_sum = 0 - for _ in range(n): - LED_current_sum += self.get_LED_current() - self.short_pause() - LED_current = LED_current_sum / n - - if LED_current > target_LED_current: - # Current is still slightly too high, step the DAC setting - final_DAC_setting += 1 - if final_DAC_setting > self.MAX_DAC_SETTING: - logger.error(f"Averaging loop exceeded MAX_DAC_SETTING ({self.MAX_DAC_SETTING}) without reaching target current. Failing calibration.") - return False, -1, -1 - else: - # We are at or below the target current, we're done - break - - logger.debug(f"calibrate_board -- final_DAC_setting: {format(final_DAC_setting, '02x')}, LED_current: {LED_current}") - - return True, final_DAC_setting, LED_current - - - def cleanup_for_exit(self): - self.turn_diag_pin_off() - self.short_pause() - self.close_spi() - self.close_gpio_system() - - # ----------------------- - - -def main(): - - success = True - - # The calibrator class does all of the work here - calibrator = StrobeOutputCalibrator() - - parser = argparse.ArgumentParser(description="PiTrac Controller Board Strobe Output Calibrator. This tool iteratively adjusts the board's DAC in order to find the right setting for the desired LED current for the strobe LED circuit.\nWARNING - Setting the LDO voltage below 4.5v can break your Control Board.") - parser.add_argument("-o", "--old_LED", action="store_true", help="PiTrac is using old 100W LED. Default behavior is V3 LED") - parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") - parser.add_argument("-q", "--quiet", action="store_true", help="Quiet output") - parser.add_argument("-w", "--overwrite", action="store_true", help="Overwrites any existing strobe setting in user_settings.json") - parser.add_argument("--target_output", default=0,type=float, help="Set target LED current output (in amps) (ADVANCED)") - parser.add_argument("--ignore", action="store_true", help="Attempt calibration even if the Controller Board version is not 3.0 (ADVANCED)") - action_group = parser.add_mutually_exclusive_group() - action_group.add_argument("-p", "--print_settings", action="store_true", help="Print the current DAC setting and last ADC measurement from the user_settings.json file") - action_group.add_argument("-a0", "--read_ADC_CH0", action="store_true", help="Measure and print the current (12-bit) ADC CH0 measurement from the Connector Board") - action_group.add_argument("-a1", "--read_ADC_CH1", action="store_true", help="Measure and print the current (12-bit) ADC CH1 measurement from the Connector Board)") - action_group.add_argument("-c", "--read_LED_current", action="store_true", help="Compute and print the current LED current (based on the ADC CH0 measurement) from the Connector Board") - action_group.add_argument("-l", "--read_LDO_voltage", action="store_true", help="Compute and print the current LDO value (based on the ADC CH1 measurement) from the Connector Board") - action_group.add_argument("--DAC_setting", default=None,type=int, help="Set the DAC input to a specific value. Value is 8 bits long (ADVANCED).") - action_group.add_argument("--get_DAC_start", action="store_true", help="Check LDO voltage while sweeping DAC values to find lowest safe value") - - args = parser.parse_args() - - if args.verbose: - logging.basicConfig(level=logging.DEBUG) - elif args.quiet: - logging.basicConfig(level=logging.WARNING) - else: - logging.basicConfig(level=logging.INFO) - - - logger.debug(f"Calibrator initialized") - - if not calibrator.setup_spi_channels(): - logger.error(f"SPI initialization failed. Cannot proceed with calibration.") - return 1 - - if not calibrator.open_gpio_system(): - logger.error(f"GPIO initialization failed. Cannot proceed with calibration.") - calibrator.close_gpio_system() - return 1 - - try: - - # Process other options - - if args.read_ADC_CH0: - ADC_response = calibrator.get_ADC_value_CH0() - logger.info(f"Value read from ADC: {format(ADC_response, '02x')}" ) - - elif args.read_ADC_CH1: - ADC_response = calibrator.get_ADC_value_CH1() - logger.info(f"Value read from ADC: {format(ADC_response, '02x')}" ) - - elif args.read_LDO_voltage: - LDO_Voltage = calibrator.get_LDO_voltage() - logger.info(f"Computed LDO voltage (from ADC): {format(LDO_Voltage, '0.2f')}" ) - - elif args.read_LED_current: - - # ensure that the LDO voltage is within safe bounds before pulsing the DIAG pin - LDO_Voltage = calibrator.get_LDO_voltage() - if LDO_Voltage > calibrator.ABSOLUTE_LOWEST_LDO_VOLTAGE: - LED_current = calibrator.get_LED_current() - logger.info(f"Computed LED current (from ADC): {format(LED_current, '0.2f')}" ) - else: - logger.warning(f"LDO voltage is below minimum value, cannot safely pulse DIAG pin to get LED current. LDO Voltage: {format(LDO_Voltage, '0.2f')}") - return 1 - - elif args.DAC_setting is not None: - desired_DAC_setting = args.DAC_setting - - # Check if desired DAC setting is within the allowable bounds of MIN_DAC_SETTING and MAX_DAC_SETTING - if desired_DAC_setting > calibrator.MAX_DAC_SETTING: - logger.warning(f"Maximum allowable DAC setting is: {format(calibrator.MAX_DAC_SETTING, '02x')}" ) - return 1 - if desired_DAC_setting < calibrator.MIN_DAC_SETTING: - logger.warning(f"Minimum allowable DAC setting is: {format(calibrator.MIN_DAC_SETTING, '02x')}" ) - return 1 - - # Set the DAC value - calibrator.set_DAC(desired_DAC_setting) - # Wait a moment for the setting to take effect - calibrator.short_pause() - - # check the LDO voltage - LDO_voltage = calibrator.get_LDO_voltage() - logger.warning(f"DAC is set to: {format(desired_DAC_setting, '02x')}" ) - if LDO_voltage < calibrator.ABSOLUTE_LOWEST_LDO_VOLTAGE: - logger.warning(f"LDO voltage is below minimum value. This is VERY DANGEROUS. If this is unintentional, run --get_DAC_start to find the minimum safe DAC value.") - - elif args.print_settings: - DAC_value = calibrator.get_calibration_settings_from_json() - - if DAC_value < 0: - logger.debug(f"Current DAC Setting: ") - else: - logger.info(f"DAC value from user settings: {format(DAC_value, '02x')}" ) - - elif args.get_DAC_start: - # sweep DAC values from low to high - DAC_start_setting, LDO_voltage = calibrator.find_DAC_start_setting() - - # If even a DAC value of 0 was below the ABSOLUTE_LOWEST_LDO_VOLTAGE then fail calibration - if DAC_start_setting < 0: - logger.warning(f"DAC value of 0 is below minimum LDO voltage ({format(calibrator.ABSOLUTE_LOWEST_LDO_VOLTAGE, '0.2f')}): {format(LDO_voltage, '0.2f')}\nThis indicates a problem with the controller board") - else: - - calibrator.set_DAC(DAC_start_setting) - - # Wait a moment for the setting to take effect - calibrator.short_pause() - - # check the LDO voltage - LDO_voltage = calibrator.get_LDO_voltage() - - logger.info(f"DAC_start_setting = 0x{format(DAC_start_setting, '02x')}. LDO_voltage = {format(LDO_voltage, '0.2f')}") - - else: - # Default calibration behavior - iteratively find the closest setting for the DAC that will get the desired ADC reading (but not under) - logger.info(f"Calibrating PiTrac Control Board. This may take a minute or two. Please wait..." ) - - if calibrator.json_file_has_calibration_settings() and not args.overwrite: - logger.error(f"Calibration settings already exist in user_settings.json. Use the --overwrite flag to overwrite them.") - return 1 - - control_board_version = calibrator.config_manager.get_config("gs_config.strobing.kConnectionBoardVersion") - logger.debug(f"control_board_version = {control_board_version}") - if control_board_version is None: - control_board_version = "0" - - control_board_version_value = int(control_board_version) - - # This calibration function is only relevant for the Version 3.x Control Board - if control_board_version_value != 3 and not args.ignore: - logger.error(f"The controller board is the wrong version ({control_board_version_value}) for this calibration utility. Must be using a Verison 3 board.") - return 1 - - if (args.target_output > 0.0): - target_LED_current = args.target_output - elif (args.old_LED): - target_LED_current = calibrator.OLD_TARGET_LED_CURRENT_SETTING - else: - target_LED_current = calibrator.V3_TARGET_LED_CURRENT_SETTING - - - logger.debug(f"target_LED_current = {target_LED_current}") - - - # Perform the actual calibration here - success, final_DAC_setting, LED_current = calibrator.calibrate_board(target_LED_current) - - - if success and final_DAC_setting > 0: - logger.info(f"Calibration successful. Final DAC setting: {format(final_DAC_setting, '02x')}, corresponding LED current: {format(LED_current, '0.2f')}") - # Save the final DAC setting and corresponding ADC output to the user_settings.json file for later reference - calibrator.config_manager.set_config(calibrator.DAC_SETTING_JSON_PATH, final_DAC_setting) - else: - logger.info(f"Calibration failed.") - calibrator.set_DAC_to_safest_level() - - - calibrator.short_pause() - - logger.debug(f"Calibration operation completed." ) - - except KeyboardInterrupt: - print("\nCtrl+C pressed. Performing cleanup...") - - except Exception as e: - logger.error(f"An error occurred: {e}") - calibrator.set_DAC_to_safest_level() - - return 1 # Failure - - finally: - calibrator.cleanup_for_exit() - - if success: - return 0 - else: - return 1 - - -if __name__ == "__main__": - sys.exit(main()) From da8c76721668fe35ca29fb0edcf2f7488a9a3187 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 21 Mar 2026 13:50:33 -0400 Subject: [PATCH 19/28] move strobe deps to web server: pip for spidev/gpiozero, apt for system gpio backends --- Software/web-server/requirements.txt | 2 ++ packaging/build.sh | 4 ++-- packaging/src/lib/pitrac-common-functions.sh | 11 ++++++++++- 3 files changed, 14 insertions(+), 3 deletions(-) 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/packaging/build.sh b/packaging/build.sh index b951271b..b9d1cd15 100755 --- a/packaging/build.sh +++ b/packaging/build.sh @@ -145,8 +145,8 @@ build_dev() { fi done - # Python runtime dependencies for CLI tool - for pkg in python3 python3-pip python3-yaml python3-opencv python3-numpy python3-lgpio python3-rpi-lgpio python3-gpiozero; do + # Python runtime dependencies + for pkg in python3 python3-pip python3-yaml python3-opencv python3-numpy; do if ! dpkg -l | grep -q "^ii $pkg"; then missing_deps+=("$pkg") fi diff --git a/packaging/src/lib/pitrac-common-functions.sh b/packaging/src/lib/pitrac-common-functions.sh index 4b88c252..222bf384 100755 --- a/packaging/src/lib/pitrac-common-functions.sh +++ b/packaging/src/lib/pitrac-common-functions.sh @@ -468,7 +468,16 @@ install_python_dependencies() { fi log_info "Installing Python dependencies for web server..." - + + # gpiozero (in requirements.txt) needs these system GPIO backends on Raspberry Pi OS. + # They're not available on PyPI so we install them via apt. + for pkg in python3-lgpio python3-rpi-lgpio; do + if ! dpkg -l | grep -q "^ii $pkg"; then + log_info "Installing system GPIO backend: $pkg" + INITRD=No apt-get install -y "$pkg" 2>/dev/null || log_warn "Could not install $pkg" + fi + done + if [[ $EUID -eq 0 ]]; then if pip3 install -r "$web_server_dir/requirements.txt" --break-system-packages --ignore-installed 2>/dev/null; then log_success "Python dependencies installed successfully" From eaa54dc0099ad2274451be0600a7f971513b7cb9 Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 21 Mar 2026 13:51:26 -0400 Subject: [PATCH 20/28] fix broken SVG image path in pcb-assembly docs --- docs/hardware/pcb-assembly.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/hardware/pcb-assembly.md b/docs/hardware/pcb-assembly.md index 2f444e8b..91c9dea7 100644 --- a/docs/hardware/pcb-assembly.md +++ b/docs/hardware/pcb-assembly.md @@ -251,7 +251,7 @@ After assembly, must run current calibration before you will be able to capture | VIR+ | VIR+ | | VIR- | VIR- | - ![PiTrac V3 Connection Guide]({{ '/assets/images/hardware/PiTrac_V3_Connection_Guide.svg' | relative_url }}) + ![PiTrac V3 Connection Guide]({{ '/assets/images/enclosure_assembly/PiTrac_V3_Connection_Guide.svg' | relative_url }}) ## Configuring PiTrac From ef3e0be64a5268f4e76a89e202062b5d3b3c4b4f Mon Sep 17 00:00:00 2001 From: Connor Gallopo Date: Sat, 21 Mar 2026 14:06:17 -0400 Subject: [PATCH 21/28] move board version and enclosure to basic settings, fix strobe UI state handling --- Software/web-server/configurations.json | 4 +- Software/web-server/static/js/calibration.js | 50 +++++++++++++------ .../web-server/strobe_calibration_manager.py | 10 ++-- .../web-server/templates/calibration.html | 8 ++- 4 files changed, 49 insertions(+), 23 deletions(-) diff --git a/Software/web-server/configurations.json b/Software/web-server/configurations.json index 45c026fd..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.", @@ -294,7 +294,7 @@ }, "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/static/js/calibration.js b/Software/web-server/static/js/calibration.js index 557dc40b..4c087fd8 100644 --- a/Software/web-server/static/js/calibration.js +++ b/Software/web-server/static/js/calibration.js @@ -648,25 +648,47 @@ class CalibrationManager { try { const [settingsRes, configRes] = await Promise.all([ fetch('/api/strobe-calibration/settings'), - fetch('/api/config') + 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' + settings.dac_setting.toString(16).toUpperCase().padStart(4, '0'); + 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'; } } - - if (configRes.ok) { - const config = await configRes.json(); - const boardEl = document.getElementById('strobe-board-version'); - const boardVersion = config.hardware?.board_version || config.system?.board_version; - boardEl.textContent = boardVersion || 'Unknown'; - } } catch (error) { console.error('Error loading strobe settings:', error); } @@ -760,7 +782,7 @@ class CalibrationManager { const btn = document.getElementById('strobe-calibrate-btn'); const cancelBtn = document.getElementById('strobe-cancel-btn'); btn.disabled = false; - btn.textContent = 'Calibrate'; + btn.textContent = 'Recalibrate'; cancelBtn.style.display = 'none'; document.getElementById('strobe-progress-fill').style.width = '100%'; @@ -790,11 +812,9 @@ class CalibrationManager { } onStrobeCalibrationFailed(status) { - const btn = document.getElementById('strobe-calibrate-btn'); const cancelBtn = document.getElementById('strobe-cancel-btn'); - btn.disabled = false; - btn.textContent = 'Calibrate'; cancelBtn.style.display = 'none'; + this.loadStrobeSettings(); document.getElementById('strobe-progress-fill').style.width = '100%'; document.getElementById('strobe-progress-fill').style.background = 'var(--error)'; @@ -814,14 +834,12 @@ class CalibrationManager { } onStrobeCalibrationCancelled() { - const btn = document.getElementById('strobe-calibrate-btn'); const cancelBtn = document.getElementById('strobe-cancel-btn'); - btn.disabled = false; - btn.textContent = 'Calibrate'; cancelBtn.style.display = 'none'; document.getElementById('strobe-state').textContent = 'Idle'; document.getElementById('strobe-progress-message').textContent = 'Cancelled by user'; + this.loadStrobeSettings(); } async cancelStrobeCalibration() { diff --git a/Software/web-server/strobe_calibration_manager.py b/Software/web-server/strobe_calibration_manager.py index 70d54f02..e6537e8f 100644 --- a/Software/web-server/strobe_calibration_manager.py +++ b/Software/web-server/strobe_calibration_manager.py @@ -313,14 +313,16 @@ async def start_calibration(self, led_type: str = "v3", # Validate board version board_version = self.config_manager.get_config("gs_config.strobing.kConnectionBoardVersion") if board_version is None or int(board_version) != 3: - return {"status": "error", - "message": f"Board version must be V3 (got {board_version})"} + 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: - return {"status": "error", - "message": "Calibration already exists. Set overwrite=true to replace."} + 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: diff --git a/Software/web-server/templates/calibration.html b/Software/web-server/templates/calibration.html index d3e24e9e..49b891ba 100644 --- a/Software/web-server/templates/calibration.html +++ b/Software/web-server/templates/calibration.html @@ -304,7 +304,13 @@

Strobe Calibration

-
+ + +