Compare commits
9 Commits
0e786c12eb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f7898adc5d | |||
| fb2d4ffc00 | |||
| b47962e5c8 | |||
| 9f54a0cb2e | |||
| 999443ebd5 | |||
| 391fae9f0e | |||
| 7b540e1ed4 | |||
| 45baf4c956 | |||
| ba4431c01f |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -11,3 +11,9 @@
|
|||||||
*.app
|
*.app
|
||||||
.snapshots/*
|
.snapshots/*
|
||||||
|
|
||||||
|
# PyInstaller 打包输出
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.spec
|
||||||
|
remo_disp.log
|
||||||
|
|
||||||
|
|||||||
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"python-envs.defaultEnvManager": "ms-python.python:conda",
|
||||||
|
"python-envs.defaultPackageManager": "ms-python.python:conda",
|
||||||
|
"python-envs.pythonProjects": []
|
||||||
|
}
|
||||||
146
README.md
146
README.md
@@ -1,3 +1,145 @@
|
|||||||
# DTU-RemtoeLCD
|
# DTU-RemoteLCD - 远程显示通信工具
|
||||||
|
|
||||||
网络调试的远程显示LCD模拟软件
|
与 **RemoDispBus** 协议兼容的远程 LCD 显示与按键模拟工具。通过网络连接 DTU 装置,在 Web 浏览器中实时查看 LCD 画面并模拟按键操作,适用于调试、监控等场景。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- **实时屏幕显示**:将装置 LCD 显存以 1bpp 位图形式拉取并渲染为 PNG
|
||||||
|
- **按键模拟**:支持方向键、确认、返回、复归等按键
|
||||||
|
- **WebSocket 传输**:采用 Socket.IO,服务端主动推送,约 100ms 刷新
|
||||||
|
- **跨平台**:浏览器访问,无需安装客户端
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Python 3.7+
|
||||||
|
- 与 DTU 装置网络互通(默认端口 7003)
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python remo_disp_server.py [装置IP]
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后访问 **http://localhost:8181**。
|
||||||
|
|
||||||
|
### 3. 使用说明
|
||||||
|
|
||||||
|
- 点击「连接」→ 输入装置 IP 和端口(默认 7003)→ 连接
|
||||||
|
- 左侧显示装置 LCD 画面(约 100ms 刷新)
|
||||||
|
- 右侧使用方向键、确认、复归、返回进行按键操作
|
||||||
|
- 点击「断开」断开与装置的连接
|
||||||
|
|
||||||
|
**可选预连接**:启动时传入 IP 可预连接装置,例如:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python remo_disp_server.py 192.168.253.3
|
||||||
|
```
|
||||||
|
|
||||||
|
## 打包为 exe(Windows)
|
||||||
|
|
||||||
|
在项目目录下执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装 PyInstaller 后打包
|
||||||
|
pip install pyinstaller
|
||||||
|
python -m PyInstaller remo_disp_server.spec --noconfirm
|
||||||
|
```
|
||||||
|
|
||||||
|
或直接双击运行 `build_exe.bat`。
|
||||||
|
|
||||||
|
打包完成后可执行文件为 **dist\\remo_disp_server.exe**。双击运行,或在命令行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dist\remo_disp_server.exe
|
||||||
|
dist\remo_disp_server.exe 192.168.253.3
|
||||||
|
```
|
||||||
|
|
||||||
|
运行后浏览器访问 http://localhost:8181。日志会输出到控制台,同目录下会生成 `remo_disp.log`。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
DTU-RemoteLCD/
|
||||||
|
├── remo_disp_server.py # Web 服务(Flask + Socket.IO)
|
||||||
|
├── remo_disp_server.spec # PyInstaller 打包配置
|
||||||
|
├── build_exe.bat # 一键打包脚本(Windows)
|
||||||
|
├── remo_disp_ui.html # Web 前端页面
|
||||||
|
├── requirements.txt # Python 依赖
|
||||||
|
├── static/ # 静态资源(可选)
|
||||||
|
│ └── favicon.ico # 网站图标
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 协议说明
|
||||||
|
|
||||||
|
| 项目 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **端口** | 7003 |
|
||||||
|
| **工具 → 装置** | `[0xAA][功能码][长度高][长度低][数据...][CRC]` |
|
||||||
|
| **装置 → 工具** | `[0xBB][功能码][长度高][长度低][数据...][CRC]` |
|
||||||
|
| **CRC** | 数据区逐字节异或,取低 8 位 |
|
||||||
|
|
||||||
|
```
|
||||||
|
刷新屏幕请求
|
||||||
|
AA 03 00 04 00 00 00 00 00
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**功能码**:
|
||||||
|
|
||||||
|
| 码 | 名称 | 说明 |
|
||||||
|
|----|------|------|
|
||||||
|
| 0 | KEEPLIVE | 保活 |
|
||||||
|
| 1 | INIT | 初始化,获取屏幕宽高、显存大小 |
|
||||||
|
| 2 | KEY | 按键 |
|
||||||
|
| 3 | LCDMEM | 读取显存(屏幕画面) |
|
||||||
|
|
||||||
|
## 按键映射
|
||||||
|
|
||||||
|
| 键名 | 协议码 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| U | 0x02 | 上 |
|
||||||
|
| D | 0x40 | 下 |
|
||||||
|
| L | 0x10 | 左 |
|
||||||
|
| R | 0x08 | 右 |
|
||||||
|
| ENT | 0x20 | 确认 |
|
||||||
|
| ESC | 0x01 | 返回/取消 |
|
||||||
|
| RESET | 0x04 | 复归 |
|
||||||
|
| F2 | 0x80 | F2 |
|
||||||
|
|
||||||
|
## 依赖说明
|
||||||
|
|
||||||
|
| 依赖 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| Flask | Web 框架 |
|
||||||
|
| flask-socketio | WebSocket 支持 |
|
||||||
|
| python-socketio | Socket.IO 服务端 |
|
||||||
|
| Pillow | 1bpp 位图转 PNG |
|
||||||
|
| loguru | 结构化日志、错误追踪 |
|
||||||
|
|
||||||
|
## 日志与调试
|
||||||
|
|
||||||
|
- 后端使用 `loguru` 输出日志,默认会打印到控制台,并写入当前目录下的 `remo_disp.log`。
|
||||||
|
- 在 `remo_disp_server.py` 顶部有 `LOG_LEVEL` 常量:
|
||||||
|
- `LOG_LEVEL = "INFO"`:只输出 INFO/WARNING/ERROR 级别日志(默认)。
|
||||||
|
- `LOG_LEVEL = "DEBUG"`:输出包括 DEBUG 在内的详细调试信息。
|
||||||
|
- exe 版本(`remo_disp_server.exe`)同样会在运行目录生成 `remo_disp.log`,方便线下排查问题。
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
- **Web 服务端口**:默认 8181,在 `remo_disp_server.py` 的 `main()` 中修改
|
||||||
|
- **装置端口**:默认 7003,连接时可输入自定义端口
|
||||||
|
- **Favicon**:将 `favicon.ico` 放入 `static/` 目录即可显示网站图标
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
本项目仅供内部调试使用。
|
||||||
|
|||||||
17
build_exe.bat
Normal file
17
build_exe.bat
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
echo 正在打包 DTU-RemoteLCD 为 exe ...
|
||||||
|
echo.
|
||||||
|
|
||||||
|
rem 使用当前环境中的 python 和 pip,而不是系统默认 pip
|
||||||
|
python -m pip install pyinstaller -q
|
||||||
|
python -m PyInstaller remo_disp_server.spec --noconfirm
|
||||||
|
|
||||||
|
if %ERRORLEVEL% equ 0 (
|
||||||
|
echo.
|
||||||
|
echo 打包完成。可执行文件: dist\remo_disp_server.exe
|
||||||
|
echo 直接双击运行,或在命令行: dist\remo_disp_server.exe [装置IP]
|
||||||
|
) else (
|
||||||
|
echo 打包失败,请检查错误信息。
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
504
remo_disp_server.py
Normal file
504
remo_disp_server.py
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
远程显示 Web 服务 - Flask + WebSocket 实现
|
||||||
|
|
||||||
|
通过 RemoDispBus 协议与 DTU 装置通信,将远程 LCD 画面以 Web 页面形式展示。
|
||||||
|
前端通过 WebSocket 连接,服务端主动推送屏幕数据,实现低延迟传输。
|
||||||
|
|
||||||
|
用法: python remo_disp_server.py [装置IP]
|
||||||
|
启动后访问 http://localhost:8181
|
||||||
|
依赖: pip install flask flask-socketio pillow loguru
|
||||||
|
|
||||||
|
日志级别: 在代码中设置 LOG_LEVEL(见下方),"INFO" 不显示 DEBUG,"DEBUG" 显示调试信息。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# base64:将二进制 PNG 编码为字符串,便于通过 JSON/WebSocket 传输
|
||||||
|
import base64
|
||||||
|
# socket:与 DTU 装置的 TCP 通信
|
||||||
|
import socket
|
||||||
|
# struct:将整数打包为二进制(如起始地址 4 字节)
|
||||||
|
import struct
|
||||||
|
# threading:后台线程持续拉取屏幕并推送
|
||||||
|
import threading
|
||||||
|
# sys:读取命令行参数(装置 IP)
|
||||||
|
import sys
|
||||||
|
# os:获取当前脚本目录、路径与文件检查
|
||||||
|
import os
|
||||||
|
# io:BytesIO 用于在内存中保存 PNG
|
||||||
|
import io
|
||||||
|
|
||||||
|
|
||||||
|
def _get_base_path() -> str:
|
||||||
|
"""打包成 exe 时资源在 sys._MEIPASS,否则为脚本所在目录。"""
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
return sys._MEIPASS # type: ignore[attr-defined]
|
||||||
|
return os.path.dirname(os.path.abspath(__file__))
|
||||||
|
# typing:类型注解
|
||||||
|
from typing import Optional, Tuple, Set
|
||||||
|
# loguru:结构化日志,记录错误与调试信息
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
# Flask:Web 框架,提供 HTTP 路由
|
||||||
|
from flask import Flask, request, send_file
|
||||||
|
# SocketIO:WebSocket 支持,实现实时双向通信
|
||||||
|
from flask_socketio import SocketIO, emit
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 协议常量(RemoDispBus)
|
||||||
|
# =============================================================================
|
||||||
|
# TAG_CLIENT 0xAA:客户端发送帧的帧头标识
|
||||||
|
# TAG_DEVICE 0xBB:设备回复帧的帧头标识
|
||||||
|
TAG_CLIENT, TAG_DEVICE = 0xAA, 0xBB
|
||||||
|
# PORT 7003:RemoDispBus 协议默认端口
|
||||||
|
PORT = 7003
|
||||||
|
# CMD_KEEPLIVE 0:保活
|
||||||
|
# CMD_INIT 1:初始化,获取屏幕宽高、显存大小
|
||||||
|
# CMD_KEY 2:按键
|
||||||
|
# CMD_LCDMEM 3:读取显存(屏幕画面)
|
||||||
|
CMD_KEEPLIVE, CMD_INIT, CMD_KEY, CMD_LCDMEM = 0, 1, 2, 3
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 日志级别(在代码中直接修改)
|
||||||
|
# =============================================================================
|
||||||
|
# "INFO":仅输出 INFO/WARNING/ERROR,不显示 DEBUG
|
||||||
|
# "DEBUG":输出包括 DEBUG 在内的全部日志
|
||||||
|
LOG_LEVEL = "INFO"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 全局状态
|
||||||
|
# =============================================================================
|
||||||
|
# _sock:与 DTU 装置的 TCP socket,None 表示未连接
|
||||||
|
_sock: Optional[socket.socket] = None
|
||||||
|
# _screen_clients:当前需要接收屏幕推送的 WebSocket 客户端 sid 集合
|
||||||
|
_screen_clients: Set[str] = set()
|
||||||
|
# _refresh_thread:后台刷新线程,持续拉取屏幕并推送
|
||||||
|
_refresh_thread: Optional[threading.Thread] = None
|
||||||
|
# _refresh_stop:Event,set 时刷新线程退出
|
||||||
|
_refresh_stop = threading.Event()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 协议帧编解码
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def calc_crc(data: bytes) -> int:
|
||||||
|
"""
|
||||||
|
对数据区逐字节异或,取低 8 位作为 CRC 校验码。
|
||||||
|
协议规定:CRC = data[0] ^ data[1] ^ ... ^ data[n-1],取低 8 位。
|
||||||
|
"""
|
||||||
|
crc = 0 # 初始值为 0
|
||||||
|
for b in data:
|
||||||
|
crc ^= b # 逐字节异或
|
||||||
|
return crc & 0xFF # 取低 8 位作为最终 CRC
|
||||||
|
|
||||||
|
|
||||||
|
def build_frame(cmd: int, data: bytes) -> bytes:
|
||||||
|
"""
|
||||||
|
构造一帧发送数据,格式: [TAG(1)][cmd(1)][len_hi(1)][len_lo(1)][data][crc(1)]
|
||||||
|
length 大端 2 字节;crc 对 data 计算。
|
||||||
|
"""
|
||||||
|
length = len(data) # 数据区长度
|
||||||
|
# 构造帧头:TAG_CLIENT + cmd + 长度高字节 + 长度低字节(大端序)
|
||||||
|
header = bytes([TAG_CLIENT, cmd & 0xFF, (length >> 8) & 0xFF, length & 0xFF])
|
||||||
|
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) # 返回 (命令码, 数据)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 连接与收发
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def connect(host: str, port: int = PORT) -> bool:
|
||||||
|
"""
|
||||||
|
建立与 DTU 装置的 TCP 连接。
|
||||||
|
若有旧连接则先关闭;连接时超时 3 秒;连接成功后设为 2 秒。
|
||||||
|
"""
|
||||||
|
global _sock
|
||||||
|
try:
|
||||||
|
if _sock:
|
||||||
|
_sock.close() # 关闭旧连接
|
||||||
|
logger.info("[connect] 已关闭旧连接")
|
||||||
|
_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 创建 TCP socket
|
||||||
|
_sock.settimeout(3.0) # 连接阶段超时 3 秒
|
||||||
|
_sock.connect((host, port)) # 连接目标主机和端口
|
||||||
|
_sock.settimeout(2.0) # 连接成功后收发超时 2 秒
|
||||||
|
logger.info("[connect] 连接 DTU 成功: {host}:{port}", host=host, port=port)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[connect] 连接失败: {host}:{port} - {err}", host=host, port=port, err=e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _send(cmd: int, data: bytes = b"") -> bool:
|
||||||
|
"""发送一帧到设备"""
|
||||||
|
if not _sock:
|
||||||
|
logger.error("[_send] 未连接设备,无法发送 cmd={cmd:#x}", cmd=cmd)
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
frame = build_frame(cmd, data)
|
||||||
|
_sock.sendall(frame) # 构造帧并一次性发送
|
||||||
|
logger.debug("[_send] 已发送 cmd={cmd:#x}, data_len={length}", cmd=cmd, length=len(data))
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[_send] 发送失败 cmd={cmd:#x} - {err}", cmd=cmd, err=e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _recv() -> Optional[Tuple[int, bytes]]:
|
||||||
|
"""
|
||||||
|
从设备接收一帧。若首次接收长度不足,会再尝试接收最多 2 次(共 3 次),
|
||||||
|
拼齐整帧后再解析。单次 recv 最多 256KB。
|
||||||
|
"""
|
||||||
|
if not _sock:
|
||||||
|
logger.error("[_recv] 未连接设备,无法接收数据")
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
buffer = b""
|
||||||
|
max_attempts = 3 # 首次 + 最多再收 2 次
|
||||||
|
for attempt in range(max_attempts):
|
||||||
|
chunk = _sock.recv(256 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
logger.debug("[_recv] 第 {n} 次 recv 收到空数据,连接可能已关闭", n=attempt + 1)
|
||||||
|
break
|
||||||
|
buffer += chunk
|
||||||
|
if len(buffer) < 5: # 至少需要 5 字节才能解析头
|
||||||
|
logger.debug("[_recv] 第 {n} 次 recv 后共 {len_} 字节,继续接收", n=attempt + 1, len_=len(buffer))
|
||||||
|
continue
|
||||||
|
data_len = (buffer[2] << 8) | buffer[3] # 数据区长度
|
||||||
|
need = 4 + data_len + 1 # 整帧长度
|
||||||
|
if len(buffer) >= need:
|
||||||
|
logger.debug("[_recv] 第 {n} 次 recv 后收齐整帧 len={len_}", n=attempt + 1, len_=need)
|
||||||
|
return parse_frame(buffer[:need])
|
||||||
|
logger.debug(
|
||||||
|
"[_recv] 第 {n} 次 recv 后共 {have} 字节,需要 {need} 字节,继续接收",
|
||||||
|
n=attempt + 1,
|
||||||
|
have=len(buffer),
|
||||||
|
need=need,
|
||||||
|
)
|
||||||
|
if len(buffer) < 5:
|
||||||
|
logger.error("[_recv] 接收数据太短, len={len_}", len_=len(buffer))
|
||||||
|
return None
|
||||||
|
need = 4 + ((buffer[2] << 8) | buffer[3]) + 1
|
||||||
|
logger.error(
|
||||||
|
"[_recv] 尝试 {max_attempts} 次后数据仍不完整, 需要 {need} 字节, 实际 {actual} 字节",
|
||||||
|
max_attempts=max_attempts,
|
||||||
|
need=need,
|
||||||
|
actual=len(buffer),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[_recv] 接收异常 - {err}", err=e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def send_key(code: int) -> bool:
|
||||||
|
"""发送按键码到设备"""
|
||||||
|
return _send(CMD_KEY, bytes([code & 0xFF])) # 按键码单字节
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_screen() -> Optional[bytes]:
|
||||||
|
"""
|
||||||
|
向设备请求当前屏幕显存,返回 1bpp 位图数据。
|
||||||
|
发送 CMD_LCDMEM + 起始地址 0;设备回复 payload 前 4 字节为地址,后续为位图,返回 payload[4:]。
|
||||||
|
"""
|
||||||
|
if not _sock:
|
||||||
|
logger.error("[fetch_screen] 未连接设备,无法读取屏幕")
|
||||||
|
return None
|
||||||
|
if not _send(CMD_LCDMEM, struct.pack(">I", 0)): # 发送读取显存命令,起始地址 0(大端 4 字节)
|
||||||
|
logger.error("[fetch_screen] 发送 CMD_LCDMEM 失败")
|
||||||
|
return None
|
||||||
|
logger.debug("[fetch_screen] 发送 CMD_LCDMEM 成功")
|
||||||
|
result = _recv() # 接收设备回复
|
||||||
|
if not result:
|
||||||
|
logger.error("[fetch_screen] 未收到任何回复(result=None)")
|
||||||
|
return None
|
||||||
|
cmd, payload = result
|
||||||
|
if cmd != CMD_LCDMEM:
|
||||||
|
# 可能是按键 ACK、保活等其他短帧,按键操作时较常见,这里只做调试日志,不视为错误
|
||||||
|
logger.debug("[fetch_screen] 收到非 LCDMEM 帧 cmd={cmd:#x},忽略", cmd=cmd)
|
||||||
|
return None
|
||||||
|
# 确认为 LCDMEM 回复
|
||||||
|
if len(payload) >= 4: # 前 4 字节为地址,后面是位图
|
||||||
|
logger.debug("[fetch_screen] 收到 LCDMEM,payload_len={len_}", len_=len(payload))
|
||||||
|
return payload[4:]
|
||||||
|
logger.debug("[fetch_screen] 收到 LCDMEM,payload_len={len_} (<4)", len_=len(payload))
|
||||||
|
return payload or None
|
||||||
|
|
||||||
|
|
||||||
|
def mono_to_png(data: bytes, width: int, height: int) -> Optional[bytes]:
|
||||||
|
"""
|
||||||
|
将 1bpp 位图转为 PNG 字节流。
|
||||||
|
每字节 8 像素,高位在前;bi=(y*w+x)//8 为字节索引,bit=7-(x%8) 为位索引;1 为白 255,0 为黑 0。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
logger.debug(
|
||||||
|
"[mono_to_png] 开始转换为 PNG,width={width}, height={height}, data_len={len_}",
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
len_=len(data),
|
||||||
|
)
|
||||||
|
img = Image.new("1", (width, height)) # 创建 1bpp 黑白图像
|
||||||
|
pix = img.load() # 获取像素访问对象
|
||||||
|
'''
|
||||||
|
for y in range(height):
|
||||||
|
for x in range(width):
|
||||||
|
bi = (y * width + x) // 8 # 字节索引:每 8 像素一字节
|
||||||
|
bit = 7 - (x % 8) # 位索引:高位在前
|
||||||
|
pix[x, y] = 0 if (bi < len(data) and (data[bi] >> bit) & 1) else 255 # 1→白 0→黑
|
||||||
|
'''
|
||||||
|
for y in range(height):
|
||||||
|
for x in range(width):
|
||||||
|
pix[x, y] = data[y * width + x]
|
||||||
|
buf = io.BytesIO() # 内存缓冲区
|
||||||
|
img.save(buf, format="PNG") # 保存为 PNG 格式
|
||||||
|
png_bytes = buf.getvalue()
|
||||||
|
logger.debug("[mono_to_png] 转换 PNG 成功, png_len={len_}", len_=len(png_bytes))
|
||||||
|
return png_bytes # 返回 PNG 字节流
|
||||||
|
except ImportError:
|
||||||
|
logger.error("[mono_to_png] 缺少 PIL/Pillow, 请执行 pip install pillow")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[mono_to_png] 转换 PNG 失败 - {err}", err=e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def disconnect_device():
|
||||||
|
"""关闭与设备的连接"""
|
||||||
|
global _sock
|
||||||
|
try:
|
||||||
|
if _sock:
|
||||||
|
logger.info("[disconnect_device] 正在关闭与 DTU 的连接")
|
||||||
|
_sock.close() # 关闭 socket
|
||||||
|
_sock = None # 清空引用
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[disconnect_device] 关闭连接时异常 - {err}", err=e)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 屏幕推送线程
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _screen_refresh_loop():
|
||||||
|
"""
|
||||||
|
后台线程:每约 100ms 拉取一帧屏幕,转 PNG 后 base64 推送给各 WebSocket 客户端。
|
||||||
|
若检测到远程服务端断开(连续多次拉取失败),则断开设备并停止重复请求。
|
||||||
|
_refresh_stop.wait(0.1) 既作间隔,也便于收到 set 时快速退出。
|
||||||
|
"""
|
||||||
|
global _screen_clients, _refresh_stop
|
||||||
|
logger.info("[_screen_refresh_loop] 刷新线程启动")
|
||||||
|
# 连续拉取失败次数,超过阈值则认为远程已断开
|
||||||
|
MAX_CONSECUTIVE_FAILURES = 5
|
||||||
|
consecutive_failures = 0
|
||||||
|
try:
|
||||||
|
while not _refresh_stop.is_set(): # 未被要求停止时持续循环
|
||||||
|
if _screen_clients and _sock: # 有客户端且已连接设备
|
||||||
|
data = fetch_screen() # 拉取屏幕位图
|
||||||
|
logger.debug("[fetch_screen] 拉取屏幕位图数据: {data}", data=data)
|
||||||
|
if data:
|
||||||
|
consecutive_failures = 0 # 成功则清零
|
||||||
|
# 当前协议未返回宽高,这里固定为 160x160,可根据实际情况调整
|
||||||
|
w, h = 160, 160
|
||||||
|
png = mono_to_png(data, w, h) # 转为 PNG
|
||||||
|
if png:
|
||||||
|
b64 = base64.b64encode(png).decode("ascii") # base64 编码便于 JSON 传输
|
||||||
|
for sid in list(_screen_clients): # 复制列表避免迭代时修改
|
||||||
|
try:
|
||||||
|
socketio.emit(
|
||||||
|
"screen",
|
||||||
|
{"png": b64, "w": w, "h": h},
|
||||||
|
room=sid,
|
||||||
|
) # 推送给该客户端
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(
|
||||||
|
"[_screen_refresh_loop] 推送 screen 给 {sid} 失败 - {err}",
|
||||||
|
sid=sid,
|
||||||
|
err=e,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 拉取失败,累计连续失败次数
|
||||||
|
consecutive_failures += 1
|
||||||
|
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
||||||
|
logger.warning(
|
||||||
|
"[_screen_refresh_loop] 连续 {n} 次拉取失败,判定远程服务端已断开,停止请求并断开连接",
|
||||||
|
n=consecutive_failures,
|
||||||
|
)
|
||||||
|
disconnect_device()
|
||||||
|
# 通知所有订阅客户端:设备已断开
|
||||||
|
for sid in list(_screen_clients):
|
||||||
|
try:
|
||||||
|
socketio.emit("device_disconnected", {"reason": "remote_closed"}, room=sid)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("[_screen_refresh_loop] 推送 device_disconnected 失败 sid={sid} - {err}", sid=sid, err=e)
|
||||||
|
consecutive_failures = 0
|
||||||
|
else:
|
||||||
|
consecutive_failures = 0 # 无客户端或未连接时清零,便于下次连接后重新计数
|
||||||
|
_refresh_stop.wait(timeout=0.1) # 等待 100ms,若被 set 则立即返回
|
||||||
|
finally:
|
||||||
|
logger.info("[_screen_refresh_loop] 刷新线程退出")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Flask + SocketIO
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
app = Flask(__name__) # 创建 Flask 应用
|
||||||
|
app.config["SECRET_KEY"] = "remo_disp" # 会话密钥
|
||||||
|
socketio = SocketIO(app, cors_allowed_origins="*") # 创建 SocketIO,允许跨域
|
||||||
|
|
||||||
|
FAVICON_PATH = os.path.join(_get_base_path(), "static", "favicon.ico") # favicon 文件路径
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/favicon.ico")
|
||||||
|
def favicon():
|
||||||
|
"""提供网站图标"""
|
||||||
|
if os.path.exists(FAVICON_PATH):
|
||||||
|
return send_file(FAVICON_PATH, mimetype="image/x-icon")
|
||||||
|
return "", 404 # 不存在则返回 404
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
@app.route("/index.html")
|
||||||
|
def index():
|
||||||
|
"""主页,返回前端 HTML"""
|
||||||
|
return send_file(
|
||||||
|
os.path.join(_get_base_path(), "remo_disp_ui.html"),
|
||||||
|
mimetype="text/html; charset=utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on("connect")
|
||||||
|
def on_connect():
|
||||||
|
"""WebSocket 客户端连接时触发(可留空)"""
|
||||||
|
logger.info("[on_connect] WebSocket 客户端已连接 sid={sid}", sid=request.sid)
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on("disconnect")
|
||||||
|
def on_disconnect():
|
||||||
|
"""WebSocket 客户端断开(关标签页等)时,从 _screen_clients 移除,若无客户端则断开设备"""
|
||||||
|
global _screen_clients, _refresh_thread, _refresh_stop
|
||||||
|
sid = request.sid # 获取断开客户端的 session id
|
||||||
|
logger.info("[on_disconnect] WebSocket 客户端断开 sid={sid}", sid=sid)
|
||||||
|
if sid in _screen_clients:
|
||||||
|
_screen_clients.discard(sid) # 从订阅列表移除
|
||||||
|
if not _screen_clients: # 若无其他客户端
|
||||||
|
_refresh_stop.set() # 通知刷新线程停止
|
||||||
|
disconnect_device() # 断开设备连接
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on("connect_device")
|
||||||
|
def on_connect_device(data):
|
||||||
|
"""前端请求连接设备:连接成功后加入 _screen_clients,启动刷新线程,回复 connect_result"""
|
||||||
|
global _screen_clients, _refresh_thread, _refresh_stop
|
||||||
|
host = (data.get("host") or "").strip() # 从 data 取 host,去空格
|
||||||
|
port = int(data.get("port") or PORT) # 从 data 取 port,缺省 7003
|
||||||
|
logger.info("[on_connect_device] 请求连接 DTU: {host}:{port}", host=host, port=port)
|
||||||
|
ok = connect(host, port) # 建立 TCP 连接
|
||||||
|
if ok:
|
||||||
|
_screen_clients.add(request.sid) # 将当前客户端加入屏幕订阅
|
||||||
|
_refresh_stop.clear() # 清除停止标志,允许刷新线程运行
|
||||||
|
if _refresh_thread is None or not _refresh_thread.is_alive(): # 刷新线程未运行
|
||||||
|
_refresh_thread = threading.Thread(target=_screen_refresh_loop, daemon=True) # 创建后台线程
|
||||||
|
_refresh_thread.start() # 启动线程
|
||||||
|
logger.info("[on_connect_device] 已启动屏幕刷新线程")
|
||||||
|
emit("connect_result", {"success": True}) # 回复连接成功
|
||||||
|
logger.info("[on_connect_device] 连接 DTU 成功,已加入订阅 sid={sid}", sid=request.sid)
|
||||||
|
else:
|
||||||
|
logger.error("[on_connect_device] 连接 DTU 失败: {host}:{port}", host=host, port=port)
|
||||||
|
emit("connect_result", {"success": False, "error": "connect failed"}) # 回复连接失败
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on("disconnect_device")
|
||||||
|
def on_disconnect_device():
|
||||||
|
"""前端请求断开设备:从 _screen_clients 移除,若无客户端则断开设备,回复 disconnect_result"""
|
||||||
|
global _screen_clients, _refresh_stop
|
||||||
|
sid = request.sid
|
||||||
|
logger.info("[on_disconnect_device] 前端请求断开设备 sid={sid}", sid=sid)
|
||||||
|
_screen_clients.discard(sid) # 从订阅列表移除
|
||||||
|
if not _screen_clients: # 若无其他客户端
|
||||||
|
_refresh_stop.set() # 通知刷新线程停止
|
||||||
|
disconnect_device() # 断开设备连接
|
||||||
|
emit("disconnect_result", {"ok": True}) # 回复断开成功
|
||||||
|
logger.info("[on_disconnect_device] 设备已断开(若为最后一个客户端)")
|
||||||
|
|
||||||
|
|
||||||
|
@socketio.on("key")
|
||||||
|
def on_key(data):
|
||||||
|
"""前端发送按键:从 data 取 code,转发给设备"""
|
||||||
|
code = int(data.get("code", 0)) # 按键码,缺省 0
|
||||||
|
logger.debug("[on_key] 收到按键 code={code:#x}", code=code)
|
||||||
|
send_key(code) # 发送给 DTU 设备
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 启动
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _configure_log_level():
|
||||||
|
"""根据全局 LOG_LEVEL 配置日志级别(见文件顶部 LOG_LEVEL 常量)。"""
|
||||||
|
level = LOG_LEVEL.upper() if isinstance(LOG_LEVEL, str) else "INFO"
|
||||||
|
if level not in ("TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
|
||||||
|
level = "INFO"
|
||||||
|
logger.remove() # 移除默认的 stderr 输出
|
||||||
|
logger.add(sys.stderr, level=level, format="<green>{time:HH:mm:ss}</green> | <level>{level: <8}</level> | <level>{message}</level>")
|
||||||
|
logger.add(
|
||||||
|
"remo_disp.log",
|
||||||
|
rotation="1 week",
|
||||||
|
encoding="utf-8",
|
||||||
|
enqueue=True,
|
||||||
|
backtrace=True,
|
||||||
|
diagnose=False,
|
||||||
|
level=level,
|
||||||
|
)
|
||||||
|
return level
|
||||||
|
|
||||||
|
|
||||||
|
@logger.catch
|
||||||
|
def main():
|
||||||
|
"""主入口:启动 Web 服务"""
|
||||||
|
port = 8181 # HTTP 服务端口
|
||||||
|
log_level = _configure_log_level()
|
||||||
|
logger.info("日志级别: {level}", level=log_level)
|
||||||
|
logger.info("远程显示服务: http://localhost:{port}", port=port)
|
||||||
|
logger.info("使用 WebSocket 传输,在浏览器中打开上述地址")
|
||||||
|
if len(sys.argv) >= 2:
|
||||||
|
host = sys.argv[1]
|
||||||
|
logger.info("[main] 尝试预连接 DTU: {host}", host=host)
|
||||||
|
if connect(host): # 若命令行有 IP,预连接
|
||||||
|
logger.info("[main] 已预连接: {host}", host=host)
|
||||||
|
else:
|
||||||
|
logger.error("[main] 预连接失败: {host}", host=host)
|
||||||
|
socketio.run(app, host="0.0.0.0", port=port, debug=True, allow_unsafe_werkzeug=True) # 监听所有网卡
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
63
remo_disp_server.spec
Normal file
63
remo_disp_server.spec
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
# PyInstaller 打包配置:python -m PyInstaller remo_disp_server.spec
|
||||||
|
# 打包后运行 dist\remo_disp_server\remo_disp_server.exe [装置IP]
|
||||||
|
|
||||||
|
block_cipher = None
|
||||||
|
|
||||||
|
# 需要随 exe 一起打包的数据文件(HTML、静态资源)
|
||||||
|
added_files = [
|
||||||
|
('remo_disp_ui.html', '.'),
|
||||||
|
]
|
||||||
|
# 若存在 static 目录则打包(favicon 等)
|
||||||
|
import os as _os
|
||||||
|
if _os.path.isdir('static'):
|
||||||
|
added_files.append(('static', 'static'))
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
['remo_disp_server.py'],
|
||||||
|
pathex=[],
|
||||||
|
binaries=[],
|
||||||
|
datas=added_files,
|
||||||
|
hiddenimports=[
|
||||||
|
'engineio.async_drivers.threading',
|
||||||
|
'flask_socketio',
|
||||||
|
'python_engineio',
|
||||||
|
'python_socketio',
|
||||||
|
'werkzeug',
|
||||||
|
'PIL',
|
||||||
|
'PIL._tkinter_finder',
|
||||||
|
'loguru',
|
||||||
|
],
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[],
|
||||||
|
win_no_prefer_redirects=False,
|
||||||
|
win_private_assemblies=False,
|
||||||
|
cipher=block_cipher,
|
||||||
|
noarchive=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.zipfiles,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name='remo_disp_server',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=True,
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
)
|
||||||
481
remo_disp_ui.html
Normal file
481
remo_disp_ui.html
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!-- 文档类型声明:HTML5 -->
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<!-- 根元素,语言设为简体中文 -->
|
||||||
|
<head>
|
||||||
|
<!-- 头部:元数据与资源 -->
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<!-- 字符编码:UTF-8 -->
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<!-- 视口:适配移动端,初始缩放 1 -->
|
||||||
|
<title>远程显示</title>
|
||||||
|
<!-- 页面标题 -->
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||||
|
<!-- 网站图标 -->
|
||||||
|
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
|
||||||
|
<!-- Socket.IO 客户端库,用于 WebSocket 通信 -->
|
||||||
|
<style>
|
||||||
|
/* ========== 全局样式 ========== */
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
/* 通配符:盒模型为 border-box,去除默认边距 */
|
||||||
|
body {
|
||||||
|
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
|
||||||
|
/* 字体:微软雅黑、苹方,无衬线回退 */
|
||||||
|
background: linear-gradient(135deg, #e8f4fc 0%, #f0f8ff 50%, #e6f2ff 100%);
|
||||||
|
/* 背景:135 度渐变,浅蓝到淡蓝 */
|
||||||
|
min-height: 100vh;
|
||||||
|
/* 最小高度为视口高度 */
|
||||||
|
padding: 16px;
|
||||||
|
/* 内边距 */
|
||||||
|
}
|
||||||
|
/* ========== 主容器 ========== */
|
||||||
|
.app {
|
||||||
|
max-width: 900px;
|
||||||
|
/* 最大宽度限制 */
|
||||||
|
margin: 0 auto;
|
||||||
|
/* 水平居中 */
|
||||||
|
background: #fff;
|
||||||
|
/* 白色背景 */
|
||||||
|
border-radius: 12px;
|
||||||
|
/* 圆角 */
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||||
|
/* 阴影 */
|
||||||
|
overflow: hidden;
|
||||||
|
/* 子元素超出则裁剪 */
|
||||||
|
}
|
||||||
|
/* ========== 顶部菜单栏 ========== */
|
||||||
|
.menu-bar {
|
||||||
|
display: flex;
|
||||||
|
/* 弹性布局 */
|
||||||
|
align-items: center;
|
||||||
|
/* 垂直居中 */
|
||||||
|
justify-content: center;
|
||||||
|
/* 水平居中 */
|
||||||
|
gap: 8px;
|
||||||
|
/* 子元素间距 */
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
/* 底部分割线 */
|
||||||
|
}
|
||||||
|
.menu-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* 纵向排列图标和文字 */
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
/* 鼠标指针为手型 */
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
/* 过渡动画 */
|
||||||
|
color: #333;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.menu-item:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
/* 悬停时浅灰背景 */
|
||||||
|
}
|
||||||
|
.menu-item .icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
/* 图标区域 */
|
||||||
|
}
|
||||||
|
.menu-item.connect .icon { color: #22c55e; }
|
||||||
|
/* 连接按钮图标:绿色 */
|
||||||
|
.menu-item.settings .icon { color: #3b82f6; }
|
||||||
|
/* 设置按钮图标:蓝色 */
|
||||||
|
.menu-item.exit .icon { color: #ef4444; }
|
||||||
|
/* 断开按钮图标:红色 */
|
||||||
|
.menu-item.about .icon { color: #64748b; }
|
||||||
|
/* 关于按钮图标:灰色 */
|
||||||
|
.menu-item.connected .icon { color: #22c55e; }
|
||||||
|
.menu-item.connected { color: #22c55e; }
|
||||||
|
/* 已连接状态:绿色 */
|
||||||
|
/* ========== 主内容区 ========== */
|
||||||
|
.main {
|
||||||
|
display: flex;
|
||||||
|
padding: 16px;
|
||||||
|
gap: 20px;
|
||||||
|
/* 左右分栏间距 */
|
||||||
|
}
|
||||||
|
/* ========== 显示区域 ========== */
|
||||||
|
.display-area {
|
||||||
|
flex: 1;
|
||||||
|
/* 占据剩余空间 */
|
||||||
|
min-width: 0;
|
||||||
|
/* 允许 flex 子项收缩 */
|
||||||
|
aspect-ratio: 1;
|
||||||
|
/* 宽高比 1:1 */
|
||||||
|
max-height: 420px;
|
||||||
|
background: #1e3a5f;
|
||||||
|
/* 深蓝背景,模拟 LCD 边框 */
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: inset 0 2px 8px rgba(0,0,0,0.2);
|
||||||
|
/* 内阴影 */
|
||||||
|
}
|
||||||
|
.display-area canvas {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
/* canvas 自适应容器 */
|
||||||
|
image-rendering: pixelated;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
/* 像素风格渲染,避免模糊 */
|
||||||
|
}
|
||||||
|
.display-placeholder {
|
||||||
|
color: rgba(255,255,255,0.5);
|
||||||
|
/* 半透明白色提示文字 */
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
/* ========== 右侧控制面板 ========== */
|
||||||
|
.control-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
/* 垂直方向居中,与左侧显示区对齐 */
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px;
|
||||||
|
align-self: stretch;
|
||||||
|
/* 与 .main 同高,便于内容居中 */
|
||||||
|
}
|
||||||
|
/* ========== D-pad 方向键 ========== */
|
||||||
|
.dpad {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 50px 50px 50px;
|
||||||
|
grid-template-rows: 50px 50px 50px;
|
||||||
|
gap: 4px;
|
||||||
|
/* 3x3 网格布局 */
|
||||||
|
}
|
||||||
|
.dpad .btn {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(180deg, #f8f9fa 0%, #e9ecef 100%);
|
||||||
|
/* 渐变背景 */
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #22c55e;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.dpad .btn:hover {
|
||||||
|
background: linear-gradient(180deg, #fff 0%, #f1f3f5 100%);
|
||||||
|
box-shadow: 0 3px 6px rgba(0,0,0,0.15);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
/* 悬停上浮效果 */
|
||||||
|
}
|
||||||
|
.dpad .btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||||
|
/* 按下时恢复 */
|
||||||
|
}
|
||||||
|
.dpad .btn-up { grid-column: 2; grid-row: 1; }
|
||||||
|
/* 上键:中上 */
|
||||||
|
.dpad .btn-down { grid-column: 2; grid-row: 3; }
|
||||||
|
/* 下键:中下 */
|
||||||
|
.dpad .btn-left { grid-column: 1; grid-row: 2; }
|
||||||
|
/* 左键:左中 */
|
||||||
|
.dpad .btn-right { grid-column: 3; grid-row: 2; }
|
||||||
|
/* 右键:右中 */
|
||||||
|
.dpad .btn-ok { grid-column: 2; grid-row: 2; }
|
||||||
|
/* 确认键:正中 */
|
||||||
|
/* ========== 动作按钮 ========== */
|
||||||
|
.action-btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(180deg, #f8f9fa 0%, #e9ecef 100%);
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.action-btn:hover {
|
||||||
|
background: linear-gradient(180deg, #fff 0%, #f1f3f5 100%);
|
||||||
|
box-shadow: 0 3px 6px rgba(0,0,0,0.15);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.action-btn .icon { color: #22c55e; font-size: 16px; }
|
||||||
|
/* ========== 连接设置弹窗 ========== */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
/* 默认隐藏 */
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
/* 全屏覆盖 */
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
/* 半透明黑色遮罩 */
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.modal.show { display: flex; }
|
||||||
|
/* 显示时用 flex 居中 */
|
||||||
|
.modal-content {
|
||||||
|
background: #fff;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
|
||||||
|
min-width: 320px;
|
||||||
|
}
|
||||||
|
.modal-content h3 { margin-bottom: 16px; font-size: 16px; }
|
||||||
|
.modal-content input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.modal-content .btn-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.modal-content button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.modal-content .btn-primary {
|
||||||
|
background: #22c55e;
|
||||||
|
color: #fff;
|
||||||
|
/* 主按钮:绿色 */
|
||||||
|
}
|
||||||
|
.modal-content .btn-secondary {
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #333;
|
||||||
|
/* 次要按钮:灰色 */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- 页面主体 -->
|
||||||
|
<div class="app">
|
||||||
|
<!-- 主应用容器 -->
|
||||||
|
<div class="menu-bar">
|
||||||
|
<!-- 顶部菜单栏 -->
|
||||||
|
<button class="menu-item connect" id="btnConnect" title="连接">
|
||||||
|
<span class="icon">📶</span>
|
||||||
|
<span>连接</span>
|
||||||
|
</button>
|
||||||
|
<button class="menu-item settings" title="设置">
|
||||||
|
<span class="icon">⚙</span>
|
||||||
|
<span>设置</span>
|
||||||
|
</button>
|
||||||
|
<button class="menu-item exit" title="断开">
|
||||||
|
<span class="icon">⏻</span>
|
||||||
|
<span>断开</span>
|
||||||
|
</button>
|
||||||
|
<button class="menu-item about" id="btnAbout" title="关于">
|
||||||
|
<span class="icon">ℹ</span>
|
||||||
|
<span>关于</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="main">
|
||||||
|
<!-- 主内容区:显示 + 控制 -->
|
||||||
|
<div class="display-area">
|
||||||
|
<!-- 屏幕显示区域 -->
|
||||||
|
<canvas id="screen" style="display:none;"></canvas>
|
||||||
|
<!-- canvas 初始隐藏,有画面后显示 -->
|
||||||
|
<span class="display-placeholder" id="placeholder">点击「连接」连接装置</span>
|
||||||
|
<!-- 未连接时的提示文字 -->
|
||||||
|
</div>
|
||||||
|
<div class="control-panel">
|
||||||
|
<!-- 右侧控制面板 -->
|
||||||
|
<div class="dpad">
|
||||||
|
<!-- 方向键 + 确认 -->
|
||||||
|
<button class="btn btn-up" data-key="U">▲</button>
|
||||||
|
<button class="btn btn-left" data-key="L">◀</button>
|
||||||
|
<button class="btn btn-ok" data-key="ENT">✓</button>
|
||||||
|
<button class="btn btn-right" data-key="R">▶</button>
|
||||||
|
<button class="btn btn-down" data-key="D">▼</button>
|
||||||
|
</div>
|
||||||
|
<div class="action-btns">
|
||||||
|
<!-- 复归、返回按钮 -->
|
||||||
|
<button class="action-btn" data-key="RESET">
|
||||||
|
<span class="icon">↺</span>
|
||||||
|
<span>复归</span>
|
||||||
|
</button>
|
||||||
|
<button class="action-btn" data-key="ESC">
|
||||||
|
<span class="icon">↶</span>
|
||||||
|
<span>返回</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 连接弹窗 -->
|
||||||
|
<div class="modal" id="connectModal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h3>连接装置</h3>
|
||||||
|
<input type="text" id="hostInput" placeholder="装置 IP 地址" value="127.0.0.1">
|
||||||
|
<input type="number" id="portInput" placeholder="端口" value="7003">
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn-secondary" id="modalCancel">取消</button>
|
||||||
|
<button class="btn-primary" id="modalConnect">连接</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* 前端逻辑:通过 Socket.IO 与后端 WebSocket 通信。
|
||||||
|
* - connect_device: 连接装置,成功后后端持续推送 screen 事件
|
||||||
|
* - disconnect_device: 断开装置
|
||||||
|
* - key: 发送按键码
|
||||||
|
*/
|
||||||
|
// ========== 常量与 DOM 引用 ==========
|
||||||
|
// 按键名 -> RemoDispBus 协议码:U/D/L/R 方向,ENT 确认,ESC 返回,RESET 复归
|
||||||
|
const KEY_MAP = { U: 0x02, D: 0x40, L: 0x10, R: 0x08, ENT: 0x20, ESC: 0x01, RESET: 0x04 };
|
||||||
|
const canvas = document.getElementById('screen');
|
||||||
|
// canvas 元素,用于绘制远程屏幕
|
||||||
|
const placeholder = document.getElementById('placeholder');
|
||||||
|
// 占位提示文字
|
||||||
|
const connectBtn = document.getElementById('btnConnect');
|
||||||
|
// 连接按钮
|
||||||
|
const connectModal = document.getElementById('connectModal');
|
||||||
|
// 连接弹窗
|
||||||
|
const hostInput = document.getElementById('hostInput');
|
||||||
|
// IP 输入框
|
||||||
|
const portInput = document.getElementById('portInput');
|
||||||
|
// 端口输入框
|
||||||
|
const modalConnect = document.getElementById('modalConnect');
|
||||||
|
// 弹窗内「连接」按钮
|
||||||
|
const modalCancel = document.getElementById('modalCancel');
|
||||||
|
// 弹窗内「取消」按钮
|
||||||
|
|
||||||
|
// ========== WebSocket 连接 ==========
|
||||||
|
const socket = io();
|
||||||
|
// 连接当前页面的 origin,建立 Socket.IO 连接
|
||||||
|
|
||||||
|
socket.on('connect', () => {});
|
||||||
|
// 连接成功时(可留空)
|
||||||
|
socket.on('connect_result', (data) => {
|
||||||
|
// 收到后端连接结果
|
||||||
|
if (data.success) {
|
||||||
|
hideConnectModal();
|
||||||
|
// 关闭弹窗
|
||||||
|
connectBtn.classList.add('connected');
|
||||||
|
// 标记为已连接状态
|
||||||
|
connectBtn.querySelector('span:last-child').textContent = '已连接';
|
||||||
|
// 更新按钮文字
|
||||||
|
} else {
|
||||||
|
alert('连接失败: ' + (data.error || '未知错误'));
|
||||||
|
// 弹出错误提示
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 服务端推送屏幕:data.png 为 base64 编码的 PNG,解码后绘制到 canvas
|
||||||
|
socket.on('screen', (data) => {
|
||||||
|
if (!data.png) return;
|
||||||
|
// 无 base64 数据则跳过
|
||||||
|
const binary = atob(data.png);
|
||||||
|
// base64 解码为二进制字符串
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||||
|
// 转为 Uint8Array
|
||||||
|
const blob = new Blob([bytes], { type: 'image/png' });
|
||||||
|
// 创建 PNG Blob
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
// 创建临时 URL
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
// 图片加载完成后,放大一倍显示(160x160 -> 320x320)
|
||||||
|
const scale = 2;
|
||||||
|
canvas.width = img.width * scale;
|
||||||
|
canvas.height = img.height * scale;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
// 关闭平滑,像素清晰放大
|
||||||
|
ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, canvas.width, canvas.height);
|
||||||
|
// 绘制到 canvas(2 倍尺寸)
|
||||||
|
canvas.style.display = 'block';
|
||||||
|
placeholder.style.display = 'none';
|
||||||
|
// 显示 canvas,隐藏占位符
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
// 释放临时 URL
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
// 触发加载
|
||||||
|
});
|
||||||
|
socket.on('disconnect_result', () => {});
|
||||||
|
// 收到断开结果(可留空)
|
||||||
|
|
||||||
|
function showConnectModal() { connectModal.classList.add('show'); }
|
||||||
|
// 显示连接弹窗
|
||||||
|
function hideConnectModal() { connectModal.classList.remove('show'); }
|
||||||
|
// 隐藏连接弹窗
|
||||||
|
connectBtn.onclick = showConnectModal;
|
||||||
|
// 点击连接按钮 -> 显示弹窗
|
||||||
|
modalCancel.onclick = hideConnectModal;
|
||||||
|
// 点击取消 -> 隐藏弹窗
|
||||||
|
|
||||||
|
modalConnect.onclick = () => {
|
||||||
|
// 点击弹窗内「连接」按钮
|
||||||
|
const host = hostInput.value.trim();
|
||||||
|
// 获取 IP,去空格
|
||||||
|
const port = portInput.value || '7003';
|
||||||
|
// 获取端口,缺省 7003
|
||||||
|
socket.emit('connect_device', { host, port });
|
||||||
|
// 向后端发送连接请求
|
||||||
|
};
|
||||||
|
|
||||||
|
function sendKey(key) {
|
||||||
|
// 发送按键到后端
|
||||||
|
if (!connectBtn.classList.contains('connected')) return;
|
||||||
|
// 未连接则不发送
|
||||||
|
socket.emit('key', { code: KEY_MAP[key] ?? 0 });
|
||||||
|
// 根据按键名查协议码并发送
|
||||||
|
}
|
||||||
|
|
||||||
|
function doDisconnect() {
|
||||||
|
// 执行断开
|
||||||
|
socket.emit('disconnect_device');
|
||||||
|
// 通知后端断开设备
|
||||||
|
connectBtn.classList.remove('connected');
|
||||||
|
connectBtn.querySelector('span:last-child').textContent = '连接';
|
||||||
|
// 恢复按钮状态和文字
|
||||||
|
canvas.style.display = 'none';
|
||||||
|
placeholder.style.display = '';
|
||||||
|
placeholder.textContent = '点击「连接」连接装置';
|
||||||
|
// 隐藏 canvas,显示占位符
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.dpad .btn, .action-btn').forEach(btn => {
|
||||||
|
// 为所有方向键和动作按钮绑定点击
|
||||||
|
btn.onclick = () => sendKey(btn.dataset.key);
|
||||||
|
// 点击时发送对应按键码
|
||||||
|
});
|
||||||
|
document.querySelector('.menu-item.exit').onclick = doDisconnect;
|
||||||
|
// 断开按钮 -> 执行断开
|
||||||
|
document.getElementById('btnAbout').onclick = () => alert('阜阳师范大学物理与电子工程学院\nDTU 远程液晶屏幕通信工具');
|
||||||
|
// 关于按钮 -> 弹出说明
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Dependencies for DTU-RemoteLCD web service
|
||||||
|
Flask>=2.0.0
|
||||||
|
Pillow>=9.0.0
|
||||||
|
flask-socketio>=5.0.0
|
||||||
|
python-socketio>=5.0.0
|
||||||
|
loguru>=0.7.0
|
||||||
BIN
static/favicon.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.9 MiB |
Reference in New Issue
Block a user