初始提交卡包 USB 工程
纳入固件源码、屏幕组件、nand_xfer 工具和文档。忽略本机构建产物、sdkconfig,以及含本机路径的依赖锁。 Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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 }
|
||||
@@ -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" }
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user