<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>优客李李</title>
    <description>要当闲云野鹤，要食人间烟火</description>
    <link>https://uulili.com/</link>
    <atom:link href="https://uulili.com/feed.xml" rel="self" type="application/rss+xml" />
    <pubDate>Mon, 09 Nov 2026 01:41:30 +0000</pubDate>
    <lastBuildDate>Mon, 09 Nov 2026 01:41:30 +0000</lastBuildDate>
    <generator>Jekyll v3.10.0</generator>
    
      <item>
        <title>技术笔记：十年前的 Flask 网站静态化，搬到 Cloudflare 免费托管</title>
        <description>&lt;blockquote&gt;
  &lt;p&gt;这是一份给自己看的完整记录：以后再改这个站、或者把别的老站也这么搬，先读这篇。网站是很多年前帮一个实验室做的，下文只叫“这个站”，不写名字和域名。动手的是 Mac mini 上的 Claude Code，我负责拍板和点确认。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;结论速览&quot;&gt;结论速览&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;项目&lt;/th&gt;
      &lt;th&gt;之前&lt;/th&gt;
      &lt;th&gt;之后&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;托管&lt;/td&gt;
      &lt;td&gt;AWS Lightsail 一台服务器，只跑这一个站&lt;/td&gt;
      &lt;td&gt;Cloudflare Workers 静态资源，免费&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;花费&lt;/td&gt;
      &lt;td&gt;半年 $35.65，月均 $5.94，一年约 $71&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;服务器费 0&lt;/strong&gt;；只剩域名注册费&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;技术栈&lt;/td&gt;
      &lt;td&gt;Python 2.7 + Flask 0.12 + Apache，Ubuntu 16.04（2021 年就停止维护）&lt;/td&gt;
      &lt;td&gt;Python 3.12 + Flask 3 导出静态文件；后台只在本机跑&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;更新频率&lt;/td&gt;
      &lt;td&gt;一年改 2 次左右&lt;/td&gt;
      &lt;td&gt;不变，改完 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;make publish&lt;/code&gt; 半分钟上线&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;大文件&lt;/td&gt;
      &lt;td&gt;S3：研究页视频 + PyMOL 下载，26 个文件 362MB&lt;/td&gt;
      &lt;td&gt;视频进站点；17 个下载文件放 R2（免费额度 10GB）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;时间&lt;/td&gt;
      &lt;td&gt;当年开发 2 个月，之后部署、更新数据、2.0 加后台写教程，一直有维护成本&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;静态化一天做完&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;代价&lt;/td&gt;
      &lt;td&gt;—&lt;/td&gt;
      &lt;td&gt;国内直连变慢（实测见后文），我接受了&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;img src=&quot;/img/tn-static-aws-bill.png&quot; alt=&quot;AWS 半年账单，几乎全是 Lightsail&quot; /&gt;&lt;/p&gt;

&lt;p&gt;这台服务器为了稳定，我没让它跑任何别的服务。&lt;strong&gt;一年改两次内容，每个月照交 6 美元。&lt;/strong&gt;这笔钱早该省。&lt;/p&gt;

&lt;h2 id=&quot;为什么能静态化&quot;&gt;为什么能静态化&lt;/h2&gt;

&lt;p&gt;先把“动态”的部分列清楚，才知道能不能搬：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;部分&lt;/th&gt;
      &lt;th&gt;原来怎么做&lt;/th&gt;
      &lt;th&gt;静态化后&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;6 个页面&lt;/td&gt;
      &lt;td&gt;Jinja 模板 + 查 SQLite 渲染&lt;/td&gt;
      &lt;td&gt;导出成 HTML&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;10 个研究子页&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/research/&amp;lt;名字&amp;gt;&lt;/code&gt; 渲染对应模板&lt;/td&gt;
      &lt;td&gt;逐个导出&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;3 个 JSON 接口&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;?selectedItem=…&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;?selectedTag=…&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;?tag=…&lt;/code&gt; 查询字符串&lt;/td&gt;
      &lt;td&gt;改成路径参数，每个地址导出成一个 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.json&lt;/code&gt; 文件&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;后台&lt;/td&gt;
      &lt;td&gt;Flask-Admin，Basic Auth，线上直接改&lt;/td&gt;
      &lt;td&gt;原样保留，只在本机运行&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;页面缓存&lt;/td&gt;
      &lt;td&gt;Flask-Cache，后台提交后清缓存&lt;/td&gt;
      &lt;td&gt;删掉&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;访客这边没有表单、登录、搜索，&lt;strong&gt;全是只读&lt;/strong&gt;。这就够了。数据也小：论文 221 条、结构 105 个、成员 55 人，数据库 164KB，图片 39MB。&lt;/p&gt;

&lt;h2 id=&quot;导出&quot;&gt;导出&lt;/h2&gt;

&lt;p&gt;没用 Frozen-Flask，自己写了 88 行的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;freeze.py&lt;/code&gt;，用 Flask 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;test_client&lt;/code&gt; 逐个请求再写文件。原因：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;想让 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/people&lt;/code&gt; 写成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;people.html&lt;/code&gt;，托管平台会自动把 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/people&lt;/code&gt; 对应过去；Frozen-Flask 默认写成没有扩展名的文件。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;带查询字符串的接口导不出来&lt;/strong&gt;，得先改路由。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;接口改法：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;/current_structure?selectedItem=5GMK  →  /data/structure/5GMK.json
/orientation_tag?selectedTag=…        →  /data/orientation/0.json   （按页面上的顺序编号）
/current_event?tag=Fun Time           →  /data/event/fun-time.json
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;方向名里有空格和 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;amp;beta;&lt;/code&gt;，不适合当文件名，所以用序号。前端两个 JS 文件各改一两处请求地址。&lt;/p&gt;

