From ef919b905391ee82ad76d1565f475241aab5bdf3 Mon Sep 17 00:00:00 2001 From: wongyiekheng Date: Fri, 6 Feb 2026 09:52:23 +0800 Subject: [PATCH] first commit --- .gitignore | 3 + Flash/.gitignore | 2 + Flash/Reburn.cfg | 7 + Flash/Reburn_amebapro3_auto.cfg | 6 + Flash/Reset.cfg | 5 + Flash/Reset_amebapro3_auto.cfg | 4 + Flash/Settings.json | 26 + Flash/base/__init__.py | 3 + Flash/base/config_utils.py | 30 + Flash/base/device_info.py | 42 + Flash/base/device_profile.py | 79 + Flash/base/download_handler.py | 1415 +++++++++++++++++ Flash/base/efuse_data.py | 20 + Flash/base/errno.py | 41 + Flash/base/flash_utils.py | 145 ++ Flash/base/floader_handler.py | 646 ++++++++ Flash/base/image_info.py | 28 + Flash/base/json_utils.py | 48 + Flash/base/memory_info.py | 24 + Flash/base/next_op.py | 15 + Flash/base/remote_serial.py | 371 +++++ Flash/base/rom_handler.py | 322 ++++ Flash/base/rt_settings.py | 72 + Flash/base/rtk_flash_type.py | 12 + Flash/base/rtk_logging.py | 49 + Flash/base/rtk_utils.py | 21 + Flash/base/sense_status.py | 24 + Flash/base/spic_addr_mode.py | 12 + Flash/base/sys_utils.py | 9 + Flash/base/version.py | 16 + Flash/changelog.txt | 25 + Flash/flash.py | 474 ++++++ Flash/flash_amebapro3.py | 522 ++++++ Flash/pro3_gpio.py | 82 + Flash/version_info.py | 7 + README.md | 22 + agents.md | 99 ++ ameba_control_panel/__init__.py | 3 + ameba_control_panel/__main__.py | 4 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 186 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 229 bytes .../__pycache__/__main__.cpython-310.pyc | Bin 0 -> 220 bytes .../__pycache__/app.cpython-310.pyc | Bin 0 -> 2208 bytes .../__pycache__/app.cpython-314.pyc | Bin 0 -> 4698 bytes .../__pycache__/config.cpython-310.pyc | Bin 0 -> 1442 bytes .../__pycache__/config.cpython-314.pyc | Bin 0 -> 2120 bytes ameba_control_panel/app.py | 56 + ameba_control_panel/config.py | 50 + .../device_tab_controller.cpython-310.pyc | Bin 0 -> 20932 bytes .../device_tab_controller.cpython-314.pyc | Bin 0 -> 47687 bytes .../controllers/device_tab_controller.py | 514 ++++++ .../command_player.cpython-310.pyc | Bin 0 -> 1931 bytes .../command_player.cpython-314.pyc | Bin 0 -> 3838 bytes .../__pycache__/flash_runner.cpython-310.pyc | Bin 0 -> 5108 bytes .../__pycache__/flash_runner.cpython-314.pyc | Bin 0 -> 11197 bytes .../history_service.cpython-310.pyc | Bin 0 -> 2500 bytes .../history_service.cpython-314.pyc | Bin 0 -> 4963 bytes .../__pycache__/line_parser.cpython-310.pyc | Bin 0 -> 448 bytes .../__pycache__/line_parser.cpython-314.pyc | Bin 0 -> 696 bytes .../__pycache__/log_buffer.cpython-310.pyc | Bin 0 -> 2685 bytes .../__pycache__/log_buffer.cpython-314.pyc | Bin 0 -> 4787 bytes .../__pycache__/port_service.cpython-310.pyc | Bin 0 -> 1009 bytes .../__pycache__/port_service.cpython-314.pyc | Bin 0 -> 1412 bytes .../search_service.cpython-310.pyc | Bin 0 -> 1176 bytes .../search_service.cpython-314.pyc | Bin 0 -> 2152 bytes .../serial_service.cpython-310.pyc | Bin 0 -> 6253 bytes .../serial_service.cpython-314.pyc | Bin 0 -> 13657 bytes .../__pycache__/session_store.cpython-310.pyc | Bin 0 -> 1634 bytes .../__pycache__/session_store.cpython-314.pyc | Bin 0 -> 3129 bytes .../services/command_player.py | 60 + ameba_control_panel/services/flash_runner.py | 163 ++ .../services/history_service.py | 56 + ameba_control_panel/services/line_parser.py | 7 + ameba_control_panel/services/log_buffer.py | 52 + ameba_control_panel/services/port_service.py | 19 + .../services/search_service.py | 27 + .../services/serial_service.py | 174 ++ ameba_control_panel/services/session_store.py | 33 + .../__pycache__/timeutils.cpython-310.pyc | Bin 0 -> 454 bytes .../__pycache__/timeutils.cpython-314.pyc | Bin 0 -> 742 bytes ameba_control_panel/utils/timeutils.py | 10 + .../device_tab_view.cpython-310.pyc | Bin 0 -> 5126 bytes .../device_tab_view.cpython-314.pyc | Bin 0 -> 12572 bytes .../__pycache__/log_view.cpython-310.pyc | Bin 0 -> 3455 bytes .../__pycache__/log_view.cpython-314.pyc | Bin 0 -> 6833 bytes ameba_control_panel/views/device_tab_view.py | 201 +++ ameba_control_panel/views/log_view.py | 83 + requirements.txt | 6 + script/__pycache__/auto_run.cpython-310.pyc | Bin 0 -> 595 bytes .../__pycache__/package_exe.cpython-310.pyc | Bin 0 -> 1930 bytes script/auto_run.py | 20 + script/package_exe.py | 66 + 92 files changed, 6332 insertions(+) create mode 100644 .gitignore create mode 100644 Flash/.gitignore create mode 100644 Flash/Reburn.cfg create mode 100644 Flash/Reburn_amebapro3_auto.cfg create mode 100644 Flash/Reset.cfg create mode 100644 Flash/Reset_amebapro3_auto.cfg create mode 100644 Flash/Settings.json create mode 100644 Flash/base/__init__.py create mode 100644 Flash/base/config_utils.py create mode 100644 Flash/base/device_info.py create mode 100644 Flash/base/device_profile.py create mode 100644 Flash/base/download_handler.py create mode 100644 Flash/base/efuse_data.py create mode 100644 Flash/base/errno.py create mode 100644 Flash/base/flash_utils.py create mode 100644 Flash/base/floader_handler.py create mode 100644 Flash/base/image_info.py create mode 100644 Flash/base/json_utils.py create mode 100644 Flash/base/memory_info.py create mode 100644 Flash/base/next_op.py create mode 100644 Flash/base/remote_serial.py create mode 100644 Flash/base/rom_handler.py create mode 100644 Flash/base/rt_settings.py create mode 100644 Flash/base/rtk_flash_type.py create mode 100644 Flash/base/rtk_logging.py create mode 100644 Flash/base/rtk_utils.py create mode 100644 Flash/base/sense_status.py create mode 100644 Flash/base/spic_addr_mode.py create mode 100644 Flash/base/sys_utils.py create mode 100644 Flash/base/version.py create mode 100644 Flash/changelog.txt create mode 100644 Flash/flash.py create mode 100644 Flash/flash_amebapro3.py create mode 100644 Flash/pro3_gpio.py create mode 100644 Flash/version_info.py create mode 100644 README.md create mode 100644 agents.md create mode 100644 ameba_control_panel/__init__.py create mode 100644 ameba_control_panel/__main__.py create mode 100644 ameba_control_panel/__pycache__/__init__.cpython-310.pyc create mode 100644 ameba_control_panel/__pycache__/__init__.cpython-314.pyc create mode 100644 ameba_control_panel/__pycache__/__main__.cpython-310.pyc create mode 100644 ameba_control_panel/__pycache__/app.cpython-310.pyc create mode 100644 ameba_control_panel/__pycache__/app.cpython-314.pyc create mode 100644 ameba_control_panel/__pycache__/config.cpython-310.pyc create mode 100644 ameba_control_panel/__pycache__/config.cpython-314.pyc create mode 100644 ameba_control_panel/app.py create mode 100644 ameba_control_panel/config.py create mode 100644 ameba_control_panel/controllers/__pycache__/device_tab_controller.cpython-310.pyc create mode 100644 ameba_control_panel/controllers/__pycache__/device_tab_controller.cpython-314.pyc create mode 100644 ameba_control_panel/controllers/device_tab_controller.py create mode 100644 ameba_control_panel/services/__pycache__/command_player.cpython-310.pyc create mode 100644 ameba_control_panel/services/__pycache__/command_player.cpython-314.pyc create mode 100644 ameba_control_panel/services/__pycache__/flash_runner.cpython-310.pyc create mode 100644 ameba_control_panel/services/__pycache__/flash_runner.cpython-314.pyc create mode 100644 ameba_control_panel/services/__pycache__/history_service.cpython-310.pyc create mode 100644 ameba_control_panel/services/__pycache__/history_service.cpython-314.pyc create mode 100644 ameba_control_panel/services/__pycache__/line_parser.cpython-310.pyc create mode 100644 ameba_control_panel/services/__pycache__/line_parser.cpython-314.pyc create mode 100644 ameba_control_panel/services/__pycache__/log_buffer.cpython-310.pyc create mode 100644 ameba_control_panel/services/__pycache__/log_buffer.cpython-314.pyc create mode 100644 ameba_control_panel/services/__pycache__/port_service.cpython-310.pyc create mode 100644 ameba_control_panel/services/__pycache__/port_service.cpython-314.pyc create mode 100644 ameba_control_panel/services/__pycache__/search_service.cpython-310.pyc create mode 100644 ameba_control_panel/services/__pycache__/search_service.cpython-314.pyc create mode 100644 ameba_control_panel/services/__pycache__/serial_service.cpython-310.pyc create mode 100644 ameba_control_panel/services/__pycache__/serial_service.cpython-314.pyc create mode 100644 ameba_control_panel/services/__pycache__/session_store.cpython-310.pyc create mode 100644 ameba_control_panel/services/__pycache__/session_store.cpython-314.pyc create mode 100644 ameba_control_panel/services/command_player.py create mode 100644 ameba_control_panel/services/flash_runner.py create mode 100644 ameba_control_panel/services/history_service.py create mode 100644 ameba_control_panel/services/line_parser.py create mode 100644 ameba_control_panel/services/log_buffer.py create mode 100644 ameba_control_panel/services/port_service.py create mode 100644 ameba_control_panel/services/search_service.py create mode 100644 ameba_control_panel/services/serial_service.py create mode 100644 ameba_control_panel/services/session_store.py create mode 100644 ameba_control_panel/utils/__pycache__/timeutils.cpython-310.pyc create mode 100644 ameba_control_panel/utils/__pycache__/timeutils.cpython-314.pyc create mode 100644 ameba_control_panel/utils/timeutils.py create mode 100644 ameba_control_panel/views/__pycache__/device_tab_view.cpython-310.pyc create mode 100644 ameba_control_panel/views/__pycache__/device_tab_view.cpython-314.pyc create mode 100644 ameba_control_panel/views/__pycache__/log_view.cpython-310.pyc create mode 100644 ameba_control_panel/views/__pycache__/log_view.cpython-314.pyc create mode 100644 ameba_control_panel/views/device_tab_view.py create mode 100644 ameba_control_panel/views/log_view.py create mode 100644 requirements.txt create mode 100644 script/__pycache__/auto_run.cpython-310.pyc create mode 100644 script/__pycache__/package_exe.cpython-310.pyc create mode 100644 script/auto_run.py create mode 100644 script/package_exe.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..146d24b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +build +dist +*.spec diff --git a/Flash/.gitignore b/Flash/.gitignore new file mode 100644 index 0000000..acc8171 --- /dev/null +++ b/Flash/.gitignore @@ -0,0 +1,2 @@ +/devices/ +*.pyc diff --git a/Flash/Reburn.cfg b/Flash/Reburn.cfg new file mode 100644 index 0000000..bfc75bf --- /dev/null +++ b/Flash/Reburn.cfg @@ -0,0 +1,7 @@ +dtr=0 +rts=1 +delay=200 +dtr=1 +rts=0 +delay=100 +dtr=0 diff --git a/Flash/Reburn_amebapro3_auto.cfg b/Flash/Reburn_amebapro3_auto.cfg new file mode 100644 index 0000000..7ee0b5d --- /dev/null +++ b/Flash/Reburn_amebapro3_auto.cfg @@ -0,0 +1,6 @@ +dtr=1 +rts=1 +delay=50 +rts=0 +delay=20 +dtr=0 diff --git a/Flash/Reset.cfg b/Flash/Reset.cfg new file mode 100644 index 0000000..0493c30 --- /dev/null +++ b/Flash/Reset.cfg @@ -0,0 +1,5 @@ +dtr=0 +rts=1 +delay=200 +rts=0 +dtr=0 diff --git a/Flash/Reset_amebapro3_auto.cfg b/Flash/Reset_amebapro3_auto.cfg new file mode 100644 index 0000000..eb924ff --- /dev/null +++ b/Flash/Reset_amebapro3_auto.cfg @@ -0,0 +1,4 @@ +dtr=0 +rts=1 +delay=50 +rts=0 diff --git a/Flash/Settings.json b/Flash/Settings.json new file mode 100644 index 0000000..7bbaa0f --- /dev/null +++ b/Flash/Settings.json @@ -0,0 +1,26 @@ +{ + "SensePacketCount": 32, + "RequestRetryCount": 3, + "RequestRetryIntervalInMillisecond": 10, + "AsyncResponseTimeoutInMilliseccond": 1000, + "SyncResponseTimeoutInMillisecond": 1000, + "BaudrateSwitchDelayInMillisecond": 200, + "RomBootDelayInMillisecond": 100, + "UsbRomBootDelayInMillisecond": 1000, + "UsbFloaderBootDelayInMillisecond": 1000, + "SwitchBaudrateAtFloader": 0, + "WriteResponseTimeoutInMillisecond": 2000, + "FloaderBootDelayInMillisecond": 1000, + "AutoSwitchToDownloadModeWithDtrRts": 0, + "AutoResetDeviceWithDtrRts": 0, + "FlashProtectionProcess": 0, + "EraseByBlock": 0, + "ProgramConfig1": 0, + "ProgramConfig2": 0, + "DisableNandAccessWithUart": 0, + "RamDownloadPaddingByte": 0, + "AutoProgramSpicAddrMode4Byte": 0, + "AutoSwitchToDownloadModeWithDtrRtsTimingFile": "Reburn_amebapro3_auto.cfg", + "AutoResetDeviceWithDtrRtsTimingFile": "Reset_amebapro3_auto.cfg", + "PostProcess": "RESET" +} \ No newline at end of file diff --git a/Flash/base/__init__.py b/Flash/base/__init__.py new file mode 100644 index 0000000..b88d93a --- /dev/null +++ b/Flash/base/__init__.py @@ -0,0 +1,3 @@ +from .download_handler import * +from .rtk_logging import * +from .rt_settings import * \ No newline at end of file diff --git a/Flash/base/config_utils.py b/Flash/base/config_utils.py new file mode 100644 index 0000000..3efe851 --- /dev/null +++ b/Flash/base/config_utils.py @@ -0,0 +1,30 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Realtek Semiconductor Corp. +# SPDX-License-Identifier: Apache-2.0 + +import os + + +class ConfigUtils: + @staticmethod + def get_key_value_pairs(logger, file_path): + result = [] + with open(file_path, 'r') as file: + for line in file: + line = line.strip() + if not line: + continue + + parts = line.split("=", 1) + if len(parts) == 2: + key, value = parts + try: + result.append({key: int(value)}) + except ValueError: + logger.warning(f"Skipping line with non-integer value: {line}") + else: + logger.warning(f"Skipping improperly formatted line: {line}") + + return result \ No newline at end of file diff --git a/Flash/base/device_info.py b/Flash/base/device_info.py new file mode 100644 index 0000000..5ff0a02 --- /dev/null +++ b/Flash/base/device_info.py @@ -0,0 +1,42 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Realtek Semiconductor Corp. +# SPDX-License-Identifier: Apache-2.0 + +from .rtk_flash_type import * +from .memory_info import * + + +class DeviceInfo(object): + def __init__(self): + self.did = 0 + self.image_type = 0 + self.cmd_set_version = 0 + self.wifi_mac = None + self.memory_type = None + self.flash_mid = None + self.flash_did = None + self.flash_mfg = "" + self.flash_model = "" + self.flash_page_size = 0 + self.flash_oob_size = 0 + self.flash_pages_per_block = 0 + self.flash_blocks_per_lun = 0 + self.flash_luns_per_target = None + self.flash_max_bad_block_per_lun = 0 + self.flash_req_host_ecc_level = None + self.flash_targets = None + self.flash_capacity = 0 + + def get_wifi_mac_text(self): + mac_list = [] + for chr in self.wifi_mac: + mac_list.append(hex(chr)[2:].zfill(2).upper()) + return ":".join(mac_list) + + def flash_block_size(self): + return self.flash_page_size * self.flash_pages_per_block + + def is_boot_from_nand(self): + return self.memory_type == MemoryInfo.MEMORY_TYPE_NAND diff --git a/Flash/base/device_profile.py b/Flash/base/device_profile.py new file mode 100644 index 0000000..0596923 --- /dev/null +++ b/Flash/base/device_profile.py @@ -0,0 +1,79 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Realtek Semiconductor Corp. +# SPDX-License-Identifier: Apache-2.0 + +from .image_info import * +from .efuse_data import * +from .version import * + + +class RtkDeviceProfile(): + DEFAULT_FLASH_START_ADDR = 0x08000000 + DEFAULT_RAM_START_ADDR = 0x20000000 + DEVICE_ID_AMEBAD = 0x6548 + DEVICE_ID_AMEBAZ = 0x6547 + + def __init__(self, **kwargs): + self.version = kwargs.get("Version", "1.1.1") + self.device_name = kwargs.get("DeviceName", "") + self.device_id = kwargs.get("DeviceID", 0) + self.memory_type = kwargs.get("MemoryType", 0) + self.support_usb_download = kwargs.get("SupportUsbDownload", False) + self.flash_start_address = kwargs.get("FlashStartAddress", self.DEFAULT_FLASH_START_ADDR) + self.ram_start_address = kwargs.get("RamStartAddress", self.DEFAULT_RAM_START_ADDR) + self.floader = kwargs.get("Floader", "") + self.floader_address = kwargs.get("FloaderAddress", 0) + self.handshake_baudrate = kwargs.get("HandshakeBaudrate", 0) + self.log_baudrate = kwargs.get("LogBaudrate", 0) + self.logical_efuse_len = kwargs.get("LogicalEfuseLen", 0) + self.physical_efuse_len = kwargs.get("PhysicalEfuseLen", 0) + self.images = [] + self.default_efuse_map = [] + for image_info in kwargs.get("Images", []): + self.images.append(ImageInfo(**image_info)) + + for efuse_data in kwargs.get("DefaultEfuseMap", []): + self.default_efuse_map.append(EfuseData(**efuse_data)) + + def is_amebad(self): + return (self.device_id == self.DEVICE_ID_AMEBAD) + + def is_amebaz(self): + return (self.device_id == self.DEVICE_ID_AMEBAZ) + + def is_ram_address(self, address): + return (address >= self.DEFAULT_RAM_START_ADDR) + + def is_flash_address(self, address): + return (address >= self.DEFAULT_FLASH_START_ADDR) + + def get_version(self): + if self.version: + return Version(self.version) + else: + return Version("1.0.0") + + def __repr__(self): + image_info_list = [ii.__repr__() for ii in self.images] + efuse_data_list = [ed.__repr__() for ed in self.default_efuse_map] + profile_dict = { + "Images": image_info_list, + "DefaultEfuseMap": efuse_data_list, + "Version": f"{self.version}", + "DeviceName": self.device_name, + "DeviceID": self.device_id, + "MemoryType": self.memory_type.value, + "SupportUsbDownload": self.support_usb_download, + "FlashStartAddress": self.flash_start_address, + "RamStartAddress": self.ram_start_address, + "Floader": self.floader, + "FloaderAddress": self.floader_address, + "HandshakeBaudrate": self.handshake_baudrate, + "LogBaudrate": self.log_baudrate, + "LogicalEfuseLen": self.logical_efuse_len, + "PhysicalEfuseLen": self.physical_efuse_len + } + + return profile_dict diff --git a/Flash/base/download_handler.py b/Flash/base/download_handler.py new file mode 100644 index 0000000..71486f3 --- /dev/null +++ b/Flash/base/download_handler.py @@ -0,0 +1,1415 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Realtek Semiconductor Corp. +# SPDX-License-Identifier: Apache-2.0 + +from serial.tools.list_ports import comports +import serial +import serial.tools.list_ports +from datetime import datetime + +from .rom_handler import * +from .floader_handler import * +from .flash_utils import * +from .device_profile import * +from .json_utils import * +from .rt_settings import * +from .spic_addr_mode import * +from .memory_info import * +from .config_utils import * +from .remote_serial import RemoteSerial +from typing import Optional, Dict, Any + +_RTK_USB_VID = "0BDA" + +CmdEsc = b'\x1b\r\n' +CmdSetBackupRegister = bytes("EW 0x480003C0 0x200\r\n", encoding="utf-8") +CmdResetIntoDownloadMode = bytes("reboot uartburn\r\n", encoding="utf-8") + +OtpSpicAddrModeAddr = 0x02 +OtpSpicAddrModeMask = 0x02 +OtpSpicAddrModePos = 1 +OtpSpicAddrMode4Byte = (1 << OtpSpicAddrModePos) +OtpSpicAddrMode3Byte = (0 << OtpSpicAddrModePos) + +OtpSpicAddrModeAddrForAmebaD = 0x0E +OtpSpicAddrModeMaskForAmebaD = 0x40 +OtpSpicAddrModePosForAmebaD = 6 +OtpSpicAddrMode4ByteForAmebaD = (1 << OtpSpicAddrModePosForAmebaD) +OtpSpicAddrMode3ByteForAmebaD = (0 << OtpSpicAddrModePosForAmebaD) + + +class Ameba(object): + def __init__(self, + profile: RtkDeviceProfile, + serial_port: serial.Serial, + baudrate: int, + image_path: str, + setting: RtSettings, + logger, + download_img_info=None, + chip_erase=False, + memory_type=None, + erase_info=None, + remote_server: Optional[str] = None, + remote_port: Optional[int] = None, + remote_password: Optional[str] = None): + self.logger = logger + self.profile_info = profile + self.serial_port = None + self.serial_port_name = serial_port + self.remote_server = remote_server + self.remote_port = remote_port + self.remote_password = remote_password + self.is_usb = self.is_realtek_usb() if not remote_server else False + self.initial_serial_port() + self.baudrate = baudrate + self.image_path = image_path + self.download_img_info = download_img_info + self.chip_erase = chip_erase + self.memory_type = memory_type + self.setting = setting + self.device_info = None + self.erase_info = erase_info + self.is_all_ram = True + + self.rom_handler = RomHandler(self) + self.floader_handler = FloaderHandler(self) + + def __del__(self): + if self.serial_port: + if self.serial_port.is_open: + self.logger.info(f"{self.serial_port.port} try to close.") + self.serial_port.close() + while self.serial_port.is_open: + pass + self.logger.info(f"{self.serial_port.port} closed.") + + def initial_serial_port(self): + # initial serial port + try: + # determine whether to use a remote serial port + self.logger.debug(f"Remote server: self.logger type={type(self.logger)}, value={self.logger}") + if self.remote_server and self.remote_port: + self.logger.info(f"Connect to remote serial server: {self.remote_server}:{self.remote_port} (Serial port: {self.serial_port_name})") + # initialize remote serial port + self.serial_port = RemoteSerial( + remote_server=self.remote_server, + remote_port=self.remote_port, + port=self.serial_port_name, + baudrate=self.profile_info.handshake_baudrate, + logger=self.logger + ) + if self.remote_password: + self.logger.debug("Remote server: password set, will send validate command") + self.serial_port.validate(self.remote_password) + self.serial_port.open() + else: + # initialize local serial port + if self.is_usb: + self.serial_port = serial.Serial(self.serial_port_name, + baudrate=self.profile_info.handshake_baudrate, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + bytesize=serial.EIGHTBITS) + else: + self.serial_port = serial.Serial() + self.serial_port.port = self.serial_port_name + self.serial_port.baudrate = self.profile_info.handshake_baudrate + self.serial_port.parity = serial.PARITY_NONE + self.serial_port.stopbits = serial.STOPBITS_ONE + self.serial_port.bytesize = serial.EIGHTBITS + self.serial_port.dtr = False + self.serial_port.rts = False + self.serial_port.open() + except Exception as err: + self.logger.error(f"Initialize serial port failed: {err}") + sys.exit(1) + + # --- check if serial port is open (remote/local compatible) --- + def is_open(self) -> bool: + if isinstance(self.serial_port, RemoteSerial): + return self.serial_port.is_open + elif isinstance(self.serial_port, serial.Serial): + return self.serial_port.is_open + return False + + def switch_baudrate(self, baud, delay_s, force=False): + ret = ErrType.OK + + if (baud == self.serial_port.baudrate) and (not force): + self.logger.debug(f"Don't need to switch baudrate: {baud}") + return ret + + self.logger.debug(f"Switch baudrate: {self.serial_port.baudrate} -> {baud}") + + try: + # remote serial port: close and reopen (no dynamic switching supported yet) + if isinstance(self.serial_port, RemoteSerial): + self.serial_port.close() + #time.sleep(delay_s) + self.serial_port.baudrate = baud + self.serial_port.open() + else: + # local serial port + if self.is_usb: + for retry in range(10): + try: + if self.serial_port.is_open: + self.serial_port.close() + while self.serial_port.is_open: + pass + ret = ErrType.OK + break + except: + ret = ErrType.SYS_IO + time.sleep(0.1) + if ret != ErrType.OK: + self.logger.warning(f"Close serial port failed") + time.sleep(delay_s) + + if self.serial_port.baudrate != baud: + self.serial_port.baudrate = baud + + if self.is_usb: + for rty in range(10): + try: + self.serial_port.open() + ret = ErrType.OK + break + except: + ret = ErrType.SYS_IO + time.sleep(0.1) + except Exception as e: + self.logger.error(f"An exception occurs when switching baudrate: {str(e)}") + ret = ErrType.SYS_IO + + if ret == ErrType.OK: + self.logger.debug(f"Switch baudrate success: {baud}") + else: + self.logger.debug(f"Switch baudrate failed") + + return ret + + def read_bytes(self, timeout_seconds, size=1): + ret = ErrType.OK + read_ch = None + + start_time = datetime.now() + while self.serial_port.inWaiting() < size: + if self.remote_server: + time.sleep(0.001) # avoid waiting idly and improve efficiency. + if (datetime.now() - start_time).seconds >= timeout_seconds: + return ErrType.DEV_TIMEOUT, None + + try: + read_ch = self.serial_port.read(size=size) + except Exception as err: + self.logger.error(f"[{self.serial_port.port}] read bytes err: {err}") + ret = ErrType.SYS_IO + + return ret, read_ch + + def write_bytes(self, data_bytes): + self.serial_port.write(data_bytes) + + def write_string(self, string): + bytes_array = string.encode("utf-8") + self.serial_port.write(bytes_array) + + def is_realtek_usb(self): + if self.remote_server: + return False + ports = serial.tools.list_ports.comports() + for port, desc, hvid in sorted(ports): + if port == self.serial_port_name: + # hvid: USB VID:PID=0BDA:8722 SER=5 LOCATION=1-1 + if _RTK_USB_VID in hvid: + return True + else: + return False + + def switch_baudrate_old(self, baud, delay_s, force=False): + ret = ErrType.OK + + if (baud == self.serial_port.baudrate) and (not force): + self.logger.debug(f"Reactive port {self.serial_port.port} ignored, baudrate no change") + return ret + + if baud != self.serial_port.baudrate: + self.logger.debug( + f"Reactive port {self.serial_port.port} with baudrate from {self.serial_port.baudrate} to {baud}") + else: + self.logger.debug( + f"Reactive port {self.serial_port.port} with baudrate {baud}") + + # if uart dtr/rts enable, should skip close/reopen operation + # if USB port, should close/reopen port when switch baudrate + if self.is_usb: + # check if already activated + for retry in range(10): + try: + if self.serial_port.is_open: + self.serial_port.close() + + while self.serial_port.is_open: + pass + ret = ErrType.OK + except: + ret = ErrType.SYS_IO + + if ret == ErrType.OK: + break + + time.sleep(0.1) + + if ret != ErrType.OK: + self.logger.warning(f"Failed to close {self.serial_port.port} when reactive it.") + + time.sleep(delay_s) + + if self.serial_port.baudrate != baud: + self.serial_port.baudrate = baud + + if self.is_usb: + ret = ErrType.OK + for rty in range(10): + try: + self.serial_port.open() + ret = ErrType.OK + except: + ret = ErrType.SYS_IO + + if ret == ErrType.OK: + break + + time.sleep(0.1) + + if ret == ErrType.OK: + self.logger.debug(f"Reactive port {self.serial_port.port} ok") + else: + self.logger.debug(f"Reactive port {self.serial_port.port} fail") + + return ret + + def check_download_mode(self): + ret = ErrType.SYS_IO + is_floader = False + boot_delay = self.setting.usb_rom_boot_delay_in_second if self.profile_info.support_usb_download else self.setting.rom_boot_delay_in_second + + self.logger.debug(f"Check download mode with baudrate {self.serial_port.baudrate}") + retry = 0 + while retry < 3: + retry += 1 + try: + self.logger.debug(f"Check whether in rom download mode") + ret = self.rom_handler.handshake() + if ret == ErrType.OK: + self.logger.debug(f"Handshake ok, device in rom download mode") + break + + if not self.is_usb: + self.logger.debug( + f'Assume in application or ROM normal mode with baudrate {self.profile_info.log_baudrate}') + self.switch_baudrate(self.profile_info.log_baudrate, self.setting.baudrate_switch_delay_in_second) + + self.logger.debug("Try to reset device...") + + self.serial_port.flushOutput() + + self.write_bytes(CmdEsc) + time.sleep(0.1) + + if self.profile_info.is_amebad(): + self.serial_port.flushOutput() + self.write_bytes(CmdSetBackupRegister) + time.sleep(0.1) + + self.serial_port.flushOutput() + self.write_bytes(CmdResetIntoDownloadMode) + + self.switch_baudrate(self.profile_info.handshake_baudrate, boot_delay, True) + + self.logger.debug( + f'Check whether reset in ROM download mode with baudrate {self.profile_info.handshake_baudrate}') + + ret = self.rom_handler.handshake() + if ret == ErrType.OK: + self.logger.debug("Handshake ok, device in ROM download mode") + break + else: + self.logger.debug("Handshake fail, cannot enter UART download mode") + + self.switch_baudrate(self.baudrate, self.setting.baudrate_switch_delay_in_second, True) + + self.logger.debug(f"Check whether in floader with baudrate {self.baudrate}") + ret, status = self.floader_handler.sense(self.setting.sync_response_timeout_in_second) + if ret == ErrType.OK: + # do not reset floader + is_floader = True + self.logger.debug("Floader handshake ok") + break + else: + self.logger.debug(f"Floader handshake fail: {ret}") + except Exception as err: + self.logger.error(f"Check download mode exception: {err}") + + return ret, is_floader + + def prepare(self, show_device_info=True): + ret = ErrType.OK + floader_init_baud = self.baudrate if self.is_usb else (self.profile_info.handshake_baudrate if + (self.setting.switch_baudrate_at_floader == 1) else self.baudrate) + boot_delay = self.setting.usb_floader_boot_delay_in_second if self.profile_info.support_usb_download else self.setting.floader_boot_delay_in_second + + if (not self.is_usb) and (self.setting.auto_switch_to_download_mode_with_dtr_rts != 0): + ret = self.auto_enter_download_mode() + if ret != ErrType.OK: + self.logger.error(f"Enter download mode by DTR/RTS fail: {ret}") + return ret + + ret, is_floader = self.check_download_mode() + if ret != ErrType.OK: + self.logger.error(f"Enter download mode fail: {ret}") + return ret + + if not is_floader: + # download flashloader to RAM + ret = self.rom_handler.download_floader() + if ret != ErrType.OK: + self.rom_handler.abort() + self.logger.error(f"Flashloader download fail: {ret}") + return ret + + ret = self.switch_baudrate(floader_init_baud, boot_delay, True) + if ret != ErrType.OK: + self.logger.error(f"Flashloader boot fail: {ret}") + return ret + + ret = self.floader_handler.handshake(self.baudrate) + if ret != ErrType.OK: + self.logger.error(f"Flashloader handshake fail: {ret}") + return ret + + ret, self.device_info = self.floader_handler.query() + if ret != ErrType.OK: + self.logger.error(f"Query fail: {ret}") + return ret + + if not show_device_info: + return ret + + self.logger.info("Device info:") + self.logger.info(f'* DID: {hex(self.device_info.did)}') + self.logger.info(f'* ImageType: 0x{format(self.device_info.image_type, "04x")}') + self.logger.info( + f'* CmdSetVersion: {(self.device_info.cmd_set_version >> 8) & 0xFF}.{self.device_info.cmd_set_version & 0xFF}') + if self.device_info.is_boot_from_nand(): + self.logger.info(f'* MemoryType: NAND') + self.logger.info(f'* FlashMID: 0x{format(self.device_info.flash_mid, "02X")}') # customized, do not modify + if self.device_info.flash_mid == FlashUtils.NandMfgMicron: + self.logger.info(f'* FlashDID: 0x{format(self.device_info.flash_did, "04X")}') + else: + self.logger.info(f'* FlashDID: 0x{format(self.device_info.flash_did, "02X")}') + self.logger.info(f'* FlashMFG: {self.device_info.flash_mfg}') + self.logger.info(f'* FlashModel: {self.device_info.flash_model}') + self.logger.info( + f'* FlashCapacity: {self.device_info.flash_capacity // 1024 // 1024 // (1024 // 8)}Gb/{self.device_info.flash_capacity // 1024 // 1024}MB') + self.logger.info(f'* FlashBlockSize: {self.device_info.flash_block_size() // 1024}KB') + self.logger.info(f'* FlashPageSize: {self.device_info.flash_page_size}B') + self.logger.info(f'* FlashOobSize: {self.device_info.flash_oob_size}B') + self.logger.info(f'* FlashPagesPerBlock: {self.device_info.flash_pages_per_block}') + self.logger.info(f'* FlashBlocksPerLun: {self.device_info.flash_blocks_per_lun}') + self.logger.info(f'* FlashLunsPerTarget: {self.device_info.flash_luns_per_target}') + self.logger.info(f'* FlashTargets: {self.device_info.flash_targets}') + self.logger.info(f'* FlashMaxBadBlocksPerLun: {self.device_info.flash_max_bad_block_per_lun}') + self.logger.info(f'* FlashReqHostEccLevel: {self.device_info.flash_req_host_ecc_level}') + else: + self.logger.info(f'* MemoryType: NOR') + self.logger.info(f'* FlashMID: {hex(self.device_info.flash_mid)}') + self.logger.info(f'* FlashDID: {hex(self.device_info.flash_did)}') + self.logger.info( + f'* FlashCapacity: {self.device_info.flash_capacity // 1024 // (1024 // 8)}Mb/{self.device_info.flash_capacity // 1024 // 1024}MB') + self.logger.info(f'* FlashPageSize: {self.device_info.flash_page_size}B') + + self.logger.info(f'* WiFiMAC: {self.device_info.get_wifi_mac_text()}') + + if (self.device_info.did != self.profile_info.device_id) and (self.device_info.did != 0xFFFF): + self.logger.error("Device ID mismatch:") + self.logger.error(f'* Device: {hex(self.device_info.did)}') + self.logger.error(f'* Device Profile: {hex(self.profile_info.device_id)}') + return ErrType.SYS_PARAMETER + + if self.device_info.memory_type != self.profile_info.memory_type: + self.logger.error("Flash type mismatch:") + self.logger.error(f'* Device: {self.device_info.memory_type}') + self.logger.error(f'* Device Profile: {self.profile_info.memory_type}') + return ErrType.SYS_PARAMETER + + if self.device_info.is_boot_from_nand(): + if self.device_info.flash_req_host_ecc_level > 0: + self.logger.error(f"Unsupported NAND flash model without internal ECC") + return ErrType.SYS_IO + if self.device_info.flash_pages_per_block != FlashUtils.NandDefaultPagePerBlock.value: + self.logger.error( + f"Unsupported NAND flash model with {self.device_info.flash_pages_per_block} pages per block") + return ErrType.SYS_IO + + program_param1 = self.setting.program_config1.to_bytes(8, byteorder="little") + program_param2 = self.setting.program_config2.to_bytes(8, byteorder="little") + param = [program_param1, program_param2] + ret = self.floader_handler.config(param) + if ret != ErrType.OK: + self.logger.error(f"Config device fail: {ret}") + + return ret + + def check_flash_lock(self, flash_status): + ret = ErrType.OK + + if self.device_info.is_boot_from_nand(): + cmd = FlashUtils.NandCmdGetFeatures.value + address = FlashUtils.NandRegProtection.value + bp_mask = FlashUtils.NandRegProtectionBpMask.value + if self.device_info.flash_mid == FlashUtils.NandMfgWinbond or self.device_info.flash_mid == FlashUtils.NandMfgMicron: + bp_mask = FlashUtils.NandRegProtectionBpMaskWinbondMicron + else: + cmd = FlashUtils.NorCmdReadStatusReg1.value + address = 0 + bp_mask = FlashUtils.NorStatusReg1BpMask.value + + ret, protection = self.read_flash_status_register(cmd, address) + flash_status.protection = protection + if ret == ErrType.OK: + if (protection & bp_mask) != 0: + flash_status.is_locked = True + + return ret + + def check_and_process_flash_lock(self, flash_status): + follow_up_action = self.setting.flash_protection_process + + ret = self.check_flash_lock(flash_status) + if ret != ErrType.OK: + self.logger.error(f"Flash protection check fail: {ret}") + + if flash_status.is_locked: + self.logger.warning("Flash block protection detected") + + if follow_up_action == RtSettings.FLASH_PROTECTION_PROCESS_PROMPT: + self.logger.info(f"Follow-up Actions:") + self.logger.info(f"1: Try operation with block protected(may fail)") + self.logger.info(f"2: Remove the protection and restore the protection after operation") + self.logger.info(f"3: Abort the operation") + retry = 0 + while retry < 3: + try: + follow_up_action = int(input("Please Input the selected action index: ").strip()) + if RtSettings.FLASH_PROTECTION_PROCESS_PROMPT < follow_up_action <= RtSettings.FLASH_PROTECTION_PROCESS_ABORT: + break + else: + self.logger.info(f"{follow_up_action} is invalid") + except Exception as err: + self.logger.error(f"Input is invalid: {err}") + continue + else: + return ErrType.SYS_PARAMETER + + if follow_up_action == RtSettings.FLASH_PROTECTION_PROCESS_UNLOCK: + flash_status.need_unlock = True + self.logger.info("Remove the flash block protection...") + ret = self.unlock_flash() + if ret != ErrType.OK: + self.logger.error(f"Fail to remove the flash lock protection: {ret}") + return ret + elif follow_up_action == RtSettings.FLASH_PROTECTION_PROCESS_ABORT: + self.logger.warning(f"Operation aborted for block protection") + return ErrType.SYS_CANCEL + else: + self.logger.warning(f"Trying to operate with block protection") + return ErrType.SYS_PARAMETER + + return ret + + def unlock_flash(self): + return self.lock_flash(0) + + def lock_flash(self, protection): + if self.device_info.is_boot_from_nand(): + cmd = FlashUtils.NandCmdSetFeatures.value + address = FlashUtils.NandRegProtection.value + else: + cmd = FlashUtils.NorCmdWriteStatusReg1.value + address = 0 + + ret = self.write_flash_status_register(cmd, address, protection) + + return ret + + def read_flash_status_register(self, cmd, address): + return self.floader_handler.read_status_register(cmd, address) + + def write_flash_status_register(self, cmd, address, status): + return self.floader_handler.write_status_register(cmd, address, status) + + def dtr_rts_timing_mapping(self, timing_list): + ret = ErrType.OK + + if not self.serial_port.is_open: + return ErrType.SYS_IO + + dtr = self.serial_port.dtr + rts = self.serial_port.rts + + for key_val in timing_list: + for key, val in key_val.items(): + if key.upper() == "DTR": + self.serial_port.dtr = (val != 0) + elif key.upper() == "RTS": + self.serial_port.rts = (val != 0) + elif key.upper() == "DELAY": + time.sleep(val / 1000) + else: + self.logger.error(f"Unsupport DTR/RTS timing type: [{key}: {val}]") + + self.serial_port.dtr = dtr + self.serial_port.rts = rts + + return ret + + def auto_enter_download_mode(self): + ret = ErrType.OK + + if not self.serial_port.is_open: + return ErrType.SYS_IO + + reburn_timing_file = os.path.join(RtkUtils.get_executable_root_path(), self.setting.auto_switch_to_download_mode_with_dtr_rts_file) + if os.path.exists(reburn_timing_file): + reburn_timing = ConfigUtils.get_key_value_pairs(self.logger, reburn_timing_file) + try: + if reburn_timing: + ret = self.dtr_rts_timing_mapping(reburn_timing) + except Exception as err: + self.logger.error(f"Fail to auto enter download mode: {err}") + ret = ErrType.SYS_IO + else: + self.logger.debug(f"{reburn_timing_file} is not exists!") + + return ret + + def auto_reset_device(self): + ret = ErrType.OK + + if not self.serial_port.is_open: + return ErrType.SYS_IO + + reset_timing_file = os.path.join(RtkUtils.get_executable_root_path(), self.setting.auto_reset_device_with_dtr_rts_file) + if os.path.exists(reset_timing_file): + reset_timing = ConfigUtils.get_key_value_pairs(self.logger, reset_timing_file) + try: + if reset_timing: + ret = self.dtr_rts_timing_mapping(reset_timing) + except Exception as err: + self.logger.error(f"Fail to reset device: {err}") + ret = ErrType.SYS_IO + else: + self.logger.debug(f"{reset_timing_file} is not exists!") + + return ret + + def post_process(self): + ret = ErrType.OK + + post_process_str = self.setting.post_process.strip().upper() + try: + next_op = NextOpType[post_process_str] + self.logger.debug(f"Next option: {next_op}") + except KeyError: + self.logger.error(f"No matching enum found for {post_process_str}") + next_op = NextOpType.NONE + + if next_op != NextOpType.NONE: + if (next_op == NextOpType.RESET) and (not self.is_usb) and (self.setting.auto_reset_device_with_dtr_rts != 0): + self.logger.debug(f"Reset device with DTR/RTS...") + ret = self.auto_reset_device() + if ret != ErrType.OK: + self.logger.warning(f"Reset device with DTR/RTS fail: {ret}") + else: + if next_op == NextOpType.RESET: + self.logger.info(f"Reset device without DTR/RTS") + + ret = self.floader_handler.next_operation(next_op, 0) + if ret != ErrType.OK: + self.logger.warning(f"Next option {next_op} fail: {ret}") + + # should close serial port + if self.serial_port and self.is_open(): + try: + self.logger.info(f"close {self.serial_port.port}...") + self.serial_port.close() + self.logger.info(f"{self.serial_port.port} close done") + except Exception as e: + self.logger.error(f"close error: {e}", exc_info=True) + self.serial_port = None + + return ret + + def check_protocol(self): + ret = True + + if (not self.is_usb) and self.setting.disable_nand_access_with_uart == 1: + self.logger.warning( + f"NAND access via UART is not allowed, please check the COM port and make sure the device profile matches the attached device!") + ret = False + + return ret + + def check_protocol_for_download(self): + ret = True + has_nand = False + + for image_info in self.profile_info.images: + if (image_info.memory_type == MemoryInfo.MEMORY_TYPE_NAND): + has_nand = True + break + + if has_nand: + ret = self.check_protocol() + + return ret + + def _process_image(self, img_name): + if img_name.strip().startswith(("A:", "B:")): + img_name = img_name.split(":")[1].split("(")[0].strip() + if img_name.endswith(".dtb"): + img_path_files = os.listdir(self.image_path) + for img_f in img_path_files: + self.logger.debug(img_f) + if img_f.endswith(".dtb") and os.path.isfile(os.path.join(self.image_path, img_f)): + img_name = img_f + break + else: + img_name = None + + return img_name + + def verify_images(self): + ret = ErrType.OK + image_selected = False + + all_images = self.profile_info.images + if self.download_img_info: + all_images = self.download_img_info + + for image_info in all_images: + if not image_info.mandatory: + continue + if not self.download_img_info: + image_name = self._process_image(image_info.image_name) + if image_name is None: + self.logger.error(f"Cannot find a valid {image_name} for download") + ret = ErrType.SYS_PARAMETER + break + image_path = os.path.realpath(os.path.join(self.image_path, image_name)) + else: + image_name = os.path.basename(image_info.image_name) + image_path = image_info.image_name + + if not os.path.exists(image_path): + self.logger.error(f"Image file {image_name} dose not exist: {image_path}") + ret = ErrType.SYS_PARAMETER + break + + if image_info.start_address < 0: + self.logger.error(f"Start address is not valid specified for image {image_name}") + ret = ErrType.SYS_PARAMETER + break + if image_info.end_address < 0: + self.logger.error(f"End address is not valid specified for image {image_name}") + ret = ErrType.SYS_PARAMETER + break + if image_info.start_address >= image_info.end_address: + self.logger.error( + f"Invalid address range {image_info.start_address}-{image_info.end_address} for {image_name}") + ret = ErrType.SYS_PARAMETER + break + image_size = os.path.getsize(image_path) + if image_size > (image_info.end_address - image_info.start_address): + self.logger.error( + f"Image file {image_path} is too large for {image_name}, please adjust the memory layout") + ret = ErrType.SYS_PARAMETER + break + + is_start_address_in_ram = self.profile_info.is_ram_address(image_info.start_address) + is_end_address_in_ram = self.profile_info.is_ram_address(image_info.end_address) + if (((self.memory_type == MemoryInfo.MEMORY_TYPE_RAM) and ( + (not is_start_address_in_ram) or (not is_end_address_in_ram))) or + ((self.memory_type == MemoryInfo.MEMORY_TYPE_NOR) and ( + is_start_address_in_ram or is_end_address_in_ram))): + self.logger.error( + f"Invalid address range {image_info.start_address}-{image_info.end_address} for {image_name}") + ret = ErrType.SYS_PARAMETER + break + if not is_start_address_in_ram: + self.is_all_ram = False + + image_selected = True + else: + if not image_selected: + self.logger.warning(f"No image selected!") + ret = ErrType.SYS_PARAMETER + + return ret + + def post_verify_images(self): + ret = ErrType.OK + + max_addr = self.profile_info.flash_start_address + self.device_info.flash_capacity + image_dowload_list = self.download_img_info if self.download_img_info else self.profile_info.images + for image_info in image_dowload_list: + is_ram = self.profile_info.is_ram_address(image_info.start_address) + if not is_ram: + if (image_info.start_address > max_addr) or (image_info.end_address > max_addr): + self.logger.error(f"Invalid layout, image {image_info.image_name} address overflow") + ret = ErrType.SYS_OVERRANGE + break + if (self.device_info.is_boot_from_nand() and + (((image_info.start_address % self.device_info.flash_block_size()) != 0) or + ((image_info.end_address % self.device_info.flash_block_size()) != 0))): + self.logger.error(f"{image_info.image_name} address range not aligned") + ret = ErrType.SYS_PARAMETER + break + return ret + + def validate_config_for_erase(self): + ret = ErrType.OK + + if self.erase_info: + if ((self.profile_info.memory_type == MemoryInfo.MEMORY_TYPE_NAND) and ( + self.memory_type == MemoryInfo.MEMORY_TYPE_NOR)) or \ + ((self.profile_info.memory_type == MemoryInfo.MEMORY_TYPE_NOR) and ( + self.memory_type == MemoryInfo.MEMORY_TYPE_NAND)): + self.logger.error("Unsupported memory type.") + ret = ErrType.SYS_PARAMETER + return ret + + if (self.memory_type == MemoryInfo.MEMORY_TYPE_RAM) and (not self.profile_info.is_ram_address(self.erase_info.start_address)): + self.logger.error(f"Invalid RAM start address: {self.erase_info.start_address}") + ret = ErrType.SYS_PARAMETER + return ret + elif (self.memory_type != MemoryInfo.MEMORY_TYPE_RAM) and \ + (not ((self.profile_info.memory_type == MemoryInfo.MEMORY_TYPE_NAND) or self.profile_info.is_flash_address(self.erase_info.start_address))): + self.logger.error(f"Invalid start address: {self.erase_info.start_address}") + ret = ErrType.SYS_PARAMETER + return ret + + if (self.memory_type == MemoryInfo.MEMORY_TYPE_NOR): + if not self.is_address_block_aligned(self.erase_info.start_address): + self.logger.warning(f"NOR flash start address should be aligned to {FlashUtils.NorDefaultBlockSize.value}B.") + ret = ErrType.SYS_PARAMETER + return ret + if (self.erase_info.size_in_kbyte != 0xFFFFFFFF) and (not self.is_address_block_aligned(self.erase_info.size_in_byte())): + self.logger.warning(f"NOR flash erase size should be aligned to {FlashUtils.NorDefaultBlockSize.value}B.") + ret = ErrType.SYS_PARAMETER + return ret + self.logger.info(f"NOR flash erase: start address={hex(self.erase_info.start_address)}, size={self.erase_info.size_in_kbyte}KB.") + else: + if not self.is_address_block_aligned(self.erase_info.start_address): + self.logger.warning(f"NAND flash start address should be block size aligned!") + ret = ErrType.SYS_PARAMETER + return ret + if not self.is_address_block_aligned(self.erase_info.end_address): + self.logger.warning(f"NAND flash end address should be block size aligned!") + ret = ErrType.SYS_PARAMETER + return ret + if (self.erase_info.end_address <= self.erase_info.start_address): + self.logger.warning(f"NAND flash start address should be less than end address!") + ret = ErrType.SYS_PARAMETER + return ret + + self.logger.info(f"NAND flash erase: start address={hex(self.erase_info.start_address)}, end address={hex(self.erase_info.end_address)}") + + return ret + + def check_protocol_for_erase(self): + ret = True + + if (not self.is_usb) and (self.erase_info.memory_type == MemoryInfo.MEMORY_TYPE_NAND) and ( + self.setting.disable_nand_access_with_uart == 1): + ret = False + self.logger.warning( + f"NAND access via UART is not allowed, please check the COM port and make sure the device profile matches the attached device!") + return ret + + def is_address_block_aligned(self, address): + ret = False + + if self.device_info: + block_size = self.device_info.flash_block_size() + ret = ((address % block_size) == 0) + + return ret + + def post_validate_config_for_erase(self): + ret = ErrType.OK + + if self.erase_info and (not self.profile_info.is_ram_address(self.erase_info.start_address)): + if not self.is_address_block_aligned(self.erase_info.start_address): + ret = ErrType.SYS_PARAMETER + self.logger.warning( + f"Flash start address should be aligned to block size {self.device_info.flash_block_size()}KB") + if self.erase_info.memory_type == MemoryInfo.MEMORY_TYPE_NAND and ( + not self.is_address_block_aligned(self.erase_info.end_address)): + ret = ErrType.SYS_PARAMETER + self.logger.warning( + f"Flash end address should be aligned to block size {self.device_info.flash_block_size()}KB") + if self.erase_info.memory_type == MemoryInfo.MEMORY_TYPE_NOR and ( + not self.is_address_block_aligned(self.erase_info.size_in_byte())): + ret = ErrType.SYS_PARAMETER + self.logger.warning( + f"Flash size should be aligned to block size {self.device_info.flash_block_size()}KB") + return ret + + def calculate_checksum(self, image): + with open(image, 'rb') as stream: + img_content = stream.read() + img_len = len(img_content) + img_arr = list(img_content) + chksum = 0 + offset = 0 + while (img_len - offset) > 3: + chksum += (img_arr[offset + 0] | (img_arr[offset + 1] << 8) | (img_arr[offset + 2] << 16) | ( + img_arr[offset + 3] << 24)) + offset += 4 + + tmp = 0 + while (img_len - offset - tmp) > 0: + chksum += img_arr[offset + tmp] << (8 * tmp) + tmp += 1 + + chksum = chksum & 0xffffffff + return chksum + + def erase_flash_chip(self): + self.logger.info(f"Chip erase start") # customized, do not modify + ret = self.floader_handler.erase_flash(MemoryInfo.MEMORY_TYPE_NOR, RtkDeviceProfile.DEFAULT_FLASH_START_ADDR, + 0, 0xFFFFFFFF, + nor_erase_timeout_in_second(0xFFFFFFFF), + sense=True, force=False) + return ret + + def download_images(self): + ret = ErrType.OK + + # support chip erase + if self.chip_erase and (self.memory_type == MemoryInfo.MEMORY_TYPE_NOR): + ret = self.erase_flash_chip() + if ret != ErrType.OK: + self.logger.error(f"Chip erase fail") + return ret + self.logger.info(f"Chip erase end") + + if self.download_img_info: + for image_info in self.download_img_info: + img_path = image_info.image_name + img_name = os.path.basename(img_path) + image_info.image_name = img_name + + self.logger.info(f"{img_name} download...") + ret = self._download_image(img_path, image_info) + if ret != ErrType.OK: + self.logger.info(f"{img_name} download fail: {ret}") + break + else: + is_area_A = False + is_area_B = False + for image_info in self.profile_info.images: + is_mandatory = image_info.mandatory + if not is_mandatory: + continue + img_name = image_info.image_name + self.logger.info(f"{img_name} download...") + if img_name.strip().startswith(("A:", "a_")): + is_area_A = True + elif img_name.strip().startswith(("B:", "b_")): + is_area_B = True + + if is_area_A and is_area_B: + self.logger.error(f"NOT support both A and B download at the same time") + ret = ErrType.SYS_PARAMETER + break + + img_name = self._process_image(img_name) + img_path = os.path.realpath(os.path.join(self.image_path, img_name)) + ret = self._download_image(img_path, image_info) + if ret != ErrType.OK: + self.logger.info(f"{img_name} download fail: {ret}") + break + + if ret == ErrType.OK: + self.logger.info("All images download done") + + return ret + + def get_page_alligned_size(self, size, page_size): + result = size + + if (size % page_size) != 0: + result = (size // page_size + 1) * page_size + + return result + + def _download_image(self, image_path, image_info): + ret = ErrType.OK + + page_size = self.device_info.flash_page_size + block_size = self.device_info.flash_block_size() + pages_per_block = self.device_info.flash_pages_per_block + last_erase_addr = 0 + next_erase_addr = 0 + checksum = 0 + write_timeout = 0 + is_ram = (image_info.memory_type == MemoryInfo.MEMORY_TYPE_RAM) + padding_data = self.setting.ram_download_padding_byte if is_ram else FlashUtils.FlashWritePaddingData.value + start_time = datetime.now() + + with open(image_path, 'rb') as stream: + img_content = stream.read() + + img_length = len(img_content) + aligned_img_length = self.get_page_alligned_size(img_length, page_size) + self.logger.debug( + f"Image download size={aligned_img_length}({img_length}), start_addr={hex(image_info.start_address)}, " + f"end_addr={hex(image_info.end_address)}") + + addr = image_info.start_address + tx_sum = 0 + if ((image_info.memory_type == MemoryInfo.MEMORY_TYPE_NAND) or ( + is_ram and (self.profile_info.memory_type == MemoryInfo.MEMORY_TYPE_NAND))): + write_timeout = nand_program_timeout_in_second(block_size, + page_size) + FlashUtils.NandBlockEraseTimeoutInSeconds.value + img = io.BytesIO(img_content) + img_bytes = img.read(img_length) + data_bytes = img_bytes.ljust(aligned_img_length, padding_data.to_bytes(1, byteorder="little")) + + is_last_page = False + progress_int = 0 + while not is_last_page: + if addr >= image_info.end_address: + self.logger.debug(f"Overrange target={hex(addr)}, end={hex(image_info.end_address)}") + ret = ErrType.SYS_OVERRANGE + break + + ret = self.floader_handler.erase_flash(image_info.memory_type, addr, addr + block_size, block_size, + nand_erase_timeout_in_second(block_size, block_size), + sense=True) + if ret == ErrType.DEV_NAND_BAD_BLOCK.value or ret == ErrType.DEV_NAND_WORN_BLOCK.value: + self.logger.info( + f"{'Bad' if ret == ErrType.DEV_NAND_BAD_BLOCK else 'Worn'} block: 0x{format(addr, '08X')}") + addr += self.device_info.flash_block_size() + next_erase_addr = addr + continue + elif ret != ErrType.OK: + break + + next_erase_addr = addr + self.device_info.flash_block_size() + + i = 0 + while i < pages_per_block: + if tx_sum + page_size >= aligned_img_length: + is_last_page = True + + need_sense = (is_last_page or (i == pages_per_block - 1)) + ret = self.floader_handler.write(image_info.memory_type, data_bytes[tx_sum: tx_sum + page_size], + page_size, addr, write_timeout, need_sense=need_sense) + if ret == ErrType.OK: + idx = 0 + while idx < page_size: + checksum += (data_bytes[tx_sum + idx] + (data_bytes[tx_sum + idx + 1] << 8) + ( + data_bytes[tx_sum + idx + 2] << 16) + (data_bytes[tx_sum + idx + 3] << 24)) + idx += 4 + checksum &= 0xFFFFFFFF + addr += page_size + tx_sum += page_size + else: + self.logger.error(f"Write to addr={format(addr, '08x')}, size={page_size} fail: {ret}") + break + + if is_last_page: + break + + i += 1 + progress = int((tx_sum / aligned_img_length) * 100) + if int((progress) / 10) != progress_int: + progress_int += 1 + self.logger.info(f"Programming progress: {progress}%") # customized, do not modified + + if ret != ErrType.OK: + break + + if ret == ErrType.OK: + if image_info.full_erase and (next_erase_addr < image_info.end_address): + self.logger.debug( + f"Erase extra address range: {hex(next_erase_addr)}-{hex(image_info.end_address)}") + ret = self.floader_handler.erase_flash(image_info.memory_type, next_erase_addr, + image_info.end_address, + (image_info.end_address - next_erase_addr), + nand_erase_timeout_in_second( + (image_info.end_address - next_erase_addr), block_size), + sense=True) + if ret == ErrType.DEV_NAND_BAD_BLOCK.value or ret == ErrType.DEV_NAND_WORN_BLOCK.value: + self.logger.debug( + f"{'Bad' if ret == ErrType.DEV_NAND_BAD_BLOCK else 'Worn'} block: {hex(addr)}") + ret = ErrType.OK + elif ret != ErrType.OK: + self.logger.error(f"Fail to erase block {hex(addr)}:{ret}") + + if tx_sum >= aligned_img_length: + if aligned_img_length < 1024: + self.logger.debug(f"Image download done: {aligned_img_length}bytes") + elif aligned_img_length < 1024 * 1024: + self.logger.debug(f"Image download done: {aligned_img_length // 1024}KB") + else: + self.logger.debug(f"Image download done: {round(aligned_img_length / 1024 / 1024, 2)}MB") + else: + self.logger.warning(f"Image download uncompleted: {tx_sum}/{aligned_img_length}") + + elapse_ms = round((datetime.now() - start_time).total_seconds() * 1000, 0) + kbps = aligned_img_length * 8 // elapse_ms + size_kb = aligned_img_length // 1024 + + if self.is_usb: + self.logger.info( + f"{image_info.image_name} download done: {size_kb}KB / {elapse_ms}ms / {kbps / 1000}Mbps") + else: + self.logger.info(f"{image_info.image_name} download done: {size_kb}KB / {elapse_ms}ms / {kbps}Kbps") + else: + write_pages = 0 + img = io.BytesIO(img_content) + img_bytes = img.read(img_length) + data_bytes = img_bytes.ljust(aligned_img_length, padding_data.to_bytes(1, byteorder="little")) + progress_int = 0 + while tx_sum < aligned_img_length: + if write_pages == 0: + if (addr % (64 * FlashUtils.NorDefaultPageSize.value)) == 0 and \ + ((aligned_img_length - tx_sum >= 64 * FlashUtils.NorDefaultPageSize.value)): + block_size = 64 * FlashUtils.NorDefaultPageSize.value + else: + block_size = 4 * FlashUtils.NorDefaultPageSize.value + + pages_per_block = block_size // page_size + erase_addr = addr + write_timeout = FlashUtils.NorPageProgramTimeoutInSeconds.value * int( + max(self.setting.sense_packet_count, pages_per_block)) + nor_erase_timeout_in_second( + divide_then_round_up(block_size, 1024)) + if erase_addr != last_erase_addr: + if (not is_ram) and (erase_addr % block_size) != 0: + # error: # customized, do not modify + self.logger.error( + f"Flash erase address align error: addr {hex(erase_addr)} not aligned to block size {hex(block_size)}") + ret = ErrType.SYS_PARAMETER + break + + ret = self.floader_handler.erase_flash(image_info.memory_type, erase_addr, + erase_addr + block_size, block_size, + nor_erase_timeout_in_second( + divide_then_round_up(block_size, 1024))) + if ret != ErrType.OK: + break + + last_erase_addr = erase_addr + next_erase_addr = erase_addr + block_size + + need_sense = ((((write_pages + 1) % self.setting.sense_packet_count) == 0) or + (write_pages + 1 >= pages_per_block) or + (tx_sum + page_size >= aligned_img_length)) + ret = self.floader_handler.write(image_info.memory_type, data_bytes[tx_sum: tx_sum + page_size], + page_size, addr, write_timeout, need_sense=need_sense) + if ret != ErrType.OK: + self.logger.debug(f"Write to addr={hex(addr)} size={page_size} fail: {ret}") + break + + write_pages += 1 + if write_pages >= pages_per_block: + write_pages = 0 + + idx = 0 + while idx < page_size: + checksum += (data_bytes[tx_sum + idx] + (data_bytes[tx_sum + idx + 1] << 8) + ( + data_bytes[tx_sum + idx + 2] << 16) + (data_bytes[tx_sum + idx + 3] << 24)) + idx += 4 + + checksum &= 0xFFFFFFFF + + addr += page_size + tx_sum += page_size + + progress = int((tx_sum / aligned_img_length) * 100) + if int((progress) / 10) != progress_int: + progress_int += 1 + self.logger.info(f"Programming progress: {progress}%") # customized, do not modified + + if image_info.full_erase and (next_erase_addr < image_info.end_address): + self.logger.debug(f"Erase extra address range: {hex(next_erase_addr)}-{hex(image_info.end_address)}") + ret = self.floader_handler.erase_flash(image_info.memory_type, next_erase_addr, image_info.end_address, + (image_info.end_address - next_erase_addr), + nor_erase_timeout_in_second(divide_then_round_up( + (image_info.end_address - next_erase_addr), 1024)), + sense=True) + if ret != ErrType.OK: + self.logger.warning( + f"Fail to extra address range {hex(next_erase_addr)}-{hex(image_info.end_address)}") + + if aligned_img_length < 1024: + self.logger.debug(f"Image download done: {aligned_img_length}bytes") + elif aligned_img_length < 1024 * 1024: + self.logger.debug(f"Image download done: {aligned_img_length // 1024}KB") + else: + self.logger.debug(f"Image download done: {round(aligned_img_length / 1024 / 1024, 2)}MB") + + elapse_ms = round((datetime.now() - start_time).total_seconds() * 1000, 0) + kbps = aligned_img_length * 8 // elapse_ms + size_kb = aligned_img_length // 1024 + + if self.is_usb: + self.logger.info( + f"{image_info.image_name} download done: {size_kb}KB / {elapse_ms}ms / {kbps / 1000}Mbps") + else: + self.logger.info(f"{image_info.image_name} download done: {size_kb}KB / {elapse_ms}ms / {kbps}Kbps") + + if ret == ErrType.OK: + cal_checksum = 0 + if image_info.memory_type == MemoryInfo.MEMORY_TYPE_NAND: + checksum_timeout = nand_checksum_timeout_in_second(aligned_img_length, None) + else: + checksum_timeout = nor_checksum_timeout_in_second(aligned_img_length) + ret, cal_checksum = self.floader_handler.checksum(image_info.memory_type, image_info.start_address, + image_info.end_address, aligned_img_length, + checksum_timeout) + if ret == ErrType.OK: + if cal_checksum != checksum: + self.logger.debug(f"Checksum fail: expect {hex(checksum)} get {hex(cal_checksum)}") + ret = ErrType.SYS_CHECKSUM + + return ret + + def erase_flash(self): + ret = ErrType.OK + + if self.erase_info.memory_type == MemoryInfo.MEMORY_TYPE_NAND: + erase_total_size = self.erase_info.end_address - self.erase_info.start_address + erase_size = 0 + addr = self.erase_info.start_address + while addr < self.erase_info.end_address: + ret = self.floader_handler.erase_flash(self.erase_info.memory_type, + addr, + addr + self.device_info.flash_block_size(), + self.device_info.flash_block_size(), + nand_erase_timeout_in_second(self.device_info.flash_block_size(), + self.device_info.flash_block_size()), + sense=True) + if ret == ErrType.OK: + erase_size += self.device_info.flash_block_size() + # TODO:update erase progress + # self.logger.info(f"Erase progress: {int()}") + self.logger.debug( + f"NAND erase address ={hex(addr)}, size = {self.device_info.flash_block_size() / 1024}KB OK") + elif ret == ErrType.DEV_NAND_BAD_BLOCK: + self.logger.warning( + f"NAND erase address = {hex(addr)} size = {self.device_info.flash_block_size() / 1024}KB skipped: bad block") + ret = ErrType.OK + elif ret == ErrType.DEV_NAND_WORN_BLOCK: + self.logger.warning( + f"NAND erase address = {hex(addr)} size = {self.device_info.flash_block_size() / 1024}KB failed: mark warning block") + ret = ErrType.OK + else: + self.logger.warning( + f"NAND erase address = {hex(addr)} size = {self.device_info.flash_block_size() / 1024}KB failed: {ret}") + break + addr += self.device_info.flash_block_size() + + if ret == ErrType.OK: + self.logger.info(f"Erase nand done") + elif self.erase_info.memory_type == MemoryInfo.MEMORY_TYPE_NOR: + if self.setting.erase_by_block != 0: + addr = self.erase_info.start_address + size_erased = 0 + while size_erased < self.erase_info.size_in_byte(): + if ((addr % (64 * FlashUtils.NorDefaultPageSize.value)) == 0) and \ + (( + self.erase_info.size_in_byte() - size_erased) >= 64 * FlashUtils.NorDefaultPageSize.value): + block_size = 64 * FlashUtils.NorDefaultPageSize.value + elif ((addr % (32 * FlashUtils.NorDefaultPageSize.value)) == 0) and \ + (( + self.erase_info.size_in_byte() - size_erased) >= 32 * FlashUtils.NorDefaultPageSize.value): + block_size = 32 * FlashUtils.NorDefaultPageSize.value + else: + block_size = 4 * FlashUtils.NorDefaultPageSize.value + + need_sense = ((size_erased + block_size) >= self.erase_info.size_in_byte()) + ret = self.floader_handler.erase_flash(self.erase_info.memory_type, addr, addr + block_size, + block_size, + nor_erase_timeout_in_second( + divide_then_round_up(block_size, 1024)), + sense=need_sense) + if ret != ErrType.OK: + break + + addr += block_size + size_erased += block_size + # TODO:update erase progress + # self.logger.info(f"Erase Progress: {}") + else: + ret = self.floader_handler.erase_flash(self.erase_info.memory_type, + self.erase_info.start_address, + self.erase_info.end_address, + self.erase_info.size_in_kbyte, + nor_erase_timeout_in_second( + divide_then_round_up(self.erase_info.size_in_byte(), 1024)), + sense=True) + if ret == ErrType.OK: + self.logger.info(f"Erase nor done") + elif self.erase_info.memory_type == MemoryInfo.MEMORY_TYPE_RAM: + # memset 16MB ram, cost time: 0.01s + ret = self.floader_handler.erase_flash(self.erase_info.memory_type, + self.erase_info.start_address, + self.erase_info.end_address, + self.erase_info.size_in_byte(), + self.setting.sync_response_timeout_in_second, + sense=True) + if ret == ErrType.OK: + self.logger.info("Erase ram done") + else: + # TBD + pass + + return ret + + def set_spic_address_mode(self, mode): + ret = ErrType.OK + is_amebad = self.profile_info.is_amebad() + otp_spic_addr_mode_addr = OtpSpicAddrModeAddrForAmebaD if is_amebad else OtpSpicAddrModeAddr + otp_spic_addr_mode_mask = OtpSpicAddrModeMask if is_amebad else OtpSpicAddrModeMask + otp_spic_addr_mode_pos = OtpSpicAddrModePosForAmebaD if is_amebad else OtpSpicAddrModePos + + if self.device_info.is_boot_from_nand(): + return ret + + ret, buf = self.floader_handler.otp_read_logical_map(0, self.profile_info.logical_efuse_len) + if ret != ErrType.OK: + self.logger.error(f"Fail to read eFuse: {ret}") + return + + cfg = buf[otp_spic_addr_mode_addr] + if cfg != 0xFF: + if ((cfg & otp_spic_addr_mode_mask) >> otp_spic_addr_mode_pos) == mode: + self.logger.info(f"No need to change supported flash size") + return ErrType.OK + + cfg &= (~otp_spic_addr_mode_mask) + cfg |= ((mode << otp_spic_addr_mode_pos) & otp_spic_addr_mode_mask) + else: + if mode == SpicAddrMode.FOUR_BYTE_MODE.value: + cfg = OtpSpicAddrMode4Byte + else: + self.logger.info(f"No need to change default supported flash size") + return ErrType.OK + + # buf[otp_spic_addr_mode_addr] = cfg + buf_array = bytearray(buf) + buf_array[otp_spic_addr_mode_addr] = cfg + buf = bytes(buf_array) + self.logger.info(f"Program eFuse to change supported flash size {'>' if (mode == SpicAddrMode.FOUR_BYTE_MODE.value) else '<='}16MB") + + ret = self.floader_handler.otp_write_logical_map(otp_spic_addr_mode_addr, 1, buf) + if ret != ErrType.OK: + self.logger.error(f"Fail to program eFuse[{otp_spic_addr_mode_addr}]: {ret}") + return ret + time.sleep(0.01) + + ret, buf = self.floader_handler.otp_read_logical_map(0, self.profile_info.logical_efuse_len) + if ret != ErrType.OK: + self.logger.error(f"Fail to read eFuse: {ret}") + return ret + + cfg = buf[otp_spic_addr_mode_addr] + if ((cfg & otp_spic_addr_mode_mask) >> otp_spic_addr_mode_pos) == mode: + self.logger.info(f"Program eFuse done") + return ErrType.OK + else: + self.logger.error(f"Fail to verify eFuse[{otp_spic_addr_mode_addr}]") + self.logger.error(f"Fail to program eFuse") + return ErrType.SYS_CHECKSUM + + def get_spic_address_mode(self): + ret = ErrType.OK + is_amebad = self.profile_info.is_amebad() + otp_spic_addr_mode_addr = OtpSpicAddrModeAddrForAmebaD if is_amebad else OtpSpicAddrModeAddr + otp_spic_addr_mode_mask = OtpSpicAddrModeMask if is_amebad else OtpSpicAddrModeMask + otp_spic_addr_mode_4byte = OtpSpicAddrMode4ByteForAmebaD if is_amebad else OtpSpicAddrMode4Byte + mode = SpicAddrMode.THREE_BYTE_MODE.value + + if self.device_info.is_boot_from_nand(): + return ret, mode + + ret, buf = self.floader_handler.otp_read_logical_map(0, self.profile_info.logical_efuse_len) + if ret != ErrType.OK: + self.logger.error(f"Fail to read eFuse: {ret}") + return ret, mode + + cfg = buf[otp_spic_addr_mode_addr] + if cfg != 0xFF: + if (cfg & otp_spic_addr_mode_mask) == otp_spic_addr_mode_4byte: + mode = SpicAddrMode.FOUR_BYTE_MODE.value + self.logger.info(f"Current supported flash size >16MB") + else: + self.logger.info(f"Current supported flash size <=16MB") + else: + self.logger.info(f"Current supported flash size <=16MB as default") + + return ret, mode + + def check_supported_flash_size(self, memory_type): + ret = ErrType.OK + reset_system = False + + if memory_type == MemoryInfo.MEMORY_TYPE_NAND: + return ret, reset_system + + self.logger.info(f"Check supported flash size...") + ret = self.prepare(show_device_info=False) + if ret != ErrType.OK: + self.logger.error("Prepare for check supported flash size fail") + return ret, reset_system + + ret, mode = self.get_spic_address_mode() + if ret != ErrType.OK: + return ret, reset_system + + flash_size = self.device_info.flash_capacity // 1024 // 1024 # MB + if self.setting.auto_program_spic_addr_mode_4byte == 0: + if (mode == SpicAddrMode.THREE_BYTE_MODE.value) and (flash_size > 16): + self.logger.warning(f"Flash size: {flash_size}MB, OTP supported flash size <= 16MB") + ret = ErrType.SYS_CANCEL + if (mode == SpicAddrMode.FOUR_BYTE_MODE.value) and (flash_size <= 16): + self.logger.warning(f"Flash size: {flash_size}MB, OTP supported flash size >16MB") + ret = ErrType.SYS_CANCEL + else: + if mode == SpicAddrMode.THREE_BYTE_MODE.value and flash_size > 16: + self.logger.warning(f"OTP supports flash size <=16MB, tool will program OTP to support flash size >16MB") + ret = self.set_spic_address_mode(SpicAddrMode.FOUR_BYTE_MODE.value) + reset_system = True + elif mode == SpicAddrMode.FOUR_BYTE_MODE.value and flash_size <= 16: + self.logger.warning(f"OTP supports flash size >16MB, tool will program OTP to support flash size <=16MB") + ret = self.set_spic_address_mode(SpicAddrMode.THREE_BYTE_MODE.value) + reset_system = True + + if reset_system: + boot_delay = self.setting.usb_rom_boot_delay_in_second if self.profile_info.support_usb_download else self.setting.floader_boot_delay_in_second + # reset floader with next option + self.floader_handler.next_operation(NextOpType.REBURN, 0) + self.logger.info(f"Reburn delay {boot_delay}s") + time.sleep(boot_delay) + + return ret, reset_system diff --git a/Flash/base/efuse_data.py b/Flash/base/efuse_data.py new file mode 100644 index 0000000..d26f0b8 --- /dev/null +++ b/Flash/base/efuse_data.py @@ -0,0 +1,20 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Realtek Semiconductor Corp. +# SPDX-License-Identifier: Apache-2.0 + +class EfuseData(): + def __init__(self, **kwargs): + self.length = 16 + + self.offset = kwargs.get("Offset", 0) + self.value = kwargs.get("Value", [0] * self.length) + + def __repr__(self): + efuse_data_dict = { + "Value": self.value, + "Offset": self.offset + } + + return efuse_data_dict \ No newline at end of file diff --git a/Flash/base/errno.py b/Flash/base/errno.py new file mode 100644 index 0000000..44d119e --- /dev/null +++ b/Flash/base/errno.py @@ -0,0 +1,41 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Realtek Semiconductor Corp. +# SPDX-License-Identifier: Apache-2.0 + +import enum + +_DEV_ERR_BASE = 0X00E0 +_SYS_ERR_BASE = 0x0100 + + +class ErrType(enum.Enum): + OK = 0X00 + + DEV_ERR_BASE = _DEV_ERR_BASE + + DEV_BUSY = _DEV_ERR_BASE + 0X1 # Busy + DEV_TIMEOUT = _DEV_ERR_BASE + 0X2 # Operation timeout + DEV_FULL = _DEV_ERR_BASE + 0X4 # Operation timeout + DEV_INVALID = _DEV_ERR_BASE + 0x5 # device invalid + DEV_LENGTH = _DEV_ERR_BASE + 0x6 # device length + DEV_CHECKSUM = _DEV_ERR_BASE + 0x7 # device checksum + DEV_ADDRESS = _DEV_ERR_BASE + 0x8 # device address + DEV_NAND_BAD_BLOCK = _DEV_ERR_BASE + 0x9 # device nand bad block + DEV_NAND_WORN_BLOCK = _DEV_ERR_BASE + 0xA # device nand worn block + DEV_NAND_BIT_FLIP_WARNING = _DEV_ERR_BASE + 0xB # device nand bit flip warning + DEV_NAND_BIT_FLIP_ERROR = _DEV_ERR_BASE + 0xC # device nand bit flip error + DEV_NAND_BIT_FLIP_FATAL = _DEV_ERR_BASE + 0xD # device nand bit flip fatal + + SYS_ERR_BASE = _SYS_ERR_BASE + SYS_TIMEOUT = _SYS_ERR_BASE + 0x02 # Operation timeout + SYS_PARAMETER = _SYS_ERR_BASE + 0X3 # Invalid parameter + SYS_IO = _SYS_ERR_BASE + 0x05 # IO error + SYS_NAK = _SYS_ERR_BASE + 0x15 # Device NAK + SYS_PROTO = _SYS_ERR_BASE + 0x22 # Protocol error + SYS_CHECKSUM = _SYS_ERR_BASE + 0x23 # checksum error + SYS_OVERRANGE = _SYS_ERR_BASE + 0x24 # operation overrange + SYS_CANCEL = _SYS_ERR_BASE + 0x30 # Operation cancelled + SYS_UNKNOWN = _SYS_ERR_BASE + 0xEE # Unknown error + diff --git a/Flash/base/flash_utils.py b/Flash/base/flash_utils.py new file mode 100644 index 0000000..52e7896 --- /dev/null +++ b/Flash/base/flash_utils.py @@ -0,0 +1,145 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Realtek Semiconductor Corp. +# SPDX-License-Identifier: Apache-2.0 + +from enum import Enum +from .sys_utils import * + + +class FlashBPS: + def __init__(self): + self.need_unlock = False + self.protection = 0 + self.is_locked = False + + +class FlashUtils(Enum): + # NAND Flash MID + NandMfgDosilicon = 0xE5 + NandMfgGigadevice = 0xC8 + NandMfgMacronix = 0xC2 + NandMfgMicron = 0x2C + NandMfgWinbond = 0xEF + + # NAND Flash commands + NandCmdGetFeatures = 0x0F + NandCmdSetFeatures = 0x1F + + # NAND Flash registers + NandRegProtection = 0xA0 + NandRegFeature = 0xB0 + NandRegStatus = 0xC0 + NandRegProtectionBpMask = 0x38 + NandRegProtectionBpMaskWinbondMicron = 0x78 + + # NOR Flash command & registers + NorCmdReadStatusReg1 = 0x05 + NorCmdWriteStatusReg1 = 0x01 + NorStatusReg1BpMask = 0x3C + + # NOR Flash default page/block size + NorDefaultPageSize = 1024 + NorDefaultPagePerBlock = 4 + NorDefaultBlockSize = (NorDefaultPageSize * NorDefaultPagePerBlock) + + # NAND Flash default page/block size + NandDefaultPageSize = 2048 + NandDefaultPagePerBlock = 64 + NandDefaultBlockSize = (NandDefaultPageSize * NandDefaultPagePerBlock) + + # Flash write padding data + FlashWritePaddingData = 0xFF + + MinFlashProcessTimeoutInSecond = 1 + + # NOR Flash program / read / erase timeout + # Max page program time: typical 0.5ms for GD25Q128E, max 3ms for W25Q256JV + # Take 0.5ms * 10 = 5ms + NorPageProgramTimeoutInSeconds = 0.005 + # Max 4KB sector erase time: typical 70ms for GD25Q256D, max 400ms for GD25Q256D + # Take(400ms * 2) > (70ms * 10 = 700ms) + Nor4kSectorEraseTimeoutInSenonds = 0.8 + # Max 32KB block erase time: typical 0.16s for GD25Q128E, max 1.6s for W25Q256JV + # Take 2s > 0.16s * 10 = 1.6s + Nor32kBlockEraseTimeoutInSeconds = 2 # larger than max + # Max 64KB block erase time: typical 0.25s for GD25Q128E, max 1.6s for GD25Q128E + # Take 0.25s * 10 = 2.5s + Nor64kBlockEraseTimeoutInSeconds = 2.5 + # Max chip erase time: typical 150s for W25Q256JV, max 400s for W25Q256JV + # Take 1000s > 400s * 2 = 800s + NorChipEraseTimeoutInSeconds = 1000 + # Read flash with 1IO @ 10MHz(10Mbps) + NorRead1KiBTimeoutInSecond = 0.001 + # NOR calculate checksum with 1.5MB / s, test data(15.8MB / 8s) + NorCalculate1KiBChecksumTimeoutInSeconds = 0.001 + + # NAND Flash 4KB page size read / program / block erase timeout + # Page size read from Array with ECC(900us) + page size(4KB) read from cache with 1IO @ 10MHz(4KB / 10Mbps=3.2ms) + # Max read from array with ECC: 90us for MT29F4G01ABBF, max 170us for MT29F4G01ABBF + # Take 90us * 10 = 900us + 3.2ms + NandPageReadTimeoutInSeconds = 0.004 + # page program with ECC(4ms) + page size(4KB) load with 1IO @ 10MHz(4KB / 10Mbps=3.2ms) + # Max page program with ECC: 400us for MX35LF4GE4AD, max 800us for MX35LF4GE4AD + # Take 400us * 10 = 4ms + 3.2ms + NandPageProgramTimeoutInSeconds = 0.007 + # Max block erase time: typical 4ms for MX35LFxG24AD, max 10ms for MT29F2G01ABAG + # Take 4ms * 10 = 40ms + NandBlockEraseTimeoutInSeconds = 0.04 + # NAND calculate checksum with 1.5MB / s, test data(2KB / 4KB:47MB / 23s) + NandCalculate1KiBChecksumTimeoutInSeconds = 0.001 + + +def nor_read_timeout_in_second(size_in_byte): + return int(max(divide_then_round_up(size_in_byte, 1024) * FlashUtils.NorRead1KiBTimeoutInSecond.value, + FlashUtils.MinFlashProcessTimeoutInSecond.value)) + + +def nor_erase_timeout_in_second(size_in_kbyte): + if size_in_kbyte == 0xFFFFFFFF: + return int(max(FlashUtils.NorChipEraseTimeoutInSeconds.value, + FlashUtils.MinFlashProcessTimeoutInSecond.value)) + elif size_in_kbyte == 4: + return int(max(FlashUtils.Nor4kSectorEraseTimeoutInSenonds.value, + FlashUtils.MinFlashProcessTimeoutInSecond.value)) + elif size_in_kbyte == 32: + return int(max(FlashUtils.Nor32kBlockEraseTimeoutInSeconds.value, + FlashUtils.MinFlashProcessTimeoutInSecond.value)) + elif size_in_kbyte == 64: + return int(max(FlashUtils.Nor64kBlockEraseTimeoutInSeconds.value, + FlashUtils.MinFlashProcessTimeoutInSecond.value)) + else: + return int( + max(divide_then_round_up(size_in_kbyte, 4) * FlashUtils.Nor4kSectorEraseTimeoutInSenonds.value, + FlashUtils.MinFlashProcessTimeoutInSecond.value)) + + +def nor_checksum_timeout_in_second(size_in_byte): + return int(max(divide_then_round_up(size_in_byte, + 1024) * FlashUtils.NorCalculate1KiBChecksumTimeoutInSeconds.value, + FlashUtils.MinFlashProcessTimeoutInSecond.value)) + + +def nand_read_timeout_in_second(size_in_byte, page_size): + return int(max( + divide_then_round_up(size_in_byte, page_size) * FlashUtils.NandPageReadTimeoutInSeconds.value, + FlashUtils.MinFlashProcessTimeoutInSecond.value)) + + +def nand_program_timeout_in_second(size_in_byte, page_size): + return int(max( + divide_then_round_up(size_in_byte, page_size) * FlashUtils.NandPageProgramTimeoutInSeconds.value, + FlashUtils.MinFlashProcessTimeoutInSecond.value)) + + +def nand_erase_timeout_in_second(size_in_byte, block_size): + return int(max( + divide_then_round_up(size_in_byte, block_size) * FlashUtils.NandBlockEraseTimeoutInSeconds.value, + FlashUtils.MinFlashProcessTimeoutInSecond.value)) + + +def nand_checksum_timeout_in_second(size_in_byte, page_size): + return int(max( + divide_then_round_up(size_in_byte, 1024) * FlashUtils.NandCalculate1KiBChecksumTimeoutInSeconds.value, + FlashUtils.MinFlashProcessTimeoutInSecond.value)) diff --git a/Flash/base/floader_handler.py b/Flash/base/floader_handler.py new file mode 100644 index 0000000..eda67fc --- /dev/null +++ b/Flash/base/floader_handler.py @@ -0,0 +1,646 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Realtek Semiconductor Corp. +# SPDX-License-Identifier: Apache-2.0 + +import time +import ctypes + +from .sense_status import * +from .device_info import * +from .next_op import * +from .flash_utils import * + +BAUDSET = 0x81 +QUERY = 0x02 +CONFIG = 0x83 +WRITE = 0x84 +READ = 0x05 +CHKSM = 0x06 +SENSE = 0x07 +NEXTOP = 0x08 + +FS_ERASE = 0xA0 +FS_RDSTS = 0x21 +FS_WTSTS = 0xA2 +FS_MKBAD = 0xA3 +FS_CHKMAP = 0x24 +FS_CHKBAD = 0x25 +FS_CHKBLK = 0x26 + +OTP_RRAW = 0x40 +OTP_WRAW = 0xC1 +OTP_RMAP = 0x42 +OTP_WMAP = 0xC3 + +ACK_BUF_EMPTY = 0xB0 +ACK_BUF_FULL = 0xB1 + +NP_EXIT = 0x01 +NP_BAUDRATE_RECOVER = 0x02 + +SOF = 0xA5 + +QUERY_DATA_OFFSET_DID = 0 +QUERY_DATA_OFFSET_IMAGE_TYPE = 2 +QUERY_DATA_OFFSET_CMD_SET_VERSION = 4 +QUERY_DATA_OFFSET_MEMORY_TYPE = 6 +QUERY_DATA_OFFSET_FLASH_MID = 7 +QUERY_DATA_OFFSET_FLASH_DID = 8 +QUERY_DATA_OFFSET_FLASH_MFG = 10 +QUERY_DATA_OFFSET_FLASH_MODEL = 22 +QUERY_DATA_OFFSET_FLASH_PAGE_SIZE = 42 +QUERY_DATA_OFFSET_FLASH_OOB_SIZE = 46 +QUERY_DATA_OFFSET_FLASH_PAGES_PER_BLOCK = 48 +QUERY_DATA_OFFSET_FLASH_BLOCKS_PER_LUN = 52 +QUERY_DATA_OFFSET_FLASH_LUNS_PER_TARGET = 56 +QUERY_DATA_OFFSET_FLASH_MAX_BAD_BLOCKS_PER_LUN = 57 +QUERY_DATA_OFFSET_FLASH_REQ_HOST_ECC_LEVEL = 59 +QUERY_DATA_OFFSET_FLASH_TARGETS = 60 +QUERY_DATA_OFFSET_FLASH_CAPACITY = 61 +QUERY_DATA_OFFSET_WIFI_MAC = 65 + +# Read otp logical map time:26ms, physical map time: 170ms +# Program otp logical map time: 7ms +OTP_READ_TIMEOUT_IN_SECONDS = 10 + + +class FloaderHandler(object): + def __init__(self, ameba_obj): + self.ameba = ameba_obj + self.serial_port = ameba_obj.serial_port + self.profile = ameba_obj.profile_info + self.logger = ameba_obj.logger + self.setting = ameba_obj.setting + super().__init__() + + def send_request(self, request, length, timeout, is_sync=True): + ret = ErrType.SYS_UNKNOWN + response_bytes = None + + len_l = length & 0xFF + len_h = (length >> 8) & 0xFF + + frame_data = [(SOF)] + frame_data.append(len_l) + frame_data.append(len_h) + frame_data.append((len_l ^ len_h) & 0xFF) + + frame_bytes = bytearray(frame_data) + frame_bytes += request[:length] + checksum = sum(request) + + frame_bytes += (checksum & 0xFF).to_bytes(1, byteorder="little") + + try: + retry = 0 + while retry < self.setting.request_retry_count: + retry += 1 + + self.serial_port.flushInput() + self.serial_port.flushOutput() + + self.ameba.write_bytes(frame_bytes) + self.logger.debug(f"Request: len={length}, payload={request.hex()}") + + ret, ret_byte = self.ameba.read_bytes(timeout) + if ret != ErrType.OK: + self.logger.error(f"Response error: {ret}, timeout:{timeout}") + continue + if is_sync: + if ret_byte[0] == SOF: + ret, ret_bytes = self.ameba.read_bytes(timeout, size=3) + if ret == ErrType.OK: + len_l = ret_bytes[0] + len_h = ret_bytes[1] + len_xor = ret_bytes[2] + response_len = (len_h << 8) + len_l + if len_xor == (len_l ^ len_h): + ret, response_bytes = self.ameba.read_bytes(self.setting.async_response_timeout_in_second, + size=response_len + 1) + if ret == ErrType.OK: + if response_len >= len(response_bytes) - 1: + self.logger.debug( + f"Response: len={response_len}, payload={response_bytes.hex()}") + checksm = sum(response_bytes[:response_len]) % 256 + if checksm == response_bytes[response_len]: + self.logger.debug(f"Checksum={checksm}({hex(checksm)}), ok") + break + else: + self.logger.debug( + f"Response checksum error: expect {hex(checksm)}, get {hex(response_bytes[response_len + 1])}") + ret = ErrType.SYS_CHECKSUM + else: + ret = ErrType.SYS_PROTO + self.logger.debug( + f"Response length error: expect {len_xor}, get {len_l ^ len_h}") + else: + self.logger.debug(f"Read response payload error: {ret}") + else: + ret = ErrType.SYS_PROTO + self.logger.debug(f"Response length check error: {ret_bytes.hex()}") + else: + ret = ErrType.SYS_PROTO + self.logger.error(f"Read response length error: {ret}") + elif ret_byte[0] >= ErrType.DEV_ERR_BASE.value: + ret = ret_byte + self.logger.debug(f"Negative response 0x{ret_byte.hex()}: ") + if ret_byte[0] == ErrType.DEV_FULL.value: + time.sleep(self.setting.request_retry_interval_second) + else: + ret = ErrType.SYS_PROTO + self.logger.debug(f"Unexpected response opcode {ret_byte.hex()}") + else: + if ret_byte[0] == ACK_BUF_FULL: + self.logger.debug(f"ACK: Rx buffer full, wait {self.setting.request_retry_interval_second}s") + time.sleep(self.setting.request_retry_interval_second) + ret = ErrType.OK + elif ret_byte[0] == ACK_BUF_EMPTY: + self.logger.debug(f"Response: ACK") + ret = ErrType.OK + break + elif ret_byte[0] >= ErrType.DEV_ERR_BASE.value: + ret = ret_byte + self.logger.debug(f"Negative response: {ret_byte}") + if ret_byte[0] == ErrType.DEV_FULL.value or ErrType.DEV_BUSY.value: + time.sleep(self.setting.request_retry_interval_second) + else: + ret = ErrType.SYS_PROTO + self.logger.debug(f"Unexpected response {ret_byte}") + + if ret == ErrType.OK or ret == ErrType.SYS_CANCEL: + break + else: + time.sleep(self.setting.request_retry_interval_second) + except Exception as err: + self.logger.debug(f"Response exception: {err}") + ret = ErrType.SYS_IO + + return ret, response_bytes + + def sense(self, timeout, op_code=None, data=None): + self.logger.debug(f"Sense...") + ret, sense_ack = self.send_request(SENSE.to_bytes(1, byteorder="little"), length=1, timeout=timeout) + if ret == ErrType.OK: + sense_status = SenseStatus() + self.logger.debug(f"Sense response raw data: {sense_ack.hex()}") + if sense_ack[0] == (SENSE): + ret = sense_status.parse(sense_ack, 1) + if ret == ErrType.OK: + self.logger.debug( + f"Sense response: opcode={hex(sense_status.op_code)}, status=0x{format(sense_status.status, '02x')}, data={hex(sense_status.data)}") + if sense_status.status != ErrType.OK.value: + ret = sense_status.status + self.logger.warning( + f"Sense fail: opcode={hex(sense_status.op_code)}, data={sense_status.data}, status={ret}") + elif (op_code is not None) and (sense_status.op_code != op_code): + ret = ErrType.SYS_PROTO + self.logger.error( + f"Sense protocol error: expect opcode-data {op_code.hex()}-{hex(data)}, get {hex(sense_status.op_code)}-{hex(sense_status.data)}") + else: + if (data is not None) and sense_status.data != data: + self.logger.debug( + f"Sense protocol warning: opcode {op_code} expect data {data}, get {sense_status.data}, ignored") + self.logger.debug("Sense ok") + else: + self.logger.debug(f"Sense fail to parse sense response") + else: + self.logger.debug(f"Sense fail: unexpected opcode {sense_ack[0]}") + else: + self.logger.debug(f"Sense fail: {ret}") + return ret, sense_ack + + def handshake(self, baudrate): + ret = ErrType.SYS_UNKNOWN + + self.logger.debug(f"Floader handshake start") + try: + if self.serial_port.baudrate != baudrate and self.setting.switch_baudrate_at_floader == 1: + baudset = [BAUDSET] + + baudset.extend(list(baudrate.to_bytes(4, byteorder="little"))) + + self.logger.debug(f"BAUDSET {baudrate}") + + baudset_bytes = bytearray(baudset) + ret, _ = self.send_request(baudset_bytes, len(baudset_bytes), self.setting.async_response_timeout_in_second, + is_sync=False) + if ret != ErrType.OK: + self.logger.debug(f"BAUDSET fail: {ret}") + return ret + ret = self.ameba.switch_baudrate(baudrate, self.setting.baudrate_switch_delay_in_second, True) + if ret != ErrType.OK: + self.logger.error(f"Fail to switch baudrate to {baudrate}: {ret}") + return ret + + for retry in range(3): + if retry > 0: + self.logger.debug(f"Sense retry {retry}#") + else: + self.logger.debug("Sense") + + ret, resp = self.sense(self.setting.sync_response_timeout_in_second) + if ret == ErrType.OK: + break + else: + self.logger.debug(f"Sense failed: {ret}") + time.sleep(self.setting.request_retry_interval_second) + continue + + if ret == ErrType.OK: + self.logger.debug(f"Floader handshake done") + else: + self.logger.error(f"Floader handshake timeout") + except Exception as err: + self.logger.error(f"Floader handshake exception: {err}") + + return ret + + def query(self): + device_info = DeviceInfo() + self.logger.debug(f"QUERY...") + ret, resp = self.send_request(QUERY.to_bytes(1, byteorder="little"), length=1, + timeout=self.setting.sync_response_timeout_in_second) + if ret == ErrType.OK: + if resp[0] == (QUERY): + device_info.did = resp[QUERY_DATA_OFFSET_DID + 1] + (resp[QUERY_DATA_OFFSET_DID + 2] << 8) + device_info.image_type = resp[QUERY_DATA_OFFSET_IMAGE_TYPE + 1] + ( + resp[QUERY_DATA_OFFSET_IMAGE_TYPE + 2] << 8) + device_info.cmd_set_version = resp[QUERY_DATA_OFFSET_CMD_SET_VERSION + 1] + ( + resp[QUERY_DATA_OFFSET_CMD_SET_VERSION + 2] << 8) + device_info.wifi_mac = resp[QUERY_DATA_OFFSET_WIFI_MAC + 1: QUERY_DATA_OFFSET_WIFI_MAC + 1 + 6] + device_info.memory_type = resp[QUERY_DATA_OFFSET_MEMORY_TYPE + 1] + device_info.flash_mid = resp[QUERY_DATA_OFFSET_FLASH_MID + 1] + device_info.flash_did = resp[QUERY_DATA_OFFSET_FLASH_DID + 1] + ( + resp[QUERY_DATA_OFFSET_FLASH_DID + 2] << 8) + device_info.flash_mfg = resp[ + QUERY_DATA_OFFSET_FLASH_MFG + 1: QUERY_DATA_OFFSET_FLASH_MFG + 1 + 12].decode("utf-8") + device_info.flash_model = resp[ + QUERY_DATA_OFFSET_FLASH_MODEL + 1: QUERY_DATA_OFFSET_FLASH_MODEL + 1 + 20].decode("utf-8") + device_info.flash_page_size = (resp[QUERY_DATA_OFFSET_FLASH_PAGE_SIZE + 1] + + (resp[QUERY_DATA_OFFSET_FLASH_PAGE_SIZE + 2] << 8) + + (resp[QUERY_DATA_OFFSET_FLASH_PAGE_SIZE + 3] << 16) + + (resp[QUERY_DATA_OFFSET_FLASH_PAGE_SIZE + 4] << 24)) + device_info.flash_oob_size = (resp[QUERY_DATA_OFFSET_FLASH_OOB_SIZE + 1] + ( + resp[QUERY_DATA_OFFSET_FLASH_OOB_SIZE + 2] << 8)) + device_info.flash_pages_per_block = (resp[QUERY_DATA_OFFSET_FLASH_PAGES_PER_BLOCK + 1] + + (resp[QUERY_DATA_OFFSET_FLASH_PAGES_PER_BLOCK + 2] << 8) + + (resp[QUERY_DATA_OFFSET_FLASH_PAGES_PER_BLOCK + 3] << 16) + + (resp[QUERY_DATA_OFFSET_FLASH_PAGES_PER_BLOCK + 4] << 24)) + device_info.flash_blocks_per_lun = (resp[QUERY_DATA_OFFSET_FLASH_BLOCKS_PER_LUN + 1] + + (resp[QUERY_DATA_OFFSET_FLASH_BLOCKS_PER_LUN + 2] << 8) + + (resp[QUERY_DATA_OFFSET_FLASH_BLOCKS_PER_LUN + 3] << 16) + + (resp[QUERY_DATA_OFFSET_FLASH_BLOCKS_PER_LUN + 4] << 24)) + device_info.flash_luns_per_target = resp[QUERY_DATA_OFFSET_FLASH_LUNS_PER_TARGET + 1] + device_info.flash_max_bad_block_per_lun = (resp[QUERY_DATA_OFFSET_FLASH_MAX_BAD_BLOCKS_PER_LUN + 1] + + (resp[ + QUERY_DATA_OFFSET_FLASH_MAX_BAD_BLOCKS_PER_LUN + 2] << 8)) + device_info.flash_req_host_ecc_level = resp[QUERY_DATA_OFFSET_FLASH_REQ_HOST_ECC_LEVEL + 1] + device_info.flash_targets = resp[QUERY_DATA_OFFSET_FLASH_TARGETS + 1] + device_info.flash_capacity = (resp[QUERY_DATA_OFFSET_FLASH_CAPACITY + 1] + + (resp[QUERY_DATA_OFFSET_FLASH_CAPACITY + 2] << 8) + + (resp[QUERY_DATA_OFFSET_FLASH_CAPACITY + 3] << 16) + + (resp[QUERY_DATA_OFFSET_FLASH_CAPACITY + 4] << 24)) + else: + self.logger.debug(f"Query: unexpected response: {resp[0]}") + ret = ErrType.SYS_PROTO + + return ret, device_info + + def config(self, configs): + config_data = [(CONFIG)] + + config_data.append(configs[0][0]) + config_data.append(configs[0][1]) + config_data.append(configs[0][2]) + config_data.append(configs[0][3]) + config_data.append(configs[0][4]) + config_data.append(configs[0][5]) + config_data.append(configs[0][6]) + config_data.append(configs[0][7]) + + config_data.append(configs[1][0]) + config_data.append(configs[1][1]) + config_data.append(configs[1][2]) + config_data.append(configs[1][3]) + config_data.append(configs[1][4]) + config_data.append(configs[1][5]) + config_data.append(configs[1][6]) + config_data.append(configs[1][7]) + + request_bytes = bytearray(config_data) + self.logger.debug(f"CONFIG: {configs[0].hex()} {configs[1].hex()}") + ret, _ = self.send_request(request_bytes, len(request_bytes), self.setting.async_response_timeout_in_second, is_sync=False) + + return ret + + def next_operation(self, opcode, operand): + request_data = [NEXTOP] + + request_data.append((opcode.value) & 0xFF) + request_data.extend(list(operand.to_bytes(4, byteorder="little"))) + + self.logger.debug(f"NEXTOTP: opecode={opcode}, operand={operand}") + + request_bytes = bytearray(request_data) + + ret, _ = self.send_request(request_bytes, len(request_bytes), self.setting.sync_response_timeout_in_second, is_sync=False) + + return ret + + def reset_in_download_mode(self): + self.logger.debug(f"Reset in download mode") + return self.next_operation(NextOpType.REBURN, 0) + + def write(self, mem_type, src, size, addr, timeout, need_sense=False): + sense_status = SenseStatus() + + write_data = [WRITE] + write_data.append(mem_type&0xFF) + write_data.extend(list(addr.to_bytes(4, byteorder="little"))) + + write_array = bytearray(write_data) + write_array += src[:size] + + self.logger.debug(f"WRITE: addr={hex(addr)}, size={size}, mem_type={mem_type}, need_sense={need_sense}") + ret, _ = self.send_request(write_array, len(write_array), self.setting.write_response_timeout_in_second, is_sync=False) + if ret == ErrType.OK: + if need_sense: + ret, sense_ack = self.sense(timeout, op_code=WRITE, data=addr) + if ret != ErrType.OK: + self.logger.error(f"WRITE addr={hex(addr)} fail: {ret}") + + return ret + + def read(self, mem_type, addr, size, timeout): + resp = None + + read_data = [READ] + read_data.append((mem_type & 0xFF)) + read_data.extend(list(addr.to_bytes(4, byteorder="little"))) + read_data.extend(list(size.to_bytes(4, byteorder="little"))) + + self.logger.debug(f"READ: addr={hex(addr)}, size={size}, mem_type={mem_type}") + read_bytes = bytearray(read_data) + ret, resp_ack = self.send_request(read_bytes, len(read_bytes), timeout) + if ret == ErrType.OK: + if resp_ack[0] == READ: + resp = resp_ack[1:] + else: + self.logger.debug(f"READ got unexpected response {hex(resp_ack[0])}") + ret = ErrType.SYS_PROTO + else: + self.logger.debug(f"READ fail: {ret}") + + return ret + + def checksum(self, mem_type, start_addr, end_addr, size, timeout): + chk_rest = 0 + request_data = [CHKSM] + request_data.append((mem_type & 0xFF)) + request_data.extend(list(start_addr.to_bytes(4, byteorder='little'))) + + request_data.extend(list(end_addr.to_bytes(4, byteorder='little'))) + + request_data.extend(list(size.to_bytes(4, byteorder='little'))) + + self.logger.debug(f"CHKSM: start={hex(start_addr)}, end={hex(end_addr)}, size={size}, mem_type={mem_type}") + request_bytes = bytearray(request_data) + ret, resp = self.send_request(request_bytes, len(request_bytes), timeout) + if ret == ErrType.OK: + if resp[0] == int(CHKSM): + chk_rest = resp[1] + (resp[2] << 8) + (resp[3] << 16) + (resp[4] << 24) + self.logger.debug(f"CHKSM: result={hex(chk_rest)}") + else: + self.logger.debug(f"CHKSM: unexpected response {resp[0]}") + ret = ErrType.SYS_PROTO + else: + self.logger.error(f"CHKSM fail: {ret}") + + return ret, chk_rest + + def erase_flash(self, mem_type, start_addr, end_addr, size, timeout, sense=False, force=False): + self.logger.debug(f"Erase flash: start_addr={hex(start_addr)}, end_addr={hex(end_addr)} size={size}") + request_data = [FS_ERASE] + request_data.append((mem_type & 0xFF)) + request_data.append(1 if force else 0) + + request_data.extend(list(start_addr.to_bytes(4, byteorder="little"))) + request_data.extend(list((end_addr & 0xFFFFFFFF).to_bytes(4, byteorder="little"))) + + request_data.extend(list((size & 0xFFFFFFFF).to_bytes(4, byteorder="little"))) + + if force: + self.logger.warning(f"FS_ERASE: start_addr={hex(start_addr)}, end_addr={hex(end_addr)}, size={size}, mem_type={mem_type} force") + else: + self.logger.debug(f"FS_ERASE: start_addr={hex(start_addr)}, end_addr={hex(end_addr)}, size={size}, mem_type={mem_type}") + + request_bytes = bytearray(request_data) + ret, _ = self.send_request(request_bytes, len(request_bytes), self.setting.async_response_timeout_in_second, is_sync=False) + if ret != ErrType.OK: + self.logger.warning(f"FS_ERASE start_addr={hex(start_addr)}, end_addr={hex(end_addr)}, size={size}, force={force}, fail:{ret}") + return ret + + if sense: + ret, sense_status = self.sense(timeout, op_code=FS_ERASE, data=start_addr) + if ret != ErrType.OK: + self.logger.error(f"FS_ERASE start_addr={hex(start_addr)}, size={size} force={force} fail: {ret}") + + return ret + + def read_status_register(self, cmd, address): + status = None + + request_data = [FS_RDSTS] + request_data.append(cmd) + request_data.append(address) + + self.logger.debug(f"FS_RDSTS: cmd={cmd}, address={address}") + + request_bytes = bytearray(request_data) + ret, resp = self.send_request(request_bytes, len(request_bytes), self.setting.sync_response_timeout_in_second) + if ret == ErrType.OK: + if resp[0] == FS_RDSTS: + status = resp[1] + self.logger.debug(f"FS_RDSTS: status={status}") + else: + self.logger.debug(f"FS_RDSTS: got unexpected response, {resp[0]}") + ret = ErrType.SYS_PROTO + else: + self.logger.debug(f"FS_RDSTS failed: {ret}") + + return ret, status + + def write_status_register(self, cmd, addr, value): + request_data = [FS_WTSTS] + request_data.append(cmd) + request_data.append(addr) + request_data.append(value) + + self.logger.debug(f"FS_WTSTS: cmd={hex(cmd)}, addr={hex(addr)}, value={hex(value)}") + + request_bytes = bytearray(request_data) + ret, _ = self.send_request(request_bytes, len(request_bytes), self.setting.async_response_timeout_in_second, is_sync=False) + + return ret + + def mark_bad_block(self, address): + request_data = [FS_MKBAD] + + request_data.extend(list(address.to_bytes(4, byteorder="little"))) + request_bytes = bytearray(request_data) + self.logger.debug(f"FS_MKBAD: addr={format(address, '08x')}") + return self.send_request(request_bytes, len(request_bytes), self.setting.async_response_timeout_in_second, is_sync=False) + + def check_bad_block(self, address): + ret = ErrType.OK + status = None + + request_data = [FS_CHKBAD] + + request_data.extend(list(address.to_bytes(4, byteorder="little"))) + request_bytes = bytearray(request_data) + self.logger.debug(f"FS_CHKBAD: addr={format(address, '08x')}") + + ret, resp = self.send_request(request_bytes, len(request_bytes), self.setting.sync_response_timeout_in_second) + if ret == ErrType.OK: + if resp[0] == FS_CHKBAD: + status = resp[1] + else: + self.logger.debug(f"FS_CHKBAD got unexpected response {hex(resp[0])}") + ret = ErrType.SYS_PROTO + else: + self.logger.debug(f"FS_CHKBAD fail: {ret}") + + return ret, status + + def check_block_status(self, address): + ret = ErrType.OK + block_status = 0 + page_status = [0, 0] + + request_data = [FS_CHKBLK] + + request_data.extend(list(address.to_bytes(4, byteorder="little"))) + request_bytes = bytearray(request_data) + self.logger.debug(f"FS_CHKBLK: addr={format(address, '08x')}") + + ret, resp = self.send_request(request_bytes, len(request_bytes), self.setting.sync_response_timeout_in_second) + if ret == ErrType.OK: + if resp[0] == FS_CHKBLK: + block_status = resp[1] + page_status[0] = ctypes.c_uint64(resp[2] + (resp[3] << 8) + (resp[4] << 16) + + (resp[5] << 24) + (resp[6] << 32) + (resp[7] << 40) + + (resp[8] << 48) + (resp[9] << 56)).value + page_status[1] = ctypes.c_uint64(resp[10] + (resp[11] << 8) + (resp[12] << 16) + + (resp[13] << 24) + (resp[14] << 32) + (resp[15] << 40) + + (resp[16] << 48) + (resp[17] << 56)).value + + self.logger.debug( + f"FS_CHKBLK: block_status={hex(block_status)}, page status={format(page_status[0], '16x')} {format(page_status[1], '16x')}") + else: + self.logger.debug(f"FS_CHKBLK fail: {ret}") + + return ret, block_status + + def check_map_status(self, address): + ret = ErrType.OK + + status = 0 + request_data = [FS_CHKMAP] + request_data.extend(list(address.to_bytes(4, byteorder="little"))) + request_bytes = bytearray(request_data) + self.logger.debug(f"FS_CHKMAP: addr={format(address, '08x')}") + + ret, resp = self.send_request(request_bytes, len(request_bytes), self.setting.sync_response_timeout_in_second) + if ret == ErrType.OK: + if resp[0] == FS_CHKMAP: + status = (resp[1] + (resp[2] << 8) + (resp[3] << 16) + (resp[4] << 24)) & 0xFFFFFFFF + self.logger.debug(f"FS_CHKMAP: status={format(status, '08x')}") + else: + self.logger.debug(f"FS_CHKMAP fail: {ret}") + + return ret, status + + def otp_read_map(self, cmd, address, size): + request_data = [cmd] + + request_data.extend(list(address.to_bytes(4, byteorder="little"))) + + request_data.extend(list(size.to_bytes(4, byteorder="little"))) + + request_bytes = bytearray(request_data) + + ret, buf = self.send_request(request_bytes, len(request_bytes), OTP_READ_TIMEOUT_IN_SECONDS) + if ret == ErrType.OK: + if buf[0] == cmd: + self.logger.debug(f"Otp read: {buf[0]}") + return ret, buf[1:-1] + else: + self.logger.debug(f"Otp read fail: unexpected response {buf[0]}") + ret = ErrType.SYS_PROTO + + return ret, buf + + def otp_write_map(self, cmd, address, size, data): + request_data = [cmd] + + request_data.extend(list(address.to_bytes(4, byteorder="little"))) + + request_data.extend(list(size.to_bytes(4, byteorder="little"))) + + for idx in range(size): + request_data.append(data[address + idx]) + + request_bytes = bytearray(request_data) + + ret, _ = self.send_request(request_bytes, len(request_bytes), self.setting.async_response_timeout_in_second, is_sync=False) + + return ret + + def otp_read_physical_map(self, address, size): + self.logger.debug(f"OTP_RRAW: addr={hex(address)}, size={size}") + + ret, buf = self.otp_read_map(OTP_RRAW, address, size) + if ret == ErrType.OK: + self.logger.debug(f"OTP_RRAW: {buf}") + self.logger.debug("OTP_RRAW done") + else: + self.logger.debug(f"OTP_RRAW fail: {ret}") + + return ret, buf + + def otp_write_physical_map(self, address, size, data): + self.logger.debug(f"OTP_WRAW: addr={hex(address)}, size={size}, data={data}") + + ret = self.otp_write_map(OTP_WMAP, address, size, data) + if ret == ErrType.OK: + self.logger.debug("OTP_WRAW done") + else: + self.logger.debug(f"OTP_WRAW fail: {ret}") + + return ret + + def otp_read_logical_map(self, address, size): + self.logger.debug(f"OTP_RMAP: addr={hex(address)}, size={size}") + + ret, buf = self.otp_read_map(OTP_RMAP, address, size) + if ret == ErrType.OK: + self.logger.debug(f"OTP_RMAP: {buf}") + self.logger.debug("OTP_RMAP done") + else: + self.logger.debug(f"OTP_RMAP fail: {ret}") + + return ret, buf + + def otp_write_logical_map(self, address, size, data): + self.logger.debug(f"OTP_WMAP: addr={hex(address)}, size={size}, data={data}") + + ret = self.otp_write_map(OTP_WMAP, address, size, data) + if ret == ErrType.OK: + self.logger.debug("OTP_WMAP done") + else: + self.logger.debug(f"OTP_WMAP fail: {ret}") + + return ret diff --git a/Flash/base/image_info.py b/Flash/base/image_info.py new file mode 100644 index 0000000..860d637 --- /dev/null +++ b/Flash/base/image_info.py @@ -0,0 +1,28 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Realtek Semiconductor Corp. +# SPDX-License-Identifier: Apache-2.0 + +class ImageInfo(): + def __init__(self, **kwargs): + self.image_name = kwargs.get("ImageName", "") + self.start_address = kwargs.get("StartAddress", 0) + self.end_address = kwargs.get("EndAddress", 0) + self.memory_type = kwargs.get("MemoryType", 0) + self.full_erase = kwargs.get("FullErase", False) + self.mandatory = kwargs.get("Mandatory", False) + self.description = kwargs.get("Description", "") + + def __repr__(self): + image_info_dict = { + "ImageName": self.image_name, + "StartAddress": self.start_address, + "EndAddress": self.end_address, + "MemoryType": self.memory_type, + "FullErase": self.full_erase, + "Mandatory": self.mandatory, + "Description": self.description + } + + return image_info_dict \ No newline at end of file diff --git a/Flash/base/json_utils.py b/Flash/base/json_utils.py new file mode 100644 index 0000000..715a0ee --- /dev/null +++ b/Flash/base/json_utils.py @@ -0,0 +1,48 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Realtek Semiconductor Corp. +# SPDX-License-Identifier: Apache-2.0 + +import os +import json +import base64 +from pyDes import * + +_DES_KEY = "574C414E" # 0x574C414E, WLAN +_DES_IV = [0x40, 0x52, 0x65, 0x61, 0x6C, 0x73, 0x69, 0x6C] # @Realsil + + +class JsonUtils: + @staticmethod + def load_from_file(file_path, need_decrypt=True): + result = None + if os.path.exists(file_path): + with open(file_path, 'r', encoding='utf-8') as f: + f_content = f.read() + + if need_decrypt: + k = des(_DES_KEY, CBC, _DES_IV, padmode=PAD_PKCS5) + des_k = k.decrypt(base64.b64decode(f_content)) + profile_str = des_k.decode("utf-8") + result = json.loads(profile_str) + else: + result = json.loads(f_content) + + return result + + @staticmethod + def save_to_file(file_path, data, need_encrypt=False): + path_dir = os.path.dirname(file_path) + os.makedirs(path_dir, exist_ok=True) + if need_encrypt: + ek = des(_DES_KEY.encode("utf-8"), CBC, _DES_IV, pad=None, padmode=PAD_PKCS5) + en_bytes = ek.encrypt(json.dumps(data).encode("utf-8")) + save_data = base64.b64encode(en_bytes) + with open(file_path, "wb") as json_file: + json_file.write(save_data) + else: + save_data = data + + with open(file_path, "w") as json_file: + json.dump(save_data, json_file, indent=2) diff --git a/Flash/base/memory_info.py b/Flash/base/memory_info.py new file mode 100644 index 0000000..efaf2e3 --- /dev/null +++ b/Flash/base/memory_info.py @@ -0,0 +1,24 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Realtek Semiconductor Corp. +# SPDX-License-Identifier: Apache-2.0 + + +class MemoryInfo: + MEMORY_TYPE_RAM = 0 + MEMORY_TYPE_NOR = 1 + MEMORY_TYPE_NAND = 2 + MEMORY_TYPE_VENDOR = 3 + MEMORY_TYPE_HYBRID = 4 + MAX_PARTITION_MEMORY_TYPE = MEMORY_TYPE_VENDOR + MAX_DEVICE_MEMORY_TYPE = MEMORY_TYPE_HYBRID + + def __init__(self): + self.start_address = 0 + self.end_address = 0 + self.memory_type = self.MEMORY_TYPE_RAM + self.size_in_kbyte = 0 + + def size_in_byte(self): + return self.size_in_kbyte * 1024 diff --git a/Flash/base/next_op.py b/Flash/base/next_op.py new file mode 100644 index 0000000..600e40c --- /dev/null +++ b/Flash/base/next_op.py @@ -0,0 +1,15 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Realtek Semiconductor Corp. +# SPDX-License-Identifier: Apache-2.0 + +from enum import Enum + + +class NextOpType(Enum): + NONE = 0 # Do nothing, hold on in download mode + INDICATION = 1 # Indicate download result via PWM/GPIO + RESET = 2 # Exit download mode + BOOT = 3 # Jump to Ram + REBURN = 4 # Reset into download mode again \ No newline at end of file diff --git a/Flash/base/remote_serial.py b/Flash/base/remote_serial.py new file mode 100644 index 0000000..cafc8f6 --- /dev/null +++ b/Flash/base/remote_serial.py @@ -0,0 +1,371 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Realtek Semiconductor Corp. +# SPDX-License-Identifier: Apache-2.0 + +import socket +import json +import time +import threading +import base64 +from typing import Optional, Dict, Any +import serial +from serial.serialutil import SerialException, SerialTimeoutException +from datetime import datetime + + +class RemoteSerial: + def __init__(self, logger, remote_server: str, remote_port: int, port: str, baudrate: int = 9600): + """ + Initialize remote serial proxy + :param logger: logger object (external input, for unified log format) + :param remote_server: remote serial server IP + :param remote_port: remote serial server port + :param port: remote serial port name (e.g. "COM3", "/dev/ttyUSB0") + :param baudrate: serial baud rate + """ + self.logger = logger + self.remote_server = remote_server + self.remote_port = remote_port + self.port = port + self.baudrate = baudrate + + # Core state variables + self.tcp_socket: Optional[socket.socket] = None + self.receive_buffer = b"" # Binary receive buffer + self.receive_thread: Optional[threading.Thread] = None + self.is_open = False # Whether serial is open (TCP connected + serial command succeeds) + self.response_event = threading.Event() # Command response synchronization event + self.last_response: Dict[str, Any] = {} # Last command response + + # Initialize logger + self.logger.debug( + f"[RemoteSerial][{self.port}] Initialize remote serial proxy - " + f"Server: {self.remote_server}:{self.remote_port}, baudrate: {self.baudrate}" + ) + # 1. Establish TCP connection (just connect, not start the receive thread) + self._connect_tcp() + self.receive_thread = threading.Thread( + target=self._receive_loop, + daemon=True, + name=f"RemoteSerialRecv-{self.port}" + ) + self.receive_thread.start() + self.logger.debug(f"[RemoteSerial][{self.port}] Receive thread started: {self.receive_thread.name}") + + def _connect_tcp(self) -> bool: + """ + Establish TCP connection with remote serial server + :return: Returns True if connected successfully, raises SerialException on failure + """ + self.logger.debug(f"[RemoteSerial][{self.port}] Trying TCP connection: {self.remote_server}:{self.remote_port}") + try: + self.tcp_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + # self.tcp_socket.settimeout(10) # TCP connect timeout 10s + self.tcp_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + self.logger.debug(f"[RemoteSerial][{self.port}] TCP_NODELAY set") + self.tcp_socket.connect((self.remote_server, self.remote_port)) + self.logger.debug(f"[RemoteSerial][{self.port}] TCP connection succeeded") + return True + except socket.timeout: + raise SerialException(f"[RemoteSerial][{self.port}] TCP connection timed out (10s)- {self.remote_server}:{self.remote_port}") + except ConnectionRefusedError: + raise SerialException(f"[RemoteSerial][{self.port}] TCP connection refused - server not started or wrong port") + except Exception as e: + raise SerialException(f"[RemoteSerial][{self.port}] TCP connection failed: {str(e)}") + + def _receive_loop(self): + """ + Core logic of receive thread: continuously read TCP data, parse into buffer or trigger response + """ + self.logger.debug(f"[RemoteSerial][{self.port}] Receive thread running") + buffer = "" + while self.tcp_socket: + try: + # Read TCP data (as text, as server sends JSON+newline-delimited) + data = self.tcp_socket.recv(4096).decode('utf-8', errors='strict') + if not data: + self.logger.error(f"[RemoteSerial][{self.port}] Server closed connection, recv returned empty data") + raise ConnectionAbortedError("Server closed connection") + + buffer += data + + # Split by newline for complete messages (handle sticky packets) + while '\n' in buffer: + msg_str, buffer = buffer.split('\n', 1) + msg_str = msg_str.strip() + if not msg_str: + continue + self._parse_message(msg_str) + except ConnectionResetError as e: + self.logger.error(f"[RemoteSerial][{self.port}] Connection reset: {str(e)}") + break + except ConnectionAbortedError as e: + self.logger.error(f"[RemoteSerial][{self.port}] Receive thread exception: {str(e)}") + break + except Exception as e: + self.logger.error(f"[RemoteSerial][{self.port}] Receive thread exception: {str(e)}", exc_info=True) + break + + self.is_open = False + if self.tcp_socket: + self.tcp_socket.close() + self.logger.info(f"[RemoteSerial][{self.port}] Receive thread exited") + + def _parse_message(self, msg_str: str): + """ + Parse JSON message from server + :param msg_str: Complete JSON string (without newline) + """ + try: + msg = json.loads(msg_str) + msg_type = msg.get("type") + + if msg_type == "command_response": + self.last_response = msg + self.logger.debug( + f"[RemoteSerial][{self.port}] Received command response - " + f"Success: {msg.get('success')}, message: {msg.get('message', '无')}" + ) + self.response_event.set() + + elif msg_type == "serial_data": + if msg.get("port") != self.port: + self.logger.warning(f"[RemoteSerial][{self.port}] Received data from other port (ignored): {msg.get('port')}") + return + + base64_data = msg.get("data", "") + if not base64_data: + self.logger.warning(f"[RemoteSerial][{self.port}] Serial data empty (Base64)") + return + + try: + raw_data = base64.b64decode(base64_data, validate=True) + self.receive_buffer += raw_data + except base64.binascii.Error as e: + self.logger.error(f"[RemoteSerial][{self.port}] Base64 decode failed: {str(e)}, data: {base64_data[:100]}...") + + else: + self.logger.warning(f"[RemoteSerial][{self.port}] Unknown message type: {msg_type}, message: {msg_str[:200]}...") + + except json.JSONDecodeError as e: + self.logger.error(f"[RemoteSerial][{self.port}] JSON parse failed: {str(e)}, raw: {msg_str[:200]}...") + + def _send_command(self, cmd: Dict[str, Any], timeout: float = 5.0) -> Dict[str, Any]: + """ + Send command to remote server and wait for response + :param cmd: command dict (will be converted to JSON) + :param timeout: response timeout (seconds) + :return: server response dict + """ + if not self.is_open or not self.tcp_socket: + raise SerialException(f"[RemoteSerial][{self.port}] Send command failed: serial not open") + + self.response_event.clear() + self.last_response = {} + cmd_str = json.dumps(cmd) + "\n" + + try: + self.logger.debug(f"[RemoteSerial][{self.port}] Sending command (timeout {timeout}s): {cmd_str.strip()[:300]}...") + self.tcp_socket.sendall(cmd_str.encode('utf-8')) + + # Wait for response or timeout + if self.response_event.wait(timeout): + return self.last_response + else: + raise SerialTimeoutException( + f"[RemoteSerial][{self.port}] Command response timeout ({timeout}s) - Command: {cmd.get('type')}" + ) + except Exception as e: + raise SerialException(f"[RemoteSerial][{self.port}] Send command exception: {str(e)}") + + def validate(self, password): + try: + validate_cmd = { + "type": "validate", + "password": password + } + self.is_open = True + resp = self._send_command(validate_cmd, timeout=10.0) + + if not resp.get("success", False): + self.logger.debug(f"[RemoteSerial][{self.port}] Validate failed") + raise SerialException(f"[RemoteSerial][{self.port}] Remote serial validate failed: Wrong password") + self.is_open = False + self.logger.debug(f"[RemoteSerial][{self.port}] Remote serial port validate successfully") + except Exception as e: + self.close() + raise SerialException(f"[RemoteSerial][{self.port}] Validate serial failed: {str(e)}") + + def open(self): + if self.is_open: + self.logger.debug(f"[RemoteSerial][{self.port}] Serial already opened, skip") + return + self.logger.debug(f"[RemoteSerial][{self.port}] Trying to open serial") + try: + self.is_open = True + open_cmd = { + "type": "open_port", + "port": self.port, + "options": { + "baudRate": self.baudrate, + "dataBits": 8, + "stopBits": 1, + "parity": "none", + "timeout": 0.1 + } + } + resp = self._send_command(open_cmd, timeout=10.0) + + if not resp.get("success", False): + self.logger.debug(f"[RemoteSerial][{self.port}] Open failed") + self.is_open = False + err_msg = resp.get("message", "Unknown error") + raise SerialException(f"[RemoteSerial][{self.port}] Remote serial open failed: {err_msg}") + + # set is_open and start receive thread + self.is_open = True + + self.logger.debug(f"[RemoteSerial][{self.port}] Remote serial port opened successfully (baudrate: {self.baudrate})") + except Exception as e: + self.close() + raise SerialException(f"[RemoteSerial][{self.port}] Open serial failed: {str(e)}") + + def close(self): + """ + Close remote serial: send close_port command + cleanup TCP and thread + """ + self.logger.debug(f"[RemoteSerial][{self.port}] Start closing remote serial") + + # 1. Mark state as closed (end receive thread loop) + if not self.is_open: + self.logger.debug(f"[RemoteSerial][{self.port}] Serial already closed, skip") + return + # 2. Send close serial command (try even if TCP error) + try: + if self.tcp_socket: + close_cmd = {"type": "close_port", "port": self.port} + resp = self._send_command(close_cmd, timeout=3.0) + if resp.get("success", False): + self.logger.debug(f"[RemoteSerial][{self.port}] Remote serial close command succeeded") + else: + self.logger.warning(f"[RemoteSerial][{self.port}] Remote serial close command failed: {resp.get('message', 'No response')}") + except Exception as e: + self.logger.error(f"[RemoteSerial][{self.port}] Send close command exception: {str(e)}") + + self.is_open = False + self.receive_buffer = b"" + self.logger.debug(f"[RemoteSerial][{self.port}] Remote serial closed") + + def write(self, data: bytes): + """ + Write binary data to remote serial (Base64 encode first) + :param data: binary data to send + """ + if not self.is_open: + raise SerialException(f"[RemoteSerial][{self.port}] Write failed: serial not open") + if not data: + self.logger.debug(f"[RemoteSerial][{self.port}] Write empty data (ignored)") + return + + try: + base64_data = base64.b64encode(data).decode('utf-8') + write_cmd = { + "type": "write_data", + "port": self.port, + "data": base64_data + } + + self.logger.debug( + f"[RemoteSerial][{self.port}] Write data - " + f"Raw length: {len(data)}B, Base64 length: {len(base64_data)}B" + ) + resp = self._send_command(write_cmd, timeout=10.0) + + if not resp.get("success", False): + raise SerialException(f"[RemoteSerial][{self.port}] Write data failed: {resp.get('message', 'Unknown error')}") + self.logger.debug(f"[RemoteSerial][{self.port}] Write data succeeded") + except Exception as e: + raise SerialException(f"[RemoteSerial][{self.port}] Write exception: {str(e)}") + + def read(self, size: int = 1) -> bytes: + """ + Read specified length of binary data from receive buffer + :param size: length to read (bytes) + :return: binary data read (returns actual length if less than size) + """ + if not self.is_open: + raise SerialException(f"[RemoteSerial][{self.port}] Read failed: serial not open") + + # Get data from buffer + read_data = self.receive_buffer[:size] + self.receive_buffer = self.receive_buffer[size:] + self.logger.debug( + f"[RemoteSerial][{self.port}] Read data - " + f"Requested: {size}B, Read: {len(read_data)}B, Buffer left: {len(self.receive_buffer)}B" + ) + return read_data + + def inWaiting(self) -> int: + """ + Return number of bytes waiting in receive buffer + :return: buffer length + """ + if not self.is_open: + raise SerialException(f"[RemoteSerial][{self.port}] Read failed: serial not open") + waiting = len(self.receive_buffer) + return waiting + + def flushInput(self): + """ + Clear receive buffer (local only, no server interaction) + """ + old_len = len(self.receive_buffer) + self.receive_buffer = b"" + self.logger.debug(f"[RemoteSerial][{self.port}] Cleared receive buffer - old length: {old_len}B") + + def flushOutput(self): + """ + Flush output buffer (no local output buffer for remote serial, just for serial.Serial API compatibility) + """ + self.logger.debug(f"[RemoteSerial][{self.port}] flushOutput: No action required for remote serial (no local output buffer)") + + # ------------------------------ + # Simulate serial.Serial's DTR/RTS properties + # ------------------------------ + @property + def dtr(self): + self.logger.debug(f"[RemoteSerial][{self.port}] Get DTR: not supported, return False") + return False + + @dtr.setter + def dtr(self, value): + self.logger.debug(f"[RemoteSerial][{self.port}] Set DTR: not supported, ignored value={value}") + + @property + def rts(self): + self.logger.debug(f"[RemoteSerial][{self.port}] Get RTS: not supported, return False") + return False + + @rts.setter + def rts(self, value): + self.logger.debug(f"[RemoteSerial][{self.port}] Set RTS: not supported, ignored value={value}") + # ------------------------------ + # Context manager support (with statement) + # ------------------------------ + def __enter__(self): + self.open() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + self.logger.info(f"[RemoteSerial][{self.port}] __exit__ close") + if exc_type: + self.logger.error(f"[RemoteSerial][{self.port}] Context manager exception: {exc_type.__name__}: {exc_val}") + + def __del__(self): + """Destructor: make sure to close resource""" + if self.is_open: + self.logger.debug(f"[RemoteSerial][{self.port}] Destructor: closing unreleased remote serial") + self.close() \ No newline at end of file diff --git a/Flash/base/rom_handler.py b/Flash/base/rom_handler.py new file mode 100644 index 0000000..046ec70 --- /dev/null +++ b/Flash/base/rom_handler.py @@ -0,0 +1,322 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Realtek Semiconductor Corp. +# SPDX-License-Identifier: Apache-2.0 + +import io +import os +import sys +import time + +from .errno import * +from .rtk_utils import * + +STX = 0x02 # Transfer data +EOT = 0x04 # End of transfer +BAUDSET = 0x05 # For handshake +BAUDCHK = 0x07 # For handshake, only for UART +ACK = 0x06 # Acknowledge +NAK = 0x15 # Negativ acknowledge +CAN = 0x18 # Cancel transfer, only for UART +ESC = 0x1B # Cancel transfer + +STX_BASE_LEN = 8 +STX_UART_RAM_DATA_LEN = 1024 +STX_USB_RAM_DATA_LEN = 2048 +STX_MAX_LEN = STX_BASE_LEN + STX_USB_RAM_DATA_LEN +FloaderSizeLimit = 1024 * 1024 # 1MB + +StxBaseLen = 1024 +StxUartDataLen = 1024 +StxUsbDataLen = 2048 +StxMaxDataLen = StxBaseLen + StxUsbDataLen +DEFAULT_TIMEOUT = 0.5 +STX_TIMEOUT = 1 + +FloaderDictionary = "Devices/Floaders" + + +class RomHandler(object): + def __init__(self, ameba_obj, padding="FF"): + self.ameba = ameba_obj + self.serial_port = ameba_obj.serial_port + self.logger = ameba_obj.logger + self.profile = ameba_obj.profile_info + self.baudrate = ameba_obj.baudrate + self.is_usb = ameba_obj.is_usb + self.stx_packet_no = 1 + self.padding = padding + self.setting = ameba_obj.setting + + def get_baudrate_idx(self, rate): + ''' rom built-in rate table ''' + return { + 110: 0, + 300: 1, + 600: 2, + 1200: 3, + 2400: 4, + 4800: 5, + 9600: 6, + 14400: 7, + 19200: 8, + 28800: 9, + 38400: 10, + 57600: 11, + 76800: 12, + 115200: 13, + 128000: 14, + 153600: 15, + 230400: 16, + 380400: 17, + 460800: 18, + 500000: 19, + 921600: 20, + 1000000: 21, + 1382400: 22, + 1444400: 23, + 1500000: 24, + 1843200: 25, + 2000000: 26, + 2100000: 27, + 2764800: 28, + 3000000: 29, + 3250000: 30, + 3692300: 31, + 3750000: 32, + 4000000: 33, + 6000000: 34 + }.get(rate, 13) + + def send_request(self, request, length, timeout): + ret = ErrType.SYS_UNKNOWN + response = [] + + try: + for retry in range(2): + if retry > 0: + self.logger.debug(f"Request retry {retry}#: len={length}, payload={request.hex()}") + else: + self.logger.debug(f"Request: len={length}, payload={request.hex()}") + + self.serial_port.flushInput() + self.serial_port.flushOutput() + + self.ameba.write_bytes(request) + + for resp_retry in range(3): + ret, ch = self.ameba.read_bytes(timeout) + if ret != ErrType.OK: + self.logger.debug(f"Reponse error: {ret}") + break + + if ch[0] == ACK: + self.logger.debug(f"Response ACK") + break + elif ch[0] == NAK: + ret = ErrType.SYS_NAK + self.logger.debug(f"Response NAK") + continue + elif ch[0] == CAN: + ret = ErrType.SYS_CANCEL + self.logger.debug(f"Response CAN") + break + else: + ret = ErrType.SYS_PROTO + self.logger.debug(f"Unexpected reponse: 0x{ch.hex()}") + continue + + if ret == ErrType.OK or ret == ErrType.SYS_CANCEL: + break + else: + time.sleep(self.setting.request_retry_interval_second) + except Exception as err: + self.logger.error(f"Send request exception: {err}") + ret = ErrType.SYS_IO + + return ret + + def reset(self): + self.stx_packet_no = 1 + + def handshake(self): + ret = ErrType.OK + self.logger.debug(f"Handshake:{'USB' if self.is_usb else 'UART'}") + index = self.get_baudrate_idx(self.baudrate) + + if not self.is_usb: + ret = self.check_alive() + if ret != ErrType.OK: + self.logger.debug(f"Handshake fail: device not alive") + return ret + + retry = 0 + while retry < 2: + if retry > 0: + self.logger.debug(f"Handshake retry {retry}#") + + retry += 1 + + if self.setting.switch_baudrate_at_floader == 0: + self.logger.debug(f"BAUDSET: {index} ({self.baudrate})") + _bytes = [BAUDSET] + _bytes.append(index) + cmd = bytearray(_bytes) + + ret = self.send_request(cmd, 2, DEFAULT_TIMEOUT) + if ret == ErrType.OK: + self.logger.debug(f"BAUDSET ok") + else: + self.logger.debug(f"BAUDSET fail: {ret}") + continue + + if self.is_usb: + break + + ret = self.ameba.switch_baudrate(self.baudrate, self.setting.baudrate_switch_delay_in_second) + if ret != ErrType.OK: + continue + + self.logger.debug(f"BAUDCHK") + ret = self.send_request(BAUDCHK.to_bytes(1, byteorder="little"), 1, DEFAULT_TIMEOUT) + if ret == ErrType.OK: + self.logger.debug("BAUDCHK ok") + break + else: + self.logger.error(f"BAUDCHK fail: {ret}") + else: + if self.is_usb: + self.logger.debug(f"BAUDSET") + _bytes = [BAUDSET] + _bytes.append(index) + cmd = bytearray(_bytes) + ret = self.send_request(cmd, 2, DEFAULT_TIMEOUT) + if ret == ErrType.OK: + self.logger.debug(f"Baudset ok") + break + else: + self.logger.debug(f"Baudset fail: {ret}") + else: + self.logger.debug("BAUDCHK") + ret = self.send_request(BAUDCHK.to_bytes(1, byteorder="little"), 1, DEFAULT_TIMEOUT) + if ret == ErrType.OK: + self.logger.debug(f"Baudchk ok") + break + else: + self.logger.debug(f"Baudchk fail: {ret}") + + if ret == ErrType.OK: + self.logger.debug("Handshake done") + else: + self.logger.debug(f"Handshake fail: {ret}") + + return ret + + def check_alive(self): + ret, ch = self.ameba.read_bytes(DEFAULT_TIMEOUT) + if ret == ErrType.OK: + if ch[0] != NAK: + self.logger.debug(f"Check alive error,expect NAK, get 0x{ch.hex()}") + else: + self.logger.debug(f"Check alive error: {ret}") + + return ret + + def transfer(self, address, data_bytes): + self.logger.debug(f"STX {self.stx_packet_no}#: addr={hex(address)}") + stx_data = [STX] + stx_data.append(self.stx_packet_no & 0xFF) + stx_data.append((~self.stx_packet_no) & 0xFF) + + stx_data.extend(list(address.to_bytes(4, byteorder='little'))) + + stx_bytes = bytearray(stx_data) + stx_bytes += data_bytes + + checksum = sum(stx_bytes[3:]) % 256 + stx_bytes += checksum.to_bytes(1, byteorder="little") + + ret = self.send_request(stx_bytes, len(stx_bytes), STX_TIMEOUT) + if ret == ErrType.OK: + self.logger.debug(f"STX {self.stx_packet_no}# done") + self.stx_packet_no += 1 + else: + self.logger.debug(f"STX {self.stx_packet_no}# fail: {ret}") + + return ret + + def end_transfer(self): + self.logger.debug(f"EOT") + return self.send_request(EOT.to_bytes(1, byteorder="little"), 1, DEFAULT_TIMEOUT) + + def abort(self): + self.logger.debug(f"ESC") + self.send_request(ESC.to_bytes(1, byteorder="little"), 1, DEFAULT_TIMEOUT) + + def get_page_aligned_size(self, size, page_size): + result = size + + if (size % page_size) != 0: + result = (size // page_size + 1) * page_size + + return result + + def get_floader_path(self): + floader_path = None + + if self.profile.floader: + floader_path = os.path.realpath(os.path.join(RtkUtils.get_executable_root_path(), FloaderDictionary, self.profile.floader)) + + if floader_path is None: + self.logger.error("Flashloader not specified in device profile") + elif not os.path.exists(floader_path): + self.logger.error(f"Flashloader not exists: {floader_path}") + + return floader_path + + def download_floader(self): + ret = ErrType.OK + page_size = StxUsbDataLen if self.is_usb else StxUartDataLen + self.reset() + + floader = self.get_floader_path() + if floader is None: + return ErrType.SYS_PARAMETER + with open(floader, 'rb') as stream: + floader_content = stream.read() + + floader_size = len(floader_content) + if floader_size > FloaderSizeLimit: + self.logger.error(f"Invalid floader {floader} with too large size: {floader_size}") + return ErrType.SYS_OVERRANGE + + _baud_bytes = self.serial_port.baudrate.to_bytes(4, byteorder='little') + + floader_aligned_size = self.get_page_aligned_size(floader_size, page_size) + datas = io.BytesIO(floader_content) + data_bytes = datas.read(floader_size) + + data_bytes += _baud_bytes + + data_bytes = data_bytes.ljust(floader_aligned_size, b"\x00") + + idx = 0 + floader_addr = self.profile.floader_address + while idx < floader_aligned_size: + trans_data = data_bytes[idx:idx+page_size] + ret = self.transfer(floader_addr, trans_data) + if ret != ErrType.OK: + return ret + idx += page_size + floader_addr += page_size + + ret = self.end_transfer() + if ret != ErrType.OK: + if self.profile.is_amebaz(): + # AmebaZ would tx ACK after rx EOT but would not wait tx ACK done, so in low baudrate(115200...), + # ACK will not be received + # This issue has been fixed after AmebaD + ret = ErrType.OK + + return ret diff --git a/Flash/base/rt_settings.py b/Flash/base/rt_settings.py new file mode 100644 index 0000000..c5a1588 --- /dev/null +++ b/Flash/base/rt_settings.py @@ -0,0 +1,72 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Realtek Semiconductor Corp. +# SPDX-License-Identifier: Apache-2.0 + + +class RtSettings(): + MIN_ROM_BOOT_DELAY_IN_MILLISECOND = 50 + MIN_USB_ROM_BOOT_DELAY_IN_MILLISECOND = 200 + MIN_USB_FLOADER_BOOT_DELAY_IN_MILLISECOND = 200 + + FLASH_PROTECTION_PROCESS_PROMPT = 0 + FLASH_PROTECTION_PROCESS_TRY = 1 + FLASH_PROTECTION_PROCESS_UNLOCK = 2 + FLASH_PROTECTION_PROCESS_ABORT = 3 + + def __init__(self, **kwargs): + self.sense_packet_count = kwargs.get("SensePacketCount", 32) + self.request_retry_count = kwargs.get("RequestRetryCount", 3) + self.request_retry_interval_second = round(kwargs.get("RequestRetryIntervalInMillisecond", 10) / 1000, 2) + self.async_response_timeout_in_second = round(kwargs.get("AsyncResponseTimeoutInMilliseccond", 1000) / 1000, 2) + self.sync_response_timeout_in_second = round(kwargs.get("SyncResponseTimeoutInMillisecond", 1000) / 1000, 2) + self.baudrate_switch_delay_in_second = round(kwargs.get("BaudrateSwitchDelayInMillisecond", 200) / 1000, 2) + self.rom_boot_delay_in_second = round(max(kwargs.get("RomBootDelayInMillisecond", 100), self.MIN_ROM_BOOT_DELAY_IN_MILLISECOND) / 1000, 2) + self.usb_rom_boot_delay_in_second = round(max(kwargs.get("UsbRomBootDelayInMillisecond", 1000), self.MIN_USB_ROM_BOOT_DELAY_IN_MILLISECOND) / 1000, 2) + self.usb_floader_boot_delay_in_second = round(max(kwargs.get("UsbFloaderBootDelayInMillisecond", 1000), self.MIN_USB_FLOADER_BOOT_DELAY_IN_MILLISECOND) / 1000, 2) + self.switch_baudrate_at_floader = kwargs.get("SwitchBaudrateAtFloader", 0) + self.write_response_timeout_in_second = round(kwargs.get("WriteResponseTimeoutInMillisecond", 2000) / 1000, 2) + self.floader_boot_delay_in_second = round(kwargs.get("FloaderBootDelayInMillisecond", 1000) / 1000, 2) + self.auto_switch_to_download_mode_with_dtr_rts = kwargs.get("AutoSwitchToDownloadModeWithDtrRts", 0) + self.auto_reset_device_with_dtr_rts = kwargs.get("AutoResetDeviceWithDtrRts", 0) + self.flash_protection_process = kwargs.get("FlashProtectionProcess", self.FLASH_PROTECTION_PROCESS_PROMPT) + self.erase_by_block = kwargs.get("EraseByBlock", 0) + self.program_config1 = kwargs.get("ProgramConfig1", 0) + self.program_config2 = kwargs.get("ProgramConfig2", 0) + self.disable_nand_access_with_uart = kwargs.get("DisableNandAccessWithUart", 0) + self.ram_download_padding_byte = kwargs.get("RamDownloadPaddingByte", 0x00) + self.auto_program_spic_addr_mode_4byte = kwargs.get("AutoProgramSpicAddrMode4Byte", 0) + self.auto_switch_to_download_mode_with_dtr_rts_file = kwargs.get("AutoSwitchToDownloadModeWithDtrRtsTimingFile", "Reburn.cfg") + self.auto_reset_device_with_dtr_rts_file = kwargs.get("AutoResetDeviceWithDtrRtsTimingFile", "Reset.cfg") + self.post_process = kwargs.get("PostProcess", "RESET") + + def __repr__(self): + profile_dict = { + "SensePacketCount": self.sense_packet_count, + "RequestRetryCount": self.request_retry_count, + "RequestRetryIntervalInMillisecond": int(self.request_retry_interval_second * 1000), + "AsyncResponseTimeoutInMilliseccond": int(self.async_response_timeout_in_second * 1000), + "SyncResponseTimeoutInMillisecond": int(self.sync_response_timeout_in_second * 1000), + "BaudrateSwitchDelayInMillisecond": int(self.baudrate_switch_delay_in_second * 1000), + "RomBootDelayInMillisecond": int(self.rom_boot_delay_in_second * 1000), + "UsbRomBootDelayInMillisecond": int(self.usb_rom_boot_delay_in_second * 1000), + "UsbFloaderBootDelayInMillisecond": int(self.usb_floader_boot_delay_in_second * 1000), + "SwitchBaudrateAtFloader": self.switch_baudrate_at_floader, + "WriteResponseTimeoutInMillisecond": int(self.write_response_timeout_in_second * 1000), + "FloaderBootDelayInMillisecond": int(self.floader_boot_delay_in_second * 1000), + "AutoSwitchToDownloadModeWithDtrRts": self.auto_switch_to_download_mode_with_dtr_rts, + "AutoResetDeviceWithDtrRts": self.auto_reset_device_with_dtr_rts, + "FlashProtectionProcess": self.flash_protection_process, + "EraseByBlock": self.erase_by_block, + "ProgramConfig1": self.program_config1, + "ProgramConfig2": self.program_config2, + "DisableNandAccessWithUart": self.disable_nand_access_with_uart, + "RamDownloadPaddingByte": self.ram_download_padding_byte, + "AutoProgramSpicAddrMode4Byte": self.auto_program_spic_addr_mode_4byte, + "AutoSwitchToDownloadModeWithDtrRtsTimingFile": self.auto_switch_to_download_mode_with_dtr_rts_file, + "AutoResetDeviceWithDtrRtsTimingFile": self.auto_reset_device_with_dtr_rts_file, + "PostProcess": self.post_process + } + + return profile_dict \ No newline at end of file diff --git a/Flash/base/rtk_flash_type.py b/Flash/base/rtk_flash_type.py new file mode 100644 index 0000000..fc66df6 --- /dev/null +++ b/Flash/base/rtk_flash_type.py @@ -0,0 +1,12 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Realtek Semiconductor Corp. +# SPDX-License-Identifier: Apache-2.0 + +from enum import Enum + + +class RtkFlashType(Enum): + NOR = 0 + NAND = 1 diff --git a/Flash/base/rtk_logging.py b/Flash/base/rtk_logging.py new file mode 100644 index 0000000..977acbf --- /dev/null +++ b/Flash/base/rtk_logging.py @@ -0,0 +1,49 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Realtek Semiconductor Corp. +# SPDX-License-Identifier: Apache-2.0 + +import sys +import logging +from colorama import Fore, Style, init + +# init Colorama +init(autoreset=True) + + +def create_logger(name, log_level="INFO", stream=sys.stdout, file=None): + if log_level == "DEBUG": + level = logging.DEBUG + elif log_level == "WARNING": + level = logging.WARNING + elif log_level == "ERROR": + level = logging.ERROR + elif log_level == "FATAL": + level = logging.FATAL + else: + level = logging.INFO + + logger = logging.getLogger(name) + if not logger.handlers: + formatter = logging.Formatter( + fmt=f'[%(asctime)s.%(msecs)03d][%(levelname)s] [{name}]%(message)s', + datefmt='%Y-%m-%d %H:%M:%S') + + logging.addLevelName(logging.DEBUG, f"D") + logging.addLevelName(logging.INFO, f"I") + logging.addLevelName(logging.WARNING, f"{Fore.YELLOW}W{Style.RESET_ALL}") + logging.addLevelName(logging.ERROR, f"{Fore.RED}E{Style.RESET_ALL}") + logging.addLevelName(logging.FATAL, f"{Fore.RED}{Style.BRIGHT}F{Style.RESET_ALL}") + consoleHandler = logging.StreamHandler(stream) + consoleHandler.setFormatter(formatter) + logger.addHandler(consoleHandler) + + if file is not None: + fileHandler = logging.FileHandler(file, mode='a') + fileHandler.setFormatter(formatter) + logger.addHandler(fileHandler) + + logger.propagate = False # Prevent logging from propagating to the root logger + logger.setLevel(level) + return logger diff --git a/Flash/base/rtk_utils.py b/Flash/base/rtk_utils.py new file mode 100644 index 0000000..8568b35 --- /dev/null +++ b/Flash/base/rtk_utils.py @@ -0,0 +1,21 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Realtek Semiconductor Corp. +# SPDX-License-Identifier: Apache-2.0 + +import os +import sys + + +class RtkUtils: + @staticmethod + def get_executable_root_path(): + if getattr(sys, 'frozen', False): # judge if frozen as exe + # get exe dir + executable_root = os.path.dirname(os.path.abspath(sys.executable)) + else: + # get py dir + executable_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + return executable_root diff --git a/Flash/base/sense_status.py b/Flash/base/sense_status.py new file mode 100644 index 0000000..78282d5 --- /dev/null +++ b/Flash/base/sense_status.py @@ -0,0 +1,24 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Realtek Semiconductor Corp. +# SPDX-License-Identifier: Apache-2.0 + +from .errno import * + + +class SenseStatus: + def __init__(self): + self.op_code = None + self.status = None + self.data = None + + def parse(self, data, offset): + ret = ErrType.SYS_OVERRANGE + if len(data) >= offset + 6: + self.op_code = data[offset] + self.status = data[offset + 1] + self.data = data[offset+2] + (data[offset+3]<<8) + (data[offset+4]<<16) + (data[offset+5]<<24) + ret = ErrType.OK + + return ret \ No newline at end of file diff --git a/Flash/base/spic_addr_mode.py b/Flash/base/spic_addr_mode.py new file mode 100644 index 0000000..81f5d22 --- /dev/null +++ b/Flash/base/spic_addr_mode.py @@ -0,0 +1,12 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Realtek Semiconductor Corp. +# SPDX-License-Identifier: Apache-2.0 + +from enum import Enum + + +class SpicAddrMode(Enum): + THREE_BYTE_MODE = 0 + FOUR_BYTE_MODE = 1 diff --git a/Flash/base/sys_utils.py b/Flash/base/sys_utils.py new file mode 100644 index 0000000..2abc752 --- /dev/null +++ b/Flash/base/sys_utils.py @@ -0,0 +1,9 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Realtek Semiconductor Corp. +# SPDX-License-Identifier: Apache-2.0 + + +def divide_then_round_up(dividend, divisor): + return int((dividend + divisor - 1) / divisor) diff --git a/Flash/base/version.py b/Flash/base/version.py new file mode 100644 index 0000000..4c4917f --- /dev/null +++ b/Flash/base/version.py @@ -0,0 +1,16 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Realtek Semiconductor Corp. +# SPDX-License-Identifier: Apache-2.0 + +class Version: + def __init__(self, version_str): + # parser version_str with pattern major.minor.micro + parts = version_str.split(".") + self.major = int(parts[0]) + self.minor = int(parts[1] if len(parts) > 1 else 0) + self.micro = int(parts[2] if len(parts) > 2 else 0) + + def __repr__(self): + return f"{self.major}.{self.minor}.{self.micro}" diff --git a/Flash/changelog.txt b/Flash/changelog.txt new file mode 100644 index 0000000..e9e39e8 --- /dev/null +++ b/Flash/changelog.txt @@ -0,0 +1,25 @@ +ChangeLog: +20250218 v1.0.0.1 (1) Support erase user area + (2) Support chip erase before download + (3) Add version info +20250220 v1.0.0.2 Tune command args +20250221 v1.0.0.3 (1) Fix log-file output bug + (2) Tune log format + (3) Add FileVersion in FileVersionInfo +20250321 v1.0.1.0 (1) Support linux images download + (2) Optimize request timeout + (3) Support check flash block protection before process flash +20250401 v1.0.1.1 (1) Check and program otp for flash size >=16MB + (2) Fix dtr/rts level unexpectedly changed issue when open/close serial port +20250408 v1.0.1.2 Fix nand flash download error with bad block +20250423 v1.0.1.3 (1) Fix download fail in 4byte address mode + (2) Supported otp about flash size >=16MB work for download +20250605 v1.0.2.0 Seperate flashloaders from flash.exe +20250701 v1.0.3.0 (1) Support compatibility with MinGW path format + (2) Support customized DTR/RTS timing for reset/reburn +20250703 v1.1.0.0 Support 1-N download/erase +20251105 v1.1.1.0 (1) Support for remote server port download + (2) Set PostProcess as reset after flashing finished + (3) Add no_reset param + (4) Change some log level + (5) Rename flashloader folder with Devices \ No newline at end of file diff --git a/Flash/flash.py b/Flash/flash.py new file mode 100644 index 0000000..78cf932 --- /dev/null +++ b/Flash/flash.py @@ -0,0 +1,474 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Realtek Semiconductor Corp. +# SPDX-License-Identifier: Apache-2.0 + +import os +import sys +import argparse +import base64 +import re +import threading +from copy import deepcopy + +from base import * +import version_info + +MinSupportedDeviceProfileMajorVersion = 1 +MinSupportedDeviceProfileMinorVersion = 1 +setting_file = "Settings.json" + + +def convert_mingw_path_to_windows(mingw_path): + drive_match = re.match(r'^/([a-zA-Z])/', mingw_path) + if drive_match: + drive_letter = drive_match.group(1).upper() + ":\\" + windows_path_tail = mingw_path[3:] + else: + drive_letter = "" + windows_path_tail = mingw_path + + windows_path_tail = windows_path_tail.replace('/', '\\') + + windows_path = drive_letter + windows_path_tail + + return windows_path + + +def sys_exit(logger, status, ret): + if status: + logger.info(f"Finished PASS") # customized, do not modify + sys.exit(0) + else: + logger.error(f"Finished FAIL: {ret}") # customized, do not modify + sys.exit(1) + + +def decoder_partition_string(partition_table_base64): + try: + if partition_table_base64 is None: + return None + partition_value = base64.b64decode(partition_table_base64).decode("utf-8") + partition_list = json.loads(partition_value) + return partition_list + except Exception as err: + raise argparse.ArgumentTypeError("Invalid partition table format with base64") from err + + +# --- add remote server params --- +def flash_process_entry(profile_info, serial_port, serial_baudrate, image_dir, settings, images_info, + chip_erase, + memory_type, memory_info, download, + log_level, log_f, + remote_server=None, remote_port=None, remote_password=None): + logger = create_logger(serial_port, log_level=log_level, file=log_f) + + ameba = Ameba(profile_info, serial_port, serial_baudrate, image_dir, settings, logger, + download_img_info=images_info, + chip_erase=chip_erase, + memory_type=memory_type, + erase_info=memory_info, + remote_server=remote_server, + remote_port=remote_port, + remote_password=remote_password) + if download: + # download + if not ameba.check_protocol_for_download(): + ret = ErrType.SYS_PROTO + sys_exit(logger, False, ret) + + ret, is_reburn = ameba.check_supported_flash_size(memory_type) + if ret != ErrType.OK: + logger.error(f"Check supported flash size fail") + sys_exit(logger, False, ret) + + if is_reburn: + ameba.__del__() + # reset with remote params + ameba = Ameba(profile_info, serial_port, serial_baudrate, image_dir, settings, logger, + download_img_info=images_info, + chip_erase=chip_erase, + memory_type=memory_type, + erase_info=memory_info, + remote_server=remote_server, + remote_port=remote_port, + remote_password=remote_password) + + logger.info(f"Image download start...") # customized, do not modify + ret = ameba.prepare() + if ret != ErrType.OK: + logger.error("Download prepare fail") + sys_exit(logger, False, ret) + + ret = ameba.verify_images() + if ret != ErrType.OK: + sys_exit(logger, False, ret) + + if not ameba.is_all_ram: + ret = ameba.post_verify_images() + if ret != ErrType.OK: + sys_exit(logger, False, ret) + + if not ameba.is_all_ram: + flash_status = FlashBPS() + ret = ameba.check_and_process_flash_lock(flash_status) + if ret != ErrType.OK: + logger.error("Download image fail") + sys_exit(logger, False, ret) + + ret = ameba.download_images() + if ret != ErrType.OK: + logger.error("Download image fail") + sys_exit(logger, False, ret) + + if (not ameba.is_all_ram) and flash_status.need_unlock: + logger.info("Restore the flash block protection...") + ret = ameba.lock_flash(flash_status.protection) + if ret != ErrType.OK: + logger.error(f"Fail to restore the flash block protection") + sys_exit(logger, False, ret) + + ret = ameba.post_process() + if ret != ErrType.OK: + logger.error("Post process fail") + sys_exit(logger, False, ret) + else: + # erase + ret = ameba.prepare() + if ret != ErrType.OK: + logger.error("Erase prepare fail") + sys_exit(logger, False, ret) + + if chip_erase: + ret = ameba.erase_flash_chip() + if ret != ErrType.OK: + logger.error("Chip erase fail") + sys_exit(logger, False, ret) + sys_exit(logger, True, ret) + + ret = ameba.validate_config_for_erase() + if ret != ErrType.OK: + sys_exit(logger, False, ret) + + ret = ameba.post_validate_config_for_erase() + if ret != ErrType.OK: + sys_exit(logger, False, ret) + + if (not profile_info.is_ram_address(memory_info.start_address)): + flash_status = FlashBPS() + ret = ameba.check_and_process_flash_lock(flash_status) + if ret != ErrType.OK: + logger.error("Erase fail") + sys_exit(logger, False, ret) + + ret = ameba.erase_flash() + if ret != ErrType.OK: + logger.error(f"Erase {memory_type} failed") + sys_exit(logger, False, ret) + + if (not profile_info.is_ram_address(memory_info.start_address)) and flash_status.need_unlock: + logger.info("Restore the flash block protection...") + ret = ameba.lock_flash(flash_status.protection) + if ret != ErrType.OK: + logger.error(f"Fail to restore the flash block protection") + sys_exit(logger, False, ret) + + sys_exit(logger, True, ret) + + +def main(argc, argv): + parser = argparse.ArgumentParser(description=None) + parser.add_argument('-d', '--download', action='store_true', help='download images') + parser.add_argument('-f', '--profile', type=str, help='device profile') + parser.add_argument('-p', '--port', nargs="+", help='serial port') + parser.add_argument('-b', '--baudrate', type=int, help='serial port baud rate') + parser.add_argument('-i', '--image', type=str, help='single image') + parser.add_argument('-r', '--image-dir', type=str, help='image directory') + parser.add_argument('-a', '--start-address', type=str, help='start address, hex') + parser.add_argument('-n', '--end-address', type=str, help='end address, hex') + parser.add_argument('-z', '--size', type=int, help='size in KB') + parser.add_argument('-m', '--memory-type', choices=['nor', 'nand', 'ram'], default="nor", + help='specified memory type') + parser.add_argument('-e', '--erase', action='store_true', help='erase flash') + parser.add_argument('-o', '--log-file', type=str, help='output log file with path') + parser.add_argument('-v', '--version', action='version', version=f'%(prog)s {version_info.version}') + + parser.add_argument('--chip-erase', action='store_true', help='chip erase') + parser.add_argument('--log-level', default='info', help='log level') + parser.add_argument('--partition-table', help="layout info, list") + + parser.add_argument('--remote-server', type=str, help='remote serial server IP address') + parser.add_argument('--remote-password', type=str, help='remote serial server validation password') + parser.add_argument('--no-reset', action='store_true', help='do not reset after flashing finished') + + args = parser.parse_args() + download = args.download + profile = args.profile + image = args.image + image_dir = args.image_dir + chip_erase = args.chip_erase + serial_ports = args.port + serial_baudrate = args.baudrate + log_level = args.log_level.upper() + log_file = args.log_file + erase = args.erase + start_addr = args.start_address + end_addr = args.end_address + size = args.size + mem_t = args.memory_type + partition_table = decoder_partition_string(args.partition_table) + + remote_server = args.remote_server + remote_port = 58916 + remote_password = args.remote_password + no_reset = args.no_reset + + if mem_t is not None: + if mem_t == "nand": + memory_type = MemoryInfo.MEMORY_TYPE_NAND + elif mem_t == "ram": + memory_type = MemoryInfo.MEMORY_TYPE_RAM + else: + memory_type = MemoryInfo.MEMORY_TYPE_NOR + else: + memory_type = None + + if log_file is not None: + log_path = os.path.dirname(log_file) + if log_path: + if not os.path.exists(log_path): + os.makedirs(log_path, exist_ok=True) + log_f = log_file + else: + log_f = os.path.join(os.getcwd(), log_file) + else: + log_f = None + logger = create_logger("main", log_level=log_level, file=log_f) + if log_file is not None: + logger.info(f"Log file: {log_file}") + + logger.info(f"Flash Version: {version_info.version}") + + if remote_server: + logger.info(f"Using remote serial server: {remote_server}:{remote_port}") + + if profile is None: + logger.error('Invalid arguments, no device profile specified') + parser.print_usage() + sys.exit(1) + + if not os.path.exists(profile): + logger.error("Device profile '" + profile + "' does not exist") + sys.exit(1) + logger.info(f'Device profile: {profile}') + + if serial_ports is None: + logger.error('Invalid arguments, no serial port specified') + parser.print_usage() + sys.exit(1) + logger.info(f'Serial port: {serial_ports}') + + if serial_baudrate is None: + logger.error('Invalid arguments, no serial baudrate specified') + parser.print_usage() + sys.exit(1) + logger.info(f'Baudrate: {serial_baudrate}') + + if all([download, erase]): + logger.warning("Download and erase are set true, only do image download ") + elif not (download or erase or chip_erase): + logger.error("Download or erase or chip-erase should be set") + sys.exit(1) + + memory_info = None + images_info = None + if download: + # download + if (image is None) and (image_dir is None) and (partition_table is None): + logger.error('Invalid arguments, no image or image_dir input') + parser.print_usage() + sys.exit(1) + + if image is not None: + download_img_info = ImageInfo() + if not os.path.exists(image): + logger.error(f"Image {image} does not exist") + sys.exit(1) + download_img_info.image_name = image + download_img_info.description = os.path.basename(image) + + if memory_type is None: + logger.error(f"Memory type is required for single image download") + sys.exit(1) + + if start_addr is None: + logger.error(f"Start address is required for single image download") + sys.exit(1) + + try: + start_address = int(start_addr, 16) + except Exception as err: + logger.error(f"Start address is invalid: {err}") + sys.exit(1) + download_img_info.start_address = start_address + + if memory_type == MemoryInfo.MEMORY_TYPE_NAND: + if end_addr is None: + logger.error(f"End address is required for nand flash download") + sys.exit(1) + + try: + end_address = int(end_addr, 16) + except Exception as err: + logger.error(f"End address is invalid: {err}") + sys.exit(1) + else: + end_address = start_address + os.path.getsize(image) + + download_img_info.end_address = end_address + download_img_info.memory_type = memory_type + download_img_info.mandatory = True + images_info = [download_img_info] + elif partition_table is not None: + images_info = [] + for img_info in partition_table: + img_json = ImageInfo(** img_info) + if sys.platform == "win32": + img_p = convert_mingw_path_to_windows(img_json.image_name) + img_json.image_name = img_p + img_json.description = os.path.basename(img_json.image_name) + images_info.append(img_json) + else: + images_info = None + if not os.path.exists(image_dir): + logger.error(f"Image directory {image_dir} does not exist") + sys.exit(1) + + logger.info(f'Image dir: {image_dir}') + if images_info: + logger.info(f'Image info:') + for img_info in images_info: + for key, value in img_info.__repr__().items(): + if key == "ImageName": + key = "Image" + logger.info(f'> {key}: {value}') + else: + # erase + if all([chip_erase, erase]): + logger.warning(f"Both chip erase and erase are enabled, do chip erase only") + if not chip_erase: + memory_info = MemoryInfo() + if start_addr is None: + logger.error(f"Start address is required for erase flash") + sys.exit(1) + + try: + start_address = int(start_addr, 16) + except Exception as err: + logger.error(f"Start address is invalid: {err}") + sys.exit(1) + memory_info.start_address = start_address + + if memory_type is None: + logger.error("Memory type is required for erase") + sys.exit(1) + + memory_info.memory_type = memory_type + + if memory_type == MemoryInfo.MEMORY_TYPE_NAND: + if end_addr is None: + logger.error(f"End address is required for nand flash download") + sys.exit(1) + else: + if size is None: + logger.error(f"Erase size is required") + sys.exit(1) + + if end_addr: + try: + end_address = int(end_addr, 16) + except Exception as err: + logger.error(f"End address is invalid: {err}") + sys.exit(1) + else: + end_address = 0 + + memory_info.size_in_kbyte = size + + if end_address == 0: + end_address = start_address + size + memory_info.end_address = end_address + + if chip_erase: + logger.info(f"Chip erase: {chip_erase}") + if memory_type is None: + logger.warning("Memory type is required for chip erase") + sys.exit(1) + if memory_type != MemoryInfo.MEMORY_TYPE_NOR: + logger.warning("Memory type should be 'nor' for chip erase") + else: + logger.info(f"Chip erase: False") + + # check device profile + try: + profile_json = JsonUtils.load_from_file(profile) + if profile_json is None: + logger.error(f"Fail to load device profile {profile}") + sys.exit(1) + profile_info = RtkDeviceProfile(**profile_json) + ver = profile_info.get_version() + if ver.major >= MinSupportedDeviceProfileMajorVersion and ver.minor >= MinSupportedDeviceProfileMinorVersion and profile_info.device_id != 0: + logger.info(f"Device profile {profile} loaded") + else: + logger.error(f"Fail to load device profile {profile}, unsupported version {ver.__repr__()}") + sys.exit(1) + except Exception as err: + logger.error(f"Load device profile {profile} exception: {err}") + sys.exit(1) + + # load settings + setting_path = os.path.realpath(os.path.join(RtkUtils.get_executable_root_path(), setting_file)) + logger.info(f"Settings path: {setting_path}") + try: + if os.path.exists(setting_path): + dt = JsonUtils.load_from_file(setting_path, need_decrypt=False) + settings = RtSettings(** dt) + else: + logger.debug(f"{setting_file} not exists!") + settings = RtSettings(**{}) + except Exception as err: + logger.error(f"Load settings exception: {err}") + settings = RtSettings(** {}) + # save Setting.json + try: + if no_reset: + settings.post_process = "NONE" + else: + settings.post_process = "RESET" + JsonUtils.save_to_file(setting_path, settings.__repr__()) + except Exception as err: + logger.debug(f"save {setting_file} exception: {err}") + + threads_list = [] + + for sp in serial_ports: + flash_thread = threading.Thread(target=flash_process_entry, args=( + profile_info, sp, serial_baudrate, image_dir, settings, deepcopy(images_info), chip_erase, + memory_type, memory_info, download, log_level, log_f, + remote_server, remote_port, remote_password)) + threads_list.append(flash_thread) + flash_thread.start() + + for thred in threads_list: + thred.join() + + logger.info(f"All flash threads have completed") + # Flush and exit promptly to avoid lingering shutdown delays in embedded runners + sys.stdout.flush() + sys.stderr.flush() + os._exit(0) + + +if __name__ == "__main__": + main(len(sys.argv), sys.argv[1:]) diff --git a/Flash/flash_amebapro3.py b/Flash/flash_amebapro3.py new file mode 100644 index 0000000..2314c6b --- /dev/null +++ b/Flash/flash_amebapro3.py @@ -0,0 +1,522 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Wrapper for flashing AmebaPro3 via a USB‑CDC bridge (e.g., AmebaSmart). + +It forces the flasher to treat the port as a plain UART so the ROM XMODEM +protocol uses 1024‑byte STX frames instead of the 2048‑byte USB variant. +Command line arguments are identical to flash.py. +""" + +import os +import sys +import argparse +import pathlib +import time +import json +import base64 +from typing import Callable +import serial +from serial import SerialException + +# Ensure local imports resolve to the sibling flash package files. +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +if BASE_DIR not in sys.path: + sys.path.insert(0, BASE_DIR) + +# Patch Ameba hooks to force UART mode and to prefer floader handshake first. +import base.download_handler as download_handler # type: ignore +from base.json_utils import JsonUtils # type: ignore +from base.rt_settings import RtSettings # type: ignore +from base.device_profile import RtkDeviceProfile # type: ignore +from base.rtk_logging import create_logger # type: ignore +from base.download_handler import ErrType # type: ignore +from base.rtk_utils import RtkUtils # type: ignore +from base import rom_handler as base_rom_handler # type: ignore + +# Force UART path (so STX = 1024B). +download_handler.Ameba.is_realtek_usb = lambda self: False + + +def _check_download_mode_floader_first(self): + """Prefer floader handshake, then fall back to ROM + reset sequence.""" + ErrType = download_handler.ErrType + CmdEsc = download_handler.CmdEsc + CmdSetBackupRegister = download_handler.CmdSetBackupRegister + CmdResetIntoDownloadMode = download_handler.CmdResetIntoDownloadMode + + ret = ErrType.SYS_IO + is_floader = False + boot_delay = self.setting.usb_rom_boot_delay_in_second if self.profile_info.support_usb_download else self.setting.rom_boot_delay_in_second + + self.logger.debug(f"Check download mode with baudrate {self.serial_port.baudrate}") + retry = 0 + while retry < 3: + retry += 1 + try: + # 1) See if flashloader is already running (common after stage-1). + self.logger.debug(f"Check whether in floader with baudrate {self.baudrate}") + ret, status = self.floader_handler.sense(self.setting.sync_response_timeout_in_second) + if ret == ErrType.OK: + is_floader = True + self.logger.debug("Floader handshake ok") + break + else: + self.logger.debug(f"Floader handshake fail: {ret}") + + # 2) ROM handshake. + self.logger.debug(f"Check whether in rom download mode") + ret = self.rom_handler.handshake() + if ret == ErrType.OK: + self.logger.debug(f"Handshake ok, device in rom download mode") + break + + # 3) Try to reset into ROM download mode (UART path only). + if not self.is_usb: + self.logger.debug( + f'Assume in application or ROM normal mode with baudrate {self.profile_info.log_baudrate}') + self.switch_baudrate(self.profile_info.log_baudrate, self.setting.baudrate_switch_delay_in_second) + + self.logger.debug("Try to reset device...") + + self.serial_port.flushOutput() + self.write_bytes(CmdEsc) + time.sleep(0.1) + + if self.profile_info.is_amebad(): + self.serial_port.flushOutput() + self.write_bytes(CmdSetBackupRegister) + time.sleep(0.1) + + self.serial_port.flushOutput() + self.write_bytes(CmdResetIntoDownloadMode) + + self.switch_baudrate(self.profile_info.handshake_baudrate, boot_delay, True) + + self.logger.debug( + f'Check whether reset in ROM download mode with baudrate {self.profile_info.handshake_baudrate}') + + ret = self.rom_handler.handshake() + if ret == ErrType.OK: + self.logger.debug("Handshake ok, device in ROM download mode") + break + else: + self.logger.debug("Handshake fail, cannot enter UART download mode") + + self.switch_baudrate(self.baudrate, self.setting.baudrate_switch_delay_in_second, True) + except Exception as err: + self.logger.error(f"Check download mode exception: {err}") + + return ret, is_floader + + +download_handler.Ameba.check_download_mode = _check_download_mode_floader_first + +# --------------------------------------------------------------------------- +# Resource path patches (packaged exe support) +# --------------------------------------------------------------------------- +_orig_get_root = RtkUtils.get_executable_root_path + + +def _patched_get_root(): + """ + Ensure flash assets resolve in both source and PyInstaller builds. + Priority: + 1) _MEIPASS/Flash (onefile extraction) + 2) dir of the running executable + /Flash + 3) fallback to original logic + """ + # PyInstaller onefile: files are extracted to _MEIPASS + mei = getattr(sys, "_MEIPASS", None) + if mei: + flash_dir = os.path.join(mei, "Flash") + if os.path.isdir(flash_dir): + return flash_dir + + # PyInstaller onedir: Flash sits next to the exe + exe_dir = os.path.dirname(os.path.abspath(sys.executable)) + flash_dir = os.path.join(exe_dir, "Flash") + if os.path.isdir(flash_dir): + return flash_dir + + return _orig_get_root() + + +RtkUtils.get_executable_root_path = staticmethod(_patched_get_root) + + +def _patched_get_floader_path(self): + """ + Wrapper around RomHandler.get_floader_path to retry common packaged locations + without touching base code. Uses patched root (above) first, then falls back + to the original implementation for source runs. + """ + # Prefer patched root (covers _MEIPASS/Flash and exe_dir/Flash) + root = RtkUtils.get_executable_root_path() + if self.profile.floader: + candidate = os.path.realpath( + os.path.join(root, base_rom_handler.FloaderDictionary, self.profile.floader) + ) + if os.path.exists(candidate): + return candidate + + # Fallback: original behavior + return _orig_rom_get_floader_path(self) + + +_orig_rom_get_floader_path = base_rom_handler.RomHandler.get_floader_path +base_rom_handler.RomHandler.get_floader_path = _patched_get_floader_path + +# Reuse the original entry point and argument parsing. +import flash # type: ignore + + +def resolve_profile_path(path: str) -> str: + """Return an absolute profile path. If relative and not found from CWD, try relative to BASE_DIR.""" + p = pathlib.Path(path) + if p.is_absolute(): + return str(p) + if p.exists(): + return str(p.resolve()) + alt = pathlib.Path(BASE_DIR) / path + if alt.exists(): + return str(alt.resolve()) + return str(p) # let flash.py error out visibly + + +def main(): + default_profile = str((pathlib.Path(BASE_DIR) / "Devices/Profiles/AmebaPro3_FreeRTOS_NOR.rdev").resolve()) + parser = argparse.ArgumentParser( + description="AmebaPro3 flasher wrapper (UART mode via USB-CDC bridge).", + add_help=True, + ) + parser.add_argument("-p", "--port", help="CDC device of AmebaSmart bridge for BOOT/RESET commands (e.g. /dev/ttyACM0 or COM29)") + parser.add_argument("-P", "--bridge-port", dest="bridge_port", help="Alias for --port (bridge CDC device), if both set, --bridge-port wins") + parser.add_argument("-t", "--target", dest="target_port", help="Serial/CDC device to the target SoC for flashing (passed to flash.py)") + parser.add_argument("-B", "--baudrate", type=int, default=1500000, help="Baudrate (default 1500000)") + parser.add_argument("-m", "--memory-type", default="nor", choices=["nor", "nand", "ram"], help="Memory type") + parser.add_argument("--profile", default=default_profile, help="Device profile (.rdev)") + parser.add_argument("-b", "--boot", help="Boot image path (e.g. amebapro3_boot.bin)") + parser.add_argument("-a", "--app", help="App image path (e.g. amebapro3_app.bin)") + parser.add_argument("-i", "--image-dir", dest="image_dir", help="Override image directory (skips -a/-b)") + parser.add_argument("-bin", dest="single_image", help="Single image path (custom address mode)") + parser.add_argument("-s", "--start", dest="start_addr", help="Start address (hex) for single image") + parser.add_argument("-e", "--end", dest="end_addr", help="End address (hex) for single image") + parser.add_argument("--debug", action="store_true", help="Set log-level to debug") + parser.add_argument("--auto-dtr", action="store_true", + help="(Deprecated) legacy auto DTR/RTS toggle; ignored when using GPIO helper.") + parser.add_argument("-r", "--reset", action="store_true", + help="Force reset to normal mode after flashing (post-process RESET). Default unless -dl 1.") + parser.add_argument("-dl", "--download-mode", type=int, choices=[0, 1], + help="1: stay in download mode (no reset). 0: exit to normal mode (reset).") + parser.add_argument("extra", nargs=argparse.REMAINDER, help="Additional args passed through to flash.py") + + args = parser.parse_args() + + def uart_index_from_port(port: str) -> int | None: + # Extract trailing digits; COM29 -> 29, /dev/ttyACM0 -> 0, else None + import re + m = re.search(r'(\d+)$', port or "") + return int(m.group(1)) if m else None + + bridge_port = args.bridge_port or args.port + target_port = args.target_port + + # Decide image directory vs per-image flashing. + image_dir = args.image_dir + if args.single_image: + boot_path = pathlib.Path(args.single_image).expanduser() + if not boot_path.exists(): + print("Custom image not found", file=sys.stderr) + sys.exit(1) + app_path = None + image_dir = None + if not (args.start_addr and args.end_addr): + print("Custom image requires --start and --end addresses (hex)", file=sys.stderr) + sys.exit(1) + start_addr = args.start_addr + end_addr = args.end_addr + elif args.boot or args.app: + if not (args.boot and args.app): + print("Both --boot and --app must be provided together", file=sys.stderr) + sys.exit(1) + boot_path = pathlib.Path(args.boot).expanduser() + app_path = pathlib.Path(args.app).expanduser() + if not boot_path.exists() or not app_path.exists(): + print("Boot or app image not found", file=sys.stderr) + sys.exit(1) + # per-image flashing, so don't pass image-dir + image_dir = None + start_addr = None + end_addr = None + else: + boot_path = None + app_path = None + start_addr = None + end_addr = None + + # If the user supplied an image directory (-i) but no explicit boot/app, + # map the standard images inside that directory to the fixed boot/app + # address ranges expected by AmebaPro3 (boot: 0x08000000-0x08040000, + # app: 0x08040000-0x08440000). This avoids relying on the profile's + # partition table size checks, which can reject larger binaries. + if image_dir and not (boot_path or app_path or args.single_image): + candidate_boot = pathlib.Path(image_dir) / "amebapro3_boot.bin" + candidate_app = pathlib.Path(image_dir) / "amebapro3_app.bin" + if candidate_boot.exists() and candidate_app.exists(): + boot_path = candidate_boot + app_path = candidate_app + image_dir = None # switch to per-image flashing path below + start_addr = None + end_addr = None + + # If invoked via a basename containing "reset" (e.g., pro3_reset), default to reset-only. + exe_stem = pathlib.Path(sys.argv[0]).stem.lower() + if (args.download_mode is None) and (not args.reset) and ("reset" in exe_stem): + args.reset = True + args.download_mode = 0 + + # Control-only flags: -dl / -r are meant for toggling modes, not flashing. + control_only = (args.download_mode is not None) or args.reset + + # Optional: enable DTR/RTS driven auto boot/reset by patching settings load. + # We monkeypatch JsonUtils.load_from_file to inject the flags before flash.py reads Settings.json. + created_files = [] + patched_load = False + + # If control-only flags are provided alongside any flash inputs, bail out early. + if control_only and any([args.single_image, boot_path, app_path, image_dir]): + print("Cannot combine -dl/-r with flashing arguments; use them alone to toggle mode/reset.", file=sys.stderr) + sys.exit(1) + + # Disable built-in DTR/RTS automation; we'll drive GPIO via pro3_gpio helper. + orig_load: Callable = JsonUtils.load_from_file + + def patched_load(path, need_decrypt=True): + # Only inject for Settings.json; leave profiles untouched. + if os.path.basename(path) == "Settings.json": + # Settings.json in this project is plain JSON (not DES/base64), + # so force no-decrypt to avoid parse errors. + data = orig_load(path, False) or {} + data["AutoSwitchToDownloadModeWithDtrRts"] = 0 + data["AutoResetDeviceWithDtrRts"] = 0 + data["PostProcess"] = "NONE" + return data + return orig_load(path, need_decrypt) + + JsonUtils.load_from_file = patched_load # type: ignore + patched_load = True + + # Inline GPIO control using ASCII BOOT/RESET commands handled by AmebaSmart firmware. + def send_boot_reset(port: str, baud: int, idx: int, boot: int | None = None, reset: int | None = None): + """ + Issue ASCII commands understood by AmebaSmart CDC bridge. + Firmware uses "BOOT <0|1>" and "RESET <0|1>". + """ + with serial.Serial(port=port, baudrate=baud, timeout=0.5, rtscts=False, dsrdtr=False) as ser: + if boot is not None: + ser.write(f"BOOT {idx} {boot}\r\n".encode()) + if reset is not None: + ser.write(f"RESET {idx} {reset}\r\n".encode()) + ser.flush() + + def boot_seq(port: str, baud: int, idx: int): + """ + Enter download mode (dl=1): + boot=1 held throughout flash, reset pulse: boot 0 1 -> reset 0 1 -> reset 0 0 -> reset 0 1 + Send within a single serial open to avoid CDC drop between steps. BOOT left asserted. + """ + with serial.Serial(port=port, baudrate=baud, timeout=0.5, rtscts=False, dsrdtr=False) as ser: + ser.write(f"BOOT {idx} 1\r\n".encode()) # boot 0 1 + ser.write(f"RESET {idx} 1\r\n".encode()) # reset 0 1 + ser.flush() + time.sleep(0.1) + ser.write(f"RESET {idx} 0\r\n".encode()) # reset 0 0 + ser.flush() + time.sleep(0.1) + ser.write(f"RESET {idx} 1\r\n".encode()) # reset 0 1 + ser.flush() + time.sleep(0.2) # allow CDC re-enumeration into download; BOOT stays asserted + + def reset_seq(port: str, baud: int, idx: int): + """ + Pulse RESET only (BOOT unchanged): reset=1 -> reset=0 -> reset=1 + """ + send_boot_reset(port, baud, idx, reset=1) + time.sleep(0.05) + send_boot_reset(port, baud, idx, reset=0) + time.sleep(0.05) + send_boot_reset(port, baud, idx, reset=1) + + def reset_exit_seq(port: str, baud: int, idx: int): + """ + Exit download mode (dl=0): + reset=1/boot=0 -> reset=0/boot=0 -> reset=1/boot=0 (final high). + """ + time.sleep(0.1) # small guard before exit sequence + with serial.Serial(port=port, baudrate=baud, timeout=0.5, rtscts=False, dsrdtr=False) as ser: + ser.write(f"BOOT {idx} 0\r\n".encode()) # boot 0 0 + ser.flush() + time.sleep(0.2) + ser.write(f"RESET {idx} 1\r\n".encode()) # reset 0 1 (assert) + ser.flush() + time.sleep(0.2) + ser.write(f"RESET {idx} 0\r\n".encode()) # reset 0 0 (release) + ser.flush() + time.sleep(0.2) + ser.write(f"RESET {idx} 1\r\n".encode()) # final high + ser.flush() + time.sleep(0.5) + + def release_to_normal(port: str, baud: int, idx: int): + """After flashing: BOOT=0, RESET high, with retries for re-enum.""" + reset_exit_seq(port, baud, idx) + time.sleep(0.2) + try: + send_boot_reset(port, baud, idx, boot=0) + send_boot_reset(port, baud, idx, reset=1) + except SerialException: + pass + + # Reset-only / mode-only handling: if -dl/-r is provided without any images, + # just toggle the lines and exit without running flash.py. + if control_only: + ctrl_port = args.bridge_port or args.port + if not ctrl_port: + print("Bridge port (--port) is required for GPIO control", file=sys.stderr) + sys.exit(1) + idx = 0 # only UART index 0 drives BOOT/RESET; others are no-op per firmware + try: + if args.download_mode == 1: + boot_seq(ctrl_port, args.baudrate, idx) + elif args.download_mode == 0: + reset_exit_seq(ctrl_port, args.baudrate, idx) + elif args.reset: + reset_seq(ctrl_port, args.baudrate, idx) # retain BOOT state + sys.exit(0) + finally: + if patched_load: + JsonUtils.load_from_file = orig_load # type: ignore + + def run_flash(argv_tail): + """Invoke flash.main with a constructed argv list.""" + original_argv = sys.argv + try: + sys.argv = ["flash.py"] + argv_tail + try: + flash.main(len(argv_tail), argv_tail) + return 0 + except SystemExit as e: + return int(e.code) if isinstance(e.code, int) else 1 + finally: + sys.argv = original_argv + + def try_reset_port(port: str): + """Best-effort reset using inline BOOT/RESET commands, then small delay.""" + try: + reset_seq(port, args.baudrate) + time.sleep(0.5) + except SerialException: + pass + + rc = 0 + try: + flash_port = target_port or bridge_port + if not flash_port: + print("Target port (--target) or bridge port (--port) required for flashing", file=sys.stderr) + sys.exit(1) + + common = [ + "-d", + "--profile", resolve_profile_path(args.profile), + "--baudrate", str(args.baudrate), + "--memory-type", args.memory_type, + "--log-level", "debug" if args.debug else "info", + ] + if flash_port: + common += ["--port", flash_port] + common += args.extra + + # If flashing (not control_only), pre-drive into download mode via bridge if available. + if not control_only and bridge_port: + boot_seq(bridge_port, args.baudrate, 0) + + if args.single_image: + single_argv = common + [ + "--image", str(boot_path), + "--start-address", start_addr, + "--end-address", end_addr, + ] + run_flash(single_argv) + elif boot_path and app_path: + # Always build a custom partition table (base64-encoded JSON) so flash.py + # downloads both images in one session without intermediate reset. + partition_entries = [ + { + "ImageName": str(boot_path), + "StartAddress": int("0x08000000", 16), + "EndAddress": int("0x08040000", 16), + "MemoryType": 1, # NOR + "FullErase": False, + "Mandatory": True, + "Description": boot_path.name, + }, + { + "ImageName": str(app_path), + "StartAddress": int("0x08040000", 16), + "EndAddress": int("0x08440000", 16), + "MemoryType": 1, # NOR + "FullErase": False, + "Mandatory": True, + "Description": app_path.name, + }, + ] + partition_json = json.dumps(partition_entries) + partition_b64 = base64.b64encode(partition_json.encode()).decode() + + merged_argv = common + [ + "--partition-table", partition_b64, + ] + rc = run_flash(merged_argv) + if rc != 0: + # Retry once: drive download again, then re-run single pass; if still fail, fallback two-step. + if bridge_port: + boot_seq(bridge_port, args.baudrate, 0) + rc = run_flash(merged_argv) + if rc != 0: + print("Single-pass flash failed (code {}). Aborting (no post-flash).".format(rc), file=sys.stderr) + sys.exit(rc) + if rc != 0: + sys.exit(rc) + else: + dir_argv = common.copy() + if image_dir: + dir_argv += ["--image-dir", image_dir] + rc = run_flash(dir_argv) + if rc != 0: + sys.exit(rc) + finally: + if patched_load: + JsonUtils.load_from_file = orig_load # type: ignore + # After successful flashing, release BOOT and reset to normal mode via bridge if available. + if not control_only: + ctrl_port = bridge_port or flash_port + if ctrl_port: + for _ in range(3): + try: + release_to_normal(ctrl_port, args.baudrate, 0) + break + except SerialException: + time.sleep(0.5) + else: + # Best-effort final deassert to cover missed bytes. + try: + send_boot_reset(ctrl_port, args.baudrate, 0, boot=0) + send_boot_reset(ctrl_port, args.baudrate, 0, reset=1) + except SerialException: + pass + # (optional cleanup of timing files could go here) + + +if __name__ == "__main__": + main() diff --git a/Flash/pro3_gpio.py b/Flash/pro3_gpio.py new file mode 100644 index 0000000..9322986 --- /dev/null +++ b/Flash/pro3_gpio.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# Simple GPIO control for AmebaPro3 boot/reset via ASCII commands over CDC-ACM. + +import argparse +import time +import serial +from serial import SerialException +from serial.tools import list_ports + + +def boot_seq(port: str, baud: int): + with serial.Serial(port=port, baudrate=baud, timeout=0.2) as ser: + ser.write(b"BOOT 1\n") + ser.flush() + time.sleep(0.05) + ser.write(b"RESET 0\n") + ser.flush() + time.sleep(0.02) + ser.write(b"BOOT 0\n") + ser.write(b"RESET 0\n") # keep reset asserted; caller can release later + ser.flush() + + +def reset_seq(port: str, baud: int): + with serial.Serial(port=port, baudrate=baud, timeout=0.2) as ser: + ser.write(b"BOOT 0\n") + ser.write(b"RESET 1\n") + ser.flush() + time.sleep(0.05) + ser.write(b"RESET 0\n") + ser.flush() + time.sleep(0.02) + ser.write(b"RESET 1\n") + ser.flush() + + +def main(): + p = argparse.ArgumentParser(description="Send BOOT/RESET GPIO commands to AmebaPro3 over CDC-ACM") + p.add_argument('-p', '--port', required=True, help='Serial device, e.g., /dev/ttyACM0') + p.add_argument('-B', '--baudrate', type=int, default=115200, help='Baudrate (only for opening port)') + p.add_argument('--boot-seq', action='store_true', help='Drive boot entry sequence via BOOT/RESET commands') + p.add_argument('--reset-seq', action='store_true', help='Drive reset sequence via BOOT/RESET commands') + p.add_argument('--boot', choices=['0', '1'], help='Assert/deassert BOOT line (0=deassert,1=assert)') + p.add_argument('--reset', choices=['0', '1'], help='Assert/deassert RESET line (0=deassert,1=assert)') + p.add_argument('--list', action='store_true', help='List available serial ports and exit') + args = p.parse_args() + + if args.list: + for port in list_ports.comports(): + print(f"{port.device}: {port.description}") + return + + if args.boot_seq: + try: + boot_seq(args.port, args.baudrate) + except SerialException as e: + p.error(f"Open {args.port} failed: {e}. Is another program using the port?") + return + if args.reset_seq: + try: + reset_seq(args.port, args.baudrate) + except SerialException as e: + p.error(f"Open {args.port} failed: {e}. Is another program using the port?") + return + + if args.boot is not None or args.reset is not None: + try: + with serial.Serial(port=args.port, baudrate=args.baudrate, timeout=0.2, rtscts=False, dsrdtr=False) as ser: + if args.boot is not None: + ser.write(f"BOOT {args.boot}\n".encode()) + if args.reset is not None: + ser.write(f"RESET {args.reset}\n".encode()) + ser.flush() + except SerialException as e: + p.error(f"Open {args.port} failed: {e}. Is another program using the port?") + return + + p.error("No action specified (use --boot-seq, --reset-seq, or --boot/--reset)") + + +if __name__ == '__main__': + main() diff --git a/Flash/version_info.py b/Flash/version_info.py new file mode 100644 index 0000000..8451b7a --- /dev/null +++ b/Flash/version_info.py @@ -0,0 +1,7 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 Realtek Semiconductor Corp. +# SPDX-License-Identifier: Apache-2.0 + +version = "1.1.1.0" diff --git a/README.md b/README.md new file mode 100644 index 0000000..0842321 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# Ameba Control Panel + +PySide6 desktop app for AmebaPro3/Pro2/Smart UART control. Key features: per-device tabs, fast log view with timestamped RX/INFO lines, history pane with persistence, command list playback, flash/mode/reset integration, and background port refresh. + +## Quick start +```bash +python -m pip install -r requirements.txt +python -m pip install PySide6 PyInstaller # if not already present +python -m ameba_control_panel.app +``` + +Or run via helper: +```bash +python script/auto_run.py +``` + +## Packaging +```bash +python script/package_exe.py +``` + +The build adds hidden imports (`pyDes`, `colorama`) and bundles the `Flash/` folder. diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..06f0ebe --- /dev/null +++ b/agents.md @@ -0,0 +1,99 @@ +# Ameba Control Panel (PySide) + +## Goal +Build a PySide-based desktop app titled **“Ameba Control Panel”**. It serves three Ameba families—AmebaPro3 (RTL8735C), AmebaPro2 (RTL8735B), AmebaSmart (RTL8730E)—each on its own tab. The app must prioritize responsive UART interactions, smooth log rendering, and safe concurrent execution. + +## Architecture & Design +- Use an explicit OOP structure: separate UI widgets (views), controllers (signal/slot glue), and services (serial I/O, command history, process runners). No “minimal” scripts—prefer clearly named classes and modules. +- Tabs: one tab per device class; each tab owns its own controller/service instances (no shared mutable state across tabs). +- Event loop: base on PySide6 (Qt 6). Encapsulate threading via `QThread`/`QObject` workers for serial I/O; use `multiprocessing` (or `concurrent.futures.ProcessPoolExecutor`) for heavy parsing or binary tasks to keep the UI thread idle. +- Allow optional C/C++ extensions (e.g., pybind11) for high-throughput UART parsing; isolate behind a Python service interface so the GUI remains unchanged if the extension is absent. + +## Per-Tab Requirements +- Connection controls: COM port dropdown (full system path/name), baudrate dropdown+editable field (populate with common values; default 1500000). Connect/Disconnect toggle shows live status. +- UART console: fast-rendering log view with timestamps per line; color-differentiate TX vs RX (no text prefixes); must support copy/paste, multi-range selection, and full scrolling (vertical and horizontal). Provide `Clear`, `Save`, and `Copy` actions. Maintain an in-memory ring buffer to avoid UI lag on high traffic. (Current build hides TX lines in the UI log; only RX/INFO show.) +- Find/search: non-blocking search over buffered logs; case-sensitive toggle plus case-insensitive default; highlight matches; provide Find/Next/Previous/All actions. Perform search in a worker to avoid UI stalls and apply highlights on completion. +- Command entry: text input + `Send` button + Enter to send. On send, append to history unless it is a duplicate of the most recent entry (skip duplicates). +- Command history pane on the left: scrollable (vertical and horizontal), single-click focuses and loads the command into the input bar; double-click sends immediately without duplicating the entry; `Delete` key removes the selected record. Persist history per device tab under `%LocalAppData%/AmebaControlPanel//history.txt`. +- Background port refresh on change events; only mutate the COM list when a port is added or removed. Maintain a stable, non-reordered dropdown: preserve existing order/selection and insert newcomers deterministically (e.g., append sorted new ports). Log connection state changes in the console. (Port additions are intentionally not logged; removals are.) +- Command-list loader: file Browseer accepts `.txt` with one command per line; `Load CmdList` button streams commands out automatically with a user-configurable per-command delay and per-character delay. + +## Logging & Performance +- Timestamp format `YYYY-MM-DD HH:MM:SS.mmm`. Store both full archive and UI tail (e.g., last 100k lines) for quick redraws. +- Use a batched append pipeline: gather serial lines in a thread-safe queue; UI thread flushes at short intervals (e.g., 30–60 Hz) to keep scrolling smooth. +- Multi-select copy should concatenate selected ranges with timestamps preserved. + +## Concurrency Rules +- Serial read/write happens in dedicated `QThread` workers; UI never blocks on I/O. +- Use signals for crossing threads; avoid direct widget access from workers. +- For CPU-heavy tasks (parsing, file save), delegate to a `ProcessPoolExecutor`; C/C++ extensions (e.g., pybind11 modules) are encouraged where performance-critical, keeping a Python fallback. +- Ensure clean teardown: on tab close or app exit, stop workers, flush queues, and close ports gracefully. + +## Backend Interfaces +- Serial service API: `open(port, baud)`, `close()`, `write(bytes)`, `signal line_received(str, direction)`, `signal status_changed(state)`. +- History service: `load()`, `save(entries)`, `add(entry)`, `delete(entry)`. +- Optional C/C++ module: provide drop-in replacements for line framing or log buffering; keep a pure-Python fallback. + +## Current Implementation Notes (2026-02-05) +- UART writes default to `\r\n` (CRLF) line endings to satisfy Ameba monitor commands. +- Flash action closes the DUT UART, runs `flash_amebapro3.py`, reconnects, then triggers an auto “Normal mode” step after ~100 ms while keeping the port open. +- Download mode, Normal mode (manual button), and Device Reset now run without closing/reopening the DUT UART; connection state stays steady. +- TX log lines are suppressed in the UI; RX/INFO still render with timestamps and colors. +- Port additions are not logged; port removals are. +- In frozen/packaged builds, the flash helper runs **in-process** (inline) using the bundled Python runtime; no external Python is required. Non-frozen/dev builds still shell out to the system interpreter. +- When packaging, include hidden imports `pyDes`, `colorama`; `package_exe.py` already adds these. + +## Snapshot for PySide Rewrite (layout + behavior) +- Tabs: three device tabs, each fully isolated (own controller/services). Keep the per-tab state separation when porting. +- Layout (per tab): + - Top rows: DUT COM dropdown + Refresh + Baud + Connect toggle; Control COM dropdown + Baud; Mode buttons (Normal/Download/Reset) aligned right. + - Flash rows: App path + Browse, Boot path + Browse + Flash. + - Bottom split: left fixed-width history list (~220 px); right column with Log view, Find row (input + case toggle + find/next/prev/all), Command-list row (file path + Browse + per-cmd delay + per-char delay + Load), Command entry row (input + Send). +- Styling: light “Fusion” palette; monospaced log font (JetBrains Mono/Consolas fallback), modest padding/margins; log colors—RX green-ish (#1b5e20), TX (currently hidden) blue-ish (#0d47a1), INFO gray (#424242); timestamps `YYYY-MM-DD HH:MM:SS.mmm`. +- Logging pipeline: serial worker enqueues lines into `SimpleQueue`; a Qt timer flushes every ~30 ms, appends to the log view, maintains tail buffer via `LogBuffer` (max ~10k lines in UI tail, full archive available for save); perf label shows lines/sec and queue depth updated ~1 Hz. +- Search: uses Qt document find for next/prev; “Find all” scans full log text, highlights matches. +- History: single-click loads into input; double-click sends immediately; Delete key removes; duplicates skipped on consecutive add; persisted per device under `%LocalAppData%/AmebaControlPanel//history.txt`. +- Command send: Enter or Send button; CRLF appended; TX hidden from UI log. +- Command list playback: loads .txt (one command/line); per-command delay (ms) and per-char delay; stops if disconnected; appends to history without duplicate of last. +- Port refresh: 2s timer; stable ordering; auto-reconnect when previously active port returns; logs removals only. +- Flash/mode/reset: uses `Flash/flash_amebapro3.py`; in packaged build runs inline (no external python) with stdout/stderr piped to log; flash closes DUT UART, reopens, auto Normal-mode after 300 ms; mode/reset keep UART open. + +## UI Layout (per tab) +- Top bar uses three rows: **Row 1** houses the COM port dropdown (full path/name), baudrate input, and the right-aligned `Normal Mode`, `Download Mode`, `Device Reset` buttons. **Row 2** holds the application filepath bar (editable, shows full path/name) with its Browseer. **Row 3** holds the bootloader filepath bar with its Browseer and the `Flash` button (flashes app + bootloader via UART). +- Bottom split: left pane is the command history list (fixed ~220px) with delete/load/save; right pane is the console/log area with the log view, a dedicated find bar immediately below the log (case toggle + find/find next/prev/all), then a row for the command-list loader (filepath input + Browseer + delay + `Load CmdList`) directly above the bottom command input row (text field left, `Send` button right). +- Apply a light theme; use consistent spacing/padding; fonts legible and monospaced for log areas. +- COM port dropdown should show port name plus description; actions use the underlying device path. +- File Browseers must expose an editable input bar showing the selected file name and full path; the application/bootloader filepath bars, their Browseer buttons, and the `Flash` button sit on the second row alongside the COM port dropdown and baudrate input. +- Command list filepath input + file Browseer and its controls live in the bottom section, on the row above command entry; the Browseer still appears to the left of the cmd delay controls. +- Flash/Mode/Reset actions call `Flash/flash_amebapro3.py`: + - Flash: `--boot --app -t -p -B ` (baudrate matches DUT COM port, Close the UART before invoking (tool needs exclusive handle) and reconnect afterward if it was previously connected.) + - Download Mode: `--download-mode 1 -t -p -B ` (baudrate matches DUT COM port) + - Normal Mode: `--download-mode 0 -t -p -B ` (baudrate matches DUT COM port) + - Device Reset: `--reset -t -p ` (baudrate matches DUT COM port) +- Log text color adapts to theme: white on dark, black on light; RX/TX/INFO colors adjust accordingly. + +### Layout sketch (not to scale) + +``` +Top Row 1: [ DUT COM dropdown | Baudrate | Connect/Disconnect | Refresh] +Top Row 2: [ Control Devices COM dropdown | Baudrate | Connect] ]---- [ Normal Mode ][ Download Mode ][ Device Reset ] +Top Row 3: [ Application filepath input ]---- [ Browse ] +Top Row 4: [ Bootloader filepath input ]----[ Browse | Flash ] + +Bottom Left Pane: Command History list (fixed ~220px width, no wrap, left & right scrollable) +Bottom Right Pane: + [ Log view ] + [ Find row: text input ---------------------- case toggle | find | next | prev | all ] + [ Command-list row: Command File filepath | Browse | Delay | Load ] + [ Command entry row: text input --------------------------- | Send ] +``` + +## Testing & Diagnostics +- Add simple perf counters (lines/sec, queue depth) displayed in status bar or debug overlay to verify “fast enough” logging. + +## Packaging +- Target Python 3.11+ with PySide6. Provide `requirements.txt` and a short `README` snippet for running. +- Keep C/C++ extension optional; guard imports and present a clear message if missing. +- Add a `script/` directory containing: + - `auto_run.py`: launches the GUI with any needed environment bootstrapping (PYTHONPATH, dll search path, etc.). + - `package_exe.py` (or `.bat`/`.sh` wrapper) to build a standalone executable (e.g., via PyInstaller) bundling all Python deps, C/C++ extensions, and helper scripts; ensure the packaged env includes needed DLLs and preserves the light theme assets. When bundling, include the entire `Flash/` folder (e.g., PyInstaller `--add-data "Flash;Flash"` on Windows pathsep rules) so `flash_amebapro3.py` is available inside the frozen bundle. diff --git a/ameba_control_panel/__init__.py b/ameba_control_panel/__init__.py new file mode 100644 index 0000000..136a9be --- /dev/null +++ b/ameba_control_panel/__init__.py @@ -0,0 +1,3 @@ +"""Ameba Control Panel package.""" + +__all__ = ["app"] diff --git a/ameba_control_panel/__main__.py b/ameba_control_panel/__main__.py new file mode 100644 index 0000000..c1757ed --- /dev/null +++ b/ameba_control_panel/__main__.py @@ -0,0 +1,4 @@ +from ameba_control_panel.app import main + +if __name__ == "__main__": + main() diff --git a/ameba_control_panel/__pycache__/__init__.cpython-310.pyc b/ameba_control_panel/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..29c2bdefef4d98aaebf6f82f0626bc0fcb24019c GIT binary patch literal 186 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 VX#SI;x8v_r{%jOtU%FLn^k0kqb~pe4 literal 0 HcmV?d00001 diff --git a/ameba_control_panel/__pycache__/config.cpython-310.pyc b/ameba_control_panel/__pycache__/config.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2334ab731ab27b63f8f33626749714036b0c3710 GIT binary patch literal 1442 zcmZ8g-EZ4e6u;MY5;t|yrcKwD(vl4Y7Kw(g-PkluXm09kk@BT{Ou`6VoZg$(;Mn2X zooF;9q*lD~KXif@{tLvzo_W5ndtv_pLLhOj+pg(+<#W#OT%Y6f8DuhP1kW%3p18ki z2)&kI`q2SAMhJ(mK;(d856 z1lu{5qbqckzDL*S`zHxN>vUrZeLxGBXsFr?^d?)Rw;mz-F)h-MPBlAEl`bmXej_t1 zVKM9Yeh@j48~9-p@C(@7si~hhsPE{a!3dnpGxsP|}ko)8526BIYwTUMA;uo96(Pohr ziccRF8xM=!a&g#$A0~%9m@t1|208t}AI@9x83+v7m?B$&mSCn*1(K9pz2MpfPh9XM z@D!x3i_9K-?(VZT4~DMCgc?R%sNb?gaTE!ymPODn0TV0XlEIQ&oht)#Y8j3+78(NXOwFN?izQJb-#V_#>=mp~0NPU4$ zFjdx27e(rs_7b*i4N(o=SXW9kg;8%+%(~fb@0mSQ zDIt^~6e;FEcX{9o^?*fjWI{U(MoiB9zzNw^qj(+y6NWP$6S*0Lx_lEfp=^+WHRwoQ z;kN*k6ut^9c2-)FV=+8}ykRW3BK5MHV)pHJv5?E$MQRKy=DCCOAd=DX*!2&1LSE^R z6=|9cOXT`BQg1yWJ+oFP^;#3Ex+HP6-tRsowPw%iJTvR0(G|;Wt5YR?Sq5-0A?O5-a7<*RL?ALo_*X-|!T&2}$w3;y>9q`f>nO?17b$e!`O{$GvJb-*frnDf5 z%zdg{@r1`U(;uDh#pklm;R7I9m}3Jf26a<4sNMNd6a`No?Qd;XSLbndCX2J!_%ECK EALN&98vp#@hEhbmGpmQo~ll_-(YLvthIkRGb4GrKl~q~rCQ@4YvF?{}!# z-vo4g|IeQCg8=Z7F^%E10eAE-1rk(X3`F zT=Qc4|M|SnB;BAq&)#?U2UbhP83FG&{@9iG=v>ToKAL)72b6Ps# zAZ;kJ+KzfZpqo)2y{Equ)D{v#d_P17Ua=#ioGD7hqDCa5Xhmaz^4@|(qzzRv49W;u ziEPo?ncpd?a=9(OEw4+FS*=KPO^sxwqO2<4eZztBmjghlx+(eMWd|tm3zfkg2PGai zB`wXMT$>4iQn_;;lt;ggK)HSJ=E&go$Y3EdI6pm@nI6o=1~>EcF4v~6mF41wZL&@2 z>1=REHz=*ZDi96<;n&U)#{iAqs(^UQN`@TANH|8$W8|`sn>dk&dckY@6Y_0kL(b~j zrlQKG!yvlp_)^|AovO4htENZ)8gFQ-rkftpKut< zR+yZUE>bW#&VDC*$4~Z-AMc%snoZW>#zP8$8clGSTb5ZV`bjwr=m0*iN#{8 zW+0JVPR%B97A?-D(#hNgtE+l#0jnsQHQZ4*1r=C^HU9D)fC80u!>8O9hd9KKax@1V zuh?4CrORYTFY2t+ET3DcsOj4hG1#GfdYCPvL9IuPhFI^Vq7nKxIw+~Y8*p+n+-r;O z7wp;c<$K9&J?*izX@ydIWb199!LvDAFa5V=2qx=hmZ)JD>Z(E;Gi`;|pgoq4S0Qd# zY)Aq6Va3(pvYrMynR}tph7kYuKIAyXy>n(%Fn#I8*?2mh%_idcxEZwKS#K3qrzpE( zsOD2~0O<_yhmRa{%mkil*ex&YvsZ_$OKjDI+ z>qGR-mh)#RsK8Uv|6uyw^nZ?4OwR1gCea*TVy;b|QiQXMD34Kc4kh2&;!7mw;!Ei~o{29d z%;4-|CbPI;F&NQPa;86@$|Q67cqWVIGI_g0uximmj4WqpuJ~REV(sMf*MyduK&k&Q=ne67+hGR;FI4=mUBHX`1HW^fZ+Iuyen_jV{m21) z_>i%$Tfy17KUD3x^waL6=;NDDgwOsEg6|84tC6Xn&pb*!-hCq6Jah{&z7nVgKRN<| M None: + super().__init__() + self.setWindowTitle(config.APP_NAME) + self._tabs = QTabWidget() + self.setCentralWidget(self._tabs) + self.controllers: list[DeviceTabController] = [] + for profile in config.DEVICE_PROFILES: + controller = DeviceTabController(profile) + self.controllers.append(controller) + self._tabs.addTab(controller.view, profile.label) + + def closeEvent(self, event) -> None: # noqa: N802 + for c in self.controllers: + c.shutdown() + super().closeEvent(event) + + +def _apply_light_palette(app: QApplication) -> None: + palette = QPalette() + palette.setColor(QPalette.Window, QColor(245, 245, 245)) + palette.setColor(QPalette.WindowText, QColor(0, 0, 0)) + palette.setColor(QPalette.Base, QColor(255, 255, 255)) + palette.setColor(QPalette.AlternateBase, QColor(240, 240, 240)) + palette.setColor(QPalette.ToolTipBase, QColor(255, 255, 255)) + palette.setColor(QPalette.ToolTipText, QColor(0, 0, 0)) + palette.setColor(QPalette.Text, QColor(0, 0, 0)) + palette.setColor(QPalette.Button, QColor(240, 240, 240)) + palette.setColor(QPalette.ButtonText, QColor(0, 0, 0)) + palette.setColor(QPalette.BrightText, QColor(255, 0, 0)) + app.setPalette(palette) + + +def main() -> None: + QApplication.setStyle("Fusion") + app = QApplication(sys.argv) + _apply_light_palette(app) + window = MainWindow() + window.resize(1200, 800) + window.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/ameba_control_panel/config.py b/ameba_control_panel/config.py new file mode 100644 index 0000000..5f71250 --- /dev/null +++ b/ameba_control_panel/config.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Tuple + + +APP_NAME = "Ameba Control Panel" +UI_LOG_TAIL_LINES = 100_000 +LOG_FLUSH_INTERVAL_MS = 30 +PERF_UPDATE_INTERVAL_MS = 1_000 +PORT_REFRESH_INTERVAL_MS = 2_000 +DEFAULT_BAUD = 1_500_000 +COMMON_BAUD_RATES = [ + 115_200, + 230_400, + 460_800, + 921_600, + 1_000_000, + DEFAULT_BAUD, + 2_000_000, + 3_000_000, +] + +TIMESTAMP_FMT = "%Y-%m-%d %H:%M:%S.%f" + + +@dataclass(frozen=True) +class DeviceProfile: + key: str + label: str + rx_color: str + tx_color: str + info_color: str + + +DEVICE_PROFILES: Tuple[DeviceProfile, ...] = ( + DeviceProfile("amebapro3", "AmebaPro3 (RTL8735C)", "#1b5e20", "#0d47a1", "#424242"), + DeviceProfile("amebapro2", "AmebaPro2 (RTL8735B)", "#1b5e20", "#0d47a1", "#424242"), + DeviceProfile("amebasmart", "AmebaSmart (RTL8730E)", "#1b5e20", "#0d47a1", "#424242"), +) + + +def app_data_dir() -> Path: + base = os.environ.get("LOCALAPPDATA") + if base: + return Path(base) / "AmebaControlPanel" + # Fallback for non-Windows dev environments. + return Path.home() / ".local" / "share" / "AmebaControlPanel" diff --git a/ameba_control_panel/controllers/__pycache__/device_tab_controller.cpython-310.pyc b/ameba_control_panel/controllers/__pycache__/device_tab_controller.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f149c2e932282a67e1d9a7ab40400642dfe37d6c GIT binary patch literal 20932 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

