diff --git "a/datasheet/QuecPython_Yttrium \345\274\200\345\217\221\346\235\277\344\273\213\347\273\215 - QuecPython.pdf" "b/datasheet/QuecPython_Yttrium \345\274\200\345\217\221\346\235\277\344\273\213\347\273\215 - QuecPython.pdf" new file mode 100644 index 0000000..a36113d Binary files /dev/null and "b/datasheet/QuecPython_Yttrium \345\274\200\345\217\221\346\235\277\344\273\213\347\273\215 - QuecPython.pdf" differ diff --git "a/datasheet/Quectel_QuecPython_Yttrium\345\274\200\345\217\221\346\235\277_\344\272\247\345\223\201\350\247\204\346\240\274\344\271\246_V1.0.1_Preliminary_20260401-.pdf" "b/datasheet/Quectel_QuecPython_Yttrium\345\274\200\345\217\221\346\235\277_\344\272\247\345\223\201\350\247\204\346\240\274\344\271\246_V1.0.1_Preliminary_20260401-.pdf" new file mode 100644 index 0000000..d10e197 Binary files /dev/null and "b/datasheet/Quectel_QuecPython_Yttrium\345\274\200\345\217\221\346\235\277_\344\272\247\345\223\201\350\247\204\346\240\274\344\271\246_V1.0.1_Preliminary_20260401-.pdf" differ diff --git a/datasheet/README.md b/datasheet/README.md index 20b4fba..8346fda 100644 --- a/datasheet/README.md +++ b/datasheet/README.md @@ -1,5 +1,46 @@ # 相关资料归集 -> 1、开发板规格书、原理图、丝印图 +Yttrium 开发板相关设计资料和规格文档。 + +## 文件清单 + +### 原理图与 PCB + +| 文件 | 说明 | 页数 | +|---|---|---| +| `EG800Z_LCD_SCH_V1.1_20250326.pdf` | 原理图:EG800Z 模块 + LCD + 外设电路 | 7 页 | +| `EG800Z_LCD_PCB_V1.1_20250326.pdf` | PCB 丝印图:Top 层 + Bottom 层 | 2 页 | + +> 密码:`quectel` > -> 2、相关元器件datasheet \ No newline at end of file +> 原理图分页:Sheet 1 封面 / Sheet 2 EG800Z 模块引脚 / Sheet 3 电源 / Sheet 4 USB + RS485 / Sheet 5 LCD + Camera + 音频 / Sheet 6 以太网 + CAN + Flash / Sheet 7 按键 + LED + 蜂鸣器 + +### 产品文档 + +| 文件 | 说明 | 页数 | +|---|---|---| +| `QuecPython_Yttrium 开发板介绍.pdf` | QuecPython 官方开发板介绍:接口布局、开关说明、基础参数 | 9 页 | +| `Yttrium开发板_产品规格书_V1.0.1.pdf` | 产品规格书:详细接口表、电气参数、尺寸重量、认证信息 | 22 页 | + +## 关键信息速查 + +### 拨码开关 + +| 开关 | 功能 | +|---|---| +| S0301 | 供电方式选择:DC 5V / USB Type-C | +| S0401 | 主 UART 连接:USB UART / RS485 | +| S0601 | SPI0 连接:NET(以太网)/ FLASH | +| S0705 | 蜂鸣器复用:1-3 导通 → GPIO2 控制蜂鸣器 | + +### 接口连接器 + +| 编号 | 类型 | 信号 | +|---|---|---| +| J0501 | 16-pin FPC | LCD(SPI + 触摸 I2C) | +| J0503 | 6-pin FPC | Camera | +| J0601 | RJ45 | 以太网 | +| J0602 | 3-pin 端子 | CAN | +| J0403 | 3-pin 端子 | RS485 | +| J0702 | 8-pin 排针 | 预留:MAIN_TXD/RXD、LCD、CTP_INT、GND、ADC | +| J0703 | 8-pin 排针 | 预留:QSPI、LCD 信号 | diff --git a/docs/01_introduction/README.md b/docs/01_introduction/README.md index 22637d9..f1aa59e 100644 --- a/docs/01_introduction/README.md +++ b/docs/01_introduction/README.md @@ -144,7 +144,7 @@ Yttrium 开发板基于 **Quectel EG800Z QuecOpen** 模组(EC718 系列), | 固件包 | `firmware/` | | 烧录工具 | `tools/FlashTools_V4.1.19_2509010.zip` | | 串口工具 | `tools/sscom5.13.1.zip` | -| Demo 代码 | `src/yttrium.py` | +| Demo 代码 | `src/_main.py` + 各功能模块 `.py` | | 图标资源 | `icons/`、`images/` | | 烧录教程 | [02_firmwareFlashing](../02_firmwareFlashing/README.md) | | 运行教程 | [03_runDemo](../03_runDemo/README.md) | diff --git a/docs/03_runDemo/README.md b/docs/03_runDemo/README.md index 0e8f128..08127fb 100644 --- a/docs/03_runDemo/README.md +++ b/docs/03_runDemo/README.md @@ -9,7 +9,7 @@ | 开发板 | 已烧录 QuecPython 固件(参考 [02_firmwareFlashing](../02_firmwareFlashing/README.md)) | | PC 工具 | QPYCOM(QuecPython 串口交互工具) | | 硬件连接 | 开发板通过 USB 线连接至 PC | -| 代码文件 | `src/yttrium.py` + `icons/` + `images/` | +| 代码文件 | `src/` 下所有 `.py` 文件 + `icons/` + `images/` | ## QPYCOM 连接设备 @@ -65,13 +65,40 @@ hello world ``` U:/ -├── yttrium.py # 主应用 +├── _main.py # 入口文件(PAGE_MAP + MainScreen + YttriumApp) +├── app_base.py # AppPage 基类 +├── led.py # LED 控制 +├── clock.py # 时钟 +├── adc.py # ADC 仪表盘 +├── calc.py # 计算器 +├── comm_hub.py # 通信协议 Hub +├── uart.py # UART 终端 +├── can.py # CAN 终端 +├── at.py # AT 终端 +├── rs485.py # RS485 终端 +├── game_hub.py # 游戏 Hub +├── snake.py # 贪吃蛇 +├── game_2048.py # 2048 +├── tetris.py # 俄罗斯方块 +├── qr_shop.py # 饮品选购 +├── audio.py # 录音播放 +├── usb_browser.py # U 盘浏览器 +├── phone.py # 电话拨号 +├── eth_hub.py # 以太网 Hub +├── eth_info.py # 网络信息 +├── ping.py # Ping 工具 +├── tcp.py # TCP 客户端 +├── buzzer.py # 蜂鸣器 +├── weather.py # 天气 +├── keyboard.py # 虚拟键盘 +├── camera.py # 摄像头 +├── scan.py # 扫码 ├── icons/ # 图标资源(48×48 PNG,~50 个) └── images/ # 商品图片(100×70,8 张) ``` ## 启动 Demo -- 直接右键yttrimu.py运行代码 +- 直接右键 `_main.py` 运行代码 ![alt text](image-6.png) @@ -79,7 +106,7 @@ U:/ ```python >>> import example ->>> example.exec('/usr/yttrium.py') +>>> example.exec('/usr/_main.py') ``` 开发板 LCD 将显示 **YttriumEC718** 主界面——5×3 图标网格,触摸点击进入各功能页面。 diff --git a/docs/03_runDemo/image-2.png b/docs/03_runDemo/image-2.png index e701f6c..12113cc 100644 Binary files a/docs/03_runDemo/image-2.png and b/docs/03_runDemo/image-2.png differ diff --git a/docs/03_runDemo/image-4.png b/docs/03_runDemo/image-4.png index 355822e..0d6a402 100644 Binary files a/docs/03_runDemo/image-4.png and b/docs/03_runDemo/image-4.png differ diff --git a/docs/03_runDemo/image-5.png b/docs/03_runDemo/image-5.png index 27b0c57..b66b230 100644 Binary files a/docs/03_runDemo/image-5.png and b/docs/03_runDemo/image-5.png differ diff --git a/docs/03_runDemo/image-6.png b/docs/03_runDemo/image-6.png index c05b983..4feb44a 100644 Binary files a/docs/03_runDemo/image-6.png and b/docs/03_runDemo/image-6.png differ diff --git a/docs/04_ledControl/README.md b/docs/04_led/README.md similarity index 95% rename from docs/04_ledControl/README.md rename to docs/04_led/README.md index f85c4c6..2b6c843 100644 --- a/docs/04_ledControl/README.md +++ b/docs/04_led/README.md @@ -60,7 +60,7 @@ def _on_back(self, e=None): ## 代码位置 -[src/yttrium.py](../../src/yttrium.py) → `class LedPage(AppPage)`(第 121 行起) +[src/led.py](../../src/led.py) → `class LedPage(AppPage)` ## 涉及模块 diff --git a/docs/05_clock/README.md b/docs/05_clock/README.md new file mode 100644 index 0000000..a00c4a0 --- /dev/null +++ b/docs/05_clock/README.md @@ -0,0 +1,53 @@ +# 时钟 + 秒表 + +本章讲解 Demo 中的时钟与秒表功能,左侧实时时钟,右侧秒表计时。 + +## 功能概述 + +- 左半区:绿字大时间 + 日期星期 +- 右半区:秒表(计次/复位/启动/暂停) + +## 运行效果 + +左侧 `HH:MM:SS`(绿色大字)+ 日期星期行,中间竖线分隔,右侧秒表显示 + Lap / Reset / Start / Stop 四个按钮。 + +## 代码解析 + +### 时钟更新 + +```python +t = utime.localtime() +self._time_lbl.set_text("{:02d}:{:02d}:{:02d}".format(t[3], t[4], t[5])) +self._date_lbl.set_text("{:04d}/{:02d}/{:02d} {:s}".format( + t[0], t[1], t[2], ("Mon","Tue","Wed","Thu","Fri","Sat","Sun")[t[6]])) +``` + +### 秒表驱动 + +```python +# lv.timer 每 20ms 累加 +self._elapsed += 20 +m = self._elapsed // 60000 +s = (self._elapsed // 1000) % 60 +ms = (self._elapsed // 10) % 100 +``` + +### 按钮操作 + +| 按钮 | 功能 | +|---|---| +| Lap | 计次(记录当前时间) | +| Reset | 复位归零 | +| Start | 开始 | +| Stop | 暂停 | + +## 代码位置 + +[src/clock.py](../../src/clock.py) → `class ClockPage(AppPage)` + +## 涉及模块 + +| 模块 | 用途 | +|---|---| +| `utime` | 系统时间 | +| `lvgl` | label、btn、timer | diff --git a/docs/05_adcVoltmeter/README.md b/docs/06_adc/README.md similarity index 92% rename from docs/05_adcVoltmeter/README.md rename to docs/06_adc/README.md index 42d2ee8..46be8c6 100644 --- a/docs/05_adcVoltmeter/README.md +++ b/docs/06_adc/README.md @@ -26,13 +26,6 @@ | 黄色(警告) | 240° ~ 300° | 1.65V ~ 3.3V | `0xFFEB3B` | | 红色(危险) | 300° ~ 360° | > 3.3V | `0xFF5252` | -指针角度计算: -``` -angle = 180° + (voltage / 3.3V) × 180° -0V → 180°(最左) -3.3V → 360°(最右) -``` - ## 代码解析 ### 1. 初始化 ADC @@ -87,8 +80,6 @@ def _on_back(self, e=None): ## 完整数据流 -![数据流](images/adc-flow.png) - 1. `lv.timer` 每 300ms 触发 `_adc_tick` 2. `ADC.read(ADC0)` × 6 次 → 取平均 raw 3. `raw × scale` → 电压值 V @@ -110,7 +101,7 @@ ADC0 测试点在板上有引出(参考 `datasheet/` 原理图 Sheet 7)。EG ## 代码位置 -[src/yttrium.py](../../src/yttrium.py) → `class AdcPage(AppPage)`(第 429 行起) +[src/adc.py](../../src/adc.py) → `class AdcPage(AppPage)` ## 涉及模块 diff --git a/docs/06_communicationHub/README.md b/docs/06_communicationHub/README.md deleted file mode 100644 index a59e83c..0000000 --- a/docs/06_communicationHub/README.md +++ /dev/null @@ -1,152 +0,0 @@ -# 通信协议 Hub - -本章讲解 Demo 中的通信协议 Hub,包含 **UART**、**CAN**、**AT**、**RS485** 四个通信终端,通过二级页面统一入口访问。 - -## 功能概述 - -Comm Hub 是一个二级导航页面,展示 4 个协议入口图标,点击进入对应的交互终端: - -![Comm Hub](images/comm-hub.png) - -| 协议 | 状态 | 说明 | -|---|---|---| -| UART | ✅ 已调通 | 串口终端,TX/RX 收发 + 键盘输入 | -| CAN | 界面就绪 | CAN 总线监控终端 | -| AT | 界面就绪 | AT 命令交互终端,真实 atcmd 收发 | -| RS485 | 界面就绪 | RS485 Modbus 终端 | - -## 导航架构 - -``` -主屏幕 → Comm Hub(二级页面) - ├── UART → UART 终端 - ├── CAN → CAN 终端 - ├── AT → AT 终端 - └── RS485 → RS485 终端 -``` - -- Comm Hub 的返回按钮 → 回到主屏幕 -- 子页面(UART/CAN/AT/RS485)的返回按钮 → 回到 Comm Hub - -## 各协议详解 - -### 1. UART 串口终端 - -![UART 界面](images/uart-terminal.png) - -| 参数 | 值 | -|---|---| -| 端口 | UART2 | -| 波特率 | 115200 | -| 数据位 | 8 | -| 校验 | None | -| 停止位 | 1 | -| 流控 | 无 | - -界面组成: -- 状态栏:`UART2:115200` -- 日志区(最多 6 行):TX(青色)/ RX(绿色)双向日志 -- 输入区:LVGL 虚拟键盘 + textarea - -```python -# UART 初始化 -from machine import UART -uart = UART(UART.UART2, 115200, 8, 0, 1, 0) -# 发送 -uart.write(data.encode()) -# 接收(lv.timer 轮询) -rx_data = uart.read(uart.any()) -``` - -### 2. CAN 总线终端 - -![CAN 界面](images/can-terminal.png) - -| 参数 | 值 | -|---|---| -| 速率 | 500kbps | -| 收发器 | SIT65HVD230DR | -| 模组引脚 | CAN_RXD / CAN_TXD / CAN_STB | - -界面组成: -- 状态栏:`CAN:500kbps` -- 日志区:显示发送/接收的 CAN 帧 -- 输入区:键盘 + textarea 输入帧数据 - -硬件连接:EG800Z 模组 CAN 引脚 → SIT65HVD230DR 收发器 → 3-pin 端子(CANH / CANL / GND)。 - -### 3. AT 命令终端 - -![AT 界面](images/at-terminal.png) - -界面组成: -- 状态栏:`Modem: Ready` -- 日志区(最多 6 行):AT 命令及响应 -- 输入区:键盘 + textarea 输入 AT 命令 - -```python -import atcmd -# 发送 AT 命令并接收响应 -response = atcmd.send("AT+CSQ\r\n") -``` - -### 4. RS485 终端 - -![RS485 界面](images/rs485-terminal.png) - -| 参数 | 值 | -|---|---| -| 波特率 | 9600 | -| 数据位 | 8 | -| 校验 | None | -| 停止位 | 1 | -| 收发器 | SIT3088EESA | -| 接线 | A / B 差分线 | - -界面组成: -- 状态栏:`RS485:9600-8-N-1 A/B` -- 日志区:TX(青色)/ RX(绿色)Modbus 帧 -- 输入区:键盘 + textarea 输入 Modbus 命令 - -**RS485 方向控制**:`RS485_TX/RX_Select` 信号(高 = TX,低 = RX),由驱动层自动切换,应用层无需干预。 - -## 通用代码结构 - -四个协议终端页面共享相同的架构: - -``` -ProtocolPage(AppPage) -├── 状态栏(lv.obj + lv.label) -├── 日志区(lv.obj,深色终端背景,最多 6 行) -│ └── 彩色标签(TX=青色 / RX=绿色 / 系统=灰色) -├── 输入区(lv.textarea + lv.keyboard) -│ └── 发送按钮 → 调用协议 API → 追加日志 -└── _on_back() → 回到 Comm Hub -``` - -## 代码位置 - -| 文件 | 行号 | 内容 | -|---|---|---| -| [src/yttrium.py](../../src/yttrium.py) | L3121 | `CommHubPage` — Hub 页面 | -| | L3181 | `UartPage` — UART 终端 | -| | L728 | `CanPage` — CAN 终端 | -| | L3404 | `AtPage` — AT 终端 | -| | L3607 | `Rs485Page` — RS485 终端 | - -## 涉及模块 - -| 模块 | 用途 | -|---|---| -| `machine.UART` | 硬件串口收发 | -| `atcmd` | AT 命令发送/接收 | -| `lvgl` | 终端 UI(键盘、文本框、日志行) | - -## 硬件连接 - -| 接口 | 板载端子/连接器 | 说明 | -|---|---|---| -| UART | USB-C → XR21B1411IL16 USB-UART | 通过 USB 连接 PC 通信 | -| CAN | J0602 3-pin 端子(CANH/CANL/GND) | 外接 CAN 总线设备 | -| RS485 | J0403 3-pin 端子(A/B/GND) | 外接 RS485 总线设备 | -| AT | 模组内部 AT 引擎 | 无需外部连接 | diff --git a/docs/07_calc/README.md b/docs/07_calc/README.md new file mode 100644 index 0000000..8f7a5a7 --- /dev/null +++ b/docs/07_calc/README.md @@ -0,0 +1,38 @@ +# 计算器 + +基础四则运算计算器,5×4 按钮网格。 + +## 功能概述 + +- 四则运算 + 百分比 +- C 清空、< 退格 +- 顶部表达式显示栏 + +## 按键布局 + +``` +┌───┬───┬───┬───┐ +│ C │ < │ % │ / │ +├───┼───┼───┼───┤ +│ 7 │ 8 │ 9 │ * │ +├───┼───┼───┼───┤ +│ 4 │ 5 │ 6 │ - │ +├───┼───┼───┼───┤ +│ 1 │ 2 │ 3 │ + │ +├───┴───┼───┼───┤ +│ 0 │ . │ = │ +└───────┴───┴───┘ +``` + +## 颜色方案 + +| 类型 | 色值 | 说明 | +|---|---|---| +| 数字键 | `0x334466` | 深蓝 | +| 运算符 | `0x0F3460` | 更深蓝 | +| 功能键 | `0x2C3E50` | 灰蓝 | +| 等号 | `0x00E676` | 亮绿 | + +## 代码位置 + +[src/calc.py](../../src/calc.py) → `class CalcPage(AppPage)` diff --git a/docs/08_comm/README.md b/docs/08_comm/README.md new file mode 100644 index 0000000..e1e9a7c --- /dev/null +++ b/docs/08_comm/README.md @@ -0,0 +1,67 @@ +# 通信协议 Hub + +本章讲解 Demo 中的通信协议 Hub,包含 **UART**、**CAN**、**AT**、**RS485** 四个通信终端,通过二级页面统一入口访问。 + +## 功能概述 + +Comm Hub 是一个二级导航页面,展示 4 个协议入口图标,点击进入对应的交互终端: + +| 协议 | 状态 | 说明 | +|---|---|---| +| UART | ✅ 已调通 | 串口终端,TX/RX 收发 + 键盘输入 | +| CAN | 界面就绪 | CAN 总线监控终端 | +| AT | 界面就绪 | AT 命令交互终端,真实 atcmd 收发 | +| RS485 | 界面就绪 | RS485 Modbus 终端 | + +## 导航架构 + +``` +主屏幕 → Comm Hub(二级页面) + ├── UART → UART 终端 + ├── CAN → CAN 终端 + ├── AT → AT 终端 + └── RS485 → RS485 终端 +``` + +- Comm Hub 的返回按钮 → 回到主屏幕 +- 子页面(UART/CAN/AT/RS485)的返回按钮 → 回到 Comm Hub + +## 各协议详解 + +### 1. UART 串口终端 + +| 参数 | 值 | +|---|---| +| 端口 | UART2 | +| 波特率 | 115200 | +| 数据位 | 8 | +| 校验 | None | +| 停止位 | 1 | +| 流控 | 无 | + +界面组成: +- 状态栏:`UART2:115200` +- 日志区(最多 6 行):TX(青色)/ RX(绿色)双向日志 +- 输入区:LVGL 虚拟键盘 + textarea + +```python +# UART 初始化 +from machine import UART +uart = UART(UART.UART2, 115200, 8, 0, 1, 0) +# 发送 +uart.write(data.encode()) +# 接收(lv.timer 轮询) +rx_data = uart.read(uart.any()) +``` + +### 2. CAN 总线终端 + +### 3. AT 命令终端 + +### 4. RS485 终端 + +## 硬件连接 + +| 接口 | 板载端子/连接器 | 说明 | +|---|---|---| +| UART | USB-C → XR21B1411IL16 USB-UART | 通过 USB 连接 PC 通信 | diff --git a/docs/09_key/README.md b/docs/09_key/README.md new file mode 100644 index 0000000..dbb5a31 --- /dev/null +++ b/docs/09_key/README.md @@ -0,0 +1,30 @@ +# 虚拟键盘 + +LVGL 内置 `lv.keyboard` 控件实现文本输入。 + +## 功能概述 + +- 多行输入框(支持换行) +- QWERTY 全键盘,贴屏底固定 160px 高 +- 默认文本 "Yttrium-Dev" + +## 关键代码 + +```python +ta = lv.textarea(self.screen) +ta.set_text("Yttrium-Dev") +ta.set_size(w - 12, screen_h - kb_h - base_y) + +kb = lv.keyboard(self.screen) +kb.set_textarea(ta) # 绑定输入框 +kb.set_size(w, 160) # 贴屏底 +``` + +## 注意事项 + +- 不用 `set_one_line(True)`,否则回车无法换行 +- `set_textarea()` 会自动调整键盘位置 + +## 代码位置 + +[src/keyboard.py](../../src/keyboard.py) → `class KeyboardPage(AppPage)` diff --git a/docs/10_camera/README.md b/docs/10_camera/README.md new file mode 100644 index 0000000..be77baf --- /dev/null +++ b/docs/10_camera/README.md @@ -0,0 +1,4 @@ +# 10_camera + +TODO + diff --git a/docs/09_qrShopping/README.md b/docs/11_qr/README.md similarity index 98% rename from docs/09_qrShopping/README.md rename to docs/11_qr/README.md index e4fae2a..cd55a6d 100644 --- a/docs/09_qrShopping/README.md +++ b/docs/11_qr/README.md @@ -155,7 +155,7 @@ def _do_pay(self): ## 代码位置 -[src/yttrium.py](../../src/yttrium.py) → `class QrPage(AppPage)`(第 1008 行起) +[src/qr_shop.py](../../src/qr_shop.py) → `class QrPage(AppPage)` ## 涉及模块 diff --git a/docs/10_audioRecorder/README.md b/docs/12_audio/README.md similarity index 100% rename from docs/10_audioRecorder/README.md rename to docs/12_audio/README.md diff --git a/docs/13_usb/README.md b/docs/13_usb/README.md new file mode 100644 index 0000000..6fbbd71 --- /dev/null +++ b/docs/13_usb/README.md @@ -0,0 +1,4 @@ +# 13_usb + +TODO + diff --git a/docs/14_phone/README.md b/docs/14_phone/README.md new file mode 100644 index 0000000..458196f --- /dev/null +++ b/docs/14_phone/README.md @@ -0,0 +1,43 @@ +# 电话拨号 + +模拟手机拨号键盘,支持号码输入和通话状态管理。 + +## 功能概述 + +- 标准 3×4 数字键盘(1-9、*、0、#) +- 顶部号码显示 + Del 按钮 +- CALL(绿)/ Back(灰)操作按钮 +- 通话状态机:IDLE → CALLING → ACTIVE / INCOMING + +## 按键布局 + +``` +┌─────┬─────┬─────┐ +│ 1 │ 2 │ 3 │ +│ 4 │ 5 │ 6 │ +│ 7 │ 8 │ 9 │ +│ * │ 0 │ # │ +├─────┴─────┴─────┤ +│ CALL Back │ +└─────────────────┘ +``` + +## 通话状态 + +| 状态 | CALL 按钮 | Back 按钮 | +|---|---|---| +| IDLE | CALL(绿) | Back(灰) | +| CALLING | 置灰 | Hangup(红) | +| ACTIVE | 置灰 | Hangup(红) | +| INCOMING | Answer(绿) | Reject(红) | + +## 代码位置 + +[src/phone.py](../../src/phone.py) → `class PhonePage(AppPage)` + +## 涉及模块 + +| 模块 | 用途 | +|---|---| +| `lvgl` | btn、label 布局 | +| `voiceCall` | 通话 API(可选) | diff --git a/docs/08_gameHub/README.md b/docs/15_game/README.md similarity index 95% rename from docs/08_gameHub/README.md rename to docs/15_game/README.md index 45c1377..f512a80 100644 --- a/docs/08_gameHub/README.md +++ b/docs/15_game/README.md @@ -150,7 +150,7 @@ GamePage(AppPage) ## 代码位置 -[src/yttrium.py](../../src/yttrium.py) +[src/game_hub.py](../../src/game_hub.py) — Hub,[src/snake.py](../../src/snake.py) / [src/game_2048.py](../../src/game_2048.py) / [src/tetris.py](../../src/tetris.py) | 类 | 行号 | 说明 | |---|---|---| diff --git a/docs/07_ethernetApp/README.md b/docs/16_eth/README.md similarity index 100% rename from docs/07_ethernetApp/README.md rename to docs/16_eth/README.md diff --git a/docs/17_buzzer/README.md b/docs/17_buzzer/README.md new file mode 100644 index 0000000..51d3db1 --- /dev/null +++ b/docs/17_buzzer/README.md @@ -0,0 +1,35 @@ +# 蜂鸣器 + +GPIO 驱动板载蜂鸣器,短响 Beep 和持续响 On/Off。 + +## 硬件连接 + +蜂鸣器通过拨码开关 **S0705** 连接到 GPIO2: + +> S0705 的 1-3 导通 → GPIO2 作为蜂鸣器 BEEP 的控制 + +电路:`EG800Z GPIO2 → R0701(1K) → Q0701(DTC043ZEBTL) → BUZ0701` + +## 功能 + +- Beep:短响 150ms +- On / Off:持续响 / 关闭 +- 状态显示:Ready / Beeping... / Sustained ON + +## 关键代码 + +```python +from machine import Pin +buz = Pin(Pin.GPIO2, Pin.OUT, Pin.PULL_DISABLE, 0) + +# 短响 +buz.write(1); utime.sleep_ms(150); buz.write(0) + +# 持续 +buz.write(1) # 开 +buz.write(0) # 关 +``` + +## 代码位置 + +[src/buzzer.py](../../src/buzzer.py) → `class BuzzerPage(AppPage)` diff --git a/docs/18_weather/README.md b/docs/18_weather/README.md new file mode 100644 index 0000000..78fd641 --- /dev/null +++ b/docs/18_weather/README.md @@ -0,0 +1,66 @@ +# 天气应用 + +通过蜂窝网络(4G)获取 wttr.in 实时天气数据,展示当前天气和三日预报。 + +## 功能概述 + +- 数据源:wttr.in 免费 API(HTTP) +- 当前天气:温度、描述、湿度 +- 三日预报:最高/最低温度 + 天气图标 +- 城市切换:点击城市名循环切换 5 个城市 +- 15 分钟自动刷新 + +## 界面布局 + +``` +┌─────────────────────────────┐ +│ 2026/07/31 [☀️] 32° │ +│ 14:30:45 晴 │ +│ Guilin > 💧65% │ +│ OK 14:30 │ +├────────┬────────┬───────────┤ +│ ☀️ │ 🌤️ │ ☁️ │ +│ Today │Tomorrow│ Day+2 │ +│ 32/25 │ 31/24 │ 30/23 │ +└────────┴────────┴───────────┘ +``` + +## 关键代码 + +```python +# 拉取数据 +url = "http://wttr.in/Guilin?format=j1" +data = request.get(url).json() +cur = data["current_condition"][0] +cur["temp_C"] # 温度 +cur["weatherDesc"] # 描述 + +# 三日预报 +for d in data["weather"][:3]: + d["maxtempC"], d["mintempC"] # 最高/最低温 +``` + +## 天气图标 + +7 个 48×48 PNG,根据 wttr.in 天气码自动切换: + +| 图标 | 天气 | +|---|---| +| `weather_sunny.png` | 晴 | +| `weather_partly_cloudy.png` | 多云 | +| `weather_cloudy.png` | 阴 | +| `weather_rainy.png` | 雨 | +| `weather_thunder.png` | 雷暴 | +| `weather_snowy.png` | 雪 | +| `weather_foggy.png` | 雾 | + +## 代码位置 + +[src/weather.py](../../src/weather.py) → `class WeatherPage(AppPage)` + +## 涉及模块 + +| 模块 | 用途 | +|---|---| +| `request` | HTTP 请求 | +| `lvgl` | card、img、label、timer、btn | diff --git a/docs/README.md b/docs/README.md index e7793e7..ce592e1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,15 +8,38 @@ Yttrium 开发板 配套教程文档 ## 教程目录 +### 基础篇 | 章节 | 内容 | |---|---| | [01 introduction](01_introduction/README.md) | 开发板介绍、硬件规格、Pin 定义 | | [02 firmwareFlashing](02_firmwareFlashing/README.md) | 固件烧录(FlashTools) | | [03 runDemo](03_runDemo/README.md) | 使用 QPYCOM 部署并启动 Demo | -| [04 ledControl](04_ledControl/README.md) | PWM 调光 & LED 控制 | -| [05 adcVoltmeter](05_adcVoltmeter/README.md) | ADC 采样 & 电压仪表盘 | -| [06 communicationHub](06_communicationHub/README.md) | UART / CAN / AT / RS485 通信 | -| [07 ethernetApp](07_ethernetApp/README.md) | W5500 以太网:IP / Ping / TCP | -| [08 gameHub](08_gameHub/README.md) | 贪吃蛇 / 2048 / 俄罗斯方块 | -| [09 qrShopping](09_qrShopping/README.md) | 饮品选购 + 二维码生成 | -| [10 audioRecorder](10_audioRecorder/README.md) | 录音播放 & 波形显示 | + +### Demo 应用(对应主屏 5×3 图标网格) + +**第一行** +| 章节 | 图标 | 功能 | +|---|---|---| +| [04 led](04_led/README.md) | LED | PWM 调光控制 | +| [05 clock](05_clock/README.md) | Clock | 实时时钟 + 秒表 | +| [06 adc](06_adc/README.md) | ADC | ADC 采样电压仪表盘 | +| [07 calc](07_calc/README.md) | Calc | 四则运算计算器 | +| [08 comm](08_comm/README.md) | Comm | 通信协议 Hub(UART/CAN/AT/RS485) | + +**第二行** +| 章节 | 图标 | 功能 | +|---|---|---| +| [09 key](09_key/README.md) | Key | LVGL 虚拟键盘 | +| [10 camera](10_camera/README.md) | Camera | 摄像头预览(暂未实现) | +| [11 qr](11_qr/README.md) | QR | 饮品选购 + 二维码支付 | +| [12 audio](12_audio/README.md) | Audio | 录音播放 | +| [13 usb](13_usb/README.md) | USB | U 盘浏览器(暂未实现) | + +**第三行** +| 章节 | 图标 | 功能 | +|---|---|---| +| [14 phone](14_phone/README.md) | Phone | 电话拨号 | +| [15 game](15_game/README.md) | Game | 游戏 Hub(贪吃蛇/2048/俄罗斯方块) | +| [16 eth](16_eth/README.md) | ETH | 以太网 Hub(IP/Ping/TCP) | +| [17 buzzer](17_buzzer/README.md) | Buzzer | 蜂鸣器控制 | +| [18 weather](18_weather/README.md) | Weather | 蜂窝网络天气 | diff --git a/src/icons/weather_cloudy.png b/src/icons/weather_cloudy.png new file mode 100644 index 0000000..4926b4c Binary files /dev/null and b/src/icons/weather_cloudy.png differ diff --git a/src/icons/weather_foggy.png b/src/icons/weather_foggy.png new file mode 100644 index 0000000..33e9ae1 Binary files /dev/null and b/src/icons/weather_foggy.png differ diff --git a/src/icons/weather_partly_cloudy.png b/src/icons/weather_partly_cloudy.png new file mode 100644 index 0000000..e14b4ff Binary files /dev/null and b/src/icons/weather_partly_cloudy.png differ diff --git a/src/icons/weather_rainy.png b/src/icons/weather_rainy.png new file mode 100644 index 0000000..6067e28 Binary files /dev/null and b/src/icons/weather_rainy.png differ diff --git a/src/icons/weather_snowy.png b/src/icons/weather_snowy.png new file mode 100644 index 0000000..bed3901 Binary files /dev/null and b/src/icons/weather_snowy.png differ diff --git a/src/icons/weather_sunny.png b/src/icons/weather_sunny.png new file mode 100644 index 0000000..87b8691 Binary files /dev/null and b/src/icons/weather_sunny.png differ diff --git a/src/icons/weather_thunder.png b/src/icons/weather_thunder.png new file mode 100644 index 0000000..3691263 Binary files /dev/null and b/src/icons/weather_thunder.png differ diff --git a/src/weather.py b/src/weather.py index 51ee40c..bcf2275 100644 --- a/src/weather.py +++ b/src/weather.py @@ -1,102 +1,238 @@ -# Yttrium Demo — auto‑split from yttrium.py +# Yttrium Demo — weather page import utime -import _thread import lvgl as lv from app_base import AppPage +# ============ 配置 ============ +CITIES = [ + ("Guilin", "Guilin"), + ("Shanghai", "Shanghai"), + ("Beijing", "Beijing"), + ("Guangzhou","Guangzhou"), + ("Shenzhen", "Shenzhen"), +] +CITY_IDX = 0 +CITY, CITY_NAME = CITIES[CITY_IDX] +FETCH_INTERVAL_MS = 900000 + +WEATHER_ICONS = { + "113": "weather_sunny", "116": "weather_partly_cloudy", + "119": "weather_cloudy", "122": "weather_cloudy", + "143": "weather_foggy", "248": "weather_foggy", "260": "weather_foggy", + "176": "weather_rainy", "263": "weather_rainy", "266": "weather_rainy", + "293": "weather_rainy", "296": "weather_rainy", "299": "weather_rainy", + "302": "weather_rainy", "305": "weather_rainy", "308": "weather_rainy", + "353": "weather_rainy", "356": "weather_rainy", "359": "weather_rainy", + "179": "weather_snowy", "227": "weather_snowy", "230": "weather_snowy", + "323": "weather_snowy", "326": "weather_snowy", "329": "weather_snowy", + "332": "weather_snowy", "338": "weather_snowy", "368": "weather_snowy", + "371": "weather_snowy", + "200": "weather_thunder", "386": "weather_thunder", "389": "weather_thunder", + "392": "weather_thunder", "395": "weather_thunder", +} + + class WeatherPage(AppPage): - """天气:展示卡片式界面。真实数据需联网 + HTTP API(下一步接),现为占位布局。""" + def _create_content(self): base_y = self.content_y w = self._sw + h = self.content_h + top_h = h * 3 // 5 + bot_h = h - top_h + cx = w // 2 + + # ====== 上半区 ====== + top_card = lv.obj(self.screen) + top_card.remove_style_all() + top_card.set_size(w - 16, top_h - 8) + top_card.set_pos(8, base_y + 4) + top_card.set_style_bg_color(lv.color_hex(0x16213E), 0) + top_card.set_style_bg_opa(lv.OPA.COVER, 0) + top_card.set_style_radius(12, 0) + top_card.set_style_pad_all(0, 0) + top_card.clear_flag(lv.obj.FLAG.CLICKABLE) + self._refs.append(top_card) + + self._date_lbl = lv.label(top_card) + self._date_lbl.set_text("----/--/--") + self._date_lbl.set_style_text_font(lv.font_montserrat_14, 0) + self._date_lbl.set_style_text_color(lv.color_hex(0xFFFFFF), 0) + self._date_lbl.set_pos(16, 12) + self._refs.append(self._date_lbl) + + self._time_lbl = lv.label(top_card) + self._time_lbl.set_text("--:--:--") + self._time_lbl.set_style_text_font(lv.font_montserrat_14, 0) + self._time_lbl.set_style_text_color(lv.color_hex(0xFFFFFF), 0) + self._time_lbl.set_pos(16, 36) + self._refs.append(self._time_lbl) + + self._city_btn = lv.btn(top_card) + self._city_btn.set_size(90, 24) + self._city_btn.set_pos(14, 56) + self._city_btn.set_style_bg_color(lv.color_hex(0x334466), 0) + self._city_btn.set_style_bg_opa(lv.OPA.COVER, 0) + self._city_btn.set_style_radius(4, 0) + self._city_btn.set_style_shadow_width(0, 0) + self._city_btn.set_style_border_width(0, 0) + self._city_btn.set_style_pad_all(0, 0) + self._city_btn.add_event_cb(lambda e: self._next_city(), lv.EVENT.CLICKED, None) + self._city_text = lv.label(self._city_btn) + self._city_text.set_text(CITY_NAME + " >") + self._city_text.set_style_text_font(lv.font_montserrat_14, 0) + self._city_text.set_style_text_color(lv.color_hex(0xFFFFFF), 0) + self._city_text.center() + self._refs.extend((self._city_btn, self._city_text)) + + self._weather_icon = lv.img(top_card) + self._weather_icon.set_size(48, 48) + self._weather_icon.set_pos(w - 16 - 48 - 20, 10) + self._weather_icon.set_src("U:/icons/weather_sunny.png") + self._refs.append(self._weather_icon) + + self._temp_lbl = lv.label(top_card) + self._temp_lbl.set_text("--") + self._temp_lbl.set_style_text_font(lv.font_montserrat_14, 0) + self._temp_lbl.set_style_text_color(lv.color_hex(0xFF9800), 0) + self._temp_lbl.set_pos(cx + 12, 12) + self._refs.append(self._temp_lbl) + + self._desc_lbl = lv.label(top_card) + self._desc_lbl.set_text("--") + self._desc_lbl.set_style_text_font(lv.font_montserrat_14, 0) + self._desc_lbl.set_style_text_color(lv.color_hex(0xFFFFFF), 0) + self._desc_lbl.set_pos(cx + 12, 40) + self._refs.append(self._desc_lbl) + + self._hum_lbl = lv.label(top_card) + self._hum_lbl.set_text("--") + self._hum_lbl.set_style_text_font(lv.font_montserrat_14, 0) + self._hum_lbl.set_style_text_color(lv.color_hex(0x4DA6FF), 0) + self._hum_lbl.set_pos(cx + 12, 64) + self._refs.append(self._hum_lbl) + + self._status_lbl = lv.label(top_card) + self._status_lbl.set_text("Wait...") + self._status_lbl.set_style_text_font(lv.font_montserrat_14, 0) + self._status_lbl.set_style_text_color(lv.color_hex(0xFFFFFF), 0) + self._status_lbl.set_pos(16, top_h - 28) + self._refs.append(self._status_lbl) + + # ====== 下半区:3 张预报卡片 ====== + self._forecast_refs = [] + card_w = (w - 24) // 3 + card_h = bot_h - 8 + days = ("Today", "Tomorrow", "Day+2") + for i in range(3): + fx = 8 + i * (card_w + 4) + fy = base_y + top_h + f_card = lv.obj(self.screen) + f_card.remove_style_all() + f_card.set_size(card_w, card_h) + f_card.set_pos(fx, fy) + f_card.set_style_bg_color(lv.color_hex(0x16213E), 0) + f_card.set_style_bg_opa(lv.OPA.COVER, 0) + f_card.set_style_radius(10, 0) + f_card.set_style_pad_all(0, 0) + f_card.clear_flag(lv.obj.FLAG.CLICKABLE) + self._refs.append(f_card) + + f_icon = lv.img(f_card) + f_icon.set_size(48, 48) + f_icon.set_pos((card_w - 48) // 2, 0) + f_icon.set_src("U:/icons/weather_sunny.png") + self._refs.append(f_icon) + + f_day = lv.label(f_card) + f_day.set_text(days[i]) + f_day.set_style_text_font(lv.font_montserrat_14, 0) + f_day.set_style_text_color(lv.color_hex(0xFFFFFF), 0) + f_day.set_pos(4, 52) + self._refs.append(f_day) + + f_temp = lv.label(f_card) + f_temp.set_text("--") + f_temp.set_style_text_font(lv.font_montserrat_14, 0) + f_temp.set_style_text_color(lv.color_hex(0xFF9800), 0) + f_temp.set_pos(4, card_h - 22) + self._refs.append(f_temp) + + self._forecast_refs.append((f_icon, f_day, f_temp)) + + # ====== Timer ====== + self._tick_count = 0 + self._fetch_interval_ticks = max(1, FETCH_INTERVAL_MS // 1000) + self._timer_active = True + self._fetching = False + self._timer = lv.timer_create(self._tick, 1000, None) + + # ============ Timer ============ + + def _tick(self, timer): + if not self._timer_active: + return + self._tick_count += 1 + try: + t = utime.localtime() + self._date_lbl.set_text("{:04d}/{:02d}/{:02d}".format(t[0], t[1], t[2])) + self._time_lbl.set_text("{:02d}:{:02d}:{:02d}".format(t[3], t[4], t[5])) + except Exception: + pass + + need = (self._tick_count == 3) or \ + (self._tick_count > 3 and (self._tick_count - 3) % self._fetch_interval_ticks == 0) + if need and not self._fetching: + self._fetching = True + self._fetch_weather() + + # ============ Fetch ============ + + def _fetch_weather(self): + print("[WEATHER] fetch") + try: + import request + + url = "http://wttr.in/{}?format=j1".format(CITY) + print("[WEATHER] GET", url) + resp = request.get(url) + data = resp.json() + resp.close() + print("[WEATHER] got data") + + cur = data["current_condition"][0] + self._temp_lbl.set_text("{}".format(cur["temp_C"])) + self._desc_lbl.set_text(cur["weatherDesc"][0]["value"]) + self._hum_lbl.set_text("{}%".format(cur.get("humidity", "--"))) + ic = WEATHER_ICONS.get(str(cur["weatherCode"]), "weather_partly_cloudy") + self._weather_icon.set_src("U:/icons/{}.png".format(ic)) + + for i, d in enumerate(data["weather"][:3]): + icon, day_lbl, temp_lbl = self._forecast_refs[i] + fc_code = d["hourly"][4]["weatherCode"] + fc_ic = WEATHER_ICONS.get(str(fc_code), "weather_partly_cloudy") + icon.set_src("U:/icons/{}.png".format(fc_ic)) + temp_lbl.set_text("{}/{}".format(d["maxtempC"], d["mintempC"])) + + self._status_lbl.set_text("OK {:02d}:{:02d}".format( + utime.localtime()[3], utime.localtime()[4])) + + except Exception as e: + print("[WEATHER]", e) + self._status_lbl.set_text("Err") + self._fetching = False + + # ============ City ============ - # ---- 主卡片:当前天气 ---- - card = lv.obj(self.screen) - card.remove_style_all() - card.set_size(w - 24, 150) - card.set_pos(12, base_y + 4) - card.set_style_bg_color(lv.color_hex(0x16213E), 0) - card.set_style_bg_opa(lv.OPA.COVER, 0) - card.set_style_radius(12, 0) - card.set_style_pad_all(0, 0) - card.set_style_border_width(0, 0) - card.clear_flag(lv.obj.FLAG.CLICKABLE) - self._refs.append(card) - - # 城市 - city = lv.label(card) - city.set_text("Shanghai") - city.set_style_text_font(lv.font_montserrat_14, 0) - city.set_style_text_color(lv.color_hex(0xFFFFFF), 0) - city.set_pos(20, 14) - self._refs.append(city) - - # 大温度 - temp = lv.label(card) - temp.set_text("--℃") - temp.set_style_text_font(lv.font_montserrat_14, 0) - temp.set_style_text_color(lv.color_hex(0x00E676), 0) - temp.set_pos(20, 44) - self._refs.append(temp) - - # 天气描述 - desc = lv.label(card) - desc.set_text("--") - desc.set_style_text_font(lv.font_montserrat_14, 0) - desc.set_style_text_color(lv.color_hex(0xFFFFFF), 0) - desc.set_pos(20, 110) - self._refs.append(desc) - - # 右上角"图标"占位(云/太阳用文字近似) - ic = lv.label(card) - ic.set_text("~") - ic.set_style_text_font(lv.font_montserrat_14, 0) - ic.set_style_text_color(lv.color_hex(0xFFEB3B), 0) - ic.set_pos(w - 24 - 40, 14) - self._refs.append(ic) - - # ---- 三个小信息卡:湿度 / 风速 / 体感 ---- - info_y = base_y + 162 - info_h = 60 - gap = 8 - cw = (w - 24 - 2 * gap) // 3 - infos = (("Humidity", "-- %", 0x2196F3), - ("Wind", "-- km/h", 0x9C27B0), - ("Feels", "--℃", 0xFF9800)) - for i, (cap, val, col) in enumerate(infos): - x = 12 + i * (cw + gap) - c = lv.obj(self.screen) - c.remove_style_all() - c.set_size(cw, info_h) - c.set_pos(x, info_y) - c.set_style_bg_color(lv.color_hex(0x16213E), 0) - c.set_style_bg_opa(lv.OPA.COVER, 0) - c.set_style_radius(8, 0) - c.set_style_pad_all(0, 0) - c.set_style_border_width(0, 0) - c.clear_flag(lv.obj.FLAG.CLICKABLE) - cap_lbl = lv.label(c) - cap_lbl.set_text(cap) - cap_lbl.set_style_text_font(lv.font_montserrat_14, 0) - cap_lbl.set_style_text_color(lv.color_hex(0xFFFFFF), 0) - cap_lbl.set_pos(8, 6) - val_lbl = lv.label(c) - val_lbl.set_text(val) - val_lbl.set_style_text_font(lv.font_montserrat_14, 0) - val_lbl.set_style_text_color(lv.color_hex(col), 0) - val_lbl.set_pos(8, 30) - self._refs.extend((c, cap_lbl, val_lbl)) - - # 底部提示 - hint = lv.label(self.screen) - hint.set_text("Demo layout — live data needs network + HTTP API") - hint.set_style_text_font(lv.font_montserrat_14, 0) - hint.set_style_text_color(lv.color_hex(0xFFFFFF), 0) - hint.set_pos((w - 260) // 2, info_y + info_h + 8) - self._refs.append(hint) - - -# ======================================================= -# 游戏二级界面(主桌面点 Game 进来,再选具体游戏) -# ======================================================= + def _next_city(self): + global CITY_IDX, CITY, CITY_NAME + CITY_IDX = (CITY_IDX + 1) % len(CITIES) + CITY, CITY_NAME = CITIES[CITY_IDX] + self._city_text.set_text(CITY_NAME + " >") + self._status_lbl.set_text("Switch...") + self._tick_count = 0 + self._fetching = False + def _on_back(self, e=None): + self._timer_active = False + super()._on_back(e) diff --git a/src/yttrium.py b/src/yttrium.py deleted file mode 100644 index 2701beb..0000000 --- a/src/yttrium.py +++ /dev/null @@ -1,4713 +0,0 @@ -""" -Yttrium(EC718) 480x320 大屏主应用(完全自包含) -================================================ -- LCD 驱动内联(来自 python_biglcd):面板为 320x480 物理竖屏(ST7796),LVGL - 软件旋转(sw_rotate + 270 度)后得到 480x320 横屏逻辑分辨率。 -- UI 由 test_ext_img.py 内联而来:AppPage 基类 + 15 个 App 页面 + PAGE_MAP - + MainScreen 主桌面(状态栏 + 5x3 图标网格 + 导航)。 -- 触摸使用 ft6x36(irq=40, reset=39, group=1);若新板触摸 IC / 引脚不同,改 YttriumApp._init_touch。 - -部署:本文件 + icons/ 放到设备 U:/ 同目录,直接运行本文件(不再需要 python_lcd.py)。 -""" - -import utime -import _thread -import lvgl as lv -# import voiceCall -from machine import LCD, Pin -from tp import ft6x36 as Ft6x36 - - -class AppPage: - """应用页面基类。子类重写 _create_content() 实现各自功能。 - - 子类只需关心内容区域,标题栏和返回按钮由基类自动创建。 - """ - - TITLE_BAR_H = 30 - - def __init__(self, screen_w, screen_h, title, back_cb): - """ - Args: - screen_w: 屏幕宽度 - screen_h: 屏幕高度 - title: 页面标题(显示在标题栏) - back_cb: 返回主界面的回调函数 - """ - self._sw = screen_w - self._sh = screen_h - self._back_cb = back_cb - self._refs = [] # 防止 GC 回收 - - # 创建页面 - self.screen = lv.obj() - self.screen.remove_style_all() - self.screen.set_size(screen_w, screen_h) - self.screen.set_style_bg_color(lv.color_hex(0x2C2C46), 0) - self.screen.set_style_bg_opa(lv.OPA.COVER, 0) - self.screen.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) - self.screen.clear_flag(lv.obj.FLAG.SCROLLABLE) # 关掉滚动:避免键盘/输入框聚焦时屏幕滚动把内容挤出 - - self._create_title_bar(title) - self._create_content() - - def _create_title_bar(self, title): - """创建顶部标题栏 + 返回按钮。""" - bar = lv.obj(self.screen) - bar.remove_style_all() - bar.set_size(self._sw, self.TITLE_BAR_H) - bar.set_pos(0, 0) - bar.set_style_bg_color(lv.color_hex(0x16213E), 0) - bar.set_style_bg_opa(lv.OPA.COVER, 0) - - # 返回按钮 - back_btn = lv.btn(bar) - back_btn.set_size(50, 24) - back_btn.set_pos(4, 3) - back_btn.set_style_bg_color(lv.color_hex(0x334466), 0) - back_btn.set_style_bg_opa(lv.OPA.COVER, 0) - back_btn.set_style_radius(4, 0) - back_btn.set_style_shadow_width(0, 0) - back_btn.set_style_border_width(0, 0) - - back_lbl = lv.label(back_btn) - back_lbl.set_text("< Back") - back_lbl.set_style_text_font(lv.font_montserrat_14, 0) - back_lbl.set_style_text_color(lv.color_hex(0x2C2C46), 0) - back_lbl.center() - - # 页面标题 - title_lbl = lv.label(bar) - title_lbl.set_text(title) - title_lbl.set_style_text_font(lv.font_montserrat_14, 0) - title_lbl.set_style_text_color(lv.color_hex(0x2C2C46), 0) - title_lbl.set_pos(60, 7) - - back_btn.add_event_cb( - self._on_back, - lv.EVENT.CLICKED, None, - ) - self._refs.extend((bar, back_btn, back_lbl, title_lbl)) - - def _create_content(self): - """创建页面内容。子类重写此方法。""" - hint = lv.label(self.screen) - hint.set_text("TODO") - hint.set_style_text_font(lv.font_montserrat_14, 0) - hint.set_style_text_color(lv.color_hex(0x555555), 0) - hint.set_pos(self._sw // 2 - 20, self._sh // 2 - 7) - self._refs.append(hint) - - def _on_back(self, e=None): - """返回按钮回调。""" - print("[PAGE] back") - self._back_cb() - - @property - def content_y(self): - """内容区域起始 Y 坐标(标题栏下方)。""" - return self.TITLE_BAR_H + 4 - - @property - def content_h(self): - """内容区域可用高度。""" - return self._sh - self.TITLE_BAR_H - 4 - - -# ======================================================= -# 各 App 页面(后续在此添加具体功能) -# ======================================================= - -class LedPage(AppPage): - """LED 滑块控制亮度。""" - def _create_content(self): - base_y = self.content_y - cx = self._sw // 2 - - # 灯泡光晕 - glow = lv.obj(self.screen) - glow.remove_style_all() - glow.set_size(92, 92) - glow.set_pos(cx - 46, base_y + 24) - glow.set_style_bg_color(lv.color_hex(0x0F3460), 0) - glow.set_style_bg_opa(lv.OPA.COVER, 0) - glow.set_style_radius(46, 0) - glow.clear_flag(lv.obj.FLAG.CLICKABLE) - self._refs.append(glow) - - # 灯泡(绿色圆) - bulb = lv.obj(self.screen) - bulb.remove_style_all() - bulb.set_size(52, 52) - bulb.set_pos(cx - 26, base_y + 30) - bulb.set_style_bg_color(lv.color_hex(0x00E676), 0) - bulb.set_style_bg_opa(lv.OPA.COVER, 0) - bulb.set_style_radius(26, 0) - bulb.clear_flag(lv.obj.FLAG.CLICKABLE) - self._refs.append(bulb) - self._bulb = bulb - - # 亮度百分比 - self._led_pct = lv.label(self.screen) - self._led_pct.set_text("75%") - self._led_pct.set_style_text_font(lv.font_montserrat_14, 0) - self._led_pct.set_style_text_color(lv.color_hex(0x00E676), 0) - self._led_pct.set_pos(cx - 24, base_y + 120) - self._refs.append(self._led_pct) - - # 滑块 - self._slider = lv.slider(self.screen) - self._slider.set_size(236, 8) - self._slider.set_pos(cx - 118, base_y + 170) - self._slider.set_value(75, lv.ANIM.OFF) - self._slider.set_style_bg_color(lv.color_hex(0x16213E), 0) # 轨道 - self._slider.set_style_bg_color(lv.color_hex(0x00E676), lv.PART.INDICATOR) # 已填充段 - self._slider.set_style_bg_color(lv.color_hex(0x2C2C46), lv.PART.KNOB) # 手柄 - self._slider.add_event_cb(self._on_slider, lv.EVENT.VALUE_CHANGED, None) - self._refs.append(self._slider) - - # 状态行 - self._led_info = lv.label(self.screen) - self._led_info.set_text("PWM CH:11 Duty:75%") - self._led_info.set_style_text_font(lv.font_montserrat_14, 0) - self._led_info.set_style_text_color(lv.color_hex(0x888888), 0) - self._led_info.set_pos(cx - 70, base_y + 186) - self._refs.append(self._led_info) - - # ===== 真实 PWM(STATUS 灯 = pin25 / PWM11;实测可硬件调光)===== - # cycleTime=1000us ≈ 1kHz。highTime = cycle * 占空比/100。 - self._pwm = None - self._pwm_above = None - self._pwm_cycle = 1000 - try: - from misc import PWM - self._pwm_above = PWM.ABOVE_1US - self._pwm = PWM(PWM.PWM11, PWM.ABOVE_1US, 750, self._pwm_cycle) # 默认 75%(对齐滑块) - self._pwm.open() - print("[LED] PWM11 open ok (STATUS LED)") - except Exception as e: - print("[LED] PWM init failed:", e) - self._set_brightness(75) - - def _set_brightness(self, v): - """v: 0~100。更新百分比/状态行 + 灯泡透明度(视觉) + 真实 PWM 占空比。""" - if v < 0: - v = 0 - if v > 100: - v = 100 - self._led_pct.set_text("{}%".format(v)) - self._led_info.set_text("PWM CH:11 Duty:{}%".format(v)) - try: - self._bulb.set_style_bg_opa(int(255 * v / 100), 0) # 屏幕灯泡随亮度 - except Exception: - pass - if self._pwm: - try: - high = int(self._pwm_cycle * v / 100) - if high < 1: - high = 1 # ABOVE_1US 不接受 0 - self._pwm.open(self._pwm_above, high, self._pwm_cycle) - except Exception as ex: - print("[LED] pwm set:", ex) - - def _on_slider(self, e=None): - self._set_brightness(self._slider.get_value()) - - def _on_back(self, e=None): - if self._pwm: - try: - self._pwm.close() - except Exception: - pass - super()._on_back(e) - - -class ClockPage(AppPage): - """实时时钟 + 秒表。""" - - DAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") - - def _create_content(self): - base_y = self.content_y - - # ====== 左半区:时钟 ====== - - # 时间 - 大字 - self._time_lbl = lv.label(self.screen) - self._time_lbl.set_text("00:00:00") - self._time_lbl.set_style_text_font(lv.font_montserrat_14, 0) - self._time_lbl.set_style_text_color(lv.color_hex(0x00E676), 0) - self._time_lbl.set_pos(20, base_y + 20) - self._refs.append(self._time_lbl) - - # 日期 - self._date_lbl = lv.label(self.screen) - self._date_lbl.set_text("----/--/-- ---") - self._date_lbl.set_style_text_font(lv.font_montserrat_14, 0) - self._date_lbl.set_style_text_color(lv.color_hex(0xAAAAAA), 0) - self._date_lbl.set_pos(20, base_y + 50) - self._refs.append(self._date_lbl) - - # 分隔线 - sep = lv.obj(self.screen) - sep.remove_style_all() - sep.set_size(1, self.content_h - 20) - sep.set_pos(140, base_y + 10) - sep.set_style_bg_color(lv.color_hex(0x334466), 0) - sep.set_style_bg_opa(lv.OPA.COVER, 0) - self._refs.append(sep) - - # ====== 右半区:秒表 ====== - - rx = 160 - - # 秒表标题 - sw_title = lv.label(self.screen) - sw_title.set_text("Stopwatch") - sw_title.set_style_text_font(lv.font_montserrat_14, 0) - sw_title.set_style_text_color(lv.color_hex(0x888888), 0) - sw_title.set_pos(rx, base_y + 5) - self._refs.append(sw_title) - - # 秒表数值 - self._sw_lbl = lv.label(self.screen) - self._sw_lbl.set_text("00:00.00") - self._sw_lbl.set_style_text_font(lv.font_montserrat_14, 0) - self._sw_lbl.set_style_text_color(lv.color_hex(0x2C2C46), 0) - self._sw_lbl.set_pos(rx, base_y + 30) - self._refs.append(self._sw_lbl) - - # 计次列表(最多显示 3 条) - self._lap_labels = [] - for i in range(3): - lbl = lv.label(self.screen) - lbl.set_text("") - lbl.set_style_text_font(lv.font_montserrat_14, 0) - lbl.set_style_text_color(lv.color_hex(0x666666), 0) - lbl.set_pos(rx, base_y + 55 + i * 16) - self._lap_labels.append(lbl) - self._refs.append(lbl) - - # 按钮行 - btn_y = base_y + self.content_h - 42 - btn_w = 54 - btn_h = 30 - - # Start / Stop - self._btn_ss = lv.btn(self.screen) - self._btn_ss.set_size(btn_w, btn_h) - self._btn_ss.set_pos(rx, btn_y) - self._btn_ss.set_style_bg_color(lv.color_hex(0x00E676), 0) - self._btn_ss.set_style_bg_opa(lv.OPA.COVER, 0) - self._btn_ss.set_style_radius(4, 0) - self._btn_ss.set_style_shadow_width(0, 0) - self._btn_ss.set_style_border_width(0, 0) - self._btn_ss.add_event_cb(lambda e: self._sw_toggle(), lv.EVENT.CLICKED, None) - self._btn_ss_lbl = lv.label(self._btn_ss) - self._btn_ss_lbl.set_text("Start") - self._btn_ss_lbl.set_style_text_font(lv.font_montserrat_14, 0) - self._btn_ss_lbl.set_style_text_color(lv.color_hex(0x000000), 0) - self._btn_ss_lbl.center() - - # Lap / Reset - self._btn_lr = lv.btn(self.screen) - self._btn_lr.set_size(btn_w, btn_h) - self._btn_lr.set_pos(rx + btn_w + 8, btn_y) - self._btn_lr.set_style_bg_color(lv.color_hex(0x334466), 0) - self._btn_lr.set_style_bg_opa(lv.OPA.COVER, 0) - self._btn_lr.set_style_radius(4, 0) - self._btn_lr.set_style_shadow_width(0, 0) - self._btn_lr.set_style_border_width(0, 0) - self._btn_lr.add_event_cb(lambda e: self._sw_lap_reset(), lv.EVENT.CLICKED, None) - self._btn_lr_lbl = lv.label(self._btn_lr) - self._btn_lr_lbl.set_text("Lap") - self._btn_lr_lbl.set_style_text_font(lv.font_montserrat_14, 0) - self._btn_lr_lbl.set_style_text_color(lv.color_hex(0x2C2C46), 0) - self._btn_lr_lbl.center() - - self._refs.extend((sw_title, self._sw_lbl, self._btn_ss, self._btn_ss_lbl, - self._btn_lr, self._btn_lr_lbl, sep)) - - # -- 秒表状态 -- - self._sw_running = False - self._sw_start_ms = 0 - self._sw_elapsed = 0 # 累计毫秒 - self._sw_laps = [] - self._update_running = False - - # -- 启动更新线程 -- - self._update_running = True - _thread.start_new_thread(self._update_loop, ()) - self._update_clock() - - # ---------- 时钟 ---------- - - def _update_clock(self): - t = utime.localtime() - self._time_lbl.set_text("{:02d}:{:02d}:{:02d}".format(t[3], t[4], t[5])) - self._date_lbl.set_text("{:04d}/{:02d}/{:02d} {}".format( - t[0], t[1], t[2], self.DAYS[t[6]])) - - # ---------- 秒表 ---------- - - def _sw_toggle(self): - if self._sw_running: - # Stop - self._sw_running = False - self._sw_elapsed += utime.ticks_ms() - self._sw_start_ms - self._btn_ss_lbl.set_text("Start") - self._btn_ss.set_style_bg_color(lv.color_hex(0x00E676), 0) - self._btn_lr_lbl.set_text("Reset") - else: - # Start - self._sw_start_ms = utime.ticks_ms() - self._sw_running = True - self._btn_ss_lbl.set_text("Stop") - self._btn_ss.set_style_bg_color(lv.color_hex(0xFF5252), 0) - self._btn_lr_lbl.set_text("Lap") - - def _sw_lap_reset(self): - if self._sw_running: - # 记录计次 - total = self._sw_elapsed + (utime.ticks_ms() - self._sw_start_ms) - self._sw_laps.append(total) - if len(self._sw_laps) > 3: - self._sw_laps = self._sw_laps[-3:] - self._render_laps() - else: - # 重置 - self._sw_elapsed = 0 - self._sw_laps = [] - self._sw_lbl.set_text("00:00.00") - for lbl in self._lap_labels: - lbl.set_text("") - self._btn_lr_lbl.set_text("Lap") - - def _render_sw(self): - total = self._sw_elapsed - if self._sw_running: - total += utime.ticks_ms() - self._sw_start_ms - self._sw_lbl.set_text(self._format_ms(total)) - - def _render_laps(self): - for i, lbl in enumerate(self._lap_labels): - idx = len(self._sw_laps) - len(self._lap_labels) + i - if 0 <= idx < len(self._sw_laps): - lbl.set_text("Lap{}: {}".format(idx + 1, - self._format_ms(self._sw_laps[idx]))) - else: - lbl.set_text("") - - @staticmethod - def _format_ms(ms): - cs = (ms // 10) % 100 - s = (ms // 1000) % 60 - m = (ms // 60000) % 60 - return "{:02d}:{:02d}.{:02d}".format(m, s, cs) - - # ---------- 更新线程 ---------- - - def _update_loop(self): - while self._update_running: - utime.sleep_ms(200) - try: - self._update_clock() - if self._sw_running: - self._render_sw() - except Exception: - pass - - # ---------- 退出清理 ---------- - - def _on_back(self, e=None): - self._update_running = False - self._sw_running = False - utime.sleep_ms(250) - super()._on_back(e) - - -class AdcPage(AppPage): - """ADC 电压仪表盘。""" - def _create_content(self): - import math - base_y = self.content_y - cx = self._sw // 2 - cy = base_y + 78 - R = 60 - - # 三色弧段(半圆,开口朝下) - for a0, a1, col in ((180, 240, 0x00E676), (240, 300, 0xFFEB3B), (300, 360, 0xFF5252)): - a = lv.arc(self.screen) - a.set_size(R * 2, R * 2) - a.set_pos(cx - R, cy - R) - a.set_bg_angles(a0, a1) - a.set_range(0, 100) - a.set_value(100) - a.set_style_arc_color(lv.color_hex(0x16213E), lv.PART.MAIN) # 背景弧 - a.set_style_arc_width(12, lv.PART.MAIN) - a.set_style_arc_color(lv.color_hex(col), lv.PART.INDICATOR) # 前景(进度)弧 - a.set_style_arc_width(12, lv.PART.INDICATOR) - a.remove_style(None, lv.PART.KNOB) - a.clear_flag(lv.obj.FLAG.CLICKABLE) - self._refs.append(a) - - # 指针(lv.line,静态指向 ~1.85V) - ang = math.radians(281) - px = cx + (R - 16) * math.cos(ang) - py = cy + (R - 16) * math.sin(ang) - p0 = lv.point_t() - p0.x = cx - p0.y = cy - p1 = lv.point_t() - p1.x = int(px) - p1.y = int(py) - needle = lv.line(self.screen) - needle.set_points([p0, p1], 2) - needle.set_style_line_color(lv.color_hex(0x2C2C46), 0) - needle.set_style_line_width(3, 0) - needle.set_style_line_rounded(True, 0) - self._refs.append(needle) - self._needle = needle - self._p0, self._p1 = p0, p1 - self._cx, self._cy, self._R = cx, cy, R - - # 圆心 - hub = lv.obj(self.screen) - hub.remove_style_all() - hub.set_size(8, 8) - hub.set_pos(cx - 4, cy - 4) - hub.set_style_bg_color(lv.color_hex(0x2C2C46), 0) - hub.set_style_bg_opa(lv.OPA.COVER, 0) - hub.set_style_radius(4, 0) - hub.clear_flag(lv.obj.FLAG.CLICKABLE) - self._refs.append(hub) - - # 数值 - self._adc_val = lv.label(self.screen) - self._adc_val.set_text("1.85") - self._adc_val.set_style_text_font(lv.font_montserrat_14, 0) - self._adc_val.set_style_text_color(lv.color_hex(0x00E676), 0) - self._adc_val.set_pos(cx - 28, cy + 18) - self._refs.append(self._adc_val) - unit = lv.label(self.screen) - unit.set_text("Volts") - unit.set_style_text_font(lv.font_montserrat_14, 0) - unit.set_style_text_color(lv.color_hex(0xAAAAAA), 0) - unit.set_pos(cx - 24, cy + 44) - self._refs.append(unit) - - # 刻度标签 - for tx, t in ((cx - R, "0V"), (cx - 14, "1.65V"), (cx + R - 18, "3.3V")): - lbl = lv.label(self.screen) - lbl.set_text(t) - lbl.set_style_text_font(lv.font_montserrat_14, 0) - lbl.set_style_text_color(lv.color_hex(0x666666), 0) - lbl.set_pos(tx, cy + R + 6) - self._refs.append(lbl) - - # 通道信息 - info = lv.label(self.screen) - info.set_text("ADC0 Raw: 1483 / 4095") - info.set_style_text_font(lv.font_montserrat_14, 0) - info.set_style_text_color(lv.color_hex(0x888888), 0) - info.set_pos(cx - 80, base_y + 186) - self._refs.append(info) - self._adc_info = info - - # ===== 真实 ADC(lv.timer 在主线程采样,绝不开子线程碰 LVGL)===== - self._adc = None - self._adc_chan = 0 - try: - from misc import ADC - self._adc = ADC() - self._adc_chan = ADC.ADC0 - self._adc.open() - print("[ADC] open ADC0 ok") - except Exception as e: - print("[ADC] init failed:", e) - self._adc_scale = 3.3 / 757.0 # 实测:3.3V 输入 -> raw≈757(板上 ~5.4:1 分压)。用精确 raw 再微调 - self._adc_active = True - self._adc_timer = lv.timer_create(self._adc_tick, 300, None) - self._adc_tick(None) # 立即先读一次 - - def _adc_tick(self, timer): - """lv.timer 回调(主线程):读 ADC0 -> 电压,更新指针角度 + 数值 + raw。""" - if not self._adc_active or self._adc is None: - return - import math - try: - samples = [self._adc.read(self._adc_chan) for _ in range(6)] - except Exception: - return - samples = [s for s in samples if s is not None] - if not samples: - return - raw = sum(samples) // len(samples) # 6 次采样平均,抑制抖动 - v = raw * self._adc_scale - self._adc_val.set_text("{:.2f}".format(v)) - self._adc_info.set_text("ADC0 Raw: {} / 4095".format(raw)) - vv = 0.0 if v < 0 else (3.3 if v > 3.3 else v) - ang = math.radians(180 + (vv / 3.3) * 180) # 0V=180°, 3.3V=360° - self._p1.x = int(self._cx + (self._R - 16) * math.cos(ang)) - self._p1.y = int(self._cy + (self._R - 16) * math.sin(ang)) - self._needle.set_points([self._p0, self._p1], 2) - - def _on_back(self, e=None): - self._adc_active = False # 停止采样(timer 还在但 no-op) - super()._on_back(e) - - -class CalcPage(AppPage): - """基础计算器 -- 四则运算,5x4 按钮网格。""" - - # -- 颜色 -- - C_NUM = 0x334466 - C_OP = 0x0F3460 - C_FUNC = 0x2C3E50 - C_EQ = 0x00E676 - C_TXT = 0xFFFFFF - - # -- 布局(内容区 480x286)-- - DISP_H = 48 # 显示栏高度 - COLS = 4 - ROWS = 5 - COL_W = 120 # 480 // 4,满宽 - ROW_H = 47 # (286 - 48) // 5 - - # (文字, 颜色, 列跨度) - _LAYOUT = ( - [("C", C_FUNC, 1), ("<", C_FUNC, 1), ("%", C_FUNC, 1), ("/", C_OP, 1)], - [("7", C_NUM, 1), ("8", C_NUM, 1), ("9", C_NUM, 1), ("*", C_OP, 1)], - [("4", C_NUM, 1), ("5", C_NUM, 1), ("6", C_NUM, 1), ("-", C_OP, 1)], - [("1", C_NUM, 1), ("2", C_NUM, 1), ("3", C_NUM, 1), ("+", C_OP, 1)], - [("0", C_NUM, 2), (".", C_NUM, 1), ("=", C_EQ, 1)], - ) - - def _create_content(self): - base_y = self.content_y # 34 - - # -- 显示栏 -- - disp = lv.obj(self.screen) - disp.remove_style_all() - disp.set_size(self._sw, self.DISP_H) - disp.set_pos(0, base_y) - disp.set_style_bg_color(lv.color_hex(0x16213E), 0) - disp.set_style_bg_opa(lv.OPA.COVER, 0) - disp.set_style_pad_all(4, 0) - self._refs.append(disp) - - self._expr_label = lv.label(disp) - self._expr_label.set_text("") - self._expr_label.set_style_text_font(lv.font_montserrat_14, 0) - self._expr_label.set_style_text_color(lv.color_hex(0x888888), 0) - self._expr_label.set_pos(4, 4) - self._refs.append(self._expr_label) - - self._result_label = lv.label(disp) - self._result_label.set_text("0") - self._result_label.set_style_text_font(lv.font_montserrat_14, 0) - self._result_label.set_style_text_color(lv.color_hex(self.C_TXT), 0) - self._result_label.set_pos(4, 26) - self._refs.append(self._result_label) - - # -- 按钮网格 -- - grid_y = base_y + self.DISP_H - for ri, row in enumerate(self._LAYOUT): - ci = 0 - for text, color, span in row: - bw = self.COL_W * span - bh = self.ROW_H - bx = ci * self.COL_W - by = grid_y + ri * self.ROW_H - - btn = lv.btn(self.screen) - btn.set_size(bw, bh) - btn.set_pos(bx, by) - btn.set_style_bg_color(lv.color_hex(color), 0) - btn.set_style_bg_opa(lv.OPA.COVER, 0) - btn.set_style_radius(2, 0) - btn.set_style_shadow_width(0, 0) - btn.set_style_border_width(0, 0) - btn.set_style_pad_all(0, 0) - - lbl = lv.label(btn) - lbl.set_text(text) - lbl.set_style_text_font(lv.font_montserrat_14, 0) - lbl.set_style_text_color(lv.color_hex(self.C_TXT), 0) - lbl.center() - - btn.add_event_cb( - lambda e, t=text: self._on_btn(t), - lv.EVENT.CLICKED, None, - ) - self._refs.extend((btn, lbl)) - ci += span - - # -- 计算器状态 -- - self._expr = "" - self._result = "0" - self._fresh = False # 刚算完,下次输入覆盖 - - # ---------- 按钮处理 ---------- - - def _on_btn(self, key): - if key == "C": - self._expr = "" - self._result = "0" - self._fresh = False - elif key == "<": - if self._expr: - self._expr = self._expr[:-1] - elif key == "=": - self._calc() - return - elif key == "%": - self._percent() - elif key in "+-*/": - if self._fresh: - self._expr = self._result - self._fresh = False - # 防止连续运算符 - if self._expr and self._expr[-1] in "+-*/": - self._expr = self._expr[:-1] - self._expr += key - else: # 数字 / 小数点 - if self._fresh: - self._expr = "" - self._fresh = False - # 防止多个小数点 - if key == ".": - parts = self._expr.replace("+", "#").replace("-", "#") \ - .replace("*", "#").replace("/", "#").split("#") - if "." in parts[-1]: - return - self._expr += key - - self._update_display() - - def _calc(self): - if not self._expr: - return - try: - val = eval(self._expr) - # 整数不显示小数点 - if isinstance(val, float) and val == int(val): - self._result = str(int(val)) - else: - self._result = str(val) - except Exception: - self._result = "Error" - self._expr_label.set_text(self._expr + " =") - self._result_label.set_text(self._result) - self._fresh = True - - def _percent(self): - if not self._expr: - return - # 提取末尾数字并除以 100 - i = len(self._expr) - while i > 0 and (self._expr[i - 1].isdigit() or self._expr[i - 1] == "."): - i -= 1 - num_str = self._expr[i:] - if num_str: - try: - val = float(num_str) / 100 - self._expr = self._expr[:i] + str(val) - except ValueError: - pass - self._update_display() - - def _update_display(self): - self._expr_label.set_text(self._expr) - if not self._expr: - self._result_label.set_text("0") - elif not self._fresh: - self._result_label.set_text("") - - -class CanPage(AppPage): - """CAN 总线监控终端(带键盘输入)。""" - KB_H = 128 - - def _create_content(self): - base_y = self.content_y - w = self._sw - - # 键盘贴屏幕底;textarea 填满键盘上方剩余空间(KeyboardPage 同款做法) - kb_y = self._sh - self.KB_H - - # 状态条 - bar = lv.obj(self.screen) - bar.remove_style_all() - bar.set_size(w, 17) - bar.set_pos(0, base_y) - bar.set_style_bg_color(lv.color_hex(0x16213E), 0) - bar.set_style_bg_opa(lv.OPA.COVER, 0) - self._refs.append(bar) - - for tx, t, col in ((6, "CAN:500kbps", 0x00E676),): - lbl = lv.label(self.screen) - lbl.set_text(t) - lbl.set_style_text_font(lv.font_montserrat_14, 0) - lbl.set_style_text_color(lv.color_hex(col), 0) - lbl.set_pos(tx, base_y + 1) - self._refs.append(lbl) - - # 日志区(固定高度,留空间给 textarea + 键盘) - log_y = base_y + 17 - log_h = 110 - term = lv.obj(self.screen) - term.remove_style_all() - term.set_size(w, log_h) - term.set_pos(0, log_y) - term.set_style_bg_color(lv.color_hex(0x0D1117), 0) - term.set_style_bg_opa(lv.OPA.COVER, 0) - term.set_style_pad_all(0, 0) - term.clear_flag(lv.obj.FLAG.SCROLLABLE) - self._refs.append(term) - - rows = (("18FE", "8", "02 1A 4F 00 11 00 7E 90", 0x4CAF50), - ("0CF0", "8", "7F 00 FF 33 00 00 00 8A", 0x4CAF50), - ("18EA", "3", "00 00 18 FE 00 00 00 00", 0x4CAF50), - ("18FE", "8", "EE 01 2B 00 00 00 00 C0", 0xFF9800), - ("0C01", "8", "55 AA 01 02 03 04 05 06", 0x4CAF50)) - line_h = 17 - for i, (cid, dlc, data, col) in enumerate(rows): - ry = log_y + 4 + i * line_h - for tx, txt, c in ((6, cid, col), (54, "[" + dlc + "]", 0x888888), (92, data, 0xCCCCCC)): - lbl = lv.label(self.screen) - lbl.set_text(txt) - lbl.set_style_text_font(lv.font_montserrat_14, 0) - lbl.set_style_text_color(lv.color_hex(c), 0) - lbl.set_pos(tx, ry) - self._refs.append(lbl) - - # textarea 填满日志下方到键盘顶部的全部空间 - ta_y = log_y + log_h + 2 - ta_h = kb_y - ta_y - self._ta = lv.textarea(self.screen) - self._ta.set_size(w - 12, ta_h) - self._ta.set_pos(6, ta_y) - self._ta.set_style_bg_color(lv.color_hex(0x16213E), 0) - self._ta.set_style_bg_opa(lv.OPA.COVER, 0) - self._ta.set_style_radius(6, 0) - self._ta.set_style_border_width(1, 0) - self._ta.set_style_border_color(lv.color_hex(0x334466), 0) - self._ta.set_style_text_color(lv.color_hex(0x2C2C46), 0) - self._ta.set_style_text_font(lv.font_montserrat_14, 0) - self._ta.set_placeholder_text("> 0x18FE ...") - self._refs.append(self._ta) - - # Send / Clear 浮动在 textarea 右上角 - by = ta_y + 2 - self._mk_cmd_btn(w - 122, by, 52, "Send", 0x00E676, 0x000000) - self._mk_cmd_btn(w - 64, by, 52, "Clear", 0x334466, 0xFFFFFF) - - # LVGL 键盘:set_textarea 会自动把它放到 textarea 紧下方(= 屏幕底) - kb = lv.keyboard(self.screen) - kb.set_textarea(self._ta) - kb.set_size(w, self.KB_H) - kb.set_style_bg_color(lv.color_hex(0x0F3460), 0) - kb.set_style_bg_opa(lv.OPA.COVER, 0) - kb.set_style_bg_color(lv.color_hex(0x334466), lv.PART.ITEMS) - kb.set_style_text_color(lv.color_hex(0x2C2C46), lv.PART.ITEMS) - kb.set_style_min_height(0, lv.PART.ITEMS) - self._kb = kb - self._refs.append(kb) - try: - self.screen.update_layout() - except Exception: - pass - kb.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) - def _pin_kb(t): - kb.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) - self._kb_pin_timer = lv.timer_create(_pin_kb, 250, None) - - def _mk_cmd_btn(self, x, y, w, text, bg, fg): - btn = lv.btn(self.screen) - btn.set_size(w, 24) - btn.set_pos(x, y) - btn.set_style_bg_color(lv.color_hex(bg), 0) - btn.set_style_bg_opa(lv.OPA.COVER, 0) - btn.set_style_radius(3, 0) - btn.set_style_shadow_width(0, 0) - btn.set_style_border_width(0, 0) - btn.set_style_pad_all(0, 0) - btn.add_event_cb(lambda e: print("[CAN] Send:", self._ta.get_text()), lv.EVENT.CLICKED, None) - lbl = lv.label(btn) - lbl.set_text(text) - lbl.set_style_text_font(lv.font_montserrat_14, 0) - lbl.set_style_text_color(lv.color_hex(fg), 0) - lbl.center() - self._refs.extend((btn, lbl)) - - -class KeyboardPage(AppPage): - """虚拟键盘参数配置(用 LVGL 内置 keyboard 控件)。""" - - def _create_content(self): - base_y = self.content_y - w = self._sw - - # 输入框 - # 输入框(直接放屏幕上,不要外框、不要 SSID 标签) - # 高度顶到键盘顶部:base_y(34) → 键盘顶(_sh - 160),消除中间空隙 - kb_h = 160 - self._ta = lv.textarea(self.screen) - self._ta.set_text("Yttrium-Dev") - # 不调 set_one_line(True),原因: - # 1) 它内部强制 set_height(字体高+上下pad),会把高度压回一行, - # 覆盖 set_size,输入框永远是矮的; - # 2) 单行模式下 lv_textarea_add_char 直接丢弃 '\n',键盘回车无法换行。 - # 保持默认多行模式:高度由 set_size 决定,回车可换行。 - self._ta.set_style_bg_color(lv.color_hex(0x16213E), 0) - self._ta.set_style_bg_opa(lv.OPA.COVER, 0) - self._ta.set_style_radius(6, 0) - self._ta.set_style_border_width(1, 0) - self._ta.set_style_border_color(lv.color_hex(0x334466), 0) - self._ta.set_style_text_color(lv.color_hex(0x2C2C46), 0) - self._ta.set_style_text_font(lv.font_montserrat_14, 0) - # 高度顶到键盘顶部:base_y(34) → 键盘顶(_sh - kb_h),消除中间空隙。 - self._ta.set_size(w - 12, self._sh - kb_h - base_y) - self._ta.set_pos(6, base_y) - self._refs.append(self._ta) - - # LVGL 内置键盘:固定高度、贴屏底(手机键盘样式),按键接近正方形不被拉长 - kb = lv.keyboard(self.screen) - kb.set_textarea(self._ta) # 先联动(这一步会把键盘自动挪位) - kb.set_size(w, kb_h) - kb.set_style_bg_color(lv.color_hex(0x0F3460), 0) - kb.set_style_bg_opa(lv.OPA.COVER, 0) - kb.set_style_bg_color(lv.color_hex(0x334466), lv.PART.ITEMS) - kb.set_style_text_color(lv.color_hex(0x2C2C46), lv.PART.ITEMS) - kb.set_style_min_height(0, lv.PART.ITEMS) # 去掉按键最小高度,4 行才能完整塞进 200 - self._kb = kb - self._refs.append(kb) - try: - self.screen.update_layout() - except Exception: - pass - # set_pos 被键盘内部 align 覆盖(设 120 读到 240),改用底部对齐(align 优先级更高) - kb.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) - def _pin_kb(t): - kb.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) - self._kb_pin_timer = lv.timer_create(_pin_kb, 250, None) - print("[KB] _sh=", self._sh, "kb_h=", kb.get_height(), "kb_y=", kb.get_y(), - "ta_h=", self._ta.get_height(), "ta_y=", self._ta.get_y()) - - -class CameraPage(AppPage): - """摄像头实时预览。""" - def _create_content(self): - base_y = self.content_y - w = self._sw - cx = w // 2 - - # 取景框 - vf = lv.obj(self.screen) - vf.remove_style_all() - vf.set_size(w - 12, 150) - vf.set_pos(6, base_y + 4) - vf.set_style_bg_color(lv.color_hex(0x000000), 0) - vf.set_style_bg_opa(lv.OPA.COVER, 0) - vf.set_style_radius(6, 0) - vf.clear_flag(lv.obj.FLAG.SCROLLABLE) - self._refs.append(vf) - - # 占位提示(真实画面需 Camera 固件) - ph = lv.label(vf) - ph.set_text("Camera\nPreview") - ph.set_style_text_font(lv.font_montserrat_14, 0) - ph.set_style_text_color(lv.color_hex(0x334466), 0) - ph.center() - self._refs.append(ph) - - # 十字准星 - ch = base_y + 4 + 75 - for ww, hh, xx, yy in ((24, 2, cx - 12, ch - 1), (2, 24, cx - 1, ch - 12)): - seg = lv.obj(self.screen) - seg.remove_style_all() - seg.set_size(ww, hh) - seg.set_pos(xx, yy) - seg.set_style_bg_color(lv.color_hex(0x00E676), 0) - seg.set_style_bg_opa(lv.OPA.COVER, 0) - seg.clear_flag(lv.obj.FLAG.CLICKABLE) - self._refs.append(seg) - ring = lv.obj(self.screen) - ring.remove_style_all() - ring.set_size(36, 36) - ring.set_pos(cx - 18, ch - 18) - ring.set_style_bg_opa(lv.OPA.TRANSP, 0) - ring.set_style_border_width(1, 0) - ring.set_style_border_color(lv.color_hex(0x00E676), 0) - ring.clear_flag(lv.obj.FLAG.CLICKABLE) - self._refs.append(ring) - - # REC 指示 + 型号 - rec = lv.obj(self.screen) - rec.remove_style_all() - rec.set_size(6, 6) - rec.set_pos(w - 34, base_y + 15) - rec.set_style_bg_color(lv.color_hex(0xFF5252), 0) - rec.set_style_bg_opa(lv.OPA.COVER, 0) - rec.set_style_radius(3, 0) - self._refs.append(rec) - rec_lbl = lv.label(self.screen) - rec_lbl.set_text("REC") - rec_lbl.set_style_text_font(lv.font_montserrat_14, 0) - rec_lbl.set_style_text_color(lv.color_hex(0xFF5252), 0) - rec_lbl.set_pos(w - 64, base_y + 11) - self._refs.append(rec_lbl) - model = lv.label(self.screen) - model.set_text("GC0308") - model.set_style_text_font(lv.font_montserrat_14, 0) - model.set_style_text_color(lv.color_hex(0xAAAAAA), 0) - model.set_pos(12, base_y + 11) - self._refs.append(model) - - # 信息栏 - info_y = base_y + 158 - bar = lv.obj(self.screen) - bar.remove_style_all() - bar.set_size(w, 48) - bar.set_pos(0, info_y) - bar.set_style_bg_color(lv.color_hex(0x16213E), 0) - bar.set_style_bg_opa(lv.OPA.COVER, 0) - self._refs.append(bar) - for tx, cap, val, col in ((12, "Resolution", "240x240", 0xFFFFFF), - (104, "FPS", "15", 0x00E676), - (154, "Format", "RGB565", 0xFFFFFF)): - c1 = lv.label(self.screen) - c1.set_text(cap) - c1.set_style_text_font(lv.font_montserrat_14, 0) - c1.set_style_text_color(lv.color_hex(0x888888), 0) - c1.set_pos(tx, info_y + 6) - self._refs.append(c1) - c2 = lv.label(self.screen) - c2.set_text(val) - c2.set_style_text_font(lv.font_montserrat_14, 0) - c2.set_style_text_color(lv.color_hex(col), 0) - c2.set_pos(tx, info_y + 22) - self._refs.append(c2) - - # 快门键 - sh = lv.btn(self.screen) - sh.set_size(30, 30) - sh.set_pos(w - 60, info_y + 10) - sh.set_style_bg_color(lv.color_hex(0x2C2C46), 0) - sh.set_style_bg_opa(lv.OPA.COVER, 0) - sh.set_style_radius(15, 0) - sh.set_style_shadow_width(0, 0) - sh.set_style_border_width(3, 0) - sh.set_style_border_color(lv.color_hex(0x2C2C46), 0) - sh.add_event_cb(lambda e: print("[CAM] capture (TODO)"), lv.EVENT.CLICKED, None) - self._refs.append(sh) - # TODO: 接入摄像头预览(需 Camera 固件) - - -class QrPage(AppPage): - """Drink Shop -> Pay -> QR.""" - TAB_H = 28 - COLS = 4 - TAB_COFFEE = 0 - TAB_TEA = 1 - - COFFEE = ( - ("U:/images/coconut.jpg", "Coconut", "$18"), - ("U:/images/americano.jpg", "Americano", "$15"), - ("U:/images/Mint.jpg", "Mint", "$20"), - ("U:/images/hazelnut.jpg", "Hazelnut", "$22"), - ) - TEA = ( - ("U:/images/bubble.jpg", "Bubble", "$14"), - ("U:/images/pudding.jpg", "Pudding", "$16"), - ("U:/images/Brown_sugar.jpg", "Brown Sugar", "$15"), - ("U:/images/taro_bobo.jpg", "Taro Bobo", "$18"), - ) - - def _create_content(self): - base_y = self.content_y - w = self._sw - - tab_y = base_y - self._tab_coffee = self._mk_tab(0, tab_y, w // 2, self.TAB_H, - "Coffee", 0x6F4E37, True) - self._tab_tea = self._mk_tab(w // 2, tab_y, w // 2, self.TAB_H, - "Milk Tea", 0xC4956A, False) - self._active_tab = self.TAB_COFFEE - - self._grid_base_y = tab_y + self.TAB_H + 4 - self._card_w = 108 - self._card_h = 118 - self._selected = set() - self._product_refs = [] - - btn_y = self._sh - self.TITLE_BAR_H - 4 - 34 - self._total_lbl = lv.label(self.screen) - self._total_lbl.set_text("Total: $0") - self._total_lbl.set_style_text_font(lv.font_montserrat_14, 0) - self._total_lbl.set_style_text_color(lv.color_hex(0x2C2C46), 0) - self._total_lbl.set_pos(6, btn_y + 6) - self._refs.append(self._total_lbl) - - pay_btn = lv.btn(self.screen) - pay_btn.set_size(120, 30) - pay_btn.set_pos(w - 130, btn_y) - pay_btn.set_style_bg_color(lv.color_hex(0x00E676), 0) - pay_btn.set_style_bg_opa(lv.OPA.COVER, 0) - pay_btn.set_style_radius(6, 0) - pay_btn.set_style_shadow_width(0, 0) - pay_btn.set_style_border_width(0, 0) - pay_btn.add_event_cb(lambda e: self._do_pay(), lv.EVENT.CLICKED, None) - pay_lbl = lv.label(pay_btn) - pay_lbl.set_text("Pay") - pay_lbl.set_style_text_font(lv.font_montserrat_14, 0) - pay_lbl.set_style_text_color(lv.color_hex(0x000000), 0) - pay_lbl.center() - self._refs.extend((pay_btn, pay_lbl)) - - self._show_products(self.COFFEE) - - def _mk_tab(self, x, y, w, h, text, color, active): - btn = lv.btn(self.screen) - btn.set_size(w, h) - btn.set_pos(x, y) - bg = color if active else 0x334466 - btn.set_style_bg_color(lv.color_hex(bg), 0) - btn.set_style_bg_opa(lv.OPA.COVER, 0) - btn.set_style_radius(4, 0) - btn.set_style_shadow_width(0, 0) - btn.set_style_border_width(0, 0) - btn.set_style_pad_all(0, 0) - btn.add_event_cb(lambda e, tab=text: self._on_tab(tab), lv.EVENT.CLICKED, None) - lbl = lv.label(btn) - lbl.set_text(text) - lbl.set_style_text_font(lv.font_montserrat_14, 0) - lbl.set_style_text_color(lv.color_hex(0x2C2C46), 0) - lbl.center() - self._refs.extend((btn, lbl)) - return btn - - def _on_tab(self, tab): - if tab == "Coffee" and self._active_tab != self.TAB_COFFEE: - self._active_tab = self.TAB_COFFEE - self._tab_coffee.set_style_bg_color(lv.color_hex(0x6F4E37), 0) - self._tab_tea.set_style_bg_color(lv.color_hex(0x334466), 0) - self._pending_items = self.COFFEE - lv.timer_create(lambda t: self._do_switch(), 50, None) - elif tab == "Milk Tea" and self._active_tab != self.TAB_TEA: - self._active_tab = self.TAB_TEA - self._tab_tea.set_style_bg_color(lv.color_hex(0xC4956A), 0) - self._tab_coffee.set_style_bg_color(lv.color_hex(0x334466), 0) - self._pending_items = self.TEA - lv.timer_create(lambda t: self._do_switch(), 50, None) - - def _do_switch(self): - if hasattr(self, '_pending_items') and self._pending_items is not None: - items = self._pending_items - self._pending_items = None - self._show_products(items) - - def _show_products(self, items): - # 只删卡片本身(card.delete() 会递归删子控件) - for refs in self._product_refs: - if refs: - try: refs[0].delete() - except: pass - self._product_refs = [] - self._selected = set() - self._update_total() - - gap_x = (self._sw - self.COLS * self._card_w) // (self.COLS + 1) - gap_y = 6 - for i, (img_path, name, price) in enumerate(items): - col = i % self.COLS - row = i // self.COLS - cx = gap_x + col * (self._card_w + gap_x) - cy = self._grid_base_y + row * (self._card_h + gap_y) - refs = self._mk_card(cx, cy, self._card_w, self._card_h, - img_path, name, price, i) - self._product_refs.append(refs) - - def _mk_card(self, x, y, w, h, img_path, name, price, idx): - refs = [] - card = lv.obj(self.screen) - card.remove_style_all() - card.set_size(w, h) - card.set_pos(x, y) - card.set_style_bg_color(lv.color_hex(0x16213E), 0) - card.set_style_bg_opa(lv.OPA.COVER, 0) - card.set_style_radius(6, 0) - card.set_style_pad_all(0, 0) - card.clear_flag(lv.obj.FLAG.SCROLLABLE) - refs.append(card) - - # 图片区 - img_h = h - 44 - ic = lv.img(card) - ic.set_src(img_path) - ic.set_size(w - 8, img_h - 4) - ic.set_pos(4, 4) - ic.set_style_radius(4, 0) - refs.append(ic) - - # 名称(图片下方) - name_lbl = lv.label(card) - name_lbl.set_text(name) - name_lbl.set_style_text_font(lv.font_montserrat_14, 0) - name_lbl.set_style_text_color(lv.color_hex(0xCCCCCC), 0) - name_lbl.set_pos(6, img_h + 6) - refs.append(name_lbl) - - # 价格(左下)+ 勾选圆(右下) - price_lbl = lv.label(card) - price_lbl.set_text(price) - price_lbl.set_style_text_font(lv.font_montserrat_14, 0) - price_lbl.set_style_text_color(lv.color_hex(0xFF9800), 0) - price_lbl.set_pos(6, img_h + 24) - refs.append(price_lbl) - - sel_btn = lv.btn(card) - sel_btn.set_size(18, 18) - sel_btn.set_pos(w - 22, img_h + 22) - sel_btn.set_style_bg_opa(lv.OPA.TRANSP, 0) - sel_btn.set_style_shadow_width(0, 0) - sel_btn.set_style_border_width(2, 0) - sel_btn.set_style_border_color(lv.color_hex(0x888888), 0) - sel_btn.set_style_radius(9, 0) - sel_btn.set_style_pad_all(0, 0) - sel_btn.add_event_cb(lambda e, i=idx: self._on_select(i, e.get_target()), - lv.EVENT.CLICKED, None) - sel_lbl = lv.label(sel_btn) - sel_lbl.set_text("") - sel_lbl.center() - refs.extend((sel_btn, sel_lbl)) - return refs - - def _on_select(self, idx, btn): - if idx in self._selected: - self._selected.discard(idx) - btn.set_style_bg_opa(lv.OPA.TRANSP, 0) - btn.set_style_border_color(lv.color_hex(0x888888), 0) - else: - self._selected.add(idx) - btn.set_style_bg_color(lv.color_hex(0x00E676), 0) - btn.set_style_bg_opa(lv.OPA.COVER, 0) - btn.set_style_border_color(lv.color_hex(0x00E676), 0) - self._update_total() - - def _update_total(self): - items = self.COFFEE if self._active_tab == self.TAB_COFFEE else self.TEA - total = 0 - for idx in self._selected: - total += int(items[idx][2].replace("$", "")) - self._total_lbl.set_text("Total: $" + str(total)) - - def _do_pay(self): - if not self._selected: - return - items = self.COFFEE if self._active_tab == self.TAB_COFFEE else self.TEA - total = 0 - order = [] - for idx in self._selected: - _, name, price_str = items[idx] - total += int(price_str.replace("$", "")) - order.append(name) - - pay_text = "YttriumPay\nTotal:${}\n{}".format(total, ",".join(order)) - - # 保存当前 shop 页面引用,切到新 screen 释放内存 - self._shop_screen = self.screen - pay_screen = lv.obj() - pay_screen.remove_style_all() - pay_screen.set_size(self._sw, self._sh) - pay_screen.set_style_bg_color(lv.color_hex(0x2C2C46), 0) - pay_screen.set_style_bg_opa(lv.OPA.COVER, 0) - pay_screen.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) - pay_screen.clear_flag(lv.obj.FLAG.SCROLLABLE) - lv.scr_load(pay_screen) - - self._show_pay_screen(pay_screen, total, order, pay_text) - - def _show_pay_screen(self, screen, total, order, pay_text): - w, h = self._sw, self._sh - - # 返回按钮(左上角) - back_btn = lv.btn(screen) - back_btn.set_size(60, 24) - back_btn.set_pos(8, 6) - back_btn.set_style_bg_color(lv.color_hex(0x334466), 0) - back_btn.set_style_bg_opa(lv.OPA.COVER, 0) - back_btn.set_style_radius(4, 0) - back_btn.set_style_shadow_width(0, 0) - back_btn.set_style_border_width(0, 0) - back_lbl = lv.label(back_btn) - back_lbl.set_text("< Back") - back_lbl.set_style_text_font(lv.font_montserrat_14, 0) - back_lbl.set_style_text_color(lv.color_hex(0x2C2C46), 0) - back_lbl.center() - - # 白底面板 - pw, ph = 240, 250 - panel = lv.obj(screen) - panel.remove_style_all() - panel.set_size(pw, ph) - panel.set_pos((w - pw) // 2, (h - ph) // 2) - panel.set_style_bg_color(lv.color_hex(0xFFFFFF), 0) - panel.set_style_bg_opa(lv.OPA.COVER, 0) - panel.set_style_radius(12, 0) - panel.set_style_pad_all(0, 0) - - title = lv.label(panel) - title.set_text("Scan to Pay") - title.set_style_text_font(lv.font_montserrat_14, 0) - title.set_style_text_color(lv.color_hex(0x000000), 0) - title.set_pos((pw - 100) // 2, 8) - - # QR 区 - qr_size = 120 - qr_x = (pw - qr_size) // 2 - qr_y = 28 - qr_bg = lv.obj(panel) - qr_bg.remove_style_all() - qr_bg.set_size(qr_size, qr_size) - qr_bg.set_pos(qr_x, qr_y) - qr_bg.set_style_bg_color(lv.color_hex(0xFFFFFF), 0) - qr_bg.set_style_bg_opa(lv.OPA.COVER, 0) - qr_bg.set_style_radius(4, 0) - qr_bg.set_style_pad_all(0, 0) - qr_bg.clear_flag(lv.obj.FLAG.CLICKABLE) - - # 真二维码(旧 QrPage 同款方块法,独立 screen 内存充足) - n, matrix = self._pay_qr_matrix(pay_text, max_n=21) - cell = qr_size // n - off = (qr_size - cell * n) // 2 - for r in range(n): - for c in range(n): - if not matrix[r][c]: - continue - box = lv.obj(qr_bg) - box.remove_style_all() - box.set_size(cell, cell) - box.set_pos(off + c * cell, off + r * cell) - box.set_style_bg_color(lv.color_hex(0x000000), 0) - box.set_style_bg_opa(lv.OPA.COVER, 0) - box.clear_flag(lv.obj.FLAG.CLICKABLE) - - # 总价 + 明细 - total_lbl = lv.label(panel) - total_lbl.set_text("Total: ${}".format(total)) - total_lbl.set_style_text_font(lv.font_montserrat_14, 0) - total_lbl.set_style_text_color(lv.color_hex(0x000000), 0) - total_lbl.set_pos(10, qr_y + qr_size + 8) - - detail = lv.label(panel) - detail.set_text(", ".join(order[:4])) - detail.set_style_text_font(lv.font_montserrat_14, 0) - detail.set_style_text_color(lv.color_hex(0x888888), 0) - detail.set_pos(10, qr_y + qr_size + 26) - - # 返回按钮事件 - back_btn.add_event_cb(lambda e: self._back_to_shop(screen), lv.EVENT.CLICKED, None) - - def _pay_qr_matrix(self, text, max_n=21): - """生成二维码矩阵。有 qrcode 模块用真码,否则占位。""" - try: - import qrcode - result = qrcode.getQRData(text, 1, 0xFFFF, 0x0000) - if result != -1: - side, data = result - if side > 0: - if side > max_n: - ratio = side // max_n + 1 - side = max_n - else: - ratio = 1 - mat = [[False] * side for _ in range(side)] - for py in range(side): - for px in range(side): - sy = py * ratio - sx = px * ratio - si = (sy * (side * ratio) + sx) * 2 - if si + 1 < len(data): - pixel = (data[si] << 8) | data[si + 1] - mat[py][px] = (pixel == 0) - return side, mat - except Exception: - pass - n = max_n - finder = [(0, 0), (0, n - 7), (n - 7, 0)] - mat = [[False] * n for _ in range(n)] - st = (hash(text) & 0xFFFFFFFF) or 12345 - for r in range(n): - for c in range(n): - st = (st * 1103515245 + 12345) & 0x7FFFFFFF - on = (st / 0x7FFFFFFF) > 0.52 - for fr, fc in finder: - if fr <= r < fr + 7 and fc <= c < fc + 7: - on = (r == fr or r == fr + 6 or c == fc or - c == fc + 6 or - (fr + 2 <= r <= fr + 4 and fc + 2 <= c <= fc + 4)) - mat[r][c] = on - return n, mat - - def _back_to_shop(self, pay_screen): - lv.scr_load(self._shop_screen) - # 恢复商品卡片 - items = self.COFFEE if self._active_tab == self.TAB_COFFEE else self.TEA - self._show_products(items) - -class AudioPage(AppPage): - """录音与播放控制。""" - def _create_content(self): - import math - base_y = self.content_y - w = self._sw - cx = w // 2 - - # 文件名 - fn = lv.label(self.screen) - fn.set_text("rec_001.wav") - fn.set_style_text_font(lv.font_montserrat_14, 0) - fn.set_style_text_color(lv.color_hex(0x2C2C46), 0) - fn.set_pos(cx - 45, base_y + 14) - self._refs.append(fn) - - # 波形区 - wf = lv.obj(self.screen) - wf.remove_style_all() - wf.set_size(w - 16, 90) - wf.set_pos(8, base_y + 34) - wf.set_style_bg_color(lv.color_hex(0x0D1117), 0) - wf.set_style_bg_opa(lv.OPA.COVER, 0) - wf.clear_flag(lv.obj.FLAG.SCROLLABLE) - self._refs.append(wf) - - # 波形柱(已播放绿 / 未播放灰) - bars = 56 - bw = (w - 16) / bars - played = int(bars * 0.55) - for i in range(bars): - h = int(8 + 36 * abs(math.sin(i * 0.5)) * (0.5 + 0.5 * math.cos(i * 0.17))) - col = 0x00E676 if i < played else 0x334466 - x = 8 + i * bw - y0 = base_y + 34 + (90 - h) // 2 - bar = lv.obj(self.screen) - bar.remove_style_all() - bar.set_size(max(1, int(bw) - 1), h) - bar.set_pos(int(x), int(y0)) - bar.set_style_bg_color(lv.color_hex(col), 0) - bar.set_style_bg_opa(lv.OPA.COVER, 0) - bar.clear_flag(lv.obj.FLAG.CLICKABLE) - self._refs.append(bar) - - # 进度线 - px = 8 + int(played * bw) - prog = lv.obj(self.screen) - prog.remove_style_all() - prog.set_size(2, 90) - prog.set_pos(px, base_y + 34) - prog.set_style_bg_color(lv.color_hex(0x2C2C46), 0) - prog.set_style_bg_opa(lv.OPA.COVER, 0) - prog.clear_flag(lv.obj.FLAG.CLICKABLE) - self._refs.append(prog) - - # 时间 - for tx, t in ((10, "00:08"), (w - 40, "00:15")): - tlbl = lv.label(self.screen) - tlbl.set_text(t) - tlbl.set_style_text_font(lv.font_montserrat_14, 0) - tlbl.set_style_text_color(lv.color_hex(0xAAAAAA), 0) - tlbl.set_pos(tx, base_y + 128) - self._refs.append(tlbl) - - # Rec / Play / Stop - by = base_y + 158 - bw_btn = 76 - gap = (w - bw_btn * 3) // 4 - self._mk_audio_btn(gap, by, bw_btn, "Rec", 0xFF5252, 0xFFFFFF) - self._mk_audio_btn(gap * 2 + bw_btn, by, bw_btn, "Play", 0x00E676, 0x000000) - self._mk_audio_btn(gap * 3 + bw_btn * 2, by, bw_btn, "Stop", 0x334466, 0xFFFFFF) - # TODO: 接入录音/播放(audio 模块) - - def _mk_audio_btn(self, x, y, w, text, bg, fg): - btn = lv.btn(self.screen) - btn.set_size(w, 34) - btn.set_pos(x, y) - btn.set_style_bg_color(lv.color_hex(bg), 0) - btn.set_style_bg_opa(lv.OPA.COVER, 0) - btn.set_style_radius(4, 0) - btn.set_style_shadow_width(0, 0) - btn.set_style_border_width(0, 0) - btn.set_style_pad_all(0, 0) - btn.add_event_cb(lambda e: print("[AUDIO]", text, "(TODO)"), lv.EVENT.CLICKED, None) - lbl = lv.label(btn) - lbl.set_text(text) - lbl.set_style_text_font(lv.font_montserrat_14, 0) - lbl.set_style_text_color(lv.color_hex(fg), 0) - lbl.center() - self._refs.extend((btn, lbl)) - - -class UsbPage(AppPage): - """USB U盘文件浏览器。""" - FILES = ( - ("[DIR]", "music", 0x4CAF50, True), - ("[DIR]", "photos", 0x4CAF50, True), - ("WAV", "rec_001.wav", 0xFF9800, False), - ("WAV", "voice_03.wav", 0xFF9800, False), - ("TXT", "notes.txt", 0x2196F3, False), - ("BIN", "firmware.bin", 0x9C27B0, False), - ("PNG", "logo.png", 0x00BCD4, False), - ("CSV", "log_0624.csv", 0x00E676, False), - ) - - def _create_content(self): - base_y = self.content_y - w = self._sw - - # 路径栏 - bar = lv.obj(self.screen) - bar.remove_style_all() - bar.set_size(w, 22) - bar.set_pos(0, base_y) - bar.set_style_bg_color(lv.color_hex(0x16213E), 0) - bar.set_style_bg_opa(lv.OPA.COVER, 0) - self._refs.append(bar) - - p1 = lv.label(self.screen) - p1.set_text("U:/") - p1.set_style_text_font(lv.font_montserrat_14, 0) - p1.set_style_text_color(lv.color_hex(0x00E676), 0) - p1.set_pos(6, base_y + 3) - self._refs.append(p1) - p2 = lv.label(self.screen) - p2.set_text("8.2 GB free / 14.9 GB") - p2.set_style_text_font(lv.font_montserrat_14, 0) - p2.set_style_text_color(lv.color_hex(0xAAAAAA), 0) - p2.set_pos(92, base_y + 4) - self._refs.append(p2) - - # 文件列表 - item_h = 18 - for i, (ext, name, col, is_dir) in enumerate(self.FILES): - ry = base_y + 24 + i * item_h - if i == 0: # 选中高亮 - sel = lv.obj(self.screen) - sel.remove_style_all() - sel.set_size(w, item_h) - sel.set_pos(0, ry - 1) - sel.set_style_bg_color(lv.color_hex(0x334466), 0) - sel.set_style_bg_opa(lv.OPA.COVER, 0) - sel.clear_flag(lv.obj.FLAG.CLICKABLE) - self._refs.append(sel) - le = lv.label(self.screen) - le.set_text(ext) - le.set_style_text_font(lv.font_montserrat_14, 0) - le.set_style_text_color(lv.color_hex(col), 0) - le.set_pos(8, ry + 1) - self._refs.append(le) - ln = lv.label(self.screen) - ln.set_text(name) - ln.set_style_text_font(lv.font_montserrat_14, 0) - ln.set_style_text_color(lv.color_hex(0x2C2C46), 0) - ln.set_pos(50, ry + 1) - self._refs.append(ln) - if is_dir: - la = lv.label(self.screen) - la.set_text(">") - la.set_style_text_font(lv.font_montserrat_14, 0) - la.set_style_text_color(lv.color_hex(0x666666), 0) - la.set_pos(w - 14, ry + 1) - self._refs.append(la) - - # 翻页 - pg = lv.label(self.screen) - pg.set_text("1 / 3") - pg.set_style_text_font(lv.font_montserrat_14, 0) - pg.set_style_text_color(lv.color_hex(0x555555), 0) - pg.set_pos(w // 2 - 14, base_y + 206) - self._refs.append(pg) - # TODO: 读取 U:/ 真实文件列表 - - -class PhonePage(AppPage): - """电话拨号 -- 拨号/来电/通话/挂断。""" - - ROW_H = 47 # 6 行 × 47 = 282,落进内容高 286 - COL_W = 160 # 480 // 3,3 列满宽 - COL_W_LAST = 160 # 480 - 2*160 - - def _create_content(self): - base_y = self.content_y - w = self._sw - - # ====== Row 0: 号码显示 + Del ====== - display_w = (w * 6) // 9 # 189 - del_w = w - display_w - (w // 9) # 63 - - self._num_label = lv.label(self.screen) - self._num_label.set_text("") - self._num_label.set_style_text_font(lv.font_montserrat_14, 0) - self._num_label.set_style_text_color(lv.color_hex(0x2C2C46), 0) - self._num_label.set_style_text_align(lv.TEXT_ALIGN.CENTER, 0) - self._num_label.set_size(display_w, self.ROW_H) - self._num_label.set_pos(w // 9, base_y) - self._refs.append(self._num_label) - - # 通话状态标签(覆盖号码区域) - self._status_label = lv.label(self.screen) - self._status_label.set_text("") - self._status_label.set_style_text_font(lv.font_montserrat_14, 0) - self._status_label.set_style_text_color(lv.color_hex(0x00E676), 0) - self._status_label.set_pos(w // 9, base_y) - self._refs.append(self._status_label) - - # Del 按钮 - del_btn = lv.btn(self.screen) - del_btn.set_size(del_w, self.ROW_H) - del_btn.set_pos(display_w + w // 9, base_y) - del_btn.set_style_bg_color(lv.color_hex(0xFF6B6B), 0) - del_btn.set_style_bg_opa(lv.OPA.COVER, 0) - del_btn.set_style_radius(2, 0) - del_btn.set_style_shadow_width(0, 0) - del_btn.set_style_border_width(0, 0) - del_btn.add_event_cb(lambda e: self._on_del(), lv.EVENT.CLICKED, None) - del_lbl = lv.label(del_btn) - del_lbl.set_text("Del") - del_lbl.set_style_text_font(lv.font_montserrat_14, 0) - del_lbl.set_style_text_color(lv.color_hex(0x2C2C46), 0) - del_lbl.center() - self._refs.extend((del_btn, del_lbl)) - self._del_btn = del_btn - - # ====== Row 1-4: 数字键盘 ====== - digits = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'] - for i, d in enumerate(digits): - ri = i // 3 + 1 - ci = i % 3 - bw = self.COL_W_LAST if ci == 2 else self.COL_W - bx = ci * self.COL_W - by = base_y + ri * self.ROW_H - - btn = lv.btn(self.screen) - btn.set_size(bw, self.ROW_H) - btn.set_pos(bx, by) - btn.set_style_bg_color(lv.color_hex(0x2C2C46), 0) - btn.set_style_bg_opa(lv.OPA.COVER, 0) - btn.set_style_radius(2, 0) - btn.set_style_shadow_width(0, 0) - btn.set_style_border_width(1, 0) - btn.set_style_border_color(lv.color_hex(0xCCCCCC), 0) - btn.set_style_pad_all(0, 0) - btn.add_event_cb( - lambda e, digit=d: self._on_digit(digit), - lv.EVENT.CLICKED, None) - lbl = lv.label(btn) - lbl.set_text(d) - lbl.set_style_text_font(lv.font_montserrat_14, 0) - lbl.set_style_text_color(lv.color_hex(0x000000), 0) - lbl.center() - self._refs.extend((btn, lbl)) - - # ====== Row 5: CALL / BACK ====== - row5_y = base_y + 5 * self.ROW_H - - self._call_btn = lv.btn(self.screen) - self._call_btn.set_size(self.COL_W, self.ROW_H) - self._call_btn.set_pos(self.COL_W, row5_y) - self._call_btn.set_style_bg_color(lv.color_hex(0x4CAF50), 0) - self._call_btn.set_style_bg_opa(lv.OPA.COVER, 0) - self._call_btn.set_style_radius(4, 0) - self._call_btn.set_style_shadow_width(0, 0) - self._call_btn.set_style_border_width(0, 0) - self._call_btn.add_event_cb(lambda e: self._on_call(), lv.EVENT.CLICKED, None) - self._call_lbl = lv.label(self._call_btn) - self._call_lbl.set_text("CALL") - self._call_lbl.set_style_text_font(lv.font_montserrat_14, 0) - self._call_lbl.set_style_text_color(lv.color_hex(0x2C2C46), 0) - self._call_lbl.center() - self._refs.extend((self._call_btn, self._call_lbl)) - - self._back_btn = lv.btn(self.screen) - self._back_btn.set_size(self.COL_W_LAST, self.ROW_H) - self._back_btn.set_pos(2 * self.COL_W, row5_y) - self._back_btn.set_style_bg_color(lv.color_hex(0x9E9E9E), 0) - self._back_btn.set_style_bg_opa(lv.OPA.COVER, 0) - self._back_btn.set_style_radius(4, 0) - self._back_btn.set_style_shadow_width(0, 0) - self._back_btn.set_style_border_width(0, 0) - self._back_btn.add_event_cb(lambda e: self._on_back_btn(), lv.EVENT.CLICKED, None) - self._back_lbl = lv.label(self._back_btn) - self._back_lbl.set_text("Back") - self._back_lbl.set_style_text_font(lv.font_montserrat_14, 0) - self._back_lbl.set_style_text_color(lv.color_hex(0x2C2C46), 0) - self._back_lbl.center() - self._refs.extend((self._back_btn, self._back_lbl)) - - # -- 状态 -- - self._phone_number = "" - self._state = "IDLE" - - # -- 注册通话回调 -- - # voiceCall.setCallback(self._call_callback) - - # ---------- 按钮处理 ---------- - - def _on_digit(self, d): - if self._state != "IDLE": - return - self._phone_number += d - self._num_label.set_text(self._phone_number) - - def _on_del(self): - if self._state != "IDLE": - return - if self._phone_number: - self._phone_number = self._phone_number[:-1] - self._num_label.set_text(self._phone_number) - - # def _on_call(self): - # if self._state == "IDLE": - # if not self._phone_number: - # return - # voiceCall.callStart(self._phone_number) - # self._state = "CALLING" - # self._update_ui() - # elif self._state == "INCOMING": - # voiceCall.callAnswer() - # self._state = "ACTIVE" - # self._update_ui() - - # def _on_back_btn(self): - # if self._state in ("CALLING", "ACTIVE", "INCOMING"): - # voiceCall.callEnd() - # self._state = "IDLE" - # self._update_ui() - # else: - # self._on_back() - - # ---------- 通话回调 ---------- - - def _call_callback(self, args): - call_info = args[0] - try: - number = args[6] if len(args) > 6 else "" - except Exception: - number = "" - - if call_info == 10: # 来电 - self._phone_number = number - self._state = "INCOMING" - self._update_ui() - elif call_info == 11: # 接通 - self._state = "ACTIVE" - self._update_ui() - elif call_info == 12: # 挂断 - self._state = "IDLE" - self._phone_number = "" - self._update_ui() - - # ---------- UI 更新 ---------- - - def _update_ui(self): - if self._state == "IDLE": - self._status_label.set_text("") - self._num_label.set_text(self._phone_number) - self._call_lbl.set_text("CALL") - self._call_btn.set_style_bg_color(lv.color_hex(0x4CAF50), 0) - self._back_lbl.set_text("Back") - self._back_btn.set_style_bg_color(lv.color_hex(0x9E9E9E), 0) - self._del_btn.set_style_bg_opa(lv.OPA.COVER, 0) - elif self._state == "CALLING": - self._status_label.set_text("Calling...") - self._num_label.set_text(self._phone_number) - self._call_btn.set_style_bg_opa(lv.OPA._50, 0) - self._back_lbl.set_text("Hangup") - self._back_btn.set_style_bg_color(lv.color_hex(0xFF5252), 0) - self._del_btn.set_style_bg_opa(lv.OPA._50, 0) - elif self._state == "ACTIVE": - self._status_label.set_text("Connected") - self._num_label.set_text(self._phone_number) - self._call_btn.set_style_bg_opa(lv.OPA._50, 0) - self._back_lbl.set_text("Hangup") - self._back_btn.set_style_bg_color(lv.color_hex(0xFF5252), 0) - self._del_btn.set_style_bg_opa(lv.OPA._50, 0) - elif self._state == "INCOMING": - self._status_label.set_text("Incoming:") - self._num_label.set_text(self._phone_number) - self._call_lbl.set_text("Answer") - self._call_btn.set_style_bg_color(lv.color_hex(0x4CAF50), 0) - self._call_btn.set_style_bg_opa(lv.OPA.COVER, 0) - self._back_lbl.set_text("Reject") - self._back_btn.set_style_bg_color(lv.color_hex(0xFF5252), 0) - self._del_btn.set_style_bg_opa(lv.OPA._50, 0) - - -class SnakePage(AppPage): - """贪吃蛇游戏 -- 34x20 网格,触摸滑动操控。""" - - CELL = 14 - COLS = 34 - ROWS = 20 - OX = 2 # 网格 X 偏移(34*14=476,居中于 480) - OY = 3 # 网格 Y 偏移(20*14=280,居中于 286) - BASE_TICK = 150 # 初始帧间隔 ms - - COL_EMPTY = 0x16213E - COL_HEAD = 0x00E676 - COL_BODY = 0x4CAF50 - COL_FOOD = 0xFF5252 - - def _create_content(self): - # -- 游戏容器 -- - gc = lv.obj(self.screen) - gc.remove_style_all() - gc.set_size(self._sw, self.content_h) - gc.set_pos(0, self.content_y) - gc.set_style_bg_color(lv.color_hex(0x0F3460), 0) - gc.set_style_bg_opa(lv.OPA.COVER, 0) - gc.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) - gc.set_style_pad_all(0, 0) - gc.clear_flag(lv.obj.FLAG.SCROLLABLE) - gc.add_event_cb(self._on_touch, lv.EVENT.ALL, None) - self._refs.append(gc) - self._gc = gc - - # -- 网格 -- - self._cells = [] - for r in range(self.ROWS): - row = [] - for c in range(self.COLS): - cell = lv.obj(gc) - cell.remove_style_all() - cell.set_size(self.CELL, self.CELL) - cell.set_pos(self.OX + c * self.CELL, - self.OY + r * self.CELL) - cell.set_style_bg_color(lv.color_hex(self.COL_EMPTY), 0) - cell.set_style_bg_opa(lv.OPA.COVER, 0) - cell.set_style_radius(1, 0) - cell.clear_flag(lv.obj.FLAG.CLICKABLE) # 穿透触摸到 gc - row.append(cell) - self._refs.append(cell) - self._cells.append(row) - - # -- 分数(标题栏右侧)-- - self._score_label = lv.label(self.screen) - self._score_label.set_text("Score: 0") - self._score_label.set_style_text_font(lv.font_montserrat_14, 0) - self._score_label.set_style_text_color(lv.color_hex(0x2C2C46), 0) - self._score_label.set_pos(self._sw - 80, 7) - self._refs.append(self._score_label) - - # -- 提示遮罩 -- - pw, ph = 180, 60 - panel = lv.obj(gc) - panel.remove_style_all() - panel.set_size(pw, ph) - panel.set_pos((self._sw - pw) // 2, - (self.content_h - ph) // 2) - panel.set_style_bg_color(lv.color_hex(0x000000), 0) - panel.set_style_bg_opa(lv.OPA._60, 0) - panel.set_style_radius(8, 0) - panel.clear_flag(lv.obj.FLAG.CLICKABLE) # 穿透触摸到 gc - self._refs.append(panel) - self._panel = panel - - self._overlay = lv.label(panel) - self._overlay.set_text("Tap to Start") - self._overlay.set_style_text_font(lv.font_montserrat_14, 0) - self._overlay.set_style_text_color(lv.color_hex(0x2C2C46), 0) - self._overlay.center() - self._refs.append(self._overlay) - - # -- 游戏状态 -- - self._state = "WAITING" - self._snake = [] - self._direction = (1, 0) - self._next_dir = (1, 0) - self._food = None - self._score = 0 - self._running = False - self._touch_start = None - # tick 用 lv.timer 驱动(回调在 task_handler/主线程里执行),不再开子线程碰 LVGL - self._tick_timer = lv.timer_create(self._on_tick, self.BASE_TICK, None) - - # ---------- 游戏启动 ---------- - - def _start_game(self): - self._snake = [ - (self.COLS // 2, self.ROWS // 2), - (self.COLS // 2 - 1, self.ROWS // 2), - (self.COLS // 2 - 2, self.ROWS // 2), - ] - self._direction = (1, 0) - self._next_dir = (1, 0) - self._score = 0 - self._score_label.set_text("Score: 0") - - # 清空网格 -> 画初始蛇 - for r in range(self.ROWS): - for c in range(self.COLS): - self._cells[r][c].set_style_bg_color( - lv.color_hex(self.COL_EMPTY), 0) - for i, (c, r) in enumerate(self._snake): - col = self.COL_HEAD if i == 0 else self.COL_BODY - self._cells[r][c].set_style_bg_color(lv.color_hex(col), 0) - - self._spawn_food() - - self._state = "PLAYING" - self._panel.set_style_bg_opa(lv.OPA.TRANSP, 0) - self._overlay.set_text("") - - self._running = True - # tick 由 lv.timer 驱动(主线程),不再开子线程 - if self._tick_timer: - self._tick_timer.set_period(self._tick_period()) - - # ---------- 食物 ---------- - - def _spawn_food(self): - import urandom - snake_set = set(self._snake) - empty = [] - for r in range(self.ROWS): - for c in range(self.COLS): - if (c, r) not in snake_set: - empty.append((c, r)) - if not empty: - self._end_game() - return - self._food = empty[urandom.getrandbits(10) % len(empty)] - fc, fr = self._food - self._cells[fr][fc].set_style_bg_color( - lv.color_hex(self.COL_FOOD), 0) - - # ---------- 游戏循环 ---------- - - def _tick_period(self): - return max(80, self.BASE_TICK - self._score * 2) - - def _on_tick(self, timer): - """lv.timer 回调(跑在主线程 task_handler 里):推进一步;顺手按分数刷新周期。""" - try: - if self._state == "PLAYING": - self._game_tick() - timer.set_period(self._tick_period()) - except Exception as e: - print("[SNAKE] tick error:", e) - - def _game_tick(self): - self._direction = self._next_dir - hx, hy = self._snake[0] - nx = hx + self._direction[0] - ny = hy + self._direction[1] - - # 碰墙 - if nx < 0 or nx >= self.COLS or ny < 0 or ny >= self.ROWS: - self._end_game() - return - # 碰自身 - if (nx, ny) in self._snake: - self._end_game() - return - - self._snake.insert(0, (nx, ny)) - - old_tail = None - if (nx, ny) == self._food: - self._score += 1 - self._score_label.set_text("Score: {}".format(self._score)) - self._spawn_food() - else: - old_tail = self._snake.pop() - tc, tr = old_tail - self._cells[tr][tc].set_style_bg_color( - lv.color_hex(self.COL_EMPTY), 0) - - # 增量渲染 - self._cells[ny][nx].set_style_bg_color( - lv.color_hex(self.COL_HEAD), 0) - if len(self._snake) > 1: - bx, by = self._snake[1] - self._cells[by][bx].set_style_bg_color( - lv.color_hex(self.COL_BODY), 0) - - # ---------- 游戏结束 ---------- - - def _end_game(self): - self._state = "GAME_OVER" - self._running = False - self._panel.set_style_bg_opa(lv.OPA._60, 0) - self._overlay.set_text("Game Over!\nScore: {}\nTap to restart".format( - self._score)) - self._overlay.center() - - # ---------- 触摸输入 ---------- - - def _get_touch_pos(self): - """通过活动 indev 获取当前触摸坐标。""" - indev = lv.indev_get_act() - if indev: - pt = lv.point_t() - indev.get_point(pt) - return (pt.x, pt.y) - return None - - def _on_touch(self, e): - code = e.get_code() - - if code == lv.EVENT.PRESSED: - pos = self._get_touch_pos() - if pos: - self._touch_start = pos - return - - if code != lv.EVENT.RELEASED: - return - - # 点击(短按)-> 开始 / 重新开始 - if self._state in ("WAITING", "GAME_OVER"): - self._start_game() - return - - # 滑动 -> 改变方向 - if self._state == "PLAYING" and self._touch_start: - pos = self._get_touch_pos() - if not pos: - return - dx = pos[0] - self._touch_start[0] - dy = pos[1] - self._touch_start[1] - if abs(dx) < 10 and abs(dy) < 10: - return - if abs(dx) > abs(dy): - nd = (1, 0) if dx > 0 else (-1, 0) - else: - nd = (0, 1) if dy > 0 else (0, -1) - cur = self._direction - if (nd[0] + cur[0], nd[1] + cur[1]) != (0, 0): - self._next_dir = nd - - # ---------- 退出清理 ---------- - - def _on_back(self, e=None): - print("[SNAKE] cleaning up...") - self._running = False - self._state = "GAME_OVER" - utime.sleep_ms(self.BASE_TICK + 20) - super()._on_back(e) - - -class Game2048Page(AppPage): - """2048 游戏 -- 4x4 网格,滑动合并。""" - - SIZE = 4 - CELL = 48 - GAP = 4 - # [诊断] 设 False:方块只变色、不显示数字,用来排查"网格倾斜"是否跟数字渲染有关 - SHOW_NUMBERS = True - # (背景色, 文字色) - TILE_COLORS = { - 0: (0x1A1A2E, 0x1A1A2E), - 2: (0x334466, 0xFFFFFF), - 4: (0x3D5A80, 0xFFFFFF), - 8: (0xE07020, 0xFFFFFF), - 16: (0xE85D20, 0xFFFFFF), - 32: (0xE84420, 0xFFFFFF), - 64: (0xE82C20, 0xFFFFFF), - 128: (0xEDCF72, 0x1A1A2E), - 256: (0xEDCC61, 0x1A1A2E), - 512: (0xEDC850, 0x1A1A2E), - 1024: (0xEDC53F, 0x1A1A2E), - 2048: (0xEDC22E, 0x1A1A2E), - } - - def _create_content(self): - base_y = self.content_y - grid_total = self.SIZE * self.CELL + (self.SIZE + 1) * self.GAP # 212 - grid_x = (self._sw - grid_total) // 2 - grid_y = base_y + (self.content_h - grid_total) // 2 - - # -- 分数栏 -- - self._score_lbl = lv.label(self.screen) - self._score_lbl.set_text("Score: 0") - self._score_lbl.set_style_text_font(lv.font_montserrat_14, 0) - self._score_lbl.set_style_text_color(lv.color_hex(0x2C2C46), 0) - self._score_lbl.set_pos(10, base_y + 4) - self._refs.append(self._score_lbl) - - self._best_lbl = lv.label(self.screen) - self._best_lbl.set_text("Best: 0") - self._best_lbl.set_style_text_font(lv.font_montserrat_14, 0) - self._best_lbl.set_style_text_color(lv.color_hex(0x888888), 0) - self._best_lbl.set_pos(self._sw - 75, base_y + 4) - self._refs.append(self._best_lbl) - - # -- 网格背景 -- - bg = lv.obj(self.screen) - bg.remove_style_all() - bg.set_size(grid_total, grid_total) - bg.set_pos(grid_x, grid_y) - bg.set_style_bg_color(lv.color_hex(0x0F3460), 0) - bg.set_style_bg_opa(lv.OPA.COVER, 0) - bg.set_style_radius(6, 0) - bg.add_flag(lv.obj.FLAG.CLICKABLE) - bg.clear_flag(lv.obj.FLAG.SCROLLABLE) - bg.add_event_cb(self._on_touch, lv.EVENT.ALL, None) - self._refs.append(bg) - - # -- 4x4 格子 -- - self._cells = [[None] * self.SIZE for _ in range(self.SIZE)] - self._labels = [[None] * self.SIZE for _ in range(self.SIZE)] - for r in range(self.SIZE): - for c in range(self.SIZE): - x = grid_x + self.GAP + c * (self.CELL + self.GAP) - y = grid_y + self.GAP + r * (self.CELL + self.GAP) - - cell = lv.obj(self.screen) - cell.remove_style_all() - cell.set_size(self.CELL, self.CELL) - cell.set_pos(x, y) - cell.set_style_bg_color(lv.color_hex(0x2C2C46), 0) - cell.set_style_bg_opa(lv.OPA.COVER, 0) - cell.set_style_radius(4, 0) - cell.clear_flag(lv.obj.FLAG.CLICKABLE) - self._cells[r][c] = cell - - lbl = lv.label(cell) - lbl.set_text("") - lbl.set_style_text_font(lv.font_montserrat_14, 0) - lbl.set_style_text_color(lv.color_hex(0x2C2C46), 0) - lbl.center() - self._labels[r][c] = lbl - - self._refs.extend((cell, lbl)) - - # -- 遮罩 -- - pw, ph = 160, 50 - panel = lv.obj(bg) - panel.remove_style_all() - panel.set_size(pw, ph) - panel.set_pos((grid_total - pw) // 2, (grid_total - ph) // 2) - panel.set_style_bg_color(lv.color_hex(0x000000), 0) - panel.set_style_bg_opa(lv.OPA._60, 0) - panel.set_style_radius(8, 0) - panel.clear_flag(lv.obj.FLAG.CLICKABLE) - self._refs.append(panel) - self._panel = panel - - self._overlay = lv.label(panel) - self._overlay.set_text("Tap to Start") - self._overlay.set_style_text_font(lv.font_montserrat_14, 0) - self._overlay.set_style_text_color(lv.color_hex(0x2C2C46), 0) - self._overlay.center() - self._refs.append(self._overlay) - - # -- 状态 -- - self._grid = [[0] * self.SIZE for _ in range(self.SIZE)] - self._score = 0 - self._best = 0 - self._state = "WAITING" - self._touch_start = None - - # ---------- 游戏逻辑 ---------- - - def _start_game(self): - self._grid = [[0] * self.SIZE for _ in range(self.SIZE)] - self._score = 0 - self._state = "PLAYING" - self._spawn() - self._spawn() - self._render_all() - self._update_score() - self._panel.set_style_bg_opa(lv.OPA.TRANSP, 0) - self._overlay.set_text("") - - def _spawn(self): - """随机空位生成 2(90%) 或 4(10%)。""" - import urandom - empty = [(r, c) for r in range(self.SIZE) - for c in range(self.SIZE) if self._grid[r][c] == 0] - if not empty: - return - r, c = empty[urandom.getrandbits(8) % len(empty)] - self._grid[r][c] = 2 if urandom.getrandbits(8) % 10 != 0 else 4 - - def _slide_row_left(self, row): - """左滑一行,返回 (新行, 得分)。""" - # 去零压缩 - nums = [x for x in row if x != 0] - # 合并 - merged = [] - score = 0 - skip = False - for i in range(len(nums)): - if skip: - skip = False - continue - if i + 1 < len(nums) and nums[i] == nums[i + 1]: - val = nums[i] * 2 - merged.append(val) - score += val - skip = True - else: - merged.append(nums[i]) - # 补零 - merged += [0] * (self.SIZE - len(merged)) - return merged, score - - def _move(self, direction): - """执行移动。direction: 0=左 1=右 2=上 3=下。""" - old = [row[:] for row in self._grid] - total_score = 0 - - if direction == 0: # 左 - for r in range(self.SIZE): - self._grid[r], s = self._slide_row_left(self._grid[r]) - total_score += s - elif direction == 1: # 右 - for r in range(self.SIZE): - rev = self._grid[r][::-1] - self._grid[r], s = self._slide_row_left(rev) - self._grid[r] = self._grid[r][::-1] - total_score += s - elif direction == 2: # 上 - for c in range(self.SIZE): - col = [self._grid[r][c] for r in range(self.SIZE)] - new_col, s = self._slide_row_left(col) - total_score += s - for r in range(self.SIZE): - self._grid[r][c] = new_col[r] - elif direction == 3: # 下 - for c in range(self.SIZE): - col = [self._grid[r][c] for r in range(self.SIZE)][::-1] - new_col, s = self._slide_row_left(col) - new_col.reverse() - total_score += s - for r in range(self.SIZE): - self._grid[r][c] = new_col[r] - - # 是否有变化 - if self._grid == old: - return - - self._score += total_score - self._spawn() - self._render_all() - self._update_score() - - # 检查胜负 - if self._check_win(): - self._state = "WON" - self._panel.set_style_bg_opa(lv.OPA._60, 0) - self._overlay.set_text("You Win! 2048\nTap to continue") - self._overlay.center() - elif self._check_game_over(): - self._state = "GAME_OVER" - self._panel.set_style_bg_opa(lv.OPA._60, 0) - self._overlay.set_text( - "Game Over!\nScore: {}\nTap to restart".format(self._score)) - self._overlay.center() - - def _check_win(self): - return any(self._grid[r][c] == 2048 - for r in range(self.SIZE) for c in range(self.SIZE)) - - def _check_game_over(self): - # 有空位就没结束 - for r in range(self.SIZE): - for c in range(self.SIZE): - if self._grid[r][c] == 0: - return False - # 有相邻相同就没结束 - for r in range(self.SIZE): - for c in range(self.SIZE): - v = self._grid[r][c] - if c + 1 < self.SIZE and self._grid[r][c + 1] == v: - return False - if r + 1 < self.SIZE and self._grid[r + 1][c] == v: - return False - return True - - # ---------- 渲染 ---------- - - def _render_all(self): - for r in range(self.SIZE): - for c in range(self.SIZE): - v = self._grid[r][c] - bg_col, txt_col = self.TILE_COLORS.get( - v, (0x00E676, 0x000000)) - self._cells[r][c].set_style_bg_color(lv.color_hex(bg_col), 0) - if v and self.SHOW_NUMBERS: - self._labels[r][c].set_text(str(v)) - self._labels[r][c].set_style_text_color( - lv.color_hex(txt_col), 0) - else: - self._labels[r][c].set_text("") - - def _update_score(self): - self._score_lbl.set_text("Score: {}".format(self._score)) - if self._score > self._best: - self._best = self._score - self._best_lbl.set_text("Best: {}".format(self._best)) - - # ---------- 触摸输入 ---------- - - def _get_touch_pos(self): - indev = lv.indev_get_act() - if indev: - pt = lv.point_t() - indev.get_point(pt) - return (pt.x, pt.y) - return None - - def _on_touch(self, e): - code = e.get_code() - - if code == lv.EVENT.PRESSED: - pos = self._get_touch_pos() - if pos: - self._touch_start = pos - return - - if code != lv.EVENT.RELEASED: - return - - # 点击 -> 开始 / 继续 / 重启 - if self._state == "WAITING": - self._start_game() - return - if self._state == "WON": - # 继续玩 - self._state = "PLAYING" - self._panel.set_style_bg_opa(lv.OPA.TRANSP, 0) - self._overlay.set_text("") - return - if self._state == "GAME_OVER": - self._start_game() - return - - # 滑动 -> 移动 - if self._state == "PLAYING" and self._touch_start: - pos = self._get_touch_pos() - if not pos: - return - dx = pos[0] - self._touch_start[0] - dy = pos[1] - self._touch_start[1] - if abs(dx) < 15 and abs(dy) < 15: - return - if abs(dx) > abs(dy): - self._move(0 if dx < 0 else 1) - else: - self._move(2 if dy < 0 else 3) - - -class TetrisPage(AppPage): - """俄罗斯方块 -- 10x20 网格,触摸滑动操控。""" - - CELL = 14 - COLS = 10 - ROWS = 20 - GAME_X = 2 # 游戏区 X 偏移(容器内) - GAME_Y = 3 # 游戏区 Y 偏移(容器内) - BASE_TICK = 500 - COL_EMPTY = 0x16213E - - # 7 种方块: (矩阵, 颜色) - SHAPES = ( - ([[0,0,0,0],[1,1,1,1],[0,0,0,0],[0,0,0,0]], 0x00BCD4), # I 青 - ([[1,1],[1,1]], 0xFFEB3B), # O 黄 - ([[0,1,0],[1,1,1],[0,0,0]], 0x9C27B0), # T 紫 - ([[0,1,1],[1,1,0],[0,0,0]], 0x4CAF50), # S 绿 - ([[1,1,0],[0,1,1],[0,0,0]], 0xFF5252), # Z 红 - ([[1,0,0],[1,1,1],[0,0,0]], 0xFF9800), # L 橙 - ([[0,0,1],[1,1,1],[0,0,0]], 0x2196F3), # J 蓝 - ) - - # 消行计分: 0行=0, 1行=100, 2行=300, 3行=500, 4行=800 - LINE_SCORES = (0, 100, 300, 500, 800) - - def _create_content(self): - base_y = self.content_y - - # -- 游戏区容器(板子 + 右侧信息栏 整体水平居中于内容区宽度)-- - board_w = self.COLS * self.CELL + 4 - board_h = self.ROWS * self.CELL + 6 - info_w = 4 * self.CELL + 24 - left = (self._sw - (board_w + 12 + info_w)) // 2 - if left < 2: - left = 2 - - gc = lv.obj(self.screen) - gc.remove_style_all() - gc.set_size(board_w, board_h) - gc.set_pos(left, base_y) - gc.set_style_bg_color(lv.color_hex(0x0F3460), 0) - gc.set_style_bg_opa(lv.OPA.COVER, 0) - gc.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) - gc.set_style_pad_all(0, 0) - gc.clear_flag(lv.obj.FLAG.SCROLLABLE) - gc.add_flag(lv.obj.FLAG.CLICKABLE) - gc.add_event_cb(self._on_touch, lv.EVENT.ALL, None) - self._refs.append(gc) - self._gc = gc - - # -- 游戏区网格 10x20 -- - self._cells = [] - for r in range(self.ROWS): - row = [] - for c in range(self.COLS): - cell = lv.obj(gc) - cell.remove_style_all() - cell.set_size(self.CELL, self.CELL) - cell.set_pos(self.GAME_X + c * self.CELL, - self.GAME_Y + r * self.CELL) - cell.set_style_bg_color(lv.color_hex(self.COL_EMPTY), 0) - cell.set_style_bg_opa(lv.OPA.COVER, 0) - cell.set_style_radius(1, 0) - cell.clear_flag(lv.obj.FLAG.CLICKABLE) - row.append(cell) - self._refs.append(cell) - self._cells.append(row) - - # -- 信息栏(右侧)-- - info_x = left + board_w + 12 - - # "Next" 标签 - lbl_next = lv.label(self.screen) - lbl_next.set_text("Next:") - lbl_next.set_style_text_font(lv.font_montserrat_14, 0) - lbl_next.set_style_text_color(lv.color_hex(0x2C2C46), 0) - lbl_next.set_pos(info_x, base_y + 4) - self._refs.append(lbl_next) - - # 预览区 4x4 网格 - pv_ox = info_x + 8 - pv_oy = base_y + 22 - self._pv_cells = [] - for r in range(4): - for c in range(4): - cell = lv.obj(self.screen) - cell.remove_style_all() - cell.set_size(self.CELL, self.CELL) - cell.set_pos(pv_ox + c * self.CELL, pv_oy + r * self.CELL) - cell.set_style_bg_color(lv.color_hex(self.COL_EMPTY), 0) - cell.set_style_bg_opa(lv.OPA.COVER, 0) - cell.set_style_radius(1, 0) - self._pv_cells.append(cell) - self._refs.append(cell) - - # 分数 / 消行 / 等级(排在预览下方) - info_y = pv_oy + 4 * self.CELL + 10 - self._score_lbl = lv.label(self.screen) - self._score_lbl.set_text("Score: 0") - self._score_lbl.set_style_text_font(lv.font_montserrat_14, 0) - self._score_lbl.set_style_text_color(lv.color_hex(0x2C2C46), 0) - self._score_lbl.set_pos(info_x, info_y) - self._refs.append(self._score_lbl) - - self._lines_lbl = lv.label(self.screen) - self._lines_lbl.set_text("Lines: 0") - self._lines_lbl.set_style_text_font(lv.font_montserrat_14, 0) - self._lines_lbl.set_style_text_color(lv.color_hex(0x2C2C46), 0) - self._lines_lbl.set_pos(info_x, info_y + 20) - self._refs.append(self._lines_lbl) - - self._level_lbl = lv.label(self.screen) - self._level_lbl.set_text("Level: 1") - self._level_lbl.set_style_text_font(lv.font_montserrat_14, 0) - self._level_lbl.set_style_text_color(lv.color_hex(0x2C2C46), 0) - self._level_lbl.set_pos(info_x, info_y + 40) - self._refs.append(self._level_lbl) - - # -- 提示遮罩(铺满板子宽度,竖直居中)-- - pw, ph = board_w, 60 - panel = lv.obj(gc) - panel.remove_style_all() - panel.set_size(pw, ph) - panel.set_pos((board_w - pw) // 2, (board_h - ph) // 2) - panel.set_style_bg_color(lv.color_hex(0x000000), 0) - panel.set_style_bg_opa(lv.OPA._60, 0) - panel.set_style_radius(8, 0) - panel.clear_flag(lv.obj.FLAG.CLICKABLE) - self._refs.append(panel) - self._panel = panel - - self._overlay = lv.label(panel) - self._overlay.set_text("Tap to Start") - self._overlay.set_style_text_font(lv.font_montserrat_14, 0) - self._overlay.set_style_text_color(lv.color_hex(0x2C2C46), 0) - self._overlay.center() - self._refs.append(self._overlay) - - # -- 游戏状态 -- - self._state = "WAITING" - self._board = [[0] * self.COLS for _ in range(self.ROWS)] - self._piece = None - self._next_idx = 0 - self._bag = [] - self._score = 0 - self._lines = 0 - self._level = 1 - self._running = False - self._touch_start = None - # tick 用 lv.timer 驱动(回调在 task_handler/主线程里执行),不再开子线程碰 LVGL - self._tick_timer = lv.timer_create(self._on_tick, self.BASE_TICK, None) - - # ---------- 方块逻辑 ---------- - - def _rot_matrix(self, mat): - """顺时针旋转矩阵。""" - rows = len(mat) - cols = len(mat[0]) - return [[mat[rows - 1 - r][c] for r in range(rows)] for c in range(cols)] - - def _get_shape(self, idx, rot): - """获取第 idx 种方块旋转 rot 次后的矩阵和颜色。""" - mat, color = self.SHAPES[idx] - for _ in range(rot % 4): - mat = self._rot_matrix(mat) - return mat, color - - def _bag_next(self): - """7-bag 随机发生器。""" - import urandom - if not self._bag: - self._bag = list(range(7)) - # Fisher-Yates shuffle - for i in range(6, 0, -1): - j = urandom.getrandbits(8) % (i + 1) - self._bag[i], self._bag[j] = self._bag[j], self._bag[i] - return self._bag.pop() - - def _cell_coords(self, mat, px, py): - """返回方块占用的 (col, row) 列表。""" - coords = [] - for r in range(len(mat)): - for c in range(len(mat[0])): - if mat[r][c]: - coords.append((px + c, py + r)) - return coords - - def _fits(self, mat, px, py): - """检查方块在 (px, py) 是否合法。""" - for r in range(len(mat)): - for c in range(len(mat[0])): - if mat[r][c]: - x, y = px + c, py + r - if x < 0 or x >= self.COLS or y >= self.ROWS: - return False - if y >= 0 and self._board[y][x]: - return False - return True - - # ---------- 游戏控制 ---------- - - def _start_game(self): - self._board = [[0] * self.COLS for _ in range(self.ROWS)] - self._score = 0 - self._lines = 0 - self._level = 1 - self._bag = [] - self._render_board() - - self._next_idx = self._bag_next() - self._spawn_piece() - - self._state = "PLAYING" - self._panel.set_style_bg_opa(lv.OPA.TRANSP, 0) - self._overlay.set_text("") - self._update_info() - - self._running = True - # tick 由 lv.timer 驱动(主线程),不再开子线程 - if self._tick_timer: - self._tick_timer.set_period(self._tick_period()) - - def _spawn_piece(self): - idx = self._next_idx - self._next_idx = self._bag_next() - mat, color = self._get_shape(idx, 0) - px = (self.COLS - len(mat[0])) // 2 - py = 0 - # 如果顶部放不下,游戏结束 - if not self._fits(mat, px, py): - self._end_game() - return - self._piece = {'idx': idx, 'rot': 0, 'x': px, 'y': py} - self._render_piece() - self._render_preview() - - def _move(self, dx, dy): - if not self._piece or self._state != "PLAYING": - return False - p = self._piece - mat, _ = self._get_shape(p['idx'], p['rot']) - if self._fits(mat, p['x'] + dx, p['y'] + dy): - self._clear_piece() - p['x'] += dx - p['y'] += dy - self._render_piece() - return True - return False - - def _rotate_piece(self): - if not self._piece or self._state != "PLAYING": - return - p = self._piece - new_rot = (p['rot'] + 1) % 4 - mat, _ = self._get_shape(p['idx'], new_rot) - # 尝试原位、左移1、右移1、上移1(wall kick) - for dx, dy in [(0, 0), (-1, 0), (1, 0), (0, -1), (-2, 0), (2, 0)]: - if self._fits(mat, p['x'] + dx, p['y'] + dy): - self._clear_piece() - p['rot'] = new_rot - p['x'] += dx - p['y'] += dy - self._render_piece() - return - - def _hard_drop(self): - if not self._piece: - return - p = self._piece - mat, _ = self._get_shape(p['idx'], p['rot']) - dy = 0 - while self._fits(mat, p['x'], p['y'] + dy + 1): - dy += 1 - self._clear_piece() - p['y'] += dy - self._render_piece() - self._score += dy * 2 # 硬降加分 - self._lock() - - def _lock(self): - """锁定当前方块到 board,检查消行。""" - p = self._piece - mat, color = self._get_shape(p['idx'], p['rot']) - for r in range(len(mat)): - for c in range(len(mat[0])): - if mat[r][c]: - bx, by = p['x'] + c, p['y'] + r - if 0 <= by < self.ROWS and 0 <= bx < self.COLS: - self._board[by][bx] = color - - self._piece = None - cleared = self._clear_lines() - if cleared: - self._lines += cleared - self._score += self.LINE_SCORES[min(cleared, 4)] * self._level - self._level = self._lines // 10 + 1 - self._update_info() - self._render_board() - - self._spawn_piece() - - def _clear_lines(self): - """消除满行,返回消除行数。""" - full = [r for r in range(self.ROWS) - if all(self._board[r][c] for c in range(self.COLS))] - if not full: - return 0 - for r in sorted(full): - del self._board[r] - self._board.insert(0, [0] * self.COLS) - return len(full) - - # ---------- 渲染 ---------- - - def _render_board(self): - for r in range(self.ROWS): - for c in range(self.COLS): - col = self._board[r][c] or self.COL_EMPTY - self._cells[r][c].set_style_bg_color(lv.color_hex(col), 0) - - def _render_piece(self): - if not self._piece: - return - p = self._piece - mat, color = self._get_shape(p['idx'], p['rot']) - for r in range(len(mat)): - for c in range(len(mat[0])): - if mat[r][c]: - x, y = p['x'] + c, p['y'] + r - if 0 <= y < self.ROWS and 0 <= x < self.COLS: - self._cells[y][x].set_style_bg_color( - lv.color_hex(color), 0) - - def _clear_piece(self): - if not self._piece: - return - p = self._piece - mat, _ = self._get_shape(p['idx'], p['rot']) - for r in range(len(mat)): - for c in range(len(mat[0])): - if mat[r][c]: - x, y = p['x'] + c, p['y'] + r - if 0 <= y < self.ROWS and 0 <= x < self.COLS: - col = self._board[y][x] or self.COL_EMPTY - self._cells[y][x].set_style_bg_color( - lv.color_hex(col), 0) - - def _render_preview(self): - mat, color = self._get_shape(self._next_idx, 0) - # 居中预览 - ox = (4 - len(mat[0])) // 2 - oy = (4 - len(mat)) // 2 - for r in range(4): - for c in range(4): - idx = r * 4 + c - mr, mc = r - oy, c - ox - if 0 <= mr < len(mat) and 0 <= mc < len(mat[0]) and mat[mr][mc]: - self._pv_cells[idx].set_style_bg_color( - lv.color_hex(color), 0) - else: - self._pv_cells[idx].set_style_bg_color( - lv.color_hex(self.COL_EMPTY), 0) - - def _update_info(self): - self._score_lbl.set_text("Score: {}".format(self._score)) - self._lines_lbl.set_text("Lines: {}".format(self._lines)) - self._level_lbl.set_text("Level: {}".format(self._level)) - - # ---------- 游戏循环 ---------- - - def _tick_period(self): - return max(100, self.BASE_TICK - (self._level - 1) * 50) - - def _on_tick(self, timer): - """lv.timer 回调(跑在主线程 task_handler 里):每次下一格,碰底锁定;顺手按等级刷新周期。""" - try: - if self._state == "PLAYING" and self._piece: - if not self._move(0, 1): - self._lock() - timer.set_period(self._tick_period()) - except Exception as e: - print("[TETRIS] tick error:", e) - - # ---------- 游戏结束 ---------- - - def _end_game(self): - self._state = "GAME_OVER" - self._running = False - self._panel.set_style_bg_opa(lv.OPA._60, 0) - self._overlay.set_text("Game Over!\nScore: {}\nTap to restart".format( - self._score)) - self._overlay.center() - - # ---------- 触摸输入 ---------- - - def _get_touch_pos(self): - indev = lv.indev_get_act() - if indev: - pt = lv.point_t() - indev.get_point(pt) - return (pt.x, pt.y) - return None - - def _on_touch(self, e): - code = e.get_code() - - if code == lv.EVENT.PRESSED: - pos = self._get_touch_pos() - if pos: - self._touch_start = pos - return - - if code == lv.EVENT.RELEASED: - if self._state in ("WAITING", "GAME_OVER"): - self._start_game() - return - - if self._state == "PLAYING" and self._touch_start: - pos = self._get_touch_pos() - if not pos: - return - dx = pos[0] - self._touch_start[0] - dy = pos[1] - self._touch_start[1] - if abs(dx) < 10 and abs(dy) < 10: - self._rotate_piece() # 点击 -> 旋转 - return - if abs(dx) > abs(dy): - if dx > 0: - self._move(1, 0) - else: - self._move(-1, 0) - else: - if dy > 20: - self._hard_drop() # 长下滑 -> 硬降 - else: - self._move(0, 1) - return - - if code == lv.EVENT.PRESSING and self._state == "PLAYING": - # 长按下 -> 软降加速 - pos = self._get_touch_pos() - if pos and self._touch_start: - dy = pos[1] - self._touch_start[1] - if dy > 30: - self._move(0, 1) - - # ---------- 退出清理 ---------- - - def _on_back(self, e=None): - print("[TETRIS] cleaning up...") - self._running = False - self._state = "GAME_OVER" - utime.sleep_ms(self.BASE_TICK + 20) - super()._on_back(e) - - -class ScanPage(AppPage): - """条码/二维码扫描。""" - def _create_content(self): - base_y = self.content_y - w = self._sw - cx = w // 2 - - # 取景画面 - vf = lv.obj(self.screen) - vf.remove_style_all() - vf.set_size(w - 12, 198) - vf.set_pos(6, base_y + 4) - vf.set_style_bg_color(lv.color_hex(0x0D1117), 0) - vf.set_style_bg_opa(lv.OPA.COVER, 0) - vf.clear_flag(lv.obj.FLAG.SCROLLABLE) - self._refs.append(vf) - - # 扫描框四角(L 形,细矩形拼) - box = 150 - bx = (w - box) // 2 - by = base_y + 30 - ln = 18 - corners = ( - (bx, by, ln, 3), (bx, by, 3, ln), - (bx + box - ln, by, ln, 3), (bx + box, by, 3, ln), - (bx, by + box - 3, ln, 3), (bx, by + box - ln, 3, ln), - (bx + box - ln, by + box - 3, ln, 3), (bx + box, by + box - ln, 3, ln), - ) - for xx, yy, ww, hh in corners: - seg = lv.obj(self.screen) - seg.remove_style_all() - seg.set_size(ww, hh) - seg.set_pos(xx, yy) - seg.set_style_bg_color(lv.color_hex(0x00E676), 0) - seg.set_style_bg_opa(lv.OPA.COVER, 0) - seg.clear_flag(lv.obj.FLAG.CLICKABLE) - self._refs.append(seg) - - # 扫描线 - sl = lv.obj(self.screen) - sl.remove_style_all() - sl.set_size(box - 8, 2) - sl.set_pos(bx + 4, by + box // 2) - sl.set_style_bg_color(lv.color_hex(0x00E676), 0) - sl.set_style_bg_opa(lv.OPA.COVER, 0) - sl.clear_flag(lv.obj.FLAG.CLICKABLE) - self._refs.append(sl) - - # 提示 - hint = lv.label(self.screen) - hint.set_text("Align QR code within frame") - hint.set_style_text_font(lv.font_montserrat_14, 0) - hint.set_style_text_color(lv.color_hex(0xCCCCCC), 0) - hint.set_pos(cx - 80, by + box + 12) - self._refs.append(hint) - # TODO: 接入摄像头解码(需 Camera 固件) - - -# ======================================================= -# 蜂鸣器 / 天气 -# ======================================================= - -class BuzzerPage(AppPage): - """蜂鸣器:Beep 短响 / On-Off 持续响。脚位确认后填 PIN_BUZZER 即可真响。""" - PIN_BUZZER = Pin.GPIO2 # GPIO2 → Q0701 → BUZ0701 - - def _create_content(self): - base_y = self.content_y - - self._buz = None - try: - if self.PIN_BUZZER is not None: - self._buz = Pin(self.PIN_BUZZER, Pin.OUT, Pin.PULL_DISABLE, 0) - except Exception as e: - print("[BUZZER] pin init failed:", e) - - # ---- 顶部信息卡 ---- - card = lv.obj(self.screen) - card.remove_style_all() - card.set_size(self._sw - 24, 96) - card.set_pos(12, base_y + 6) - card.set_style_bg_color(lv.color_hex(0x16213E), 0) - card.set_style_bg_opa(lv.OPA.COVER, 0) - card.set_style_radius(10, 0) - card.set_style_pad_all(0, 0) - card.set_style_border_width(0, 0) - card.clear_flag(lv.obj.FLAG.CLICKABLE) - self._refs.append(card) - - # 喇叭图标(圆 + 弧线近似) - icon = lv.label(card) - icon.set_text(")") if False else icon.set_text(">]") - icon.set_style_text_font(lv.font_montserrat_14, 0) - icon.set_style_text_color(lv.color_hex(0xFF9800), 0) - icon.set_pos(16, 38) - self._refs.append(icon) - - self._buz_title = lv.label(card) - self._buz_title.set_text("Buzzer") - self._buz_title.set_style_text_font(lv.font_montserrat_14, 0) - self._buz_title.set_style_text_color(lv.color_hex(0x2C2C46), 0) - self._buz_title.set_pos(60, 20) - self._refs.append(self._buz_title) - - self._buz_status = lv.label(card) - self._buz_status.set_text("Ready" if self._buz else "Pin not configured") - self._buz_status.set_style_text_font(lv.font_montserrat_14, 0) - self._buz_status.set_style_text_color( - lv.color_hex(0x00E676 if self._buz else 0xFF5252), 0) - self._buz_status.set_pos(60, 56) - self._refs.append(self._buz_status) - - # ---- 两个大按钮 ---- - bw, bh, gap = 200, 70, 16 - total = bw * 2 + gap - ox = (self._sw - total) // 2 - by = base_y + 120 - - # Beep - b1 = lv.btn(self.screen) - b1.set_size(bw, bh) - b1.set_pos(ox, by) - b1.set_style_bg_color(lv.color_hex(0x00E676), 0) - b1.set_style_bg_opa(lv.OPA.COVER, 0) - b1.set_style_radius(10, 0) - b1.set_style_shadow_width(0, 0) - b1.set_style_border_width(0, 0) - l1 = lv.label(b1) - l1.set_text("Beep") - l1.set_style_text_font(lv.font_montserrat_14, 0) - l1.set_style_text_color(lv.color_hex(0x000000), 0) - l1.center() - b1.add_event_cb(self._on_beep, lv.EVENT.CLICKED, None) - - # On / Off - self._buz_state = False - b2 = lv.btn(self.screen) - b2.set_size(bw, bh) - b2.set_pos(ox + bw + gap, by) - b2.set_style_bg_color(lv.color_hex(0x334466), 0) - b2.set_style_bg_opa(lv.OPA.COVER, 0) - b2.set_style_radius(10, 0) - b2.set_style_shadow_width(0, 0) - b2.set_style_border_width(0, 0) - self._buz_lbl = lv.label(b2) - self._buz_lbl.set_text("Turn On") - self._buz_lbl.set_style_text_font(lv.font_montserrat_14, 0) - self._buz_lbl.set_style_text_color(lv.color_hex(0x2C2C46), 0) - self._buz_lbl.center() - b2.add_event_cb(self._on_toggle, lv.EVENT.CLICKED, None) - - self._refs.extend((card, b1, l1, b2, self._buz_lbl)) - - # 底部提示 - hint = lv.label(self.screen) - hint.set_text("Beep = short tone On/Off = sustained") - hint.set_style_text_font(lv.font_montserrat_14, 0) - hint.set_style_text_color(lv.color_hex(0x666666), 0) - hint.set_pos((self._sw - 230) // 2, by + bh + 10) - self._refs.append(hint) - - def _set_status(self, text, col): - self._buz_status.set_text(text) - self._buz_status.set_style_text_color(lv.color_hex(col), 0) - - def _beep_on(self): - if self._buz: - try: - self._buz.write(1) - except Exception: - pass - - def _beep_off(self): - if self._buz: - try: - self._buz.write(0) - except Exception: - pass - - def _on_beep(self, e=None): - """短响 150ms(主线程事件回调里做,不开子线程)。""" - print("[BUZZER] beep") - self._beep_on() - self._set_status("Beeping...", 0xFF9800) - utime.sleep_ms(150) - self._beep_off() - self._set_status("Ready" if not self._buz_state else "Sustained ON", 0x00E676) - - def _on_toggle(self, e=None): - self._buz_state = not self._buz_state - if self._buz_state: - self._buz_lbl.set_text("Turn Off") - self._beep_on() - self._set_status("Sustained ON", 0xFF5252) - else: - self._buz_lbl.set_text("Turn On") - self._beep_off() - self._set_status("Ready", 0x00E676) - - def _on_back(self, e=None): - self._beep_off() # 离开页面确保不响 - super()._on_back(e) - - -class WeatherPage(AppPage): - """天气:展示卡片式界面。真实数据需联网 + HTTP API(下一步接),现为占位布局。""" - def _create_content(self): - base_y = self.content_y - w = self._sw - - # ---- 主卡片:当前天气 ---- - card = lv.obj(self.screen) - card.remove_style_all() - card.set_size(w - 24, 150) - card.set_pos(12, base_y + 4) - card.set_style_bg_color(lv.color_hex(0x16213E), 0) - card.set_style_bg_opa(lv.OPA.COVER, 0) - card.set_style_radius(12, 0) - card.set_style_pad_all(0, 0) - card.set_style_border_width(0, 0) - card.clear_flag(lv.obj.FLAG.CLICKABLE) - self._refs.append(card) - - # 城市 - city = lv.label(card) - city.set_text("Shanghai") - city.set_style_text_font(lv.font_montserrat_14, 0) - city.set_style_text_color(lv.color_hex(0xAAAAAA), 0) - city.set_pos(20, 14) - self._refs.append(city) - - # 大温度 - temp = lv.label(card) - temp.set_text("--℃") - temp.set_style_text_font(lv.font_montserrat_14, 0) - temp.set_style_text_color(lv.color_hex(0x00E676), 0) - temp.set_pos(20, 44) - self._refs.append(temp) - - # 天气描述 - desc = lv.label(card) - desc.set_text("--") - desc.set_style_text_font(lv.font_montserrat_14, 0) - desc.set_style_text_color(lv.color_hex(0x2C2C46), 0) - desc.set_pos(20, 110) - self._refs.append(desc) - - # 右上角"图标"占位(云/太阳用文字近似) - ic = lv.label(card) - ic.set_text("~") - ic.set_style_text_font(lv.font_montserrat_14, 0) - ic.set_style_text_color(lv.color_hex(0xFFEB3B), 0) - ic.set_pos(w - 24 - 40, 14) - self._refs.append(ic) - - # ---- 三个小信息卡:湿度 / 风速 / 体感 ---- - info_y = base_y + 162 - info_h = 60 - gap = 8 - cw = (w - 24 - 2 * gap) // 3 - infos = (("Humidity", "-- %", 0x2196F3), - ("Wind", "-- km/h", 0x9C27B0), - ("Feels", "--℃", 0xFF9800)) - for i, (cap, val, col) in enumerate(infos): - x = 12 + i * (cw + gap) - c = lv.obj(self.screen) - c.remove_style_all() - c.set_size(cw, info_h) - c.set_pos(x, info_y) - c.set_style_bg_color(lv.color_hex(0x16213E), 0) - c.set_style_bg_opa(lv.OPA.COVER, 0) - c.set_style_radius(8, 0) - c.set_style_pad_all(0, 0) - c.set_style_border_width(0, 0) - c.clear_flag(lv.obj.FLAG.CLICKABLE) - cap_lbl = lv.label(c) - cap_lbl.set_text(cap) - cap_lbl.set_style_text_font(lv.font_montserrat_14, 0) - cap_lbl.set_style_text_color(lv.color_hex(0x888888), 0) - cap_lbl.set_pos(8, 6) - val_lbl = lv.label(c) - val_lbl.set_text(val) - val_lbl.set_style_text_font(lv.font_montserrat_14, 0) - val_lbl.set_style_text_color(lv.color_hex(col), 0) - val_lbl.set_pos(8, 30) - self._refs.extend((c, cap_lbl, val_lbl)) - - # 底部提示 - hint = lv.label(self.screen) - hint.set_text("Demo layout — live data needs network + HTTP API") - hint.set_style_text_font(lv.font_montserrat_14, 0) - hint.set_style_text_color(lv.color_hex(0x555555), 0) - hint.set_pos((w - 260) // 2, info_y + info_h + 8) - self._refs.append(hint) - - -# ======================================================= -# 游戏二级界面(主桌面点 Game 进来,再选具体游戏) -# ======================================================= - -class GameHubPage(AppPage): - """游戏二级界面:自带标题栏(返回主界面) + 3 个游戏图标。 - 点具体游戏 -> 打开该游戏页,游戏页的返回 -> 回到本二级界面。""" - GAMES = ( - ("U:/icons/demo-snake-game.png", "Snake"), - ("U:/icons/demo-2048.png", "2048"), - ("U:/icons/demo-tetris.png", "Tetris"), - ) - - def _create_content(self): - self._sub_page = None - n = len(self.GAMES) - block_w = 120 - icon_size = 56 - ox = (self._sw - n * block_w) // 2 # 整体水平居中 - cy = self.content_y + (self.content_h - (icon_size + 24)) // 2 - - for i, (icon_path, name) in enumerate(self.GAMES): - bx = ox + i * block_w + (block_w - icon_size) // 2 - btn = lv.btn(self.screen) - btn.set_size(icon_size, icon_size) - btn.set_pos(bx, cy) - btn.set_style_bg_opa(lv.OPA.TRANSP, 0) - btn.set_style_shadow_width(0, 0) - btn.set_style_border_width(0, 0) - btn.set_style_radius(8, 0) - btn.set_style_pad_all(0, 0) - btn.set_style_bg_opa(lv.OPA.COVER, lv.STATE.PRESSED) - btn.set_style_bg_color(lv.color_hex(0x334466), lv.STATE.PRESSED) - ic = lv.img(btn) - ic.set_src(icon_path) - ic.center() - lbl = lv.label(self.screen) - lbl.set_text(name) - lbl.set_style_text_font(lv.font_montserrat_14, 0) - lbl.set_style_text_color(lv.color_hex(0xCCCCCC), 0) - lbl.set_pos(bx + (icon_size - 44) // 2, cy + icon_size + 4) - btn.add_event_cb(lambda e, nm=name: self._open_game(nm), - lv.EVENT.CLICKED, None) - self._refs.extend((btn, ic, lbl)) - - def _open_game(self, name): - """打开具体游戏;游戏的返回 -> 回到本二级界面。""" - print("[NAV] open game:", name) - page_cls = PAGE_MAP.get(name) - if page_cls is None: - return - self._sub_page = page_cls(self._sw, self._sh, name, self._back_to_hub) - lv.scr_load(self._sub_page.screen) - - def _back_to_hub(self, e=None): - lv.scr_load(self.screen) - self._sub_page = None - - -# ======================================================= -# 通信协议二级界面(主桌面点 Comm 进来,再选具体协议) -# ======================================================= - -class CommHubPage(AppPage): - """通信协议二级界面:自带标题栏(返回主界面) + 4 个协议图标。 - 点具体图标 -> 打开对应页面,子页的返回 -> 回到本二级界面。""" - PROTOCOLS = ( - ("U:/icons/new-uart.png", "UART"), - ("U:/icons/new-can.png", "CAN"), - ("U:/icons/new-at.png", "AT"), - ("U:/icons/new-rs485.png", "RS485"), - ) - - def _create_content(self): - self._sub_page = None - n = len(self.PROTOCOLS) - block_w = 96 - icon_size = 56 - ox = (self._sw - n * block_w) // 2 - cy = self.content_y + (self.content_h - (icon_size + 24)) // 2 - - for i, (icon_path, name) in enumerate(self.PROTOCOLS): - bx = ox + i * block_w + (block_w - icon_size) // 2 - btn = lv.btn(self.screen) - btn.set_size(icon_size, icon_size) - btn.set_pos(bx, cy) - btn.set_style_bg_opa(lv.OPA.TRANSP, 0) - btn.set_style_shadow_width(0, 0) - btn.set_style_border_width(0, 0) - btn.set_style_radius(8, 0) - btn.set_style_pad_all(0, 0) - btn.set_style_bg_opa(lv.OPA.COVER, lv.STATE.PRESSED) - btn.set_style_bg_color(lv.color_hex(0x334466), lv.STATE.PRESSED) - ic = lv.img(btn) - ic.set_src(icon_path) - ic.center() - lbl = lv.label(self.screen) - lbl.set_text(name) - lbl.set_style_text_font(lv.font_montserrat_14, 0) - lbl.set_style_text_color(lv.color_hex(0xCCCCCC), 0) - lbl.set_pos(bx + (icon_size - 44) // 2, cy + icon_size + 4) - btn.add_event_cb(lambda e, nm=name: self._open_proto(nm), - lv.EVENT.CLICKED, None) - self._refs.extend((btn, ic, lbl)) - - def _open_proto(self, name): - """打开具体协议页面;返回 -> 回到本二级界面。""" - print("[NAV] open proto:", name) - page_cls = PAGE_MAP.get(name) - if page_cls is None: - return - self._sub_page = page_cls(self._sw, self._sh, name, self._back_to_hub) - lv.scr_load(self._sub_page.screen) - - def _back_to_hub(self, e=None): - lv.scr_load(self.screen) - self._sub_page = None - - -# ======================================================= -# 各协议子页面(UART / AT / RS485) -# ======================================================= - -class UartPage(AppPage): - """UART 串口终端(带键盘输入,真实收发)。""" - KB_H = 128 - MAX_LOG = 6 - # UART 配置 - UART_PORT = 2 # UART2 - UART_BAUD = 115200 - UART_BITS = 8 - UART_PARITY = 0 - UART_STOP = 1 - UART_FLOW = 0 # 0=无流控 - - def _create_content(self): - base_y = self.content_y - w = self._sw - kb_y = self._sh - self.KB_H - - bar = lv.obj(self.screen) - bar.remove_style_all() - bar.set_size(w, 17) - bar.set_pos(0, base_y) - bar.set_style_bg_color(lv.color_hex(0x16213E), 0) - bar.set_style_bg_opa(lv.OPA.COVER, 0) - self._refs.append(bar) - - cfg = "UART{}:{}".format(self.UART_PORT, self.UART_BAUD) - self._status_lbl = lv.label(self.screen) - self._status_lbl.set_text(cfg) - self._status_lbl.set_style_text_font(lv.font_montserrat_14, 0) - self._status_lbl.set_style_text_color(lv.color_hex(0x00E676), 0) - self._status_lbl.set_pos(6, base_y + 1) - self._refs.append(self._status_lbl) - - # 日志区 - log_y = base_y + 17 - log_h = 100 - term = lv.obj(self.screen) - term.remove_style_all() - term.set_size(w, log_h) - term.set_pos(0, log_y) - term.set_style_bg_color(lv.color_hex(0x0D1117), 0) - term.set_style_bg_opa(lv.OPA.COVER, 0) - term.set_style_pad_all(0, 0) - term.clear_flag(lv.obj.FLAG.SCROLLABLE) - self._refs.append(term) - - self._log_lines = [] - self._log_labels = [] - self._log_y = log_y + 2 - self._line_h = 16 - - # 输入区 - ta_y = log_y + log_h + 2 - ta_h = kb_y - ta_y - self._ta = lv.textarea(self.screen) - self._ta.set_size(w - 12, ta_h) - self._ta.set_pos(6, ta_y) - self._ta.set_style_bg_color(lv.color_hex(0x16213E), 0) - self._ta.set_style_bg_opa(lv.OPA.COVER, 0) - self._ta.set_style_radius(6, 0) - self._ta.set_style_border_width(1, 0) - self._ta.set_style_border_color(lv.color_hex(0x334466), 0) - self._ta.set_style_text_color(lv.color_hex(0x2C2C46), 0) - self._ta.set_style_text_font(lv.font_montserrat_14, 0) - self._ta.set_placeholder_text("> send hex or text ...") - self._refs.append(self._ta) - - by = ta_y + 2 - self._mk_send_btn(w - 122, by, 52, "Send", 0x00E676, 0x000000) - self._mk_clear_btn(w - 64, by, 52, "Clear", 0x334466, 0xFFFFFF) - - kb = lv.keyboard(self.screen) - kb.set_textarea(self._ta) - kb.set_size(w, self.KB_H) - kb.set_style_bg_color(lv.color_hex(0x0F3460), 0) - kb.set_style_bg_opa(lv.OPA.COVER, 0) - kb.set_style_bg_color(lv.color_hex(0x334466), lv.PART.ITEMS) - kb.set_style_text_color(lv.color_hex(0x2C2C46), lv.PART.ITEMS) - kb.set_style_min_height(0, lv.PART.ITEMS) - self._kb = kb - self._refs.append(kb) - try: - self.screen.update_layout() - except Exception: - pass - kb.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) - def _pin_kb(t): - kb.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) - self._kb_pin_timer = lv.timer_create(_pin_kb, 250, None) - - # 初始化 UART - self._uart = None - try: - from machine import UART - self._uart = UART(self.UART_PORT, self.UART_BAUD, - self.UART_BITS, self.UART_PARITY, - self.UART_STOP, self.UART_FLOW) - self._status_lbl.set_text("{} Open".format(cfg)) - self._status_lbl.set_style_text_color(lv.color_hex(0x00E676), 0) - except Exception as ex: - self._add_log("ERR", "UART init: {}".format(ex), 0xFF5252) - self._status_lbl.set_style_text_color(lv.color_hex(0xFF5252), 0) - - # 启动 RX 轮询 - self._rx_active = True - self._rx_timer = lv.timer_create(self._poll_rx, 200, None) - - # ---------- 轮询接收 ---------- - - def _poll_rx(self, timer): - if not self._rx_active or self._uart is None: - return - try: - n = self._uart.any() - if n > 0: - data = self._uart.read(min(n, 128)) - if data: - hex_str = " ".join("{:02X}".format(b) for b in data) - self._add_log("RX<", hex_str, 0x4CAF50) - except Exception: - pass - - # ---------- Send ---------- - - def _mk_send_btn(self, x, y, w, text, bg, fg): - btn = lv.btn(self.screen) - btn.set_size(w, 24) - btn.set_pos(x, y) - btn.set_style_bg_color(lv.color_hex(bg), 0) - btn.set_style_bg_opa(lv.OPA.COVER, 0) - btn.set_style_radius(3, 0) - btn.set_style_shadow_width(0, 0) - btn.set_style_border_width(0, 0) - btn.set_style_pad_all(0, 0) - btn.add_event_cb(lambda e: self._do_send(), lv.EVENT.CLICKED, None) - lbl = lv.label(btn) - lbl.set_text(text) - lbl.set_style_text_font(lv.font_montserrat_14, 0) - lbl.set_style_text_color(lv.color_hex(fg), 0) - lbl.center() - self._refs.extend((btn, lbl)) - - def _do_send(self): - if self._uart is None: - self._add_log("ERR", "UART not open", 0xFF5252) - return - text = self._ta.get_text() - if not text: - return - try: - data = text.encode('utf-8') - self._uart.write(data) - hex_str = " ".join("{:02X}".format(b) for b in data) - self._add_log("TX>", hex_str, 0x00E676) - except Exception as ex: - self._add_log("ERR", str(ex), 0xFF5252) - self._ta.set_text("") - - # ---------- Clear ---------- - - def _mk_clear_btn(self, x, y, w, text, bg, fg): - btn = lv.btn(self.screen) - btn.set_size(w, 24) - btn.set_pos(x, y) - btn.set_style_bg_color(lv.color_hex(bg), 0) - btn.set_style_bg_opa(lv.OPA.COVER, 0) - btn.set_style_radius(3, 0) - btn.set_style_shadow_width(0, 0) - btn.set_style_border_width(0, 0) - btn.set_style_pad_all(0, 0) - btn.add_event_cb(lambda e: self._do_clear(), lv.EVENT.CLICKED, None) - lbl = lv.label(btn) - lbl.set_text(text) - lbl.set_style_text_font(lv.font_montserrat_14, 0) - lbl.set_style_text_color(lv.color_hex(fg), 0) - lbl.center() - self._refs.extend((btn, lbl)) - - def _do_clear(self): - self._log_lines = [] - for lbl in self._log_labels: - try: - lbl.delete() - except Exception: - pass - self._log_labels = [] - - # ---------- 日志 ---------- - - def _add_log(self, tag, text, color): - self._log_lines.append((tag, text, color)) - if len(self._log_lines) > self.MAX_LOG: - self._log_lines.pop(0) - self._render_log() - - def _render_log(self): - for lbl in self._log_labels: - try: - lbl.delete() - except Exception: - pass - self._log_labels = [] - for i, (tag, text, color) in enumerate(self._log_lines): - ry = self._log_y + i * self._line_h - tl = lv.label(self.screen) - tl.set_text(tag) - tl.set_style_text_font(lv.font_montserrat_14, 0) - tl.set_style_text_color(lv.color_hex(color), 0) - tl.set_pos(4, ry) - self._log_labels.append(tl) - cl = lv.label(self.screen) - cl.set_text(text[:40]) - cl.set_style_text_font(lv.font_montserrat_14, 0) - cl.set_style_text_color(lv.color_hex(0xCCCCCC), 0) - cl.set_pos(44, ry) - self._log_labels.append(cl) - - def _on_back(self, e=None): - self._rx_active = False - utime.sleep_ms(300) - super()._on_back(e) - - -class AtPage(AppPage): - """AT 命令交互终端(带键盘输入,真实 AT 收发)。""" - KB_H = 128 - MAX_LOG = 6 - - def _create_content(self): - base_y = self.content_y - w = self._sw - kb_y = self._sh - self.KB_H - - bar = lv.obj(self.screen) - bar.remove_style_all() - bar.set_size(w, 17) - bar.set_pos(0, base_y) - bar.set_style_bg_color(lv.color_hex(0x16213E), 0) - bar.set_style_bg_opa(lv.OPA.COVER, 0) - self._refs.append(bar) - - self._status_lbl = lv.label(self.screen) - self._status_lbl.set_text("Modem: Ready") - self._status_lbl.set_style_text_font(lv.font_montserrat_14, 0) - self._status_lbl.set_style_text_color(lv.color_hex(0x00E676), 0) - self._status_lbl.set_pos(6, base_y + 1) - self._refs.append(self._status_lbl) - - log_y = base_y + 17 - log_h = 100 - term = lv.obj(self.screen) - term.remove_style_all() - term.set_size(w, log_h) - term.set_pos(0, log_y) - term.set_style_bg_color(lv.color_hex(0x0D1117), 0) - term.set_style_bg_opa(lv.OPA.COVER, 0) - term.set_style_pad_all(0, 0) - term.clear_flag(lv.obj.FLAG.SCROLLABLE) - self._refs.append(term) - - # 动态日志行(最多 MAX_LOG 条) - self._log_lines = [] # [(tag, text, color), ...] - self._log_labels = [] # lv.label references - self._log_y = log_y + 2 - self._line_h = 16 - - ta_y = log_y + log_h + 2 - ta_h = kb_y - ta_y - self._ta = lv.textarea(self.screen) - self._ta.set_size(w - 12, ta_h) - self._ta.set_pos(6, ta_y) - self._ta.set_style_bg_color(lv.color_hex(0x16213E), 0) - self._ta.set_style_bg_opa(lv.OPA.COVER, 0) - self._ta.set_style_radius(6, 0) - self._ta.set_style_border_width(1, 0) - self._ta.set_style_border_color(lv.color_hex(0x334466), 0) - self._ta.set_style_text_color(lv.color_hex(0x2C2C46), 0) - self._ta.set_style_text_font(lv.font_montserrat_14, 0) - self._ta.set_placeholder_text("> AT+") - self._refs.append(self._ta) - - by = ta_y + 2 - self._mk_send_btn(w - 122, by, 52, "Send", 0xFF9800, 0x000000) - self._mk_clear_btn(w - 64, by, 52, "Clear", 0x334466, 0xFFFFFF) - - kb = lv.keyboard(self.screen) - kb.set_textarea(self._ta) - kb.set_size(w, self.KB_H) - kb.set_style_bg_color(lv.color_hex(0x0F3460), 0) - kb.set_style_bg_opa(lv.OPA.COVER, 0) - kb.set_style_bg_color(lv.color_hex(0x334466), lv.PART.ITEMS) - kb.set_style_text_color(lv.color_hex(0x2C2C46), lv.PART.ITEMS) - kb.set_style_min_height(0, lv.PART.ITEMS) - self._kb = kb - self._refs.append(kb) - try: - self.screen.update_layout() - except Exception: - pass - kb.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) - def _pin_kb(t): - kb.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) - self._kb_pin_timer = lv.timer_create(_pin_kb, 250, None) - - # ---------- Send ---------- - - def _mk_send_btn(self, x, y, w, text, bg, fg): - btn = lv.btn(self.screen) - btn.set_size(w, 24) - btn.set_pos(x, y) - btn.set_style_bg_color(lv.color_hex(bg), 0) - btn.set_style_bg_opa(lv.OPA.COVER, 0) - btn.set_style_radius(3, 0) - btn.set_style_shadow_width(0, 0) - btn.set_style_border_width(0, 0) - btn.set_style_pad_all(0, 0) - btn.add_event_cb(lambda e: self._do_send(), lv.EVENT.CLICKED, None) - lbl = lv.label(btn) - lbl.set_text(text) - lbl.set_style_text_font(lv.font_montserrat_14, 0) - lbl.set_style_text_color(lv.color_hex(fg), 0) - lbl.center() - self._refs.extend((btn, lbl)) - - def _do_send(self): - cmd = self._ta.get_text() - if not cmd: - return - # 确保以 \r\n 结尾 - if not cmd.endswith("\r\n"): - cmd += "\r\n" - self._add_log("TX>", cmd.strip(), 0x00E676) - self._status_lbl.set_text("Modem: Sending...") - self._status_lbl.set_style_text_color(lv.color_hex(0xFF9800), 0) - - try: - import atcmd - resp = bytearray(256) - ret = atcmd.sendSync(cmd, resp, '', 10) - if ret == 0: - text = resp.decode('utf-8', 'ignore').strip() - if text: - for line in text.split('\r\n'): - line = line.strip() - if line and line not in ('OK',): - self._add_log("RX<", line, 0x4CAF50) - self._status_lbl.set_text("Modem: OK") - self._status_lbl.set_style_text_color(lv.color_hex(0x00E676), 0) - else: - self._add_log("ERR", "Send failed (ret={})".format(ret), 0xFF5252) - self._status_lbl.set_text("Modem: Error") - self._status_lbl.set_style_text_color(lv.color_hex(0xFF5252), 0) - except Exception as ex: - self._add_log("ERR", str(ex), 0xFF5252) - self._status_lbl.set_text("Modem: Exception") - self._status_lbl.set_style_text_color(lv.color_hex(0xFF5252), 0) - - self._ta.set_text("") - - # ---------- Clear ---------- - - def _mk_clear_btn(self, x, y, w, text, bg, fg): - btn = lv.btn(self.screen) - btn.set_size(w, 24) - btn.set_pos(x, y) - btn.set_style_bg_color(lv.color_hex(bg), 0) - btn.set_style_bg_opa(lv.OPA.COVER, 0) - btn.set_style_radius(3, 0) - btn.set_style_shadow_width(0, 0) - btn.set_style_border_width(0, 0) - btn.set_style_pad_all(0, 0) - btn.add_event_cb(lambda e: self._do_clear(), lv.EVENT.CLICKED, None) - lbl = lv.label(btn) - lbl.set_text(text) - lbl.set_style_text_font(lv.font_montserrat_14, 0) - lbl.set_style_text_color(lv.color_hex(fg), 0) - lbl.center() - self._refs.extend((btn, lbl)) - - def _do_clear(self): - """清空日志区域。""" - self._log_lines = [] - for lbl in self._log_labels: - try: - lbl.delete() - except Exception: - pass - self._log_labels = [] - - # ---------- 日志显示 ---------- - - def _add_log(self, tag, text, color): - """追加一行日志到显示区,超出 MAX_LOG 则滚动。""" - self._log_lines.append((tag, text, color)) - if len(self._log_lines) > self.MAX_LOG: - self._log_lines.pop(0) - self._render_log() - - def _render_log(self): - """根据 _log_lines 重建日志标签。""" - # 清除旧标签 - for lbl in self._log_labels: - try: - lbl.delete() - except Exception: - pass - self._log_labels = [] - - for i, (tag, text, color) in enumerate(self._log_lines): - ry = self._log_y + i * self._line_h - # tag - tl = lv.label(self.screen) - tl.set_text(tag) - tl.set_style_text_font(lv.font_montserrat_14, 0) - tl.set_style_text_color(lv.color_hex(color), 0) - tl.set_pos(4, ry) - self._log_labels.append(tl) - # content - cl = lv.label(self.screen) - cl.set_text(text[:36]) - cl.set_style_text_font(lv.font_montserrat_14, 0) - cl.set_style_text_color(lv.color_hex(0xCCCCCC), 0) - cl.set_pos(44, ry) - self._log_labels.append(cl) - - -class Rs485Page(AppPage): - """RS485 差分总线监控终端(带键盘输入)。""" - KB_H = 128 - - def _create_content(self): - base_y = self.content_y - w = self._sw - kb_y = self._sh - self.KB_H - - bar = lv.obj(self.screen) - bar.remove_style_all() - bar.set_size(w, 17) - bar.set_pos(0, base_y) - bar.set_style_bg_color(lv.color_hex(0x16213E), 0) - bar.set_style_bg_opa(lv.OPA.COVER, 0) - self._refs.append(bar) - - for tx, t, col in ((6, "RS485:9600-8-N-1 A/B", 0x00BCD4),): - lbl = lv.label(self.screen) - lbl.set_text(t) - lbl.set_style_text_font(lv.font_montserrat_14, 0) - lbl.set_style_text_color(lv.color_hex(col), 0) - lbl.set_pos(tx, base_y + 1) - self._refs.append(lbl) - - log_y = base_y + 17 - log_h = 100 - term = lv.obj(self.screen) - term.remove_style_all() - term.set_size(w, log_h) - term.set_pos(0, log_y) - term.set_style_bg_color(lv.color_hex(0x0D1117), 0) - term.set_style_bg_opa(lv.OPA.COVER, 0) - term.set_style_pad_all(0, 0) - term.clear_flag(lv.obj.FLAG.SCROLLABLE) - self._refs.append(term) - - rows = (("TX>", "[01][03][00][00][00][01][84][0A]", 0x00BCD4), - ("RX<", "[01][03][02][00][7F][F8][50]", 0x4CAF50), - ("TX>", "[02][06][00][01][00][03][98][7B]", 0x00BCD4), - ("RX<", "[02][06][00][01][00][03][98][7B]", 0x4CAF50), - ("---", "Bus Idle", 0x888888), - ("TX>", "[03][03][00][02][00][02][65][ED]", 0x00BCD4)) - line_h = 17 - for i, (tag, data, col) in enumerate(rows): - ry = log_y + 3 + i * line_h - for tx, txt, c in ((4, tag, col), (44, data, 0xCCCCCC)): - lbl = lv.label(self.screen) - lbl.set_text(txt) - lbl.set_style_text_font(lv.font_montserrat_14, 0) - lbl.set_style_text_color(lv.color_hex(c), 0) - lbl.set_pos(tx, ry) - self._refs.append(lbl) - - ta_y = log_y + log_h + 2 - ta_h = kb_y - ta_y - self._ta = lv.textarea(self.screen) - self._ta.set_size(w - 12, ta_h) - self._ta.set_pos(6, ta_y) - self._ta.set_style_bg_color(lv.color_hex(0x16213E), 0) - self._ta.set_style_bg_opa(lv.OPA.COVER, 0) - self._ta.set_style_radius(6, 0) - self._ta.set_style_border_width(1, 0) - self._ta.set_style_border_color(lv.color_hex(0x334466), 0) - self._ta.set_style_text_color(lv.color_hex(0x2C2C46), 0) - self._ta.set_style_text_font(lv.font_montserrat_14, 0) - self._ta.set_placeholder_text("> Modbus RTU / RAW ...") - self._refs.append(self._ta) - - by = ta_y + 2 - self._mk_cmd_btn(w - 122, by, 52, "Send", 0x00BCD4, 0x000000) - self._mk_cmd_btn(w - 64, by, 52, "Clear", 0x334466, 0xFFFFFF) - - kb = lv.keyboard(self.screen) - kb.set_textarea(self._ta) - kb.set_size(w, self.KB_H) - kb.set_style_bg_color(lv.color_hex(0x0F3460), 0) - kb.set_style_bg_opa(lv.OPA.COVER, 0) - kb.set_style_bg_color(lv.color_hex(0x334466), lv.PART.ITEMS) - kb.set_style_text_color(lv.color_hex(0x2C2C46), lv.PART.ITEMS) - kb.set_style_min_height(0, lv.PART.ITEMS) - self._kb = kb - self._refs.append(kb) - try: - self.screen.update_layout() - except Exception: - pass - kb.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) - def _pin_kb(t): - kb.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) - self._kb_pin_timer = lv.timer_create(_pin_kb, 250, None) - - def _mk_cmd_btn(self, x, y, w, text, bg, fg): - btn = lv.btn(self.screen) - btn.set_size(w, 24) - btn.set_pos(x, y) - btn.set_style_bg_color(lv.color_hex(bg), 0) - btn.set_style_bg_opa(lv.OPA.COVER, 0) - btn.set_style_radius(3, 0) - btn.set_style_shadow_width(0, 0) - btn.set_style_border_width(0, 0) - btn.set_style_pad_all(0, 0) - btn.add_event_cb(lambda e: print("[RS485] Send:", self._ta.get_text()), lv.EVENT.CLICKED, None) - lbl = lv.label(btn) - lbl.set_text(text) - lbl.set_style_text_font(lv.font_montserrat_14, 0) - lbl.set_style_text_color(lv.color_hex(fg), 0) - lbl.center() - self._refs.extend((btn, lbl)) - - -# ======================================================= -# 以太网二级界面 + 子页面 -# ======================================================= - -class EthHubPage(AppPage): - """以太网二级界面:3 个功能入口。""" - ITEMS = ( - ("U:/icons/new-eth-info.png", "IP"), - ("U:/icons/new-eth-ping.png", "PING"), - ("U:/icons/new-eth-tcp.png", "TCP"), - ) - - def _create_content(self): - self._sub_page = None - n = len(self.ITEMS) - block_w = 120 - icon_size = 56 - ox = (self._sw - n * block_w) // 2 - cy = self.content_y + (self.content_h - (icon_size + 24)) // 2 - - for i, (icon_path, name) in enumerate(self.ITEMS): - bx = ox + i * block_w + (block_w - icon_size) // 2 - btn = lv.btn(self.screen) - btn.set_size(icon_size, icon_size) - btn.set_pos(bx, cy) - btn.set_style_bg_opa(lv.OPA.TRANSP, 0) - btn.set_style_shadow_width(0, 0) - btn.set_style_border_width(0, 0) - btn.set_style_radius(8, 0) - btn.set_style_pad_all(0, 0) - btn.set_style_bg_opa(lv.OPA.COVER, lv.STATE.PRESSED) - btn.set_style_bg_color(lv.color_hex(0x334466), lv.STATE.PRESSED) - ic = lv.img(btn) - ic.set_src(icon_path) - ic.center() - lbl = lv.label(self.screen) - lbl.set_text(name) - lbl.set_style_text_font(lv.font_montserrat_14, 0) - lbl.set_style_text_color(lv.color_hex(0xCCCCCC), 0) - lbl.set_pos(bx + (icon_size - 44) // 2, cy + icon_size + 4) - btn.add_event_cb(lambda e, nm=name: self._open(nm), - lv.EVENT.CLICKED, None) - self._refs.extend((btn, ic, lbl)) - - def _open(self, name): - print("[NAV] open eth:", name) - page_cls = PAGE_MAP.get(name) - if page_cls is None: - return - self._sub_page = page_cls(self._sw, self._sh, name, self._back_to_hub) - lv.scr_load(self._sub_page.screen) - - def _back_to_hub(self, e=None): - lv.scr_load(self.screen) - self._sub_page = None - - -class EthInfoPage(AppPage): - """网络信息页面(真实以太网初始化)。""" - def _create_content(self): - base_y = self.content_y - w = self._sw - - # 动态值标签(key 固定,value 可更新) - self._info_keys = ("MAC", "IP", "Mask", "GW", "DNS1", "DNS2", "Status") - self._info_vals = {} # key → lv.label - for i, key in enumerate(self._info_keys): - ry = base_y + 4 + i * 26 - kl = lv.label(self.screen) - kl.set_text(key) - kl.set_style_text_font(lv.font_montserrat_14, 0) - kl.set_style_text_color(lv.color_hex(0x888888), 0) - kl.set_pos(16, ry) - self._refs.append(kl) - - vl = lv.label(self.screen) - vl.set_text("...") - vl.set_style_text_font(lv.font_montserrat_14, 0) - vl.set_style_text_color(lv.color_hex(0xCCCCCC), 0) - vl.set_pos(80, ry) - self._refs.append(vl) - self._info_vals[key] = vl - - # 按钮行 - btn_y = base_y + self.content_h - 36 - self._mk_btn(16, btn_y, 72, "Init", 0x1565C0, 0xFFFFFF, self._do_init) - self._mk_btn(96, btn_y, 72, "Refresh", 0x0277BD, 0xFFFFFF, self._do_refresh) - self._mk_btn(176, btn_y, 72, "DHCP", 0x00838F, 0xFFFFFF, self._do_dhcp) - - # 初始化以太网 - self._nic = None - self._do_init() - - # -- 以太网操作 -- - - def _do_init(self): - """初始化 W5500 网卡。""" - try: - import ethernet - mac = b'\x00\x11\x22\x33\x44\x55' - # 使用默认引脚配置(-1 沿用上次/默认值) - self._nic = ethernet.W5500(mac, '', '', '', -1, -1, -1, -1, 0) - self._set_val("Status", "Init OK") - self._do_refresh() - except ImportError: - self._set_val("Status", "No ethernet module") - except Exception as ex: - self._set_val("Status", "Init err: {}".format(ex)) - - def _do_refresh(self): - """读取 ipconfig 并更新显示。""" - if self._nic is None: - return - try: - info = self._nic.ipconfig() - if info and len(info) >= 2: - # info[0] = (mac, hostname) - # info[1] = (iptype, ip, subnet, gateway, dns1, dns2) - mac_host = info[0] - net = info[1] - self._set_val("MAC", mac_host[0]) - self._set_val("IP", net[1] if net[1] else "(none)") - self._set_val("Mask", net[2] if net[2] else "(none)") - self._set_val("GW", net[3] if net[3] else "(none)") - self._set_val("DNS1", net[4] if net[4] else "(none)") - self._set_val("DNS2", net[5] if net[5] else "(none)") - self._set_val("Status", "OK " + mac_host[1]) - except Exception as ex: - self._set_val("Status", "Read err: {}".format(ex)) - - def _do_dhcp(self): - """DHCP 获取 IP。""" - if self._nic is None: - return - try: - self._set_val("Status", "DHCP...") - ret = self._nic.dhcp() - if ret == 0: - self._set_val("Status", "DHCP OK") - self._nic.set_up() - self._do_refresh() - else: - self._set_val("Status", "DHCP fail ({})".format(ret)) - except Exception as ex: - self._set_val("Status", "DHCP err: {}".format(ex)) - - # -- UI 辅助 -- - - def _set_val(self, key, text): - lbl = self._info_vals.get(key) - if lbl: - lbl.set_text(str(text)) - - def _mk_btn(self, x, y, w, text, bg, fg, cb): - btn = lv.btn(self.screen) - btn.set_size(w, 28) - btn.set_pos(x, y) - btn.set_style_bg_color(lv.color_hex(bg), 0) - btn.set_style_bg_opa(lv.OPA.COVER, 0) - btn.set_style_radius(4, 0) - btn.set_style_shadow_width(0, 0) - btn.set_style_border_width(0, 0) - btn.set_style_pad_all(0, 0) - btn.add_event_cb(lambda e: cb(), lv.EVENT.CLICKED, None) - lbl = lv.label(btn) - lbl.set_text(text) - lbl.set_style_text_font(lv.font_montserrat_14, 0) - lbl.set_style_text_color(lv.color_hex(fg), 0) - lbl.center() - self._refs.extend((btn, lbl)) - - -class PingPage(AppPage): - """Ping 网络诊断页面(带键盘输入)。""" - KB_H = 128 - MAX_LOG = 6 - - def _create_content(self): - base_y = self.content_y - w = self._sw - kb_y = self._sh - self.KB_H - - self._status_lbl = lv.label(self.screen) - self._status_lbl.set_text("Ping") - self._status_lbl.set_style_text_font(lv.font_montserrat_14, 0) - self._status_lbl.set_style_text_color(lv.color_hex(0x00E676), 0) - self._status_lbl.set_pos(6, base_y + 4) - self._refs.append(self._status_lbl) - - log_y = base_y + 20 - log_h = kb_y - log_y - 32 - term = lv.obj(self.screen) - term.remove_style_all() - term.set_size(w, log_h) - term.set_pos(0, log_y) - term.set_style_bg_color(lv.color_hex(0x0D1117), 0) - term.set_style_bg_opa(lv.OPA.COVER, 0) - term.set_style_pad_all(0, 0) - term.clear_flag(lv.obj.FLAG.SCROLLABLE) - self._refs.append(term) - - self._log_lines = [] - self._log_labels = [] - self._log_y = log_y + 2 - self._line_h = 16 - - ta_y = log_y + log_h + 2 - ta_h = kb_y - ta_y - self._ta = lv.textarea(self.screen) - self._ta.set_size(w - 122, ta_h) - self._ta.set_pos(6, ta_y) - self._ta.set_style_bg_color(lv.color_hex(0x16213E), 0) - self._ta.set_style_bg_opa(lv.OPA.COVER, 0) - self._ta.set_style_radius(6, 0) - self._ta.set_style_border_width(1, 0) - self._ta.set_style_border_color(lv.color_hex(0x334466), 0) - self._ta.set_style_text_color(lv.color_hex(0x2C2C46), 0) - self._ta.set_style_text_font(lv.font_montserrat_14, 0) - self._ta.set_placeholder_text("> IP or domain ...") - self._ta.set_text("8.8.8.8") - self._refs.append(self._ta) - - by = ta_y + 2 - self._mk_btn(w - 110, by, 48, "Ping", 0x00838F, 0xFFFFFF, self._do_ping) - self._mk_btn(w - 56, by, 48, "Clear", 0x334466, 0xFFFFFF, self._do_clear) - - kb = lv.keyboard(self.screen) - kb.set_textarea(self._ta) - kb.set_size(w, self.KB_H) - kb.set_style_bg_color(lv.color_hex(0x0F3460), 0) - kb.set_style_bg_opa(lv.OPA.COVER, 0) - kb.set_style_bg_color(lv.color_hex(0x334466), lv.PART.ITEMS) - kb.set_style_text_color(lv.color_hex(0x2C2C46), lv.PART.ITEMS) - kb.set_style_min_height(0, lv.PART.ITEMS) - self._kb = kb - self._refs.append(kb) - try: - self.screen.update_layout() - except Exception: - pass - kb.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) - def _pin_kb(t): - kb.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) - self._kb_pin_timer = lv.timer_create(_pin_kb, 250, None) - - def _do_ping(self): - target = self._ta.get_text().strip() - if not target: - return - self._add_log("PING", target, 0x00BCD4) - self._status_lbl.set_text("Pinging...") - try: - import uping - # uping.ping(target, count=4) 返回 (sent, received, times) - result = uping.ping(target, 4, 1000) - if result: - sent, recv, times = result - self._add_log("DONE", "{}/{} received, {}ms".format(recv, sent, times[0] if times else "?"), 0x4CAF50) - self._status_lbl.set_text("{}: {}/{} ok".format(target, recv, sent)) - else: - self._add_log("FAIL", "no response", 0xFF5252) - self._status_lbl.set_text("{}: timeout".format(target)) - except ImportError: - self._add_log("ERR", "uping module not available", 0xFF5252) - self._status_lbl.set_text("uping N/A") - except Exception as ex: - self._add_log("ERR", str(ex), 0xFF5252) - self._status_lbl.set_text("Error") - - def _mk_btn(self, x, y, w, text, bg, fg, cb): - btn = lv.btn(self.screen) - btn.set_size(w, 24) - btn.set_pos(x, y) - btn.set_style_bg_color(lv.color_hex(bg), 0) - btn.set_style_bg_opa(lv.OPA.COVER, 0) - btn.set_style_radius(3, 0) - btn.set_style_shadow_width(0, 0) - btn.set_style_border_width(0, 0) - btn.set_style_pad_all(0, 0) - btn.add_event_cb(lambda e: cb(), lv.EVENT.CLICKED, None) - lbl = lv.label(btn) - lbl.set_text(text) - lbl.set_style_text_font(lv.font_montserrat_14, 0) - lbl.set_style_text_color(lv.color_hex(fg), 0) - lbl.center() - self._refs.extend((btn, lbl)) - - def _do_clear(self): - self._log_lines = [] - for l in self._log_labels: - try: l.delete() - except: pass - self._log_labels = [] - - def _add_log(self, tag, text, color): - self._log_lines.append((tag, text, color)) - if len(self._log_lines) > self.MAX_LOG: - self._log_lines.pop(0) - self._render_log() - - def _render_log(self): - for l in self._log_labels: - try: l.delete() - except: pass - self._log_labels = [] - for i, (tag, text, color) in enumerate(self._log_lines): - ry = self._log_y + i * self._line_h - tl = lv.label(self.screen) - tl.set_text(tag) - tl.set_style_text_font(lv.font_montserrat_14, 0) - tl.set_style_text_color(lv.color_hex(color), 0) - tl.set_pos(4, ry) - self._log_labels.append(tl) - cl = lv.label(self.screen) - cl.set_text(text[:36]) - cl.set_style_text_font(lv.font_montserrat_14, 0) - cl.set_style_text_color(lv.color_hex(0xCCCCCC), 0) - cl.set_pos(54, ry) - self._log_labels.append(cl) - - -class TcpPage(AppPage): - """TCP Client 页面(带键盘输入)。""" - KB_H = 128 - MAX_LOG = 6 - - def _create_content(self): - base_y = self.content_y - w = self._sw - kb_y = self._sh - self.KB_H - - self._status_lbl = lv.label(self.screen) - self._status_lbl.set_text("TCP: Disconnected") - self._status_lbl.set_style_text_font(lv.font_montserrat_14, 0) - self._status_lbl.set_style_text_color(lv.color_hex(0xFF5252), 0) - self._status_lbl.set_pos(6, base_y + 4) - self._refs.append(self._status_lbl) - - log_y = base_y + 20 - log_h = kb_y - log_y - 32 - term = lv.obj(self.screen) - term.remove_style_all() - term.set_size(w, log_h) - term.set_pos(0, log_y) - term.set_style_bg_color(lv.color_hex(0x0D1117), 0) - term.set_style_bg_opa(lv.OPA.COVER, 0) - term.set_style_pad_all(0, 0) - term.clear_flag(lv.obj.FLAG.SCROLLABLE) - self._refs.append(term) - - self._log_lines = [] - self._log_labels = [] - self._log_y = log_y + 2 - self._line_h = 16 - - ta_y = log_y + log_h + 2 - ta_h = kb_y - ta_y - self._ta = lv.textarea(self.screen) - self._ta.set_size(w - 122, ta_h) - self._ta.set_pos(6, ta_y) - self._ta.set_style_bg_color(lv.color_hex(0x16213E), 0) - self._ta.set_style_bg_opa(lv.OPA.COVER, 0) - self._ta.set_style_radius(6, 0) - self._ta.set_style_border_width(1, 0) - self._ta.set_style_border_color(lv.color_hex(0x334466), 0) - self._ta.set_style_text_color(lv.color_hex(0x2C2C46), 0) - self._ta.set_style_text_font(lv.font_montserrat_14, 0) - self._ta.set_placeholder_text("> message ...") - self._refs.append(self._ta) - - by = ta_y + 2 - self._mk_btn(w - 110, by, 48, "Send", 0x2E7D32, 0xFFFFFF, self._do_send) - self._mk_btn(w - 56, by, 48, "Clear", 0x334466, 0xFFFFFF, self._do_clear) - - kb = lv.keyboard(self.screen) - kb.set_textarea(self._ta) - kb.set_size(w, self.KB_H) - kb.set_style_bg_color(lv.color_hex(0x0F3460), 0) - kb.set_style_bg_opa(lv.OPA.COVER, 0) - kb.set_style_bg_color(lv.color_hex(0x334466), lv.PART.ITEMS) - kb.set_style_text_color(lv.color_hex(0x2C2C46), lv.PART.ITEMS) - kb.set_style_min_height(0, lv.PART.ITEMS) - self._kb = kb - self._refs.append(kb) - try: - self.screen.update_layout() - except Exception: - pass - kb.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) - def _pin_kb(t): - kb.align(lv.ALIGN.BOTTOM_LEFT, 0, 0) - self._kb_pin_timer = lv.timer_create(_pin_kb, 250, None) - - self._sock = None - - def _do_send(self): - text = self._ta.get_text() - if not text: - return - self._add_log("TX>", text, 0x2E7D32) - try: - if self._sock is None: - import socket - self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self._sock.settimeout(5) - self._add_log("---", "Connecting...", 0x888888) - self._sock.connect(("192.168.1.1", 8080)) - self._add_log("---", "Connected", 0x4CAF50) - self._status_lbl.set_text("TCP: Connected") - self._status_lbl.set_style_text_color(lv.color_hex(0x00E676), 0) - self._sock.send(text.encode()) - except Exception as ex: - self._add_log("ERR", str(ex), 0xFF5252) - self._sock = None - self._status_lbl.set_text("TCP: Disconnected") - self._status_lbl.set_style_text_color(lv.color_hex(0xFF5252), 0) - self._ta.set_text("") - - def _mk_btn(self, x, y, w, text, bg, fg, cb): - btn = lv.btn(self.screen) - btn.set_size(w, 24) - btn.set_pos(x, y) - btn.set_style_bg_color(lv.color_hex(bg), 0) - btn.set_style_bg_opa(lv.OPA.COVER, 0) - btn.set_style_radius(3, 0) - btn.set_style_shadow_width(0, 0) - btn.set_style_border_width(0, 0) - btn.set_style_pad_all(0, 0) - btn.add_event_cb(lambda e: cb(), lv.EVENT.CLICKED, None) - lbl = lv.label(btn) - lbl.set_text(text) - lbl.set_style_text_font(lv.font_montserrat_14, 0) - lbl.set_style_text_color(lv.color_hex(fg), 0) - lbl.center() - self._refs.extend((btn, lbl)) - - def _do_clear(self): - self._log_lines = [] - for l in self._log_labels: - try: l.delete() - except: pass - self._log_labels = [] - - def _add_log(self, tag, text, color): - self._log_lines.append((tag, text, color)) - if len(self._log_lines) > self.MAX_LOG: - self._log_lines.pop(0) - self._render_log() - - def _render_log(self): - for l in self._log_labels: - try: l.delete() - except: pass - self._log_labels = [] - for i, (tag, text, color) in enumerate(self._log_lines): - ry = self._log_y + i * self._line_h - tl = lv.label(self.screen) - tl.set_text(tag) - tl.set_style_text_font(lv.font_montserrat_14, 0) - tl.set_style_text_color(lv.color_hex(color), 0) - tl.set_pos(4, ry) - self._log_labels.append(tl) - cl = lv.label(self.screen) - cl.set_text(text[:36]) - cl.set_style_text_font(lv.font_montserrat_14, 0) - cl.set_style_text_color(lv.color_hex(0xCCCCCC), 0) - cl.set_pos(54, ry) - self._log_labels.append(cl) - - def _on_back(self, e=None): - if self._sock: - try: - self._sock.close() - except Exception: - pass - self._sock = None - super()._on_back(e) - - -# ======================================================= -# 页面注册表:标签 -> 页面类 -# ======================================================= - -PAGE_MAP = { - "LED": LedPage, - "Game": GameHubPage, - "Clock": ClockPage, - "Weather": WeatherPage, - "Buzzer": BuzzerPage, - "ADC": AdcPage, - "Calc": CalcPage, - "Comm": CommHubPage, - "UART": UartPage, - "CAN": CanPage, - "AT": AtPage, - "RS485": Rs485Page, - "ETH": EthHubPage, - "IP": EthInfoPage, - "PING": PingPage, - "TCP": TcpPage, - "Key": KeyboardPage, - "Camera": CameraPage, - "QR": QrPage, - "Audio": AudioPage, - "USB": UsbPage, - "Phone": PhonePage, - "Snake": SnakePage, - "2048": Game2048Page, - "Tetris": TetrisPage, - "Scan": ScanPage, -} - - -# ======================================================= -# 主屏幕:状态栏 + 5x3 图标网格 -# ======================================================= - -class MainScreen: - """5列x3行应用图标主桌面,含顶部状态栏。""" - - STATUS_BAR_H = 22 - COLS = 5 - ROWS = 3 - CELL_W = 56 - CELL_H = 70 - BTN_PAD = 4 - - APP_GRID = ( - ("U:/icons/demo-led-slider.png", "LED"), - ("U:/icons/demo-clock.png", "Clock"), - ("U:/icons/demo-adc-gauge.png", "ADC"), - ("U:/icons/demo-calculator.png", "Calc"), - ("U:/icons/demo-communication.png", "Comm"), - - ("U:/icons/demo-keyboard.png", "Key"), - ("U:/icons/demo-camera.png", "Camera"), - ("U:/icons/demo-qr-generate.png", "QR"), - ("U:/icons/demo-audio-record.png", "Audio"), - ("U:/icons/demo-usb-browser.png", "USB"), - - ("U:/icons/demo-phone-dialer.png", "Phone"), - ("U:/icons/demo-game.png", "Game"), - ("U:/icons/new-eth.png", "ETH"), - ("U:/icons/task-buzzer.png", "Buzzer"), - ("U:/icons/partly-cloudy-day.png", "Weather"), - ) - - def __init__(self, screen_w, screen_h): - self._sw = screen_w - self._sh = screen_h - self._btn_refs = [] - self._current_page = None - - self.screen = lv.obj() - self.screen.remove_style_all() - self.screen.set_size(screen_w, screen_h) - self.screen.set_style_bg_color(lv.color_hex(0x2C2C46), 0) - self.screen.set_style_bg_opa(lv.OPA.COVER, 0) - self.screen.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) - self.screen.clear_flag(lv.obj.FLAG.SCROLLABLE) # 关掉滚动:避免键盘/输入框聚焦时屏幕滚动把内容挤出 - lv.scr_load(self.screen) - - self._create_status_bar() - self._create_app_grid() - - # -- 状态栏 -- - - def _create_status_bar(self): - bar = lv.obj(self.screen) - bar.remove_style_all() - bar.set_size(self._sw, self.STATUS_BAR_H) - bar.set_pos(0, 0) - bar.set_style_bg_color(lv.color_hex(0x16213E), 0) - bar.set_style_bg_opa(lv.OPA.COVER, 0) - bar.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) - - ic_set = lv.img(bar) - ic_set.set_src("U:/icons/settings.png") - ic_set.set_pos(4, 3) - - self._time_label = lv.label(bar) - t = utime.localtime() - self._time_label.set_text("{:02d}:{:02d}:{:02d}".format(t[3], t[4], t[5])) - self._time_label.set_style_text_color(lv.color_hex(0x2C2C46), 0) - self._time_label.set_style_text_font(lv.font_montserrat_14, 0) - self._time_label.set_pos((self._sw - 60) // 2, 3) - _thread.start_new_thread(self._time_tick, ()) - - ic_wifi = lv.img(bar) - ic_wifi.set_src("U:/icons/wifi.png") - ic_wifi.set_pos(self._sw - 38, 3) - - ic_bat = lv.img(bar) - ic_bat.set_src("U:/icons/battery.png") - ic_bat.set_pos(self._sw - 18, 3) - - # -- 图标网格 -- - - def _create_app_grid(self): - grid_w = self.COLS * self.CELL_W - grid_h = self.ROWS * self.CELL_H - offset_x = (self._sw - grid_w) // 2 - offset_y = self.STATUS_BAR_H + (self._sh - self.STATUS_BAR_H - grid_h) // 2 - - for idx, (icon_path, label_text) in enumerate(self.APP_GRID): - col = idx % self.COLS - row = idx // self.COLS - cx = offset_x + col * self.CELL_W + self.CELL_W // 2 - cy = offset_y + row * self.CELL_H - - btn_size = 48 + self.BTN_PAD - btn = lv.btn(self.screen) - btn.set_size(btn_size, btn_size) - btn.set_pos(cx - btn_size // 2, cy) - btn.set_style_bg_opa(lv.OPA.TRANSP, 0) - btn.set_style_shadow_width(0, 0) - btn.set_style_border_width(0, 0) - btn.set_style_radius(6, 0) - btn.set_style_pad_all(0, 0) - btn.set_style_bg_opa(lv.OPA.COVER, lv.STATE.PRESSED) - btn.set_style_bg_color(lv.color_hex(0x334466), lv.STATE.PRESSED) - - ic = lv.img(btn) - ic.set_src(icon_path) - ic.center() - - btn.add_event_cb( - self._on_icon_clicked, - lv.EVENT.CLICKED, None, - ) - self._btn_refs.append((btn, ic, label_text)) - - def _on_icon_clicked(self, e): - """图标点击事件回调。""" - target = e.get_target() - for btn, _ic, name in self._btn_refs: - if btn == target: - self._open_page(name) - return - - # -- 页面导航 -- - - def _open_page(self, app_name): - """根据 app_name 查找 PAGE_MAP,创建对应页面。""" - print("[NAV] open:", app_name) - page_cls = PAGE_MAP.get(app_name, AppPage) - self._current_page = page_cls( - self._sw, self._sh, app_name, self._go_back - ) - lv.scr_load(self._current_page.screen) - - def _go_back(self, e=None): - """返回主界面,释放子页面。""" - print("[NAV] back to main") - lv.scr_load(self.screen) - self._current_page = None - - def _time_tick(self): - """每秒更新状态栏时间。""" - while True: - utime.sleep_ms(1000) - try: - t = utime.localtime() - self._time_label.set_text( - "{:02d}:{:02d}:{:02d}".format(t[3], t[4], t[5])) - except Exception: - pass - - def set_time(self, text): - self._time_label.set_text(text) - - -# ======================================================= -# LCD + 触摸 + LVGL 驱动(320x240 横屏,自包含) -# ======================================================= - - -class YttriumApp: - """320x240 横屏主应用:内联 LCD 初始化 + LVGL 软件旋转 + ft6x36 触摸。""" - - # -- 屏幕 / 引脚 -- - LCD_PHYS_W = 320 # 面板物理宽(竖屏)—— python_biglcd - LCD_PHYS_H = 480 # 面板物理高(竖屏) - SCREEN_W = 480 # LVGL 逻辑宽(旋转后的横屏) - SCREEN_H = 320 # LVGL 逻辑高 - - # -- 触摸 ft6x36 引脚(按板子原理图,示例:ft6x36(irq=40, reset=39, group=1))-- - PIN_TP_IRQ = 40 # ft6x36 中断引脚 - PIN_TP_RESET = 39 # ft6x36 复位引脚 - TP_I2C_GROUP = 1 # I2C 组号 - # 注:本板背光已默认导通(屏幕能亮),无需单独拉脚; - # GPIO40 是触摸 IRQ,切勿当背光拉成输出,否则触摸失效。 - - # -- 区域参数(硬件旋转后横屏:X:0~479, Y:0~319)-- - XSTART_H = 0x00 - XSTART_L = 0x00 - YSTART_H = 0x00 - YSTART_L = 0x00 - XEND_H = 0x01 - XEND_L = 0xDF # 0x01DF = 479 - YEND_H = 0x01 - YEND_L = 0x3F # 0x013F = 319 - - # -- 面板初始化序列(320x480 ST7796,来自 python_biglcd)-- - LCD_INIT_DATA = bytes(( - 2, 0, 120, - 0, 1, 0xF0, - 1, 1, 0xC3, - 0, 1, 0xF0, - 1, 1, 0x96, - 0, 1, 0x36, - 1, 1, 0x20, # MADCTL: MV(硬件旋转成横屏) - 0, 1, 0x3A, - 1, 1, 0x05, - 0, 1, 0xB0, - 1, 1, 0x80, - 0, 2, 0xB6, - 1, 1, 0x00, - 1, 1, 0x02, - 0, 4, 0xB5, - 1, 1, 0x02, - 1, 1, 0x03, - 1, 1, 0x00, - 1, 1, 0x04, - 0, 2, 0xB1, - 1, 1, 0x80, - 1, 1, 0x10, - 0, 1, 0xB4, - 1, 1, 0x00, - 0, 1, 0xB7, - 1, 1, 0xC6, - 0, 1, 0xC5, - 1, 1, 0x1C, - 0, 1, 0xE4, - 1, 1, 0x31, - 0, 8, 0xE8, - 1, 1, 0x40, - 1, 1, 0x8A, - 1, 1, 0x00, - 1, 1, 0x00, - 1, 1, 0x29, - 1, 1, 0x19, - 1, 1, 0xA5, - 1, 1, 0x33, - 0, 0, 0xC2, - 0, 0, 0xA7, - 0, 14, 0xE0, - 1, 1, 0xF0, 1, 1, 0x09, 1, 1, 0x13, 1, 1, 0x12, 1, 1, 0x12, - 1, 1, 0x2B, 1, 1, 0x3C, 1, 1, 0x44, 1, 1, 0x4B, 1, 1, 0x1B, - 1, 1, 0x18, 1, 1, 0x17, 1, 1, 0x1D, 1, 1, 0x21, - 0, 14, 0xE1, - 1, 1, 0xF0, 1, 1, 0x09, 1, 1, 0x13, 1, 1, 0x0C, 1, 1, 0x0D, - 1, 1, 0x27, 1, 1, 0x3B, 1, 1, 0x44, 1, 1, 0x4D, 1, 1, 0x0B, - 1, 1, 0x17, 1, 1, 0x17, 1, 1, 0x1D, 1, 1, 0x21, - 0, 1, 0xF0, - 1, 1, 0x3C, - 0, 1, 0xF0, - 1, 1, 0x69, - 0, 0, 0x13, - 0, 0, 0x11, - 0, 0, 0x29, - )) - LCD_DISPLAY_ON = bytes((0, 0, 0x11, 2, 0, 20, 0, 0, 0x29)) - LCD_DISPLAY_OFF = bytes((0, 0, 0x28, 2, 0, 120, 0, 0, 0x10)) - LCD_INVALID = bytes(( - 0, 4, 0x2a, - 1, 1, XSTART_H, 1, 1, XSTART_L, 1, 1, XEND_H, 1, 1, XEND_L, - 0, 4, 0x2b, - 1, 1, YSTART_H, 1, 1, YSTART_L, 1, 1, YEND_H, 1, 1, YEND_L, - 0, 0, 0x2c, - )) - - def __init__(self): - self._tp = None - self._init_lcd() - self._init_pins() - self._init_touch() # 真实 ft6x36 触摸 - self._init_lvgl() - self.main_screen = MainScreen(self.SCREEN_W, self.SCREEN_H) - # 不在这里启动渲染子线程:task_handler 改由 __main__ 的主线程循环跑, - # 这样 read_cb/_ft_read 里的 read_xy 就在主线程执行(ft6x36 只在主线程读得到坐标)。 - self._init_sim_touch() # 假指针 indev(REPL 调试用;主线程循环跑时 REPL 不可用) - - # ---------------------- LCD 面板初始化 ---------------------- - def _init_lcd(self): - print("[LCD] init ...") - Pin(Pin.GPIO37, Pin.OUT, Pin.PULL_DISABLE, 1) # LCD_VDD_EN 供电 - self._lcd = LCD() - self._lcd.lcd_init( - self.LCD_INIT_DATA, self.LCD_PHYS_W, self.LCD_PHYS_H, - 26000, 1, 4, 0, # SPI 时钟 26MHz - self.LCD_INVALID, self.LCD_DISPLAY_ON, self.LCD_DISPLAY_OFF, None, - ) - - # ---------------------- 引脚 ---------------------- - def _init_pins(self): - # GPIO37 LCD 供电已在 _init_lcd 置高。 - # 背光本板默认导通(屏幕能亮);GPIO40 是触摸 IRQ、GPIO39 是触摸 RESET, - # 由 ft6x36 自行管理,这里不能动它们,否则触摸失效。 - pass - - # ---------------------- 触摸(ft6x36)---------------------- - def _init_touch(self): - print("[TP] init ft6x36 ...") - try: - # width/height/i2c_mode 是关键(参照 _main(2).py):传物理面板尺寸 + i2c_mode=1 - self._tp = Ft6x36(irq=self.PIN_TP_IRQ, - reset=self.PIN_TP_RESET, - group=self.TP_I2C_GROUP, - width=self.LCD_PHYS_W, height=self.LCD_PHYS_H, - i2c_mode=1) - self._tp.activate() - self._tp.init() - self._tp_lock = _thread.allocate_lock() - print("[TP] ft6x36 ready") - except Exception as e: - print("[TP] init failed(触摸不可用):", e) - self._tp = None - - # ---------------------- LVGL 显示 / 输入驱动 ---------------------- - def _init_lvgl(self): - print("[LVGL] init ...") - lv.init() - - # 显示缓冲(半屏双缓冲,按逻辑横屏 480x320 算,每次刷半屏 = 480x160) - self._disp_buf = lv.disp_draw_buf_t() - # 整屏单缓冲:半屏双缓冲在整屏重绘(切屏)时分上下两半刷、有明显的"半屏切"接缝; - # 整屏一次刷完,切屏干净。总 RAM 与原来两块半屏相同(~300KB)。 - self._buf1 = bytearray(self.SCREEN_W * self.SCREEN_H * 2) - self._disp_buf.init(self._buf1, None, len(self._buf1) // 2) - - # 显示驱动:硬件旋转后逻辑横屏 480x320,LVGL 不做软件旋转 - self._disp_drv = lv.disp_drv_t() - self._disp_drv.init() - self._disp_drv.draw_buf = self._disp_buf - self._disp_drv.flush_cb = self._lcd.lcd_write - self._disp_drv.hor_res = self.SCREEN_W # 480(逻辑横屏) - self._disp_drv.ver_res = self.SCREEN_H # 320 - self._disp_drv.sw_rotate = 0 - self._disp_drv.register() - - # 触摸输入设备:read_cb 用 _ft_read(read_xy + 旋转)。 - # 现在没有手动渲染线程了,只有 lvgl 自己的内部线程调 task_handler,不会有两个线程抢 task_handler - # 的冲突;read_xy 在这个内部线程里跑(和主线程一样是“对的上下文”)。 - if self._tp is not None: - self._indev_drv = lv.indev_drv_t() - self._indev_drv.init() - self._indev_drv.type = lv.INDEV_TYPE.POINTER - self._indev_drv.read_cb = self._ft_read - self._indev_drv.register() - - def _ft_read(self, drv, data): - """LVGL read_cb:read_xy() + 硬件旋转翻转(参照 _main(2).py)。跑在主线程。""" - self._tp_lock.acquire() - try: - xy = self._tp.read_xy() - if xy is not None and len(xy) >= 2: - raw_x, raw_y = int(xy[0]), int(xy[1]) - if raw_x == 0 and raw_y == 0: - data.state = lv.INDEV_STATE.RELEASED - return - # 硬件 MADCTL=0x20 (MV=1):物理竖屏 320x480 → 逻辑横屏 480x320 - # logic_x = raw_y, logic_y = LCD_PHYS_W - 1 - raw_x - logic_x = raw_y - logic_y = self.LCD_PHYS_W - 1 - raw_x - if logic_x < 0: - logic_x = 0 - elif logic_x >= self.SCREEN_W: - logic_x = self.SCREEN_W - 1 - if logic_y < 0: - logic_y = 0 - elif logic_y >= self.SCREEN_H: - logic_y = self.SCREEN_H - 1 - data.point.x = logic_x - data.point.y = logic_y - data.state = lv.INDEV_STATE.PRESSED - else: - data.state = lv.INDEV_STATE.RELEASED - except Exception as e: - print("[TP] err", e) - data.state = lv.INDEV_STATE.RELEASED - finally: - self._tp_lock.release() - - def _start_render_thread(self): - print("[THREAD] starting LVGL render thread ...") - _thread.start_new_thread(self._render_loop, ()) - - def _render_loop(self): - """渲染线程:只跑 LVGL task_handler。触摸读取在 _ft_read(LVGL 回调)里。""" - while True: - lv.tick_inc(20) - lv.task_handler() - utime.sleep_ms(20) - - # ---------------------- 模拟触摸(tp 未就绪时用)---------------------- - def _init_sim_touch(self): - """注册一个假指针 indev,由渲染线程驱动点击状态机。 - - tp 固件就绪后:取消 __init__ 里的 self._init_touch() 注释, - 并把本方法调用注释掉即可。 - - 机制:sim_tap() 在主线程里把一次点击排入 self._sim_queue; - _sim_read() 在渲染线程每次被调用时,按队列推进 - PRESSED(若干轮) -> RELEASED(若干轮),从而稳定地让 LVGL 识别为 - 一次完整的点击并触发 CLICKED。时序完全跑在渲染线程的 LVGL 上下文里, - 避免主线程连击时与渲染线程竞争导致“要点好几次才生效”。 - """ - self._sim_queue = [] # 待执行的点击任务队列 - self._sim_cur = None # 当前正在执行的任务 - - self._sim_indev = lv.indev_drv_t() - self._sim_indev.init() - self._sim_indev.type = lv.INDEV_TYPE.POINTER - self._sim_indev.read_cb = self._sim_read - self._sim_indev.register() - - def _sim_read(self, drv, data): - """indev read 回调(渲染线程每 ~5ms 调一次):推进点击状态机。""" - # 当前任务跑完则取下一个 - if self._sim_cur is None and self._sim_queue: - self._sim_cur = self._sim_queue.pop(0) - - cur = self._sim_cur - if cur is None: - data.state = 0 # 空闲:松开 - return - - # cur = {"rx":.., "ry":.., "press_left":N, "release_left":N} - if cur["press_left"] > 0: - data.point.x = cur["rx"] - data.point.y = cur["ry"] - data.state = 1 # PRESSED - cur["press_left"] -= 1 - elif cur["release_left"] > 0: - data.state = 0 # RELEASED - cur["release_left"] -= 1 - else: - self._sim_cur = None - data.state = 0 - - def sim_tap(self, lx, ly, press_rounds=6, release_rounds=6): - """点逻辑屏幕坐标 (lx, ly),触发一次 CLICKED。 - - 横屏由面板 MADCTL 硬件旋转完成,LVGL 看到的就是逻辑 480x320, - 所以直接用逻辑坐标即可(不再需要物理反算)。 - - 实现:把一次点击(press_rounds 轮 PRESSED + release_rounds 轮 RELEASED) - 排入队列,由主循环 task_handler 在 _sim_read 里推进。本方法给一个最大等待时间, - 到点即返回;点击会在主循环里继续完成、触发 CLICKED。 - 连击安全:多次 sim_tap 依次排队、顺序执行,不会互相覆盖。 - """ - rx, ry = lx, ly - self._sim_queue.append({ - "rx": rx, "ry": ry, - "press_left": press_rounds, - "release_left": release_rounds, - }) - # 给渲染线程足够时间消费这一帧(press+release 共 12 轮,每轮~5ms≈60ms, - # 留足余量到 300ms)。用较长 sleep 让步,避免主线程忙等饿死渲染线程。 - # 到时仍未消费完也不强等——点击照样会在后台完成。 - for _ in range(30): - utime.sleep_ms(10) - if not self._sim_queue and self._sim_cur is None: - break - - -if __name__ == "__main__": - app = YttriumApp() - print("Yttrium 480x320 (hardware-rotate) main screen ready!") - - # ---------------------- REPL 模拟点击(调试用,真实触摸也可同时用)---------------------- - def tap(lx, ly): - app.sim_tap(lx, ly) - - def tap_grid(col, row): - CELL_W, CELL_H = 56, 70 - offset_x = (app.SCREEN_W - 5 * CELL_W) // 2 - offset_y = 22 + (app.SCREEN_H - 22 - 3 * CELL_H) // 2 - cx = offset_x + col * CELL_W + CELL_W // 2 - cy = offset_y + row * CELL_H + CELL_H // 2 - app.sim_tap(cx, cy) - - def back(): - app.sim_tap(29, 15) - - # ---------------------- 主线程跑 task_handler(真实触摸的关键)---------------------- - # ft6x36 的 read_xy 只在主线程读得到坐标,所以 task_handler 必须放主线程, - # 这样 _ft_read 里的 read_xy 才在主线程执行。 - # 代价:运行时 REPL 被占用(不回 >>>);Ctrl-C 中断回 REPL 调试。 - # 想用上面 tap_grid 调试(无真实触摸):注释掉下面这个 while、重启。 - print("running on main thread (REPL busy; Ctrl-C to debug)") - while True: - lv.tick_inc(20) - lv.task_handler() - utime.sleep_ms(20) diff --git a/tools/README.md b/tools/README.md index dcd327f..420003f 100644 --- a/tools/README.md +++ b/tools/README.md @@ -1,3 +1,40 @@ # 工具 -> 案例开发使用到的工具,如:固件烧录工具、代码调试工具、串口工具、lvgl字库 +案例开发使用到的工具集合。 + +## 工具清单 + +| 工具 | 文件 | 版本 | 用途 | +|---|---|---|---| +| FlashTools | `FlashTools_V4.1.19_2509010.zip` | V4.1.19 | 固件烧录工具,将 `.pac` 固件包烧录到 EG800Z 模组 | +| QPYcom | `QPYcom_V4.2.0.zip` | V4.2.0 | QuecPython 串口交互工具:REPL 终端、文件传输、一键导入代码 | +| SSCOM | `sscom5.13.1.zip` | V5.13.1 | 通用串口调试工具,备选终端 | + +## 使用说明 + +### FlashTools + +1. 解压后运行 `FlashTools.exe` +2. 选择固件目录中的 `quec_download_usb.ini` +3. USB 连接开发板,选择 Quectel USB AT Port +4. 勾选 Erase All (User Mode) 和 Erase NVM +5. 点击 Reset and download 开始烧录 + +> 详见 [02_firmwareFlashing](../docs/02_firmwareFlashing/README.md) + +### QPYcom + +1. 解压后运行 QPYcom +2. 选择 Quectel USB AT Port,波特率 115200 +3. 点击打开串口,按 Enter 进入 REPL +4. 右键 `/usr/` 目录 → 一键导入 → 选择 `src/` 文件夹部署代码 + +> 详见 [03_runDemo](../docs/03_runDemo/README.md) + +### SSCOM + +通用串口调试工具,支持多种波特率,用于替代 QPYCOM 进行基础串口通信测试。默认波特率 115200。 + +## 文件大小 + +> 注意:`QPYcom_V4.2.0.zip` 约 194MB,已加入 `.gitignore`,需从移远官方渠道单独下载。 diff --git a/yttrium_product_page.html b/yttrium_product_page.html deleted file mode 100644 index 00949cb..0000000 --- a/yttrium_product_page.html +++ /dev/null @@ -1,711 +0,0 @@ - - - - - -Yttrium 开发板 — QuecPython + LVGL 综合 Demo | 移远通信 - - - - - -
-
-
- - -
- Yttrium EG800Z 开发板正面 -
- - -
- QuecPython 生态 -

