Files
DTU-RemtoeLCD/remo_disp_server.py
T

787 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 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)
_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
# =============================================================================
# 接收耐心参数(MCU 侧 CH395F 按 1KB 块握手发送,速度较慢,客户端需耐心)
# =============================================================================
# CH395F 发送 FIFO 为 1KB 硬上限,每写满一块要等芯片"发送缓冲释放"中断才能
# 写下一块,节奏还受 PC 端 TCP 延迟确认(40~200ms)门控,整帧 LCDMEM(3209 字节)
# 分 4 块到达耗时 0.5~3 秒以上属正常现象。旧实现 3 次短等待就放弃,会把帧截断,
# 且迟到的尾块污染下一帧帧头,导致之后所有解析永久错乱。故改为:
RECV_CHUNK_TIMEOUT = 3.0 # 单次 recv 最长等待秒数;超时只表示"暂无数据",不算失败
RECV_FIRST_BYTE_TIMEOUT = 3.0 # 发出请求后等待首字节的秒数;全程无字节判为对端无应答,快速失败
RECV_TOTAL_BUDGET = 10.0 # 收齐一整帧的总时间预算(秒);超预算判本帧失败(丢弃进行中帧并复位状态机)
# =============================================================================
# 接收状态机(对齐 MCU 端 ui_net_sink.c 的 rm_parser:TAG 定界、长度定长、CRC 校验、逐字节解析)
# =============================================================================
# 一帧可跨多次 recv;状态跨调用保持;失步后(任意噪声字节 / 截断残帧),
# TAG 态丢弃非帧头字节,在下一个 0xBB 处自动重新同步——与 MCU 侧策略完全一致。
# 接收方向长度字段上限(MCU 侧对 客户端→设备 方向用 RM_MAX_RX_PAYLOAD=256 防长度字段损坏
# 引发失步;此处按 设备→客户端 方向的最大合法帧折算):
# 设备→客户端方向最大合法帧为 LCDMEM 应答 = 4(偏移) + 3200(160x160 1bpp 位图) = 3204 字节,
# 再加 5KB 余量覆盖其他帧与更大屏幕;长度字段超过此值判损坏(失步)立即复位状态机,
# 防止状态机被锁死在等待永远到不齐的数万个字节上
RM_MAX_RX_PAYLOAD = 3204 + 5 * 1024
class _RmParser:
"""
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 秒以上;
- 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
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
_sock.settimeout(min(RECV_CHUNK_TIMEOUT, remaining))
try:
chunk = _sock.recv(256 * 1024)
except (socket.timeout, TimeoutError):
logger.debug(
"[_recv] recv 超时(本轮无数据),状态机进行中 {pos} 字节,继续等待",
pos=_rm_parser.pos,
)
continue
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()