EKy$nBcj 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 None: + super().__init__(parent) + self.profile = profile + self.view = DeviceTabView(profile) + self.serial = SerialService() + self.history = HistoryService(profile.key) + self.log_buffer = LogBuffer() + self._pending: Deque[Tuple[str, str]] = deque() + self._port_list: List[PortInfo] = [] + self._search_worker: Optional[SearchWorker] = None + self._matches: List[int] = [] + self._match_index = -1 + self._command_player: Optional[CommandPlayer] = None + self._flash_runner: Optional[FlashRunner] = None + self._connected_port: Optional[str] = None + self._connected_baud: Optional[int] = None + self._session = SessionStore() + self._session_state = self._session.get(profile.key) + + self._flush_timer = QTimer(self) + self._flush_timer.setInterval(config.LOG_FLUSH_INTERVAL_MS) + self._flush_timer.timeout.connect(self._flush_pending) + self._flush_timer.start() + + self._port_timer = QTimer(self) + self._port_timer.setInterval(config.PORT_REFRESH_INTERVAL_MS) + self._port_timer.timeout.connect(self.refresh_ports) + self._port_timer.start() + + self._wire_ui() + self._load_history() + self.refresh_ports(initial=True) + self._restore_session() + + # UI wiring ---------------------------------------------------------------- + def _wire_ui(self) -> None: + v = self.view + v.history_list.installEventFilter(self) + v.refresh_button.clicked.connect(lambda: self.refresh_ports(force_log=True)) + v.connect_button.toggled.connect(self._toggle_connection) + v.send_button.clicked.connect(self._send_from_input) + v.command_input.returnPressed.connect(self._send_from_input) + v.history_list.itemClicked.connect(self._load_history_item) + v.history_list.itemDoubleClicked.connect(self._send_history_item) + v.clear_btn.clicked.connect(self._clear_log) + v.save_btn.clicked.connect(self._save_log) + v.copy_btn.clicked.connect(v.log_view.copy_selected) + v.find_btn.clicked.connect(self._run_find) + v.find_all_btn.clicked.connect(self._run_find_all) + v.next_btn.clicked.connect(self._find_next) + v.prev_btn.clicked.connect(self._find_prev) + v.cmdlist_browse_btn.clicked.connect(self._browse_cmdlist) + v.load_cmdlist_btn.clicked.connect(self._start_cmdlist_playback) + v.app_browse_btn.clicked.connect(self._browse_app_path) + v.boot_browse_btn.clicked.connect(self._browse_boot_path) + v.flash_btn.clicked.connect(self._run_flash) + v.normal_btn.clicked.connect(lambda: self._run_mode("normal")) + v.download_btn.clicked.connect(lambda: self._run_mode("download")) + v.reset_btn.clicked.connect(lambda: self._run_mode("reset")) + # Delete via shortcut/action + self._delete_action = QAction(v.history_list) + self._delete_action.setShortcut(QKeySequence.Delete) + self._delete_action.setShortcutContext(Qt.WidgetWithChildrenShortcut) + self._delete_action.triggered.connect(self._delete_selected_history) + v.history_list.addAction(self._delete_action) + v.history_list.installEventFilter(self) + v.history_list.viewport().installEventFilter(self) + self.serial.line_received.connect(self._enqueue_line) + self.serial.status_changed.connect(self._on_serial_status) + + v.log_view.set_colors(self.profile.rx_color, self.profile.tx_color, self.profile.info_color) + + v.dut_port_combo.currentIndexChanged.connect(self._save_session) + v.control_port_combo.currentIndexChanged.connect(self._save_session) + v.dut_baud_combo.editTextChanged.connect(self._save_session) + v.control_baud_combo.editTextChanged.connect(self._save_session) + v.app_path_edit.editingFinished.connect(self._save_session) + v.boot_path_edit.editingFinished.connect(self._save_session) + v.cmdlist_path_edit.editingFinished.connect(self._save_session) + # Keyboard shortcuts + + def eventFilter(self, obj, event): # noqa: N802 + if event.type() == QEvent.KeyPress and obj in (self.view.history_list, self.view.history_list.viewport()): + if event.key() == Qt.Key_Delete: + self._delete_selected_history() + return True + return super().eventFilter(obj, event) + + # History ------------------------------------------------------------------ + def _load_history(self) -> None: + entries = self.history.load() + self.view.populate_history(entries) + + def _restore_session(self) -> None: + if not self._session_state: + return + dut_baud = self._session_state.get("dut_baud") + ctrl_baud = self._session_state.get("ctrl_baud") + if dut_baud: + self.view.dut_baud_combo.setCurrentText(str(dut_baud)) + if ctrl_baud: + self.view.control_baud_combo.setCurrentText(str(ctrl_baud)) + if app := self._session_state.get("app_path"): + self.view.app_path_edit.setText(app) + if boot := self._session_state.get("boot_path"): + self.view.boot_path_edit.setText(boot) + if cmd := self._session_state.get("cmd_file"): + self.view.cmdlist_path_edit.setText(cmd) + + def _load_history_item(self, item) -> None: + self.view.command_input.setText(item.text()) + self.view.command_input.setFocus() + + def _send_history_item(self, item) -> None: + self._send_command(item.text(), add_to_history=False) + + def _delete_selected_history(self) -> None: + items = self.view.history_list.selectedItems() + if not items: + return + rows = [self.view.history_list.row(it) for it in items] + self.history.delete_indices(rows) + self._load_history() + self._save_session() + + def _save_session(self) -> None: + dut_baud = int(self.view.dut_baud_combo.currentText() or config.DEFAULT_BAUD) + ctrl_baud = int(self.view.control_baud_combo.currentText() or config.DEFAULT_BAUD) + payload = { + "dut_port": self.view.dut_port_combo.currentData(), + "ctrl_port": self.view.control_port_combo.currentData(), + "dut_baud": dut_baud, + "ctrl_baud": ctrl_baud, + "app_path": self.view.app_path_edit.text(), + "boot_path": self.view.boot_path_edit.text(), + "cmd_file": self.view.cmdlist_path_edit.text(), + } + self._session.set(self.profile.key, payload) + + # Ports -------------------------------------------------------------------- + def refresh_ports(self, initial: bool = False, force_log: bool = False) -> None: + new_ports = scan_ports() + new_map = {p.device: p.description for p in new_ports} + old_map = {p.device: p.description for p in self._port_list} + + removed = [p for p in self._port_list if p.device not in new_map] + added = [p for p in new_ports if p.device not in old_map] + added.sort(key=lambda p: p.device) + + if not added and not removed and not force_log and not initial: + return + + if removed: + for p in removed: + self._enqueue_line(f"Port removed: {p.device}", "info") + elif force_log: + self._enqueue_line("Port list refreshed", "info") + + merged: List[PortInfo] = list(self._port_list) + for p in added: + merged.append(p) + # prune removed from merged preserving order + merged = [p for p in merged if p.device in new_map] + self._port_list = merged + + current_dut = self.view.dut_port_combo.currentText() + current_ctrl = self.view.control_port_combo.currentText() + + # Prefer session selections during first load + preferred_dut = self._session_state.get("dut_port") if initial else current_dut + preferred_ctrl = self._session_state.get("ctrl_port") if initial else current_ctrl + + def _update_combo(combo, selected): + combo.blockSignals(True) + combo.clear() + for p in self._port_list: + combo.addItem(f"{p.device} ({p.description})", p.device) + index = combo.findData(selected) + if index >= 0: + combo.setCurrentIndex(index) + elif combo.count() > 0: + combo.setCurrentIndex(0) + combo.blockSignals(False) + + _update_combo(self.view.dut_port_combo, preferred_dut) + _update_combo(self.view.control_port_combo, preferred_ctrl) + if initial: + self._save_session() + + # Connection --------------------------------------------------------------- + def _toggle_connection(self, checked: bool) -> None: + if checked: + self._connect_serial() + else: + self.serial.close() + self.view.connect_button.setText("Connect") + self._enqueue_line("Disconnected", "info") + + def _connect_serial(self) -> None: + port = self.view.dut_port_combo.currentData() + baud = int(self.view.dut_baud_combo.currentText() or config.DEFAULT_BAUD) + if not port: + QMessageBox.warning(self.view, "Connect", "Please choose a DUT COM port.") + self.view.connect_button.setChecked(False) + return + self.serial.open(port, baud) + self._connected_port = port + self._connected_baud = baud + + @Slot(object) + def _on_serial_status(self, state: SerialState) -> None: + if state.connected: + self.view.connect_button.setText("Disconnect") + self._enqueue_line(f"Connected to {state.port} @ {state.baudrate}", "info") + self._connected_port = state.port + self._connected_baud = state.baudrate + self._save_session() + else: + if state.error: + self._enqueue_line(f"Serial error: {state.error}", "info") + self.view.connect_button.setChecked(False) + self.view.connect_button.setText("Connect") + if self._command_player and self._command_player.isRunning(): + self._command_player.stop() + + # Sending ------------------------------------------------------------------ + def _send_from_input(self) -> None: + text = self.view.command_input.text() + self._send_command(text, add_to_history=True) + + def _send_command(self, text: str, add_to_history: bool) -> None: + if not text.strip(): + return + if not self.serial.is_connected(): + self._enqueue_line("Cannot send: not connected", "info") + return + self.serial.write(text) + if add_to_history: + self.history.add(text) + self._load_history() + self.view.command_input.clear() + + # Log handling ------------------------------------------------------------- + @Slot(str, str) + def _enqueue_line(self, text: str, direction: str) -> None: + # Drop control chars and whitespace-only lines + cleaned = "".join(ch for ch in text if ch.isprintable()) + if not cleaned.strip(): + return + # Remove embedded timestamp/com port wrappers from flash helper output + if direction == "info" and cleaned.startswith("[") and "]" in cleaned: + trimmed = cleaned + for _ in range(2): + if trimmed.startswith("[") and "]" in trimmed: + trimmed = trimmed.split("]", 1)[1].lstrip() + while trimmed.startswith("[") and "]" in trimmed: + prefix = trimmed.split("]", 1)[0] + if prefix.startswith("[COM") or prefix.startswith("[main"): + trimmed = trimmed.split("]", 1)[1].lstrip() + continue + break + cleaned = trimmed + if direction == "info" and "Flash helper completed with code 0" in cleaned: + return + self._pending.append((cleaned, direction)) + + def _flush_pending(self) -> None: + if not self._pending: + return + to_flush: List[LogLine] = [] + while self._pending: + text, direction = self._pending.popleft() + if not text.strip(): + continue + line = self.log_buffer.append(text, direction) + to_flush.append(line) + # Only show RX/INFO in UI + visible = [l for l in to_flush if l.direction != "tx"] + self.view.log_view.append_lines(visible) + + def _clear_log(self) -> None: + self.log_buffer.clear() + self.view.log_view.clear_log() + self._matches.clear() + self._match_index = -1 + self.view.log_view.set_matches([]) + + def _save_log(self) -> None: + path, _ = QFileDialog.getSaveFileName(self.view, "Save Log", str(Path.home() / "ameba_log.txt")) + if not path: + return + Path(path).write_text(self.log_buffer.as_text(full=True), encoding="utf-8") + self._enqueue_line(f"Saved log to {path}", "info") + + # Search ------------------------------------------------------------------- + def _run_find(self) -> None: + self._run_find_all() + if self._matches: + self._match_index = 0 + self._scroll_to_match() + + def _run_find_all(self) -> None: + if self._search_worker and self._search_worker.isRunning(): + return + needle = self.view.find_input.text() + if not needle: + self.view.log_view.set_matches([]) + self._matches = [] + self._match_index = -1 + return + lines = [l.as_display() for l in self.log_buffer.tail() if l.direction != "tx"] + self._search_worker = SearchWorker(lines, needle, self.view.case_checkbox.isChecked()) + self._search_worker.finished.connect(self._on_search_finished) + self._search_worker.start() + + @Slot(list) + def _on_search_finished(self, rows: List[int]) -> None: + self._matches = rows + self.view.log_view.set_matches(rows) + self._match_index = 0 if rows else -1 + self._scroll_to_match() + + def _find_next(self) -> None: + if not self._matches: + self._run_find_all() + return + self._match_index = (self._match_index + 1) % len(self._matches) + self._scroll_to_match() + + def _find_prev(self) -> None: + if not self._matches: + self._run_find_all() + return + self._match_index = (self._match_index - 1) % len(self._matches) + self._scroll_to_match() + + def _scroll_to_match(self) -> None: + if self._match_index < 0 or not self._matches: + return + row = self._matches[self._match_index] + doc = self.view.log_view.document() + block = doc.findBlockByNumber(row) + if not block.isValid(): + return + cursor = self.view.log_view.textCursor() + cursor.setPosition(block.position()) + self.view.log_view.setTextCursor(cursor) + self.view.log_view.centerCursor() + + # Command list playback ---------------------------------------------------- + def _browse_cmdlist(self) -> None: + path, _ = QFileDialog.getOpenFileName(self.view, "Command list", "", "Text files (*.txt);;All files (*)") + if path: + self.view.cmdlist_path_edit.setText(path) + self._save_session() + + def _start_cmdlist_playback(self) -> None: + if self._command_player and self._command_player.isRunning(): + QMessageBox.information(self.view, "CmdList", "Command list already playing.") + return + filepath = Path(self.view.cmdlist_path_edit.text()) + if not filepath.exists(): + QMessageBox.warning(self.view, "CmdList", "Please choose a valid command list file.") + return + if not self.serial.is_connected(): + self._enqueue_line("Cannot play command list: not connected", "info") + return + self._command_player = CommandPlayer( + filepath, + self.view.per_cmd_delay.value(), + self.view.per_char_delay.value(), + ) + self._command_player.send_raw.connect(self._send_raw_from_player) + self._command_player.finished_file.connect(self._on_cmdlist_finished) + self._command_player.error.connect(lambda msg: self._enqueue_line(f"CmdList error: {msg}", "info")) + self._command_player.command_started.connect(self._on_cmd_started) + self._command_player.start() + self._enqueue_line(f"Playing command list: {filepath.name}", "info") + + @Slot(str) + def _on_cmd_started(self, cmd: str) -> None: + self.history.add(cmd) + self._load_history() + + @Slot(bytes) + def _send_raw_from_player(self, payload: bytes) -> None: + if not self.serial.is_connected(): + self._enqueue_line("Command list stopped: disconnected", "info") + if self._command_player: + self._command_player.stop() + return + self.serial.write_raw(payload) + + def _on_cmdlist_finished(self) -> None: + self._enqueue_line("Command list finished", "info") + + # Flash / modes ------------------------------------------------------------ + def _browse_app_path(self) -> None: + path, _ = QFileDialog.getOpenFileName(self.view, "Select application image", "", "Binary files (*);;All files (*)") + if path: + self.view.app_path_edit.setText(path) + self._save_session() + + def _browse_boot_path(self) -> None: + path, _ = QFileDialog.getOpenFileName(self.view, "Select bootloader image", "", "Binary files (*);;All files (*)") + if path: + self.view.boot_path_edit.setText(path) + self._save_session() + + def _run_flash(self) -> None: + app = Path(self.view.app_path_edit.text()) + boot = Path(self.view.boot_path_edit.text()) + dut = self.view.dut_port_combo.currentData() + ctrl = self.view.control_port_combo.currentData() + baud = int(self.view.dut_baud_combo.currentText() or config.DEFAULT_BAUD) + if not (app.exists() and boot.exists() and dut and ctrl): + QMessageBox.warning(self.view, "Flash", "Please provide app, boot paths and both COM ports.") + return + args = ["--boot", str(boot), "--app", str(app), "-t", dut, "-p", ctrl, "-B", str(baud)] + self._invoke_flash(args, close_uart=True, auto_normal=True) + + def _run_mode(self, mode: str) -> None: + dut = self.view.dut_port_combo.currentData() + ctrl = self.view.control_port_combo.currentData() + baud = int(self.view.dut_baud_combo.currentText() or config.DEFAULT_BAUD) + if not (dut and ctrl): + QMessageBox.warning(self.view, "Mode", "Select DUT and Control COM ports.") + return + if mode == "download": + args = ["--download-mode", "1", "-t", dut, "-p", ctrl, "-B", str(baud)] + elif mode == "normal": + args = ["--download-mode", "0", "-t", dut, "-p", ctrl, "-B", str(baud)] + elif mode == "reset": + args = ["--reset", "-t", dut, "-p", ctrl, "-B", str(baud)] + else: + return + self._invoke_flash(args, close_uart=False, auto_normal=False) + + def _invoke_flash(self, args: List[str], close_uart: bool, auto_normal: bool) -> None: + if self._flash_runner and self._flash_runner.isRunning(): + QMessageBox.information(self.view, "Flash", "Flash helper already running.") + return + was_connected = self.serial.is_connected() + if close_uart and was_connected: + self.serial.close() + flash_script = self._resolve_flash_script() + if not flash_script.exists(): + QMessageBox.critical(self.view, "Flash", f"flash_amebapro3.py not found at {flash_script}") + return + self._flash_runner = FlashRunner(args, flash_script) + self._flash_runner.output.connect(lambda line: self._enqueue_line(line, "info")) + self._flash_runner.finished.connect( + lambda code: self._on_flash_finished(code, close_uart, auto_normal, was_connected) + ) + self._flash_runner.start() + self._enqueue_line(f"Running flash helper with args: {' '.join(args)}", "info") + + def _resolve_flash_script(self) -> Path: + candidates = [ + Path(QCoreApplication.applicationDirPath()) / "Flash" / "flash_amebapro3.py", + Path(QCoreApplication.applicationDirPath()) / "flash_amebapro3.py", + Path(__file__).resolve().parents[2] / "Flash" / "flash_amebapro3.py", + Path(__file__).resolve().parent / "../../Flash/flash_amebapro3.py", + ] + for path in candidates: + if path.exists(): + return path.resolve() + return candidates[0].resolve() + + def _on_flash_finished(self, code: int, close_uart: bool, auto_normal: bool, was_connected: bool) -> None: + self._enqueue_line(f"Flash helper completed with code {code}", "info") + if self._flash_runner: + self._flash_runner.wait(100) + self._flash_runner = None + if close_uart and was_connected and self._connected_port and self._connected_baud: + QTimer.singleShot( + 200, + lambda: self.serial.open(self._connected_port or "", self._connected_baud or config.DEFAULT_BAUD), + ) + if auto_normal: + QTimer.singleShot(500, lambda: self._run_mode("normal")) + + # Cleanup ------------------------------------------------------------------ + def shutdown(self) -> None: + if self._command_player and self._command_player.isRunning(): + self._command_player.stop() + self._command_player.wait(3000) + if self._flash_runner and self._flash_runner.isRunning(): + self._flash_runner.requestInterruption() + self._flash_runner.wait(5000) + self.serial.close() diff --git a/ameba_control_panel/services/__pycache__/command_player.cpython-310.pyc b/ameba_control_panel/services/__pycache__/command_player.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fa107c36b6304f83b9826f39aa483499e80492e9 GIT binary patch literal 1931 zcmZ8iUvJws5GN^0mSxM1o1pF5^=&Ao4oxv^7`CAp3Uuq9=B7)5ZrTb#5$Q&r{)M8K z#BhcJ4$!A!U%`3o2iTY4>z?-1_YNp_Cp&FC8am$b{*ZV49bNjqM_~N*cAoy)BIIur zE)EXDJwj;x6EK1ZnvtA_lp@bE7BZNv%*uJln>C)GoYpu7e%RKot8yC1ex!|dLL%@VTEgP7I4#g^l)!1 z6AjGceI;XIoZa*p=nUV@N}XV+i^EDKrp8BCDHia*Gr#$S)?wo+s7aOM-!GA$G344&lmhtbGc^(&HCyS4z8X9+$ zX0n0|lm%OxjY_I0$wee27({t(thCT(&@AuA>ixP?#Yz_X&EP!kUsgFkHI9;cq6))@ zWg(MQZcT>B<38}w`Yte<%*lez_!(T+jLt~Jv?b__&6!}=3CO&Wt#`65ELe2}fo%9t zQHb#jK0v^q_Z|l=W7iWnt8t?!Em9ptrX8JUXskRwGQDUmMblr(?{WiIH7Sa;cox`( z*D@O^grDjl@sad^yrK{|O0u}Fqv#cRvmNJhFOHJ3(5lR$DlTMproI=vAZpYZoto@)5~kuvwfaLf_a_~2N-m_k43x*nrUZ7#$-$d73?Kl zurqQ(pOE4@C)zp&gA+!{v=26XVQUA9OR?Nf$j{IrC+rXCkKGk_NDs+W&gi$~>5nH+ z=y9w5l#=4{%v!j@I-@6i#_O*RZh@w~uvgbc#-8B6aW$H8@Vk0qH-0~W-zDHWU}ulJ za5h^wFB$Hv{Y4Kn9`XkdmNue=QsJ*WxUjB}buZBL)okV4Z+ve(0f>w@7WNszZr+8Q z*5&uoA$0I?YESg&_LtbKvPen+mpo;@*PzHa(4eJ~pmd~CrK)QNMfSa5ptgW9ZS3Pn z%Oh>Py2?_WrG>0b`@5q=Hl1(m#?Nq>r`lMxR_ZD`+Q6li>7S#Z*0Iu3s4J*+Ygvd$ z#fPvf9!VOm(_Dg6CS|2Aqm4fTEZ6%|G*Av4^+6MMxxXck5@TTix7nwX!mwmteF!#7 zas#a`05vjA*D-pT_5g4_>ON<08F#5eIlzxoM!PUCWwhzhYs`T)_ME-$ZL-(b2GhYh zL?P}PIuWT@xCWgFQ)p)rHMbM6;e&uSMS%&E`2Q(+t;_0bP&Bpo*!0r+W~dJ6Fz}W= ziBHM0F}uh$-Pv&1qNN-p=uED&!a2 vkMw=Ov%)u5;hWLqvpgm4e&JXRRvMVNcbB#J2CuQkT6T%cx`2SIY|Hu&!LQCx literal 0 HcmV?d00001 diff --git a/ameba_control_panel/services/__pycache__/command_player.cpython-314.pyc b/ameba_control_panel/services/__pycache__/command_player.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fc0fd8eaefc6cb426af9781d3f23c77b8bf9b246 GIT binary patch literal 3838 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 literal 0 HcmV?d00001 diff --git a/ameba_control_panel/services/__pycache__/line_parser.cpython-310.pyc b/ameba_control_panel/services/__pycache__/line_parser.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dfae93ee73b6745de638678035c2139d522cf038 GIT binary patch literal 448 zcmYjNy-ve05I!eq`j<+G2Vjqo=nJ3<39)uzKy-=R_*w-yc6D}vA~slg3B^1C-;hIJp#IERC^V`o+a?wcCjG~UEH;>6izM{#G)>Jm1E=Fe(t=>oxruZ z(|}Ey7!2V6LbeA@rr8SIBIN8jO|JP0gx&_M!b{hOWFBA{FvF`5x>d+3n%}nd6dqud zvcSKfV*_U9<2;u-)~U=3f{AF$L_}Gdi#Ed+T4Zz$j&QPy!4O`+fwTJtAi>-+JZHx& zWT6xC(7oquK;T_C*k;@$*0J#pPqiqFuY~Ta-0(uHPc+)^NY#yXK_k{p^U3|zzLMKf zTjpBHEb7I%$fA?&##Upi6?es9{C}xc2z8RSMbV^{1i+OwHg|mhYx2VW?<^b@ql!h=y=pUV1lpI~d(P8+<9(KF`)v8S~Eq&&)+p zr;i#EEz})MX(~%6E_5c3>_O(n(^qZLqxV$!8-V@T4l}b4yE5r#;*qLifi7JH=*FBe s_7&F0uFLA*+~C*wOywsytbXaBJXWJINBFx~!0t1@0`E&}e8krHUkDAI!~g&Q literal 0 HcmV?d00001 diff --git a/ameba_control_panel/services/__pycache__/log_buffer.cpython-310.pyc b/ameba_control_panel/services/__pycache__/log_buffer.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..463d6106873fae97a3e38fd7eed9d55c28958b05 GIT binary patch literal 2685 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 literal 0 HcmV?d00001 diff --git a/ameba_control_panel/services/__pycache__/port_service.cpython-310.pyc b/ameba_control_panel/services/__pycache__/port_service.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ec081fa07c1b3a5b59ab6bba109f538a2fca910b GIT binary patch literal 1009 zcmZuwO=}x55S6qaUOP_gB)v40kn5J>^aq#_XrUBJC^&_(C5X*RNw<|(8%ZuCI49@Y zL;pc?>`$t$J@wjS=%F*~I5D&Wjoz$wXWq=b)vK!=0_*pmS^0w!@)Moq6@s%*2(|A4 z2qLH=W13Nlm?<_6vY^3%3dd0vH8@o9xRtdik%5Rr{FS~Uq9u|mB9a-K(JT>tu`X7{ z+EtXbMQ=pXjbE5ErG6!^D(!Mt>dJ!K7P-p{m0NVeH>GuOb`+ev*2dXGaN`rBFJx6< zgyj{0vyaTT0Z5WjK{ECZDgah&I6nh8L&zWbQ ztu&a;?qX5=&KuO{rUl=%VcQr?ha{{^n`eH#tO_*|lH2pjeUz>&e0Z$2^0AR_VyfFU zY}SWGQAY*L_I@!y5$qS(ou>c<73_*Qa!qf@OEROEbV|(TlwLCzh(Lrhb^{ZiQtZ^h ze?yHh)E0y~B~xYJ)6ii3 zKEd#)BOm8=Ei2KGOPN{6+o-wvht~rEoT^R0Y|xPQVVfu59HjIR5?~0kfo2oUBQR+P zRdX_d0fA}^Rs;fDC|p{DlJ&7WugmJx;GgHaAcitm0|&FU4e5FRFwqjTMqf?alHpvH VdpK~r2Bu58Ae$~rfe*vJH^fLee literal 0 HcmV?d00001 diff --git a/ameba_control_panel/services/__pycache__/port_service.cpython-314.pyc b/ameba_control_panel/services/__pycache__/port_service.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9aed91aa2bd4b8c72b53b1251fdbc23d0aef2401 GIT binary patch literal 1412 zcmZWp-D@0G6hC)ncJ{kTAWNbIvuV=WrLtBUa1{|0t-GXMw_^J`BC z^q(8v?*!m4$>gh$0y1F;#;^$%=|PtWvM90E)4ICQ71n!3w;&1%8$GjI6vai@(3ij} zSmv8(SFQ#iUz`K0w5p|&)pP?7AP3j>a9 zd1MRQ@og>{oE6AGLEZlbkCwoC{ddiT=YY4`$0fJZ{w9T^6S1`5> z!_rQpMj>4xLlmq6dSWJ}0b1?_+hL+}!4h-iUiTa~sg7LkFfWp>{S1A1kkY{3btz3M zlXPPNWw>`#%?*(oUq8bIyg@}R%rCbK=P+JJ*ZN6VuFDf6G|pc1Y=l-O|A1KzzAgvaGC57i&&baTgK}e zCa~~WmOPp90>_U>F6DcH*l{tw#MlZ$KQWmrVixS5kk$Mw&-I21SXDD*ySCu+@sq3} zO<_i}lwp3^0*a>a<1Zr_!!a0S=7}V?SB~U%r>!^@iwXmesZ~U(!blK|VVFvh4zeD7 zCP1(RdB;w}rzF0Yh>OXt#^H@>-g)Ba)SyPezQr}~fe-|B6Yjz7Fx>;F-l zKP=V1t$khl{_3r3ch6q9*ZAn5)IF?Dk1ziHv`XDMenEw;eK*ls=+z2CAEQ|Ey8|*VZ)T7gJ z7VD|r(Bo5?4ZeYdEWz-bVLUUrE9P$+_sO^rRi!ol`;el)^{sm!Vrz`9Oc3aWX_0%Q zys^G)%Oexyho-4Q>`c3KHpsokn&OI3&x&l0FKWpA12kG#!5KRT1|91_@s1;kP9RDR zGq_-TX%r&ATp*c5snV9MQXy77uP{(oY&O;JOi~%7H`TryU=TMX*g&{oVW?D5<=!iG z0pEA?+I+}W(b(2Cm0IN1RDXEmj>^J#G)M_dulIKs$Ls5M(peDb;)+G zNfr$ET#pzHO8x~U{soN=K7!vsWM}*U&ihuG(X4PcXw6E(GkFdt>;O)98=)Vs*kAB| z=N&&0C$uiyqfT1U$$npi5=KYh9(Vj~&E}iV zY3j0dw`ek|n$IZq(AZ^dT;3YDP1A9{SQx9**xg3AmoyCOye;O&2T|%TK~hbEnCHjt z4sGe-E}a!_X-PW2gAlVZlWYgyZF~|GkpH{IzHa>{A|61Jeep=@%L>vz*q70b$(j&M z5mLTp52;No))m`iq*UGLWkvQ_sl#PnU9PwYj}c*P;(K%rv2zb;a&pxlg{dl~)-oaP zj?!^dOk&k{K>`Vz^6j{v-AfY+y1&H!$DodSJ;d$tqO=DNn;ydSpW|7n&6C|%?Tf}4 fM@`YQ>Cdl)`=ws;^fHNO)KGoQF^u^Zza@SG#@`qF literal 0 HcmV?d00001 diff --git a/ameba_control_panel/services/__pycache__/search_service.cpython-314.pyc b/ameba_control_panel/services/__pycache__/search_service.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..95d5cfcebbc7aa89d840acfd22b3d24170a5c073 GIT binary patch literal 2152 zcmb7FO>7%Q6rT02*XzWNO;gvzb>sX|FsN=&QVJwOh(;)Bo04uS=RneBYfq9Dn_V+I zZV3vKB@jwPMN%$}IMEa8t%w6~f&&MRji?fLR89z?-dY)vUXXZiZ8u5Fk9e}*yqWjr z&6{uDd;45lw1og&`||^9KqllDoHQv|7o^$<2o-Xk=+YP&mAI5aIWQI&mAUN8@|ZFj zuI zLjW>_1Yv=!=|LXOlOc(>Kv1fPOj45-=BHftHf06$(eOhwkl|dZ_6!IW@OVD(;)jG3 zVJ%J0O7oHqC+M=S=s{i8L%McU0gvIk0WcRq+j6M)UKlU;rpMWgP#sE(HWf0=L|ei1 zsNqq^v$%DU3gv?9+CpWNmzg7!Gp<9gBUb9H%N9)TVN5;Z)X2-z6U-ej3a-PMYa1og zp|){xAlIMkpEeih1#_=z+N12ERiGXScuP3ZpXW-8B4QYghoFWbI(Ea5+pTJaqrEx= z+e8WG?ju{b?%{Bw-595@kX&49l@NTtqc|rC0IDD7gVPE62NiNhi>`z=v?p(A zsp^4)ms_jJLzg4f%#lqk&5~fUC%N#7a5brx2H~HQ>!<_?B{Qs27Dx63vVFj^5^IN{ zo)`5f6R0}?42?Y0R>(GK3HIGbwsy`Rb^-ytPl&1iHa|d)VIUI?#qQzh#$*nznns(4 zFSKJd(P$YvD}>}ovnn$L-wbxDyY+Rs>Ae_jcz@i4jCcHfWE0`Xjhnetl5(UJn8r8q z2|VC)KSIq2=QC8J0iZP zMqtHH#m~UBLaMR$)uq*?<)vyQzB;}#z7^@*h;**ATaljIk$u(fr?$F}!S81Gv9*z$>Ls&gxqgWt_m z?n>W{k*`mFdGf2%upUc%@_sd*yxMl9ZQa|9_udbYRR2AVgrlp`mFSk1+R#$#$2YZJ zzapT>5E&l_AGmBQwiE|iDzuaq!&q>OWgBJan(vfNyP=60#;nCWj<`6kVK9URVS*mt zhYqoIup_vbfWCqsy@FwK&a4Y%PNCI^Xrt>CsBIetlyw&v`(^!Vy{z#o#E{|N>jL)R zBXT>ISSxwz+i}hTK{kLB0wf^`i56!KUmOT_@E#(0jo=JqXCmz5LdEgx(!JjdY1PSthgp?QL2+Xf)cK2@YHWr9oO-=R3 zbaz!({i?cK`FzI0@9*Mv?RN{7^(hth{u~s}Se6L?07h9#)UAdn3qicB%d+IRUAFn{ zlpVb7y4&!|9`|wTsfJ(n8|iX-_)VsqK_9oCZREwaQU#Xw0F@`zDnP=RJt$wqCAgsMrBdX>U`fWA3-^%@+jxIynu2+&7eG^b7()R z29ou1KVyE3QQEbgGpcy@`+Bj$Lg`%M{(%4}xZ;p@SgK1VN*vI(6c6LGWIu zQXjU=1i>DS1%Ww?<=?f;B3?}HoyHK?D*@(J(L=Iz)sI_j8a?37yA405Q^k9aZ(JR*Dw zOl0+~f#}&gaIl`}S%K_HP&;x4?v4e{i&6uhxwN7(TxrF+D@w&|?1r7THng9$W-Y=V zx&d28Iv87p`(8(PG;FBWfMH+eLN3@ewMb*TsVNCwWg0v$&` z4-D!19I5EfXz4CR)&^9w0mW?CN+@~9h9Vr4+yT@xZn68U#;aEI5yy)Bt>=Wb4YhU` z4)k=QbWij}-yyYMwXOl%w#853n=3F8+B?Tf+UzVQd+@zHu<{4|X} zk|n%&m3YVesY^O^21`3w8CII+mHu(x?32275?zzVc#OYH9plsgW{gdsTxtEIU()aH zivZMARBPx~CtAG_djQ>*(Xcd+;VZl+^O;yiw_<5-6_3SUVxT~0HdONn499$h$YUUJ zAp~l6!k~Ji(p=Xnc66f_G33PQ^S7#+aX)eFBK5aJ9bwK8b|0CqQB7uDM?tf-S*b;_ z%YGAk3EsoYfvdH4>=@lPah~8mFuJO1H?=ZfrH|e7jdrvZ`xHQ^*<`sk`OK}>TcIv` z<{Lb-zEzD=?aEfYMZu=tS`Q$k6X{z~?C`f^=r!bIu?;7sNkRo8#ez5?3gRRl|9$!I zj3eQ2Qc9F_!V%P-!(ZMnNcqn~1~vCH4%N?%>ocFp_r0a@7xzy{?~C2iG1)yd>AaZV zyn?x_5@hrvo*bB+_X#jPF+i}}5g{B-iKB2cn2Q+5o`5S$h2VEbDtC>Yhm5J{l?;W- zdu}IM`_?n&aqxx)S_KICx~am=8W4;3#%_18Te?Zn_pW3;@``q&RHCX(f_J3L5=IJX zff>Y~lcKvgDbsF8a}k|l_xe_(!vhjJXk!WI!9X5Da7edD3R${Pl)OqqQ-Y^m=0W$b zE}KlsGp(XiNIIOC-KA+uOV+a&E{UfvXg!}3ZYJv?S0;U$hfxLPuzADebj92i$zt{y zloLj+_8aK^ISp5k-MPsHP&1EK^ryu`LlMCc?3;&(oCLwVyS$U^4M)+vwWMhC`s8=B zhBRxA5J8S=8J`G$dKO&lwKzL7PgC_NB0nOMCUThwkGDb`NrZTwgsG~o*GHj>#s4OC z;h=R4994xhpKoO-JCPaRdkg{0g%8>D!cd}54&In?B2htO zNZU_|1f>z=7?T1|&m(9-lz^P0JmU9Yb{zwp93aI~B62r8C6idzJ;`IS^2MbZfQfuh)%J8p6GCn&)8;Q~k2y>s}x@$x}!7;DN` zh>+=0oDN4~uYN|tb)A)KrRCUu;wfdmi}E@i{QmLcg<_sHZJtFj@sVeUBW)N;zvJA? z=P#VS^6F~v(%CC7o9C!r6FEzb4uxMNmvxP}M$yGwCBoaXyEo);G$Z><2JwX=PC+ap zr_cTCm@B_{((TS4*r`#_U|*iVRWiFVuI`N-+m!ZE#E?po(vy<1d-7uo8{tqL8=;#- z4)Yz1bk93~?uVD2U1r6=&R#?vOJ_(EBg3Qc6*DL{_lw6Ug61Sx*d$o@_yKYD#S4a= z15%OkvJny}b$vuWL_{R9Vp|T=La}uuNt7bQMWk(VobtihAh$h~8zmTFBDXu18xeCH zC0Te=$3%(bu6cp@?-Myggm!pB2N$WLL1fAnpG^x$k50w;B-I6_qjqVU;1ecD(VUzMk6;0Zn2w~3 z90wSV8=YaI0LNHH*95>*!6qMnN6E{VA@tAiMM$ojBXnMvSl$?#r=<)Np@#;bWuBvD zlA-jhfkjyiGDxulk9k*`LL~dvw%v3i8)s$Nr^A(N=-S_)Ux+{gC25gE$C?BjruibN=p#Jg9Ec|f;0Xoz@F1Og z-NTb&@OftXkzl;BltHmD*+i7y&VOda%|BQ%CM}lWKR-i0vkw1Qb)*{0&-@t1>nk7V_w% zzQ=vdA#^@qKx3U#Dh<&PL|c@}Ae8P{eKFFu&0q8K;&d&1J-PashRtyf2jlVm;wQ8~ z!j^f_JvOoCMW51>kJyl*msPRyI%0QDm!wEtHpP}`ldI&Sj-}h)5>e2D zbvP0}9y&pN2ZFBm}vsyV92syG^v71bOHE2?yCXe=O8 ziFhm$8BuMRBj%(81HCUh^+g^=qa&&%D9aJ~8f(f?CBHvBbTa7otGRyv$w**qgkXo? z|D~~^kwi(p-+w$LM-^IQID%qiq-?3*FBfAaYO&uRrClEO4=IWqIyR;RqbLIjvIYE@ zy1#wfV4oam^iEZm>?ZW%oz z=b=X)xd24V5q*_?lIuLzJ(pK-?!a7r(Ya^l%01&96FcLgN3PUsr{PFME`qS|Q^Z55 zSSnS3muhyza>Sz3r()$fIU5Duh$)iS;T1~Q0BaSzrV=(Hjkrbx#pX4G8by4&l-whB z>O*hgwV=dGB?hfKcnzmfBL_8bDpG3XQ4PJm*M@SOjFj?xuU&BsuL!tni}X#X`JhujsXSL2_Pcy@)Pt zzoi2z(>BZb6ctibfJn8D4#~l=a=*YJuf)kYqFUvkGA4&ru{RP9avGpxb37uS98#h* zp&EG=bpc9CkP^{dS=!`VQp+k(JKBtBoPEZumQ8mln(UvaWcq2I+Sa2)|juAQuruHoVaQIKjAti{TO+_|^^R`gn=b898*) zaNwFOsu&H99M`bV5nqeRsW!i#^G}?3k|+%b;~byzIx1d~iAHUj9EE#O;odmABRS9K z#H7;66|+*+T=B{axpOP4FXY|n*ca~@c)w#{rsKH@aaLM+r?mR*{I~LFN}DFc3(mO> z8z;m!9U5X&Nt@6F^au+y#&V>+Ucd&LnS4knxIX&5pW7&%5+BLCPZ~qKunAX zZ?)y1aF@rId|H~E3##y7N6d+A1jrv}xlc@f>kx7c;?eUUFCaU% zBnT12VYEYSgHV#S`Xnsn)!JRUyQ=hpj5rhO$zSci1}<-u+&(`uz& zH<{|K2sIP}ZxEv@*ZL^a@>1hI;_?|m@8cA9UarO1#?ehy?DHgH9rBq{)@jyfZ~{AD z!S+AJXbz^nYQ1(cCDyqm!EpO(lleO2HRQ*rh}jSYW`t}w^NC(bmqQ-5|8+io*0ty} z4d5WqUM;iY){H^%sqd7B+B`Nbp^ZU{$-a@BjfTxjX~_#wQ^f`i)@WscK3c)3@TZ^6 zV39s!pP*xXV`4S>w}(|is*ei01)8Y_g@h2>Zmwh@A!Ft^VHM2728qe=kff$jv6A)t zcS<|rGd%P)>rgVqE?^$iS0y_j>aDON*{GOUZ${8@I%@G*bjmovd+ZSQ|BR?;aJ}Bd zXXq(?f2QXp(^R@WQ1Q_98hz1v(`NqY6mM7t9@b&nkF%XIDkP7#GaW+D3;NzvEkp78 z>H{s*>JK{?2MRJ~kR3Pw=VwP>dsFU-x2{sSwj0V;juJWU6_e4_JGR3G^cJN%SnPqq+5)C$4V;OSvC zXLRVyNQBI`k;n-@7AY#h(~2rm+bHp)hAIl00&0fNqZXtqEXb-uwdhF*jd3<_ve=65 zlvlsK^R1oJ?(dgfE4%Ld;n5!)z3H1R_fE85=$Ui4;to&D;fXuyVvf4+9lq}VVc8GL z-u3;<(SJg%nR@T6qkle^*_;=h=bdqBWlUN*E0xciJhp?vT;Yl<>n^X07dFHS8{&nl z-!EJ}FFFcV&Xuj2Yi@}*Z;v%^k2iP5nmcEjyRPO>=FF8=yzRK^h?h3UN}H#*%$Bx( zTwZ;3^SsGixoNJk<(=2Q{o1?FO}#eL*cop;5NkYe^Y~2T!6|Xx$||el6)mxfmUu;L ztfKXL`@40wD|XM9u%f2ABn!cQ?e`4YTgmXzVO_z#0$E z)c4>GV3_{k)!bQc?+6-zPO6jty^S5 z<)%B0Yo_;p_to!+^JaW~z9_NM$}6v2eg%aED?fCXBD-Ma{dr_pTseFBY`kP`tYqy> zNy{bingQ(lKosI+#f{aY`;MI#h9 z-~Ys2s;xNgUK4Y#nQp&c7k6*Dx0uUHtNEa6V$jd$a~`l?AGlZFyHiq?KFv=aNF%g= zA2@vXqc=cyueYu3T)}=~^X%Koe$u?NE1&)Ay6U~H?5BCF0RI~++tG_F-Lwk?JIn;<7k4$9Z`PU6-OUDp;Kmxjzi^dxuVKGv z76@LmVxMS!PoO&QnFWGHs`FlhecvYYdm9Ccw-)uZnt$mi+utbuvOyrY(Y}9E&M%)Z z5xh|#c+)PxX+|e_q^6ly0a#8mC!=#cd^XoCns)vi>T3Mt(e?Ca&?ZVYCF$vA%K_-= z6i-Iyc{-(N--UxC_Qwl<~Wr~s`x zwf96>AEZ@gz(_Q9M$nA#Er~w7Vrz;f>b1Zix5{m3a^Eth9ACFp-UV3RjmXyv*Oe3u z2coA!Fzq{jQu|~ZMn6o<+ikb>6R1v`IbM-T$Z<^=0i>7=TT{alxS^R zSJ6hUx)k6^a&VOMkQm&yunnKY5gv6vb#2yz(^$*oHE0f^<{AgIfZ&jPP2ii!&G-ay zi%}&qsurg2ALY?8SRjuE-#AS$m&c^?+fpT;a+5_y&xMT{nKuc3A$@V{<#6lT}8Q$-xI|5 zh|ggd!y7@=pe!EMBmZwbsxE*3nXvL=5T5NUPF2z;`BAC^@F4j{o`LmdaDW^x?8ACp9Phq$y%4)lWJ|8^S|2BQ?#>X^6z5@8V_DJNUV*AKC;p>f>&7T`6*vc0>F!s8FUGt>tKPCTnEGLM>ePW zQDJS0|8vX~HoPWS8?pY3kZo;V6Y9D$D&o^?cCsf-UNPyF8+7Zn`p8?H5pdgSK~Kc6 zGs0T(7bk+QW5UOGfxI(+5*%2U;(+cKx#xsVN=C@GE02NkETb9z3Q4s^^;773S?qtl zw@Ni{*c_0b#vF*|5$1P8VRG_wE_dTw6Qz3z%A<&OPj$3+bhaJr@%f)>JJ>Goqx>&Z z#AUEZju70Lj0E9e7^iHKm?=sEm&vMo2}gh%fFHcyp!zt2dYfjxb9I+AhPYG}ld8T~ zK7IH{4e@OUV%rYfRA#Duv(mwNlht-On}t8;a$j+N-8oh8fve`5PJG2(H8EFB+|?L! zHBQ$}E8lQwfdyYhqbmfNoN^EqU@=PtF) z%YSq{zU$f8u4ixcKQ~kV{B6ez_a@pi?Dvd1A2?pPA0-{`hRf4l#BOY8TZ`nUMVmWp z{|kdoK0^CV=b=f+e#JuUElNLKEf+0YNh)n6!wm`9ZAdg0M{HFOM5>&{{Vjlpn{A_X zkWr|!JKSO?qcYYZ5U2|3ekOQ$J1TUY^&ll-rHoB z$+F^vDicv7gWCho0am$I8;)X?5MP?ViffH(`4U=9T56<6MmrF}b7f@(#aHZ??US7| zuG$F;C)u)?R2G*!G08J!nNnt@wVAmM)5X&{x2iT=ue&X6Ns1O-v?cca(abfO4$wpK z|1wQk$G?^EkBH1NBxIH$c>w-ZzE&{HWej&*m?h@UX~57k&@2)93<0L+Fg9TFnvz%q z4Lo8yuQ@>jDWZYQcDRlWDuP=d!i`@M7&=h$>S5G`D}sXtle8vHd`>{}Uty97>aoZN z=%b4w@_#I)BeE;yHxZ>%5m_8+#WGaH4a+ms<~Smdk!ZHn%(=_2^j_|rUVW)|#@#y6 z0ot;`m-%?qbZ-%kGx!GuTg@GJ>14?_4*X>Yd~ddz`_Tdn^?G4j0lQJKqOI9{qe(!V z=1HRM!%=wn9q+@9R$@Bm@ROlGuq27&gNMXOe-sNc)v~nl(~P;Vp>4v+^BFT)*59S*mS@8}rSG=$&!r3!XGIf`q%~(4 zaS(?5v5RrvNj?=AVMO^n7#>>~A}W@nS8)0&V7FSlHQ~EIk@7u`TR?3Mh%keD;(XU=BKEz?sLvoG1xp$%bUuIQDRen!yO z1%ecCE=}@RP@%WUCi{@8mXXLQ++cDRSA`5s!_P@Pb(A|P>s!1ct}JCm_Xm&x$KS1~ zR*it8gdw;D5|@BtWHcC73)3l<@;DKYkkIu1-1E5?9CP;ki`&j```V7l?X&iVJF8k| zR&6-n`5(nKQyXWBpP1;Jlk(zHMNF!QOLZ}+Zps(0-x90eGAnK6CFC2AOZ73Seku@e zcrw=T;3jLT?8R6{@L zTf{hcNPR}pQ)*~drqF+4I=D9BB*PuBsz_JVnnIOnC2|5RN~aNi`OJ5dNj!aXn(Wku9G=R@8OQqT?zHbS^S~P z4aLY=tSLr+y{XO3Zn*7jHRc=D0^&bhJU!eZBLev@euhSFvNV&3X`ZYlfzv!y$G;vR zZ~}ZUvSZVrlU17QCM(9XP{}}=r~;1lD*&(vI`Nu2eVFD=hb3{jCHDYUGmyq@CB8xu z)G0{Ao%O|@^F47#ZOnn2;oFX;yJnyzc_Q@VD54$a_-HmzLLpE^dUpP@oom-sXRGjh z;W4DqI~dVGh-g_uM)8`-FOrG8W*RpwNR}dmgaxK)Tlh!5&Ov?p5I9L=L>CwaV%!`t zLiS@vx0CwaB){u44jG0>1fwS%#aZfPHMvqz3{Sb2yyc?oN4H5Es26Wib3@Tk7z{o< z9MtYwj!_j(1!UOhcWQqJIF%Swn`R};L#Nb|^mA+!@O^GWLpXo!#g_tq^K{%^^}f9d zx13kDU*0}>YWno0?K4GNaowpOb%Sc?@yC?!wxf9&s(EB44)uyCQzH-Z$vjAL?=pw7DW4mBrw47+;aa z$k)YXwJj9&Y7dVy?IhKKM?}W|7&2Al)H1#S4SYFw@{2CDYtL79-g57p^1ULJ{1R$vO2#^Zh=*{!gtHm>I(_~FMRbiP zpQ0$!IJ-ib6nz!H=pIDN8)s1Ne@7brrxH}oPgg)Te_9Cs`>BM({+R=qT`EIp{+K?UAG0TC z*pt6u4Ii;Rkg_KGXC_go0>2chV8EK}5YR%^V%}4Nuq5lD(N=1+W literal 0 HcmV?d00001 diff --git a/ameba_control_panel/services/__pycache__/session_store.cpython-310.pyc b/ameba_control_panel/services/__pycache__/session_store.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b37a814a46be2faf280b7f4d2fcf257367f39343 GIT binary patch literal 1634 zcmZuxOK;mo5Z)Ilil!Y~vD=~!&;Wg)ED{&!DTg4akp^uqssRNk%A#E`+I2#i4`p|i zSb}c$HUB|A`q;m-*Pikhd}(J$IF*|$z~$@=XJ@`|X4q_Q#st=<^t||s6Y@76))(l= z_k>XUCkTotT9S%pl%mW^ma+EDGY)TFiYmy0)+WlZ>Setu$|6d1p!uqQLC&ZO_K6Dj zNf!S|R8K|cL`7#T+fZyo(*9eFoKnA$*LCA^S2VR9!dV>U?nTPH_@Nj(&kyUl-#M)G zv;1*WJJXa$d9BMuVzss4axk$?J;tln*E=AOK&wec6@lajiYsw0ApL;~R0xs)2@?}p zpl+)~ZGsf4U3E)sgVa+ykm#OIMqR9tYmD}x(QamHFNRI6$8;?$$_$>zNZ}_SII<*? z7Pzw|S@8>kbdAU*9i{@VeKH#T{L4@t9X|cZ2XYFy;iYny!>ep^bqQ=(T^?T&vjf*% z-OrH+Iqrx62Vmk`tIexotStyFBhq4;2h+Kaq%7*fNx2EmHiRvtAx-FFZynIV^;hp< ziNK@9@&JUzmLn^=Boj8_iYoSoUcgC;Ut2_ixM03d0UsqTUh!M2e@#+8yb5OS`2GVx z6X|-~sG@$JhF)Ohe1JHuNg#sn8=WiZ^r$I>=HYabf{?No>Q7f>RT{xOO)b%=yVdvt5>O>XB$FEJ{bcgw4_{;}t zR!wb-|3jdmAM#fkd&>&g2J|jn?A*YMb{{K^Jt0r@+;i*9)fNuZ?;zc~3j_7X7}LC7 zD8J(a3B|UZ&=p%k>t3)kIwA%sPPrEfuIY-ypKF&gvkeCG1#Z}(Yt{K0j(6a(c>RDb zwr^w!&dA=pEe=lW^+Fk+wm2Vwu!PB(kR^QxNv-Jh1a%3qj;GJzvy`u;l7ixY`2Y>t zeFplLw-~43W=3Eh0v_yn)5i-%2kT2zwA!yw8?I^FFsNPgig4;`m{TczEM?WGS&4EY z<;z)Kb{2CRov?om=5CNH^9^qQk=ROD4by`S=fmO_k(kCo9L8dcx0W=)IL9;iCqW#8 zY@4=|BB!hAaZzd{+Px@?X9kJ&p_@-(0uA<$!Mghxs(~LvLz)fLV#E)n;-iNhphx(t Rvj`v|5sQG$ZMw?`{9iG8W*Yzi literal 0 HcmV?d00001 diff --git a/ameba_control_panel/services/__pycache__/session_store.cpython-314.pyc b/ameba_control_panel/services/__pycache__/session_store.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..34ef83d8e45e4e8378dc789a99181425360fba38 GIT binary patch literal 3129 zcmcImUrZE77@z%nd&kM49z_s55Q4o{!3b7LEtM7%IGgLa82V6fbKWhodhF5J!AhSl zjcGJ#6rVK4L`|9$p4z_IH~ZG728tH5)%3w9-y~N;<5RzH_Le)4#-vI2Vdk5eZ)U#l z`~ALe_U-CzAp&26aLM{nBIFqk{7?1-p?DjF0y#}|VTcS0Oo*W@4v9?Uy~HHwr6GAx zVG3W9ht$CU3k+&Zi;*#^L}@#;|N$8g)#1!OEBp2s}WBgI0D-&A4I1D23WI47a}0iH^#pZE&-* zlRL0RB&Bu*o11OCE5bD4E~9`Sx&iJ5iwO`4WEAK4H$rSbETogbs00iFITi4=%DvC4 z7|{s?NJz$Ii1hK{;h|5GMymhRpsRrQYy!G$nx&!3<2^5J)eOV3 zEe1~3ZgSF_<_^|_W`R7ZZMiIe6MB5`5bw0`wa}H&)i901>VHF)VlvPOG;x8}K<6RJ zEu)xF6hn)`dW>bIafLuL%XnA59F+K{Ld#?PEeHj&yCTY|^{9HP*tTGVSgJp9dEjc|?;sN${!WX! zc%*cjF6pu_L%u6eR@GBb^8-_9YTC>J(pGrwc&Cj#TsiLazWF_La5YhqD1&YLr?idCwsnmm2{I+7+h|o#=*Wa%(t%M@; znhkh?I`;`1sO6EJvjaC0*Aq9A*OPa9 z=jc-O&{CvpLF;-Ig_2i7v5jy2cNBC*S34y@BEXU0X3=-h6yWL%Rv&r}-fDb_^kWZVpO4$*i9aaIJ%AKVn zHa@Z*1frK9hd?DRm4i>;hY1JIXfHtk)HB&82w;XgR0n#HF)eT613KHgsO`Z730?_a zt>SI~tXST8-X`NAe4}mneStSRWEhkA>~s!g_(l1AI-M)6L=0oxq7KVhwrS@LgErv_ zNMMS_az;?B`);iPzd7&=nK9CgQR~b!Gab-JDERH(>l?%&HkeZ!g0IP{2tPNgA$WaP z!&rh>HAsdv)yKu#jeFu@v~+wLe%HWKigBSx9#>=L None: + super().__init__(parent) + self._filepath = filepath + self._per_cmd_delay = max(0, per_cmd_delay_ms) / 1000.0 + self._per_char_delay = max(0, per_char_delay_ms) / 1000.0 + self._running = True + + def run(self) -> None: + try: + lines = self._filepath.read_text(encoding="utf-8").splitlines() + except Exception as exc: # noqa: BLE001 + self.error.emit(str(exc)) + return + + try: + for raw in lines: + if not self._running: + break + stripped = raw.strip("\r\n") + if not stripped: + continue + self.command_started.emit(stripped) + if self._per_char_delay > 0: + for ch in stripped: + if not self._running: + break + self.send_raw.emit(ch.encode("utf-8", errors="ignore")) + time.sleep(self._per_char_delay) + self.send_raw.emit(b"\r\n") + else: + self.send_raw.emit((stripped + "\r\n").encode("utf-8", errors="ignore")) + if self._per_cmd_delay: + time.sleep(self._per_cmd_delay) + finally: + self.finished_file.emit() + + @Slot() + def stop(self) -> None: + self._running = False diff --git a/ameba_control_panel/services/flash_runner.py b/ameba_control_panel/services/flash_runner.py new file mode 100644 index 0000000..7119c14 --- /dev/null +++ b/ameba_control_panel/services/flash_runner.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import os +import runpy +import subprocess +import sys +import threading +from pathlib import Path +from typing import List, Optional + +from PySide6.QtCore import QObject, QThread, Signal + + +class FlashRunner(QThread): + output = Signal(str) + finished = Signal(int) + + def __init__(self, args: List[str], flash_script: Path, parent: Optional[QObject] = None) -> None: + super().__init__(parent) + self._args = args + self._flash_script = flash_script + + def run(self) -> None: + # Prefer packaged FlashHelper.exe when frozen; fallback to inline or python. + helper_exe = self._helper_executable() + if helper_exe and helper_exe.exists(): + cmd = [str(helper_exe)] + self._args + elif getattr(sys, "frozen", False): + exit_code = self._run_inline() + self.finished.emit(exit_code) + return + else: + cmd = [sys.executable, str(self._flash_script)] + self._args + + try: + with subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + cwd=str(self._flash_script.parent), + ) as proc: + if proc.stdout: + for line in proc.stdout: + stripped = line.rstrip("\n") + cleaned = self._strip_embedded_timestamp(stripped) + if cleaned.strip(): + self.output.emit(cleaned) + if self.isInterruptionRequested(): + proc.terminate() + break + if proc.poll() is None: + proc.wait() + self.finished.emit(proc.returncode or 0) + except FileNotFoundError: + self.output.emit("flash helper not found") + self.finished.emit(1) + + def _run_inline(self) -> int: + # Basic inline execution for packaged builds. + writer = _SignalWriter(self.output.emit) + argv_backup = list(sys.argv) + sys.argv = [str(self._flash_script)] + self._args + cwd_backup = Path.cwd() + stdout_backup = sys.stdout + stderr_backup = sys.stderr + os_exit_backup = os._exit + + def _soft_exit(code=0): + raise SystemExit(code) + + os.chdir(self._flash_script.parent) + sys.stdout = writer # type: ignore[assignment] + sys.stderr = writer # type: ignore[assignment] + os._exit = _soft_exit # type: ignore[assignment] + try: + runpy.run_path(str(self._flash_script), run_name="__main__") + writer.flush() + return 0 + except SystemExit as exc: # flash script might call sys.exit + writer.flush() + return int(exc.code or 0) + finally: + sys.argv = argv_backup + os.chdir(cwd_backup) + sys.stdout = stdout_backup + sys.stderr = stderr_backup + os._exit = os_exit_backup + writer.close() + + def _helper_executable(self) -> Optional[Path]: + if not getattr(sys, "frozen", False): + return None + base_dir = Path(sys.executable).resolve().parent + candidates = [ + base_dir / "FlashHelper.exe", + base_dir / "FlashHelper", + ] + for c in candidates: + if c.exists(): + return c + return None + + @staticmethod + def _strip_embedded_timestamp(line: str) -> str: + # Example: "[2026-02-05 10:39:05.391][I] [COM29] [main]Flash Version..." + if not (line.startswith("[") and "]" in line): + return line + # Remove up to two leading bracketed segments (timestamp, level) + trimmed = line + for _ in range(2): + if trimmed.startswith("[") and "]" in trimmed: + trimmed = trimmed.split("]", 1)[1].lstrip() + # Strip optional "[COMxx]" or "[main]" prefixes that follow + while trimmed.startswith("[") and "]" in trimmed: + prefix = trimmed.split("]", 1)[0] + if prefix.startswith("[COM") or prefix.startswith("[main"): + trimmed = trimmed.split("]", 1)[1].lstrip() + continue + break + return trimmed + + +class _SignalWriter: + """File-like writer that forwards lines to a Qt signal.""" + + def __init__(self, emit_line) -> None: + self._emit_line = emit_line + self._buffer = "" + self._lock = threading.Lock() + self._closed = False + + def write(self, data: str) -> int: # type: ignore[override] + if self._closed: + return len(data) + with self._lock: + self._buffer += data + while "\n" in self._buffer: + line, self._buffer = self._buffer.split("\n", 1) + cleaned = line.rstrip("\r") + if cleaned.strip(): + try: + self._emit_line(cleaned) + except RuntimeError: + # Signal source deleted (thread exiting); drop quietly. + self._closed = True + break + return len(data) + + def flush(self) -> None: # type: ignore[override] + with self._lock: + if self._buffer.strip() and not self._closed: + try: + self._emit_line(self._buffer.rstrip("\r\n")) + except RuntimeError: + self._closed = True + self._buffer = "" + + def close(self) -> None: + with self._lock: + self._closed = True + self._buffer = "" diff --git a/ameba_control_panel/services/history_service.py b/ameba_control_panel/services/history_service.py new file mode 100644 index 0000000..5adf676 --- /dev/null +++ b/ameba_control_panel/services/history_service.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from pathlib import Path +from typing import List + +from ameba_control_panel import config + + +class HistoryService: + def __init__(self, device_key: str) -> None: + self.device_key = device_key + self._path = config.app_data_dir() / device_key / "history.txt" + self._entries: List[str] = [] + + @property + def path(self) -> Path: + return self._path + + def load(self) -> List[str]: + try: + data = self._path.read_text(encoding="utf-8").splitlines() + self._entries = data + except FileNotFoundError: + self._entries = [] + return list(self._entries) + + def save(self, entries: List[str] | None = None) -> None: + entries = entries if entries is not None else self._entries + self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.write_text("\n".join(entries), encoding="utf-8") + self._entries = list(entries) + + def add(self, entry: str) -> None: + entry = entry.rstrip("\n") + if entry and (not self._entries or self._entries[-1] != entry): + self._entries.append(entry) + self.save() + + def delete(self, entry: str) -> None: + try: + self._entries.remove(entry) + self.save() + except ValueError: + pass + + def delete_indices(self, indices: List[int]) -> None: + """Delete entries by list indices (supports duplicates).""" + if not indices: + return + for idx in sorted(set(indices), reverse=True): + if 0 <= idx < len(self._entries): + self._entries.pop(idx) + self.save() + + def entries(self) -> List[str]: + return list(self._entries) diff --git a/ameba_control_panel/services/line_parser.py b/ameba_control_panel/services/line_parser.py new file mode 100644 index 0000000..124d4e8 --- /dev/null +++ b/ameba_control_panel/services/line_parser.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +try: + from ameba_fastlog import decode_line # type: ignore +except Exception: # noqa: BLE001 + def decode_line(data: bytes) -> str: + return data.decode(errors="ignore") diff --git a/ameba_control_panel/services/log_buffer.py b/ameba_control_panel/services/log_buffer.py new file mode 100644 index 0000000..16d1e12 --- /dev/null +++ b/ameba_control_panel/services/log_buffer.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass +from typing import Deque, List, Sequence + +from ameba_control_panel import config +from ameba_control_panel.utils.timeutils import timestamp_ms + + +@dataclass +class LogLine: + text: str + direction: str # "rx", "tx", "info" + timestamp: str + + def as_display(self) -> str: + return f"{self.timestamp} {self.text}" + + +class LogBuffer: + """Keeps a full archive plus a bounded UI tail.""" + + def __init__(self, max_tail: int = config.UI_LOG_TAIL_LINES) -> None: + self._max_tail = max_tail + self._tail: Deque[LogLine] = deque(maxlen=max_tail) + self._archive: List[LogLine] = [] + + def append(self, text: str, direction: str) -> LogLine: + line = LogLine(text=text.rstrip("\n"), direction=direction, timestamp=timestamp_ms()) + self._tail.append(line) + self._archive.append(line) + return line + + def extend(self, items: Sequence[LogLine]) -> None: + for line in items: + self._tail.append(line) + self._archive.append(line) + + def tail(self) -> Deque[LogLine]: + return self._tail + + def archive(self) -> List[LogLine]: + return self._archive + + def clear(self) -> None: + self._tail.clear() + self._archive.clear() + + def as_text(self, full: bool = False) -> str: + source = self._archive if full else self._tail + return "\n".join(line.as_display() for line in source) diff --git a/ameba_control_panel/services/port_service.py b/ameba_control_panel/services/port_service.py new file mode 100644 index 0000000..2697c00 --- /dev/null +++ b/ameba_control_panel/services/port_service.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import List + +from serial.tools import list_ports + + +@dataclass(frozen=True) +class PortInfo: + device: str + description: str + + +def scan_ports(include_synthetic: bool = True) -> List[PortInfo]: + ports = [PortInfo(p.device, p.description) for p in list_ports.comports()] + if include_synthetic: + ports.append(PortInfo("synthetic", "Synthetic loopback source")) + return ports diff --git a/ameba_control_panel/services/search_service.py b/ameba_control_panel/services/search_service.py new file mode 100644 index 0000000..93159c1 --- /dev/null +++ b/ameba_control_panel/services/search_service.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from typing import List + +from PySide6.QtCore import QThread, Signal + + +class SearchWorker(QThread): + finished = Signal(list) + + def __init__(self, lines: List[str], needle: str, case_sensitive: bool) -> None: + super().__init__() + self._lines = lines + self._needle = needle + self._case_sensitive = case_sensitive + + def run(self) -> None: + if not self._needle: + self.finished.emit([]) + return + needle = self._needle if self._case_sensitive else self._needle.lower() + matches: List[int] = [] + for idx, line in enumerate(self._lines): + hay = line if self._case_sensitive else line.lower() + if needle in hay: + matches.append(idx) + self.finished.emit(matches) diff --git a/ameba_control_panel/services/serial_service.py b/ameba_control_panel/services/serial_service.py new file mode 100644 index 0000000..9a82539 --- /dev/null +++ b/ameba_control_panel/services/serial_service.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import queue +import threading +import time +from dataclasses import dataclass +from typing import Optional, Tuple + +import serial +from PySide6.QtCore import QObject, QThread, Signal, Slot + +from ameba_control_panel import config +from ameba_control_panel.services.line_parser import decode_line + + +@dataclass +class SerialState: + port: str + baudrate: int + connected: bool + error: Optional[str] = None + + +class _SerialWorker(QThread): + line_received = Signal(str, str) # text, direction + status_changed = Signal(object) # SerialState + + def __init__(self, port: str, baudrate: int, parent: Optional[QObject] = None) -> None: + super().__init__(parent) + self._port = port + self._baudrate = baudrate + self._write_queue: "queue.SimpleQueue[Tuple[bytes, bool]]" = queue.SimpleQueue() + self._running = threading.Event() + self._serial: Optional[serial.Serial] = None + + def run(self) -> None: + try: + self._serial = serial.Serial(self._port, self._baudrate, timeout=0.05) + self.status_changed.emit(SerialState(self._port, self._baudrate, True)) + except Exception as exc: # noqa: BLE001 + self.status_changed.emit(SerialState(self._port, self._baudrate, False, str(exc))) + return + + self._running.set() + try: + while self._running.is_set(): + # writes + try: + while True: + payload, log_tx = self._write_queue.get_nowait() + self._serial.write(payload) + if log_tx: + try: + text = payload.decode(errors="ignore").rstrip("\r\n") + except Exception: + text = repr(payload) + self.line_received.emit(text, "tx") + # fallthrough only when queue empty + except queue.Empty: + pass + + # reads + line = self._serial.readline() + if line: + try: + text = decode_line(line).strip("\r\n") + except Exception: + text = repr(line) + self.line_received.emit(text, "rx") + finally: + if self._serial: + try: + self._serial.close() + except Exception: + pass + self.status_changed.emit(SerialState(self._port, self._baudrate, False)) + + @Slot(str) + def write_text(self, text: str) -> None: + if not text.endswith("\r\n"): + text = text + "\r\n" + self._write_queue.put((text.encode("utf-8", errors="ignore"), True)) + + @Slot(bytes) + def write_bytes(self, payload: bytes) -> None: + self._write_queue.put((payload, False)) + + @Slot() + def stop(self) -> None: + self._running.clear() + + +class _SyntheticWorker(QThread): + line_received = Signal(str, str) + status_changed = Signal(object) + + def __init__(self, rate_hz: float = 50.0, parent: Optional[QObject] = None) -> None: + super().__init__(parent) + self._rate_hz = rate_hz + self._running = threading.Event() + self._counter = 0 + + def run(self) -> None: + self._running.set() + self.status_changed.emit(SerialState("synthetic", config.DEFAULT_BAUD, True)) + try: + while self._running.is_set(): + self._counter += 1 + self.line_received.emit(f"SYN {self._counter:06d}", "rx") + time.sleep(1.0 / self._rate_hz) + finally: + self.status_changed.emit(SerialState("synthetic", config.DEFAULT_BAUD, False)) + + @Slot(str) + def write_text(self, text: str) -> None: + # Echo back as RX to simulate loopback. + clean = text.rstrip("\r\n") + self.line_received.emit(clean, "tx") + self.line_received.emit(f"ECHO: {clean}", "rx") + + @Slot(bytes) + def write_bytes(self, payload: bytes) -> None: + try: + clean = payload.decode(errors="ignore") + except Exception: + clean = repr(payload) + self.line_received.emit(clean, "tx") + self.line_received.emit(f"ECHO: {clean}", "rx") + + @Slot() + def stop(self) -> None: + self._running.clear() + + +class SerialService(QObject): + line_received = Signal(str, str) + status_changed = Signal(object) + + def __init__(self, parent: Optional[QObject] = None) -> None: + super().__init__(parent) + self._worker: Optional[QThread] = None + + def open(self, port: str, baudrate: int) -> None: + self.close() + if port.lower() == "synthetic": + worker: QThread = _SyntheticWorker() + else: + worker = _SerialWorker(port, baudrate) + worker.line_received.connect(self.line_received) + worker.status_changed.connect(self.status_changed) + self._worker = worker + worker.start() + + def close(self) -> None: + if self._worker: + try: + self._worker.stop() # type: ignore[attr-defined] + self._worker.wait(1000) + except Exception: + pass + self._worker = None + + def write(self, text: str) -> None: + if self._worker: + self._worker.write_text(text) # type: ignore[attr-defined] + + def write_raw(self, data: bytes | str) -> None: + if isinstance(data, str): + data = data.encode("utf-8", errors="ignore") + if self._worker: + self._worker.write_bytes(data) # type: ignore[attr-defined] + + def is_connected(self) -> bool: + return bool(self._worker and self._worker.isRunning()) diff --git a/ameba_control_panel/services/session_store.py b/ameba_control_panel/services/session_store.py new file mode 100644 index 0000000..bdab980 --- /dev/null +++ b/ameba_control_panel/services/session_store.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import json +import tempfile +from pathlib import Path +from typing import Dict, Any + + +SESSION_PATH = Path(tempfile.gettempdir()) / "AmebaControlPanel" / "session.json" + + +class SessionStore: + def __init__(self) -> None: + self._path = SESSION_PATH + self._data: Dict[str, Dict[str, Any]] = {} + self._load() + + def _load(self) -> None: + try: + self._data = json.loads(self._path.read_text(encoding="utf-8")) + except Exception: + self._data = {} + + def save(self) -> None: + self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.write_text(json.dumps(self._data, indent=2), encoding="utf-8") + + def get(self, device_key: str) -> Dict[str, Any]: + return dict(self._data.get(device_key, {})) + + def set(self, device_key: str, payload: Dict[str, Any]) -> None: + self._data[device_key] = payload + self.save() diff --git a/ameba_control_panel/utils/__pycache__/timeutils.cpython-310.pyc b/ameba_control_panel/utils/__pycache__/timeutils.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..693f9e272d8872ce20faca3f129c513c71fc6c92 GIT binary patch literal 454 zcmZusu};G<5Vf5&gf?MeDL;Ueg-Xl_fe=Uxt&rL&i)HE*1;tKfJ3`FBf3#!8A9-c! z!UrHwxl0E+aKqhu*1dPnb((ew)cf~K`3ds_$!-NeE(oFa6o4d>Rz%U9QpBucish`u zK^3Sl4*~DXNbbFcc}K=$l6BYUmQmjmTGvjvvep)8EQKpvsS3c+wAQoo!AGWWjnO`^ z&P-9ZTNvaDgY*G7vY<<{U@mwJmlR{Yu_FTK*l7K;*=(|o!F?a-`pL&&H^WmV#J6;E zJG>rGE{AvgW;j_9(}UNxeH{x`+zSqIoT)257rLkhjVmiVKz)l&=FdJsW}Q%Tu51i) z%b@j{NnwmKW;$pYLCYfF<$Tt_zY5L`u5FVY{&T9$*LwVA+M)z>K0Z^mY^vg%0&CAE KBw-0V2z~(uMQpGD literal 0 HcmV?d00001 diff --git a/ameba_control_panel/utils/__pycache__/timeutils.cpython-314.pyc b/ameba_control_panel/utils/__pycache__/timeutils.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..384e9c225eb4ac72d179656cc73846376c998e61 GIT binary patch literal 742 zcmZuvOKTHR6h3!mGKNl4gb3MGiW4XVAr@NNB56Q;kStQhF}NCf8IwsJm`AvCqqrN; zl`iy0xDo$Ae@2Uo!hkF7A{$Y%=tha>OcK%JfqU+EzI!;&dvmqC0w^DUzVyH0{e_IB zD09H3I|x!Z1e*$M+ut6fh#@fhaO_RA4fy~sL#-o4hiK}(VVU!NZ~OYDtoU02FR_!L-v%}%vNqO z%%D!lH`9eD$0EwqM2ZiTP#?x3bY(&j&Ft>I+m0pTEsnXQhyy-yBQN03wi=s_O~(zr zC+@#b&RF_E;!ue>YK&fFCC(RJc$|we1}@@BDyUMp0EM-Fkb2k8uPM^dC$$tTe%@4o zAO&a1gS8m-9|FM**j|`c;ctG99ev4JRKq3NUX0Pkl`+5}DF>5Zv)Qa(5E{B>Y7{@u zia4nu^(QEPR_=AW_aF7{bS?g%+Y|IH#44#0y_U!^k#0ERVIr;}mAAfuAce0+ str: + # Trim microseconds to milliseconds. + return datetime.now().strftime(config.TIMESTAMP_FMT)[:-3] diff --git a/ameba_control_panel/views/__pycache__/device_tab_view.cpython-310.pyc b/ameba_control_panel/views/__pycache__/device_tab_view.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8cdaa0d43720af2addb9308479f4c00d22da624a GIT binary patch literal 5126 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 MzoQ!sL!Zw64_ldkcmMzZ literal 0 HcmV?d00001 diff --git a/ameba_control_panel/views/__pycache__/device_tab_view.cpython-314.pyc b/ameba_control_panel/views/__pycache__/device_tab_view.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..af66e68d7a1307ccbd866a093b67be519d2b4593 GIT binary patch literal 12572 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 AnE(I) literal 0 HcmV?d00001 diff --git a/ameba_control_panel/views/device_tab_view.py b/ameba_control_panel/views/device_tab_view.py new file mode 100644 index 0000000..cfc2480 --- /dev/null +++ b/ameba_control_panel/views/device_tab_view.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Iterable + +from PySide6.QtCore import Qt +from PySide6.QtGui import QFont, QKeySequence, QShortcut +from PySide6.QtWidgets import ( + QAbstractItemView, + QCheckBox, + QComboBox, + QGridLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QPushButton, + QSpinBox, + QSplitter, + QVBoxLayout, + QWidget, +) + +from ameba_control_panel import config +from ameba_control_panel.views.log_view import LogView + + +class DeviceTabView(QWidget): + def __init__(self, profile, parent=None) -> None: + super().__init__(parent) + self.profile = profile + self._build_ui() + + def _build_ui(self) -> None: + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(8, 8, 8, 8) + main_layout.setSpacing(6) + + self.dut_port_combo = QComboBox() + self.refresh_button = QPushButton("Refresh") + self.dut_baud_combo = QComboBox() + self.dut_baud_combo.setEditable(True) + for b in config.COMMON_BAUD_RATES: + self.dut_baud_combo.addItem(str(b)) + self.dut_baud_combo.setCurrentText(str(config.DEFAULT_BAUD)) + self.connect_button = QPushButton("Connect") + self.connect_button.setCheckable(True) + + self.control_port_combo = QComboBox() + self.control_baud_combo = QComboBox() + self.control_baud_combo.setEditable(True) + for b in config.COMMON_BAUD_RATES: + self.control_baud_combo.addItem(str(b)) + self.control_baud_combo.setCurrentText(str(config.DEFAULT_BAUD)) + + self.normal_btn = QPushButton("Normal Mode") + self.download_btn = QPushButton("Download Mode") + self.reset_btn = QPushButton("Device Reset") + + row1 = QHBoxLayout() + row1.setSpacing(6) + row1.addWidget(QLabel("DUT COM")) + row1.addWidget(self.dut_port_combo) + row1.addWidget(QLabel("Baud")) + row1.addWidget(self.dut_baud_combo) + row1.addSpacing(12) + row1.addWidget(QLabel("Control COM")) + row1.addWidget(self.control_port_combo) + row1.addWidget(QLabel("Baud")) + row1.addWidget(self.control_baud_combo) + row1.addWidget(self.connect_button) + row1.addWidget(self.refresh_button) + row1.addStretch() + row1.addWidget(self.normal_btn) + row1.addWidget(self.download_btn) + row1.addWidget(self.reset_btn) + main_layout.addLayout(row1) + + self.app_path_edit = QLineEdit() + self.app_browse_btn = QPushButton("Browse") + row2 = QHBoxLayout() + row2.setSpacing(6) + row2.addWidget(QLabel("Application")) + row2.addWidget(self.app_path_edit) + row2.addWidget(self.app_browse_btn) + main_layout.addLayout(row2) + + self.boot_path_edit = QLineEdit() + self.boot_browse_btn = QPushButton("Browse") + self.flash_btn = QPushButton("Flash") + row3 = QHBoxLayout() + row3.setSpacing(6) + row3.addWidget(QLabel("Bootloader")) + row3.addWidget(self.boot_path_edit) + row3.addWidget(self.boot_browse_btn) + row3.addWidget(self.flash_btn) + main_layout.addLayout(row3) + + splitter = QSplitter(Qt.Horizontal) + main_layout.addWidget(splitter, 1) + + # Left history pane + left_widget = QWidget() + left_layout = QVBoxLayout(left_widget) + left_layout.setContentsMargins(0, 0, 6, 0) + left_layout.setSpacing(4) + history_label = QLabel("Command History") + self.history_list = QListWidget() + self.history_list.setSelectionMode(QListWidget.ExtendedSelection) + self.history_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.history_list.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel) + self.history_list.setFixedWidth(220) + left_layout.addWidget(history_label) + left_layout.addWidget(self.history_list, 1) + splitter.addWidget(left_widget) + + # Right log and controls + right_widget = QWidget() + right_layout = QVBoxLayout(right_widget) + right_layout.setContentsMargins(0, 0, 0, 0) + right_layout.setSpacing(4) + + toolbar = QHBoxLayout() + toolbar.setSpacing(6) + self.clear_btn = QPushButton("Clear") + self.save_btn = QPushButton("Save") + self.copy_btn = QPushButton("Copy") + toolbar.addStretch() + toolbar.addWidget(self.copy_btn) + toolbar.addWidget(self.save_btn) + toolbar.addWidget(self.clear_btn) + right_layout.addLayout(toolbar) + + self.log_view = LogView(config.UI_LOG_TAIL_LINES) + log_font = QFont("JetBrains Mono") + log_font.setStyleHint(QFont.Monospace) + log_font.setPointSize(10) + self.log_view.setFont(log_font) + right_layout.addWidget(self.log_view, 1) + + find_row = QHBoxLayout() + find_row.setSpacing(6) + self.find_input = QLineEdit() + self.case_checkbox = QCheckBox("Case sensitive") + self.find_btn = QPushButton("Find") + self.next_btn = QPushButton("Next") + self.prev_btn = QPushButton("Prev") + self.find_all_btn = QPushButton("Find All") + find_row.addWidget(QLabel("Find")) + find_row.addWidget(self.find_input, 1) + find_row.addWidget(self.case_checkbox) + find_row.addWidget(self.find_btn) + find_row.addWidget(self.next_btn) + find_row.addWidget(self.prev_btn) + find_row.addWidget(self.find_all_btn) + right_layout.addLayout(find_row) + + cmdlist_row = QHBoxLayout() + cmdlist_row.setSpacing(6) + self.cmdlist_path_edit = QLineEdit() + self.cmdlist_browse_btn = QPushButton("Browse") + self.per_cmd_delay = QSpinBox() + self.per_cmd_delay.setRange(0, 60_000) + self.per_cmd_delay.setSuffix(" ms/cmd") + self.per_cmd_delay.setValue(50) + self.per_char_delay = QSpinBox() + self.per_char_delay.setRange(0, 5_000) + self.per_char_delay.setSuffix(" ms/char") + self.per_char_delay.setValue(0) + self.load_cmdlist_btn = QPushButton("Load") + cmdlist_row.addWidget(QLabel("Cmd File")) + cmdlist_row.addWidget(self.cmdlist_path_edit, 1) + cmdlist_row.addWidget(self.cmdlist_browse_btn) + cmdlist_row.addWidget(self.per_cmd_delay) + cmdlist_row.addWidget(self.per_char_delay) + cmdlist_row.addWidget(self.load_cmdlist_btn) + right_layout.addLayout(cmdlist_row) + + send_row = QHBoxLayout() + send_row.setSpacing(6) + self.command_input = QLineEdit() + self.command_input.setPlaceholderText("Enter command") + self.send_button = QPushButton("Send") + send_row.addWidget(self.command_input, 1) + send_row.addWidget(self.send_button) + right_layout.addLayout(send_row) + + splitter.addWidget(right_widget) + splitter.setStretchFactor(1, 4) + + # Shortcuts + QShortcut(QKeySequence("Ctrl+S"), self, activated=self._copy_all) + + def populate_history(self, entries: Iterable[str]) -> None: + self.history_list.clear() + for entry in entries: + QListWidgetItem(entry, self.history_list) + + def _copy_all(self) -> None: + self.log_view.copy_selected() diff --git a/ameba_control_panel/views/log_view.py b/ameba_control_panel/views/log_view.py new file mode 100644 index 0000000..2ed34a3 --- /dev/null +++ b/ameba_control_panel/views/log_view.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from collections import deque +from typing import Iterable, List + +from PySide6.QtGui import QColor, QFont, QTextCharFormat, QTextCursor, QTextOption +from PySide6.QtWidgets import QTextEdit + +from ameba_control_panel.services.log_buffer import LogLine + + +class LogView(QTextEdit): + """Fast-ish append-only log with selectable text and match highlighting.""" + + def __init__(self, max_items: int, parent=None) -> None: + super().__init__(parent) + self.setReadOnly(True) + self.setWordWrapMode(QTextOption.NoWrap) + self.setLineWrapMode(QTextEdit.NoWrap) + self.setHorizontalScrollBarPolicy(self.horizontalScrollBarPolicy()) + font = QFont("JetBrains Mono") + font.setStyleHint(QFont.Monospace) + font.setPointSize(10) + self.setFont(font) + self._max_items = max_items + self._lines = deque() + self._colors = { + "rx": QColor("#1b5e20"), + "tx": QColor("#0d47a1"), + "info": QColor("#424242"), + } + self._match_rows: List[int] = [] + + def set_colors(self, rx: str, tx: str, info: str) -> None: + self._colors = {"rx": QColor(rx), "tx": QColor(tx), "info": QColor(info)} + + def append_lines(self, lines: Iterable[LogLine]) -> None: + cursor = self.textCursor() + cursor.movePosition(QTextCursor.End) + doc = self.document() + for line in lines: + self._lines.append(line) + fmt = QTextCharFormat() + fmt.setForeground(self._colors.get(line.direction, self._colors["info"])) + cursor.insertText(line.as_display(), fmt) + cursor.insertBlock() + self.setTextCursor(cursor) + # Trim overflow blocks and mirror deque + while len(self._lines) > self._max_items and doc.blockCount() > 0: + self._lines.popleft() + block = doc.firstBlock() + cur = QTextCursor(block) + cur.select(QTextCursor.BlockUnderCursor) + cur.removeSelectedText() + cur.deleteChar() + self._apply_matches() + self.verticalScrollBar().setValue(self.verticalScrollBar().maximum()) + + def clear_log(self) -> None: + self._lines.clear() + self.clear() + self._match_rows = [] + + def set_matches(self, rows: List[int]) -> None: + self._match_rows = rows + self._apply_matches() + + def _apply_matches(self) -> None: + extra = [] + doc = self.document() + for row in self._match_rows: + block = doc.findBlockByNumber(row) + if not block.isValid(): + continue + cursor = QTextCursor(block) + sel = QTextEdit.ExtraSelection() + sel.cursor = cursor + sel.format.setBackground(QColor("#fff59d")) + extra.append(sel) + self.setExtraSelections(extra) + + def copy_selected(self) -> None: + self.copy() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..926b4e1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +pyserial>=3.5 +pylink-square>=0.15.1; platform_system == "Windows" or platform_system == "Linux" +pyDes>=2.0.1 +colorama>=0.4.6 +PySide6>=6.6 +PyInstaller>=6.0 diff --git a/script/__pycache__/auto_run.cpython-310.pyc b/script/__pycache__/auto_run.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..798b6a811d1bd808fe0cbc03470d44bfef7a83a0 GIT binary patch literal 595 zcmYjO&1w`u5U%c?zg;&PBWUpEaX{h=h$xbP2baL!7NP0g?wDC;dxq-XKqQ_f50V`7 z0KQCLJ^2cPXw`_2f~xBJ`m36+rn|kpDUf|v*UfJM@Pmu{rl@#J9!@A&u%d+}XW5Fa zTuK49vYE}Vv!!xs0IIg+Uy^C%i4hyk=OY=6=&Feb^at(`s4A3(|3ynvWEHrN=k(4v!-=grxIQ0 z+6$M8-k|d_m7iTSF=A5TGNkg{1g?x@{3y;U!*W1qSTK9&3PT&nH Pn2D(>CNo*7De3wPiF%N0 literal 0 HcmV?d00001 diff --git a/script/__pycache__/package_exe.cpython-310.pyc b/script/__pycache__/package_exe.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c4763042b3545fbcf846f879a97416ecbbf2309a GIT binary patch literal 1930 zcmZuy&2Aev5GJ{+)oS%)*-0Fy0Rp0zLIqa6v^}Ivn>0;;06}0hhoZV{Xpy$o-d!$8 zs)-izrOwq)5Cm|Jc_m(ZYA-$4K+z5K``ETUgxxp(9e$9 z94%n(BZT!&Kybuyfl55V*wLgQ6XL(YB=FyG62dnqT4gkeKxdD4c=9@&wD~PQ;9Ee& zqQi$LM13vDal*Ix&N-fRMfbSJukq{r2H!moFcPuoUi9Cg<3SJco9FH}^4!nS2fsM) zM%Z>)RY}84E-U>E_;8<@nGLj>ssJ=>$b~k*8)YLU%v@Dd;*PF|({b~md)tRV7<7y| z2G81@RG0^+WQpFOC4q_Gptr$uGzyzKp)ejxRWd^v-CsPZv|&Xd6irjcdCHk#l&Pb6 zDJnx>=4M9COwg%RN=yx{!AGh^%@i|Id1HrCtyJcq0%z)i8IYA@ziUZlF7X$a`0j{U zBDD=GR*F%eLU36*aNvoeevi}u=*t^=s`A==QnTp^I}(|ACC2r__A~G;bG~KZTL(0? zJHOK!Vl{L&+OiUdc_D0gAZ200XTVP}>|Biqh#imuTz3*}q#4!~RnUjDI{x0b<`+_W#^C3YH3%p;D9PMMsB z7=4ebL5NIu@dzXL^XJ-5?*~-jmx#x_vqb7CPXM?s@9{o(Ft`oyy(N4L=;4~)HoYZy z&3CSnjo;*ZroRk$a7JK{oZ+MG)7CP)7%W@PK1Trit!Fk`#kJ|OxsjQgS1zip#|6`~ zX7^EL)Pk-wXqjuBS4Vegv+K9%p)5G0$KP`0!rDCDOr2a)NM)L@bBT}Xck_zAs264? zE1H(H&g-k)&76Bd4|Ao>xap>8C8x3iwrtvIIxPgNnogRoVZDq!$#d20fhuIsy$7W@ zU=Jm@E{lCuiNYD-xi&6E??Lqd=|nH3I$4QY2lKq(&b;Gw(}#N`r2f>TPyx2Rzj&T= z@p-eGrn8)LQKflVOJ(k9p>kF>hD|5dCP-wN$K|F>PZT`E?x zCi$oKMq8`ukF(6>bC%h*5?U5N3L8PyL4nywyn?$l)Xf6Q2^gcg0Eul)XAmHJeFK?s zmX$28zzWz~Q{5;2RgaW{ns#Nes-OUpVic)uXDL)t8ry=))WUWSU6y3dbhlnyVXSNj zm8gByu9hulY7Z7yX8JxXL)^nLj>!<#Ax;7UR1Xg@`ICeMCzt@&gEfM=`HMjk{x-Po zttr>rn$Ny**ijEUU87Dt>=atWbZ*!|Ay(~bYl(@O)1T z;1b{1R>6h;B;=`4=+%@9kMh!MK#jI+0#MJPzXRCHb$A;?WC3ePJBO>f<791@vqgU3 zv%@3iqvLROz>% literal 0 HcmV?d00001 diff --git a/script/auto_run.py b/script/auto_run.py new file mode 100644 index 0000000..c1e284b --- /dev/null +++ b/script/auto_run.py @@ -0,0 +1,20 @@ +import sys +from pathlib import Path + + +def _bootstrap_path() -> None: + root = Path(__file__).resolve().parent.parent + if getattr(sys, "frozen", False): + # In onefile, the bundled libs live alongside the exe + root = Path(sys._MEIPASS) if hasattr(sys, "_MEIPASS") else root # type: ignore[attr-defined] + if str(root) not in sys.path: + sys.path.insert(0, str(root)) + + +_bootstrap_path() + +from ameba_control_panel.app import main # noqa: E402 + + +if __name__ == "__main__": + main() diff --git a/script/package_exe.py b/script/package_exe.py new file mode 100644 index 0000000..7865a9f --- /dev/null +++ b/script/package_exe.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + + +def _add_data_arg(src: Path, dest: str) -> str: + """ + Format a PyInstaller --add-data argument with the correct path separator + for the current platform. + """ + sep = ";" if os.name == "nt" else ":" + return f"{src}{sep}{dest}" + + +def build(onefile: bool) -> None: + root = Path(__file__).resolve().parent.parent + entry = root / "script" / "auto_run.py" + flash_dir = root / "Flash" + + if not entry.exists(): + sys.exit(f"Entry script missing: {entry}") + if not flash_dir.exists(): + sys.exit(f"Flash folder missing: {flash_dir}") + + # Keep PyInstaller searches predictable. + os.chdir(root) + + try: + import PyInstaller.__main__ as pyinstaller + except ImportError: + sys.exit("PyInstaller is not installed. Run `python -m pip install PyInstaller` first.") + + args = [ + "--noconfirm", + "--clean", + "--onefile" if onefile else "--onedir", + "--name=AmebaControlPanel", + f"--distpath={root / 'dist'}", + f"--workpath={root / 'build'}", + "--paths", + str(root), + "--collect-all", + "PySide6", + "--hidden-import=serial", + "--hidden-import=serial.tools.list_ports", + "--hidden-import=pyDes", + "--hidden-import=colorama", + "--add-data", + _add_data_arg(flash_dir, "Flash"), + str(entry), + ] + + pyinstaller.run(args) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Build Ameba Control Panel executable with PyInstaller") + parser.add_argument( + "--onedir", + action="store_true", + help="Create an onedir bundle instead of a single-file exe", + ) + build(onefile=not parser.parse_args().onedir)