Add CYD photo album with jpg/png/gif viewer and sample assets.

Ship TF-card album UI, half-res decode canvas, prebuilt firmware, and documented flash/upload workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-19 18:07:56 +08:00
commit 99618a46b0
35 changed files with 3211 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
.pio/
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
*.pyc
__pycache__/
.DS_Store
Thumbs.db
*.elf
# 构建产物不入库;firmware/ 预编译包要入库
# assets/photos 样张入库,其它临时资源可忽略
assets/*
!assets/photos/
!assets/photos/**

9
CHANGELOG.md Normal file
View File

@@ -0,0 +1,9 @@
# Changelog
## 2026-10-19 — Photo album
- CYD LVGL 壳层来自 tftest(触摸映射冻结,TF + SoftSPI)。
- 扫描 `/photos` 与 `/` 下 jpg/png/gif;点左/右或 `<`/`>` 切换。
- JPEGDEC / PNGdec / AnimatedGIF 解码到 160×120 RGB565 画布,界面 2× 显示。
- 提供 `assets/photos` 样张与 `tools/make_test_photos.py`;串口 `#PUT` 上传。
- 预编译包:`firmware/cyd/`。

57
README.md Normal file
View File

@@ -0,0 +1,57 @@
# CYD Photo Album
ESP32-2432S028R 电子相册:从 TF 卡读取 **JPG / PNG / GIF**,触控切换相片。
硬件与触摸标定复用 `tftest`(SoftSPI + BL/TR 对角修正,**勿改** `src/touch.cpp`)。
## 功能
- 扫描 TF 卡 `/photos` 与根目录的 `.jpg` / `.jpeg` / `.png` / `.gif`
- 半分辨率画布 160×120,界面 2× 放大到 320×240(适配无 PSRAM 的 DRAM)
- JPG 过大自动 1/2、1/4、1/8 缩放;PNG / GIF 等比缩入画布
- **点左半屏 = 上一张,点右半屏 = 下一张**;底栏 `<` / `>` 同样切换
- GIF 自动播帧;右上角显示 TF 状态
- 串口 `#PUT` 传文件到卡(`tools/sd_put.py`)
## 测试图
样张在 `assets/photos/`(已入库):
| 文件 | 格式 |
|------|------|
| `01_blue.jpg` | JPG |
| `02_red.jpg` | JPG |
| `03_green.png` | PNG |
| `04_bounce.gif` | GIF |
```bash
python tools/make_test_photos.py
python tools/sd_put.py assets/photos/01_blue.jpg --port COM3 --remote /01_blue.jpg
python tools/sd_put.py assets/photos/02_red.jpg --port COM3 --remote /02_red.jpg
python tools/sd_put.py assets/photos/03_green.png --port COM3 --remote /03_green.png
python tools/sd_put.py assets/photos/04_bounce.gif --port COM3 --remote /04_bounce.gif
```
## 构建 / 烧录
```bash
pio run -e cyd -t upload --upload-port COM3
```
或先导出再刷预编译包:
```powershell
.\firmware\export_from_build.ps1
cd firmware\cyd
.\flash.ps1 -Port COM3
```
## 目录要点
| 路径 | 说明 |
|------|------|
| `src/touch.cpp` | 触摸标定,勿改 |
| `src/photo_list.cpp` | 扫描相册文件 |
| `src/photo_view.cpp` | JPG/PNG/GIF 解码 |
| `src/ui.cpp` | 相册界面与切换 |
| `firmware/cyd/` | 预编译固件 + `flash.ps1` |

BIN
assets/photos/01_blue.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
assets/photos/02_red.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
assets/photos/03_green.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

BIN
assets/photos/04_bounce.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

13
copy_lv_conf.py Normal file
View File

@@ -0,0 +1,13 @@
Import("env") # noqa: F821
from pathlib import Path
src = Path(env["PROJECT_DIR"]) / "include" / "lv_conf.h" # noqa: F821
dst_dir = Path(env["PROJECT_LIBDEPS_DIR"]) / env["PIOENV"] # noqa: F821
dst_dir.mkdir(parents=True, exist_ok=True)
dst = dst_dir / "lv_conf.h"
if src.exists():
dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
lvgl = dst_dir / "lvgl" / "lv_conf.h"
if (dst_dir / "lvgl").exists():
lvgl.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
print(f"copy_lv_conf: synced -> {dst}")

47
firmware/README.md Normal file
View File

@@ -0,0 +1,47 @@
# 预编译固件(可随时刷写)
当前归档目录:[`cyd/`](cyd/)
对应板子:**CYD ESP32-2432S028R**(ILI9341)
功能:电子相册(JPG / PNG / GIF)+ SoftSPI 触控 + TF 卡
版本信息见 [`cyd/VERSION.txt`](cyd/VERSION.txt)。
## 直接刷写(无需重新编译)
1. USB 连接板子
2. 执行:
```powershell
cd firmware\cyd
.\flash.ps1
# 或指定端口
.\flash.ps1 -Port COM3
```
也可运行 `flash.bat`。
依赖其一即可:
- 已安装 PlatformIO(`pio`,自带 esptool)
- 或 `pip install esptool`
## 手动 esptool
```bash
esptool.py --chip esp32 --port COM3 --baud 921600 write_flash -z ^
--flash_mode dio --flash_freq 40m --flash_size 4MB ^
0x1000 bootloader.bin ^
0x8000 partitions.bin ^
0xe000 boot_app0.bin ^
0x10000 firmware.bin
```
## 从源码重新打包进本目录
在仓库根目录:
```powershell
.\firmware\export_from_build.ps1
# 仅复制、不编译:
.\firmware\export_from_build.ps1 -SkipBuild
```

16
firmware/cyd/VERSION.txt Normal file
View File

@@ -0,0 +1,16 @@
name: CYD Photo album (cyd)
git: unknown
date: 2026-10-19
board: ESP32-2432S028R (ILI9341)
features: jpg/png/gif album, SoftSPI touch, TF
flash_size: 4MB
offsets:
0x1000 bootloader.bin
0x8000 partitions.bin
0xe000 boot_app0.bin
0x10000 firmware.bin
sha256:
bootloader.bin 3D234A7471F67B013686DABD4DEE7C1FA915C9928463616A94BC9297ACF1ABF8
boot_app0.bin F94C5D786A7A8FAB06AC5D10E33BF37711A6697636DC037559EA19CC410A17F0
firmware.bin CB21DB25D8AAC041B39C10AA2E63E00D2401D1A7EA4F956F9B4985D1EA87C8EB
partitions.bin 148B959CBFF1C38AA8E1D5C0BA9D612C54997B945E56A63F41223EEF650653A1

BIN
firmware/cyd/boot_app0.bin Normal file

Binary file not shown.

BIN
firmware/cyd/bootloader.bin Normal file

Binary file not shown.

BIN
firmware/cyd/firmware.bin Normal file

Binary file not shown.

5
firmware/cyd/flash.bat Normal file
View File

@@ -0,0 +1,5 @@
@echo off
setlocal
cd /d "%~dp0"
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0flash.ps1" %*
exit /b %ERRORLEVEL%

98
firmware/cyd/flash.ps1 Normal file
View File

@@ -0,0 +1,98 @@
# Flash archived CYD TF+Speaker firmware (no rebuild needed)
# Usage:
# .\flash.ps1
# .\flash.ps1 -Port COM5
# .\flash.ps1 -Port COM5 -Baud 460800
param(
[string]$Port = "",
[int]$Baud = 921600
)
$ErrorActionPreference = "Stop"
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $here
function Find-Esptool {
$pio = Get-Command pio -ErrorAction SilentlyContinue
if (-not $pio) {
$pioPath = Join-Path $env:USERPROFILE "AppData\Local\Programs\Python\Python312\Scripts\pio.exe"
if (Test-Path $pioPath) {
$env:Path = "$(Split-Path $pioPath);$env:Path"
$pio = Get-Command pio -ErrorAction SilentlyContinue
}
}
if ($pio) {
$pkg = Join-Path $env:USERPROFILE ".platformio\packages\tool-esptoolpy\esptool.py"
if (Test-Path $pkg) { return @{ Kind = "py"; Path = $pkg } }
}
$py = Get-Command python -ErrorAction SilentlyContinue
if ($py) {
try {
& python -c "import esptool" 2>$null
if ($LASTEXITCODE -eq 0) { return @{ Kind = "mod"; Path = "esptool" } }
} catch {}
}
$esp = Get-Command esptool.py -ErrorAction SilentlyContinue
if ($esp) { return @{ Kind = "py"; Path = $esp.Source } }
return $null
}
if (-not $Port) {
$ports = @()
try {
Add-Type -AssemblyName System.IO.Ports -ErrorAction SilentlyContinue | Out-Null
$ports = [System.IO.Ports.SerialPort]::GetPortNames() | Sort-Object
} catch {}
if (-not $ports -or $ports.Count -eq 0) {
$ports = Get-CimInstance Win32_SerialPort -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty DeviceID | Sort-Object
}
if (-not $ports -or $ports.Count -eq 0) {
Write-Error "No serial ports found. Plug in the CYD USB cable, then retry with -Port COMx"
}
if ($ports.Count -eq 1) {
$Port = $ports[0]
} else {
Write-Host "Available ports: $($ports -join ', ')"
$Port = $ports[-1]
Write-Host "Using last port: $Port (override with -Port)"
}
}
$tool = Find-Esptool
if (-not $tool) {
Write-Error "esptool not found. Install PlatformIO (`pio`) or: pip install esptool"
}
$needed = @("bootloader.bin", "partitions.bin", "boot_app0.bin", "firmware.bin")
foreach ($f in $needed) {
if (-not (Test-Path (Join-Path $here $f))) {
Write-Error "Missing $f — run ..\export_from_build.ps1 from repo root first"
}
}
$argsCommon = @(
"--chip", "esp32",
"--port", $Port,
"--baud", "$Baud",
"--before", "default_reset",
"--after", "hard_reset",
"write_flash", "-z",
"--flash_mode", "dio",
"--flash_freq", "40m",
"--flash_size", "4MB",
"0x1000", "bootloader.bin",
"0x8000", "partitions.bin",
"0xe000", "boot_app0.bin",
"0x10000", "firmware.bin"
)
Write-Host "Flashing $here -> $Port @ $Baud"
if ($tool.Kind -eq "mod") {
& python -m esptool @argsCommon
} else {
& python $tool.Path @argsCommon
}
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host "Done. Open serial monitor at 115200 if needed."

BIN
firmware/cyd/partitions.bin Normal file

Binary file not shown.

View File

@@ -0,0 +1,58 @@
# Rebuild (optional) and refresh firmware/cyd from PlatformIO build output.
# Usage:
# .\firmware\export_from_build.ps1
# .\firmware\export_from_build.ps1 -SkipBuild
param([switch]$SkipBuild)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
Set-Location $root
$env:Path = "C:\Users\dsx2016\AppData\Local\Programs\Python\Python312\Scripts;" + $env:Path
if (-not $SkipBuild) {
pio run -e cyd
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
$src = Join-Path $root ".pio\build\cyd"
$dst = Join-Path $root "firmware\cyd"
New-Item -ItemType Directory -Force -Path $dst | Out-Null
foreach ($f in @("bootloader.bin", "partitions.bin", "firmware.bin")) {
$p = Join-Path $src $f
if (-not (Test-Path $p)) { Write-Error "Missing build artifact: $p" }
Copy-Item $p $dst -Force
}
$bootApp = Join-Path $env:USERPROFILE ".platformio\packages\framework-arduinoespressif32\tools\partitions\boot_app0.bin"
if (-not (Test-Path $bootApp)) {
Write-Error "boot_app0.bin not found under PlatformIO packages"
}
Copy-Item $bootApp (Join-Path $dst "boot_app0.bin") -Force
# Keep flash scripts if already present
$git = ""
try { $git = (git -C $root rev-parse --short HEAD).Trim() } catch { $git = "unknown" }
$date = Get-Date -Format "yyyy-MM-dd"
$hashes = Get-ChildItem (Join-Path $dst "*.bin") | Get-FileHash -Algorithm SHA256
$lines = @(
"name: CYD Photo album (cyd)",
"git: $git",
"date: $date",
"board: ESP32-2432S028R (ILI9341)",
"features: jpg/png/gif album, SoftSPI touch, TF card",
"flash_size: 4MB",
"offsets:",
" 0x1000 bootloader.bin",
" 0x8000 partitions.bin",
" 0xe000 boot_app0.bin",
" 0x10000 firmware.bin",
"sha256:"
)
foreach ($h in $hashes) {
$lines += (" {0,-15} {1}" -f (Split-Path $h.Path -Leaf), $h.Hash)
}
$lines | Set-Content -Encoding utf8 (Join-Path $dst "VERSION.txt")
Write-Host "Exported -> $dst (git $git)"

1127
include/lv_conf.h Normal file

File diff suppressed because it is too large Load Diff

21
include/photo_list.h Normal file
View File

@@ -0,0 +1,21 @@
#pragma once
#include <stdint.h>
static constexpr int PHOTO_MAX = 64;
static constexpr int PHOTO_PATH_MAX = 96;
enum PhotoKind : uint8_t {
PHOTO_JPG = 0,
PHOTO_PNG = 1,
PHOTO_GIF = 2,
};
struct PhotoEntry {
char path[PHOTO_PATH_MAX];
PhotoKind kind;
};
uint16_t photo_list_count();
const PhotoEntry *photo_list_get(uint16_t index);
uint16_t photo_list_scan(); // returns count

22
include/photo_view.h Normal file
View File

@@ -0,0 +1,22 @@
#pragma once
#include <lvgl.h>
#include <stdint.h>
#include "photo_list.h"
bool photo_view_init();
void photo_view_deinit();
// Decode entry into half-res RGB565 canvas (160x120, shown 2x).
bool photo_view_show(const PhotoEntry *entry);
const lv_image_dsc_t *photo_view_dsc();
uint16_t photo_view_canvas_w();
uint16_t photo_view_canvas_h();
uint16_t photo_view_img_w();
uint16_t photo_view_img_h();
bool photo_view_is_gif();
// Advance GIF frame if due; returns true when pixels changed.
bool photo_view_gif_tick();

24
include/pins.h Normal file
View File

@@ -0,0 +1,24 @@
#pragma once
// CYD ESP32-2432S028R
#define TFT_BL_PIN 21
// TF / microSD (VSPI;触摸用 SoftSPI,不抢总线)
#define SD_MISO 19
#define SD_MOSI 23
#define SD_SCK 18
#define SD_CS 5
#define SPEAKER_PIN 26
#define CYD_TOUCH_IRQ 36
#define CYD_TOUCH_MOSI 32
#define CYD_TOUCH_MISO 39
#define CYD_TOUCH_CLK 25
#define CYD_TOUCH_CS 33
#define SCREEN_WIDTH 240
#define SCREEN_HEIGHT 320
#define UI_HOR_RES 320
#define UI_VER_RES 240

21
include/sd_info.h Normal file
View File

@@ -0,0 +1,21 @@
#pragma once
#include <Arduino.h>
struct SdCardInfo {
bool mounted;
bool present;
const char *type;
const char *filesystem;
uint64_t card_bytes;
uint64_t total_bytes;
uint64_t used_bytes;
uint64_t free_bytes;
uint32_t root_files;
String error;
};
bool sd_init();
void sd_deinit();
SdCardInfo sd_read_info();
String format_bytes(uint64_t bytes);

7
include/sd_put.h Normal file
View File

@@ -0,0 +1,7 @@
#pragma once
#include <Arduino.h>
// 串口协议: "#PUT <path> <bytes>\n" -> READY -> raw -> DONE/FAIL
// 在 loop 里调用;返回 true 表示刚处理完一次传输
bool sd_put_poll();

12
include/touch.h Normal file
View File

@@ -0,0 +1,12 @@
#pragma once
#include <lvgl.h>
#include <stdint.h>
void touch_init();
void touch_read_cb(lv_indev_t *indev, lv_indev_data_t *data);
// 调试:最近一次触点(物理 / LVGL 旋转后逻辑)
extern int16_t g_dbg_phys_x, g_dbg_phys_y;
extern int16_t g_dbg_logic_x, g_dbg_logic_y;
extern bool g_dbg_touch_down;

9
include/ui.h Normal file
View File

@@ -0,0 +1,9 @@
#pragma once
#include "sd_info.h"
#include <stdint.h>
void ui_create();
void ui_update(const SdCardInfo &info);
void ui_tick();
void ui_reload_album(); // rescan TF and show current / first photo

59
platformio.ini Normal file
View File

@@ -0,0 +1,59 @@
; CYD ESP32-2432S028 — 电子相册(TF + 触控 + LVGL)
[env:cyd]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 460800
upload_speed = 460800
board_build.partitions = default.csv
lib_deps =
bodmer/TFT_eSPI@^2.5.43
lvgl/lvgl@9.2.2
bitbank2/JPEGDEC@^1.5.0
bitbank2/PNGdec@^1.0.3
bitbank2/AnimatedGIF@^2.1.1
; JPEGDEC 声明依赖 bb_spi_lcd,本项目不需要
lib_ignore =
bb_spi_lcd
build_flags =
-DCORE_DEBUG_LEVEL=0
-DUSER_SETUP_LOADED=1
-DILI9341_2_DRIVER=1
-DTFT_WIDTH=240
-DTFT_HEIGHT=320
-DTFT_MISO=12
-DTFT_MOSI=13
-DTFT_SCLK=14
-DTFT_CS=15
-DTFT_DC=2
-DTFT_RST=-1
-DTFT_BL=21
-DTFT_BACKLIGHT_ON=HIGH
-DTOUCH_CS=-1
-DLOAD_GLCD=1
-DLOAD_FONT2=1
-DLOAD_FONT4=1
-DLOAD_FONT6=1
-DLOAD_FONT7=1
-DLOAD_FONT8=1
-DLOAD_GFXFF=1
-DSMOOTH_FONT=1
-DSPI_FREQUENCY=40000000
-DSPI_READ_FREQUENCY=20000000
-DLV_CONF_SKIP
-DLV_COLOR_DEPTH=16
-DLV_MEM_SIZE=24576
-DLV_FONT_MONTSERRAT_14=1
-DLV_FONT_MONTSERRAT_16=1
-DLV_FONT_MONTSERRAT_20=1
-DLV_USE_TFT_ESPI=1
-DLV_USE_FLEX=1
-DLV_USE_BUTTON=1
-DLV_USE_LABEL=1
-DLV_USE_IMAGE=1
-I${PROJECT_DIR}/include
extra_scripts = pre:copy_lv_conf.py

91
src/main.cpp Normal file
View File

@@ -0,0 +1,91 @@
#include <Arduino.h>
#include <lvgl.h>
#include "pins.h"
#include "photo_view.h"
#include "sd_info.h"
#include "sd_put.h"
#include "touch.h"
#include "ui.h"
static constexpr uint32_t DRAW_BUF_SIZE =
(SCREEN_WIDTH * SCREEN_HEIGHT / 10 * (LV_COLOR_DEPTH / 8));
static uint32_t draw_buf[DRAW_BUF_SIZE / 4];
static uint32_t last_refresh_ms = 0;
static constexpr uint32_t REFRESH_MS = 10000;
static void refresh_sd_ui(bool rescan_photos) {
SdCardInfo info = sd_read_info();
Serial.println("---- TF Card ----");
if (!info.mounted) {
Serial.printf("Error: %s\n", info.error.c_str());
} else {
Serial.printf("Type: %s FS: %s Cap: %s\n", info.type, info.filesystem,
format_bytes(info.card_bytes).c_str());
}
ui_update(info);
if (rescan_photos && info.mounted) {
ui_reload_album();
}
}
void setup() {
Serial.setRxBufferSize(16384);
Serial.begin(460800);
delay(200);
Serial.println();
Serial.printf("LVGL %d.%d.%d | CYD Photo Album\n", lv_version_major(),
lv_version_minor(), lv_version_patch());
Serial.println("BOOT_OK");
pinMode(TFT_BL_PIN, OUTPUT);
digitalWrite(TFT_BL_PIN, HIGH);
touch_init();
if (!photo_view_init()) {
Serial.println("WARN: photo canvas alloc failed");
}
lv_init();
lv_display_t *disp =
lv_tft_espi_create(SCREEN_WIDTH, SCREEN_HEIGHT, draw_buf, sizeof(draw_buf));
lv_display_set_rotation(disp, LV_DISPLAY_ROTATION_270);
lv_indev_t *indev = lv_indev_create();
lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER);
lv_indev_set_read_cb(indev, touch_read_cb);
lv_indev_set_display(indev, disp);
ui_create();
if (!sd_init()) {
Serial.println("SD mount failed at boot");
}
refresh_sd_ui(true);
last_refresh_ms = millis();
}
void loop() {
if (sd_put_poll()) {
refresh_sd_ui(true);
}
lv_timer_handler();
lv_tick_inc(5);
ui_tick();
const uint32_t now = millis();
if (now - last_refresh_ms >= REFRESH_MS) {
last_refresh_ms = now;
// Keep TF status fresh; do not rescan photos (avoids reset while viewing)
refresh_sd_ui(false);
}
delay(5);
}

101
src/photo_list.cpp Normal file
View File

@@ -0,0 +1,101 @@
#include "photo_list.h"
#include <SD.h>
#include <string.h>
static PhotoEntry g_list[PHOTO_MAX];
static uint16_t g_count = 0;
static bool ends_with_ci(const char *name, const char *ext) {
const size_t n = strlen(name);
const size_t e = strlen(ext);
if (n < e) {
return false;
}
for (size_t i = 0; i < e; i++) {
char a = name[n - e + i];
char b = ext[i];
if (a >= 'A' && a <= 'Z') {
a = (char)(a - 'A' + 'a');
}
if (b >= 'A' && b <= 'Z') {
b = (char)(b - 'A' + 'a');
}
if (a != b) {
return false;
}
}
return true;
}
static bool classify(const char *name, PhotoKind *kind) {
if (ends_with_ci(name, ".jpg") || ends_with_ci(name, ".jpeg")) {
*kind = PHOTO_JPG;
return true;
}
if (ends_with_ci(name, ".png")) {
*kind = PHOTO_PNG;
return true;
}
if (ends_with_ci(name, ".gif")) {
*kind = PHOTO_GIF;
return true;
}
return false;
}
static void add_file(const char *dir, const char *name, PhotoKind kind) {
if (g_count >= PHOTO_MAX) {
return;
}
PhotoEntry *e = &g_list[g_count];
if (dir[0] == '/' && dir[1] == '\0') {
snprintf(e->path, sizeof(e->path), "/%s", name);
} else {
snprintf(e->path, sizeof(e->path), "%s/%s", dir, name);
}
e->kind = kind;
g_count++;
}
static void scan_dir(const char *dir) {
File root = SD.open(dir);
if (!root || !root.isDirectory()) {
if (root) {
root.close();
}
return;
}
File f = root.openNextFile();
while (f) {
if (!f.isDirectory()) {
const char *name = f.name();
// SD lib may return full path; keep basename
const char *base = strrchr(name, '/');
base = base ? base + 1 : name;
PhotoKind kind;
if (classify(base, &kind)) {
add_file(dir, base, kind);
}
}
f = root.openNextFile();
}
root.close();
}
uint16_t photo_list_count() { return g_count; }
const PhotoEntry *photo_list_get(uint16_t index) {
if (index >= g_count) {
return nullptr;
}
return &g_list[index];
}
uint16_t photo_list_scan() {
g_count = 0;
scan_dir("/photos");
scan_dir("/");
return g_count;
}

496
src/photo_view.cpp Normal file
View File

@@ -0,0 +1,496 @@
#include "photo_view.h"
#include "pins.h"
#include <AnimatedGIF.h>
#include <Arduino.h>
#include <JPEGDEC.h>
#include <PNGdec.h>
#include <SD.h>
#include <string.h>
// Half-res canvas (fits ESP32 DRAM). UI shows it at 2x -> 320x240.
static constexpr uint16_t CANVAS_W = UI_HOR_RES / 2;
static constexpr uint16_t CANVAS_H = UI_VER_RES / 2;
static constexpr size_t CANVAS_PIXELS = (size_t)CANVAS_W * (size_t)CANVAS_H;
static uint16_t g_pixels[CANVAS_PIXELS];
static bool g_ready = false;
static lv_image_dsc_t g_dsc;
static uint16_t g_img_w = 0;
static uint16_t g_img_h = 0;
static JPEGDEC *g_jpeg = nullptr;
static PNG *g_png = nullptr;
static AnimatedGIF *g_gif = nullptr;
static File g_file;
static bool g_gif_open = false;
static uint32_t g_gif_next_ms = 0;
static int g_off_x = 0;
static int g_off_y = 0;
static int g_src_w = 0;
static int g_src_h = 0;
static int g_dst_w = 0;
static int g_dst_h = 0;
static void canvas_clear(uint16_t color) {
for (size_t i = 0; i < CANVAS_PIXELS; i++) {
g_pixels[i] = color;
}
}
static void canvas_sync_dsc() {
g_dsc.header.magic = LV_IMAGE_HEADER_MAGIC;
g_dsc.header.cf = LV_COLOR_FORMAT_RGB565;
g_dsc.header.flags = 0;
g_dsc.header.w = CANVAS_W;
g_dsc.header.h = CANVAS_H;
g_dsc.header.stride = CANVAS_W * 2;
g_dsc.data_size = CANVAS_PIXELS * 2;
g_dsc.data = (const uint8_t *)g_pixels;
g_dsc.reserved = nullptr;
}
static void free_jpeg() {
if (g_jpeg) {
delete g_jpeg;
g_jpeg = nullptr;
}
}
static void free_png() {
if (g_png) {
delete g_png;
g_png = nullptr;
}
}
static void gif_close_session() {
if (g_gif_open && g_gif) {
g_gif->close();
g_gif_open = false;
}
if (g_gif) {
delete g_gif;
g_gif = nullptr;
}
}
static void fit_size(int nw, int nh, int *dw, int *dh) {
*dw = nw;
*dh = nh;
if (*dw <= (int)CANVAS_W && *dh <= (int)CANVAS_H) {
return;
}
const float sx = (float)CANVAS_W / (float)nw;
const float sy = (float)CANVAS_H / (float)nh;
const float s = sx < sy ? sx : sy;
*dw = (int)(nw * s + 0.5f);
*dh = (int)(nh * s + 0.5f);
if (*dw < 1) {
*dw = 1;
}
if (*dh < 1) {
*dh = 1;
}
}
static int jpeg_scale_for(int w, int h) {
if (w > CANVAS_W * 4 || h > CANVAS_H * 4) {
return JPEG_SCALE_EIGHTH;
}
if (w > CANVAS_W * 2 || h > CANVAS_H * 2) {
return JPEG_SCALE_QUARTER;
}
if (w > CANVAS_W || h > CANVAS_H) {
return JPEG_SCALE_HALF;
}
return 0;
}
static void *jpeg_open(const char *filename, int32_t *size) {
g_file = SD.open(filename);
if (!g_file) {
return nullptr;
}
*size = (int32_t)g_file.size();
return &g_file;
}
static void jpeg_close(void *handle) {
if (handle) {
((File *)handle)->close();
}
}
static int32_t jpeg_read(JPEGFILE *handle, uint8_t *buffer, int32_t length) {
if (!handle || !handle->fHandle) {
return 0;
}
return (int32_t)((File *)handle->fHandle)->read(buffer, length);
}
static int32_t jpeg_seek(JPEGFILE *handle, int32_t position) {
if (!handle || !handle->fHandle) {
return 0;
}
return ((File *)handle->fHandle)->seek(position) ? position : 0;
}
static int jpeg_draw(JPEGDRAW *p) {
if (!p || !p->pPixels) {
return 0;
}
for (int row = 0; row < p->iHeight; row++) {
const int y = p->y + row;
if (y < 0 || y >= (int)CANVAS_H) {
continue;
}
const uint16_t *src = p->pPixels + row * p->iWidth;
for (int col = 0; col < p->iWidth; col++) {
const int x = p->x + col;
if (x < 0 || x >= (int)CANVAS_W) {
continue;
}
g_pixels[(size_t)y * CANVAS_W + (size_t)x] = src[col];
}
}
return 1;
}
static void *png_open(const char *filename, int32_t *size) {
g_file = SD.open(filename);
if (!g_file) {
return nullptr;
}
*size = (int32_t)g_file.size();
return &g_file;
}
static void png_close(void *handle) {
if (handle) {
((File *)handle)->close();
}
}
static int32_t png_read(PNGFILE *handle, uint8_t *buffer, int32_t length) {
if (!handle || !handle->fHandle) {
return 0;
}
return (int32_t)((File *)handle->fHandle)->read(buffer, length);
}
static int32_t png_seek(PNGFILE *handle, int32_t position) {
if (!handle || !handle->fHandle) {
return 0;
}
return ((File *)handle->fHandle)->seek(position) ? position : 0;
}
static int png_draw(PNGDRAW *p) {
if (!p || !g_png || g_src_w <= 0 || g_dst_w <= 0) {
return 0;
}
// Line buffer for source width (cap to avoid stack blow)
static uint16_t line[640];
if (p->iWidth > 640) {
return 0;
}
g_png->getLineAsRGB565(p, line, PNG_RGB565_LITTLE_ENDIAN, 0x0000);
// Nearest-neighbor: which dest rows map from this source row?
for (int dy = 0; dy < g_dst_h; dy++) {
const int sy = (dy * g_src_h) / g_dst_h;
if (sy != p->y) {
continue;
}
const int y = g_off_y + dy;
if (y < 0 || y >= (int)CANVAS_H) {
continue;
}
for (int dx = 0; dx < g_dst_w; dx++) {
const int sx = (dx * g_src_w) / g_dst_w;
if (sx < 0 || sx >= p->iWidth) {
continue;
}
const int x = g_off_x + dx;
if (x < 0 || x >= (int)CANVAS_W) {
continue;
}
g_pixels[(size_t)y * CANVAS_W + (size_t)x] = line[sx];
}
}
return 1;
}
static void *gif_open(const char *fname, int32_t *pSize) {
g_file = SD.open(fname);
if (!g_file) {
return nullptr;
}
*pSize = (int32_t)g_file.size();
return &g_file;
}
static void gif_close(void *pHandle) {
if (pHandle) {
((File *)pHandle)->close();
}
}
static int32_t gif_read(GIFFILE *pFile, uint8_t *pBuf, int32_t iLen) {
if (!pFile || !pFile->fHandle) {
return 0;
}
int32_t n = (int32_t)((File *)pFile->fHandle)->read(pBuf, iLen);
pFile->iPos = (int32_t)((File *)pFile->fHandle)->position();
return n;
}
static int32_t gif_seek(GIFFILE *pFile, int32_t iPosition) {
if (!pFile || !pFile->fHandle) {
return 0;
}
((File *)pFile->fHandle)->seek(iPosition);
pFile->iPos = (int32_t)((File *)pFile->fHandle)->position();
return pFile->iPos;
}
static void gif_draw(GIFDRAW *pDraw) {
if (!pDraw || g_src_w <= 0 || g_dst_w <= 0) {
return;
}
// Map source frame lines into scaled dest canvas
const int src_y = pDraw->iY + pDraw->y;
for (int dy = 0; dy < g_dst_h; dy++) {
const int sy = (dy * g_src_h) / g_dst_h;
if (sy != src_y) {
continue;
}
const int y = g_off_y + dy;
if (y < 0 || y >= (int)CANVAS_H) {
continue;
}
for (int dx = 0; dx < g_dst_w; dx++) {
const int sx = (dx * g_src_w) / g_dst_w;
const int lx = sx - pDraw->iX;
if (lx < 0 || lx >= pDraw->iWidth) {
continue;
}
const uint8_t idx = pDraw->pPixels[lx];
if (pDraw->ucHasTransparency && idx == pDraw->ucTransparent) {
continue;
}
const int x = g_off_x + dx;
if (x < 0 || x >= (int)CANVAS_W) {
continue;
}
g_pixels[(size_t)y * CANVAS_W + (size_t)x] = pDraw->pPalette[idx];
}
}
}
static bool load_jpeg(const char *path) {
gif_close_session();
free_png();
free_jpeg();
g_jpeg = new JPEGDEC();
if (!g_jpeg) {
return false;
}
if (!g_jpeg->open(path, jpeg_open, jpeg_close, jpeg_read, jpeg_seek,
jpeg_draw)) {
Serial.printf("JPEG open fail: %s err=%d\n", path, g_jpeg->getLastError());
free_jpeg();
return false;
}
const int nw = g_jpeg->getWidth();
const int nh = g_jpeg->getHeight();
const int scale = jpeg_scale_for(nw, nh);
int dw = nw;
int dh = nh;
if (scale == JPEG_SCALE_HALF) {
dw /= 2;
dh /= 2;
} else if (scale == JPEG_SCALE_QUARTER) {
dw /= 4;
dh /= 4;
} else if (scale == JPEG_SCALE_EIGHTH) {
dw /= 8;
dh /= 8;
}
g_img_w = (uint16_t)dw;
g_img_h = (uint16_t)dh;
const int xoff = ((int)CANVAS_W - dw) / 2;
const int yoff = ((int)CANVAS_H - dh) / 2;
canvas_clear(0x0000);
g_jpeg->setPixelType(RGB565_LITTLE_ENDIAN);
const bool ok = g_jpeg->decode(xoff, yoff, scale);
g_jpeg->close();
free_jpeg();
if (!ok) {
Serial.printf("JPEG decode fail: %s\n", path);
}
return ok;
}
static bool load_png(const char *path) {
gif_close_session();
free_jpeg();
free_png();
g_png = new PNG();
if (!g_png) {
return false;
}
const int rc =
g_png->open(path, png_open, png_close, png_read, png_seek, png_draw);
if (rc != PNG_SUCCESS) {
Serial.printf("PNG open fail: %s rc=%d\n", path, rc);
free_png();
return false;
}
g_src_w = g_png->getWidth();
g_src_h = g_png->getHeight();
if (g_src_w <= 0 || g_src_h <= 0 || g_src_w > 640) {
Serial.printf("PNG bad size %dx%d\n", g_src_w, g_src_h);
g_png->close();
free_png();
return false;
}
fit_size(g_src_w, g_src_h, &g_dst_w, &g_dst_h);
g_img_w = (uint16_t)g_dst_w;
g_img_h = (uint16_t)g_dst_h;
g_off_x = ((int)CANVAS_W - g_dst_w) / 2;
g_off_y = ((int)CANVAS_H - g_dst_h) / 2;
canvas_clear(0x0000);
const int drc = g_png->decode(nullptr, 0);
g_png->close();
free_png();
if (drc != PNG_SUCCESS) {
Serial.printf("PNG decode fail: %s rc=%d\n", path, drc);
return false;
}
return true;
}
static bool load_gif(const char *path) {
free_jpeg();
free_png();
gif_close_session();
g_gif = new AnimatedGIF();
if (!g_gif) {
return false;
}
g_gif->begin(GIF_PALETTE_RGB565_LE);
if (!g_gif->open(path, gif_open, gif_close, gif_read, gif_seek, gif_draw)) {
Serial.printf("GIF open fail: %s\n", path);
gif_close_session();
return false;
}
g_src_w = g_gif->getCanvasWidth();
g_src_h = g_gif->getCanvasHeight();
if (g_src_w <= 0 || g_src_h <= 0) {
gif_close_session();
return false;
}
fit_size(g_src_w, g_src_h, &g_dst_w, &g_dst_h);
g_img_w = (uint16_t)g_dst_w;
g_img_h = (uint16_t)g_dst_h;
g_off_x = ((int)CANVAS_W - g_dst_w) / 2;
g_off_y = ((int)CANVAS_H - g_dst_h) / 2;
g_gif_open = true;
canvas_clear(0x0000);
int delay_ms = 0;
g_gif->playFrame(true, &delay_ms);
if (delay_ms < 20) {
delay_ms = 100;
}
g_gif_next_ms = millis() + (uint32_t)delay_ms;
return true;
}
bool photo_view_init() {
if (g_ready) {
return true;
}
canvas_clear(0x0000);
canvas_sync_dsc();
g_ready = true;
Serial.printf("photo_view: canvas %ux%u (%u bytes), free heap %u\n",
(unsigned)CANVAS_W, (unsigned)CANVAS_H,
(unsigned)(CANVAS_PIXELS * 2), (unsigned)ESP.getFreeHeap());
return true;
}
void photo_view_deinit() {
gif_close_session();
free_jpeg();
free_png();
g_ready = false;
g_dsc.data = nullptr;
}
bool photo_view_show(const PhotoEntry *entry) {
if (!entry || !photo_view_init()) {
return false;
}
bool ok = false;
switch (entry->kind) {
case PHOTO_JPG:
ok = load_jpeg(entry->path);
break;
case PHOTO_PNG:
ok = load_png(entry->path);
break;
case PHOTO_GIF:
ok = load_gif(entry->path);
break;
}
canvas_sync_dsc();
return ok;
}
const lv_image_dsc_t *photo_view_dsc() {
return g_ready ? &g_dsc : nullptr;
}
uint16_t photo_view_canvas_w() { return CANVAS_W; }
uint16_t photo_view_canvas_h() { return CANVAS_H; }
uint16_t photo_view_img_w() { return g_img_w; }
uint16_t photo_view_img_h() { return g_img_h; }
bool photo_view_is_gif() { return g_gif_open; }
bool photo_view_gif_tick() {
if (!g_gif_open || !g_gif) {
return false;
}
const uint32_t now = millis();
if ((int32_t)(now - g_gif_next_ms) < 0) {
return false;
}
int delay_ms = 0;
g_gif->playFrame(true, &delay_ms);
if (delay_ms < 20) {
delay_ms = 100;
}
g_gif_next_ms = now + (uint32_t)delay_ms;
return true;
}

136
src/sd_info.cpp Normal file
View File

@@ -0,0 +1,136 @@
#include "sd_info.h"
#include "pins.h"
#include <FS.h>
#include <SD.h>
#include <SPI.h>
static SPIClass sd_spi(VSPI);
static bool sd_ready = false;
static const char *card_type_name(uint8_t t) {
switch (t) {
case CARD_MMC: return "MMC";
case CARD_SD: return "SDSC";
case CARD_SDHC: return "SDHC";
case CARD_NONE: return "NONE";
default: return "UNKNOWN";
}
}
static const char *detect_filesystem() {
File root = SD.open("/");
if (!root) {
return "-";
}
root.close();
uint64_t total = SD.totalBytes();
if (total == 0) {
return "FAT";
}
if (total > 32ULL * 1024ULL * 1024ULL) {
return "FAT32";
}
if (total > 2ULL * 1024ULL * 1024ULL) {
return "FAT16";
}
return "FAT12/16";
}
static uint32_t count_root_entries() {
File root = SD.open("/");
if (!root || !root.isDirectory()) {
return 0;
}
uint32_t n = 0;
File f = root.openNextFile();
while (f) {
n++;
f = root.openNextFile();
}
root.close();
return n;
}
bool sd_init() {
if (sd_ready) {
return true;
}
pinMode(SD_CS, OUTPUT);
digitalWrite(SD_CS, HIGH);
pinMode(CYD_TOUCH_CS, OUTPUT);
digitalWrite(CYD_TOUCH_CS, HIGH);
sd_spi.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
if (!SD.begin(SD_CS, sd_spi, 25000000)) {
sd_ready = false;
return false;
}
sd_ready = true;
return true;
}
void sd_deinit() {
if (sd_ready) {
SD.end();
sd_ready = false;
}
}
SdCardInfo sd_read_info() {
SdCardInfo info{};
info.mounted = false;
info.present = false;
info.type = "NONE";
info.filesystem = "-";
info.card_bytes = 0;
info.total_bytes = 0;
info.used_bytes = 0;
info.free_bytes = 0;
info.root_files = 0;
if (!sd_ready && !sd_init()) {
info.error = "Mount failed";
return info;
}
uint8_t type = SD.cardType();
info.type = card_type_name(type);
if (type == CARD_NONE) {
info.error = "No card";
info.present = false;
return info;
}
info.present = true;
info.mounted = true;
info.card_bytes = SD.cardSize();
info.total_bytes = SD.totalBytes();
info.used_bytes = SD.usedBytes();
info.free_bytes =
(info.total_bytes > info.used_bytes) ? (info.total_bytes - info.used_bytes) : 0;
info.filesystem = detect_filesystem();
info.root_files = count_root_entries();
info.error = "";
return info;
}
String format_bytes(uint64_t bytes) {
const double b = static_cast<double>(bytes);
char buf[48];
if (bytes >= (1ULL << 30)) {
snprintf(buf, sizeof(buf), "%.2f GB", b / (1024.0 * 1024.0 * 1024.0));
} else if (bytes >= (1ULL << 20)) {
snprintf(buf, sizeof(buf), "%.2f MB", b / (1024.0 * 1024.0));
} else if (bytes >= (1ULL << 10)) {
snprintf(buf, sizeof(buf), "%.2f KB", b / 1024.0);
} else {
snprintf(buf, sizeof(buf), "%llu B", static_cast<unsigned long long>(bytes));
}
return String(buf);
}

121
src/sd_put.cpp Normal file
View File

@@ -0,0 +1,121 @@
#include "sd_put.h"
#include "sd_info.h"
#include <SD.h>
static String read_line_timeout(uint32_t timeout_ms) {
String line;
const uint32_t start = millis();
while (millis() - start < timeout_ms) {
while (Serial.available()) {
const char c = (char)Serial.read();
if (c == '\n' || c == '\r') {
if (line.length()) {
return line;
}
continue;
}
line += c;
if (line.length() > 200) {
return line;
}
}
delay(1);
}
return line;
}
bool sd_put_poll() {
if (!Serial.available()) {
return false;
}
// 只在行首是 #PUT 时进入,避免吃掉普通日志
if (Serial.peek() != '#') {
// 丢弃非协议噪声到换行,防止堵死
while (Serial.available() && Serial.peek() != '#') {
const int c = Serial.read();
if (c == '\n' || c == '\r') {
break;
}
}
return false;
}
const String line = read_line_timeout(500);
if (!line.startsWith("#PUT ")) {
return false;
}
// #PUT /paomo.mp3 4143009
int sp1 = line.indexOf(' ', 5);
if (sp1 < 0) {
Serial.println("PUT_ERR bad args");
return true;
}
const String path = line.substring(5, sp1);
const uint32_t size = (uint32_t)line.substring(sp1 + 1).toInt();
if (path.length() < 2 || path[0] != '/' || size == 0 || size > 64UL * 1024UL * 1024UL) {
Serial.println("PUT_ERR bad path/size");
return true;
}
if (!sd_init()) {
Serial.println("PUT_ERR sd");
return true;
}
if (SD.exists(path.c_str())) {
SD.remove(path.c_str());
}
File f = SD.open(path.c_str(), FILE_WRITE);
if (!f) {
Serial.println("PUT_ERR open");
return true;
}
Serial.println("READY");
Serial.flush();
// 窗口协议:每收满一块回 ACK,避免 RX 溢出
constexpr size_t k_win = 4096;
uint8_t buf[k_win];
uint32_t got = 0;
while (got < size) {
const uint32_t need = size - got;
const size_t chunk = need > k_win ? k_win : (size_t)need;
size_t n = 0;
const uint32_t wait_start = millis();
while (n < chunk && millis() - wait_start < 30000) {
if (!Serial.available()) {
delay(1);
continue;
}
Serial.setTimeout(50);
const int r = Serial.readBytes(buf + n, chunk - n);
if (r > 0) {
n += (size_t)r;
}
}
if (n == 0) {
f.close();
SD.remove(path.c_str());
Serial.println("PUT_ERR timeout");
return true;
}
if (f.write(buf, n) != n) {
f.close();
SD.remove(path.c_str());
Serial.println("PUT_ERR write");
return true;
}
got += n;
Serial.printf("ACK %u\n", (unsigned)got);
Serial.flush();
}
f.flush();
f.close();
Serial.println("DONE");
return true;
}

125
src/touch.cpp Normal file
View File

@@ -0,0 +1,125 @@
#include "touch.h"
#include "pins.h"
#include <Arduino.h>
/**
* SoftSPI XPT2046
* BL↔TR 是矩形主对角线镜像(XY 对调),不是 180°(180° 会搞成 TL↔BR)。
* 先按 rot2 得到旧 UI,再做对角修正,反变换喂给 LVGL 270。
*/
int16_t g_dbg_phys_x = -1;
int16_t g_dbg_phys_y = -1;
int16_t g_dbg_logic_x = -1;
int16_t g_dbg_logic_y = -1;
bool g_dbg_touch_down = false;
static void soft_write(uint8_t data) {
for (int i = 7; i >= 0; --i) {
digitalWrite(CYD_TOUCH_MOSI, (data >> i) & 1);
digitalWrite(CYD_TOUCH_CLK, HIGH);
delayMicroseconds(2);
digitalWrite(CYD_TOUCH_CLK, LOW);
delayMicroseconds(2);
}
}
static uint16_t soft_read12() {
uint16_t v = 0;
digitalWrite(CYD_TOUCH_CLK, HIGH);
delayMicroseconds(2);
digitalWrite(CYD_TOUCH_CLK, LOW);
delayMicroseconds(2);
for (int i = 0; i < 12; ++i) {
digitalWrite(CYD_TOUCH_CLK, HIGH);
delayMicroseconds(2);
v = (uint16_t)((v << 1) | (digitalRead(CYD_TOUCH_MISO) ? 1u : 0u));
digitalWrite(CYD_TOUCH_CLK, LOW);
delayMicroseconds(2);
}
return v;
}
static uint16_t xpt_read(uint8_t cmd) {
digitalWrite(CYD_TOUCH_CS, LOW);
soft_write(cmd);
uint16_t v = soft_read12();
digitalWrite(CYD_TOUCH_CS, HIGH);
return v;
}
static int16_t clampi(int32_t v, int32_t lo, int32_t hi) {
if (v < lo) {
return (int16_t)lo;
}
if (v > hi) {
return (int16_t)hi;
}
return (int16_t)v;
}
void touch_init() {
pinMode(CYD_TOUCH_CS, OUTPUT);
pinMode(CYD_TOUCH_CLK, OUTPUT);
pinMode(CYD_TOUCH_MOSI, OUTPUT);
pinMode(CYD_TOUCH_MISO, INPUT);
pinMode(CYD_TOUCH_IRQ, INPUT);
digitalWrite(CYD_TOUCH_CS, HIGH);
digitalWrite(CYD_TOUCH_CLK, LOW);
Serial.println("Touch: SoftSPI + diagonal fix (BL/TR)");
}
void touch_read_cb(lv_indev_t * /*indev*/, lv_indev_data_t *data) {
uint16_t adc_x = xpt_read(0x90);
uint16_t adc_y = xpt_read(0xD0);
adc_x = (uint16_t)((adc_x + xpt_read(0x90)) / 2);
adc_y = (uint16_t)((adc_y + xpt_read(0xD0)) / 2);
const bool irq = (digitalRead(CYD_TOUCH_IRQ) == LOW);
const bool in_range =
(adc_x > 80 && adc_x < 4000 && adc_y > 80 && adc_y < 4000);
if (!in_range || (!irq && (adc_x < 200 || adc_y < 200))) {
data->state = LV_INDEV_STATE_RELEASED;
g_dbg_touch_down = false;
return;
}
const int16_t p_x = (int16_t)adc_x;
const int16_t p_y = (int16_t)(4095 - adc_y);
int16_t x = map(p_x, 200, 3700, 1, SCREEN_WIDTH);
int16_t y = map(p_y, 240, 3800, 1, SCREEN_HEIGHT);
x = clampi(x, 0, SCREEN_WIDTH - 1);
y = clampi(y, 0, SCREEN_HEIGHT - 1);
// 旧 UI(LVGL270):(y, 239-x) — 该空间里 BL↔TR,做主对角线镜像
// (ux,uy) -> (uy*(319/239), ux*(239/319))
const int32_t old_ux = y;
const int32_t old_uy = (SCREEN_WIDTH - 1) - x;
int16_t ui_x = clampi((old_uy * (UI_HOR_RES - 1)) / (UI_VER_RES - 1), 0,
UI_HOR_RES - 1);
int16_t ui_y = clampi((old_ux * (UI_VER_RES - 1)) / (UI_HOR_RES - 1), 0,
UI_VER_RES - 1);
// 反变换喂 LVGL,使最终落在 (ui_x, ui_y)
const int16_t feed_x = (int16_t)((SCREEN_WIDTH - 1) - ui_y);
const int16_t feed_y = ui_x;
g_dbg_phys_x = x;
g_dbg_phys_y = y;
g_dbg_logic_x = ui_x;
g_dbg_logic_y = ui_y;
g_dbg_touch_down = true;
static uint32_t last_log = 0;
if (millis() - last_log > 80) {
last_log = millis();
Serial.printf("Touch UI(%d,%d)\n", ui_x, ui_y);
}
data->point.x = feed_x;
data->point.y = feed_y;
data->state = LV_INDEV_STATE_PRESSED;
}