Yttrium 开发板
EG800Z · QuecPython + LVGL

-

- 基于 Quectel EG800Z QuecOpen 模组(EC718 系列),搭载 480×320 触控屏, - 集成以太网、CAN、RS485、音频、摄像头等 15+ 外设 Demo, - 一套开发板覆盖物联网全场景原型验证。 -

- - -
-
- 15+ - 内置 Demo 应用 -
-
- 480×320 - LVGL 触控横屏 -
-
- LTE Cat.1 - 4G 全网通 -
-
- 1 文件 - Python 即开即用 -
-
- - - -
- -
-
-
- - -
-
-

为什么选择 Yttrium

-

把"物联网原型验证"的门槛,压到一台开发板 + 几行 Python。

- -
-
-
-

即烧即用 零配置

-

FlashTools 一键烧录固件,QPYCOM 一键导入代码,接通电源即刻运行 15 个 Demo。

-
-
-
-

LTE Cat.1 真联网

-

板载 EG800Z 4G 模组,数据直连云端。搭配 W5500 以太网,有线无线双通道。

-
-
-
-

Python 全栈开发

-

QuecPython 生态:LVGL 拖拽式 GUI + machine API 直驱硬件,不碰 C 不啃寄存器。

-
-
-
-

15+ 场景全覆盖

