Compare commits
3 Commits
9f54a0cb2e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f7898adc5d | |||
| fb2d4ffc00 | |||
| b47962e5c8 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -11,3 +11,9 @@
|
||||
*.app
|
||||
.snapshots/*
|
||||
|
||||
# PyInstaller 打包输出
|
||||
dist/
|
||||
build/
|
||||
*.spec
|
||||
remo_disp.log
|
||||
|
||||
|
||||
43
README.md
43
README.md
@@ -43,11 +43,34 @@ python remo_disp_server.py [装置IP]
|
||||
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/ # 静态资源(可选)
|
||||
@@ -55,9 +78,7 @@ DTU-RemoteLCD/
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 协议说明(RemoDispBus)
|
||||
|
||||
与 `HMI/RemoeDisp/RemoDispBus.c` 兼容:
|
||||
## 协议说明
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
@@ -66,6 +87,13 @@ DTU-RemoteLCD/
|
||||
| **装置 → 工具** | `[0xBB][功能码][长度高][长度低][数据...][CRC]` |
|
||||
| **CRC** | 数据区逐字节异或,取低 8 位 |
|
||||
|
||||
```
|
||||
刷新屏幕请求
|
||||
AA 03 00 04 00 00 00 00 00
|
||||
```
|
||||
|
||||
|
||||
|
||||
**功能码**:
|
||||
|
||||
| 码 | 名称 | 说明 |
|
||||
@@ -96,6 +124,15 @@ DTU-RemoteLCD/
|
||||
| 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`,方便线下排查问题。
|
||||
|
||||
## 配置说明
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
@@ -8,7 +8,9 @@
|
||||
|
||||
用法: python remo_disp_server.py [装置IP]
|
||||
启动后访问 http://localhost:8181
|
||||
依赖: pip install flask flask-socketio pillow
|
||||
依赖: pip install flask flask-socketio pillow loguru
|
||||
|
||||
日志级别: 在代码中设置 LOG_LEVEL(见下方),"INFO" 不显示 DEBUG,"DEBUG" 显示调试信息。
|
||||
"""
|
||||
|
||||
# base64:将二进制 PNG 编码为字符串,便于通过 JSON/WebSocket 传输
|
||||
@@ -21,12 +23,21 @@ import struct
|
||||
import threading
|
||||
# sys:读取命令行参数(装置 IP)
|
||||
import sys
|
||||
# os:获取当前脚本目录,拼接 favicon 路径
|
||||
# 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
|
||||
@@ -47,6 +58,13 @@ PORT = 7003
|
||||
# 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"
|
||||
|
||||
# =============================================================================
|
||||
# 全局状态
|
||||
# =============================================================================
|
||||
@@ -90,19 +108,29 @@ 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) < 6 or raw[0] != TAG_DEVICE: # 至少 6 字节且帧头为 TAG_DEVICE
|
||||
print("[parse_frame] 错误: 帧太短或帧头不是 TAG_DEVICE(0xBB), len=%d" % len(raw))
|
||||
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字节)
|
||||
print("[parse_frame] 错误: 数据不完整, 需要 %d 字节, 实际 %d 字节" % (4 + length + 1, len(raw)))
|
||||
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 校验失败
|
||||
print("[parse_frame] 错误: CRC 校验失败")
|
||||
logger.error("[parse_frame] CRC 校验失败")
|
||||
return None
|
||||
return (raw[1], data) # 返回 (命令码, 数据)
|
||||
cmd = raw[1]
|
||||
logger.debug("[parse_frame] 成功解析帧 cmd={cmd:#x}, length={length}", cmd=cmd, length=length)
|
||||
return (cmd, data) # 返回 (命令码, 数据)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -118,49 +146,77 @@ def connect(host: str, port: int = PORT) -> bool:
|
||||
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:
|
||||
print("[connect] 错误: 连接失败 - %s" % 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:
|
||||
print("[_send] 错误: 未连接设备")
|
||||
logger.error("[_send] 未连接设备,无法发送 cmd={cmd:#x}", cmd=cmd)
|
||||
return False
|
||||
try:
|
||||
_sock.sendall(build_frame(cmd, data)) # 构造帧并一次性发送
|
||||
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:
|
||||
print("[_send] 错误: 发送失败 - %s" % e)
|
||||
logger.exception("[_send] 发送失败 cmd={cmd:#x} - {err}", cmd=cmd, err=e)
|
||||
return False
|
||||
|
||||
|
||||
def _recv() -> Optional[Tuple[int, bytes]]:
|
||||
"""
|
||||
从设备接收一帧,一次性 recv 最多 256KB。
|
||||
至少需 5 字节(4 字节头+1 字节数据或 CRC);根据头中 length 检查是否收齐整帧。
|
||||
从设备接收一帧。若首次接收长度不足,会再尝试接收最多 2 次(共 3 次),
|
||||
拼齐整帧后再解析。单次 recv 最多 256KB。
|
||||
"""
|
||||
if not _sock:
|
||||
print("[_recv] 错误: 未连接设备")
|
||||
logger.error("[_recv] 未连接设备,无法接收数据")
|
||||
return None
|
||||
try:
|
||||
data = _sock.recv(256 * 1024) # 单次接收最多 256KB
|
||||
if len(data) < 5: # 至少需要 5 字节(4 头 + 1 数据/CRC)
|
||||
print("[_recv] 错误: 接收数据太短, len=%d" % len(data))
|
||||
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
|
||||
length = (data[2] << 8) | data[3] # 解析数据区长度
|
||||
if len(data) < 4 + length + 1: # 数据不完整
|
||||
print("[_recv] 错误: 数据不完整, 需要 %d 字节, 实际 %d 字节" % (4 + length + 1, len(data)))
|
||||
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
|
||||
return parse_frame(data[:4 + length + 1]) # 解析并返回 (cmd, data)
|
||||
except Exception as e:
|
||||
print("[_recv] 错误: 接收异常 - %s" % e)
|
||||
logger.exception("[_recv] 接收异常 - {err}", err=e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -175,19 +231,27 @@ def fetch_screen() -> Optional[bytes]:
|
||||
发送 CMD_LCDMEM + 起始地址 0;设备回复 payload 前 4 字节为地址,后续为位图,返回 payload[4:]。
|
||||
"""
|
||||
if not _sock:
|
||||
print("[fetch_screen] 错误: 未连接设备")
|
||||
logger.error("[fetch_screen] 未连接设备,无法读取屏幕")
|
||||
return None
|
||||
if not _send(CMD_LCDMEM, struct.pack(">I", 0)): # 发送读取显存命令,起始地址 0(大端 4 字节)
|
||||
print("[fetch_screen] 错误: 发送 CMD_LCDMEM 失败")
|
||||
logger.error("[fetch_screen] 发送 CMD_LCDMEM 失败")
|
||||
return None
|
||||
logger.debug("[fetch_screen] 发送 CMD_LCDMEM 成功")
|
||||
result = _recv() # 接收设备回复
|
||||
if result and result[0] == CMD_LCDMEM: # 确认为 LCDMEM 回复
|
||||
payload = result[1] # 数据区
|
||||
if len(payload) >= 4: # 前 4 字节为地址,后面是位图
|
||||
return payload[4:]
|
||||
return payload or None
|
||||
print("[fetch_screen] 错误: 未收到有效 LCDMEM 回复")
|
||||
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]:
|
||||
@@ -197,18 +261,34 @@ def mono_to_png(data: bytes, width: int, height: int) -> Optional[bytes]:
|
||||
"""
|
||||
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] = 255 if (bi < len(data) and (data[bi] >> bit) & 1) else 0 # 1→白 0→黑
|
||||
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 格式
|
||||
return buf.getvalue() # 返回 PNG 字节流
|
||||
png_bytes = buf.getvalue()
|
||||
logger.debug("[mono_to_png] 转换 PNG 成功, png_len={len_}", len_=len(png_bytes))
|
||||
return png_bytes # 返回 PNG 字节流
|
||||
except ImportError:
|
||||
print("[mono_to_png] 错误: 缺少 PIL/Pillow, 请执行 pip install pillow")
|
||||
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
|
||||
|
||||
|
||||
@@ -217,10 +297,11 @@ def disconnect_device():
|
||||
global _sock
|
||||
try:
|
||||
if _sock:
|
||||
logger.info("[disconnect_device] 正在关闭与 DTU 的连接")
|
||||
_sock.close() # 关闭 socket
|
||||
_sock = None # 清空引用
|
||||
except Exception as e:
|
||||
print("[disconnect_device] 错误: 关闭连接时异常 - %s" % e)
|
||||
logger.exception("[disconnect_device] 关闭连接时异常 - {err}", err=e)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -230,24 +311,60 @@ def disconnect_device():
|
||||
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:
|
||||
info = {"width": 160, "height": 160} # 设置长宽 160x160
|
||||
w, h = info.get("width", 160), info.get("height", 160)
|
||||
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:
|
||||
pass
|
||||
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] 刷新线程退出")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -258,7 +375,7 @@ app = Flask(__name__) # 创建 Flask 应用
|
||||
app.config["SECRET_KEY"] = "remo_disp" # 会话密钥
|
||||
socketio = SocketIO(app, cors_allowed_origins="*") # 创建 SocketIO,允许跨域
|
||||
|
||||
FAVICON_PATH = os.path.join(os.path.dirname(__file__), "static", "favicon.ico") # favicon 文件路径
|
||||
FAVICON_PATH = os.path.join(_get_base_path(), "static", "favicon.ico") # favicon 文件路径
|
||||
|
||||
|
||||
@app.route("/favicon.ico")
|
||||
@@ -274,7 +391,7 @@ def favicon():
|
||||
def index():
|
||||
"""主页,返回前端 HTML"""
|
||||
return send_file(
|
||||
os.path.join(os.path.dirname(__file__), "remo_disp_ui.html"),
|
||||
os.path.join(_get_base_path(), "remo_disp_ui.html"),
|
||||
mimetype="text/html; charset=utf-8",
|
||||
)
|
||||
|
||||
@@ -282,7 +399,7 @@ def index():
|
||||
@socketio.on("connect")
|
||||
def on_connect():
|
||||
"""WebSocket 客户端连接时触发(可留空)"""
|
||||
pass
|
||||
logger.info("[on_connect] WebSocket 客户端已连接 sid={sid}", sid=request.sid)
|
||||
|
||||
|
||||
@socketio.on("disconnect")
|
||||
@@ -290,6 +407,7 @@ 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: # 若无其他客户端
|
||||
@@ -303,6 +421,7 @@ def on_connect_device(data):
|
||||
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) # 将当前客户端加入屏幕订阅
|
||||
@@ -310,8 +429,11 @@ def on_connect_device(data):
|
||||
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"}) # 回复连接失败
|
||||
|
||||
|
||||
@@ -320,17 +442,20 @@ 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 设备
|
||||
|
||||
|
||||
@@ -338,14 +463,40 @@ def on_key(data):
|
||||
# 启动
|
||||
# =============================================================================
|
||||
|
||||
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 服务端口
|
||||
print(f"远程显示服务: http://localhost:{port}")
|
||||
print("使用 WebSocket 传输,在浏览器中打开上述地址")
|
||||
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:
|
||||
connect(sys.argv[1]) # 若命令行有 IP,预连接
|
||||
print(f"已预连接: {sys.argv[1]}")
|
||||
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) # 监听所有网卡
|
||||
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -143,8 +143,12 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* 垂直方向居中,与左侧显示区对齐 */
|
||||
gap: 16px;
|
||||
padding: 8px;
|
||||
align-self: stretch;
|
||||
/* 与 .main 同高,便于内容居中 */
|
||||
}
|
||||
/* ========== D-pad 方向键 ========== */
|
||||
.dpad {
|
||||
@@ -332,7 +336,7 @@
|
||||
<div class="modal" id="connectModal">
|
||||
<div class="modal-content">
|
||||
<h3>连接装置</h3>
|
||||
<input type="text" id="hostInput" placeholder="装置 IP 地址" value="192.168.253.3">
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 远程显示 Web 服务依赖
|
||||
# remo_disp_server.py 需要 Flask + flask-socketio + Pillow
|
||||
# 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
|
||||
Reference in New Issue
Block a user