ht32-code-mcp 0.1.1__tar.gz → 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ht32_code_mcp-0.1.2/DEPLOY.md +198 -0
- {ht32_code_mcp-0.1.1 → ht32_code_mcp-0.1.2}/PKG-INFO +1 -1
- {ht32_code_mcp-0.1.1 → ht32_code_mcp-0.1.2}/pyproject.toml +1 -1
- ht32_code_mcp-0.1.2/src/ht32_code_mcp/exporter.py +277 -0
- {ht32_code_mcp-0.1.1 → ht32_code_mcp-0.1.2}/src/ht32_code_mcp/server.py +51 -7
- {ht32_code_mcp-0.1.1 → ht32_code_mcp-0.1.2}/tests/test_tools.py +76 -0
- {ht32_code_mcp-0.1.1 → ht32_code_mcp-0.1.2}/uv.lock +1 -1
- ht32_code_mcp-0.1.1/src/ht32_code_mcp/exporter.py +0 -128
- {ht32_code_mcp-0.1.1 → ht32_code_mcp-0.1.2}/.gitignore +0 -0
- {ht32_code_mcp-0.1.1 → ht32_code_mcp-0.1.2}/.gitkeep +0 -0
- {ht32_code_mcp-0.1.1 → ht32_code_mcp-0.1.2}/README.md +0 -0
- {ht32_code_mcp-0.1.1 → ht32_code_mcp-0.1.2}/e2e_scenario_a.py +0 -0
- {ht32_code_mcp-0.1.1 → ht32_code_mcp-0.1.2}/e2e_scenario_b.py +0 -0
- {ht32_code_mcp-0.1.1 → ht32_code_mcp-0.1.2}/ht32-code-mcp-update.md +0 -0
- {ht32_code_mcp-0.1.1 → ht32_code_mcp-0.1.2}/smoke_stdio.py +0 -0
- {ht32_code_mcp-0.1.1 → ht32_code_mcp-0.1.2}/src/ht32_code_mcp/__init__.py +0 -0
- {ht32_code_mcp-0.1.1 → ht32_code_mcp-0.1.2}/src/ht32_code_mcp/__main__.py +0 -0
- {ht32_code_mcp-0.1.1 → ht32_code_mcp-0.1.2}/src/ht32_code_mcp/index.py +0 -0
- {ht32_code_mcp-0.1.1 → ht32_code_mcp-0.1.2}/src/ht32_code_mcp/knowledge.py +0 -0
- {ht32_code_mcp-0.1.1 → ht32_code_mcp-0.1.2}/tests/conftest.py +0 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# MCP Server 部署指南(ht32-code)
|
|
2
|
+
|
|
3
|
+
HT32 Code MCP Server 的部署、配置与排错。代码在 `.work/mcp-server/`,已发布到 PyPI(`ht32-code-mcp`)。
|
|
4
|
+
|
|
5
|
+
## 部署信息
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
MCP Server 地址(Streamable HTTP): https://mcp.s621.work/ht32-code
|
|
9
|
+
PyPI 包: https://pypi.org/project/ht32-code-mcp/(uvx ht32-code-mcp 即可运行)
|
|
10
|
+
Bundle 下载地址: https://mcp.s621.work/ht32-bundles/<token>.zip
|
|
11
|
+
本地 Gitea: http://100.66.1.6:3000/S621/ht32-code.git
|
|
12
|
+
MCP Server 代码: .work/mcp-server/
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## 部署链路
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
PyPI 发布包 → 1Panel 原生 MCP 管理(类型 uvx,启动命令 uvx ht32-code-mcp,输出类型 streamableHttp)
|
|
19
|
+
→ supergateway 包装 stdio 为 Streamable HTTP(stateful)
|
|
20
|
+
→ OpenResty 暴露公网
|
|
21
|
+
├─ /ht32-code/* → MCP 协议端点(反向代理到容器)
|
|
22
|
+
└─ /ht32-bundles/* → bundle zip 静态目录(P1-3)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
代码默认走 `stdio`(`MCP_TRANSPORT` 未设置时),supergateway 负责 stdio→Streamable HTTP 转换。
|
|
26
|
+
与 ht32-wiki 同构:SSE 无心跳会被 OpenResty 空闲切断,故走 streamableHttp stateful。
|
|
27
|
+
|
|
28
|
+
## 关键配置
|
|
29
|
+
|
|
30
|
+
1Panel MCP 表单:
|
|
31
|
+
|
|
32
|
+
- 类型:`uvx`
|
|
33
|
+
- 启动命令:`uvx ht32-code-mcp`(**必须带 uvx 前缀**,容器里没装裸命令)
|
|
34
|
+
- 输出类型:`streamableHttp`
|
|
35
|
+
- 流式传输路径:`/ht32-code`(切输出类型后是**新字段**,默认空,必须手填;必填校验)
|
|
36
|
+
- 协议版本:`2025-06-18`(1Panel 预填默认值,勿动;仅用于 supergateway 对下游 stdio 的 auto-init)
|
|
37
|
+
- 镜像:`supercorp/supergateway:next-uvx`(**必须覆盖默认的 `:uvx`**——见下方踩坑)
|
|
38
|
+
- 参数:`--stateful --sessionTimeout 3600000`
|
|
39
|
+
- 环境变量:`CODE_ROOT=/app/code`
|
|
40
|
+
- 挂载:`/opt/ht32-code:/app/code`(**不用加 `:ro`**——`index/code_index.json`
|
|
41
|
+
已 gitignore 且容器内可能需重建,只读会阻断;容器路径不能有前导空格,否则 `invalid mount path`)
|
|
42
|
+
|
|
43
|
+
### 镜像 tag 踩坑(2026-09-20)
|
|
44
|
+
|
|
45
|
+
默认镜像 `supercorp/supergateway:uvx` 构建时解析到旧版
|
|
46
|
+
`@modelcontextprotocol/sdk`(LATEST_PROTOCOL_VERSION=2025-06-18),新版客户端
|
|
47
|
+
(Trae 发 `2025-11-25`)initialize 直接被拒:`Bad Request: Unsupported protocol
|
|
48
|
+
version`。根因是 supergateway 3.4.3 对 SDK 声明的是浮动依赖 `^1.18.2`,镜像构建
|
|
49
|
+
日期决定行为;`next-uvx`(4.0.0-rc.4)将 SDK **精确锁定 1.30.0**,无此问题。
|
|
50
|
+
|
|
51
|
+
## 云端 zip 下载通道(P1-3,0.1.2+)
|
|
52
|
+
|
|
53
|
+
`export_project_bundle` 有两种交付形态,**由服务端环境变量决定,调用方无需判断**:
|
|
54
|
+
|
|
55
|
+
| 模式 | 触发条件 | 行为 |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| 云端 zip | `BUNDLE_DIR` 与 `BUNDLE_BASE_URL` **同时非空** | 打包到 `BUNDLE_DIR`,返回 `<BUNDLE_BASE_URL>/<token>.zip` + SHA256 + 过期时间 |
|
|
58
|
+
| 本地目录 | 二者任一为空(默认) | 物化到 `out_dir`(须为空目录/不存在) |
|
|
59
|
+
|
|
60
|
+
### 环境变量
|
|
61
|
+
|
|
62
|
+
| 变量 | 默认 | 说明 |
|
|
63
|
+
|------|------|------|
|
|
64
|
+
| `BUNDLE_DIR` | 空(= 关闭云端模式) | 容器内 bundle 输出目录,须可写 |
|
|
65
|
+
| `BUNDLE_BASE_URL` | 空(= 关闭云端模式) | 公网下载前缀,如 `https://mcp.s621.work/ht32-bundles` |
|
|
66
|
+
| `BUNDLE_TTL_SECONDS` | `3600` | zip 有效期;每次 export 前顺带清理过期 zip |
|
|
67
|
+
|
|
68
|
+
非数字的 `BUNDLE_TTL_SECONDS` 会静默回退默认值,不报错。
|
|
69
|
+
|
|
70
|
+
### 实现要点
|
|
71
|
+
|
|
72
|
+
- **token**:`secrets.token_urlsafe(24)`(192 bit 熵),文件名即 token;不可枚举、不可猜测
|
|
73
|
+
- **物化在系统临时目录**(`tempfile.mkdtemp`)而非 bundle 目录——bundle 目录里只出现
|
|
74
|
+
最终 zip,中途失败的半成品不会暴露成可下载文件
|
|
75
|
+
- **清理**:`sweep_expired()` 在每次 export 时按 mtime 删除超期 `*.zip`;
|
|
76
|
+
进程崩溃遗留的临时目录不影响交付(不在 bundle 目录内)
|
|
77
|
+
- **TTL 到点不主动删**:靠下次 export 的 sweep 或 P1-2 定时脚本清理;过期后
|
|
78
|
+
重新调用工具即可拿新 URL
|
|
79
|
+
|
|
80
|
+
### 服务器侧落地(3 步)
|
|
81
|
+
|
|
82
|
+
**第 1 步:宿主机建 bundle 目录**
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
mkdir -p /opt/ht32-bundles
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**第 2 步:1Panel 补挂载与环境变量**
|
|
89
|
+
|
|
90
|
+
| 配置项 | 值 |
|
|
91
|
+
|--------|-----|
|
|
92
|
+
| 挂载 | `/opt/ht32-bundles:/app/bundles`(**不要加 `:ro`,需可写**) |
|
|
93
|
+
| 环境变量 | `BUNDLE_DIR=/app/bundles` |
|
|
94
|
+
| 环境变量 | `BUNDLE_BASE_URL=https://mcp.s621.work/ht32-bundles` |
|
|
95
|
+
| 环境变量 | `BUNDLE_TTL_SECONDS=3600`(可选,默认即 3600) |
|
|
96
|
+
|
|
97
|
+
完整配置(含现有项):
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
类型: uvx
|
|
101
|
+
启动命令: uvx ht32-code-mcp
|
|
102
|
+
输出类型: streamableHttp
|
|
103
|
+
流式传输路径:/ht32-code
|
|
104
|
+
协议版本: 2025-06-18
|
|
105
|
+
镜像: supercorp/supergateway:next-uvx
|
|
106
|
+
参数: --stateful --sessionTimeout 3600000
|
|
107
|
+
环境变量: CODE_ROOT=/app/code
|
|
108
|
+
BUNDLE_DIR=/app/bundles
|
|
109
|
+
BUNDLE_BASE_URL=https://mcp.s621.work/ht32-bundles
|
|
110
|
+
挂载: /opt/ht32-code:/app/code
|
|
111
|
+
/opt/ht32-bundles:/app/bundles
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**第 3 步:OpenResty 加静态 location**
|
|
115
|
+
|
|
116
|
+
配置里已有 `include /www/sites/mcp.s621.work/proxy/*.conf;`(server 级),
|
|
117
|
+
在该目录下新建 `bundles.conf`:
|
|
118
|
+
|
|
119
|
+
```nginx
|
|
120
|
+
location ^~ /ht32-bundles/ {
|
|
121
|
+
alias /opt/ht32-bundles/;
|
|
122
|
+
autoindex off; # 禁止目录列举(防 token 泄露面扩大)
|
|
123
|
+
add_header Cache-Control "no-store";
|
|
124
|
+
types { application/zip zip; }
|
|
125
|
+
default_type application/zip;
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
改完 `nginx -t && nginx -s reload`。
|
|
130
|
+
|
|
131
|
+
> **注意(容器化 OpenResty)**:若 OpenResty 跑在容器里而非宿主机原生进程,
|
|
132
|
+
> `/opt/ht32-bundles` 还必须**同时挂进 OpenResty 容器**(同一宿主机路径),
|
|
133
|
+
> 否则 nginx 读到的是容器内空目录,下载必然 404。
|
|
134
|
+
> 现有 `proxy_pass http://127.0.0.1:8001/ht32-code` 能通,说明二者网络可达;
|
|
135
|
+
> 但「可达」不等于「文件系统共享」,这一条需按实际部署形态确认。
|
|
136
|
+
|
|
137
|
+
### 验证生效
|
|
138
|
+
|
|
139
|
+
1. 调 `get_code_version`,应出现:
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
云端 zip 下载:启用(TTL 3600s,https://mcp.s621.work/ht32-bundles)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
仍显示「未配置」→ 环境变量没注入成功(查变量名 / 容器是否重建)。
|
|
146
|
+
|
|
147
|
+
2. 调 `export_project_bundle(chip="HT32F52352")`,应返回:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
下载 URL:https://mcp.s621.work/ht32-bundles/<token>.zip
|
|
151
|
+
有效期:3600 秒(至 ...)
|
|
152
|
+
大小:1.53 MB(455 文件)
|
|
153
|
+
SHA256:...
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
3. 浏览器/curl 拉该 URL:应得约 1.5 MB zip;`sha256sum` 与报告一致;
|
|
157
|
+
解压后 455 文件、`project/MDK_ARMv5/ht32f5-template.uvprojx` 在,UV4 编译 0E0W。
|
|
158
|
+
|
|
159
|
+
4. 确认 `ls /opt/ht32-bundles` 只有 `*.zip`(无残留临时目录)。
|
|
160
|
+
|
|
161
|
+
## PyPI 包发布流程
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
# token 在 ht32-code 根目录 .env 的 UV_PUBLISH_TOKEN
|
|
165
|
+
uv lock && uv build && uv publish
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
发布后同步 `VERSION` 文件末行:`MCP Server: <版本>(deployed, PyPI, <日期>)`。
|
|
169
|
+
|
|
170
|
+
## 踩过的坑
|
|
171
|
+
|
|
172
|
+
### 1. 云端跑的是 PyPI 包,不是 /opt 仓库(P0-1)
|
|
173
|
+
|
|
174
|
+
- **症状**:改了仓库代码、云端 MCP 行为不变
|
|
175
|
+
- **根因**:`uvx ht32-code-mcp` 从 PyPI 拉包,`/opt/ht32-code` 只是被检索的数据源
|
|
176
|
+
- **修复**:改代码必须**发版 + 重建容器**,改仓库文件本身不生效
|
|
177
|
+
|
|
178
|
+
### 2. 索引只读挂载
|
|
179
|
+
|
|
180
|
+
- **症状**:容器启动报索引无法写入/重建失败
|
|
181
|
+
- **根因**:`/opt/ht32-code:/app/code:ro` 时 `index/code_index.json` 不可写
|
|
182
|
+
- **修复**:挂载去掉 `:ro`
|
|
183
|
+
|
|
184
|
+
### 3. 镜像 tag 导致协议版本被拒
|
|
185
|
+
|
|
186
|
+
见上方「镜像 tag 踩坑」。
|
|
187
|
+
|
|
188
|
+
## 当前部署状态
|
|
189
|
+
|
|
190
|
+
| # | 任务 | 说明 | 状态 |
|
|
191
|
+
|---|------|------|------|
|
|
192
|
+
| 3.1 | 7 个 MCP 工具实现 | `.work/mcp-server/src/ht32_code_mcp/`(FastMCP) | ✅ |
|
|
193
|
+
| 3.2 | 工具级测试 | `pytest` 36 用例全绿 | ✅ |
|
|
194
|
+
| 3.3 | PyPI 发布(hatchling) | `ht32-code-mcp` 0.1.1(2026-09-20);0.1.2 为 bundle 通道版本 | ✅ |
|
|
195
|
+
| 3.4 | 云端 git clone + 挂载 | `/opt/ht32-code:/app/code` | ✅ |
|
|
196
|
+
| 3.5 | 1Panel 部署(uvx + supergateway → streamableHttp stateful,镜像 next-uvx) | `https://mcp.s621.work/ht32-code` | ✅ |
|
|
197
|
+
| 3.6 | OpenResty 超时修复(`proxy_read_timeout 3600s`) | 修复 60s 空闲断连 | ✅ |
|
|
198
|
+
| 3.7 | 云端 zip 下载通道(P1-3) | 代码就绪并端到端验证;**服务器侧 3 步待执行** | ⏳ |
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""export_project_bundle:从模板组装自包含可编译 Keil 工程(本地目录 / 云端 zip)。
|
|
2
|
+
|
|
3
|
+
模板自带全量官方库树(library/),因此导出只需复制模板 + 按 devices.json
|
|
4
|
+
参数化 uvprojx(Device / C 宏 / 汇编宏 / system / startup 文件指向)。
|
|
5
|
+
|
|
6
|
+
两种交付形态:
|
|
7
|
+
- 本地目录模式:物化到 out_dir(本地 stdio 调试用)。
|
|
8
|
+
- 云端 zip 模式:物化到临时目录 → 打包成 <token>.zip 放进 bundle 目录 →
|
|
9
|
+
由 nginx 静态目录按随机 token + TTL 提供下载(服务器与用户电脑解耦)。
|
|
10
|
+
|
|
11
|
+
参数化依据:raw/templates/ht32f5-keil/devices.json(device/cdef/adef/sys/startup)。
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import dataclasses
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import secrets
|
|
20
|
+
import shutil
|
|
21
|
+
import tempfile
|
|
22
|
+
import time
|
|
23
|
+
import zipfile
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
# 模板中默认型号(HT32F52352)的 system / startup 文件名主干,导出时按目标型号替换
|
|
28
|
+
_TEMPLATE_SYS_STEM = "system_ht32f5xxxx_01"
|
|
29
|
+
_TEMPLATE_STARTUP_STEM = "startup_ht32f5xxxx_01"
|
|
30
|
+
|
|
31
|
+
# 云端 zip 产物默认有效期(秒)
|
|
32
|
+
DEFAULT_BUNDLE_TTL = 3600
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ExportError(Exception):
|
|
36
|
+
"""导出失败(型号未知 / 源文件缺失 / 目标目录非空等)。"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclasses.dataclass
|
|
40
|
+
class Materialized:
|
|
41
|
+
"""一次物化的结果(本地/云端共用)。"""
|
|
42
|
+
|
|
43
|
+
device: str
|
|
44
|
+
entry: dict
|
|
45
|
+
sys_c: Path
|
|
46
|
+
sys_h: Path
|
|
47
|
+
startup: Path
|
|
48
|
+
files: int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclasses.dataclass
|
|
52
|
+
class Bundle:
|
|
53
|
+
"""云端 zip 产物信息。"""
|
|
54
|
+
|
|
55
|
+
token: str
|
|
56
|
+
path: Path
|
|
57
|
+
size: int
|
|
58
|
+
sha256: str
|
|
59
|
+
expires_at: float
|
|
60
|
+
materialized: Materialized
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _read_text(path: Path) -> str:
|
|
64
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _sha256(path: Path) -> str:
|
|
68
|
+
h = hashlib.sha256()
|
|
69
|
+
with open(path, "rb") as f:
|
|
70
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
71
|
+
h.update(chunk)
|
|
72
|
+
return h.hexdigest()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _fmt_time(ts: float) -> str:
|
|
76
|
+
return datetime.fromtimestamp(ts).astimezone().strftime("%Y-%m-%d %H:%M:%S %z")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _rmtree(path: Path) -> None:
|
|
80
|
+
"""删除目录树;Windows 上句柄释放有延迟,失败重试几次后再放弃。
|
|
81
|
+
|
|
82
|
+
放弃也无害:临时目录在系统 temp 下,容器重启即清空。
|
|
83
|
+
"""
|
|
84
|
+
for attempt in range(3):
|
|
85
|
+
if not path.exists():
|
|
86
|
+
return
|
|
87
|
+
try:
|
|
88
|
+
shutil.rmtree(path)
|
|
89
|
+
return
|
|
90
|
+
except OSError:
|
|
91
|
+
if attempt == 2:
|
|
92
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
93
|
+
else:
|
|
94
|
+
time.sleep(0.2 * (attempt + 1))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def sweep_expired(bundle_dir: Path, ttl_seconds: int) -> int:
|
|
98
|
+
"""清理过期 zip 产物,返回清理条数。
|
|
99
|
+
|
|
100
|
+
惰性清理:每次打包前扫一遍即可,无需常驻定时任务。
|
|
101
|
+
"""
|
|
102
|
+
if not bundle_dir.is_dir():
|
|
103
|
+
return 0
|
|
104
|
+
now = time.time()
|
|
105
|
+
swept = 0
|
|
106
|
+
for p in bundle_dir.glob("*.zip"):
|
|
107
|
+
try:
|
|
108
|
+
if now - p.stat().st_mtime > ttl_seconds:
|
|
109
|
+
p.unlink()
|
|
110
|
+
swept += 1
|
|
111
|
+
except OSError:
|
|
112
|
+
continue # 并发/权限竞争时跳过,不影响本次打包
|
|
113
|
+
return swept
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ProjectExporter:
|
|
117
|
+
def __init__(self, code_root: Path) -> None:
|
|
118
|
+
self.root = Path(code_root)
|
|
119
|
+
self.template_dir = self.root / "raw" / "templates" / "ht32f5-keil"
|
|
120
|
+
# 模板自带全量官方库树,system/startup 在此校验
|
|
121
|
+
self.lib_device = (self.template_dir / "library" / "Device"
|
|
122
|
+
/ "Holtek" / "HT32F5xxxx" / "Source")
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------- 本地目录模式
|
|
125
|
+
def export(self, chip: str, out_dir: str) -> str:
|
|
126
|
+
out = Path(out_dir).expanduser().resolve()
|
|
127
|
+
if out.exists() and any(out.iterdir()):
|
|
128
|
+
raise ExportError(f"目标目录非空,拒绝覆盖:{out}(请指定新目录或先清空)")
|
|
129
|
+
m = self._materialize(chip, out)
|
|
130
|
+
return dir_report(out, m)
|
|
131
|
+
|
|
132
|
+
# ------------------------------------------------------- 云端 zip 模式
|
|
133
|
+
def export_zip(self, chip: str, bundle_dir: str, ttl_seconds: int) -> Bundle:
|
|
134
|
+
bdir = Path(bundle_dir).expanduser().resolve()
|
|
135
|
+
bdir.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
sweep_expired(bdir, ttl_seconds)
|
|
137
|
+
|
|
138
|
+
token = secrets.token_urlsafe(24)
|
|
139
|
+
# 物化在系统临时目录:bundle 目录只暴露最终 zip,不半成品公开
|
|
140
|
+
tmp = Path(tempfile.mkdtemp(prefix="ht32-bundle-"))
|
|
141
|
+
try:
|
|
142
|
+
m = self._materialize(chip, tmp)
|
|
143
|
+
zip_path = bdir / f"{token}.zip"
|
|
144
|
+
_zip_tree(tmp, zip_path)
|
|
145
|
+
finally:
|
|
146
|
+
_rmtree(tmp)
|
|
147
|
+
return Bundle(token=token, path=zip_path, size=zip_path.stat().st_size,
|
|
148
|
+
sha256=_sha256(zip_path),
|
|
149
|
+
expires_at=time.time() + ttl_seconds, materialized=m)
|
|
150
|
+
|
|
151
|
+
# ------------------------------------------------------------ 物化
|
|
152
|
+
def _materialize(self, chip: str, out: Path) -> Materialized:
|
|
153
|
+
if not self.template_dir.is_dir():
|
|
154
|
+
raise ExportError(f"模板目录不存在:{self.template_dir}")
|
|
155
|
+
device, entry = self._resolve_device(chip)
|
|
156
|
+
|
|
157
|
+
# 预检模板内目标型号所需源文件(先验证后落盘,失败不产生半成品)
|
|
158
|
+
sys_c = self.lib_device / entry["sys"]
|
|
159
|
+
sys_h = sys_c.parent.parent / "Include" / (sys_c.stem + ".h")
|
|
160
|
+
startup = self.lib_device / "ARM" / entry["startup"]
|
|
161
|
+
missing = [str(p) for p in (sys_c, sys_h, startup) if not p.is_file()]
|
|
162
|
+
if missing:
|
|
163
|
+
raise ExportError("模板 library/ 缺少所需源文件:\n- " + "\n- ".join(missing))
|
|
164
|
+
|
|
165
|
+
# 复制模板树(含全量官方库,导出即自包含)
|
|
166
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
shutil.copytree(self.template_dir, out, dirs_exist_ok=True)
|
|
168
|
+
|
|
169
|
+
# 参数化 uvprojx:Device / C 宏 / 汇编宏 / system / startup 指向
|
|
170
|
+
uvprojx = out / "project" / "MDK_ARMv5" / "ht32f5-template.uvprojx"
|
|
171
|
+
text = _read_text(uvprojx)
|
|
172
|
+
text = re.sub(r"(<Device>)[^<]*(</Device>)", rf"\g<1>{device}\g<2>", text, count=1)
|
|
173
|
+
text = _replace_define(text, "USE_HT32_DRIVER", entry["cdef"])
|
|
174
|
+
text = _replace_define(text, "USE_HT32_CHIP", entry["adef"])
|
|
175
|
+
text = text.replace(_TEMPLATE_SYS_STEM, sys_c.stem)
|
|
176
|
+
text = text.replace(_TEMPLATE_STARTUP_STEM, startup.stem)
|
|
177
|
+
with open(uvprojx, "w", encoding="utf-8", newline="") as f:
|
|
178
|
+
f.write(text)
|
|
179
|
+
|
|
180
|
+
files = sum(1 for p in out.rglob("*") if p.is_file())
|
|
181
|
+
return Materialized(device=device, entry=entry, sys_c=sys_c, sys_h=sys_h,
|
|
182
|
+
startup=startup, files=files)
|
|
183
|
+
|
|
184
|
+
# ------------------------------------------------------------ 辅助
|
|
185
|
+
def _resolve_device(self, chip: str) -> tuple[str, dict]:
|
|
186
|
+
dj = self.template_dir / "devices.json"
|
|
187
|
+
if not dj.is_file():
|
|
188
|
+
raise ExportError(f"模板型号参数表缺失:{dj}")
|
|
189
|
+
meta = json.loads(dj.read_text(encoding="utf-8"))
|
|
190
|
+
want = chip.strip().upper()
|
|
191
|
+
for d in meta.get("devices", []):
|
|
192
|
+
if d.get("device", "").upper() == want:
|
|
193
|
+
return d["device"], d # 重名取首条(官方板级变体)
|
|
194
|
+
# 宽松匹配:型号主体包含(HT32F52352 含于 HT32F52352_SK 场景已由上面精确处理)
|
|
195
|
+
for d in meta.get("devices", []):
|
|
196
|
+
if want in d.get("device", "").upper():
|
|
197
|
+
return d["device"], d
|
|
198
|
+
names = [d.get("device", "") for d in meta.get("devices", [])][:20]
|
|
199
|
+
raise ExportError(
|
|
200
|
+
f"型号 '{chip}' 不在模板参数表(共 {meta.get('count', '?')} 个型号)。\n"
|
|
201
|
+
f"可用型号(前 20):{', '.join(names)}…\n"
|
|
202
|
+
"完整清单:get_file('raw/templates/ht32f5-keil/devices.json') 或 get_project_manifest()"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _zip_tree(src: Path, zip_path: Path) -> None:
|
|
207
|
+
"""把 src 下整棵树打成 zip(文件相对 src 定位,故解压即得工程根结构)。"""
|
|
208
|
+
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
|
|
209
|
+
for p in sorted(src.rglob("*")):
|
|
210
|
+
if p.is_file():
|
|
211
|
+
zf.write(p, p.relative_to(src).as_posix())
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _spec_lines(m: Materialized) -> list[str]:
|
|
215
|
+
"""两种模式共用的工程参数说明行。"""
|
|
216
|
+
return [
|
|
217
|
+
f"目标芯片:{m.device}",
|
|
218
|
+
"工程文件:project/MDK_ARMv5/ht32f5-template.uvprojx(已按型号参数化)",
|
|
219
|
+
f"C 宏(Cads):{m.entry['cdef']}",
|
|
220
|
+
f"汇编宏(Aads):{m.entry['adef']}",
|
|
221
|
+
f"system:library/Device/Holtek/HT32F5xxxx/Source/{m.sys_c.name}"
|
|
222
|
+
f"(+ Include/{m.sys_h.name})",
|
|
223
|
+
f"startup:library/Device/Holtek/HT32F5xxxx/Source/ARM/{m.startup.name}",
|
|
224
|
+
"libcfg:library/HT32F5xxxx_Driver/inc/(ht32f5xxxx_lib.h 按 C 宏自动引入)",
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
_FOLLOWUP = [
|
|
229
|
+
"1. Keil MDK 打开 project/MDK_ARMv5/ht32f5-template.uvprojx"
|
|
230
|
+
"(需 Holtek HT32_DFP pack ≥1.0.67)",
|
|
231
|
+
"2. 如需精确 IROM/IRAM 内存布局,在 Options → Device 重新选择该型号由 DFP 刷新",
|
|
232
|
+
"3. 基线编译验证(UV4 命令行复核 0 Error 0 Warning)",
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def dir_report(out: Path, m: Materialized) -> str:
|
|
237
|
+
lines = [
|
|
238
|
+
f"自包含 Keil 工程已导出:{out}",
|
|
239
|
+
*_spec_lines(m),
|
|
240
|
+
f"文件总数:{m.files}",
|
|
241
|
+
"",
|
|
242
|
+
"目录结构:",
|
|
243
|
+
" component/ 共享组件层(基础模板为空,按项目增补)",
|
|
244
|
+
" library/ CMSIS / Device / Driver / USBD(全量官方库,模板自带)",
|
|
245
|
+
" utilities/ ht32_board 板级支持",
|
|
246
|
+
" project/ MDK_ARMv5(Keil 工程文件)+ Src_USER(用户文件)",
|
|
247
|
+
"",
|
|
248
|
+
"后续步骤:",
|
|
249
|
+
*_FOLLOWUP,
|
|
250
|
+
]
|
|
251
|
+
return "\n".join(lines)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def bundle_report(b: Bundle, base_url: str, ttl_seconds: int) -> str:
|
|
255
|
+
url = f"{base_url.rstrip('/')}/{b.path.name}"
|
|
256
|
+
lines = [
|
|
257
|
+
"自包含 Keil 工程已打包(云端 zip 下载):",
|
|
258
|
+
f"下载 URL:{url}",
|
|
259
|
+
f"有效期:{ttl_seconds} 秒(至 {_fmt_time(b.expires_at)})",
|
|
260
|
+
f"大小:{b.size / 1048576:.2f} MB({b.materialized.files} 文件)",
|
|
261
|
+
f"SHA256:{b.sha256}",
|
|
262
|
+
*_spec_lines(b.materialized),
|
|
263
|
+
"",
|
|
264
|
+
"下载解压:zip 内文件直接位于根(project/ library/ utilities/ component/),"
|
|
265
|
+
"解压到目标工程目录即可;过期后重新调用本工具获取新 URL。",
|
|
266
|
+
"后续步骤:",
|
|
267
|
+
*_FOLLOWUP,
|
|
268
|
+
]
|
|
269
|
+
return "\n".join(lines)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _replace_define(text: str, anchor: str, new_value: str) -> str:
|
|
273
|
+
"""替换 uvprojx 中包含 anchor 的 <Define> 行内容(Cads/Aads 各一条)。"""
|
|
274
|
+
pattern = re.compile(r"(<Define>)[^<]*" + re.escape(anchor) + r"[^<]*(</Define>)")
|
|
275
|
+
if not pattern.search(text):
|
|
276
|
+
raise ExportError(f"uvprojx 中未找到含 {anchor} 的 <Define>,模板结构可能已变化")
|
|
277
|
+
return pattern.sub(rf"\g<1>{new_value}\g<2>", text, count=1)
|
|
@@ -12,7 +12,12 @@ from pathlib import Path
|
|
|
12
12
|
|
|
13
13
|
from fastmcp import FastMCP
|
|
14
14
|
|
|
15
|
-
from .exporter import
|
|
15
|
+
from .exporter import (
|
|
16
|
+
DEFAULT_BUNDLE_TTL,
|
|
17
|
+
ExportError,
|
|
18
|
+
ProjectExporter,
|
|
19
|
+
bundle_report,
|
|
20
|
+
)
|
|
16
21
|
from .index import CodeIndex, normalize_series
|
|
17
22
|
from .knowledge import KnowledgeSearch
|
|
18
23
|
|
|
@@ -20,6 +25,22 @@ from .knowledge import KnowledgeSearch
|
|
|
20
25
|
DEFAULT_CODE_ROOT = Path(__file__).resolve().parents[4]
|
|
21
26
|
CODE_ROOT = Path(os.environ.get("CODE_ROOT", DEFAULT_CODE_ROOT))
|
|
22
27
|
|
|
28
|
+
|
|
29
|
+
def _env_int(name: str, default: int) -> int:
|
|
30
|
+
raw = os.environ.get(name, "").strip()
|
|
31
|
+
try:
|
|
32
|
+
return int(raw) if raw else default
|
|
33
|
+
except ValueError:
|
|
34
|
+
return default
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# 云端 bundle 下载通道:BUNDLE_DIR(容器内可写目录,nginx 静态目录同源)+
|
|
38
|
+
# BUNDLE_BASE_URL(公网下载前缀)同时配置才启用;否则回退本地目录导出。
|
|
39
|
+
BUNDLE_DIR = os.environ.get("BUNDLE_DIR", "").strip()
|
|
40
|
+
BUNDLE_BASE_URL = os.environ.get("BUNDLE_BASE_URL", "").strip()
|
|
41
|
+
BUNDLE_TTL = _env_int("BUNDLE_TTL_SECONDS", DEFAULT_BUNDLE_TTL)
|
|
42
|
+
CLOUD_BUNDLE = bool(BUNDLE_DIR and BUNDLE_BASE_URL)
|
|
43
|
+
|
|
23
44
|
index = CodeIndex(CODE_ROOT).load()
|
|
24
45
|
knowledge = KnowledgeSearch(CODE_ROOT)
|
|
25
46
|
exporter = ProjectExporter(CODE_ROOT)
|
|
@@ -38,7 +59,8 @@ _INSTRUCTIONS = """HT32 代码知识库(函数库/例程/工程模板),当
|
|
|
38
59
|
- 找官方例程(按外设/系列/芯片) → find_example → get_file(例程路径) 看原码
|
|
39
60
|
- 取 raw/ 真实源文件(驱动 .c/.h、例程、devices.json 等) → get_file
|
|
40
61
|
- 检索知识页(初始化套路/易错点/子系列差异) → search_code_knowledge
|
|
41
|
-
- 新建工程 → get_project_manifest 看模板 → export_project_bundle(chip, out_dir) 导出自包含工程
|
|
62
|
+
- 新建工程 → get_project_manifest 看模板 → export_project_bundle(chip[, out_dir]) 导出自包含工程
|
|
63
|
+
(云端返回 zip 下载 URL,本地返回落盘目录)
|
|
42
64
|
|
|
43
65
|
## 通用约定
|
|
44
66
|
- 路径一律为仓库相对 posix 路径(raw/... 开头),get_file 直接可用
|
|
@@ -75,6 +97,9 @@ def get_code_version() -> str:
|
|
|
75
97
|
lines = [
|
|
76
98
|
f"ht32-code 知识库版本:{index.data.get('code_version', 'unknown')}",
|
|
77
99
|
f"MCP server:ht32-code-mcp {_MCP_VERSION}",
|
|
100
|
+
("云端 zip 下载:启用(TTL "
|
|
101
|
+
f"{BUNDLE_TTL}s,{BUNDLE_BASE_URL})" if CLOUD_BUNDLE
|
|
102
|
+
else "云端 zip 下载:未配置(本地目录模式,export 需提供 out_dir)"),
|
|
78
103
|
"",
|
|
79
104
|
"VERSION 文件内容:",
|
|
80
105
|
version_text,
|
|
@@ -277,25 +302,44 @@ def get_project_manifest() -> str:
|
|
|
277
302
|
"+ utilities/(ht32_board 板级支持)+ project/(MDK_ARMv5 工程文件 + Src_USER 用户文件)"
|
|
278
303
|
)
|
|
279
304
|
lines.append("")
|
|
280
|
-
lines.append("导出工程:export_project_bundle(chip, out_dir) —— 复制模板(含全量官方库)"
|
|
305
|
+
lines.append("导出工程:export_project_bundle(chip[, out_dir]) —— 复制模板(含全量官方库)"
|
|
281
306
|
"并按型号参数化 uvprojx(Device/C 宏/汇编宏/system/startup),"
|
|
282
307
|
"产出自包含可编译工程。")
|
|
308
|
+
lines.append("交付方式:" + (
|
|
309
|
+
f"云端 zip 下载(随机 token + {BUNDLE_TTL}s TTL,返回下载 URL,out_dir 忽略)"
|
|
310
|
+
if CLOUD_BUNDLE else "本地目录导出(out_dir 必填)"))
|
|
283
311
|
return "\n".join(lines)
|
|
284
312
|
|
|
285
313
|
|
|
286
314
|
@mcp.tool()
|
|
287
|
-
def export_project_bundle(chip: str, out_dir: str) -> str:
|
|
288
|
-
"""按芯片型号从模板组装自包含可编译 Keil
|
|
315
|
+
def export_project_bundle(chip: str, out_dir: str = "") -> str:
|
|
316
|
+
"""按芯片型号从模板组装自包含可编译 Keil 工程。
|
|
289
317
|
|
|
290
318
|
动作:复制 raw/templates/ht32f5-keil 模板(模板自带全量官方库 library/)→
|
|
291
319
|
按 devices.json 参数化 uvprojx(Device、C 宏、汇编宏,以及 system/startup
|
|
292
320
|
文件指向)。产物可直接用 Keil MDK 打开编译(需 HT32_DFP pack)。
|
|
293
|
-
|
|
321
|
+
|
|
322
|
+
两种交付形态(由服务端环境决定,无需调用方判断):
|
|
323
|
+
- 云端模式:返回带 TTL 的 zip 下载 URL(含 SHA256 与大小),
|
|
324
|
+
此时 out_dir 被忽略。
|
|
325
|
+
- 本地模式:物化到 out_dir(须为空目录或不存在;不会覆盖已有内容)。
|
|
294
326
|
|
|
295
327
|
Args:
|
|
296
328
|
chip: 芯片型号,如 "HT32F52352"(大小写不敏感)
|
|
297
|
-
out_dir:
|
|
329
|
+
out_dir: 输出目录(本地模式必填;云端模式忽略)
|
|
298
330
|
"""
|
|
331
|
+
if CLOUD_BUNDLE:
|
|
332
|
+
try:
|
|
333
|
+
bundle = exporter.export_zip(chip, BUNDLE_DIR, BUNDLE_TTL)
|
|
334
|
+
except ExportError as e:
|
|
335
|
+
return f"导出失败:{e}"
|
|
336
|
+
except OSError as e:
|
|
337
|
+
return f"导出失败(打包或写盘失败,检查 bundle 目录权限/空间):{e}"
|
|
338
|
+
return bundle_report(bundle, BUNDLE_BASE_URL, BUNDLE_TTL)
|
|
339
|
+
if not out_dir.strip():
|
|
340
|
+
return ("导出失败:缺少 out_dir。当前为本地目录模式"
|
|
341
|
+
"(服务端未配置 BUNDLE_DIR / BUNDLE_BASE_URL),"
|
|
342
|
+
'请提供输出目录,如 out_dir="D:\\\\proj\\\\HT32_OTA_DEMO"。')
|
|
299
343
|
try:
|
|
300
344
|
return exporter.export(chip, out_dir)
|
|
301
345
|
except ExportError as e:
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
"""
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
+
import os
|
|
7
8
|
import re
|
|
9
|
+
import time
|
|
10
|
+
import zipfile
|
|
8
11
|
from pathlib import Path
|
|
9
12
|
|
|
10
13
|
import pytest
|
|
@@ -225,6 +228,79 @@ def test_export_refuses_nonempty_dir(tmp_path):
|
|
|
225
228
|
assert (out / "keep.txt").is_file() # 原有内容未被破坏
|
|
226
229
|
|
|
227
230
|
|
|
231
|
+
# ---------------------------------------------------------------- 云端 zip bundle
|
|
232
|
+
def test_export_zip_positive(tmp_path):
|
|
233
|
+
bdir = tmp_path / "bundles"
|
|
234
|
+
b = srv.exporter.export_zip("HT32F52352", str(bdir), 3600)
|
|
235
|
+
assert b.path.is_file() and b.path.name == f"{b.token}.zip"
|
|
236
|
+
assert b.size > 0 and len(b.sha256) == 64
|
|
237
|
+
with zipfile.ZipFile(b.path) as zf:
|
|
238
|
+
names = zf.namelist()
|
|
239
|
+
assert len(names) == b.materialized.files # zip 内条目与物化文件数一致
|
|
240
|
+
for rel in ("project/MDK_ARMv5/ht32f5-template.uvprojx",
|
|
241
|
+
"project/Src_USER/main.c", "utilities/ht32_board.c",
|
|
242
|
+
"library/HT32F5xxxx_Driver/inc/ht32f5xxxx_lib.h"):
|
|
243
|
+
assert rel in names, rel
|
|
244
|
+
# 参数化结果落在 zip 内的工程文件中
|
|
245
|
+
text = zf.read("project/MDK_ARMv5/ht32f5-template.uvprojx").decode("utf-8")
|
|
246
|
+
assert "<Device>HT32F52352</Device>" in text
|
|
247
|
+
assert "USE_HT32F52342_52" in text
|
|
248
|
+
# 临时物化目录已清理,bundle 目录只剩 zip
|
|
249
|
+
assert [p.name for p in bdir.iterdir()] == [b.path.name]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_export_zip_report_cloud_mode(tmp_path, monkeypatch):
|
|
253
|
+
monkeypatch.setattr(srv, "CLOUD_BUNDLE", True)
|
|
254
|
+
monkeypatch.setattr(srv, "BUNDLE_DIR", str(tmp_path / "bundles"))
|
|
255
|
+
monkeypatch.setattr(srv, "BUNDLE_BASE_URL", "https://mcp.s621.work/ht32-bundles")
|
|
256
|
+
monkeypatch.setattr(srv, "BUNDLE_TTL", 3600)
|
|
257
|
+
out = srv.export_project_bundle("HT32F52352") # 云端模式无需 out_dir
|
|
258
|
+
assert "云端 zip 下载" in out
|
|
259
|
+
m = re.search(r"下载 URL:(\S+)", out)
|
|
260
|
+
assert m and m.group(1).startswith("https://mcp.s621.work/ht32-bundles/")
|
|
261
|
+
assert m.group(1).endswith(".zip")
|
|
262
|
+
assert "有效期:3600 秒" in out and "SHA256:" in out
|
|
263
|
+
assert "目标芯片:HT32F52352" in out
|
|
264
|
+
# 报告中的 URL 真实可指到文件
|
|
265
|
+
assert (tmp_path / "bundles" / m.group(1).rsplit("/", 1)[-1]).is_file()
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_export_zip_unknown_chip(tmp_path):
|
|
269
|
+
with pytest.raises(srv.ExportError):
|
|
270
|
+
srv.exporter.export_zip("HT32F99999", str(tmp_path / "bundles"), 3600)
|
|
271
|
+
assert not list((tmp_path / "bundles").glob("*.zip"))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_export_zip_sweep_expired(tmp_path):
|
|
275
|
+
bdir = tmp_path / "bundles"
|
|
276
|
+
bdir.mkdir()
|
|
277
|
+
old = time.time() - 7200
|
|
278
|
+
stale_zip = bdir / "stale.zip"
|
|
279
|
+
stale_zip.write_bytes(b"old")
|
|
280
|
+
fresh_zip = bdir / "fresh.zip"
|
|
281
|
+
fresh_zip.write_bytes(b"keep")
|
|
282
|
+
os.utime(stale_zip, (old, old))
|
|
283
|
+
|
|
284
|
+
b = srv.exporter.export_zip("HT32F52352", str(bdir), 3600)
|
|
285
|
+
assert not stale_zip.exists() # 过期被清理
|
|
286
|
+
assert fresh_zip.is_file() # 未过期保留
|
|
287
|
+
assert b.path.is_file()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_export_local_mode_requires_out_dir(monkeypatch):
|
|
291
|
+
monkeypatch.setattr(srv, "CLOUD_BUNDLE", False)
|
|
292
|
+
out = srv.export_project_bundle("HT32F52352")
|
|
293
|
+
assert "导出失败" in out and "out_dir" in out
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def test_version_reports_bundle_mode(monkeypatch):
|
|
297
|
+
monkeypatch.setattr(srv, "CLOUD_BUNDLE", False)
|
|
298
|
+
assert "云端 zip 下载:未配置" in srv.get_code_version()
|
|
299
|
+
monkeypatch.setattr(srv, "CLOUD_BUNDLE", True)
|
|
300
|
+
monkeypatch.setattr(srv, "BUNDLE_BASE_URL", "https://mcp.s621.work/ht32-bundles")
|
|
301
|
+
assert "云端 zip 下载:启用" in srv.get_code_version()
|
|
302
|
+
|
|
303
|
+
|
|
228
304
|
# ---------------------------------------------------------------- 索引可重复构建
|
|
229
305
|
def test_index_rebuild_deterministic():
|
|
230
306
|
from ht32_code_mcp.index import CodeIndex
|
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
"""export_project_bundle:从模板组装自包含可编译 Keil 工程到本地目录。
|
|
2
|
-
|
|
3
|
-
模板自带全量官方库树(library/),因此导出只需复制模板 + 按 devices.json
|
|
4
|
-
参数化 uvprojx(Device / C 宏 / 汇编宏 / system / startup 文件指向)。
|
|
5
|
-
本阶段(P0-2)不做 token 下载 URL,云端阶段再扩展。
|
|
6
|
-
参数化依据:raw/templates/ht32f5-keil/devices.json(device/cdef/adef/sys/startup)。
|
|
7
|
-
"""
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import json
|
|
11
|
-
import re
|
|
12
|
-
import shutil
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
|
|
15
|
-
# 模板中默认型号(HT32F52352)的 system / startup 文件名主干,导出时按目标型号替换
|
|
16
|
-
_TEMPLATE_SYS_STEM = "system_ht32f5xxxx_01"
|
|
17
|
-
_TEMPLATE_STARTUP_STEM = "startup_ht32f5xxxx_01"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class ExportError(Exception):
|
|
21
|
-
"""导出失败(型号未知 / 源文件缺失 / 目标目录非空等)。"""
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def _read_text(path: Path) -> str:
|
|
25
|
-
return path.read_text(encoding="utf-8", errors="replace")
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class ProjectExporter:
|
|
29
|
-
def __init__(self, code_root: Path) -> None:
|
|
30
|
-
self.root = Path(code_root)
|
|
31
|
-
self.template_dir = self.root / "raw" / "templates" / "ht32f5-keil"
|
|
32
|
-
# 模板自带全量官方库树,system/startup 在此校验
|
|
33
|
-
self.lib_device = (self.template_dir / "library" / "Device"
|
|
34
|
-
/ "Holtek" / "HT32F5xxxx" / "Source")
|
|
35
|
-
|
|
36
|
-
# ------------------------------------------------------------ 主流程
|
|
37
|
-
def export(self, chip: str, out_dir: str) -> str:
|
|
38
|
-
if not self.template_dir.is_dir():
|
|
39
|
-
raise ExportError(f"模板目录不存在:{self.template_dir}")
|
|
40
|
-
device, entry = self._resolve_device(chip)
|
|
41
|
-
|
|
42
|
-
# 1. 预检目标目录
|
|
43
|
-
out = Path(out_dir).expanduser().resolve()
|
|
44
|
-
if out.exists() and any(out.iterdir()):
|
|
45
|
-
raise ExportError(f"目标目录非空,拒绝覆盖:{out}(请指定新目录或先清空)")
|
|
46
|
-
|
|
47
|
-
# 2. 预检模板内目标型号所需源文件(先验证后落盘,失败不产生半成品)
|
|
48
|
-
sys_c = self.lib_device / entry["sys"]
|
|
49
|
-
sys_h = sys_c.parent.parent / "Include" / (sys_c.stem + ".h")
|
|
50
|
-
startup = self.lib_device / "ARM" / entry["startup"]
|
|
51
|
-
missing = [str(p) for p in (sys_c, sys_h, startup) if not p.is_file()]
|
|
52
|
-
if missing:
|
|
53
|
-
raise ExportError("模板 library/ 缺少所需源文件:\n- " + "\n- ".join(missing))
|
|
54
|
-
|
|
55
|
-
# 3. 复制模板树(含全量官方库,导出即自包含)
|
|
56
|
-
out.mkdir(parents=True, exist_ok=True)
|
|
57
|
-
shutil.copytree(self.template_dir, out, dirs_exist_ok=True)
|
|
58
|
-
|
|
59
|
-
# 4. 参数化 uvprojx:Device / C 宏 / 汇编宏 / system / startup 指向
|
|
60
|
-
uvprojx = out / "project" / "MDK_ARMv5" / "ht32f5-template.uvprojx"
|
|
61
|
-
text = _read_text(uvprojx)
|
|
62
|
-
text = re.sub(r"(<Device>)[^<]*(</Device>)", rf"\g<1>{device}\g<2>", text, count=1)
|
|
63
|
-
text = _replace_define(text, "USE_HT32_DRIVER", entry["cdef"])
|
|
64
|
-
text = _replace_define(text, "USE_HT32_CHIP", entry["adef"])
|
|
65
|
-
text = text.replace(_TEMPLATE_SYS_STEM, sys_c.stem)
|
|
66
|
-
text = text.replace(_TEMPLATE_STARTUP_STEM, startup.stem)
|
|
67
|
-
with open(uvprojx, "w", encoding="utf-8", newline="") as f:
|
|
68
|
-
f.write(text)
|
|
69
|
-
|
|
70
|
-
return self._report(out, device, entry, sys_c, sys_h, startup)
|
|
71
|
-
|
|
72
|
-
# ------------------------------------------------------------ 辅助
|
|
73
|
-
def _resolve_device(self, chip: str) -> tuple[str, dict]:
|
|
74
|
-
dj = self.template_dir / "devices.json"
|
|
75
|
-
if not dj.is_file():
|
|
76
|
-
raise ExportError(f"模板型号参数表缺失:{dj}")
|
|
77
|
-
meta = json.loads(dj.read_text(encoding="utf-8"))
|
|
78
|
-
want = chip.strip().upper()
|
|
79
|
-
for d in meta.get("devices", []):
|
|
80
|
-
if d.get("device", "").upper() == want:
|
|
81
|
-
return d["device"], d # 重名取首条(官方板级变体)
|
|
82
|
-
# 宽松匹配:型号主体包含(HT32F52352 含于 HT32F52352_SK 场景已由上面精确处理)
|
|
83
|
-
for d in meta.get("devices", []):
|
|
84
|
-
if want in d.get("device", "").upper():
|
|
85
|
-
return d["device"], d
|
|
86
|
-
names = [d.get("device", "") for d in meta.get("devices", [])][:20]
|
|
87
|
-
raise ExportError(
|
|
88
|
-
f"型号 '{chip}' 不在模板参数表(共 {meta.get('count', '?')} 个型号)。\n"
|
|
89
|
-
f"可用型号(前 20):{', '.join(names)}…\n"
|
|
90
|
-
"完整清单:get_file('raw/templates/ht32f5-keil/devices.json') 或 get_project_manifest()"
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
def _report(self, out: Path, device: str, entry: dict,
|
|
94
|
-
sys_c: Path, sys_h: Path, startup: Path) -> str:
|
|
95
|
-
n_files = sum(1 for p in out.rglob("*") if p.is_file())
|
|
96
|
-
lines = [
|
|
97
|
-
f"自包含 Keil 工程已导出:{out}",
|
|
98
|
-
f"目标芯片:{device}",
|
|
99
|
-
f"工程文件:project/MDK_ARMv5/ht32f5-template.uvprojx(已按型号参数化)",
|
|
100
|
-
f"C 宏(Cads):{entry['cdef']}",
|
|
101
|
-
f"汇编宏(Aads):{entry['adef']}",
|
|
102
|
-
f"system:library/Device/Holtek/HT32F5xxxx/Source/{sys_c.name}"
|
|
103
|
-
f"(+ Include/{sys_h.name})",
|
|
104
|
-
f"startup:library/Device/Holtek/HT32F5xxxx/Source/ARM/{startup.name}",
|
|
105
|
-
f"libcfg:library/HT32F5xxxx_Driver/inc/(ht32f5xxxx_lib.h 按 C 宏自动引入)",
|
|
106
|
-
f"文件总数:{n_files}",
|
|
107
|
-
"",
|
|
108
|
-
"目录结构:",
|
|
109
|
-
" component/ 共享组件层(基础模板为空,按项目增补)",
|
|
110
|
-
" library/ CMSIS / Device / Driver / USBD(全量官方库,模板自带)",
|
|
111
|
-
" utilities/ ht32_board 板级支持",
|
|
112
|
-
" project/ MDK_ARMv5(Keil 工程文件)+ Src_USER(用户文件)",
|
|
113
|
-
"",
|
|
114
|
-
"后续步骤:",
|
|
115
|
-
"1. Keil MDK 打开 project/MDK_ARMv5/ht32f5-template.uvprojx"
|
|
116
|
-
"(需 Holtek HT32_DFP pack ≥1.0.67)",
|
|
117
|
-
"2. 如需精确 IROM/IRAM 内存布局,在 Options → Device 重新选择该型号由 DFP 刷新",
|
|
118
|
-
"3. 基线编译验证(P0-4.2 将用 UV4 命令行复核 0 Error 0 Warning)",
|
|
119
|
-
]
|
|
120
|
-
return "\n".join(lines)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def _replace_define(text: str, anchor: str, new_value: str) -> str:
|
|
124
|
-
"""替换 uvprojx 中包含 anchor 的 <Define> 行内容(Cads/Aads 各一条)。"""
|
|
125
|
-
pattern = re.compile(r"(<Define>)[^<]*" + re.escape(anchor) + r"[^<]*(</Define>)")
|
|
126
|
-
if not pattern.search(text):
|
|
127
|
-
raise ExportError(f"uvprojx 中未找到含 {anchor} 的 <Define>,模板结构可能已变化")
|
|
128
|
-
return pattern.sub(rf"\g<1>{new_value}\g<2>", text, count=1)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|