更改了协议解析方法,与MCU一致,使用状态机
This commit is contained in:
@@ -11,6 +11,12 @@
|
|||||||
*.app
|
*.app
|
||||||
.snapshots/*
|
.snapshots/*
|
||||||
|
|
||||||
|
# Python 虚拟环境与缓存
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
# PyInstaller 打包输出
|
# PyInstaller 打包输出
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
|||||||
Vendored
+4
-3
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
"python-envs.defaultEnvManager": "ms-python.python:conda",
|
"python-envs.defaultEnvManager": "ms-python.python:system",
|
||||||
"python-envs.defaultPackageManager": "ms-python.python:conda"
|
"python-envs.defaultPackageManager": "ms-python.python:pip",
|
||||||
}
|
"python.defaultInterpreterPath": "${workspaceFolder}\\.venv\\Scripts\\python.exe"
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,36 +16,92 @@
|
|||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 1. 安装依赖
|
### 1. 创建虚拟环境
|
||||||
|
|
||||||
|
在项目根目录下创建名为 `.venv` 的虚拟环境(需 Python 3.7+,已在 Python 3.14.7 验证):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Windows (PowerShell)
|
||||||
|
python -m venv .venv
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux / macOS
|
||||||
|
python3 -m venv .venv
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 激活虚拟环境
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Windows (PowerShell)
|
||||||
|
.\.venv\Scripts\Activate.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Windows (CMD)
|
||||||
|
.venv\Scripts\activate.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux / macOS
|
||||||
|
source .venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
> 激活后命令行前缀会出现 `(.venv)`;后续步骤也可直接使用 `.venv` 内的解释器而无需激活(见下方替代写法)。
|
||||||
|
|
||||||
|
### 3. 安装依赖
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 启动服务
|
已验证的依赖版本:
|
||||||
|
|
||||||
|
| 依赖 | 版本 |
|
||||||
|
|------|------|
|
||||||
|
| Flask | 3.1.3 |
|
||||||
|
| Flask-SocketIO | 5.6.1 |
|
||||||
|
| python-socketio | 5.17.0 |
|
||||||
|
| Pillow | 12.3.0 |
|
||||||
|
| loguru | 0.7.3 |
|
||||||
|
|
||||||
|
### 4. 启动服务
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python remo_disp_server.py [装置IP]
|
python remo_disp_server.py [装置IP]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
未激活虚拟环境时,可直接指定 venv 内解释器:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Windows (PowerShell)
|
||||||
|
.\.venv\Scripts\python.exe remo_disp_server.py
|
||||||
|
.\.venv\Scripts\python.exe remo_disp_server.py 192.168.253.3 # 预连接装置
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux / macOS
|
||||||
|
.venv/bin/python remo_disp_server.py [装置IP]
|
||||||
|
```
|
||||||
|
|
||||||
启动后访问 **http://localhost:8181**。
|
启动后访问 **http://localhost:8181**。
|
||||||
|
|
||||||
### 3. 使用说明
|
### 5. 退出虚拟环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
deactivate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用说明
|
||||||
|
|
||||||
- 点击「连接」→ 输入装置 IP 和端口(默认 7003)→ 连接
|
- 点击「连接」→ 输入装置 IP 和端口(默认 7003)→ 连接
|
||||||
- 左侧显示装置 LCD 画面(约 100ms 刷新)
|
- 左侧显示装置 LCD 画面(约 100ms 刷新)
|
||||||
- 右侧使用方向键、确认、复归、返回进行按键操作
|
- 右侧使用方向键、确认、复归、返回进行按键操作
|
||||||
- 点击「断开」断开与装置的连接
|
- 点击「断开」断开与装置的连接
|
||||||
|
|
||||||
**可选预连接**:启动时传入 IP 可预连接装置,例如:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python remo_disp_server.py 192.168.253.3
|
|
||||||
```
|
|
||||||
|
|
||||||
## 打包为 exe(Windows)
|
## 打包为 exe(Windows)
|
||||||
|
|
||||||
在项目目录下执行:
|
在项目目录下执行(建议先激活虚拟环境,或使用 `.venv` 内解释器):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 安装 PyInstaller 后打包
|
# 安装 PyInstaller 后打包
|
||||||
@@ -53,7 +109,7 @@ pip install pyinstaller
|
|||||||
python -m PyInstaller remo_disp_server.spec --noconfirm
|
python -m PyInstaller remo_disp_server.spec --noconfirm
|
||||||
```
|
```
|
||||||
|
|
||||||
或直接双击运行 `build_exe.bat`。
|
或直接双击运行 `build_exe.bat`(其内部使用当前激活环境的 python,因此请先激活 `.venv` 再运行)。
|
||||||
|
|
||||||
打包完成后可执行文件为 **dist\\remo_disp_server.exe**。双击运行,或在命令行:
|
打包完成后可执行文件为 **dist\\remo_disp_server.exe**。双击运行,或在命令行:
|
||||||
|
|
||||||
@@ -73,6 +129,7 @@ DTU-RemoteLCD/
|
|||||||
├── build_exe.bat # 一键打包脚本(Windows)
|
├── build_exe.bat # 一键打包脚本(Windows)
|
||||||
├── remo_disp_ui.html # Web 前端页面
|
├── remo_disp_ui.html # Web 前端页面
|
||||||
├── requirements.txt # Python 依赖
|
├── requirements.txt # Python 依赖
|
||||||
|
├── .venv/ # Python 虚拟环境(本地创建,不入库)
|
||||||
├── static/ # 静态资源(可选)
|
├── static/ # 静态资源(可选)
|
||||||
│ └── favicon.ico # 网站图标
|
│ └── favicon.ico # 网站图标
|
||||||
└── README.md
|
└── README.md
|
||||||
@@ -94,6 +151,11 @@ AA 03 00 04 00 00 00 00 00
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**客户端解析**(`remo_disp_server.py`):与 MCU 端(`ui_net_sink.c` 的 `rm_parser_feed`)
|
||||||
|
同款逐字节状态机解析:TAG(0xBB) 定界 → CMD → LEN(16 位大端) → DATA → CRC(数据区异或);
|
||||||
|
一帧可跨多个 TCP 包;长度字段设上限(`RM_MAX_RX_PAYLOAD`)防损坏长度锁死状态机;
|
||||||
|
失步后在下一个帧头自动重同步。两端解析策略一致,便于联调对拍。
|
||||||
|
|
||||||
**功能码**:
|
**功能码**:
|
||||||
|
|
||||||
| 码 | 名称 | 说明 |
|
| 码 | 名称 | 说明 |
|
||||||
@@ -139,6 +201,10 @@ AA 03 00 04 00 00 00 00 00
|
|||||||
- **Web 服务端口**:默认 8181,在 `remo_disp_server.py` 的 `main()` 中修改
|
- **Web 服务端口**:默认 8181,在 `remo_disp_server.py` 的 `main()` 中修改
|
||||||
- **装置端口**:默认 7003,连接时可输入自定义端口
|
- **装置端口**:默认 7003,连接时可输入自定义端口
|
||||||
- **Favicon**:将 `favicon.ico` 放入 `static/` 目录即可显示网站图标
|
- **Favicon**:将 `favicon.ico` 放入 `static/` 目录即可显示网站图标
|
||||||
|
- **接收耐心参数**(`remo_disp_server.py` 顶部,装置响应慢时可调节):
|
||||||
|
- `RECV_CHUNK_TIMEOUT = 3.0` — 单次 recv 最长等待秒数,超时仅表示"暂无数据",不算失败
|
||||||
|
- `RECV_FIRST_BYTE_TIMEOUT = 3.0` — 发出请求后等待响应首字节的秒数,全程无字节判为对端无应答,快速失败
|
||||||
|
- `RECV_TOTAL_BUDGET = 10.0` — 收齐一整帧的总时间预算(秒)。MCU 经 CH395F 按 1KB 块握手发送,整帧(如 3209 字节的 LCDMEM)可能耗时 0.5~3 秒以上;若装置更慢可适当调大
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB |
+333
-77
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
通过 RemoDispBus 协议与 DTU 装置通信,将远程 LCD 画面以 Web 页面形式展示。
|
通过 RemoDispBus 协议与 DTU 装置通信,将远程 LCD 画面以 Web 页面形式展示。
|
||||||
前端通过 WebSocket 连接,服务端主动推送屏幕数据,实现低延迟传输。
|
前端通过 WebSocket 连接,服务端主动推送屏幕数据,实现低延迟传输。
|
||||||
|
连接建立后自动发送 CMD_INIT 获取屏幕分辨率;获取失败时默认 160x160。
|
||||||
|
接收解析:逐字节状态机(与 MCU 端 ui_net_sink.c 的 rm_parser 同款),失步后自动重同步。
|
||||||
|
|
||||||
用法: python remo_disp_server.py [装置IP]
|
用法: python remo_disp_server.py [装置IP]
|
||||||
启动后访问 http://localhost:8181
|
启动后访问 http://localhost:8181
|
||||||
@@ -27,6 +29,14 @@ import sys
|
|||||||
import os
|
import os
|
||||||
# io:BytesIO 用于在内存中保存 PNG
|
# io:BytesIO 用于在内存中保存 PNG
|
||||||
import io
|
import io
|
||||||
|
# re:解析 CMD_INIT 可能返回的 ASCII 形式分辨率(如「160x160」)
|
||||||
|
import re
|
||||||
|
# time:_recv 耐心接收的时间预算计时
|
||||||
|
import time
|
||||||
|
# deque:同块内凑齐但本轮未返回的完整帧的挂起队列
|
||||||
|
from collections import deque
|
||||||
|
# time:_recv 总时间预算计时
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
def _get_base_path() -> str:
|
def _get_base_path() -> str:
|
||||||
@@ -76,6 +86,10 @@ _screen_clients: Set[str] = set()
|
|||||||
_refresh_thread: Optional[threading.Thread] = None
|
_refresh_thread: Optional[threading.Thread] = None
|
||||||
# _refresh_stop:Event,set 时刷新线程退出
|
# _refresh_stop:Event,set 时刷新线程退出
|
||||||
_refresh_stop = threading.Event()
|
_refresh_stop = threading.Event()
|
||||||
|
# DEFAULT_SCREEN_SIZE:未通过 CMD_INIT 获取到宽高时使用的默认分辨率
|
||||||
|
DEFAULT_SCREEN_SIZE: Tuple[int, int] = (160, 160)
|
||||||
|
# _screen_size:当前屏幕(宽, 高),由 init_screen() 更新,断开时重置为默认
|
||||||
|
_screen_size: Tuple[int, int] = DEFAULT_SCREEN_SIZE
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -104,35 +118,6 @@ def build_frame(cmd: int, data: bytes) -> bytes:
|
|||||||
return header + data + bytes([calc_crc(data)]) # 帧头 + 数据 + CRC
|
return header + data + bytes([calc_crc(data)]) # 帧头 + 数据 + CRC
|
||||||
|
|
||||||
|
|
||||||
def parse_frame(raw: bytes) -> Optional[Tuple[int, bytes]]:
|
|
||||||
"""
|
|
||||||
解析设备回复帧,返回 (cmd, data) 或 None。
|
|
||||||
帧格式: [TAG(1)][cmd(1)][len_hi(1)][len_lo(1)][data(length)][crc(1)]
|
|
||||||
最短合法帧长度为 5 字节(长度为 0 时: 1+1+2+0+1)。
|
|
||||||
"""
|
|
||||||
if len(raw) < 5 or raw[0] != TAG_DEVICE: # 至少 5 字节且帧头为 TAG_DEVICE
|
|
||||||
logger.error(
|
|
||||||
"[parse_frame] 帧太短或帧头不是 TAG_DEVICE(0xBB), len={len_}",
|
|
||||||
len_=len(raw),
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
length = (raw[2] << 8) | raw[3] # 大端序解析数据区长度
|
|
||||||
if len(raw) < 4 + length + 1: # 检查是否收齐整帧(头4字节+数据+CRC1字节)
|
|
||||||
logger.error(
|
|
||||||
"[parse_frame] 数据不完整, 需要 {need} 字节, 实际 {actual} 字节",
|
|
||||||
need=4 + length + 1,
|
|
||||||
actual=len(raw),
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
data = raw[4:4 + length] # 提取数据区
|
|
||||||
if calc_crc(data) != raw[4 + length]: # CRC 校验失败
|
|
||||||
logger.error("[parse_frame] CRC 校验失败")
|
|
||||||
return None
|
|
||||||
cmd = raw[1]
|
|
||||||
logger.debug("[parse_frame] 成功解析帧 cmd={cmd:#x}, length={length}", cmd=cmd, length=length)
|
|
||||||
return (cmd, data) # 返回 (命令码, 数据)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# 连接与收发
|
# 连接与收发
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -152,6 +137,10 @@ def connect(host: str, port: int = PORT) -> bool:
|
|||||||
_sock.connect((host, port)) # 连接目标主机和端口
|
_sock.connect((host, port)) # 连接目标主机和端口
|
||||||
_sock.settimeout(2.0) # 连接成功后收发超时 2 秒
|
_sock.settimeout(2.0) # 连接成功后收发超时 2 秒
|
||||||
logger.info("[connect] 连接 DTU 成功: {host}:{port}", host=host, port=port)
|
logger.info("[connect] 连接 DTU 成功: {host}:{port}", host=host, port=port)
|
||||||
|
_rm_parser.reset() # 新连接 = 新协议流,复位接收状态机(对齐 MCU 的 accept 时 reset)
|
||||||
|
_pending_frames.clear() # 丢弃旧连接的挂起帧
|
||||||
|
# 连接成功后尝试通过 CMD_INIT 动态获取屏幕分辨率,失败则保持默认 160x160
|
||||||
|
init_screen()
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("[connect] 连接失败: {host}:{port} - {err}", host=host, port=port, err=e)
|
logger.exception("[connect] 连接失败: {host}:{port} - {err}", host=host, port=port, err=e)
|
||||||
@@ -173,51 +162,227 @@ def _send(cmd: int, data: bytes = b"") -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _recv() -> Optional[Tuple[int, bytes]]:
|
# =============================================================================
|
||||||
|
# 接收耐心参数(MCU 侧 CH395F 按 1KB 块握手发送,速度较慢,客户端需耐心)
|
||||||
|
# =============================================================================
|
||||||
|
# CH395F 发送 FIFO 为 1KB 硬上限,每写满一块要等芯片"发送缓冲释放"中断才能
|
||||||
|
# 写下一块,节奏还受 PC 端 TCP 延迟确认(40~200ms)门控,整帧 LCDMEM(3209 字节)
|
||||||
|
# 分 4 块到达耗时 0.5~3 秒以上属正常现象。旧实现 3 次短等待就放弃,会把帧截断,
|
||||||
|
# 且迟到的尾块污染下一帧帧头,导致之后所有解析永久错乱。故改为:
|
||||||
|
RECV_CHUNK_TIMEOUT = 3.0 # 单次 recv 最长等待秒数;超时只表示"暂无数据",不算失败
|
||||||
|
RECV_FIRST_BYTE_TIMEOUT = 3.0 # 发出请求后等待首字节的秒数;全程无字节判为对端无应答,快速失败
|
||||||
|
RECV_TOTAL_BUDGET = 10.0 # 收齐一整帧的总时间预算(秒);超预算判本帧失败(丢弃进行中帧并复位状态机)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 接收状态机(对齐 MCU 端 ui_net_sink.c 的 rm_parser:TAG 定界、长度定长、CRC 校验、逐字节解析)
|
||||||
|
# =============================================================================
|
||||||
|
# 一帧可跨多次 recv;状态跨调用保持;失步后(任意噪声字节 / 截断残帧),
|
||||||
|
# TAG 态丢弃非帧头字节,在下一个 0xBB 处自动重新同步——与 MCU 侧策略完全一致。
|
||||||
|
# 接收方向长度字段上限(MCU 侧对 客户端→设备 方向用 RM_MAX_RX_PAYLOAD=256 防长度字段损坏
|
||||||
|
# 引发失步;此处按 设备→客户端 方向的最大合法帧折算):
|
||||||
|
# 设备→客户端方向最大合法帧为 LCDMEM 应答 = 4(偏移) + 3200(160x160 1bpp 位图) = 3204 字节,
|
||||||
|
# 再加 5KB 余量覆盖其他帧与更大屏幕;长度字段超过此值判损坏(失步)立即复位状态机,
|
||||||
|
# 防止状态机被锁死在等待永远到不齐的数万个字节上
|
||||||
|
RM_MAX_RX_PAYLOAD = 3204 + 5 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class _RmParser:
|
||||||
"""
|
"""
|
||||||
从设备接收一帧。若首次接收长度不足,会再尝试接收最多 2 次(共 3 次),
|
RemoDispBus 接收状态机(逐字节解析,行为对齐 MCU 端 rm_parser_feed)
|
||||||
拼齐整帧后再解析。单次 recv 最多 256KB。
|
|
||||||
|
状态(对齐 rm_st_t 枚举):
|
||||||
|
ST_TAG - 等待帧头 TAG(0xBB);非帧头字节一律丢弃(失步后自动重同步)
|
||||||
|
ST_CMD - 等待 CMD 字节
|
||||||
|
ST_LEN_H - 等待长度高字节
|
||||||
|
ST_LEN_L - 等待长度低字节,据此确定 payload 长度(超 RM_MAX_RX_PAYLOAD 判失步复位)
|
||||||
|
ST_DATA - 等待 payload 数据字节
|
||||||
|
ST_CRC - 等待 CRC 校验字节,帧完整(CRC 通过才交付,交付后复位)
|
||||||
|
|
||||||
|
重组缓冲 frame[] 为定长(对齐 MCU 的静态数组 g_rm.frame);状态跨 recv 调用保持
|
||||||
|
(一帧可跨多次 recv);新建连接/断开/长度越界/预算耗尽时调用 reset()
|
||||||
|
清除进行中帧(对齐 rm_parser_reset)。
|
||||||
|
"""
|
||||||
|
ST_TAG = 0
|
||||||
|
ST_CMD = 1
|
||||||
|
ST_LEN_H = 2
|
||||||
|
ST_LEN_L = 3
|
||||||
|
ST_DATA = 4
|
||||||
|
ST_CRC = 5
|
||||||
|
|
||||||
|
def __init__(self, max_payload: int):
|
||||||
|
self.max_payload = max_payload
|
||||||
|
self.frame = bytearray(4 + max_payload + 1) # 重组缓冲: [TAG][CMD][LEN_H][LEN_LO][DATA...][CRC]
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""复位状态机到初始(等待帧头)状态,清除进行中帧(对齐 rm_parser_reset)"""
|
||||||
|
self.st = self.ST_TAG
|
||||||
|
self.pos = 0 # frame[] 已写入字节数
|
||||||
|
self.plen = 0 # 本帧 payload 长度
|
||||||
|
self.didx = 0 # payload 已接收字节数
|
||||||
|
|
||||||
|
def feed(self, b: int) -> Optional[Tuple[int, bytes]]:
|
||||||
|
"""
|
||||||
|
向状态机喂入一个字节并推进状态(对齐 rm_parser_feed);
|
||||||
|
凑齐整帧且 CRC 通过时返回 (cmd, payload),否则返回 None(含重同步丢弃、复位、CRC 失败)。
|
||||||
|
"""
|
||||||
|
st = self.st
|
||||||
|
if st == self.ST_TAG:
|
||||||
|
if b != TAG_DEVICE:
|
||||||
|
return None # 丢弃非帧头字节(失步后自动重同步)
|
||||||
|
self.frame[self.pos] = b
|
||||||
|
self.pos += 1
|
||||||
|
self.st = self.ST_CMD
|
||||||
|
return None
|
||||||
|
if st == self.ST_CMD:
|
||||||
|
self.frame[self.pos] = b
|
||||||
|
self.pos += 1
|
||||||
|
self.st = self.ST_LEN_H
|
||||||
|
return None
|
||||||
|
if st == self.ST_LEN_H:
|
||||||
|
self.frame[self.pos] = b
|
||||||
|
self.pos += 1
|
||||||
|
self.st = self.ST_LEN_L
|
||||||
|
return None
|
||||||
|
if st == self.ST_LEN_L:
|
||||||
|
self.frame[self.pos] = b
|
||||||
|
self.pos += 1
|
||||||
|
self.plen = (self.frame[2] << 8) | self.frame[3]
|
||||||
|
if self.plen > self.max_payload:
|
||||||
|
logger.warning(
|
||||||
|
"[_rm_parser] 长度字段 {plen} 超出上限 {max},判定失步并复位状态机",
|
||||||
|
plen=self.plen, max=self.max_payload,
|
||||||
|
)
|
||||||
|
self.reset()
|
||||||
|
return None
|
||||||
|
self.didx = 0
|
||||||
|
self.st = self.ST_DATA if self.plen > 0 else self.ST_CRC
|
||||||
|
return None
|
||||||
|
if st == self.ST_DATA:
|
||||||
|
self.frame[self.pos] = b
|
||||||
|
self.pos += 1
|
||||||
|
self.didx += 1
|
||||||
|
if self.didx >= self.plen:
|
||||||
|
self.st = self.ST_CRC
|
||||||
|
return None
|
||||||
|
# ST_CRC:帧尾 CRC 校验字节
|
||||||
|
self.frame[self.pos] = b
|
||||||
|
self.pos += 1
|
||||||
|
data = self.frame[4:4 + self.plen] # 数据段(不含帧头与 CRC 自身)
|
||||||
|
if b == calc_crc(data):
|
||||||
|
cmd = self.frame[1]
|
||||||
|
logger.debug("[_rm_parser] 整帧 CRC 通过, cmd={cmd:#x}, plen={plen}", cmd=cmd, plen=self.plen)
|
||||||
|
result = (cmd, bytes(data))
|
||||||
|
else:
|
||||||
|
logger.warning("[_rm_parser] 整帧 CRC 校验失败,丢弃该坏帧")
|
||||||
|
result = None
|
||||||
|
self.reset() # 交付后复位(对齐 MCU rm_parser_feed 一帧结束即 reset)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# 全局解析器:状态跨 _recv 调用保持(一帧可跨多次 recv);
|
||||||
|
# 新建连接/断开时复位(对齐 MCU 的 accept/close 时 rm_parser_reset)
|
||||||
|
_rm_parser = _RmParser(RM_MAX_RX_PAYLOAD)
|
||||||
|
|
||||||
|
# 同块内已凑齐但本轮未返回的完整帧(同一 recv 块中的第 2+ 帧):
|
||||||
|
# 入挂起队列,下一次 _recv 优先交付(无需再读 socket),防止丢帧
|
||||||
|
# (对应 MCU "每次 poll 喂完整个缓冲"的行为在客户端请求/应答模型下的等价物)
|
||||||
|
_pending_frames: "deque[Tuple[int, bytes]]" = deque()
|
||||||
|
|
||||||
|
|
||||||
|
def _recv(deadline: Optional[float] = None, skip_first_byte_wait: bool = False) -> Optional[Tuple[int, bytes]]:
|
||||||
|
"""
|
||||||
|
从设备接收一帧(两阶段耐心等待 + 状态机解析):
|
||||||
|
- 解析由 _rm_parser 状态机完成(对齐 MCU 端 rm_parser_feed 的逐字节解析):
|
||||||
|
TAG 定界、长度定长、CRC 通过才交付;一帧可跨多次 recv;失步后在下一个 0xBB 自动重同步;
|
||||||
|
- 块内凑齐但本轮未返回的完整帧进 _pending_frames 挂起队列,
|
||||||
|
下一次 _recv 优先交付(无需读 socket),防止丢帧;
|
||||||
|
- 阶段 1:等待响应首字节(RECV_FIRST_BYTE_TIMEOUT);全程无字节 → 判对端无应答,快速失败;
|
||||||
|
skip_first_byte_wait:续接调用(数据已出现)时跳过该检查;
|
||||||
|
- 阶段 2:持续接收直至凑齐整帧,或超过总预算;
|
||||||
|
单次 recv 超时(RECV_CHUNK_TIMEOUT)只表示"暂无数据",继续等待——
|
||||||
|
MCU 经 CH395F 按 1KB 块握手发送,整帧耗时可达 0.5~3 秒以上;
|
||||||
|
- deadline:总预算截止点(monotonic 时钟);None 表示从当前起新计 RECV_TOTAL_BUDGET 预算;
|
||||||
|
给出时与调用方共享预算(如 fetch_screen 跳过夹带短帧的场景,避免每次调用重起预算叠加);
|
||||||
|
- 超预算 / 连接关闭:复位状态机(丢弃进行中帧),防止旧帧污染下一轮。
|
||||||
|
成功返回 (cmd, payload);失败返回 None。
|
||||||
"""
|
"""
|
||||||
if not _sock:
|
if not _sock:
|
||||||
logger.error("[_recv] 未连接设备,无法接收数据")
|
logger.error("[_recv] 未连接设备,无法接收数据")
|
||||||
return None
|
return None
|
||||||
|
old_timeout = _sock.gettimeout()
|
||||||
try:
|
try:
|
||||||
buffer = b""
|
now0 = time.monotonic()
|
||||||
max_attempts = 3 # 首次 + 最多再收 2 次
|
if deadline is None:
|
||||||
for attempt in range(max_attempts):
|
deadline = now0 + RECV_TOTAL_BUDGET
|
||||||
chunk = _sock.recv(256 * 1024)
|
if skip_first_byte_wait:
|
||||||
if not chunk:
|
first_byte_deadline = 0.0 # 续接等待(数据已出现):只受总截止约束
|
||||||
logger.debug("[_recv] 第 {n} 次 recv 收到空数据,连接可能已关闭", n=attempt + 1)
|
else:
|
||||||
break
|
first_byte_deadline = now0 + RECV_FIRST_BYTE_TIMEOUT
|
||||||
buffer += chunk
|
first_byte_seen = False
|
||||||
if len(buffer) < 5: # 至少需要 5 字节才能解析头
|
while True:
|
||||||
logger.debug("[_recv] 第 {n} 次 recv 后共 {len_} 字节,继续接收", n=attempt + 1, len_=len(buffer))
|
# 已有本轮之前凑齐的完整帧(挂起队列):优先交付,无需读 socket
|
||||||
|
if _pending_frames:
|
||||||
|
frame = _pending_frames.popleft()
|
||||||
|
logger.debug("[_recv] 交付挂起帧 cmd={cmd:#x}, plen={plen}", cmd=frame[0], plen=len(frame[1]))
|
||||||
|
return frame
|
||||||
|
now = time.monotonic()
|
||||||
|
if now >= deadline:
|
||||||
|
logger.error(
|
||||||
|
"[_recv] 总预算耗尽(已等待 {waited:.1f}s),进行中帧不完整(状态机已有 {pos} 字节),丢弃并复位状态机",
|
||||||
|
waited=now - now0, pos=_rm_parser.pos,
|
||||||
|
)
|
||||||
|
_rm_parser.reset()
|
||||||
|
return None
|
||||||
|
if not first_byte_seen and now >= first_byte_deadline:
|
||||||
|
logger.error(
|
||||||
|
"[_recv] 等待 {t:.0f}s 全程无字节,判定对端无应答,快速失败",
|
||||||
|
t=RECV_FIRST_BYTE_TIMEOUT,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
remaining = deadline - now
|
||||||
|
_sock.settimeout(min(RECV_CHUNK_TIMEOUT, remaining))
|
||||||
|
try:
|
||||||
|
chunk = _sock.recv(256 * 1024)
|
||||||
|
except (socket.timeout, TimeoutError):
|
||||||
|
logger.debug(
|
||||||
|
"[_recv] recv 超时(本轮无数据),状态机进行中 {pos} 字节,继续等待",
|
||||||
|
pos=_rm_parser.pos,
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
data_len = (buffer[2] << 8) | buffer[3] # 数据区长度
|
if not chunk:
|
||||||
need = 4 + data_len + 1 # 整帧长度
|
logger.warning("[_recv] 收到空数据,连接已被对端关闭,复位状态机")
|
||||||
if len(buffer) >= need:
|
_rm_parser.reset()
|
||||||
logger.debug("[_recv] 第 {n} 次 recv 后收齐整帧 len={len_}", n=attempt + 1, len_=need)
|
_pending_frames.clear()
|
||||||
return parse_frame(buffer[:need])
|
return None
|
||||||
logger.debug(
|
if not first_byte_seen:
|
||||||
"[_recv] 第 {n} 次 recv 后共 {have} 字节,需要 {need} 字节,继续接收",
|
first_byte_seen = True
|
||||||
n=attempt + 1,
|
logger.debug("[_recv] 收到响应首字节,进入整帧等待")
|
||||||
have=len(buffer),
|
# 逐字节喂入状态机(对齐 MCU ui_net_sink_poll: for i in n: rm_parser_feed(g_rx[i]))
|
||||||
need=need,
|
first_frame = None
|
||||||
)
|
for b in chunk:
|
||||||
if len(buffer) < 5:
|
f = _rm_parser.feed(b)
|
||||||
logger.error("[_recv] 接收数据太短, len={len_}", len_=len(buffer))
|
if f is None:
|
||||||
return None
|
continue
|
||||||
need = 4 + ((buffer[2] << 8) | buffer[3]) + 1
|
if first_frame is None:
|
||||||
logger.error(
|
first_frame = f # 本轮返回首个完整帧
|
||||||
"[_recv] 尝试 {max_attempts} 次后数据仍不完整, 需要 {need} 字节, 实际 {actual} 字节",
|
else:
|
||||||
max_attempts=max_attempts,
|
# 同块内第 2+ 个完整帧:入挂起队列,由下一次 _recv 优先交付
|
||||||
need=need,
|
if len(_pending_frames) >= 16:
|
||||||
actual=len(buffer),
|
logger.warning("[_recv] 挂起队列溢出({n} 帧),清空等待重新对齐", n=len(_pending_frames))
|
||||||
)
|
_pending_frames.clear()
|
||||||
return None
|
_pending_frames.append(f)
|
||||||
|
if first_frame is not None:
|
||||||
|
return first_frame
|
||||||
|
# 本轮未凑齐整帧:继续耐心循环
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("[_recv] 接收异常 - {err}", err=e)
|
logger.exception("[_recv] 接收异常 - {err}", err=e)
|
||||||
return None
|
return None
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
_sock.settimeout(old_timeout if old_timeout else RECV_CHUNK_TIMEOUT)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def send_key(code: int) -> bool:
|
def send_key(code: int) -> bool:
|
||||||
@@ -229,6 +394,9 @@ def fetch_screen() -> Optional[bytes]:
|
|||||||
"""
|
"""
|
||||||
向设备请求当前屏幕显存,返回 1bpp 位图数据。
|
向设备请求当前屏幕显存,返回 1bpp 位图数据。
|
||||||
发送 CMD_LCDMEM + 起始地址 0;设备回复 payload 前 4 字节为地址,后续为位图,返回 payload[4:]。
|
发送 CMD_LCDMEM + 起始地址 0;设备回复 payload 前 4 字节为地址,后续为位图,返回 payload[4:]。
|
||||||
|
若前面夹带 KEEPLIVE/KEY ACK 等短帧(按键操作后常见,其应答滞留字节流),
|
||||||
|
在同一总预算内继续接收真正的 LCDMEM 应答(最多跳过 3 帧),
|
||||||
|
避免夹带短帧被误计为拉取失败、累计触发断开判定。
|
||||||
"""
|
"""
|
||||||
if not _sock:
|
if not _sock:
|
||||||
logger.error("[fetch_screen] 未连接设备,无法读取屏幕")
|
logger.error("[fetch_screen] 未连接设备,无法读取屏幕")
|
||||||
@@ -237,14 +405,20 @@ def fetch_screen() -> Optional[bytes]:
|
|||||||
logger.error("[fetch_screen] 发送 CMD_LCDMEM 失败")
|
logger.error("[fetch_screen] 发送 CMD_LCDMEM 失败")
|
||||||
return None
|
return None
|
||||||
logger.debug("[fetch_screen] 发送 CMD_LCDMEM 成功")
|
logger.debug("[fetch_screen] 发送 CMD_LCDMEM 成功")
|
||||||
result = _recv() # 接收设备回复
|
# 连续多次 _recv 共享同一总预算截止点,避免每次调用重起 10s 预算叠加
|
||||||
if not result:
|
deadline = time.monotonic() + RECV_TOTAL_BUDGET
|
||||||
logger.error("[fetch_screen] 未收到任何回复(result=None)")
|
for retry in range(3):
|
||||||
return None
|
result = _recv(deadline=deadline, skip_first_byte_wait=retry > 0) # 接收设备回复
|
||||||
cmd, payload = result
|
if not result:
|
||||||
if cmd != CMD_LCDMEM:
|
logger.error("[fetch_screen] 未收到任何回复(result=None, 第 {n} 次尝试)", n=retry + 1)
|
||||||
# 可能是按键 ACK、保活等其他短帧,按键操作时较常见,这里只做调试日志,不视为错误
|
return None
|
||||||
logger.debug("[fetch_screen] 收到非 LCDMEM 帧 cmd={cmd:#x},忽略", cmd=cmd)
|
cmd, payload = result
|
||||||
|
if cmd == CMD_LCDMEM:
|
||||||
|
break
|
||||||
|
# 可能是按键 ACK、保活等其他短帧,按键操作时常见,继续接收真正的应答
|
||||||
|
logger.debug("[fetch_screen] 收到非 LCDMEM 帧 cmd={cmd:#x}(夹带短帧),继续等待真正应答", cmd=cmd)
|
||||||
|
else:
|
||||||
|
logger.warning("[fetch_screen] 连续 3 帧均非 LCDMEM,放弃本帧")
|
||||||
return None
|
return None
|
||||||
# 确认为 LCDMEM 回复
|
# 确认为 LCDMEM 回复
|
||||||
if len(payload) >= 4: # 前 4 字节为地址,后面是位图
|
if len(payload) >= 4: # 前 4 字节为地址,后面是位图
|
||||||
@@ -254,6 +428,85 @@ def fetch_screen() -> Optional[bytes]:
|
|||||||
return payload or None
|
return payload or None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_init_payload(payload: bytes) -> Optional[Tuple[int, int]]:
|
||||||
|
"""
|
||||||
|
解析 CMD_INIT 回复数据区,尝试提取屏幕(宽, 高)。
|
||||||
|
不同设备固件编码可能不同,按优先级依次尝试:
|
||||||
|
1) ASCII 文本,如「160x160」「160,160」「160 160」
|
||||||
|
2) 宽 2 字节大端 + 高 2 字节大端(其后若有显存大小等字节则忽略)
|
||||||
|
3) 宽 2 字节小端 + 高 2 字节小端
|
||||||
|
4) 宽 1 字节 + 高 1 字节
|
||||||
|
通过合理性检查的候选中,优先取宽度为 8 的倍数者(单色 LCD 常见约定)。
|
||||||
|
返回 (width, height);全部解析失败返回 None。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def plausible(w: int, h: int) -> bool:
|
||||||
|
"""宽高合理性检查:8~4096 且总像素数按 8 对齐。"""
|
||||||
|
return 8 <= w <= 4096 and 8 <= h <= 4096 and (w * h) % 8 == 0
|
||||||
|
|
||||||
|
# 1) ASCII 形式:必须先于二进制形式检查,否则「160x160」会被误判为字节对
|
||||||
|
try:
|
||||||
|
text = payload.decode("ascii", "ignore").strip()
|
||||||
|
# 带分隔符(x/X/*/,)或纯空白分隔两组数字
|
||||||
|
m = re.match(r"^(\d{1,4})\s*[xX*,]\s*(\d{1,4})$", text) or re.match(r"^(\d{1,4})\s+(\d{1,4})$", text)
|
||||||
|
if m:
|
||||||
|
w_a, h_a = int(m.group(1)), int(m.group(2))
|
||||||
|
if plausible(w_a, h_a):
|
||||||
|
return (w_a, h_a)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2/3/4) 收集二进制候选 (宽, 高) 对
|
||||||
|
candidates = []
|
||||||
|
if len(payload) >= 4:
|
||||||
|
candidates.append(((payload[0] << 8) | payload[1], (payload[2] << 8) | payload[3])) # 双大端
|
||||||
|
candidates.append((payload[0] | (payload[1] << 8), payload[2] | (payload[3] << 8))) # 双小端
|
||||||
|
if len(payload) == 2:
|
||||||
|
# 仅当数据区恰为 2 字节时才按双单字节解读,避免把 ASCII 长文本误判
|
||||||
|
candidates.append((payload[0], payload[1]))
|
||||||
|
|
||||||
|
# 优先宽度为 8 的倍数;否则放宽条件再选一轮
|
||||||
|
for w_c, h_c in candidates:
|
||||||
|
if plausible(w_c, h_c) and w_c % 8 == 0:
|
||||||
|
return (w_c, h_c)
|
||||||
|
for w_c, h_c in candidates:
|
||||||
|
if plausible(w_c, h_c):
|
||||||
|
return (w_c, h_c)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def init_screen() -> Optional[Tuple[int, int]]:
|
||||||
|
"""
|
||||||
|
发送 CMD_INIT(空数据),解析回复并更新全局 _screen_size。
|
||||||
|
发送失败、接收失败、回复非 INIT 帧或数据无法解析时,
|
||||||
|
保持默认 DEFAULT_SCREEN_SIZE(160x160)并返回 None。
|
||||||
|
应答等待受 _recv 耐心参数约束:设备无应答时 3s(首字节)内失败并回退默认分辨率。
|
||||||
|
"""
|
||||||
|
global _screen_size
|
||||||
|
_screen_size = DEFAULT_SCREEN_SIZE # 先复位为默认值,解析成功才更新
|
||||||
|
if not _sock:
|
||||||
|
logger.error("[init_screen] 未连接设备,无法初始化")
|
||||||
|
return None
|
||||||
|
if not _send(CMD_INIT, b""):
|
||||||
|
logger.warning("[init_screen] 发送 CMD_INIT 失败,使用默认分辨率 {w}x{h}", w=DEFAULT_SCREEN_SIZE[0], h=DEFAULT_SCREEN_SIZE[1])
|
||||||
|
return None
|
||||||
|
result = _recv()
|
||||||
|
if not result:
|
||||||
|
logger.warning("[init_screen] 未收到 CMD_INIT 回复,使用默认分辨率 {w}x{h}", w=DEFAULT_SCREEN_SIZE[0], h=DEFAULT_SCREEN_SIZE[1])
|
||||||
|
return None
|
||||||
|
cmd, payload = result
|
||||||
|
if cmd != CMD_INIT:
|
||||||
|
logger.warning("[init_screen] 回复帧 cmd={cmd:#x} 非 INIT,使用默认分辨率 {w}x{h}", cmd=cmd, w=DEFAULT_SCREEN_SIZE[0], h=DEFAULT_SCREEN_SIZE[1])
|
||||||
|
return None
|
||||||
|
parsed = _parse_init_payload(payload)
|
||||||
|
if parsed:
|
||||||
|
_screen_size = parsed
|
||||||
|
logger.info("[init_screen] 获取屏幕分辨率 {w}x{h} 成功 (payload_len={len_})", w=parsed[0], h=parsed[1], len_=len(payload))
|
||||||
|
return parsed
|
||||||
|
logger.warning("[init_screen] INIT 数据无法解析分辨率(len={len_}),使用默认分辨率 {w}x{h}", len_=len(payload), w=DEFAULT_SCREEN_SIZE[0], h=DEFAULT_SCREEN_SIZE[1])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def mono_to_png(data: bytes, width: int, height: int) -> Optional[bytes]:
|
def mono_to_png(data: bytes, width: int, height: int) -> Optional[bytes]:
|
||||||
"""
|
"""
|
||||||
将 1bpp 位图转为 PNG 字节流。
|
将 1bpp 位图转为 PNG 字节流。
|
||||||
@@ -314,13 +567,16 @@ def mono_to_png(data: bytes, width: int, height: int) -> Optional[bytes]:
|
|||||||
|
|
||||||
|
|
||||||
def disconnect_device():
|
def disconnect_device():
|
||||||
"""关闭与设备的连接"""
|
"""关闭与设备的连接,重置屏幕分辨率,并复位接收状态机"""
|
||||||
global _sock
|
global _sock, _screen_size
|
||||||
try:
|
try:
|
||||||
if _sock:
|
if _sock:
|
||||||
logger.info("[disconnect_device] 正在关闭与 DTU 的连接")
|
logger.info("[disconnect_device] 正在关闭与 DTU 的连接")
|
||||||
_sock.close() # 关闭 socket
|
_sock.close() # 关闭 socket
|
||||||
_sock = None # 清空引用
|
_sock = None # 清空引用
|
||||||
|
_screen_size = DEFAULT_SCREEN_SIZE # 重置分辨率为默认 160x160
|
||||||
|
_rm_parser.reset() # 复位接收状态机(对齐 MCU 的 close 时 reset)
|
||||||
|
_pending_frames.clear() # 丢弃挂起帧
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("[disconnect_device] 关闭连接时异常 - {err}", err=e)
|
logger.exception("[disconnect_device] 关闭连接时异常 - {err}", err=e)
|
||||||
|
|
||||||
@@ -347,8 +603,8 @@ def _screen_refresh_loop():
|
|||||||
logger.debug("[fetch_screen] 拉取屏幕位图数据: {data}", data=data)
|
logger.debug("[fetch_screen] 拉取屏幕位图数据: {data}", data=data)
|
||||||
if data:
|
if data:
|
||||||
consecutive_failures = 0 # 成功则清零
|
consecutive_failures = 0 # 成功则清零
|
||||||
# 当前协议未返回宽高,这里固定为 160x160,可根据实际情况调整
|
# 屏幕分辨率:init_screen() 已通过 CMD_INIT 更新,未获取到则为默认 160x160
|
||||||
w, h = 160, 160
|
w, h = _screen_size
|
||||||
png = mono_to_png(data, w, h) # 转为 PNG
|
png = mono_to_png(data, w, h) # 转为 PNG
|
||||||
if png and isinstance(png, (bytes, bytearray)) and png.startswith(b"\x89PNG\r\n\x1a\n"):
|
if png and isinstance(png, (bytes, bytearray)) and png.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||||
b64 = base64.b64encode(png).decode("ascii") # base64 编码便于 JSON 传输
|
b64 = base64.b64encode(png).decode("ascii") # base64 编码便于 JSON 传输
|
||||||
@@ -456,7 +712,7 @@ def on_connect_device(data):
|
|||||||
_refresh_thread = threading.Thread(target=_screen_refresh_loop, daemon=True) # 创建后台线程
|
_refresh_thread = threading.Thread(target=_screen_refresh_loop, daemon=True) # 创建后台线程
|
||||||
_refresh_thread.start() # 启动线程
|
_refresh_thread.start() # 启动线程
|
||||||
logger.info("[on_connect_device] 已启动屏幕刷新线程")
|
logger.info("[on_connect_device] 已启动屏幕刷新线程")
|
||||||
emit("connect_result", {"success": True}) # 回复连接成功
|
emit("connect_result", {"success": True, "w": _screen_size[0], "h": _screen_size[1]}) # 回复连接成功并携带当前分辨率
|
||||||
logger.info("[on_connect_device] 连接 DTU 成功,已加入订阅 sid={sid}", sid=request.sid)
|
logger.info("[on_connect_device] 连接 DTU 成功,已加入订阅 sid={sid}", sid=request.sid)
|
||||||
else:
|
else:
|
||||||
logger.error("[on_connect_device] 连接 DTU 失败: {host}:{port}", host=host, port=port)
|
logger.error("[on_connect_device] 连接 DTU 失败: {host}:{port}", host=host, port=port)
|
||||||
|
|||||||
+8
-3
@@ -392,7 +392,11 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
// 服务端推送屏幕:data.png 为 base64 编码的 PNG,解码后绘制到 canvas
|
// 服务端推送屏幕:data.png 为 base64 编码的 PNG,解码后绘制到 canvas
|
||||||
const scale = 2; // 放大倍数(160x160 -> 320x320)
|
// 缩放倍数:根据后端上报的 w/h(来自 CMD_INIT)动态选择,目标约 320px 显示尺寸,限制在 1~4 倍
|
||||||
|
function pickScale(w, h) {
|
||||||
|
const longest = Math.max(w || 160, h || 160);
|
||||||
|
return Math.max(1, Math.min(4, Math.round(320 / longest)));
|
||||||
|
}
|
||||||
let canvasInitialized = false;
|
let canvasInitialized = false;
|
||||||
let latestFrameId = 0; // 用于丢弃旧帧,避免异步解码乱序导致闪烁
|
let latestFrameId = 0; // 用于丢弃旧帧,避免异步解码乱序导致闪烁
|
||||||
socket.on('screen', (data) => {
|
socket.on('screen', (data) => {
|
||||||
@@ -416,10 +420,11 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 首帧初始化 canvas 尺寸(只做一次,避免反复重设导致清空闪烁)
|
// 初始化 canvas 尺寸(避免反复重设导致清空闪烁;分辨率变化时按新尺寸重新初始化)
|
||||||
|
const scale = pickScale(data.w, data.h);
|
||||||
const targetW = img.width * scale;
|
const targetW = img.width * scale;
|
||||||
const targetH = img.height * scale;
|
const targetH = img.height * scale;
|
||||||
if (!canvasInitialized) {
|
if (!canvasInitialized || canvas.width !== targetW || canvas.height !== targetH) {
|
||||||
canvas.width = targetW;
|
canvas.width = targetW;
|
||||||
canvas.height = targetH;
|
canvas.height = targetH;
|
||||||
canvasInitialized = true;
|
canvasInitialized = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user