<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
	<channel>
		<title>大飞的技术博客</title>
		<link>https://shenyifei.com</link>
		<description>大飞的个人技术博客:IoT/嵌入式、Web 全栈与 AI 应用的工程实践记录。</description>
		<language>zh-CN</language>
		<lastBuildDate>Fri, 04 Sep 2026 03:55:20 GMT</lastBuildDate>
		<atom:link href="https://shenyifei.com/rss.xml" rel="self" type="application/rss+xml" xmlns:atom="http://www.w3.org/2005/Atom"/>
		<item>
			<title>给深度睡眠的设备记时间:RX8010SJ RTC 驱动实录</title>
			<link>https://shenyifei.com/posts/rx8010-rtc-esp32c3</link>
			<guid isPermaLink="true">https://shenyifei.com/posts/rx8010-rtc-esp32c3</guid>
			<pubDate>Thu, 03 Sep 2026 00:00:00 GMT</pubDate>
			<description>为什么深度睡眠的设备需要一颗外部 RTC:RX8010SJ 的 BCD 时间寄存器、burst 读写消除撕裂、VL 位判断时间可信,以及挂在别人总线上的驱动设计。</description>
			<category>ESP32</category>
			<category>RTC</category>
			<category>嵌入式</category>
			<content:encoded><![CDATA[<p>PocketOracle 每次醒来都想回答一个问题:「现在几点」。ESP32-C3 深度睡眠等于一次
重启,内部定时器全部失忆,芯片自己不知道墙上时间。所以板上加了一颗 EPSON
RX8010SJ 实时钟(I2C 地址 0x32),主控睡觉时它靠自己的电源继续走时,醒来一问
便知。这篇文章记录这颗 RTC 的驱动落地过程。</p>
<p>先说一个 bring-up 花絮:原理图/BOM 上曾把它标成 RX8025T。两者都是爱普生的
I2C RTC,名字像双胞胎,寄存器布局却完全不同(RX8010 的时间寄存器从 0x10
开始,RX8025T 从 0x00 开始)。驱动落地前一定要按实际芯片的手册来——最终
确认板上丝印对应的是 RX8010SJ。</p>
<h2>挂在别人的总线上</h2>
<p>RX8010SJ 和加速度计 SC7A20 共享同一条 I2C 总线。ESP-IDF 新版 i2c_master
驱动的模型是「总线」和「设备」两级:总线创建一次,设备各自往上挂。项目里
让 SC7A20 的驱动负责创建总线,RTC 驱动通过一个访问器拿现成的总线句柄:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">esp_err_t</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> rx8010_init</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">void</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">{</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (s_ok) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> ESP_OK;</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">    // 总线由 sc7a20_init() 创建,必须先跑</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">    i2c_master_bus_handle_t</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> bus </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> sc7a20_get_i2c_bus</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">();</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (bus </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">==</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> NULL</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> ESP_ERR_INVALID_STATE;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">    i2c_device_config_t</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> cfg </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">        .dev_addr_length </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> I2C_ADDR_BIT_LEN_7,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">        .device_address </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> 0x</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">32</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">        .scl_speed_hz </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 100000</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">    };</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">    // 挂设备 → 读一次 CTRL 确认应答 → 清 FLAG</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span></code></pre>
<p>「RTC 组件依赖加速度计组件」看着有点怪,但在这块板上总线的上拉配置、
深睡眠 hold 都是加速度计 bring-up 时一并处理的,让总线只有一个创建者,
比两个组件各自抢着建总线干净。代价是初始化顺序有了隐式约定:
<code>sc7a20_init()</code> 必须先于 <code>rx8010_init()</code>。</p>
<h2>时间寄存器:BCD 码</h2>
<p>时间落在 7 个连续寄存器里,数值全是 BCD 码(每半个字节一位十进制数):</p>
<table>
<thead>
<tr>
<th>寄存器</th>
<th>地址</th>
<th>内容</th>
</tr>
</thead>
<tbody>
<tr>
<td>SEC/MIN/HOUR</td>
<td>0x10–0x12</td>
<td>秒/分/时,HOUR 用 24 小时制</td>
</tr>
<tr>
<td>WDAY/MDAY/MON/YEAR</td>
<td>0x13–0x16</td>
<td>星期/日/月/年(年 = 2000+值)</td>
</tr>
</tbody>
</table>
<p>转换只需要两个宏:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">#define</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> BCD_TO_DEC</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">x</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)  (((x) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">>></span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 4</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">*</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 10</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> +</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> ((x) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">&#x26;</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> 0x</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">0F</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">))</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">#define</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> DEC_TO_BCD</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">x</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)  ((((x) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">/</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 10</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">&#x3C;&#x3C;</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 4</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">|</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> ((x) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">%</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 10</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">))</span></span></code></pre>
<p>读取时还要按位掩码剥离标志位:秒寄存器的 bit7 是 VL 位(下文主角),
小时的 bit7 是 12/24 制标记。掩码忘了剥,时间就会偶尔「跳」出一个怪值。</p>
<h2>读时间:burst 读消除撕裂</h2>
<p>第一直觉是逐个寄存器读 7 次。但这有时间撕裂问题:读秒和读分之间如果
恰好发生进位(59 秒跳下一分),拼出来的就是一个从未存在过的时间。
解法是把 7 个字节压缩进一条 I2C 事务连续读出,进位无法再插进读取
过程的中间:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">uint8_t</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> reg </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> REG_SEC, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">buf</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">[</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">7</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">];</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">i2c_master_transmit_receive</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(s_dev, </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">&#x26;</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">reg</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">1</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, buf, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">7</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">100</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">);</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">   // 一次读 0x10-0x16</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">time</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">-></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">vl     </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">buf</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">[</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">0</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">] </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">>></span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 7</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">&#x26;</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 1</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">;</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">time</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">-></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">second </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> BCD_TO_DEC</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">buf</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">[</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">0</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">] </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">&#x26;</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> 0x</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">7F</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">);</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">// ...</span></span></code></pre>
<p>写时间同理用 burst 写,并且前后要包一层 RESET 保护(下一节)。</p>
<h2>写时间:RESET 位 + burst 写</h2>
<p>更新时间不能对着正在走数的寄存器直接写。RX8010 的流程是:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">wr</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(REG_CTRL, </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">0x</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">40</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">);</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">          // 1. CTRL 的 RESET 位置 1,暂停走时</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">// 2. 一条事务 burst 写 7 个时间寄存器,VL 位顺手写 0(清除)</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">uint8_t</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70"> buf</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">[</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">8</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">] </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> { REG_SEC, bcd_sec, bcd_min, bcd_hour,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">                   weekday </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">&#x26;</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> 0x</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">07</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, bcd_day, bcd_mon, bcd_year };</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">i2c_master_transmit</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(s_dev, buf, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">8</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">100</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">);</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">wr</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(REG_CTRL, </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">0x</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">00</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">);</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">          // 3. RESET 清零,时钟恢复走动</span></span></code></pre>
<p><code>set_time</code> 入口还做了参数范围校验(秒 0–59、月 1–12……),非法值直接
拒绝——BCD 码写进一个 70 的「分钟」,芯片不会替你报错。</p>
<h2>VL 位:时间可不可信,芯片自己知道</h2>
<p>RX8010 的妙处是 VL(Voltage Low)标志:一旦芯片电源跌破维持走时的下限,
它把秒寄存器 bit7 置 1,并一直保留到软件主动写时间清除。也就是说,
<strong>时间是否经历过掉电,芯片比你清楚</strong>:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">bool</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> rx8010_is_valid</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">void</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">{</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">    rx8010_time_t</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> t;</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">rx8010_get_time</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">&#x26;</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">t) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">!=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> ESP_OK) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">return</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> false</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">;</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    return</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> !</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">t.vl;</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">   // VL=0 才可信</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span></code></pre>
<p>主流程每次唤醒都检查一遍:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">!</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">rx8010_is_valid</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">()) {</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">    ESP_LOGW</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(TAG, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"RTC time invalid (VL set) — battery may have been disconnected"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">);</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span></code></pre>
<p>没有 VL 位之前,「设备换过电池、时间回到了默认值」这种事只能靠猜
(比如判断年份是不是 2000);有了它,一句话就能把不可信时间拦在门外。
开机自检(selftest)也把 <code>rx8010_init → get_time → is_valid</code> 串成一项
检查,连时间值一起打到串口日志里,pytest 直接拿这些行做断言。</p>
<h2>初始化顺手做的两件事</h2>
<ul>
<li>读一次 CTRL 寄存器当「在不在」探测——总线通、芯片应答才算初始化成功;</li>
<li>清 FLAG 寄存器(AF/TF/UF,报警/定时器/更新标志),保证从干净状态开始。</li>
</ul>
<h2>小结</h2>
<p>外部 RTC 驱动的代码量很小(200 行以内),真正花心思的是三件事:按实际
芯片手册核对寄存器表(别信 BOM 上的名字)、burst 读写保证一致性、用 VL
位回答「这时间敢不敢用」。对一个每天醒几次的深度睡眠设备来说,这颗
几毛钱的芯片就是它与世界时间唯一的连线。</p>]]></content:encoded>
		</item>
		<item>
			<title>墨水屏设备的电源账本:ADC 测电压、电量估算与低电策略</title>
			<link>https://shenyifei.com/posts/battery-power-esp32c3</link>
			<guid isPermaLink="true">https://shenyifei.com/posts/battery-power-esp32c3</guid>
			<pubDate>Thu, 03 Sep 2026 00:00:00 GMT</pubDate>
			<description>给电池设备做电源管理:16 点中位数采样 + eFuse 校准测电压、开漏 CHRG 脚识别充电、OCV 表插值估算电量,以及一套「测不准也不惩罚用户」的降级策略。</description>
			<category>ESP32</category>
			<category>电源</category>
			<category>嵌入式</category>
			<content:encoded><![CDATA[<p>深度睡眠解决了「省电」(见<a href="/posts/esp32c3-deep-sleep-wakeup">上一篇</a>),
但电池设备还差两件事:<strong>测得准</strong>——还剩多少电,要不要提醒用户;<strong>充得明</strong>
——现在是不是在充电。PocketOracle 用一颗 TP4054 充电芯片加一条电阻分压
支路,把这笔电源账算清了。这篇文章讲硬件、测量、估算和它们驱动的产品策略。</p>
<h2>硬件:一条分压支路 + 一个开漏指示脚</h2>
<ul>
<li><strong>电池电压采样</strong>:Li-ion 电池经 1MΩ / 2.2MΩ 电阻分压接到 GPIO4
(ADC1_CH4)。分压比 0.6875,反算电池电压要乘 32/22;</li>
<li><strong>充电检测</strong>:TP4054 的 CHRG 脚是开漏输出,充电时拉低,接在 GPIO21
(内部上拉),读电平即知状态;</li>
<li>充电芯片自带过充保护,满电 4.2V。</li>
</ul>
<p>GPIO21 有个坑要记在本子上:它是 <strong>UART0 的 TX 脚</strong>,带电池烧录固件时,
如果电池恰好正在充电,CHRG 的电平和串口 TX 驱动会打架。烧录前拔电池,
或者充完电再烧。</p>
<h2>测量:中位数 + 曲线拟合校准</h2>
<p>ESP32-C3 的 ADC 以不准出名,直接单次采样的读数能抖出一百多毫伏,反映到
电量百分比上就是好几个点的跳动。驱动用了三层降噪:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">int</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> battery_measure_mv</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">void</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">{</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">    // 1. 建 ADC 单元,12bit + 12dB 衰减</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">    // 2. eFuse 曲线拟合校准(raw → 节点 mV,芯片出厂曲线烧在 eFuse 里)</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">    // 3. 连采 16 次 → 插入排序 → 取中位数(第 7/8 个的平均)</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    int</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> node_mv </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> mid_raw </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">*</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 3300</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> /</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 4095</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">;</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">        // 校准不可用时的退化公式</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (cali) </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">adc_cali_raw_to_voltage</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(cali, mid_raw, </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">&#x26;</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">node_mv);</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    int</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> bat_mv </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> node_mv </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">*</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 32</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> /</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 22</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">;</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">             // 还原分压前的电池侧电压</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (bat_mv </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">></span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 4200</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) bat_mv </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 4200</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">;</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">           // 钳到 Li-ion 满电</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> bat_mv;</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span></code></pre>
<ul>
<li><strong>16 点中位数</strong>:比平均值稳——毛刺样本被排序挤到两头,影响不到中位数;</li>
<li><strong>曲线拟合校准</strong>:C3 的 eFuse 里烧了出厂 ADC 曲线,
<code>adc_cali_create_scheme_curve_fitting</code> 一行接入;校准方案创建失败时
退化回线性换算,日志里会告警;</li>
<li>分压支路的另一个细节:12dB 衰减下节点侧量程 0–3.6V,折算电池侧约
5.2V——4.2V 的满电锂电池离削顶还很远。(板卡注释里曾有一句「3.6V 以上
会削顶」,是理解错了量程主体,已纠正。)</li>
</ul>
<p>还有一个深度睡眠设备特有的风格:<code>battery_measure_mv()</code> 每次调用都新建
ADC 单元、用完即删。因为每次唤醒都是一次重启,不存在「复用上次的句柄」,
无状态反而是对的。</p>
<h2>电量估算:OCV 表 + 线性插值</h2>
<p>从电压算百分比,正经办法是库仑计,但那需要额外芯片和采样电阻。对这台
「99% 时间在深度睡眠、醒来量一次」的设备,开路电压(OCV)查表就够了——
测量时刻设备几乎不带负载,端电压极接近开路电压:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">static</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> const</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> struct</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> { </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">int</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> mv; </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">int</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> pct; } SOC_TABLE</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">[]</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">    {</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">3000</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">0</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}, {</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">3300</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">5</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}, {</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">3500</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">10</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}, {</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">3600</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">20</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">},</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">    {</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">3730</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">40</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}, {</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">3850</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">60</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}, {</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">3970</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">80</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}, {</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">4100</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">100</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">},</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">};</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">int</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> battery_soc</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">int</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70"> mv</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">   // 表内线性插值,两端钳位 0..100</span></span></code></pre>
<p>锚点取自 Li-ion 放电曲线的拐点:3.5–3.7V 之间电压掉得慢,3.5V 以下
断崖。插值让百分比不至于一格一格跳。</p>
<p>代码组织上有个刻意的设计:<code>battery_soc()</code> / <code>battery_level()</code> 所在的
battery_core.c <strong>零 ESP-IDF 依赖</strong>,在 PC 上直接被
<code>tools/test_battery_core.py</code> 跑断言,不用为改一行插值逻辑就烧一次板子。</p>
<h2>充电检测:一个脚,两个坑</h2>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">battery_charge_t</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> battery_charge</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">void</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">{</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">    // CHRG 开漏:内部上拉,充电时被 TP4054 拉低</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">    gpio_config_t</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> io </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">        .pin_bit_mask </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 1</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">ULL</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> &#x3C;&#x3C;</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> PIN_CHRG,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">        .mode </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> GPIO_MODE_INPUT,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">        .pull_up_en </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> GPIO_PULLUP_ENABLE,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">    };</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">    gpio_config</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">&#x26;</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">io);</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    return</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> gpio_get_level</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(PIN_CHRG) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">==</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 0</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> ?</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> BATTERY_CHARGE_YES </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">:</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> BATTERY_CHARGE_NO;</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span></code></pre>
<p>配置失败返回 UNKNOWN 而不是硬猜——调用方对 UNKNOWN 的处理是「当作没在
充电但不做低电动作」,比错误地宣称「正在充电」安全。</p>
<h2>策略:测不准也不惩罚用户</h2>
<p>测量值最终驱动的是几个用户看得见的行为,每条都为「传感器不可靠」留了退路:</p>
<table>
<thead>
<tr>
<th>情况</th>
<th>设备行为</th>
</tr>
</thead>
<tbody>
<tr>
<td>正常</td>
<td>屏幕右上角画电池图标:外壳 + 按 SOC/20 填充的 5 段电量条</td>
</tr>
<tr>
<td>充电中</td>
<td>图标换成闪电符号;<strong>视为 NORMAL</strong>,跳过一切低电动作</td>
</tr>
<tr>
<td>测量失败(返回 -1)</td>
<td>只画外壳;电量按 4200mV 处理——不因传感器故障吓唬用户</td>
</tr>
<tr>
<td>LOW(≤3450mV)</td>
<td>图标下加 "LOW" 角标;巡检间隔从 12 小时缩到 1 小时,尽快发现临界电量</td>
</tr>
<tr>
<td>CRITICAL(≤3300mV)</td>
<td>整屏画 4 倍放大的空心电池 + 「请充电」,然后睡 12 小时</td>
</tr>
</tbody>
</table>
<p>低电时<strong>缩短</strong>巡检周期看着反直觉(不是更费电吗),但这是电量曲线的断崖区:
从 LOW 掉到 CRITICAL 可能就一两天,每小时醒来一次能保证设备在彻底断电前
把「请充电」亮出来,而不是默默死掉。这段时间总共也醒不了几十次,代价可以
忽略。</p>
<p>电量图标本身是构建期生成的 1bpp 位图(<code>tools/gen_batt_icons.py</code> 生成
icon_data.c),和字库同一套管线;「请充电」三个字则直接复用文章字库的
16px 子集。</p>
<h2>小结</h2>
<p>这套电源管理的总代码不到 300 行,账本逻辑却层层设防:采样靠中位数,
换算靠出厂校准,估算靠 OCV 表,策略靠「失败即宽松」。电池设备的电源
读数永远带噪声,设计时先想好每一个读数失败时的行为,比把精度再抠高
一个百分点重要得多。</p>]]></content:encoded>
		</item>
		<item>
			<title>datasheet 说 152×152,实测 200×200:点亮 UC8253 墨水屏的三个坑</title>
			<link>https://shenyifei.com/posts/uc8253-epaper-bringup</link>
			<guid isPermaLink="true">https://shenyifei.com/posts/uc8253-epaper-bringup</guid>
			<pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
			<description>给电子求签器换屏的实录:分辨率被 datasheet 坑了、BUSY 极性反直觉、SPI 分块传输把命令流切碎,最后一套能跑的 UC8253 双平面刷新流程。</description>
			<category>ESP32</category>
			<category>墨水屏</category>
			<category>嵌入式</category>
			<content:encoded><![CDATA[<p>PocketOracle(电子抽签器)的第一版板子用 SSD1680 控制器的 1.54 寸墨水屏,驱动照
datasheet 写得很顺。换到新玻璃 E0154A04-BF-S(UltraChip UC8253 家族控制器)之后,
同一套思路全部失效——不是代码写错了,是 datasheet 在骗人。这篇文章记录三个坑和
最终跑通的驱动流程,给同样在 UC8253 上挣扎的人省点时间。</p>
<p>先交代硬件:</p>
<ul>
<li>屏幕:E0154A04-BF-S,1.54 寸黑白,UC8253 家族控制器;</li>
<li>接线:SPI 四线(SCK/MOSI/CS,没有 MISO),外加 DC(命令/数据选择)、RST、BUSY
三个控制脚;</li>
<li>主控:ESP32-C3,硬件 SPI,10 MHz。</li>
</ul>
<h2>坑一:datasheet 的分辨率是错的</h2>
<p>A01 版 datasheet 写的是 152×152、每字节 8 像素。按这个参数画图,右半边花屏,
条纹间距也对不上。</p>
<p>验证方法很土:画一张「标尺图」——每隔固定数量的数据字节画一道刻度竖线,烧进屏幕
看刻度往哪儿漂。实测刻度每 8 行数据向右漂 16 像素,反推控制器显存的行距是
50 字节/行(显存是 2 bit/像素),50 × 8 ÷ 2 = <strong>200</strong>,这才是真实的宽度。</p>
<pre><code>datasheet A01:152×152,8 px/byte   ← 别信
实测 A04 玻璃:200×200            ← 显存行距 50 B/row
</code></pre>
<p>于是驱动里的定义是:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">#define</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> EPD_WIDTH</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">  200</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">#define</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> EPD_HEIGHT</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 200</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">#define</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> EPD_FB_SIZE</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (EPD_WIDTH </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">/</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 8</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> *</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> EPD_HEIGHT)</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">   // 5000 字节/平面</span></span></code></pre>
<p>教训:拿到陌生玻璃,先烧标尺图量分辨率,再写渲染代码。同一块屏不同批次
(A01 → A04),关键参数都可能变。</p>
<h2>坑二:BUSY 低电平才是忙</h2>
<p>UC8253 的 BUSY 语义(datasheet Note 5-4,实测一致):<strong>LOW = busy,HIGH = idle</strong>。
和很多「BUSY 拉高才算忙」的习惯相反,轮询等的是高电平:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">#define</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> EPD_BUSY_IDLE_LEVEL</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 1</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">static</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> esp_err_t</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> wait_busy</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">int</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70"> timeout_ms</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">{</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    for</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">int</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> waited </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 0</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">; waited </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">&#x3C;</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> timeout_ms; waited </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">+=</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 10</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">        if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">gpio_get_level</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(PIN_EPD_BUSY) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">==</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> EPD_BUSY_IDLE_LEVEL) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> ESP_OK;</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">        vTaskDelay</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">pdMS_TO_TICKS</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">10</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">));</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">    }</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">    ESP_LOGW</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(TAG, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"BUSY stuck at </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">%d</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> after </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">%d</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> ms"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">,</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">             gpio_get_level</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(PIN_EPD_BUSY), timeout_ms);</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> ESP_ERR_TIMEOUT;</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span></code></pre>
<p>顺带一个 FreeRTOS 细节:轮询间隔必须不小于一个 tick。<code>CONFIG_FREERTOS_HZ=100</code>
时 tick 恰好 10 ms,<code>vTaskDelay(pdMS_TO_TICKS(10))</code> 是一次真正的让出;间隔再短
就截断成 <code>vTaskDelay(0)</code>,等于不让出 CPU,整个超时预算在几毫秒内烧完,BUSY 还没
等到就被误报超时。</p>
<h2>坑三:分块 SPI 传输会把命令流切碎</h2>
<p>第一版驱动把 5000 字节的显存拆成 64 字节一段分批发(以为和别家 SPI 屏一样需要
分块),结果图案永远缺一块。原因是 ESP32 的硬件 CS 在每个事务之间自动拉高,而
UC8253 的 CS 上升沿会复位控制器内部的写入状态机——控制器以为你要开始发新命令了,
于是大块显存写一半就被「截胡」。</p>
<p>解法反而更简单:整个 payload 一次事务发完,让 DMA 去搬运:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">static</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> esp_err_t</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> spi_tx</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">const</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> uint8_t</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> *</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">data</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">int</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70"> len</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">{</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">    spi_transaction_t</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> t </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">        .length </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 8</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> *</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">size_t</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)len,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">        .tx_buffer </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> data,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">    };</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    return</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> spi_device_transmit</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(s_spi, </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">&#x26;</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">t);</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">   // 一次事务,DMA 链式搬完</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span></code></pre>
<p>SPI 总线初始化时把 <code>max_transfer_sz</code> 设成 5000(一整个平面),DMA 通道用
<code>SPI_DMA_CH_AUTO</code> 自动分配。C3 的 DMA 一次链式搬运 5 KB 毫无压力。</p>
<h2>刷新:DTM1/DTM2 双平面</h2>
<p>UC8253 的全刷流程(datasheet §11.1 的「LUT from OTP」路径)每次要写两个平面:</p>
<table>
<thead>
<tr>
<th>步骤</th>
<th>命令</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>0x10 (DTM1)</td>
<td>旧帧——当前玻璃上的内容</td>
</tr>
<tr>
<td>2</td>
<td>0x13 (DTM2)</td>
<td>新图像</td>
</tr>
<tr>
<td>3</td>
<td>0x04 (PON)</td>
<td>打开电源,等 BUSY 空闲</td>
</tr>
<tr>
<td>4</td>
<td>0x12 (DRF)</td>
<td>启动刷新,等 BUSY 空闲(实测最长 15 s)</td>
</tr>
</tbody>
</table>
<p>控制器对比两个平面,为每个像素选择对应的驱动波形(白变黑、黑变白、保持不变各有
自己的脉冲序列),这就是墨水屏刷新时「闪一下」的来源。数据格式 1bpp,1=白、0=黑,
MSB 在前。</p>
<p>驱动里用一个静态数组跟踪「玻璃上现在是什么」:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">static</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> uint8_t</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70"> s_old_plane</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">[EPD_FB_SIZE];</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">   // 专供 DTM1</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">esp_err_t</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> epd_full_refresh</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">const</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> uint8_t</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> *</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">framebuffer</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">{</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">    esp_err_t</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> r </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> epd_cmd</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">0x</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">10</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, s_old_plane, EPD_FB_SIZE);</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">  // DTM1: 旧帧</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (r </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">==</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> ESP_OK) r </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> epd_cmd</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">0x</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">13</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, framebuffer, EPD_FB_SIZE);</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">  // DTM2: 新图</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (r </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">!=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> ESP_OK) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> r;</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">    memcpy</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(s_old_plane, framebuffer, EPD_FB_SIZE);</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">          // 记录玻璃状态</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">    r </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> epd_cmd</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">0x</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">04</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">NULL</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">0</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">);</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">              // PON</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (r </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">==</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> ESP_OK) r </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> wait_busy</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">3000</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">);</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (r </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">!=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> ESP_OK) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> r;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">    r </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> epd_cmd</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">0x</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">12</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">NULL</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">0</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">);</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">              // DRF</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (r </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">==</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> ESP_OK) r </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> wait_busy</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">15000</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">);</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> r;</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span></code></pre>
<p>上电后玻璃是全白的,所以 <code>epd_init</code> 成功后把 <code>s_old_plane</code> 填满 0xFF(1=白),
保证第一次刷新时 DTM1 和玻璃的真实状态一致。</p>
<h2>别覆盖 OTP 里的出厂配置</h2>
<p>UC8253 出厂把 LUT(驱动波形)、PWR(电源时序)、CDI(驱动强度)、PLL 都烧进了 OTP,
上电自动加载。除非你比屏幕厂更懂这块玻璃,否则别发命令去覆盖它们——覆盖错了轻则
灰底、重则花屏。我们的刷新流程完全依赖 OTP 默认值,实测干净利落,一毫秒的调参
都没花。</p>
<h2>睡觉:POF + DSLP</h2>
<p>显示结束后两步断电,控制器功耗归零:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">void</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> epd_sleep</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">void</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">{</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">    epd_cmd</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">0x</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">02</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">NULL</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">0</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">);</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">                  // POF: power off</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">    vTaskDelay</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">pdMS_TO_TICKS</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">20</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">));</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    static</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> const</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> uint8_t</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> DSLP</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">[]</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> {</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">0x</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">A5</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">};</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">    // 0xA5 是 datasheet 规定的魔数</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">    epd_cmd</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">0x</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">07</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, DSLP, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">1</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">);</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">                  // DSLP: deep sleep</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span></code></pre>
<p>墨水屏的价值在这一步兑现:断电后画面保持,屏幕和主控一起睡,下次唤醒只管画新帧。
对一个 99% 时间在睡觉的电池设备来说,「不刷新就不耗电」是选墨水屏而不是 OLED 的
根本理由。</p>
<h2>小结</h2>
<p>三个坑的共性是「datasheet/直觉和实物不符」:分辨率要量出来,BUSY 极性要实测确认,
CS 行为要读芯片手册的注释。驱动最终不到 160 行,却是在标尺图、逻辑分析和一次一次
烧录里磨出来的。嵌入式外设 bring-up 没有捷径,但每一次踩坑都值得记下来——下一次
换屏,大概率还会用上。</p>]]></content:encoded>
		</item>
		<item>
			<title>Next.js 静态导出的边界与取舍</title>
			<link>https://shenyifei.com/posts/nextjs-static-export-notes</link>
			<guid isPermaLink="true">https://shenyifei.com/posts/nextjs-static-export-notes</guid>
			<pubDate>Sat, 29 Aug 2026 00:00:00 GMT</pubDate>
			<description>Next.js 静态导出(output: &apos;export&apos;)意味着放弃什么、换来什么,以及什么项目适合静态导出、什么项目别碰的判断清单。</description>
			<category>Web</category>
			<category>Next.js</category>
			<category>建站</category>
			<content:encoded><![CDATA[<p>我的几个站全部用 Next.js 的 <code>output: 'export'</code> 静态导出:构建产物是纯
HTML/CSS/JS,扔到任意静态服务器就能跑,没有 Node 进程、没有服务器运维。
这个选择不是免费的,这篇文章把边界讲清楚。</p>
<h2>换来了什么</h2>
<ul>
<li><strong>部署极简</strong>:<code>out/</code> 目录整个拷上服务器/OSS 就完事,不需要 PM2、
不需要容器、不需要考虑 Node 版本漂移;</li>
<li><strong>天然抗流量</strong>:静态文件随便被 CDN 缓存,官网场景下几乎不存在
「被打挂」的概念;</li>
<li><strong>安全面小</strong>:没有服务端运行时,攻击面收缩到静态文件本身;</li>
<li><strong>构建期报错</strong>:所有数据获取都发生在 <code>next build</code>,内容错了构建直接
失败,坏内容到不了线上。</li>
</ul>
<h2>失去了什么</h2>
<p>静态导出本质是把所有「动态」挪到构建期或客户端,以下能力直接不可用:</p>
<table>
<thead>
<tr>
<th>能力</th>
<th>静态导出下的状态</th>
<th>替代方案</th>
</tr>
</thead>
<tbody>
<tr>
<td>服务端 API Routes</td>
<td>不可用(除非 <code>force-static</code>)</td>
<td>外部服务/表单代收</td>
</tr>
<tr>
<td>ISR 增量再生</td>
<td>不可用</td>
<td>重新构建重新发布</td>
</tr>
<tr>
<td>请求级动态渲染</td>
<td>不可用</td>
<td>客户端 fetch</td>
</tr>
<tr>
<td>中间件 Middleware</td>
<td>不可用</td>
<td>CDN 边缘规则</td>
</tr>
<tr>
<td>图片优化服务</td>
<td>关闭(<code>unoptimized</code>)</td>
<td>构建期压好图</td>
</tr>
</tbody>
</table>
<p>对品牌官网和技术博客,这个清单几乎无痛:内容发布频率低(重新构建即
「ISR」),交互都在客户端,表单可以走第三方代收再通知到企微/邮件。</p>
<h2>常用的「伪装动态」手法</h2>
<p><strong>1. Route Handler + force-static 生成派生文件。</strong>
RSS、sitemap 这类「构建期可确定」的动态产物:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">// app/rss.xml/route.ts</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">export</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> dynamic</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> "force-static"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">export</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> async</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> function</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> GET</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">() {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  return</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> new</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> Response</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">buildRss</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(), {</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">    headers: { </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"Content-Type"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"application/rss+xml; charset=utf-8"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> },</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">  });</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span></code></pre>
<p>构建时执行一次,输出落成真正的 <code>out/rss.xml</code> 静态文件。</p>
<p><strong>2. generateStaticParams 展开所有路径。</strong>
<code>/posts/[slug]</code> 这类动态路由必须在构建期穷举:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">export</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> function</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> generateStaticParams</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">() {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  return</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> getAllPosts</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">().</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">map</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">((</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">post</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> ({ slug: post.slug }));</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span></code></pre>
<p>忘了写这个函数,动态路由在静态导出下会被静默跳过——不报错,只是
产物里没有那些页面,发布后才发现 404。</p>
<p><strong>3. 数据获取全部收口到构建期模块。</strong>
文件读进内存、校验、缓存,页面组件只消费纯函数结果。好处是测试
不需要起 Next,数据层单测就是普通 Node 测试。</p>
<h2>判断 checklist</h2>
<p>什么项目适合静态导出:</p>
<ul>
<li>内容发布频率低,发新内容 = 触发一次重新构建可以接受;</li>
<li>没有按请求变化的个性化内容(登录态、A/B、地理定向);</li>
<li>搜索/评论/表单这类动态能力愿意交给第三方或客户端方案;</li>
<li>有 CI 能自动构建发布(没有的话,发布 = 手动跑命令)。</li>
</ul>
<p>反过来,只要有任何一条硬性不满足——比如需要 SSR 做 SEO 的个性化
首页——就老实用 <code>next start</code> 或托管到 Vercel,别跟 <code>output: 'export'</code>
较劲,它的边界是设计如此,不是 bug。</p>
<h2>一句话结论</h2>
<p>静态导出适合「内容即产品」的站:官网、博客、文档站。它把复杂度从
运行时挪到了构建期,而构建期复杂度是靠 CI 和纪律管理的,比运行时
复杂度便宜得多。</p>]]></content:encoded>
		</item>
		<item>
			<title>ESP32-C3 怎么从深度睡眠醒来:GPIO0-5、定时器与一坑三折</title>
			<link>https://shenyifei.com/posts/esp32c3-deep-sleep-wakeup</link>
			<guid isPermaLink="true">https://shenyifei.com/posts/esp32c3-deep-sleep-wakeup</guid>
			<pubDate>Sat, 29 Aug 2026 00:00:00 GMT</pubDate>
			<description>电池设备的唤醒方案演变实录:ESP32-C3 只有 GPIO0-5 能当深度睡眠唤醒源、唯一接进 RTC 域的中断脚恰好是坏的、固件用 3 秒轮询硬扛,直到硬件改版把按键挪进 RTC 域。</description>
			<category>ESP32</category>
			<category>低功耗</category>
			<category>嵌入式</category>
			<content:encoded><![CDATA[<p>PocketOracle 是电池供电的:绝大多数时间深度睡眠,按键(或亮屏时的摇晃)才点亮
墨水屏抽一签。「怎么醒」这件事在这个项目上走完了完整的弧线:先踩到 ESP32-C3
的硬件红线,再赶上唯一接进 RTC 域的中断脚恰好有硬件缺陷,固件用定时器轮询
硬扛了几个版本,最后靠硬件改版收尾。这篇文章把整条线捋一遍。</p>
<p>现在的状态机长这样:</p>
<pre><code>深度睡眠 ──KEY1 按下 / 定时器──▶ 醒来,抽签上屏 ──3 秒无操作──▶ 回去睡
</code></pre>
<h2>硬件红线:只有 GPIO0-5 能做唤醒源</h2>
<p>ESP32-C3 深度睡眠的 GPIO 唤醒只认 GPIO0–5(RTC 域引脚),其他引脚睡死过去就再也
叫不醒了。这块板子把两条路都踩了一遍:</p>
<ul>
<li>旧版布线里,加速度计 INT1 在 <strong>GPIO19</strong>、一个按键在 <strong>GPIO20</strong>——运行时当
输入随便用,但<strong>不能</strong>做睡眠唤醒源;</li>
<li>唯一接进 RTC 域的是加速度计 INT2(GPIO2),它被指定为唤醒引脚——偏偏还是
个坏的(下文);</li>
</ul>
<p>唤醒 API 是<strong>电平触发</strong>,不是边沿:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">esp_deep_sleep_enable_gpio_wakeup</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">1</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">ULL</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> &#x3C;&#x3C;</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> PIN_KEY1, ESP_GPIO_WAKEUP_GPIO_LOW);</span></span></code></pre>
<p>设计教训最终由改版落实:新版 PCB 把主按键(SW1,固件里叫 KEY1)挪到了
<strong>GPIO2</strong>,按键唤醒从此有了合法身份;摇晃只在亮屏窗口内当「换签」手势,
不再承担唤醒。</p>
<h2>醒来第一件事:谁叫醒的我</h2>
<p>深度睡眠醒来等于一次重启:ROM → 二级 bootloader → <code>app_main</code> 从头跑,RAM
内容不保留。入口先用唤醒原因分诊:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">bool</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> first_boot </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> esp_sleep_get_wakeup_cause</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">() </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">==</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> ESP_SLEEP_WAKEUP_UNDEFINED;</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (first_boot) {</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">    enter_deep_sleep</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(SLEEP_US_12H);</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">   // 装电池后的第一次开机:布置唤醒源,立刻回去睡</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">;</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">// 走到这里:KEY1 唤醒或定时器到点 —— 量电量、抽签、上屏</span></span></code></pre>
<p>冷启动分支很关键:设备装电池那一刻开始,固件的任务就是布置好唤醒源然后立刻
消失,绝不能「亮一下屏表示开机」——现场没有人在看。</p>
<h2>现役方案:按键电平唤醒 + 长短两档定时器</h2>
<p>进睡前布置唤醒源:KEY1 低电平唤醒 + 一个兜底定时器——哪怕按键彻底失效,
设备也会定期醒来刷一次存在感:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">void</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> enter_deep_sleep</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">uint64_t</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70"> sleep_us</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">{</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">    // hold 住 I2C 引脚,让 RTC 域的 SC7A20 在睡眠中不掉电</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">sc7a20_init</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">() </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">==</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> ESP_OK) {</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">        gpio_hold_en</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(PIN_I2C_SDA);</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">        gpio_hold_en</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(PIN_I2C_SCL);</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">        gpio_deep_sleep_hold_en</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">();</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">    }</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">    esp_deep_sleep_enable_gpio_wakeup</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">1</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">ULL</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> &#x3C;&#x3C;</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> PIN_KEY1, ESP_GPIO_WAKEUP_GPIO_LOW);</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">    esp_sleep_enable_timer_wakeup</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(sleep_us);</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">   // 正常 12h;低电量时由上层缩短为 1h</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">    esp_deep_sleep_start</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">();</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span></code></pre>
<p>定时器间隔现在是电源策略的入参:电量正常睡 12 小时,低电量缩短到 1 小时
巡检(见<a href="/posts/battery-power-esp32c3">电源篇</a>)。</p>
<h2>弯路:唯一的唤醒脚是坏的,轮询硬扛了几个版本</h2>
<p>旧板上被寄予厚望的 INT2(GPIO2)是 stuck HIGH 的硬件缺陷——引脚被外部驱动
为高,而唤醒要等的恰恰是高电平,挂上唤醒源芯片瞬间就被自己叫醒。等改版
之前,固件切到 POLL_MODE 硬扛:</p>
<pre><code>深度睡眠(3 秒定时器)
   │ 醒来
   ▼
看 INT1(GPIO19)是否 HIGH(摇晃锁存)、按键是否 LOW
   │ 都不是 → 立刻回去睡 3 秒,循环
   │ 有触发
   ▼
抽签上屏,3 秒窗口内可连着换签
</code></pre>
<ul>
<li><strong>传感器侧照常攒事件</strong>:SC7A20 靠 I2C 引脚的 gpio_hold 在睡眠中保住供电
和配置,摇晃会把 INT1 锁存成 HIGH,等着主控来读;</li>
<li><strong>主控每 3 秒付一次「假醒」的成本</strong>:完整跑一遍 bootloader → app_main →
自检 → I2C 初始化 → 读几个电平 → 回睡,单次几百毫秒;</li>
<li><strong>代价是响应延迟</strong>:摇晃到亮屏平均晚 1.5 秒、最差 3 秒。对一个「摇签」
设备,这个延迟反而有点像抽签的仪式感。</li>
</ul>
<p>轮询方案撑到了改版那天,随后和摇晃唤醒一起整体退役——新版板上 INT2 不再
接线,唤醒职责完全交给 GPIO2 上的按键。</p>
<h2>唤醒后的世界是全新的</h2>
<p>深度睡眠唤醒等于重启,三条直接影响写代码的方式:</p>
<ol>
<li><strong>所有外设重新初始化</strong>。SPI/I2C/GPIO 配置一个都省不掉;驱动里的 <code>s_ready</code>
这类「已初始化」标志是静态变量,重启后自动归零,恰好是对的。</li>
<li><strong>gpio_hold 穿越睡眠</strong>。唤醒后先 <code>gpio_hold_dis</code> 释放,再重新配置引脚,
I2C 总线才能正常驱动——顺序反了就是「设备第二次交互就失灵」。</li>
<li><strong>想跨睡眠记状态,只能走 <code>RTC_DATA_ATTR</code> 慢速内存或 NVS</strong>。这个项目的
签文每次随机,干脆不记,少一个状态同步问题。</li>
</ol>
<h2>小结</h2>
<p>现在 <code>enter_deep_sleep</code> 缩到了十行:hold 住 I2C、挂按键唤醒、上定时器、睡。
回头看这一坑三折:GPIO0-5 的红线是布线时欠的账,轮询是给硬件缺陷打的临时
补丁,改版才是真正的还债。「硬件缺陷不可怕,可怕的是固件没有给硬件留退路」
——退路的价值不是永远走下去,而是撑到硬件修好的那天。</p>]]></content:encoded>
		</item>
		<item>
			<title>摇一摇换一签:SC7A20 运动中断调教记录</title>
			<link>https://shenyifei.com/posts/sc7a20-motion-irq</link>
			<guid isPermaLink="true">https://shenyifei.com/posts/sc7a20-motion-irq</guid>
			<pubDate>Fri, 28 Aug 2026 00:00:00 GMT</pubDate>
			<description>把 SC7A20 加速度计调到「一摇就触发、放桌上不误报」:双地址探测、内部上拉、AOI 运动检测的寄存器链路,以及重力导致中断永远锁死的坑。</description>
			<category>ESP32</category>
			<category>传感器</category>
			<category>嵌入式</category>
			<content:encoded><![CDATA[<p>PocketOracle 的核心交互是「摇一摇出签」——摇晃不直接唤醒设备,但在亮屏的
换签窗口里,摇一下就能换一张签。检测摇晃的是一颗 SC7A20 三轴加速度计
(国产芯片,LIS2DH 兼容寄存器),挂 I2C 总线。这篇文章记录从上电探测到运动
中断稳定的全过程,重点是那个差点把人逼疯的坑:不接高通滤波器,重力会让
中断永远锁死。</p>
<h2>上电:先确认芯片在</h2>
<p>SC7A20 有两个可选 I2C 地址(0x19,SDO 接高;0x18,SDO 接低)。这颗板子的 SDO
悬空,电平不确定,所以驱动把两个地址各试一遍,用 WHO_AM_I(寄存器 0x0F,固定
返回 0x11)确认身份:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">const</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> uint16_t</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> addrs</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">[]</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> { </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">0x</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">19</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">0x</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">18</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> };</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">   // SDO 悬空,两个地址都试</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">for</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">int</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> i </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 0</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">; i </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">&#x3C;</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 2</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">; i</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">++</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) {</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">    // 挂设备 → 读 WHO_AM_I → 等于 0x11 就找到了,否则摘掉换下一个</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span></code></pre>
<p>比「芯片不在」更隐蔽的是「总线上不去」。bringup 时设备怎么都扫不到,最后发现
外部 4.7k 上拉电阻根本没起作用——只有打开 ESP32 的内部上拉之后,SC7A20(0x19)
和同一条总线上的 RX8025T 时钟芯片(0x32)才有 ACK:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">i2c_master_bus_config_t</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> bus </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">    // ...</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">    .flags.enable_internal_pullup </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> true</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">,</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">   // 外部上拉缺失,内部上拉顶上</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">};</span></span></code></pre>
<p>内部上拉驱动 100 kHz 的 I2C 实测稳定;想上 400 kHz 之前,还是先把外部上拉补好。</p>
<h2>运动检测的寄存器链路</h2>
<p>SC7A20 的运动中断是一套 AOI(方向/事件检测)逻辑,从采样到 INT 引脚要过七道
配置。最终生效的是 INT1 一路,负责亮屏窗口内的「摇一摇换签」;旧板曾把同样
的配置复用到 INT2 做深度睡眠唤醒,新板改版后这条路已随摇晃唤醒一起移除:</p>
<table>
<thead>
<tr>
<th>寄存器</th>
<th>值</th>
<th>作用</th>
</tr>
</thead>
<tbody>
<tr>
<td>CTRL1 (0x20)</td>
<td>0x2F</td>
<td>ODR=10 Hz 低功耗模式,XYZ 三轴使能</td>
</tr>
<tr>
<td>CTRL2 (0x21)</td>
<td>0x01</td>
<td>高通滤波器(HPF)接入 AOI 通路——本文主角</td>
</tr>
<tr>
<td>INTx_THS (0x32/0x36)</td>
<td>0x30</td>
<td>触发阈值,约 770 mg(2 g 量程)</td>
</tr>
<tr>
<td>INTx_DUR (0x33/0x37)</td>
<td>0x05</td>
<td>持续 5 个采样 @ 10 Hz = 500 ms 才算数</td>
</tr>
<tr>
<td>INTx_CFG (0x30/0x34)</td>
<td>0x2A</td>
<td>XYZ 高事件,OR 组合(任一轴超标即算)</td>
</tr>
<tr>
<td>CTRL5 (0x24)</td>
<td>bit3/bit1</td>
<td>LIR:事件锁存,读到 SRC 才释放</td>
</tr>
<tr>
<td>CTRL3 / CTRL6</td>
<td>0x40 / 0x20</td>
<td>把 AOI1/AOI2 路由到 INT1/INT2 引脚</td>
</tr>
</tbody>
</table>
<p>阈值和时长的取舍是这套配置的灵魂:</p>
<ul>
<li><strong>THS = 0x30 ≈ 770 mg</strong>:放桌上敲键盘、隔壁关门这类低于 500 mg 的振动全部
过滤,用手腕摇一下(轻松超过 1 g 的变化)才触发;</li>
<li><strong>DUR = 5 @ 10 Hz</strong>:单次尖峰——比如设备掉在桌上的瞬间——不触发,持续
500 ms 的运动才被认定为「摇晃」。</li>
</ul>
<h2>坑:重力是最大的干扰源</h2>
<p>第一版配置没开 HPF(CTRL2 保持 0),现象很诡异:设备一进深度睡眠,INT 立刻变高;
醒来清掉锁存,下一轮又锁,永远消停不下来。</p>
<p>翻寄存器手册才想明白:AOI 检测的是「加速度是否超过阈值」,而静止平放时 Z 轴永远
扛着 +1 g 的重力,770 mg 的阈值根本罩不住它——事件条件<strong>恒成立</strong>,锁存清一次
立一次。</p>
<p>解法就一行:把高通滤波器接进 AOI 通路(CTRL2 = 0x01)。HPF 把静态重力分量滤掉,
只剩运动产生的动态变化,事件条件就只对真实的加速度变化成立了。代价是重写配置后
HPF 需要几百毫秒稳定,进睡前要留出这段时间,否则第一下是误触发。</p>
<p>这个坑的通用教训:<strong>加速度计的「静止」在原始数据里不是零,是 1 g</strong>。任何基于
绝对阈值的判断,都要先想清楚重力去哪儿了。</p>
<h2>锁存与释放:LIR 机制</h2>
<p>CTRL5 的 LIR 位决定事件的处理模型:不锁存时 INT 是内部定时的脉冲;开启 LIR 后,
INT 电平保持到软件读 SRC 寄存器为止。睡眠唤醒场景必须开 LIR——芯片要在主控睡觉
期间把事件「攒住」,等主控醒来确认:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">esp_err_t</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> sc7a20_clear_irq_int1</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">void</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">{</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    uint8_t</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> src;</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    return</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> rd</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(REG_INT1_SRC, </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">&#x26;</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">src);</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">   // 读 SRC 即释放锁存</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span></code></pre>
<p>释放时机也有讲究:换签窗口里检测到 INT1 变高之后,要先清锁存再继续等下一
次,否则同一个事件会被反复读到。项目里的做法是「边沿触发 + 触发后等 INT1
回低,期间不停清锁存」,保证一次摇晃只换一张签。</p>
<h2>睡眠时让传感器活着</h2>
<p>深度睡眠时 ESP32-C3 的 GPIO 会掉配置,但 SC7A20 必须继续采样攒事件。做法是把
I2C 两个脚用 <code>gpio_hold</code> 钉在当前电平,再打开深度睡眠期间的全局 hold:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">gpio_hold_en</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(PIN_I2C_SDA);</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">gpio_hold_en</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(PIN_I2C_SCL);</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">gpio_deep_sleep_hold_en</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">();</span></span></code></pre>
<p>对应的坑在唤醒侧:hold 的状态会活着穿过唤醒,重新初始化 I2C 之前必须先把两个脚
释放掉(<code>gpio_hold_dis</code>),否则总线被 hold 卡死,第一次唤醒之后的每次 I2C 访问
都「not responding」。这个 bug 的表象是「设备第二次摇晃就失灵」,非常误导。</p>
<h2>小结</h2>
<p>到这里整条链路稳定运行:摇晃 → 传感器锁存事件 → 主控在换签窗口里读到 INT1
电平,读 SRC 释放锁存 → 换一张签;设备放桌上,HPF 滤掉重力、阈值滤掉环境
振动,可以安安静静睡大觉。加速度计运动检测的配置项看着多,真正要调的只有
三个数:阈值、时长、HPF 开关——剩下的都是把它们正确地写进正确的寄存器。</p>]]></content:encoded>
		</item>
		<item>
			<title>一仓四站:pnpm monorepo 里养多个官网</title>
			<link>https://shenyifei.com/posts/multi-site-monorepo</link>
			<guid isPermaLink="true">https://shenyifei.com/posts/multi-site-monorepo</guid>
			<pubDate>Fri, 28 Aug 2026 00:00:00 GMT</pubDate>
			<description>四个官网住在一个仓库里:目录怎么分、依赖怎么共享、端口怎么错开,以及为什么「全都一样」反而是最优解。</description>
			<category>Web</category>
			<category>monorepo</category>
			<category>建站</category>
			<content:encoded><![CDATA[<p>手上有四个网站要维护:三个公司官网加这个博客。它们技术栈相同、部署方式相同、
维护者也是同一个人,于是都住进了一个 pnpm monorepo。这篇文章讲讲这套结构
的实际运作方式,以及几个当时犹豫过的决定。</p>
<h2>目录结构</h2>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span>website/</span></span>
<span class="line"><span>├── packages/</span></span>
<span class="line"><span>│   ├── www.yunjuiot.com.cn/   # 公司主站(端口 3000)</span></span>
<span class="line"><span>│   ├── www.elinchong.com/     # 品牌站(端口 3001)</span></span>
<span class="line"><span>│   ├── www.xunhong168.com/    # 单页官网(端口 3002)</span></span>
<span class="line"><span>│   └── www.shenyifei.com/     # 这个博客(端口 3003)</span></span>
<span class="line"><span>├── shared/                    # 跨包共享资产(pcb3d 流水线等)</span></span>
<span class="line"><span>├── scripts/                   # OG 封面、PDF 等 Python 生成脚本</span></span>
<span class="line"><span>└── docs/plans/                # 每个功能的设计文档</span></span></code></pre>
<p>每个站点是一个独立的 Next.js App Router 包,有自己的 <code>package.json</code>
和 <code>next.config.ts</code>,互不引用对方的组件。共享的只有根目录的依赖声明、
生成脚本和资产——组件级别的「复用」我刻意没做,后面说为什么。</p>
<h2>依赖:全在根上</h2>
<p>站点级 <code>package.json</code> 只声明 scripts,依赖全部挂在仓库根:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">{</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">  "name"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"www.shenyifei.com"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">  "version"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"0.1.0"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">  "private"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">true</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">  "scripts"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">: {</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">    "dev"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"next dev --turbopack -p 3003"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">,</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">    "build"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"next build"</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">  }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span></code></pre>
<p>四个站共用一个 Next.js 版本,升级时一次升级全部,不存在「A 站还在
Next 14、B 站已经 Next 15」的漂移。代价是升级需要四站都回归一遍,
但静态导出站点的回归成本低(构建过 = 大概率没事)。</p>
<h2>约定大于配置</h2>
<p>同一件事在四个站里长得一模一样:</p>
<ul>
<li><code>output: 'export'</code> 静态导出,<code>images: { unoptimized: true }</code>;</li>
<li><code>app/layout.tsx</code> 里集中管理 SEO 元数据、OG 封面、结构化数据;</li>
<li><code>sitemap.ts</code> / <code>robots.ts</code> / <code>not-found.tsx</code> 三个基础设施文件齐全;</li>
<li>端口号写死在 dev script 里,3000 起,一个站一格。</li>
</ul>
<p>约定带来的好处在改版时最明显:我知道任何一个文件的「对应物」在另外
三个站的哪个位置,AI 辅助改版时提示词都省了。</p>
<h2>为什么不做组件级共享</h2>
<p>最早考虑过把 Header/Footer/ShareBar 抽成 workspace 共享包,后来放弃了:</p>
<ol>
<li>四个站的<strong>品牌视觉完全不同</strong>,能共享的只有逻辑骨架,而骨架只有几十行;</li>
<li>抽象包一旦改坏,四个站同时挂,耦合的爆炸半径反而变大;</li>
<li>每个站代码量都不大(几百行),复用收益撑不起一层抽象的维护成本。</li>
</ol>
<p>真正值得共享的是<strong>资产和流水线</strong>:PCB 的 STEP→GLB 转换脚本、OG 封面
生成脚本,这些放 <code>shared/</code> 和 <code>scripts/</code>,谁用谁调,不产生运行时耦合。</p>
<h2>一个 pnpm 特有的坑</h2>
<p>pnpm 的 node_modules 是绝对路径 junction,某个包里有 node_modules 时
(比如 <code>shared/pcb3d</code> 自带依赖),webpack 的 FileSystemInfo 快照会把它
误处理,构建直接崩:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">// next.config.ts — 按语义并入 managedPaths 跳过快照</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">config.snapshot.managedPaths </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> [</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  ...</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(config.snapshot.managedPaths </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">??</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> []),</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">  /</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">[</span><span style="--shiki-light:#22863A;--shiki-light-font-weight:bold;--shiki-dark:#85E89D;--shiki-dark-font-weight:bold">\\</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">/]</span><span style="--shiki-light:#032F62;--shiki-dark:#DBEDFF">scripts</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">[</span><span style="--shiki-light:#22863A;--shiki-light-font-weight:bold;--shiki-dark:#85E89D;--shiki-dark-font-weight:bold">\\</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">/]</span><span style="--shiki-light:#032F62;--shiki-dark:#DBEDFF">pcb3d</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">[</span><span style="--shiki-light:#22863A;--shiki-light-font-weight:bold;--shiki-dark:#85E89D;--shiki-dark-font-weight:bold">\\</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">/]</span><span style="--shiki-light:#032F62;--shiki-dark:#DBEDFF">node_modules</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">/</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">];</span></span></code></pre>
<p>这类问题没有通用解,记住 pnpm 的 monorepo 里出现「莫名其妙的构建崩溃」,
先怀疑 junction 路径。</p>
<h2>小结</h2>
<p>monorepo 对「多个小站、一个人维护」的场景是明确的正解:依赖统一、
约定统一、部署统一。但要忍住两个冲动——不要过早抽共享组件包,
也不要为了让目录好看而引入构建复杂度。</p>]]></content:encoded>
		</item>
		<item>
			<title>从 STEP 到 model-viewer:给官网做 PCB 3D 展示</title>
			<link>https://shenyifei.com/posts/pcb-3d-pipeline</link>
			<guid isPermaLink="true">https://shenyifei.com/posts/pcb-3d-pipeline</guid>
			<pubDate>Wed, 26 Aug 2026 00:00:00 GMT</pubDate>
			<description>立创EDA 导出的 STEP 模型如何走完「三角化 → 配色 → GLB → 量化 → 网页展示」全流程,以及每一步踩过的坑。</description>
			<category>IoT</category>
			<category>Three.js</category>
			<category>工具链</category>
			<content:encoded><![CDATA[<p>公司官网要展示 PCB 板卡的 3D 模型,让客户在下单前能转着看板子。硬件同事交来的是
立创EDA 专业版导出的 STEP 文件,而网页端要的是能在浏览器里流畅渲染的 GLB。
这篇文章记录整条流水线和每一步的取舍。</p>
<h2>总体链路</h2>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span>立创EDA STEP → step2obj.py (OCCT 三角化) → obj2glb.mjs (GLB)</span></span>
<span class="line"><span>            → gltf-transform 量化 → public/models/*.glb → &#x3C;model-viewer></span></span></code></pre>
<p>分四步,每步一个独立脚本,串起来跑。</p>
<h2>第一步:STEP 三角化,为什么不用 WASM</h2>
<p>STEP 是 B-Rep 边界表示,浏览器渲染不了,先要三角化成网格。现成方案里
occt-import-js 提供 WASM 版的 OCCT,看似最省事,但实测对这种多层板 STEP
会输出空网格,问题出在它的 WASM 构建裁剪了部分几何内核能力。</p>
<p>最后用的本机 Python + <code>cadquery-ocp</code>(完整 OCCT 绑定)做三角化,
顺带解决另一个需求:按层名配色。</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D"># step2obj.py 核心逻辑(节选)</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">from</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> OCP</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">.STEPControl </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> STEPControl_Reader</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">from</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> OCP</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">.BRepMesh </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> BRepMesh_IncrementalMesh</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">reader </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> STEPControl_Reader()</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">reader.ReadFile(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"board.step"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">reader.TransferRoots()</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">shape </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> reader.OneShape()</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">BRepMesh_IncrementalMesh(shape, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">0.3</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">False</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">0.5</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">True</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span></code></pre>
<p>PCB 的丝印层、阻焊层、基板在 STEP 里是不同颜色的实体,三角化后按颜色
拆分材质写入 OBJ,后面 glTF 转换时会自然映射成多个 primitive。</p>
<h2>第二步:OBJ → GLB</h2>
<p>这步没有技术含量,<code>obj2gltf</code> 一行命令,写成脚本只是因为要处理贴图路径和
缺省参数。GLB 比 OBJ+MTL 好在单文件、二进制、带 PBR 材质。</p>
<h2>第三步:量化,模型从 40MB 到 4MB</h2>
<p>原始 GLB 转出来 40MB 左右,网页加载不能接受。<code>gltf-transform</code> 三连:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">npx</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> gltf-transform</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> weld</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> board.glb</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> t.glb</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">npx</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> gltf-transform</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> prune</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> t.glb</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> t2.glb</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">npx</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> gltf-transform</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> quantize</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> t2.glb</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> board-q.glb</span></span></code></pre>
<p><code>quantize</code> 使用 KHR_mesh_quantization 扩展,把顶点属性从 float32 压到
int16,配合 weld 去重和 prune 清理,体积降到 4MB,视觉上几乎无差别。
<code>&#x3C;model-viewer></code> 原生支持这个扩展,不需要额外解码库。</p>
<p>另一个经验:导出前在 EDA 里隐藏小元件(0402 电阻电容之类)的 3D 模型,
源文件体积直接砍半——这些元件在客户视角下本来也看不清。</p>
<h2>第四步:网页端</h2>
<p>展示组件直接用 <code>@google/model-viewer</code>,自托管它编译好的单文件,
不依赖运行时 CDN:</p>
<pre class="shiki shiki-themes github-light github-dark" style="--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e" tabindex="0"><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">&#x3C;</span><span style="--shiki-light:#22863A;--shiki-dark:#85E89D">model-viewer</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">  src</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"/models/board.glb"</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">  camera-controls</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">  auto-rotate</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">  shadow-intensity</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"1"</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">  style</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"width:100%;height:480px"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">></span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">&#x3C;/</span><span style="--shiki-light:#22863A;--shiki-dark:#85E89D">model-viewer</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">></span></span></code></pre>
<p>相比自己写 Three.js,<code>model-viewer</code> 把相机惯性、AR 入口、无障碍这些都
处理好了。如果后续要做「点击元件高亮 BOM」这类深度交互,再换 Three.js
也不迟——模型和管线都是复用的。</p>
<h2>小结</h2>
<table>
<thead>
<tr>
<th>坑</th>
<th>解法</th>
</tr>
</thead>
<tbody>
<tr>
<td>occt-import-js WASM 三角化空网格</td>
<td>本机 Python + cadquery-ocp</td>
</tr>
<tr>
<td>GLB 体积过大</td>
<td>weld + prune + quantize 三连</td>
</tr>
<tr>
<td>源文件含设计 IP</td>
<td>STEP 留在私有目录,只有 GLB 进 public</td>
</tr>
<tr>
<td>CDN 依赖</td>
<td>model-viewer 自托管</td>
</tr>
</tbody>
</table>
<p>整条管线写成三个脚本,换新板子就是「导出 STEP → 跑脚本 → 加一条记录」,
非前端同事也能自己发新版。</p>]]></content:encoded>
		</item>
	</channel>
</rss>