Files
GateWay_ConfigureAPP/generate_manual.py
T
2026-08-19 10:11:54 +08:00

516 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
生成《网关配置工具使用说明书》PDF
"""
import os
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm, cm
from reportlab.lib.colors import HexColor, white, black
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_JUSTIFY
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
PageBreak, KeepTogether, HRFlowable
)
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
# ── 注册中文字体 ──────────────────────────────────────────────
FONT_DIR = os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts")
FONT_NAME = "SimSun"
FONT_BOLD = "SimHei"
pdfmetrics.registerFont(TTFont(FONT_NAME, os.path.join(FONT_DIR, "simsun.ttc")))
pdfmetrics.registerFont(TTFont(FONT_BOLD, os.path.join(FONT_DIR, "simhei.ttf")))
# ── 颜色 ─────────────────────────────────────────────────────
PRIMARY = HexColor("#1565C0")
ACCENT = HexColor("#4CAF50")
LIGHT_BG = HexColor("#E3F2FD")
TABLE_HEAD_BG = HexColor("#1565C0")
TABLE_ALT_BG = HexColor("#F5F5F5")
BORDER_COLOR = HexColor("#BDBDBD")
# ── 样式 ──────────────────────────────────────────────────────
styles = getSampleStyleSheet()
style_title = ParagraphStyle(
"CoverTitle", fontName=FONT_BOLD, fontSize=28, leading=40,
alignment=TA_CENTER, textColor=PRIMARY, spaceAfter=10 * mm
)
style_subtitle = ParagraphStyle(
"CoverSub", fontName=FONT_NAME, fontSize=14, leading=20,
alignment=TA_CENTER, textColor=HexColor("#616161"), spaceAfter=6 * mm
)
style_h1 = ParagraphStyle(
"H1", fontName=FONT_BOLD, fontSize=18, leading=26,
textColor=PRIMARY, spaceBefore=12 * mm, spaceAfter=4 * mm
)
style_h2 = ParagraphStyle(
"H2", fontName=FONT_BOLD, fontSize=14, leading=20,
textColor=HexColor("#1976D2"), spaceBefore=8 * mm, spaceAfter=3 * mm
)
style_h3 = ParagraphStyle(
"H3", fontName=FONT_BOLD, fontSize=12, leading=18,
textColor=HexColor("#388E3C"), spaceBefore=5 * mm, spaceAfter=2 * mm
)
style_body = ParagraphStyle(
"BodyCN", fontName=FONT_NAME, fontSize=10.5, leading=18,
alignment=TA_JUSTIFY, spaceAfter=2 * mm
)
style_note = ParagraphStyle(
"Note", fontName=FONT_NAME, fontSize=9.5, leading=16,
textColor=HexColor("#757575"), leftIndent=8 * mm, spaceAfter=2 * mm
)
style_toc = ParagraphStyle(
"TOC", fontName=FONT_NAME, fontSize=12, leading=22,
leftIndent=0, spaceAfter=1 * mm
)
style_toc_sub = ParagraphStyle(
"TOCSub", fontName=FONT_NAME, fontSize=11, leading=20,
leftIndent=10 * mm, spaceAfter=1 * mm
)
# ── 工具函数 ──────────────────────────────────────────────────
LQ = "\u300c" # 左角括号
RQ = "\u300d" # 右角括号
def heading1(text):
return Paragraph(text, style_h1)
def heading2(text):
return Paragraph(text, style_h2)
def heading3(text):
return Paragraph(text, style_h3)
def body(text):
return Paragraph(text, style_body)
def note(text):
return Paragraph(text, style_note)
def hr():
return HRFlowable(width="100%", thickness=0.5, color=BORDER_COLOR, spaceAfter=3 * mm)
def make_table(headers, rows, col_widths=None):
data = [headers] + rows
wrapped = []
for i, row in enumerate(data):
wrapped_row = []
for cell in row:
if i == 0:
s = ParagraphStyle("th", fontName=FONT_BOLD, fontSize=10, leading=14,
textColor=white, alignment=TA_CENTER)
else:
s = ParagraphStyle("td", fontName=FONT_NAME, fontSize=9.5, leading=14,
alignment=TA_CENTER)
wrapped_row.append(Paragraph(str(cell), s))
wrapped.append(wrapped_row)
t = Table(wrapped, colWidths=col_widths, repeatRows=1)
style_cmds = [
("BACKGROUND", (0, 0), (-1, 0), TABLE_HEAD_BG),
("TEXTCOLOR", (0, 0), (-1, 0), white),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("GRID", (0, 0), (-1, -1), 0.5, BORDER_COLOR),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
]
for i in range(1, len(data)):
if i % 2 == 0:
style_cmds.append(("BACKGROUND", (0, i), (-1, i), TABLE_ALT_BG))
t.setStyle(TableStyle(style_cmds))
return t
# ── 构建文档 ──────────────────────────────────────────────────
OUTPUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "网关配置工具使用说明书.pdf")
doc = SimpleDocTemplate(
OUTPUT, pagesize=A4,
topMargin=20 * mm, bottomMargin=20 * mm,
leftMargin=20 * mm, rightMargin=20 * mm
)
story = []
# ==================== 封面 ====================
story.append(Spacer(1, 50 * mm))
story.append(Paragraph("网关配置工具", style_title))
story.append(Paragraph("使用说明书", style_title))
story.append(Spacer(1, 15 * mm))
story.append(HRFlowable(width="60%", thickness=2, color=PRIMARY, spaceAfter=6 * mm))
story.append(Paragraph("适用于物联网网关设备的串口配置管理", style_subtitle))
story.append(Spacer(1, 10 * mm))
cover_info = [
["软件名称", "网关配置工具"],
["适用设备", "物联网网关(HC32F460 + 4G/Cat1 + Ethernet"],
["通信接口", "串口(UART),波特率 500000"],
["运行环境", "Windows 7/10/11"],
["文档版本", "V1.0"],
]
cover_table = Table(cover_info, colWidths=[40 * mm, 90 * mm])
cover_table.setStyle(TableStyle([
("FONTNAME", (0, 0), (0, -1), FONT_BOLD),
("FONTNAME", (1, 0), (1, -1), FONT_NAME),
("FONTSIZE", (0, 0), (-1, -1), 11),
("TEXTCOLOR", (0, 0), (0, -1), PRIMARY),
("ALIGN", (0, 0), (0, -1), "RIGHT"),
("ALIGN", (1, 0), (1, -1), "LEFT"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("RIGHTPADDING", (0, 0), (-1, -1), 8),
("LINEBELOW", (0, 0), (-1, -2), 0.3, BORDER_COLOR),
]))
story.append(cover_table)
story.append(PageBreak())
# ==================== 目录 ====================
story.append(heading1("目 录"))
story.append(hr())
toc_items = [
("1", "产品概述"),
(" 1.1", "功能简介"),
(" 1.2", "系统要求"),
(" 1.3", "软件界面总览"),
("2", "快速入门"),
(" 2.1", "连接设备"),
(" 2.2", "查看网关配置"),
("3", "功能详解"),
(" 3.1", "串口连接"),
(" 3.2", "查看配置(para"),
(" 3.3", "通讯方式切换"),
(" 3.4", "ETH网络参数配置"),
(" 3.5", "Lora信道配置"),
(" 3.6", "低功耗配置"),
(" 3.7", "自定义指令"),
(" 3.8", "通讯日志"),
("4", "指令参考"),
("5", "常见问题(FAQ"),
("6", "注意事项"),
]
for num, title in toc_items:
s = style_toc if not num.startswith(" ") else style_toc_sub
story.append(Paragraph(f"{num} {title}", s))
story.append(PageBreak())
# ==================== 1. 产品概述 ====================
story.append(heading1("1 产品概述"))
story.append(hr())
story.append(heading2("1.1 功能简介"))
story.append(body(
f"网关配置工具是一款基于串口通信的物联网网关设备配置管理软件。通过本工具,用户可以方便地完成以下操作:"
))
story.append(Spacer(1, 2 * mm))
features = [
["功能模块", "说明"],
["串口通信", "通过USB转串口连接网关设备,波特率500000,实时收发指令"],
["配置读取", "一键读取网关全部参数,包括软件/硬件版本、MAC地址、电池状态、注册状态等"],
["通讯方式切换", "支持Cat1(4G)和ETH(以太网)两种通讯方式的切换"],
["ETH参数配置", "配置以太网相关参数:IP地址、端口、子网掩码、网关、服务器地址等"],
["Lora信道配置", "选择并设置Lora通信信道频率(410.1MHz ~ 524.9MHz"],
["低功耗配置", "查看采集间隔、上报间隔、紧急上报等低功耗参数"],
["自定义指令", "支持手动输入任意指令与网关交互"],
["通讯日志", "实时显示发送和接收的通讯数据,便于调试"],
]
story.append(make_table(features[0], features[1:], col_widths=[35 * mm, 115 * mm]))
story.append(Spacer(1, 3 * mm))
story.append(heading2("1.2 系统要求"))
req_rows = [
["操作系统", "Windows 7 / 10 / 1164位推荐)"],
["接口", "USB接口(用于连接USB转串口线)"],
["驱动", "USB转串口芯片驱动(如CH340/CP2102等)"],
["运行依赖", "无需安装Python环境,直接运行可执行文件"],
]
story.append(make_table(["项目", "要求"], req_rows, col_widths=[35 * mm, 115 * mm]))
story.append(heading2("1.3 软件界面总览"))
story.append(body("软件界面自上而下分为以下几个区域:"))
ui_rows = [
[f"{LQ}串口连接{RQ}", "选择串口、刷新端口列表、打开/关闭串口连接"],
[f"基本指令区", f"查看配置、切换通讯方式(Cat1/ETH)的快捷按钮"],
[f"当前网关配置", f"显示网关读取到的各项参数(版本、MAC、电池、注册状态等)"],
[f"ETH网络参数", f"显示当前ETH网络参数(IP、端口、掩码、服务器地址等)"],
[f"低功耗配置", f"显示采集间隔、上报间隔等低功耗相关参数"],
[f"ETH参数配置", f"输入并发送ETH相关配置参数"],
[f"Lora信道配置", f"选择Lora信道并发送"],
[f"通讯日志", f"实时显示收发数据(蓝色=发送,橙色=接收)"],
[f"自定义指令", f"手动输入任意指令发送"],
]
story.append(make_table(["区域", "功能说明"], ui_rows, col_widths=[35 * mm, 115 * mm]))
story.append(PageBreak())
# ==================== 2. 快速入门 ====================
story.append(heading1("2 快速入门"))
story.append(hr())
story.append(heading2("2.1 连接设备"))
story.append(body("按照以下步骤连接网关设备:"))
steps = [
"使用USB转串口线连接电脑与网关设备的调试串口;",
"确认电脑已安装对应的USB转串口驱动;",
f"启动{LQ}网关配置工具{RQ}软件;",
f"{LQ}串口连接{RQ}区域,点击<b>刷新</b>按钮,扫描可用串口;",
"从下拉列表中选择对应的串口号(如COM3);",
"点击<b>打开串口</b>按钮,建立连接。",
]
for i, s in enumerate(steps, 1):
story.append(Paragraph(f"<b>步骤{i}</b>{s}", style_body))
story.append(Spacer(1, 2 * mm))
story.append(note(f"连接成功后,按钮变为红色{LQ}关闭串口{RQ},状态栏显示已连接的串口号。"))
story.append(heading2("2.2 查看网关配置"))
story.append(body(
f"串口连接成功后,点击<b>查看配置(para)</b>按钮,软件将自动向网关发送{LQ}para{RQ}指令,"
f"网关返回配置信息后,界面各区域将自动更新显示当前参数值。"
))
story.append(PageBreak())
# ==================== 3. 功能详解 ====================
story.append(heading1("3 功能详解"))
story.append(hr())
# 3.1
story.append(heading2("3.1 串口连接"))
story.append(body("串口连接是使用本工具的前提。软件固定使用以下串口参数:"))
serial_rows = [
["波特率", "500000"],
["数据位", "8"],
["停止位", "1"],
["校验位", "None(无校验)"],
]
story.append(make_table(["参数", ""], serial_rows, col_widths=[40 * mm, 60 * mm]))
story.append(Spacer(1, 2 * mm))
story.append(note("如无法连接,请检查串口驱动是否正确安装,以及串口是否被其他程序占用。"))
# 3.2
story.append(heading2("3.2 查看配置(para"))
story.append(body(
f"点击{LQ}查看配置(para){RQ}按钮后,软件发送{LQ}para{RQ}指令,网关返回包含以下信息的配置数据块:"
))
para_rows = [
["软件版本", "SoftWare V", "网关固件版本号"],
["硬件版本", "HardWare V", "网关硬件版本号"],
["网关MAC", "GateWay MAC", "网关唯一MAC地址"],
["电池", "Battery", "当前电池电量"],
["注册状态", "Register Status", "0=未注册,1=已注册"],
["通讯方式", "CommInf", "当前通讯方式(Cat1/Eth"],
["读取间隔", "CommUnitReadInterval", "通讯单元读取间隔(秒)"],
["Cat1 IMEI", "Cat1 IMEI", "4G模块IMEI号"],
["Cat1 SIM", "Cat1 SIM", "SIM卡信息"],
["Lora频率", "Lora fc", "当前Lora信道频率"],
]
story.append(make_table(["显示项", "指令关键字", "说明"], para_rows, col_widths=[30 * mm, 45 * mm, 75 * mm]))
# 3.3
story.append(heading2("3.3 通讯方式切换"))
story.append(body("网关支持两种通讯方式,可通过快捷按钮切换:"))
comm_rows = [
["Cat1(comm cat1)", "comm cat1", "切换到4G/Cat1通讯模式"],
["ETH(comm eth)", "comm eth", "切换到以太网通讯模式"],
]
story.append(make_table(["按钮", "发送指令", "说明"], comm_rows, col_widths=[40 * mm, 35 * mm, 75 * mm]))
story.append(Spacer(1, 2 * mm))
story.append(note(f"切换通讯方式后,需要重新点击{LQ}查看配置{RQ}刷新当前状态。ETH参数配置必须在ETH模式下进行。"))
story.append(PageBreak())
# 3.4
story.append(heading2("3.4 ETH网络参数配置"))
story.append(body(
f"ETH参数配置区域用于设置网关以太网相关的网络参数。<b>必须先切换到ETH模式</b>才能发送ETH相关指令。"
))
story.append(Spacer(1, 2 * mm))
eth_rows = [
["网关IP", "eth ip", "192.168.1.101", "网关本地IP地址"],
["网关端口", "eth port", "10000", "网关本地端口号(1-65535"],
["服务器网关", "eth gw", "192.168.1.1", "网络网关地址"],
["服务器IP", "eth dip", "192.168.1.100", "远端服务器IP地址"],
["服务器端口", "eth dport", "8080", "远端服务器端口号(1-65535"],
]
story.append(make_table(
["参数名称", "指令前缀", "默认值", "说明"],
eth_rows, col_widths=[28 * mm, 25 * mm, 35 * mm, 62 * mm]
))
story.append(Spacer(1, 3 * mm))
story.append(heading3("ETH配置流程"))
eth_steps = [
f"点击<b>ETH(comm eth)</b>按钮切换到以太网模式;",
"在各输入框中填入目标参数值;",
"逐项点击<b>发送</b>按钮,将参数写入网关;",
f"最后点击<b>应用ETH配置(eth apply)</b>按钮,使配置生效。",
]
for i, s in enumerate(eth_steps, 1):
story.append(Paragraph(f"{i}. {s}", style_body))
story.append(Spacer(1, 2 * mm))
story.append(note(f"仅发送参数而未点击{LQ}应用ETH配置{RQ},参数不会生效。IP地址格式为 x.x.x.x,端口范围为1-65535。"))
# 3.5
story.append(heading2("3.5 Lora信道配置"))
story.append(body(
"Lora信道配置用于设置网关的Lora无线通信频率。信道列表覆盖410.1MHz至524.9MHz范围,"
"频率间隔0.2MHz,共575个信道可供选择。"
))
story.append(Spacer(1, 2 * mm))
story.append(heading3("操作步骤"))
lora_steps = [
"在Lora信道下拉框中选择目标信道(显示信道编号和对应频率);",
f"点击<b>发送</b>按钮,发送{LQ}lora fc [频率值]{RQ}指令。",
]
for i, s in enumerate(lora_steps, 1):
story.append(Paragraph(f"{i}. {s}", style_body))
story.append(Spacer(1, 2 * mm))
lora_range = [
["起始频率", "410.1 MHz(信道1"],
["终止频率", "524.9 MHz(信道575"],
["频率间隔", "0.2 MHz200 kHz"],
["指令格式", "lora fc [频率值,单位Hz]"],
]
story.append(make_table(["项目", ""], lora_range, col_widths=[35 * mm, 115 * mm]))
# 3.6
story.append(heading2("3.6 低功耗配置"))
story.append(body(
f"低功耗配置显示网关的低功耗运行参数,这些参数通过{LQ}查看配置(para){RQ}指令读取,在{LQ}低功耗配置{RQ}分组框中显示:"
))
lp_rows = [
["采集间隔(CI)", "传感器数据采集的时间间隔"],
["上报间隔(RI)", "正常数据上报的时间间隔"],
["紧急上报间隔(EI)", "紧急状态下的数据上报间隔"],
["紧急上报时长(ET)", "紧急上报模式持续时长"],
]
story.append(make_table(["参数", "说明"], lp_rows, col_widths=[40 * mm, 110 * mm]))
story.append(Spacer(1, 2 * mm))
story.append(note("低功耗参数单位可能为秒(sec)或分钟(min),以网关返回的实际值为准。"))
story.append(PageBreak())
# 3.7
story.append(heading2("3.7 自定义指令"))
story.append(body(
"自定义指令区域允许用户手动输入任意指令与网关交互,适用于高级调试和特殊配置场景。"
))
story.append(Spacer(1, 2 * mm))
custom_steps = [
"在指令输入框中输入要发送的指令文本;",
"按<b>Enter</b>键或点击<b>发送</b>按钮发送指令;",
"发送后输入框自动清空,回复数据将在通讯日志中显示。",
]
for i, s in enumerate(custom_steps, 1):
story.append(Paragraph(f"{i}. {s}", style_body))
# 3.8
story.append(heading2("3.8 通讯日志"))
story.append(body("通讯日志区域实时显示与网关的通讯数据,采用深色背景方便阅读:"))
log_rows = [
["蓝色文字", "发送的指令(&gt;&gt;&gt; 前缀)", "包含时间戳和发送的指令内容"],
["橙色文字", "接收的数据(&lt;&lt;&lt; 前缀)", "包含时间戳和网关返回的原始数据"],
]
story.append(make_table(["颜色", "类型", "说明"], log_rows, col_widths=[25 * mm, 45 * mm, 80 * mm]))
story.append(Spacer(1, 2 * mm))
story.append(note(f"点击{LQ}清空日志{RQ}按钮可清除当前日志内容。日志数据仅保留在内存中,关闭软件后自动清除。"))
story.append(PageBreak())
# ==================== 4. 指令参考 ====================
story.append(heading1("4 指令参考"))
story.append(hr())
story.append(body("以下为网关配置工具使用的全部指令汇总:"))
story.append(Spacer(1, 2 * mm))
cmd_rows = [
["para", "读取网关全部配置参数", "查看配置(para)"],
["comm cat1", "切换到Cat1通讯模式", "Cat1(comm cat1)"],
["comm eth", "切换到ETH通讯模式", "ETH(comm eth)"],
["eth ip &lt;IP&gt;", "设置网关本地IP地址", "ETH参数配置"],
["eth port &lt;端口&gt;", "设置网关本地端口号", "ETH参数配置"],
["eth gw &lt;IP&gt;", "设置网络网关地址", "ETH参数配置"],
["eth dip &lt;IP&gt;", "设置远端服务器IP", "ETH参数配置"],
["eth dport &lt;端口&gt;", "设置远端服务器端口", "ETH参数配置"],
["eth apply", "应用ETH配置参数", "应用ETH配置"],
["lora fc &lt;频率&gt;", "设置Lora信道频率(Hz)", "Lora信道配置"],
]
story.append(make_table(
["指令格式", "功能说明", "对应操作"],
cmd_rows, col_widths=[42 * mm, 58 * mm, 50 * mm]
))
story.append(Spacer(1, 3 * mm))
story.append(note("所有指令以回车换行(\\r\\n)结尾。自定义指令区域可发送以上指令及网关支持的其他指令。"))
story.append(PageBreak())
# ==================== 5. 常见问题 ====================
story.append(heading1("5 常见问题(FAQ"))
story.append(hr())
faqs = [
("Q1:打开串口失败怎么办?",
"1)检查USB转串口线是否正确连接;2)确认串口驱动已安装(设备管理器中可见COM口);"
f"3)确认该串口未被其他程序占用;4)尝试点击{LQ}刷新{RQ}重新扫描串口列表。"),
("Q2:发送指令后没有回复?",
f"1)确认串口已成功打开(按钮显示红色{LQ}关闭串口{RQ});2)检查串口线RX/TX是否接反;"
"3)确认网关设备已上电且正常运行;4)尝试重新连接串口。"),
(f"Q3ETH参数发送提示{LQ}请先切换到ETH模式{RQ}",
f"ETH参数配置必须在ETH通讯模式下进行。请先点击{LQ}ETH(comm eth){RQ}按钮切换模式,"
"再进行ETH参数的发送。"),
("Q4:修改ETH参数后未生效?",
f"修改ETH参数后,需要点击{LQ}应用ETH配置(eth apply){RQ}按钮,参数才会真正生效。"
"仅发送参数值而未应用,网关不会更新配置。"),
("Q5:如何确认当前网关的通讯方式?",
f"点击{LQ}查看配置(para){RQ}按钮,在{LQ}当前网关配置{RQ}区域查看{LQ}通讯方式{RQ}字段,"
"显示为Cat1或Eth。"),
("Q6:Lora信道列表很长,如何快速选择?",
"Lora信道下拉框支持鼠标滚轮滚动选择,也可以点击下拉后直接滚动浏览。"
"信道编号和频率一一对应,选择后点击发送即可。"),
]
for q, a in faqs:
story.append(Paragraph(f"<b>{q}</b>", style_body))
story.append(Paragraph(f"\u3000\u3000{a}", style_body))
story.append(Spacer(1, 2 * mm))
story.append(PageBreak())
# ==================== 6. 注意事项 ====================
story.append(heading1("6 注意事项"))
story.append(hr())
warnings = [
"<b>通讯模式切换:</b>在进行ETH参数配置前,务必先切换到ETH通讯模式,否则参数发送将被拒绝。",
f"<b>ETH配置应用:</b>修改ETH网络参数后,必须点击{LQ}应用ETH配置{RQ}按钮才能使参数生效。",
"<b>串口独占:</b>同一串口在同一时间只能被一个程序打开,请确保使用前关闭其他可能占用串口的软件。",
"<b>波特率固定:</b>本工具固定使用500000波特率,与网关固件匹配,请勿尝试修改。",
"<b>参数格式:</b>IP地址需符合标准格式(如192.168.1.101),端口号范围为1-65535,否则发送可能失败。",
f"<b>数据备份:</b>修改配置前建议先通过{LQ}查看配置{RQ}记录当前参数,以便需要时恢复。",
"<b>断电保护:</b>配置修改后请确认网关已保存参数,避免在参数写入过程中断电。",
"<b>驱动安装:</b>首次使用需安装USB转串口芯片驱动(如CH340、CP2102等),具体型号取决于使用的转接线。",
]
for w in warnings:
story.append(Paragraph(f"\u26a0 {w}", style_body))
story.append(Spacer(1, 1 * mm))
story.append(Spacer(1, 20 * mm))
story.append(hr())
story.append(Paragraph("\u2014 文档结束 \u2014", ParagraphStyle(
"End", fontName=FONT_NAME, fontSize=10, alignment=TA_CENTER,
textColor=HexColor("#9E9E9E")
)))
# ── 生成PDF ──────────────────────────────────────────────────
doc.build(story)
print(f"PDF已生成: {OUTPUT}")