-

LED → ADC → 通信协议 → 游戏 → 以太网 → QR 支付,从点灯到完整 IoT 系统一站式演练。

-
-
-
-
- - -
-
-

硬件规格

-

EG800Z_LCD_V1.1 主板,Quectel Wireless Solutions 设计。

- -
- - -
-

核心系统

- - - - - - -
主控模组EG800Z QuecOpen
蜂窝网络LTE Cat.1
存储128Mbit SPI NOR Flash
SIM单 SIM 卡槽(USIM1)
天线U.FL 连接器(50Ω)
-
- - -
-

显示 & 音频

- - - - - - -
LCD 驱动ST7796
分辨率320×480 → 480×320
触控ft6x36(I2C)
音频 CodecES8311
功放CS8126S
-
- - -
-

通信接口

- - - - - - -
以太网CH390D(W5500)
CANSIT65HVD230DR
RS485SIT3088EESA
USB双 USB-C
USB-UARTXR21B1411IL16
-
- - -
-

电源系统

- - - - - - -
主 DC-DCETA2893E8A
输出4V @ 3A
3.3V LDOSGM2028(500mA)
2.8V LDOSGM2019(300mA)
输入DC 5V / USB / VBAT
-
- - -
-

扩展接口

- - - - - - -
LCD16-pin FPC(FH34SRJ)
Camera6-pin FPC
音频扬声器 + 麦克风
调试8-pin 排针 ×2
按键PWR / RESET / BOOT
-
- - -
-