&lt;p&gt;导出时顺便写两个托管平台认的文件：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;_redirects   /index → /，以及一个旧栏目地址跳到新栏目
_headers     /static/* 缓存 1 天；/data/* 缓存 10 分钟
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_headers&lt;/code&gt; 是上线后补的：Workers 静态资源默认 &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;max-age=0, must-revalidate&lt;/code&gt;&lt;/strong&gt;，浏览器每张图都要回去问一次有没有更新，结构页几十张缩略图就是几十个来回。旧服务器是缓存 12 小时。&lt;/p&gt;

&lt;h2 id=&quot;python-2--3&quot;&gt;Python 2 → 3&lt;/h2&gt;

&lt;p&gt;代码量不大（不算数据约 700 行），但&lt;strong&gt;几处会静默出错&lt;/strong&gt;：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;问题&lt;/th&gt;
      &lt;th&gt;表现&lt;/th&gt;
      &lt;th&gt;改法&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;zip()&lt;/code&gt; 在 Python 3 返回迭代器&lt;/td&gt;
      &lt;td&gt;模板遍历一次就空了；&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;json.dumps&lt;/code&gt; 序列化失败&lt;/td&gt;
      &lt;td&gt;包一层 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;list()&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;r.encode(&apos;utf-8&apos;)&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;方向名变成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;b&apos;…&apos;&lt;/code&gt; 写进页面&lt;/td&gt;
      &lt;td&gt;删掉&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;flask.json.dumps(…, cls=…)&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Flask 2.3 起忽略 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cls&lt;/code&gt;，自定义编码器不生效，报 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Category is not JSON serializable&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;改用标准库 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;json&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;SQLAlchemy 2 的单列查询返回 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Row&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;不是 tuple，编码器不认&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[list(r) for r in rows]&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;自定义编码器用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dir(obj)&lt;/code&gt; 扫字段&lt;/td&gt;
      &lt;td&gt;SQLAlchemy 2 多出 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;registry&lt;/code&gt; 等属性&lt;/td&gt;
      &lt;td&gt;改成只取 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mapper.column_attrs&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;__unicode__&lt;/code&gt; 里写着 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;self.name&lt;/code&gt;，但模型没有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;name&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Python 2 时没被调用到；Flask-Admin 在 Python 3 会调用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;__str__&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;改成真实字段&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Flask-Admin 2 只剩 Bootstrap 4&lt;/td&gt;
      &lt;td&gt;24 个自定义后台模板里用的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;label label-info&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;well&lt;/code&gt; 失效&lt;/td&gt;
      &lt;td&gt;换成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;badge&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;card&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;全局一个 session，本地开发服务器是多线程的&lt;/td&gt;
      &lt;td&gt;偶发错乱&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;scoped_session&lt;/code&gt; + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;teardown_appcontext&lt;/code&gt; 里 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;remove()&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;数据库路径写死 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/home/&amp;lt;用户&amp;gt;/…&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;换机器就找不到&lt;/td&gt;
      &lt;td&gt;改成相对项目目录&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;还翻出一个老 bug，是我当年埋的。结构页第 4 个标签名里有希腊字母，标签文字经过 HTML 解码（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;amp;beta;&lt;/code&gt; → &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;β&lt;/code&gt;），而传给接口的名字列表没解码，两边对不上。原来靠服务端写死了一个兜底才显示出来。&lt;strong&gt;静态化后兜底没了，问题就暴露了。&lt;/strong&gt;改成解码后再比较。&lt;/p&gt;

&lt;h2 id=&quot;验收和线上逐行对比&quot;&gt;验收：和线上逐行对比&lt;/h2&gt;

&lt;p&gt;改之前先把线上站存了一份快照：7 个页面、12 个研究子页、112 个 JSON 接口（每个结构一个）。导出后逐个对比：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;19 个页面去掉空白后&lt;strong&gt;逐行一致&lt;/strong&gt;；&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;112 个 JSON 内容一致&lt;/strong&gt;（忽略原来编码器多吐出来的值为 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;null&lt;/code&gt; 的关联字段）；&lt;/li&gt;
  &lt;li&gt;无头浏览器点一遍：结构页 4 个标签加起来 23+36+18+28=105 个结构，和数据库条数一致；点开详情有图有字；实验室活动 3 个标签正常；&lt;/li&gt;
  &lt;li&gt;手机 375、平板 768、桌面 1400 三种宽度截图，列表页和线上&lt;strong&gt;像素级一致&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;后台在临时副本上测，正式数据没碰：&lt;strong&gt;15 个栏目新建、编辑、删除&lt;/strong&gt;；结构图上传后主图裁成 500×500、自动生成 100×100 缩略图；删记录连文件带缩略图一起删；媒体文件上传删除；再用真实浏览器点一遍新建论文的表单（年份是 select2 下拉框），存进去、前台论文页能看到。&lt;/p&gt;

&lt;h2 id=&quot;部署workers-静态资源&quot;&gt;部署：Workers 静态资源&lt;/h2&gt;

&lt;p&gt;本来打算用 Cloudflare Pages。新版 wrangler（4.157）跑 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pages project create&lt;/code&gt; 时，会转去建一个 Worker，没给静态目录就直接失败。&lt;strong&gt;Pages 已经并进 Workers&lt;/strong&gt;，现在推荐的是 Workers 静态资源，免费额度一样，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_redirects&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_headers&lt;/code&gt;、404 页都支持。&lt;/p&gt;

&lt;div class=&quot;language-jsonc highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
7
8
9
10
11
12
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&amp;lt;worker 名&amp;gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;compatibility_date&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;2026-11-01&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;routes&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;pattern&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&amp;lt;域名&amp;gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;custom_domain&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}],&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;workers_dev&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kc&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;preview_urls&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;kc&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;assets&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;directory&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;./build&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;html_handling&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;auto-trailing-slash&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;not_found_handling&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;404-page&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;API Token 需要的权限（第一个下拉框先选 Zone 还是 Account，选错了就找不到后面的项）：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;范围&lt;/th&gt;
      &lt;th&gt;权限&lt;/th&gt;
      &lt;th&gt;用途&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Account&lt;/td&gt;
      &lt;td&gt;Workers Scripts · Edit&lt;/td&gt;
      &lt;td&gt;部署&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Account&lt;/td&gt;
      &lt;td&gt;Workers R2 Storage · Edit&lt;/td&gt;
      &lt;td&gt;建桶、传文件、绑域名&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Zone（只选这几个域名）&lt;/td&gt;
      &lt;td&gt;DNS · Edit&lt;/td&gt;
      &lt;td&gt;改记录（注意不是 DNS Settings）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Zone&lt;/td&gt;
      &lt;td&gt;Workers Routes · Edit&lt;/td&gt;
      &lt;td&gt;绑自定义域名&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Zone&lt;/td&gt;
      &lt;td&gt;Dynamic URL Redirects · Edit&lt;/td&gt;
      &lt;td&gt;跳转规则&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Token 只有 Pages 权限时，wrangler 读不到账户 ID，要手动给 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLOUDFLARE_ACCOUNT_ID&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;日常用 Makefile 包起来：&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;make admin     &lt;span class=&quot;c&quot;&gt;# 本机打开后台改内容&lt;/span&gt;
make publish   &lt;span class=&quot;c&quot;&gt;# 导出 → 部署 → 把数据库和图片提交并推到 GitHub&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;publish&lt;/code&gt; 里加自动提交，是因为内容（数据库、图片）也在仓库里。之前要手动提交，&lt;strong&gt;忘了的话 GitHub 上的“备份”其实是旧的&lt;/strong&gt;。我肯定会忘。&lt;/p&gt;

&lt;h2 id=&quot;s3-搬到-r2&quot;&gt;S3 搬到 R2&lt;/h2&gt;

&lt;p&gt;S3 上的视频和下载文件开着防盗链：只放行 Referer 是本站 https 的请求。所以直接 curl 是 403，带上 Referer 就能下，&lt;strong&gt;不需要 AWS 密钥&lt;/strong&gt;：&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;curl &lt;span class=&quot;nt&quot;&gt;-H&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Referer: https://&amp;lt;域名&amp;gt;/&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-o&lt;/span&gt; 文件 &lt;span class=&quot;s2&quot;&gt;&quot;https://s3.&amp;lt;区域&amp;gt;.amazonaws.com/&amp;lt;桶&amp;gt;/&amp;lt;路径&amp;gt;&quot;&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;9 个视频&lt;/strong&gt;（1.5–4.7MB）直接放进站点。文件名里的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;+&lt;/code&gt; 改成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_&lt;/code&gt;，免得 URL 里有歧义。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;17 个 PyMOL 文件&lt;/strong&gt;（最大 47MB，超过 Workers 静态资源单文件 25MB 的上限）放 R2，绑子域名 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;files.&amp;lt;域名&amp;gt;&lt;/code&gt;。上传用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wrangler r2 object put --remote&lt;/code&gt;，传完逐个下载算 SHA-256，和 S3 原件一致。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;绑完自定义域名，前一分钟有几个文件返回 403，等边缘节点同步后就好了。&lt;/p&gt;

&lt;h2 id=&quot;域名&quot;&gt;域名&lt;/h2&gt;

&lt;h3 id=&quot;换-nameserver&quot;&gt;换 nameserver&lt;/h3&gt;

&lt;p&gt;Cloudflare 里 Add a site → Connect a domain，自动导入 DNS 记录。导入后&lt;strong&gt;先把指向旧服务器的记录改成灰色云朵（DNS only）&lt;/strong&gt;：旧服务器自己做 http→https 跳转，Cloudflare 有的 SSL 模式用 http 回源，两边叠在一起可能无限跳转。改成灰色，切换前网站照旧由旧服务器提供。&lt;/p&gt;

&lt;p&gt;坑：&lt;strong&gt;每个域名分到的 nameserver 不一定是同一对&lt;/strong&gt;。我在 GoDaddy 批量给两个域名填了同一对，其中一个一直 pending，查 API 才发现它分到的是另一对。&lt;/p&gt;

&lt;h3 id=&quot;切换&quot;&gt;切换&lt;/h3&gt;

&lt;p&gt;删掉主域名的 A 记录，紧接着用 API 绑 Worker 自定义域名（证书自动签）。还缓存着旧记录的访客继续去旧服务器，旧服务器还开着，所以没有中断。&lt;/p&gt;

&lt;p&gt;只做跳转的域名：根记录指向占位地址 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;192.0.2.1&lt;/code&gt; 并开代理，再加一条 301 规则，请求在 Cloudflare 边缘就被跳走，不回源。规则里的目标地址：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;保留路径：target_url.expression = concat(&quot;https://&amp;lt;主域名&amp;gt;&quot;, http.request.uri.path)
固定地址：target_url.value = &quot;https://…&quot;     ← 写成 expression 会报 Filter parsing error
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;注册商&quot;&gt;注册商&lt;/h3&gt;

&lt;p&gt;&lt;img src=&quot;/img/tn-static-godaddy-renew.png&quot; alt=&quot;GoDaddy 一个 .com 的续费价&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/tn-static-cf-price.png&quot; alt=&quot;Cloudflare .com 价格 11 月 1 日起 $11.17&quot; /&gt;&lt;/p&gt;

&lt;p&gt;同一个 .com，GoDaddy 续费 $22.99/年，Cloudflare 按成本价 $11.17/年（11 月 1 日刚从 $10.46 涨上来），&lt;strong&gt;每年差 $11.82，差不多一半&lt;/strong&gt;。转入要先在 Cloudflare 上 active，再在 GoDaddy 解锁、拿授权码；转入时付 1 年，加在原到期日后面，不浪费。转移完成前别改注册人信息，会被锁 60 天。&lt;/p&gt;

&lt;h2 id=&quot;国内访问&quot;&gt;国内访问&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;这是唯一变差的地方。&lt;/strong&gt;2026-11-05 下午，同一条家庭宽带，直连各请求一个 2KB 的 logo：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt; &lt;/th&gt;
      &lt;th&gt;成功&lt;/th&gt;
      &lt;th&gt;平均耗时&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Cloudflare 免费版&lt;/td&gt;
      &lt;td&gt;17/20，3 次超时&lt;/td&gt;
      &lt;td&gt;4–8 秒&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;旧服务器（AWS 美国俄亥俄）&lt;/td&gt;
      &lt;td&gt;10/10&lt;/td&gt;
      &lt;td&gt;0.9 秒&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;拆开看，建立 TCP 连接 0.27 秒，和 AWS 差不多；慢在之后时不时卡 3–5 秒，像丢包重传。海外访问反过来会更快。我看完数据，决定接受。不回滚。要国内快，只能备案走国内 CDN。&lt;/p&gt;

&lt;p&gt;另外 &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;*.workers.dev&lt;/code&gt; 在国内整个被封&lt;/strong&gt;：DNS 解析出错误地址，连真实 IP 也会在 TLS 握手时被重置。测试地址只能挂代理看；绑自己的域名后不受影响。这个测试地址后来也关了（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;workers_dev: false&lt;/code&gt;）。&lt;/p&gt;

&lt;h2 id=&quot;顺手博客图床收回仓库&quot;&gt;顺手：博客图床收回仓库&lt;/h2&gt;

&lt;p&gt;2020 年写过一篇用 &lt;a href=&quot;/成长日记/2020/04/30/amazon-s3-with-upic-picture-bed/&quot;&gt;用 Amazon S3 + uPic 搭图床&lt;/a&gt;。那个桶里 44 张图，博客引用了 39 张，一共 3.3MB。我早忘了当年的 key 和 secret。好在&lt;strong&gt;图本来就是公开的，直接下载就行&lt;/strong&gt;，不需要密钥。放进仓库 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;img/markdown-img/&lt;/code&gt;，10 篇文章和关于页的链接改成站内路径。后来的文章本来就直接把图放 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/img/&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;想看桶里全部文件但没有密钥时，用 AWS 控制台右上角的 CloudShell，它用当前登录身份：&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;aws s3 &lt;span class=&quot;nb&quot;&gt;ls &lt;/span&gt;s3://&amp;lt;桶&amp;gt; &lt;span class=&quot;nt&quot;&gt;--recursive&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--summarize&lt;/span&gt; | &lt;span class=&quot;nb&quot;&gt;tail&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-3&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;让-ai-动手时的分寸&quot;&gt;让 AI 动手时的分寸&lt;/h2&gt;

&lt;p&gt;我被 Claude Code 的安全检查拦下过两次：删正式域名的 DNS 记录、把 R2 桶公开到子域名。它拦得对，&lt;strong&gt;这两步都对外、不好撤&lt;/strong&gt;。我看过它列出的改动清单和回滚办法后，回一句“确认”它才执行。还有一次它想借另一把 Token 去查账户 ID，也被拦了：那把 Token 是给别的服务续证书用的，不该挪用。&lt;/p&gt;

&lt;h2 id=&quot;踩坑记录&quot;&gt;踩坑记录&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;zsh 里 URL 带 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;?&lt;/code&gt; 不加引号会被当通配符，命令直接报 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;no matches found&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;zsh 不会按空格拆变量，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;set -- $r&lt;/code&gt; 拿不到多个参数，校验脚本因此全判“异常”。&lt;/li&gt;
  &lt;li&gt;本机经代理访问 Cloudflare 每个请求要 1–2 秒，无头浏览器连看几个带视频的页面后，下一个页面会卡住超时；交互测试要单开页面。&lt;/li&gt;
  &lt;li&gt;Workers 静态资源单文件上限 25MB。&lt;/li&gt;
  &lt;li&gt;页面引用的 Google Analytics 是 2023 年就停止收数据的旧版 UA 代码，还拖慢加载，删了。&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;还没做的&quot;&gt;还没做的&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;观察一两周后删 S3 桶、关旧服务器；然后清掉指向旧服务器的测试子域名记录和仓库里的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy/&lt;/code&gt; 目录。&lt;/li&gt;
  &lt;li&gt;晚高峰的国内速度没测，估计只会更差。&lt;/li&gt;
  &lt;li&gt;另外 10 个防御性注册的 .com 还在 GoDaddy，到期前再决定转不转。&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Thu, 05 Nov 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/2026/11/05/%E6%8A%80%E6%9C%AF%E7%AC%94%E8%AE%B0-%E5%8D%81%E5%B9%B4%E5%89%8D%E7%9A%84%E5%8A%A8%E6%80%81%E7%BD%91%E7%AB%99%E9%9D%99%E6%80%81%E5%8C%96%E6%90%AC%E5%88%B0Cloudflare/</link>
        <guid isPermaLink="true">https://uulili.com/2026/11/05/%E6%8A%80%E6%9C%AF%E7%AC%94%E8%AE%B0-%E5%8D%81%E5%B9%B4%E5%89%8D%E7%9A%84%E5%8A%A8%E6%80%81%E7%BD%91%E7%AB%99%E9%9D%99%E6%80%81%E5%8C%96%E6%90%AC%E5%88%B0Cloudflare/</guid>
        
        <category>技术笔记</category>
        
        <category>Cloudflare</category>
        
        <category>静态网站</category>
        
        <category>Flask</category>
        
        <category>省钱</category>
        
        
      </item>
    
      <item>
        <title>Mac mini 家庭服务器：一句话搭好一个工作台，项目已开源</title>
        <description>&lt;blockquote&gt;
  &lt;p&gt;家庭服务器，听起来很极客，好像是程序员专属的玩具；但在大模型爆发的时代，它其实适用于每一个家庭。因为我们部署、管理和维护等代码工作，&lt;strong&gt;正是当今大模型最擅长的事之一&lt;/strong&gt;。我把自己踩过的坑做成了一个开源项目，&lt;strong&gt;让 AI 一句话就能搭好&lt;/strong&gt;一个工作台。　&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;我家这台是 Mac mini。我在买 Mac mini 之前其实考虑了很久——我已经有一台 mbp 了，多一台电脑，我能用得过来吗？但买了之后，我发现是越用越爽。因为一台家庭服务器的核心是&lt;strong&gt;7x24不间断运行&lt;/strong&gt;。这就意味着你随时想用什么服务，它都会随时给你回应。而且随着时间的推移，自动化服务自然会越来越丰富——这是个不断跟随自己成长的设备，&lt;strong&gt;是一种资产&lt;/strong&gt;。这是它目前的样子：&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/mp008-img1.jpg&quot; alt=&quot;我家的工作台：10 个服务全部在线&quot; /&gt;&lt;/p&gt;

&lt;p&gt;上面一共 10 个服务。Mac mini 上 7 个：电影库、本地视频生成、配音、本地大模型、写这个公众号用的工作台、投资记账、字幕；旁边的 NAS 和树莓派上还有 3 个：群晖后台、n8n 和 Home Assistant。&lt;/p&gt;

&lt;h2 id=&quot;家庭服务器是什么&quot;&gt;家庭服务器是什么&lt;/h2&gt;

&lt;p&gt;说白了，就是&lt;strong&gt;家里一台 24 小时开着的电脑&lt;/strong&gt;，上面跑着你自己的常驻服务。&lt;/p&gt;

&lt;p&gt;家庭服务器能干的事包括且不限于：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;用途&lt;/th&gt;
      &lt;th&gt;常见的服务&lt;/th&gt;
      &lt;th&gt;能帮你做什么&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;影音&lt;/td&gt;
      &lt;td&gt;Jellyfin&lt;/td&gt;
      &lt;td&gt;下好的电影自动配海报，电视、投影仪、手机都能看&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;相册&lt;/td&gt;
      &lt;td&gt;Immich&lt;/td&gt;
      &lt;td&gt;手机照片自动备份，能按人脸、地点、以图搜图&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;密码&lt;/td&gt;
      &lt;td&gt;Vaultwarden&lt;/td&gt;
      &lt;td&gt;全家的密码库，Bitwarden 的各种客户端都能连&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;广告过滤&lt;/td&gt;
      &lt;td&gt;AdGuard Home&lt;/td&gt;
      &lt;td&gt;全家设备一起去广告&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;自动化&lt;/td&gt;
      &lt;td&gt;n8n、Home Assistant&lt;/td&gt;
      &lt;td&gt;定时抓资讯发到手机；坐久了桌子自己升起来&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;AI&lt;/td&gt;
      &lt;td&gt;Ollama 等&lt;/td&gt;
      &lt;td&gt;本地跑大模型，聊天、生成都不出这台机器&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;还有一些服务不需要后台，但其实也非常适合部署。比如我现在用得最多的，是 &lt;strong&gt;Paseo&lt;/strong&gt;。它是 Claude Code、Codex 这类编码 Agent 的控制台：Agent 常驻在 Mac mini 上跑，我随时随地都能连上去看进度、接着下指令，&lt;strong&gt;电脑启动的会话，手机能继续&lt;/strong&gt;，反之亦然——彻底解除了多设备会话同步问题。现在 AI 能在多台设备之间长期稳定地干活，靠的就是它。它的好处，下期详细讲。&lt;/p&gt;

&lt;p&gt;家庭服务器用什么机器好？我的看法是：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Mac mini 最合适&lt;/strong&gt;。功耗低、安静、长期开机没问题，Apple 芯片还能跑本地 AI。&lt;/li&gt;
  &lt;li&gt;NAS 适合放数据。服务装太多，它会吃力。&lt;/li&gt;
  &lt;li&gt;树莓派适合跑一两个轻量的，比如 Home Assistant。&lt;/li&gt;
  &lt;li&gt;闲置的 Linux 电脑也可以，算次选。&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;服务一多麻烦就来了&quot;&gt;服务一多，麻烦就来了&lt;/h2&gt;

&lt;p&gt;我遇到的是这 4 个麻烦：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;地址记不住&lt;/strong&gt;。每个服务一个端口，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;8096&lt;/code&gt; 是电影，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;8190&lt;/code&gt; 是视频生成，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;8123&lt;/code&gt; 是智能家居……10 个，根本记不住，每次都要翻笔记。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;浏览器报“不安全”&lt;/strong&gt;。家里的服务默认是 http，有的功能（比如录音、复制）在不安全的页面上还用不了。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;想在外面用，又怕暴露&lt;/strong&gt;。在路由器上开端口最简单，但等于把家门开在公网上，扫端口的机器人一天到晚都在找这种门。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;挂了不知道&lt;/strong&gt;。哪个服务停了，往往是要用的时候才发现。&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;工作台一个入口管理所有服务&quot;&gt;工作台：一个入口管理所有服务&lt;/h2&gt;

&lt;p&gt;我的解法是给这些常驻服务做一个工作台。它其实就 4 件事：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;每个服务一个名字&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;jellyfin.你的域名.com&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ha.你的域名.com&lt;/code&gt;，不用再记端口。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;正规证书&lt;/strong&gt;：浏览器有小锁，自动续期。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;只有你能打开&lt;/strong&gt;：别人就算知道地址，也连不上。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;一个仪表盘&lt;/strong&gt;：每个服务在不在线，机器的 CPU、内存、磁盘、温度，一眼看完；每天再自动体检一次。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;维护起来也简单。所有服务写在一份清单里，&lt;strong&gt;加服务就是加一行&lt;/strong&gt;，域名、证书、卡片、体检都跟着自动更新。&lt;/p&gt;

&lt;h2 id=&quot;在外面访问的几种办法&quot;&gt;在外面访问的几种办法&lt;/h2&gt;

&lt;p&gt;“只有你能打开”，局域网肯定没问题，关键要看从外面怎么连回家。常见的有这几种：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;方式&lt;/th&gt;
      &lt;th&gt;怎么连&lt;/th&gt;
      &lt;th&gt;谁能访问&lt;/th&gt;
      &lt;th&gt;要注意&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;公网 IP + 端口转发&lt;/td&gt;
      &lt;td&gt;路由器开端口，DDNS 跟着 IP 变&lt;/td&gt;
      &lt;td&gt;全世界&lt;/td&gt;
      &lt;td&gt;很多家宽没有公网 IP；暴露在公网，安全全靠服务自己&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;内网穿透（frp、花生壳等）&lt;/td&gt;
      &lt;td&gt;借一台中转服务器&lt;/td&gt;
      &lt;td&gt;默认全世界&lt;/td&gt;
      &lt;td&gt;速度看中转；多数要付费&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Cloudflare Tunnel&lt;/td&gt;
      &lt;td&gt;家里主动连 Cloudflare&lt;/td&gt;
      &lt;td&gt;全世界，可以加登录&lt;/td&gt;
      &lt;td&gt;国内访问不稳定；不适合传视频&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Tailscale / ZeroTier&lt;/td&gt;
      &lt;td&gt;你的设备组成一个虚拟局域网&lt;/td&gt;
      &lt;td&gt;只有你登录的设备&lt;/td&gt;
      &lt;td&gt;每台设备要装一个 App&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;如果服务只给自己和家人用，&lt;strong&gt;我推荐最后一种&lt;/strong&gt;。我用的是 Tailscale，个人免费版目前能加 &lt;strong&gt;6 个用户，设备数量不限&lt;/strong&gt;。在家时它自动走局域网直连，出门在外也能连回来。远程桌面也靠它搞定了。&lt;/p&gt;

&lt;h2 id=&quot;我的推荐一个只有你能打开的专属网址&quot;&gt;我的推荐：一个只有你能打开的专属网址&lt;/h2&gt;

&lt;p&gt;在 Tailscale 上再加一个自己的域名，就是一套很舒服的方案：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;环节&lt;/th&gt;
      &lt;th&gt;用什么&lt;/th&gt;
      &lt;th&gt;花费&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;域名&lt;/td&gt;
      &lt;td&gt;任意注册商买的域名，DNS 交给 Cloudflare 管&lt;/td&gt;
      &lt;td&gt;非&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.com&lt;/code&gt; 的域名巨便宜&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;解析&lt;/td&gt;
      &lt;td&gt;一条泛解析，指向服务器的 Tailscale 地址&lt;/td&gt;
      &lt;td&gt;免费&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;证书&lt;/td&gt;
      &lt;td&gt;Let’s Encrypt，用 DNS 验证&lt;/td&gt;
      &lt;td&gt;免费&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;入口&lt;/td&gt;
      &lt;td&gt;Caddy，按域名把请求转给各个服务&lt;/td&gt;
      &lt;td&gt;免费&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;连接&lt;/td&gt;
      &lt;td&gt;Tailscale&lt;/td&gt;
      &lt;td&gt;个人免费&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;效果是：我打开 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mini.uulili.com&lt;/code&gt; 就是工作台，点进去每个服务都有自己的地址和SSL小锁。这个域名公开解析，但解析到的是 Tailscale 里的地址，不在我 Tailscale 网络里的设备，&lt;strong&gt;想连也连不上&lt;/strong&gt;。&lt;/p&gt;

&lt;h2 id=&quot;homelab-desk让-ai-一句话搭好&quot;&gt;homelab-desk：让 AI 一句话搭好&lt;/h2&gt;

&lt;p&gt;工作台项目叫 homelab-desk，已经在 GitHub &lt;strong&gt;开源&lt;/strong&gt;，MIT 协议：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;GitHub 项目地址&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;https://github.com/shisaq/homelab-desk&quot;&gt;https://github.com/shisaq/homelab-desk&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;img src=&quot;/img/mp008-github.jpg&quot; alt=&quot;homelab-desk 的 GitHub 主页&quot; /&gt;&lt;/p&gt;

&lt;p&gt;它做这几件事：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;部署总览&lt;/strong&gt;：读你的服务清单，生成 Caddy 配置、申请证书、只放行 Tailscale、装成开机自启的后台服务。可以重复执行，改了清单再跑一次就行。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;实时仪表盘&lt;/strong&gt;：每个服务在不在线、响应多快、近 1 小时的记录；机器的 CPU（每颗核都有）、内存构成和压力、磁盘、网络，装了插件还有温度、GPU、UPS。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;选风格&lt;/strong&gt;：4 套主题，每套都有深浅两种，样子见下一节。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;推荐服务&lt;/strong&gt;：内置一份目录，影音、相册、密码、广告过滤、自动化、AI、下载、阅读等，共 30 项。每项写清适合谁、怎么装、放到反向代理后面要注意什么。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;每天体检&lt;/strong&gt;：检查每个服务和证书剩余天数，可以接到自己的通知渠道。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;上面我踩过的坑，&lt;strong&gt;项目里都处理掉了&lt;/strong&gt;。比如监听 443 的限制，它会改成按来源只放行 Tailscale；在线检测放在服务器上做，避开浏览器的跨域误判；Home Assistant 这类要额外配置的服务，目录里直接写明。&lt;/p&gt;

&lt;h2 id=&quot;顺手做了不同的风格&quot;&gt;顺手做了不同的风格&lt;/h2&gt;

&lt;p&gt;工作台总共 &lt;strong&gt;4 套主题&lt;/strong&gt;：玻璃、苹果的液态玻璃、简洁、终端，每套都有深浅两种，页面右上角随手切。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/mp008-img3.jpg&quot; alt=&quot;4 套主题：玻璃、液态玻璃（浅色）、简洁、终端（演示数据）&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;怎么用&quot;&gt;怎么用&lt;/h2&gt;

&lt;p&gt;把项目克隆下来，把里面的 skill 链接给 Claude Code 或 Codex，然后对它说一句：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;对 Claude Code 或 Codex 说&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;帮我用 homelab-desk 搭一个家庭服务器工作台&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;它会检查前置条件，带你在 Cloudflare 建 Token、建 DNS 记录，部署，最后验收。&lt;strong&gt;以后想加服务，也是一句话&lt;/strong&gt;：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;以后加服务&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;把 Immich 加进来&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;不想用 AI 的，也可以手动跑 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;init&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;doctor&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dns&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy&lt;/code&gt; 4 条命令。&lt;/p&gt;

&lt;p&gt;为什么不做成 Docker？Mac 上的 Docker 跑在虚拟机里，拿不到 Tailscale 地址，也读不到宿主机的温度和 GPU。项目本身只有 Caddy、一个 Python 小服务和一个静态页面，原生跑更简单。你的服务装在哪都行，装在 Docker 里的也一样，项目只认“地址:端口”。&lt;/p&gt;

&lt;p&gt;市面上的 Homepage、Homarr、Dashy 管的是导航页面，Uptime Kuma 管监控。homelab-desk 管的是&lt;strong&gt;从零到能用的整条链路&lt;/strong&gt;。已经在用它们的，也可以把它们当成一个服务加进来。&lt;/p&gt;

&lt;h2 id=&quot;适合谁&quot;&gt;适合谁&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;已经有一台常开的 Mac，上面跑着几个服务：直接用。&lt;/li&gt;
  &lt;li&gt;用 Linux 的小伙伴：适配代码已经写好，但还没在真机上验证过，欢迎测试反馈。&lt;/li&gt;
  &lt;li&gt;还没有服务器的：可以先从上面那张表里挑一两个你想要的服务，一台 Mac mini 就能跑起来。&lt;/li&gt;
  &lt;li&gt;想把服务公开给朋友、不装 Tailscale 直接用浏览器打开的：&lt;strong&gt;这个项目不负责这件事&lt;/strong&gt;。那需要一台公网服务器和用户管理，安全问题就够你喝一壶的了。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;前置条件是 3 样：一个托管在 Cloudflare 的域名，装好并登录的 Tailscale，以及 Claude Code 或 Codex（手动部署则不需要）。&lt;/p&gt;

&lt;p&gt;总结一下：家庭服务器的价值在那些服务，&lt;strong&gt;工作台让你管得过来&lt;/strong&gt;。项目地址再放一次，求个星星：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;GitHub 项目地址&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;&lt;a href=&quot;https://github.com/shisaq/homelab-desk&quot;&gt;https://github.com/shisaq/homelab-desk&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;用的时候遇到问题，欢迎留言或者在 GitHub 上提。衷心希望你的负债少少，资产多多。&lt;/p&gt;

&lt;h2 id=&quot;博客版补充&quot;&gt;博客版补充&lt;/h2&gt;

&lt;p&gt;工作台上的几个服务，博客里有单独的实测：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;本地大模型：&lt;a href=&quot;/2026/10/17/小钢炮-Mac-mini-跑-27B-最快-35-token-s/&quot;&gt;小钢炮 Mac mini 跑 27B，最快 35 token/s&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;本地视频生成（H3 Studio）：&lt;a href=&quot;/2026/10/09/Mac-mini-跑-AI-视频-内存占多少-生成要多久/&quot;&gt;Mac mini 跑 AI 视频：内存占多少，生成要多久？&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;配音（TTS 调音台）：&lt;a href=&quot;/2026/10/29/技术笔记-Mac-mini-本地微调-Qwen3-TTS-声音/&quot;&gt;技术笔记：Mac mini 本地微调 Qwen3-TTS 声音&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;本文首发于微信公众号「像素李的AI笔记」：&lt;a href=&quot;https://mp.weixin.qq.com/s/zdt7_JzSg99ziI7RdLP7OQ&quot;&gt;原文链接&lt;/a&gt;。博客版保留了文中的外链，和公众号版不同的地方都标了“博客版”。&lt;/p&gt;
</description>
        <pubDate>Sat, 31 Oct 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/2026/10/31/Mac-mini-%E5%AE%B6%E5%BA%AD%E6%9C%8D%E5%8A%A1%E5%99%A8-%E4%B8%80%E5%8F%A5%E8%AF%9D%E6%90%AD%E5%A5%BD%E4%B8%80%E4%B8%AA%E5%B7%A5%E4%BD%9C%E5%8F%B0-%E9%A1%B9%E7%9B%AE%E5%B7%B2%E5%BC%80%E6%BA%90/</link>
        <guid isPermaLink="true">https://uulili.com/2026/10/31/Mac-mini-%E5%AE%B6%E5%BA%AD%E6%9C%8D%E5%8A%A1%E5%99%A8-%E4%B8%80%E5%8F%A5%E8%AF%9D%E6%90%AD%E5%A5%BD%E4%B8%80%E4%B8%AA%E5%B7%A5%E4%BD%9C%E5%8F%B0-%E9%A1%B9%E7%9B%AE%E5%B7%B2%E5%BC%80%E6%BA%90/</guid>
        
        <category>技术分享</category>
        
        <category>本地实测</category>
        
        <category>Mac mini</category>
        
        <category>家庭服务器</category>
        
        <category>开源</category>
        
        
      </item>
    
      <item>
        <title>技术笔记：Mac mini 本地微调 Qwen3-TTS 声音</title>
        <description>&lt;blockquote&gt;
  &lt;p&gt;这是一份给自己看的完整记录：以后再训练新的声音、或者在新对话里接着改，先读这篇。代码都在 &lt;a href=&quot;https://github.com/shisaq/qwen3-tts-console&quot;&gt;qwen3-tts-console&lt;/a&gt;（网页在根目录，训练流程在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;finetune/&lt;/code&gt;）。文中两个被训练的人称为说话人 A、B。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;结论速览&quot;&gt;结论速览&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;项目&lt;/th&gt;
      &lt;th&gt;数值&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;机器&lt;/td&gt;
      &lt;td&gt;Mac mini，M5 Pro，64GB；GPU 最多能用 51.8GB（81%）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;起因&lt;/td&gt;
      &lt;td&gt;10 秒参考音频的零样本复刻，音色和口音不像本人&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;素材&lt;/td&gt;
      &lt;td&gt;A：6 段视频约 24 分钟 + 1 段 23 分钟对话；B：13 段视频约 42 分钟 + 同一段对话&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;自动处理&lt;/td&gt;
      &lt;td&gt;约 90 分钟素材，十几分钟切完、转写完、筛完人&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;人工复核后&lt;/td&gt;
      &lt;td&gt;A 165 段 / 18.0 分钟；B 361 段 / 38.9 分钟（复核用时约 1.5 小时）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;训练（1.7B，10 轮）&lt;/td&gt;
      &lt;td&gt;A 17 分钟，B 37 分钟，内存峰值约 39–41GB&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;声纹相似度&lt;/td&gt;
      &lt;td&gt;A：0.72 → 0.84；B：0.57 → 0.82（零样本 → 最好的一轮）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;错字率&lt;/td&gt;
      &lt;td&gt;所有版本都低于 1%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;上线&lt;/td&gt;
      &lt;td&gt;A 用第 8 轮，B 用第 10 轮；生成 5 秒语音约 2 秒，加载 0.5 秒，每个模型约 3GB&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;低内存&lt;/td&gt;
      &lt;td&gt;0.6B 冻结 20 层：峰值 10.6GB，相似度 0.83，推算 16GB 机器能跑&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;整体结构&quot;&gt;整体结构&lt;/h2&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
7
8
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;视频 ─(先自己提取音频)─▶ wav
  ─ prep_data.py   FunASR 转写 + 切 2–10 秒 + CAM++ 筛人      （.venv：torch + funasr）
  ─ review.py      网页复核：听、改字、保留/丢弃              （同上）
  ─ build_train.py Qwen3-TTS-Tokenizer-12Hz 编码成语音 token  （.venv-train：qwen-tts + torch）
  ─ sft.py         在 Base 模型上微调，存成 custom_voice 检查点（同上，MPS）
  ─ gen_samples.py 每一轮转成 MLX 8bit，和零样本一起生成样音   （网页的 venv：mlx-audio）
  ─ score_samples.py 相似度、错字率、语速；对比页靠耳朵挑       （.venv）
  ─ ttsd 的 tuned_voices 配置 ─▶ 网页“我的音色”里出现
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;3 个 Python 环境是故意分开的：FunASR、qwen-tts、mlx-audio 各自钉了不同版本的依赖，混在一起容易打架。测试过的版本：funasr 1.4.16、modelscope 1.40.2、qwen-tts 0.1.1、torch 2.14.1；mlx-audio 用网页 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;requirements.txt&lt;/code&gt; 里钉的提交。&lt;/p&gt;

&lt;p&gt;网页本身（ttsd）是一个 Python 单文件服务，用 launchd 常驻、Caddy 反代，模型用 mlx-audio 跑 8bit 量化版，空闲 10 分钟自动卸载。&lt;/p&gt;

&lt;h2 id=&quot;数据准备&quot;&gt;数据准备&lt;/h2&gt;

&lt;h3 id=&quot;转写funasr不是-whisper&quot;&gt;转写：FunASR，不是 Whisper&lt;/h3&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;AutoModel(model=&quot;paraformer-zh&quot;, vad_model=&quot;fsmn-vad&quot;, punc_model=&quot;ct-punc&quot;, spk_model=&quot;cam++&quot;)&lt;/code&gt;：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Paraformer-zh&lt;/strong&gt;：中文识别准，给每个字的时间戳。1.8 分钟音频转写 12 秒。英文单词经常拼错（Gartner → garten），以英文为主的片段直接丢弃（字母数多于汉字）。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;fsmn-vad / ct-punc&lt;/strong&gt;：断句和加标点。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;CAM++&lt;/strong&gt;：声纹。实测同一个人的片段 0.72–0.75，不同人 0.08–0.36，两个人的参考录音之间 0.31。阈值：≥ 0.55 保留，0.45–0.55 待定，更低丢弃。主持人、插话、对话里的另一方都靠它去掉。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;一个 FunASR 1.4.16 的 bug&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sentence_info&lt;/code&gt; 把每个标点放晚一个字（“开发者。大家好，很高兴”变成“开发者大。家好很，高兴”），按它切片段会在词中间断开。完整文本 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;text&lt;/code&gt; 和逐字时间戳 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timestamp&lt;/code&gt; 是对的，所以自己重切：按 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[A-Za-z&apos;]+|标点|单个汉字&lt;/code&gt; 分词，词数和时间戳数一一对应（实测完全对得上），遇到标点断句，说话人标签取时间上重叠最多的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sentence_info&lt;/code&gt; 段。&lt;/p&gt;

&lt;h3 id=&quot;切片规则&quot;&gt;切片规则&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;同一说话人、间隔 ≤ 0.5 秒的句子合并成 2–10 秒的片段；够 4 秒且遇到句号就提前断开。&lt;/li&gt;
  &lt;li&gt;前后各留 0.08 / 0.15 秒，但不越过相邻句子的中点。&lt;/li&gt;
  &lt;li&gt;有声部分响度统一到 -20 dBFS，峰值不超过 -1 dBFS。&lt;/li&gt;
  &lt;li&gt;噪声底取整段音频 50ms 帧能量的 10% 分位，下限 -90 dB（合成或降噪过的音频会出现数字静音，不设下限信噪比会显示成 150 多）。&lt;/li&gt;
  &lt;li&gt;同一事件的两个剪辑版本会有重复句子：文本相似度 &amp;gt; 0.8 且来自不同文件的，保留信噪比高的那段。&lt;/li&gt;
  &lt;li&gt;自动标记：英文、语气词（呃嗯额）、重复/口误、连串中文数字，复核时优先看。&lt;/li&gt;
  &lt;li&gt;英文术语统一大小写（ai → AI、gpt → GPT），产品名的误识别放进配置的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;terms&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;人工复核&quot;&gt;人工复核&lt;/h3&gt;

&lt;p&gt;&lt;img src=&quot;/img/tn-qwen3tts-review.jpg&quot; alt=&quot;复核页（演示数据：系统音色合成，界面就是实际用的那个）&quot; /&gt;&lt;/p&gt;

&lt;p&gt;改动写进 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;edits.json&lt;/code&gt;（按片段 id），重跑数据准备不会丢。A 的 198 段里改了 55 段文字（29 段错别字、19 段英文词、7 段只动标点），丢掉 33 段。B 的 469 段改了 109 段、丢掉 108 段，用时约 1.5 小时。我的感受是自动识别已经很准，只有少数断句不对。&lt;/p&gt;

&lt;h2 id=&quot;训练脚本官方-sft_12hzpy-的-4-处问题&quot;&gt;训练脚本：官方 sft_12hz.py 的 4 处问题&lt;/h2&gt;

&lt;p&gt;先用&lt;strong&gt;没训练过&lt;/strong&gt;的 Base 模型在真实片段上算损失：设置正确时损失应该很低，因为预训练模型本来就能预测真实语音。第一次冒烟测试损失 7.5，接近瞎猜（词表 3072，均匀分布约 8.0），才去对照推理代码排查：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;设置&lt;/th&gt;
      &lt;th&gt;talker 初始损失&lt;/th&gt;
      &lt;th&gt;子模型初始损失&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;官方脚本&lt;/td&gt;
      &lt;td&gt;11.9&lt;/td&gt;
      &lt;td&gt;9.1&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;标签只错位一次&lt;/td&gt;
      &lt;td&gt;7.9&lt;/td&gt;
      &lt;td&gt;–&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;再加 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;text_projection&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;1.2&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;–&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;子模型用上一帧隐藏状态、损失不再错位&lt;/td&gt;
      &lt;td&gt;–&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;5.4&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;标签错位两次&lt;/strong&gt;（&lt;a href=&quot;https://github.com/QwenLM/Qwen3-TTS/issues/179&quot;&gt;QwenLM/Qwen3-TTS#179&lt;/a&gt;）：talker 的 HF 损失内部会错位一次，脚本又手动 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[:, 1:]&lt;/code&gt;。模型学成预测隔一帧的 token，症状是越训语速越快。改法：输入和标签都不切片，让损失函数自己错位。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;漏了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;text_projection&lt;/code&gt;&lt;/strong&gt;：推理时所有文字向量（角色标记、tts_pad/bos/eos、正文）都先过 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;talker.text_projection&lt;/code&gt;，训练脚本直接用了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;text_embedding&lt;/code&gt; 的输出。维度都是 2048，不报错，模型一直在学推理时见不到的输入。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;子模型也错位两次&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;forward_sub_talker_finetune&lt;/code&gt; 返回的 logits 已经和 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;codec_ids[:, 1:]&lt;/code&gt; 对齐，内置损失又错一位；而且推理时 code predictor 拿到的是“预测出这一帧的那个位置”（t−1）的隐藏状态。改法：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hidden[:, :-1][codec_mask[:, 1:]]&lt;/code&gt; 配 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;codec_ids[:, 1:][codec_mask[:, 1:]]&lt;/code&gt;，自己算不错位的交叉熵。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;bf16 存权重&lt;/strong&gt;：学习率 2e-6 时，AdamW 每步的更新量小于 bf16 能表示的精度，大部分被舍入掉。改成 fp32 存权重、bf16 autocast 计算。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;其他设置：学习率 2e-6，5% 预热 + 余弦衰减，batch 2 × 梯度累积 4，梯度裁剪 1.0；说话人向量取 64 个训练片段声纹的平均（比单条参考录音稳），写进 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;codec_embedding&lt;/code&gt; 的第 3000 行；检查点按 bf16 保存，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tts_model_type&lt;/code&gt; 改成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;custom_voice&lt;/code&gt;，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;spk_id&lt;/code&gt; 只有这一个人。&lt;/p&gt;

&lt;p&gt;训练数据用的是不带语言标签的前缀（nothink），所以&lt;strong&gt;生成时语言要用 auto&lt;/strong&gt;，指定 chinese 会切到模型没见过的前缀。Base 模型从没学过指令，微调出来的声音&lt;strong&gt;不支持语气描述&lt;/strong&gt;。&lt;/p&gt;

&lt;h3 id=&quot;内存第一次跑进了交换&quot;&gt;内存：第一次跑进了交换&lt;/h3&gt;

&lt;p&gt;全量训练 1.6B 参数（文字嵌入层也训练）时占 44GB，第 2 轮存检查点时额外复制一份权重，系统开始用交换内存，每步从 1.2 秒变成 15 秒。改了 3 处：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;冻结文字嵌入层&lt;/strong&gt;：3.11 亿参数、15 万个词元，18 分钟录音只用得到几百个。省约 4GB，也少一点过拟合（细节见下一节）。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;存检查点时逐个张量先搬到 CPU 再转 bf16&lt;/strong&gt;，存完 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;torch.mps.empty_cache()&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;冻结的参数改存 bf16&lt;/strong&gt;（不更新，不需要 fp32）。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;之后 1.7B 全量稳定在 37–41GB，每步 1.2 秒。&lt;/p&gt;

&lt;h3 id=&quot;冻结文字嵌入层有没有隐患&quot;&gt;冻结文字嵌入层，有没有隐患&lt;/h3&gt;

&lt;p&gt;冻结不是去掉这一层：它照常参与计算，只是训练时不更新，保持 Base 模型原来的值。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;它管什么。&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;talker.model.text_embedding&lt;/code&gt; 是一张 151936 × 2048 的查找表，每个词元一行，只回答“这是哪个字”。后面决定“这个人怎么念”的部分都在训练：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;部分&lt;/th&gt;
      &lt;th&gt;形状 / 规模&lt;/th&gt;
      &lt;th&gt;训练时&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;text_embedding&lt;/code&gt;（认字）&lt;/td&gt;
      &lt;td&gt;151936 × 2048，3.11 亿参数&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;冻结&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;text_projection&lt;/code&gt;（把文字向量翻译给说话模型）&lt;/td&gt;
      &lt;td&gt;两层 2048 × 2048&lt;/td&gt;
      &lt;td&gt;训练&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;talker 28 层（怎么念、语调、节奏）&lt;/td&gt;
      &lt;td&gt;约 14 亿参数&lt;/td&gt;
      &lt;td&gt;训练&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;codec_embedding&lt;/code&gt;（声音编码、说话人向量所在）&lt;/td&gt;
      &lt;td&gt;3072 × 2048&lt;/td&gt;
      &lt;td&gt;训练&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;code predictor（每帧后 15 层声音编码）&lt;/td&gt;
      &lt;td&gt;约 1.7 亿参数&lt;/td&gt;
      &lt;td&gt;训练&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;speaker encoder（只用来算说话人向量）&lt;/td&gt;
      &lt;td&gt;–&lt;/td&gt;
      &lt;td&gt;冻结&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;为什么数据少时冻结反而更稳。&lt;/strong&gt; 不冻结的话，嵌入层每步只有训练文本里出现过的那几百行拿到梯度（AdamW 对梯度为 0 的行几乎不动），训练完“见过的字”和“没见过的字”在模型眼里慢慢变得不一致：换一句新话，可能有的字像他、有的字不像。冻结后 15 万个词元保持原样、彼此一致。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;精度没有损失。&lt;/strong&gt; Base 模型发布的权重本来就是 bf16（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;text_embedding&lt;/code&gt; 和 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;text_projection&lt;/code&gt; 都是），冻结部分用 bf16 存，和原始权重逐位相同；保存检查点时本来也存 bf16。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;真正的局限。&lt;/strong&gt; 冻结的嵌入层不能给一个词“换身份”。如果某个词他的读法很特别（产品名的固定念法、方言里某个字的读音），只能靠后面 28 层按上下文去学；多数情况够用，但如果训练后某个词&lt;strong&gt;还是固定念错&lt;/strong&gt;：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;先在稿子里换写法：同音字、拆开、加空格，最省事；&lt;/li&gt;
  &lt;li&gt;数据多了（一两个小时）再训练时加 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--train_text_embedding&lt;/code&gt;，多占约 4GB。新语言、新方言也属于这种情况。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;证据的边界。&lt;/strong&gt; 支持“认字没受损”的：微调后所有版本在训练集外的句子上错字率都低于 1%，包括 AI Agent、RPA 这类英文词，相似度也正常提升。没做过的：冻结和不冻结的严格对照。两个上线的音色都是冻结后训练的，不冻结的版本只跑过 1 轮（内存爆了），所以“两者效果一样”没有直接数据。要确认的话：同一份数据、batch 1 打开文字嵌入层训练 6 轮，对比相似度、错字率，再专门听几个专有名词。&lt;/p&gt;

&lt;h2 id=&quot;怎么判断像不像&quot;&gt;怎么判断像不像&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Qwen 自带的声纹编码器不能用来评估&lt;/strong&gt;：零样本复刻和参考录音的相似度 0.991，可另一个男声也有 0.974。8bit 和 bf16 的复刻都是 0.991（我听也没区别，所以线上留 8bit）。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;用 CAM++，比对本人 40 个真实片段的平均声纹&lt;/strong&gt;，不是单条参考录音。&lt;/li&gt;
  &lt;li&gt;训练集外的 5 句话 × 2 个随机种子，每个版本 10 条样音；另外看转写错字率和语速（真人约 5 字/秒，语速明显变快就是错位问题）。&lt;/li&gt;
  &lt;li&gt;分数是和训练录音比的，录音环境（麦克风、房间）也可能被学进去，&lt;strong&gt;最后靠耳朵挑&lt;/strong&gt;。对比页把同一句话的各个版本放在一起。&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;结果&quot;&gt;结果&lt;/h2&gt;

&lt;p&gt;&lt;img src=&quot;/img/tn-qwen3tts-epochs.png&quot; alt=&quot;每一轮的声纹相似度&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/tn-qwen3tts-loss.png&quot; alt=&quot;每一轮的训练误差&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;训练 2 轮就有大部分提升；之后基本持平。&lt;/li&gt;
  &lt;li&gt;训练误差到第 10 轮降到 0.1–0.2，开始背答案：A 的第 10 轮分数回落，个别样音有一声咔哒。所以每 2 轮存一版。&lt;/li&gt;
  &lt;li&gt;A 的第 8、10 轮又各加测 8 句 × 3 个种子：0.815 对 0.827，打平。最后都是我听种子 11 那一组挑的：A 用第 8 轮，B 用第 10 轮，另一轮留作备用。&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;低内存方案&quot;&gt;低内存方案&lt;/h2&gt;

&lt;p&gt;&lt;img src=&quot;/img/tn-qwen3tts-memory.png&quot; alt=&quot;训练 6 轮的内存峰值和相似度&quot; /&gt;&lt;/p&gt;

&lt;p&gt;训练 6 轮效果就够用了。同样 A 的 18 分钟录音，batch 1 × 累积 8，在 64GB 机器上实测：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;模型 · 设置&lt;/th&gt;
      &lt;th&gt;峰值&lt;/th&gt;
      &lt;th&gt;用时&lt;/th&gt;
      &lt;th&gt;相似度&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;1.7B 默认（batch 2，10 轮训练的第 6 轮）&lt;/td&gt;
      &lt;td&gt;约 39GB&lt;/td&gt;
      &lt;td&gt;约 10 分钟&lt;/td&gt;
      &lt;td&gt;0.842&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;1.7B 冻结 20 层&lt;/td&gt;
      &lt;td&gt;18.2GB&lt;/td&gt;
      &lt;td&gt;7 分钟&lt;/td&gt;
      &lt;td&gt;0.804&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;1.7B 冻结 24 层&lt;/td&gt;
      &lt;td&gt;15.6GB&lt;/td&gt;
      &lt;td&gt;6 分钟&lt;/td&gt;
      &lt;td&gt;0.785&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;1.7B 冻结 26 层&lt;/td&gt;
      &lt;td&gt;13.6GB&lt;/td&gt;
      &lt;td&gt;5.5 分钟&lt;/td&gt;
      &lt;td&gt;0.789&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;0.6B 不冻结&lt;/td&gt;
      &lt;td&gt;16.1GB&lt;/td&gt;
      &lt;td&gt;7 分钟&lt;/td&gt;
      &lt;td&gt;0.808&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;0.6B 冻结 14 层&lt;/td&gt;
      &lt;td&gt;11.9GB&lt;/td&gt;
      &lt;td&gt;5 分钟&lt;/td&gt;
      &lt;td&gt;0.827&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;0.6B 冻结 20 层&lt;/td&gt;
      &lt;td&gt;10.6GB&lt;/td&gt;
      &lt;td&gt;5 分钟&lt;/td&gt;
      &lt;td&gt;0.829&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;只改 batch 1 几乎不省（35.6 → 33.3GB），内存大头是被训练参数的权重、梯度和 Adam 状态（fp32 每参数 16 字节）。&lt;/p&gt;

&lt;p&gt;macOS 不会把全部内存给 GPU：这台 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;torch.mps.recommended_max_memory()&lt;/code&gt; 是 51.8GB（81%）。按同比例推算（偏乐观，小机器比例可能更低，芯片也更慢；都没实测）：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;机器&lt;/th&gt;
      &lt;th&gt;GPU 约可用&lt;/th&gt;
      &lt;th&gt;推荐&lt;/th&gt;
      &lt;th&gt;还剩&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;64GB&lt;/td&gt;
      &lt;td&gt;52GB&lt;/td&gt;
      &lt;td&gt;1.7B 默认&lt;/td&gt;
      &lt;td&gt;约 13GB&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;48GB&lt;/td&gt;
      &lt;td&gt;39GB&lt;/td&gt;
      &lt;td&gt;1.7B 冻结 20 层（默认正好贴着上限）&lt;/td&gt;
      &lt;td&gt;约 21GB&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;32GB&lt;/td&gt;
      &lt;td&gt;26GB&lt;/td&gt;
      &lt;td&gt;1.7B 冻结 20 层&lt;/td&gt;
      &lt;td&gt;约 8GB&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;24GB&lt;/td&gt;
      &lt;td&gt;19GB&lt;/td&gt;
      &lt;td&gt;0.6B 冻结 14 层&lt;/td&gt;
      &lt;td&gt;约 7GB&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;16GB&lt;/td&gt;
      &lt;td&gt;13GB&lt;/td&gt;
      &lt;td&gt;0.6B 冻结 20 层&lt;/td&gt;
      &lt;td&gt;约 2GB&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;0.6B 冻结一部分层，分数反而比 0.6B 全量高一点；每组只有 10 条样音，±0.02 以内算误差，0.6B 也还没用耳朵对比过。&lt;/p&gt;

&lt;h2 id=&quot;部署&quot;&gt;部署&lt;/h2&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;c&quot;&gt;# 选好的一轮转成 MLX 8bit，放进模型目录&lt;/span&gt;
python &lt;span class=&quot;nt&quot;&gt;-m&lt;/span&gt; mlx_audio.convert &lt;span class=&quot;nt&quot;&gt;--hf-path&lt;/span&gt; &amp;lt;ckpt&amp;gt;/epoch-8 &lt;span class=&quot;nt&quot;&gt;--mlx-path&lt;/span&gt; ~/Models/qwen3-tts/tuned/&amp;lt;&lt;span class=&quot;nb&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;-e8-8bit&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-q&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--q-bits&lt;/span&gt; 8
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.config/ttsd/config.json&lt;/code&gt;：&lt;/p&gt;

&lt;div class=&quot;language-json highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;nl&quot;&gt;&quot;tuned_voices&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;&amp;lt;id&amp;gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;显示名·微调&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;model&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;tuned/&amp;lt;id&amp;gt;-e8-8bit&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;speaker&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&amp;lt;id&amp;gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;gender&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;m&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;lang&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;zh&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;speaker&lt;/code&gt; 必须和训练时的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--speaker_name&lt;/code&gt; 一致。改完 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;launchctl kickstart -k gui/$(id -u)/&amp;lt;label&amp;gt;&lt;/code&gt; 重启，再用 API 建一个临时稿件生成一句验证，删掉稿件。&lt;/p&gt;

&lt;p&gt;ttsd 的改动：音色 id 是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tuned:&amp;lt;id&amp;gt;&lt;/code&gt;，每个微调音色单独占一个模型槽；生成时语言固定 auto、不传语气；网页里归到“我的音色”，选中时语气框变灰。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/tn-qwen3tts-webui.jpg&quot; alt=&quot;网页里的微调音色（名字已改成“说话人A”）&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;踩坑记录&quot;&gt;踩坑记录&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Safari 点播放没声音、下载正常&lt;/strong&gt;：网页用 Web Audio 播放，服务端和代码都没问题，重启 Safari 就好了。播放失败目前不提示，以后可以加一个报错。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;ttsd 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;safe_id&lt;/code&gt; 只认十六进制&lt;/strong&gt;：微调音色 id（如英文名）会被判无效，音色不出现。改成“必须是配置里已有的 key”，新加的单元测试抓到的。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;说话人 key 要用英文&lt;/strong&gt;：它会变成模型里的 speaker id 和目录名。开源版强制 ASCII。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;HF 下载卡在最后一步&lt;/strong&gt;：文件已经下完（大小对得上），卡在校验改名。临时文件名里就是 sha256，自己算一遍对上了再 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mv&lt;/code&gt; 到位。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pkill -f&lt;/code&gt; 误杀&lt;/strong&gt;：杀下载进程时的匹配串，也出现在另一个等待下载的后台命令里，把它一起杀了。按 PID 杀更稳。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;zsh 不按空格拆变量&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;set -- $var&lt;/code&gt; 在 zsh 里不拆分，循环参数要写开或用数组。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;mlx-audio 会读 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sys.argv&lt;/code&gt;&lt;/strong&gt;：自己的脚本用命令行传模型路径，被它当成了模型名。改成写在脚本里。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;MPS 上声纹编码器的 dtype&lt;/strong&gt;：模型是 bf16 时，mel 输入要转成同样的 dtype。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;截图脱敏&lt;/strong&gt;：截图前在页面里用 JS 改名、删掉真人内容；复核页用系统音色合成的演示数据重跑一遍再截。Playwright 渲染图表时，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;font-family&lt;/code&gt; 里的双引号会截断 HTML 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;style=&quot;…&quot;&lt;/code&gt;，字体就变回默认衬线体。&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;复现&quot;&gt;复现&lt;/h2&gt;

&lt;p&gt;最短路径（2026-10-29 加的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;run.sh&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy.py&lt;/code&gt;，视频文件可以直接放进素材文件夹，非 wav 的用 ffmpeg 解码）：&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;nb&quot;&gt;cd &lt;/span&gt;finetune &lt;span class=&quot;o&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;cp &lt;/span&gt;voiceprep.example.json voiceprep.json   &lt;span class=&quot;c&quot;&gt;# 填好后&lt;/span&gt;
./run.sh prep                 &lt;span class=&quot;c&quot;&gt;# 切片 + 转写 + 筛人，完成后打开复核页&lt;/span&gt;
./run.sh train alice          &lt;span class=&quot;c&quot;&gt;# 编码 → 训练 → 样音 → 打分；小内存加 --epochs 6 --batch_size 1 --grad_accum 8 --freeze_layers 20&lt;/span&gt;
./run.sh review               &lt;span class=&quot;c&quot;&gt;# /compare/alice 挑一轮&lt;/span&gt;
./run.sh deploy alice 8 &lt;span class=&quot;nt&quot;&gt;--name&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;显示名&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;--gender&lt;/span&gt; m &lt;span class=&quot;nt&quot;&gt;--restart&lt;/span&gt; &amp;lt;ttsd 的 launchd label&amp;gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy&lt;/code&gt; 会把模型复制进控制台的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;models_dir/tuned/&lt;/code&gt;，备份后改 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tuned_voices&lt;/code&gt;（speaker id 从检查点里读），可选重启。逐步手动跑：&lt;/p&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
7
8
9
10
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;nb&quot;&gt;cd &lt;/span&gt;finetune
&lt;span class=&quot;nb&quot;&gt;cp &lt;/span&gt;voiceprep.example.json voiceprep.json      &lt;span class=&quot;c&quot;&gt;# 说话人、参考录音和文字稿、素材目录&lt;/span&gt;
.venv/bin/python prep_data.py
.venv/bin/python review.py                    &lt;span class=&quot;c&quot;&gt;# 127.0.0.1:18410，无登录，只在本机开；远程用 ssh -L&lt;/span&gt;
.venv-train/bin/python build_train.py alice
.venv-train/bin/python sft.py &lt;span class=&quot;nt&quot;&gt;--train_jsonl&lt;/span&gt; &amp;lt;work&amp;gt;/alice/train_with_codes.jsonl &lt;span class=&quot;nt&quot;&gt;--speaker_name&lt;/span&gt; alice &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
    &lt;span class=&quot;nt&quot;&gt;--output_dir&lt;/span&gt; &amp;lt;work&amp;gt;/alice/ckpt &lt;span class=&quot;nt&quot;&gt;--epochs&lt;/span&gt; 10 &lt;span class=&quot;nt&quot;&gt;--save_epochs&lt;/span&gt; 2,4,6,8,10
&lt;span class=&quot;c&quot;&gt;#   小内存：--batch_size 1 --grad_accum 8 --freeze_layers 20 [--init_model_path Qwen/Qwen3-TTS-12Hz-0.6B-Base]&lt;/span&gt;
../.venv/bin/python gen_samples.py alice
.venv/bin/python score_samples.py alice       &lt;span class=&quot;c&quot;&gt;# 然后打开 /compare/alice 听&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;还没验证的&quot;&gt;还没验证的&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;16GB / 24GB / 32GB 机器上的真实内存和速度（都是按 64GB 的峰值和 81% 推算的）。&lt;/li&gt;
  &lt;li&gt;0.6B 微调版的听感；网页目前默认按 1.7B 的预设/克隆模型加载，0.6B 微调版能转换、能生成，但没上线。&lt;/li&gt;
  &lt;li&gt;更多数据（几小时）能不能继续提高；10 秒以内的片段占比对口音的影响。&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Thu, 29 Oct 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/2026/10/29/%E6%8A%80%E6%9C%AF%E7%AC%94%E8%AE%B0-Mac-mini-%E6%9C%AC%E5%9C%B0%E5%BE%AE%E8%B0%83-Qwen3-TTS-%E5%A3%B0%E9%9F%B3/</link>
        <guid isPermaLink="true">https://uulili.com/2026/10/29/%E6%8A%80%E6%9C%AF%E7%AC%94%E8%AE%B0-Mac-mini-%E6%9C%AC%E5%9C%B0%E5%BE%AE%E8%B0%83-Qwen3-TTS-%E5%A3%B0%E9%9F%B3/</guid>
        
        <category>技术笔记</category>
        
        <category>Qwen3-TTS</category>
        
        <category>Mac mini</category>
        
        <category>本地AI</category>
        
        <category>语音合成</category>
        
        
      </item>
    
      <item>
        <title>Mac mini 内存买多大？M6、M5 Pro 选购这篇就够了</title>
        <description>&lt;p&gt;选购 Mac mini 的关键是什么？CPU 已经同级别碾压了，存储空间后期还可以自己加；但&lt;strong&gt;内存是焊死在主板上的&lt;/strong&gt;，而内存的大小跟它作为服务器的用途息息相关。&lt;/p&gt;

&lt;p&gt;买小了，以后想跑的模型放不下，只能换机器。买大了，妥妥的杀鸡用牛刀。而不同的配置，差价就有上万元。如果你也想买一台，内存买多大、芯片怎么选、大概要花多少钱？&lt;/p&gt;

&lt;p&gt;下面先给不同用途的推荐配置，完整的配置表和价格表放在文末。&lt;/p&gt;

&lt;h2 id=&quot;先看推荐&quot;&gt;先看推荐&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;只当家里的服务器，&lt;strong&gt;不跑大模型：16GB&lt;/strong&gt;。M6 16+512 8,499 元，甚至 M4 二手 3,000–4,000 元。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;想试试本地模型：M6 32GB&lt;/strong&gt;，9,999 元起。27B 能跑但很慢；MoE 模型快，写文章够用，但整体质量不及稠密模型。&lt;/li&gt;
  &lt;li&gt;想把 27B 当日常用，&lt;strong&gt;聊长对话：M5 Pro 48GB&lt;/strong&gt;，19,749 元。&lt;/li&gt;
  &lt;li&gt;还要同时挂别的服务，或者以后想试&lt;strong&gt;更大的模型：M5 Pro 64GB&lt;/strong&gt;，20,499 元起。&lt;/li&gt;
  &lt;li&gt;2 万上下，更看重速度：&lt;strong&gt;Mac Studio 起步款 19,999 元&lt;/strong&gt;，比 mini 64GB 还便宜，但内存只有 36GB。预算到 2.7 万，Studio 64GB 比 mini 64GB 更值。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;用途确定后，&lt;strong&gt;硬盘选 512GB 就够&lt;/strong&gt;，256GB 实在太小。更大的容量不必一步到位，省下的钱后面自己加外置硬盘，或者找专业机构改造内置硬盘。&lt;/p&gt;

&lt;p&gt;本地模型我就拿现在最火的 Qwen3.8-27B 举例了，引擎是 oMLX。我这台是 M5 Pro 64GB，余量最充足，模型在不同设置下实际占多少内存，都能测出来。其他档位我没有真机，是拿实测的内存峰值去算的；M6 的速度用的是公开的实测数据。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/mp007-qwen-omlx.png&quot; alt=&quot;Qwen3.8-27B（Hugging Face 上月下载 745 万次）和 oMLX（GitHub 2.29 万星）&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;本地模型内存占用参考&quot;&gt;本地模型内存占用参考&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;27B 4-bit（Q4）短对话：峰值 17.2GB&lt;/li&gt;
  &lt;li&gt;27B Q4 聊到 160K 上下文：峰值约 38GB&lt;/li&gt;
  &lt;li&gt;27B 8-bit（Q8）短对话：峰值 29.0GB&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这只是模型这一个进程。macOS 自己、浏览器、后台服务也要占内存。所以&lt;strong&gt;要拿总内存减掉模型峰值&lt;/strong&gt;，看剩下的够不够其他程序日常使用。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/mp007-img2.png&quot; alt=&quot;27B 聊到 160K 要约 38GB，各机型还剩多少、差多少&quot; /&gt;&lt;/p&gt;

&lt;p&gt;内存不够时，Mac 会把一部分内容挪到硬盘上（swap），程序不会马上崩。看着能跑。但我不把这算“能用”。&lt;strong&gt;频繁 swap，服务器就不稳定了&lt;/strong&gt;。&lt;/p&gt;

&lt;h2 id=&quot;个人本地服务器最佳选项16gb&quot;&gt;个人本地服务器最佳选项：16GB&lt;/h2&gt;

&lt;p&gt;16GB 内存的机器不要考虑本地大模型。&lt;strong&gt;它的定位很清晰：本地服务器&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;我这台长期挂着 Hermes（AI 助手，16GB 上就只用云端模型）、Jellyfin（家里的影音库）、Syncthing（几台设备之间同步文件），16GB 跑这些没压力。&lt;/p&gt;

&lt;p&gt;其他适合长期挂着的还有这些：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Time Machine：家里其他 Mac 自动备份到这台。系统自带，在共享设置里把一个文件夹设成备份目的地就行。备份吃硬盘，一般外接一块容量充足的盘。&lt;/li&gt;
  &lt;li&gt;AdGuard Home：在 DNS 这一层拦广告和追踪域名，路由器指过来，全家设备都生效。它只按域名拦，不是所有广告都能去掉。&lt;/li&gt;
  &lt;li&gt;FreshRSS：自己的 RSS 阅读器，手机和电脑共用一份已读状态。&lt;/li&gt;
  &lt;li&gt;Paperless-ngx：合同、发票、说明书扫进去，自动 OCR，以后按字就能搜到原件。一次导入一大堆旧文件时，OCR 会有峰值。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;img src=&quot;/img/mp007-img3.png&quot; alt=&quot;16GB 的 Mac mini 可以长期挂着的服务&quot; /&gt;&lt;/p&gt;

&lt;p&gt;FreshRSS 和 Paperless-ngx 一般用 Docker 跑，要多开一个 Linux 虚拟机。Docker Desktop 默认给它的内存上限是主机的一半，16GB 机器就是 8GB。所以&lt;strong&gt;一项一项加&lt;/strong&gt;，OCR、视频转码这种有峰值的活错开时间。&lt;/p&gt;

&lt;p&gt;这一档芯片不重要。M6 16GB 的带宽是 153GB/s，比 24GB、32GB 款的 170GB/s 还低一点，但当服务器用不到这个差别。二手 M4 也够。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/mp007-img4.png&quot; alt=&quot;苹果官网：M6 16GB 款 153GB/s，24GB、32GB 款 170GB/s，M5 Pro 307GB/s&quot; /&gt;&lt;/p&gt;

&lt;p&gt;既然舍去了本地大模型，这个档位适合思路清晰，锁定云端大模型的玩家。既有云端顶级大模型加持，又有7x24随时响应的个人服务器，还能省下一大笔钱。&lt;/p&gt;

&lt;p&gt;值得一提的是，虽然 M6 起步款比 M4 当年的 4,499 元贵了 2,500 元，但这颗 2 纳米的芯片是实打实的厉害。&lt;/p&gt;

&lt;h2 id=&quot;本地模型m6-32gb&quot;&gt;本地模型：M6 32GB&lt;/h2&gt;

&lt;p&gt;先算内存。27B Q4 短对话峰值 17.2GB，32GB 还剩 14.8GB。放得下。聊到 160K 要约 38GB，已经超过总内存，上下文只能收短（推算）。&lt;/p&gt;

&lt;p&gt;再看速度。模型每输出一个 token，大致都要把权重从内存里读一遍，所以&lt;strong&gt;输出速度跟内存带宽关系很大&lt;/strong&gt;。M6 是 170GB/s，M5 Pro 是 307GB/s，差了 1.8 倍。&lt;/p&gt;

&lt;p&gt;M6 我没有真机，查了别人的实测，都是 32GB 款跑 Qwen3.8-27B 4-bit：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;一位德国网友在 GitHub 公开的测试（LM Studio，GGUF 格式）：写英文、德文段落每秒 11–12 个 token，写代码 17.5 个&lt;/li&gt;
  &lt;li&gt;一个视频评测（直接用 MLX）：每秒 8–9 个&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;我这台写 300 字是每秒 25.9 个。引擎、格式都不一样，不能严格对比，但差距在 2 倍以上，比带宽差得还多。M6 只有 12 核 GPU，我这台是 20 核，可能也有关系（我猜）。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/mp007-omlx-27b-300.png&quot; alt=&quot;上一篇的实测：我这台用 oMLX 跑 27B 写 300 字，每秒 25.9 个 token&quot; /&gt;&lt;/p&gt;

&lt;p&gt;按每秒 10 个左右的速度，偶尔问几句可以，当日常主力偏慢。所以 27B 在 M6 32GB 上仅限于能跑。&lt;strong&gt;日常使用基本劝退&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;想在 M6 上跑得快，可以换 MoE 模型。MoE 总参数大，但每输出一个 token 只用其中一小部分，要读的权重少，对带宽要求就低得多。&lt;/p&gt;

&lt;p&gt;拿 Qwen3.6-35B-A3B 的 4-bit 版来说。M6 32GB 上有两份别人的实测；我这台也用 oMLX 跑了同一个模型的 4-bit 版，提示词类型和参数照着 GitHub 那份测试来：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;短输入（德语、英语、代码、JSON）：M6 每秒 63–69 个 token，我这台约 110 个，&lt;strong&gt;是 M6 的 1.6–1.7 倍&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;读入约 2 万 token 的资料后：M6 每秒输出 58.5 个，我这台 80.4 个；读入速度 M6 每秒 1,215 个，我这台 2,176 个&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;img src=&quot;/img/mp007-moe.png&quot; alt=&quot;MoE 4-bit，M5 Pro 和 M6 的输出速度&quot; /&gt;&lt;/p&gt;

&lt;p&gt;带宽差 1.8 倍，短输入的速度差跟它很接近。&lt;/p&gt;

&lt;p&gt;本机内存测试如下：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;短输入：峰值约 21.5GB&lt;/li&gt;
  &lt;li&gt;读入 2 万 token：约 23.7GB&lt;/li&gt;
  &lt;li&gt;读入 6.5 万 token：约 24.7GB&lt;/li&gt;
  &lt;li&gt;读入 13 万 token：约 27.0GB&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;img src=&quot;/img/mp007-moe-7.png&quot; alt=&quot;MoE 4-bit 读 14 万字，内存峰值 27GB&quot; /&gt;&lt;/p&gt;

&lt;p&gt;全程没用 swap。MoE 读长资料，内存涨得很慢。拿 32GB 去减（推算）：短输入剩约 10GB，2 万 token 剩约 8GB，13 万 token 只剩 5GB。日本那位用户报的是 19.7GB，比我测的低一些，可能是统计口径不同。&lt;/p&gt;

&lt;p&gt;所以 M6 32GB 跑 MoE 4-bit，&lt;strong&gt;长资料也读得进去，剩 8–10GB 给系统&lt;/strong&gt;，开开浏览器、日常办公可以。但模型只能开这一个，再挂 Docker、影音库这类服务可能就得错开用了。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/mp007-omlx-moe-300.png&quot; alt=&quot;oMLX 网页对话：MoE 4-bit 写 300 字，每秒 106.8 个 token&quot; /&gt;&lt;/p&gt;

&lt;p&gt;当然 MoE 也有代价。在我这台 mini 上，它每秒约 110 个 token，是 27B 的 3–4 倍；可 35B-A3B 每个 token 只激活约 3B 参数，&lt;strong&gt;质量会比 27B 差一些&lt;/strong&gt;。我就怕它一直输出低质量的内容，擦屁股的工作更不好做。所以我日常还是用 27B。如果你更追求速度，Mac 系统还是首选 MoE 吧。&lt;/p&gt;

&lt;p&gt;还有一个选项是 M5 Pro 24GB，12,999 元。芯片虽然更快，可内存比 M6 32GB 还少 8GB，27B Q4 短对话剩 6.8GB，再开几个程序就吃紧了。只看本地模型，&lt;strong&gt;这一档有点尴尬&lt;/strong&gt;。&lt;/p&gt;

&lt;h2 id=&quot;天天用-27bm5-pro-48gb-起&quot;&gt;天天用 27B：M5 Pro 48GB 起&lt;/h2&gt;

&lt;p&gt;如果天天用本地大模型，免不了聊长对话、丢长资料进去。这时&lt;strong&gt;内存要按 160K 算&lt;/strong&gt;：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;48GB：聊到 160K 剩约 10GB 给系统和其他程序，能跑，不算宽裕&lt;/li&gt;
  &lt;li&gt;64GB：剩约 26GB（本机实测峰值）&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;M5 Pro 各档带宽都是 307GB/s，48GB 款的输出速度应该接近我这台：回“你好”每秒 35.8 token，写 300 字每秒 25.9 token（48GB 是推算）。&lt;/p&gt;

&lt;p&gt;想跑 MoE 4-bit 也宽裕：读入 13 万 token 峰值约 27GB，48GB 还剩 21GB。&lt;/p&gt;

&lt;h2 id=&quot;同时挂别的服务64gb&quot;&gt;同时挂别的服务：64GB&lt;/h2&gt;

&lt;p&gt;这是我这台的配置：M5 Pro，18 核 CPU / 20 核 GPU，64GB 内存。它同时挂着 Hermes、Jellyfin 和本地视频生成 MiniMax H3。&lt;/p&gt;

&lt;p&gt;我给模型进程设了 48GB 的上限，剩下 16GB 留给系统和其他服务。就算这样，&lt;strong&gt;模型和 H3 也不能同时跑&lt;/strong&gt;。模型约 38GB，H3 约 22GB，合计约 60GB，留给系统的余量太少。我实测过，只能错开用。&lt;/p&gt;

&lt;p&gt;所以 64GB 也不是什么都能一起开。错开用，都放得下。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/mp007-img9.png&quot; alt=&quot;64GB 怎么分：只开 27B 和 27B、H3 一起开&quot; /&gt;&lt;/p&gt;

&lt;p&gt;我为什么买 64GB？买一台就打算用很久，而&lt;strong&gt;内存无法改&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;苹果生态在 AI 上发展得很快，很多本地模型，生文字、生图、生视频、生声音，方向都是在保证质量的基础上尽量压缩体积。内存越大，越能有入场券。另外它还要跑日常任务，我不希望频繁出现内存爆满、swap 的情况，否则服务器就不稳定，不能被称作服务器了。&lt;/p&gt;

&lt;h2 id=&quot;2-万上下mac-mini-还是-mac-studio&quot;&gt;2 万上下：Mac mini 还是 Mac Studio&lt;/h2&gt;

&lt;p&gt;&lt;img src=&quot;/img/mp007-mini-studio.jpg&quot; alt=&quot;Mac mini（左）和 Mac Studio（右）。图：Kyu3a，维基共享资源，CC BY-SA 4.0&quot; /&gt;&lt;/p&gt;

&lt;p&gt;到了 2 万这个价位，Mac Studio 跟 Mac mini 是一个价了：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Mac mini M5 Pro 48GB+1TB：19,749 元，带宽 307GB/s&lt;/li&gt;
  &lt;li&gt;Mac Studio M5 Max 36GB+512GB：19,999 元，带宽 460GB/s&lt;/li&gt;
  &lt;li&gt;Mac mini M5 Pro 64GB+512GB：20,499 元，带宽 307GB/s&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;img src=&quot;/img/mp007-img11.png&quot; alt=&quot;同价位 Mac Studio 和 Mac mini 的价格与内存带宽&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Studio 起步款比 mini 64GB 还便宜 500 元。它是拿 Max 芯片换掉了一部分内存：带宽高 1.5 倍，内存只有 36GB。跑 27B Q4 短对话剩 18.8GB，速度应该会比我这台快；但聊到 160K 要约 38GB，放不下。所以&lt;strong&gt;天天聊长对话，还是 mini 48GB、64GB 合适&lt;/strong&gt;；主要是短问短答，又想快一点，Studio 起步款更划算。&lt;/p&gt;

&lt;p&gt;预算再往上，差别就更明显了：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Mac mini M5 Pro 64GB+1TB：22,749 元，带宽 307GB/s&lt;/li&gt;
  &lt;li&gt;Mac mini M5 Pro 64GB+2TB：27,999 元，带宽 307GB/s&lt;/li&gt;
  &lt;li&gt;Mac Studio M5 Max 64GB+512GB：27,499 元，40 核 GPU，带宽 614GB/s&lt;/li&gt;
  &lt;li&gt;Mac Studio M5 Max 128GB+512GB：39,499 元，带宽 614GB/s&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;同样 64GB+512GB，Studio 比 mini 贵 7,000 元，换来 2 倍带宽和更好的散热。跟 mini 64+2TB 比，便宜 500 元，带宽是 2 倍，只是硬盘小了 1.5TB。&lt;strong&gt;硬盘可以外接，带宽加不了&lt;/strong&gt;。预算到了 2.7 万，又主要为了本地模型，Studio 64GB 更值。&lt;/p&gt;

&lt;p&gt;现在想想，如果不考虑体积，或许 128GB 的 Studio 也是更好的选择，散热更好，空间更足。苹果自己的芯片也在往前走，我猜愿意再等等的话，后面的芯片大概率更适合本地模型。只是这些都是等待成本。人生百年，等来等去，不如早买早享受。&lt;/p&gt;

&lt;p&gt;当然我的现状其实不止是用本地大模型，云端模型总是更香的。除非你清醒地认为本地模型足以胜任你的本地工作，否则最好还是&lt;strong&gt;不要妄想买了 Mac mini 后，从此就能离开云端模型&lt;/strong&gt;。&lt;/p&gt;

&lt;h2 id=&quot;硬盘够用就行后面外接&quot;&gt;硬盘：够用就行，后面外接&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;硬盘不像内存，可以后面加&lt;/strong&gt;。用途定了，内置盘选够用的就行。&lt;/p&gt;

&lt;p&gt;跑本地模型要留空间。只当服务器的话，512GB 就够，影音、备份放外置盘。256GB 实在太小，系统就能吃掉一大块。如果买的是 256GB，我建议直接改造内置硬盘，内置盘比外置盘稳定。改造要找专业机构，可能会影响官方保修。我觉得还是直接 512GB 保险一些。&lt;/p&gt;

&lt;p&gt;官网加硬盘的价格不低：M6 32GB 从 256GB 加到 1TB 多 3,750 元，M5 Pro 64GB 从 1TB 加到 2TB 多 5,250 元。这笔钱可以先省下来，后面按需要买外置硬盘。我这台选 2TB，是因为我日常工作上要备份大量视频。只跑模型、当服务器，其实用不着 2TB。&lt;/p&gt;

&lt;h2 id=&quot;配置表和价格表&quot;&gt;配置表和价格表&lt;/h2&gt;

&lt;p&gt;价格是 2026 年 10 月的国行官网价，没算双十一活动。M4 官网已经不卖，写的是闲鱼二手价。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/mp007-img12.png&quot; alt=&quot;按用途选 Mac：配置和价格&quot; /&gt;&lt;/p&gt;

&lt;p&gt;这台 Mac mini 之前的实测：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://mp.weixin.qq.com/s/thx2mUELkcduNu7ifyb5fw&quot;&gt;《小钢炮 Mac mini 跑 27B，最快 35 token/s》&lt;/a&gt;：本地大模型怎么配、跑多快&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://mp.weixin.qq.com/s/iHqF5izRKrq8wT1bJgvGUg&quot;&gt;《Mac mini 跑 AI 视频：内存占多少，生成要多久？》&lt;/a&gt;：本地跑 MiniMax H3 生成视频&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://mp.weixin.qq.com/s/s3sMxi6Q_7Ujax9Ce6AU4Q&quot;&gt;《两台电脑持久同步文件，不占我的 iCloud 空间》&lt;/a&gt;：16GB 那一档提到的 Syncthing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;手上有 M6 32GB、M5 Pro 48GB 或 Mac Studio 的小伙伴，欢迎在评论区留下你的实测速度。还想让我拿这台 mini 测点什么，也欢迎留言。希望对你有帮助。&lt;/p&gt;

&lt;p&gt;来源：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;我这台的数据：Mac mini M5 Pro 64GB 实测，oMLX 跑 Qwen3.8-27B，见上一篇《小钢炮 Mac mini 跑 27B，最快 35 token/s》&lt;/li&gt;
  &lt;li&gt;Mac mini、Mac Studio 带宽和配置：apple.com.cn/mac-mini/specs、apple.com.cn/mac-studio/specs&lt;/li&gt;
  &lt;li&gt;M6 32GB 跑 27B（LM Studio，GGUF）：github.com/HDZucht/Mac-Mini-M6-LLM-benchmarks&lt;/li&gt;
  &lt;li&gt;M6 32GB 跑 27B（MLX，视频评测）：youtube.com/watch?v=7ugFOo8e8H4&lt;/li&gt;
  &lt;li&gt;M6 32GB 跑 MoE：github.com/HDZucht/Mac-Mini-M6-LLM-benchmarks、note.com/corestudio/n/n583c1b00f8ef&lt;/li&gt;
  &lt;li&gt;M5 Pro 跑 MoE 4-bit：我这台用 oMLX 0.7.0 实测，模型 mlx-community/Qwen3.6-35B-A3B-4bit（2026 年 10 月 20 日）&lt;/li&gt;
  &lt;li&gt;M6 2 纳米、6,999 元起：2026 年 8 月 25 日发布会报道（IT之家、新浪科技）；M4 Mac mini 国行 4,499 元起：2024 年发售价（IT之家）&lt;/li&gt;
  &lt;li&gt;16GB 服务器上的服务：Docker、AdGuard Home、FreshRSS、Paperless-ngx 官方文档&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;本文首发于微信公众号「像素李的AI笔记」：&lt;a href=&quot;https://mp.weixin.qq.com/s/BZT74CovkFwzYiFMCngvbg&quot;&gt;原文链接&lt;/a&gt;。博客版保留了文中的外链，和公众号版不同的地方都标了“博客版”。&lt;/p&gt;
</description>
        <pubDate>Tue, 20 Oct 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/2026/10/20/Mac-mini-%E5%86%85%E5%AD%98%E4%B9%B0%E5%A4%9A%E5%A4%A7-M6-M5-Pro-%E9%80%89%E8%B4%AD%E8%BF%99%E7%AF%87%E5%B0%B1%E5%A4%9F%E4%BA%86/</link>
        <guid isPermaLink="true">https://uulili.com/2026/10/20/Mac-mini-%E5%86%85%E5%AD%98%E4%B9%B0%E5%A4%9A%E5%A4%A7-M6-M5-Pro-%E9%80%89%E8%B4%AD%E8%BF%99%E7%AF%87%E5%B0%B1%E5%A4%9F%E4%BA%86/</guid>
        
        <category>技术分享</category>
        
        <category>本地实测</category>
        
        <category>Mac mini</category>
        
        <category>本地AI</category>
        
        <category>大模型</category>
        
        
      </item>
    
      <item>
        <title>小钢炮 Mac mini 跑 27B，最快 35 token/s</title>
        <description>&lt;p&gt;用云端 AI 久了，有件事一直让我分神：额度。5 小时有一个上限，一周还有一个上限，干到一半提示用完，只能等。挺烦的。网络不好的时候也会卡住。还有一些问题，我不太想发到云端，但也没别的办法。&lt;/p&gt;

&lt;p&gt;所以这次我在家里的 Mac mini 上把本地大模型认真跑了一遍。图的就两点：token 自由，想用多少用多少；免审查，少被拒绝。&lt;/p&gt;

&lt;p&gt;前后测了三天。能用，而且能 7×24 小时开着。对话聊到 13 万 token，回一句还是 3–4 秒。云端额度用完了，它自动顶上。&lt;/p&gt;

&lt;p&gt;下面是怎么配、不同设置差在哪，还有如果你也想买一台，内存该怎么选。&lt;/p&gt;

&lt;h2 id=&quot;我的机器和用法&quot;&gt;我的机器和用法&lt;/h2&gt;

&lt;p&gt;Mac mini，M5 Pro，20 核 GPU，64GB 内存，2TB 硬盘。它平时是家里的服务器，还跑着 AI 助手、影音库和视频生成，模型不能把内存全占了。所以我给模型进程设了 48GB 的上限，超了就停。&lt;/p&gt;

&lt;p&gt;模型用的是 Qwen3.8-27B，一个 270 亿参数的模型。引擎试了 oMLX 和 MTPLX 两个，最后留下 oMLX。&lt;/p&gt;

&lt;h2 id=&quot;跑起来到底多快&quot;&gt;跑起来到底多快&lt;/h2&gt;

&lt;p&gt;日常用的感受是这样：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;场景&lt;/th&gt;
      &lt;th&gt;等多久&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;已经聊了 13 万 token，再问一句&lt;/td&gt;
      &lt;td&gt;3–4 秒开始回答&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;聊到 21 万 token&lt;/td&gt;
      &lt;td&gt;4–7 秒&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;一次丢进去 1 万多字的新资料&lt;/td&gt;
      &lt;td&gt;几十秒到一分多钟&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;输出速度&lt;/td&gt;
      &lt;td&gt;每秒 20–27 个 token，短回答最快到 35.8&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;写这篇的时候，我又打开 oMLX 自带的网页对话测了一下。回一句“你好”，输出每秒 35.8 个 token，这是我见过最快的一次；让它写 300 字左右的介绍，每秒 25.9 个 token，从发出到写完一共 10.2 秒。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/mp003-omlx-300.png&quot; alt=&quot;oMLX 网页对话：写 300 字，每秒 25.9 个 token&quot; /&gt;&lt;/p&gt;

&lt;p&gt;整个过程内存最高约 38GB，没有用到 swap。服务重启以后，之前的对话缓存还在，接着聊不用重新读。&lt;/p&gt;

&lt;p&gt;这个速度能日常用。前提是开了一个设置：前缀缓存。&lt;/p&gt;

&lt;h2 id=&quot;最关键的设置前缀缓存&quot;&gt;最关键的设置：前缀缓存&lt;/h2&gt;

&lt;p&gt;不开缓存的时候，你每问一句，模型都要把整段历史从头读一遍。聊到 6 万 token，每句要等 2–3 分钟。我第一轮测试测的是“一次读入 26 万 token 要多久”，结果是首字等了 18 分钟，差点得出“本地长对话没法用”的结论。&lt;/p&gt;

&lt;p&gt;开了缓存，它只读新增的那部分。3–4 秒就是这么来的。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/mp003-img2.png&quot; alt=&quot;开不开缓存，等待时间差几十倍&quot; /&gt;&lt;/p&gt;

&lt;p&gt;缓存比换量化、开加速都重要。这是我这次最大的收获。缓存我放在 SSD 上，上限 100GB，大约能存四五个聊满的长对话。&lt;/p&gt;

&lt;h2 id=&quot;不同设置差在哪&quot;&gt;不同设置差在哪&lt;/h2&gt;

&lt;h3 id=&quot;4-bit-就够了&quot;&gt;4-bit 就够了&lt;/h3&gt;

&lt;p&gt;模型可以压成不同精度，位数越低越省内存。我拿 4-bit（Q4）和 8-bit（Q8）做了 63 道题的对比：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt; &lt;/th&gt;
      &lt;th&gt;结构化题&lt;/th&gt;
      &lt;th&gt;代码&lt;/th&gt;
      &lt;th&gt;内存峰值（短对话）&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Q8&lt;/td&gt;
      &lt;td&gt;30/42&lt;/td&gt;
      &lt;td&gt;8/8&lt;/td&gt;
      &lt;td&gt;29.0GB&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Q4&lt;/td&gt;
      &lt;td&gt;34/42&lt;/td&gt;
      &lt;td&gt;7/8&lt;/td&gt;
      &lt;td&gt;17.2GB&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Q4 不比 Q8 差。代码少对了一题，工具调用两边都是 3/3。一次读入 26 万 token 时，Q4 每秒读 237 个 token，峰值 36.1GB；Q8 每秒 160 个，峰值 45.4GB。oMLX 对 4-bit 有专门的加速，所以 Q4 又快又省。我又让 Q4 连续跑了两小时，13 道题全对，内存一直稳在 36GB。&lt;/p&gt;

&lt;h3 id=&quot;免审查版能力一样收益没测出来&quot;&gt;免审查版：能力一样，收益没测出来&lt;/h3&gt;

&lt;p&gt;免审查版和官方版我都压成了 Q4，同一套题，得分几乎一样，速度也一样（每秒 16.61 和 16.70 个 token）。&lt;/p&gt;

&lt;p&gt;但我准备的几道敏感题，两个版本都正常回答了，免审查到底多帮了多少，这次没测出来。按模型作者的说明，它是“减少拒答”，不是“完全不拒答”，开思考模式时仍有约 8.7% 会拒绝。&lt;/p&gt;

&lt;h3 id=&quot;moe-模型快但吃内存&quot;&gt;MoE 模型：快，但吃内存&lt;/h3&gt;

&lt;p&gt;还试了一个 35B 的 MoE 模型，它每次只用到 3B 参数，所以快：短对话每秒输出 56 个 token，是上面配置的 3–4 倍。但 8-bit 版光权重就占 35GB 左右，峰值到了 47GB，长对话不稳定。在 64GB 上，我把它淘汰了。&lt;/p&gt;

&lt;h2 id=&quot;照着抄的配置&quot;&gt;照着抄的配置&lt;/h2&gt;

&lt;p&gt;引擎用 oMLX 0.7.0，模型是 Qwen3.8-27B 免审查版 4-bit。下面这些，左边是设置，右边是在 oMLX 里怎么写：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;项目&lt;/th&gt;
      &lt;th&gt;设置&lt;/th&gt;
      &lt;th&gt;在 oMLX 里&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;前缀缓存&lt;/td&gt;
      &lt;td&gt;开，放 SSD，上限 100GB&lt;/td&gt;
      &lt;td&gt;启动参数 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--paged-ssd-cache-dir&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--paged-ssd-cache-max-size 100GB&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;MTP 加速&lt;/td&gt;
      &lt;td&gt;开&lt;/td&gt;
      &lt;td&gt;模型设置 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mtp_enabled: true&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;思考模式&lt;/td&gt;
      &lt;td&gt;关&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;enable_thinking: false&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;上下文窗口&lt;/td&gt;
      &lt;td&gt;160K&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;max_context_window: 163840&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;内存上限&lt;/td&gt;
      &lt;td&gt;48GB&lt;/td&gt;
      &lt;td&gt;启动参数 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--memory-guard-gb 48&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;同时处理&lt;/td&gt;
      &lt;td&gt;1 个请求&lt;/td&gt;
      &lt;td&gt;启动参数 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--max-concurrent-requests 1&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;空闲卸载&lt;/td&gt;
      &lt;td&gt;30 分钟&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ttl_seconds: 1800&lt;/code&gt;，下次约 4 秒重新载入&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;两个要注意的地方：&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MTP 要先导入。&lt;/strong&gt; 这个 4-bit 模型的 MTP 权重放在单独的文件里，oMLX 默认读不到，要用 oMLX 自带的导入功能（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;import_mtplx_sidecar&lt;/code&gt;）整理一遍。它会直接改模型目录，先用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cp -cR&lt;/code&gt; 复制一份。值得折腾：聊到 13 万 token 时，输出从每秒 10.6 个 token 提到 26 个。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4-bit 有专门的读入加速（oQ A8）&lt;/strong&gt;，大约快 1.4 倍，我是开着的。Q6、Q8 用不上。&lt;/p&gt;

&lt;p&gt;这几个我试过，不建议开：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;KV 缓存量化&lt;/strong&gt;：读入和输出都慢了 2–3 倍。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;ANE 预填充&lt;/strong&gt;：反而慢了 30%，还多吃 16GB 内存，而且会顶掉上面的 oQ A8。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;内存热缓存&lt;/strong&gt;：直接把内存撑爆。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;256K 窗口&lt;/strong&gt;：能跑完，但贴着 48GB 的上限，没有余量。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;我还把它接进了 AI 助手 Hermes，当作备用模型：云端限流、额度用完或者断网时，自动切到本地。代价是每开一个新对话，第一句要多等 20 秒左右，因为助手每次都要带上一大段说明，大约 1.4 万 token。之后就快了。&lt;/p&gt;

&lt;h2 id=&quot;从哪儿用它&quot;&gt;从哪儿用它&lt;/h2&gt;

&lt;p&gt;同一个模型，我从四个地方用。oMLX 同时提供 OpenAI 和 Anthropic 两种格式的接口，所以常见的工具都能接上。快慢差别不在模型，在于每次请求要先读多少固定内容：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;入口&lt;/th&gt;
      &lt;th&gt;新对话第一句&lt;/th&gt;
      &lt;th&gt;适合做什么&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;oMLX 自带的网页对话&lt;/td&gt;
      &lt;td&gt;约 1.4 秒&lt;/td&gt;
      &lt;td&gt;纯聊天、写东西、不想上云的问题&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Hermes（在微信、飞书里用）&lt;/td&gt;
      &lt;td&gt;约 20 秒&lt;/td&gt;
      &lt;td&gt;日常助手；云端不可用时自动切过来&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Claude Code&lt;/td&gt;
      &lt;td&gt;约 35 秒&lt;/td&gt;
      &lt;td&gt;简单的编码，不想让代码离开机器时&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Codex&lt;/td&gt;
      &lt;td&gt;约 54 秒&lt;/td&gt;
      &lt;td&gt;同上&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;同一个对话聊下去，各个入口都会命中缓存，差距就小了。不过说实话，27B 的 4-bit 模型做多文件的编码明显不如云端模型，我只拿它做简单的活。&lt;/p&gt;

&lt;p&gt;笔记本上用也方便。我用 Tailscale 把它转到自己的设备上，只有我的设备能连，出门也能用。&lt;/p&gt;

&lt;h2 id=&quot;能不能一直开着&quot;&gt;能不能一直开着&lt;/h2&gt;

&lt;p&gt;能。但有几件事要知道。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;耗电&lt;/strong&gt;：推理的时候比空闲多耗 50–60W，峰值约 80W。模型闲着时几乎不额外耗电，30 分钟没用还会自动卸载。我平时大部分时间在打字，按每天累计推理一两个小时算，一年多用几十度电。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;温度&lt;/strong&gt;：芯片 1–2 分钟升到 75–77°C 后就不涨了，没有温控警告，也没降频。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;硬盘&lt;/strong&gt;：写入主要来自缓存，每 1000 token 新内容约写 140MB。很重度地用，一天约 28GB，一年 10TB 左右。主流 2TB 固态的标称写入寿命一般在几百 TB 以上，用十年问题不大。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;不能和别的大任务一起跑&lt;/strong&gt;：我这台还跑视频生成，峰值约 22GB，加上模型的 38GB 就超了，只能错开用。&lt;/p&gt;

&lt;h2 id=&quot;如果你想买内存怎么选&quot;&gt;如果你想买，内存怎么选&lt;/h2&gt;

&lt;p&gt;我只测了 64GB 这一台。下面除了 64GB，都是按实测的内存峰值推算的，只能当参考，买之前最好再看看同配置的实测。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/mp003-img3.png&quot; alt=&quot;27B 4-bit 模型的内存峰值&quot; /&gt;&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;内存&lt;/th&gt;
      &lt;th&gt;我的判断&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;64GB&lt;/td&gt;
      &lt;td&gt;实测：27B 4-bit、160K 长对话、同时跑其他服务都没问题，只是不能和视频生成这类大任务同时跑&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;48GB&lt;/td&gt;
      &lt;td&gt;推测：27B 4-bit 能跑，短对话峰值只要 17GB；但长对话到 160K 时模型峰值约 38GB，再给系统留余量就紧了，上下文可能要收短&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;32GB&lt;/td&gt;
      &lt;td&gt;推测：27B 4-bit 短对话可以试，长对话空间不多，也很难再同时跑别的服务；更现实的是换小一点的模型&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;24GB 及以下&lt;/td&gt;
      &lt;td&gt;推测：27B 这一档不太现实，建议直接选小模型&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;关于新出的 M6 Mac mini：苹果公布的配置最高 32GB 内存，所以按上面的推算，只能落在 32GB 这一档。新芯片快多少、引擎有没有为它优化，现在都没有实测，我不给结论。&lt;/p&gt;

&lt;p&gt;我的取舍是：跑本地大模型，内存比芯片档位重要。芯片决定快慢，内存决定能不能跑、能聊多长。硬盘也别太小，缓存和模型都要占地方。&lt;/p&gt;

&lt;p&gt;如果你主要用云端，偶尔想要个不限量、不出机器的备用，一台大内存的 Mac mini 现在已经能做到。希望这些数字对你选配置有帮助。&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;本文首发于微信公众号「像素李的AI笔记」，可在公众号内搜索阅读。博客版保留了文中的外链，和公众号版不同的地方都标了“博客版”。&lt;/p&gt;
</description>
        <pubDate>Sat, 17 Oct 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/2026/10/17/%E5%B0%8F%E9%92%A2%E7%82%AE-Mac-mini-%E8%B7%91-27B-%E6%9C%80%E5%BF%AB-35-token-s/</link>
        <guid isPermaLink="true">https://uulili.com/2026/10/17/%E5%B0%8F%E9%92%A2%E7%82%AE-Mac-mini-%E8%B7%91-27B-%E6%9C%80%E5%BF%AB-35-token-s/</guid>
        
        <category>技术分享</category>
        
        <category>本地实测</category>
        
        <category>Mac mini</category>
        
        <category>本地AI</category>
        
        <category>大模型</category>
        
        
      </item>
    
      <item>
        <title>Mac mini 跑 AI 视频：内存占多少，生成要多久？</title>
        <description>&lt;p&gt;如果准备买一台 Mac mini 跑本地 AI，内存选多少，估计是最纠结的地方。尤其是视频模型，光下载就上百 GB，很容易觉得机器也得配上百 GB 内存。&lt;/p&gt;

&lt;p&gt;我在家里的 M5 Pro 64GB 上跑了一轮 MiniMax-H3。短片测试的内存峰值大多在 18.5–22GB，9 秒那次是 25.6GB；推荐参数生成约 2 秒的竖屏视频，要等 3 分钟左右。&lt;/p&gt;

&lt;p&gt;所以，64GB 不是这次短片测试全部用得上的内存。但选配置也不能只照着 22GB 这个数买，系统、其他软件和 GPU 可用的内存还得一起算。下面把我实际跑过的结果列出来，给还没买机器的人一个参考。&lt;/p&gt;

&lt;p&gt;用的是 Redis 作者 antirez 写的 h3.c，纯 C + Metal。文生视频、首尾帧和参考图都能用，还能一起生成声音。&lt;/p&gt;

&lt;p&gt;先放两段生成效果：&lt;/p&gt;

&lt;video src=&quot;/img/minimax-h3-fox-t2v.mp4&quot; controls=&quot;&quot; playsinline=&quot;&quot; preload=&quot;metadata&quot; style=&quot;width:100%&quot; aria-label=&quot;雪地里的狐狸&quot;&gt;&lt;/video&gt;

&lt;video src=&quot;/img/minimax-h3-clerk-t2v.mp4&quot; controls=&quot;&quot; playsinline=&quot;&quot; preload=&quot;metadata&quot; style=&quot;width:100%&quot; aria-label=&quot;说“欢迎光临”的面包店店员&quot;&gt;&lt;/video&gt;

&lt;p&gt;（博客版：上面换成了带声音的原视频，公众号版放的是无声 GIF。）店员那段的“欢迎光临”说对了，不过也不是每次都这么顺利，下面说。&lt;/p&gt;

&lt;h2 id=&quot;内存64gb-用不满小内存也不能直接照着买&quot;&gt;内存：64GB 用不满，小内存也不能直接照着买&lt;/h2&gt;

&lt;p&gt;Mac mini，M5 Pro，20 核 GPU，64GB 内存，2TB 硬盘。平时还跑着其他服务，测试的时候没停。你可能也想问：只是跑这个模型，需要买到 64GB 吗？&lt;/p&gt;

&lt;p&gt;先看记录：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;测试&lt;/th&gt;
      &lt;th&gt;记录的内存峰值&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;约 2 秒的短片，不同加速参数&lt;/td&gt;
      &lt;td&gt;18.5–22GB&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;9 秒竖屏视频&lt;/td&gt;
      &lt;td&gt;25.6GB&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;这些是生成过程记录的峰值，不是整台机器连系统和其他服务一起的总占用。这批测试没有用到 swap，64GB 确实有不少余量。在服务启动之前，余量在93%左右，跑服务的时候，余量仍可以保持在60%左右。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;下载的模型体积和运行内存不是一个数。&lt;/strong&gt; h3.c 会分阶段加载不同部分；我这台 M5 跑的路径还会在加载时转成 int8。134GiB 的权重放在硬盘上，不代表同时要吃掉 134GiB 内存。&lt;/p&gt;

&lt;p&gt;不过，还有个数要看：程序识别到这台机器的 &lt;strong&gt;GPU 推荐工作集是 51.8GiB&lt;/strong&gt;。它表示 Metal 建议 GPU 同时使用的内存规模，不是模型已经占了 51.8GiB，也不是超过它就一定不能运行。Mac 用的是统一内存，但不能把包装上的 64GB 全当成 GPU 可以随意占满的空间。&lt;/p&gt;

&lt;p&gt;这也是我没有直接写“24GB 就够”的原因。短片峰值已经在 18.5–22GB，9 秒那次到了 25.6GB；系统和后台程序还没算进去。换成小内存机器，原来这套参数还有没有余量，要重新验证。&lt;/p&gt;

&lt;p&gt;如果现在让我为这套用法选内存，我会这样考虑：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;内存配置&lt;/th&gt;
      &lt;th&gt;按这次测试怎么考虑&lt;/th&gt;
      &lt;th&gt;有没有实测&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;24GB&lt;/td&gt;
      &lt;td&gt;短片峰值已接近整机内存，9 秒那次超过它；不能照搬这套参数，得另测省内存方案&lt;/td&gt;
      &lt;td&gt;没有&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;48GB&lt;/td&gt;
      &lt;td&gt;比这批峰值有更多余量，主要做短镜头、同时运行的程序不多，值得考虑&lt;/td&gt;
      &lt;td&gt;没有，是根据本机记录推测&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;64GB&lt;/td&gt;
      &lt;td&gt;我还常驻其他服务，这批测试没有用到 swap，用起来比较省心&lt;/td&gt;
      &lt;td&gt;有，就是本文这台&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;原报告里对 48GB 的估算，是假设 GPU 工作集约为内存的八成，大约有 38GB。这个比例只是估算，不能当成每台机器固定的配额；买到其他配置，仍要看程序实际识别的数。我也没有把 48GB 跑过一遍，所以这里说的是“值得考虑”，不是保证它和 64GB 一样快。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;小内存还有一条路，但要用时间换空间。&lt;/strong&gt; h3.c 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--ssd-streaming&lt;/code&gt; 会一边计算，一边从 SSD 读取主模型的后续部分。作者在 M5 Max 上记录的主模型张量存储降到了约 2.0–2.1GiB；注意，这不是整个程序只用 2GB，文本编码、视频解码、系统和其他缓冲区仍然要内存。&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/antirez/h3.c#tutorial&quot;&gt;作者的测试&lt;/a&gt;里，两种画幅的一轮主模型计算分别慢了 26% 和 84%，比较的是同一套 BF16 路径。它不能直接换算成“24GB 的 Mac mini 总共要等几分钟”，也不能套到我这里的 int8 测试上。这次我没有测流式加载，所以先不给这个答案。&lt;/p&gt;

&lt;p&gt;所以，64GB 的价值在于留余量，不能仅凭这几段短片说它是刚需。如果主要跑 H3 短镜头，我会先考虑 48GB；如果还想同时跑其他模型和服务，就更愿意留到 64GB。24GB 得先验证省内存方案，再决定能不能接受它的速度。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;那现在讨论很多的 M6 Mac mini 呢？&lt;/strong&gt; &lt;a href=&quot;https://www.apple.com/mac-mini/specs/&quot;&gt;苹果公布的配置&lt;/a&gt;最高是 32GB 统一内存，选不了前面说的 48GB 或 64GB。对照这次 18.5–22GB 的短片峰值，32GB 看起来有尝试的空间；但 9 秒已经到 25.6GB，还要给系统和其他程序留位置，不能只看“32 比 25.6 大”就判断一定能跑。有 m6 的小伙伴，欢迎在评论区留下你的测试结果。&lt;/p&gt;

&lt;p&gt;还有软件适配这件事。截至这篇更新时，&lt;a href=&quot;https://github.com/antirez/h3.c&quot;&gt;h3.c 的公开说明&lt;/a&gt;仍主要围绕 M3 Max、M5 Max 做优化，尚未列出 M6 的专门优化和实测结果。我这里用的也是针对 M5 的加速路径，不能因为 M6 更新，就把本机约三分钟的成绩直接套过去。&lt;/p&gt;

&lt;p&gt;所以，如果主要为了 H3 买 M6，我会先等它的适配情况和同配置实测；如果还要做别的事，H3 只是偶尔玩玩，那可以继续把它放在候选里。我没有 M6，暂时不给它编一个预计耗时。&lt;/p&gt;

&lt;p&gt;内存放得下之后，接下来才是每天用不用得下去：生成一段视频，要等多久？&lt;/p&gt;

&lt;h2 id=&quot;生成速度短镜头够用长片要等&quot;&gt;生成速度：短镜头够用，长片要等&lt;/h2&gt;

&lt;p&gt;下面都是这台机器的实际记录，竖屏 576×1024，推荐画质：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;请求时长&lt;/th&gt;
      &lt;th&gt;总耗时&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;2 秒&lt;/td&gt;
      &lt;td&gt;2 分 40 秒&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;3 秒&lt;/td&gt;
      &lt;td&gt;3 分 35 秒&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;5 秒&lt;/td&gt;
      &lt;td&gt;7 分 24 秒&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;9 秒&lt;/td&gt;
      &lt;td&gt;17 分 20 秒&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;程序会把帧数往上凑，所以实际视频会比请求的时长略长。时间从开始跑算到输出文件，加载模型、生成和解码都算进去了。&lt;/p&gt;

&lt;p&gt;2 秒和 3 秒的可以接受，做一个短镜头够用。再往上，等的时间就长了。我最初用两个数据点估算，觉得 9 秒大概要 12 分钟，结果跑了 17 分钟。15 秒没测，平时也用不到那么长的。&lt;/p&gt;

&lt;p&gt;本文用的 h3.c 版本是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;8974cc0&lt;/code&gt;，测试时间是 9 月底到 10 月上旬。&lt;/p&gt;

&lt;h2 id=&quot;推荐档和草稿档&quot;&gt;推荐档和草稿档&lt;/h2&gt;

&lt;p&gt;同一张图、同一个提示词和种子，我测了几种加速方法。一段 2.33 秒的竖屏视频：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;默认参数：433 秒。&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--layers 45 --core-reuse 4&lt;/code&gt;：188 秒。&lt;/li&gt;
  &lt;li&gt;内部尺寸降到 288×512，再用上一组参数：59 秒。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;第二组和默认那组，原尺寸裁脸对比几乎看不出区别，所以我把它定成了推荐档。&lt;/p&gt;

&lt;p&gt;第三组细节明显发软，但构图和动作基本没变，可以先看方向对不对。草稿一分钟一版，满意再用推荐档重跑。&lt;/p&gt;

&lt;p&gt;这个“一分钟”和“看不出区别”都是这组素材的结果，换素材还是得自己看。&lt;/p&gt;

&lt;p&gt;如果先用草稿试方向，满意后才重跑推荐档，就不用每次都等三分钟。不过，省下内存和等待时间之后，还有一笔不能漏掉：硬盘。&lt;/p&gt;

&lt;h2 id=&quot;硬盘也得算进去&quot;&gt;硬盘也得算进去&lt;/h2&gt;

&lt;p&gt;Hugging Face 上整个仓库有 464GiB，但事实上不需要完全下载到本地。h3.c 用不到根目录的那套权重。&lt;/p&gt;

&lt;p&gt;文生视频和首尾帧需要 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FL2VA&lt;/code&gt;，134GiB；参考图需要 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Ref2VA&lt;/code&gt;，也是 134GiB。我后来对了一下文件，发现两边的文本编码器、VAE 等文件完全一样，只有视频主模型不同。用 APFS 克隆复用相同文件，再下 62GiB 就够了。&lt;/p&gt;

&lt;p&gt;文生和参考图两套都要的话，按这次的复用方式，模型本身约占 196GiB。这个数还没算系统、软件、输入素材和生成文件。只跑文生视频可以先下 134GiB 那套，不需要一开始就把所有功能装齐。&lt;/p&gt;

&lt;p&gt;国内下载可以用 ModelScope 上 MiniMax 官方的同名仓库，我这边实测 73MB/s，134GiB 半小时下完。&lt;/p&gt;

&lt;p&gt;机器能跑，效果又是另一回事。前面的 GIF 是成功的结果，下面这几次失败更能说明实际用起来要注意什么。&lt;/p&gt;

&lt;h2 id=&quot;台词要按格式写&quot;&gt;台词要按格式写&lt;/h2&gt;

&lt;p&gt;第一次写提示词，我把台词混在一段描述里，结果人物说出来的是含糊的、像日语又不是日语的音节。&lt;/p&gt;

&lt;p&gt;后来照着格式，把台词单独放进去：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&amp;lt;d&amp;gt;[Chinese] 欢迎光临！&amp;lt;/d&amp;gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;再标上说话人 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;(S1)&lt;/code&gt;，这才正常。&lt;/p&gt;

&lt;p&gt;我还专门对比了程序直接拼提示词和让 AI 扩写。面包店这个场景里，程序拼的也能说对，描述用中文、英文都行。AI 写了一大段，反而在开头多了一行字幕。所以现在默认直接由程序组装，需要更细的镜头描述才开 AI 扩写。&lt;/p&gt;

&lt;p&gt;真人也试了一下，“国庆快乐”说成了“国供快乐”。动画店员那段没问题，不代表真人台词也稳，这个还得继续测。&lt;/p&gt;

&lt;h2 id=&quot;参考图模式&quot;&gt;参考图模式&lt;/h2&gt;

&lt;p&gt;如果你用过 minimax h3，就知道它有3种模式：文生视频、图生视频、首尾帧视频。&lt;/p&gt;

&lt;p&gt;文生视频和首尾帧大家都理解；但图生视频需要的是参考图。而参考图要怎么给，描述要怎么写，还是需要一些技巧的。&lt;/p&gt;

&lt;p&gt;我给了人物图和雪地松林，想让女孩走进树林介绍环境。素材是这几张：&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/minimax-h3-refs.jpg&quot; alt=&quot;人物和场景参考图&quot; /&gt;&lt;/p&gt;

&lt;p&gt;结果有时开头还是面包店，有时全程都是人物图里的纯色背景，松林根本没用上。&lt;/p&gt;

&lt;p&gt;后来加了一句：人物图只取人物，背景不出现。同样四组组合，不加这句有两次失败，加上之后四次都对了。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/minimax-h3-bg-without.jpg&quot; alt=&quot;不排除人物图背景的结果&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/minimax-h3-bg-with.jpg&quot; alt=&quot;明确排除人物图背景后的结果&quot; /&gt;&lt;/p&gt;

&lt;p&gt;动作也一样。“慢慢走进树林，介绍环境”，测了 6 次都没得到画面和台词一起正确的结果。改成“从左边走进来，停下来，介绍这个地方”，4 次都对了。&lt;/p&gt;

&lt;p&gt;每张图是什么，也要写清楚。我这组测试用具体的英文名词加一句特征比较稳，中文定义和笼统的 person、place 都不太行。&lt;/p&gt;

&lt;p&gt;这些测试用的是同一套素材、两个种子，每种写法就试了几次，别把这里的成功率当成保证。要自己试的话，人物图尽量用干净背景，场景图里也别放抢眼的人或动物，先出草稿看效果。&lt;/p&gt;

&lt;h2 id=&quot;自制-webui-已开源&quot;&gt;自制 WebUI 已开源&lt;/h2&gt;

&lt;p&gt;我把它包成了一个服务，网页、命令行、微信和飞书对话共用一个任务队列。&lt;/p&gt;

&lt;p&gt;网页里填描述、台词，选画幅和画质就能提交。参考图每张填用途、它是什么、有什么特征，程序会拼好格式。有场景图时，就自动加上前面那句“其他图只取主体，不要背景”。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/minimax-h3-ui-ref-mode.jpg&quot; alt=&quot;网页参考图模式&quot; /&gt;&lt;/p&gt;

&lt;p&gt;现在在对话里说“图里的女孩挥手说欢迎光临，3 秒”，附上图，生成完就能收到视频。空闲一小时会自动关服务。&lt;/p&gt;

&lt;p&gt;这套网页和队列整理后开源了，叫 h3-studio (求星星)：&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://github.com/shisaq/h3-studio&quot;&gt;https://github.com/shisaq/h3-studio&lt;/a&gt;
翻译、AI 扩写、聊天推送都是可选插件。视频生成在本机跑；选了云端翻译或 Codex 扩写，对应的文字、图片还是会发出去。想全程本地就选本机翻译，不开扩写。&lt;/p&gt;

&lt;p&gt;目前我会继续用它做 2–5 秒的短镜头，让插画动起来，或者先试试一个想法。长视频等得久，台词也没稳定到可以不检查。&lt;/p&gt;

&lt;p&gt;如果你还没买 Mac mini，可以先对照这些结果想想自己的用途：主要生成两三秒素材，还是要经常出长视频；只跑 H3，还是同时跑别的模型和服务。前者对我来说已经能用，后者要把等待时间和资源余量多算一些。&lt;/p&gt;

&lt;p&gt;我测的是 M5 Pro 64GB，不能把这里的速度直接套到其他芯片或内存配置上。这篇能提供的是这台机器实际做出来的东西、占用和耗时。买的时候除了内存，也别漏掉硬盘：模型下载不小，生成的视频也会继续占硬盘。&lt;/p&gt;

&lt;h2 id=&quot;总结&quot;&gt;总结&lt;/h2&gt;

&lt;p&gt;本地模型肯定没有云端模型能力强，minimax当然也不例外，跟即梦这样的模型没法比。而且为了跑本地模型，一台丐版的 Mac mini 肯定也是不够的。但它胜在2方面：1，再也不用为了积分或者token消耗而担心；2，隐私性强。&lt;/p&gt;

&lt;p&gt;本文只是我自己的测试和分享，希望能给你带来一些参考价值。欢迎大家在下方留言讨论。&lt;/p&gt;

&lt;hr /&gt;

&lt;p&gt;本文首发于微信公众号「像素李的AI笔记」：&lt;a href=&quot;https://mp.weixin.qq.com/s/iHqF5izRKrq8wT1bJgvGUg&quot;&gt;原文链接&lt;/a&gt;。博客版保留了文中的外链，和公众号版不同的地方都标了“博客版”。&lt;/p&gt;
</description>
        <pubDate>Fri, 09 Oct 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/2026/10/09/Mac-mini-%E8%B7%91-AI-%E8%A7%86%E9%A2%91-%E5%86%85%E5%AD%98%E5%8D%A0%E5%A4%9A%E5%B0%91-%E7%94%9F%E6%88%90%E8%A6%81%E5%A4%9A%E4%B9%85/</link>
        <guid isPermaLink="true">https://uulili.com/2026/10/09/Mac-mini-%E8%B7%91-AI-%E8%A7%86%E9%A2%91-%E5%86%85%E5%AD%98%E5%8D%A0%E5%A4%9A%E5%B0%91-%E7%94%9F%E6%88%90%E8%A6%81%E5%A4%9A%E4%B9%85/</guid>
        
        <category>技术分享</category>
        
        <category>本地实测</category>
        
        <category>Mac mini</category>
        
        <category>本地AI</category>
        
        <category>AI视频</category>
        
        
      </item>
    
      <item>
        <title>LogicPro智能速度自动匹配不标准的录制素材</title>
        <description>&lt;p&gt;在 Logic Pro 中，要让软件的网格和节拍完全跟随你录制的那首非标准节奏（自由节奏/没有打节拍器）的歌，最核心的思路是让软件去适应你的音频（让网格变轨跟随音频），而不是把你的音频强行拉直。&lt;/p&gt;

&lt;p&gt;最快捷、最智能的解决方法是使用 Logic Pro 的 智能速度（Smart Tempo） 功能，它可以可以自动分析音频中的重拍，并自动生成一条起伏的速度轨道来匹配你的录音。如果需要更精准的手动微调，可以使用 节拍映射（Beat Mapping）。以下是具体的操作步骤：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;导入音频：将您录好的音频文件拖入 Logic Pro 的空白音轨中。&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;修剪开头：将音频前段多余的空白部分剪掉，并把音频的起始点对齐到网格的起始位置（例如小节 1 的第 1 拍）。&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;设置跟随模式：双击该音频区域打开编辑器，点击 Smart Tempo 选项卡，点击 Analyze（分析）。&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;img src=&quot;/img/logic-smartTempo.png&quot; alt=&quot;smart tempo&quot; /&gt;&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;适应速度：在顶部 LCD 速度显示区域（或 Smart Tempo 项目设置中），将项目速度模式（Project Tempo Mode）从“Keep”更改为 Adapt（适应）。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;img src=&quot;/img/logic-adapt.png&quot; alt=&quot;adapt模式&quot; /&gt;&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;应用至项目：右键点击音频区域，选择 Tempo -&amp;gt; Apply Region Tempo to Project Tempo（应用区域速度至项目速度）。Logic 将自动分析您的录音，建立专属的的速度轨道（Tempo Map）。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;img src=&quot;/img/logic-regionToProject.png&quot; alt=&quot;匹配至项目&quot; /&gt;&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;打开节拍器听一下，此时节拍器的速度会随着您的录音忽快忽慢，完美贴合。&lt;/li&gt;
&lt;/ol&gt;
</description>
        <pubDate>Sun, 12 Jul 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/2026/07/12/LogicPro%E6%99%BA%E8%83%BD%E9%80%9F%E5%BA%A6%E8%87%AA%E5%8A%A8%E5%8C%B9%E9%85%8D%E4%B8%8D%E6%A0%87%E5%87%86%E7%9A%84%E5%BD%95%E5%88%B6%E7%B4%A0%E6%9D%90/</link>
        <guid isPermaLink="true">https://uulili.com/2026/07/12/LogicPro%E6%99%BA%E8%83%BD%E9%80%9F%E5%BA%A6%E8%87%AA%E5%8A%A8%E5%8C%B9%E9%85%8D%E4%B8%8D%E6%A0%87%E5%87%86%E7%9A%84%E5%BD%95%E5%88%B6%E7%B4%A0%E6%9D%90/</guid>
        
        <category>技术分享</category>
        
        <category>howto</category>
        
        <category>音视频制作</category>
        
        
      </item>
    
      <item>
        <title>给空调做一个“会睡觉”的定时器：Home Assistant + HomeKit 实战与踩坑全记录</title>
        <description>&lt;p&gt;夏天到了，晚上吹着空调睡觉，最怕两件事：一是吹一整宿，早上冻得鼻子发酸、电表也跑得欢；二是想定时关，可我家这台空调本身&lt;strong&gt;没有定时关机功能&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;厂商的小程序里其实是有定时的——但你想想那个画面：人都躺平了，还得摸黑解锁手机、打开小程序、等它连上云、点进去设定时……困意全没了。我想要的是&lt;strong&gt;躺在床上，对着 Apple Home 或 Siri 拨一下，就告诉它“X 小时后关”&lt;/strong&gt;，然后安心闭眼。&lt;/p&gt;

&lt;p&gt;这篇文章记录我把这个“会睡觉的定时器”做出来的完整过程。它最终很简单，但中间撞了好几堵看不见的墙——而那些墙，恰恰是这篇东西最有价值的部分。如果你也在折腾 Home Assistant 接空调，这些坑能帮你少走两个晚上的弯路。&lt;/p&gt;

&lt;h2 id=&quot;先看最终成果&quot;&gt;先看最终成果&lt;/h2&gt;

&lt;p&gt;在 Apple Home 里，多了一个叫「天窗空调定时」的滑块控件。&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;躺床上拖一下：&lt;strong&gt;拖到 30% = 3 小时后关，25% = 2.5 小时&lt;/strong&gt;，0.5 小时一档；&lt;/li&gt;
  &lt;li&gt;想取消：拖回 0 / 关掉它；&lt;/li&gt;
  &lt;li&gt;还有一道&lt;strong&gt;保底&lt;/strong&gt;：万一我设完忘了、或者压根没设就睡着了，到了 23:00 它会自己判断——空调还开着就再给 3 小时，凌晨自动关；&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;只管卧室那一台，白天开空调完全不受影响&lt;/strong&gt;，绝不会无缘无故把你白天的空调关掉。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;整条链路是这样的：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
7
8
9
10
11
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;Apple Home / Siri
      ↓  拖滑块 / 语音
HomeKit 桥（Home Assistant 自带）
      ↓
fan.skylight_ac_timer   ← 一个“风扇壳”滑块（别急，下面解释为什么是风扇）
      ↓  百分比 ÷ 10 = 小时
input_number.skylight_ac_hours   ← 真正存“几小时”的地方
      ↓
timer.skylight_ac_manual   ← 倒计时
      ↓  到点
climate.turn_off → 卧室空调（本地局域网控制）
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;看着平平无奇对吧？但“为什么偏偏是一个风扇”“为什么不直接用空调自己的定时”——这两个问题背后，是整整一晚的探案。&lt;/p&gt;

&lt;h2 id=&quot;需求先拆清楚&quot;&gt;需求先拆清楚&lt;/h2&gt;

&lt;p&gt;动手前我把脑子里的模糊愿望拆成了五条硬指标，这一步特别重要，能帮你判断每个方案“到底行不行”：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;手动、任意时长&lt;/strong&gt;：今晚想 2 小时，明晚可能 3 小时，得能自己定。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;保底兜底&lt;/strong&gt;：人是会忘事的。啥都没设就睡着了，也得有个安全网把它关掉。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;只针对一间卧室&lt;/strong&gt;：家里两台空调，另一台不许碰。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;白天不误伤&lt;/strong&gt;：白天开空调是正常使用，定时逻辑绝不能在白天把它关了。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;躺着能操作&lt;/strong&gt;：最好就在 Apple Home / Siri 里，不用再开第三个 App。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;带着这五条，我们出发。&lt;/p&gt;

&lt;h2 id=&quot;第一道弯home-app-自己能定时吗&quot;&gt;第一道弯：Home App 自己能定时吗？&lt;/h2&gt;

&lt;p&gt;最自然的想法：Apple Home 不是能做自动化吗，直接设个“X 小时后关”不就行了？&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;不行。&lt;/strong&gt; Apple Home 的自动化触发器只有那么几种（时间、配件状态、有人到家/离开、传感器），它&lt;strong&gt;没有“倒计时 / 延时 X 小时”这种原生控件&lt;/strong&gt;。你能做的只有“每天固定几点关”——可我的时长每天不一样，固定时间直接出局。&lt;/p&gt;

&lt;p&gt;iOS 的「快捷指令」里倒是有“等待”动作，但手机上的“等待”跑在你手机上，锁屏久了就断——拿来扛 2 小时极不靠谱。&lt;/p&gt;

&lt;p&gt;结论：&lt;strong&gt;真正的倒计时得交给一台一直醒着的机器&lt;/strong&gt;。我家正好有一台树莓派跑着 Home Assistant（HA），它就是干这个的最佳人选。于是问题变成：HA 怎么控这台空调、又怎么把控件递回 Apple Home。&lt;/p&gt;

&lt;h2 id=&quot;第二道弯探案时间空调自己不就有定时吗&quot;&gt;第二道弯（探案时间）：空调自己不就有定时吗？&lt;/h2&gt;

&lt;p&gt;这是我钻进去最久、也最长见识的一段。&lt;/p&gt;

&lt;p&gt;我家这台是华凌（美的体系），在 HA 里通过 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;midea_ac_lan&lt;/code&gt; 这个&lt;strong&gt;本地局域网&lt;/strong&gt;集成接入——也就是说 HA 直接在内网跟空调对话，不走云。那么，小程序里那个“定时关机”，&lt;strong&gt;到底是云端在帮我倒计时，还是空调自己在数？&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;我一度想当然地说“云端”。但转念一想不对：厂商要是给千千万万台空调在云上各跑一个倒计时，这成本和逻辑都说不通。&lt;strong&gt;倒计时几乎一定是写进空调本机、由它固件自己数的&lt;/strong&gt;，小程序只是把“N 分钟后关”这条指令下发下去而已。&lt;/p&gt;

&lt;p&gt;如果是这样，那这条本地指令能不能我自己发？我把 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;midea-local&lt;/code&gt; 库的代码扒开看了。空调的设置命令是协议里的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0x40&lt;/code&gt; 包，我直接去读它构造命令体的那段：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
7
8
9
10
11
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;c1&quot;&gt;# midealocal/devices/ac/message.py · MessageGeneralSet._body
&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;bytearray&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;([&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;power&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;prompt_tone&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;mode&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;target_temperature&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;fan_speed&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;mh&quot;&gt;0x00&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;   &lt;span class=&quot;c1&quot;&gt;# ← 这几个写死的 0x00……
&lt;/span&gt;    &lt;span class=&quot;mh&quot;&gt;0x00&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;   &lt;span class=&quot;c1&quot;&gt;# ← 正是 Midea 协议里 on-timer / off-timer 的槽位
&lt;/span&gt;    &lt;span class=&quot;mh&quot;&gt;0x00&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;swing_mode&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;...&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;真相大白：&lt;strong&gt;协议槽位就在那儿（那几个 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0x00&lt;/code&gt; 正是定时字节），但这个本地库根本没去填它&lt;/strong&gt;。再去翻状态回包的解析（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;XC0MessageBody&lt;/code&gt;），它读了三十来个字段——温度、模式、睡眠、防冻……&lt;strong&gt;唯独不读 timer&lt;/strong&gt;。全库唯一的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timer&lt;/code&gt; 只是子协议里的一个布尔“能力标志”，不是时长。&lt;/p&gt;

&lt;p&gt;所以结论很清醒：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;我的直觉对了——&lt;strong&gt;空调固件确实支持本地定时，不是云端逻辑&lt;/strong&gt;；&lt;/li&gt;
  &lt;li&gt;但这个开源本地集成&lt;strong&gt;没把这功能接出来&lt;/strong&gt;；&lt;/li&gt;
  &lt;li&gt;想用，就得魔改这个库（改命令编码、加可写实体），代价是每次更新被覆盖、还得为你这具体型号把字节编码试准；&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;而且最关键&lt;/strong&gt;：就算接通了，HomeKit 那头照样没有“时长控件”可用，UI 问题一点没解决。&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
  &lt;p&gt;💡 &lt;strong&gt;给同路人的提醒&lt;/strong&gt;：遇到“某功能 App 能用、集成里没有”，先去读集成/底层库的源码，往往能在十分钟内判断“是没接出来”还是“协议不支持”。这一步省下的是你瞎试的好几个晚上。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;于是我果断收手：&lt;strong&gt;别碰库&lt;/strong&gt;。倒计时放 HA 自己做就好——一个 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timer&lt;/code&gt; 助手足够稳，连重启都能续上（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;restore: true&lt;/code&gt;）。“倒计时跑在哪”其实根本不影响体验，真正没解决的是下一关。&lt;/p&gt;

&lt;h2 id=&quot;第三道弯翻车现场homekit-的三道墙&quot;&gt;第三道弯（翻车现场）：HomeKit 的三道墙&lt;/h2&gt;

&lt;p&gt;需求第 5 条是“躺着在 Home 里操作”。我想要一个&lt;strong&gt;像调空调温度那样&lt;/strong&gt;、能 +/- 连续拨的控件，一眼看到“几小时”。听起来不难吧？Apple 用三堵墙教我做人。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;第一堵：HomeKit 没有“数字 / 时长滑块”。&lt;/strong&gt; 我翻了 HA 的 HomeKit 桥源码，它支持的控件类型就这些：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fans / lights / covers / humidifiers / thermostats / locks / switches / sensors…&lt;/code&gt;——&lt;strong&gt;没有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;number&lt;/code&gt;&lt;/strong&gt;。你的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;input_number&lt;/code&gt; 根本递不过去。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;第二堵：能连续滑的，只有两种单位。&lt;/strong&gt; 翻下来，Apple 原生 Home 里能渲染成“连续滑块/表盘”的就两类：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;控件&lt;/th&gt;
      &lt;th&gt;来源&lt;/th&gt;
      &lt;th&gt;显示单位&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;灯亮度 / 风扇转速 / 窗帘 / 加湿器&lt;/td&gt;
      &lt;td&gt;百分比特征&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;锁死 %&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;恒温器&lt;/td&gt;
      &lt;td&gt;温度特征&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;锁死 °&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;单位是协议特征写死的，Apple &lt;strong&gt;不让你把 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;°&lt;/code&gt; 改成“小时”&lt;/strong&gt;。（第三方 HomeKit App 如 Eve 能显示自定义单位，但用苹果原生 Home 就这两种。）&lt;/p&gt;

&lt;p&gt;那……恒温器至少数字是真的啊！我设 2.5，它就显示 2.5，只是旁边带个 °，能忍。于是我真的用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;generic_thermostat&lt;/code&gt; 造了个假恒温器，把“温度”当“小时”，范围声明成 0–8、步进 0.5。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;第三堵，也是压垮这个方案的那根稻草：Apple Home 给恒温器目标温度设了一个 ~7° 的硬地板。&lt;/strong&gt; 我在 HA 里明明写了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;min_temp: 0&lt;/code&gt;，可手机上那个表盘&lt;strong&gt;只能拨 7.0 / 7.5 / 8.0 三档&lt;/strong&gt;——0 到 7 全被 Apple 吞了。总不能让人“最少定 7 小时”吧？这个方案当场报废。&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;🧱 &lt;strong&gt;一句话记住&lt;/strong&gt;：在 Apple &lt;strong&gt;原生&lt;/strong&gt; Home 里，想要全程连续的控件，要么接受“显示是 %”，要么接受“恒温器最低 7°”。鱼与熊掌，real-number 和 full-range 不可兼得。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;最终方案用一个风扇收尾&quot;&gt;最终方案：用一个“风扇”收尾&lt;/h2&gt;

&lt;p&gt;撞完三堵墙，路反而清楚了：既然“显示真实小时数”做不到，那就退一步选&lt;strong&gt;全程不卡的百分比滑块&lt;/strong&gt;，把“是 %”这个瑕疵用一个&lt;strong&gt;好记的映射&lt;/strong&gt;抹平。&lt;/p&gt;

&lt;p&gt;我选了&lt;strong&gt;风扇&lt;/strong&gt;（也可以用灯，风扇胜在能设“档数”、好对齐）：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;设 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;speed_count: 20&lt;/code&gt;，于是滑块有 20 档，&lt;strong&gt;每档 5% = 0.5 小时&lt;/strong&gt;，最高 100% = 10 小时，拖动时会卡档，半小时精度轻松对准；&lt;/li&gt;
  &lt;li&gt;映射做成最好心算的：&lt;strong&gt;百分比 ÷ 10 = 小时&lt;/strong&gt;。30% → 3 小时，25% → 2.5 小时；&lt;/li&gt;
  &lt;li&gt;这个“风扇”其实是个空壳，真正存小时数的是背后的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;input_number&lt;/code&gt;，风扇只是它递给 HomeKit 的外衣。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;整套配置如下，HA 用户可以直接抄（我用 &lt;a href=&quot;https://www.home-assistant.io/docs/configuration/packages/&quot;&gt;packages&lt;/a&gt; 的方式把一整块功能塞进一个文件，方便整体回滚）。&lt;strong&gt;你只需要把那个真·空调实体 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;climate.197912093838856_climate&lt;/code&gt; 换成你自己的&lt;/strong&gt;，其余照搬即可。&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;c1&quot;&gt;# packages/skylight_ac_timer.yaml&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# 卧室空调·睡眠定时关&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;#   Apple Home 里一个“风扇壳滑块” fan.skylight_ac_timer：&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;#   百分比 ÷ 10 = 小时（30%→3h，25%→2.5h），20 档、每档 0.5h、最高 10h，拖到 0 / 关闭 = 取消。&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;#   背后 input_number.skylight_ac_hours 存小时数；倒计时到点关空调并复位 0。&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;#   保底：23:00 判断一次，空调还开着且没设定时 → 起 3 小时（→02:00 关）。白天开空调不进此逻辑。&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;input_number&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;skylight_ac_hours&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;卧室空调定时小时数&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;min&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;max&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;10&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;step&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0.5&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;mode&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;slider&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;icon&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;mdi:timer-outline&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;timer&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;skylight_ac_manual&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;卧室空调手动倒计时&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;02:00:00&quot;&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;restore&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;true&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;skylight_ac_fallback&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;卧室空调保底倒计时&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;03:00:00&quot;&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;restore&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;true&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# ⚠️ 用新版 template 语法（旧 `fan: platform: template` 将在 2026.6 移除）&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;template&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;fan&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;卧室空调定时&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;default_entity_id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;fan.skylight_ac_timer&lt;/span&gt;   &lt;span class=&quot;c1&quot;&gt;# 锁住英文 entity_id，HomeKit 桥引用更稳&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;state&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;{{&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;on&apos;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;if&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;(states(&apos;input_number.skylight_ac_hours&apos;)&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;float(0)&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;0)&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;else&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;off&apos;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;}}&quot;&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;percentage&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;{{&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;(states(&apos;input_number.skylight_ac_hours&apos;)&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;float(0)&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;10)&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;}}&quot;&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;speed_count&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;20&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;turn_on&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
          &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;choose&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
              &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;conditions&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
                  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;condition&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;template&lt;/span&gt;
                    &lt;span class=&quot;na&quot;&gt;value_template&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;{{&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;states(&apos;input_number.skylight_ac_hours&apos;)&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;float(0)&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;0.5&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;}}&quot;&lt;/span&gt;
                &lt;span class=&quot;na&quot;&gt;sequence&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
                  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;action&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;input_number.set_value&lt;/span&gt;
                    &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;entity_id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;input_number.skylight_ac_hours&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;
                    &lt;span class=&quot;na&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;2&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;   &lt;span class=&quot;c1&quot;&gt;# 直接点“开”不拖滑块 = 默认 2 小时&lt;/span&gt;
                  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;action&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;timer.start&lt;/span&gt;
                    &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;entity_id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;timer.skylight_ac_manual&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;
                    &lt;span class=&quot;na&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;02:00:00&quot;&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;turn_off&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
          &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;action&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;input_number.set_value&lt;/span&gt;
            &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;entity_id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;input_number.skylight_ac_hours&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;na&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;0&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;
          &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;action&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;timer.cancel&lt;/span&gt;
            &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;entity_id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;timer.skylight_ac_manual&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;set_percentage&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
          &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;variables&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
              &lt;span class=&quot;na&quot;&gt;hrs&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;{{&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;((percentage&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;float(0)&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;/&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;10)&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;2)&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;|&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;round(0)&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;/&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;}}&quot;&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# 折到最近的 0.5&lt;/span&gt;
          &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;action&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;input_number.set_value&lt;/span&gt;
            &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;entity_id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;input_number.skylight_ac_hours&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;na&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;{{&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;hrs&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;}}&quot;&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;
          &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;choose&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
              &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;conditions&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
                  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;condition&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;template&lt;/span&gt;
                    &lt;span class=&quot;na&quot;&gt;value_template&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;{{&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;hrs&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&amp;gt;=&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;0.5&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s&quot;&gt;}}&quot;&lt;/span&gt;
                &lt;span class=&quot;na&quot;&gt;sequence&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
                  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;action&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;timer.start&lt;/span&gt;
                    &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;entity_id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;timer.skylight_ac_manual&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;
                    &lt;span class=&quot;na&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
                      &lt;span class=&quot;na&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;&amp;gt;&lt;/span&gt;
                        &lt;span class=&quot;s&quot;&gt;{% set s = (hrs * 3600) | int %}&lt;/span&gt;
                        &lt;span class=&quot;s&quot;&gt;{{ &apos;%02d:%02d:00&apos; % (s // 3600, (s % 3600) // 60) }}&lt;/span&gt;
            &lt;span class=&quot;na&quot;&gt;default&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
              &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;action&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;timer.cancel&lt;/span&gt;
                &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;entity_id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;timer.skylight_ac_manual&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;automation&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;c1&quot;&gt;# 倒计时到点：关空调 + 复位小时数到 0&lt;/span&gt;
  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;skylight_ac_manual_finish&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;alias&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;卧室空调·倒计时到→关机并复位&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;trigger&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;platform&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;event&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;event_type&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;timer.finished&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;event_data&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;entity_id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;timer.skylight_ac_manual&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;action&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;service&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;climate.turn_off&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;entity_id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;climate.197912093838856_climate&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;   &lt;span class=&quot;c1&quot;&gt;# ← 换成你的空调&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;service&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;input_number.set_value&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;entity_id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;input_number.skylight_ac_hours&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;0&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;c1&quot;&gt;# 保底：23:00 定点判断&lt;/span&gt;
  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;skylight_ac_fallback_check_2300&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;alias&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;卧室空调·23点判断→若开着且没设定时则起3小时保底&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;trigger&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;platform&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;time&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;at&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;23:00:00&quot;&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;condition&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;condition&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;template&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;value_template&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;&amp;gt;&lt;/span&gt;
          &lt;span class=&quot;s&quot;&gt;{{ states(&apos;climate.197912093838856_climate&apos;) not in [&apos;off&apos;,&apos;unavailable&apos;,&apos;unknown&apos;] }}&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;condition&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;state&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;entity_id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;timer.skylight_ac_manual&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;state&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;idle&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;action&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;service&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;timer.start&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;entity_id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;timer.skylight_ac_fallback&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;03:00:00&quot;&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;skylight_ac_fallback_disarm&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;alias&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;卧室空调·关机→取消保底&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;trigger&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;platform&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;state&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;entity_id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;climate.197912093838856_climate&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;to&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;off&quot;&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;action&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;service&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;timer.cancel&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;entity_id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;timer.skylight_ac_fallback&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;

  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;skylight_ac_fallback_finish&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;alias&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;卧室空调·保底3小时到→关机&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;trigger&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;platform&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;event&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;event_type&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;timer.finished&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;event_data&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;entity_id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;timer.skylight_ac_fallback&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;action&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;service&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;climate.turn_off&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;entity_id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;climate.197912093838856_climate&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;}&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;几个设计点值得单独说说&quot;&gt;几个设计点，值得单独说说&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;为什么不直接让风扇存小时数，要多一个 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;input_number&lt;/code&gt;？&lt;/strong&gt; 因为模板风扇本身是“无状态”的外壳，它的百分比需要一个真实的地方存。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;input_number&lt;/code&gt; 就是那个“单一事实来源”：风扇读它显示、写它生效，倒计时到点把它清零，整条逻辑就闭环了，也不怕重启丢值。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;保底为什么是“23:00 判断一次”，而不是“开机就起 3 小时”？&lt;/strong&gt; 这正是需求第 4 条（白天不误伤）的关键。如果做成“一开机就 arm 3 小时”，那你白天开空调，3 小时后也会被自动关——这不是我们要的。改成&lt;strong&gt;只在 23:00 这个点判断一次&lt;/strong&gt;：那时候空调还开着、又没设手动定时，才认为“这人是开着空调睡了”，给它兜底。白天的空调，从头到尾不进这套逻辑。&lt;/p&gt;

&lt;p&gt;（小取舍：如果你 23:00 之后才进卧室开空调，那一晚保底不生效——这种情况手动拖一下滑块即可。我作息规律，基本卡得上，就接受了这个边界。）&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;手动定时优先于保底。&lt;/strong&gt; 23:00 判断时有一条 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timer.skylight_ac_manual == idle&lt;/code&gt; 的条件：只要你已经拖了滑块（手动倒计时在跑），保底就不插手；它俩还能自然叠加——手动更短会先关，关机那一刻又会触发“取消保底”，不会重复。&lt;/p&gt;

&lt;h2 id=&quot;怎么把这个控件递进-apple-home&quot;&gt;怎么把这个控件递进 Apple Home&lt;/h2&gt;

&lt;p&gt;HA 自带一个 &lt;strong&gt;HomeKit 桥&lt;/strong&gt;集成，能把指定实体暴露成 Apple Home 里的配件。把上面的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fan.skylight_ac_timer&lt;/code&gt; 加进去就行：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;设置 → 设备与服务 → 找到 &lt;strong&gt;HomeKit Bridge&lt;/strong&gt; → 配置 → 在“要包含的实体”里勾上 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fan.skylight_ac_timer&lt;/code&gt; → 保存。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;稍等片刻，Apple Home 里就会冒出一个「卧室空调定时」的风扇配件。是的，&lt;strong&gt;图标是个风扇&lt;/strong&gt;——这是我们为了“全程连续滑块”付出的代价，你就当它是“把热气吹走的倒计时”好了 🌬️。拖它、或者对 Siri 说“把卧室空调定时设成 30%”，3 小时后空调自己就睡了。&lt;/p&gt;

&lt;h2 id=&quot;临门一脚的小坑旧版模板语法要迁移&quot;&gt;临门一脚的小坑：旧版模板语法要迁移&lt;/h2&gt;

&lt;p&gt;做完没几天，HA 后台弹了个“修复”提示：&lt;strong&gt;旧的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fan: platform: template&lt;/code&gt; 写法将在 2026.6 移除&lt;/strong&gt;，让我迁到新版 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;template:&lt;/code&gt; → &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fan:&lt;/code&gt; 语法。&lt;/p&gt;

&lt;p&gt;好在 HA 的修复向导会&lt;strong&gt;直接生成迁移好的新配置&lt;/strong&gt;，照着替换就行。上面那份完整配置我已经是&lt;strong&gt;迁移后的新语法&lt;/strong&gt;了，你直接抄不会再碰到这个提醒。这里也顺手记一条经验：&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;🔧 &lt;strong&gt;定期看一眼 HA 的“修复（Repairs）”面板&lt;/strong&gt;。很多弃用都会提前一两个版本预警，并附带自动生成的新配置——趁早处理，别等升级当天炸给你看。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;踩坑速查表&quot;&gt;踩坑速查表&lt;/h2&gt;

&lt;p&gt;把这一晚的弯路浓缩成一张表，方便你对号入座：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;坑&lt;/th&gt;
      &lt;th&gt;真相&lt;/th&gt;
      &lt;th&gt;对策&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Home App 想直接定时&lt;/td&gt;
      &lt;td&gt;没有原生“延时/倒计时”控件&lt;/td&gt;
      &lt;td&gt;倒计时交给常醒的 HA（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timer&lt;/code&gt; 助手）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;想用空调自带定时&lt;/td&gt;
      &lt;td&gt;固件支持，但本地集成没把它接出来（命令字节写死 0）&lt;/td&gt;
      &lt;td&gt;别魔改库，HA 自己做倒计时即可&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;想要数字滑块&lt;/td&gt;
      &lt;td&gt;HomeKit 桥没有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;number&lt;/code&gt; 类型&lt;/td&gt;
      &lt;td&gt;借“风扇/灯”的百分比壳&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;想用恒温器显示真实小时&lt;/td&gt;
      &lt;td&gt;Apple Home 把目标温度卡在 ~7° 地板&lt;/td&gt;
      &lt;td&gt;放弃“真实数字”，用 % 滑块 + 好记映射&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;百分比不直观&lt;/td&gt;
      &lt;td&gt;单位改不了&lt;/td&gt;
      &lt;td&gt;选 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;÷10&lt;/code&gt; 这种心算友好的映射 + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;speed_count&lt;/code&gt; 卡档&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;旧模板语法弃用&lt;/td&gt;
      &lt;td&gt;2026.6 移除 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fan: platform: template&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;用 Repairs 向导迁到新版 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;template:&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;写在最后&quot;&gt;写在最后&lt;/h2&gt;

&lt;p&gt;绕了一大圈，最后落地的东西特别朴素：&lt;strong&gt;躺床上拖一下滑块，空调就会在我设定的时间安静睡去；哪怕我先睡了，也有一道 23 点的安全网替我兜着。&lt;/strong&gt; 一个不起眼的小工具，却实实在在地把“起夜关空调”这件小事从我的生活里抹掉了。&lt;/p&gt;

&lt;p&gt;我很喜欢这种折腾。它表面是在跟空调较劲，本质是在跟那些“看不见的限制”打交道——Apple 的地板、协议里写死的零、即将被移除的旧语法。每撞一堵墙，你对这套系统的理解就厚一分。而最有意思的部分，往往不是最终那段能抄的配置，而是“为什么偏偏是一个风扇”背后的那一整晚。&lt;/p&gt;

&lt;p&gt;愿你也能给自己的生活，装上一两个这样“会睡觉”的小开关。&lt;/p&gt;

&lt;p&gt;—— 给你的生活加点阳光 ☀️&lt;/p&gt;
</description>
        <pubDate>Wed, 03 Jun 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/2026/06/03/%E7%BB%99%E7%A9%BA%E8%B0%83%E5%81%9A%E4%B8%80%E4%B8%AA%E4%BC%9A%E7%9D%A1%E8%A7%89%E7%9A%84%E5%AE%9A%E6%97%B6%E5%99%A8/</link>
        <guid isPermaLink="true">https://uulili.com/2026/06/03/%E7%BB%99%E7%A9%BA%E8%B0%83%E5%81%9A%E4%B8%80%E4%B8%AA%E4%BC%9A%E7%9D%A1%E8%A7%89%E7%9A%84%E5%AE%9A%E6%97%B6%E5%99%A8/</guid>
        
        <category>技术分享</category>
        
        <category>智能家居</category>
        
        <category>Home Assistant</category>
        
        <category>HomeKit</category>
        
        <category>踩坑</category>
        
        
      </item>
    
      <item>
        <title>当 AI 把窗外的鸟认成树懒：一台树莓派的观鸟相机调教记</title>
        <description>&lt;p&gt;我家住4楼，窗外面常有鸟来落脚。有时只是停几秒，有时会在栏杆之间来回跳。我从小喜欢看动物世界，现在家里的娃也集成了这个基因。我们都想知道这些鸟什么时候来、来了几次。&lt;/p&gt;

&lt;p&gt;于是我翻出一套吃灰很久的 &lt;a href=&quot;https://aiyprojects.withgoogle.com/vision/&quot;&gt;Google AIY Vision Kit&lt;/a&gt;：树莓派 Zero，加一块 Vision Bonnet，再接一颗树莓派摄像头。它是 2017 年那波「纸盒子 AI 套件」里的视觉版，板上有一颗视觉处理芯片，可以在本地跑图像分类。&lt;/p&gt;

&lt;p&gt;目标很简单：平时低功耗盯着窗外，有东西落到指定区域就自动录 10 秒视频；我打开浏览器时，能看到实时画面、最近识别结果和录像列表。&lt;/p&gt;

&lt;p&gt;现在它长这样：&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/birdcam-dashboard-202606.png&quot; alt=&quot;新版观鸟相机控制台&quot; /&gt;&lt;/p&gt;

&lt;p&gt;这套东西现在能做到：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;有活物落到窗台区域时，自动录一段 10 秒 1080p 视频；&lt;/li&gt;
  &lt;li&gt;只保留最近 30 段录像，旧的自动删除；&lt;/li&gt;
  &lt;li&gt;网页上有实时画面、当前识别状态、最近事件和录像列表；&lt;/li&gt;
  &lt;li&gt;识别日志会保留最近 1000 条，重启后也不会丢；&lt;/li&gt;
  &lt;li&gt;录像页可以直接预览，也可以下载；&lt;/li&gt;
  &lt;li&gt;所有推理都在本地完成，不依赖云服务。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;下面这只灰喜鹊，就是它自己抓到的。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/birdcam-catch.jpg&quot; alt=&quot;观鸟相机抓拍到的灰喜鹊&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;硬件不新但刚好够用&quot;&gt;硬件不新，但刚好够用&lt;/h2&gt;

&lt;p&gt;这套硬件很老：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Raspberry Pi Zero，性能非常弱；&lt;/li&gt;
  &lt;li&gt;Google AIY Vision Bonnet，负责跑视觉模型；&lt;/li&gt;
  &lt;li&gt;一颗树莓派摄像头，贴着窗玻璃看外面；&lt;/li&gt;
  &lt;li&gt;系统还是 2017 年前后的老镜像。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Google 当年给 Vision Kit 准备过几类模型。官方介绍里提到过 MobileNets 物体识别、表情识别、人/猫/狗检测等 demo；&lt;a href=&quot;https://aiyprojects.withgoogle.com/models/&quot;&gt;AIY 模型页面&lt;/a&gt;里也能看到 Vision Kit 支持的模型类型。我的镜像里真正能稳定跑起来的，是一个基于 &lt;a href=&quot;https://arxiv.org/abs/1704.04861&quot;&gt;MobileNet&lt;/a&gt; 的 ImageNet 1000 类通用分类模型。&lt;/p&gt;

&lt;p&gt;我本来想用更适合自然观察的 &lt;a href=&quot;https://openaccess.thecvf.com/content_cvpr_2018/html/Van_Horn_The_INaturalist_Species_CVPR_2018_paper.html&quot;&gt;iNaturalist 数据集&lt;/a&gt;相关模型，毕竟它面向真实世界的动植物照片。可惜这块老镜像和老 Bonnet 的工具链太旧，折腾到最后还是回到通用分类模型。&lt;/p&gt;

&lt;p&gt;这也决定了后面的故事：老模型不太会认鸟——但也是这个特性，让后面的识别结果更好玩了。&lt;/p&gt;

&lt;h2 id=&quot;先让相机省着干活&quot;&gt;先让相机省着干活&lt;/h2&gt;

&lt;p&gt;一块 Pi Zero 同时做直播、录像和 AI 推理，很容易把自己拖死。我的做法是把任务拆开：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;空闲时：
  只跑一路 640x360 的低码率 H.264，用运动矢量判断 ROI 有没有动

有动静：
  抓一帧 1080p 原图
  裁出窗台/落脚区
  丢给 Vision Bonnet 跑分类

判定需要录像：
  独占摄像头录 10 秒 1080p
  ffmpeg 封装成 mp4
  回到空闲检测

有人打开网页：
  临时开一路 384x216 MJPEG 预览
  没人看就关掉
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;检测和预览是两条路。网页上的直播画面很糊，但真正喂给模型的是 1080p 原图裁出来的局部。这点很重要，因为我一开始也以为问题出在直播画质太差。&lt;/p&gt;

&lt;p&gt;核心检测逻辑大概是这样：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;n&quot;&gt;camera&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;capture&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;buf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;format&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;jpeg&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;use_video_port&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;splitter_port&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;crop&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Image&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;open&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;buf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;crop&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ROI&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;classes&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;image_classification&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get_classes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;inf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;run&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;crop&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;max_num_objects&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;网页只是观察窗口，不参与判断。判断用的是裁剪后的落脚区。&lt;/p&gt;

&lt;h2 id=&quot;它真的把鸟认成了树懒&quot;&gt;它真的把鸟认成了树懒&lt;/h2&gt;

&lt;p&gt;最有意思的部分来啦。&lt;/p&gt;

&lt;p&gt;我一开始用的是很直觉的规则：如果模型输出里出现鸟类词，比如 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;junco&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sparrow&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;magpie&lt;/code&gt;，而且置信度超过阈值，就录像。&lt;/p&gt;

&lt;p&gt;实际效果很差。后来我把日志拉出来看，发现空场景和有鸟时的标签非常有规律：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;场景&lt;/th&gt;
      &lt;th&gt;模型常见 top 判断&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;空栏杆&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;handrail&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bannister&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;worm fence&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;玻璃/纱窗/反光&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mosquito net&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;window screen&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;有鸟落脚&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;capuchin&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;langur&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;squirrel monkey&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hog&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;orangutan&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;也就是说，它经常把鸟认成猴子、猪、猩猩——在北京4楼的窗外，如果真的出现了这些动物，那绝对是在做白日梦，哈哈。&lt;/p&gt;

&lt;p&gt;但如果我换个问法：「这还是不是空栏杆？」它反而很稳定。&lt;/p&gt;

&lt;p&gt;于是判断逻辑改成了这样：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
7
8
9
10
11
12
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;n&quot;&gt;STRUCTURE_WORDS&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&apos;bannister&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;handrail&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;fence&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;window&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;screen&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&apos;mosquito&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;net&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;grille&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;picket&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;pole&apos;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;is_structure&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;label&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;bool&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;tokens&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;label&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;STRUCTURE_WORDS&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;occupied&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;top_score&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;floor&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;and&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;is_structure&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;top_label&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;bird_word_hit&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;occupied&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;record_clip&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;这就是整套系统真正的转折点。它不需要认出「这是一只灰喜鹊」。它只要发现「这块区域不再像平时那根空栏杆」，我就录下来。&lt;/p&gt;

&lt;p&gt;这听起来有点歪，但很实用。漏拍是永久损失，误录最多就是我翻录像时跳过一段。对观鸟相机来说，我愿意偏向多录。&lt;/p&gt;

&lt;h2 id=&quot;把-ai-的判断摆到网页上&quot;&gt;把 AI 的判断摆到网页上&lt;/h2&gt;

&lt;p&gt;之前我调得很痛苦，是因为只能看日志。后来我把实时状态和历史记录都接到网页上，调参就舒服多了。&lt;/p&gt;

&lt;p&gt;这是我先让 AI 生成的一版视觉概念图，用来确定控制台的方向：暗色、画面优先、状态集中在右侧。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/birdcam-ui-concept.png&quot; alt=&quot;观鸟相机界面概念图&quot; /&gt;&lt;/p&gt;

&lt;p&gt;最终页面没有照搬概念图里的假数据和装饰，只保留了信息结构：直播画面在左侧，右侧是当前状态、置信度、时段策略、快速入口和最近事件。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/birdcam-dashboard-202606.png&quot; alt=&quot;新版观鸟相机控制台&quot; /&gt;&lt;/p&gt;

&lt;p&gt;实时状态分三类：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;绿色「鸟」：模型真的在候选里报出了鸟类词；&lt;/li&gt;
  &lt;li&gt;黄色「活物?」：模型没报鸟，但 top 标签不像空栏杆；&lt;/li&gt;
  &lt;li&gt;灰色「无鸟」：还是栏杆、纱窗、反光这类空场景。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;旧版页面更朴素，但已经能看出这个思路：&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/birdcam-overlay.jpg&quot; alt=&quot;直播页底部的实时识别浮层&quot; /&gt;&lt;/p&gt;

&lt;p&gt;日志页是另一块关键拼图。它每 2 秒刷新一次，把最近识别事件列出来。后来我又把日志从内存环形队列改成了磁盘上的 JSONL 文件，最多保留 1000 条。这样服务重启后，调参上下文还在。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/birdcam-log.jpg&quot; alt=&quot;识别历史页：模型把鸟叫成各种动物，但带 REC 的都会被录下来&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;录像页先能看再能下载&quot;&gt;录像页：先能看，再能下载&lt;/h2&gt;

&lt;p&gt;最开始的录像页只是一个文件名列表。后来我把它改成了卡片式录像库：每个视频可以直接预览，下面显示时间、识别摘要和下载按钮。&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/img/birdcam-clips-202606.png&quot; alt=&quot;新版录像页&quot; /&gt;&lt;/p&gt;

&lt;p&gt;录像只保留最近 30 个。每次新录像封装完成后，程序会按修改时间删除更旧的文件；服务启动时也会清理一次。所以重启不会影响这个上限。&lt;/p&gt;

&lt;h2 id=&quot;分时段调灵敏度&quot;&gt;分时段调灵敏度&lt;/h2&gt;

&lt;p&gt;鸟不是均匀出现的。清晨和黄昏更活跃，夜里基本是噪声。所以我给它做了一个简单的时段档位：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;n&quot;&gt;PROFILES&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&apos;dawn&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;  &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.08&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;  &lt;span class=&quot;s&quot;&gt;&apos;清晨高发&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&apos;dusk&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;  &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.08&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;  &lt;span class=&quot;s&quot;&gt;&apos;黄昏高发&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&apos;day&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;   &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.12&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;8&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;  &lt;span class=&quot;s&quot;&gt;&apos;日间常规&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&apos;night&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.22&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;15&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;夜间保守&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;清晨和黄昏门槛低一点，冷却时间短一点；夜里门槛高一点，免得风吹窗帘、玻璃反光都触发录像。这个策略很土，但很符合真实使用。&lt;/p&gt;

&lt;h2 id=&quot;如果你也想做一个&quot;&gt;如果你也想做一个&lt;/h2&gt;

&lt;p&gt;这套方案不一定要 AIY Vision Kit。任何「树莓派 + 摄像头 + 一个能跑分类或检测的模型」都能复刻。差别只是推理放在本地，还是丢给别的设备或云端。&lt;/p&gt;

&lt;p&gt;我觉得关键不是模型有多强，而是这几条：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;先确定一个固定 ROI。不要全画面找目标，只盯喂食器、栏杆、窝口这种小区域。&lt;/li&gt;
  &lt;li&gt;空闲时用廉价运动检测，不要一直跑模型。&lt;/li&gt;
  &lt;li&gt;有动静再抓高清图，裁 ROI，跑分类。&lt;/li&gt;
  &lt;li&gt;不要急着问「是不是鸟」。先收集空场景会被识别成什么，再把规则改成「还像不像空场景」。&lt;/li&gt;
  &lt;li&gt;把模型输出显示出来。网页、日志、JSON 都行，别盲调。&lt;/li&gt;
  &lt;li&gt;录像和直播分开。预览可以糊，录像要清楚。&lt;/li&gt;
  &lt;li&gt;给存储设上限。我的录像是 30 个，日志是 1000 条。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;现在这套程序就是一个 Python 文件加一个常驻服务，没有数据库，没有云端账号，也没有复杂前端。它不聪明，但每天早上能告诉我：昨晚窗外有没有来过什么东西。&lt;/p&gt;

&lt;p&gt;它依然会把鸟叫成树懒 sloth。我家娃的英语比我好，一下就反应过来了；我还得让她帮我翻译一下才反应过来~&lt;/p&gt;

&lt;p&gt;但总之，识别到活物，通常都是好消息。&lt;/p&gt;

&lt;p&gt;优客李李，给你的生活加点阳光~&lt;/p&gt;
</description>
        <pubDate>Wed, 03 Jun 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/2026/06/03/%E5%BD%93AI%E6%8A%8A%E7%AA%97%E5%A4%96%E7%9A%84%E9%B8%9F%E8%AE%A4%E6%88%90%E6%A0%91%E6%87%92-%E4%B8%80%E5%8F%B0%E6%A0%91%E8%8E%93%E6%B4%BE%E7%9A%84%E8%A7%82%E9%B8%9F%E7%9B%B8%E6%9C%BA%E8%B0%83%E6%95%99%E8%AE%B0/</link>
        <guid isPermaLink="true">https://uulili.com/2026/06/03/%E5%BD%93AI%E6%8A%8A%E7%AA%97%E5%A4%96%E7%9A%84%E9%B8%9F%E8%AE%A4%E6%88%90%E6%A0%91%E6%87%92-%E4%B8%80%E5%8F%B0%E6%A0%91%E8%8E%93%E6%B4%BE%E7%9A%84%E8%A7%82%E9%B8%9F%E7%9B%B8%E6%9C%BA%E8%B0%83%E6%95%99%E8%AE%B0/</guid>
        
        <category>技术分享</category>
        
        <category>树莓派</category>
        
        <category>AIY Vision Kit</category>
        
        <category>计算机视觉</category>
        
        <category>智能家居</category>
        
        
      </item>
    
      <item>
        <title>把乐歌 E2 升降桌改造成智能家居：ESP32 + ESPHome + Home Assistant 实战</title>
        <description>&lt;p&gt;最近我把家里的乐歌 E2 升降桌接入了 Home Assistant，并进一步暴露到 Apple Home 里。最后的效果是：在 Home App 里点一下“sit”或“stand”，桌子就会自动去到坐姿或站姿记忆高度。&lt;/p&gt;

&lt;p&gt;这件事一开始看起来像是“找两根线上下短接一下”的简单小活，实际折腾下来才发现，乐歌这类桌子的手控器接口并不是传统按键短接，而是一套跑在 RJ45 线里的 UART 串口协议。&lt;/p&gt;

&lt;p&gt;这篇文章记录完整过程：怎么识别引脚、怎么确认协议、怎么用 ESP32 + ESPHome 接入 Home Assistant，以及最后如何解决供电和 HomeKit 控制。&lt;/p&gt;

&lt;h2 id=&quot;最终成果&quot;&gt;最终成果&lt;/h2&gt;

&lt;p&gt;硬件上，桌子的原手控面板仍然保留，ESP32 只是并联接入手控接口的串口线。&lt;/p&gt;

&lt;p&gt;软件上，现在有这样一条链路：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
7
8
9
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;Apple Home / Siri
        ↓
Home Assistant
        ↓
ESPHome API
        ↓
ESP32 UART
        ↓
乐歌 E2 升降桌控制器
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;最终可用能力：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Home Assistant 中有两个按钮：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sit&lt;/code&gt; 和 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;stand&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;Apple Home 中也可以控制这两个动作&lt;/li&gt;
  &lt;li&gt;ESP32 不需要额外 USB 电源，直接由桌子 RJ45 接口中的 5V 供电&lt;/li&gt;
  &lt;li&gt;原桌子手控器不受影响，仍然可以正常按实体按钮&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;硬件清单&quot;&gt;硬件清单&lt;/h2&gt;

&lt;p&gt;这次用到的东西不少，我第一次接触了单片机：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;乐歌 E2 升降桌&lt;/li&gt;
  &lt;li&gt;ESP32-WROOM-32 开发板，30Pin，Type-C 口&lt;/li&gt;
  &lt;li&gt;RJ45 转 8Pin 免焊接线端子&lt;/li&gt;
  &lt;li&gt;杜邦线若干&lt;/li&gt;
  &lt;li&gt;万用表&lt;/li&gt;
  &lt;li&gt;一台树莓派，运行 Docker 版 Home Assistant&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;因为我不会焊接，所以整个方案尽量围绕“免焊、可拆、可回滚”来做。所有连接都是杜邦线加螺丝端子。&lt;/p&gt;

&lt;h2 id=&quot;第一阶段确认它不是普通按键&quot;&gt;第一阶段：确认它不是普通按键&lt;/h2&gt;

&lt;p&gt;我最开始的直觉是：升降桌手控器上有上升、下降、1、2、3、4 记忆位，那 RJ45 里面也许就是几根按键线，按下某个按钮就是某根线接地。&lt;/p&gt;

&lt;p&gt;结果不是。&lt;/p&gt;

&lt;p&gt;用 ESP32 的 UART 调试后发现，桌子在静止时也会持续广播数据。波特率测试下来，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;9600&lt;/code&gt; 最稳定。&lt;/p&gt;

&lt;p&gt;静止时能看到类似这样的帧：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;9B:04:11:7C:C3:9D
9B:07:12:00:00:00:B8:94:9D
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;桌子移动时，数据会明显变化，例如：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;7F:FD:06:F2:65:9D
7F:87:3F:80:86:9D
06:06:7F:F9:36:9D
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;这一步非常关键。它说明：手控器接口不是简单按键短接，而是 UART 串口通信。&lt;/p&gt;

&lt;h2 id=&quot;rj45-引脚测量&quot;&gt;RJ45 引脚测量&lt;/h2&gt;

&lt;p&gt;RJ45 水晶头方向按下面这个姿势看：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;金属触点朝上&lt;/li&gt;
  &lt;li&gt;卡扣朝下&lt;/li&gt;
  &lt;li&gt;线朝远离自己&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;我测到的结果：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pin1&lt;/code&gt;：约 5.11V&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pin2&lt;/code&gt;：GND&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pin3&lt;/code&gt;：约 4.1V&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pin4&lt;/code&gt;：约 4.2V&lt;/li&gt;
  &lt;li&gt;其它脚约 0V&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;后续验证下来：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pin1&lt;/code&gt; 可以给 ESP32 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VIN&lt;/code&gt; 供电&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pin2&lt;/code&gt; 是 GND&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pin3&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pin4&lt;/code&gt; 是 UART TX/RX&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这里有一个风险点：ESP32 的 GPIO 严格来说不是 5V tolerant，而这里 UART 线测出来有 4V 多。我的实际测试能工作，但长期更严谨的方案应该在桌子 TX 到 ESP32 RX 之间加电平转换或分压。当前版本先按最小改动完成。&lt;/p&gt;

&lt;h2 id=&quot;esp32-接线&quot;&gt;ESP32 接线&lt;/h2&gt;

&lt;p&gt;ESP32 这边使用：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GPIO16&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;D16&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GPIO17&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;D17&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GND&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VIN&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;最终接线：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;RJ45 pin1  +5V   -&amp;gt; ESP32 VIN
RJ45 pin2  GND   -&amp;gt; ESP32 GND
RJ45 TX          -&amp;gt; ESP32 RX / GPIO16
RJ45 RX          -&amp;gt; ESP32 TX / GPIO17
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;注意：ESP32 的 TX 要接桌子 RX，ESP32 的 RX 要接桌子 TX。如果收不到数据，就先交换 TX/RX。&lt;/p&gt;

&lt;p&gt;另外，桌子的 5V 要接 ESP32 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VIN&lt;/code&gt;，不要接 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;3V3&lt;/code&gt;。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VIN&lt;/code&gt; 是 5V 输入，会经过板载稳压芯片变成 ESP32 使用的 3.3V；&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;3V3&lt;/code&gt; 是板上已经稳压后的 3.3V 电源脚，把 5V 接上去很可能烧板。&lt;/p&gt;

&lt;p&gt;测试桌子 5V 供电时，我先拔掉了电脑 USB，只让桌子的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pin1 -&amp;gt; VIN&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pin2 -&amp;gt; GND&lt;/code&gt; 给 ESP32 上电。确认它能稳定上线后，再接回 TX/RX 测试 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sit&lt;/code&gt; 和 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;stand&lt;/code&gt;。&lt;/p&gt;

&lt;h2 id=&quot;esphome-配置&quot;&gt;ESPHome 配置&lt;/h2&gt;

&lt;p&gt;最终 ESPHome 配置非常短：&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;na&quot;&gt;esphome&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;desk&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;esp32&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;board&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;esp32dev&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;framework&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;arduino&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;wifi&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;ssid&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;!secret&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;wifi_ssid&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;password&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;!secret&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;wifi_password&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;logger&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;baud_rate&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;0&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;api&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;ota&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;platform&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;esphome&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;uart&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;desk_uart&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;baud_rate&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;9600&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;tx_pin&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;GPIO17&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;rx_pin&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;GPIO16&lt;/span&gt;

&lt;span class=&quot;na&quot;&gt;button&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;platform&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;template&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;sit&quot;&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;sit&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;on_press&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;uart.write&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
          &lt;span class=&quot;na&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;desk_uart&lt;/span&gt;
          &lt;span class=&quot;na&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;0x9b&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;0x06&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;0x02&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;0x04&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;0x00&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;0xac&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;0xa3&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;0x9d&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;]&lt;/span&gt;

  &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;platform&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;template&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;stand&quot;&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;stand&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;on_press&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;pi&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;uart.write&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt;
          &lt;span class=&quot;na&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;desk_uart&lt;/span&gt;
          &lt;span class=&quot;na&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;0x9b&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;0x06&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;0x02&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;0x08&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;0x00&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;0xac&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;0xa6&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;0x9d&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;]&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;这里一开始我用的是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;switch&lt;/code&gt;，后来改成了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;button&lt;/code&gt;。原因很简单：坐姿和站姿不是“开关状态”，而是“执行一次命令”。如果用开关，Home Assistant 会误以为它有持续的 on/off 状态；但实际上点完 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;stand&lt;/code&gt; 并不代表桌子一定已经到位，也无法知道中途是否被手控器打断。&lt;/p&gt;

&lt;p&gt;所以现阶段最准确的模型是：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sit&lt;/code&gt;：发送一次坐姿记忆位命令&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;stand&lt;/code&gt;：发送一次站姿记忆位命令&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;等以后解析出高度帧，再考虑升级成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sensor&lt;/code&gt; + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;number&lt;/code&gt; + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;select&lt;/code&gt;，甚至做成类似窗帘的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cover&lt;/code&gt; 模型。&lt;/p&gt;

&lt;h2 id=&quot;当前状态读取&quot;&gt;当前状态读取&lt;/h2&gt;

&lt;p&gt;后来我又继续做了一轮状态读取测试。目标不是继续深度逆向整套协议，而是先解决一个实际问题：Home Assistant 至少要知道桌子现在大概是坐姿、站姿，还是正在移动。&lt;/p&gt;

&lt;p&gt;静止时，桌子会持续发这样的帧：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;9B:04:11:7C:C3:9D
9B:07:12:00:00:00:B8:94:9D
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;但这两种静止帧本身不能区分坐姿和站姿。也就是说，如果 ESP32 刚刚冷启动，而桌子没有发生过任何动作，单靠这两条空闲数据无法判断桌子当前到底在什么高度。&lt;/p&gt;

&lt;p&gt;真正有用的是坐站切换过程中的帧。测试下来，目前可以用下面几类信号做判断：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;9B:04:15:BF:C2:9D              # 桌子正在移动
9B:07:12:07:CF:...:...:9D      # 坐姿预设相关状态
9B:07:12:06:06:...:...:9D      # 站姿预设相关状态
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;所以现在 ESPHome 里多了几个状态实体：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;text_sensor.desk_posture       # sitting / standing / unknown
binary_sensor.desk_sitting
binary_sensor.desk_standing
binary_sensor.desk_moving
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;这里没有把坐姿和站姿做成一个 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;switch&lt;/code&gt;。原因是它并不是真正的开关：物理面板也可以改变桌子状态，中途还可能被打断。更准确的设计是保留 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sit&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;stand&lt;/code&gt; 两个按钮，再单独暴露只读状态。&lt;/p&gt;

&lt;p&gt;实际测试结果如下：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
7
8
9
10
11
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;stand -&amp;gt; sit:
初始 standing
按 sit 后 moving=true
约 21 秒后 moving=false
最终 sitting=true, standing=false, posture=sitting

sit -&amp;gt; stand:
初始 sitting
按 stand 后 moving=true
约 21 秒后 moving=false
最终 sitting=false, standing=true, posture=standing
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;另外，我还测试了直接按桌子物理面板的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1&lt;/code&gt; 键。桌子回到坐姿后，Home Assistant 也同步读到了：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;sitting=true
standing=false
moving=false
posture=sitting
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;这说明 ESP32 现在不是只知道“自己发出的命令”，而是真的在旁路监听桌子的 UART 通信。无论是 Home Assistant 触发，还是手按物理面板，只要桌子完成一次坐站切换，状态都能被更新回来。&lt;/p&gt;

&lt;p&gt;当前限制也很清楚：冷启动后的第一次静止状态可能只能恢复上一次记录，不一定代表真实高度。但只要之后发生一次坐站动作，状态就会重新变得可信。对我现在的用法来说，这已经够用了。&lt;/p&gt;

&lt;h2 id=&quot;home-assistant-接入&quot;&gt;Home Assistant 接入&lt;/h2&gt;

&lt;p&gt;Home Assistant 是新装在树莓派上的 Docker 版，地址类似：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;http://192.168.31.123:8123
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;ESPHome 节点刷好后，Home Assistant 自动发现了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;desk&lt;/code&gt;。添加 ESPHome integration 后，就能看到两个实体：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;button.desk_sit
button.desk_stand
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;我还在路由器里给 ESP32 做了 DHCP 静态绑定，这样每次断电重连后，IP 不会漂移，Home Assistant 连接更稳定。&lt;/p&gt;

&lt;h2 id=&quot;接入-apple-home&quot;&gt;接入 Apple Home&lt;/h2&gt;

&lt;p&gt;要把 Home Assistant 里的实体暴露到 Apple Home，使用的是 Home Assistant 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HomeKit Bridge&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;路径：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;Settings -&amp;gt; Devices &amp;amp; services -&amp;gt; Add Integration -&amp;gt; HomeKit Bridge
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;创建 HomeKit Bridge 时，只选择暴露桌子相关实体：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;button.desk_sit
button.desk_stand
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;然后 Home Assistant 会给出 HomeKit 配对二维码或 PIN。打开 iPhone 的 Home App，添加配件，扫码或输入 PIN 即可。&lt;/p&gt;

&lt;p&gt;Docker 版 Home Assistant 如果遇到 HomeKit 找不到 Bridge，通常和网络模式、mDNS 广播有关。最省心的方式是让 Home Assistant 容器使用 host network，让它和局域网设备处在同一网络广播环境中。&lt;/p&gt;

&lt;h2 id=&quot;供电从电脑-usb-到桌子-5v&quot;&gt;供电：从电脑 USB 到桌子 5V&lt;/h2&gt;

&lt;p&gt;一开始 ESP32 是电脑 USB-C 供电，只接了：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;RJ45 GND -&amp;gt; ESP32 GND
RJ45 TX/RX -&amp;gt; ESP32 RX/TX
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;这叫“监听模式”，比较安全，但不适合长期使用：桌子下面还要吊一根 USB 电源线，非常不优雅。&lt;/p&gt;

&lt;p&gt;后来确认 RJ45 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pin1&lt;/code&gt; 是约 5.11V 后，我把它接到了 ESP32 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VIN&lt;/code&gt;。测试结果稳定，不需要外接 USB 电源。&lt;/p&gt;

&lt;p&gt;这里的注意事项：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;5V 接 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VIN&lt;/code&gt;，不要接 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;3V3&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;GND 接真正的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GND&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;测试时不要同时接电脑 USB 和桌子 5V&lt;/li&gt;
  &lt;li&gt;用万用表电压档测 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pin1&lt;/code&gt; 到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pin2&lt;/code&gt;，不要用电流档直接短接测量&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;我还犯过一个小错误：曾经把 GND 接到了旁边的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;D13&lt;/code&gt;。好在当前固件没有使用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;D13&lt;/code&gt;，实际没有造成影响。但这个错误提醒我，最后一定要把线固定好，不能让杜邦线长期处于受力和晃动状态。&lt;/p&gt;

&lt;h2 id=&quot;现在还不完美的地方&quot;&gt;现在还不完美的地方&lt;/h2&gt;

&lt;p&gt;功能上已经可用了，但外观还比较工程样机：一根粗 RJ45 线、几根杜邦线、一块裸露 ESP32 板子吊在桌子下面。&lt;/p&gt;

&lt;p&gt;后续准备做一个小盒子，把 RJ45 端子和 ESP32 收进去：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;使用小 ABS 项目盒&lt;/li&gt;
  &lt;li&gt;换短款柔软扁平 RJ45 线&lt;/li&gt;
  &lt;li&gt;ESP32 和端子用双面胶或固定座固定&lt;/li&gt;
  &lt;li&gt;线入口做应力释放，避免外部拉扯直接作用到螺丝端子&lt;/li&gt;
  &lt;li&gt;整个盒子用魔术贴固定在桌板底部&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;目标是最后只看到一个小盒子和一根短线，而不是一团实验线。&lt;/p&gt;

&lt;h2 id=&quot;这次折腾的收获&quot;&gt;这次折腾的收获&lt;/h2&gt;

&lt;p&gt;这次最大的收获不是“让桌子能被手机控制”，而是把一个原本封闭的普通家具有条理地拆成了几个可理解的问题：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;它的接口是不是电源和按键？&lt;/li&gt;
  &lt;li&gt;如果不是按键，它在说什么协议？&lt;/li&gt;
  &lt;li&gt;怎么只监听，不冒险干扰原控制器？&lt;/li&gt;
  &lt;li&gt;怎么发最小命令完成坐姿/站姿？&lt;/li&gt;
  &lt;li&gt;怎么让 Home Assistant 和 Apple Home 认为它是一个正常智能家居设备？&lt;/li&gt;
  &lt;li&gt;怎么把临时 USB 供电改成桌子内部供电？&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;最后答案很简洁：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;乐歌 E2 RJ45 手控接口
  -&amp;gt; UART 9600
  -&amp;gt; ESP32 + ESPHome
  -&amp;gt; Home Assistant
  -&amp;gt; HomeKit Bridge
  -&amp;gt; Apple Home
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;现在这张桌子终于不只是“电动升降桌”，而是家里智能家居系统的一部分了。&lt;/p&gt;

&lt;h2 id=&quot;下一步把桌子变成真正的健康提醒&quot;&gt;下一步：把桌子变成真正的健康提醒&lt;/h2&gt;

&lt;p&gt;现在的桌子已经可以被 Home Assistant 和 Apple Home 控制，但它本质上还是“手动触发”：我想坐下或站起来时，点一下按钮。&lt;/p&gt;

&lt;p&gt;下一步我想把它做成一个更主动的健康工作流：结合传感器和番茄钟，让桌子在合适的时机自动提醒甚至强制切换坐站状态。&lt;/p&gt;

&lt;p&gt;初步设想是：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;用传感器判断我是否真的在桌前，比如人体存在传感器、椅子压力传感器，或者电脑是否处于活跃状态&lt;/li&gt;
  &lt;li&gt;用 Home Assistant 做番茄钟逻辑：工作 25 分钟后自动切到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;stand&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;站立 5 分钟后再允许切回 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sit&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;如果传感器判断我不在桌前，就不触发升降，避免空桌子自己动&lt;/li&gt;
  &lt;li&gt;后续如果能解析出桌子的高度数据，再加入“已经在站姿就不重复执行”的判断&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这一版的关键不再是“能不能控制桌子”，而是“什么时候应该控制”。智能家居真正有用的地方，往往不是把手机变成遥控器，而是让设备根据场景自己做对的事。&lt;/p&gt;

&lt;h2 id=&quot;实战续集把番茄钟真的做出来了&quot;&gt;实战续集：把番茄钟真的做出来了&lt;/h2&gt;

&lt;p&gt;上面是设想，后来我把它完整落地了。最终效果是：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;我在桌前工作满 30 分钟，桌子自动切换坐 / 站姿势（节律就是“每 30 分钟换一次姿势”，换姿势后的前 5 分钟当作“换脑子”）&lt;/li&gt;
  &lt;li&gt;我离开桌子，番茄钟自动暂停，桌子不会对着空座位自己升降&lt;/li&gt;
  &lt;li&gt;离开超过 10 分钟（比如午饭）回来，自动重开一轮，回到坐姿重新计时&lt;/li&gt;
  &lt;li&gt;录歌、开 LogicPro、开会时，我进一个 macOS 专注模式，桌子整体冻结&lt;/li&gt;
  &lt;li&gt;晚上 23:00 到次日 07:00 硬冻结，任何原因都不动，免得半夜被动作惊到&lt;/li&gt;
  &lt;li&gt;全程 Mac 上不挂任何 GUI 程序，纯后台&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这一版折腾下来，真正花时间的不是“写自动化”，而是踩平台环境的坑。下面把架构、判断逻辑和三个硬核坑都记下来。&lt;/p&gt;

&lt;h3 id=&quot;怎么判断我在不在桌前&quot;&gt;怎么判断“我在不在桌前”&lt;/h3&gt;

&lt;p&gt;这是整件事的输入信号。我比较过几条路：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;ping / 在线探测&lt;/strong&gt;：只能判断“开机在线”，区分不了“在用”和“合盖睡眠”，不行。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;HA Companion App&lt;/strong&gt;：装一个官方 macOS App 就能上报 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Active&lt;/code&gt; 状态，最省事。但它是个常驻 GUI 程序，而且默认上报一大堆信息（前台 App、摄像头、位置……）。我想要无感，不行。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;最终方案&lt;/strong&gt;：读 macOS 自带的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HIDIdleTime&lt;/code&gt;（距离上次键鼠操作的纳秒数），后台脚本定时上报给 HA。这是唯一能真正反映“键鼠有没有在动”的信号。&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;ioreg &lt;span class=&quot;nt&quot;&gt;-c&lt;/span&gt; IOHIDSystem | &lt;span class=&quot;nb&quot;&gt;awk&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;/HIDIdleTime/ {print int($NF/1000000000)}&apos;&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;整体架构与数据流&quot;&gt;整体架构与数据流&lt;/h3&gt;

&lt;p&gt;最终链路长这样（后面会解释为什么中间要绕一道 webhook）：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
7
8
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;Mac launchd（每 20 秒）
  └─ /usr/bin/curl ──HTTP──▶ HA Webhook（接收 idle 秒数）
                                └─ 自动化 mqtt.publish ─▶ mosquitto（topic: mac/desk/idle）
                                      └─ MQTT sensor（expire_after 90s）
                                            └─ binary_sensor.mac_at_desk（idle &amp;lt; 180s）
                                                  └─ should_run（在桌前 且 未专注 且 非夜间）
                                                        └─ timer（30 分钟）
                                                              └─ 到点 ─▶ button.desk_sit / desk_stand
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;mosquitto 用 Docker 跑在树莓派上，关掉匿名、开账号密码和 ACL。HA 用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ha&lt;/code&gt; 账号连它。&lt;/p&gt;

&lt;h3 id=&quot;判断逻辑把该不该动拆成可独立验证的布尔量&quot;&gt;判断逻辑：把“该不该动”拆成可独立验证的布尔量&lt;/h3&gt;

&lt;p&gt;我没有把逻辑堆成一坨，而是拆成几个一眼能测真值的中间量：&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. 在不在桌前&lt;/strong&gt;&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;c1&quot;&gt;# binary_sensor.mac_at_desk&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;state&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;s&quot;&gt;{% set v = states(&apos;sensor.mac_idle_seconds&apos;) %}&lt;/span&gt;
  &lt;span class=&quot;s&quot;&gt;{{ v not in [&apos;unknown&apos;,&apos;unavailable&apos;,&apos;none&apos;, None] and (v | int(99999) &amp;lt; 180) }}&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;注意 MQTT sensor 上挂了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;expire_after: 90&lt;/code&gt;：Mac 睡眠 / 合盖后 launchd 不再上报，90 秒内收不到数据，传感器自动失活 → &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;at_desk&lt;/code&gt; 变 off → 桌子默认不动。这是个“失联即安全”的兜底。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. 番茄钟该不该运行&lt;/strong&gt;（三个闸门合一）&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
7
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;c1&quot;&gt;# binary_sensor.desk_pomodoro_should_run&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;state&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;s&quot;&gt;{% set t = now() %}&lt;/span&gt;
  &lt;span class=&quot;s&quot;&gt;{% set night = t.hour &amp;gt;= 23 or t.hour &amp;lt; 7 %}&lt;/span&gt;
  &lt;span class=&quot;s&quot;&gt;{{ is_state(&apos;binary_sensor.mac_at_desk&apos;,&apos;on&apos;)&lt;/span&gt;
     &lt;span class=&quot;s&quot;&gt;and is_state(&apos;input_boolean.desk_pause_focus&apos;,&apos;off&apos;)&lt;/span&gt;
     &lt;span class=&quot;s&quot;&gt;and not night }}&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;计时器只在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;should_run&lt;/code&gt; 为 on 时走；离桌 / 专注 / 夜间任一成立，立刻 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timer.pause&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. 回来是“续上”还是“重开一轮”（这里藏着一个我后来才发现的坑）&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;这是体验上最关键的一条。短暂离开（上厕所、倒水）希望接着原进度；长时间离开（午饭）希望重新开始。我用“离开时长”统一区分：&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
3
4
5
6
7
8
9
10
11
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;c1&quot;&gt;# 触发器：HA 启动 + should_run 任意 → on（关键：别只写 from: &quot;off&quot;）&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# 条件：should_run 为 on，且 timer 当前不是 active（避免重复启动）&lt;/span&gt;
&lt;span class=&quot;na&quot;&gt;away_secs&lt;/span&gt;&lt;span class=&quot;pi&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;pi&quot;&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;s&quot;&gt;{% if trigger.platform == &apos;state&apos; and trigger.from_state&lt;/span&gt;
        &lt;span class=&quot;s&quot;&gt;and trigger.from_state.state == &apos;off&apos; and trigger.to_state %}&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;{{ (trigger.to_state.last_changed - trigger.from_state.last_changed).total_seconds() }}&lt;/span&gt;
  &lt;span class=&quot;s&quot;&gt;{% else %}&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;99999&lt;/span&gt;
  &lt;span class=&quot;s&quot;&gt;{% endif %}&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# away_secs &amp;lt; 600 且 timer 处于 paused → timer.start（续上）&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# 否则 → 起 30 分钟（开新一轮，从当前姿势开始，不强制移动桌子）&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;这一条同时覆盖了午饭、夜间结束、长时间录制结束等所有“离开很久”的情况，不用各写一遍。&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;⚠️ &lt;strong&gt;别学我最初的写法&lt;/strong&gt;：我第一版的触发器只写了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;from: &quot;off&quot; → to: &quot;on&quot;&lt;/code&gt;，埋了个雷。每次 Home Assistant 一重启，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;should_run&lt;/code&gt; 是从 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;unknown → on&lt;/code&gt;，并不匹配 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;off→on&lt;/code&gt;，于是启动自动化根本不触发——番茄钟就一直停在 idle，桌子再也不会动；而整套又没有“开机即启”的兜底，等于&lt;strong&gt;只要重启过一次就废了&lt;/strong&gt;（我清理配置连着重启了十来次，才发现它早就不转了）。&lt;/p&gt;

  &lt;p&gt;修法：把触发器&lt;strong&gt;放宽成「任意 → on」并补上「HA 启动（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;platform: homeassistant, event: start&lt;/code&gt;）」&lt;/strong&gt;，再用「timer 不是 active」这个条件兜住重复启动；&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;away_secs&lt;/code&gt; 只在真正的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;off→on&lt;/code&gt; 时才计算，其余（开机、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;unknown→on&lt;/code&gt;）一律当作开新一轮。&lt;strong&gt;“只认某一种状态跳变”是 HA 自动化里最常见的坑之一——一定要把重启 / 冷启动时的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;unknown&lt;/code&gt; 也算进去。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;4. 到点换姿势&lt;/strong&gt;&lt;/p&gt;

&lt;div class=&quot;language-yaml highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;c1&quot;&gt;# timer.finished 时&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# 当前是坐姿 → 按 stand；否则 → 按 sit&lt;/span&gt;
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;补一条后来加的小改进：&lt;strong&gt;计时的本质其实是“当前姿势已经保持了多久”&lt;/strong&gt;。所以不只“到点自动翻”会重置——&lt;strong&gt;你自己手动起坐切了姿势，30 分钟也从落位那一刻重新算&lt;/strong&gt;，免得出现“刚站起来没几分钟又被翻回坐”的别扭。实现上就是监听桌子落到坐 / 站任一姿势、且 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;should_run&lt;/code&gt; 为 on 时，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timer.start&lt;/code&gt; 30 分钟即可。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;因为夜间 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;should_run&lt;/code&gt; 恒为 off、计时器被暂停，永远不会触发 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;finished&lt;/code&gt;，所以 23:00–07:00 的硬冻结是天然成立的，不需要额外拦截。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. 录歌 / 开会怎么暂停&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;没有去逐个识别“LogicPro 在不在前台”“摄像头是否在用”这种又多又脆的条件，而是用一个 &lt;strong&gt;macOS 专注模式&lt;/strong&gt;当总开关：用「快捷指令」建两条个人自动化，专注模式打开 / 关闭时各 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt; 一个 HA Webhook，HA 翻转 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;input_boolean.desk_pause_focus&lt;/code&gt;。单一真相来源，干净。&lt;/p&gt;

&lt;h3 id=&quot;三个把我卡住的坑这次最值钱的部分&quot;&gt;三个把我卡住的坑（这次最值钱的部分）&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;坑一：macOS 26 的“本地网络隐私”按二进制授权&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;我一开始让 Mac 端脚本直接用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mosquitto_pub&lt;/code&gt; 把数据发给树莓派上的 broker。手动在终端跑——成功；交给 launchd 后台跑——&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;runs&lt;/code&gt; 在涨、退出码 0，但 broker 一条都收不到。&lt;/p&gt;

&lt;p&gt;换成 Python 直连，报错变清晰了：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[Errno 65] No route to host&lt;/code&gt;。但同一个 launchd 上下文里，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt; 访问树莓派却返回 200。&lt;/p&gt;

&lt;p&gt;结论：&lt;strong&gt;macOS 26 的本地网络隐私是按可执行文件授权的&lt;/strong&gt;。终端里手动跑，进程继承了终端 App 的“本地网络”授权；launchd 拉起的无头进程是独立身份，没有这个授权，于是 Homebrew 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;python3&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mosquitto_pub&lt;/code&gt; 访问局域网被静默拒绝。而 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/usr/bin/curl&lt;/code&gt; 是苹果系统签名的二进制，被放行。&lt;/p&gt;

&lt;p&gt;诊断的关键一步，是在同一个 launchd 脚本里同时试 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt; 和直连 MQTT：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;launchd 上下文：curl_to_pi=200      ← 网络是通的
launchd 上下文：mosquitto/python 失败 ← 只有三方二进制被拦
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;解法：&lt;strong&gt;让被放行的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt; 来跨局域网这一跳&lt;/strong&gt;。Mac 不再直连 broker，而是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt; 一个 HA Webhook，由 HA 内部再 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mqtt.publish&lt;/code&gt;。这样既绕开了本地网络隐私，又保住了 broker、最小权限账号和 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;expire_after&lt;/code&gt; 安全网。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;坑二：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mosquitto_pub&lt;/code&gt; 在 launchd 下报 “Bad file descriptor”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;其实就是坑一的“劣质报错”——socket 被本地网络隐私拒绝后，mosquitto 的网络循环踩到坏 fd，报成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Error: Bad file descriptor&lt;/code&gt;，把人往 stdin/fd 的方向带偏。换 Python 看到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;No route to host&lt;/code&gt; 才认清真相。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;坑三：中文 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;name&lt;/code&gt; 生成拼音 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;entity_id&lt;/code&gt;，跨实体引用全断&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;HA 里 MQTT / 模板实体的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;entity_id&lt;/code&gt; 是由 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;name&lt;/code&gt; 做 slug 生成的。我图省事给传感器起了中文名 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Mac 空闲秒数&lt;/code&gt;，HA 把它转成了拼音：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;table class=&quot;rouge-table&quot;&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td class=&quot;rouge-gutter gl&quot;&gt;&lt;pre class=&quot;lineno&quot;&gt;1
2
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;sensor.mac_kong_xian_miao_shu
binary_sensor.mac_zai_zhuo_qian
&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;而我模板里引用的是英文 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sensor.mac_idle_seconds&lt;/code&gt; —— 永远取不到值，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;at_desk&lt;/code&gt; 恒为 off。现象很迷惑：数据明明在面板上跳，传感器却一直 off。&lt;/p&gt;

&lt;p&gt;（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;input_boolean&lt;/code&gt; / &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timer&lt;/code&gt; 不受影响，它们的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;entity_id&lt;/code&gt; 取自配置里的键名，不是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;name&lt;/code&gt;。）&lt;/p&gt;

&lt;p&gt;教训：&lt;strong&gt;会被别的模板 / 自动化引用的技术实体，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;name&lt;/code&gt; 一律用英文&lt;/strong&gt;，让 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;entity_id&lt;/code&gt; 干净可预测；中文留给 friendly name 是不安全的，它会进到 id 里。&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;附带还有个小坑：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ioreg | awk &apos;… exit}&apos;&lt;/code&gt; 里 awk 命中即停，会给上游 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ioreg&lt;/code&gt; 发 SIGPIPE，叠加脚本的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;set -e -o pipefail&lt;/code&gt;，会让脚本拿到 idle 后“静默早退”，根本执行不到上报。把 awk 的提前 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;exit&lt;/code&gt; 去掉就好。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;一点收获&quot;&gt;一点收获&lt;/h3&gt;

&lt;p&gt;这次最大的体会有两条。&lt;/p&gt;

&lt;p&gt;一是&lt;strong&gt;把复杂判断拆成一串可独立灌值验证的布尔量&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;在桌前 / 未专注 / 非夜间&lt;/code&gt;，每一层都能单独 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt; 一个值进去、再去数据库里读真值确认，排错时不至于面对一个黑盒。&lt;/p&gt;

&lt;p&gt;二是&lt;strong&gt;跨平台自动化的魔鬼全在环境细节里&lt;/strong&gt;，不在主逻辑：本地网络隐私按二进制授权、slug 的中文转拼音规则——这些东西配置语法上完全正确，却让整条链路静默失效。先把“每一跳到底通没通”验证清楚，比对着 YAML 反复读要快得多。&lt;/p&gt;
</description>
        <pubDate>Sun, 31 May 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/2026/05/31/%E4%B9%90%E6%AD%8CE2%E5%8D%87%E9%99%8D%E6%A1%8C%E6%94%B9%E9%80%A0%E6%88%90%E6%99%BA%E8%83%BD%E5%AE%B6%E5%B1%85/</link>
        <guid isPermaLink="true">https://uulili.com/2026/05/31/%E4%B9%90%E6%AD%8CE2%E5%8D%87%E9%99%8D%E6%A1%8C%E6%94%B9%E9%80%A0%E6%88%90%E6%99%BA%E8%83%BD%E5%AE%B6%E5%B1%85/</guid>
        
        <category>技术分享</category>
        
        <category>智能家居</category>
        
        <category>ESPHome</category>
        
        <category>Home Assistant</category>
        
        
      </item>
    
  </channel>
</rss>
