first commit
This commit is contained in:
commit
ef919b9053
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
build
|
||||||
|
dist
|
||||||
|
*.spec
|
||||||
2
Flash/.gitignore
vendored
Normal file
2
Flash/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/devices/
|
||||||
|
*.pyc
|
||||||
7
Flash/Reburn.cfg
Normal file
7
Flash/Reburn.cfg
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
dtr=0
|
||||||
|
rts=1
|
||||||
|
delay=200
|
||||||
|
dtr=1
|
||||||
|
rts=0
|
||||||
|
delay=100
|
||||||
|
dtr=0
|
||||||
6
Flash/Reburn_amebapro3_auto.cfg
Normal file
6
Flash/Reburn_amebapro3_auto.cfg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
dtr=1
|
||||||
|
rts=1
|
||||||
|
delay=50
|
||||||
|
rts=0
|
||||||
|
delay=20
|
||||||
|
dtr=0
|
||||||
5
Flash/Reset.cfg
Normal file
5
Flash/Reset.cfg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
dtr=0
|
||||||
|
rts=1
|
||||||
|
delay=200
|
||||||
|
rts=0
|
||||||
|
dtr=0
|
||||||
4
Flash/Reset_amebapro3_auto.cfg
Normal file
4
Flash/Reset_amebapro3_auto.cfg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
dtr=0
|
||||||
|
rts=1
|
||||||
|
delay=50
|
||||||
|
rts=0
|
||||||
26
Flash/Settings.json
Normal file
26
Flash/Settings.json
Normal 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
3
Flash/base/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from .download_handler import *
|
||||||
|
from .rtk_logging import *
|
||||||
|
from .rt_settings import *
|
||||||
30
Flash/base/config_utils.py
Normal file
30
Flash/base/config_utils.py
Normal 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
42
Flash/base/device_info.py
Normal 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
|
||||||
79
Flash/base/device_profile.py
Normal file
79
Flash/base/device_profile.py
Normal 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
|
||||||
1415
Flash/base/download_handler.py
Normal file
1415
Flash/base/download_handler.py
Normal file
File diff suppressed because it is too large
Load Diff
20
Flash/base/efuse_data.py
Normal file
20
Flash/base/efuse_data.py
Normal 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
41
Flash/base/errno.py
Normal 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
145
Flash/base/flash_utils.py
Normal 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))
|
||||||
646
Flash/base/floader_handler.py
Normal file
646
Flash/base/floader_handler.py
Normal 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
28
Flash/base/image_info.py
Normal 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
48
Flash/base/json_utils.py
Normal 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
24
Flash/base/memory_info.py
Normal 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
15
Flash/base/next_op.py
Normal 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
371
Flash/base/remote_serial.py
Normal 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
322
Flash/base/rom_handler.py
Normal 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
72
Flash/base/rt_settings.py
Normal 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
|
||||||
12
Flash/base/rtk_flash_type.py
Normal file
12
Flash/base/rtk_flash_type.py
Normal 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
49
Flash/base/rtk_logging.py
Normal 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
21
Flash/base/rtk_utils.py
Normal 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
|
||||||
24
Flash/base/sense_status.py
Normal file
24
Flash/base/sense_status.py
Normal 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
|
||||||
12
Flash/base/spic_addr_mode.py
Normal file
12
Flash/base/spic_addr_mode.py
Normal 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
9
Flash/base/sys_utils.py
Normal 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
16
Flash/base/version.py
Normal 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
25
Flash/changelog.txt
Normal 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
474
Flash/flash.py
Normal 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
522
Flash/flash_amebapro3.py
Normal file
@ -0,0 +1,522 @@
|
|||||||
|
#! /usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Wrapper for flashing AmebaPro3 via a USB‑CDC bridge (e.g., AmebaSmart).
|
||||||
|
|
||||||
|
It forces the flasher to treat the port as a plain UART so the ROM XMODEM
|
||||||
|
protocol uses 1024‑byte STX frames instead of the 2048‑byte USB variant.
|
||||||
|
Command line arguments are identical to flash.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
import pathlib
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
from typing import Callable
|
||||||
|
import serial
|
||||||
|
from serial import SerialException
|
||||||
|
|
||||||
|
# Ensure local imports resolve to the sibling flash package files.
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
if BASE_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, BASE_DIR)
|
||||||
|
|
||||||
|
# Patch Ameba hooks to force UART mode and to prefer floader handshake first.
|
||||||
|
import base.download_handler as download_handler # type: ignore
|
||||||
|
from base.json_utils import JsonUtils # type: ignore
|
||||||
|
from base.rt_settings import RtSettings # type: ignore
|
||||||
|
from base.device_profile import RtkDeviceProfile # type: ignore
|
||||||
|
from base.rtk_logging import create_logger # type: ignore
|
||||||
|
from base.download_handler import ErrType # type: ignore
|
||||||
|
from base.rtk_utils import RtkUtils # type: ignore
|
||||||
|
from base import rom_handler as base_rom_handler # type: ignore
|
||||||
|
|
||||||
|
# Force UART path (so STX = 1024B).
|
||||||
|
download_handler.Ameba.is_realtek_usb = lambda self: False
|
||||||
|
|
||||||
|
|
||||||
|
def _check_download_mode_floader_first(self):
|
||||||
|
"""Prefer floader handshake, then fall back to ROM + reset sequence."""
|
||||||
|
ErrType = download_handler.ErrType
|
||||||
|
CmdEsc = download_handler.CmdEsc
|
||||||
|
CmdSetBackupRegister = download_handler.CmdSetBackupRegister
|
||||||
|
CmdResetIntoDownloadMode = download_handler.CmdResetIntoDownloadMode
|
||||||
|
|
||||||
|
ret = ErrType.SYS_IO
|
||||||
|
is_floader = False
|
||||||
|
boot_delay = self.setting.usb_rom_boot_delay_in_second if self.profile_info.support_usb_download else self.setting.rom_boot_delay_in_second
|
||||||
|
|
||||||
|
self.logger.debug(f"Check download mode with baudrate {self.serial_port.baudrate}")
|
||||||
|
retry = 0
|
||||||
|
while retry < 3:
|
||||||
|
retry += 1
|
||||||
|
try:
|
||||||
|
# 1) See if flashloader is already running (common after stage-1).
|
||||||
|
self.logger.debug(f"Check whether in floader with baudrate {self.baudrate}")
|
||||||
|
ret, status = self.floader_handler.sense(self.setting.sync_response_timeout_in_second)
|
||||||
|
if ret == ErrType.OK:
|
||||||
|
is_floader = True
|
||||||
|
self.logger.debug("Floader handshake ok")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
self.logger.debug(f"Floader handshake fail: {ret}")
|
||||||
|
|
||||||
|
# 2) ROM handshake.
|
||||||
|
self.logger.debug(f"Check whether in rom download mode")
|
||||||
|
ret = self.rom_handler.handshake()
|
||||||
|
if ret == ErrType.OK:
|
||||||
|
self.logger.debug(f"Handshake ok, device in rom download mode")
|
||||||
|
break
|
||||||
|
|
||||||
|
# 3) Try to reset into ROM download mode (UART path only).
|
||||||
|
if not self.is_usb:
|
||||||
|
self.logger.debug(
|
||||||
|
f'Assume in application or ROM normal mode with baudrate {self.profile_info.log_baudrate}')
|
||||||
|
self.switch_baudrate(self.profile_info.log_baudrate, self.setting.baudrate_switch_delay_in_second)
|
||||||
|
|
||||||
|
self.logger.debug("Try to reset device...")
|
||||||
|
|
||||||
|
self.serial_port.flushOutput()
|
||||||
|
self.write_bytes(CmdEsc)
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
if self.profile_info.is_amebad():
|
||||||
|
self.serial_port.flushOutput()
|
||||||
|
self.write_bytes(CmdSetBackupRegister)
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
self.serial_port.flushOutput()
|
||||||
|
self.write_bytes(CmdResetIntoDownloadMode)
|
||||||
|
|
||||||
|
self.switch_baudrate(self.profile_info.handshake_baudrate, boot_delay, True)
|
||||||
|
|
||||||
|
self.logger.debug(
|
||||||
|
f'Check whether reset in ROM download mode with baudrate {self.profile_info.handshake_baudrate}')
|
||||||
|
|
||||||
|
ret = self.rom_handler.handshake()
|
||||||
|
if ret == ErrType.OK:
|
||||||
|
self.logger.debug("Handshake ok, device in ROM download mode")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
self.logger.debug("Handshake fail, cannot enter UART download mode")
|
||||||
|
|
||||||
|
self.switch_baudrate(self.baudrate, self.setting.baudrate_switch_delay_in_second, True)
|
||||||
|
except Exception as err:
|
||||||
|
self.logger.error(f"Check download mode exception: {err}")
|
||||||
|
|
||||||
|
return ret, is_floader
|
||||||
|
|
||||||
|
|
||||||
|
download_handler.Ameba.check_download_mode = _check_download_mode_floader_first
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Resource path patches (packaged exe support)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_orig_get_root = RtkUtils.get_executable_root_path
|
||||||
|
|
||||||
|
|
||||||
|
def _patched_get_root():
|
||||||
|
"""
|
||||||
|
Ensure flash assets resolve in both source and PyInstaller builds.
|
||||||
|
Priority:
|
||||||
|
1) _MEIPASS/Flash (onefile extraction)
|
||||||
|
2) dir of the running executable + /Flash
|
||||||
|
3) fallback to original logic
|
||||||
|
"""
|
||||||
|
# PyInstaller onefile: files are extracted to _MEIPASS
|
||||||
|
mei = getattr(sys, "_MEIPASS", None)
|
||||||
|
if mei:
|
||||||
|
flash_dir = os.path.join(mei, "Flash")
|
||||||
|
if os.path.isdir(flash_dir):
|
||||||
|
return flash_dir
|
||||||
|
|
||||||
|
# PyInstaller onedir: Flash sits next to the exe
|
||||||
|
exe_dir = os.path.dirname(os.path.abspath(sys.executable))
|
||||||
|
flash_dir = os.path.join(exe_dir, "Flash")
|
||||||
|
if os.path.isdir(flash_dir):
|
||||||
|
return flash_dir
|
||||||
|
|
||||||
|
return _orig_get_root()
|
||||||
|
|
||||||
|
|
||||||
|
RtkUtils.get_executable_root_path = staticmethod(_patched_get_root)
|
||||||
|
|
||||||
|
|
||||||
|
def _patched_get_floader_path(self):
|
||||||
|
"""
|
||||||
|
Wrapper around RomHandler.get_floader_path to retry common packaged locations
|
||||||
|
without touching base code. Uses patched root (above) first, then falls back
|
||||||
|
to the original implementation for source runs.
|
||||||
|
"""
|
||||||
|
# Prefer patched root (covers _MEIPASS/Flash and exe_dir/Flash)
|
||||||
|
root = RtkUtils.get_executable_root_path()
|
||||||
|
if self.profile.floader:
|
||||||
|
candidate = os.path.realpath(
|
||||||
|
os.path.join(root, base_rom_handler.FloaderDictionary, self.profile.floader)
|
||||||
|
)
|
||||||
|
if os.path.exists(candidate):
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
# Fallback: original behavior
|
||||||
|
return _orig_rom_get_floader_path(self)
|
||||||
|
|
||||||
|
|
||||||
|
_orig_rom_get_floader_path = base_rom_handler.RomHandler.get_floader_path
|
||||||
|
base_rom_handler.RomHandler.get_floader_path = _patched_get_floader_path
|
||||||
|
|
||||||
|
# Reuse the original entry point and argument parsing.
|
||||||
|
import flash # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_profile_path(path: str) -> str:
|
||||||
|
"""Return an absolute profile path. If relative and not found from CWD, try relative to BASE_DIR."""
|
||||||
|
p = pathlib.Path(path)
|
||||||
|
if p.is_absolute():
|
||||||
|
return str(p)
|
||||||
|
if p.exists():
|
||||||
|
return str(p.resolve())
|
||||||
|
alt = pathlib.Path(BASE_DIR) / path
|
||||||
|
if alt.exists():
|
||||||
|
return str(alt.resolve())
|
||||||
|
return str(p) # let flash.py error out visibly
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
default_profile = str((pathlib.Path(BASE_DIR) / "Devices/Profiles/AmebaPro3_FreeRTOS_NOR.rdev").resolve())
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="AmebaPro3 flasher wrapper (UART mode via USB-CDC bridge).",
|
||||||
|
add_help=True,
|
||||||
|
)
|
||||||
|
parser.add_argument("-p", "--port", help="CDC device of AmebaSmart bridge for BOOT/RESET commands (e.g. /dev/ttyACM0 or COM29)")
|
||||||
|
parser.add_argument("-P", "--bridge-port", dest="bridge_port", help="Alias for --port (bridge CDC device), if both set, --bridge-port wins")
|
||||||
|
parser.add_argument("-t", "--target", dest="target_port", help="Serial/CDC device to the target SoC for flashing (passed to flash.py)")
|
||||||
|
parser.add_argument("-B", "--baudrate", type=int, default=1500000, help="Baudrate (default 1500000)")
|
||||||
|
parser.add_argument("-m", "--memory-type", default="nor", choices=["nor", "nand", "ram"], help="Memory type")
|
||||||
|
parser.add_argument("--profile", default=default_profile, help="Device profile (.rdev)")
|
||||||
|
parser.add_argument("-b", "--boot", help="Boot image path (e.g. amebapro3_boot.bin)")
|
||||||
|
parser.add_argument("-a", "--app", help="App image path (e.g. amebapro3_app.bin)")
|
||||||
|
parser.add_argument("-i", "--image-dir", dest="image_dir", help="Override image directory (skips -a/-b)")
|
||||||
|
parser.add_argument("-bin", dest="single_image", help="Single image path (custom address mode)")
|
||||||
|
parser.add_argument("-s", "--start", dest="start_addr", help="Start address (hex) for single image")
|
||||||
|
parser.add_argument("-e", "--end", dest="end_addr", help="End address (hex) for single image")
|
||||||
|
parser.add_argument("--debug", action="store_true", help="Set log-level to debug")
|
||||||
|
parser.add_argument("--auto-dtr", action="store_true",
|
||||||
|
help="(Deprecated) legacy auto DTR/RTS toggle; ignored when using GPIO helper.")
|
||||||
|
parser.add_argument("-r", "--reset", action="store_true",
|
||||||
|
help="Force reset to normal mode after flashing (post-process RESET). Default unless -dl 1.")
|
||||||
|
parser.add_argument("-dl", "--download-mode", type=int, choices=[0, 1],
|
||||||
|
help="1: stay in download mode (no reset). 0: exit to normal mode (reset).")
|
||||||
|
parser.add_argument("extra", nargs=argparse.REMAINDER, help="Additional args passed through to flash.py")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
def uart_index_from_port(port: str) -> int | None:
|
||||||
|
# Extract trailing digits; COM29 -> 29, /dev/ttyACM0 -> 0, else None
|
||||||
|
import re
|
||||||
|
m = re.search(r'(\d+)$', port or "")
|
||||||
|
return int(m.group(1)) if m else None
|
||||||
|
|
||||||
|
bridge_port = args.bridge_port or args.port
|
||||||
|
target_port = args.target_port
|
||||||
|
|
||||||
|
# Decide image directory vs per-image flashing.
|
||||||
|
image_dir = args.image_dir
|
||||||
|
if args.single_image:
|
||||||
|
boot_path = pathlib.Path(args.single_image).expanduser()
|
||||||
|
if not boot_path.exists():
|
||||||
|
print("Custom image not found", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
app_path = None
|
||||||
|
image_dir = None
|
||||||
|
if not (args.start_addr and args.end_addr):
|
||||||
|
print("Custom image requires --start and --end addresses (hex)", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
start_addr = args.start_addr
|
||||||
|
end_addr = args.end_addr
|
||||||
|
elif args.boot or args.app:
|
||||||
|
if not (args.boot and args.app):
|
||||||
|
print("Both --boot and --app must be provided together", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
boot_path = pathlib.Path(args.boot).expanduser()
|
||||||
|
app_path = pathlib.Path(args.app).expanduser()
|
||||||
|
if not boot_path.exists() or not app_path.exists():
|
||||||
|
print("Boot or app image not found", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
# per-image flashing, so don't pass image-dir
|
||||||
|
image_dir = None
|
||||||
|
start_addr = None
|
||||||
|
end_addr = None
|
||||||
|
else:
|
||||||
|
boot_path = None
|
||||||
|
app_path = None
|
||||||
|
start_addr = None
|
||||||
|
end_addr = None
|
||||||
|
|
||||||
|
# If the user supplied an image directory (-i) but no explicit boot/app,
|
||||||
|
# map the standard images inside that directory to the fixed boot/app
|
||||||
|
# address ranges expected by AmebaPro3 (boot: 0x08000000-0x08040000,
|
||||||
|
# app: 0x08040000-0x08440000). This avoids relying on the profile's
|
||||||
|
# partition table size checks, which can reject larger binaries.
|
||||||
|
if image_dir and not (boot_path or app_path or args.single_image):
|
||||||
|
candidate_boot = pathlib.Path(image_dir) / "amebapro3_boot.bin"
|
||||||
|
candidate_app = pathlib.Path(image_dir) / "amebapro3_app.bin"
|
||||||
|
if candidate_boot.exists() and candidate_app.exists():
|
||||||
|
boot_path = candidate_boot
|
||||||
|
app_path = candidate_app
|
||||||
|
image_dir = None # switch to per-image flashing path below
|
||||||
|
start_addr = None
|
||||||
|
end_addr = None
|
||||||
|
|
||||||
|
# If invoked via a basename containing "reset" (e.g., pro3_reset), default to reset-only.
|
||||||
|
exe_stem = pathlib.Path(sys.argv[0]).stem.lower()
|
||||||
|
if (args.download_mode is None) and (not args.reset) and ("reset" in exe_stem):
|
||||||
|
args.reset = True
|
||||||
|
args.download_mode = 0
|
||||||
|
|
||||||
|
# Control-only flags: -dl / -r are meant for toggling modes, not flashing.
|
||||||
|
control_only = (args.download_mode is not None) or args.reset
|
||||||
|
|
||||||
|
# Optional: enable DTR/RTS driven auto boot/reset by patching settings load.
|
||||||
|
# We monkeypatch JsonUtils.load_from_file to inject the flags before flash.py reads Settings.json.
|
||||||
|
created_files = []
|
||||||
|
patched_load = False
|
||||||
|
|
||||||
|
# If control-only flags are provided alongside any flash inputs, bail out early.
|
||||||
|
if control_only and any([args.single_image, boot_path, app_path, image_dir]):
|
||||||
|
print("Cannot combine -dl/-r with flashing arguments; use them alone to toggle mode/reset.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Disable built-in DTR/RTS automation; we'll drive GPIO via pro3_gpio helper.
|
||||||
|
orig_load: Callable = JsonUtils.load_from_file
|
||||||
|
|
||||||
|
def patched_load(path, need_decrypt=True):
|
||||||
|
# Only inject for Settings.json; leave profiles untouched.
|
||||||
|
if os.path.basename(path) == "Settings.json":
|
||||||
|
# Settings.json in this project is plain JSON (not DES/base64),
|
||||||
|
# so force no-decrypt to avoid parse errors.
|
||||||
|
data = orig_load(path, False) or {}
|
||||||
|
data["AutoSwitchToDownloadModeWithDtrRts"] = 0
|
||||||
|
data["AutoResetDeviceWithDtrRts"] = 0
|
||||||
|
data["PostProcess"] = "NONE"
|
||||||
|
return data
|
||||||
|
return orig_load(path, need_decrypt)
|
||||||
|
|
||||||
|
JsonUtils.load_from_file = patched_load # type: ignore
|
||||||
|
patched_load = True
|
||||||
|
|
||||||
|
# Inline GPIO control using ASCII BOOT/RESET commands handled by AmebaSmart firmware.
|
||||||
|
def send_boot_reset(port: str, baud: int, idx: int, boot: int | None = None, reset: int | None = None):
|
||||||
|
"""
|
||||||
|
Issue ASCII commands understood by AmebaSmart CDC bridge.
|
||||||
|
Firmware uses "BOOT <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
82
Flash/pro3_gpio.py
Normal 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
7
Flash/version_info.py
Normal 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
22
README.md
Normal 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
99
agents.md
Normal 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., 30–60 Hz) to keep scrolling smooth.
|
||||||
|
- Multi-select copy should concatenate selected ranges with timestamps preserved.
|
||||||
|
|
||||||
|
## Concurrency Rules
|
||||||
|
- Serial read/write happens in dedicated `QThread` workers; UI never blocks on I/O.
|
||||||
|
- Use signals for crossing threads; avoid direct widget access from workers.
|
||||||
|
- For CPU-heavy tasks (parsing, file save), delegate to a `ProcessPoolExecutor`; C/C++ extensions (e.g., pybind11 modules) are encouraged where performance-critical, keeping a Python fallback.
|
||||||
|
- Ensure clean teardown: on tab close or app exit, stop workers, flush queues, and close ports gracefully.
|
||||||
|
|
||||||
|
## Backend Interfaces
|
||||||
|
- Serial service API: `open(port, baud)`, `close()`, `write(bytes)`, `signal line_received(str, direction)`, `signal status_changed(state)`.
|
||||||
|
- History service: `load()`, `save(entries)`, `add(entry)`, `delete(entry)`.
|
||||||
|
- Optional C/C++ module: provide drop-in replacements for line framing or log buffering; keep a pure-Python fallback.
|
||||||
|
|
||||||
|
## Current Implementation Notes (2026-02-05)
|
||||||
|
- UART writes default to `\r\n` (CRLF) line endings to satisfy Ameba monitor commands.
|
||||||
|
- Flash action closes the DUT UART, runs `flash_amebapro3.py`, reconnects, then triggers an auto “Normal mode” step after ~100 ms while keeping the port open.
|
||||||
|
- Download mode, Normal mode (manual button), and Device Reset now run without closing/reopening the DUT UART; connection state stays steady.
|
||||||
|
- TX log lines are suppressed in the UI; RX/INFO still render with timestamps and colors.
|
||||||
|
- Port additions are not logged; port removals are.
|
||||||
|
- In frozen/packaged builds, the flash helper runs **in-process** (inline) using the bundled Python runtime; no external Python is required. Non-frozen/dev builds still shell out to the system interpreter.
|
||||||
|
- When packaging, include hidden imports `pyDes`, `colorama`; `package_exe.py` already adds these.
|
||||||
|
|
||||||
|
## Snapshot for PySide Rewrite (layout + behavior)
|
||||||
|
- Tabs: three device tabs, each fully isolated (own controller/services). Keep the per-tab state separation when porting.
|
||||||
|
- Layout (per tab):
|
||||||
|
- Top rows: DUT COM dropdown + Refresh + Baud + Connect toggle; Control COM dropdown + Baud; Mode buttons (Normal/Download/Reset) aligned right.
|
||||||
|
- Flash rows: App path + Browse, Boot path + Browse + Flash.
|
||||||
|
- Bottom split: left fixed-width history list (~220 px); right column with Log view, Find row (input + case toggle + find/next/prev/all), Command-list row (file path + Browse + per-cmd delay + per-char delay + Load), Command entry row (input + Send).
|
||||||
|
- Styling: light “Fusion” palette; monospaced log font (JetBrains Mono/Consolas fallback), modest padding/margins; log colors—RX green-ish (#1b5e20), TX (currently hidden) blue-ish (#0d47a1), INFO gray (#424242); timestamps `YYYY-MM-DD HH:MM:SS.mmm`.
|
||||||
|
- Logging pipeline: serial worker enqueues lines into `SimpleQueue`; a Qt timer flushes every ~30 ms, appends to the log view, maintains tail buffer via `LogBuffer` (max ~10k lines in UI tail, full archive available for save); perf label shows lines/sec and queue depth updated ~1 Hz.
|
||||||
|
- Search: uses Qt document find for next/prev; “Find all” scans full log text, highlights matches.
|
||||||
|
- History: single-click loads into input; double-click sends immediately; Delete key removes; duplicates skipped on consecutive add; persisted per device under `%LocalAppData%/AmebaControlPanel/<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.
|
||||||
3
ameba_control_panel/__init__.py
Normal file
3
ameba_control_panel/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""Ameba Control Panel package."""
|
||||||
|
|
||||||
|
__all__ = ["app"]
|
||||||
4
ameba_control_panel/__main__.py
Normal file
4
ameba_control_panel/__main__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
from ameba_control_panel.app import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
BIN
ameba_control_panel/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
ameba_control_panel/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
ameba_control_panel/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
ameba_control_panel/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
ameba_control_panel/__pycache__/__main__.cpython-310.pyc
Normal file
BIN
ameba_control_panel/__pycache__/__main__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
ameba_control_panel/__pycache__/app.cpython-310.pyc
Normal file
BIN
ameba_control_panel/__pycache__/app.cpython-310.pyc
Normal file
Binary file not shown.
BIN
ameba_control_panel/__pycache__/app.cpython-314.pyc
Normal file
BIN
ameba_control_panel/__pycache__/app.cpython-314.pyc
Normal file
Binary file not shown.
BIN
ameba_control_panel/__pycache__/config.cpython-310.pyc
Normal file
BIN
ameba_control_panel/__pycache__/config.cpython-310.pyc
Normal file
Binary file not shown.
BIN
ameba_control_panel/__pycache__/config.cpython-314.pyc
Normal file
BIN
ameba_control_panel/__pycache__/config.cpython-314.pyc
Normal file
Binary file not shown.
56
ameba_control_panel/app.py
Normal file
56
ameba_control_panel/app.py
Normal 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()
|
||||||
50
ameba_control_panel/config.py
Normal file
50
ameba_control_panel/config.py
Normal 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"
|
||||||
Binary file not shown.
Binary file not shown.
514
ameba_control_panel/controllers/device_tab_controller.py
Normal file
514
ameba_control_panel/controllers/device_tab_controller.py
Normal 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()
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
60
ameba_control_panel/services/command_player.py
Normal file
60
ameba_control_panel/services/command_player.py
Normal 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
|
||||||
163
ameba_control_panel/services/flash_runner.py
Normal file
163
ameba_control_panel/services/flash_runner.py
Normal 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 = ""
|
||||||
56
ameba_control_panel/services/history_service.py
Normal file
56
ameba_control_panel/services/history_service.py
Normal 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)
|
||||||
7
ameba_control_panel/services/line_parser.py
Normal file
7
ameba_control_panel/services/line_parser.py
Normal 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")
|
||||||
52
ameba_control_panel/services/log_buffer.py
Normal file
52
ameba_control_panel/services/log_buffer.py
Normal 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)
|
||||||
19
ameba_control_panel/services/port_service.py
Normal file
19
ameba_control_panel/services/port_service.py
Normal 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
|
||||||
27
ameba_control_panel/services/search_service.py
Normal file
27
ameba_control_panel/services/search_service.py
Normal 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)
|
||||||
174
ameba_control_panel/services/serial_service.py
Normal file
174
ameba_control_panel/services/serial_service.py
Normal 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())
|
||||||
33
ameba_control_panel/services/session_store.py
Normal file
33
ameba_control_panel/services/session_store.py
Normal 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()
|
||||||
BIN
ameba_control_panel/utils/__pycache__/timeutils.cpython-310.pyc
Normal file
BIN
ameba_control_panel/utils/__pycache__/timeutils.cpython-310.pyc
Normal file
Binary file not shown.
BIN
ameba_control_panel/utils/__pycache__/timeutils.cpython-314.pyc
Normal file
BIN
ameba_control_panel/utils/__pycache__/timeutils.cpython-314.pyc
Normal file
Binary file not shown.
10
ameba_control_panel/utils/timeutils.py
Normal file
10
ameba_control_panel/utils/timeutils.py
Normal 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]
|
||||||
Binary file not shown.
Binary file not shown.
BIN
ameba_control_panel/views/__pycache__/log_view.cpython-310.pyc
Normal file
BIN
ameba_control_panel/views/__pycache__/log_view.cpython-310.pyc
Normal file
Binary file not shown.
BIN
ameba_control_panel/views/__pycache__/log_view.cpython-314.pyc
Normal file
BIN
ameba_control_panel/views/__pycache__/log_view.cpython-314.pyc
Normal file
Binary file not shown.
201
ameba_control_panel/views/device_tab_view.py
Normal file
201
ameba_control_panel/views/device_tab_view.py
Normal 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()
|
||||||
83
ameba_control_panel/views/log_view.py
Normal file
83
ameba_control_panel/views/log_view.py
Normal 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
6
requirements.txt
Normal 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
|
||||||
BIN
script/__pycache__/auto_run.cpython-310.pyc
Normal file
BIN
script/__pycache__/auto_run.cpython-310.pyc
Normal file
Binary file not shown.
BIN
script/__pycache__/package_exe.cpython-310.pyc
Normal file
BIN
script/__pycache__/package_exe.cpython-310.pyc
Normal file
Binary file not shown.
20
script/auto_run.py
Normal file
20
script/auto_run.py
Normal 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
66
script/package_exe.py
Normal 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)
|
||||||
Loading…
x
Reference in New Issue
Block a user