可用 GPIO

- - - - - - -
GPIO2蜂鸣器
GPIO3 / 4通用 IO
GPIO15 / 16通用 IO
GPIO17 / 26通用 IO
GPIO37LCD_VDD_EN
-
- -
-
-
- - -
-
-

15+ 内置 Demo 应用

-

一个固件,全部演示。触控点击即开即用。

- -
- -
-
-
LED 滑块
-
PWM11 调光,滑块控制 STATUS LED 亮度
- 已调通 -
- -
-
-
时钟 + 秒表
-
实时时钟 + 计次秒表
- 已调通 -
- -
-
-
ADC 仪表盘
-
三色弧段仪表,ADC0 实时采样 0~3.3V
- 已调通 -
- -
-
-
计算器
-
四则运算 + 百分比,5×4 按钮
- UI 就绪 -
- -
-
-
虚拟键盘
-
LVGL 内置键盘 + 文本框
- UI 就绪 -
- -
-
-
饮品选购
-
Coffee / Milk Tea → 勾选 → Pay → 二维码
- 已调通 -
- -
-
-
录音播放
-
波形显示 + Rec / Play / Stop
- UI 就绪 -
- -
-
-
电话拨号
-
拨号键盘 + CALL / Back
- UI 就绪 -
- -
-
-
蜂鸣器
-
Beep 短响 / On-Off 持续响
- UI 就绪 -
- -
-
-
天气
-
天气信息展示
- UI 就绪 -
- -
-
-
摄像头
-
GC0308 取景框 + 快门键
- 暂不支持 -
- -
-
-
U 盘浏览器
-
文件列表 + 目录切换
- 暂不支持 -
- -
-
-
贪吃蛇
-
34×20 网格,触摸滑动操控
- 已调通 -
- -
-
-
2048
-
经典 4×4 网格,滑动合并
- 已调通 -
- -
-
-
俄罗斯方块
-
10×20 网格,触摸旋转 / 平移 / 加速
- 已调通 -
- -
-
-
- - -
-
-
- - -
-

