初始导入:PhoneRail 五路手机直供电源板

原理图/PCB 已在 EasyEDA 里做到 12 页跨页 0 漏接,固件刚完成骨架包 A1。
这次是把已有的一切一次性纳入版本控制,没有改动任何设计内容。

纳入:
- 设计与需求文档、PROGRESS.md、小米8 参考原理图 PDF 与追脚脚本
- .claude/ 下的 EasyEDA 桥接工具链与几何/网表缓存(eda-parts.json、
  pcb-parts.json、pcb-pads.json、p2-ch1.json 等,是重画各页的唯一事实来源)
- 117 张验证截图(本项目所有方向问题都是靠人眼看截图发现的,属于证据不是垃圾)
- firmware/ 的 A1 骨架与 site/ 文档站源码

.gitignore 排除:构建产物(firmware/build、host/build、sdkconfig)、
site/node_modules、__pycache__、桥接日志 _bridge.log、脚本自标的 *.stale。

.gitattributes 默认 `* -text`:现有 .py/.json 大多是 CRLF,原样存,
不让 git 重写行尾。唯一例外是 *.sh 强制 LF —— 带 \r 的 shell 脚本 bash 跑不了。

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QYuExfgyYa6DzRRcUso8M8
这个提交包含在:
zhou yang
2026-09-06 18:18:14 +08:00
共同撰写人 Claude Opus 5
当前提交 2bfe308b30
共修改 526 个文件,包含 80633 行新增和 0 行删除
+231
查看文件
@@ -0,0 +1,231 @@
import { defineConfig } from 'vitepress'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
/**
* 侧边栏直接读**项目根**的原稿,不读 docs/ 里的副本 —— 保证目录永远跟着正文走。
* (docs/ 里的 index.md 由 scripts/sync-docs.mjs 在 predev / prebuild 时拷进来,
* 拷贝的原因见那个脚本顶部的注释。)
*/
const DOC = path.resolve(__dirname, '../../需求文档.md')
const PLAN = path.resolve(__dirname, '../../固件开发计划.md')
/**
* 与 VitePress 内部(@mdit-vue/shared)一致的 slugify。
* 复制一份是因为 @mdit-vue/shared 打包在 vitepress 里,外部 import 不到。
* 生成的锚点必须和正文里 <h2> 的 id 完全一致,改动前先跑 `node scripts/check-anchors.mjs`。
*/
function slugify(str: string): string {
return str
.normalize('NFKD')
.replace(/[\u0300-\u036F]/g, '')
.trim()
.toLowerCase()
.replace(/[\u0000-\u001f]/g, '')
.replace(/[\s~`!@#$%^&*()\-_+=[\]{}|\\;:"'“”‘’<>,.?/]+/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/^(\d)/, '_$1')
}
const DIAGRAM_DIR = path.resolve(__dirname, '../diagrams')
/**
* 把 ```diagram=<名字> 代码块换成 diagrams/<名字>.svg 的内联 SVG。
*
* 为什么不直接把 SVG 写进 markdown:GitHub 与编辑器会把内联 HTML 吃掉或原样显示,
* 原稿就没法看了。所以**源文件里保留 ASCII 框图**(既是可读的降级,也是 alt 内容),
* 只在站点构建时替换 —— 和 ⚠️ 告警块那套是同一个思路。
*
* ⚠️ **代价:ASCII 与 SVG 是两份。** 改了框图内容两边都要改。
* 找不到对应 svg 时**退回渲染成普通代码块**,站点不会因此崩掉。
*/
function inlineDiagram(name: string): string | null {
const f = path.join(DIAGRAM_DIR, `${name}.svg`)
if (!fs.existsSync(f)) return null
const svg = fs.readFileSync(f, 'utf8').replace(/<\?xml[^>]*\?>\s*/, '')
return `<figure class="dg">${svg}</figure>`
}
/** 侧边栏标签:去掉 markdown 强调、反引号,以及破折号后面的补充说明 */
function label(text: string): string {
return text
.replace(/`/g, '')
.replace(/\*\*/g, '')
.split(/\s*——\s*/)[0]
.trim()
}
/** 从源稿里现读 h2,侧边栏永远跟正文同步 */
function chapters(file: string, base: string): { text: string; link: string }[] {
const out: { text: string; link: string }[] = []
let inFence = false
for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
if (/^\s*```/.test(line)) {
inFence = !inFence
continue
}
if (inFence) continue
const m = /^##\s+(.+?)\s*$/.exec(line)
if (!m) continue
const raw = m[1].replace(/\s*#+\s*$/, '')
out.push({ text: label(raw), link: `${base}#${slugify(raw)}` })
}
return out
}
const CH = chapters(DOC, '/')
const CH_PLAN = chapters(PLAN, '/plan')
const pick = (prefix: string) => CH.find((c) => c.text.startsWith(prefix))!
export default defineConfig({
// docs/index.md = 项目根 需求文档.md 的同步副本。需求文档本身就是首页,
// 不另做一个空洞的 landing page。
srcDir: 'docs',
lang: 'zh-CN',
title: 'PhoneRail',
titleTemplate: ':title · PhoneRail 项目文档',
description: '五路手机直供电源板 —— 项目需求文档',
cleanUrls: true,
lastUpdated: false,
metaChunk: true,
head: [
[
'link',
{
rel: 'icon',
href:
'data:image/svg+xml,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">' +
'<rect width="32" height="32" rx="6" fill="#0f766e"/>' +
'<path d="M18 4 9 18h6l-2 10 10-14h-6z" fill="#fbbf24"/>' +
'</svg>'
)
}
],
['meta', { name: 'theme-color', content: '#0f766e' }]
],
themeConfig: {
siteTitle: 'PhoneRail 需求文档',
nav: [
{ text: '需求文档', link: '/' },
{ text: '固件开发计划', link: '/plan' },
{
text: '快速定位',
items: [
{ text: pick('4.').text, link: pick('4.').link },
{ text: pick('5.').text, link: pick('5.').link },
{ text: pick('6.').text, link: pick('6.').link },
{ text: pick('7.').text, link: pick('7.').link },
{ text: pick('9.').text, link: pick('9.').link },
{ text: pick('10.').text, link: pick('10.').link },
{ text: pick('12.').text, link: pick('12.').link }
]
},
{ text: '2026-09-06', link: '/#_1-背景与目标' }
],
sidebar: [
{ text: '需求文档', items: CH, collapsed: false },
{ text: '固件开发计划', items: CH_PLAN, collapsed: true }
],
outline: { level: [2, 3], label: '本页目录' },
aside: true,
docFooter: { prev: false, next: false },
returnToTopLabel: '回到顶部',
sidebarMenuLabel: '目录',
darkModeSwitchLabel: '主题',
lightModeSwitchTitle: '切换到浅色',
darkModeSwitchTitle: '切换到深色',
search: {
provider: 'local',
options: {
translations: {
button: { buttonText: '搜索', buttonAriaLabel: '搜索文档' },
modal: {
displayDetails: '显示详情',
resetButtonTitle: '清除',
backButtonTitle: '返回',
noResultsText: '没有找到',
footer: {
selectText: '选择',
navigateText: '切换',
closeText: '关闭'
}
}
},
miniSearch: {
// 默认分词按非单词字符切,中文会整段变成一个 token,搜不到。
// 用 Intl.Segmenter 按词切一遍,再退回默认切分处理英文 / 型号。
options: {
tokenize: (text: string) => {
const seg = new Intl.Segmenter('zh-CN', { granularity: 'word' })
const out: string[] = []
for (const { segment, isWordLike } of seg.segment(text)) {
if (isWordLike) out.push(segment)
}
return out
},
processTerm: (term: string) => term.toLowerCase()
},
searchOptions: { fuzzy: 0.2, prefix: true, boost: { title: 4, text: 2 } }
}
}
},
footer: {
message:
'本文只回答「要做成什么样」;「怎么做到」在 <code>五路直供电源板设计.md</code> 里。',
copyright: 'PhoneRail · 五路手机直供电源板'
}
},
markdown: {
lineNumbers: false,
theme: { light: 'github-light', dark: 'github-dark' },
config(md) {
// ```diagram=xxx → 内联 diagrams/xxx.svg(找不到就照常渲染成代码块)
const fence = md.renderer.rules.fence!
md.renderer.rules.fence = (tokens, idx, opts, env, self) => {
const m = /^diagram=([a-z0-9-]+)$/i.exec(tokens[idx].info.trim())
if (m) {
const svg = inlineDiagram(m[1])
if (svg) return svg
console.warn(`[diagram] 找不到 diagrams/${m[1]}.svg,退回代码块`)
}
return fence(tokens, idx, opts, env, self)
}
// 正文里以 ⚠️ / ❌ 开头的引用块渲染成警告卡片,以 ✅ 开头的渲染成通过卡片。
// 用插件而不是 ::: warning,是为了让 markdown 源文件在 GitHub / 编辑器里也正常。
md.core.ruler.push('callout_blockquote', (state) => {
const t = state.tokens
for (let i = 0; i < t.length; i++) {
if (t[i].type !== 'blockquote_open') continue
let text = ''
for (let j = i + 1; j < t.length; j++) {
if (t[j].type === 'blockquote_close') break
if (t[j].type === 'inline') {
text = t[j].content
break
}
}
if (/^\s*[⚠❗❌]/.test(text)) t[i].attrJoin('class', 'callout callout-warn')
else if (/^\s*✅/.test(text)) t[i].attrJoin('class', 'callout callout-ok')
}
return true
})
}
}
})
+289
查看文件
@@ -0,0 +1,289 @@
/* PhoneRail 需求文档站 —— 主题定制
取色:品牌青(板子的阻焊)+ 警示琥珀(铜箔)。
两处非默认的排版决定:
1) 正文加宽到 1080px —— 文档里 5~6 列的需求表在 688px 下会被压成一堆折行;
2) ⚠️ / ✅ 开头的引用块渲染成告警卡片(转换在 config.mts 的 markdown 插件里)。 */
:root {
--vp-c-brand-1: #0d8074;
--vp-c-brand-2: #0f766e;
--vp-c-brand-3: #115e59;
--vp-c-brand-soft: rgba(13, 148, 136, 0.14);
--vp-layout-max-width: 1560px;
--vp-sidebar-width: 300px;
--rail-warn: #b45309;
--rail-warn-bg: rgba(245, 158, 11, 0.09);
--rail-warn-border: #f59e0b;
--rail-ok: #15803d;
--rail-ok-bg: rgba(34, 197, 94, 0.09);
--rail-ok-border: #22c55e;
--vp-font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI',
'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', 'Source Han Sans SC',
sans-serif;
}
.dark {
--vp-c-brand-1: #2dd4bf;
--vp-c-brand-2: #14b8a6;
--vp-c-brand-3: #0d9488;
--vp-c-brand-soft: rgba(45, 212, 191, 0.16);
--rail-warn: #fbbf24;
--rail-warn-bg: rgba(245, 158, 11, 0.12);
--rail-warn-border: #b45309;
--rail-ok: #4ade80;
--rail-ok-bg: rgba(34, 197, 94, 0.11);
--rail-ok-border: #15803d;
}
/* ---------- 版心 ---------- */
.VPDoc.has-aside .content-container {
max-width: 1080px;
}
.VPDoc:not(.has-sidebar) .content {
max-width: 1080px;
}
/* ---------- 表格:这份文档主要就是表 ---------- */
.vp-doc table {
display: block;
width: max-content;
min-width: 100%;
max-width: 100%;
overflow-x: auto;
font-size: 14px;
line-height: 1.6;
border-collapse: collapse;
}
.vp-doc th {
background: var(--vp-c-bg-soft);
font-size: 13px;
letter-spacing: 0.02em;
white-space: nowrap;
}
.vp-doc th,
.vp-doc td {
padding: 7px 12px;
vertical-align: top;
}
/* 需求编号(表格首列里的 **FR-1**)等宽显示,便于扫读与引用 */
.vp-doc table td:first-child > strong {
font-family: var(--vp-font-family-mono);
font-size: 0.92em;
font-weight: 600;
color: var(--vp-c-brand-1);
white-space: nowrap;
}
/* 首列是编号 / 短标签,别让它折行 */
.vp-doc table td:first-child {
white-space: nowrap;
}
/* …但纯文字的首列(术语表、风险表)允许折 */
.vp-doc table td:first-child:not(:has(> strong)) {
white-space: normal;
}
/* ---------- 告警卡片(config.mts 里给 blockquote 打的 class) ---------- */
.vp-doc blockquote.callout {
margin: 20px 0;
padding: 14px 18px;
border-radius: 8px;
border-left-width: 4px;
border-left-style: solid;
}
.vp-doc blockquote.callout > p {
margin: 0;
color: var(--vp-c-text-1);
}
.vp-doc blockquote.callout > p + p {
margin-top: 10px;
}
.vp-doc blockquote.callout-warn {
background: var(--rail-warn-bg);
border-left-color: var(--rail-warn-border);
}
.vp-doc blockquote.callout-warn strong {
color: var(--rail-warn);
}
.vp-doc blockquote.callout-ok {
background: var(--rail-ok-bg);
border-left-color: var(--rail-ok-border);
}
.vp-doc blockquote.callout-ok strong {
color: var(--rail-ok);
}
/* ---------- 矢量框图(config.mts 把 ```diagram=xxx 换成内联 SVG) ---------- */
:root {
--dg-panel-fill: #f4f7f7;
--dg-panel-line: #ccd8d5;
--dg-box-fill: #ffffff;
--dg-box-line: #9aaeaa;
--dg-line: #7d918c;
--dg-key-fill: #e3f4f1;
--dg-key-line: #0d8074;
--dg-hot-fill: #fdf3e3;
--dg-hot-line: #d99b3a;
--dg-hard-fill: #f0f5ea;
--dg-hard-line: #7fa05e;
}
.dark {
--dg-panel-fill: #1a2220;
--dg-panel-line: #33413d;
--dg-box-fill: #222d2b;
--dg-box-line: #4d5f5a;
--dg-line: #8aa19b;
--dg-key-fill: #113029;
--dg-key-line: #2dd4bf;
--dg-hot-fill: #302516;
--dg-hot-line: #b57f2e;
--dg-hard-fill: #1e2a1a;
--dg-hard-line: #6f8d52;
}
.vp-doc figure.dg {
margin: 24px 0;
padding: 0;
overflow-x: auto;
}
/* 太窄的屏幕上让图横向滚动而不是把字压成一团 */
.vp-doc figure.dg svg {
display: block;
width: 100%;
min-width: 560px;
height: auto;
}
.vp-doc figure.dg .dg-panel {
fill: var(--dg-panel-fill);
stroke: var(--dg-panel-line);
stroke-width: 1.2;
}
.vp-doc figure.dg .dg-box {
fill: var(--dg-box-fill);
stroke: var(--dg-box-line);
stroke-width: 1.2;
}
.vp-doc figure.dg .dg-key {
fill: var(--dg-key-fill);
stroke: var(--dg-key-line);
stroke-width: 1.6;
}
.vp-doc figure.dg .dg-hot {
fill: var(--dg-hot-fill);
stroke: var(--dg-hot-line);
stroke-width: 1.4;
}
.vp-doc figure.dg .dg-hard {
fill: var(--dg-hard-fill);
stroke: var(--dg-hard-line);
stroke-width: 1.4;
}
.vp-doc figure.dg .dg-wire {
stroke: var(--dg-line);
stroke-width: 1.4;
fill: none;
}
.vp-doc figure.dg .dg-dash {
stroke-dasharray: 4 3;
}
.vp-doc figure.dg .dg-bus {
stroke: var(--dg-key-line);
stroke-width: 1.4;
fill: none;
}
.vp-doc figure.dg .dg-pins path {
stroke: var(--dg-box-line);
stroke-width: 2.2;
fill: none;
}
.vp-doc figure.dg .dg-head {
fill: var(--dg-line);
stroke: none;
}
.vp-doc figure.dg text {
font-family: var(--vp-font-family-base);
fill: var(--vp-c-text-1);
}
.vp-doc figure.dg .dg-th {
font-size: 14px;
font-weight: 700;
}
.vp-doc figure.dg .dg-t {
font-size: 13px;
}
.vp-doc figure.dg .dg-s {
font-size: 11.5px;
fill: var(--vp-c-text-2);
}
.vp-doc figure.dg .dg-c {
text-anchor: middle;
}
.vp-doc figure.dg .dg-r {
text-anchor: end;
}
/* ---------- 标题与正文细节 ---------- */
.vp-doc h2 {
margin-top: 44px;
padding-top: 22px;
border-top-color: var(--vp-c-divider);
}
.vp-doc h3 {
margin-top: 30px;
}
/* 文档里用 `---` 分节,VitePress 默认 hr 太重,压淡一点 */
.vp-doc hr {
border-top: 1px solid var(--vp-c-divider);
opacity: 0.55;
margin: 36px 0;
}
/* ASCII 框图:等宽 + 不换行,横向滚动 */
.vp-doc div[class*='language-'] pre {
line-height: 1.5;
}
/* 行内代码里的网络名 / 位号,稍微收一点 */
.vp-doc :not(pre) > code {
font-size: 0.86em;
padding: 2px 5px;
}
/* ---------- 侧边栏 ---------- */
.VPSidebarItem.level-1 > .item > .text {
font-size: 13px;
color: var(--vp-c-text-2);
}
.VPSidebarItem.level-1 .VPSidebarItem.is-link > .item > .link > .text {
font-size: 14px;
}
/* ---------- 首页顶部:文档头部那张 元信息表 更像一张卡片 ---------- */
.vp-doc h1 + table {
margin-top: 18px;
}
+7
查看文件
@@ -0,0 +1,7 @@
import DefaultTheme from 'vitepress/theme'
import type { Theme } from 'vitepress'
import './custom.css'
export default {
extends: DefaultTheme
} satisfies Theme
+113
查看文件
@@ -0,0 +1,113 @@
# site —— 需求文档的网页版(VitePress)
把项目根的 **`需求文档.md`** 渲染成一个可搜索、带目录的静态站。
```bash
cd site
npm install # 首次
npm run dev # 本地预览 http://localhost:5173/
npm run build # 产出静态站到 site/.vitepress/dist/
npm run preview # 预览构建产物
```
## 内容从哪来
**唯一事实来源是 `D:\DCDC\需求文档.md`。**
`npm run dev` / `npm run build` 之前会自动跑 `scripts/sync-docs.mjs`,
把它拷成 `site/docs/index.md`。
> ⚠️ **`site/docs/` 下的文件是生成物,不要在里面改内容** —— 改了会在下次同步时被覆盖。
> 要改内容就改项目根的 `需求文档.md`。
为什么要拷而不是让 VitePress 直接指向项目根:`srcDir` 指到 `D:\DCDC` 之后,
那里的 `.md` 编译出的虚拟模块要 `import 'vue/server-renderer'`,而 `node_modules`
在 `site/` 下(是子目录不是父目录),Rollup 解析不到,构建会直接失败。
## 想再加一篇(比如设计文档 / PROGRESS)
改 `scripts/sync-docs.mjs` 里的 `PAGES`:
```js
const PAGES = [
['需求文档.md', 'index.md'],
['五路直供电源板设计.md', 'design.md'] // 新增
]
```
再在 `.vitepress/config.mts` 的 `themeConfig.nav` / `sidebar` 里加一项即可。
(设计文档有 432 KB / 6900 行,会是一个很长的单页,建议单独放一条 nav。)
## 站点结构上的两个决定
1. **需求文档就是首页**,没有另做 landing page —— 这个站只有一篇正文,
多一层入口只会多一次点击。
2. **侧边栏是从 `需求文档.md` 的 `##` 标题现算的**,不是手写的常量。
正文加一章,侧边栏自动就有,不会漏。
代价是 `config.mts` 里得自己实现一份和 VitePress 一致的 `slugify()`
(`@mdit-vue/shared` 打包在 vitepress 内部,外面 import 不到)。
两边只要差一个字符,侧边栏链接就会**静默跳空**——页面照样打开,只是不滚动,肉眼很难发现。
所以 `npm run build` 之后会自动跑 `scripts/check-anchors.mjs`,
拿构建产物里真实的标题 `id` 对一遍生成的锚点,对不上就让构建失败。
## 部署(home 服务器)
线上地址:**<http://phonerail.home.example:7755/>** —— 挂在统一门户后面,要先登录。
更新内容只要一条命令(在 Git Bash 里,从 `site/` 跑):
```bash
bash deploy/deploy.sh
```
它做三件事:`npm run build` → `tar over ssh` 同步到 `/root/phonerail-docs` → 回读验证。
| 项 | 值 |
|---|---|
| 静态文件 | `10.5.5.13:/root/phonerail-docs` |
| nginx | `/etc/nginx/conf.d/phonerail-docs.conf`(源在 `deploy/nginx-phonerail-docs.conf`) |
| 监听 | **`127.0.0.1:7757`** —— 只给门户反代用 |
| 门户注册 | 应用表 `key=phonerail` / `port=7757`,授权 admin + staff + viewer |
> ⚠️ **7757 故意只绑 loopback。** 这台机器实测是大范围 / DMZ 转发,绑 `0.0.0.0` 的端口
> 默认公网可达,而应用端口那一路是**不带门禁**的 —— 现有 12 个应用就是这个状态。
> 绑 loopback 之后,唯一入口只剩门户,代价是**内网直连 `10.5.5.13:7757` 也进不来**。
改了 `deploy/nginx-phonerail-docs.conf` 之后要单独推一次:
```bash
scp deploy/nginx-phonerail-docs.conf 10.5.5.13:/etc/nginx/conf.d/phonerail-docs.conf
ssh 10.5.5.13 'nginx -t && systemctl reload nginx'
```
改了应用注册信息(改名 / 换端口 / 增删应用)走门户 → 应用管理,然后:
```bash
ssh 10.5.5.13 'python3 /root/portal/deploy/gen-vhosts.py --write'
```
## 其它
- **搜索**:本地搜索(不联网)。默认分词器会把整段中文当成一个 token,
已在 config 里换成 `Intl.Segmenter` 分词,中文能搜出来。
- **矢量框图**:markdown 里写成
````markdown
```diagram=system
(这里仍然是 ASCII 框图)
```
````
的代码块,站点上会被替换成 `site/diagrams/<名字>.svg` 的**内联 SVG**(当前三张:
`system` / `layers` / `links`)。内联而不是 `<img>`,是为了让 SVG 用页面的 CSS 变量
跟着明暗主题走。找不到对应 svg 时**退回渲染成普通代码块**,站点不会崩。
> ⚠️ **ASCII 与 SVG 是两份,改框图内容要改两处。**
> 保留 ASCII 是有意的:它既是 GitHub / 编辑器里的可读降级,也是 SVG 的 alt 内容。
> 颜色一律用 `custom.css` 里的 `--dg-*` 变量与 `.dg-box` / `.dg-key` 这类 class,
> **SVG 文件里不要写死颜色**,否则深色主题下会瞎。
- **告警块**:正文里以 `⚠️` / `❌` 开头的引用块会渲染成橙色警告卡片,
`✅` 开头的渲染成绿色。这个转换在 `config.mts` 的 markdown 插件里做,
**不改 markdown 源文件** —— 所以原稿在 GitHub / 编辑器里看仍然正常。
- **版心加宽到 1080px**:文档里 5~6 列的需求表在默认的 688px 下会被压成一堆折行。
+39
查看文件
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# 重新构建并同步 PhoneRail 需求文档站到 home 服务器。
# 在 Git Bash 里从 site/ 目录跑: bash deploy/deploy.sh
#
# 只做「内容更新」这一件事。首次部署时另外做过的两步(已完成,不会重复):
# 1. scp deploy/nginx-phonerail-docs.conf -> /etc/nginx/conf.d/phonerail-docs.conf
# 2. 在门户应用表里加 key=phonerail / port=7757,再跑
# python3 /root/portal/deploy/gen-vhosts.py --write
# 只有改了 nginx 配置或应用注册信息时才需要重跑那两步。
set -euo pipefail
HOST="${DEPLOY_HOST:-10.5.5.13}"
REMOTE_DIR="/root/phonerail-docs"
cd "$(dirname "$0")/.."
echo "==> 构建(会先同步 ../需求文档.md 并校验锚点)"
npm run build
echo "==> 同步到 ${HOST}:${REMOTE_DIR}"
cd .vitepress/dist
tar -cf - . | ssh "$HOST" "
rm -rf ${REMOTE_DIR} &&
mkdir -p ${REMOTE_DIR} &&
tar -xf - -C ${REMOTE_DIR} &&
chmod 0711 /root &&
chmod -R o+rX ${REMOTE_DIR}
"
echo "==> 验证"
ssh "$HOST" "
curl -sS -o /dev/null -w ' loopback 7757 -> HTTP %{http_code}\n' http://127.0.0.1:7757/
curl -sS -o /dev/null -w ' 门户未登录(期望 302) -> HTTP %{http_code}\n' \
-H 'Host: phonerail.home.example' http://127.0.0.1:7755/
curl -sS http://127.0.0.1:7757/ | grep -o '<title>[^<]*</title>' | sed 's/^/ /'
"
echo "==> 完成:http://phonerail.home.example:7755/"
+52
查看文件
@@ -0,0 +1,52 @@
# PhoneRail 需求文档站(VitePress 静态产物)
# 部署到 /etc/nginx/conf.d/phonerail-docs.conf
#
# ⚠️ 只 listen 127.0.0.1 —— 正门是统一门户 http://phonerail.home.example:7755/,
# 门户的 vhost 反代到 127.0.0.1:7757,不需要这个端口对外可达。
# 这台机器实测是大范围/DMZ 转发,绑 0.0.0.0 = 公网直连即绕过登录
# (现有 12 个应用就是这个状态,见 skill §关键约定 7)。这里不重蹈。
# 代价:内网直连 10.5.5.13:7757 也进不来。要留内网兜底就再加一行
# `listen 10.5.5.13:7757;` —— 但那样公网同样可达,是个真实的取舍。
server {
listen 127.0.0.1:7757;
server_name _;
root /root/phonerail-docs;
index index.html;
charset utf-8;
# nginx.conf 里只有 gzip on 没有 gzip_types,默认只压 text/html,
# 不在这里补上的话 JS/CSS 全是裸传(见 skill §关键约定 6)
gzip_static on;
gzip_types text/plain text/css application/javascript application/json
application/xml image/svg+xml font/woff font/woff2;
gzip_min_length 1024;
gzip_vary on;
# VitePress 的 assets 文件名带内容哈希,可以永久缓存
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# 入口页绝不能被缓存:缓存住之后页面不再走服务器,
# 门户的 auth_request 就永远不会触发(skill §关键约定 8 踩过两次的坑)
location = /index.html {
add_header Cache-Control "no-cache";
}
# cleanUrls: /foo -> foo.html;只有一篇正文时也保持这个行为。
# 结尾必须是 =404 而不是 /404.html —— try_files 的最后一个参数是内部跳转,
# 会把 404 页以 HTTP 200 吐出去(实测过)。交给下面的 error_page 才有真 404。
location / {
try_files $uri $uri.html $uri/ =404;
}
error_page 404 /404.html;
server_tokens off;
access_log /var/log/nginx/phonerail-docs.access.log;
error_log /var/log/nginx/phonerail-docs.error.log warn;
}
+52
查看文件
@@ -0,0 +1,52 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 466" role="img"
aria-label="分层与职责边界:上位机做崩溃判定,ESP32 只做遥测执行记录,保护在硬实时层">
<defs>
<marker id="lg-arw" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 z" class="dg-head"/>
</marker>
</defs>
<!-- 上位机 -->
<rect x="150" y="10" width="600" height="58" rx="8" class="dg-panel"/>
<text x="450" y="34" class="dg-th dg-c">农场管理系统(既有,本项目不交付 —— N7)</text>
<text x="450" y="55" class="dg-s dg-c">adb 探活 · 业务调度 · 崩溃判定</text>
<!-- 业务面 -->
<line x1="270" y1="68" x2="270" y2="150" class="dg-wire" marker-end="url(#lg-arw)"/>
<text x="262" y="103" class="dg-s dg-r">adb over WiFi</text>
<text x="262" y="119" class="dg-s dg-r">业务面</text>
<!-- 控制面 -->
<line x1="630" y1="68" x2="630" y2="150" class="dg-wire" marker-end="url(#lg-arw)"/>
<text x="642" y="97" class="dg-s">控制面(SW-12)</text>
<text x="642" y="113" class="dg-s">WiFi 常态</text>
<text x="642" y="129" class="dg-s">USB CDC 兜底</text>
<!-- 手机 -->
<rect x="120" y="150" width="300" height="66" rx="8" class="dg-box"/>
<text x="270" y="181" class="dg-t dg-c">5 台小米8 主板</text>
<text x="270" y="200" class="dg-s dg-c">业务负载</text>
<!-- 控制器 -->
<rect x="480" y="150" width="300" height="66" rx="8" class="dg-box dg-key"/>
<text x="630" y="181" class="dg-t dg-c">PhoneRail 控制器 · ESP32-S3</text>
<text x="630" y="200" class="dg-s dg-c">遥测 · 执行 · 记录</text>
<!-- 控制器 → 手机 -->
<line x1="480" y1="183" x2="420" y2="183" class="dg-wire" marker-end="url(#lg-arw)"/>
<text x="450" y="240" class="dg-s dg-c">供电 · 侧键 · EDL · VBUS</text>
<!-- 控制器 → 硬实时层 -->
<line x1="630" y1="216" x2="630" y2="292" class="dg-wire" marker-end="url(#lg-arw)"/>
<text x="642" y="258" class="dg-s">只能建议,不能否决</text>
<!-- 硬实时层 -->
<rect x="120" y="292" width="660" height="96" rx="8" class="dg-panel dg-hard"/>
<text x="450" y="318" class="dg-th dg-c">硬实时层 —— 不含任何软件</text>
<text x="450" y="341" class="dg-t dg-c">eFuse 独立 OVP / 限流(SR-3) · 放电 MOS 与 EN 反相联锁(SR-7)</text>
<text x="450" y="362" class="dg-t dg-c">EFUSE_EN / VBUS_EN / 栅极的上下拉电阻(SR-6)</text>
<text x="450" y="381" class="dg-s dg-c">凡是「必须及时」的事都已经在这一层里,固件跑不到也烧不了手机</text>
<text x="450" y="424" class="dg-s dg-c">因此固件是「遥测 + 执行器 + 记录仪」,不是保护装置</text>
<text x="450" y="446" class="dg-s dg-c">看门狗复位是可接受的失效(复位即全关);上位机失联则什么都不做(§10.2)</text>
</svg>

之后

宽度:  |  高度:  |  大小: 2.9 KiB

+43
查看文件
@@ -0,0 +1,43 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 404" role="img"
aria-label="双链路架构:WiFi 与 USB CDC 两个传输适配器汇到单一命令处理器">
<defs>
<marker id="kg-arw" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 z" class="dg-head"/>
</marker>
</defs>
<rect x="60" y="10" width="270" height="46" rx="8" class="dg-box"/>
<text x="195" y="30" class="dg-t dg-c">WiFi(MQTT / HTTP)</text>
<text x="195" y="47" class="dg-s dg-c">常态链路</text>
<rect x="430" y="10" width="270" height="46" rx="8" class="dg-box"/>
<text x="565" y="30" class="dg-t dg-c">USB CDC(调试口 J22)</text>
<text x="565" y="47" class="dg-s dg-c">兜底链路,与日志同一条流</text>
<line x1="195" y1="56" x2="195" y2="92" class="dg-wire" marker-end="url(#kg-arw)"/>
<line x1="565" y1="56" x2="565" y2="92" class="dg-wire" marker-end="url(#kg-arw)"/>
<rect x="60" y="92" width="270" height="46" rx="8" class="dg-box"/>
<text x="195" y="112" class="dg-t dg-c">传输适配器 A</text>
<text x="195" y="129" class="dg-s dg-c">只管收发字节与分帧</text>
<rect x="430" y="92" width="270" height="46" rx="8" class="dg-box"/>
<text x="565" y="112" class="dg-t dg-c">传输适配器 B</text>
<text x="565" y="129" class="dg-s dg-c">只管收发字节与分帧</text>
<path d="M195 138 V168 H565 V138" class="dg-wire" fill="none"/>
<line x1="380" y1="168" x2="380" y2="196" class="dg-wire" marker-end="url(#kg-arw)"/>
<rect x="150" y="196" width="460" height="84" rx="8" class="dg-box dg-key"/>
<text x="380" y="222" class="dg-t dg-c">单一命令处理器</text>
<text x="380" y="244" class="dg-s dg-c">鉴权 · 限幅与越界拒绝(SR-13) · 全局序号判重</text>
<text x="380" y="264" class="dg-s dg-c">安全约束只写在这里,绝不写进适配器</text>
<line x1="380" y1="280" x2="380" y2="312" class="dg-wire" marker-end="url(#kg-arw)"/>
<rect x="150" y="312" width="460" height="46" rx="8" class="dg-box"/>
<text x="380" y="332" class="dg-t dg-c">执行器状态机 / 遥测</text>
<text x="380" y="349" class="dg-s dg-c">§10.6 · §10.3</text>
<text x="380" y="386" class="dg-s dg-c">两条链路同时在线,不做主备切换 —— 谁先送到算谁的,靠序号去重</text>
</svg>

之后

宽度:  |  高度:  |  大小: 2.4 KiB

+117
查看文件
@@ -0,0 +1,117 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 622" role="img"
aria-label="PhoneRail 系统组成:12V 适配器进下板五路功率级,经 2×20 排针接载板与五块小米8 主板">
<defs>
<marker id="dg-arw" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 z" class="dg-head"/>
</marker>
</defs>
<!-- ── 适配器 ── -->
<rect x="380" y="10" width="200" height="36" rx="18" class="dg-box"/>
<text x="480" y="33" class="dg-t dg-c">DC 12 V / 10 A 适配器</text>
<line x1="480" y1="46" x2="480" y2="70" class="dg-wire" marker-end="url(#dg-arw)"/>
<!-- ── 下板 ── -->
<rect x="40" y="72" width="880" height="230" rx="10" class="dg-panel"/>
<text x="56" y="95" class="dg-th">下板(电源板 + 控制大脑) 160 × 125 mm</text>
<!-- 功率链 -->
<rect x="56" y="106" width="156" height="46" rx="5" class="dg-box"/>
<text x="134" y="125" class="dg-t dg-c">输入保护</text>
<text x="134" y="141" class="dg-s dg-c">F1 · TVS · 反接 P-MOS</text>
<rect x="229" y="106" width="156" height="46" rx="5" class="dg-box"/>
<text x="307" y="125" class="dg-t dg-c">五路独立 Buck</text>
<text x="307" y="141" class="dg-s dg-c">480 kHz · 4 A / 路</text>
<rect x="402" y="106" width="156" height="46" rx="5" class="dg-box dg-hot"/>
<text x="480" y="125" class="dg-t dg-c">eFuse ×5</text>
<text x="480" y="141" class="dg-s dg-c">OVP 4.36 V · 限流 4.45 A</text>
<rect x="575" y="106" width="156" height="46" rx="5" class="dg-box"/>
<text x="653" y="125" class="dg-t dg-c">分流 5 mΩ + INA226</text>
<text x="653" y="141" class="dg-s dg-c">每路 V + I</text>
<rect x="748" y="106" width="156" height="46" rx="5" class="dg-box"/>
<text x="826" y="125" class="dg-t dg-c">输出 ×5</text>
<text x="826" y="141" class="dg-s dg-c">P+ / P− 到排针</text>
<line x1="212" y1="129" x2="229" y2="129" class="dg-wire" marker-end="url(#dg-arw)"/>
<line x1="385" y1="129" x2="402" y2="129" class="dg-wire" marker-end="url(#dg-arw)"/>
<line x1="558" y1="129" x2="575" y2="129" class="dg-wire" marker-end="url(#dg-arw)"/>
<line x1="731" y1="129" x2="748" y2="129" class="dg-wire" marker-end="url(#dg-arw)"/>
<!-- 控制 -->
<rect x="56" y="166" width="156" height="48" rx="5" class="dg-box dg-key"/>
<text x="134" y="186" class="dg-t dg-c">ESP32-S3-WROOM-1</text>
<text x="134" y="202" class="dg-s dg-c">19 根信号</text>
<rect x="229" y="166" width="156" height="48" rx="5" class="dg-box"/>
<text x="307" y="186" class="dg-t dg-c">DAC63204 ×2</text>
<text x="307" y="202" class="dg-s dg-c">0x4A / 0x4B</text>
<rect x="402" y="166" width="156" height="48" rx="5" class="dg-box"/>
<text x="480" y="186" class="dg-t dg-c">74HC595 ×3</text>
<text x="480" y="202" class="dg-s dg-c">24 位移位链</text>
<rect x="575" y="166" width="156" height="48" rx="5" class="dg-box"/>
<text x="653" y="186" class="dg-t dg-c">PCA9555</text>
<text x="653" y="202" class="dg-s dg-c">0x20 · FLT / PG</text>
<rect x="748" y="166" width="156" height="48" rx="5" class="dg-box"/>
<text x="826" y="186" class="dg-t dg-c">INA226 #6</text>
<text x="826" y="202" class="dg-s dg-c">输入侧 0x43</text>
<!-- I²C 干线 -->
<line x1="56" y1="228" x2="904" y2="228" class="dg-bus"/>
<line x1="134" y1="214" x2="134" y2="228" class="dg-bus"/>
<line x1="307" y1="214" x2="307" y2="228" class="dg-bus"/>
<line x1="653" y1="214" x2="653" y2="228" class="dg-bus"/>
<line x1="826" y1="214" x2="826" y2="228" class="dg-bus"/>
<line x1="653" y1="152" x2="653" y2="228" class="dg-bus"/>
<text x="480" y="243" class="dg-s dg-c">I²C 单条总线 400 kHz —— 10 个器件(含载板 LM75BD)</text>
<rect x="56" y="252" width="848" height="40" rx="5" class="dg-box"/>
<text x="480" y="277" class="dg-t dg-c">侧键驱动管 ×15 · 风扇驱动(低边 PWM + 续流管) · 辅助 12 V→3.3 V / 5 V</text>
<!-- ── 板间 ── -->
<g class="dg-pins">
<path d="M180 302 h120 M180 306 h120 M180 310 h120"/>
<path d="M660 302 h120 M660 306 h120 M660 310 h120"/>
<path d="M180 362 h120 M180 366 h120 M180 370 h120"/>
<path d="M660 362 h120 M660 366 h120 M660 370 h120"/>
</g>
<line x1="240" y1="312" x2="240" y2="360" class="dg-wire dg-dash"/>
<line x1="720" y1="312" x2="720" y2="360" class="dg-wire dg-dash"/>
<text x="480" y="330" class="dg-t dg-c">2×20 排针 ×2(共 80 针)—— 只走电源与低速控制</text>
<text x="480" y="349" class="dg-s dg-c">一根高速差分都不穿连接器 · 13 mm 缝=装配空间 + 侧面进风的天然风道</text>
<!-- ── 上板 ── -->
<rect x="40" y="374" width="880" height="234" rx="10" class="dg-panel"/>
<text x="56" y="397" class="dg-th">上板(载板) 220 × 135 mm</text>
<rect x="56" y="408" width="156" height="52" rx="5" class="dg-box"/>
<text x="134" y="439" class="dg-t dg-c">小米8 主板 ①</text>
<rect x="229" y="408" width="156" height="52" rx="5" class="dg-box"/>
<text x="307" y="439" class="dg-t dg-c">小米8 主板 ②</text>
<rect x="402" y="408" width="156" height="52" rx="5" class="dg-box"/>
<text x="480" y="439" class="dg-t dg-c">小米8 主板 ③</text>
<rect x="575" y="408" width="156" height="52" rx="5" class="dg-box"/>
<text x="653" y="439" class="dg-t dg-c">小米8 主板 ④</text>
<rect x="748" y="408" width="156" height="52" rx="5" class="dg-box"/>
<text x="826" y="439" class="dg-t dg-c">小米8 主板 ⑤</text>
<rect x="56" y="474" width="414" height="44" rx="5" class="dg-box"/>
<text x="263" y="493" class="dg-t dg-c">USB2517I 7 口 Hub · 上游 USB-C(UFP)</text>
<text x="263" y="509" class="dg-s dg-c">VBUS 负载开关 ×5,默认断开</text>
<rect x="490" y="474" width="414" height="44" rx="5" class="dg-box"/>
<text x="697" y="493" class="dg-t dg-c">侧键顶针 ×15 · EDL 模拟开关 ×5</text>
<text x="697" y="509" class="dg-s dg-c">EDL_EN 的下拉在载板侧,不在下板</text>
<rect x="56" y="532" width="848" height="44" rx="5" class="dg-box dg-hot"/>
<text x="480" y="551" class="dg-t dg-c">5 × (P+ / P− 焊盘 + 2200 µF 铝电解 + MLCC 组)</text>
<text x="480" y="567" class="dg-s dg-c">电容组必须在排线端;每路独立回流地</text>
<text x="480" y="595" class="dg-s dg-c">定位孔 NPTH Φ2.5 + 尼龙立柱 —— 五台手机的地绝不在载板上并成一根(SR-12)</text>
</svg>

之后

宽度:  |  高度:  |  大小: 6.4 KiB

+727
查看文件
@@ -0,0 +1,727 @@
# 五路手机直供电源板 —— 项目需求文档
| 项 | 值 |
|---|---|
| 文档版本 | **v1.2**(2026-09-06)—— 上位机链路定稿为**方案 C(WiFi 常态 + USB CDC 兜底)**,回填 §10.8,新增 D5a / SW-12 / IF-19 与两条验收<br>v1.1 —— 新增 **§10 软件架构设计**,原 §10~§14 顺延为 §11~§15 |
| 项目代号 | PhoneRail |
| 对应设计文档 | `五路直供电源板设计.md` v1.6 及其后续增补(§22 起的两板架构) |
| 状态 | 需求已冻结;硬件处于 PCB 布线阶段(见 `PROGRESS.md`) |
> **本文档与设计文档的分工**
> 本文只回答 **「要做成什么样、凭什么算做到了」**;
> **「怎么做到」** 一律在 `五路直供电源板设计.md` 里,本文只给条目号交叉引用。
> 两者冲突时:**电气数值以设计文档为准**(它是逐项算过并对过 datasheet 的),
> **需求的取舍与验收标准以本文为准**。
> 设计文档 §0.1 的 13 条决策已定稿,本文把它们表述成需求,不重新讨论。
> **唯一的例外是 §10 软件架构设计** —— 那是「怎么做」,但固件在设计文档里没有落脚点,
> 暂放本文,理由写在该节开头。
---
## 1. 背景与目标
### 1.1 要解决的问题
手机农场里成排运行的小米8(dipper)主板,用原装电池供电有三个绕不开的问题:
1. **电池会老化、会鼓包**,几十台规模下变成持续的维护负担和安全隐患;
2. **手机卡死时没法远程恢复** —— 人必须跑到机架前拔电池 / 长按电源键;
3. **充电管理系统一直在工作**,长期 100% 电量反而触发系统的异常逻辑。
### 1.2 项目目标
做一块**电池位直供**的电源板:拆掉电芯与保护板,从主板电池座直接灌一个受控的、
稳定在正常锂电范围内的电压;同时把「断电重启、按键模拟、进 Fastboot / 9008、
USB 通断」这些原本要人手做的动作全部电气化,交给板上 MCU 远程执行。
**一句话验收目标**:五台小米8 主板挂在这套板子上连续跑 24 小时无异常重启,
任何一台卡死时都能在**不到机架前**的情况下恢复到正常运行状态。
### 1.3 目标用户与使用场景
| 用户 | 场景 |
|---|---|
| 农场运维 | 日常挂机;某台无响应时下发一次重启;查某台的电流曲线定位接触不良 |
| 刷机 / 开发 | 远程进 Fastboot 或 9008(EDL) 刷包;有线 adb 抓日志 |
| 本项目自身 | 首板调试、长稳测试、失效注入验证 |
---
## 2. 范围
### 2.1 交付范围(In Scope)
| # | 交付物 |
|---|---|
| D1 | **下板(电源板)**:12V 输入 → 五路 4 A 直供 + eFuse + 遥测 + ESP32-S3 控制 |
| D2 | **上板(载板)**:五块主板的机械承载 + 板载 USB Hub + VBUS 开关 + 侧键顶针 + EDL 口 |
| D3 | 两板对插的板对板接口(两条 2×20 排针,共 80 针) |
| D4 | 手机侧线束与离板电容组(电池排线改造、尾插 USB 线、EDL 引线) |
| D5 | ESP32-S3 固件(遥测、两级重启、按键 / EDL 模拟、VBUS 通断、风扇闭环、告警) |
| D5a | **双链路命令接口**(2026-09-06 用户拍板):**WiFi 为常态、USB CDC 为兜底**,两条链路共用同一套命令语义。上位机侧的调用方由农场既有系统实现(N7),本项目交付板侧协议与实现,见 §10.8 |
| D6 | 完整原理图(11 页)+ 两块 PCB + BOM + 首板调试与验收记录 |
### 2.2 明确不做(Out of Scope)
| # | 不做 | 理由 |
|---|---|---|
| N1 | 电池充放电管理、模拟真实电池阻抗 | 直供不需要,电芯已移除 |
| N2 | 支持 1S 以外的机型 / 非小米8 主板 | 全部机械与引脚定义都是 dipper 专用(§13) |
| N3 | 自制 USB-C 尾插转接板 | 决策 10:装原厂尾插小板,口温 NTC / CC / ESD / 天线馈点全在上面 |
| N4 | USB PD / BC1.2 协商,接快充充电器 | 决策 11:上游只接电脑 / Hub,VBUS 恒 5 V |
| N5 | 把过流保护做进 MCU 实时环路 | 那是 eFuse 的职责(SR-3) |
| N6 | 五路 Buck 错相同步 | §8.4 已否决:器件离散度远大于想制造的相位差 |
| N7 | 上位机管理软件 / Web 界面 | 本期只交付固件与命令接口,上层由农场既有系统对接 |
---
## 3. 系统组成(需求视角)
```diagram=system
12V/10A 适配器
│
┌────┴─────────────── 下板(电源 + 大脑)────────────────────┐
│ 输入保护 → 五路独立 Buck → eFuse → 分流检测 → 输出 │
│ ESP32-S3 + DAC ×2 + INA226 ×6 + 74HC595 ×3 + PCA9555 │
│ 侧键驱动管 ×15 · 风扇驱动 · 辅助 3V3 / 5V │
└────┬──────────── 2×20 排针 ×2(无高速信号)─────────────────┘
┌────┴─────────────── 上板(载板)────────────────────────────┐
│ 5 × 小米8 主板(NPTH 定位孔 + 尼龙立柱) │
│ USB2517I 7 口 Hub + 上游 USB-C + VBUS 负载开关 ×5 │
│ 侧键顶针 ×15 · EDL 模拟开关 ×5 · P+/P− 焊盘 + 电容组 ×5 │
└──────────────────────────────────────────────────────────────┘
```
两板之间保留 **13 mm 缝**,既是装配空间也是散热风道。
---
## 4. 功能需求(FR)
| ID | 需求 | 优先级 | 设计出处 |
|---|---|---|---|
| **FR-1** | 同时为 **5 台**小米8 主板供电,各路**电气独立**(独立回流地,不在手机端共地) | 必须 | §1 / §6.4 |
| **FR-2** | 每路输出电压可**独立微调**,调节在正常锂电范围内进行,步进优于 1 mV | 必须 | §4 |
| **FR-3** | 每路可**独立硬断电并主动放电**,实现真正意义上的「拔电池」 | 必须 | §7 |
| **FR-4** | 每台手机可模拟 **3 个侧键**(PWR / VOL+ / VOL−),支持组合键 | 必须 | §13.3 |
| **FR-5** | 每台手机可远程进入 **EDL(9008)** 模式 | 必须 | §22.4 |
| **FR-6** | 每台手机的 **USB VBUS 可独立通断**,默认断开 | 必须 | §15.1 |
| **FR-7** | 对外只用**一根 USB 线**,五路下行由板载 Hub 分 | 必须 | §15.1 |
| **FR-8** | 每路可实时读取**输出电压与电流**;整板可读取**输入电压与电流** | 必须 | §8.2 |
| **FR-9** | 上报每路 eFuse 故障、电源好(PG)等状态 | 必须 | §8.2 |
| **FR-10** | 主动风冷,风扇转速可调、可检测「有风扇停转」 | 必须 | §22.6 |
| **FR-11** | 记录过流 / 故障事件(哪一路、何时、多大电流) | 高 | §8.3 |
| **FR-12** | 模拟电量曲线,避免系统因「电量永远 100%」触发异常逻辑 | 中(可选) | §8.3 |
| **FR-13** | 板级温度监控,超阈值先降流再关路 | 高 | §8.3 |
| **FR-14** | 整板功率预算,超过适配器能力时按优先级降载 | 中 | §8.3 |
| **FR-15** | 调试口不依赖外部转串口芯片,插上 USB-C 即可下载与看日志 | 必须 | §8.1 |
### 4.1 崩溃恢复流程(FR-3 / FR-4 的组合,本项目的核心价值)
固件必须实现**两级重启**,且**先软后硬**:
| 级 | 动作 | 触发条件 | 要求 |
|---|---|---|---|
| 一级(软) | 长按电源键 ≈10 s | `adb` 探活连续失败 N 次,或电流曲线卡死 | 优先使用,对闪存和文件系统友好 |
| 二级(硬) | `EFUSE_EN` ↓ → 放电 → 等 3~5 s → `EFUSE_EN` ↑ | 一级无效 | 输出必须真的掉到 <0.5 V,不能靠自然放电 |
> **为什么二级必须有主动放电**:手机侧 2200 µF 配 ≈50 µA 关机待机电流,自然放电到安全
> 电压要 **154 秒**,「断电 10 秒」根本不触发重启。见 §7.1。
---
## 5. 电气与性能需求(PR)
| ID | 参数 | 要求 | 出处 |
|---|---|---|---|
| **PR-1** | 输入电压 | 12 V ±10%,成品适配器 | §1 |
| **PR-2** | 输入电流 | ≤ 8 A(含风扇 ≈8.3 A),建议配 12 V / 10 A(120 W) | §1 / §22.6 |
| **PR-3** | 输出路数 | 5,彼此独立不共地 | §1 |
| **PR-4** | 输出电压范围 | **3.52 ~ 4.01 V** 连续可调 | §4.3 |
| **PR-5** | **硬件电压上限** | 标称 **4.01 V**,最坏容差 **4.12 V**,**软件无法越过** | §4.4 |
| **PR-6** | 电压设定分辨率 | 12 位 DAC 微调,步进 ≈0.12 mV | §4.5 |
| **PR-7** | 输出电流 | 4 A / 路(峰值);手机常态 0.3 ~ 1 A | §1 |
| **PR-8** | 输出纹波 | < 20 mVpp(板端测试点) | §1 |
| **PR-9** | 开关频率 | 480 kHz,五路统一,不错相 | §1 / §8.4 |
| **PR-10** | 整板功率 | 峰值 80 W(不会发生);常态 10 ~ 25 W | §1 |
| **PR-11** | 电流测量分辨率 | ≥ 0.5 mA(5 mΩ 分流 + Kelvin 四线) | §8.2 |
| **PR-12** | 负载阶跃响应 | 0→4 A 阶跃(带 20 cm 线 + 电容组)压降 < 300 mV,**无 6 kHz 振铃** | §6.2 |
| **PR-13** | 满载温升 | 4 A 连续 30 min,电感 / IC 温升 < 40 K | §11 |
| **PR-14** | 断电放电时间 | `EFUSE_EN` ↓ 后 **200 ms 内**输出 < 0.5 V | §7.2 |
| **PR-15** | 上电斜坡 | 缓启动 0.36 V/ms,涌流 ≤ 0.8 A;`EN`↑ 到稳定 ≈17 ms | §5 |
### 5.1 输出上限为什么定在 4.01 V(这条需求的来源)
去掉原厂保护板后,输出上限**由 1S 锂电端子的绝对上限 4.45 V 反推**,而不是由保护板决定。
4.01 V 这个数字不是「够用就行」,是为了**给 eFuse 的过压跳闸窗口让开 86 mV** ——
若按早期的 4.22 V,第一层的最坏输出(4.34 V)会和 eFuse 的最坏跳闸下沿(4.206 V)重叠,
坏角落下 DAC 失效会**误跳保护**。详见 §5 的容差窗口分析。
**因此 PR-4 / PR-5 是安全需求的一部分,不得为了「多给一点电压」而放宽。**
---
## 6. 安全需求(SR)—— 本项目最高优先级
> **必须记录的失效模式**:直供电最致命的失效**不是固件写错电压值**,而是
> **反馈分压的下电阻开路 → FB = 0 V → Buck 满占空比 → 输出直接跳到 12 V**。
> **软件对此完全无能为力。** 全部安全需求都要围绕这一条来读。
| ID | 需求 | 出处 |
|---|---|---|
| **SR-1** | **三层保护彼此独立**:任何一层单独失效都不得导致手机损坏 | §5 |
| **SR-2** | **第一层 · 拓扑限幅**:分压网络与 DAC 量程在物理上把输出限死在 3.42 ~ 4.12 V(含最坏容差),全部落在正常锂电电压内。防固件 bug / I²C 误码 / MCU 跑飞 | §4 |
| **SR-3** | **第二层 · eFuse 独立过压断开**(每路一颗,**最后一道硬件防线**):OVP 4.36 V 标称、**断开而非钳位**、响应 2 µs;限流 4.45 A 恒流、不依赖 MCU | §5 |
| **SR-4** | eFuse 的过压响应**必须来得及**:按最坏值(Buck 高侧限流 14 A、负载 2200 µF)核算,余量 ≥ 10 倍 | §5 |
| **SR-5** | **第三层 · 软件监控**:电压偏离设定 >±100 mV 且持续 20 ms → 拉低该路 EN 并上报。**仅承担记录与告警,不承担实时保护** | §5 |
| **SR-6** | **复位安全**:ESP32 复位 / 上电期间,五路输出全关、五路 VBUS 全断、15 路按键全不按、5 路 EDL 全不触发、风扇不转。靠**上下拉电阻**保证,不靠固件 | §8.1 / §22.4 |
| **SR-7** | 放电 MOS 与 eFuse EN **硬件反相联锁**,物理上不可能同时导通 | §7.3 |
| **SR-8** | **输入保护**:保险丝 + TVS + P-MOS 反接保护 | §2 |
| **SR-9** | **12 V 母线泄放**:母线 >13.2 V 时导通泄放,防 USB 反灌或突卸能量抬高母线 | §15.2 |
| **SR-10** | **USB 反灌三条对策全做,一条都不能省**:① 软件关充电 ② VBUS 负载开关 ③ 母线泄放 | 决策 12 / §13.5 |
| **SR-11** | 上游 USB 不做 PD 协商(CC 双 5.1 kΩ 下拉,纯 UFP),杜绝 VBUS 被抬到 9 / 12 V | §15.1 |
| **SR-12** | 五台手机的地**不得**在载板上并成一根:定位孔做 **NPTH**,立柱用**尼龙** | §25.2 |
| **SR-13** | 固件**不得**提供任何能让输出超过 4.2 V 的接口 | §8.3 |
| **SR-14** | 任一路输出**不得**给 ESP32 供电(用独立的 12 V→3.3 V 小 Buck) | §8.3 |
| **SR-15** | 电源**必须**接原厂排线的 `P+`/`P−`(保护板输出侧),**不是** `B+`/`B−` | 决策 6 / §13.5 |
> ⚠️ **SR-15 是最容易在装配环节做错、且常温下完全测不出来的一条。**
> 焊在 `B+`/`B−` 时一切表现正常,只有过压时才暴露 —— 那时保护板仍串在通路里。
> 判别与验证步骤见 §13.5 / §13.7,已列为打样前阻塞项(V-1)。
> ⚠️ **`J600` 的 `GND3`/`GND4` 固定脚实际接的是 VBATT,`J1401` 的 `GND2` 是 USB_VBUS。**
> 按名字当地用会直接短路电池。
---
## 7. 接口需求(IF)
> **编号只增不改**:新增接口一律取下一个空号,位置按逻辑归类 ——
> 所以表内序号不一定连续(如 IF-19 排在 IF-14 后面)。ID 是稳定的引用标识,
> 不是目录序号;改动已有编号会让所有交叉引用与验收记录失效。
### 7.1 手机侧接口(每台一套)
| ID | 接口 | 要求 | 出处 |
|---|---|---|---|
| **IF-1** | 供电 | 原装 BM3E 电池排线的 `P+`/`P−` 焊点;保留 BTB 公座 / NTC / ID,**旁路保护板**;电芯移除 | §13.2 / §13.5 |
| **IF-2** | 侧键 | J601(VOL+) / J602(VOL−) / J603(PWR) / J604(公共地),**对地短接**方式,绝不从外部灌电压 | §13.3 |
| **IF-3** | EDL | 短接主板上的 TP1204 ↔ TP1205(1.8 V / 1 mA),用**模拟开关**,不用普通 N-MOS | §22.4 |
| **IF-4** | USB | 装**原厂尾插小板**,本板只接 VBUS / D± / GND 四根 | 决策 10 / §15.1 |
| **IF-5** | 离板电容组 | 每路 2200 µF **普通铝电解**(靠 ESR 阻尼)+ 100 µF ×2 + 22 µF ×2 + 100 nF,**必须装在排线端,不在电源板上** | §6.3 |
| **IF-6** | 线缆 | ≤ 20 cm、18 AWG 或更粗、正负双绞或紧贴平行;板端用 Micro-Fit 3.0 / XT30,**不用杜邦线** | §6.4 |
> ⚠️ **IF-5 的 2200 µF 千万不能买成聚合物电容。** 整个阻尼设计靠的就是普通铝电解那
> 几十 mΩ 的 ESR;换成 12 mΩ 的聚合物,Q 从 0.19 跳回 4.5,线缆 LC 会在 6.2 kHz 上振铃。
> ⚠️ **IF-5 的大电容必须在排线端。** 放在电源板上等于隔着 300 nH 的线缆电感,基本无效。
### 7.2 板对板接口
| ID | 需求 | 出处 |
|---|---|---|
| **IF-7** | 两条 2×20 排针(共 80 针),分列板子两端 | §22.2 / §25.10 |
| **IF-8** | **一根高速差分都不穿连接器** —— Hub、上游 USB-C、VBUS 开关全部上移到载板 | §22.1 |
| **IF-9** | 排针只走电源与低速控制;每路 `P+`/`P−` 独立分配针位,保证独立回流(FR-1) | §22.2 |
| **IF-10** | 两板间净空 13 mm;载板底面元件与下板高元件的高度预算必须逐点核过 | §26.2 |
### 7.3 对外接口
| ID | 接口 | 要求 |
|---|---|---|
| **IF-11** | 电源输入 | DC 12 V 插座(XT30 级),配 10 A 适配器 |
| **IF-12** | 上游 USB | 载板 USB-C(UFP),一根线接电脑 |
| **IF-13** | 调试 USB | 下板 USB-C,ESP32-S3 原生 USB-Serial-JTAG;**VBUS 故意不接**(物理上杜绝反灌 3V3)。**同一个口兼作 SW-12 的兜底命令链路**(原生 USB-Serial-JTAG 本身就是 CDC-ACM,不影响 esptool 下载),分帧要求见 §10.8 |
| **IF-14** | 风扇 | 5 × KF2510-3A(+12 V / 回流 / TACH) |
| **IF-19** | 控制网络 | **WiFi(ESP32-S3 板载,无需增加器件)**,SW-12 的常态命令链路。凭据与服务端地址存 NVS,**不编译进固件** —— 新板上架时先用 IF-13 的 USB 链路配网 |
> ⚠️ **IF-12 与 IF-13 是两个长得一样的 USB-C,插错的现象是「adb 整排不见」**,
> 很难联想到是插错口。**丝印必须写清楚**,建议用不同颜色座子或在外壳上做标记。
### 7.4 内部数字接口
| ID | 需求 |
|---|---|
| **IF-15** | I²C 单条总线挂 10 个器件:INA226 ×6(0x40/41/44/45/42/43)、DAC63204 ×2(0x4A / 0x4B)、PCA9555(0x20)、LM75BD(0x48),地址互不冲突 |
| **IF-16** | ⚠️ **INA226 的通道号 ≠ 地址低位**(为布线让路重排过),固件必须**查表**,不能算 |
| **IF-17** | 74HC595 ×3 级联成 **24 位**移位链:15 路侧键 + `HUB_RST` + 5 路 `EDL_EN`;**先移入的字节落在 U33 上** |
| **IF-18** | `EFUSE_EN` ×5 与 `VBUS_EN` ×5 **必须 GPIO 直连**,不得经移位寄存器 —— 安全关键,故障瞬间要能立刻关断 |
---
## 8. 机械与热需求(MR / TR)
| ID | 需求 | 出处 |
|---|---|---|
| **MR-1** | 载板 220 × 135 mm,四角 R5;五块主板 **3+2 双排面对面**,第二排居中 | §25.1 |
| **MR-2** | 下板 160 × 125 mm,装配时相对载板原点偏移 (30, 5) | §25.2 |
| **MR-3** | 每台手机 7 个定位孔,全部 **NPTH Φ2.5 + 尼龙立柱**(见 SR-12) | §25.2 |
| **MR-4** | 侧键用顶针接触,顶针焊盘必须避免把相邻触点短路 | §25.3 |
| **MR-5** | 排线穿槽:电池排线 8×38 → 槽 10×3.0;尾插排线 18×55 → 槽 20×3.0(在 J1401 正下方) | §25.16 |
| **TR-1** | 总热负荷 ≈25 W(5×SDM845 满载 10~15 W + 下板功率级 ≈9 W) | §22.6 |
| **TR-2** | 强制风冷 **4 进 1 出 = 机箱正压**(灰尘往外走);两板间 13 mm 缝作天然风道 | §22.6 |
| **TR-3** | 所需风量仅 ≈2.9 CFM,**风量不是瓶颈,风道与正压才是设计要点** | §22.6 |
| **TR-4** | 风扇 5 × 12 V ≈0.70 A;低边 N-MOS + PWM,漏极**必须并续流二极管** | §22.6 |
| **TR-5** | 风扇闭环的测温点**放载板**(测的是手机),不能用下板功率级的两路 NTC 做反馈 | §22.6 |
| **TR-6** | TACH 必须分压后再进 GPIO;⚠️ 低边调速下 **PWM 关断期间读不到转速**,固件需在导通窗口采样 | §22.6 |
---
## 9. 固件需求(SW)
> 本节只列「固件要做到什么」。**怎么组织成一份固件见 §10 软件架构设计。**
### 9.1 必做
| ID | 需求 |
|---|---|
| **SW-1** | 崩溃检测 + 两级自动重启(§4.1)—— **整块板子最核心的价值** |
| **SW-2** | 20 ms 周期巡检六路 INA226,电压偏离 >±100 mV 持续 20 ms 即关路告警(SR-5) |
| **SW-3** | 过流 / 故障事件记录:路号、时刻、电流值 —— 排查排线接触不良的唯一线索 |
| **SW-4** | 单路电压微调接口(跑不稳的手机提到 4.0 V,或反向降压降温),**上限受 SR-13 约束** |
| **SW-5** | 侧键序列:短按 100 ms 唤醒 / 长按 2 s 开机 / 长按 10 s 强制重启 / VOL−+PWR 进 Fastboot / VOL++PWR 进 Recovery |
| **SW-6** | EDL 时序**固定为** `EDL_EN=1` → `VBUS_EN=1` → 等枚举 → `EDL_EN=0`(先短接再插 USB) |
| **SW-7** | 开机脚本执行「关充电」(写 `input_suspend` 类节点),路径需在首台机上确认后写死 |
| **SW-8** | USB 反灌保护:检测到该路电流转负或母线上升,**200 ms 内**断开该路 VBUS |
| **SW-9** | 风扇 PWM 闭环(以载板 LM75BD 为反馈)+ TACH 停转告警 |
| **SW-10** | 整板功率预算:读输入侧 INA226,超适配器能力时按优先级降载 |
| **SW-11** | 上电默认全关:五路输出关、VBUS 断、按键不按、EDL 不触发 |
| **SW-12** | **双链路命令接口**:WiFi 为常态、USB CDC 为兜底,两条同时在线、共用同一套命令语义与同一个命令处理器;命令带全局序号做幂等判重。链路失联**不触发任何动作**。架构见 §10.8 |
### 9.2 明确禁止
| ID | 禁止事项 | 理由 |
|---|---|---|
| **SW-N1** | 让 ESP32 参与过流保护的实时环路 | 那是 eFuse 的活,MCU 不够快也不够可靠 |
| **SW-N2** | 提供任何能让输出超过 4.2 V 的接口 | SR-13 |
| **SW-N3** | 烧写 `DIS_USB_JTAG` eFuse | 烧了之后引脚 JTAG 和 USB 两边都进不去,板上没有 JTAG 排针 |
| **SW-N4** | 把 INA226 通道号当地址算 | IF-16 |
---
## 10. 软件架构设计
> **这一节为什么在需求文档里**:本文原则上只讲「做成什么样」,但**固件在设计文档里没有
> 落脚点** —— 设计文档 §8.3 只有一张按价值排序的功能清单,没有结构。在补出一份独立的
> 软件设计文档之前,架构先放这里,与 §9 的固件需求贴在一起看。
> 硬件的「怎么做」仍然一律在设计文档。
### 10.1 分层与职责边界
```diagram=layers
┌─ 农场管理系统(既有,本项目不交付 —— N7)────────────────────────┐
│ adb 探活 / 业务调度 / 崩溃判定 │
└───────┬────────────────────────────────┬──────────────────────────┘
│ adb over WiFi(业务面) │ 控制面(10.8,⚠️ 链路待定)
▼ ▼
5 台小米8 主板 ┌─────────────────────┐
▲ │ PhoneRail 控制器 │
│ 供电 / 侧键 / EDL / VBUS │ ESP32-S3 固件 │
└──────────────────────────┤ 遥测 · 执行 · 记录 │
└──────────┬──────────┘
│ 只能建议,不能否决
┌───────────────▼───────────────┐
│ 硬实时层:eFuse OVP / 限流 │
│ 放电 MOS 反相联锁 / 上下拉 │
│ —— 不含任何软件(SR-3、SR-7) │
└───────────────────────────────┘
```
**一条边界决定了下面所有取舍**:固件是**遥测 + 执行器 + 记录仪**,不是保护装置
(SR-5、SW-N1)。凡是「必须及时」的事都已经在硬件里了;固件里没有任何一条路径
是「不跑到就会烧手机」的。因此:
- 任务优先级不需要为保护让路,只需要保证遥测不被执行器序列饿死;
- 看门狗复位是**可接受的失效**(复位即全关,SR-6),不需要为了「保持输出」而弱化看门狗;
- 固件可以随时被重启、被降级运行,硬件安全性不变。
### 10.2 架构的第一个判断:失联 ≠ 断电
| 状态 | 输出该怎么办 | 为什么 |
|---|---|---|
| ESP32 复位 / 上电中 | **全关**(硬件下拉保证,SR-6) | 状态未知,且此刻手机本来就没在跑 |
| 与上位机失联 | **保持供电不动** | 五台手机正在跑业务;因为控制链路断了就把它们全断电,是把一次网络故障放大成一次全场停机 |
| 本地判据触发(SR-5 / SW-8 / 过温) | **关对应那一路** | 已知的危险,且范围可控 |
⚠️ 这三行看起来像常识,但它们互相矛盾过一次:**「安全默认值 = 关」只对未知状态成立**。
失联是已知状态,此时最安全的动作是「什么都不做」。固件里不要写「心跳超时就关输出」。
**推论**:失联期间固件必须能独立运行全部本地职责 —— 巡检、风扇闭环、事件记录、
按队列执行未完成的序列。上位机回来之后靠事件日志补齐时间线(10.7)。
### 10.3 任务划分(FreeRTOS / ESP-IDF)
| 任务 | 周期 | 优先级 | 职责 | 失败后果 |
|---|---|---|---|---|
| `t_safety` | **20 ms** | 最高 | 读 6 片 INA226(V+I)与 PCA9555 输入;执行 SW-2 / SW-8 判据;越界直接操作 `EFUSE_EN` / `VBUS_EN`(**GPIO 直连,不经任何队列或抽象层**) | 告警延迟;保护本身不受影响(eFuse 是硬件) |
| `t_thermal` | 1 s | 中 | 两路 NTC(下板功率级)+ 载板 LM75BD(手机侧)→ 风扇 PWM 闭环;过温先降流后关路(FR-13、TR-5) | 风扇维持上一个占空比 |
| `t_actuator` | 事件驱动 | 中 | 每路一个状态机:断电重启 / 按键序列 / EDL 序列(10.6) | 序列停在中间 —— 必须有超时兜底 |
| `t_link` | 事件驱动 | 低 | 上位机命令解析与遥测上报(10.8) | 见 10.2:不影响本地职责 |
| `t_log` | 空闲 | 最低 | 环形缓冲落盘(10.7) | 丢日志,不丢功能 |
**共享资源只有两个**,各配一把互斥锁:
1. **I²C 总线** —— 10 个器件在同一条上(IF-15),其中 LM75BD 还在排针另一侧的载板上。
2. **74HC595 的 24 位链** —— 全板共享,见 10.6 的影子寄存器。
**I²C 带宽核算**(400 kHz,`t_safety` 是唯一的高频用户):
```
一次 INA226 寄存器读 ≈ 46 个位周期 ≈ 115 µs
6 片 × 2 个寄存器(总线电压 + 分流电压) = 12 次 ≈ 1.4 ms
PCA9555 读两个输入端口 ≈ 0.12 ms
合计 ≈ 1.5 ms / 20 ms = 约 8 % 占用
```
余量充足,`t_thermal` 和偶发的 DAC 写完全挤得下。**首板上电时用逻辑分析仪实测复核一次**
——这个数是按理想时序算的,没算时钟延展和重试。
### 10.4 五路一张表:禁止用通道号算任何东西
这是**本项目最容易写出「看起来对、实际错一路」的地方**。三组编号都不是逻辑顺序:
- **INA226 地址**按布线难度重排过(IF-16,设计文档 §8.2);
- **74HC595 的位序**按 PCB 几何排(设计文档 §17.2 / §22.4);
- **GPIO 编号**按出线方向倒推(设计文档 §26.18)。
所以固件里**只允许有一张静态配置表**,全部五路循环都索引它:
```c
typedef struct {
uint8_t ina_addr; /* 0x40 0x41 0x44 0x45 0x42 —— 查表,不能算 */
uint8_t dac_chip; /* 0x4A: CH1-3 0x4B: CH4-5 */
uint8_t dac_ch; /* U6.OUT0/1/2 U7.OUT0/1 */
gpio_num_t efuse_en; /* GPIO 直连 */
gpio_num_t vbus_en; /* GPIO 直连 */
uint8_t key_bit[3]; /* PWR / VUP / VDN 在 24 位链里的位号 */
uint8_t edl_bit; /* U33 上的位号 */
uint8_t flt_bit; /* PCA9555 IO0_0..4 */
uint8_t pg_bit; /* PCA9555 IO1_0..4 */
} rail_cfg_t;
```
> ⚠️ **`ina_addr = 0x40 + ch` 这行代码一定会被写出来,而且只有 CH3/CH4/CH5 是错的。**
> 实际地址是 `0x40 / 0x41 / 0x44 / 0x45 / 0x42`。查表,不要算。
同理,**PCA9555 的方向寄存器只能配成全输入**:备用脚 Port0[7] / Port1[7:5] 在硬件上
直接接地,配成输出并写 1 就是对地短路(设计文档 §17.2 已在图纸上注明)。
### 10.5 电压通道:DAC 只做微调,而且是反向的
| 事实 | 含义 |
|---|---|
| `Vd = 0 V` → 输出 **4.010 V**(上限) | **DAC 写得越大,输出越低** |
| `Vd = 0.548 V` → 输出 3.90 V(NVM 出厂默认) | 上电即安全值,不依赖 ESP32 初始化成功 |
| `Vd = 2.424 V`(满量程)→ 输出 **3.524 V**(下限) | 满量程是 2.424 V,不是 2.5 V(内部基准 1.212 V ×2) |
> ⚠️ **反向关系是本节最容易写反的一条。** 写反的现象不是报错,而是
> 「调高电压的命令把手机调到了 3.52 V」——手机照样能开机,只是更容易在负载尖峰下掉电,
> 而且**要跑一阵子才看得出来**。固件里把这一步封成 `rail_set_mv()` 单一入口,
> 内部做 mV → Vd → code 的换算,**不要在别处直接写 DAC 码值**。
三条固件规矩:
1. **越界即拒绝**:`rail_set_mv()` 收到 >4010 mV 的请求直接返回错误并记事件(SR-13)。
注意这是**第四道**限制,前三道在硬件里 —— 它的作用只是让操作者早点知道自己写错了。
2. **不用 DAC 做保护**:任何时候都不要靠「把电压调低」来处理异常,那是 `EFUSE_EN` 的活。
3. **出厂标定**:分压网络与 DAC 基准都有 ±1 % 级的容差,用该路 INA226 读回实际输出,
做一次两点线性标定存进 NVS。标定值缺失时退回理论公式(误差约 ±40 mV,可用)。
### 10.6 执行器:三个状态机 + 一个影子寄存器
**每路三个序列,彼此互斥、不可重入**(同一路上同时只能跑一个):
| 序列 | 时序 | 关键约束 |
|---|---|---|
| 硬重启 | `EN↓` → 等 3~5 s → `EN↑` → 等 **17 ms**(tON 5.1 ms + 缓启动斜坡 11.5 ms)→ 等 PG 置位 | 放电由硬件联锁自动完成(SR-7),固件**不要**去控制放电 MOS |
| 按键 | 置位 → 保持 T(100 ms / 2 s / 10 s)→ 清零 | 组合键(VOL−+PWR)必须**同一帧**写进去,不能先后两次 |
| EDL | `EDL_EN=1` → `VBUS_EN=1` → 等枚举 → `EDL_EN=0` | 顺序固定(SW-6):先短接再插 USB |
**影子寄存器是这一节的核心**:15 路按键 + `HUB_RST` + 5 路 `EDL_EN` 全在**同一条 24 位
移位链**上,写任何一位都要把整条链重发一遍。
```
ESP32.KEY_SER → U21 → U22 → U33 (先移入的字节落在链尾 U33)
写入顺序:U33 字节 → U22 字节 → U21 字节,最后一个 RCLK 上升沿统一生效
```
规矩:**内存里保存一份 24 位影子值,全固件只有一个函数能写 595**,任何任务改按键
或 EDL 都是「改影子 → 请求刷新」。多处直接操作移位链,症状是**另一台手机的按键被顺手
清掉**,而且复现要靠两个动作正好撞在一起 —— 属于会拖很久的那类 bug。
> `RCLK` 打点之前输出不变,所以整帧刷新是原子的;`OE#` 保持有效,**不要**用拉高 `OE#`
> 的方式「临时全松开」——那会让 15 个栅极同时进高阻,靠的是每个栅极自己的 100 kΩ 下拉,
> 是复位路径,不是正常控制路径。
### 10.7 事件记录与存储
事件是 SW-3 的交付物,也是排查排线接触不良的唯一线索,所以**记录路径不能依赖上位机在线**。
| 项 | 设计 |
|---|---|
| 缓冲 | RAM 环形缓冲,≥256 条;`t_safety` 只入队,不落盘 |
| 落盘 | `t_log` 空闲时批量写 NVS / SPIFFS 分区 |
| 单条 | 时间戳 + 路号 + 事件类型 + 当时的 V/I(16 B 量级) |
| 容量 | 4096 条 ≈ 64 KB,按每天几十条算够存半年以上 |
| 时间 | 上电从 0 计;上位机连上后下发一次墙钟做偏移校正(不加 RTC 芯片) |
必记事件:eFuse FAULT / PG 变化、SR-5 触发关路、SW-8 反灌断 VBUS、过温降载、
每次重启序列(软 / 硬、谁下的命令)、越界命令被拒、I²C 器件失联、风扇停转。
### 10.8 上位机接口 ✅ 已定稿(2026-09-06 用户拍板:方案 C)
**这一节回答的问题**:SW-1 的触发条件写的是「`adb` 探活连续失败 N 次」,
而 **ESP32 跑不了 adb** —— 所以崩溃判定必然发生在上位机,板子只负责执行动作。
这意味着必须有一条控制链路,而在此之前**全部文档里都没有定过它**。
**结论:WiFi 为常态链路,USB CDC 为兜底链路,两条同时在线。**
选它不是因为「都要」,而是因为 **SW-1 是这块板子的核心价值,
而 WiFi 与手机 adb 走的是同一张网** —— 只做 WiFi 等于让「救援通道」和「故障通道」
共命运,恰好在最需要它的时候一起失效。USB 的增量成本很低:线本来就插着。
留档备查的两个单选方案:
| 方案 | 优点 | 为什么不单独用 |
|---|---|---|
| A. 只做 WiFi | 无线、可多台组网;设计文档 §9.5 的 3V3 预算里**已经给 ESP32-S3 的 WiFi 突发留了 ≈350 mA**,说明当初就是按带 WiFi 设计的 | 网炸了救砖通道跟着炸,见上 |
| B. 只做 USB CDC | 最可靠,不依赖网络 | 每块板占上位机一个 USB 口;机架规模上去之后不现实 |
#### 两条链路怎么共存
```diagram=links
WiFi (MQTT/HTTP) USB CDC (J22)
│ │
┌───────▼────────┐ ┌────────▼───────┐
│ 传输适配器 A │ │ 传输适配器 B │ ← 只管收发字节与分帧
└───────┬────────┘ └────────┬───────┘
└──────────┬─────────────┘
┌──────▼───────┐
│ 单一命令处理器 │ ← 全部鉴权、限幅、幂等判重都在这里
└──────┬───────┘
▼
执行器 / 遥测
```
四条规矩:
1. **命令处理器只有一个。** 两条链路是**传输**,不是两套功能。任何安全约束
(SR-13 的限幅、越界拒绝、序号判重)都写在处理器里,绝不写进适配器 ——
否则以后加第三条链路时必然漏掉一处,而**功能测试完全测不出来**。
2. **不做主备切换,两条都常开。** 谁先送到算谁的,靠 3 的序号去重。
做「主备」就要判定"主链路死了没有",而那个判定本身就是新的故障点。
3. **序号是全局的,不分链路。** 上位机给每条命令一个单调递增的序号,
板子记住最近 N 个已执行的序号。WiFi 重传 + USB 又发了一遍同一条命令时,
第二次必须被丢弃 —— 否则「按 10 秒电源键」会执行两遍,第二遍正好按在刚开机的手机上。
4. **USB 链路与日志共用一条流。** ESP32-S3 的原生 USB-Serial-JTAG 本身就是
CDC-ACM,不需要切到 TinyUSB,也不影响 esptool 下载(FR-15 不变)。
代价是命令响应和运行日志在同一条串流上,**必须分帧**(建议命令用带前缀的单行
JSON,日志走另一个前缀),否则日志一刷就把响应冲散。
> ⚠️ **别把 WiFi 凭据和 MQTT 地址编译进固件。** 放 NVS,用 USB 链路配置 ——
> 这正好是兜底链路的第二个用途:新板子上架时先插 USB 配网。
命令语义与链路无关,两条链路完全一致:
| 命令 | 参数 | 说明 |
|---|---|---|
| `status` | — | 五路 V/I/PG/FLT、输入 V/I、两路 NTC + 载板温度、风扇转速、各路状态机状态 |
| `set_voltage` | 路号, mV | 走 `rail_set_mv()`,越界拒绝(10.5) |
| `power_cycle` | 路号, 断电秒数 | 二级硬重启(4.1) |
| `key` | 路号, 键位掩码, 保持 ms | 单键与组合键同一个接口 |
| `edl` | 路号, on/off | 内部按 SW-6 的固定时序展开 |
| `vbus` | 路号, on/off | 默认断开 |
| `fan` | 占空比 / auto | `auto` 交回 `t_thermal` 闭环 |
| `events` | 起始序号 | 增量拉取事件日志 |
三条接口硬约束:
- **没有任何命令能让输出超过 4.2 V**(SR-13)—— 包括「原始 DAC 写入」这类调试命令,
它压根不该存在于发布固件里。
- **命令必须幂等或带序号**:链路重传时不能让「按 10 秒电源键」执行两遍(上面第 3 条)。
- **链路失联不触发任何动作**(10.2):没有心跳超时关输出这回事。
### 10.9 启动顺序
```
1 上电:GPIO 全部显式置安全态(硬件下拉已保证,固件再确认一次)
2 I²C 扫描 10 个器件 —— 缺任何一个都进「降级模式」(10.10),不要照常启动
3 读回 DAC 通道值(NVM 默认 3.90 V),与期望比对
4 载入 NVS 里的标定值与上次的每路使能状态
5 逐路上电,**路与路之间错开 100 ms**
6 等各路 PG,超时则记事件并保持该路关闭
7 起风扇闭环,起链路任务
```
> ⚠️ **第 5 步的错开不是讲究,是必须**:单路缓启动涌流 0.8 A,五路同时开就是 4 A
> 叠在 12 V 输入上,而输入预算本来就是 8.3 A(PR-2)。错开 100 ms 之后
> 各路的斜坡(11.5 ms)完全不重叠。
### 10.10 降级矩阵
| 失效 | 检测方式 | 行为 |
|---|---|---|
| 某片 INA226 无应答 | I²C NAK | 该路失去遥测 → **保持供电**,标记为「盲跑」,禁用该路的 SR-5 / SW-8 判据并持续告警 |
| I²C 总线挂死(SDA 被拉低) | 连续超时 | 发 9 个时钟脉冲做总线恢复;仍不通则复位 I²C 外设;再不通进降级模式 |
| DAC 无应答 | I²C NAK | **不关输出** —— DAC 不响应时输出停在 NVM 默认的 3.90 V,是安全值 |
| PCA9555 无应答 | I²C NAK | 失去 FAULT / PG 可见性;改用 INA226 的电压判据兜底 |
| 载板 LM75BD 无应答 | I²C NAK | 多半是排针接触不良(它在排针另一侧)→ **风扇转到 100 %** 并告警,不要按「读不到温度=不热」处理 |
| 上位机失联 | 心跳超时 | 见 10.2:**什么都不做** |
| 看门狗复位 | — | 复位即全关(SR-6),重启后按 10.9 恢复,并记一条复位事件 |
**降级模式**的定义:只保留「关断能力」与「事件记录」,不再自动上电、不接受调压命令,
持续告警。宁可五台手机不开机,也不要在盲态下带电跑。
### 10.11 关于 FR-12「模拟电量曲线」的一个说明
这条需求的实现方式和它的字面意思不一样,值得在架构层写清楚:**手机的电量计是从
电池端电压推算 SoC 的**,我们给的电压就是它看到的全部信息。所以「模拟电量曲线」
= **按锂电放电曲线缓慢改变输出电压**(比如 12 小时从 4.01 V 走到 3.60 V,再跳回去)。
两个后果:
- 它**不需要**任何新硬件,是 `rail_set_mv()` 之上的一个慢速调度器;
- 它与 SW-4(单路电压微调)**共用同一个执行器**,两者必须互斥 ——
否则运维把某台调到 4.0 V,曲线任务转头又把它拉回 3.6 V。
FR-12 是「中(可选)」,建议**首版先不做**,把接口留出来即可。
### 10.12 架构层面的待定项
| 项 | 状态 |
|---|---|
| ~~上位机链路选型(10.8)~~ | ✅ **2026-09-06 用户拍板:方案 C**,WiFi 常态 + USB CDC 兜底 |
| **OTA 升级** | 既然定了 WiFi,**建议做**:双分区 + 失败回滚。没有 OTA 的话一次坏固件要把板子从机架上拆下来插 USB,而 USB 兜底链路本身也能烧 —— 所以这是「省事」不是「救命」,可以排在首版之后 |
| WiFi 掉线重连策略 | 指数退避 + 上限;**重连过程不得阻塞 `t_safety`**(10.3),链路任务优先级最低就是为这个 |
| 时间同步 | 当前方案是上位机下发墙钟做偏移,不加 RTC。若日志要脱机长期归档需重议 |
| FR-12 电量曲线 | 建议首版不做,只留接口(10.11) |
| `input_suspend` 节点路径 | 与 SW-7 / V-8 是同一件事,首台机上确认后写死 |
---
## 11. 约束与假设
### 11.1 硬约束
| ID | 约束 |
|---|---|
| **C-1** | 全部为 1S 机型,负载固定为小米8(dipper)主板 |
| **C-2** | 原厂电池保护板**被旁路** ⇒ 失去一层与本板独立的防线,全部安全责任落在 SR-2 / 3 / 5 上 |
| **C-3** | 无电池 ⇒ 失去一个内阻只有几十 mΩ 的缓冲电容。**瞬态阻抗设计(§6)比电压精度重要得多**,是这类板子的主要故障来源 |
| **C-4** | ESP32-S3-WROOM-1 可用 IO 有限(19 根信号已排满),新增功能只能走移位寄存器或 I²C 扩展 |
| **C-5** | USB2517I 要求 D± 走线 ≤ 6 inch(152 mm) ⇒ Hub 只能放在载板通道正中 |
| **C-6** | BM24(尾插)连接器只有 **10 次插拔寿命** ⇒ 装配流程必须减少反复插拔 |
| **C-7** | 小米8 原理图 PDF 是第三方扫描件,**一切引脚 / 机械结论以实测为准** |
### 11.2 假设(若不成立需重新评估)
| ID | 假设 |
|---|---|
| **A-1** | 上游 USB 恒为 5 V(只接电脑 / Hub,不接快充头) |
| **A-2** | 手机关机待机电流在 ≈50 µA 量级 |
| **A-3** | 目标 ROM 上存在可写的「关充电」sysfs 节点(SW-7,路径待确认) |
| **A-4** | 手机常态功耗 0.3 ~ 1 A / 路,五路同时 4 A 满载不会发生 |
---
## 12. 验收准则
### 12.1 首板上电验收(17 步,**严禁第一次上电就接手机**)
完整步骤与通过标准见设计文档 **§11**。其中:
- **第 5 步(人为断开 R2,模拟致命故障)** 与 **第 6 步(负载阶跃 + 阻尼验证)**
是这块板子的两个**核心验证项,不得跳过** —— 它们分别验证 SR-3 和 PR-12 / C-3。
- **第 12 步(ESP32 复位安全测试)** 验证 SR-6。
- **第 14 步(USB 反灌测试)** 验证 SR-10 / SW-8。
### 12.2 需求 → 验收方法对照
| 需求 | 验收方法 | 通过标准 |
|---|---|---|
| PR-4 / PR-5 | 扫 DAC 全量程 | 输出扫过 3.52 ~ 4.01 V,与公式误差 <1%;**任何码值都到不了 4.2 V** |
| SR-2 | 固件写越界值 | 输出被拓扑限死,不越过上限 |
| SR-3 / SR-4 | 人为断开反馈下电阻 | eFuse 断开,输出侧尖峰不超过 4.7 V |
| SR-5 | 注入 ±150 mV 偏差 | 20 ms 内关路并产生告警记录 |
| SR-6 | 手动复位 ESP32 | 示波器确认五路立即关断、15 路按键无一误触发、EDL 无触发 |
| SR-7 | 固件同时置位 EN 与放电 | 硬件互斥,10 Ω 上无持续功耗 |
| SR-9 | 可调电源把母线升到 13.5 V | 泄放导通并拉回 13.2 V 以下 |
| SR-10 / SW-8 | 不关充电直接接 USB | 电流转负 → 固件 200 ms 内断 VBUS |
| SR-15 | 排线加 4.40 V | **不断开**(断开说明焊到了 `B+`/`B−`) |
| PR-12 | 0→4 A 阶跃(带 20 cm 线 + 电容组) | 压降 <300 mV,**无 6 kHz 振铃** |
| PR-13 | 满载 4 A 跑 30 min | 温升 <40 K |
| PR-14 | 断电重启时序 | 输出 200 ms 内 <0.5 V |
| FR-4 / SW-5 | 逐项试按键 | 短按唤醒 / 长按 10 s 强制重启 / VOL−+PWR 进 Fastboot 均有效 |
| FR-5 / SW-6 | 下发 EDL 序列 | 主机可见 9008 设备 |
| FR-7 | 五台全接 | `adb devices` 与 `fastboot devices` 均可见 |
| FR-10 / TR-6 | 拔掉一个风扇 | TACH 频率下降约 1/5,产生告警 |
| SW-12 | 断开 WiFi(拔 AP / 关热点),从 USB 下命令 | 命令照常执行;**五路输出不受影响**(10.2 的失联行为) |
| SW-12 | 同一序号的命令从两条链路各发一遍 | **只执行一次**,第二条被判重丢弃并记事件 |
| **总体目标** | 五台长稳 | **24 h 无重启、无告警** |
### 12.3 装配前必做的实物验证(阻塞项)
| 项 | 内容 | 出处 |
|---|---|---|
| V-1 | **排线旁路验证**:确认焊在 `P+`/`P−`,4.40 V 不断开 | §13.7 |
| V-2 | **侧键触点定位**:万用表 5 分钟,拍照存档 | §13.3 |
| V-3 | **环路补偿实测扫频**(三个位置已留焊盘) | §9.2 |
| V-4 | **量两个新连接器实物高度**(XT30PW-M / 2510-3A)—— 余量只有 1~3 mm,**别信估计值** | §26.16 |
| V-5 | **两条排线路径试装**(电池 / 尾插穿槽走背面) | §25.16 |
| V-6 | **J7n 镜像位按底层装法复做**;装好后做两点通断复核 | §24.3 / §25.16 |
| V-7 | **风扇线装配顺序**:必须「先插五根风扇线,再合上载板」 | §26.16 |
| V-8 | 确认目标 ROM 上 `input_suspend` 的实际路径 | §15.2 |
> 完整的待办 / 已关闭清单以设计文档 **§12** 为准,本表只列对需求验收有约束的。
---
## 13. 已知风险与已接受的取舍
| # | 风险 | 处置 |
|---|---|---|
| R1 | 去掉原厂保护板 = 少一层独立防线 | **已接受**。定量核算 eFuse 余量 ≈11 倍;且 eFuse 断开后 2200 µF 仍撑着,手机继续运行不掉电,表现优于保护板断开 |
| R2 | 最坏角落下 eFuse 可能到 4.519 V 才跳,超 1S 上限约 60 mV | **已接受**。走到这一步说明第一层已失效,输出以 6.4 mV/µs 冲过这 70 mV 只需 ≈10 µs,远短于锂电过压损伤时间常数 |
| R3 | 两个同款 USB-C 插错 | **已接受**(用户在 USB-B / USB-C 之间选了 C)。靠丝印 + 颜色 + 外壳标记缓解 |
| R4 | 低边调速导致 PWM 关断期间读不到转速 | **已接受**。若最终选 4 线 PWM 风扇,MOS + 续流管 + 分压可全删 |
| R5 | IF-5 的 100 µF MLCC 在 3.9 V 偏压下只剩 ≈40 µF,中频储能从 200 µF 降到 ≈80 µF | **待复核**。严格照原方案需换真聚合物 |
| R6 | 电感 SPM6530T-4R7M 的 Isat 6.2 A < 原定的 7 A | **已接受**,裕量分析见设计文档「已知遗留」 |
| R7 | BM24 仅 10 次插拔寿命 | 靠装配流程规避(C-6) |
| R8 | 手机主板机械固定方式(是否连中框)未定 | **不阻塞打样** |
---
## 14. 需求 → 设计追溯表(速查)
| 需求域 | 设计文档章节 |
|---|---|
| 规格 / 输入输出 | §1、§3 |
| 电压范围与本质安全 | §4(含 §4.4 失效态验算) |
| 三层保护链 | §5 |
| 瞬态阻抗与阻尼(C-3) | §6 |
| 断电重启与放电 | §7 |
| MCU 接口 / GPIO / I²C | §8、§26.18 |
| 选型与 BOM | §9 |
| PCB 与叠层 | §10、§25、§26 |
| 验收与调试 | §11、§14 |
| 手机侧接口 | §13 |
| USB 子系统 | §15、§18、§21 |
| 两板架构与机械 | §22、§25 |
| 散热 | §22.6 |
| EDL | §22.4 |
| **软件架构** | **设计文档里没有对应章节 —— 见本文 §10** |
---
## 15. 术语
| 术语 | 含义 |
|---|---|
| **直供** | 拆掉电芯与保护板,从电池座直接供给受控电压 |
| **dipper** | 小米8 的代号 |
| **P+ / P−** | 电池保护板的**输出侧**焊点(本项目的接入点) |
| **B+ / B−** | 电芯侧焊点(**接错这里是本项目最隐蔽的装配错误**) |
| **eFuse** | 带可调过压 / 限流 / 缓启动的电子保险丝(TPS25940A) |
| **EDL / 9008** | 高通紧急下载模式 |
| **本质安全** | 靠拓扑与元件取值在物理上限死危险状态,而不是靠软件判断 |
| **下板 / 载板** | 下板 = 电源板;载板(上板)= 承载五块手机主板的板 |
+266
查看文件
@@ -0,0 +1,266 @@
# PhoneRail 固件开发计划
| 项 | 值 |
|---|---|
| 版本 | v1.0(2026-09-06) |
| 范围 | `需求文档.md` 的 **D5 / D5a** —— ESP32-S3 固件与双链路命令接口 |
| 架构依据 | `需求文档.md` **§10 软件架构设计**(本计划不重复架构,只排施工顺序) |
| 目标平台 | ESP32-S3-WROOM-1 / ESP-IDF v5.x / C |
> **这份计划是给「一次会话 ≤ 20% 上下文」这个约束量身排的。**
> 它不是甘特图,是一串**自带入场清单与验收判据的任务包**:
> 每包一次会话做完,做完时代码是绿的、交接是写好的,下一次会话不需要回头翻上一次的过程。
---
## 1. 决定这份计划形状的三个约束
| # | 约束 | 对计划的影响 |
|---|---|---|
| **C-a** | **硬件还没有。** PCB 仍在布线(`PROGRESS.md`:下板还欠 1044 条连接性) | 绝大部分固件必须能在**没有板子**的情况下写完并验完 ⇒ 先做模拟器,见 §4 |
| **C-b** | **一次会话 ≤ 20 % 上下文**(按 200K 窗口 ≈ **40K token**) | 每个任务包必须**自带入场读取清单并给出上限**;源文件行数要控制;禁止整篇读大文档,见 §2 |
| **C-c** | **保护不在软件里**(需求 SR-3 / SW-N1) | 固件写错的后果是「功能不好用」,不是「烧手机」⇒ 可以先把逻辑全部写完再上板调,不必等硬件边写边试 |
C-c 是最容易被忽略但最解放人的一条:**这块板子的固件不是安全关键件**,
所以「离线写完 90 %、上板只调 10 %」这条路线是成立的。
---
## 2. 上下文预算 —— 每次会话怎么花那 40K
### 2.1 实测的「读一份文档要花多少」
| 文件 | 大小 | ≈ token | 占 20 % 预算 |
|---|---|---|---|
| `CLAUDE.md` | 3 KB | ~1K | 2.5 % |
| `需求文档.md` **全文** | 47 KB | ~15K | **38 %** |
|  └ 单看 §10 软件架构设计 | 19 KB | ~6K | 15 % |
|  └ 单看 §9 固件需求 | 2 KB | ~0.7K | 2 % |
| `PROGRESS.md` **全文** | 195 KB | ~60K | **150 % ⛔** |
| `五路直供电源板设计.md` **全文** | 438 KB | ~140K | **350 % ⛔** |
> 换算口径:UTF-8 中文约 3 字节/字,中文约 1 token/字,所以 **字节 ÷ 3 ≈ token**。
> 英文代码更省,约 4 字节/token。
### 2.2 一次会话的预算分配
| 段 | 预算 | 内容 |
|---|---|---|
| 入场 | **≤ 8 %**(16K) | `CLAUDE.md` + 本计划里**该任务包那一节** + 上一次交接 + 要改的 1~3 个源文件 |
| 干活 | **≤ 8 %**(16K) | 写代码、跑构建与测试、读报错 |
| 收尾 | **≤ 4 %**(8K) | 更新交接、提交、把新踩到的坑写回文档 |
### 2.3 硬规矩(违反其中任何一条,当次会话必然超预算)
1. **禁止整篇读 `五路直供电源板设计.md` 与 `PROGRESS.md`。** 需要什么就 `grep -n` 那个条目号
再 `sed -n 'a,bp'` 取那一段。这两份文件单独任何一个都装不进一次会话。
2. **禁止整篇读 `需求文档.md`。** 按需求 ID 取:`grep -n "SR-13\|SW-8" 需求文档.md`。
要看架构就只读 §10 里对应的小节。
3. **单个源文件控制在 ≈300 行以内。** 读一个 800 行的 C 文件≈8K token = 预算的 20 %。
超了就拆文件 —— 这条是**为了让后续会话读得起**,不是代码洁癖。
4. **构建与测试输出一律 `2>&1 | tail -40`。** ESP-IDF 全量构建日志有几千行。
5. **长任务后台跑**(`run_in_background`)**只看摘要**,不要贴全量。
6. **一次会话只碰一个任务包。** 顺手做掉相邻的一小块,通常就是下一次会话被迫回头重读的原因。
7. **固件会话与 EasyEDA 会话分开。** `easyeda-sch-geometry` skill + `eda-parts.json` +
截图验证那一套,本身就要吃掉一大半预算,和固件混在一起必然两头都做不完。
---
## 3. 会话协议
### 3.1 每次会话的固定动作
```
开场:读 CLAUDE.md
读 固件开发计划.md 里【本次任务包】那一节(只读那一节)
读 firmware/HANDOFF.md(上一次的交接,控制在 60 行以内)
按任务包的「入场读取」清单读文件,读完就动手
收尾:跑一次完整验收命令,贴结果
更新 firmware/HANDOFF.md(覆盖,不追加 —— 它是「现状」不是「日志」)
把新发现的坑写回 需求文档.md §10 或本计划,一两句话即可
```
### 3.2 `firmware/HANDOFF.md` 模板(**≤ 60 行**,超了就是在写日记)
```markdown
# 交接
更新:<日期> 完成任务包 <编号>
## 一句话现状
<现在能跑到哪一步>
## 下一步
<下一个任务包编号 + 一句话>
## 本次新增/修改的文件
- path — 干什么的
## 验收命令与结果
$ <命令>
<最后几行输出>
## 踩到的坑(只写会再犯的)
- ...
```
> **为什么覆盖而不是追加**:`PROGRESS.md` 现在 195 KB、读一次就是 1.5 倍会话预算,
> 就是追加写出来的。交接文件的唯一读者是「下一次会话的开头 30 秒」,它只需要现状。
> 历史留在 git log 里。
### 3.3 任务包的完成定义(DoD)
一个任务包算做完,必须同时满足:
- [ ] 宿主机测试全绿(`ctest` 或等价命令),**且新增了针对本包的测试**;
- [ ] ESP-IDF 目标也能编过(`idf.py build`,不要求上板);
- [ ] `HANDOFF.md` 已更新;
- [ ] **没有跨会话的半成品** —— 宁可缩小本包范围,也不要留一个编不过的中间态。
---
## 4. 工程结构与两个关键决定
### 4.1 决定一:宿主机可编译 + 硬件抽象层
```
firmware/
├── main/ 应用层 —— 与硬件无关,宿主机上能编能跑
│ ├── rail.c/.h 通道抽象、rail_set_mv()(§10.5)
│ ├── safety.c SR-5 / SW-8 巡检判据(§10.3)
│ ├── seq_*.c 三个执行器状态机(§10.6)
│ ├── cmd.c 单一命令处理器(§10.8)
│ ├── evlog.c 事件环形缓冲(§10.7)
│ └── thermal.c 风扇闭环(§10.3)
├── components/
│ ├── hal/ i2c / gpio / pwm / time 的**接口**
│ │ ├── esp/ 真实实现(ESP-IDF)
│ │ └── sim/ 模拟实现(宿主机)
│ └── drv/ ina226 / dac63204 / pca9555 / hc595 / lm75
├── sim/ 假板子:五路电压模型 + 手机负载 + 可注入故障
└── test/ 宿主机单元测试与场景测试
```
**HAL 只抽象四样东西:I²C 事务、GPIO 电平、PWM/定时、单调时钟。**
不要抽象得更多 —— 抽象层每多一个概念,后面每次会话都要多读一遍。
### 4.2 决定二:先写模拟器(A2 + A3 之前)
没有模拟器,每个任务包的「验收」都只能写成「等板子到了再说」,
于是 C-a 会把整个计划堵死到打样之后。
模拟器要模拟的,只有**能让逻辑跑起来的最小集合**:
| 模拟 | 到什么程度 | 不模拟什么 |
|---|---|---|
| I²C 器件 | 寄存器读写 + 可编程返回值 + 可注入 NAK | 真实时序、时钟延展 |
| 五路输出 | 一阶模型:`EN` 开 → 按 0.36 V/ms 斜坡到设定值;关 → 按 τ=22 ms 衰减 | 环路动态、纹波 |
| 手机负载 | 一个可设定的电流值 + 「卡死」标志 | 真实功耗曲线 |
| 故障注入 | 过压、负电流(反灌)、器件失联、PG 不来、风扇停转 | —— |
> ⚠️ **模拟器不是用来证明设计对的**,它只用来证明**代码逻辑对**。
> 电压、余量、时序裕量那些结论全部来自设计文档的计算与首板实测,
> 别拿模拟器跑出来的波形去推翻 §5 的任何一个数。
---
## 5. Track A —— 无硬件即可完成(**现在就能开始**)
每包 = 一次会话。「入场读」列的是**除 `CLAUDE.md` 与 `HANDOFF.md` 之外**要读的。
| 包 | 目标 | 入场读 | 验收 |
|---|---|---|---|
| **A1** | 工程骨架:CMake 双目标(idf + host)、空 `main`、能跑的 `ctest` | 本节 | `ctest` 通过(1 个假测试);`idf.py build` 通过 |
| **A2** | HAL 接口 + `sim` 实现(i2c/gpio/pwm/time) | A1 产物 | 测试里能挂上一个假 I²C 器件并读回预置值 |
| **A3** | INA226 驱动 + 六片地址表 | §10.4;设计文档 §8.2 那张地址表(`sed` 取 20 行) | 单测断言 `0x40/41/44/45/42/43`,**并有一条测试专门断言「通道号 ≠ 地址低位」** |
| **A4** | DAC63204 驱动 + `rail_set_mv()` | §10.5 全节(约 1.5K token) | 断言 ①4020 mV 被拒(SR-13)②写更大的码值输出更低(反向关系)③3900 mV ↔ Vd 0.548 V |
| **A5** | 74HC595 24 位链 + 影子寄存器单一写入点 | §10.6「影子寄存器」那段;设计文档 §17.2 的位表 | 断言 ①改 CH1 按键不动 CH2~5 的位 ②字节顺序 U33→U22→U21 |
| **A6** | PCA9555 驱动 | §10.4 末段 | 断言**方向寄存器永远不会把 Port0[7] / Port1[7:5] 配成输出**(配错直接 abort) |
| **A7** | 通道配置表 + 表驱动遍历 | §10.4 全节 | 启动自检:五路的地址/位号无重复、无越界 |
| **A8** | 任务骨架与调度(5 个任务、两把锁) | §10.3 全节 | sim 时钟下 `t_safety` 周期 20 ms ±1 ms;I²C 占用打印出来与 §10.3 的 8 % 对得上 |
| **A9** | 三个执行器状态机 + 互斥 | §10.6 全节 | 场景测试:硬重启走完 17 ms 上电时序;按键序列不重入;EDL 顺序固定 |
| **A10** | 安全巡检(SR-5 / SW-8) | §10.2 + 需求 SR-5 / SW-8 两行 | 注入过压 → 20 ms 内关路并记事件;注入 −60 mA → 断 VBUS |
| **A11** | 事件日志(环形缓冲 + 落盘) | §10.7 全节 | 写满 4096 条不丢最新;掉电重启后能读回 |
| **A12** | 命令处理器(语义 + 序号判重 + 限幅) | §10.8 命令表 + 三条硬约束 | **同序号发两遍只执行一次**;越界命令被拒且记事件 |
| **A13** | 传输适配器 B:USB CDC 分帧 | §10.8 第 4 条 | 宿主机上用管道灌「命令 + 日志混流」,命令仍能被正确切分 |
| **A14** | 传输适配器 A:WiFi/MQTT 骨架 | §10.8 | 协议层用 sim 传输测通;**真连网留到 B 段** |
| **A15** | 启动顺序 + 降级矩阵 | §10.9 + §10.10 | 逐个「拔掉」器件,行为与 §10.10 的表逐行对上;五路上电错开 100 ms |
| **A16** | 风扇闭环 + TACH 采样窗口 | §10.3 `t_thermal` + 需求 TR-6 | PWM < 100 % 时不会把「读不到脉冲」误判成停转 |
**顺序**:A1 → A2 是硬前置;A3~A6 四个驱动包彼此独立(可换顺序);
A7 依赖 A3~A6;A8 依赖 A2;A9~A11 依赖 A7+A8;A12 依赖 A9;A13/A14 依赖 A12;
A15 依赖全部;A16 独立,随时可插。
**预计 16 次会话**。其中 A4 / A9 / A12 是三个「容易超预算」的包,
如果开场发现读完入场清单已经用掉 12 %,就地拆成两包,别硬撑。
---
## 6. Track B —— 需要真板(板子到之前只能写脚本)
Track B 的验收判据**不是我定的,是 `需求文档.md` §12.1 那 17 步**。
这里只做一件事:把那 17 步拆成能一次会话做完的包,并在板子到之前先把脚本写好。
| 包 | 对应 §12.1 | 说明 |
|---|---|---|
| **B0** | 全部 | 把 17 步做成一份**逐步 checklist 脚本**(提示 + 记录 + 存档),板子到之前就能写完 |
| **B1** | 步 1~2 | 上电、3.3 V、I²C 扫到 10 个器件 |
| **B2** | 步 3~4 | 单路 Buck 空载 3.90 V;DAC 扫全量程与公式误差 < 1 % |
| **B3** | 步 5 | **人为断开 R2 → eFuse 断开** ⭐ 核心验证项,单独一次会话,不要和别的混 |
| **B4** | 步 6 | 0→4 A 阶跃、无 6 kHz 振铃 ⭐ 另一个核心验证项 |
| **B5** | 步 7~9 | 满载温升、断电重启时序、五路全装 |
| **B6** | 步 12 | 复位安全:手动复位 ESP32,示波器确认全关、15 路按键无一误触发 |
| **B7** | 步 13~15 | 母线泄放、USB 反灌(**不关充电**注入)、关充电后复测 |
| **B8** | 步 10~11、16 | 接第一台手机、按键与组合键、有线 adb / Fastboot |
| **B9** | 步 17 | 五台长稳 24 h + SW-12 的两条链路验收 |
> ⚠️ **B3 与 B4 不要和任何别的事放在同一次会话。** 它们是这块板子的两个核心验证项
> (需求 §12.1 原话「不要跳过」),而且都要接示波器、都要人在场。
> 会话里塞了第二件事,出问题时分不清是谁引起的。
---
## 7. Track C —— 收尾
| 包 | 内容 | 触发条件 |
|---|---|---|
| **C1** | WiFi 配网流程(USB 下发凭据写 NVS,IF-19) | A14 之后 |
| **C2** | OTA:双分区 + 失败回滚 | B9 之后(§10.12 说了它是「省事」不是「救命」,别提前) |
| **C3** | 出厂标定流程(§10.5 第 3 条,两点线性标定存 NVS) | B2 之后 |
| **C4** | 文档回填:把实测值填回 `需求文档.md` §12.2,把踩到的坑写进 §10 | 随时 |
---
## 8. 里程碑
| 里程碑 | 判据 | 依赖硬件 |
|---|---|---|
| **M1 逻辑跑通** | A1~A16 全绿;模拟器上能完成一次完整的「崩溃 → 软重启 → 硬重启」 | ❌ 不需要 |
| **M2 板子活了** | §12.1 的 17 步全过,尤其 B3 / B4 | ✅ |
| **M3 交付** | 五台 24 h 无重启无告警;双链路验收通过 | ✅ |
**M1 完全不依赖打样进度**,可以和 PCB 布线并行 —— 这是本计划最主要的排期收益。
---
## 9. 明确不做的事
| # | 不做 | 理由 |
|---|---|---|
| 1 | 不为「更好测试」去改硬件设计 | 硬件已定稿(需求 §0.1 的 13 条决策) |
| 2 | 不在固件里做任何形式的过流保护环路 | SW-N1 |
| 3 | 不做上位机 UI | N7;本项目只交付板侧协议与实现 |
| 4 | 不追求模拟器的物理精度 | §4.2 的警告 |
| 5 | 首版不做 FR-12 电量曲线 | §10.11:只留接口 |
| 6 | 不写「跨会话的大重构」 | 违反 §3.3 的 DoD |
---
## 10. 待定项
| 项 | 说明 |
|---|---|
| ESP-IDF 具体版本 | 本计划按 **v5.x** 写;A1 落地时钉死一个小版本并写进 `HANDOFF.md`(IDF 小版本之间 CMake 与 `menuconfig` 有差异,换版本 = 一次白花的会话) |
| MQTT broker 与 topic 约定 | 依赖农场既有系统,A14 之前需要对方给出 |
| 单元测试框架 | 建议 Unity(IDF 自带)+ 宿主机 CMake/CTest;A1 定 |
| `input_suspend` 节点路径 | 与需求 V-8 是同一件事,首台机上确认 |
+2563
查看文件
文件差异内容过多而无法显示 加载差异
+19
查看文件
@@ -0,0 +1,19 @@
{
"name": "phonerail-docs",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "PhoneRail 五路手机直供电源板 —— 项目需求文档站",
"scripts": {
"sync": "node scripts/sync-docs.mjs",
"predev": "node scripts/sync-docs.mjs",
"dev": "vitepress dev",
"prebuild": "node scripts/sync-docs.mjs",
"build": "vitepress build",
"postbuild": "node scripts/check-anchors.mjs",
"preview": "vitepress preview"
},
"devDependencies": {
"vitepress": "^1.6.3"
}
}
+85
查看文件
@@ -0,0 +1,85 @@
// 校验侧边栏 / 导航里生成的锚点,在构建出来的 HTML 里真的存在。
//
// 侧边栏是 config.mts 用一份**自己实现的** slugify 从源稿的 h2 现算出来的
// (@mdit-vue/shared 打包在 vitepress 内部,外面 import 不到)。
// 只要那份实现和 VitePress 的哪怕差一个字符,链接就会静默跳空 —— 页面照样打开,
// 只是不滚动,肉眼极难发现。所以每次 build 之后自动比一遍。
//
// 多页之后还多了一类错:锚点对了但**页面指错了**(/#xxx 写成 /plan#xxx)。
// 所以这里按「路由 → 该页的 id 集合」建表,逐条链接校验它落在正确的页上。
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const DIST = path.resolve(here, '../.vitepress/dist')
if (!fs.existsSync(DIST)) {
console.error(`[check-anchors] 没有构建产物:${DIST}`)
process.exit(1)
}
// VitePress 自己注入的锚点,不是正文标题
const BUILTIN = new Set(['VPContent', 'VPSidebarNav'])
/** dist 下的全部 html(不含 404) */
function pages(dir, out = []) {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name)
if (e.isDirectory()) pages(p, out)
else if (e.name.endsWith('.html') && e.name !== '404.html') out.push(p)
}
return out
}
/** 文件路径 → cleanUrls 下的路由:index.html -> '/',plan.html -> '/plan' */
function routeOf(file) {
const rel = path.relative(DIST, file).split(path.sep).join('/').replace(/\.html$/, '')
return rel === 'index' ? '/' : '/' + rel.replace(/\/index$/, '')
}
const idsByRoute = new Map()
const files = pages(DIST)
for (const f of files) {
const html = fs.readFileSync(f, 'utf8')
const ids = new Set([...html.matchAll(/<h[1-6][^>]*\sid="([^"]+)"/g)].map((m) => m[1]))
idsByRoute.set(routeOf(f), ids)
}
let checked = 0
const bad = []
for (const f of files) {
const from = routeOf(f)
const html = fs.readFileSync(f, 'utf8')
for (const m of html.matchAll(/href="([^"]*#[^"]+)"/g)) {
const href = m[1]
const hash = decodeURIComponent(href.slice(href.indexOf('#') + 1))
if (BUILTIN.has(hash)) continue
const rawPath = href.slice(0, href.indexOf('#'))
if (rawPath && !rawPath.startsWith('/')) continue // 外链
const route = rawPath === '' ? from : rawPath === '/' ? '/' : rawPath.replace(/\/$/, '')
checked++
const ids = idsByRoute.get(route)
if (!ids) bad.push(`${from}:链接指向不存在的页面 ${route}`)
else if (!ids.has(hash)) bad.push(`${from}:${route}#${hash} —— 该页没有这个标题`)
}
}
if (bad.length) {
console.error(`\n[check-anchors] ✗ ${bad.length} 条锚点对不上:`)
for (const b of [...new Set(bad)]) console.error(` ${b}`)
console.error(`\n 多半是 config.mts 里的 slugify() 与 VitePress 不一致,或侧边栏的 base 写错了页。`)
for (const [route, ids] of idsByRoute) {
console.error(` ${route} 的标题 id:`)
for (const id of ids) console.error(` #${id}`)
}
process.exit(1)
}
const total = [...idsByRoute.values()].reduce((n, s) => n + s.size, 0)
console.log(
`[check-anchors] ✓ ${checked} 条锚点全部对得上` +
`(${idsByRoute.size} 个页面,共 ${total} 个标题)`
)
+41
查看文件
@@ -0,0 +1,41 @@
// 把项目根的 markdown 同步进 site/docs/,由 npm 的 predev / prebuild 自动跑。
//
// 为什么要拷而不是让 VitePress 直接指向项目根:
// srcDir 指到 D:\DCDC 之后,那里的 .md 编译出的虚拟模块要 import 'vue/server-renderer',
// 而 node_modules 在 site/ 下(是子目录不是父目录),Rollup 解析不到,构建直接失败。
// 拷进来是最省事且不污染硬件工程根目录的做法。
//
// ⚠️ site/docs/ 下的文件是**生成物**,别在里面改内容 —— 改 D:\DCDC\需求文档.md。
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const ROOT = path.resolve(here, '../..')
const OUT = path.resolve(here, '../docs')
/** [源文件, 站点内路径] */
const PAGES = [
['需求文档.md', 'index.md'],
['固件开发计划.md', 'plan.md']
]
fs.mkdirSync(OUT, { recursive: true })
for (const [src, dst] of PAGES) {
const from = path.join(ROOT, src)
const to = path.join(OUT, dst)
if (!fs.existsSync(from)) {
console.error(`[sync-docs] 找不到源文件:${from}`)
process.exit(1)
}
const body = fs.readFileSync(from, 'utf8')
const prev = fs.existsSync(to) ? fs.readFileSync(to, 'utf8') : null
if (prev === body) {
console.log(`[sync-docs] 未变化 ${src}`)
continue
}
fs.writeFileSync(to, body)
console.log(`[sync-docs] 已同步 ${src} -> docs/${dst} (${body.length} 字符)`)
}