初始提交卡包 USB 工程

纳入固件源码、屏幕组件、nand_xfer 工具和文档。忽略本机构建产物、sdkconfig,以及含本机路径的依赖锁。

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
zhengxin
2026-10-13 21:54:50 +08:00
co-authored by Cursor
commit 851e347cf5
260 changed files with 269349 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
cmake_minimum_required(VERSION 3.16)
set(SDKCONFIG_DEFAULTS "${CMAKE_CURRENT_LIST_DIR}/sdkconfig.defaults")
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(nand_xfer)
+95
View File
@@ -0,0 +1,95 @@
# SPI NAND file transfer app
This is a standalone ESP32-P4 application. The existing application and
`E:\PET\fin_1\branch_main\IntegratedV1` are not modified. Its `/extflash`
mount prefix and IntegratedV1's `/nandflash` prefix point to the same FAT
volume on the external GD5F1GM7: `/extflash/eaf/a.eaf` corresponds to
`/nandflash/eaf/a.eaf`. Neither prefix exists as a directory on the NAND.
## Build and flash (PowerShell)
Use ESP-IDF 5.5.4 and the installed P4 toolchain. From this directory:
```powershell
.\build_app.ps1
.\flash_app.ps1 -Port COM3 -AppBin .\build_release\nand_xfer.bin
```
`flash_app.ps1` checks the parent `partitions.csv` for `ota_0 = 0x70000,
10M`, validates the app image, and invokes **only**
`esptool write_flash 0x70000 <app.bin>`. To restore the official app, run
the same script with its existing app binary:
```powershell
.\flash_app.ps1 -Port COM3 -AppBin "E:\PET\fin_1\branch_main\IntegratedV1\build\<official-app>.bin"
```
Confirm which official binary to use before restoring. Neither script
flashes a bootloader, partition table, `otadata`, NVS, nor any data partition.
**Do not use** `idf.py flash`, `erase-flash`, or generated `flash_args` /
`flasher_args.json`: IDF generates entries for partitions outside the app.
The actual board must already boot `ota_0` at `0x70000` and have a compatible
partition table; the script does not change the boot selection.
## Transfer
Install pyserial once: `py -3 -m pip install pyserial`. Close other serial
programs, including `idf.py monitor`. Use the P4 USB-Serial/JTAG COM port;
baud rate is opened at 115200. After NAND initialization the device sends its
capacity and mount result, followed by `NAND-XFER READY`. No text follows
READY on that USB byte stream; binary requests also work if READY has already
been printed when the host opens the port.
```powershell
py -3 nand_put.py COM3 "C:\files\demo.eaf" "/extflash/eaf/demo.eaf"
py -3 nand_put.py COM3 "C:\files\eaf" "/extflash/eaf" --recursive
py -3 nand_put.py COM3 --info
py -3 nand_put.py COM3 --list "/extflash/eaf"
py -3 nand_put.py COM3 --verify "/extflash/eaf/demo.eaf"
py -3 nand_put.py COM3 --mkdir "/extflash/empty"
```
The directory command uploads the directory *contents* under the destination
and retains empty subdirectories. Completed files are closed, synced, read
back and CRC-checked before reporting success. An interrupted current file
may be incomplete; running the upload again overwrites it. Reconnecting
cancels any unfinished open file, not previously completed files.
Mounts are never automatically formatted. An unformatted NAND reports
`mounted=False` and refuses file writes. Formatting requires this distinct
command and a typed confirmation:
```powershell
py -3 nand_put.py COM3 --format
```
**This erases all files on the NAND FAT volume.** Do not accept a Windows
Explorer format prompt: its sector-size assumptions are incompatible with the
NAND page-backed FAT. Formatting is done only by the device using 16KB
clusters. SPI3 matches this repo's `main/FLASH/flash.h`: MOSI39, MISO41,
CLK40, CS42, SIO, mode 0, 40MHz; WP and HOLD are tied high, not GPIOs.
## Validation
```powershell
py -3 -m unittest -v test_nand_put.py
```
On the board: check capacity/READY, transfer a file and a nested directory,
compare reported size/CRC, reset and list again, reboot without `--format`,
then restore the official app at `0x70000`. Its `fopen` check through
`/nandflash/<relative-path>` is **pending board validation**. As of October 8,
2026, IntegratedV1's `board_config.h` still contains the obsolete NAND GPIO
assignments; this transfer app does not modify that firmware. A stock
IntegratedV1 binary is therefore not guaranteed to mount this board's NAND.
Protocol: `NXFR`, version 1, command byte, two reserved zero bytes, LE uint32
sequence and LE uint32 payload length, payload (at most 4352 bytes), then
IEEE CRC32 over header and payload. Responses set command bit 7 and prefix
their payload with a status byte. HELLO (11) starts a new session; INFO (1),
LIST (2, LE uint16 cursor + UTF-8 path), MKDIR (3), BEGIN (4), BLOCK (5,
LE uint32 offset + up to 4096 bytes), END (6, LE uint64 size + uint32 CRC),
VERIFY (7), CANCEL (8), PREPARE (9), CONFIRM (10) operate in sequence.
PREPARE returns a 4-byte challenge, and CONFIRM requires the challenge plus
the PREPARE sequence within 30 seconds. The host requests confirmation
*before* PREPARE; it never automatically retries CONFIRM.
+38
View File
@@ -0,0 +1,38 @@
$ErrorActionPreference = 'Stop'
$project = $PSScriptRoot
$env:IDF_PATH = if ($env:IDF_PATH) { $env:IDF_PATH } else { 'E:\Espressif\frameworks\esp-idf-v5.5.4' }
$env:IDF_TOOLS_PATH = if ($env:IDF_TOOLS_PATH) { $env:IDF_TOOLS_PATH } else { 'E:\Espressif' }
$env:IDF_PYTHON_ENV_PATH = if ($env:IDF_PYTHON_ENV_PATH) { $env:IDF_PYTHON_ENV_PATH } else { 'E:\Espressif\python_env\idf5.5_py3.11_env' }
$env:IDF_TARGET = 'esp32p4'
$env:PYTHON = Join-Path $env:IDF_PYTHON_ENV_PATH 'Scripts\python.exe'
$env:IDF_PYTHON = $env:PYTHON
$ninja = Get-ChildItem (Join-Path $env:IDF_TOOLS_PATH 'tools\ninja') -Directory | Sort-Object Name -Descending | Select-Object -First 1
$cmake = Get-ChildItem (Join-Path $env:IDF_TOOLS_PATH 'tools\cmake') -Directory | Sort-Object Name -Descending | Select-Object -First 1
$riscv = Get-ChildItem (Join-Path $env:IDF_TOOLS_PATH 'tools\riscv32-esp-elf') -Directory | Sort-Object Name -Descending | Select-Object -First 1
$toolDirs = @($ninja.FullName, (Join-Path $cmake.FullName 'bin'), (Join-Path $riscv.FullName 'riscv32-esp-elf\bin'))
if (@($toolDirs | Where-Object { -not (Test-Path $_) }).Count) { throw 'ESP-IDF build tools not installed.' }
$env:PATH = ($toolDirs -join ';') + ';' + $env:PATH
$csv = (Resolve-Path (Join-Path $project '..\partitions.csv')).Path.Replace('\','/')
$config = Join-Path $project 'sdkconfig'
# sdkconfig is generated/ignored; the actual partition table is always the parent project CSV.
if (Test-Path $config) {
$text = [System.IO.File]::ReadAllText($config)
$text = [regex]::Replace($text, '(?m)^CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=.*$', 'CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="' + $csv + '"')
$text = [regex]::Replace($text, '(?m)^CONFIG_PARTITION_TABLE_FILENAME=.*$', 'CONFIG_PARTITION_TABLE_FILENAME="' + $csv + '"')
[System.IO.File]::WriteAllText($config,$text)
} else {
$defaults = Join-Path $project 'sdkconfig.defaults'
$text = [System.IO.File]::ReadAllText($defaults)
$text = [regex]::Replace($text, '(?m)^CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=.*$', 'CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="' + $csv + '"')
[System.IO.File]::WriteAllText($config,$text)
}
Push-Location $project
try {
$ErrorActionPreference = 'Continue'
& $env:PYTHON (Join-Path $env:IDF_PATH 'tools\idf.py') -B build_release app
$result = $LASTEXITCODE
$ErrorActionPreference = 'Stop'
if ($result -ne 0) { throw "App build failed: $result" }
} finally { Pop-Location }
$app = Join-Path $project 'build_release\nand_xfer.bin'
Write-Host "App image: $app"
+18
View File
@@ -0,0 +1,18 @@
$ErrorActionPreference = 'Stop'
$project = $PSScriptRoot
$venv = Join-Path $project '.venv_gui'
$py = Join-Path $venv 'Scripts\python.exe'
Push-Location $project
try {
if (-not (Test-Path -LiteralPath $py)) {
& py -3 -m venv $venv
if ($LASTEXITCODE -ne 0) { throw "venv failed: $LASTEXITCODE" }
}
& $py -m pip install -q --upgrade pip pyinstaller pyserial
if ($LASTEXITCODE -ne 0) { throw "pip failed: $LASTEXITCODE" }
& $py -m PyInstaller --noconfirm --clean --workpath gui_build --distpath dist nand_put_gui.spec
if ($LASTEXITCODE -ne 0) { throw "PyInstaller failed: $LASTEXITCODE" }
$exe = Join-Path $project 'dist\NAND_Xfer.exe'
if (-not (Test-Path -LiteralPath $exe)) { throw "Missing $exe" }
Write-Host "GUI exe: $exe"
} finally { Pop-Location }
+29
View File
@@ -0,0 +1,29 @@
param(
[Parameter(Mandatory=$true)][string]$Port,
[Parameter(Mandatory=$true)][string]$AppBin
)
$ErrorActionPreference = 'Stop'
$partitionCsv = (Resolve-Path (Join-Path $PSScriptRoot '..\partitions.csv')).Path
$rows = @(Get-Content -LiteralPath $partitionCsv -Encoding UTF8 |
Where-Object { $_.Trim() -and -not $_.TrimStart().StartsWith('#') } |
ConvertFrom-Csv -Header Name,Type,SubType,Offset,Size,Flags |
Where-Object { $_.Name.Trim() -eq 'ota_0' -and $_.Type.Trim() -eq 'app' -and $_.SubType.Trim() -eq 'ota_0' })
if ($rows.Count -ne 1 -or $rows[0].Offset.Trim() -ne '0x70000' -or $rows[0].Size.Trim() -ne '10M') {
throw 'Expected exactly one ota_0 app partition at 0x70000, size 10M in partitions.csv.'
}
$app = (Resolve-Path -LiteralPath $AppBin).Path
$length = (Get-Item -LiteralPath $app).Length
if ($length -le 0 -or $length -gt 10MB) { throw "Image length $length is outside ota_0 partition." }
$image = [System.IO.File]::ReadAllBytes($app)
if ($image[0] -ne 0xe9) { throw 'Not an ESP application binary (image magic mismatch).' }
$idf = if ($env:IDF_PATH) { $env:IDF_PATH } else { 'E:\Espressif\frameworks\esp-idf-v5.5.4' }
$py = if ($env:IDF_PYTHON_ENV_PATH) {
Join-Path $env:IDF_PYTHON_ENV_PATH 'Scripts\python.exe'
} else { 'E:\Espressif\python_env\idf5.5_py3.11_env\Scripts\python.exe' }
if (-not (Test-Path -LiteralPath $py)) { throw "ESP-IDF Python not found: $py" }
$esptool = Join-Path $idf 'components\esptool_py\esptool\esptool.py'
if (-not (Test-Path -LiteralPath $esptool)) { throw "esptool.py not found: $esptool" }
# USB-Serial/JTAG: idf.py app-flash uploads a stub then switches baud and fails
# with Invalid head of packet (0x50). ROM loader at 2000000 writes only ota_0.
& $py $esptool --chip esp32p4 -p $Port -b 2000000 --no-stub --before default_reset --after hard_reset write_flash --flash_mode dio --flash_freq 80m --flash_size 32MB 0x70000 $app
if ($LASTEXITCODE -ne 0) { throw "Flash failed with exit code $LASTEXITCODE" }
+2
View File
@@ -0,0 +1,2 @@
idf_component_register(SRCS nand_xfer.c INCLUDE_DIRS .
REQUIRES spi_nand_flash spi_nand_flash_fatfs esp_driver_spi esp_driver_usb_serial_jtag fatfs esp_timer)
+10
View File
@@ -0,0 +1,10 @@
dependencies:
idf:
version: 5.5.4
espressif/spi_nand_flash:
version: 1.4.4
espressif/spi_nand_flash_fatfs:
version: 1.1.2
espressif/dhara:
version: 1.1.0
override_path: E:/PET/fin_1/branch_main/IntegratedV1/components/espressif__dhara
+339
View File
@@ -0,0 +1,339 @@
#include <dirent.h>
#include <errno.h>
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include "driver/spi_master.h"
#include "driver/usb_serial_jtag.h"
#include "esp_log.h"
#include "esp_random.h"
#include "esp_timer.h"
#include "esp_vfs_fat.h"
#include "esp_vfs_fat_nand.h"
#include "diskio_nand.h"
#include "diskio_impl.h"
#include "ff.h"
#include "spi_nand_flash.h"
#define ROOT "/extflash"
#define MAX_PAYLOAD 4352
#define TIMEOUT_MS 12000
/* Same SPI3 NAND pins as ../main/FLASH/flash.h on this board. */
#define PIN_MOSI 39
#define PIN_MISO 41
#define PIN_CLK 40
#define PIN_CS 42
#define FLASH_FREQ_HZ 40000000
enum { INFO=1, LIST=2, MKDIR=3, BEGIN=4, BLOCK=5, END=6, VERIFY=7,
CANCEL=8, FORMAT_PREPARE=9, FORMAT_CONFIRM=10, HELLO=11 };
enum { OK=0, BAD_FRAME=1, BAD_PATH=2, NOT_MOUNTED=3, IO_ERROR=4,
BAD_STATE=5, MISMATCH=6, DENIED=7 };
static spi_nand_flash_device_t *nand;
static bool mounted;
static FILE *active;
static char active_path[512];
static uint64_t written;
static uint32_t running_crc;
static uint32_t challenge;
static uint32_t challenge_seq;
static int64_t challenge_deadline;
static uint8_t request[16+MAX_PAYLOAD+4], response[16+MAX_PAYLOAD+4];
static uint8_t out_buf[MAX_PAYLOAD];
static uint8_t crc_buf[4096];
static uint8_t last_response[16+MAX_PAYLOAD+4];
static size_t last_response_size;
static uint32_t last_sequence;
static bool last_valid;
static bool session_ready;
static uint32_t u32(const uint8_t *p) {
return (uint32_t)p[0] | (uint32_t)p[1]<<8 | (uint32_t)p[2]<<16 | (uint32_t)p[3]<<24;
}
static void put32(uint8_t *p, uint32_t v) {
for (unsigned i=0;i<4;i++) p[i]=(uint8_t)(v>>(8*i));
}
static void put64(uint8_t *p, uint64_t v) {
put32(p,(uint32_t)v); put32(p+4,(uint32_t)(v>>32));
}
static uint32_t crc_update(uint32_t crc, const uint8_t *p, size_t len) {
crc=~crc;
for(size_t i=0;i<len;i++) {
crc^=p[i];
for(int bit=0;bit<8;bit++) crc=(crc>>1) ^ (0xedb88320u & -(crc&1u));
}
return ~crc;
}
static bool send_bytes(const uint8_t *p, size_t len) {
while(len) {
int n=usb_serial_jtag_write_bytes(p,len,pdMS_TO_TICKS(5000));
if(n<=0) return false;
p+=n; len-=n;
}
return usb_serial_jtag_wait_tx_done(pdMS_TO_TICKS(5000))==ESP_OK;
}
static bool read_exact(uint8_t *p, size_t len) {
int64_t deadline=esp_timer_get_time()+TIMEOUT_MS*1000LL;
for(size_t i=0;i<len;i++) {
int remain=(int)((deadline-esp_timer_get_time())/1000);
if(remain<=0 || usb_serial_jtag_read_bytes(p+i,1,pdMS_TO_TICKS(remain))!=1) return false;
}
return true;
}
static bool read_frame(uint8_t *cmd, uint32_t *seq, uint16_t *len) {
const uint8_t magic[]={'N','X','F','R'};
size_t match=0;
for(;;) {
uint8_t b;
if(usb_serial_jtag_read_bytes(&b,1,pdMS_TO_TICKS(1000))!=1) return false;
if(b==magic[match]) request[match++]=b;
else match=(b==magic[0]) ? (request[0]=b,1) : 0;
if(match==4) break;
}
if(!read_exact(request+4,12)) return false;
*cmd=request[5]; *seq=u32(request+8);
uint32_t length=u32(request+12);
if(request[4]!=1 || request[6] || request[7] || request[5]==0 || request[5]>HELLO || length>MAX_PAYLOAD) return false;
*len=(uint16_t)length;
if(!read_exact(request+16,*len+4)) return false;
return crc_update(0,request,16+*len)==u32(request+16+*len);
}
static void reply(uint8_t cmd, uint32_t seq, uint8_t status, const uint8_t *data, uint16_t length) {
memcpy(response,"NXFR",4); response[4]=1; response[5]=cmd|0x80;
response[6]=response[7]=0; put32(response+8,seq);
put32(response+12,length+1); response[16]=status;
if(length) memcpy(response+17,data,length);
put32(response+17+length,crc_update(0,response,17+length));
last_response_size=21+length;
memcpy(last_response,response,last_response_size);
last_sequence=seq; last_valid=true;
send_bytes(response,last_response_size);
}
static bool valid_path(const uint8_t *p, size_t length, char *out) {
if(length<strlen(ROOT) || length>=512 || memchr(p,0,length)) return false;
memcpy(out,p,length); out[length]=0;
if(strncmp(out,ROOT,strlen(ROOT)) || (out[strlen(ROOT)] && out[strlen(ROOT)]!='/')) return false;
if(out[length-1]=='/' && length>strlen(ROOT)) return false;
if(!strcmp(out,ROOT)) return true;
const char *s=out+strlen(ROOT);
while(*s) {
if(*s++!='/') return false;
const char *part=s;
while(*s && *s!='/') {
unsigned char c=(unsigned char)*s++;
if(c<32 || c==92 || c==58 || c==42 || c==63 || c==34 || c==60 || c==62 || c==124) return false;
}
if(s==part || (s-part==1 && part[0]=='.') || (s-part==2 && part[0]=='.' && part[1]=='.')) return false;
if(s>part && (s[-1]=='.' || s[-1]==' ')) return false;
}
return true;
}
static bool make_dirs(char *path, bool parents_only) {
size_t start=strlen(ROOT)+1;
for(size_t i=start;;i++) {
if(path[i]=='/' || (!parents_only && !path[i])) {
char c=path[i]; path[i]=0;
struct stat st;
if(mkdir(path,0777)!=0 && (stat(path,&st)!=0 || !S_ISDIR(st.st_mode))) {
path[i]=c; return false;
}
path[i]=c;
}
if(!path[i]) return true;
}
}
static bool read_checksum(const char *path, uint64_t *size, uint32_t *crc) {
FILE *f=fopen(path,"rb");
if(!f) return false;
*size=0; *crc=0;
size_t n;
while((n=fread(crc_buf,1,sizeof(crc_buf),f))) { *size+=n; *crc=crc_update(*crc,crc_buf,n); }
bool good=!ferror(f);
if(fclose(f)!=0) good=false;
return good;
}
static esp_err_t mount_volume(void) {
esp_vfs_fat_mount_config_t cfg={.max_files=6,.format_if_mount_failed=false,.allocation_unit_size=16384};
esp_err_t err=esp_vfs_fat_nand_mount(ROOT,nand,&cfg);
mounted=(err==ESP_OK);
return err;
}
static bool format_volume(void) {
if(mounted) {
if(esp_vfs_fat_nand_unmount(ROOT,nand)!=ESP_OK) return false;
mounted=false;
}
BYTE drive;
if(ff_diskio_get_drive(&drive)!=ESP_OK) return false;
if(ff_diskio_register_nand(drive,nand)!=ESP_OK) return false;
char name[3]={(char)('0'+drive),':',0};
void *work=ff_memalloc(4096);
FRESULT result=FR_NOT_ENOUGH_CORE;
if(work) {
MKFS_PARM opt={(BYTE)FM_ANY,0,0,0,16384};
result=f_mkfs(name,&opt,work,4096);
ff_memfree(work);
}
bool ok=result==FR_OK;
if(ok) ok=disk_ioctl(drive,CTRL_SYNC,NULL)==RES_OK;
ff_diskio_unregister(drive);
ff_diskio_clear_pdrv_nand(nand);
if(!ok) return false;
return mount_volume()==ESP_OK;
}
static void close_active(void) {
if(active) { fclose(active); active=NULL; }
active_path[0]=0; written=0; running_crc=0;
}
static void execute(uint8_t cmd, uint32_t seq, const uint8_t *payload, uint16_t len) {
uint8_t *out=out_buf; uint16_t count=0;
uint8_t status=OK; char path[512];
if(cmd==HELLO) {
if(len!=4) status=BAD_FRAME;
else {
challenge=0;
challenge_seq=0;
challenge_deadline=0;
close_active();
session_ready=true;
memcpy(out,payload,4);
count=4;
}
} else if(!session_ready) status=BAD_STATE;
else if(cmd==INFO) {
if(len) status=BAD_FRAME;
else {
uint32_t size=0, pages=0; uint64_t total=0,free=0;
spi_nand_flash_get_page_size(nand,&size);
spi_nand_flash_get_page_count(nand,&pages);
if(mounted) esp_vfs_fat_info(ROOT,&total,&free);
put32(out,size); put32(out+4,pages); out[8]=mounted;
put64(out+9,total); put64(out+17,free); count=25;
}
} else if(cmd==FORMAT_PREPARE) {
if(active || len) status=BAD_STATE;
else { challenge=esp_random(); if(!challenge) challenge=1;
challenge_seq=seq;
challenge_deadline=esp_timer_get_time()+30000000LL;
put32(out,challenge); count=4; }
} else if(cmd==FORMAT_CONFIRM) {
if(active || len!=8 || !challenge || u32(payload)!=challenge ||
u32(payload+4)!=challenge_seq || esp_timer_get_time()>challenge_deadline) status=DENIED;
else { challenge=0; challenge_seq=0; challenge_deadline=0;
if(!format_volume()) status=IO_ERROR; }
} else if(cmd==CANCEL) {
if(len) status=BAD_FRAME;
else close_active();
} else if(!mounted) status=NOT_MOUNTED;
else if(cmd==BLOCK) {
if(!active || len<=4 || len>4100 || written>UINT32_MAX || u32(payload)!=(uint32_t)written) status=BAD_STATE;
else if(fwrite(payload+4,1,len-4,active)!=len-4) { close_active(); status=IO_ERROR; }
else { written+=len-4; running_crc=crc_update(running_crc,payload+4,len-4);
put64(out,written); count=8; }
} else if(cmd==END) {
if(!active) status=BAD_STATE;
else if(len!=12) { close_active(); status=BAD_STATE; }
else if(u32(payload)!=(uint32_t)written || u32(payload+4)!=(uint32_t)(written>>32) ||
u32(payload+8)!=running_crc) { close_active(); status=MISMATCH; }
else {
bool ok=fflush(active)==0;
if(ok) ok=fsync(fileno(active))==0;
if(fclose(active)!=0) ok=false;
active=NULL;
if(ok) ok=spi_nand_flash_sync(nand)==ESP_OK;
uint64_t size=0; uint32_t crc=0;
if(ok) ok=read_checksum(active_path,&size,&crc);
if(!ok) status=IO_ERROR;
else if(size!=written || crc!=running_crc) status=MISMATCH;
else { put64(out,size); put32(out+8,crc); count=12; }
active_path[0]=0; written=0; running_crc=0;
}
} else if(cmd==LIST) {
if(len<2 || !valid_path(payload+2,len-2,path)) status=BAD_PATH;
else {
DIR *dir=opendir(path);
if(!dir) status=IO_ERROR;
else {
uint16_t skip=(uint16_t)(payload[0]|payload[1]<<8), index=0;
struct dirent *ent=NULL;
bool done=true;
while((ent=readdir(dir))) {
if(!strcmp(ent->d_name,".") || !strcmp(ent->d_name,"..")) continue;
if(index++<skip) continue;
size_t name_len=strlen(ent->d_name);
if(name_len>255) { status=BAD_PATH; break; }
if(count+name_len+13>MAX_PAYLOAD) {
index--;
done=false;
break;
}
char child[768]; struct stat st;
snprintf(child,sizeof(child),"%s/%s",path,ent->d_name);
if(stat(child,&st)!=0) { status=IO_ERROR; break; }
out[count++]=S_ISDIR(st.st_mode)?1:0;
out[count++]=(uint8_t)name_len;
put64(out+count,(uint64_t)st.st_size); count+=8;
memcpy(out+count,ent->d_name,name_len); count+=name_len;
}
closedir(dir);
if(status==OK) {
memmove(out+3,out,count);
out[0]=(uint8_t)index; out[1]=(uint8_t)(index>>8);
out[2]=done; count+=3;
} else count=0;
}
}
} else if(cmd==MKDIR || cmd==BEGIN || cmd==VERIFY) {
if(!valid_path(payload,len,path) || !strcmp(path,ROOT)) status=BAD_PATH;
else if(cmd==MKDIR) { if(!make_dirs(path,false)) status=IO_ERROR; }
else if(cmd==BEGIN) {
if(active) status=BAD_STATE;
else if(!make_dirs(path,true)) status=IO_ERROR;
else {
active=fopen(path,"wb");
if(!active) status=IO_ERROR;
else { strcpy(active_path,path); written=0; running_crc=0; }
}
} else {
uint64_t size; uint32_t crc;
if(!read_checksum(path,&size,&crc)) status=IO_ERROR;
else { put64(out,size); put32(out+8,crc); count=12; }
}
} else status=BAD_FRAME;
reply(cmd,seq,status,out,count);
}
void app_main(void) {
usb_serial_jtag_driver_config_t usb={.tx_buffer_size=8192,.rx_buffer_size=8192};
ESP_ERROR_CHECK(usb_serial_jtag_driver_install(&usb));
spi_bus_config_t bus={.mosi_io_num=PIN_MOSI,.miso_io_num=PIN_MISO,.sclk_io_num=PIN_CLK,
.quadwp_io_num=-1,.quadhd_io_num=-1,.max_transfer_sz=8192};
ESP_ERROR_CHECK(spi_bus_initialize(SPI3_HOST,&bus,SPI_DMA_CH_AUTO));
spi_device_interface_config_t device={.clock_speed_hz=FLASH_FREQ_HZ,.mode=0,.spics_io_num=PIN_CS,
.queue_size=10,.flags=0};
spi_device_handle_t spi;
ESP_ERROR_CHECK(spi_bus_add_device(SPI3_HOST,&device,&spi));
spi_nand_flash_config_t config={.device_handle=spi,.gc_factor=0,
.io_mode=SPI_NAND_IO_MODE_SIO,.flags=0};
ESP_ERROR_CHECK(spi_nand_flash_init_device(&config,&nand));
uint32_t pages=0, page_size=0;
ESP_ERROR_CHECK(spi_nand_flash_get_page_count(nand,&pages));
ESP_ERROR_CHECK(spi_nand_flash_get_page_size(nand,&page_size));
esp_err_t mount_err=mount_volume();
char startup[128];
int startup_len=snprintf(startup,sizeof(startup),
"NAND capacity: %llu bytes; mount: %s\nNAND-XFER READY\n",
(unsigned long long)pages*page_size,mount_err==ESP_OK?"ok":"unmounted");
ESP_ERROR_CHECK(startup_len<0 || startup_len>=sizeof(startup) ? ESP_FAIL : ESP_OK);
ESP_ERROR_CHECK(send_bytes((const uint8_t *)startup,startup_len) ? ESP_OK : ESP_FAIL);
for(;;) {
uint8_t cmd; uint32_t seq; uint16_t len;
if(!read_frame(&cmd,&seq,&len)) continue;
if(cmd!=HELLO && last_valid && seq==last_sequence) { send_bytes(last_response,last_response_size); continue; }
if(cmd==HELLO) last_valid=false;
execute(cmd,seq,request+16,len);
}
}
+322
View File
@@ -0,0 +1,322 @@
"""USB Serial/JTAG SPI NAND file transfer client (Python 3 + pyserial)."""
import argparse
import os
import secrets
import stat
import struct
import sys
import time
import zlib
from pathlib import Path
try:
import serial
except ImportError:
serial = None
MAGIC = b"NXFR"
HEADER = struct.Struct("<4sBBHII")
INFO, LIST, MKDIR, BEGIN, BLOCK, END, VERIFY, CANCEL, PREPARE, CONFIRM, HELLO = range(1, 12)
ERRORS = ["OK", "bad frame", "bad path", "not mounted", "I/O error",
"bad state", "CRC/size mismatch", "denied"]
ROOT = "/extflash"
def path_bytes(path, allow_root=False):
if not isinstance(path, str) or not path.startswith(ROOT):
raise ValueError("destination must start with /extflash")
if path == ROOT:
if not allow_root:
raise ValueError("the volume root is not a file/directory name")
elif not path.startswith(ROOT + "/"):
raise ValueError("path outside /extflash")
else:
parts = path[len(ROOT) + 1:].split("/")
if any(not p or p in (".", "..") or p[-1] in " ." or
any(ord(c) < 32 or c in '\\:*?"<>|' for c in p) for p in parts):
raise ValueError("invalid FAT path")
value = path.encode("utf-8")
if len(value) >= 512:
raise ValueError("UTF-8 path too long")
return value
def encode(cmd, seq, payload=b""):
if len(payload) > 4352:
raise ValueError("frame too large")
body = HEADER.pack(MAGIC, 1, cmd, 0, seq, len(payload)) + payload
return body + struct.pack("<I", zlib.crc32(body) & 0xffffffff)
class Client:
def __init__(self, port):
self.port = port
self.seq = secrets.randbits(32)
def wait_ready(self, timeout=0.4):
deadline = time.monotonic() + timeout
buf = b""
old_timeout = getattr(self.port, "timeout", None)
try:
if old_timeout is not None:
self.port.timeout = min(0.05, timeout)
while time.monotonic() < deadline:
chunk = self.port.read(1024)
if not chunk:
continue
buf = (buf + chunk)[-96:]
if b"NAND-XFER READY" in buf:
return True
finally:
if old_timeout is not None:
self.port.timeout = old_timeout
return False
def drain_rx(self):
old_timeout = getattr(self.port, "timeout", None)
try:
if old_timeout is not None:
self.port.timeout = 0
while self.port.read(4096):
pass
finally:
if old_timeout is not None:
self.port.timeout = old_timeout
def connect(self):
self.wait_ready()
self.drain_rx()
nonce = secrets.token_bytes(4)
if self.call(HELLO, nonce) != nonce:
raise ValueError("device handshake failed")
def _frame(self, deadline):
match = bytearray()
while time.monotonic() < deadline:
byte = self.port.read(1)
if not byte:
continue
match = (match + byte)[-4:]
if match == MAGIC:
break
else:
raise TimeoutError("no response magic")
header = MAGIC + self._exact(12, deadline)
magic, version, cmd, flags, seq, size = HEADER.unpack(header)
if version != 1 or flags or size > 4352:
raise ValueError("invalid response header")
payload = self._exact(size, deadline)
checksum, = struct.unpack("<I", self._exact(4, deadline))
if (zlib.crc32(header + payload) & 0xffffffff) != checksum:
raise ValueError("response CRC mismatch")
return cmd, seq, payload
def _exact(self, size, deadline):
data = bytearray()
while len(data) < size and time.monotonic() < deadline:
data.extend(self.port.read(size - len(data)))
if len(data) != size:
raise TimeoutError("incomplete response")
return bytes(data)
def call(self, cmd, payload=b"", timeout=12, retries=3):
self.seq = (self.seq + 1) & 0xffffffff
seq = self.seq
packet = encode(cmd, seq, payload)
for attempt in range(retries):
self.port.write(packet)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
reply_cmd, reply_seq, body = self._frame(deadline)
except (ValueError, TimeoutError):
break
if reply_seq != seq or reply_cmd != (cmd | 0x80):
continue
if not body:
raise RuntimeError("empty reply")
if body[0]:
code = body[0]
raise RuntimeError(ERRORS[code] if code < len(ERRORS) else f"error {code}")
return body[1:]
raise TimeoutError(f"command {cmd} unacknowledged after {retries} attempts")
def info(self):
page, pages, mounted, total, free = struct.unpack("<IIBQQ", self.call(INFO))
return page, pages, bool(mounted), total, free
def verify(self, path):
return struct.unpack("<QI", self.call(VERIFY, path_bytes(path), timeout=60))
def listdir(self, path):
encoded = path_bytes(path, allow_root=True)
cursor = 0
while True:
body = self.call(LIST, struct.pack("<H", cursor) + encoded)
if len(body) < 3:
raise ValueError("invalid directory response")
next_cursor, done = struct.unpack("<HB", body[:3])
i = 3
while i < len(body):
if i + 10 > len(body):
raise ValueError("truncated directory entry")
is_dir, name_len, size = struct.unpack_from("<BBQ", body, i)
i += 10
if i + name_len > len(body):
raise ValueError("truncated directory name")
name = body[i:i + name_len].decode("utf-8")
i += name_len
yield (is_dir, name, size)
if done:
return
if next_cursor <= cursor:
raise ValueError("directory cursor did not advance")
cursor = next_cursor
def upload(self, source, dest, log=print):
dest = path_bytes(dest).decode("utf-8")
try:
self.call(BEGIN, path_bytes(dest))
size = 0
crc = 0
with open(source, "rb") as stream:
while True:
data = stream.read(4096)
if not data:
break
if size > 0xffffffff:
raise ValueError("file exceeds 4 GiB protocol offset limit")
ack = self.call(BLOCK, struct.pack("<I", size) + data, timeout=30)
size += len(data)
crc = zlib.crc32(data, crc) & 0xffffffff
if len(ack) != 8 or struct.unpack("<Q", ack)[0] != size:
raise ValueError("unexpected device offset")
result = self.call(END, struct.pack("<QI", size, crc), timeout=90)
if len(result) != 12 or struct.unpack("<QI", result) != (size, crc):
raise ValueError("device readback differs from source")
if self.verify(dest) != (size, crc):
raise ValueError("post-close verification differs")
log(f"OK {source} -> {dest} ({size} bytes, CRC32 {crc:08x})")
except Exception:
try:
self.call(CANCEL, timeout=1, retries=1)
except Exception:
pass
raise
def directory_entries(source):
root = Path(source)
for base, dirs, files in os.walk(root, followlinks=False):
dirs.sort()
files.sort()
for name in sorted(dirs + files):
path = Path(base) / name
mode = path.lstat().st_mode
if stat.S_ISLNK(mode) or (hasattr(path, "is_junction") and path.is_junction()):
raise ValueError(f"link/junction not allowed: {path}")
if not (stat.S_ISDIR(mode) or stat.S_ISREG(mode)):
raise ValueError(f"not a normal file or directory: {path}")
yield path, path.relative_to(root).parts, stat.S_ISDIR(mode)
def open_port(name):
port = serial.Serial()
port.port = name
port.baudrate = 115200
port.timeout = 0.2
port.write_timeout = 15
port.dsrdtr = False
port.rtscts = False
port.open()
try:
port.setRTS(False)
port.setDTR(True)
except (AttributeError, OSError):
pass
return port
def format_volume(client):
token = client.call(PREPARE)
if len(token) != 4:
raise ValueError("invalid format challenge")
# Confirmation is acquired before PREPARE so the 30-second device token cannot expire at the prompt.
client.call(CONFIRM, token + struct.pack("<I", client.seq), timeout=180, retries=1)
def format_with_confirmation(client, input_fn=input):
print("WARNING: this erases ALL files on the external NAND volume.")
if input_fn("Type FORMAT /extflash to continue: ") != "FORMAT /extflash":
raise ValueError("format cancelled")
format_volume(client)
print("NAND formatted and remounted")
def main(argv=None):
parser = argparse.ArgumentParser(description="Transfer files to external SPI NAND over USB Serial/JTAG")
parser.add_argument("port")
parser.add_argument("source", nargs="?")
parser.add_argument("destination", nargs="?")
parser.add_argument("--recursive", action="store_true")
group = parser.add_mutually_exclusive_group()
group.add_argument("--info", action="store_true")
group.add_argument("--list", metavar="DEVICE_DIR")
group.add_argument("--mkdir", metavar="DEVICE_DIR")
group.add_argument("--verify", metavar="DEVICE_FILE")
group.add_argument("--format", action="store_true")
args = parser.parse_args(argv)
if args.recursive and not (args.source and args.destination):
parser.error("--recursive requires source and destination")
if args.source or args.destination:
if not args.source or not args.destination or any((args.info, args.list, args.mkdir, args.verify, args.format)):
parser.error("specify both source and destination, with no command option")
elif not any((args.info, args.list, args.mkdir, args.verify, args.format)):
parser.error("specify a file/directory transfer or a command")
if serial is None:
parser.error("pyserial missing: py -3 -m pip install pyserial")
try:
with open_port(args.port) as port:
client = Client(port)
client.connect()
if args.format:
format_with_confirmation(client)
elif args.info:
page, pages, mounted, total, free = client.info()
print(f"NAND: {page * pages} bytes ({pages} pages x {page}); mounted={mounted}; FAT={total} bytes, free={free}")
elif args.list:
for is_dir, name, size in client.listdir(args.list):
print(f"{'DIR ' if is_dir else 'FILE'} {name} {size if not is_dir else ''}")
elif args.mkdir:
client.call(MKDIR, path_bytes(args.mkdir))
elif args.verify:
size, crc = client.verify(args.verify)
print(f"{args.verify}: {size} bytes, CRC32 {crc:08x}")
else:
source = Path(args.source)
if source.is_symlink() or (hasattr(source, "is_junction") and source.is_junction()):
raise ValueError("links and junctions are not allowed")
if source.is_file() and not args.recursive:
client.upload(source, args.destination)
elif source.is_dir() and args.recursive:
target = path_bytes(args.destination).decode("utf-8")
entries = list(directory_entries(source))
for _, parts, _ in entries:
path_bytes(target + "/" + "/".join(parts))
client.call(MKDIR, path_bytes(target))
for path, parts, is_dir in entries:
remote = target + "/" + "/".join(parts)
if is_dir:
client.call(MKDIR, path_bytes(remote))
else:
client.upload(path, remote)
elif not source.exists():
raise ValueError(f"source not found: {source}")
else:
raise ValueError("use --recursive for a directory; omit it for a single file")
except (OSError, ValueError, RuntimeError, TimeoutError, serial.SerialException) as exc:
parser.exit(1, f"error: {exc}\n")
if __name__ == "__main__":
main()
+328
View File
@@ -0,0 +1,328 @@
"""NAND transfer GUI: pick COM port, local folder, FLASH folder, then batch upload."""
import queue
import threading
import tkinter as tk
from pathlib import Path
from tkinter import filedialog, messagebox, simpledialog, ttk
import nand_put as nx
try:
from serial.tools import list_ports
except ImportError:
list_ports = None
LOCAL_DEFAULT = Path(r"E:\PET\EAF")
FLASH_DEFAULT = "/extflash/eaf"
def com_choices():
if list_ports is None:
return []
items = []
for port in list_ports.comports():
extra = port.description or ""
items.append((port.device, f"{port.device} {extra}".rstrip()))
items.sort(key=lambda item: (0 if "303A" in item[1].upper() or "SERIAL/JTAG" in item[1].upper() else 1, item[0]))
return items
def join_flash(root, *parts):
path = root.rstrip("/")
for part in parts:
if part:
path += "/" + str(part)
return path
class App(tk.Tk):
def __init__(self):
super().__init__()
self.title("NAND 传输")
self.geometry("820x560")
self.minsize(640, 420)
LOCAL_DEFAULT.mkdir(parents=True, exist_ok=True)
self._jobs = queue.Queue()
self._stop = threading.Event()
self._busy = False
self._build()
self.refresh_ports()
self.after(80, self._pump)
def _build(self):
pad = {"padx": 8, "pady": 4}
top = ttk.Frame(self)
top.pack(fill="x", **pad)
ttk.Label(top, text="端口").grid(row=0, column=0, sticky="w")
self.port = ttk.Combobox(top, width=42, state="readonly")
self.port.grid(row=0, column=1, sticky="ew", padx=4)
ttk.Button(top, text="刷新", command=self.refresh_ports).grid(row=0, column=2)
ttk.Label(top, text="本地目录").grid(row=1, column=0, sticky="w")
self.local = ttk.Entry(top)
self.local.insert(0, str(LOCAL_DEFAULT))
self.local.grid(row=1, column=1, sticky="ew", padx=4)
ttk.Button(top, text="浏览", command=self.browse_local).grid(row=1, column=2)
ttk.Label(top, text="FLASH目录").grid(row=2, column=0, sticky="w")
self.flash = ttk.Entry(top)
self.flash.insert(0, FLASH_DEFAULT)
self.flash.grid(row=2, column=1, sticky="ew", padx=4)
top.columnconfigure(1, weight=1)
bar = ttk.Frame(self)
bar.pack(fill="x", **pad)
self.btn_info = ttk.Button(bar, text="查询", command=self.on_info)
self.btn_info.pack(side="left", padx=(0, 6))
self.btn_list = ttk.Button(bar, text="列目录", command=self.on_list)
self.btn_list.pack(side="left", padx=(0, 6))
self.btn_put = ttk.Button(bar, text="批量写入", command=self.on_upload)
self.btn_put.pack(side="left", padx=(0, 6))
self.btn_format = ttk.Button(bar, text="格式化", command=self.on_format)
self.btn_format.pack(side="left", padx=(0, 6))
self.btn_stop = ttk.Button(bar, text="停止", command=self.on_stop, state="disabled")
self.btn_stop.pack(side="left")
self.progress = ttk.Progressbar(self, mode="determinate")
self.progress.pack(fill="x", padx=8, pady=(0, 4))
self.status = ttk.Label(self, text="空闲")
self.status.pack(anchor="w", padx=8)
pane = ttk.Panedwindow(self, orient="vertical")
pane.pack(fill="both", expand=True, padx=8, pady=4)
list_frame = ttk.Labelframe(pane, text="FLASH 目录")
log_frame = ttk.Labelframe(pane, text="日志")
pane.add(list_frame, weight=1)
pane.add(log_frame, weight=1)
self.listing = tk.Listbox(list_frame, font=("Consolas", 10), height=8)
scroll_list = ttk.Scrollbar(list_frame, command=self.listing.yview)
self.listing.configure(yscrollcommand=scroll_list.set)
self.listing.pack(side="left", fill="both", expand=True)
scroll_list.pack(side="right", fill="y")
self.log = tk.Text(log_frame, height=10, wrap="word", state="disabled")
scroll_log = ttk.Scrollbar(log_frame, command=self.log.yview)
self.log.configure(yscrollcommand=scroll_log.set)
self.log.pack(side="left", fill="both", expand=True)
scroll_log.pack(side="right", fill="y")
def refresh_ports(self):
choices = com_choices()
labels = [label for _, label in choices]
current = self.port.get()
self.port["values"] = labels
if not labels:
self.port.set("")
self._append("没有串口。确认板子已接上,并关掉其它占用 COM 的程序。")
return
devices = {label: device for device, label in choices}
self._port_map = devices
if current in devices:
self.port.set(current)
else:
self.port.current(0)
def browse_local(self):
start = self.local.get().strip() or str(LOCAL_DEFAULT)
picked = filedialog.askdirectory(title="选择本地目录", initialdir=start)
if picked:
self.local.delete(0, "end")
self.local.insert(0, picked)
def selected_port(self):
label = self.port.get().strip()
if not label:
raise ValueError("请选择串口")
return getattr(self, "_port_map", {}).get(label, label.split()[0])
def flash_dir(self):
path = self.flash.get().strip().replace("\\", "/")
if path != nx.ROOT and path.endswith("/"):
path = path.rstrip("/")
nx.path_bytes(path, allow_root=True)
return path
def on_info(self):
self._start("查询", self._job_info)
def on_list(self):
self._start("列目录", self._job_list)
def on_upload(self):
self._start("批量写入", self._job_upload)
def on_format(self):
if not messagebox.askokcancel(
"格式化 NAND",
"将清空外置 NAND 上的全部文件。\n新盘第一次使用需要格式化;已有表情文件的盘不要点。"):
return
typed = simpledialog.askstring("确认格式化", "请输入 FORMAT /extflash")
if typed != "FORMAT /extflash":
self._append("格式化已取消")
return
self._start("格式化", self._job_format)
def on_stop(self):
self._stop.set()
self._append("停止:当前文件结束后不再继续")
def _start(self, title, job):
if self._busy:
return
try:
port = self.selected_port()
flash = self.flash_dir()
local = Path(self.local.get().strip() or str(LOCAL_DEFAULT))
except Exception as exc:
messagebox.showerror("参数错误", str(exc))
return
self._busy = True
self._stop.clear()
self.btn_info.configure(state="disabled")
self.btn_list.configure(state="disabled")
self.btn_put.configure(state="disabled")
self.btn_format.configure(state="disabled")
self.btn_stop.configure(state="normal")
self.status.configure(text=title + "…")
threading.Thread(target=self._run, args=(title, job, port, local, flash), daemon=True).start()
def _run(self, title, job, port, local, flash):
try:
if nx.serial is None:
raise ValueError("pyserial missing: py -3 -m pip install pyserial")
with nx.open_port(port) as serial_port:
client = nx.Client(serial_port)
client.connect()
job(client, local, flash)
self._post(lambda: self.status.configure(text=title + "完成"))
except Exception as exc:
self._append("错误: " + str(exc))
self._post(lambda: self.status.configure(text=title + "失败"))
finally:
self._post(self._idle)
def _job_info(self, client, local, flash):
page, pages, mounted, total, free = client.info()
self._append(
f"NAND {page * pages} bytes; mounted={mounted}; FAT={total} bytes, free={free}")
if not mounted:
self._append("未挂载:新盘需要先点「格式化」,再列目录或写入。")
def _job_format(self, client, local, flash):
self._append("正在格式化 NAND,可能需要一两分钟…")
nx.format_volume(client)
self._append("格式化完成并已重新挂载")
self._job_info(client, local, flash)
def _job_list(self, client, local, flash):
rows = []
for is_dir, name, size in client.listdir(flash):
if is_dir:
rows.append(f"DIR {name}")
else:
rows.append(f"FILE {name} {size}")
if not rows:
rows.append("(空目录)")
self._append(f"列目录 {flash} ({len(rows)} 项)")
self._post(lambda: self._show_listing(flash, rows))
def _job_upload(self, client, local, flash):
if not local.is_dir():
raise ValueError(f"本地目录不存在: {local}")
entries = list(nx.directory_entries(local))
files = [(path, parts) for path, parts, is_dir in entries if not is_dir]
dirs = [parts for _, parts, is_dir in entries if is_dir]
if not files and not dirs:
self._append(f"本地目录没有可上传的文件: {local}")
return
total = len(files)
self._post(lambda: self._set_progress(0, max(total, 1)))
if flash != nx.ROOT:
client.call(nx.MKDIR, nx.path_bytes(flash))
for parts in dirs:
if self._stop.is_set():
self._append("已停止")
return
client.call(nx.MKDIR, nx.path_bytes(join_flash(flash, *parts)))
done = 0
failed = 0
for path, parts in files:
if self._stop.is_set():
self._append("已停止")
break
dest = join_flash(flash, *parts)
self._append(f"写入 {path.name} -> {dest}")
try:
client.upload(path, dest, log=self._append)
done += 1
except Exception as exc:
failed += 1
self._append(f"失败 {path.name}: {exc}")
n = done + failed
self._post(lambda n=n, total=total: self._set_progress(n, total))
self._append(f"批量结束:成功 {done}/{total}" + (f",失败 {failed}" if failed else ""))
try:
self._job_list(client, local, flash)
except Exception as exc:
self._append("写入后列目录失败: " + str(exc))
def _set_progress(self, value, maximum):
self.progress.configure(maximum=max(maximum, 1), value=value)
self.status.configure(text=f"{value}/{maximum}")
def _show_listing(self, flash, rows):
self.listing.delete(0, "end")
self.listing.insert("end", flash)
for row in rows:
self.listing.insert("end", " " + row)
def _idle(self):
self._busy = False
self.btn_info.configure(state="normal")
self.btn_list.configure(state="normal")
self.btn_put.configure(state="normal")
self.btn_format.configure(state="normal")
self.btn_stop.configure(state="disabled")
def _append(self, line):
self._jobs.put(line)
def _post(self, fn):
self._jobs.put(fn)
def _pump(self):
try:
while True:
item = self._jobs.get_nowait()
if callable(item):
item()
else:
self.log.configure(state="normal")
self.log.insert("end", item + "\n")
self.log.see("end")
self.log.configure(state="disabled")
except queue.Empty:
pass
self.after(80, self._pump)
def main():
try:
from ctypes import windll
windll.shcore.SetProcessDpiAwareness(1)
except Exception:
pass
try:
App().mainloop()
except Exception as exc:
try:
messagebox.showerror("NAND 传输", str(exc))
except Exception:
raise
if __name__ == "__main__":
main()
+37
View File
@@ -0,0 +1,37 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_submodules
hidden = collect_submodules("serial")
hidden += ["nand_put"]
a = Analysis(
["nand_put_gui.py"],
pathex=["."],
binaries=[],
datas=[],
hiddenimports=hidden,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=["unittest", "test_nand_put"],
noarchive=False,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name="NAND_Xfer",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
+15
View File
@@ -0,0 +1,15 @@
CONFIG_IDF_TARGET="esp32p4"
CONFIG_IDF_EXPERIMENTAL_FEATURES=y
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="../partitions.csv"
CONFIG_ESPTOOLPY_FLASHSIZE_32MB=y
CONFIG_ESPTOOLPY_FLASHMODE_QIO=y
CONFIG_SPI_FLASH_SUPPORT_MXIC_CHIP=y
CONFIG_FATFS_LFN_HEAP=y
CONFIG_FATFS_MAX_LFN=255
CONFIG_FATFS_API_ENCODING_UTF_8=y
CONFIG_FATFS_VOLUME_COUNT=2
CONFIG_ESP_CONSOLE_UART_DEFAULT=y
# CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG is not set
CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
# CONFIG_NAND_FLASH_ENABLE_BDL is not set
+161
View File
@@ -0,0 +1,161 @@
import struct
import tempfile
import time
import unittest
import zlib
from pathlib import Path
from unittest.mock import patch
import nand_put as x
class FakePort:
def __init__(self, callback):
self.callback = callback
self.rx = bytearray()
self.requests = []
def write(self, data):
self.requests.append(data)
reply = self.callback(data, len(self.requests))
if reply:
self.rx.extend(reply)
return len(data)
def read(self, size):
data = self.rx[:size]
del self.rx[:size]
return bytes(data)
def reply(request, status=0, payload=b""):
magic, _, cmd, _, seq, size = x.HEADER.unpack(request[:16])
assert magic == x.MAGIC and len(request) == 20 + size
assert zlib.crc32(request[:-4]) == struct.unpack("<I", request[-4:])[0]
return x.encode(cmd | 0x80, seq, bytes([status]) + payload)
class ProtocolTests(unittest.TestCase):
def test_handshake_nonce_and_rejection(self):
port = FakePort(lambda data, _: reply(data, payload=data[16:20]))
port.rx.extend(b"NAND-XFER READY\n")
with patch.object(x.secrets, "token_bytes", return_value=b"test"):
x.Client(port).connect()
self.assertEqual(port.requests[0][5], x.HELLO)
broken = FakePort(lambda data, _: reply(data, payload=b"bad!"))
broken.rx.extend(b"NAND-XFER READY\n")
with self.assertRaisesRegex(ValueError, "handshake"):
x.Client(broken).connect()
def test_connect_hello_without_ready_banner(self):
port = FakePort(lambda data, _: reply(data, payload=data[16:20]))
client = x.Client(port)
client.wait_ready = lambda timeout=0.4: False
with patch.object(x.secrets, "token_bytes", return_value=b"test"):
client.connect()
self.assertEqual(port.requests[0][5], x.HELLO)
def test_crc_and_large_frame(self):
packet = x.encode(x.BLOCK, 0x12345678, bytes(range(256)) * 16)
self.assertEqual(len(packet), 16 + 4096 + 4)
self.assertEqual(zlib.crc32(packet[:-4]), struct.unpack("<I", packet[-4:])[0])
with self.assertRaises(ValueError):
x.encode(x.BLOCK, 1, bytes(4353))
def test_utf8_paths_and_escape(self):
self.assertEqual(x.path_bytes("/extflash/eaf/文件 1.eaf"),
b"/extflash/eaf/" + "文件 1.eaf".encode())
for path in ("/extflash2/a", "/extflash/../a", "/extflash//a",
"/extflash/a.", "/extflash/a?b", "/extflash/a\x00b"):
with self.subTest(path=path), self.assertRaises(ValueError):
x.path_bytes(path)
def test_lost_ack_reuses_sequence_and_packet(self):
port = FakePort(lambda data, n: None if n == 1 else reply(data, payload=b"done"))
result = x.Client(port).call(x.BLOCK, b"test", timeout=0.001)
self.assertEqual(result, b"done")
self.assertEqual(port.requests, [port.requests[0], port.requests[0]])
def test_bad_crc_then_retry(self):
port = FakePort(lambda data, n: reply(data)[:-1] + b"!" if n == 1 else reply(data))
self.assertEqual(x.Client(port).call(x.INFO, timeout=0.01), b"")
self.assertEqual(len(port.requests), 2)
def test_device_error_is_not_retried(self):
port = FakePort(lambda data, n: reply(data, status=4))
with self.assertRaisesRegex(RuntimeError, "I/O error"):
x.Client(port).call(x.BLOCK, b"x")
self.assertEqual(len(port.requests), 1)
def test_paged_directory(self):
first = struct.pack("<HB", 1, 0) + struct.pack("<BBQ", 1, 3, 0) + b"eaf"
second = struct.pack("<HB", 2, 1) + struct.pack("<BBQ", 0, 5, 12) + b"a.eaf"
port = FakePort(lambda data, n: reply(data, payload=first if n == 1 else second))
self.assertEqual(list(x.Client(port).listdir("/extflash")),
[(1, "eaf", 0), (0, "a.eaf", 12)])
self.assertEqual(port.requests[0][16:18], b"\x00\x00")
self.assertEqual(port.requests[1][16:18], b"\x01\x00")
def test_format_cancel_never_contacts_device(self):
class DenyClient:
def call(self, command, *args, **kwargs):
self.fail("device contacted")
with self.assertRaisesRegex(ValueError, "cancelled"):
x.format_with_confirmation(DenyClient(), input_fn=lambda _: "no")
def test_format_challenge_and_single_confirm(self):
calls = []
class Client:
seq = 123
def call(self, cmd, payload=b"", **kwargs):
calls.append((cmd, payload, kwargs))
return b"\x12\x34\x56\x78" if cmd == x.PREPARE else b""
x.format_with_confirmation(Client(), input_fn=lambda _: "FORMAT /extflash")
self.assertEqual([(c, data) for c, data, _ in calls],
[(x.PREPARE, b""), (x.CONFIRM, b"\x12\x34\x56\x78" + struct.pack("<I", 123))])
self.assertEqual(calls[1][2]["retries"], 1)
def test_directory_rejects_link_and_keeps_relative_names(self):
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
(root / "nested").mkdir()
(root / "nested" / "a.eaf").write_bytes(b"abc")
entries = list(x.directory_entries(root))
self.assertEqual([(p, is_dir) for _, p, is_dir in entries],
[(('nested',), True), (('nested', 'a.eaf'), False)])
with patch.object(Path, "is_junction", lambda self: self.name == "nested"):
with self.assertRaisesRegex(ValueError, "junction"):
list(x.directory_entries(root))
def test_upload_empty_file_readback(self):
commands = []
def callback(packet, _):
_, _, cmd, _, _, _ = x.HEADER.unpack(packet[:16])
commands.append(cmd)
return reply(packet, payload=struct.pack("<QI", 0, 0) if cmd in (x.END, x.VERIFY) else b"")
with tempfile.TemporaryDirectory() as folder:
source = Path(folder) / "empty"
source.touch()
x.Client(FakePort(callback)).upload(source, "/extflash/empty")
self.assertEqual(commands, [x.BEGIN, x.END, x.VERIFY])
def test_upload_error_stops_quickly(self):
def callback(packet, _):
if packet[5] == x.BEGIN:
return reply(packet, status=5)
return None
port = FakePort(callback)
port.timeout = 0.05
with tempfile.TemporaryDirectory() as folder:
source = Path(folder) / "a.txt"
source.write_bytes(b"hi")
started = time.monotonic()
with self.assertRaisesRegex(RuntimeError, "bad state"):
x.Client(port).upload(source, "/extflash/a.txt")
self.assertLess(time.monotonic() - started, 3)
self.assertEqual(port.requests[0][5], x.BEGIN)
self.assertEqual(port.requests[1][5], x.CANCEL)
if __name__ == "__main__":
unittest.main()