通信协议 Hub

-

UART / CAN / AT / RS485 四个协议终端,键盘输入交互式调试。

-
-
UART — UART2 / 115200
-
CAN — 500kbps
-
AT — atcmd 引擎
-
RS485 — 9600-8-N-1
-
-
- - -
-

以太网 Hub

-

W5500(CH390D)以太网,IP / Ping / TCP 三个功能入口。

-
-
IP — ipconfig / DHCP
-
PING — 网络诊断
-
TCP — TCP Client
-
-
- -
-
-
- - -
-
-

电源树

-

多级 LDO 为各外设独立供电,稳定可靠。

- -
-
-
4.0V
-
3A max
-
ETA2893E8A
-
-
-
3.3V
-
500mA
-
SGM2028
-
-
-
2.8V
-
300mA
-
SGM2019-ADJ
-
-
-
1.8V
-
300mA
-
板载 LDO
-
-
-
VDD_EXT
-
模组输出
-
EG800Z 内部
-
-
-
-
- - -
-
-

三步上手

-

从开箱到跑通第一个 Demo,不超过 10 分钟。

- -
-
-
1
-

烧录固件

-

FlashTools 选择固件包 → USB 连接 → Reset and Download → 等待完成。

-
-
-
2
-

部署代码

-

QPYCOM 右键 usr 目录 → 一键导入 → 选择 src 文件夹 → 进度条 100%。

-
-
-
3
-

启动 Demo

-

右键 yttrium.py 运行,LCD 亮起 5×3 图标网格,触摸即入各功能。

-
-
-
-

开始开发

-

每个 Demo 一个独立 .py 文件,看懂即改,改了就跑,无需编译。

-
-
-
-
- - -
-
-

资源与链接

-

源码、教程、原理图,全套开发资料开放。

- - -
-
- - - - - -