From 63fe5a92b25a6434115fe2719bcc3a75c23926cd Mon Sep 17 00:00:00 2001 From: Djuri Baars Date: Sun, 5 Jan 2025 16:26:28 +0100 Subject: [PATCH] Add flash over serial functionality --- app/fw_updater.py | 214 +++++++++++++++++++++----- app/gui/serial_flash_dialog.py | 125 +++++++++++++++ app/gui/serial_monitor.py | 126 +++++++++++++++ app/main.py | 192 +++++++++++++++++++---- app/release_checker.py | 27 ++-- partition_tables/boot_app0.bin | Bin 0 -> 8192 bytes partition_tables/bootloader.bin | Bin 0 -> 13696 bytes partition_tables/bootloader_16mb.bin | Bin 0 -> 21152 bytes partition_tables/bootloader_8mb.bin | Bin 0 -> 13696 bytes partition_tables/ota_data_initial.bin | 1 + partition_tables/partition.csv | 7 + partition_tables/partition_16mb.csv | 7 + partition_tables/partition_8mb.csv | 7 + partition_tables/partitions.bin | Bin 0 -> 3072 bytes partition_tables/partitions_16mb.bin | Bin 0 -> 3072 bytes partition_tables/partitions_8mb.bin | Bin 0 -> 3072 bytes 16 files changed, 623 insertions(+), 83 deletions(-) create mode 100644 app/gui/serial_flash_dialog.py create mode 100644 app/gui/serial_monitor.py create mode 100644 partition_tables/boot_app0.bin create mode 100644 partition_tables/bootloader.bin create mode 100644 partition_tables/bootloader_16mb.bin create mode 100644 partition_tables/bootloader_8mb.bin create mode 100644 partition_tables/ota_data_initial.bin create mode 100644 partition_tables/partition.csv create mode 100644 partition_tables/partition_16mb.csv create mode 100644 partition_tables/partition_8mb.csv create mode 100644 partition_tables/partitions.bin create mode 100644 partition_tables/partitions_16mb.bin create mode 100644 partition_tables/partitions_8mb.bin diff --git a/app/fw_updater.py b/app/fw_updater.py index 6ac6b97..94e401e 100644 --- a/app/fw_updater.py +++ b/app/fw_updater.py @@ -11,12 +11,19 @@ from app.utils import get_app_data_folder class FwUpdater: - update_progress = None - currentlyUpdating = False - def __init__(self, update_progress, event_cb): self.update_progress = update_progress self.event_cb = event_cb + self.currentlyUpdating = False + self.release_name = None + + def call_progress(self, progress): + if callable(self.update_progress): + wx.CallAfter(self.update_progress, progress) + + def call_event(self, message): + if callable(self.event_cb): + wx.CallAfter(self.event_cb, message) def get_serial_ports(self): ports = serial.tools.list_ports.comports() @@ -35,41 +42,112 @@ class FwUpdater: espota.TIMEOUT = 10 espota.serve(address, "0.0.0.0", 3232, random.randint( - 10000, 60000), "", firmware_file, type, self.update_progress) + 10000, 60000), "", firmware_file, type, self.call_progress) - wx.CallAfter(self.update_progress, 1) + wx.CallAfter(self.call_progress, 1) self.currentlyUpdating = False -# self.SetStatusText(f"Finished!") - def flash_firmware(port, baud, firmware_path): + def flash_firmware(self, port, firmware_path, hw_rev, preserve_nvs=False, baud=460800): try: - # Initialize the serial port - serial_port = serial.Serial(port, baud) + self.call_event("Starting serial flash...") + self.call_progress(0) + + # Determine flash size and partition table based on hardware revision + flash_size = "4MB" + partition_suffix = "" + littlefs_size = "4MB" + littlefs_offset = "0x380000" # For 4MB flash + + if hw_rev == "REV_B_EPD_2_13": + flash_size = "8MB" + partition_suffix = "_8mb" + littlefs_size = "8MB" + littlefs_offset = "0x700000" # After app0 (0x10000 + 0x370000) and app1 (0x370000) + elif hw_rev == "REV_V8_EPD_2_13": + flash_size = "16MB" + partition_suffix = "_16mb" + littlefs_size = "16MB" + littlefs_offset = "0xDF0000" # After app0 (0x10000 + 0x6F0000) and app1 (0x6F0000) + + # Get paths to required files + bootloader_path = os.path.join("partition_tables", f"bootloader{partition_suffix}.bin") + partition_table_path = os.path.join("partition_tables", f"partitions{partition_suffix}.bin") + boot_app0_path = os.path.join("partition_tables", "boot_app0.bin") + ota_data_path = os.path.join("partition_tables", "ota_data_initial.bin") + littlefs_path = os.path.join(get_app_data_folder(), f"{self.release_name}_littlefs_{littlefs_size}.bin") + + # Verify all required files exist + required_files = [bootloader_path, partition_table_path, boot_app0_path, ota_data_path, firmware_path, littlefs_path] + for file_path in required_files: + if not os.path.exists(file_path): + raise Exception(f"Required file not found: {file_path}") - # Initialize the ESP32ROM with the serial port - esp = esptool.ESP32ROM(serial_port) + # Common command parameters + common_args = [ + '--chip', 'esp32s3', + '--port', port, + '--baud', str(baud), + '--before', 'default_reset', + '--after', 'hard_reset' + ] + + import logging + logger = logging.getLogger() + + # First, handle erasing + try: + if not preserve_nvs: + # Erase entire flash + logger.info("Erasing flash...") + esptool.main(common_args + ['erase_flash']) + else: + # Erase specific regions, preserving NVS + logger.info("Preserve NVS...") + + self.call_progress(0.3) # 30% progress after erase + + except Exception as e: + logger.error(f"Error during flash erase: {str(e)}") + raise + + # Then, flash all components + try: + logger.info("Writing firmware...") + flash_command = common_args + [ + 'write_flash', + '--flash_mode', 'dio', + '--flash_freq', '80m', + '--flash_size', flash_size, + '0x0', bootloader_path, # Bootloader + '0x8000', partition_table_path, # Partition table + '0xe000', ota_data_path, # Boot app + '0x10000', firmware_path, # Main app (app0) + littlefs_offset, littlefs_path # LittleFS data + ] + + esptool.main(flash_command) + + self.call_progress(1.0) # 100% progress after flash + self.call_event("Firmware flashed successfully!") + self.currentlyUpdating = False # Reset the updating state after successful flash + return True + + except Exception as e: + logger.error(f"Error during firmware write: {str(e)}") + raise - # Connect to the ESP32 - esp.connect() + except Exception as e: + self.call_event(f"Failed to flash firmware: {str(e)}") + self.call_progress(0) # Reset progress bar + self.currentlyUpdating = False # Reset the updating state on error + return False - # Perform the flashing operation - esp.flash_file(firmware_path, offset=0x1000) - - # Optionally, verify the flash - esp.verify_flash(firmware_path, offset=0x1000) - - print("Firmware flashed successfully!") - - except esptool.FatalError as e: - print(f"Failed to flash firmware: {e}") - finally: - # Ensure the serial port is closed - if serial_port.is_open: - serial_port.close() - - def start_firmware_update(self, release_name, address, hw_rev): -# self.SetStatusText(f"Starting firmware update") + def start_serial_firmware_update(self, release_name, port, hw_rev, preserve_nvs=False): + if self.currentlyUpdating: + self.call_event("Another update is in progress") + return False + self.release_name = release_name # Store release name for littlefs path hw_rev_to_model = { "REV_B_EPD_2_13": "btclock_rev_b_213epd", "REV_V8_EPD_2_13": "btclock_v8_213epd", @@ -77,16 +155,38 @@ class FwUpdater: } model_name = hw_rev_to_model.get(hw_rev, "lolin_s3_mini_213epd") + local_filename = f"{get_app_data_folder()}/{release_name}_{model_name}_firmware.bin" + self.currentlyUpdating = True + + if not os.path.exists(os.path.abspath(local_filename)): + self.call_event(f"Firmware file not found: {local_filename}") + self.currentlyUpdating = False + return False - local_filename = f"{get_app_data_folder()}/{ - release_name}_{model_name}_firmware.bin" + try: + thread = Thread(target=self.flash_firmware, args=(port, os.path.abspath(local_filename), hw_rev, preserve_nvs)) + thread.start() + return True + except Exception as e: + self.call_event(f"Failed to start serial update: {str(e)}") + self.currentlyUpdating = False + return False + + def start_firmware_update(self, release_name, address, hw_rev): + hw_rev_to_model = { + "REV_B_EPD_2_13": "btclock_rev_b_213epd", + "REV_V8_EPD_2_13": "btclock_v8_213epd", + "REV_A_EPD_2_9": "lolin_s3_mini_29epd" + } + + model_name = hw_rev_to_model.get(hw_rev, "lolin_s3_mini_213epd") + local_filename = f"{get_app_data_folder()}/{release_name}_{model_name}_firmware.bin" self.updatingName = address self.currentlyUpdating = True - if self.event_cb is not None: - self.event_cb("Starting Firmware update") + self.call_event("Starting Firmware update") if os.path.exists(os.path.abspath(local_filename)): thread = Thread(target=self.run_fs_update, args=( @@ -106,13 +206,51 @@ class FwUpdater: self.updatingName = address self.currentlyUpdating = True - if self.event_cb is not None: - self.event_cb(f"Starting WebUI update {local_filename}") + self.call_event(f"Starting WebUI update {local_filename}") if os.path.exists(os.path.abspath(local_filename)): thread = Thread(target=self.run_fs_update, args=( address, os.path.abspath(local_filename), SPIFFS)) thread.start() else: - if self.event_cb is not None: - self.event_cb(f"Firmware file not found: {local_filename}") + self.call_event(f"Firmware file not found: {local_filename}") + self.currentlyUpdating = False + + def detect_hardware_revision(self, port, baud=460800): + """Detect hardware revision based on flash size""" + try: + import logging + logger = logging.getLogger() + + common_args = [ + '--chip', 'esp32s3', + '--port', port, + '--baud', str(baud), + '--no-stub', # Don't upload stub and don't reset + 'flash_id' + ] + + # Capture the output of esptool flash_id command + import io + import sys + output = io.StringIO() + old_stdout = sys.stdout + sys.stdout = output + + try: + esptool.main(common_args) + flash_id_output = output.getvalue() + finally: + sys.stdout = old_stdout + + # Parse the output to find flash size + if "16MB" in flash_id_output: + return "REV_V8_EPD_2_13" + elif "8MB" in flash_id_output: + return "REV_B_EPD_2_13" + else: + return "REV_A_EPD_2_13" # Default to 4MB version + + except Exception as e: + logger.error(f"Error detecting hardware revision: {str(e)}") + return None diff --git a/app/gui/serial_flash_dialog.py b/app/gui/serial_flash_dialog.py new file mode 100644 index 0000000..9dcf9c9 --- /dev/null +++ b/app/gui/serial_flash_dialog.py @@ -0,0 +1,125 @@ +import wx +from app.gui.serial_monitor import SerialMonitor + +class SerialFlashDialog(wx.Dialog): + def __init__(self, parent, fw_updater, release_name): + super().__init__(parent, title="Flash via Serial", size=(400, 300)) + self.fw_updater = fw_updater + self.release_name = release_name + + panel = wx.Panel(self) + vbox = wx.BoxSizer(wx.VERTICAL) + + # Port selection + hbox1 = wx.BoxSizer(wx.HORIZONTAL) + hbox1.Add(wx.StaticText(panel, label="Port:"), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5) + self.port_choice = wx.Choice(panel, choices=[]) + hbox1.Add(self.port_choice, 1) + + # Refresh button + refresh_btn = wx.Button(panel, label="Refresh") + refresh_btn.Bind(wx.EVT_BUTTON, self.on_refresh) + hbox1.Add(refresh_btn, 0, wx.LEFT, 5) + + # Serial Monitor button + monitor_btn = wx.Button(panel, label="Monitor") + monitor_btn.Bind(wx.EVT_BUTTON, self.on_monitor) + hbox1.Add(monitor_btn, 0, wx.LEFT, 5) + + vbox.Add(hbox1, 0, wx.EXPAND | wx.ALL, 5) + + # Hardware revision + hbox2 = wx.BoxSizer(wx.HORIZONTAL) + hbox2.Add(wx.StaticText(panel, label="Hardware:"), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5) + self.hw_choice = wx.Choice(panel, choices=[ + "REV_A_EPD_2_13", + "REV_B_EPD_2_13", + "REV_V8_EPD_2_13", + "REV_A_EPD_2_9" + ]) + self.hw_choice.SetSelection(0) + hbox2.Add(self.hw_choice, 1) + + # Auto-detect button + detect_btn = wx.Button(panel, label="Auto-detect") + detect_btn.Bind(wx.EVT_BUTTON, self.on_detect) + hbox2.Add(detect_btn, 0, wx.LEFT, 5) + + vbox.Add(hbox2, 0, wx.EXPAND | wx.ALL, 5) + + # Preserve NVS checkbox + self.preserve_nvs = wx.CheckBox(panel, label="Preserve NVS (keep settings)") + vbox.Add(self.preserve_nvs, 0, wx.EXPAND | wx.ALL, 5) + + # Buttons + button_sizer = wx.BoxSizer(wx.HORIZONTAL) + flash_button = wx.Button(panel, wx.ID_OK, "Flash") + cancel_button = wx.Button(panel, wx.ID_CANCEL, "Cancel") + button_sizer.Add(flash_button, 0, wx.ALL, 5) + button_sizer.Add(cancel_button, 0, wx.ALL, 5) + vbox.Add(button_sizer, 0, wx.ALIGN_CENTER | wx.ALL, 5) + + panel.SetSizer(vbox) + + self.refresh_ports() + + def refresh_ports(self): + ports = self.fw_updater.get_serial_ports() + self.port_choice.Clear() + filtered_ports = [] + for port, desc, hwid in ports: + # Filter for USB devices with vendor IDs 0x1a86 or 0x303a + if ("1A86" in hwid.upper() or "303A" in hwid.upper()): + filtered_ports.append(port) + self.port_choice.Append(f"{port} - {desc}") + + if self.port_choice.GetCount() > 0: + self.port_choice.SetSelection(0) + + def on_refresh(self, event): + self.refresh_ports() + + def on_detect(self, event): + if self.port_choice.GetSelection() == wx.NOT_FOUND: + wx.MessageBox("Please select a port first", "Error", wx.OK | wx.ICON_ERROR) + return + + port = self.get_selected_port() + if not port: + return + + # Show busy cursor + wx.BeginBusyCursor() + try: + revision = self.fw_updater.detect_hardware_revision(port) + if revision: + # Find and select the detected revision + for i in range(self.hw_choice.GetCount()): + if self.hw_choice.GetString(i) == revision: + self.hw_choice.SetSelection(i) + break + finally: + wx.EndBusyCursor() + + def on_monitor(self, event): + if self.port_choice.GetSelection() == wx.NOT_FOUND: + wx.MessageBox("Please select a port first", "Error", wx.OK | wx.ICON_ERROR) + return + + port = self.get_selected_port() + if not port: + return + + monitor = SerialMonitor(self, port) + monitor.Show() + + def get_selected_port(self): + if self.port_choice.GetSelection() == wx.NOT_FOUND: + return None + return self.port_choice.GetString(self.port_choice.GetSelection()).split(" - ")[0] + + def get_selected_revision(self): + return self.hw_choice.GetString(self.hw_choice.GetSelection()) + + def get_preserve_nvs(self): + return self.preserve_nvs.GetValue() \ No newline at end of file diff --git a/app/gui/serial_monitor.py b/app/gui/serial_monitor.py new file mode 100644 index 0000000..7cec7d8 --- /dev/null +++ b/app/gui/serial_monitor.py @@ -0,0 +1,126 @@ +import wx +import serial +import threading +import time + +class SerialMonitor(wx.Dialog): + def __init__(self, parent, port): + super().__init__(parent, title=f"Serial Monitor - {port}", size=(600, 400)) + + self.port = port + self.serial = None + self.running = False + self._destroyed = False + + # Create UI elements + panel = wx.Panel(self) + vbox = wx.BoxSizer(wx.VERTICAL) + + # Text area for output + self.text_area = wx.TextCtrl(panel, style=wx.TE_MULTILINE | wx.TE_READONLY | wx.HSCROLL) + vbox.Add(self.text_area, 1, wx.EXPAND | wx.ALL, 5) + + # Input area + hbox = wx.BoxSizer(wx.HORIZONTAL) + self.input_field = wx.TextCtrl(panel, style=wx.TE_PROCESS_ENTER) + self.input_field.Bind(wx.EVT_TEXT_ENTER, self.on_send) + hbox.Add(self.input_field, 1, wx.EXPAND | wx.RIGHT, 5) + + # Send button + send_button = wx.Button(panel, label="Send") + send_button.Bind(wx.EVT_BUTTON, self.on_send) + hbox.Add(send_button, 0) + + vbox.Add(hbox, 0, wx.EXPAND | wx.ALL, 5) + + # Baud rate selector + hbox2 = wx.BoxSizer(wx.HORIZONTAL) + hbox2.Add(wx.StaticText(panel, label="Baud Rate:"), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5) + self.baud_selector = wx.Choice(panel, choices=["9600", "115200"]) + self.baud_selector.SetSelection(1) # Default to 115200 + self.baud_selector.Bind(wx.EVT_CHOICE, self.on_baud_change) + hbox2.Add(self.baud_selector, 0) + + # Clear button + clear_button = wx.Button(panel, label="Clear") + clear_button.Bind(wx.EVT_BUTTON, self.on_clear) + hbox2.Add(clear_button, 0, wx.LEFT, 5) + + vbox.Add(hbox2, 0, wx.EXPAND | wx.ALL, 5) + + panel.SetSizer(vbox) + + # Bind close event + self.Bind(wx.EVT_CLOSE, self.on_close) + + self.start_monitor() + + def start_monitor(self): + try: + baud = int(self.baud_selector.GetString(self.baud_selector.GetSelection())) + self.serial = serial.Serial(self.port, baud, timeout=0.1) + self.running = True + self.monitor_thread = threading.Thread(target=self.read_serial) + self.monitor_thread.daemon = True + self.monitor_thread.start() + except Exception as e: + wx.MessageBox(f"Error opening port: {str(e)}", "Error", wx.OK | wx.ICON_ERROR) + self.Close() + + def read_serial(self): + while self.running: + if self.serial and self.serial.is_open: + try: + if self.serial.in_waiting: + data = self.serial.read(self.serial.in_waiting) + if data and not self._destroyed: + wx.CallAfter(self.append_text, data.decode('utf-8', errors='replace')) + except Exception as e: + if not self._destroyed: + wx.CallAfter(self.append_text, f"\nError reading from port: {str(e)}\n") + break + time.sleep(0.1) + + def append_text(self, text): + if not self._destroyed and self and self.text_area: + try: + self.text_area.AppendText(text) + except: + pass # Ignore any errors if the window is being destroyed + + def on_send(self, event): + if self.serial and self.serial.is_open and not self._destroyed: + text = self.input_field.GetValue() + '\n' + try: + self.serial.write(text.encode()) + self.input_field.SetValue("") + except Exception as e: + if not self._destroyed: + wx.MessageBox(f"Error sending data: {str(e)}", "Error", wx.OK | wx.ICON_ERROR) + + def on_baud_change(self, event): + if self.serial and not self._destroyed: + try: + self.serial.close() + self.start_monitor() + except Exception as e: + if not self._destroyed: + wx.MessageBox(f"Error changing baud rate: {str(e)}", "Error", wx.OK | wx.ICON_ERROR) + + def on_clear(self, event): + if not self._destroyed and self.text_area: + self.text_area.SetValue("") + + def on_close(self, event): + self.running = False + self._destroyed = True + if self.serial: + try: + self.serial.close() + except: + pass # Ignore any errors during cleanup + event.Skip() + + def Destroy(self): + self._destroyed = True + super().Destroy() \ No newline at end of file diff --git a/app/main.py b/app/main.py index 719dcde..eb7498c 100644 --- a/app/main.py +++ b/app/main.py @@ -1,12 +1,16 @@ import concurrent.futures import logging import traceback +import sys +from datetime import datetime import serial from app.gui.action_button_panel import ActionButtonPanel +from app.gui.serial_flash_dialog import SerialFlashDialog from app.release_checker import ReleaseChecker import wx import wx.richtext as rt +from wx.lib.mixins.listctrl import ListCtrlAutoWidthMixin from zeroconf import ServiceBrowser, Zeroconf import os @@ -21,33 +25,113 @@ from app.zeroconf_listener import ZeroconfListener from app.espota import FLASH, SPIFFS -class BTClockOTAApp(wx.App): - def OnInit(self): - return True -class RichTextCtrlHandler(logging.Handler): +# Type: ignore[attr-defined] +# The above comment tells the linter to ignore attribute-defined errors for this file + +class LogRedirector: + def __init__(self, logger, level): + self.logger = logger + self.level = level + self.buffer = "" + + def write(self, text): + self.buffer += text + while '\n' in self.buffer: + line, self.buffer = self.buffer.split('\n', 1) + if line.strip(): # Only log non-empty lines + self.logger.log(self.level, line.rstrip()) + + def flush(self): + if self.buffer: + self.logger.log(self.level, self.buffer.rstrip()) + self.buffer = "" + +class LogListCtrl(wx.ListCtrl, ListCtrlAutoWidthMixin): # type: ignore[misc] + def __init__(self, parent): + wx.ListCtrl.__init__(self, parent, style=wx.LC_REPORT | wx.LC_VIRTUAL | wx.BORDER_NONE) # type: ignore[attr-defined] + ListCtrlAutoWidthMixin.__init__(self) + + self.log_entries = [] + + # Add columns + self.InsertColumn(0, "Time", width=80) + self.InsertColumn(1, "Level", width=70) + self.InsertColumn(2, "Message", width=600) + + # Set up virtual list + self.SetItemCount(0) + + # Bind events + self.Bind(wx.EVT_LIST_CACHE_HINT, self.OnCacheHint) + + def OnGetItemText(self, item, col): + try: + if item < len(self.log_entries): + entry = self.log_entries[item] + if col == 0: + return str(entry['time']) + elif col == 1: + return str(entry['level']) + else: + return str(entry['message']) + except Exception: + pass + return "" + + def OnCacheHint(self, evt): + evt.Skip() + + def append_log(self, time, level, message): + try: + # Convert float timestamp to string if needed + if isinstance(time, (float, int)): + time = datetime.fromtimestamp(time).strftime('%H:%M:%S') + elif isinstance(time, datetime): + time = time.strftime('%H:%M:%S') + elif not isinstance(time, str): + time = str(time) + + self.log_entries.append({ + 'time': time, + 'level': level, + 'message': message + }) + self.SetItemCount(len(self.log_entries)) + # Ensure the last item is visible + if len(self.log_entries) > 0: + self.EnsureVisible(len(self.log_entries) - 1) + except Exception: + pass + +class LogListHandler(logging.Handler): def __init__(self, ctrl): super().__init__() self.ctrl = ctrl def emit(self, record): - msg = self.format(record) - wx.CallAfter(self.append_text, "\n" + msg) - - def append_text(self, text): - self.ctrl.AppendText(text) - self.ctrl.ShowPosition(self.ctrl.GetLastPosition()) - -class SerialPortsComboBox(wx.ComboBox): - def __init__(self, parent, fw_update): - self.fw_update = fw_update - self.ports = serial.tools.list_ports.comports() - wx.ComboBox.__init__(self, parent, choices=[ - port.device for port in self.ports]) + try: + # Get time from the record + if hasattr(record, 'asctime'): + time = record.asctime.split()[3] # Get HH:MM:SS part + else: + from datetime import datetime + time = datetime.fromtimestamp(record.created).strftime('%H:%M:%S') + + level = record.levelname + # Format the message with its arguments + try: + msg = record.getMessage() + except Exception: + msg = str(record.msg) + wx.CallAfter(self.ctrl.append_log, time, level, msg) + except Exception: + self.handleError(record) +class BTClockOTAApp(wx.App): + def OnInit(self): + return True class BTClockOTAUpdater(wx.Frame): - updatingName = "" - def __init__(self, parent, title): wx.Frame.__init__(self, parent, title=title, size=(800, 500)) @@ -59,19 +143,35 @@ class BTClockOTAUpdater(wx.Frame): self.zeroconf, "_http._tcp.local.", self.listener) self.api_handler = ApiHandler() self.fw_updater = FwUpdater(self.call_progress, self.SetStatusText) - panel = wx.Panel(self) - self.log_ctrl = rt.RichTextCtrl(panel, style=wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_RICH2) - monospace_font = wx.Font(10, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL) - self.log_ctrl.SetFont(monospace_font) - - handler = RichTextCtrlHandler(self.log_ctrl) - handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s', '%H:%M:%S')) - logging.getLogger().addHandler(handler) - logging.getLogger().setLevel(logging.DEBUG) - + # Create log list control + self.log_ctrl = LogListCtrl(panel) + + # Set up logging to capture all output + handler = LogListHandler(self.log_ctrl) + formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s', '%H:%M:%S') + handler.setFormatter(formatter) + + # Get the root logger and remove any existing handlers + root_logger = logging.getLogger() + for h in root_logger.handlers[:]: + root_logger.removeHandler(h) + + # Add our handler and set level to DEBUG to capture everything + root_logger.addHandler(handler) + root_logger.setLevel(logging.DEBUG) + + # Also capture esptool output + esptool_logger = logging.getLogger('esptool') + esptool_logger.addHandler(handler) + esptool_logger.setLevel(logging.DEBUG) + + # Redirect stdout and stderr to the log + sys.stdout = LogRedirector(root_logger, logging.INFO) + sys.stderr = LogRedirector(root_logger, logging.ERROR) + self.device_list = DevicesPanel(panel) vbox = wx.BoxSizer(wx.VERTICAL) @@ -81,13 +181,13 @@ class BTClockOTAUpdater(wx.Frame): self.fw_label = wx.StaticText( panel, label=f"Fetching latest version from GitHub...") - hbox.Add(self.fw_label, 1, wx.EXPAND | wx.ALL, 5) + hbox.Add(self.fw_label, 1, wx.EXPAND | wx.ALL, 5) self.actionButtons = ActionButtonPanel( panel, self) hbox.AddStretchSpacer() - hbox.Add(self.actionButtons, 2, wx.EXPAND | wx.ALL, 5) + vbox.Add(hbox, 0, wx.EXPAND | wx.ALL, 20) self.progress_bar = wx.Gauge(panel, range=100) @@ -99,6 +199,7 @@ class BTClockOTAUpdater(wx.Frame): wx.CallAfter(self.fetch_latest_release_async) wx.YieldIfNeeded() + def setup_ui(self): self.setup_menubar() self.status_bar = self.CreateStatusBar(2) @@ -109,6 +210,9 @@ class BTClockOTAUpdater(wx.Frame): filemenu = wx.Menu() menuOpenDownloadDir = filemenu.Append( wx.ID_OPEN, "&Open Download Dir", " Open the directory with firmware files and cache") + menuFlashSerial = filemenu.Append( + wx.ID_ANY, "Flash via &Serial...", " Flash firmware using serial connection") + filemenu.AppendSeparator() menuAbout = filemenu.Append( wx.ID_ABOUT, "&About", " Information about this program") menuExit = filemenu.Append( @@ -119,6 +223,7 @@ class BTClockOTAUpdater(wx.Frame): self.SetMenuBar(menuBar) self.Bind(wx.EVT_MENU, self.OnOpenDownloadFolder, menuOpenDownloadDir) + self.Bind(wx.EVT_MENU, self.on_serial_flash, menuFlashSerial) self.Bind(wx.EVT_MENU, self.OnAbout, menuAbout) self.Bind(wx.EVT_MENU, self.OnExit, menuExit) @@ -192,13 +297,13 @@ class BTClockOTAUpdater(wx.Frame): def handle_latest_release(self, future): try: - latest_release = future.result() + self.latest_release = future.result() # Store the result self.fw_label.SetLabelText(f"Downloaded firmware version: { - latest_release}\nCommit: {self.releaseChecker.commit_hash}") + self.latest_release}\nCommit: {self.releaseChecker.commit_hash}") except Exception as e: self.fw_label.SetLabel(f"Error occurred: {str(e)}") traceback.print_tb(e.__traceback__) - + def OnOpenDownloadFolder(self, e): wx.LaunchDefaultBrowser(get_app_data_folder()) @@ -210,3 +315,22 @@ class BTClockOTAUpdater(wx.Frame): def OnExit(self, e): self.Close(False) + + def on_serial_flash(self, event): + if not hasattr(self, 'latest_release'): + wx.MessageBox("Please wait for firmware to be downloaded", "Error", wx.OK | wx.ICON_ERROR) + return + + dlg = SerialFlashDialog(self, self.fw_updater, self.latest_release) + if dlg.ShowModal() == wx.ID_OK: + port = dlg.get_selected_port() + if port: + hw_rev = dlg.get_selected_revision() + preserve_nvs = dlg.get_preserve_nvs() + self.fw_updater.start_serial_firmware_update( + self.latest_release, + port, + hw_rev, + preserve_nvs + ) + dlg.Destroy() diff --git a/app/release_checker.py b/app/release_checker.py index 64b0f79..cdeafd2 100644 --- a/app/release_checker.py +++ b/app/release_checker.py @@ -19,7 +19,17 @@ class ReleaseChecker: commit_hash = "" def __init__(self): - self.progress_callback: Callable[[int], None] = None + self._progress_callback: Callable[[int], None] | None = None + + @property + def progress_callback(self) -> Callable[[int], None] | None: + return self._progress_callback + + @progress_callback.setter + def progress_callback(self, callback: Callable[[int], None] | None) -> None: + if callback is not None and not callable(callback): + raise TypeError("progress_callback must be callable or None") + self._progress_callback = callback def load_cache(self): '''Load cached data from file''' @@ -39,11 +49,9 @@ class ReleaseChecker: cache = self.load_cache() now = datetime.now() - if 'latest_release' in cache and (now - datetime.fromisoformat(cache['latest_release']['timestamp'])) < CACHE_DURATION: latest_release = cache['latest_release']['data'] else: -# url = f"https://api.github.com/repos/{repo}/releases/latest" url = f"https://git.btclock.dev/api/v1/repos/{repo}/releases/latest" try: response = requests.get(url) @@ -75,11 +83,8 @@ class ReleaseChecker: self.download_file(asset_url, release_name) ref_url = f"https://git.btclock.dev/api/v1/repos/{repo}/tags/{release_name}" - #ref_url = f"https://api.github.com/repos/{ -# repo}/git/ref/tags/{release_name}" if ref_url in cache and (now - datetime.fromisoformat(cache[ref_url]['timestamp'])) < CACHE_DURATION: commit_hash = cache[ref_url]['data'] - else: response = requests.get(ref_url) response.raise_for_status() @@ -121,12 +126,12 @@ class ReleaseChecker: if chunk: f.write(chunk) f.flush() - progress = int((i / num_chunks) * 100) - if callable(self.progress_callback): - self.progress_callback(progress) + if self._progress_callback is not None: + progress = int((i / num_chunks) * 100) + self._progress_callback(progress) - if callable(self.progress_callback): - self.progress_callback(100) + if self._progress_callback is not None: + self._progress_callback(100) class ReleaseCheckerException(Exception): diff --git a/partition_tables/boot_app0.bin b/partition_tables/boot_app0.bin new file mode 100644 index 0000000000000000000000000000000000000000..13562cabb9648287fdf70d2a22789fdf1e4156b4 GIT binary patch literal 8192 zcmeI#u?+wq2n0Z!&B7Ip%ZdwNPjZydJlFk*h+E9ra}_6R0t5&UAV7cs0RjXF5FkLH gk-)3}W&dyVhNuJx5FkK+009C72oNAZfWSu}0Te{nn*aa+ literal 0 HcmV?d00001 diff --git a/partition_tables/bootloader.bin b/partition_tables/bootloader.bin new file mode 100644 index 0000000000000000000000000000000000000000..267f65f3a628883bc07de143bc585582357aa554 GIT binary patch literal 13696 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 I0wX*G0L9ft;s5{u literal 0 HcmV?d00001 diff --git a/partition_tables/partitions_16mb.bin b/partition_tables/partitions_16mb.bin new file mode 100644 index 0000000000000000000000000000000000000000..ca9814eb66f44a83f2615dd192f06cb1d1f2a59e GIT binary patch literal 3072 zcmZ1#z{tcffq{V`fPo>etQg2Z1*-xW85kY_#S|DA@=Fp^5=#cs$s}y zNGvEYK#>G;fbxP23etQg2Z1*-xW85kY_#S|DA@=Fp^5=#cs$no^ zNGvEYK#>G;fbxP23>GkXLri(bCZNd-3>SD9iVHH+($Jg$w4a&bLN!nuBSUh2QEEzQ zZUM3xuV4R10oN4vy=>Ju+_#PSNh3?htwSi1qnyzY7!85Z5Eu=C(GVC7fzc2c4S~@R I7~vrR0KZX2qW}N^ literal 0 HcmV?d00001