873 lines
42 KiB
Python
873 lines
42 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
远程显示 Web 服务 - Flask + WebSocket 实现
|
||
|
||
通过 RemoDispBus 协议与 DTU 装置通信,将远程 LCD 画面以 Web 页面形式展示。
|
||
前端通过 WebSocket 连接,服务端主动推送屏幕数据,实现低延迟传输。
|
||
连接建立后自动发送 CMD_INIT 获取屏幕分辨率;获取失败时默认 160x160。
|
||
接收解析:逐字节状态机(与 MCU 端 ui_net_sink.c 的 rm_parser 同款),失步后自动重同步。
|
||
|
||
用法: 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
|
||
# 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:
|
||
"""打包成 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()
|
||
# 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
|
||
|
||
|
||
# =============================================================================
|
||
# 协议帧编解码
|
||
# =============================================================================
|
||
|
||
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 _enable_immediate_ack(sock: "socket.socket") -> bool:
|
||
"""
|
||
关闭本 socket 的延迟确认(仅 Windows 有效),返回是否生效。
|
||
用 ctypes 直接调 WSAIoctl(SIO_TCP_SET_ACK_FREQUENCY=1):Python 的
|
||
socket.ioctl() 有控制码白名单,不接受该码;频度 1 = 每段立即 ACK。
|
||
其他平台 / 调用失败返回 False(此时由 _recv 的"轻推"兜底,效果相同)。
|
||
"""
|
||
if sys.platform != "win32":
|
||
return False
|
||
try:
|
||
import ctypes # 仅 Windows 路径需要;放函数内避免非 Windows 环境无谓导入
|
||
|
||
ws2 = ctypes.WinDLL("ws2_32", use_last_error=True)
|
||
ws_ioctl = ws2.WSAIoctl
|
||
ws_ioctl.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_void_p,
|
||
ctypes.c_uint32, ctypes.c_void_p, ctypes.c_uint32,
|
||
ctypes.POINTER(ctypes.c_uint32), ctypes.c_void_p,
|
||
ctypes.c_void_p]
|
||
ws_ioctl.restype = ctypes.c_int
|
||
freq = ctypes.c_uint32(1) # 1 = ACK every segment
|
||
ret = ctypes.c_uint32(0)
|
||
rc = ws_ioctl(ctypes.c_void_p(sock.fileno()),
|
||
0x98000017, # SIO_TCP_SET_ACK_FREQUENCY = _WSAIOW(IOC_VENDOR, 23)
|
||
ctypes.byref(freq), 4, None, 0, ctypes.byref(ret), None, None)
|
||
if rc != 0:
|
||
logger.warning("[_enable_immediate_ack] WSAIoctl 返回 {rc}(err={err})",
|
||
rc=rc, err=ctypes.get_last_error())
|
||
return rc == 0
|
||
except Exception as e:
|
||
logger.warning("[_enable_immediate_ack] 设置失败,将使用轻推兜底 - {err}", err=e)
|
||
return False
|
||
|
||
|
||
def connect(host: str, port: int = PORT) -> bool:
|
||
"""
|
||
建立与 DTU 装置的 TCP 连接。
|
||
若有旧连接则先关闭;连接时超时 3 秒;连接成功后设为 2 秒。
|
||
连接成功后关闭本 socket 的延迟确认(LCDMEM 帧尾小块被 Nagle 扣住等 ACK,
|
||
延迟确认会把它拖 40~200ms;详见文件上方"延迟确认抑制"说明)。
|
||
"""
|
||
global _sock, IMMEDIATE_ACK_OK
|
||
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)
|
||
# 抑制延迟确认(性能关键路径:决定整帧是 ~14ms 还是 ~70ms)
|
||
IMMEDIATE_ACK_OK = _enable_immediate_ack(_sock)
|
||
logger.info("[connect] 延迟确认抑制: {mode}",
|
||
mode="已关闭(ACK frequency=1)" if IMMEDIATE_ACK_OK
|
||
else "未生效(改用入站轻推兜底)")
|
||
_rm_parser.reset() # 新连接 = 新协议流,复位接收状态机(对齐 MCU 的 accept 时 reset)
|
||
_pending_frames.clear() # 丢弃旧连接的挂起帧
|
||
# 连接成功后尝试通过 CMD_INIT 动态获取屏幕分辨率,失败则保持默认 160x160
|
||
init_screen()
|
||
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
|
||
|
||
|
||
# =============================================================================
|
||
# 延迟确认(Delayed ACK)抑制:LCDMEM 整帧被拖慢 40~200ms 的根因与解法
|
||
# =============================================================================
|
||
# 机理(2026-09-22 台架实测确证,见 STM32F4-Base 仓库
|
||
# docs/测试说明/链路延迟测试说明.md §7.3):
|
||
# CH395F 的 MSS=1024、发送 FIFO=1024B,因此 MCU 每次 1024B 写恰好是一个
|
||
# “满 MSS 段”(Nagle 允许立即发送);而 LCDMEM 应答 3209B = 3×1024 + 137B,
|
||
# 最后 137B 是“次 MSS 段” → 芯片按 Nagle 规则把它扣住,等前一数据被 ACK。
|
||
# Windows 默认延迟确认把该 ACK 推迟 40~200ms → 整帧被拖住
|
||
# (实测:尾块 43~64ms,整帧 65~75ms;关掉延迟确认后尾块 → 3.9ms、整帧 → 14ms)。
|
||
# 解法(全部在客户端,协议与设备代码都不需要改):
|
||
# ① 本 socket 关闭延迟确认:Windows 用 WSAIoctl(SIO_TCP_SET_ACK_FREQUENCY=1)
|
||
# —— Python 的 socket.ioctl() 有控制码白名单,不接受该码,故用 ctypes 直调;
|
||
# ② 兜底“轻推”:收帧途中若帧已开始却迟迟不再来数据,向设备补发 1 个字节
|
||
# (0x00,非帧头 0xBB,设备状态机会丢弃)→ 该 TCP 段捎带 ACK,同样立即解锁尾块。
|
||
# 非 Windows(或 ① 失败)时自动启用;① 生效时不启用以免多余上行流量。
|
||
IMMEDIATE_ACK_OK: bool = False # ① 是否已生效(connect() 时探测;False → 启用 ②)
|
||
NUDGE_POLL_S = 0.005 # ② 兜底模式下的 recv 超时(秒):决定"发现停顿"的粒度
|
||
NUDGE_INTERVAL = 0.02 # ② 两次轻推的最小间隔(秒):首推后若仍无数据才需要它
|
||
NUDGE_MAX_PER_RECV = 64 # ② 单次 _recv 内最多轻推次数(防失控)
|
||
|
||
# =============================================================================
|
||
# 接收耐心参数(MCU 侧 CH395F 按 1KB 块握手发送,速度较慢,客户端需耐心)
|
||
# =============================================================================
|
||
# CH395F 发送 FIFO 为 1KB 硬上限,每写满一块要等芯片"发送缓冲释放"中断才能
|
||
# 写下一块;旧实现 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:
|
||
"""
|
||
RemoDispBus 接收状态机(逐字节解析,行为对齐 MCU 端 rm_parser_feed)
|
||
|
||
状态(对齐 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 秒以上;
|
||
- 延迟确认抑制:本 socket 已关延迟确认时用长超时;否则改用短超时 + "轻推"
|
||
(帧中停顿即补发 1 字节捎带 ACK),避免帧尾小块被拖 40~200ms;
|
||
- deadline:总预算截止点(monotonic 时钟);None 表示从当前起新计 RECV_TOTAL_BUDGET 预算;
|
||
给出时与调用方共享预算(如 fetch_screen 跳过夹带短帧的场景,避免每次调用重起预算叠加);
|
||
- 超预算 / 连接关闭:复位状态机(丢弃进行中帧),防止旧帧污染下一轮。
|
||
成功返回 (cmd, payload);失败返回 None。
|
||
"""
|
||
if not _sock:
|
||
logger.error("[_recv] 未连接设备,无法接收数据")
|
||
return None
|
||
old_timeout = _sock.gettimeout()
|
||
try:
|
||
now0 = time.monotonic()
|
||
if deadline is None:
|
||
deadline = now0 + RECV_TOTAL_BUDGET
|
||
if skip_first_byte_wait:
|
||
first_byte_deadline = 0.0 # 续接等待(数据已出现):只受总截止约束
|
||
else:
|
||
first_byte_deadline = now0 + RECV_FIRST_BYTE_TIMEOUT
|
||
first_byte_seen = False
|
||
last_nudge = 0.0 # 上一次"轻推"时刻(monotonic);兜底模式用
|
||
nudges = 0 # 本轮 _recv 已轻推次数(上限 NUDGE_MAX_PER_RECV)
|
||
while True:
|
||
# 已有本轮之前凑齐的完整帧(挂起队列):优先交付,无需读 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
|
||
# 立即 ACK 已生效 → 用长超时(省 CPU);未生效(非 Windows/失败)→ 用短超时,
|
||
# 才能在"帧中停顿"期间及时轻推(见文件上方"延迟确认抑制"说明)
|
||
if IMMEDIATE_ACK_OK:
|
||
_sock.settimeout(min(RECV_CHUNK_TIMEOUT, remaining))
|
||
else:
|
||
_sock.settimeout(min(NUDGE_POLL_S, RECV_CHUNK_TIMEOUT, remaining))
|
||
try:
|
||
chunk = _sock.recv(256 * 1024)
|
||
except (socket.timeout, TimeoutError):
|
||
# 兜底轻推:帧已开始(状态机不在 TAG 态)但久久不再来数据 →
|
||
# 补发 1 个 0x00(设备状态机只认 0xBB 帧头,会丢弃该字节),
|
||
# 这个 TCP 段捎带的 ACK 即解锁被 Nagle 扣住的帧尾小块。
|
||
if (not IMMEDIATE_ACK_OK) and (_rm_parser.st != _rm_parser.ST_TAG) \
|
||
and (nudges < NUDGE_MAX_PER_RECV) \
|
||
and ((now - last_nudge) >= NUDGE_INTERVAL):
|
||
try:
|
||
_sock.sendall(b"\x00")
|
||
nudges += 1
|
||
last_nudge = time.monotonic()
|
||
logger.debug(
|
||
"[_recv] 轻推 1 字节捎带 ACK(第 {n} 次,帧中 {pos} 字节)",
|
||
n=nudges, pos=_rm_parser.pos,
|
||
)
|
||
except Exception as e:
|
||
logger.debug("[_recv] 轻推失败 - {err}", err=e)
|
||
logger.debug(
|
||
"[_recv] recv 超时(本轮无数据),状态机进行中 {pos} 字节,继续等待",
|
||
pos=_rm_parser.pos,
|
||
)
|
||
continue
|
||
if not chunk:
|
||
logger.warning("[_recv] 收到空数据,连接已被对端关闭,复位状态机")
|
||
_rm_parser.reset()
|
||
_pending_frames.clear()
|
||
return None
|
||
if not first_byte_seen:
|
||
first_byte_seen = True
|
||
logger.debug("[_recv] 收到响应首字节,进入整帧等待")
|
||
# 逐字节喂入状态机(对齐 MCU ui_net_sink_poll: for i in n: rm_parser_feed(g_rx[i]))
|
||
first_frame = None
|
||
for b in chunk:
|
||
f = _rm_parser.feed(b)
|
||
if f is None:
|
||
continue
|
||
if first_frame is None:
|
||
first_frame = f # 本轮返回首个完整帧
|
||
else:
|
||
# 同块内第 2+ 个完整帧:入挂起队列,由下一次 _recv 优先交付
|
||
if len(_pending_frames) >= 16:
|
||
logger.warning("[_recv] 挂起队列溢出({n} 帧),清空等待重新对齐", n=len(_pending_frames))
|
||
_pending_frames.clear()
|
||
_pending_frames.append(f)
|
||
if first_frame is not None:
|
||
return first_frame
|
||
# 本轮未凑齐整帧:继续耐心循环
|
||
except Exception as e:
|
||
logger.exception("[_recv] 接收异常 - {err}", err=e)
|
||
return None
|
||
finally:
|
||
try:
|
||
_sock.settimeout(old_timeout if old_timeout else RECV_CHUNK_TIMEOUT)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
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:]。
|
||
若前面夹带 KEEPLIVE/KEY ACK 等短帧(按键操作后常见,其应答滞留字节流),
|
||
在同一总预算内继续接收真正的 LCDMEM 应答(最多跳过 3 帧),
|
||
避免夹带短帧被误计为拉取失败、累计触发断开判定。
|
||
"""
|
||
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 成功")
|
||
# 连续多次 _recv 共享同一总预算截止点,避免每次调用重起 10s 预算叠加
|
||
deadline = time.monotonic() + RECV_TOTAL_BUDGET
|
||
for retry in range(3):
|
||
result = _recv(deadline=deadline, skip_first_byte_wait=retry > 0) # 接收设备回复
|
||
if not result:
|
||
logger.error("[fetch_screen] 未收到任何回复(result=None, 第 {n} 次尝试)", n=retry + 1)
|
||
return None
|
||
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
|
||
# 确认为 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 _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]:
|
||
"""
|
||
将 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),
|
||
)
|
||
expected_bits = (width * height + 7) // 8 # 1bpp 打包长度
|
||
expected_bytes = width * height # 每像素 1 字节(0/1或0-255)长度
|
||
|
||
if len(data) == expected_bits:
|
||
# 1bpp:每字节 8 像素,高位在前
|
||
logger.debug("[mono_to_png] 使用 1bpp 打包解码")
|
||
img = Image.new("1", (width, height))
|
||
pix = img.load()
|
||
for y in range(height):
|
||
row_base = y * width
|
||
for x in range(width):
|
||
byte_i = (row_base + x) // 8
|
||
bit_i = 7 - (x % 8)
|
||
bit = (data[byte_i] >> bit_i) & 1
|
||
pix[x, y] = 255 if bit else 0
|
||
elif len(data) == expected_bytes:
|
||
# 8bit:每字节直接对应一个像素,非零当作白
|
||
logger.debug("[mono_to_png] 使用每像素 1 字节解码")
|
||
img = Image.new("1", (width, height))
|
||
pix = img.load()
|
||
for y in range(height):
|
||
row_base = y * width
|
||
for x in range(width):
|
||
pix[x, y] = 255 if data[row_base + x] else 0
|
||
else:
|
||
logger.warning(
|
||
"[mono_to_png] payload 长度不匹配,丢弃该帧: data_len={len_}, expected_bits={bits}, expected_bytes={bytes}",
|
||
len_=len(data),
|
||
bits=expected_bits,
|
||
bytes=expected_bytes,
|
||
)
|
||
return None
|
||
|
||
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, _screen_size
|
||
try:
|
||
if _sock:
|
||
logger.info("[disconnect_device] 正在关闭与 DTU 的连接")
|
||
_sock.close() # 关闭 socket
|
||
_sock = None # 清空引用
|
||
_screen_size = DEFAULT_SCREEN_SIZE # 重置分辨率为默认 160x160
|
||
_rm_parser.reset() # 复位接收状态机(对齐 MCU 的 close 时 reset)
|
||
_pending_frames.clear() # 丢弃挂起帧
|
||
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 # 成功则清零
|
||
# 屏幕分辨率:init_screen() 已通过 CMD_INIT 更新,未获取到则为默认 160x160
|
||
w, h = _screen_size
|
||
png = mono_to_png(data, w, h) # 转为 PNG
|
||
if png and isinstance(png, (bytes, bytearray)) and png.startswith(b"\x89PNG\r\n\x1a\n"):
|
||
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,
|
||
)
|
||
elif png:
|
||
logger.warning(
|
||
"[_screen_refresh_loop] 生成的 PNG 无效或签名错误,丢弃该帧 png_len={len_}",
|
||
len_=len(png),
|
||
)
|
||
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, "w": _screen_size[0], "h": _screen_size[1]}) # 回复连接成功并携带当前分辨率
|
||
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()
|