296
src/ui.cpp Normal file
View File

@@ -0,0 +1,296 @@
#include "ui.h"
#include "photo_list.h"
#include "photo_view.h"
#include "pins.h"
#include <Arduino.h>
#include <lvgl.h>
#include <stdio.h>
#include <string.h>
static constexpr uint32_t COL_BG = 0x0A1016;
static constexpr uint32_t COL_INK = 0xEAF0F6;
static constexpr uint32_t COL_MUTED = 0x7A8B9C;
static constexpr uint32_t COL_OK = 0x3DDC97;
static constexpr uint32_t COL_BAD = 0xE85D4C;
static constexpr uint32_t COL_ACCENT = 0x4FC3F7;
static constexpr uint32_t COL_BTN = 0x243140;
static constexpr uint32_t COL_BTN_HI = 0x1A4A6E;
static lv_obj_t *lbl_title;
static lv_obj_t *lbl_chip;
static lv_obj_t *lbl_index;
static lv_obj_t *lbl_name;
static lv_obj_t *lbl_hint;
static lv_obj_t *img_photo;
static lv_obj_t *btn_prev;
static lv_obj_t *btn_next;
static int16_t g_index = -1;
static char g_index_text[24] = "0/0";
static char g_name_text[48] = "-";
static const char *basename_of(const char *path) {
const char *slash = strrchr(path, '/');
return slash ? slash + 1 : path;
}
static void style_btn(lv_obj_t *btn, uint32_t bg) {
lv_obj_set_style_radius(btn, 8, 0);
lv_obj_set_style_bg_color(btn, lv_color_hex(bg), 0);
lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0);
lv_obj_set_style_shadow_width(btn, 0, 0);
lv_obj_set_style_border_width(btn, 0, 0);
lv_obj_set_style_pad_all(btn, 0, 0);
}
static void refresh_labels() {
const uint16_t n = photo_list_count();
if (n == 0 || g_index < 0) {
snprintf(g_index_text, sizeof(g_index_text), "0/0");
snprintf(g_name_text, sizeof(g_name_text), "No photos");
} else {
snprintf(g_index_text, sizeof(g_index_text), "%d/%u", (int)(g_index + 1),
(unsigned)n);
const PhotoEntry *e = photo_list_get((uint16_t)g_index);
snprintf(g_name_text, sizeof(g_name_text), "%s",
e ? basename_of(e->path) : "-");
}
if (lbl_index) {
lv_label_set_text(lbl_index, g_index_text);
}
if (lbl_name) {
lv_label_set_text(lbl_name, g_name_text);
}
}
static void show_current() {
const uint16_t n = photo_list_count();
if (n == 0) {
g_index = -1;
if (img_photo) {
lv_image_set_src(img_photo, nullptr);
}
if (lbl_hint) {
lv_label_set_text(lbl_hint, "Put jpg/png/gif in / or /photos");
}
refresh_labels();
return;
}
if (g_index < 0 || g_index >= (int16_t)n) {
g_index = 0;
}
const PhotoEntry *e = photo_list_get((uint16_t)g_index);
const bool ok = e && photo_view_show(e);
if (img_photo) {
const lv_image_dsc_t *dsc = photo_view_dsc();
// Force refresh even when dsc pointer is unchanged
lv_image_set_src(img_photo, nullptr);
if (ok && dsc) {
lv_image_set_src(img_photo, dsc);
}
lv_obj_invalidate(img_photo);
}
if (lbl_hint) {
if (!ok) {
lv_label_set_text(lbl_hint, "Decode failed (size/format?)");
} else if (photo_view_is_gif()) {
lv_label_set_text(lbl_hint, "GIF | tap L/R to switch");
} else {
lv_label_set_text(lbl_hint, "Tap left=prev right=next");
}
}
refresh_labels();
}
static void go_delta(int delta) {
const uint16_t n = photo_list_count();
if (n == 0) {
return;
}
int16_t next = (int16_t)(g_index + delta);
while (next < 0) {
next = (int16_t)(next + (int16_t)n);
}
while (next >= (int16_t)n) {
next = (int16_t)(next - (int16_t)n);
}
if (next == g_index && n > 1) {
// still reload if single? skip
}
g_index = next;
show_current();
}
static void on_prev(lv_event_t *e) {
LV_UNUSED(e);
go_delta(-1);
}
static void on_next(lv_event_t *e) {
LV_UNUSED(e);
go_delta(1);
}
static void on_tap_zone(lv_event_t *e) {
lv_indev_t *indev = lv_indev_active();
if (!indev) {
return;
}
lv_point_t p;
lv_indev_get_point(indev, &p);
if (p.x < (UI_HOR_RES / 2)) {
go_delta(-1);
} else {
go_delta(1);
}
}
void ui_reload_album() {
const uint16_t n = photo_list_scan();
Serial.printf("Photos found: %u\n", (unsigned)n);
for (uint16_t i = 0; i < n; i++) {
const PhotoEntry *e = photo_list_get(i);
if (e) {
Serial.printf(" [%u] %s\n", (unsigned)i, e->path);
}
}
if (n == 0) {
g_index = -1;
} else if (g_index < 0 || g_index >= (int16_t)n) {
g_index = 0;
}
show_current();
}
void ui_create() {
photo_view_init();
lv_obj_t *scr = lv_screen_active();
lv_obj_set_style_bg_color(scr, lv_color_hex(COL_BG), 0);
lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
lv_obj_clear_flag(scr, LV_OBJ_FLAG_SCROLLABLE);
// Full-screen tap zones (behind chrome)
lv_obj_t *tap = lv_obj_create(scr);
lv_obj_set_size(tap, UI_HOR_RES, UI_VER_RES);
lv_obj_set_pos(tap, 0, 0);
lv_obj_set_style_bg_opa(tap, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(tap, 0, 0);
lv_obj_set_style_pad_all(tap, 0, 0);
lv_obj_clear_flag(tap, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(tap, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(tap, on_tap_zone, LV_EVENT_CLICKED, nullptr);
img_photo = lv_image_create(scr);
lv_obj_set_size(img_photo, photo_view_canvas_w(), photo_view_canvas_h());
lv_image_set_scale(img_photo, 512); // 256=1x, 512=2x -> full 320x240
lv_obj_align(img_photo, LV_ALIGN_CENTER, 0, 0);
lv_obj_clear_flag(img_photo, LV_OBJ_FLAG_CLICKABLE);
// Top bar
lv_obj_t *top = lv_obj_create(scr);
lv_obj_set_size(top, UI_HOR_RES, 28);
lv_obj_set_pos(top, 0, 0);
lv_obj_set_style_bg_color(top, lv_color_hex(COL_BG), 0);
lv_obj_set_style_bg_opa(top, LV_OPA_80, 0);
lv_obj_set_style_border_width(top, 0, 0);
lv_obj_set_style_pad_all(top, 0, 0);
lv_obj_set_style_radius(top, 0, 0);
lv_obj_clear_flag(top, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_clear_flag(top, LV_OBJ_FLAG_CLICKABLE);
lbl_title = lv_label_create(top);
lv_label_set_text(lbl_title, "ALBUM");
lv_obj_set_style_text_font(lbl_title, &lv_font_montserrat_16, 0);
lv_obj_set_style_text_color(lbl_title, lv_color_hex(COL_ACCENT), 0);
lv_obj_set_pos(lbl_title, 8, 5);
lbl_index = lv_label_create(top);
lv_label_set_text(lbl_index, "0/0");
lv_obj_set_style_text_font(lbl_index, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(lbl_index, lv_color_hex(COL_INK), 0);
lv_obj_align(lbl_index, LV_ALIGN_TOP_MID, 0, 6);
lbl_chip = lv_label_create(top);
lv_label_set_text(lbl_chip, "-");
lv_obj_set_style_text_font(lbl_chip, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(lbl_chip, lv_color_hex(COL_MUTED), 0);
lv_obj_align(lbl_chip, LV_ALIGN_TOP_RIGHT, -8, 6);
// Bottom bar
lv_obj_t *bot = lv_obj_create(scr);
lv_obj_set_size(bot, UI_HOR_RES, 40);
lv_obj_set_pos(bot, 0, UI_VER_RES - 40);
lv_obj_set_style_bg_color(bot, lv_color_hex(COL_BG), 0);
lv_obj_set_style_bg_opa(bot, LV_OPA_80, 0);
lv_obj_set_style_border_width(bot, 0, 0);
lv_obj_set_style_pad_all(bot, 0, 0);
lv_obj_set_style_radius(bot, 0, 0);
lv_obj_clear_flag(bot, LV_OBJ_FLAG_SCROLLABLE);
btn_prev = lv_button_create(bot);
lv_obj_set_pos(btn_prev, 8, 6);
lv_obj_set_size(btn_prev, 56, 28);
lv_obj_add_flag(btn_prev, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(btn_prev, on_prev, LV_EVENT_CLICKED, nullptr);
style_btn(btn_prev, COL_BTN);
lv_obj_t *lp = lv_label_create(btn_prev);
lv_label_set_text(lp, "<");
lv_obj_set_style_text_font(lp, &lv_font_montserrat_16, 0);
lv_obj_set_style_text_color(lp, lv_color_hex(COL_INK), 0);
lv_obj_center(lp);
btn_next = lv_button_create(bot);
lv_obj_set_pos(btn_next, UI_HOR_RES - 64, 6);
lv_obj_set_size(btn_next, 56, 28);
lv_obj_add_flag(btn_next, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(btn_next, on_next, LV_EVENT_CLICKED, nullptr);
style_btn(btn_next, COL_BTN_HI);
lv_obj_t *ln = lv_label_create(btn_next);
lv_label_set_text(ln, ">");
lv_obj_set_style_text_font(ln, &lv_font_montserrat_16, 0);
lv_obj_set_style_text_color(ln, lv_color_hex(COL_INK), 0);
lv_obj_center(ln);
lbl_name = lv_label_create(bot);
lv_label_set_text(lbl_name, "-");
lv_label_set_long_mode(lbl_name, LV_LABEL_LONG_CLIP);
lv_obj_set_width(lbl_name, UI_HOR_RES - 140);
lv_obj_set_style_text_font(lbl_name, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(lbl_name, lv_color_hex(COL_INK), 0);
lv_obj_align(lbl_name, LV_ALIGN_TOP_MID, 0, 4);
lbl_hint = lv_label_create(bot);
lv_label_set_text(lbl_hint, "Tap left/right");
lv_obj_set_style_text_font(lbl_hint, &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(lbl_hint, lv_color_hex(COL_MUTED), 0);
lv_obj_align(lbl_hint, LV_ALIGN_TOP_MID, 0, 20);
// Raise bars / buttons above tap target for hit testing
lv_obj_move_foreground(top);
lv_obj_move_foreground(bot);
}
void ui_tick() {
if (photo_view_gif_tick() && img_photo) {
lv_obj_invalidate(img_photo);
}
}
void ui_update(const SdCardInfo &info) {
if (!lbl_chip) {
return;
}
if (!info.mounted || !info.present) {
lv_label_set_text(lbl_chip, info.error.length() ? info.error.c_str() : "TF?");
lv_obj_set_style_text_color(lbl_chip, lv_color_hex(COL_BAD), 0);
} else {
char chip[40];
snprintf(chip, sizeof(chip), "OK %s", info.type);
lv_label_set_text(lbl_chip, chip);
lv_obj_set_style_text_color(lbl_chip, lv_color_hex(COL_OK), 0);
}
}

75
tools/make_test_photos.py Normal file
View File

@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Generate 320x240 JPG/PNG/GIF samples into assets/photos."""
from __future__ import annotations
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "assets" / "photos"
W, H = 320, 240
def font(size: int = 28) -> ImageFont.ImageFont:
for name in (
r"C:\Windows\Fonts\msyh.ttc",
r"C:\Windows\Fonts\arial.ttf",
r"C:\Windows\Fonts\consola.ttf",
):
try:
return ImageFont.truetype(name, size)
except OSError:
pass
return ImageFont.load_default()
def card(bg, accent, title: str, subtitle: str, path: Path, fmt: str) -> None:
im = Image.new("RGB", (W, H), bg)
d = ImageDraw.Draw(im)
for i in range(0, H, 12):
c = tuple(max(0, min(255, v + (i % 24) - 12)) for v in accent)
d.rectangle((0, i, W, i + 6), fill=c)
d.rectangle((16, 40, W - 16, H - 40), fill=bg)
d.rectangle((16, 40, W - 16, H - 40), outline=accent, width=3)
d.text((28, 70), title, fill=(234, 240, 246), font=font(36))
d.text((28, 130), subtitle, fill=(122, 139, 156), font=font(20))
d.text((28, 170), "CYD ALBUM TEST", fill=accent, font=font(16))
if fmt == "JPEG":
im.save(path, "JPEG", quality=90)
else:
im.save(path, "PNG")
print(f"wrote {path} ({path.stat().st_size} bytes)")
def main() -> None:
OUT.mkdir(parents=True, exist_ok=True)
card((10, 16, 22), (79, 195, 247), "01 JPG", "tap left / right", OUT / "01_blue.jpg", "JPEG")
card((20, 12, 28), (232, 93, 76), "02 JPG", "red sample", OUT / "02_red.jpg", "JPEG")
card((8, 28, 20), (61, 220, 151), "03 PNG", "green sample", OUT / "03_green.png", "PNG")
frames = []
for i in range(6):
im = Image.new("RGB", (W, H), (10, 16, 22))
d = ImageDraw.Draw(im)
d.text((24, 24), "04 GIF", fill=(234, 240, 246), font=font(32))
d.text((24, 70), "animated", fill=(122, 139, 156), font=font(18))
x = 20 + i * 40
d.rectangle((x, 120, x + 80, 180), fill=(255, 200, 40))
d.rectangle((20, 200, 300, 210), fill=(36, 49, 64))
frames.append(im)
gif_path = OUT / "04_bounce.gif"
frames[0].save(
gif_path,
save_all=True,
append_images=frames[1:],
duration=120,
loop=0,
optimize=False,
)
print(f"wrote {gif_path} ({gif_path.stat().st_size} bytes)")
print("DONE")
if __name__ == "__main__":
main()

150
tools/sd_put.py Normal file
View File

@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Upload a file to the CYD TF card over serial (#PUT + ACK window)."""
from __future__ import annotations
import argparse
import sys
import time
from pathlib import Path
try:
import serial
from serial.tools import list_ports
except ImportError:
print("Need pyserial: pip install pyserial", file=sys.stderr)
sys.exit(1)
WIN = 4096
def pick_port(preferred: str | None) -> str:
if preferred:
return preferred
ports = list(list_ports.comports())
for p in ports:
desc = (p.description or "") + (p.hwid or "")
if "USB" in desc.upper() or "UART" in desc.upper() or "CP210" in desc.upper() or "CH340" in desc.upper():
return p.device
if ports:
return ports[0].device
raise SystemExit("No serial port found")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("file", type=Path, help="Local file to upload")
ap.add_argument("--port", default=None)
ap.add_argument("--baud", type=int, default=460800)
ap.add_argument("--remote", default="/paomo.mp3")
args = ap.parse_args()
path: Path = args.file
if not path.is_file():
print(f"Missing file: {path}", file=sys.stderr)
return 1
size = path.stat().st_size
port = pick_port(args.port)
print(f"Port {port} {path.name} -> {args.remote} ({size} bytes)", flush=True)
ser = serial.Serial()
ser.port = port
ser.baudrate = args.baud
ser.timeout = 1
ser.dtr = False
ser.rts = False
ser.open()
# 硬复位 ESP32
ser.rts = True
time.sleep(0.1)
ser.rts = False
print("waiting BOOT_OK...", flush=True)
deadline = time.time() + 12
booted = False
while time.time() < deadline:
line = ser.readline().decode("utf-8", errors="ignore").strip()
if line:
print("<", line, flush=True)
if "BOOT_OK" in line:
booted = True
break
if not booted:
print("warn: BOOT_OK not seen, continuing", flush=True)
time.sleep(0.5)
ser.reset_input_buffer()
cmd = f"#PUT {args.remote} {size}\n".encode("ascii")
print(f"send {cmd!r}", flush=True)
ser.write(cmd)
ser.flush()
deadline = time.time() + 20
while time.time() < deadline:
line = ser.readline().decode("utf-8", errors="ignore").strip()
if line:
print("<", line, flush=True)
if line == "READY":
break
if line.startswith("PUT_ERR"):
return 2
else:
print("Timeout waiting READY", file=sys.stderr, flush=True)
return 3
sent = 0
t0 = time.time()
with path.open("rb") as f:
while sent < size:
chunk = f.read(min(WIN, size - sent))
if not chunk:
break
ser.write(chunk)
ser.flush()
sent += len(chunk)
# 等 ACK <sent>
ack_deadline = time.time() + 30
acked = False
while time.time() < ack_deadline:
line = ser.readline().decode("utf-8", errors="ignore").strip()
if not line:
continue
print("<", line, flush=True)
if line.startswith("PUT_ERR"):
return 4
if line.startswith("ACK "):
try:
got = int(line.split()[1])
except (IndexError, ValueError):
continue
if got >= sent:
acked = True
break
if line == "DONE" and sent >= size:
print("OK", flush=True)
return 0
if not acked:
print(f"Timeout waiting ACK after {sent}", file=sys.stderr, flush=True)
return 4
if sent % (256 * 1024) < WIN or sent == size:
rate = sent / max(time.time() - t0, 0.001)
print(f"sent {sent}/{size} ({rate/1024:.1f} KB/s)", flush=True)
deadline = time.time() + 30
while time.time() < deadline:
line = ser.readline().decode("utf-8", errors="ignore").strip()
if line:
print("<", line, flush=True)
if line == "DONE":
print("OK", flush=True)
return 0
if line.startswith("PUT_ERR"):
return 5
print("Timeout waiting DONE", file=sys.stderr, flush=True)
return 6
if __name__ == "__main__":
sys.exit(main())