chore: 文件夹更名 HDMI-Tool -> DLPilot(路径与文档此前已对齐)

This commit is contained in:
2026-09-14 15:25:05 +08:00
parent f0f8073afc
commit a6721415a6
57 changed files with 0 additions and 0 deletions
+890
View File
@@ -0,0 +1,890 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DLPilot 主界面 — CustomTkinter 双栏仪表盘
================================================================
布局(DLPilot/UI参考布局.svg):
左列 显示器选择 | 支持的模式 | EDID 信息 | 测试图案/纯色/自定义 | EDID 原始数据
右列 串口选择与参数 | 串口数据(收发日志) | 预设与自定义命令 | 光强/定时快捷滑条
默认深色外观, 顶栏可切换 Dark/Light/System。
本模块只管界面: 显示枚举在 display_win, EDID 解析在 edid_parse,
投屏窗口在 patterns, 串口协议在 dlpc_rs485, 运行配置在 config。
"""
import ctypes
import ctypes
import os
import queue
import re
import time
import tkinter as tk
from tkinter import filedialog, messagebox
import customtkinter as ctk
import config
import display_win
import dlpc_rs485
import edid_parse
import patterns
MONO = ('Consolas', 12)
BOLD = ('Microsoft YaHei UI', 13, 'bold')
TITLE_FONT = ('Microsoft YaHei UI', 18, 'bold')
APP_NAME = 'DLPilot'
APP_VERSION = '0.3.1'
REPO_URL = 'https://gitea.hulk.wang/hulk/LT8619C_Debug_Tools'
def get_icon_path():
"""工具图标: 源码模式取 logos/ 下, 打包模式取 exe 内嵌资源(_MEIPASS)"""
import sys
if getattr(sys, 'frozen', False):
p = os.path.join(getattr(sys, '_MEIPASS', ''), 'logo_dlpilot.ico')
else:
p = os.path.join(os.path.dirname(os.path.abspath(__file__)),
'logos', 'logo_dlpilot.ico')
return p if os.path.exists(p) else None
def _apply_dropdown_theme():
"""CTk 默认下拉菜单是白底, 在深色界面里风格突兀; 改为随外观自适应的配色。
在创建任何控件前调用, 对全部 CTkComboBox/菜单生效。"""
ctk.ThemeManager.theme['DropdownMenu']['fg_color'] = ('#F2F2F2', '#2B2B2B')
ctk.ThemeManager.theme['DropdownMenu']['hover_color'] = ('#D8D8D8', '#3C3C3C')
ctk.ThemeManager.theme['DropdownMenu']['text_color'] = ('#1A1A1A', '#DADADA')
def run_gui(auto_close=0):
"""入口: 配置外观并启动主窗口"""
ctk.set_appearance_mode('System')
_apply_dropdown_theme()
App(auto_close).mainloop()
class App(ctk.CTk):
def __init__(self, auto_close=0):
super().__init__()
self.title('DLPilot — 3D 光固化光机调试台 (EDID · 测试图案 · RS485 光机控制)')
try: # 任务栏独立分组
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID('hulk.DLPilot')
except Exception:
pass
self.geometry('1440x920')
self.minsize(1180, 800)
ic = get_icon_path()
if ic:
try:
self.iconbitmap(ic) # 窗口标题栏图标
except Exception:
pass
self.displays = []
self._disp_labels = []
self._cur_label = ''
self.slots, self.area = config.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._poll_job = None
self._tl_order = [] # 温度查询应答顺序: 'DMD'/'LAMP' 先到先得
self._build()
self._load_displays()
# 主窗口按 ESC 也能关闭所有投屏(投屏窗口失焦时的兜底出口)
self.bind('<Escape>', lambda _e: patterns.close_all())
if dlpc_rs485.HAS_SERIAL:
self._poll_dlpc()
if auto_close > 0:
self.after(auto_close * 1000, self.destroy)
# ============================================================
# 布局
# ============================================================
def _set_appearance(self, mode):
ctk.set_appearance_mode(mode)
def _card(self, parent, title, button=None, command=None, right_label=None,
right_widget=None):
"""卡片容器: 圆角边框 + 标题行(可带右侧小按钮/右对齐标签/自定义控件),
返回 (卡片, 内容 frame)
卡片自身用 grid 参与列布局; 卡片内部(head/content/行控件)一律用 pack。
"""
card = ctk.CTkFrame(parent, corner_radius=10, border_width=1,
border_color=('gray70', 'gray30'))
head = ctk.CTkFrame(card, fg_color='transparent')
head.pack(fill='x', padx=12, pady=(8, 0))
ctk.CTkLabel(head, text=title, font=BOLD, anchor='w').pack(side='left', fill='y')
if right_widget is not None: # 自定义控件(如 电流读数+查电流)
right_widget.pack(side='right', fill='y')
if right_label is not None:
ctk.CTkLabel(head, textvariable=right_label,
text_color='gray60').pack(side='right', fill='y')
if button:
ctk.CTkButton(head, text=button, width=92,
command=command).pack(side='right', fill='y')
content = ctk.CTkFrame(card, fg_color='transparent')
content.pack(fill='both', expand=True, padx=12, pady=(2, 10))
return card, content
def _build(self):
top = ctk.CTkFrame(self, fg_color='transparent')
top.pack(fill='x', padx=12, pady=(10, 2))
ctk.CTkLabel(top, text='DLPilot', font=TITLE_FONT).pack(side='left')
ctk.CTkLabel(top, text='EDID · 测试图案 · 光机控制',
text_color='gray60').pack(side='left', padx=10, pady=(6, 0))
seg = ctk.CTkSegmentedButton(top, values=['Dark', 'Light', 'System'],
command=self._set_appearance)
seg.set('System')
seg.pack(side='right')
ctk.CTkButton(top, text='关于', width=48, height=24,
fg_color='transparent', border_width=1,
border_color=('gray55', 'gray35'),
text_color='gray60', hover_color=('gray82', 'gray22'),
command=self._show_about).pack(side='right', padx=(0, 12))
body = ctk.CTkFrame(self, fg_color='transparent')
body.pack(fill='both', expand=True, padx=12, pady=(4, 12))
body.grid_columnconfigure(0, weight=1, uniform='col')
body.grid_columnconfigure(1, weight=1, uniform='col')
body.grid_rowconfigure(0, weight=1)
left = ctk.CTkFrame(body, fg_color='transparent')
left.grid(row=0, column=0, sticky='nsew')
right = ctk.CTkFrame(body, fg_color='transparent')
right.grid(row=0, column=1, sticky='nsew', padx=(10, 0))
self._build_left(left)
if dlpc_rs485.HAS_SERIAL:
self._build_right(right)
else:
hint_card, hint = self._card(right, '光机控制不可用')
hint_card.grid(row=0, column=0, sticky='nsew')
ctk.CTkLabel(hint, justify='left', wraplength=380,
text='未安装 pyserial。\n\n'
'执行 py -m pip install pyserial 后重启本工具。'
).pack(expand=True)
def _build_left(self, col):
col.grid_rowconfigure(1, weight=2) # 支持的模式
col.grid_rowconfigure(2, weight=1) # EDID 信息(压缩高度)
col.grid_rowconfigure(4, weight=0) # EDID 原始数据(固定高, 压缩)
col.grid_columnconfigure(0, weight=1)
# -- 显示器选择区域 --
self.lbl_cur_var = tk.StringVar(value='当前输出: —')
card, c = self._card(col, '显示器选择区域', right_label=self.lbl_cur_var)
card.grid(row=0, column=0, sticky='ew')
row = ctk.CTkFrame(c, fg_color='transparent')
row.pack(fill='x')
self.cbo = ctk.CTkComboBox(row, state='readonly',
command=lambda _=None: self._on_select())
self.cbo.pack(side='left', fill='x', expand=True)
ctk.CTkButton(row, text='刷新', width=64,
command=self._load_displays).pack(side='left', padx=(6, 0))
# -- 支持的模式 --
card, c = self._card(col,'支持的模式 (EDID 解算)')
card.grid(row=1, column=0, sticky='nsew', pady=(10, 0))
row = ctk.CTkFrame(c, fg_color='transparent')
row.pack(fill='x')
ctk.CTkLabel(row, text='分辨率:').pack(side='left')
self.cbo_mode = ctk.CTkComboBox(row, width=180, state='readonly')
self.cbo_mode.pack(side='left', padx=6)
ctk.CTkButton(row, text='应用', width=64,
command=self._apply_mode).pack(side='left')
ctk.CTkButton(row, text='恢复', width=64,
command=self._restore_mode).pack(side='left', padx=(6, 0))
ctk.CTkLabel(row, text='临时切换, 重启还原',
text_color='gray50').pack(side='left', padx=8)
self.txt_modes = ctk.CTkTextbox(c, font=MONO, height=150)
self.txt_modes.pack(fill='both', expand=True, pady=(8, 0))
self.txt_modes.configure(state='disabled')
# -- EDID 信息 --
card, c = self._card(col, 'EDID 信息')
card.grid(row=2, column=0, sticky='nsew', pady=(10, 0))
self.edid_scroll = ctk.CTkScrollableFrame(c, height=150)
self.edid_scroll.pack(fill='both', expand=True)
# -- 测试图案 --
card, c = self._card(col,'测试图案 — 黑底 1:1 居中, 投屏按 ESC 或"停止投屏"退出',
button='停止投屏', command=patterns.close_all)
card.grid(row=3, column=0, sticky='ew', pady=(10, 0))
row1 = ctk.CTkFrame(c, fg_color='transparent')
row1.pack(fill='x')
for key, name in patterns.PATTERN_NAMES.items():
ctk.CTkButton(row1, text=name, width=78,
command=lambda k=key: self._show_pattern(k)
).pack(side='left', padx=(0, 6))
ctk.CTkLabel(row1, text='测试卡区域:').pack(side='left', padx=(12, 4))
self.area_var = tk.StringVar(value=self.area)
seg_area = ctk.CTkSegmentedButton(row1, values=list(config.AREA_OPTIONS),
command=self._save_area)
seg_area.set(self.area)
seg_area.pack(side='left')
row2 = ctk.CTkFrame(c, fg_color='transparent')
row2.pack(fill='x', pady=(6, 0))
for ch, items in patterns.SOLID_ROWS:
ctk.CTkLabel(row2, text=f'{ch}: 0x').pack(side='left')
for label, color in items:
ctk.CTkButton(row2, text=label, width=38, height=24,
fg_color=color, hover_color=color,
text_color='#000000' if color == '#00FF00' else '#FFFFFF',
command=lambda cc=color: self._show_solid(cc)
).pack(side='left', padx=3)
# -- 图案栏位: 两排各 5 个, 等宽 --
self.slot_buttons = []
for row_idx, group in enumerate((range(0, 5), range(5, 10))):
row = ctk.CTkFrame(c, fg_color='transparent')
row.pack(fill='x', pady=(6, 0))
for i in group:
cell = ctk.CTkFrame(row, fg_color='transparent')
cell.pack(side='left', padx=(0, 6))
btn = ctk.CTkButton(cell, text=self.slots[i]['name'], width=84,
command=lambda i=i: self._show_custom(i))
btn.pack(side='left')
ctk.CTkButton(cell, text='\uE713', width=26, height=24,
font=('Segoe MDL2 Assets', 13),
command=lambda i=i: self._configure_slot(i)
).pack(side='left', padx=(3, 0))
self.slot_buttons.append(btn)
if row_idx == 0:
ctk.CTkLabel(row, text='点击投图, 齿轮按钮配置图片',
text_color='gray50').pack(side='left', padx=4)
else:
ctk.CTkLabel(row, text='校准图案(齿轮改图)',
text_color='gray50').pack(side='left', padx=4)
# -- EDID 原始数据 --
card, c = self._card(col, 'EDID 原始数据',
button='逐字节解析', command=self._show_edid_fields)
card.grid(row=4, column=0, sticky='ew', pady=(10, 0))
self.txt_hex = ctk.CTkTextbox(c, font=MONO, wrap='none', height=130)
self.txt_hex.pack(fill='x')
self.txt_hex.configure(state='disabled')
def _build_right(self, col):
col.grid_rowconfigure(1, weight=1) # 串口数据区域
col.grid_columnconfigure(0, weight=1)
# -- 串口选择与参数 --
card, c = self._card(col,'串口 — RS485 → 板载 GD32 网关 → DLPC3421')
card.grid(row=0, column=0, sticky='ew')
row1 = ctk.CTkFrame(c, fg_color='transparent')
row1.pack(fill='x')
self._dlpc_ports = dlpc_rs485.DLPC485.list_ports_info()
self.cbo_dlpc = ctk.CTkComboBox(row1, state='readonly',
values=[lb for _d, lb in self._dlpc_ports])
if config.SERIAL_CFG['port']:
self._dlpc_pick(config.SERIAL_CFG['port'])
self.cbo_dlpc.pack(side='left', fill='x', expand=True)
ctk.CTkButton(row1, text='刷新', width=64,
command=self._dlpc_refresh).pack(side='left', padx=(6, 0))
self.btn_dlpc = ctk.CTkButton(row1, text='连接', width=72,
command=self._dlpc_toggle)
self.btn_dlpc.pack(side='left', padx=(6, 0))
row2 = ctk.CTkFrame(c, fg_color='transparent')
row2.pack(fill='x', pady=(6, 0))
ctk.CTkLabel(row2, text='波特率:').pack(side='left')
self.cbo_baud = ctk.CTkComboBox(row2, width=110,
values=('115200', '57600', '38400', '9600'))
self.cbo_baud.set(str(config.SERIAL_CFG['baud']))
self.cbo_baud.pack(side='left', padx=6)
ctk.CTkLabel(row2, text='数据位:').pack(side='left')
self.cbo_databits = ctk.CTkComboBox(row2, width=62, values=('8', '7', '6', '5'))
self.cbo_databits.set(str(config.SERIAL_CFG['bytesize']))
self.cbo_databits.pack(side='left', padx=(6, 0))
ctk.CTkLabel(row2, text='校验:').pack(side='left', padx=(10, 0))
self.cbo_parity = ctk.CTkComboBox(row2, width=54, values=('N', 'E', 'O'))
self.cbo_parity.set(str(config.SERIAL_CFG['parity']))
self.cbo_parity.pack(side='left', padx=(6, 0))
ctk.CTkLabel(row2, text='停止位:').pack(side='left', padx=(10, 0))
self.cbo_stopbits = ctk.CTkComboBox(row2, width=54, values=('1', '2'))
self.cbo_stopbits.set(str(config.SERIAL_CFG['stopbits']))
self.cbo_stopbits.pack(side='left', padx=(6, 0))
self.lbl_dlpc_st = ctk.CTkLabel(row2, text='未连接', text_color='gray60')
self.lbl_dlpc_st.pack(side='left', padx=6)
# -- 串口数据区域 --
card, c = self._card(col,'串口数据 — 收发日志(译码 + 原始 hex)',
button='清空', command=self._dlpc_clear)
card.grid(row=1, column=0, sticky='nsew', pady=(10, 0))
self.txt_dlpc = ctk.CTkTextbox(c, font=MONO)
self.txt_dlpc.pack(fill='both', expand=True)
self.txt_dlpc.configure(state='disabled')
self._dlpc_log('帧格式: D5 01 LEN <命令> CRC / B5 TYPE LEN <payload> CRC, '
'累加和校验; 全部命令见 README"光机控制"')
# -- 光机命令 --
# 标题行右侧: 电流实时读数 + 查电流按钮(okR..G..B.. 应答解析)
cur_frame = ctk.CTkFrame(self, fg_color='transparent')
self.var_current = tk.StringVar(value='电流: —')
ctk.CTkLabel(cur_frame, textvariable=self.var_current, width=168,
anchor='e', text_color='#3B8ED0').pack(side='left')
ctk.CTkButton(cur_frame, text='查电流', width=64, height=24,
command=self._query_current).pack(side='left', padx=(6, 0))
card, c = self._card(col,'光机命令 — 预设 / 自定义 / TEC',
right_widget=cur_frame)
card.grid(row=2, column=0, sticky='ew', pady=(10, 0))
row1 = ctk.CTkFrame(c, fg_color='transparent')
row1.pack(fill='x')
for name, cmd in (('开光机', 'M730S0'), ('关光机', 'M730S1'),
('查版本', 'M999'), ('软件重启', 'M888'),
('开TEC', 'M7S0'), ('关TEC', 'M7S1'), ('TEC33.0°C', 'M8S330')):
ctk.CTkButton(row1, text=name, width=64,
command=lambda cc=cmd: self._dlpc_send(cc)
).pack(side='left', fill='x', expand=True, padx=(0, 4))
row2 = ctk.CTkFrame(c, fg_color='transparent')
row2.pack(fill='x', pady=(6, 0))
row2.grid_columnconfigure(0, weight=6) # 左: 预设(约 6 份)
row2.grid_columnconfigure(1, weight=4) # 右: 温度查询+实时显示(约 4 份)
lf2 = ctk.CTkFrame(row2, fg_color='transparent')
lf2.grid(row=0, column=0, sticky='ew')
ctk.CTkLabel(lf2, text='预设:', width=52, anchor='w').pack(side='left')
self._dlpc_presets = [(n, cmd) for n, cmd, _ in dlpc_rs485.PRESETS]
self.cbo_dlpc_cmd = ctk.CTkComboBox(
lf2, state='readonly',
values=[f'{n} {cmd}' for n, cmd in self._dlpc_presets])
self.cbo_dlpc_cmd.set(self._dlpc_presets[0][0] + ' ' + self._dlpc_presets[0][1])
self.cbo_dlpc_cmd.pack(side='left', fill='x', expand=True, padx=6)
ctk.CTkButton(lf2, text='发送', width=64,
command=self._dlpc_send_preset).pack(side='left')
rf2 = ctk.CTkFrame(row2, fg_color='transparent')
rf2.grid(row=0, column=1, sticky='ew', padx=(8, 0))
ctk.CTkButton(rf2, text='查DMD温度', width=90,
command=self._query_dmd).pack(side='left')
self.var_dmd = tk.StringVar(value='DMD: —')
ctk.CTkLabel(rf2, textvariable=self.var_dmd, width=110, height=28,
corner_radius=6, fg_color=('#F2F2F2', '#181818'),
anchor='w').pack(side='left', fill='x', expand=True, padx=(6, 0))
row3 = ctk.CTkFrame(c, fg_color='transparent')
row3.pack(fill='x', pady=(6, 0))
row3.grid_columnconfigure(0, weight=6)
row3.grid_columnconfigure(1, weight=4)
lf3 = ctk.CTkFrame(row3, fg_color='transparent')
lf3.grid(row=0, column=0, sticky='ew')
ctk.CTkLabel(lf3, text='自定义:', width=52, anchor='w').pack(side='left')
self.var_custom_cmd = tk.StringVar()
ent = ctk.CTkEntry(lf3, textvariable=self.var_custom_cmd,
placeholder_text='如 M730S0T2000')
ent.pack(side='left', fill='x', expand=True, padx=6)
ent.bind('<Return>', lambda _e: self._dlpc_send_custom())
ctk.CTkButton(lf3, text='发送', width=64,
command=self._dlpc_send_custom).pack(side='left')
rf3 = ctk.CTkFrame(row3, fg_color='transparent')
rf3.grid(row=0, column=1, sticky='ew', padx=(8, 0))
ctk.CTkButton(rf3, text='查LAMP温度', width=90,
command=self._query_lamp).pack(side='left')
self.var_lamp = tk.StringVar(value='LAMP: —')
ctk.CTkLabel(rf3, textvariable=self.var_lamp, width=110, height=28,
corner_radius=6, fg_color=('#F2F2F2', '#181818'),
anchor='w').pack(side='left', fill='x', expand=True, padx=(6, 0))
# -- 快捷滑条 --
card, c = self._card(col,'快捷滑条 — 光强 / 定时投光 / TEC 温度')
card.grid(row=3, column=0, sticky='ew', pady=(10, 0))
rowa = ctk.CTkFrame(c, fg_color='transparent')
rowa.pack(fill='x')
ctk.CTkLabel(rowa, text='光强(54-800):').pack(side='left')
self.var_light = tk.StringVar(value='100')
sld = ctk.CTkSlider(rowa, from_=54, to=800, number_of_steps=149,
command=lambda v: self.var_light.set(str(int(round(float(v))))))
sld.set(100)
sld.pack(side='left', fill='x', expand=True, padx=8)
self.sld_light = sld
self.ent_light = ctk.CTkEntry(rowa, width=56, justify='center',
textvariable=self.var_light)
self.ent_light.pack(side='left')
self.ent_light.bind('<Return>', self._light_entry_apply)
ctk.CTkButton(rowa, text='下发电流值', width=110,
command=self._dlpc_send_light).pack(side='left', padx=(6, 0))
rowb = ctk.CTkFrame(c, fg_color='transparent')
rowb.pack(fill='x', pady=(8, 0))
ctk.CTkLabel(rowb, text='定时投光(毫秒):').pack(side='left')
self.var_timer = tk.StringVar(value='2000')
sld_t = ctk.CTkSlider(rowb, from_=0, to=32700, number_of_steps=327,
command=lambda v: self.var_timer.set(str(int(round(float(v))))))
sld_t.set(2000)
sld_t.pack(side='left', fill='x', expand=True, padx=8)
self.sld_timer = sld_t
self.ent_timer = ctk.CTkEntry(rowb, width=56, justify='center',
textvariable=self.var_timer)
self.ent_timer.pack(side='left')
self.ent_timer.bind('<Return>', self._timer_entry_apply)
ctk.CTkButton(rowb, text='开光机(定时)', width=110,
command=self._dlpc_send_timer).pack(side='left', padx=(6, 0))
rowc = ctk.CTkFrame(c, fg_color='transparent')
rowc.pack(fill='x', pady=(8, 0))
ctk.CTkLabel(rowc, text='TEC凝露点(0.1°C):').pack(side='left')
self.var_tec = tk.StringVar(value='330')
sld_c = ctk.CTkSlider(rowc, from_=200, to=350, number_of_steps=150,
command=lambda v: self.var_tec.set(str(int(round(float(v))))))
sld_c.set(330)
sld_c.pack(side='left', fill='x', expand=True, padx=8)
self.sld_tec = sld_c
self.ent_tec = ctk.CTkEntry(rowc, width=56, justify='center',
textvariable=self.var_tec)
self.ent_tec.pack(side='left')
self.ent_tec.bind('<Return>', self._tec_entry_apply)
ctk.CTkButton(rowc, text='下发温度值', width=110,
command=self._dlpc_send_tec).pack(side='left', padx=(6, 0))
# ============================================================
# 显示器 / 分辨率 / EDID
# ============================================================
@staticmethod
def _ro(txt):
txt.configure(state='disabled')
def _set_text(self, txt, content):
txt.configure(state='normal')
txt.delete('1.0', 'end')
txt.insert('1.0', content)
txt.configure(state='disabled')
def _cur_display(self):
if self.cbo.get() in self._disp_labels:
return self.displays[self._disp_labels.index(self.cbo.get())]
return None
def _load_displays(self):
prev = self._cur_label
self.displays = display_win.get_displays()
self._disp_labels = [f"{d['device']} {d['adapter']} [{d['mon_name']}]"
for d in self.displays]
self.cbo.configure(values=self._disp_labels)
if not self.displays:
self._cur_label = ''
self.lbl_cur_var.set('未找到活动显示器')
return
idx = self._disp_labels.index(prev) if prev in self._disp_labels else 0
self._cur_label = self._disp_labels[idx]
self.cbo.set(self._cur_label)
self._on_select()
def _on_select(self, _evt=None):
if self.cbo.get() not in self._disp_labels:
return
self._cur_label = self.cbo.get()
d = self._cur_display()
self.lbl_cur_var.set(f"当前输出: {d['cur_mode']}")
keys, modes = display_win.enum_modes(d['device'])
self._modes = modes
self.cbo_mode.configure(values=keys)
if d['cur_mode'] in modes:
self.cbo_mode.set(d['cur_mode'])
elif keys:
self.cbo_mode.set(keys[0])
self._clear_edid_rows()
if not d['edid']:
self._add_edid_row('EDID', '读取失败(注册表无 EDID 数据)')
self._set_text(self.txt_modes, '')
self._set_text(self.txt_hex, '')
return
e = edid_parse.parse_edid(d['edid'])
for k, v in edid_parse.edid_info_lines(e):
self._add_edid_row(k, v)
lines = []
for k, t in enumerate(e['dtds']):
src = 'DTD 首选' if k == 0 else 'DTD'
lines.append(f"{t['hact']}x{t['vact']} @{t['refresh']:g}Hz "
f"{t['clk_khz'] / 1000:.2f}MHz {src}")
for m in e['cea_video']:
lines.append(f'{m} [CEA-VIC]')
for m in e['est']:
lines.append(f'{m} [已建立时序]')
for m in e['std']:
lines.append(f'{m} [标准时序]')
self._set_text(self.txt_modes, '\n'.join(lines))
self._set_text(self.txt_hex, edid_parse.hex_dump(d['edid']))
def _clear_edid_rows(self):
for w in self.edid_scroll.winfo_children():
w.destroy()
def _add_edid_row(self, k, v):
row = ctk.CTkFrame(self.edid_scroll, fg_color='transparent')
row.pack(fill='x', pady=1)
ctk.CTkLabel(row, text=k, width=132, anchor='w',
text_color='gray60').pack(side='left')
ctk.CTkLabel(row, text=v, anchor='w').pack(side='left',
fill='x', expand=True)
def _apply_mode(self):
d = self._cur_display()
if d is None or self.cbo_mode.get() not in self._modes:
return
dev = d['device']
if dev not in self._orig_modes: # 记住首次切改前的原始模式, 供"恢复"用
cur = display_win.DEVMODEW()
cur.dmSize = ctypes.sizeof(cur)
if display_win.user32.EnumDisplaySettingsW(
dev, display_win.ENUM_CURRENT_SETTINGS, ctypes.byref(cur)):
self._orig_modes[dev] = cur
ok, r = display_win.apply_mode(dev, self._modes[self.cbo_mode.get()])
if not ok:
messagebox.showerror('切换失败', f'ChangeDisplaySettingsEx 返回码 {r}')
return
self.after(500, self._load_displays) # 等模式生效后刷新矩形/EDID 面板
def _restore_mode(self):
d = self._cur_display()
if d is None:
return
dm = self._orig_modes.pop(d['device'], None)
if dm is None:
messagebox.showinfo('无可恢复', '该显示器没有记录切改前的模式')
return
display_win.apply_mode(d['device'], dm)
self.after(500, self._load_displays)
def _show_pattern(self, kind):
d = self._cur_display()
if d and d['rect']:
patterns.show_pattern(self, d['rect'], kind, area=self.area_var.get())
def _show_solid(self, color):
d = self._cur_display()
if d and d['rect']:
patterns.show_pattern(self, d['rect'], color, area=self.area_var.get())
def _save_area(self, value):
self.area_var.set(value)
self.area = value
config.save_slots(self.slots, self.area)
def _show_custom(self, idx):
slot = self.slots[idx]
if not slot['file']:
messagebox.showinfo('未配置图片', f'请先点击齿轮按钮为"{slot["name"]}"配置图片。')
return
path = os.path.join(config.BASE_DIR, slot['file'])
if not os.path.exists(path):
messagebox.showerror('图片不存在', path)
return
d = self._cur_display()
if d and d['rect']:
patterns.show_pattern_image(self, d['rect'], path, area=self.area_var.get())
def _configure_slot(self, idx):
dlg = ctk.CTkToplevel(self)
dlg.title(f'配置自定义栏位 {idx + 1}')
dlg.resizable(False, False)
dlg.grab_set()
dlg.after(150, dlg.grab_set) # CTkToplevel 焦点时序
name_var = tk.StringVar(value=self.slots[idx]['name'])
file_var = tk.StringVar(value=self.slots[idx]['file'] or '(未设置)')
body = ctk.CTkFrame(dlg, fg_color='transparent')
body.pack(padx=16, pady=14)
ctk.CTkLabel(body, text='按钮名称:').grid(row=0, column=0, sticky='w', pady=4)
ctk.CTkEntry(body, textvariable=name_var, width=240
).grid(row=0, column=1, columnspan=2, sticky='w', padx=6)
ctk.CTkLabel(body, text='图片文件:').grid(row=1, column=0, sticky='w', pady=4)
ctk.CTkLabel(body, textvariable=file_var, text_color='#3B8ED0'
).grid(row=1, column=1, columnspan=2, sticky='w', padx=6)
if not config.HAS_PIL:
ctk.CTkLabel(body, text='(未安装 Pillow, 仅支持 PNG/GIF; '
'安装 pillow 后可用 JPG/BMP/WebP)',
text_color='#E07A3F').grid(row=2, column=0, columnspan=3,
sticky='w')
def choose():
exts = ' '.join(f'*{e}' for e in config.IMG_EXTS)
path = filedialog.askopenfilename(parent=dlg, title='选择图片',
filetypes=[('图片文件', exts),
('所有文件', '*.*')])
if path:
try:
file_var.set(config.copy_image_into_script_dir(path))
except OSError as ex:
messagebox.showerror('复制图片失败', str(ex), parent=dlg)
def save():
name = name_var.get().strip() or f'自定义{idx + 1}'
fname = file_var.get()
self.slots[idx] = {'name': name,
'file': None if fname.startswith('(') else fname}
config.save_slots(self.slots, self.area_var.get())
self.slot_buttons[idx].configure(text=name)
dlg.destroy()
btns = ctk.CTkFrame(body, fg_color='transparent')
btns.grid(row=3 if not config.HAS_PIL else 2, column=0,
columnspan=3, sticky='w', pady=(10, 0))
ctk.CTkButton(btns, text='选择图片...', width=110,
command=choose).pack(side='left', padx=(0, 6))
ctk.CTkButton(btns, text='清除图片', width=90,
command=lambda: file_var.set('(未设置)')).pack(side='left', padx=(0, 6))
ctk.CTkButton(btns, text='保存', width=70, command=save).pack(side='left', padx=(0, 6))
ctk.CTkButton(btns, text='取消', width=70, fg_color='gray40',
hover_color='gray25', command=dlg.destroy).pack(side='left')
def _show_about(self):
dlg = ctk.CTkToplevel(self)
dlg.title(f'关于 {APP_NAME}')
dlg.resizable(False, False)
dlg.after(150, dlg.grab_set())
box = ctk.CTkFrame(dlg, fg_color='transparent')
box.pack(padx=26, pady=20)
ctk.CTkLabel(box, text=APP_NAME, font=TITLE_FONT, anchor='w').pack(anchor='w')
ctk.CTkLabel(box, text=f'v{APP_VERSION} — 3D 光固化光机调试台',
text_color='gray60', anchor='w').pack(anchor='w', pady=(0, 12))
for line in ('EDID 查看 / 逐字节解析 / 分辨率切换',
'测试图案与校准图全屏投屏',
'RS485 光机控制: 开关光机 / 光强 / 定时 / 温度 / TEC',
'配套硬件: DLPilotHDMI-0.16" DLP 转接板)',
'作者: Hulk'):
ctk.CTkLabel(box, text='· ' + line, anchor='w').pack(anchor='w', pady=1)
ctk.CTkLabel(box, text='仓库: ' + REPO_URL, anchor='w',
text_color='#3B8ED0').pack(anchor='w', pady=(12, 0))
ctk.CTkButton(box, text='关闭', width=84,
command=dlg.destroy).pack(anchor='e', pady=(14, 0))
def _show_edid_fields(self):
d = self._cur_display()
if d is None:
return
if not d['edid']:
messagebox.showinfo('无 EDID', '该显示器注册表中没有 EDID 数据, 无法逐字节解析')
return
dlg = ctk.CTkToplevel(self)
dlg.title(f'EDID 逐字节解析 — {d["device"]} [{d["mon_name"]}]')
dlg.geometry('1120x700')
dlg.after(150, dlg.grab_set())
txt = ctk.CTkTextbox(dlg, font=MONO, wrap='char')
txt.pack(fill='both', expand=True, padx=8, pady=(8, 4))
try: # 深底配色(个别 CTk 版本不支持 tag 时退化为单色)
txt.tag_config('field', foreground='#E8E8E8')
txt.tag_config('raw', foreground='#7F7F7F')
txt.tag_config('note', foreground='#9FB8CC')
except Exception:
pass
for off, hx, meaning, val, note in edid_parse.edid_field_rows(d['edid']):
try:
txt.insert('end', f'{off} {meaning} {val}\n', 'field')
if hx:
txt.insert('end', f' 原始: {hx}\n', 'raw')
if note:
txt.insert('end', f' {note}\n', 'note')
except tk.TclError: # 个别 CTk 版本不支持 tag 插入, 退化为单色
txt.insert('end', f'{off} {meaning} {val}\n')
self._ro(txt)
ctk.CTkButton(dlg, text='关闭', width=90, command=dlg.destroy).pack(pady=(0, 8))
# ============================================================
# 光机控制(RS485)
# ============================================================
def _dlpc_on_event(self, line):
# 温度查询应答: okTL:<0.1°C>, 依发送顺序归入 DMD/LAMP 实时显示框
m = re.search(r'← okTL:(-?\d+)', line)
if m and self._tl_order:
which = self._tl_order.pop(0)
try:
text = f'{which}: {int(m.group(1)) / 10:.1f}°C'
except ValueError:
text = f'{which}: {m.group(1)}'
self._dlpc_log_q.put(('temp', text))
# 电流查询应答: okR<n>G<n>B<n> -> 标题行实时读数
m = re.search(r'← okR(\d+)G(\d+)B(\d+)', line)
if m:
self._dlpc_log_q.put(('current', f'电流 R:{m.group(1)} '
f'G:{m.group(2)} B:{m.group(3)}'))
self._dlpc_log_q.put(('event', line))
def _query_current(self):
self._dlpc_send('M732')
def _query_dmd(self):
self._tl_order.append('DMD')
self._dlpc_send('M201')
def _query_lamp(self):
self._tl_order.append('LAMP')
self._dlpc_send('M202')
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 = '#2FA574'
elif ('失败' in text or '丢失' in text or '' in text
or '非法' in text or '丢弃' in text):
color = '#E07A3F'
else:
color = 'gray60'
self.lbl_dlpc_st.configure(text=text, text_color=color)
elif kind == 'temp':
if text.startswith('DMD'):
self.var_dmd.set(text)
else:
self.var_lamp.set(text)
elif kind == 'current':
self.var_current.set(text)
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.configure(text='断开' if connected else '连接')
self._poll_job = 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]) > 500:
self.txt_dlpc.delete('1.0', '2.0') # 只保留最近约 500 行
self.txt_dlpc.see('end')
self.txt_dlpc.configure(state='disabled')
def _dlpc_clear(self):
self._set_text(self.txt_dlpc, '')
def _dlpc_refresh(self):
self._dlpc_ports = dlpc_rs485.DLPC485.list_ports_info()
self.cbo_dlpc.configure(values=[lb for _d, lb in self._dlpc_ports])
devices = [d for d, _l in self._dlpc_ports]
if config.SERIAL_CFG['port'] in devices:
self._dlpc_pick(config.SERIAL_CFG['port'])
elif devices:
self._dlpc_pick(devices[0])
def _dlpc_pick(self, device):
"""按 COM 口名选中下拉项(下拉显示的是带描述的标签)"""
for i, (d, label) in enumerate(self._dlpc_ports):
if d == device:
self.cbo_dlpc.set(label)
return
def _dlpc_selected_port(self):
label = self.cbo_dlpc.get()
for d, lb in self._dlpc_ports:
if lb == label:
return d
return label.strip()
def _dlpc_toggle(self):
if self.dlpc.is_connected():
self.dlpc.close()
return
port = self._dlpc_selected_port()
if not port:
self.lbl_dlpc_st.configure(text='未选择串口', text_color='#E07A3F')
return
try:
baud = int(self.cbo_baud.get())
bytesize = int(self.cbo_databits.get())
stopbits = int(self.cbo_stopbits.get())
except ValueError:
self.lbl_dlpc_st.configure(text='串口参数非法', text_color='#E07A3F')
return
parity = self.cbo_parity.get().strip().upper()[:1] or 'N'
config.SERIAL_CFG.update(port=port, baud=baud, bytesize=bytesize,
parity=parity, stopbits=stopbits)
config.save_slots(self.slots, self.area_var.get()) # 记住本次串口
self.dlpc.open(port, baud, bytesize=bytesize, parity=parity, stopbits=stopbits)
def _dlpc_send(self, cmd):
if not self.dlpc.is_connected():
self.lbl_dlpc_st.configure(text='未连接串口', text_color='#E07A3F')
return
self.dlpc.send(cmd)
def _dlpc_send_preset(self):
label = self.cbo_dlpc_cmd.get()
for name, cmd in self._dlpc_presets:
if label.startswith(name + ' '):
self._dlpc_send(cmd)
return
def _dlpc_send_custom(self):
cmd = self.var_custom_cmd.get().strip()
if cmd:
self._dlpc_send(cmd)
def _light_entry_apply(self, _evt=None):
"""滑条数值输入框回车: 校验并限幅 54-800, 同步回滑条"""
try:
v = int(self.var_light.get())
except ValueError:
self.var_light.set(str(int(float(self.sld_light.get()))))
return
v = max(54, min(800, v))
self.sld_light.set(v)
self.var_light.set(str(v))
def _timer_entry_apply(self, _evt=None):
"""定时滑条数值输入框回车: 校验并限幅 0-32700(ms), 同步回滑条"""
try:
v = int(self.var_timer.get())
except ValueError:
self.var_timer.set(str(int(float(self.sld_timer.get()))))
return
v = max(0, min(32700, v))
self.sld_timer.set(v)
self.var_timer.set(str(v))
def _dlpc_send_light(self):
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 _dlpc_send_timer(self):
try:
v = int(self.var_timer.get())
except ValueError:
messagebox.showinfo('定时非法', '定时投光需为 0-32700 的整数毫秒(0=不限时)')
return
self._dlpc_send('M730S0' if v <= 0 else f'M730S0T{v}')
def _tec_entry_apply(self, _evt=None):
"""TEC 温度输入框回车: 校验并限幅 200-350(0.1°C), 同步回滑条"""
try:
v = int(self.var_tec.get())
except ValueError:
self.var_tec.set(str(int(float(self.sld_tec.get()))))
return
v = max(200, min(350, v))
self.sld_tec.set(v)
self.var_tec.set(str(v))
def _dlpc_send_tec(self):
try:
v = int(self.var_tec.get())
except ValueError:
messagebox.showinfo('TEC 温度非法', 'TEC 凝露点需为 200-350 的整数(0.1°C)')
return
if not 200 <= v <= 350:
messagebox.showinfo('TEC 温度越界',
'TEC 凝露点范围 200-350(0.1°C), 越界固件会回 err:OL')
return
self._dlpc_send(f'M8S{v}')
def destroy(self):
try: # 取消待触发的轮询回调, 避免销毁后 Tcl 报错
if self._poll_job:
self.after_cancel(self._poll_job)
except Exception:
pass
try: # 两种退出路径(关窗/--seconds 自动关)都收掉串口线程
self.dlpc.close()
except Exception:
pass
super().destroy()
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
把 hdmi_gui.py 打包成单文件 exe(工具名: DLPilot)
================================
使用 PyInstaller --onefile --noconsole: Python 解释器 + 标准库 + tkinter
全部打进一个 exe, 目标机器无需安装任何依赖。
用法:
python build_exe.py 自动安装 PyInstaller(如缺)并打包
python build_exe.py --debug 额外打一个带控制台的变体(--dump 可用)
产物: release/DLPilot.exe(及 --debug 时的 DLPilot-console.exe)
首次运行 onefile exe 会先解压到临时目录, 启动比脚本慢一两秒, 属正常现象。
"""
import os
import subprocess
import sys
# 本脚本位于 src/, 打包产物输出到工具根的 release/(与 pattern_slots.json 同目录)
SRC = os.path.dirname(os.path.abspath(__file__))
BASE = os.path.dirname(SRC)
ICO = os.path.join(SRC, 'logos', 'logo_dlpilot.ico')
def ensure_pyinstaller():
try:
import PyInstaller # noqa: F401
return
except ImportError:
pass
print('PyInstaller 未安装, 正在安装...')
subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'pyinstaller'])
def extra_collect_args():
"""CustomTkinter 的主题/字体等数据文件必须显式收集, 否则打包后 GUI 启动报错"""
try:
import customtkinter # noqa: F401
return ['--collect-all', 'customtkinter']
except ImportError:
print('警告: 未安装 customtkinter, 打包产物将无法启动 GUI!')
return []
def remove_stale(exe_path):
"""删除旧 exe; 若被占用(上次验证的进程还在), 先结束同名进程再重试"""
if not os.path.exists(exe_path):
return
try:
os.remove(exe_path)
return
except PermissionError:
pass
import time
print(f'{exe_path} 被占用, 结束残留进程后重试...')
subprocess.run(['taskkill', '/F', '/IM', os.path.basename(exe_path)],
capture_output=True)
time.sleep(1.0)
os.remove(exe_path)
def build(name, extra_args):
dist = os.path.join(BASE, 'release')
work = os.path.join(SRC, 'build')
remove_stale(os.path.join(dist, name + '.exe'))
cmd = [sys.executable, '-m', 'PyInstaller',
'--onefile', '--clean', '--name', name,
'--distpath', dist, '--workpath', work, '--specpath', work,
] + extra_args + extra_collect_args() + [os.path.join(SRC, 'hdmi_gui.py')]
if os.path.exists(ICO):
# exe 文件图标 + 运行时窗口图标资源(从 _MEIPASS 读取)
cmd += ['--icon', ICO, '--add-data', f'{ICO};.']
else:
print('警告: 未找到 logos/logo_dlpilot.ico(先运行 logos/make_ico.py), 本次打包不带图标')
print('\n>>', ' '.join(cmd))
subprocess.check_call(cmd)
out = os.path.join(dist, name + '.exe')
print(f'完成: {out} ({os.path.getsize(out) / 1024 / 1024:.1f} MB)')
def main():
build_console = '--debug' in sys.argv
ensure_pyinstaller()
# 常规版: 双击即用, 无黑框(GUI/图案/自定义栏位全部可用, --dump 除外)
build('DLPilot', ['--noconsole'])
if build_console:
# 调试版: 带控制台, --dump / 异常栈可见
build('DLPilot-console', [])
if __name__ == '__main__':
main()
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
工具运行配置: 运行目录定位 / pattern_slots.json(自定义栏位 + 串口记忆)
================================================================
PyInstaller onefile 打包后 __file__ 指向临时解压目录,
配置必须落在 exe 旁边, 故 frozen 时改用 sys.executable 定位。
"""
import json
import os
import shutil
import sys
import dlpc_rs485
# 可选 Pillow: 装了则支持 JPEG/BMP/WebP, 没装则用 tkinter 原生(仅 PNG/GIF)
# Image/ImageTk 在此导入一次, patterns.py 复用同一份对象
try:
from PIL import Image, ImageTk # noqa: F401
HAS_PIL = True
except ImportError:
Image = ImageTk = None
HAS_PIL = False
# 测试卡区域常量(栏位配置与投屏共用)
PAT_W, PAT_H = 640, 360
AREA_OPTIONS = ('640x360', '800x600', 'full')
DEFAULT_AREA = AREA_OPTIONS[0]
# ============================================================
# 自定义图案栏位: 配置存于脚本目录 pattern_slots.json,
# 图片文件同样保存在脚本目录下
# 注: PyInstaller onefile 打包后 __file__ 指向临时解压目录,
# 配置必须落在 exe 旁边, 故 frozen 时改用 sys.executable 定位
# ============================================================
if getattr(sys, 'frozen', False):
BASE_DIR = os.path.dirname(os.path.abspath(sys.executable))
else:
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
SLOT_COUNT = 10
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,
'bytesize': 8, 'parity': 'N', 'stopbits': 1}
def load_slots():
"""读 pattern_slots.json, 返回 (slots, area)。
新格式为 {'area': 'WxH'|'full', 'slots': [...], 'serial': {...}};
兼容旧版纯数组格式(无 area)与无 serial 键的中间格式。
"""
slots = [{'name': f'自定义{i + 1}', 'file': None} for i in range(SLOT_COUNT)]
try:
with open(SLOT_CONFIG, encoding='utf-8') as f:
data = json.load(f)
except Exception:
return slots, DEFAULT_AREA
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
try:
SERIAL_CFG['bytesize'] = int(ser.get('bytesize') or 8)
except (TypeError, ValueError):
pass
SERIAL_CFG['parity'] = str(ser.get('parity') or 'N').upper()[:1]
try:
SERIAL_CFG['stopbits'] = int(ser.get('stopbits') or 1)
except (TypeError, ValueError):
pass
else:
area, items = DEFAULT_AREA, data
for i, item in enumerate(items[:SLOT_COUNT]):
if isinstance(item, dict):
slots[i]['name'] = str(item.get('name') or slots[i]['name'])
slots[i]['file'] = item.get('file') or None
return slots, area
def save_slots(slots, area):
with open(SLOT_CONFIG, 'w', encoding='utf-8') as f:
json.dump({'area': area, 'slots': slots, 'serial': SERIAL_CFG},
f, ensure_ascii=False, indent=2)
def copy_image_into_script_dir(src_path):
"""把任意路径的图片复制到脚本目录, 重名自动加序号, 返回文件名"""
dst_name = os.path.basename(src_path)
dst = os.path.join(BASE_DIR, dst_name)
if os.path.normcase(os.path.abspath(src_path)) == os.path.normcase(dst):
return dst_name
stem, ext = os.path.splitext(dst_name)
n = 1
while os.path.exists(dst):
dst_name = f'{stem}_{n}{ext}'
dst = os.path.join(BASE_DIR, dst_name)
n += 1
shutil.copy2(src_path, dst)
return dst_name
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Win32 显示器枚举 / 分辨率切换 / 显示拓扑(纯 ctypes, 无第三方依赖)
================================================================
get_displays() 枚举活动显示器(设备名/显卡/监视器/当前模式/矩形/EDID)
enum_modes() 显卡按该屏 EDID 提供的模式列表
apply_mode() 临时切换分辨率(不写注册表, 重启还原)
ensure_display_extend() 智能拓扑: 仅转接板(EDID 厂商 XLS)接入但未进桌面时强制扩展
"""
import ctypes
import ctypes.wintypes as wt
import winreg
# ============================================================
# Win32 显示 API
# ============================================================
user32 = ctypes.windll.user32
ENUM_CURRENT_SETTINGS = 0xFFFFFFFF # (DWORD)-1
class DISPLAY_DEVICEW(ctypes.Structure):
_fields_ = [
('cb', wt.DWORD),
('DeviceName', ctypes.c_wchar * 32),
('DeviceString', ctypes.c_wchar * 128),
('StateFlags', wt.DWORD),
('DeviceID', ctypes.c_wchar * 128),
('DeviceKey', ctypes.c_wchar * 128),
]
class DEVMODEW(ctypes.Structure):
_fields_ = [
('dmDeviceName', ctypes.c_wchar * 32),
('dmSpecVersion', wt.WORD),
('dmDriverVersion', wt.WORD),
('dmSize', wt.WORD),
('dmDriverExtra', wt.WORD),
('dmFields', wt.DWORD),
('dmPositionX', wt.LONG),
('dmPositionY', wt.LONG),
('dmDisplayOrientation', wt.DWORD),
('dmDisplayFixedOutput', wt.DWORD),
('dmColor', wt.WORD),
('dmDuplex', wt.WORD),
('dmYResolution', wt.WORD),
('dmTTOption', wt.WORD),
('dmCollate', wt.WORD),
('dmFormName', ctypes.c_wchar * 32),
('dmLogPixels', wt.WORD),
('dmBitsPerPel', wt.DWORD),
('dmPelsWidth', wt.DWORD),
('dmPelsHeight', wt.DWORD),
('dmDisplayFlags', wt.DWORD),
('dmDisplayFrequency', wt.DWORD),
('dmICMMethod', wt.DWORD),
('dmICMIntent', wt.DWORD),
('dmMediaType', wt.DWORD),
('dmDitherType', wt.DWORD),
('dmReserved1', wt.DWORD),
('dmReserved2', wt.DWORD),
('dmPanningWidth', wt.DWORD),
('dmPanningHeight', wt.DWORD),
]
user32.EnumDisplayDevicesW.argtypes = [ctypes.c_wchar_p, wt.DWORD,
ctypes.POINTER(DISPLAY_DEVICEW), wt.DWORD]
user32.EnumDisplayDevicesW.restype = wt.BOOL
user32.EnumDisplaySettingsW.argtypes = [ctypes.c_wchar_p, wt.DWORD,
ctypes.POINTER(DEVMODEW)]
user32.EnumDisplaySettingsW.restype = wt.BOOL
user32.ChangeDisplaySettingsExW.argtypes = [ctypes.c_wchar_p, ctypes.POINTER(DEVMODEW),
ctypes.c_void_p, wt.DWORD, ctypes.c_void_p]
user32.ChangeDisplaySettingsExW.restype = ctypes.c_long
DM_BITSPERPEL = 0x00040000
DM_PELSWIDTH = 0x00080000
DM_PELSHEIGHT = 0x00100000
DM_DISPLAYFREQUENCY = 0x00400000
def read_edid_from_registry(device_id):
"""monitor DeviceID 'MONITOR\\<pnp>\\<inst>' → 注册表 EDID 原始字节
注意: 间接显示器/虚拟显示驱动返回的 DeviceID 实例段是 '{类GUID}\\NNNN'
格式, 注册表中并不存在该路径; 因此精确匹配失败后按 PNP ID 枚举其下
全部实例, 取第一份 EDID(同一物理屏的重复实例 EDID 相同)。
"""
if not device_id or not device_id.startswith('MONITOR\\'):
return None
parts = device_id.split('\\')
if len(parts) < 3:
return None
pnp = parts[1]
inst = device_id.split('\\', 2)[2]
def _read(path):
try:
key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path)
try:
val, _typ = winreg.QueryValueEx(key, 'EDID')
if isinstance(val, (bytes, bytearray)):
return bytes(val)
if isinstance(val, list): # 某些系统返回 int 列表
return bytes(val)
finally:
winreg.CloseKey(key)
except OSError:
pass
return None
# 1) 精确实例路径(常规真实显示器)
raw = _read(rf'SYSTEM\CurrentControlSet\Enum\DISPLAY\{pnp}\{inst}\Device Parameters')
if raw:
return raw
# 2) 按 PNP ID 遍历全部实例(间接显示器/实例名不一致)
try:
pnp_root = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE,
rf'SYSTEM\CurrentControlSet\Enum\DISPLAY\{pnp}')
except OSError:
return None
try:
for i in range(winreg.QueryInfoKey(pnp_root)[0]):
sub = winreg.EnumKey(pnp_root, i)
raw = _read(rf'SYSTEM\CurrentControlSet\Enum\DISPLAY\{pnp}\{sub}\Device Parameters')
if raw:
return raw
finally:
winreg.CloseKey(pnp_root)
return None
def get_displays():
"""枚举活动显示器: 设备名/显卡/监视器名/当前模式/屏幕矩形/EDID 字节"""
out = []
i = 0
while True:
dd = DISPLAY_DEVICEW()
dd.cb = ctypes.sizeof(dd)
if not user32.EnumDisplayDevicesW(None, i, ctypes.byref(dd), 0):
break
if dd.StateFlags & 0x1: # ATTACHED_TO_DESKTOP
# 找该输出口上活动的 monitor 条目
mon = None
j = 0
while True:
m = DISPLAY_DEVICEW()
m.cb = ctypes.sizeof(m)
if not user32.EnumDisplayDevicesW(dd.DeviceName, j, ctypes.byref(m), 0):
break
if m.StateFlags & 0x1:
mon = m
break
j += 1
cur = ''
rect = None
dm = DEVMODEW()
dm.dmSize = ctypes.sizeof(dm)
if user32.EnumDisplaySettingsW(dd.DeviceName, ENUM_CURRENT_SETTINGS,
ctypes.byref(dm)):
cur = f'{dm.dmPelsWidth}x{dm.dmPelsHeight}@{dm.dmDisplayFrequency}'
rect = (dm.dmPositionX, dm.dmPositionY, dm.dmPelsWidth, dm.dmPelsHeight)
out.append({
'device': dd.DeviceName, # \\.\DISPLAY1
'adapter': dd.DeviceString, # 显卡名
'mon_name': mon.DeviceString if mon else '',
'edid': read_edid_from_registry(mon.DeviceID) if mon else None,
'cur_mode': cur,
'rect': rect, # (x, y, w, h) 物理像素
})
i += 1
return out
def enum_modes(device):
"""枚举 device(\\\\.\\DISPLAYn) 可用模式 —— 显卡按该屏 EDID 提供,
返回 (['WxH@Hz', ...] 按像素数降序, {'WxH@Hz': DEVMODEW})"""
seen = {}
i = 0
while True:
dm = DEVMODEW()
dm.dmSize = ctypes.sizeof(dm)
if not user32.EnumDisplaySettingsW(device, i, ctypes.byref(dm)):
break
i += 1
if dm.dmBitsPerPel != 32:
continue
key = f'{dm.dmPelsWidth}x{dm.dmPelsHeight}@{dm.dmDisplayFrequency}'
seen.setdefault(key, dm)
keys = sorted(seen, key=lambda k: tuple(int(v) for v in k.replace('@', 'x').split('x')),
reverse=True)
return keys, seen
def apply_mode(device, dm):
"""把 device 切到 dm 模式(临时切换, 不写注册表, 重启还原), 返回 (成功?, 返回码)"""
dm.dmFields = DM_BITSPERPEL | DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY
r = user32.ChangeDisplaySettingsExW(device, ctypes.byref(dm), None, 0, None)
return r == 0, r # DISP_CHANGE_SUCCESSFUL == 0
def ensure_display_extend():
"""仅当转接板显示器"已接入但未进入桌面"时, 才强制扩展拓扑。
场景: 板卡热插拔后, 若 Windows 停留在"仅电脑屏幕"拓扑(会记住上次
Win+P 的选择), 板卡的 EDID 虽已安装、GPU 也在按首选时序输出, 但桌面
并未扩展过去, get_displays() 就枚举不到这块"显示器" —— 此时才需要
强制扩展把它拉回桌面。
用户主动选择的拓扑(家里的"仅第二屏幕"、手动关闭的显示器等)一律不动:
枚举"接入但不在桌面"的显示器, 只有其 EDID 厂商是转接板签名 "XLS"
(字节 0x61 0x93, 上游 lt8619c_edid.c) 时才扩展; 笔记本内屏等其它
厂商视为用户有意关闭, 不干预。
返回 SetDisplayConfig 的返回码(0=成功, -1=未触发, 其它=异常); 静默容错。
"""
try:
board_stuck = False
i = 0
while True:
dd = DISPLAY_DEVICEW()
dd.cb = ctypes.sizeof(dd)
if not user32.EnumDisplayDevicesW(None, i, ctypes.byref(dd), 0):
break
i += 1
j = 0
while True:
m = DISPLAY_DEVICEW()
m.cb = ctypes.sizeof(m)
if not user32.EnumDisplayDevicesW(dd.DeviceName, j, ctypes.byref(m), 0):
break
j += 1
if m.StateFlags & 0x1: # ATTACHED_TO_DESKTOP: 已在桌面
continue
raw = read_edid_from_registry(m.DeviceID)
if len(raw or ()) >= 10 and raw[8:10] == b'\x61\x93':
board_stuck = True # 转接板没进桌面 -> 需要拉回
if not board_stuck:
return -1
SDC_TOPOLOGY_EXTEND = 0x00000004
SDC_APPLY = 0x00000080
return ctypes.windll.user32.SetDisplayConfig(
0, None, 0, None, SDC_TOPOLOGY_EXTEND | SDC_APPLY)
except Exception:
return -1
+286
View File
@@ -0,0 +1,286 @@
#!/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, bytesize, parity, stopbits)
self._open_key = None # 当前已打开串口的参数元组
self.ser = None
self.parser = FrameParser()
self._lock = threading.Lock()
threading.Thread(target=self._run, daemon=True).start()
@staticmethod
def list_ports_info():
"""枚举串口, 返回 [(设备名, 显示标签), ...]。
标签 = "COMx 设备描述"(描述里的 "(COMx)" 尾巴去掉, COM 号已在最前),
CH344 这类多路适配器的每一路描述不同(如 serial-A/B/C/D), 依此区分;
多口描述完全相同(同型号多口)时追加物理位置/序列号消歧。
"""
if not HAS_SERIAL:
return []
items = []
for p in serial.tools.list_ports.comports():
desc = re.sub(r'\s*\((COM\d+)\)\s*$', '', (p.description or '').strip(),
flags=re.I)
if desc.upper() == p.device.upper():
desc = ''
label = f'{p.device} {desc}'.rstrip()
tail = p.location or p.serial_number or ''
items.append((p.device, label, tail))
counts = collections.Counter(label for _d, label, _t in items)
out = []
for dev, label, tail in items:
if counts[label] > 1 and tail: # 同标签多口: 附物理位置消歧
label = f'{label} [{tail}]'
out.append((dev, label))
return out
def open(self, port, baud=DEFAULT_BAUD, bytesize=8, parity='N', stopbits=1):
if not HAS_SERIAL:
self.on_status('未安装 pyserial: py -m pip install pyserial')
return
with self._lock:
self._target = (port, int(baud), int(bytesize),
str(parity).upper(), int(stopbits))
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):
# 串口参数映射(pyserial 常量): 数据位/校验/停止位
bytesizes = {8: serial.EIGHTBITS, 7: serial.SEVENBITS,
6: serial.SIXBITS, 5: serial.FIVEBITS}
parities = {'N': serial.PARITY_NONE, 'E': serial.PARITY_EVEN,
'O': serial.PARITY_ODD}
stopbits = {1: serial.STOPBITS_ONE, 2: serial.STOPBITS_TWO}
while True:
with self._lock:
target = self._target
cur = self._open_key
if target != cur:
if self.ser is not None:
try:
self.ser.close()
except Exception:
pass
self.ser = None
self._open_key = None
self.on_status('已断开')
if target:
port, baud, dbsize, parity, sbits = target
try:
self.ser = serial.Serial(
port, baud, bytesize=bytesizes.get(dbsize, serial.EIGHTBITS),
parity=parities.get(parity, serial.PARITY_NONE),
stopbits=stopbits.get(sbits, serial.STOPBITS_ONE),
timeout=0.15)
self.parser = FrameParser()
self._open_key = target
self.on_status(f'已连接 {port} @ {baud} '
f'{dbsize}{parity}{sbits}')
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)
+548
View File
@@ -0,0 +1,548 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
EDID 1.4 / CEA-861 解析(纯标准库)
================================================================
parse_edid() 原始 EDID 字节 -> 结构化 dict(DTD/已建立/标准/CEA-VIC/数据块)
edid_info_lines() EDID 信息卡片用的 (项目, 值) 行
edid_field_rows()/edid_field_text() 逐字节字段解析
hex_dump() 经典 hex + ASCII 排版
"""
import math
import struct
# ============================================================
# EDID 1.4 / CEA-861 解析
# ============================================================
# CTA-861 VIC 1..64 常用映射(65+ 及扩展 VIC 不展开)
VIC_TABLE = [
'640x480@60', '720x480@60 4:3', '720x480@60 16:9', '1280x720@60', '1920x1080i@60',
'720x480i@60 4:3', '720x480i@60 16:9', '720x240p@60 4:3', '720x240p@60 16:9',
'1440x480i@60 4:3', '1440x480i@60 16:9', '1440x240p@60 4:3', '1440x240p@60 16:9',
'1440x480p@60 4:3', '1440x480p@60 16:9', '1920x1080p@60', '720x576p@50 4:3', '720x576p@50 16:9',
'1280x720p@50', '1920x1080i@50', '720x576i@50 4:3', '720x576i@50 16:9',
'720x288p@50 4:3', '720x288p@50 16:9',
'1440x576i@50 4:3', '1440x576i@50 16:9', '1440x288p@50 4:3', '1440x288p@50 16:9',
'1440x576p@50 4:3', '1440x576p@50 16:9', '1920x1080p@50', '1920x1080p@24',
'1920x1080p@25', '1920x1080p@30',
'2880x480p@60 4:3', '2880x480p@60 16:9', '2880x576p@50 4:3', '2880x576p@50 16:9',
'1920x1080i@50(1250)', '1920x1080i@100', '1280x720p@100',
'720x576p@100 4:3', '720x576p@100 16:9', '720x576i@100 4:3', '720x576i@100 16:9',
'1920x1080i@120', '1280x720p@120',
'720x480p@120 4:3', '720x480p@120 16:9', '720x480i@120 4:3', '720x480i@120 16:9',
'720x576p@200 4:3', '720x576p@200 16:9', '720x576i@200 4:3', '720x576i@200 16:9',
'720x480p@240 4:3', '720x480p@240 16:9', '720x480i@240 4:3', '720x480i@240 16:9',
'1280x720p@24', '1280x720p@25', '1280x720p@30', '1920x1080p@120', '1920x1080p@240',
# VIC 65-88 为 3D/特殊格式, 少见不展开
*[None] * 24,
'2560x1080p@50', '2560x1080p@60', '2560x1080p@100', '2560x1080p@120',
'3840x2160p@24', '3840x2160p@25', '3840x2160p@30',
'3840x2160p@50', '3840x2160p@60', '4096x2160p@24',
]
# 已建立时序位域表(bytes 35-37, 共 24 位, '-' 为保留位)
EST_TABLE = [
'720x400@70', '720x400@88', '640x480@60', '640x480@67', '640x480@72', '640x480@75',
'800x600@56', '800x600@60',
'800x600@72', '800x600@75', '832x624@75', '1024x768@87i', '1024x768@60', '1024x768@70',
'1024x768@75', '1280x1024@75',
'1152x870@75', '-', '-', '-', '-', '-', '-', '-',
]
STD_ASPECT = {0: (10 / 16, '16:10'), 1: (3 / 4, '4:3'), 2: (4 / 5, '5:4'), 3: (9 / 16, '16:9')}
DEPTHS = [0, 6, 8, 10, 12, 14, 16, 0]
INTERFACES = ['未定义', 'DVI', 'HDMI-A', 'HDMI-B', 'MDDI', 'DisplayPort', '保留', '保留']
SYNC_TYPES = ['模拟合成', '双极模拟合成', '数字合成', '数字分离']
# CEA-861 数据块 tag(CTA-861 标准): 1=音频 2=视频 3=厂商专用(VSDB)
# 4=扬声器分配 5=VESA DTC 6=视频格式 7=扩展 tag 集合
EXT_TAG_NAMES = {
0x00: '视频能力', 0x01: '厂商扩展', 0x02: 'VESA 显示设备', 0x04: 'HDMI 4K/8K 视频格式',
0x05: '色度学', 0x06: 'HDR 静态元数据', 0x07: 'HDR 动态元数据', 0x0C: 'YCbCr4:2:0 能力',
0x0E: 'YCbCr4:2:0 视频格式', 0x78: 'HDMI 论坛 SCDB',
}
def parse_dtd(raw, o):
"""解析 18 字节详细时序描述符, 返回 dict"""
t = {}
t['clk_khz'] = struct.unpack_from('<H', raw, o)[0] * 10 # 10kHz 单位
t['hact'] = raw[o + 2] | ((raw[o + 4] & 0xF0) << 4)
t['hblank'] = raw[o + 3] | ((raw[o + 4] & 0x0F) << 8)
t['vact'] = raw[o + 5] | ((raw[o + 7] & 0xF0) << 4)
t['vblank'] = raw[o + 6] | ((raw[o + 7] & 0x0F) << 8)
t['hfront'] = raw[o + 8] | ((raw[o + 11] & 0xC0) << 2)
t['hsync'] = raw[o + 9] | ((raw[o + 11] & 0x30) << 4)
t['vfront'] = ((raw[o + 10] & 0xF0) >> 4) | ((raw[o + 11] & 0x0C) << 2)
t['vsync'] = (raw[o + 10] & 0x0F) | ((raw[o + 11] & 0x03) << 4)
t['him_mm'] = raw[o + 12] | ((raw[o + 14] & 0xF0) << 4)
t['vim_mm'] = raw[o + 13] | ((raw[o + 14] & 0x0F) << 8)
fl = raw[o + 17]
t['interlaced'] = bool(fl & 0x80)
t['sync_type'] = SYNC_TYPES[(fl >> 2) & 3]
t['polarity'] = ('V+' if fl & 0x02 else 'V-') + ' ' + ('H+' if fl & 0x01 else 'H-')
t['htotal'] = t['hact'] + t['hblank']
t['vtotal'] = t['vact'] + t['vblank']
t['refresh'] = round(t['clk_khz'] * 1000.0 / (t['htotal'] * t['vtotal']), 1)
return t
def _ascii(raw, o, max_len):
out = []
for i in range(max_len):
c = raw[o + i]
if c in (0x0A, 0x00):
break
if 32 <= c < 127:
out.append(chr(c))
return ''.join(out)
def parse_edid(raw):
"""解析 EDID(128 或 256 字节), 返回汇总 dict"""
e = {'raw': raw, 'dtds': [], 'est': [], 'std': [], 'cea_video': [], 'cea_blocks': []}
n = len(raw)
e['cksum_base'] = (sum(raw[0:128]) & 0xFF) == 0
# 厂商 ID: 3 个 5bit 压缩大写字母
b0, b1 = raw[8], raw[9]
e['manufacturer'] = ''.join([
chr(((b0 & 0x7C) >> 2) + 64),
chr((((b0 & 0x03) << 3) | ((b1 & 0xE0) >> 5)) + 64),
chr((b1 & 0x1F) + 64)])
e['product'] = struct.unpack_from('<H', raw, 10)[0]
e['serial_num'] = struct.unpack_from('<I', raw, 12)[0]
e['week'] = raw[16]
e['year'] = 1990 + raw[17]
e['ver'] = f'{raw[18]}.{raw[19]}'
if raw[20] & 0x80:
bpc = DEPTHS[(raw[20] >> 4) & 0x7]
e['input'] = '数字 / ' + INTERFACES[raw[20] & 0x7] + \
(f' / {bpc}bpc' if bpc else ' / 色深未定义')
else:
e['input'] = '模拟 (VGA 输入)'
e['wcm'], e['hcm'] = raw[21], raw[22]
e['gamma'] = 0 if raw[23] == 0xFF else (raw[23] + 100) / 100.0
feats = []
if raw[24] & 0x80: feats.append('DPMS 待机')
if raw[24] & 0x40: feats.append('DPMS 挂起')
if raw[24] & 0x20: feats.append('DPMS 关闭')
if raw[24] & 0x04: feats.append('sRGB 特性')
if raw[24] & 0x02: feats.append('首选时序=原生')
e['features'] = ', '.join(feats)
# 已建立时序位域
for i in range(24):
bit = 7 - (i % 8)
if ((raw[35 + i // 8] >> bit) & 1) and EST_TABLE[i] != '-':
e['est'].append(EST_TABLE[i])
# 标准时序(8 组 2 字节, 0x0101 = 未用)
for i in range(38, 54, 2):
a, b = raw[i], raw[i + 1]
if (a == 1 and b == 1) or (a == 0 and b == 0):
continue
h = (a + 31) * 8
v = round(h * STD_ASPECT[(a >> 6) & 3][0])
e['std'].append(f'{h}x{v}@{b + 60}')
# 基础块 4 个 18 字节描述符区: 前两字节非 0 即 DTD, 否则是监视器描述符
e['name'] = e['serial_text'] = e['range_text'] = ''
for d in range(54, 126, 18):
if raw[d] == 0 and raw[d + 1] == 0:
tag = raw[d + 2]
if tag == 0xFC:
e['name'] = _ascii(raw, d + 5, 13)
elif tag == 0xFF:
e['serial_text'] = _ascii(raw, d + 5, 13)
elif tag == 0xFD:
e['range_text'] = (f"V {raw[d+5]}~{raw[d+6]}Hz, H {raw[d+7]}~{raw[d+8]}kHz, "
f"像素时钟上限 {raw[d+9] * 10}MHz")
else:
e['dtds'].append(parse_dtd(raw, d))
# CEA-861 扩展块
e['has_ext'] = raw[126] > 0 and n >= 256
e['has_cea'] = False
e['cea_rev'] = 0
e['cea_caps'] = ''
e['cea_phys'] = ''
e['cksum_ext'] = False
if e['has_ext']:
e['cksum_ext'] = (sum(raw[128:256]) & 0xFF) == 0
if raw[128] == 0x02:
e['has_cea'] = True
e['cea_rev'] = raw[129]
caps = []
if raw[131] & 0x80: caps.append('欠扫描')
if raw[131] & 0x40: caps.append('基本音频')
if raw[131] & 0x20: caps.append('YCbCr4:4:4')
if raw[131] & 0x10: caps.append('YCbCr4:2:2')
e['cea_caps'] = ', '.join(caps)
dtd_off = raw[130]
p = 132
while dtd_off > 4 and (p - 128) < dtd_off and p + 1 < 256:
tag = (raw[p] >> 5) & 7
length = raw[p] & 0x1F
if length == 0:
break
data = raw[p + 1:p + 1 + length]
if tag == 2: # 视频数据块: VIC 列表
for k in range(length):
vic = data[k] & 0x7F
native = ' (原生)' if data[k] & 0x80 else ''
m = (VIC_TABLE[vic - 1] if 1 <= vic <= len(VIC_TABLE) else None) \
or f'VIC#{vic}'
e['cea_video'].append(m + native)
elif tag == 1: # 音频数据块: N 个 3 字节 SAD
e['cea_blocks'].append(f'音频数据块 x{length // 3}')
elif tag == 4:
e['cea_blocks'].append('扬声器分配块')
elif tag == 5:
e['cea_blocks'].append('VESA DTC 块')
elif tag == 6:
e['cea_blocks'].append('视频格式块(VFDB)')
elif tag == 3:
# 厂商专用块: 前 3 字节 IEEE OUI(小端)
oui = bytes(data[0:3])
if oui == b'\x03\x0c\x00': # HDMI 1.x VSDB: OUI 后 4 字节物理地址
if length >= 7:
e['cea_phys'] = '.'.join(str(b) for b in data[3:7])
e['cea_blocks'].append('HDMI VSDB')
elif oui == b'\xd8\x5d\xc4': # HDMI 2.0 HF-VSDB
e['cea_blocks'].append('HDMI 2.0 HF-VSDB')
else:
e['cea_blocks'].append(f'厂商块 OUI={oui.hex(" ")}')
elif tag == 7:
ext = data[0] if length >= 1 else -1
name = EXT_TAG_NAMES.get(ext, f'0x{ext:02X}')
e['cea_blocks'].append(f'扩展块({name})')
else:
e['cea_blocks'].append(f'保留块 tag{tag}')
p += 1 + length
if dtd_off >= 4: # 扩展块 DTD 区
q = 128 + dtd_off
while q + 18 <= n:
if raw[q] == 0 and raw[q + 1] == 0:
break
e['dtds'].append(parse_dtd(raw, q))
q += 18
return e
# ============================================================
# 文本化
# ============================================================
def edid_info_lines(e):
"""[(项目, 值), ...] 摘要行"""
lines = []
inch = 0
if e['wcm'] > 0 and e['hcm'] > 0:
inch = round(math.hypot(e['wcm'], e['hcm']) / 2.54, 1)
lines.append(('厂商 / 产品码', f"{e['manufacturer']} / 0x{e['product']:04X}"))
lines.append(('显示器名称', e['name'] or '(未声明)'))
if e['serial_text']:
lines.append(('序列号(文本)', e['serial_text']))
lines.append(('生产周 / 年', f"{e['week']} 周 / {e['year']}"))
lines.append(('EDID 版本', e['ver']))
lines.append(('输入类型', e['input']))
if inch > 0:
lines.append(('屏幕尺寸', f"{e['wcm']}cm x {e['hcm']}cm (约 {inch} 英寸)"))
if e['gamma'] > 0:
lines.append(('Gamma', e['gamma']))
if e['features']:
lines.append(('特性', e['features']))
if e['range_text']:
lines.append(('范围限制', e['range_text']))
if e['has_ext']:
ck = 'OK' if e['cksum_ext'] else '错误!'
lines.append(('扩展块', f"CEA-861 rev.{e['cea_rev']} 校验和 {ck}"))
if e['cea_caps']:
lines.append(('CEA 能力', e['cea_caps']))
if e['cea_phys']:
lines.append(('HDMI 物理地址', e['cea_phys']))
else:
lines.append(('扩展块', ''))
lines.append(('基础块校验和', 'OK (0x00)' if e['cksum_base'] else '错误!'))
# 每个 DTD 的完整时序参数(硬件调试重点)
for k, t in enumerate(e['dtds']):
tag = 'DTD1(首选)' if k == 0 else f'DTD{k + 1}'
lines.append((f'{tag} 模式', f"{t['hact']}x{t['vact']}@{t['refresh']} {t['clk_khz'] / 1000:.3f} MHz"))
lines.append((f'{tag} 总时序', f"{t['htotal']}x{t['vtotal']}"))
lines.append((f'{tag} 水平', f"有效 {t['hact']} | 前沿 {t['hfront']} | 同步 {t['hsync']} | "
f"后沿 {t['htotal'] - t['hact'] - t['hfront'] - t['hsync']}"))
lines.append((f'{tag} 垂直', f"有效 {t['vact']} | 前沿 {t['vfront']} | 同步 {t['vsync']} | "
f"后沿 {t['vtotal'] - t['vact'] - t['vfront'] - t['vsync']}"))
lines.append((f'{tag} 同步', f"{t['sync_type']} {t['polarity']} "
f"{'(隔行)' if t['interlaced'] else '(逐行)'}"))
if t['him_mm'] > 0:
lines.append((f'{tag} 图像尺寸', f"{t['him_mm']}x{t['vim_mm']} mm"))
return lines
def hex_dump(b):
out = []
for row in range((len(b) + 15) // 16):
off = row * 16
chunk = b[off:off + 16]
# 8+8 分组, 组间双空格: 同步头/厂商段一眼分开
hx = ' '.join(f'{v:02X}' for v in chunk[:8]) + ' ' + \
' '.join(f'{v:02X}' for v in chunk[8:])
asc = ''.join(chr(v) if 32 <= v < 127 else '.' for v in chunk)
out.append(f'{off:04X}: {hx:<50} {asc}')
return '\n'.join(out)
# ============================================================
# EDID 逐字节字段解析(调试视图): 基础块 + CTA-861 扩展块
# 每个字段一行: [偏移] 原始字节 | 含义 | 解码值 | 备注
# ============================================================
B_HEAD = '同步头 00 FF FF FF FF FF FF 00'
def _range_limits(d):
"""FD 频率范围描述符(18 字节常规布局) -> V/H 范围与像素时钟上限"""
try:
return (f"V {d[5]}~{d[6]}Hz, H {d[7]}~{d[8]}kHz, "
f"像素时钟上限 {d[9] * 10}MHz")
except (IndexError, ValueError):
return ''
def _bits(b, on, off=None):
return f'{b:02X}: ' + ('; '.join(on) if on else (off or '无置位'))
def edid_field_rows(raw):
"""把 EDID(128/256 字节)逐字段解成 [(偏移, 原始字节, 含义, 解码值, 备注), ...]"""
rows = []
n = len(raw)
def add(off, ln, meaning, val, note=''):
hx = raw[off:off + ln].hex(' ').upper()
if ln > 18: # 超长区域(如零填充)截断, 完整数据看 hex dump
hx = raw[off:off + 18].hex(' ').upper() + f' …(共 {ln} 字节)'
rows.append((f'0x{off:02X}-{off + ln - 1:02X}' if ln > 1 else f'0x{off:02X}',
hx, meaning, str(val), note))
add(0, 8, B_HEAD, 'OK' if raw[0:8] == bytes.fromhex('00FFFFFFFFFFFF00') else '不匹配!',
'不许改, 用于同步识别')
# 8-9 厂商 ID: 16 位大端, 3 组 5bit 压缩大写字母(块内唯一大端字段)
mfr = ''.join([chr(((raw[8] & 0x7C) >> 2) + 64),
chr((((raw[8] & 0x03) << 3) | ((raw[9] & 0xE0) >> 5)) + 64),
chr((raw[9] & 0x1F) + 64)])
add(8, 2, '厂商 ID(大端)', mfr,
f"3×5bit 压缩字母, 原始 0x{(raw[8] << 8) | raw[9]:04X}; 正式产品应使用注册的 PNP ID")
add(10, 2, '产品码(小端)', f"0x{struct.unpack_from('<H', raw, 10)[0]:04X} ({struct.unpack_from('<H', raw, 10)[0]})",
'厂商自定')
add(12, 4, '序列号(小端)', f"0x{struct.unpack_from('<I', raw, 12)[0]:08X}",
'厂商自定')
add(16, 1, '生产周', f"{raw[16]}", '0 = 不指定')
add(17, 1, '生产年', f"{1990 + raw[17]}", '字节值 + 1990')
add(18, 2, 'EDID 版本号', f"{raw[18]}.{raw[19]}", '常用 1.4 / 1.3(即字节 01 04 / 01 03)')
# 字节 0x14 输入类型
if raw[20] & 0x80:
itf, bpc = INTERFACES[raw[20] & 0x7], DEPTHS[(raw[20] >> 4) & 0x7]
val = f'数字 / {itf}' + (f' / {bpc}bpc' if bpc else ' / 色深未定义')
else:
val = '模拟输入'
add(20, 1, '输入类型', val,
'bit7=1 数字; 低 4 位: 1=HDMI-A 2=HDMI-B 4=DP 5=eDP')
add(21, 1, '最大画面宽(cm)', raw[21], '0 = 未知(横竖比由 DTD 图像尺寸给出)')
add(22, 1, '最大画面高(cm)', raw[22], '0 = 未知')
add(23, 1, 'Gamma', f"{(raw[23] + 100) / 100:.2f}" if raw[23] else '(未提供)',
'存储值 = gamma×100 100(如 0x78 → 2.20); 0xFF = 不提供')
add(24, 1, '特性位', _bits(raw[24], [
*(['DPMS 待机'] if raw[24] & 0x80 else []),
*(['DPMS 挂起'] if raw[24] & 0x40 else []),
*(['DPMS 关闭'] if raw[24] & 0x20 else []),
*([f"显示色彩 {['RGB 4:4:4', 'RGB+YCrCb 4:4:4', 'RGB+YCrCb 4:2:2', '未定义'][(raw[24] >> 3) & 3]}"]
if raw[24] & 0x80 else []),
*(['sRGB 默认'] if raw[24] & 0x04 else []),
*(['首选时序=原生分辨率'] if raw[24] & 0x02 else []),
*(['连续频率/GTF'] if raw[24] & 0x01 else []),
]), 'bit2 sRGB / bit1 首选时序有效 / bit0 连续频率')
add(25, 10, '色度坐标(CIE xy)', '默认 sRGB 模板' if raw[25:35] == bytes.fromhex(
'78EE95A3554D9EA6544C') else '(打包位段)', '红绿蓝白四点 xy 各 10bit 打包, 通常照抄模板')
est = []
est_bits = 0
for i in range(24):
bit = 7 - (i % 8)
if (raw[35 + i // 8] >> bit) & 1:
est_bits |= 1 << (23 - i)
if EST_TABLE[i] != '-':
est.append(EST_TABLE[i])
if est:
add(35, 3, 'Established Timing 位图', ', '.join(est),
'VESA 老 VGA 时序; 数字屏常写 FF FF 00=全支持')
else:
add(35, 3, 'Established Timing 位图',
'无有效声明' + ('(仅保留位置位)' if est_bits else ''),
'VESA 老 VGA 时序; 数字屏常写 FF FF 00=全支持')
stds = []
for i in range(38, 54, 2):
a, b = raw[i], raw[i + 1]
if (a == 1 and b == 1) or (a == 0 and b == 0):
continue
h = (a + 31) * 8
ar = STD_ASPECT[(a >> 6) & 3][1]
stds.append(f"{h}x{round(h * STD_ASPECT[(a >> 6) & 3][0])}@{b + 60} {ar}")
add(38, 16, '标准时序 ×8', '; '.join(stds) if stds else '8 槽全部未用(01 01)',
'2 字节/槽: 6bit 宽 + 2bit 比例 + 刷新率−60; 未用槽填 01 01')
# 0x36-0x7D: 4 × 18 字节描述符
for k, d in enumerate(range(54, 126, 18)):
grp = raw[d:d + 18]
if grp[0] or grp[1]:
t = parse_dtd(raw, d)
note = (f"{'(隔行)' if t['interlaced'] else '(逐行)'} "
f"{t['sync_type']} {'/'.join(t['polarity'].split())}; "
f"消隐 H{t['hblank']}/V{t['vblank']}, "
f"前/同/后 H{t['hfront']}/{t['hsync']}/{t['htotal'] - t['hact'] - t['hfront'] - t['hsync']} "
f"V{t['vfront']}/{t['vsync']}/{t['vtotal'] - t['vact'] - t['vfront'] - t['vsync']}")
add(d, 18, f'描述符{k + 1}: DTD(详细时序)',
f"{t['hact']}×{t['vact']}@{t['refresh']}Hz "
f"{t['htotal']}×{t['vtotal']} {t['clk_khz'] / 1000:.3f}MHz"
+ (' [首选时序]' if k == 0 else ''),
note + ('; 图像尺寸 %dx%d mm' % (t['him_mm'], t['vim_mm']) if t['him_mm'] else ''))
else:
tag = grp[3]
txt = _ascii(raw, d + 5, 13)
if tag == 0xFF:
add(d, 18, f'描述符{k + 1}: 序列号串(0xFF)', repr(txt), 'ASCII, 上限 13 字节')
elif tag == 0xFC:
add(d, 18, f'描述符{k + 1}: 显示器名(0xFC)', repr(txt), 'ASCII, 上限 13 字节')
elif tag == 0xFD:
add(d, 18, f'描述符{k + 1}: 范围限制(0xFD)', _range_limits(grp), '频率范围描述符')
elif tag == 0xFE:
add(d, 18, f'描述符{k + 1}: 无界文本(0xFE)', repr(txt), '')
elif tag == 0x10:
add(d, 18, f'描述符{k + 1}: 数据串(0x10)', grp[5:18].hex(' ').upper(), '二进制用途')
elif tag == 0 and not any(grp):
add(d, 18, f'描述符{k + 1}: 空', '(全 00)', '')
else:
add(d, 18, f'描述符{k + 1}: 未知类型 0x{tag:02X}', grp[5:18].hex(' ').upper(), '')
add(126, 1, '扩展块数量', raw[126], '必须与实际扩展块数一致')
cksum = (256 - sum(raw[0:127])) & 0xFF
add(127, 1, '基础块校验和', f"0x{raw[127]:02X}" + ('正确' if (sum(raw[0:128]) & 0xFF) == 0 else '错误!'),
f"整块 128 字节和 ≡ 0 (mod 256); 期望 0x{cksum:02X} = 256 前 127 字节和")
# ---- CTA-861 扩展块 ----
n_ext = raw[126] if n > 126 else 0
for bi in range(n_ext):
base = 128 + bi * 128
if base + 128 > n:
add(base, 0, f'扩展块{bi + 1}', '(数据不完整)', '')
break
blk = raw[base:base + 128]
add(base, 1, f'扩展块{bi + 1} @0x{base:02X} Tag',
'CTA-861' if blk[0] == 0x02 else f'0x{blk[0]:02X}(非 CTA)',
'固定 0x02 = CTA 扩展; HDMI 必须有')
if blk[0] != 0x02:
add(base + 1, 127, '扩展块数据', blk[1:128].hex(' ').upper(), '未知扩展类型, 跳过')
continue
add(base + 1, 1, '版本', f"rev {blk[1]}", '常用 03')
add(base + 2, 1, 'DTD 起始偏移', f"0x{blk[2]:02X} (= 块内 {blk[2]} 字节处)", '数据块链结束、DTD 区开始的偏移')
add(base + 3, 1, '标志位', _bits(blk[3], [
*(['bit7 支持 YCbCr 4:2:0'] if blk[3] & 0x80 else []),
*(['bit6 支持基础音频'] if blk[3] & 0x40 else []),
*(['bit5 支持 YCbCr 4:2:2'] if blk[3] & 0x20 else []),
*(['bit4 支持 YCbCr 4:4:4'] if blk[3] & 0x10 else []),
*(['bit0 支持欠扫描'] if blk[3] & 0x01 else []),
]), 'bit6=基础音频, bit5=4:2:2, bit7=4:2:0, bit0=欠扫描')
# 数据块链: 每块 1 字节头(高 3 位 tag + 低 5 位长度) + payload
TAG_NAMES = {1: '音频', 2: '视频 VIC', 3: '厂商专用 VSDB', 4: '扬声器分配',
5: 'VESA DTC', 6: '视频格式 VFDB', 7: '扩展 tag'}
p, first = 4, True
while base + p < base + 128 and p < blk[2] and p + 1 < 128:
tag, length = (blk[p] >> 5) & 7, blk[p] & 0x1F
payload = blk[p + 1:p + 1 + length]
if first and blk[p] == 0:
add(base + p, 1, '数据块链', '(无数据块, 直接进 DTD 区)', '4 至 DTD 区前为数据块链')
break
first = False
if tag == 2:
vics = []
for v in payload:
vi = v & 0x7F
name = (VIC_TABLE[vi - 1] if 1 <= vi <= len(VIC_TABLE) else None) or f'VIC#{vi}'
vics.append(name + ('(原生)' if v & 0x80 else ''))
add(base + p, 1 + length, '数据块: 视频', ' '.join(vics) or '(空)', f"tag 2, {length} 字节")
elif tag == 1:
fmts = []
codes = ['保留', 'LPCM', 'AAC', 'AC-3', 'MPEG1', 'MP3', 'MPEG2', 'AAC LC',
'DTS', 'ATRAC', 'DSD', 'E-AC-3', 'DTS-HD', 'MLP', 'DST', 'WMAPro']
for si in range(length // 3):
sad = payload[si * 3:(si + 1) * 3]
ch = (sad[0] & 7) + 1
rates = '/'.join(str(r) for r, b in ((32, 1), (44, 2), (48, 4)) if sad[2] & b) or '?'
mults = ''.join(f'×{m}' for m, b in ((2, 8), (3, 16), (4, 32)) if sad[2] & b)
if (sad[0] >> 3) & 0xF == 1: # LPCM: 位宽在字节1低3位(位图)
bits = '/'.join(str(v) for v, b in ((16, 1), (20, 2), (24, 4)) if sad[1] & b) or '?'
fmts.append(f'LPCM {ch}ch {bits}bit {rates}kHz{mults}')
else: # 压缩格式: 字节1高4位=最高采样率档
fmts.append(f"{codes[(sad[0] >> 3) & 0xF]} {ch}ch {rates}kHz{mults}")
add(base + p, 1 + length, '数据块: 音频', '; '.join(fmts), f"tag 1, {length // 3} × 3 字节 SAD")
elif tag == 3:
oui = bytes(payload[0:3])
names = {b'\x03\x0c\x00': 'HDMI 1.x VSDB', b'\xd8\x5d\xc4': 'HDMI 2.0 HF-VSDB'}
val = names.get(oui, f'OUI={oui.hex(" ").upper()}')
if oui == b'\x03\x0c\x00' and length >= 7:
val += f", 物理地址 {'.'.join(str(b) for b in payload[3:7])}"
add(base + p, 1 + length, '数据块: 厂商专用', val,
f"tag 3, 头 3 字节 IEEE OUI(小端){'; HDMI 2.0 需 HF-VSDB' if oui == b'\x03\x0c\x00' else ''}")
else:
name = TAG_NAMES.get(tag, f'tag{tag}')
add(base + p, 1 + length, f'数据块: {name}',
payload.hex(' ').upper() if length else '(空)', f"{length} 字节 payload")
if length == 0:
break
p += 1 + length
# DTD 区
if blk[2] >= 4:
q, cnt = blk[2], 0
while q + 18 <= 128 and any(blk[q:q + 2]):
t = parse_dtd(blk, q)
add(base + q, 18, f'扩展块 DTD{cnt + 1}',
f"{t['hact']}×{t['vact']}@{t['refresh']}Hz 总 {t['htotal']}×{t['vtotal']} "
f"{t['clk_khz'] / 1000:.3f}MHz",
"前/同/后 H%d/%d/%d V%d/%d/%d %s" % (
t['hfront'], t['hsync'], t['htotal'] - t['hact'] - t['hfront'] - t['hsync'],
t['vfront'], t['vsync'], t['vtotal'] - t['vact'] - t['vfront'] - t['vsync'],
'(隔行)' if t['interlaced'] else '(逐行)'))
cnt += 1
q += 18
if not cnt:
add(base + blk[2], 0, '扩展块 DTD 区', '(空)', '未用空间填 0')
rem = 128 - max(blk[2], 4) - 18 * cnt if blk[2] >= 4 else 0
if blk[2] >= 4 and rem > 0 and not any(blk[128 - rem:128 - 1]):
add(base + 128 - rem, rem - 1, '未用空间', '0 填充', '')
add(base + 127, 1, f'扩展块{bi + 1} 校验和',
f"0x{blk[127]:02X}" + ('正确' if (sum(blk) & 0xFF) == 0 else '错误!'),
f"期望 0x{(256 - sum(blk[0:127])) & 0xFF:02X} (同基础块规则)")
return rows
def edid_field_text(raw):
"""逐字段解析 -> 多行文本(命令行 / 文本框共用)"""
w = (7, 26, 20)
out = ['%-*s %-*s %-*s %s' % (w[0], '偏移', w[1], '含义', w[2], '解码值', '备注 / 原始字节')]
for off, hx, meaning, val, note in edid_field_rows(raw):
line = '%-*s %-*s %-*s %s' % (w[0], off, w[1], meaning, w[2], val, note)
out.append(line.rstrip())
if hx:
out.append('%-*s %-*s %s %s' % (w[0], '', w[1], '', '原始', hx))
return '\n'.join(out)
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
生成 640x360 黑白测试图, 供 HDMI 源端工具的自定义栏位使用
================================================================
输出到 ../assets/(仓库资产目录, 发布打包时随 zip 分发); 预置栏位配置
release/pattern_slots.json 指向前 3 张, 其余两张可用 "" 换上。
图案清单(均为 640x360 黑白, 1:1 投屏无缩放):
bw_star.png 西门子星: 60 楔放射星, 看极限分辨率与中心偏移
bw_sweep.png 频率扫描条纹: 周期 24px 渐减到 3px(上竖线/下横线)
bw_checker_sweep.png 渐变棋盘: 格子尺寸 2px 递增到 32px 共 9 段
bw_zoneplate.png 同心波带片: 环距向中心递减, 看各向混叠
bw_testcard.png 综合测试卡: 灰阶/刻度/细棋盘/粗细线/标记
用法: python gen_bw_patterns.py
"""
import math
import os
from PIL import Image, ImageDraw
W, H = 640, 360
SRC = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(os.path.dirname(SRC), 'assets')
def save(img, name):
os.makedirs(OUT, exist_ok=True)
path = os.path.join(OUT, name)
img.save(path)
print(f'写出 {path}')
def gen_star():
"""西门子星: 60 个黑白交替楔形, 边缘楔宽约 17px, 向中心无限加密"""
img = Image.new('L', (W, H), 255)
d = ImageDraw.Draw(img)
cx, cy, R = W // 2, H // 2, 165
n = 60
for i in range(0, n, 2): # 黑楔在偶数位, 底为白
d.pieslice([cx - R, cy - R, cx + R, cy + R],
i * 360.0 / n, (i + 1) * 360.0 / n, fill=0)
d.ellipse([cx - R - 6, cy - R - 6, cx + R + 6, cy + R + 6],
outline=0, width=3)
d.rectangle([0, 0, W - 1, H - 1], outline=0, width=2)
save(img, 'bw_star.png')
def gen_sweep():
"""频率扫描条纹: 左到右黑白条纹周期 24px 渐减到 3px; 上半竖线, 下半横线"""
img = Image.new('L', (W, H), 255)
px = img.load()
# 上半: 竖条纹
ph = 0.0
for x in range(W):
t = x / (W - 1)
ph += 1.0 / (24 - 21 * t)
if (ph % 1.0) < 0.5:
for y in range(H // 2):
px[x, y] = 0
# 下半: 横条纹
ph = 0.0
for y in range(H // 2, H):
t = (y - H // 2) / (H // 2 - 1)
ph += 1.0 / (24 - 21 * t)
if (ph % 1.0) < 0.5:
for x in range(W):
px[x, y] = 0
d = ImageDraw.Draw(img)
d.line([0, H // 2, W, H // 2], fill=0, width=2)
d.rectangle([0, 0, W - 1, H - 1], outline=0, width=2)
d.text((8, 6), 'H sweep 24px->3px', fill=0)
d.text((8, H // 2 + 6), 'V sweep 24px->3px', fill=0)
save(img, 'bw_sweep.png')
def gen_checker_sweep():
"""渐变棋盘: 格子尺寸从 2px 逐段加倍到 32px, 看多大格子开始可分辨"""
img = Image.new('L', (W, H), 0)
d = ImageDraw.Draw(img)
sizes = [2, 3, 4, 6, 8, 12, 16, 24, 32]
seg = W / len(sizes)
for i, s in enumerate(sizes):
x0, x1 = int(round(i * seg)), int(round((i + 1) * seg))
for yy in range(0, H, s):
for xx in range(x0, x1, s):
if ((xx // s) + (yy // s)) % 2 == 0:
d.rectangle([xx, yy, min(xx + s, x1) - 1, min(yy + s, H) - 1],
fill=255)
d.line([x1, 0, x1, H], fill=255, width=2)
d.text((4, 4), 'checker 2..32px', fill=255)
save(img, 'bw_checker_sweep.png')
def gen_zoneplate():
"""同心波带片: 环距中心处约 31px、边缘约 3.5px, 观察各方向混叠"""
img = Image.new('L', (W, H), 255)
px = img.load()
cx, cy, k = W / 2, H / 2, 0.0008
for y in range(H):
dy = y - cy
for x in range(W):
dx = x - cx
if math.cos(k * (dx * dx + dy * dy)) > 0:
px[x, y] = 0
d = ImageDraw.Draw(img)
d.rectangle([0, 0, W - 1, H - 1], outline=0, width=2)
save(img, 'bw_zoneplate.png')
def gen_testcard():
"""综合测试卡: 灰阶条 + 刻度 + 细棋盘 + 粗细线 + 角标"""
img = Image.new('L', (W, H), 0)
d = ImageDraw.Draw(img)
# 外框与 40px 刻度(边缘短刻线)
d.rectangle([0, 0, W - 1, H - 1], outline=255, width=2)
for x in range(40, W, 40):
d.line([x, 0, x, 10], fill=255, width=1)
d.line([x, H, x, H - 10], fill=255, width=1)
for y in range(40, H, 40):
d.line([0, y, 10, y], fill=255, width=1)
d.line([W, y, W - 10, y], fill=255, width=1)
# 顶部 16 级灰阶条
gw = W // 16
for i in range(16):
v = round(i * 255 / 15)
d.rectangle([i * gw, 20, (i + 1) * gw - 1, 44], fill=v, outline=255)
# 左下: 1px 棋盘块 (640/4=160 宽 x 72 高)
bx, by, bs = 16, H - 96, 2
for yy in range(by, by + 72, bs):
for xx in range(bx, bx + 160, bs):
if ((xx // bs) + (yy // bs)) % 2 == 0:
d.rectangle([xx, yy, xx + bs - 1, yy + bs - 1], fill=255)
d.text((bx, by - 14), '2px checker', fill=255)
# 右下: 1/2/4px 白线组(横线), 看线条再现
lx = W - 210
for row, lw in enumerate((1, 2, 4)):
y0 = H - 100 + row * 26
for off in range(0, 13, lw * 2):
d.line([lx, y0 + off, lx + 190, y0 + off], fill=255, width=lw)
d.text((lx, H - 114), '1/2/4px lines', fill=255)
# 中心十字(1px)
cx, cy = W // 2, H // 2
d.line([cx, 60, cx, H - 60], fill=255, width=1)
d.line([W // 4, cy, W - W // 4, cy], fill=255, width=1)
# 四角 L 标记
m, L = 16, 28
for sx, sy in ((m, m), (W - 1 - m, m), (m, H - 1 - m), (W - 1 - m, H - 1 - m)):
dx = -1 if sx > cx else 1
dy = -1 if sy > cy else 1
d.line([sx, sy, sx + dx * L, sy], fill=255, width=2)
d.line([sx, sy, sx, sy + dy * L], fill=255, width=2)
d.text((W // 2 - 60, 52), '640x360 TEST', fill=255)
save(img, 'bw_testcard.png')
if __name__ == '__main__':
gen_star()
gen_sweep()
gen_checker_sweep()
gen_zoneplate()
gen_testcard()
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
生成 800x600 纯色测试图, 供 RGB 位交换(0x606E)验证使用
================================================================
配合 demo 板当前 EDID 首选时序 800x600@60, 全屏纯色输出。
用法: python gen_solid_colors.py → 输出到 ../assets/solid_*.png
在 HDMI-Tool 中通过自定义栏位(⚙ 换图)加载, 发布包随附。
"""
import os
from PIL import Image
W, H = 800, 600 # 与 demo 板 EDID 首选时序一致, 全屏铺满
SRC = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(os.path.dirname(SRC), 'assets')
# (文件名后缀, RGB 值, 用途)
COLORS = [
('FF0000', (0xFF, 0x00, 0x00), '通道映射: R=D[23:16]'),
('00FF00', (0x00, 0xFF, 0x00), '通道映射: G=D[15:8]'),
('0000FF', (0x00, 0x00, 0xFF), '通道映射: B=D[7:0]'),
('AA0000', (0xAA, 0x00, 0x00), 'R 交换检测: 0xAA(10101010)'),
('550000', (0x55, 0x00, 0x00), 'R 交换检测: 0x55(01010101)'),
('00AA00', (0x00, 0xAA, 0x00), 'G 交换检测'),
('005500', (0x00, 0x55, 0x00), 'G 交换检测'),
('0000AA', (0x00, 0x00, 0xAA), 'B 交换检测'),
('000055', (0x00, 0x00, 0x55), 'B 交换检测'),
]
def main():
os.makedirs(OUT, exist_ok=True)
for suffix, rgb, note in COLORS:
img = Image.new('RGB', (W, H), rgb)
path = os.path.join(OUT, f'solid_{suffix}.png')
img.save(path)
print(f'写出 {path} # {note}')
print(f'\n{len(COLORS)} 张, 分辨率 {W}x{H}(匹配 demo 板 800x600 EDID)。')
if __name__ == '__main__':
main()
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
HDMI EDID 查看器 + 测试图案投屏 + 光机控制 — 入口与命令行模式
================================================
配合 LT8619C(HDMI 转 RGB888)→ DLPC3421 转接板调试使用。
模块划分(重构自原单文件):
app_gui.py CustomTkinter 主界面(双栏仪表盘)
display_win.py Win32 显示器枚举/分辨率/拓扑
edid_parse.py EDID 1.4 / CEA-861 解析
patterns.py 测试图案全屏投屏窗口
config.py 运行目录与 pattern_slots.json 配置
dlpc_rs485.py 光机 RS485 协议与串口链路
依赖: CLI/EDID 纯标准库; GUI 需 customtkinter(py -m pip install customtkinter);
可选 pyserial(光机控制)与 Pillow(自定义图片 JPG/BMP/WebP)。
命令行模式(自动化测试用):
python hdmi_gui.py 启动 GUI
python hdmi_gui.py --dump 控制台输出 EDID 解算结果
python hdmi_gui.py --edid-fields 控制台输出 EDID 逐字节字段解析表
python hdmi_gui.py --test-pattern Checker --seconds 3
直接出图案, 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 秒自动关闭(冒烟测试)
"""
import argparse
import os
import sys
import time
import dlpc_rs485
import display_win
import edid_parse
import patterns
from config import (AREA_OPTIONS, BASE_DIR, DEFAULT_AREA, SLOT_COUNT, load_slots)
from display_win import ensure_display_extend, get_displays
from edid_parse import edid_field_text, edid_info_lines, hex_dump, parse_edid
from patterns import PATTERN_NAMES, show_pattern, show_pattern_image
def run_gui(auto_close=0):
"""启动 CustomTkinter 主界面(实现在 app_gui.py)"""
import app_gui
app_gui.run_gui(auto_close)
def run_dump(edid_fields=False):
displays = get_displays()
if not displays:
print('未找到活动显示器')
return 1
for i, d in enumerate(displays, 1):
print()
print(f'== 显示器 {i} {d["device"]} 显卡[{d["adapter"]}] '
f'监视器[{d["mon_name"]}] 当前输出 {d["cur_mode"]}')
if not d['edid']:
print(' EDID 读取失败(注册表无 EDID 数据)')
continue
if edid_fields:
print(edid_field_text(d['edid']))
continue
e = parse_edid(d['edid'])
for k, v in edid_info_lines(e):
print(f' {k:<14}: {v}')
print(' ---- 支持的模式 ----')
for k, t in enumerate(e['dtds']):
src = 'DTD1(首选)' if k == 0 else f'DTD{k + 1}'
print(f" [{src}] {t['hact']}x{t['vact']}@{t['refresh']} "
f"{t['clk_khz'] / 1000:.3f}MHz 总{t['htotal']}x{t['vtotal']}")
for m in e['cea_video']:
print(f' [CEA-VIC] {m}')
for m in e['cea_blocks']:
print(f' [CEA-块] {m}')
for m in e['est']:
print(f' [已建立] {m}')
for m in e['std']:
print(f' [标准] {m}')
print(' ---- EDID 原始数据 ----')
print(hex_dump(d['edid']))
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.add_argument('--dump', action='store_true', help='控制台输出 EDID 解算结果, 不开 GUI')
ap.add_argument('--edid-fields', action='store_true',
help='控制台输出 EDID 逐字节字段解析表(配合 --dump 风格, 不开 GUI); exe 版不支持')
ap.add_argument('--test-pattern',
choices=list(PATTERN_NAMES) + [f'Custom{i + 1}' for i in range(SLOT_COUNT)],
default=None, help='直接全屏出测试图案(不开主窗口; CustomN 为自定义栏位)')
ap.add_argument('--area', choices=list(AREA_OPTIONS), default=DEFAULT_AREA,
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 板卡热插拔后可能停留在"仅电脑屏幕"拓扑: 仅当检测到转接板
# (EDID 厂商 XLS)接入但未进桌面时才强制扩展, 其余拓扑不动(失败静默)
ensure_display_extend()
if args.dump or args.edid_fields:
if getattr(sys, 'frozen', False):
# --noconsole 打包的 exe 没有控制台可写, 引导用户用源码方式
import tkinter as tk
from tkinter import messagebox
r = tk.Tk()
r.withdraw()
messagebox.showinfo('不支持', 'exe 版本无控制台输出。\n请用源码方式运行:\n'
'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:
print('未找到活动显示器')
sys.exit(1)
d = displays[min(args.monitor, len(displays)) - 1]
if not d['rect']:
print('无法获取显示器矩形')
sys.exit(1)
import tkinter as tk
root = tk.Tk()
root.withdraw()
if args.test_pattern.startswith('Custom'):
slots, _ = load_slots()
slot = slots[int(args.test_pattern[6:]) - 1]
if not slot['file']:
print(f"错误: {args.test_pattern}({slot['name']})未配置图片")
sys.exit(1)
show_pattern_image(root, d['rect'], os.path.join(BASE_DIR, slot['file']),
args.seconds, area=args.area)
else:
show_pattern(root, d['rect'], args.test_pattern, args.seconds, area=args.area)
sys.exit(0)
run_gui(args.seconds)
if __name__ == '__main__':
main()
+7
View File
@@ -0,0 +1,7 @@
@echo off
rem HDMI source test tool launcher (bypasses PowerShell execution policy)
rem Examples:
rem hdmi_source.bat -List
rem hdmi_source.bat -Mode 640x360
rem hdmi_source.bat -Mode 640x360 -Pattern -Restore
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0hdmi_source.ps1" %*
+487
View File
@@ -0,0 +1,487 @@
<#
.SYNOPSIS
HDMI 源端测试工具 —— 枚举/切换输出分辨率 + 全屏测试图案
.DESCRIPTION
配合 LT8619C(HDMI 转 RGB888)→ DLPC3421 转接板调试使用。
原理:电脑显卡通过 HDMI DDC 读取转接板 EDID 来枚举可用分辨率,
本脚本调用 Win32 API 完成模式枚举与切换,再用 WinForms 全屏窗口
输出测试图案。板载 EDID 只声明了 640x360@60(25.2MHz 像素时钟),
插上 HDMI 后显卡应只能发这一个模式;-List 可直接验证 EDID 是否生效。
测试图案用途:
Bars 100% 彩条 —— 肉眼/示波器验证 RGB 通道顺序与极性
GraySteps 16 级灰阶 —— 验证 RGB888 各 bit 连通性
Gradient R/G/B 三段渐变 —— 验证每通道 8bit 线性度
Checker 黑白棋盘格 —— 验证像素时钟 PCLK 与 DE 对位
Grid 网格+中心十字 —— 验证像素映射/偏移/缩放
Red/Green/Blue/White/Black/Gray50 纯色 —— 万用表/示波器测单通道电平
全屏窗口操作:空格或→下一图案,←上一图案,ESC 退出。
.EXAMPLE
tools\hdmi_source.bat -List
枚举所有活动显示器与显卡支持的分辨率(验证 EDID)
.EXAMPLE
tools\hdmi_source.bat -Monitor 1 -Mode 640x360
把显示器 1 切到 640x360(缺省 60Hz,可写 640x360@60)
.EXAMPLE
tools\hdmi_source.bat -Mode 640x360 -Pattern -Restore
切到 640x360 并全屏出图案,退出图案后恢复原分辨率
.EXAMPLE
tools\hdmi_source.bat -Pattern -PatternName Checker -Seconds 5
当前分辨率下全屏棋盘格,5 秒后自动退出(自动化测试用)
#>
param(
[switch]$List,
[int]$Monitor = 1,
[string]$Mode = '',
[switch]$Pattern,
[ValidateSet('', 'Bars', 'GraySteps', 'Gradient', 'Checker', 'Grid',
'Red', 'Green', 'Blue', 'White', 'Black', 'Gray50')]
[string]$PatternName = '',
[int]$Seconds = 0,
[switch]$Restore
)
$ErrorActionPreference = 'Stop'
# ---------------- Win32 显示 API ----------------
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
namespace HdmiSrc
{
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
public struct DISPLAY_DEVICE
{
public int cb;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string DeviceName;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string DeviceString;
public int StateFlags;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string DeviceID;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public string DeviceKey;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
public struct DEVMODE
{
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string dmDeviceName;
public short dmSpecVersion;
public short dmDriverVersion;
public short dmSize;
public short dmDriverExtra;
public int dmFields;
public int dmPositionX;
public int dmPositionY;
public int dmDisplayOrientation;
public int dmDisplayFixedOutput;
public short dmColor;
public short dmDuplex;
public short dmYResolution;
public short dmTTOption;
public short dmCollate;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string dmFormName;
public short dmLogPixels;
public int dmBitsPerPel;
public int dmPelsWidth;
public int dmPelsHeight;
public int dmDisplayFlags;
public int dmDisplayFrequency;
public int dmICMMethod;
public int dmICMIntent;
public int dmMediaType;
public int dmDitherType;
public int dmReserved1;
public int dmReserved2;
public int dmPanningWidth;
public int dmPanningHeight;
}
public static class Native
{
[DllImport("user32.dll", CharSet = CharSet.Ansi)]
public static extern bool EnumDisplayDevices(string lpDevice, uint iDevNum,
ref DISPLAY_DEVICE lpDisplayDevice, uint dwFlags);
// PowerShell $null marshal NULL, C# NULL
public static bool EnumDisplayAdapters(uint iDevNum, ref DISPLAY_DEVICE lpDisplayDevice)
{
return EnumDisplayDevices(null, iDevNum, ref lpDisplayDevice, 0);
}
[DllImport("user32.dll", CharSet = CharSet.Ansi)]
public static extern bool EnumDisplaySettings(string lpszDeviceName, int iModeNum,
ref DEVMODE lpDevMode);
[DllImport("user32.dll", CharSet = CharSet.Ansi)]
public static extern int ChangeDisplaySettingsEx(string lpszDeviceName,
ref DEVMODE lpDevMode, IntPtr hwnd, int dwflags, IntPtr lParam);
}
}
"@
# 板载 EDID 声明的目标模式,-List 时用于自检
$TargetW = 640
$TargetH = 360
function New-DevMode {
$dm = New-Object HdmiSrc.DEVMODE
$dm.dmDeviceName = ''
$dm.dmFormName = ''
$dm.dmSize = [System.Runtime.InteropServices.Marshal]::SizeOf($dm)
return $dm
}
function New-DisplayDevice {
$dd = New-Object HdmiSrc.DISPLAY_DEVICE
$dd.DeviceName = ''
$dd.DeviceString = ''
$dd.DeviceID = ''
$dd.DeviceKey = ''
$dd.cb = [System.Runtime.InteropServices.Marshal]::SizeOf($dd)
return $dd
}
# 枚举所有"已连接到桌面"的显示器(即插着 HDMI 的输出口)
function Get-Displays {
$list = @()
$i = 0
while ($true) {
$dd = New-DisplayDevice
if (-not [HdmiSrc.Native]::EnumDisplayAdapters($i, [ref]$dd)) { break }
# StateFlags bit0 = ATTACHED_TO_DESKTOP
if ($dd.StateFlags -band 0x1) {
$mon = New-DisplayDevice
[void][HdmiSrc.Native]::EnumDisplayDevices($dd.DeviceName, 0, [ref]$mon, 0)
$list += [pscustomobject]@{
Index = $list.Count + 1
Device = $dd.DeviceName
Adapter = $dd.DeviceString
MonName = $mon.DeviceString
}
}
$i++
}
return $list
}
# 显卡枚举到的全部显示模式(去重)
function Get-Modes {
param([string]$Device)
$modes = @()
$i = 0
while ($true) {
$dm = New-DevMode
if (-not [HdmiSrc.Native]::EnumDisplaySettings($Device, $i, [ref]$dm)) { break }
$modes += [pscustomobject]@{
W = $dm.dmPelsWidth
H = $dm.dmPelsHeight
Hz = $dm.dmDisplayFrequency
Bpp = $dm.dmBitsPerPel
}
$i++
}
return ($modes | Sort-Object W, H, Hz, Bpp -Unique)
}
function Get-CurrentDevMode {
param([string]$Device)
$dm = New-DevMode
[void][HdmiSrc.Native]::EnumDisplaySettings($Device, -1, [ref]$dm) # ENUM_CURRENT_SETTINGS
return $dm
}
# 应用一个 DEVMODE(先 CDS_TEST 再真正生效),返回 $true/$false
function Set-DevMode {
param([string]$Device, $dm)
$dm.dmFields = 0x00040000 -bor 0x00080000 -bor 0x00100000 -bor 0x00400000
# DM_BITSPERPEL | DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY
$rc = [HdmiSrc.Native]::ChangeDisplaySettingsEx($Device, [ref]$dm, [IntPtr]::Zero, 2, [IntPtr]::Zero)
if ($rc -ne 0) {
Write-Host (" 错误: 显卡拒绝该模式 (CDS_TEST 返回 {0})" -f $rc) -ForegroundColor Red
return $false
}
$rc = [HdmiSrc.Native]::ChangeDisplaySettingsEx($Device, [ref]$dm, [IntPtr]::Zero, 0, [IntPtr]::Zero)
if ($rc -ne 0) {
Write-Host (" 错误: 模式应用失败 (返回 {0})" -f $rc) -ForegroundColor Red
return $false
}
return $true
}
# 在显卡枚举的模式里找 W x H [@Hz] 并切换
function Switch-Mode {
param([string]$Device, [int]$W, [int]$H, [int]$Hz)
$all = @(Get-Modes $Device)
$hit = @($all | Where-Object { $_.W -eq $W -and $_.H -eq $H -and ($Hz -le 0 -or $_.Hz -eq $Hz) })
if (-not $hit) {
Write-Host (" 错误: 显卡未枚举到 {0}x{1} 模式" -f $W, $H) -ForegroundColor Red
Write-Host " 可能原因:" -ForegroundColor Yellow
Write-Host " 1. 转接板 EDID 未被读取(检查 HPD / DDC / LT8619C 供电)"
Write-Host " 2. EDID 中未包含该分辨率(当前板载 EDID 只有 640x360@60)"
Write-Host " 3. 显卡驱动拒收该 DTD(可用 CRU 手动添加自定义分辨率)"
Write-Host " 先运行 -List 查看显卡实际枚举到了哪些模式。"
return $false
}
$dm = New-DevMode
$dm.dmPelsWidth = $W
$dm.dmPelsHeight = $H
$dm.dmDisplayFrequency = $hit[0].Hz
$dm.dmBitsPerPel = 32
$ok = Set-DevMode $Device $dm
if ($ok) {
Write-Host (" 已切换 {0} -> {1}x{2}@{3}" -f $Device, $W, $H, $hit[0].Hz) -ForegroundColor Green
}
return $ok
}
# ---------------- 全屏测试图案 ----------------
function Show-PatternWindow {
param(
[string]$Device = '',
[string]$StartPattern = '',
[int]$AutoSeconds = 0
)
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
# 窗口放到目标设备名对应的屏幕上(如 \\.\DISPLAY2),找不到则回退主屏
$screen = [System.Windows.Forms.Screen]::AllScreens |
Where-Object { $_.DeviceName -eq $Device } |
Select-Object -First 1
if (-not $screen) { $screen = [System.Windows.Forms.Screen]::PrimaryScreen }
$script:patterns = @('Bars', 'GraySteps', 'Gradient', 'Checker', 'Grid',
'Red', 'Green', 'Blue', 'White', 'Black', 'Gray50')
$script:pi = 0
if ($StartPattern) {
$idx = [array]::IndexOf($script:patterns, $StartPattern)
if ($idx -ge 0) { $script:pi = $idx }
}
$script:form = New-Object System.Windows.Forms.Form
$script:form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::None
$script:form.StartPosition = [System.Windows.Forms.FormStartPosition]::Manual
$script:form.Bounds = $screen.Bounds
$script:form.TopMost = $true
$script:form.KeyPreview = $true
# 双缓冲避免棋盘格/网格闪烁
$script:form.GetType().GetProperty('DoubleBuffered',
[System.Reflection.BindingFlags]'Instance,NonPublic').SetValue($script:form, $true, $null)
$osdFont = New-Object System.Drawing.Font('Consolas', 11)
$paintHandler = {
param($s, $e)
$g = $e.Graphics
$w = $e.ClipRectangle.Width
$h = $e.ClipRectangle.Height
$name = $script:patterns[$script:pi]
switch ($name) {
'Bars' {
# 100% 饱和度竖彩条:白黄青绿品红红蓝黑
$c = @(@(255,255,255), @(255,255,0), @(0,255,255), @(0,255,0),
@(255,0,255), @(255,0,0), @(0,0,255), @(0,0,0))
$n = $c.Count
for ($k = 0; $k -lt $n; $k++) {
$bw = [Math]::Ceiling($w / $n)
$brush = New-Object System.Drawing.SolidBrush (
[System.Drawing.Color]::FromArgb($c[$k][0], $c[$k][1], $c[$k][2]))
$g.FillRectangle($brush, [int]($k * $bw), 0, [int]$bw + 1, $h)
$brush.Dispose()
}
}
'GraySteps' {
# 16 级灰阶,验证 RGB888 各 bit
$n = 16
for ($k = 0; $k -lt $n; $k++) {
$v = [int]($k * 255 / ($n - 1))
$bw = [Math]::Ceiling($w / $n)
$brush = New-Object System.Drawing.SolidBrush (
[System.Drawing.Color]::FromArgb($v, $v, $v))
$g.FillRectangle($brush, [int]($k * $bw), 0, [int]$bw + 1, $h)
$brush.Dispose()
}
}
'Gradient' {
# R/G/B 三段水平渐变,验证每通道 8bit 线性度
$seg = [Math]::Ceiling($w / 3.0)
$rgbIdx = @(0, 1, 2)
foreach ($ch in $rgbIdx) {
for ($x = 0; $x -lt $seg; $x++) {
$v = [int](255 * $x / [Math]::Max(1.0, ($seg - 1)))
$r = 0; $gg = 0; $b = 0
if ($ch -eq 0) { $r = $v } elseif ($ch -eq 1) { $gg = $v } else { $b = $v }
$brush = New-Object System.Drawing.SolidBrush (
[System.Drawing.Color]::FromArgb($r, $gg, $b))
$g.FillRectangle($brush, [int]($ch * $seg + $x), 0, 1, $h)
$brush.Dispose()
}
}
}
'Checker' {
# 黑白棋盘格,验证 PCLK/DE 对位
$g.FillRectangle([System.Drawing.Brushes]::Black, 0, 0, $w, $h)
$cell = 16
for ($y = 0; $y -lt $h; $y += $cell) {
for ($x = 0; $x -lt $w; $x += $cell) {
if ((([int]($x / $cell) + [int]($y / $cell)) % 2) -eq 0) {
$g.FillRectangle([System.Drawing.Brushes]::White, $x, $y, $cell, $cell)
}
}
}
}
'Grid' {
# 黑底 + 40px 网格 + 中心十字 + 白色边框,验证像素映射
$g.FillRectangle([System.Drawing.Brushes]::Black, 0, 0, $w, $h)
$pen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(0, 128, 0), 1)
for ($x = 40; $x -lt $w; $x += 40) { $g.DrawLine($pen, $x, 0, $x, $h) }
for ($y = 40; $y -lt $h; $y += 40) { $g.DrawLine($pen, 0, $y, $w, $y) }
$pen.Dispose()
$wp = New-Object System.Drawing.Pen ([System.Drawing.Color]::White, 2)
$cx = [int]($w / 2); $cy = [int]($h / 2)
$g.DrawLine($wp, $cx, 0, $cx, $h)
$g.DrawLine($wp, 0, $cy, $w, $cy)
$g.DrawRectangle($wp, 1, 1, $w - 3, $h - 3)
$wp.Dispose()
}
'Red' { $g.FillRectangle([System.Drawing.Brushes]::Red, 0, 0, $w, $h) }
'Green' { $g.FillRectangle([System.Drawing.Brushes]::Green, 0, 0, $w, $h) }
'Blue' { $g.FillRectangle([System.Drawing.Brushes]::Blue, 0, 0, $w, $h) }
'White' { $g.FillRectangle([System.Drawing.Brushes]::White, 0, 0, $w, $h) }
'Black' { $g.FillRectangle([System.Drawing.Brushes]::Black, 0, 0, $w, $h) }
'Gray50' {
$brush = New-Object System.Drawing.SolidBrush (
[System.Drawing.Color]::FromArgb(128, 128, 128))
$g.FillRectangle($brush, 0, 0, $w, $h)
$brush.Dispose()
}
}
# 左上角 OSD:图案名 + 分辨率 + 按键提示
$text = "[{0}] {1}x{2} 空格/→:下一图案 ←:上一图案 ESC:退出" -f $name, $w, $h
$sz = $g.MeasureString($text, $osdFont)
$bg = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(150, 0, 0, 0))
$g.FillRectangle($bg, 6, 6, $sz.Width + 12, $sz.Height + 8)
$bg.Dispose()
$g.DrawString($text, $osdFont, [System.Drawing.Brushes]::White, 12, 10)
}
$script:form.Add_Paint($paintHandler)
$script:form.Add_KeyDown({
param($s, $e)
switch ($e.KeyCode) {
'Escape' { $s.Close() }
'Space' { $script:pi = ($script:pi + 1) % $script:patterns.Count; $s.Invalidate() }
'Right' { $script:pi = ($script:pi + 1) % $script:patterns.Count; $s.Invalidate() }
'Left' { $script:pi = ($script:pi - 1 + $script:patterns.Count) % $script:patterns.Count; $s.Invalidate() }
}
})
$script:form.Add_Activated({ [System.Windows.Forms.Cursor]::Hide() })
$script:form.Add_FormClosed({ [System.Windows.Forms.Cursor]::Show() })
if ($AutoSeconds -gt 0) {
$timer = New-Object System.Windows.Forms.Timer
$timer.Interval = $AutoSeconds * 1000
$timer.Add_Tick({ $script:form.Close() })
$timer.Start()
}
Write-Host ("全屏图案已启动于 {0} ({1}x{2})" -f $screen.DeviceName, $screen.Bounds.Width, $screen.Bounds.Height)
Write-Host "空格/→:下一图案 ←:上一图案 ESC:退出"
[void]$script:form.ShowDialog()
}
function Show-Usage {
Write-Host @"
:
hdmi_source.bat -List ( EDID)
hdmi_source.bat -Monitor <n> -Mode <WxH[@Hz]> , -Mode 640x360 640x360@60
hdmi_source.bat [-Monitor <n>] -Pattern [-PatternName <>] [-Seconds <n>]
(Bar/GraySteps/Gradient/Checker/Grid/)
: -Restore 退
-Seconds <n> n 退()
:
1. HDMI -> -List 640x360(EDID )
2. -Mode 640x360 -Pattern -> , RGB //
"@
}
# ---------------- 主流程 ----------------
$displays = @(Get-Displays) # @() 保证单显示器时不退化为标量
if ($displays.Count -eq 0) {
Write-Host "错误: 未找到活动显示器" -ForegroundColor Red
exit 1
}
if ($List) {
foreach ($d in $displays) {
Write-Host ""
Write-Host ("== 显示器 {0} {1} 显卡[{2}] 监视器[{3}]" -f $d.Index, $d.Device, $d.Adapter, $d.MonName) -ForegroundColor Cyan
$cur = Get-CurrentDevMode $d.Device
Write-Host (" 当前模式: {0}x{1}@{2} {3}bpp" -f $cur.dmPelsWidth, $cur.dmPelsHeight, $cur.dmDisplayFrequency, $cur.dmBitsPerPel)
$modes = Get-Modes $d.Device
$modes | Format-Table W, H, Hz, Bpp -AutoSize
$hasTarget = $modes | Where-Object { $_.W -eq $TargetW -and $_.H -eq $TargetH }
if ($hasTarget) {
Write-Host (" [OK] 找到板载 EDID 目标模式 {0}x{1} -> EDID 已被显卡读取" -f $TargetW, $TargetH) -ForegroundColor Green
} else {
Write-Host (" [--] 未找到 {0}x{1} (若此口接的是转接板, 说明 EDID/HPD 未生效)" -f $TargetW, $TargetH) -ForegroundColor Yellow
}
}
exit 0
}
if (-not $Mode -and -not $Pattern) {
Show-Usage
exit 0
}
if ($Monitor -gt $displays.Count) {
Write-Host ("错误: -Monitor 超出范围(共 {0} 个活动显示器, 运行 -List 查看)" -f $displays.Count) -ForegroundColor Red
exit 1
}
$disp = $displays[$Monitor - 1]
Write-Host ("目标: 显示器 {0} = {1}" -f $disp.Index, $disp.Device)
$prevMode = $null
if ($Mode) {
if ($Mode -notmatch '^\s*(\d+)\s*[xX]\s*(\d+)\s*(?:@\s*(\d+)\s*)?$') {
Write-Host "错误: -Mode 格式应为 WxH 或 WxH@Hz, 例如 640x360 或 640x360@60" -ForegroundColor Red
exit 1
}
$w = [int]$Matches[1]; $h = [int]$Matches[2]
$hz = 0
if ($Matches[3]) { $hz = [int]$Matches[3] }
$prevMode = Get-CurrentDevMode $disp.Device
$ok = Switch-Mode $disp.Device $w $h $hz
if (-not $ok) { exit 1 }
Start-Sleep -Milliseconds 300 # 等显卡/显示器完成模式切换
}
if ($Pattern) {
if ($Restore -and -not $prevMode) {
Write-Host "提示: -Restore 仅在与 -Mode 同用时生效" -ForegroundColor Yellow
}
try {
Show-PatternWindow -Device $disp.Device -StartPattern $PatternName -AutoSeconds $Seconds
}
finally {
if ($Restore -and $prevMode) {
Write-Host ("恢复原分辨率 {0}x{1}@{2} ..." -f $prevMode.dmPelsWidth, $prevMode.dmPelsHeight, $prevMode.dmDisplayFrequency)
[void](Set-DevMode $disp.Device $prevMode)
}
}
}
+120
View File
@@ -0,0 +1,120 @@
# Kingst VIS 压缩边沿流分析: 数字波形按占空比签名自动分段, 输出行结构/占空比/消隐明细
# 用法: python la_wave_analyzer.py <capture.csv>
# CSV 列: Time[s], 通道A, 通道B (时间分辨率 10ns; 默认抓 B 通道 D0/D1)
# 判读基线见 doc/architecture/rgb_bit_swap_test.md §1.1
import csv, statistics, time, sys
PATH = sys.argv[1] if len(sys.argv) > 1 else \
r"E:\Hulk-Coding\LT8619C_DLPC3421_HDMI\tools\wave\B-D0-D7.csv"
CLK_NS = 25.0 # 40MHz 像素时钟
t0 = time.time()
rows = []
with open(PATH, encoding='utf-8-sig', errors='replace') as f:
r = csv.reader(f)
next(r) # header
for row in r:
if len(row) < 3:
continue
rows.append((float(row[0]) * 1e9, int(row[1]), int(row[2]))) # ns
n = len(rows)
t_start, t_stop = rows[0][0], rows[-1][0] + 1000.0 # 末行状态延续按1us估
print(f"rows={n} span={t_stop - t_start / 1e9:.9f}s")
# ---- 按行区间累计高低时间, 50ms 窗口计算占空比 ----
W = 50_000_000.0 # ns
win = {'t0': t_start, 'hi0': 0.0, 'hi1': 0.0}
wins = []
for i in range(n - 1):
t, d0, d1 = rows[i]
dt = min(rows[i + 1][0], t_stop) - t
if dt <= 0:
continue
win['hi0'] += dt if d0 else 0.0
win['hi1'] += dt if d1 else 0.0
if rows[i + 1][0] - win['t0'] >= W:
span = rows[i + 1][0] - win['t0']
wins.append((win['t0'], win['hi0'] / span, win['hi1'] / span))
win = {'t0': rows[i + 1][0], 'hi0': 0.0, 'hi1': 0.0}
# ---- 窗口分类: 依 (d0duty, d1duty) 签名 ----
def classify(a, b):
hi = lambda x: x > 0.5
if hi(a) and hi(b):
return 'FF'
if hi(a) and not hi(b):
return '55'
if not hi(a) and hi(b):
return 'AA'
return '00'
segs = []
for t, a, b in wins:
c = classify(a, b)
if segs and segs[-1][0] == c:
segs[-1][1].append((t, a, b))
else:
segs.append((c, [(t, a, b)]))
print(f"\n{'':<4}{'签名':<6}{'时长(s)':<10}{'D0占空':<10}{'D1占空':<10}")
for i, (c, ws) in enumerate(segs):
dur = (ws[-1][0] - ws[0][0]) / 1e9
a = statistics.mean(x[1] for x in ws)
b = statistics.mean(x[2] for x in ws)
print(f"{i:<6}{c:<8}{dur:<12.2f}{a:<12.1%}{b:<12.1%}")
# ---- 每段: 行周期 + 有效区宽度(长高电平段) ----
print(f"\n按段结构分析 (时钟数 @40MHz):")
for i, (c, ws) in enumerate(segs):
seg_t0 = ws[0][0] - W / 2
seg_t1 = ws[-1][0] + W / 2
ch = 0 if c in ('FF', '55') else 1 # 选有效区为高的通道
# 找长高电平段(>=15us)及其起点
starts, widths = [], []
for j in range(n - 1):
t, d0, d1 = rows[j]
if t < seg_t0 or t > seg_t1:
continue
lvl = d0 if ch == 0 else d1
if lvl and rows[j + 1][0] - t >= 15_000.0:
starts.append(t)
widths.append(rows[j + 1][0] - t)
if len(starts) < 3:
print(f"{i} [{c}]: 线段不足({len(starts)}), 跳过")
continue
periods = [b - a for a, b in zip(starts, starts[1:]) if 20_000 < b - a < 35_000]
mp = statistics.median(periods)
mw = statistics.median(widths)
print(f"{i} [{c}]: 行数~{len(starts)} 行周期={mp:.0f}ns={mp/CLK_NS:.1f}clk "
f"有效高={mw:.0f}ns={mw/CLK_NS:.1f}clk 消隐={(mp-mw)/CLK_NS:.1f}clk")
# ---- 每段抽一行: 消隐区的边沿明细 ----
print(f"\n消隐区边沿明细(每段第一行, ns 相对行起点, clk=时钟数):")
for i, (c, ws) in enumerate(segs):
seg_t0 = ws[0][0] - W / 2
ch = 0 if c in ('FF', '55') else 1
line_start = None
for j in range(n - 1):
t, d0, d1 = rows[j]
if t < seg_t0:
continue
lvl = d0 if ch == 0 else d1
if lvl and rows[j + 1][0] - t >= 15_000.0:
line_start = t
break
if line_start is None:
continue
print(f"{i} [{c}] 行起点+有效区之后:")
j0 = next(k for k in range(n - 1) if rows[k][0] >= line_start)
out = []
for k in range(j0, min(j0 + 30, n - 1)):
t, d0, d1 = rows[k]
dt = rows[k + 1][0] - t
rel = t - line_start
out.append(f" +{rel/1000:8.2f}us D0={d0} D1={d1} 持续{dt/CLK_NS:6.1f}clk")
if rel > 30_000:
break
print('\n'.join(out[:18]))
print(f"\n耗时 {time.time()-t0:.1f}s")
+74
View File
@@ -0,0 +1,74 @@
# Kingst VIS 3 通道边沿流分段: 按占空比签名分段, 输出各通道占空比
# 用法: python la_wave_analyzer3.py <capture.csv> (列: Time[s], ch8, ch9, ch10)
import csv, time, statistics
import sys
PATH = sys.argv[1] if len(sys.argv) else r"E:\Hulk-Coding\LT8619C_DLPC3421_HDMI\tools\wave\A1_RGB-SWAP.csv"
t0 = time.time()
rows = []
with open(PATH, encoding='utf-8-sig', errors='replace') as f:
r = csv.reader(f)
hdr = next(r)
print('header:', hdr)
for row in r:
if len(row) < 4:
continue
rows.append((float(row[0]) * 1e9, int(row[1]), int(row[2]), int(row[3])))
n = len(rows)
t_last = rows[-1][0] + 1000.0
print('rows=%d span=%.2fs' % (n, (t_last - rows[0][0]) / 1e9))
W = 50_000_000.0
wins = []
acc = {'t0': rows[0][0], 'hi': [0.0, 0.0, 0.0]}
for i in range(n - 1):
t, a, b, c = rows[i]
dt = min(rows[i + 1][0], t_last) - t
if dt <= 0:
continue
acc['hi'][0] += dt if a else 0.0
acc['hi'][1] += dt if b else 0.0
acc['hi'][2] += dt if c else 0.0
if rows[i + 1][0] - acc['t0'] >= W:
span = rows[i + 1][0] - acc['t0']
wins.append((acc['t0'], tuple(x / span for x in acc['hi'])))
acc = {'t0': rows[i + 1][0], 'hi': [0.0, 0.0, 0.0]}
def sig(d):
hi = [x > 0.5 for x in d]
return ('D7' if hi[0] else '--') + '/' + ('D15' if hi[1] else '--') + '/' + ('D23' if hi[2] else '--')
segs = []
for t, d in wins:
s = sig(d)
if segs and segs[-1][0] == s:
segs[-1][1].append((t, d))
else:
segs.append((s, [(t, d)]))
print('\n%-16s %10s %9s %9s %9s' % ('活动组签名', '时长(s)', 'D7占空', 'D15占空', 'D23占空'))
for s, ws in segs:
dur = ws[-1][0] - ws[0][0]
m = [statistics.mean(x[1][k] for x in ws) for k in range(3)]
print('%-16s %10.2f %8.1f%% %8.1f%% %8.1f%%' % (s, dur, m[0] * 100, m[1] * 100, m[2] * 100))
# 每段行结构抽查(第一段一次即可)
s0, ws0 = segs[0]
ch = 0
starts = []
seg_t0 = ws0[0][0] - W / 2
for j in range(n - 1):
t = rows[j][0]
if t < seg_t0:
continue
if rows[j][1] and rows[j + 1][0] - t >= 15000.0:
starts.append(t)
if len(starts) >= 5 or t > seg_t0 + 0.3e9:
break
if len(starts) >= 2:
p = statistics.median([b - a for a, b in zip(starts, starts[1:])])
print('\n行周期抽查: %.0fns (%.1f clk @40MHz)' % (p, p / 25.0))
print('耗时 %.1fs' % (time.time() - t0))
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DLPilot 图标栅格化: 黑底 512, 橙色圆角矩形, 第一行 DLP(框内黑字), 第二行 Pilot(白字)
================================================================
设计参数: 黑底 512, 橙色圆角矩形 x38 y64 w436 h186 rx34,
DLP 146px Arial Bold 黑字(框内居中), Pilot 146px Arial Bold 白字(基线 421)。
输出: logos/logo_dlpilot.png(512, 预览/复用)
logos/logo_dlpilot.ico(16~256 七档, exe 图标用)
用法: python make_ico.py
"""
import os
from PIL import Image, ImageDraw, ImageFont
BASE = os.path.dirname(os.path.abspath(__file__))
FONT = r'C:\Windows\Fonts\arialbd.ttf' # Arial Bold, Windows 标配
def render(size=512):
s = size / 512.0
img = Image.new('RGB', (size, size), (0, 0, 0))
d = ImageDraw.Draw(img)
d.rounded_rectangle([38 * s, 64 * s, (38 + 436) * s, (64 + 186) * s],
radius=34 * s, fill=(255, 153, 0))
if not os.path.exists(FONT):
raise SystemExit(f'未找到字体 {FONT}, 无法栅格化')
f = ImageFont.truetype(FONT, max(8, round(146 * s)))
cx = size / 2
# DLP: 居中于橙色框
d.text((cx, 64 * s + 186 * s / 2), 'DLP', font=f, fill=(0, 0, 0), anchor='mm')
# Pilot: 与原设计基线 421 对齐(无下伸字母, 中心 = 421 - cap/2 ~= 368)
d.text((cx, 368 * s), 'Pilot', font=f, fill=(255, 255, 255), anchor='mm')
return img
if __name__ == '__main__':
img = render(512)
png = os.path.join(BASE, 'logo_dlpilot.png')
ico = os.path.join(BASE, 'logo_dlpilot.ico')
img.save(png)
img.save(ico, sizes=[(16, 16), (24, 24), (32, 32), (48, 48),
(64, 64), (128, 128), (256, 256)])
print(f'写出 {png}')
print(f'写出 {ico}')
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
组装 DLPilot 发布包: exe + 附属资产 → 一个文件夹 + 一个 zip(解压即用)
================================================================
先把 assets/ 的图案复制进 release/(与 exe 平铺), 使 release/ 本身就是
可直接使用的完整文件夹; 再原样压成 zip —— 两者内容始终一致。
zip 解压后根目录为 DLPilot/, 与 exe 运行目录结构一致:
DLPilot.exe 主程序(先运行 build_exe.py 打包)
pattern_slots.json 预置自定义栏位(指向前 3 张 bw 图案)
bw_*.png 640x360 黑白测试图 ×5(自定义栏位用)
solid_*.png 800x600 纯色测试图 ×10(RGB 位交换测试用)
README.md 使用说明
用法:
python package_release.py 版本号自动取最近 git tag(DLPilot-v*)
python package_release.py 0.1.0 手动指定版本号
产物: release/DLPilot-v<版本>-win64.zip(zip 不入库, 上传到 Gitea Release)
"""
import os
import re
import shutil
import subprocess
import sys
import zipfile
SRC = os.path.dirname(os.path.abspath(__file__))
BASE = os.path.dirname(SRC)
RELEASE = os.path.join(BASE, 'release')
ASSETS = os.path.join(BASE, 'assets')
PATTERN_GLOBS = ('bw_*.png', 'solid_*.png')
def detect_version():
"""从最近的 DLPilot-v* tag 推断版本号, 无 tag 则报错提示手动指定"""
try:
out = subprocess.check_output(
['git', 'describe', '--tags', '--abbrev=0', '--match', 'DLPilot-v*'],
cwd=BASE, stderr=subprocess.DEVNULL)
return out.decode().strip().removeprefix('DLPilot-v')
except Exception:
sys.exit('未能从 git tag 推断版本号, 请手动指定: python package_release.py <版本号>')
def main():
version = (sys.argv[1] if len(sys.argv) > 1 else detect_version()).lstrip('v')
exe = os.path.join(RELEASE, 'DLPilot.exe')
slots = os.path.join(RELEASE, 'pattern_slots.json')
readme = os.path.join(BASE, 'README.md')
if not os.path.exists(exe):
sys.exit('缺少 release/DLPilot.exe, 先运行: python build_exe.py')
for must in (slots, readme):
if not os.path.exists(must):
sys.exit(f'缺少 {must}')
# 依 pattern_slots.json 校验栏位引用的图片都在 assets/ 里
slot_files = re.findall(r'"file":\s*"([^"]+)"', open(slots, encoding='utf-8').read())
patterns = sorted(p for g in PATTERN_GLOBS
for p in os.listdir(ASSETS)
if re.fullmatch(g.replace('*', '.*'), p))
for f in slot_files:
if f not in patterns:
sys.exit(f'pattern_slots.json 引用的 {f} 不在 assets/ 中')
# 图案复制进 release/, 与 exe 平铺 -> release/ 即完整可用的发布文件夹
for p in patterns:
shutil.copy2(os.path.join(ASSETS, p), os.path.join(RELEASE, p))
shutil.copy2(readme, os.path.join(RELEASE, 'README.md'))
zip_name = f'DLPilot-v{version}-win64.zip'
zip_path = os.path.join(RELEASE, zip_name)
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as z:
for p in ['DLPilot.exe', 'pattern_slots.json', 'README.md'] + patterns:
z.write(os.path.join(RELEASE, p), f'DLPilot/{p}')
print(f'发布文件夹: {RELEASE} (exe+配置+说明+{len(patterns)} 张图案, 平铺即用)')
print(f'发布包: {zip_path} ({os.path.getsize(zip_path) / 1024 / 1024:.1f} MB)')
print('下一步上传: python publish_gitea.py', zip_name)
if __name__ == '__main__':
main()
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
测试图案全屏投屏窗口(tkinter): 纯色/棋盘格/网格线 + 自定义图片 1:1 居中
================================================================
投屏窗口为无边框黑底 Toplevel, ESC 或点击退出; 测试卡区域可选
640x360 / 800x600 / full 铺满(常量与图片加载配置在 config)。
"""
from config import DEFAULT_AREA, HAS_PIL, Image, ImageTk, PAT_W, PAT_H
# 当前打开的投屏窗口集合: 支持"新图案替换旧图案"与 close_all() 一键恢复桌面
_OPEN = set()
def close_all():
"""关闭所有打开的投屏窗口, 恢复桌面"""
for top in list(_OPEN):
try:
top.destroy()
except Exception:
pass
_OPEN.clear()
PATTERN_NAMES = {'Red': '纯红', 'Green': '纯绿', 'Blue': '纯蓝',
'Checker': '黑白棋盘格', 'Grid': '网格线'}
# 纯色测试值(位交换测试用, 与 assets/solid_*.png 对应):
# 每通道 55/AA 互为镜像对(检测对), FF 全高基准
SOLID_ROWS = (
('R', (('55', '#550000'), ('AA', '#AA0000'), ('FF', '#FF0000'))),
('G', (('55', '#005500'), ('AA', '#00AA00'), ('FF', '#00FF00'))),
('B', (('55', '#000055'), ('AA', '#0000AA'), ('FF', '#0000FF'))),
)
def load_image_raw(path):
"""按原始像素加载图片(不缩放不裁剪; PIL 仅用于支持 JPG/BMP/WebP 等格式)"""
import tkinter as tk
if HAS_PIL:
return ImageTk.PhotoImage(Image.open(path).convert('RGB'))
return tk.PhotoImage(file=path)
def _area_box(spec, w, h):
"""区域选项 -> (x0, y0, aw, ah): 'full' 铺满整个屏幕, 'WxH' 黑底居中"""
if spec == 'full':
return 0, 0, w, h
try:
aw, ah = (int(v) for v in spec.lower().split('x', 1))
except Exception:
aw, ah = PAT_W, PAT_H
return (w - aw) // 2, (h - ah) // 2, aw, ah
def _open_fullscreen(parent, rect, auto_close=0):
"""创建覆盖 rect(x,y,w,h) 的无边框全屏黑底窗口, 返回 (top, cv)
ESC 退出(需窗口持有键盘焦点); overrideredirect 窗口必须 focus_force
强取键盘焦点, 否则收不到按键(Windows 上该组合有效)。
窗口登记进 _OPEN: 打开新投屏前会关旧的(替换语义), close_all() 一键全关。
"""
import tkinter as tk
rx, ry, rw, rh = rect
top = tk.Toplevel(parent)
top.overrideredirect(True)
top.geometry(f'{rw}x{rh}+{rx}+{ry}')
top.attributes('-topmost', True)
top.configure(bg='black')
cv = tk.Canvas(top, bg='black', highlightthickness=0, bd=0)
cv.pack(fill='both', expand=True)
def close(_evt=None):
_OPEN.discard(top)
top.destroy()
def _on_destroy(e):
if e.widget is top: # 子控件销毁也冒泡到此, 只认窗口本身
_OPEN.discard(top)
top.bind('<Escape>', close)
top.bind('<Destroy>', _on_destroy)
_OPEN.add(top)
if auto_close > 0:
top.after(auto_close * 1000, close)
top.focus_force()
return top, cv
def show_pattern(parent, rect, kind, auto_close=0, area=DEFAULT_AREA):
"""在 rect 指定的屏幕上全屏出居中测试卡(无任何 OSD 修饰), 区域由 area 指定。
打开前先关闭已有投屏窗口(替换语义, 不会多层叠罗汉)。"""
close_all()
top, cv = _open_fullscreen(parent, rect, auto_close)
def draw(_evt=None):
cv.delete('all')
w, h = max(cv.winfo_width(), rect[2]), max(cv.winfo_height(), rect[3])
x0, y0, aw, ah = _area_box(area, w, h)
if kind.startswith('#'): # 纯色测试值: 直接填充指定颜色
cv.create_rectangle(x0, y0, x0 + aw, y0 + ah, fill=kind, outline='')
elif kind == 'Red':
cv.create_rectangle(x0, y0, x0 + aw, y0 + ah, fill='#FF0000', outline='')
elif kind == 'Green':
cv.create_rectangle(x0, y0, x0 + aw, y0 + ah, fill='#00FF00', outline='')
elif kind == 'Blue':
cv.create_rectangle(x0, y0, x0 + aw, y0 + ah, fill='#0000FF', outline='')
elif kind == 'Checker':
# 40px 黑白格, 格数随区域尺寸自适应(640x360 = 16x9 格)
for gy in range((ah + 39) // 40):
for gx in range((aw + 39) // 40):
if (gx + gy) % 2 == 0:
cv.create_rectangle(x0 + gx * 40, y0 + gy * 40,
x0 + (gx + 1) * 40, y0 + (gy + 1) * 40,
fill='#FFFFFF', outline='')
elif kind == 'Grid':
# 20px 白色网格线, 黑底, 无其它修饰
for k in range(aw // 20 + 1):
cv.create_line(x0 + k * 20, y0, x0 + k * 20, y0 + ah, fill='#FFFFFF')
for k in range(ah // 20 + 1):
cv.create_line(x0, y0 + k * 20, x0 + aw, y0 + k * 20, fill='#FFFFFF')
top.bind('<Configure>', draw)
top.after(50, draw)
top.wait_window(top)
def show_pattern_image(parent, rect, path, auto_close=0, area=DEFAULT_AREA):
"""全屏黑底, 图片 1:1 居中于 area 指定的测试卡区域:
小于区域 -> 真实像素居中, 四周黑边;
大于区域 -> 等效从中心截取(区域外用黑矩形遮挡)。
打开前先关闭已有投屏窗口(替换语义)。"""
close_all()
top, cv = _open_fullscreen(parent, rect, auto_close)
def draw(_evt=None):
cv.delete('all')
w, h = max(cv.winfo_width(), rect[2]), max(cv.winfo_height(), rect[3])
x0, y0, aw, ah = _area_box(area, w, h)
photo = load_image_raw(path)
top._photo = photo # 挂在窗口上防止被垃圾回收
iw, ih = photo.width(), photo.height()
cv.create_image(x0 + (aw - iw) // 2, y0 + (ah - ih) // 2,
image=photo, anchor='nw')
# 四块黑矩形盖住测试卡区域之外 -> 大图等效中心裁剪
cv.create_rectangle(-2, -2, w + 2, y0, fill='black', outline='')
cv.create_rectangle(-2, y0 + ah, w + 2, h + 2, fill='black', outline='')
cv.create_rectangle(-2, y0, x0, y0 + ah, fill='black', outline='')
cv.create_rectangle(x0 + aw, y0, w + 2, y0 + ah, fill='black', outline='')
top.bind('<Configure>', draw)
top.after(50, draw)
top.wait_window(top)
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
创建/更新 Gitea Release 并上传发布 zip(纯标准库, 无需安装依赖)
================================================================
用法:
python publish_gitea.py DLPilot-v0.1.0-win64.zip
python publish_gitea.py <zip> --notes-file release/notes-v0.1.0.md
python publish_gitea.py <zip> --tag DLPilot-v0.1.0 --replace
行为:
- tag 与版本默认取 zip 文件名(DLPilot-vX.Y.Z-win64.zip → DLPilot-vX.Y.Z)
- release 不存在则创建(tag 不存在时 Gitea 在默认分支头自动建 tag);
已存在则复用, 仅上传/更新资产
- release 上已有同名资产时: 默认跳过, --replace 删旧再传
- API token: --token 参数或环境变量 GITEA_TOKEN(需该仓库写权限)
"""
import argparse
import json
import os
import re
import ssl
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
DEFAULT_NOTES = """## DLPilot {version}
HDMI 源端调试工具(LT8619C → DLPC3421 转接板配套): EDID 查看/逐字节解析、
分辨率切换、纯色与测试图案全屏投屏。
### 安装
下载 `DLPilot-v{version}-win64.zip`, 解压后双击 `DLPilot.exe`(Windows 10/11 x64, 免安装)。
### 包内容
- `DLPilot.exe` — 主程序
- `pattern_slots.json` — 预置自定义栏位(指向 bw_*.png)
- `bw_*.png` ×5 / `solid_*.png` ×10 — 测试图案
- `README.md` — 完整使用说明
"""
def git_remote_info():
"""从 origin 远程地址解析 API base 与 owner/repo(ssh 端口不影响 https API)"""
url = subprocess.check_output(['git', 'remote', 'get-url', 'origin']).decode().strip()
m = re.match(r'(?:ssh://git@|git@)([\w.-]+)(?::\d+)?[:/]([\w.-]+)/([\w.-]+?)(?:\.git)?$', url)
if not m:
sys.exit(f'无法从 origin 解析 Gitea 地址: {url}')
host, owner, repo = m.groups()
return f'https://{host}', owner, repo
class Api:
def __init__(self, base, owner, repo, token, insecure):
self.api = f'{base}/api/v1/repos/{owner}/{repo}'
self.headers = {'Authorization': f'token {token}'}
self.ctx = ssl._create_unverified_context() if insecure else None
def request(self, method, path, data=None, headers=None, retries=4):
"""发请求; 实例 TLS 间歇性抽风(SSL UNEXPECTED_EOF), 连接级错误自动重试"""
last = None
for attempt in range(retries):
req = urllib.request.Request(self.api + path, data=data, method=method,
headers={**self.headers, **(headers or {})})
try:
with urllib.request.urlopen(req, context=self.ctx) as resp:
body = resp.read()
return resp.status, json.loads(body) if body else None
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or b'{}')
except (urllib.error.URLError, ssl.SSLError, OSError) as e:
last = e
time.sleep(1.0 + attempt)
raise RuntimeError(f'Gitea 请求连续 {retries} 次失败: {last}')
def get_json(self, path):
status, data = self.request('GET', path)
return data if status == 200 else None
def upload_asset(self, release_id, filepath, replace):
name = os.path.basename(filepath)
status, rel = self.request('GET', f'/releases/{release_id}')
if status != 200:
sys.exit(f'读取 release {release_id} 失败: {status} {rel}')
# Gitea 版本差异: assets 可能是 {items:[...]} 或纯数组
assets = rel.get('assets') or []
if isinstance(assets, dict):
assets = assets.get('items', [])
for asset in assets:
if asset['name'] == name:
if not replace:
print(f'资产 {name} 已存在, 跳过(覆盖请加 --replace)')
return
self.request('DELETE', f"/releases/{release_id}/assets/{asset['id']}")
print(f'已删除旧资产 {name}')
boundary = uuid.uuid4().hex
payload = open(filepath, 'rb').read()
body = (f'--{boundary}\r\n'
f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n'
f'Content-Type: application/octet-stream\r\n\r\n').encode() + payload + \
f'\r\n--{boundary}--\r\n'.encode()
status, data = self.request(
'POST', f'/releases/{release_id}/assets?name={name}', body,
{'Content-Type': f'multipart/form-data; boundary={boundary}'})
if status != 201:
sys.exit(f'上传资产失败: {status} {data}')
print(f'已上传资产: {name} ({len(payload) / 1024 / 1024:.1f} MB)')
def main():
ap = argparse.ArgumentParser()
ap.add_argument('zipfile_path', help='发布 zip(release/ 下)')
ap.add_argument('--tag', help='release 标签, 默认取 zip 文件名去掉 -win64.zip')
ap.add_argument('--notes-file', help='release 说明 markdown 文件, 默认用内置模板')
ap.add_argument('--token', default=os.environ.get('GITEA_TOKEN'), help='API token')
ap.add_argument('--replace', action='store_true', help='覆盖已存在的同名资产')
ap.add_argument('--insecure', action='store_true', help='跳过 TLS 证书校验(自签证书)')
args = ap.parse_args()
if not os.path.exists(args.zipfile_path):
sys.exit(f'找不到 {args.zipfile_path}')
if not args.token:
sys.exit('缺少 API token: 传 --token 或设环境变量 GITEA_TOKEN')
tag = args.tag or re.sub(r'-win64\.zip$', '', os.path.basename(args.zipfile_path))
version = tag.removeprefix('DLPilot-v')
notes = open(args.notes_file, encoding='utf-8').read() if args.notes_file \
else DEFAULT_NOTES.format(version=version)
base, owner, repo = git_remote_info()
api = Api(base, owner, repo, args.token, args.insecure)
print(f'目标: {base}/{owner}/{repo} tag={tag}')
rel = api.get_json(f'/releases/tags/{tag}')
if rel:
print(f'release 已存在(id={rel["id"]}), 复用')
else:
status, rel = api.request('POST', '/releases', json.dumps({
'tag_name': tag,
'name': f'DLPilot v{version}',
'body': notes,
}).encode(), {'Content-Type': 'application/json'})
if status != 201:
sys.exit(f'创建 release 失败: {status} {rel}')
print(f'release 已创建(id={rel["id"]})')
api.upload_asset(rel['id'], args.zipfile_path, args.replace)
print(f'完成: {rel.get("html_url", "") or f"{base}/{owner}/{repo}/releases/tag/{tag}"}')
if __name__ == '__main__':
main()
+77
View File
@@ -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)