feat(HDMI-Tool): v0.2.0 集成光机控制(RS485→GD32网关→DLPC3421) — 本地提交, 待验证后再推送发布
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
DLPC3421 光机 RS485 控制链路(板上 GD32E230 作 RS485→I2C 网关)
|
||||
================================================================
|
||||
协议以 ../LT8619C_DLPC3421_HDMI/protocols.md 为准(上游固件仓, 含逐条命令的
|
||||
响应语义/错误码/就绪门禁与实测帧示例):
|
||||
物理层 RS485 半双工 115200 8N1(PC 经 USB-RS485 转换器接板, DE 由 MCU 硬件换向)
|
||||
命令帧 D5 01 LEN <ASCII命令> CRC 01=板类型, LEN=命令字节数
|
||||
响应帧 B5 TYPE LEN <payload> CRC payload 为 ASCII(ok / err:OL / okTL:246 ...)
|
||||
CRC 帧内第 2 字节(板类型)起累加到最后一个 payload 字节, 取低 8 位
|
||||
帧长 最小 6 字节, 最大 32 字节
|
||||
就绪门禁: DLPC3421 启动完成前 M730/M731/M732 被拒(回 0xFF), 属预期。
|
||||
|
||||
常用命令见 PRESETS(全表见 protocols.md); M7S0 是"开启"TEC 自动控温, 方向别记反。
|
||||
|
||||
仅标准库 + 可选 pyserial(缺失只影响串口功能, 同 hdmi_gui.py 的 Pillow 模式)。
|
||||
"""
|
||||
|
||||
import collections
|
||||
import queue
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
try:
|
||||
import serial
|
||||
import serial.tools.list_ports
|
||||
HAS_SERIAL = True
|
||||
except ImportError:
|
||||
HAS_SERIAL = False
|
||||
|
||||
HEADER_TX = 0xD5 # 命令帧头(Host -> Device)
|
||||
HEADER_RX = 0xB5 # 响应帧头(Device -> Host)
|
||||
BOARD_TYPE = 0x01 # 板类型字节
|
||||
FRAME_MIN, FRAME_MAX = 6, 32 # 整帧长度上下限(含帧头/CRC)
|
||||
DEFAULT_BAUD = 115200
|
||||
|
||||
# --- 响应 TYPE 码语义(protocols.md "响应 TYPE 码"表) ---
|
||||
TYPE_NAMES = {
|
||||
0xF0: '成功',
|
||||
0xF1: 'CRC 校验错',
|
||||
0xF2: '包头错',
|
||||
0xF3: '板类型错',
|
||||
0xF4: '格式/缺参数',
|
||||
0xFD: '参数越界/读回复核不一致',
|
||||
0xFE: '未知命令',
|
||||
0xFF: '通用错误(含 DLPC 就绪门禁拒绝)',
|
||||
0xFA: '开光机通信失败',
|
||||
0xFB: '关光机通信失败',
|
||||
0xFC: '读回复核通信失败',
|
||||
}
|
||||
|
||||
# --- payload 中 err:<N> 的 DLPC 侧错误码 ---
|
||||
DLPC_ERR_NAMES = {
|
||||
0: 'DLPC 成功',
|
||||
1: 'DLPC 通用错误',
|
||||
2: 'DLPC 通信超时',
|
||||
3: 'I2C NACK(器件无应答)',
|
||||
4: 'DLPC 参数无效',
|
||||
5: 'DLPC 忙',
|
||||
}
|
||||
|
||||
# GUI 预设命令表: (显示名, 命令, 说明), 顺序即下拉顺序
|
||||
PRESETS = (
|
||||
('开光机', 'M730S0', '开启 RGB 投光(需 DLPC 就绪)'),
|
||||
('关光机', 'M730S1', '关闭投光并停定时器'),
|
||||
('设光强', 'M731S100', 'RGB 三路同值, 范围 54-800, 写后读回复核'),
|
||||
('查电流', 'M732', 'okR<n>G<n>B<n>, 三路电流原始值 0-65535'),
|
||||
('查DMD温度', 'M201', 'okTL:246 = 24.6°C(有符号, 0.1°C)'),
|
||||
('查LAMP温度', 'M202', '同 M201'),
|
||||
('查投光结束', 'M737', '定时投光中不立即回包, 到期自动关灯后补发 ok'),
|
||||
('查版本', 'M999', 'okv20.0.0.1 形式'),
|
||||
('握手', 'M1', '协议握手, 回 ok'),
|
||||
('TEC自动控温开', 'M7S0', 'S0=开启 PID 自动控温(方向别记反)'),
|
||||
('TEC自动控温关', 'M7S1', 'S1=关闭'),
|
||||
('凝露点33.0°C', 'M8S330', 'TEC 凝露点, 0.1°C 单位, 范围 200-350'),
|
||||
('软件重启', 'M888', '回 ok 后 100ms 复位'),
|
||||
)
|
||||
|
||||
Frame = collections.namedtuple('Frame', 'type_code payload raw')
|
||||
|
||||
|
||||
def sum_crc(data):
|
||||
"""帧内第 2 字节(板类型)起累加到末 payload 字节, 取低 8 位(不含包头与 CRC)"""
|
||||
return sum(data) & 0xFF
|
||||
|
||||
|
||||
def build_frame(cmd):
|
||||
"""ASCII 命令字符串 -> 命令帧 bytes; 空命令/超长/非 ASCII 抛 ValueError"""
|
||||
payload = cmd.strip().encode('ascii')
|
||||
if not payload:
|
||||
raise ValueError('空命令')
|
||||
if len(payload) > FRAME_MAX - 4:
|
||||
raise ValueError(f'命令过长(>{FRAME_MAX - 4} 字节, 帧上限 {FRAME_MAX})')
|
||||
frame = bytes((HEADER_TX, BOARD_TYPE, len(payload))) + payload
|
||||
return frame + bytes((sum_crc(frame[1:]),))
|
||||
|
||||
|
||||
class FrameParser:
|
||||
"""增量字节流 -> 完整响应帧: 帧头+LEN 定界, CRC 校验, 失配丢当前帧头重新同步。
|
||||
|
||||
payload 理论上可能含 0xB5 造成同步偏移(伪帧头 CRC 必错会被跳过),
|
||||
下一帧自动恢复, 对调试工具可接受。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._buf = bytearray()
|
||||
|
||||
def feed(self, data):
|
||||
"""喂入新收到的字节, 返回本次解析出的完整 Frame 列表"""
|
||||
self._buf += data
|
||||
frames = []
|
||||
while True:
|
||||
i = self._buf.find(HEADER_RX)
|
||||
if i < 0:
|
||||
self._buf.clear()
|
||||
return frames
|
||||
if i: # 丢弃帧头前的噪声
|
||||
del self._buf[:i]
|
||||
if len(self._buf) < 3: # 帧头/TYPE/LEN 尚不完整
|
||||
return frames
|
||||
length = self._buf[2]
|
||||
if not FRAME_MIN - 4 <= length <= FRAME_MAX - 4:
|
||||
del self._buf[:1] # LEN 非法 -> 伪帧头, 前进 1 字节重同步
|
||||
continue
|
||||
total = 4 + length
|
||||
if len(self._buf) < total: # 整帧未收齐
|
||||
return frames
|
||||
raw = bytes(self._buf[:total])
|
||||
if raw[-1] == sum_crc(raw[1:-1]):
|
||||
frames.append(Frame(self._buf[1],
|
||||
raw[3:-1].decode('ascii', 'replace').strip(), raw))
|
||||
del self._buf[:total]
|
||||
else: # CRC 失配 -> 丢帧头重同步
|
||||
del self._buf[:1]
|
||||
|
||||
|
||||
def describe(fr):
|
||||
"""响应帧 -> 人读文本: TYPE 译码 + payload, err:N 追加 DLPC 错误释义"""
|
||||
if fr.type_code == 0xF0:
|
||||
text = fr.payload or 'ok'
|
||||
else:
|
||||
text = TYPE_NAMES.get(fr.type_code, f'TYPE=0x{fr.type_code:02X}')
|
||||
if fr.payload:
|
||||
text += f' {fr.payload}'
|
||||
m = re.search(r'err:(\d+)', fr.payload)
|
||||
if m:
|
||||
text += f'({DLPC_ERR_NAMES.get(int(m.group(1)), "未知错误码")})'
|
||||
return text
|
||||
|
||||
|
||||
class DLPC485:
|
||||
"""串口工作线程: 端口/波特率热切换 + 命令队列 + 回调(收发全在后台线程)。
|
||||
|
||||
on_event(str) 收发日志行(已译码, 附原始 hex)
|
||||
on_status(str) 连接状态变化(已连接/断开/失败/设备拔出)
|
||||
回调在后台线程触发, GUI 侧需自行投递回主线程(见 hdmi_gui 的轮询队列)。
|
||||
"""
|
||||
|
||||
def __init__(self, on_event, on_status):
|
||||
self.on_event, self.on_status = on_event, on_status
|
||||
self.q = queue.Queue()
|
||||
self._target = None # 想态 (port, baud); None=断开
|
||||
self.ser = None
|
||||
self.parser = FrameParser()
|
||||
self._lock = threading.Lock()
|
||||
threading.Thread(target=self._run, daemon=True).start()
|
||||
|
||||
@staticmethod
|
||||
def list_ports():
|
||||
"""枚举本机串口名(未装 pyserial 返回空表)"""
|
||||
if not HAS_SERIAL:
|
||||
return []
|
||||
return [p.device for p in serial.tools.list_ports.comports()]
|
||||
|
||||
def open(self, port, baud=DEFAULT_BAUD):
|
||||
if not HAS_SERIAL:
|
||||
self.on_status('未安装 pyserial: py -m pip install pyserial')
|
||||
return
|
||||
with self._lock:
|
||||
self._target = (port, int(baud))
|
||||
|
||||
def close(self):
|
||||
with self._lock:
|
||||
self._target = None
|
||||
|
||||
def is_connected(self):
|
||||
with self._lock:
|
||||
return self.ser is not None
|
||||
|
||||
def send(self, cmd):
|
||||
"""发送一条 ASCII 命令(自动组帧); 未连接/命令非法走 on_status 提示"""
|
||||
with self._lock:
|
||||
ser = self.ser
|
||||
if ser is None:
|
||||
self.on_status('未连接串口, 命令被丢弃')
|
||||
return
|
||||
try:
|
||||
frame = build_frame(cmd)
|
||||
except ValueError as e:
|
||||
self.on_status(f'命令非法: {e}')
|
||||
return
|
||||
self.q.put((cmd, frame))
|
||||
|
||||
def _run(self):
|
||||
while True:
|
||||
with self._lock:
|
||||
target = self._target
|
||||
cur = (self.ser.port, self.ser.baudrate) if self.ser else None
|
||||
if target != cur: # 端口/波特率变化或断开: 统一在此切换
|
||||
if self.ser is not None:
|
||||
try:
|
||||
self.ser.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ser = None
|
||||
self.on_status('已断开')
|
||||
if target:
|
||||
try:
|
||||
self.ser = serial.Serial(target[0], target[1], timeout=0.15)
|
||||
self.parser = FrameParser()
|
||||
self.on_status(f'已连接 {target[0]} @ {target[1]}')
|
||||
except Exception as e:
|
||||
self.ser = None
|
||||
with self._lock:
|
||||
self._target = None
|
||||
self.on_status(f'连接失败: {e}')
|
||||
if self.ser is not None:
|
||||
try:
|
||||
item = None
|
||||
try:
|
||||
item = self.q.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
if item is not None:
|
||||
cmd, frame = item
|
||||
self.ser.write(frame)
|
||||
self.on_event(f'→ {cmd} [{frame.hex(" ").upper()}]')
|
||||
data = self.ser.read(256)
|
||||
if data:
|
||||
for fr in self.parser.feed(data):
|
||||
self.on_event(f'← {describe(fr)} '
|
||||
f'[{fr.raw.hex(" ").upper()}]')
|
||||
except OSError:
|
||||
self.ser = None
|
||||
with self._lock:
|
||||
self._target = None
|
||||
self.on_status('串口丢失(设备拔出?), 请重新连接')
|
||||
time.sleep(0.02)
|
||||
+262
-7
@@ -6,15 +6,18 @@ HDMI EDID 查看器 + 测试图案投屏工具
|
||||
配合 LT8619C(HDMI 转 RGB888)→ DLPC3421 转接板调试使用。
|
||||
|
||||
功能:
|
||||
1. 枚举各显示输出口接的设备, 从注册表读取其 EDID 原始数据,
|
||||
1. 枚举各显示输出口, 从注册表读取其 EDID 原始数据,
|
||||
按 EDID 1.4 / CEA-861 标准解算: 厂商/产品/名称/尺寸/Gamma/输入类型、
|
||||
支持的全部模式(详细时序 DTD / 已建立时序 / 标准时序 / CEA VIC)、
|
||||
首选时序完整参数(像素时钟/消隐/同步极性)、CEA 扩展能力、校验和。
|
||||
2. 向所选屏幕全屏投测试图案(黑底 1:1 居中, 测试卡区域可选
|
||||
640x360 / 800x600 / full 铺满): 纯红 / 纯绿 / 纯蓝 / 黑白棋盘格 /
|
||||
网格线 + 3 个自定义图片栏位, 点击或 ESC 退出。
|
||||
3. 光机控制: RS485 直连板上 GD32E230 网关(→DLPC3421), 开关光机/设光强/
|
||||
查温度电流/定时投光, 协议见 dlpc_rs485.py 头注释(上游仓 protocols.md)。
|
||||
|
||||
仅用标准库(ctypes / winreg / tkinter), 无需 pip 安装任何依赖。
|
||||
仅用标准库(ctypes / winreg / tkinter), 无需 pip 安装任何依赖;
|
||||
可选 pyserial(光机控制)与 Pillow(自定义图片 JPG/BMP/WebP), 缺了不影响其余功能。
|
||||
|
||||
命令行模式(自动化测试用):
|
||||
python hdmi_gui.py 启动 GUI
|
||||
@@ -24,6 +27,8 @@ HDMI EDID 查看器 + 测试图案投屏工具
|
||||
直接出图案, 3 秒自动退出
|
||||
python hdmi_gui.py --test-pattern Blue --area 800x600
|
||||
指定测试卡区域(full=铺满所选屏)
|
||||
python hdmi_gui.py --port COM9 --dlpc-cmd M730S0 --dlpc-cmd M999
|
||||
RS485 逐条发光机命令并打印译码响应
|
||||
python hdmi_gui.py --seconds 5 GUI 5 秒自动关闭(冒烟测试)
|
||||
"""
|
||||
|
||||
@@ -33,11 +38,15 @@ import ctypes.wintypes as wt
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import queue
|
||||
import shutil
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import winreg
|
||||
|
||||
import dlpc_rs485
|
||||
|
||||
# 进程级 DPI 感知: 保证图案窗口按物理像素精确定位/绘制
|
||||
try:
|
||||
ctypes.windll.shcore.SetProcessDpiAwareness(2)
|
||||
@@ -65,11 +74,15 @@ SLOT_COUNT = 3
|
||||
SLOT_CONFIG = os.path.join(BASE_DIR, 'pattern_slots.json')
|
||||
IMG_EXTS = ['.png', '.gif'] + (['.jpg', '.jpeg', '.bmp', '.webp'] if HAS_PIL else [])
|
||||
|
||||
# 光机控制串口记忆(与栏位配置同存 pattern_slots.json 的 "serial" 键)
|
||||
SERIAL_CFG = {'port': '', 'baud': dlpc_rs485.DEFAULT_BAUD}
|
||||
|
||||
|
||||
def load_slots():
|
||||
"""读 pattern_slots.json, 返回 (slots, area)。
|
||||
|
||||
新格式为 {'area': 'WxH'|'full', 'slots': [...]}; 兼容旧版纯数组格式(无 area)。
|
||||
新格式为 {'area': 'WxH'|'full', 'slots': [...], 'serial': {...}};
|
||||
兼容旧版纯数组格式(无 area)与无 serial 键的中间格式。
|
||||
"""
|
||||
slots = [{'name': f'自定义{i + 1}', 'file': None} for i in range(SLOT_COUNT)]
|
||||
try:
|
||||
@@ -80,6 +93,13 @@ def load_slots():
|
||||
if isinstance(data, dict):
|
||||
area = str(data.get('area') or DEFAULT_AREA)
|
||||
items = data.get('slots') or []
|
||||
ser = data.get('serial')
|
||||
if isinstance(ser, dict):
|
||||
SERIAL_CFG['port'] = str(ser.get('port') or '')
|
||||
try:
|
||||
SERIAL_CFG['baud'] = int(ser.get('baud') or dlpc_rs485.DEFAULT_BAUD)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
else:
|
||||
area, items = DEFAULT_AREA, data
|
||||
for i, item in enumerate(items[:SLOT_COUNT]):
|
||||
@@ -91,7 +111,8 @@ def load_slots():
|
||||
|
||||
def save_slots(slots, area):
|
||||
with open(SLOT_CONFIG, 'w', encoding='utf-8') as f:
|
||||
json.dump({'area': area, 'slots': slots}, f, ensure_ascii=False, indent=2)
|
||||
json.dump({'area': area, 'slots': slots, 'serial': SERIAL_CFG},
|
||||
f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def copy_image_into_script_dir(src_path):
|
||||
@@ -1008,8 +1029,8 @@ def run_gui(auto_close=0):
|
||||
class App(tk.Tk):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.title('HDMI-Tool — EDID 查看器 + 测试图案 (LT8619C 转接板调试)')
|
||||
self.geometry('960x880')
|
||||
self.title('HDMI-Tool — EDID 查看器 + 测试图案 + 光机控制 (LT8619C 转接板调试)')
|
||||
self.geometry('960x1100')
|
||||
ic = get_icon_path()
|
||||
if ic:
|
||||
try:
|
||||
@@ -1020,6 +1041,10 @@ def run_gui(auto_close=0):
|
||||
self.slots, self.area = load_slots()
|
||||
self._modes = {} # 'WxH@Hz' -> DEVMODEW(当前所选屏)
|
||||
self._orig_modes = {} # device -> 切改前的原始 DEVMODEW
|
||||
# 光机控制链路: 回调发生在串口线程, 经队列投递回 GUI 主线程轮询
|
||||
self._dlpc_log_q = queue.Queue()
|
||||
self.dlpc = dlpc_rs485.DLPC485(self._dlpc_on_event, self._dlpc_on_status)
|
||||
self._dlpc_connected = False
|
||||
self._build()
|
||||
|
||||
def _build(self):
|
||||
@@ -1113,6 +1138,85 @@ def run_gui(auto_close=0):
|
||||
ttk.Label(row2, text='自定义: 点击投图, ⚙ 配置名称与图片(图片保存在脚本目录)',
|
||||
foreground='#666666').pack(side='left', padx=10)
|
||||
|
||||
# --- 光机控制: RS485 -> 板载 GD32E230 网关 -> DLPC3421 ---
|
||||
grp2 = ttk.LabelFrame(
|
||||
self, text='光机控制 — RS485 → 板载 GD32 网关 → DLPC3421 (115200 8N1)',
|
||||
padding=(10, 6))
|
||||
grp2.pack(fill='x', padx=8, pady=(6, 0))
|
||||
if not dlpc_rs485.HAS_SERIAL:
|
||||
ttk.Label(grp2, foreground='#B05000',
|
||||
text='未安装 pyserial, 光机控制不可用 — '
|
||||
'执行 py -m pip install pyserial 后重启本工具').pack(anchor='w')
|
||||
else:
|
||||
r1 = ttk.Frame(grp2)
|
||||
r1.pack(fill='x')
|
||||
ttk.Label(r1, text='串口:').pack(side='left')
|
||||
self.cbo_dlpc = ttk.Combobox(r1, width=12, state='readonly',
|
||||
values=dlpc_rs485.DLPC485.list_ports())
|
||||
if SERIAL_CFG['port']:
|
||||
self.cbo_dlpc.set(SERIAL_CFG['port'])
|
||||
self.cbo_dlpc.pack(side='left', padx=(4, 2))
|
||||
ttk.Button(r1, text='刷新', width=6,
|
||||
command=self._dlpc_refresh).pack(side='left')
|
||||
ttk.Label(r1, text='波特率:').pack(side='left', padx=(10, 0))
|
||||
self.var_baud = tk.StringVar(value=str(SERIAL_CFG['baud']))
|
||||
ttk.Combobox(r1, width=8, textvariable=self.var_baud,
|
||||
values=('115200', '57600', '38400', '9600')
|
||||
).pack(side='left', padx=4)
|
||||
self.btn_dlpc = ttk.Button(r1, text='连接', width=8,
|
||||
command=self._dlpc_toggle)
|
||||
self.btn_dlpc.pack(side='left', padx=(10, 0))
|
||||
self.lbl_dlpc_st = ttk.Label(r1, text='未连接', foreground='#666666')
|
||||
self.lbl_dlpc_st.pack(side='left', padx=10)
|
||||
|
||||
r2 = ttk.Frame(grp2)
|
||||
r2.pack(fill='x', pady=(4, 0))
|
||||
for name, cmd in (('开光机', 'M730S0'), ('关光机', 'M730S1'),
|
||||
('查电流', 'M732'), ('查版本', 'M999'),
|
||||
('软件重启', 'M888')):
|
||||
ttk.Button(r2, text=name, width=9,
|
||||
command=lambda c=cmd: self._dlpc_send(c)
|
||||
).pack(side='left', padx=3)
|
||||
ttk.Label(r2, text='光强(54-800):').pack(side='left', padx=(12, 2))
|
||||
self.var_light = tk.StringVar(value='100')
|
||||
ttk.Entry(r2, textvariable=self.var_light, width=6).pack(side='left')
|
||||
ttk.Button(r2, text='设光强', width=7,
|
||||
command=self._dlpc_set_light).pack(side='left', padx=4)
|
||||
|
||||
r3 = ttk.Frame(grp2)
|
||||
r3.pack(fill='x', pady=(4, 0))
|
||||
ttk.Label(r3, text='命令:').pack(side='left')
|
||||
self._dlpc_presets = [(n, c) for n, c, _ in dlpc_rs485.PRESETS]
|
||||
self.cbo_dlpc_cmd = ttk.Combobox(
|
||||
r3, state='readonly', width=26,
|
||||
values=[f'{n} {c}' for n, c in self._dlpc_presets])
|
||||
self.cbo_dlpc_cmd.current(0)
|
||||
self.cbo_dlpc_cmd.pack(side='left', padx=(4, 2))
|
||||
ttk.Button(r3, text='发送', width=7,
|
||||
command=self._dlpc_send_preset).pack(side='left')
|
||||
ttk.Label(r3, text='自定义:').pack(side='left', padx=(12, 2))
|
||||
self.var_custom_cmd = tk.StringVar()
|
||||
ent_cmd = ttk.Entry(r3, textvariable=self.var_custom_cmd, width=14)
|
||||
ent_cmd.pack(side='left')
|
||||
ent_cmd.bind('<Return>', lambda _e: self._dlpc_send_custom())
|
||||
ttk.Button(r3, text='发送', width=7,
|
||||
command=self._dlpc_send_custom).pack(side='left', padx=4)
|
||||
ttk.Label(r3, text='如 M730S0T2000(定时 2 秒投光)',
|
||||
foreground='#666666').pack(side='left', padx=6)
|
||||
|
||||
r4 = ttk.Frame(grp2)
|
||||
r4.pack(fill='x', pady=(4, 0))
|
||||
self.txt_dlpc = tk.Text(r4, height=4, font=('Consolas', 9),
|
||||
state='disabled', wrap='none',
|
||||
background='#101010', foreground='#C8C8C8')
|
||||
sb4 = ttk.Scrollbar(r4, orient='vertical', command=self.txt_dlpc.yview)
|
||||
self.txt_dlpc.configure(yscrollcommand=sb4.set)
|
||||
self.txt_dlpc.pack(side='left', fill='both', expand=True)
|
||||
sb4.pack(side='right', fill='y')
|
||||
self._dlpc_log('帧格式: D5 01 LEN <命令> CRC / B5 TYPE LEN <payload> CRC, '
|
||||
'累加和校验; 全部命令见 README"光机控制"')
|
||||
self.after(80, self._poll_dlpc)
|
||||
|
||||
# --- 底部: EDID hex dump + 逐字节解析按钮 ---
|
||||
bot = ttk.LabelFrame(self, text='EDID 原始数据', padding=4)
|
||||
bot.pack(fill='both', expand=False, padx=8, pady=(6, 8))
|
||||
@@ -1181,6 +1285,104 @@ def run_gui(auto_close=0):
|
||||
self.area = self.area_var.get()
|
||||
save_slots(self.slots, self.area)
|
||||
|
||||
# ---- 光机控制(RS485) ----
|
||||
def _dlpc_on_event(self, line):
|
||||
self._dlpc_log_q.put(('event', line))
|
||||
|
||||
def _dlpc_on_status(self, msg):
|
||||
self._dlpc_log_q.put(('status', msg))
|
||||
|
||||
def _poll_dlpc(self):
|
||||
"""串口线程 -> GUI 主线程: 80ms 轮询日志队列并同步连接状态"""
|
||||
try:
|
||||
while True:
|
||||
kind, text = self._dlpc_log_q.get_nowait()
|
||||
if kind == 'status':
|
||||
if text.startswith('已连接'):
|
||||
color = '#2E7D32'
|
||||
elif ('失败' in text or '丢失' in text or '未' in text
|
||||
or '非法' in text or '丢弃' in text):
|
||||
color = '#B05000'
|
||||
else:
|
||||
color = '#666666'
|
||||
self.lbl_dlpc_st.config(text=text, foreground=color)
|
||||
self._dlpc_log(text)
|
||||
except queue.Empty:
|
||||
pass
|
||||
connected = self.dlpc.is_connected()
|
||||
if connected != self._dlpc_connected:
|
||||
self._dlpc_connected = connected
|
||||
self.btn_dlpc.config(text='断开' if connected else '连接')
|
||||
self.after(80, self._poll_dlpc)
|
||||
|
||||
def _dlpc_log(self, line):
|
||||
self.txt_dlpc.configure(state='normal')
|
||||
self.txt_dlpc.insert('end', time.strftime('%H:%M:%S ') + line + '\n')
|
||||
if int(self.txt_dlpc.index('end-1c').split('.')[0]) > 300:
|
||||
self.txt_dlpc.delete('1.0', '2.0') # 只保留最近约 300 行
|
||||
self.txt_dlpc.see('end')
|
||||
self.txt_dlpc.configure(state='disabled')
|
||||
|
||||
def _dlpc_refresh(self):
|
||||
ports = dlpc_rs485.DLPC485.list_ports()
|
||||
self.cbo_dlpc['values'] = ports
|
||||
if SERIAL_CFG['port'] in ports:
|
||||
self.cbo_dlpc.set(SERIAL_CFG['port'])
|
||||
elif ports:
|
||||
self.cbo_dlpc.current(0)
|
||||
|
||||
def _dlpc_toggle(self):
|
||||
if self.dlpc.is_connected():
|
||||
self.dlpc.close()
|
||||
return
|
||||
port = self.cbo_dlpc.get().strip()
|
||||
if not port:
|
||||
self.lbl_dlpc_st.config(text='未选择串口', foreground='#B05000')
|
||||
return
|
||||
try:
|
||||
baud = int(self.var_baud.get())
|
||||
except ValueError:
|
||||
self.lbl_dlpc_st.config(text='波特率非法', foreground='#B05000')
|
||||
return
|
||||
SERIAL_CFG['port'], SERIAL_CFG['baud'] = port, baud
|
||||
save_slots(self.slots, self.area_var.get()) # 记住本次串口
|
||||
self.dlpc.open(port, baud)
|
||||
|
||||
def _dlpc_send(self, cmd):
|
||||
if not self.dlpc.is_connected():
|
||||
self.lbl_dlpc_st.config(text='未连接串口', foreground='#B05000')
|
||||
return
|
||||
self.dlpc.send(cmd)
|
||||
|
||||
def _dlpc_send_preset(self):
|
||||
i = self.cbo_dlpc_cmd.current()
|
||||
if i >= 0:
|
||||
self._dlpc_send(self._dlpc_presets[i][1])
|
||||
|
||||
def _dlpc_send_custom(self):
|
||||
cmd = self.var_custom_cmd.get().strip()
|
||||
if cmd:
|
||||
self._dlpc_send(cmd)
|
||||
|
||||
def _dlpc_set_light(self):
|
||||
from tkinter import messagebox
|
||||
try:
|
||||
v = int(self.var_light.get())
|
||||
except ValueError:
|
||||
messagebox.showinfo('光强非法', '光强需为 54-800 的整数')
|
||||
return
|
||||
if not 54 <= v <= 800:
|
||||
messagebox.showinfo('光强越界', '光强范围 54-800(越界固件会回 err:OL)')
|
||||
return
|
||||
self._dlpc_send(f'M731S{v}')
|
||||
|
||||
def destroy(self):
|
||||
try: # 两种退出路径(关窗/--seconds 自动关)都收掉串口线程
|
||||
self.dlpc.close()
|
||||
except Exception:
|
||||
pass
|
||||
super().destroy()
|
||||
|
||||
def _show_edid_fields(self):
|
||||
"""逐字节字段解析弹窗(当前所选显示器), 自动换行文本视图"""
|
||||
from tkinter import messagebox
|
||||
@@ -1384,8 +1586,50 @@ def run_dump(edid_fields=False):
|
||||
return 0
|
||||
|
||||
|
||||
def run_dlpc(port, baud, cmds):
|
||||
"""RS485 直连模式: 逐条发送光机命令, 等响应打印译码结果(自动化脚本用)。
|
||||
|
||||
返回码: 任一命令非法/超时/响应非 0xF0 成功 -> 1, 全部成功 -> 0。
|
||||
"""
|
||||
if not dlpc_rs485.HAS_SERIAL:
|
||||
print('错误: 未安装 pyserial, 请先执行 py -m pip install pyserial')
|
||||
return 2
|
||||
try:
|
||||
ser = dlpc_rs485.serial.Serial(port, baud, timeout=0.1)
|
||||
except Exception as e:
|
||||
print(f'打开串口失败: {port} @ {baud}: {e}')
|
||||
return 1
|
||||
print(f'已连接 {port} @ {baud} (RS485 半双工, 单发单收)')
|
||||
parser = dlpc_rs485.FrameParser()
|
||||
rc = 0
|
||||
for cmd in cmds:
|
||||
try:
|
||||
frame = dlpc_rs485.build_frame(cmd)
|
||||
except ValueError as e:
|
||||
print(f'→ {cmd} 命令非法: {e}')
|
||||
rc = 1
|
||||
continue
|
||||
ser.write(frame)
|
||||
print(f'→ {cmd} [{frame.hex(" ").upper()}]')
|
||||
deadline = time.time() + 35.0 # M737 定时投光中延迟应答(定时上限 32.7s)
|
||||
got = None
|
||||
while got is None and time.time() < deadline:
|
||||
data = ser.read(256)
|
||||
if data:
|
||||
got = next(iter(parser.feed(data)), None)
|
||||
if got is None:
|
||||
print('← (35s 超时无响应)')
|
||||
rc = 1
|
||||
else:
|
||||
print(f'← {dlpc_rs485.describe(got)} [{got.raw.hex(" ").upper()}]')
|
||||
if got.type_code != 0xF0:
|
||||
rc = 1
|
||||
ser.close()
|
||||
return rc
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description='HDMI EDID 查看器 + 测试图案')
|
||||
ap = argparse.ArgumentParser(description='HDMI EDID 查看器 + 测试图案 + 光机控制')
|
||||
ap.add_argument('--dump', action='store_true', help='控制台输出 EDID 解算结果, 不开 GUI')
|
||||
ap.add_argument('--edid-fields', action='store_true',
|
||||
help='控制台输出 EDID 逐字节字段解析表(配合 --dump 风格, 不开 GUI); exe 版不支持')
|
||||
@@ -1396,6 +1640,12 @@ def main():
|
||||
help='测试卡区域: WxH 黑底居中 或 full 铺满所选显示器(默认 640x360)')
|
||||
ap.add_argument('--monitor', type=int, default=1, help='--test-pattern 用的显示器序号(默认 1)')
|
||||
ap.add_argument('--seconds', type=int, default=0, help='自动退出秒数(GUI/图案冒烟测试)')
|
||||
ap.add_argument('--port', default=None, help='光机 RS485 串口(如 COM9), 配合 --dlpc-cmd')
|
||||
ap.add_argument('--baud', type=int, default=dlpc_rs485.DEFAULT_BAUD,
|
||||
help='光机串口波特率(默认 115200)')
|
||||
ap.add_argument('--dlpc-cmd', action='append', default=None, dest='dlpc_cmds',
|
||||
metavar='CMD',
|
||||
help='RS485 发送光机命令(可多次), 如 --dlpc-cmd M730S0 --dlpc-cmd M999')
|
||||
args = ap.parse_args()
|
||||
|
||||
# LT8619C 板卡热插拔后可能停留在"仅电脑屏幕"拓扑, 先强制扩展,
|
||||
@@ -1413,6 +1663,11 @@ def main():
|
||||
'python hdmi_gui.py --edid-fields')
|
||||
sys.exit(1)
|
||||
sys.exit(run_dump(edid_fields=args.edid_fields))
|
||||
if args.dlpc_cmds:
|
||||
if not args.port:
|
||||
print('错误: --dlpc-cmd 需要配合 --port 指定串口(如 --port COM9)')
|
||||
sys.exit(2)
|
||||
sys.exit(run_dlpc(args.port, args.baud, args.dlpc_cmds))
|
||||
if args.test_pattern:
|
||||
displays = get_displays()
|
||||
if not displays:
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""dlpc_rs485 协议自检(无硬件): 对照上游 protocols.md 的实测帧向量做回归
|
||||
|
||||
用法: python selftest_rs485.py (全部通过退出码 0)
|
||||
"""
|
||||
import sys
|
||||
|
||||
import dlpc_rs485 as d
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, got, want):
|
||||
ok = got == want
|
||||
print(('PASS' if ok else 'FAIL'), name, '->', got, '' if ok else f'(期望 {want})')
|
||||
if not ok:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
# 1) 帧构造: protocols.md 实测示例 M730S0T2000
|
||||
f = d.build_frame('M730S0T2000')
|
||||
check('build_frame(M730S0T2000)', f.hex(' ').upper(),
|
||||
'D5 01 0B 4D 37 33 30 53 30 54 32 30 30 30 8C')
|
||||
|
||||
# 2) 增量解析: ok 应答跨两个半包喂入
|
||||
p = d.FrameParser()
|
||||
r1 = p.feed(bytes.fromhex('B5 F0'))
|
||||
r2 = p.feed(bytes.fromhex('02 6F 6B CC'))
|
||||
check('split-feed frames', len(r1) + len(r2), 1)
|
||||
fr = (r1 + r2)[0]
|
||||
check('type_code', fr.type_code, 0xF0)
|
||||
check('payload', fr.payload, 'ok')
|
||||
check('describe(ok)', d.describe(fr), 'ok')
|
||||
|
||||
# 3) 前导噪声 + 0xFD err:OL 译码(字符串标记, 不附 DLPC 释义)
|
||||
body = bytes.fromhex('FD 06 65 72 72 3A 4F 4C') # "err:OL"
|
||||
good = bytes([0xB5]) + body + bytes([d.sum_crc(body)])
|
||||
frames = d.FrameParser().feed(b'\x01\x02' + good)
|
||||
check('noise+err-frame count', len(frames), 1)
|
||||
check('describe(err:OL)', d.describe(frames[0]), '参数越界/读回复核不一致 err:OL')
|
||||
|
||||
# 4) 0xFA + 数字错误码 err:3 追加 DLPC 释义
|
||||
body = bytes.fromhex('FA 05') + b'err:3'
|
||||
fa = bytes([0xB5]) + body + bytes([d.sum_crc(body)])
|
||||
frames = d.FrameParser().feed(fa)
|
||||
check('describe(err:3)', d.describe(frames[0]),
|
||||
'开光机通信失败 err:3(I2C NACK(器件无应答))')
|
||||
|
||||
# 5) CRC 坏帧被丢弃后继续同步到下一帧
|
||||
bad = bytearray(good)
|
||||
bad[-1] ^= 0xFF
|
||||
frames = d.FrameParser().feed(bytes(bad) + good)
|
||||
check('bad-crc then good', len(frames), 1)
|
||||
check('recovered type', frames[0].type_code, 0xFD)
|
||||
|
||||
# 6) 非法输入拒绝
|
||||
for bad_cmd in ('', ' '):
|
||||
try:
|
||||
d.build_frame(bad_cmd)
|
||||
fails.append('empty-cmd-not-rejected')
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
d.build_frame('M' * 40)
|
||||
fails.append('long-cmd-not-rejected')
|
||||
except ValueError:
|
||||
pass
|
||||
print('空/超长命令拒绝: PASS')
|
||||
|
||||
# 7) 预设命令全部可组帧
|
||||
for _name, cmd, _note in d.PRESETS:
|
||||
d.build_frame(cmd)
|
||||
print(f'预设 {len(d.PRESETS)} 条全部可组帧: PASS')
|
||||
|
||||
print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS')
|
||||
sys.exit(1 if fails else 0)
|
||||
Reference in New Issue
Block a user