first commit

This commit is contained in:
wongyiekheng 2026-02-06 09:52:23 +08:00
commit ef919b9053
92 changed files with 6332 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
build
dist
*.spec

2
Flash/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/devices/
*.pyc

7
Flash/Reburn.cfg Normal file
View File

@ -0,0 +1,7 @@
dtr=0
rts=1
delay=200
dtr=1
rts=0
delay=100
dtr=0

View File

@ -0,0 +1,6 @@
dtr=1
rts=1
delay=50
rts=0
delay=20
dtr=0

5
Flash/Reset.cfg Normal file
View File

@ -0,0 +1,5 @@
dtr=0
rts=1
delay=200
rts=0
dtr=0

View File

@ -0,0 +1,4 @@
dtr=0
rts=1
delay=50
rts=0

26
Flash/Settings.json Normal file
View File

@ -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"
}

3
Flash/base/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from .download_handler import *
from .rtk_logging import *
from .rt_settings import *

View File

@ -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

42
Flash/base/device_info.py Normal file
View File

@ -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

View File

@ -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

File diff suppressed because it is too large Load Diff

20
Flash/base/efuse_data.py Normal file
View File

@ -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

41
Flash/base/errno.py Normal file
View File

@ -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

145
Flash/base/flash_utils.py Normal file
View File

@ -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))

View File

@ -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

28
Flash/base/image_info.py Normal file
View File

@ -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

48
Flash/base/json_utils.py Normal file
View File

@ -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)

24
Flash/base/memory_info.py Normal file
View File

@ -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

15
Flash/base/next_op.py Normal file
View File

@ -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

371
Flash/base/remote_serial.py Normal file
View File

@ -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()

322
Flash/base/rom_handler.py Normal file
View File

@ -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

72
Flash/base/rt_settings.py Normal file
View File

@ -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

View File

@ -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

49
Flash/base/rtk_logging.py Normal file
View File

@ -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

21
Flash/base/rtk_utils.py Normal file
View File

@ -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

View File

@ -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

View File

@ -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

9
Flash/base/sys_utils.py Normal file
View File

@ -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)

16
Flash/base/version.py Normal file
View File

@ -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}"

25
Flash/changelog.txt Normal file
View File

@ -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

474
Flash/flash.py Normal file
View File

@ -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:])

522
Flash/flash_amebapro3.py Normal file
View File

@ -0,0 +1,522 @@
#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Wrapper for flashing AmebaPro3 via a USBCDC bridge (e.g., AmebaSmart).
It forces the flasher to treat the port as a plain UART so the ROM XMODEM
protocol uses 1024byte STX frames instead of the 2048byte 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 <idx> <0|1>" and "RESET <idx> <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()

82
Flash/pro3_gpio.py Normal file
View File

@ -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()

7
Flash/version_info.py Normal file
View File

@ -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"

22
README.md Normal file
View File

@ -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.

99
agents.md Normal file
View File

@ -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/<device>/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., 3060 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/<device>/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 <path> --app <path> -t <DUT COM port> -p <Control Device COM port> -B <baudrate>` (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 <DUT COM port> -p <Control Device COM port> -B <baud>` (baudrate matches DUT COM port)
- Normal Mode: `--download-mode 0 -t <DUT COM port> -p <Control Device COM port> -B <baud>` (baudrate matches DUT COM port)
- Device Reset: `--reset -t <DUT COM port> -p <Control Device COM port -B <baud>` (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.

View File

@ -0,0 +1,3 @@
"""Ameba Control Panel package."""
__all__ = ["app"]

View File

@ -0,0 +1,4 @@
from ameba_control_panel.app import main
if __name__ == "__main__":
main()

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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()

View File

@ -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"

View File

@ -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()

View File

@ -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

View File

@ -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 = ""

View File

@ -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)

View File

@ -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")

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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())

View File

@ -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()

View File

@ -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]

View File

@ -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()

View File

@ -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()

6
requirements.txt Normal file
View File

@ -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

Binary file not shown.

Binary file not shown.

20
script/auto_run.py Normal file
View File

@ -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()

66
script/package_exe.py Normal file
View File

@ -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)