diff --git a/python.cmake b/python.cmake index f4eacd4117..c121b280f9 100644 --- a/python.cmake +++ b/python.cmake @@ -10,7 +10,11 @@ message("") message("🐍 Python Configuration") # ====== Generate PYTHON_COMMAND ====== -find_package(Python3 3.10.0...3.13.7 COMPONENTS Interpreter REQUIRED) +<<<<<<< Updated upstream +find_package(Python3 3.10.0...3.14.5 COMPONENTS Interpreter REQUIRED) +======= +find_package(Python3 3.10.0...3.14.6 COMPONENTS Interpreter REQUIRED) +>>>>>>> Stashed changes message(" â„šī¸ Found Python ${Python3_VERSION}") set(PYTHON_COMMAND ${Python3_EXECUTABLE}) diff --git a/scripts/canup/boards.py b/scripts/canup/boards.py index e264085c6b..6e6cb26508 100644 --- a/scripts/canup/boards.py +++ b/scripts/canup/boards.py @@ -181,30 +181,6 @@ def __hash__(self) -> int: path=os.path.join("firmware", "dev", "h5dev", "h5dev_app_metadata.hex") ) -hexray_CRIT = Board( - name="CRIT", - boot_id_range_start=0x18000000, - app_id_range_start=900, - mcu=STM32H562_MCU, - path=os.path.join("firmware", "hexray", "CRIT", "hexray_CRIT_app_metadata.hex"), -) - -hexray_BMS = Board( - name="BMS", - boot_id_range_start=0x04000000, - app_id_range_start=400, - mcu=STM32H733_MCU, - path=os.path.join("firmware", "hexray", "BMS", "hexray_BMS_app_metadata.hex"), -) - -hexray_FSM = Board( - name="FSM", - boot_id_range_start=0x10000000, - app_id_range_start=600, - mcu=STM32H562_MCU, - path=os.path.join("firmware", "hexray", "FSM", "hexray_FSM_app_metadata.hex"), -) - CONFIGS = { "h7dev": [h7dev], "h5dev": [h5dev], @@ -216,5 +192,5 @@ def __hash__(self) -> int: "hexray_VC" : [hexray_VC], "hexray_FD": [hexray_DAM, hexray_VC, hexray_BMS], "hexray_Sx" : [hexray_CRIT, hexray_FSM, hexray_RSM], - "hexray" : [hexray_RSM, hexray_BMS, hexray_CRIT, hexray_DAM, hexray_CRIT, hexray_FSM, hexray_VC], + "hexray" : [hexray_RSM, hexray_BMS, hexray_CRIT, hexray_DAM, hexray_FSM, hexray_VC], } diff --git a/scripts/canup/bootloader.py b/scripts/canup/bootloader.py index bb112786ba..fa7e3221e0 100644 --- a/scripts/canup/bootloader.py +++ b/scripts/canup/bootloader.py @@ -6,6 +6,8 @@ """ from typing import Callable, Optional +import queue +import threading import math import can import time @@ -53,8 +55,10 @@ def __init__( board: boards.Board, ui_callback: Callable, ih: intelhex.IntelHex = None, - timeout: int = 1000, + timeout: int = 10, is_fd: bool = False, + inbox: Optional[queue.Queue] = None, + send_lock: Optional[threading.Lock] = None, ) -> None: self.bus: can.Bus = bus self.ih: intelhex.IntelHex = ih @@ -62,6 +66,24 @@ def __init__( self.timeout: int = timeout self.ui_callback: Callable = ui_callback self.is_fd = is_fd + # Optional inbox for routed messages (used when a receiver/dispatcher is active) + self.inbox: Optional[queue.Queue] = inbox + # Optional lock to protect bus.send across threads + self.send_lock: Optional[threading.Lock] = send_lock + + def _send(self, msg: can.Message, timeout: Optional[float] = None) -> None: + if self.send_lock: + with self.send_lock: + # preserve timeout semantics + if timeout is not None: + self.bus.send(msg, timeout=timeout) + else: + self.bus.send(msg) + else: + if timeout is not None: + self.bus.send(msg, timeout=timeout) + else: + self.bus.send(msg) def goto_bootloader(self) -> bool: """ @@ -69,9 +91,8 @@ def goto_bootloader(self) -> bool: :throws: TimeoutError if the boards do not respond :return: None """ - self.bus.send( + self._send( can.Message( - # arbitration_id=board_config.app_id_range_start + 8, arbitration_id=( self.board.boot_id_range_start | GO_TO_BOOT_CAN_ID_LOWBITS ), @@ -92,7 +113,7 @@ def goto_bootloader(self) -> bool: ) def goto_app(self) -> bool: - self.bus.send( + self._send( can.Message( arbitration_id=self.board.boot_id_range_start | GO_TO_APP_LOWBITS, data=[], @@ -104,7 +125,8 @@ def goto_app(self) -> bool: # TODO add retry protocol return ( self._await_can_msg( - lambda msg: msg.arbitration_id == self.board.app_id_range_start + 0, + lambda msg: msg.arbitration_id + == (self.board.app_id_range_start | MCU_10HZ_STATUS_CAN_ID_LOWBITS), 5, ) is not None @@ -130,7 +152,7 @@ def _validator(msg: can.Message) -> bool: else None ) - self.bus.send( + self._send( can.Message( arbitration_id=self.board.boot_id_range_start | START_UPDATE_ID_LOWBITS, data=[], @@ -172,7 +194,7 @@ def _validator(msg: can.Message): if sector.write_protect: raise RuntimeError(f"Attempted to write to a readonly memory sector!{sectors}") - self.bus.send( + self._send( can.Message( arbitration_id=self.board.boot_id_range_start | ERASE_SECTOR_CAN_ID_LOWBITS, @@ -209,12 +231,12 @@ def program(self) -> None: "Programming data", self.size_bytes(), i * CAN_FRAME_SIZE ) - data = [self.ih[address + i] for i in range(0, 8)] + data = [self.ih[address + offset] for offset in range(0, CAN_FRAME_SIZE)] success = False while not success: try: - self.bus.send( + self._send( can.Message( arbitration_id=self.board.boot_id_range_start | PROGRAM_CAN_ID_LOWBITS, @@ -255,7 +277,7 @@ def _validator(msg: can.Message): else None ) - self.bus.send( + self._send( can.Message( arbitration_id=self.board.boot_id_range_start | VERIFY_CAN_ID_LOWBITS, data=[], @@ -268,7 +290,7 @@ def _validator(msg: can.Message): return None if rx_msg.dlc < 1: - raise RuntimeError("Zero Message recieved") + raise RuntimeError("Zero Message received") return rx_msg.data[0] @@ -374,9 +396,18 @@ def _await_can_msg( start = time.time() while time.time() - start < timeout: - rx_msg: can.Message = self.bus.recv(timeout=1) - if rx_msg is None: - continue + # If an inbox is present, read routed messages from it. Otherwise fall back to + # reading directly from the bus. + if self.inbox is not None: + try: + rx_msg: can.Message = self.inbox.get(timeout=1) + except queue.Empty: + continue + else: + rx_msg: can.Message = self.bus.recv(timeout=1) + if rx_msg is None: + continue + if validator(rx_msg): return rx_msg return None @@ -393,6 +424,8 @@ def size_bytes(self) -> int: """ return int( - math.ceil((self.ih.maxaddr() - self.ih.minaddr()) / MIN_PROG_SIZE_BYTES) + math.ceil( + (self.ih.maxaddr() - self.ih.minaddr() + 1) / MIN_PROG_SIZE_BYTES + ) * MIN_PROG_SIZE_BYTES ) diff --git a/scripts/canup/update.py b/scripts/canup/update.py index 85b3b89c7e..40ec6cdfa8 100644 --- a/scripts/canup/update.py +++ b/scripts/canup/update.py @@ -8,6 +8,9 @@ import argparse import os from typing import List +import threading +import queue +from concurrent.futures import ThreadPoolExecutor, as_completed import can import intelhex @@ -58,7 +61,7 @@ def all_goto_app(live: Live, bootloaders: List[bootloader.Bootloader]): ) if not bootload_board.goto_app(): raise TimeoutError( - "Failed to send application command to {bootload_board.board.name}" + f"Failed to send application command to {bootload_board.board.name}" ) progress.remove_task(app_task) live.console.log( @@ -70,8 +73,18 @@ def update(configs: List[boards.Board], build_dir: str, is_fd: bool) -> None: """Update and handle UI.""" num_boards = len(configs) steps_task = progress.add_task("Steps") - bootloaders: List[bootloader.Bootloader] = [ - bootloader.Bootloader( + # Create per-board inboxes and a shared send lock. A single receiver will + # route incoming CAN messages to the appropriate inbox so workers can + # operate concurrently without stealing each other's replies. + send_lock = threading.Lock() + boot_inbox_map: dict = {} + app_inbox_map: dict = {} + bootloaders: List[bootloader.Bootloader] = [] + for board in configs: + inbox_q: queue.Queue = queue.Queue() + boot_inbox_map[board.boot_id_range_start] = inbox_q + app_inbox_map[board.app_id_range_start] = inbox_q + b = bootloader.Bootloader( bus=bus, board=board, ui_callback=lambda description, total, completed: progress.update( @@ -79,80 +92,175 @@ def update(configs: List[boards.Board], build_dir: str, is_fd: bool) -> None: total=total, description=description, completed=completed, - is_fd=is_fd, ), ih=intelhex.IntelHex(os.path.join(build_dir, board.path)), + is_fd=is_fd, + inbox=inbox_q, + send_lock=send_lock, ) - for board in configs - ] + bootloaders.append(b) + + # push all boards into bootloader and run updates concurrently + stop_event = threading.Event() + + def receiver_thread_fn(): + while not stop_event.is_set(): + try: + rx = bus.recv(timeout=1) + except Exception: + continue + if rx is None: + continue + q = app_inbox_map.get(rx.arbitration_id) + if q is None: + # Bootloader replies use the low 8 bits as the command field. + q = boot_inbox_map.get(rx.arbitration_id & ~0xFF) + if q is not None: + q.put(rx) + + receiver = threading.Thread(target=receiver_thread_fn, daemon=True) - # push all boards into bootloader with Live(Group(status, progress), transient=True) as live: - # push all boards into bootloader + receiver.start() + # put devices into bootloader first (they will respond to the receiver) all_goto_bootloader(live, bootloaders) live.console.log( f"Updating firmware for boards: [blue bold]{', '.join(board.name for board in configs)}" ) - for b_idx, bootload_board in enumerate(bootloaders): - # TODO do this in parallel - progress.update( - task_id=steps_task, - total=0, - completed=0, - description=f"Starting update for {bootload_board.board.name}", - ) - status.update( - f"Updating board [yellow]{b_idx + 1}/{num_boards}[/]: [blue bold]{bootload_board.board.name}" - ) - bootload_board.update() - live.console.log(f"[green]{bootload_board.board.name} updated successfully") + + exceptions = [] + + def worker(bootload_board: bootloader.Bootloader, idx: int) -> None: + try: + progress.update( + task_id=steps_task, + total=0, + completed=0, + description=f"Starting update for {bootload_board.board.name}", + ) + status.update( + f"Updating board [yellow]{idx + 1}/{num_boards}[/]: [blue bold]{bootload_board.board.name}" + ) + bootload_board.update() + live.console.log(f"[green]{bootload_board.board.name} updated successfully") + except Exception as e: + live.console.log(f"[red]Failed to update {bootload_board.board.name}: {e}") + exceptions.append(e) + + with ThreadPoolExecutor(max_workers=len(bootloaders)) as ex: + futures = [ex.submit(worker, b, i) for i, b in enumerate(bootloaders)] + for f in as_completed(futures): + try: + f.result() + except Exception: + pass + progress.remove_task(steps_task) - live.console.log( - f"[bold green]Firmware update successfully ({num_boards} board{'s' if num_boards > 1 else ''} updated)" - ) - # push all boards out of bootloader + if exceptions: + live.console.log(f"[red]One or more updates failed ({len(exceptions)} errors)") + else: + live.console.log( + f"[bold green]Firmware update successfully ({num_boards} board{'s' if num_boards > 1 else ''} updated)" + ) + all_goto_app(live, bootloaders) + stop_event.set() + receiver.join(timeout=2) + + if exceptions: + raise RuntimeError(f"Firmware update failed for {len(exceptions)} board(s)") -def erase(configs: List[boards.Board]) -> None: +def erase(configs: List[boards.Board], is_fd: bool) -> None: """Erase and handle UI.""" # push all boards into bootloader num_boards = len(configs) steps_task = progress.add_task("Steps") - bootloaders = [ - bootloader.Bootloader( + send_lock = threading.Lock() + boot_inbox_map: dict = {} + app_inbox_map: dict = {} + bootloaders: List[bootloader.Bootloader] = [] + for board in configs: + inbox_q: queue.Queue = queue.Queue() + boot_inbox_map[board.boot_id_range_start] = inbox_q + app_inbox_map[board.app_id_range_start] = inbox_q + b = bootloader.Bootloader( bus=bus, board=board, ui_callback=lambda description, total, completed: progress.update( task_id=steps_task, total=total, description=description, - completed=completed, + completed=completed ), + is_fd=is_fd, + inbox=inbox_q, + send_lock=send_lock ) - for board in configs - ] + bootloaders.append(b) + + stop_event = threading.Event() + + def receiver_thread_fn(): + while not stop_event.is_set(): + try: + rx = bus.recv(timeout=1) + except Exception: + continue + if rx is None: + continue + + q = app_inbox_map.get(rx.arbitration_id) + if q is None: + q = boot_inbox_map.get(rx.arbitration_id & ~0xFF) + if q is not None: + q.put(rx) + + receiver = threading.Thread(target=receiver_thread_fn, daemon=True) with Live(Group(status, progress), transient=True) as live: + receiver.start() all_goto_bootloader(live, bootloaders) live.console.log( f"Erasing with config: [blue bold]{', '.join(board.name for board in configs)}" ) - for b_idx, bootloader_board in enumerate(bootloaders): - # TODO do this in parallel - status.update(f"Sending board {bootloader_board.board.name} to bootloader") - status.update( - f"Erasing board [yellow]{b_idx + 1}/{num_boards}[/]: [blue bold]{bootloader_board.board.name}" - ) - bootloader_board.erase() + + exceptions = [] + + def worker(bootload_board: bootloader.Bootloader, b_idx: int) -> None: + try: + status.update(f"Sending board {bootload_board.board.name} to bootloader") + status.update( + f"Erasing board [yellow]{b_idx + 1}/{num_boards}[/]: [blue bold]{bootload_board.board.name}" + ) + bootload_board.erase() + live.console.log(f"[green]{bootload_board.board.name} erased successfully") + except Exception as e: + live.console.log(f"[red]Failed to erase {bootload_board.board.name}: {e}") + exceptions.append(e) + + with ThreadPoolExecutor(max_workers=len(bootloaders)) as ex: + futures = [ex.submit(worker, b, i) for i, b in enumerate(bootloaders)] + for f in as_completed(futures): + try: + f.result() + except Exception: + pass + + progress.remove_task(steps_task) + if exceptions: + live.console.log(f"[red]One or more erases failed ({len(exceptions)} errors)") + else: live.console.log( - f"[green]{bootloader_board.board.name} erased successfully" + f"[bold green]Erase successful ({num_boards} board{'s' if num_boards > 1 else ''} erased)" ) - progress.remove_task(steps_task) - live.console.log( - f"[bold green]Erase successful ({num_boards} board{'s' if num_boards > 1 else ''} erased)" - ) + + stop_event.set() + receiver.join(timeout=2) + + if exceptions: + raise RuntimeError(f"Erase failed for {len(exceptions)} board(s)") if __name__ == "__main__":