commit ef919b905391ee82ad76d1565f475241aab5bdf3 Author: wongyiekheng Date: Fri Feb 6 09:52:23 2026 +0800 first commit 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 0000000..29c2bde Binary files /dev/null and b/ameba_control_panel/__pycache__/__init__.cpython-310.pyc differ diff --git a/ameba_control_panel/__pycache__/__init__.cpython-314.pyc b/ameba_control_panel/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..83b06af Binary files /dev/null and b/ameba_control_panel/__pycache__/__init__.cpython-314.pyc differ diff --git a/ameba_control_panel/__pycache__/__main__.cpython-310.pyc b/ameba_control_panel/__pycache__/__main__.cpython-310.pyc new file mode 100644 index 0000000..df7346b Binary files /dev/null and b/ameba_control_panel/__pycache__/__main__.cpython-310.pyc differ diff --git a/ameba_control_panel/__pycache__/app.cpython-310.pyc b/ameba_control_panel/__pycache__/app.cpython-310.pyc new file mode 100644 index 0000000..baa77c9 Binary files /dev/null and b/ameba_control_panel/__pycache__/app.cpython-310.pyc differ diff --git a/ameba_control_panel/__pycache__/app.cpython-314.pyc b/ameba_control_panel/__pycache__/app.cpython-314.pyc new file mode 100644 index 0000000..a07d941 Binary files /dev/null and b/ameba_control_panel/__pycache__/app.cpython-314.pyc differ 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 0000000..2334ab7 Binary files /dev/null and b/ameba_control_panel/__pycache__/config.cpython-310.pyc differ diff --git a/ameba_control_panel/__pycache__/config.cpython-314.pyc b/ameba_control_panel/__pycache__/config.cpython-314.pyc new file mode 100644 index 0000000..9811541 Binary files /dev/null and b/ameba_control_panel/__pycache__/config.cpython-314.pyc differ diff --git a/ameba_control_panel/app.py b/ameba_control_panel/app.py new file mode 100644 index 0000000..da61018 --- /dev/null +++ b/ameba_control_panel/app.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import sys + +from PySide6.QtGui import QIcon, QPalette, QColor +from PySide6.QtWidgets import QApplication, QMainWindow, QTabWidget + +from ameba_control_panel import config +from ameba_control_panel.controllers.device_tab_controller import DeviceTabController + + +class MainWindow(QMainWindow): + def __init__(self) -> 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 0000000..f149c2e Binary files /dev/null and b/ameba_control_panel/controllers/__pycache__/device_tab_controller.cpython-310.pyc differ diff --git a/ameba_control_panel/controllers/__pycache__/device_tab_controller.cpython-314.pyc b/ameba_control_panel/controllers/__pycache__/device_tab_controller.cpython-314.pyc new file mode 100644 index 0000000..d3236a7 Binary files /dev/null and b/ameba_control_panel/controllers/__pycache__/device_tab_controller.cpython-314.pyc differ diff --git a/ameba_control_panel/controllers/device_tab_controller.py b/ameba_control_panel/controllers/device_tab_controller.py new file mode 100644 index 0000000..ee7f030 --- /dev/null +++ b/ameba_control_panel/controllers/device_tab_controller.py @@ -0,0 +1,514 @@ +from __future__ import annotations + +from collections import deque +from pathlib import Path +from typing import Deque, List, Optional, Tuple + +from PySide6.QtCore import QObject, QTimer, QEvent, Qt, Slot, QCoreApplication +from PySide6.QtGui import QAction, QKeySequence, QShortcut +from PySide6.QtWidgets import QFileDialog, QMessageBox + +from ameba_control_panel import config +from ameba_control_panel.services.command_player import CommandPlayer +from ameba_control_panel.services.flash_runner import FlashRunner +from ameba_control_panel.services.history_service import HistoryService +from ameba_control_panel.services.log_buffer import LogBuffer, LogLine +from ameba_control_panel.services.port_service import PortInfo, scan_ports +from ameba_control_panel.services.search_service import SearchWorker +from ameba_control_panel.services.serial_service import SerialService, SerialState +from ameba_control_panel.services.session_store import SessionStore +from ameba_control_panel.views.device_tab_view import DeviceTabView + + +class DeviceTabController(QObject): + def __init__(self, profile, parent=None) -> 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 0000000..fa107c3 Binary files /dev/null and b/ameba_control_panel/services/__pycache__/command_player.cpython-310.pyc differ 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 0000000..fc0fd8e Binary files /dev/null and b/ameba_control_panel/services/__pycache__/command_player.cpython-314.pyc differ diff --git a/ameba_control_panel/services/__pycache__/flash_runner.cpython-310.pyc b/ameba_control_panel/services/__pycache__/flash_runner.cpython-310.pyc new file mode 100644 index 0000000..2e7dfce Binary files /dev/null and b/ameba_control_panel/services/__pycache__/flash_runner.cpython-310.pyc differ diff --git a/ameba_control_panel/services/__pycache__/flash_runner.cpython-314.pyc b/ameba_control_panel/services/__pycache__/flash_runner.cpython-314.pyc new file mode 100644 index 0000000..744368c Binary files /dev/null and b/ameba_control_panel/services/__pycache__/flash_runner.cpython-314.pyc differ diff --git a/ameba_control_panel/services/__pycache__/history_service.cpython-310.pyc b/ameba_control_panel/services/__pycache__/history_service.cpython-310.pyc new file mode 100644 index 0000000..bdfa249 Binary files /dev/null and b/ameba_control_panel/services/__pycache__/history_service.cpython-310.pyc differ diff --git a/ameba_control_panel/services/__pycache__/history_service.cpython-314.pyc b/ameba_control_panel/services/__pycache__/history_service.cpython-314.pyc new file mode 100644 index 0000000..e48f8c9 Binary files /dev/null and b/ameba_control_panel/services/__pycache__/history_service.cpython-314.pyc differ 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 0000000..dfae93e Binary files /dev/null and b/ameba_control_panel/services/__pycache__/line_parser.cpython-310.pyc differ diff --git a/ameba_control_panel/services/__pycache__/line_parser.cpython-314.pyc b/ameba_control_panel/services/__pycache__/line_parser.cpython-314.pyc new file mode 100644 index 0000000..1658645 Binary files /dev/null and b/ameba_control_panel/services/__pycache__/line_parser.cpython-314.pyc differ 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 0000000..463d610 Binary files /dev/null and b/ameba_control_panel/services/__pycache__/log_buffer.cpython-310.pyc differ diff --git a/ameba_control_panel/services/__pycache__/log_buffer.cpython-314.pyc b/ameba_control_panel/services/__pycache__/log_buffer.cpython-314.pyc new file mode 100644 index 0000000..a070fe6 Binary files /dev/null and b/ameba_control_panel/services/__pycache__/log_buffer.cpython-314.pyc differ 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 0000000..ec081fa Binary files /dev/null and b/ameba_control_panel/services/__pycache__/port_service.cpython-310.pyc differ 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 0000000..9aed91a Binary files /dev/null and b/ameba_control_panel/services/__pycache__/port_service.cpython-314.pyc differ diff --git a/ameba_control_panel/services/__pycache__/search_service.cpython-310.pyc b/ameba_control_panel/services/__pycache__/search_service.cpython-310.pyc new file mode 100644 index 0000000..57baa51 Binary files /dev/null and b/ameba_control_panel/services/__pycache__/search_service.cpython-310.pyc differ 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 0000000..95d5cfc Binary files /dev/null and b/ameba_control_panel/services/__pycache__/search_service.cpython-314.pyc differ diff --git a/ameba_control_panel/services/__pycache__/serial_service.cpython-310.pyc b/ameba_control_panel/services/__pycache__/serial_service.cpython-310.pyc new file mode 100644 index 0000000..9144b4e Binary files /dev/null and b/ameba_control_panel/services/__pycache__/serial_service.cpython-310.pyc differ diff --git a/ameba_control_panel/services/__pycache__/serial_service.cpython-314.pyc b/ameba_control_panel/services/__pycache__/serial_service.cpython-314.pyc new file mode 100644 index 0000000..dd5c78c Binary files /dev/null and b/ameba_control_panel/services/__pycache__/serial_service.cpython-314.pyc differ 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 0000000..b37a814 Binary files /dev/null and b/ameba_control_panel/services/__pycache__/session_store.cpython-310.pyc differ 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 0000000..34ef83d Binary files /dev/null and b/ameba_control_panel/services/__pycache__/session_store.cpython-314.pyc differ diff --git a/ameba_control_panel/services/command_player.py b/ameba_control_panel/services/command_player.py new file mode 100644 index 0000000..b589c60 --- /dev/null +++ b/ameba_control_panel/services/command_player.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import time +from pathlib import Path +from typing import Optional + +from PySide6.QtCore import QObject, QThread, Signal, Slot + + +class CommandPlayer(QThread): + send_raw = Signal(bytes) + command_started = Signal(str) + finished_file = Signal() + error = Signal(str) + + def __init__( + self, + filepath: Path, + per_cmd_delay_ms: int, + per_char_delay_ms: int, + parent: Optional[QObject] = None, + ) -> 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 0000000..693f9e2 Binary files /dev/null and b/ameba_control_panel/utils/__pycache__/timeutils.cpython-310.pyc differ 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 0000000..384e9c2 Binary files /dev/null and b/ameba_control_panel/utils/__pycache__/timeutils.cpython-314.pyc differ diff --git a/ameba_control_panel/utils/timeutils.py b/ameba_control_panel/utils/timeutils.py new file mode 100644 index 0000000..7ac4137 --- /dev/null +++ b/ameba_control_panel/utils/timeutils.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from datetime import datetime + +from ameba_control_panel import config + + +def timestamp_ms() -> 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 0000000..8cdaa0d Binary files /dev/null and b/ameba_control_panel/views/__pycache__/device_tab_view.cpython-310.pyc differ 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 0000000..af66e68 Binary files /dev/null and b/ameba_control_panel/views/__pycache__/device_tab_view.cpython-314.pyc differ diff --git a/ameba_control_panel/views/__pycache__/log_view.cpython-310.pyc b/ameba_control_panel/views/__pycache__/log_view.cpython-310.pyc new file mode 100644 index 0000000..a406f6c Binary files /dev/null and b/ameba_control_panel/views/__pycache__/log_view.cpython-310.pyc differ diff --git a/ameba_control_panel/views/__pycache__/log_view.cpython-314.pyc b/ameba_control_panel/views/__pycache__/log_view.cpython-314.pyc new file mode 100644 index 0000000..540b2a2 Binary files /dev/null and b/ameba_control_panel/views/__pycache__/log_view.cpython-314.pyc differ 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 0000000..798b6a8 Binary files /dev/null and b/script/__pycache__/auto_run.cpython-310.pyc differ diff --git a/script/__pycache__/package_exe.cpython-310.pyc b/script/__pycache__/package_exe.cpython-310.pyc new file mode 100644 index 0000000..c476304 Binary files /dev/null and b/script/__pycache__/package_exe.cpython-310.pyc differ 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)