<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Pixel Li</title>
    <description>Notes from a home lab in China: local AI on a Mac mini, small automations, and the tinkering in between.</description>
    <link>https://uulili.com/en/</link>
    <language>en</language>
    <atom:link href="https://uulili.com/en/feed.xml" rel="self" type="application/rss+xml" />
    <pubDate>Mon, 09 Nov 2026 02:06:50 +0000</pubDate>
    <lastBuildDate>Mon, 09 Nov 2026 02:06:50 +0000</lastBuildDate>
    <generator>Jekyll v3.10.0</generator>
    
    
    
    
      <item>
        <title>Tech notes: making a ten-year-old Flask site static and moving it to free Cloudflare hosting</title>
        <description>&lt;blockquote&gt;
  &lt;p&gt;This is a complete record for myself: the next time I change this site, or move another old site the same way, read this first. The site was built for a lab many years ago; below I just call it “this site” and leave out its name and domain. The one doing the work was Claude Code on a Mac mini; I made the decisions and clicked confirm.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;summary-at-a-glance&quot;&gt;Summary at a glance&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Item&lt;/th&gt;
      &lt;th&gt;Before&lt;/th&gt;
      &lt;th&gt;After&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Hosting&lt;/td&gt;
      &lt;td&gt;One AWS Lightsail server, running only this site&lt;/td&gt;
      &lt;td&gt;Cloudflare Workers static assets, free&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Cost&lt;/td&gt;
      &lt;td&gt;$35.65 for six months, averaging $5.94 a month, about $71 a year&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;Server cost 0&lt;/strong&gt;; only the domain registration fee is left&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Stack&lt;/td&gt;
      &lt;td&gt;Python 2.7 + Flask 0.12 + Apache, Ubuntu 16.04 (unsupported since 2021)&lt;/td&gt;
      &lt;td&gt;Python 3.12 + Flask 3 exporting static files; the admin runs only locally&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Update frequency&lt;/td&gt;
      &lt;td&gt;Changed about twice a year&lt;/td&gt;
      &lt;td&gt;Unchanged; after editing, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;make publish&lt;/code&gt; goes live in half a minute&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Large files&lt;/td&gt;
      &lt;td&gt;S3: research page videos + PyMOL downloads, 26 files, 362MB&lt;/td&gt;
      &lt;td&gt;Videos go into the site; 17 download files go on R2 (free tier 10GB)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Time&lt;/td&gt;
      &lt;td&gt;2 months of development back then; then deployment, data updates, and for 2.0 adding the admin and writing tutorials, so there was always maintenance work&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;Static conversion done in one day&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Trade-off&lt;/td&gt;
      &lt;td&gt;—&lt;/td&gt;
      &lt;td&gt;Direct connections from mainland China got slower (see the measurements below); I accepted it&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/tn-static-aws-bill.png&quot; alt=&quot;AWS bill for half a year, almost all of it Lightsail&quot; /&gt;&lt;/p&gt;

&lt;p&gt;To keep it stable, I never ran any other service on this server. &lt;strong&gt;I changed the content twice a year and still paid $6 every month.&lt;/strong&gt; That’s money I should have saved long ago.&lt;/p&gt;

&lt;h2 id=&quot;why-it-can-be-made-static&quot;&gt;Why it can be made static&lt;/h2&gt;

&lt;p&gt;First I listed the “dynamic” parts clearly, so I could tell whether they could be moved:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Part&lt;/th&gt;
      &lt;th&gt;How it used to work&lt;/th&gt;
      &lt;th&gt;After static conversion&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;6 pages&lt;/td&gt;
      &lt;td&gt;Jinja templates + SQLite queries&lt;/td&gt;
      &lt;td&gt;Exported as HTML&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;10 research subpages&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/research/&amp;lt;name&amp;gt;&lt;/code&gt; renders the matching template&lt;/td&gt;
      &lt;td&gt;Exported one by one&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;3 JSON endpoints&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; query strings&lt;/td&gt;
      &lt;td&gt;Changed to path parameters, each address exported as a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.json&lt;/code&gt; file&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Admin&lt;/td&gt;
      &lt;td&gt;Flask-Admin, Basic Auth, edited directly on the live site&lt;/td&gt;
      &lt;td&gt;Kept as is, runs only locally&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Page cache&lt;/td&gt;
      &lt;td&gt;Flask-Cache, cleared after admin submissions&lt;/td&gt;
      &lt;td&gt;Removed&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;On the visitor side there are no forms, logins or search — &lt;strong&gt;it’s all read-only&lt;/strong&gt;. That’s enough. The data is small too: 221 papers, 105 structures, 55 members, a 164KB database, 39MB of images.&lt;/p&gt;

&lt;h2 id=&quot;export&quot;&gt;Export&lt;/h2&gt;

&lt;p&gt;I didn’t use Frozen-Flask; I wrote an 88-line &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;freeze.py&lt;/code&gt; that requests each URL through Flask’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;test_client&lt;/code&gt; and writes the file. Reasons:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;I wanted &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/people&lt;/code&gt; written as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;people.html&lt;/code&gt;, so the hosting platform maps &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/people&lt;/code&gt; to it automatically; Frozen-Flask by default writes extensionless files.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Endpoints with query strings can’t be exported&lt;/strong&gt;, so the routes had to change first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;How I changed the endpoints:&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   (numbered in page order)
/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;Orientation names contain spaces and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;amp;beta;&lt;/code&gt;, which don’t work well as filenames, so I used numbers. I changed one or two request URLs in each of the two front-end JS files.&lt;/p&gt;

&lt;p&gt;While I was at it, I also wrote two files the hosting platform understands:&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 → /, and one old section address redirecting to the new section
_headers     /static/* cached 1 day; /data/* cached 10 minutes
&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; was added after going live: Workers static assets default to &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;max-age=0, must-revalidate&lt;/code&gt;&lt;/strong&gt;, so the browser has to check back with the server for every image; a structure page with dozens of thumbnails means dozens of round trips. The old server cached for 12 hours.&lt;/p&gt;

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

&lt;p&gt;The code base isn’t big (about 700 lines excluding data), but &lt;strong&gt;a few places fail silently&lt;/strong&gt;:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Problem&lt;/th&gt;
      &lt;th&gt;Symptom&lt;/th&gt;
      &lt;th&gt;Fix&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; returns an iterator in Python 3&lt;/td&gt;
      &lt;td&gt;The template loop is drained after one pass; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;json.dumps&lt;/code&gt; serialization fails&lt;/td&gt;
      &lt;td&gt;Wrap in &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;Orientation names become &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;b&apos;…&apos;&lt;/code&gt; written into the page&lt;/td&gt;
      &lt;td&gt;Removed&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;Since Flask 2.3 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cls&lt;/code&gt; is ignored, the custom encoder doesn’t take effect, raising &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Category is not JSON serializable&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Switched to the standard library &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 single-column queries return &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Row&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Not a tuple, so the encoder doesn’t accept it&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;Custom encoder scans fields with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dir(obj)&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;SQLAlchemy 2 adds attributes like &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;registry&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Changed to take only &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; references &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;self.name&lt;/code&gt;, but the model has no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;name&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Never called under Python 2; Flask-Admin calls &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;__str__&lt;/code&gt; under Python 3&lt;/td&gt;
      &lt;td&gt;Changed to the real field&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Flask-Admin 2 only ships Bootstrap 4&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;label label-info&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;well&lt;/code&gt; in 24 custom admin templates stopped working&lt;/td&gt;
      &lt;td&gt;Replaced with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;badge&lt;/code&gt; and &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;One global session, the local dev server is multi-threaded&lt;/td&gt;
      &lt;td&gt;Occasional mix-ups&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;remove()&lt;/code&gt; in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;teardown_appcontext&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Database path hardcoded to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/home/&amp;lt;user&amp;gt;/…&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Not found on another machine&lt;/td&gt;
      &lt;td&gt;Changed to a path relative to the project directory&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;I also turned up an old bug that I planted myself back then. The 4th tab name on the structure page contains a Greek letter; the tab text goes through HTML decoding (&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;), but the name list passed to the endpoint wasn’t decoded, so the two sides didn’t match. It used to show up only because the server had a hardcoded fallback. &lt;strong&gt;After static conversion the fallback was gone, and the problem surfaced.&lt;/strong&gt; Fixed by decoding before comparing.&lt;/p&gt;

&lt;h2 id=&quot;verification-line-by-line-comparison-with-the-live-site&quot;&gt;Verification: line-by-line comparison with the live site&lt;/h2&gt;

&lt;p&gt;Before changing anything, I saved a snapshot of the live site: 7 pages, 12 research subpages, 112 JSON endpoints (one per structure). After export I compared them one by one:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;The 19 pages are &lt;strong&gt;line-by-line identical&lt;/strong&gt; after stripping whitespace;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The 112 JSON files match&lt;/strong&gt; (ignoring the related fields with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;null&lt;/code&gt; values that the old encoder emitted extra);&lt;/li&gt;
  &lt;li&gt;I clicked through it in a headless browser: the 4 tabs on the structure page add up to 23+36+18+28=105 structures, matching the database count; detail pages have images and text; the 3 lab activity tabs work;&lt;/li&gt;
  &lt;li&gt;Screenshots at 375 (phone), 768 (tablet) and 1400 (desktop) widths: list pages are &lt;strong&gt;pixel-identical&lt;/strong&gt; to the live site.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I tested the admin on a temporary copy, without touching the real data: &lt;strong&gt;create, edit and delete in 15 sections&lt;/strong&gt;; after uploading a structure image, the main image gets cropped to 500×500 and a 100×100 thumbnail is generated automatically; deleting a record deletes the file along with the thumbnail; uploading and deleting media files; then clicked through the new-paper form in a real browser (the year is a select2 dropdown), saved it, and it showed up on the paper page.&lt;/p&gt;

&lt;h2 id=&quot;deployment-workers-static-assets&quot;&gt;Deployment: Workers static assets&lt;/h2&gt;

&lt;p&gt;I originally planned to use Cloudflare Pages. The new wrangler (4.157) runs &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pages project create&lt;/code&gt; by redirecting to creating a Worker, and fails if no static directory is given. &lt;strong&gt;Pages has been merged into Workers&lt;/strong&gt;; what’s recommended now is Workers static assets, with the same free tier, and it supports &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; and 404 pages.&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 name&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;domain&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;The permissions the API token needs (the first dropdown is Zone or Account; pick the wrong one and the later items won’t show up):&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Scope&lt;/th&gt;
      &lt;th&gt;Permission&lt;/th&gt;
      &lt;th&gt;Purpose&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;Deploy&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;Create buckets, upload files, bind the domain&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Zone (only these domains selected)&lt;/td&gt;
      &lt;td&gt;DNS · Edit&lt;/td&gt;
      &lt;td&gt;Change records (note: not 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;Bind custom domain&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;Redirect rules&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;If the Token only has Pages permissions, wrangler can’t read the account ID, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLOUDFLARE_ACCOUNT_ID&lt;/code&gt; has to be set manually.&lt;/p&gt;

&lt;p&gt;For day-to-day use I wrapped it in a 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;# open the admin locally to edit content&lt;/span&gt;
make publish   &lt;span class=&quot;c&quot;&gt;# export → deploy → commit and push the database and images to 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;The auto-commit in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;publish&lt;/code&gt; is there because the content (database, images) is also in the repo. It used to be a manual step, and &lt;strong&gt;if I forgot, the “backup” on GitHub was actually stale&lt;/strong&gt;. I will definitely forget.&lt;/p&gt;

&lt;h2 id=&quot;moving-s3-to-r2&quot;&gt;Moving S3 to R2&lt;/h2&gt;

&lt;p&gt;The videos and download files on S3 have hotlink protection: only requests whose Referer is the https version of this site get through. So a plain curl gets a 403, but with the Referer it downloads fine, &lt;strong&gt;no AWS keys needed&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;domain&amp;gt;/&quot;&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-o&lt;/span&gt; file &lt;span class=&quot;s2&quot;&gt;&quot;https://s3.&amp;lt;region&amp;gt;.amazonaws.com/&amp;lt;bucket&amp;gt;/&amp;lt;path&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 videos&lt;/strong&gt; (1.5–4.7MB) went straight into the site. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;+&lt;/code&gt; in filenames was changed to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_&lt;/code&gt; to avoid ambiguity in URLs.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;17 PyMOL files&lt;/strong&gt; (largest 47MB, over the 25MB per-file limit for Workers static assets) went on R2, bound to the subdomain &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;files.&amp;lt;domain&amp;gt;&lt;/code&gt;. Uploaded with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wrangler r2 object put --remote&lt;/code&gt;; after uploading, each was downloaded and its SHA-256 compared, matching the S3 originals.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After binding the custom domain, a few files returned 403 for the first minute; once the edge nodes synced, they were fine.&lt;/p&gt;

&lt;h2 id=&quot;domain&quot;&gt;Domain&lt;/h2&gt;

&lt;h3 id=&quot;changing-nameservers&quot;&gt;Changing nameservers&lt;/h3&gt;

&lt;p&gt;In Cloudflare, go to Add a site → Connect a domain, and the DNS records are imported automatically. After importing, &lt;strong&gt;first change the records pointing at the old server to a grey cloud (DNS only)&lt;/strong&gt;: the old server does its own http→https redirect, and some Cloudflare SSL modes send requests to the origin over HTTP, so the two together can cause an infinite redirect loop. Once they’re grey, the site is still served by the old server until the switch.&lt;/p&gt;

&lt;p&gt;Gotcha: &lt;strong&gt;different domains aren’t necessarily assigned the same pair of nameservers&lt;/strong&gt;. I filled in the same pair for two domains in bulk at GoDaddy, and one of them stayed pending; I checked the API and found it had been assigned a different pair.&lt;/p&gt;

&lt;h3 id=&quot;switching&quot;&gt;Switching&lt;/h3&gt;

&lt;p&gt;Delete the A record for the main domain, then immediately bind the Worker custom domain via the API (the certificate is issued automatically). Visitors who still have the old record cached keep going to the old server, which is still running, so there was no downtime.&lt;/p&gt;

&lt;p&gt;For the domain used only for redirects: point the root record at the placeholder address &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;192.0.2.1&lt;/code&gt; with the proxy on, then add a 301 rule so requests are redirected at the Cloudflare edge without hitting the origin. The target address in the rule:&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;Keep the path: target_url.expression = concat(&quot;https://&amp;lt;main domain&amp;gt;&quot;, http.request.uri.path)
Fixed address: target_url.value = &quot;https://…&quot;     ← writing this as an expression gives a 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;registrar&quot;&gt;Registrar&lt;/h3&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/tn-static-godaddy-renew.png&quot; alt=&quot;GoDaddy&apos;s renewal price for a .com&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/tn-static-cf-price.png&quot; alt=&quot;Cloudflare .com price, $11.17 from November 1&quot; /&gt;&lt;/p&gt;

&lt;p&gt;For the same .com, GoDaddy renews at $22.99/year, Cloudflare at cost, $11.17/year (it just went up from $10.46 on November 1), &lt;strong&gt;a difference of $11.82 a year, roughly half the price&lt;/strong&gt;. To transfer, first make the domain active on Cloudflare, then unlock it at GoDaddy and get the authorization code; you pay for 1 year at transfer time, and it’s added after the existing expiry date, so nothing is wasted. Don’t change the registrant info before the transfer completes, or the domain gets locked for 60 days.&lt;/p&gt;

&lt;h2 id=&quot;access-from-mainland-china&quot;&gt;Access from mainland China&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;This is the only thing that got worse.&lt;/strong&gt; On the afternoon of 2026-11-05, over the same home broadband connection, I connected directly and requested one 2KB logo each time:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt; &lt;/th&gt;
      &lt;th&gt;Success&lt;/th&gt;
      &lt;th&gt;Average time&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Cloudflare free plan&lt;/td&gt;
      &lt;td&gt;17/20, 3 timeouts&lt;/td&gt;
      &lt;td&gt;4–8 seconds&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Old server (AWS Ohio, US)&lt;/td&gt;
      &lt;td&gt;10/10&lt;/td&gt;
      &lt;td&gt;0.9 seconds&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Breaking it down, establishing the TCP connection takes 0.27 seconds, about the same as with AWS; the slowness comes from the occasional 3–5 second stall afterwards, as if from packet loss and retransmission. Access from overseas is faster, by contrast. After looking at the data, I decided to accept it. No rollback. To get fast access in mainland China, the only option is ICP filing and a domestic CDN.&lt;/p&gt;

&lt;p&gt;Also, &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;*.workers.dev&lt;/code&gt; is blocked entirely in mainland China&lt;/strong&gt;: DNS resolves to the wrong address, and even a connection to the real IP gets reset during the TLS handshake. The test address could only be viewed through a proxy; once I bind my own domain, it isn’t affected. I later shut down that test address too (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;workers_dev: false&lt;/code&gt;).&lt;/p&gt;

&lt;h2 id=&quot;while-i-was-at-it-moving-the-blogs-image-host-back-into-the-repo&quot;&gt;While I was at it: moving the blog’s image host back into the repo&lt;/h2&gt;

&lt;p&gt;In 2020 I wrote a post about &lt;a href=&quot;https://uulili.com/成长日记/2020/04/30/amazon-s3-with-upic-picture-bed/&quot;&gt;setting up an image host with Amazon S3 + uPic&lt;/a&gt;. That bucket had 44 images, 39 of which were referenced by the blog, 3.3MB in total. I’d long forgotten the key and secret from back then. Fortunately &lt;strong&gt;the images were public anyway, so I could just download them&lt;/strong&gt;, no key needed. They went into the repo under &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;img/markdown-img/&lt;/code&gt;, and the links in 10 posts and the about page were changed to paths within the site. Later posts already put images straight into &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/img/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To see all the files in a bucket when you don’t have a key, use CloudShell in the top-right corner of the AWS console, which uses your current logged-in identity:&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;bucket&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;how-much-to-let-the-ai-do&quot;&gt;How much to let the AI do&lt;/h2&gt;

&lt;p&gt;Claude Code’s safety checks stopped me twice: once when deleting the DNS records for the production domain, and once when making an R2 bucket public on a subdomain. It was right to stop me, &lt;strong&gt;both steps affect the outside world and are hard to undo&lt;/strong&gt;. After I’d read the list of changes and the rollback plan it laid out, I replied “confirm”, and only then did it go ahead. Another time it wanted to borrow another Token to look up the account ID, and that was blocked too: that Token is for renewing certificates for another service and shouldn’t be repurposed.&lt;/p&gt;

&lt;h2 id=&quot;pitfalls&quot;&gt;Pitfalls&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;In zsh, a URL containing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;?&lt;/code&gt; without quotes is treated as a glob, and the command fails with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;no matches found&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;zsh doesn’t split variables on spaces, so &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;set -- $r&lt;/code&gt; doesn’t give you multiple arguments, and my validation script flagged everything as “abnormal” because of it.&lt;/li&gt;
  &lt;li&gt;Going through a proxy from my machine, each request to Cloudflare takes 1–2 seconds; after a headless browser views a few pages with video, the next page hangs and times out. Interactive tests need a separate page.&lt;/li&gt;
  &lt;li&gt;Workers static assets have a per-file limit of 25MB.&lt;/li&gt;
  &lt;li&gt;The Google Analytics the pages referenced was the old UA code that had stopped collecting data back in 2023, and it was also slowing down page loads, so I removed it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;not-done-yet&quot;&gt;Not done yet&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;After monitoring it for a week or two, delete the S3 bucket and shut down the old server; then remove the test subdomain record pointing at the old server and the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;deploy/&lt;/code&gt; directory in the repo.&lt;/li&gt;
  &lt;li&gt;I haven’t tested the speed in mainland China during peak hours; I assume it’s only worse.&lt;/li&gt;
  &lt;li&gt;The other 10 defensively registered .com domains are still at GoDaddy; I’ll decide before they expire whether to transfer them.&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Thu, 05 Nov 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/en/2026/11/05/flask-site-static-cloudflare-workers-migration/</link>
        <guid isPermaLink="true">https://uulili.com/en/2026/11/05/flask-site-static-cloudflare-workers-migration/</guid>
        
        <category>Flask</category>
        
        <category>static site</category>
        
        <category>Cloudflare Workers</category>
        
        <category>R2</category>
        
        <category>migration</category>
        
      </item>
    
      <item>
        <title>Mac mini Home Server: Set Up a Workbench in One Sentence, Open Source</title>
        <description>&lt;blockquote&gt;
  &lt;p&gt;A home server sounds very geeky, like a toy that only programmers have. But in the era of large models, it’s actually something for every household. Because the code work of deployment, management and maintenance is &lt;strong&gt;exactly one of the things today’s large models are best at&lt;/strong&gt;. I turned the pitfalls I ran into into an open-source project, &lt;strong&gt;so that AI can set up&lt;/strong&gt; a workbench &lt;strong&gt;in one sentence&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The one at my home is a Mac mini. I actually thought about it for a long time before buying the Mac mini — I already had an mbp — with one more computer, would I even use it enough? But after buying it, I found that the more I use it, the more I like it. The core of a home server is &lt;strong&gt;running 24/7 without interruption&lt;/strong&gt;. That means whatever service you want to use, whenever you want it, it’s there to respond. And over time, the automated services naturally get richer — this is a device that keeps growing along with you, &lt;strong&gt;it’s an asset&lt;/strong&gt;. This is what it looks like right now:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/mp008-img1.jpg&quot; alt=&quot;My workbench: all 10 services online&quot; /&gt;&lt;/p&gt;

&lt;p&gt;There are 10 services in total. 7 on the Mac mini: movie library, local video generation, voiceover, local large model, the workbench I use to write this WeChat account, investment bookkeeping, and subtitles. The other 3 are on the NAS and Raspberry Pi next to it: Synology admin, n8n and Home Assistant.&lt;/p&gt;

&lt;h2 id=&quot;what-a-home-server-is&quot;&gt;What a home server is&lt;/h2&gt;

&lt;p&gt;Put simply, it’s &lt;strong&gt;a computer at home that’s on 24 hours a day&lt;/strong&gt;, running your own always-on services.&lt;/p&gt;

&lt;p&gt;Things a home server can do include but are not limited to:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Purpose&lt;/th&gt;
      &lt;th&gt;Common services&lt;/th&gt;
      &lt;th&gt;What it does for you&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Media&lt;/td&gt;
      &lt;td&gt;Jellyfin&lt;/td&gt;
      &lt;td&gt;Downloaded movies automatically get posters, and you can watch them on your TV, projector or phone&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Photos&lt;/td&gt;
      &lt;td&gt;Immich&lt;/td&gt;
      &lt;td&gt;Phone photos are backed up automatically and searchable by face, location, or similar image&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Passwords&lt;/td&gt;
      &lt;td&gt;Vaultwarden&lt;/td&gt;
      &lt;td&gt;The family password vault; any Bitwarden client can connect&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Ad blocking&lt;/td&gt;
      &lt;td&gt;AdGuard Home&lt;/td&gt;
      &lt;td&gt;Ad blocking across all home devices&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Automation&lt;/td&gt;
      &lt;td&gt;n8n, Home Assistant&lt;/td&gt;
      &lt;td&gt;Scrape news on a schedule and send it to your phone; the desk rises by itself after you sit too long&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;AI&lt;/td&gt;
      &lt;td&gt;Ollama etc.&lt;/td&gt;
      &lt;td&gt;Run large models locally; chat and generation never leave this machine&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;There are also some services that don’t need a backend but are actually very well suited to running on one. For example, what I use most right now is &lt;strong&gt;Paseo&lt;/strong&gt;. It’s a console for coding Agents like Claude Code and Codex: the Agent runs permanently on the Mac mini, and I can connect from anywhere to check progress and give the next instruction. &lt;strong&gt;A session started on the computer can be continued on the phone&lt;/strong&gt;, and vice versa — the multi-device session sync problem is gone entirely. That’s what lets AI now work steadily across multiple devices over the long term. I’ll go into its benefits in detail next time.&lt;/p&gt;

&lt;p&gt;What machine is good for a home server? My take:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Mac mini is the best fit&lt;/strong&gt;. Low power, quiet, fine to leave on long-term, and Apple chips can run local AI.&lt;/li&gt;
  &lt;li&gt;NAS is good for storing data. Install too many services and it struggles.&lt;/li&gt;
  &lt;li&gt;Raspberry Pi is good for running one or two lightweight ones, like Home Assistant.&lt;/li&gt;
  &lt;li&gt;An idle Linux computer also works, but it’s a second choice.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;once-you-have-a-lot-of-services-trouble-starts&quot;&gt;Once you have a lot of services, trouble starts&lt;/h2&gt;

&lt;p&gt;The 4 troubles I ran into:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Can’t remember the addresses&lt;/strong&gt;. Each service has a port, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;8096&lt;/code&gt; is movies, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;8190&lt;/code&gt; is video generation, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;8123&lt;/code&gt; is smart home… 10 of them, impossible to remember, I have to dig through notes every time.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;The browser says “not secure”&lt;/strong&gt;. Home services default to http, and some features (like recording, copying) don’t work on insecure pages.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Want to use it from outside, but afraid of exposure&lt;/strong&gt;. Opening a port on the router is the simplest, but it’s like leaving your front door open on the public internet, and port-scanning bots are looking for doors like that all day long.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Don’t know when something is down&lt;/strong&gt;. You usually only find out a service stopped when you need to use it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;the-workbench-one-entry-point-to-manage-all-services&quot;&gt;The workbench: one entry point to manage all services&lt;/h2&gt;

&lt;p&gt;My solution is to build a workbench for these always-on services. It does just 4 things:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;A name for each service&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;jellyfin.yourdomain.com&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ha.yourdomain.com&lt;/code&gt;, no more remembering ports.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Proper certificates&lt;/strong&gt;: the browser shows a padlock, auto-renewed.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Only you can open it&lt;/strong&gt;: even if others know the address, they can’t connect.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;One dashboard&lt;/strong&gt;: whether each service is online, and the machine’s CPU, memory, disk and temperature, all at a glance; plus an automatic checkup once a day.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Maintenance is simple too. All services are written in one list, &lt;strong&gt;adding a service is adding a line&lt;/strong&gt;, and the domain, certificate, card and checkup all update automatically.&lt;/p&gt;

&lt;h2 id=&quot;several-ways-to-access-from-outside&quot;&gt;Several ways to access from outside&lt;/h2&gt;

&lt;p&gt;“Only you can open it” is a given on the LAN; the key is how to connect back home from outside. The common ones:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Method&lt;/th&gt;
      &lt;th&gt;How it connects&lt;/th&gt;
      &lt;th&gt;Who can access&lt;/th&gt;
      &lt;th&gt;Things to note&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Public IP + port forwarding&lt;/td&gt;
      &lt;td&gt;Open a port on the router; DDNS follows IP changes&lt;/td&gt;
      &lt;td&gt;The whole world&lt;/td&gt;
      &lt;td&gt;Many home broadband connections have no public IP; exposed to the public internet, security rests entirely on the service itself&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Tunneling (frp, Oray etc.)&lt;/td&gt;
      &lt;td&gt;Borrow a relay server&lt;/td&gt;
      &lt;td&gt;The whole world by default&lt;/td&gt;
      &lt;td&gt;Speed depends on the relay; mostly paid&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Cloudflare Tunnel&lt;/td&gt;
      &lt;td&gt;Your home connects out to Cloudflare&lt;/td&gt;
      &lt;td&gt;The whole world, login can be added&lt;/td&gt;
      &lt;td&gt;Unstable access from within China; not suitable for streaming video&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Tailscale / ZeroTier&lt;/td&gt;
      &lt;td&gt;Your devices form a virtual LAN&lt;/td&gt;
      &lt;td&gt;Only your logged-in devices&lt;/td&gt;
      &lt;td&gt;An App must be installed on each device&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;If the services are only for yourself and your family, &lt;strong&gt;I recommend the last one&lt;/strong&gt;. I use Tailscale, and the personal free plan currently allows &lt;strong&gt;6 users, with unlimited devices&lt;/strong&gt;. At home it automatically connects directly over the LAN, and when you’re out and about you can still connect back. Remote desktop works through it too.&lt;/p&gt;

&lt;h2 id=&quot;my-recommendation-a-private-url-only-you-can-open&quot;&gt;My recommendation: a private URL only you can open&lt;/h2&gt;

&lt;p&gt;Adding your own domain on top of Tailscale gives you a very comfortable setup:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Step&lt;/th&gt;
      &lt;th&gt;What to use&lt;/th&gt;
      &lt;th&gt;Cost&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Domain&lt;/td&gt;
      &lt;td&gt;A domain bought from any registrar, with DNS managed by Cloudflare&lt;/td&gt;
      &lt;td&gt;Non-&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.com&lt;/code&gt; domains are super cheap&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Resolution&lt;/td&gt;
      &lt;td&gt;One wildcard record pointing to the server’s Tailscale address&lt;/td&gt;
      &lt;td&gt;Free&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Certificate&lt;/td&gt;
      &lt;td&gt;Let’s Encrypt, DNS validation&lt;/td&gt;
      &lt;td&gt;Free&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Entry&lt;/td&gt;
      &lt;td&gt;Caddy, routes requests to each service by domain&lt;/td&gt;
      &lt;td&gt;Free&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Connection&lt;/td&gt;
      &lt;td&gt;Tailscale&lt;/td&gt;
      &lt;td&gt;Free for personal use&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The result: I open &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mini.uulili.com&lt;/code&gt; and it’s the workbench, and clicking into each service gives it its own address and SSL padlock. This domain resolves publicly, but it resolves to an address inside Tailscale, so devices not on my Tailscale network &lt;strong&gt;can’t connect even if they try&lt;/strong&gt;.&lt;/p&gt;

&lt;h2 id=&quot;homelab-desk-let-ai-set-it-up-in-one-sentence&quot;&gt;homelab-desk: let AI set it up in one sentence&lt;/h2&gt;

&lt;p&gt;The workbench project is called homelab-desk, and it’s already &lt;strong&gt;open source&lt;/strong&gt; on GitHub under the MIT license:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;GitHub project address&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;https://uulili.com/img/mp008-github.jpg&quot; alt=&quot;homelab-desk&apos;s GitHub homepage&quot; /&gt;&lt;/p&gt;

&lt;p&gt;It does these things:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Deployment overview&lt;/strong&gt;: reads your service list, generates the Caddy config, requests certificates, allows only Tailscale, and installs itself as a background service that starts on boot. It can be run repeatedly — just run it again after changing the list.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Real-time dashboard&lt;/strong&gt;: whether each service is online, how fast it responds, and the last 1 hour of records; the machine’s CPU (every core), memory composition and pressure, disk and network, and with plugins also temperature, GPU and UPS.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Pick a style&lt;/strong&gt;: 4 themes, each with light and dark versions; see the next section for how they look.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Recommended services&lt;/strong&gt;: a built-in catalog covering media, photos, passwords, ad blocking, automation, AI, downloads, reading and more, 30 items in total. Each item says who it’s for, how to install it, and what to watch out for behind a reverse proxy.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Daily checkup&lt;/strong&gt;: checks each service and how many days the certificate has left, and can be hooked up to your own notification channel.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The pitfalls I ran into above, &lt;strong&gt;the project handles them all&lt;/strong&gt;. For example, for the restriction on listening on 443, it switches to allowing only Tailscale by source; the online check is done on the server, avoiding the browser’s cross-origin false positives; services like Home Assistant that need extra configuration are spelled out directly in the catalog.&lt;/p&gt;

&lt;h2 id=&quot;i-also-made-a-few-different-styles-along-the-way&quot;&gt;I also made a few different styles along the way&lt;/h2&gt;

&lt;p&gt;The workbench has &lt;strong&gt;4 themes&lt;/strong&gt; in total: Glass, Apple’s Liquid Glass, Minimal and Terminal, each with light and dark versions, switchable from the top right corner of the page.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/mp008-img3.jpg&quot; alt=&quot;4 themes: Glass, Liquid Glass (light), Minimal, Terminal (demo data)&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;how-to-use-it&quot;&gt;How to use it&lt;/h2&gt;

&lt;p&gt;Clone the project, link the skill inside it to Claude Code or Codex, then say one sentence to it:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;Say to Claude Code or Codex&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;Help me set up a home server workbench with homelab-desk&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It will check the prerequisites, walk you through creating a Token and DNS records in Cloudflare, deploy everything, and finally verify it. &lt;strong&gt;Adding a service later is also one sentence&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;Adding a service later&lt;/strong&gt;&lt;/p&gt;

  &lt;p&gt;Add Immich&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you don’t want to use AI, you can also manually run the 4 commands &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;.&lt;/p&gt;

&lt;p&gt;Why not make it Docker? Docker on Mac runs in a virtual machine, so it can’t get the Tailscale address, and it can’t read the host’s temperature or GPU. The project itself is just Caddy, a small Python service and a static page, so running it natively is simpler. Your services can run anywhere, including in Docker; the project only cares about “address:port”.&lt;/p&gt;

&lt;p&gt;Homepage, Homarr and Dashy handle navigation pages, and Uptime Kuma handles monitoring. homelab-desk manages &lt;strong&gt;the whole chain from zero to usable&lt;/strong&gt;. If you’re already using them, you can add them as services too.&lt;/p&gt;

&lt;h2 id=&quot;who-its-for&quot;&gt;Who it’s for&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;You already have an always-on Mac running a few services: use it directly.&lt;/li&gt;
  &lt;li&gt;You’re on Linux: the adaptation code is already written, but hasn’t been verified on real hardware yet, so testing and feedback are welcome.&lt;/li&gt;
  &lt;li&gt;You don’t have a server yet: pick one or two services you want from the table above, and a single Mac mini can run them.&lt;/li&gt;
  &lt;li&gt;You want to expose services to friends and have them open in a browser directly, without Tailscale: &lt;strong&gt;this project doesn’t handle that&lt;/strong&gt;. That needs a public server and user management, and the security issues alone are enough to give you a headache.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The prerequisites are 3 things: a domain hosted on Cloudflare, Tailscale installed and logged in, and Claude Code or Codex (not needed for manual deployment).&lt;/p&gt;

&lt;p&gt;To sum up: the value of a home server is in those services, and &lt;strong&gt;the workbench lets you keep them manageable&lt;/strong&gt;. Project address once more, and I’d love a star:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;GitHub project address&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;If you run into problems while using it, feel free to leave a comment or open an issue on GitHub. I sincerely hope you have few liabilities and many assets.&lt;/p&gt;

&lt;h2 id=&quot;blog-edition-extras&quot;&gt;Blog edition extras&lt;/h2&gt;

&lt;p&gt;For a few of the services on the workbench, the blog has separate hands-on posts:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Local large model: &lt;a href=&quot;https://uulili.com/2026/10/17/小钢炮-Mac-mini-跑-27B-最快-35-token-s/&quot;&gt;Mac mini running 27B, up to 35 token/s&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;Local video generation (H3 Studio): &lt;a href=&quot;https://uulili.com/2026/10/09/Mac-mini-跑-AI-视频-内存占多少-生成要多久/&quot;&gt;Running AI video on a Mac mini: how much memory, how long to generate?&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;Voiceover (TTS mixing desk): &lt;a href=&quot;https://uulili.com/2026/10/29/技术笔记-Mac-mini-本地微调-Qwen3-TTS-声音/&quot;&gt;Tech notes: locally fine-tuning Qwen3-TTS voices on a Mac mini&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Sat, 31 Oct 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/en/2026/10/31/mac-mini-home-server-workbench-open-source/</link>
        <guid isPermaLink="true">https://uulili.com/en/2026/10/31/mac-mini-home-server-workbench-open-source/</guid>
        
        <category>home server</category>
        
        <category>Mac mini</category>
        
        <category>homelab</category>
        
        <category>Tailscale</category>
        
        <category>open source</category>
        
      </item>
    
      <item>
        <title>Technical notes: Local fine-tuning of Qwen3-TTS voices on a Mac mini</title>
        <description>&lt;blockquote&gt;
  &lt;p&gt;This is a complete record for my own reference: next time I train a new voice, or pick this up again in a new conversation, read this first. All the code is in &lt;a href=&quot;https://github.com/shisaq/qwen3-tts-console&quot;&gt;qwen3-tts-console&lt;/a&gt; (the web page is in the root directory, the training pipeline is in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;finetune/&lt;/code&gt;). In this post the two people being trained are called speaker A and B.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;summary-at-a-glance&quot;&gt;Summary at a glance&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Item&lt;/th&gt;
      &lt;th&gt;Value&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Machine&lt;/td&gt;
      &lt;td&gt;Mac mini, M5 Pro, 64GB; GPU can use up to 51.8GB (81%)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Reason&lt;/td&gt;
      &lt;td&gt;Zero-shot cloning from a 10-second reference clip didn’t match the person’s timbre and accent&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Source material&lt;/td&gt;
      &lt;td&gt;A: 6 videos (about 24 minutes) + 1 conversation of 23 minutes; B: 13 videos (about 42 minutes) + the same conversation&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Automatic processing&lt;/td&gt;
      &lt;td&gt;About 90 minutes of material, cut, transcribed and speaker-filtered in a dozen or so minutes&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;After manual review&lt;/td&gt;
      &lt;td&gt;A 165 segments / 18.0 minutes; B 361 segments / 38.9 minutes (review took about 1.5 hours)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Training (1.7B, 10 epochs)&lt;/td&gt;
      &lt;td&gt;A 17 minutes, B 37 minutes, peak memory about 39–41GB&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Voiceprint similarity&lt;/td&gt;
      &lt;td&gt;A: 0.72 → 0.84; B: 0.57 → 0.82 (zero-shot → best epoch)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Character error rate&lt;/td&gt;
      &lt;td&gt;Below 1% for all versions&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Deployment&lt;/td&gt;
      &lt;td&gt;A uses epoch 8, B uses epoch 10; generating 5 seconds of speech takes about 2 seconds, loading takes 0.5 seconds, and each model is about 3GB&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Low memory&lt;/td&gt;
      &lt;td&gt;0.6B with 20 layers frozen: peak 10.6GB, similarity 0.83, so a 16GB machine should be able to run it&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;overall-structure&quot;&gt;Overall structure&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;Video ─(extract audio yourself first)─▶ wav
  ─ prep_data.py   FunASR transcription + cut into 2–10 seconds + CAM++ speaker filtering      (.venv: torch + funasr)
  ─ review.py      web review: listen, fix text, keep/discard              (same as above)
  ─ build_train.py Qwen3-TTS-Tokenizer-12Hz encodes into speech tokens  (.venv-train: qwen-tts + torch)
  ─ sft.py         fine-tune on the Base model, save as custom_voice checkpoint (same as above, MPS)
  ─ gen_samples.py convert each epoch to MLX 8bit, generate sample audio together with zero-shot   (the web page&apos;s venv: mlx-audio)
  ─ score_samples.py similarity, character error rate, speaking rate; pick by ear on the comparison page       (.venv)
  ─ ttsd&apos;s tuned_voices config ─▶ appears in &quot;My Voices&quot; on the web page
&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;The 3 Python environments are deliberately separated: FunASR, qwen-tts and mlx-audio each pin different dependency versions, and mixing them tends to cause conflicts. Tested versions: funasr 1.4.16, modelscope 1.40.2, qwen-tts 0.1.1, torch 2.14.1; mlx-audio uses the commit pinned in the web page’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;requirements.txt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The web UI itself (ttsd) is a single-file Python service, kept running by launchd and reverse-proxied by Caddy. Models run as 8bit quantized versions through mlx-audio and are unloaded automatically after 10 minutes idle.&lt;/p&gt;

&lt;h2 id=&quot;data-preparation&quot;&gt;Data preparation&lt;/h2&gt;

&lt;h3 id=&quot;transcription-funasr-not-whisper&quot;&gt;Transcription: FunASR, not 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;: accurate for Chinese, and gives a timestamp for each character. 1.8 minutes of audio transcribes in 12 seconds. English words are often misspelled (Gartner → garten), so segments that are mostly English are discarded outright (more letters than Chinese characters).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;fsmn-vad / ct-punc&lt;/strong&gt;: sentence segmentation and punctuation.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;CAM++&lt;/strong&gt;: voiceprints. Measured: segments from the same person score 0.72–0.75, different people 0.08–0.36, and the two people’s reference recordings score 0.31 against each other. Threshold: ≥ 0.55 keep, 0.45–0.55 tentative, below that discard. The host, interruptions and the other party in the conversation all get filtered out this way.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;A FunASR 1.4.16 bug&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sentence_info&lt;/code&gt; puts each punctuation mark one character late (“开发者。大家好，很高兴” becomes “开发者大。家好很，高兴”), so cutting segments with it breaks words in the middle. The full text &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;text&lt;/code&gt; and per-character timestamps &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timestamp&lt;/code&gt; are correct, so I re-cut it myself: tokenize by &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[A-Za-z&apos;]+|punctuation|single Chinese character&lt;/code&gt;, the token count and timestamp count match one-to-one (measured to match exactly), break sentences at punctuation, and take the speaker label from the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sentence_info&lt;/code&gt; segment with the most temporal overlap.&lt;/p&gt;

&lt;h3 id=&quot;cutting-rules&quot;&gt;Cutting rules&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;Sentences from the same speaker with a gap ≤ 0.5 seconds are merged into 2–10-second segments; once a segment reaches 4 seconds and hits a period, cut it off early.&lt;/li&gt;
  &lt;li&gt;Leave 0.08 / 0.15 seconds at the start and end, but don’t cross the midpoint of the neighbouring sentence.&lt;/li&gt;
  &lt;li&gt;Normalize the loudness of the voiced parts to -20 dBFS, with peaks no higher than -1 dBFS.&lt;/li&gt;
  &lt;li&gt;The noise floor is the 10th percentile of 50ms frame energy over the whole audio, with a lower bound of -90 dB (synthesized or denoised audio can contain digital silence; without a lower bound the SNR comes out as 150-something).&lt;/li&gt;
  &lt;li&gt;Two edited versions of the same event will have duplicate sentences: if text similarity &amp;gt; 0.8 and they come from different files, keep the one with the higher SNR.&lt;/li&gt;
  &lt;li&gt;Automatic flags: English, filler words (呃嗯额, “uh/um”), repetitions/slips of the tongue, runs of Chinese numerals — look at these first when reviewing.&lt;/li&gt;
  &lt;li&gt;Normalize the casing of English terms (ai → AI, gpt → GPT); misrecognized product names go into the configured &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;terms&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;manual-review&quot;&gt;Manual review&lt;/h3&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/tn-qwen3tts-review.jpg&quot; alt=&quot;Review page (demo data synthesized with a system voice; this is the actual interface)&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Changes are written to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;edits.json&lt;/code&gt; (by segment id), so re-running data preparation doesn’t lose them. Of A’s 198 segments I changed the text of 55 (29 typos, 19 with English words, 7 punctuation only) and dropped 33. Of B’s 469 segments I changed 109 and dropped 108, which took about 1.5 hours. My impression is that the automatic recognition is already quite accurate, and only a few sentence breaks were wrong.&lt;/p&gt;

&lt;h2 id=&quot;training-script-4-problems-in-the-official-sft_12hzpy&quot;&gt;Training script: 4 problems in the official sft_12hz.py&lt;/h2&gt;

&lt;p&gt;First, compute the loss on real clips with the &lt;strong&gt;untrained&lt;/strong&gt; Base model: with the right settings, the loss should be very low, since a pretrained model can already predict real speech. The first smoke test gave a loss of 7.5, close to random guessing (vocabulary 3072; a uniform distribution would give about 8.0), so I compared it against the inference code to track it down:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Setting&lt;/th&gt;
      &lt;th&gt;talker initial loss&lt;/th&gt;
      &lt;th&gt;sub-model initial loss&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Official script&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;Labels shifted only once&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;Plus &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;Sub-model uses the previous frame’s hidden state; loss is no longer shifted&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;Labels shifted twice&lt;/strong&gt; (&lt;a href=&quot;https://github.com/QwenLM/Qwen3-TTS/issues/179&quot;&gt;QwenLM/Qwen3-TTS#179&lt;/a&gt;): the talker’s HF loss shifts internally once, and the script also does &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[:, 1:]&lt;/code&gt; by hand. The model learns to predict the token one frame away, and the symptom is that speech gets faster the longer you train. Fix: don’t slice the input or labels; let the loss function do the shifting itself.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Missing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;text_projection&lt;/code&gt;&lt;/strong&gt;: at inference, all text vectors (role markers, tts_pad/bos/eos, body text) first go through &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;talker.text_projection&lt;/code&gt;, but the training script used the output of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;text_embedding&lt;/code&gt; directly. Both are 2048-dimensional, so no error is raised, and the model kept learning from inputs it never sees at inference.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Sub-model also shifted twice&lt;/strong&gt;: the logits returned by &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;forward_sub_talker_finetune&lt;/code&gt; are already aligned with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;codec_ids[:, 1:]&lt;/code&gt;, and the built-in loss shifts them one more position; at inference, the code predictor gets the hidden state of “the position that predicted this frame” (t−1). Fix: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hidden[:, :-1][codec_mask[:, 1:]]&lt;/code&gt; paired with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;codec_ids[:, 1:][codec_mask[:, 1:]]&lt;/code&gt;, computing the cross-entropy myself without the shift.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Weights stored in bf16&lt;/strong&gt;: at a learning rate of 2e-6, AdamW’s per-step update is smaller than what bf16 can represent, so most of it gets rounded away. Changed to fp32 weight storage with bf16 autocast for compute.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Other settings: learning rate 2e-6, 5% warmup + cosine decay, batch 2 × gradient accumulation 4, gradient clipping 1.0; the speaker vector is the average voiceprint of 64 training clips (more stable than a single reference recording), written into row 3000 of &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;codec_embedding&lt;/code&gt;; checkpoints are saved as bf16, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tts_model_type&lt;/code&gt; changed to &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; is just this one person.&lt;/p&gt;

&lt;p&gt;The training data uses the prefix with no language tag (nothink), so &lt;strong&gt;generation must use auto as the language&lt;/strong&gt;; specifying chinese switches to a prefix the model has never seen. The Base model never learned instructions, so the fine-tuned voice &lt;strong&gt;does not support tone descriptions&lt;/strong&gt;.&lt;/p&gt;

&lt;h3 id=&quot;memory-the-first-run-hit-swap&quot;&gt;Memory: the first run hit swap&lt;/h3&gt;

&lt;p&gt;Full training of 1.6B parameters (the text embedding layer is trained too) used 44GB. At epoch 2, saving a checkpoint copied the weights once more, so the system started swapping and each step went from 1.2 seconds to 15 seconds. Changed 3 things:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Freeze the text embedding layer&lt;/strong&gt;: 311 million parameters, 150k tokens, but 18 minutes of audio only uses a few hundred of them. Saves about 4GB, and reduces overfitting a little too (details in the next section).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;When saving a checkpoint, move each tensor to the CPU first, then convert to bf16&lt;/strong&gt;, and call &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;torch.mps.empty_cache()&lt;/code&gt; after saving.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Store frozen parameters in bf16&lt;/strong&gt; (not updated, so fp32 isn’t needed).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After that, 1.7B full training held steady at 37–41GB and 1.2 seconds per step.&lt;/p&gt;

&lt;h3 id=&quot;is-freezing-the-text-embedding-layer-risky&quot;&gt;Is freezing the text embedding layer risky?&lt;/h3&gt;

&lt;p&gt;Freezing doesn’t remove the layer: it still takes part in computation; it just isn’t updated during training and keeps the Base model’s original values.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it handles.&lt;/strong&gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;talker.model.text_embedding&lt;/code&gt; is a 151936 × 2048 lookup table, one row per token, and it only answers “which character is this”. Everything after it that decides “how this person reads it” is trained:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Part&lt;/th&gt;
      &lt;th&gt;Shape / size&lt;/th&gt;
      &lt;th&gt;During training&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; (recognizing characters)&lt;/td&gt;
      &lt;td&gt;151936 × 2048, 311 million parameters&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;frozen&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; (translates text vectors for the speech model)&lt;/td&gt;
      &lt;td&gt;two layers 2048 × 2048&lt;/td&gt;
      &lt;td&gt;trained&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;talker 28 layers (how to read, intonation, rhythm)&lt;/td&gt;
      &lt;td&gt;about 1.4 billion parameters&lt;/td&gt;
      &lt;td&gt;trained&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; (speech codes, where the speaker vector lives)&lt;/td&gt;
      &lt;td&gt;3072 × 2048&lt;/td&gt;
      &lt;td&gt;trained&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;code predictor (15 layers of speech codes after each frame)&lt;/td&gt;
      &lt;td&gt;about 170 million parameters&lt;/td&gt;
      &lt;td&gt;trained&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;speaker encoder (only used to compute the speaker vector)&lt;/td&gt;
      &lt;td&gt;–&lt;/td&gt;
      &lt;td&gt;frozen&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;&lt;strong&gt;Why freezing is actually more stable with little data.&lt;/strong&gt; Without freezing, at each step only the few hundred rows that appear in the training text get gradients in the embedding layer (AdamW barely moves rows with zero gradient), and after training, “characters it has seen” and “characters it hasn’t” slowly become inconsistent in the model’s eyes: in a new sentence, some characters may sound like him and some may not. With freezing, the 150k tokens stay as they are and remain consistent with each other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No loss of precision.&lt;/strong&gt; The Base model’s released weights are already bf16 (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;text_embedding&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;text_projection&lt;/code&gt; both), so storing the frozen part in bf16 is bit-for-bit identical to the original weights; checkpoints are saved as bf16 anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The real limitation.&lt;/strong&gt; A frozen embedding layer can’t give a word a “new identity”. If his pronunciation of a word is very particular (a fixed pronunciation for a product name, the reading of a character in a dialect), only the 28 layers behind it can learn that from context; that’s enough most of the time, but if after training a word &lt;strong&gt;still comes out wrong every time&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;First change how it’s written in the script: a homophone, split it apart, or add a space—that’s the easiest way;&lt;/li&gt;
  &lt;li&gt;Once you have more data (an hour or two), add &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--train_text_embedding&lt;/code&gt; when training again, it uses about 4GB more. New languages and new dialects fall under this too.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The limits of the evidence.&lt;/strong&gt; What supports “character recognition isn’t damaged”: after fine-tuning, all versions had a character error rate below 1% on sentences outside the training set, including ones with English words like AI Agent and RPA, and similarity also improved as expected. What I haven’t done: a strict frozen vs. non-frozen comparison. Both voices that went live were trained with freezing, and the non-frozen version only ran for 1 epoch (memory blew up), so there’s no direct data showing that “the two perform the same”. To confirm: same data, batch 1 with text embedding training enabled, train for 6 epochs, compare similarity and character error rate, and listen specifically to a few proper nouns.&lt;/p&gt;

&lt;h2 id=&quot;how-to-judge-whether-it-sounds-alike&quot;&gt;How to judge whether it sounds alike&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Qwen’s built-in voiceprint encoder can’t be used for evaluation&lt;/strong&gt;: zero-shot cloning’s similarity to the reference recording was 0.991, but another male voice also scored 0.974. The 8bit and bf16 clones both scored 0.991 (I couldn’t hear a difference either, so 8bit is what I use live).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Use CAM++ and compare against the average voiceprint from 40 real clips of the person&lt;/strong&gt;, not a single reference recording.&lt;/li&gt;
  &lt;li&gt;5 sentences outside the training set × 2 random seeds, 10 samples per version; also check the transcription character error rate and speaking rate (a real person is about 5 characters/second; a noticeably faster rate means the label-shift problem).&lt;/li&gt;
  &lt;li&gt;The score is compared against the training recordings, and the recording environment (microphone, room) can also be learned, so &lt;strong&gt;the final pick is by ear&lt;/strong&gt;. The comparison page puts all versions of the same sentence side by side.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;results&quot;&gt;Results&lt;/h2&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/tn-qwen3tts-epochs.png&quot; alt=&quot;Voiceprint similarity per epoch&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/tn-qwen3tts-loss.png&quot; alt=&quot;Training loss per epoch&quot; /&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;2 epochs of training gave most of the improvement; after that it was basically flat.&lt;/li&gt;
  &lt;li&gt;Training loss dropped to 0.1–0.2 by epoch 10, and it started memorizing: A’s epoch 10 score fell back, and a few samples had a click. So I save a version every 2 epochs.&lt;/li&gt;
  &lt;li&gt;For A’s epochs 8 and 10, I also tested 8 more sentences × 3 seeds each: 0.815 vs 0.827, a tie. In the end I picked both by ear from the seed 11 group: A uses epoch 8, B uses epoch 10, and the other epoch is kept as a backup.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;low-memory-options&quot;&gt;Low-memory options&lt;/h2&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/tn-qwen3tts-memory.png&quot; alt=&quot;Peak memory and similarity for 6 epochs of training&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Training for 6 epochs is enough. Using the same 18 minutes of A’s recordings, batch 1 × accumulation 8, measured on a 64GB machine:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Model · setting&lt;/th&gt;
      &lt;th&gt;Peak&lt;/th&gt;
      &lt;th&gt;Time&lt;/th&gt;
      &lt;th&gt;Similarity&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;1.7B default (batch 2, epoch 6 of a 10-epoch run)&lt;/td&gt;
      &lt;td&gt;about 39GB&lt;/td&gt;
      &lt;td&gt;about 10 minutes&lt;/td&gt;
      &lt;td&gt;0.842&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;1.7B freeze 20 layers&lt;/td&gt;
      &lt;td&gt;18.2GB&lt;/td&gt;
      &lt;td&gt;7 minutes&lt;/td&gt;
      &lt;td&gt;0.804&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;1.7B freeze 24 layers&lt;/td&gt;
      &lt;td&gt;15.6GB&lt;/td&gt;
      &lt;td&gt;6 minutes&lt;/td&gt;
      &lt;td&gt;0.785&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;1.7B freeze 26 layers&lt;/td&gt;
      &lt;td&gt;13.6GB&lt;/td&gt;
      &lt;td&gt;5.5 minutes&lt;/td&gt;
      &lt;td&gt;0.789&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;0.6B no freezing&lt;/td&gt;
      &lt;td&gt;16.1GB&lt;/td&gt;
      &lt;td&gt;7 minutes&lt;/td&gt;
      &lt;td&gt;0.808&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;0.6B freeze 14 layers&lt;/td&gt;
      &lt;td&gt;11.9GB&lt;/td&gt;
      &lt;td&gt;5 minutes&lt;/td&gt;
      &lt;td&gt;0.827&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;0.6B freeze 20 layers&lt;/td&gt;
      &lt;td&gt;10.6GB&lt;/td&gt;
      &lt;td&gt;5 minutes&lt;/td&gt;
      &lt;td&gt;0.829&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Just changing to batch 1 barely saves anything (35.6 → 33.3GB); most of the memory goes to the weights, gradients and Adam states of the trainable parameters (fp32, 16 bytes per parameter).&lt;/p&gt;

&lt;p&gt;macOS doesn’t give all of the memory to the GPU: on this machine &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;torch.mps.recommended_max_memory()&lt;/code&gt; is 51.8GB (81%). Extrapolating at the same ratio (on the optimistic side; smaller machines may have a lower ratio and slower chips; none of this was measured):&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Machine&lt;/th&gt;
      &lt;th&gt;Roughly available to GPU&lt;/th&gt;
      &lt;th&gt;Recommendation&lt;/th&gt;
      &lt;th&gt;Left over&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 default&lt;/td&gt;
      &lt;td&gt;about 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 freeze 20 layers (default is right at the limit)&lt;/td&gt;
      &lt;td&gt;about 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 freeze 20 layers&lt;/td&gt;
      &lt;td&gt;about 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 freeze 14 layers&lt;/td&gt;
      &lt;td&gt;about 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 freeze 20 layers&lt;/td&gt;
      &lt;td&gt;about 2GB&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Freezing some layers of 0.6B actually scores a bit higher than the full 0.6B model; each group has only 10 samples, so a difference within ±0.02 is within error, and I haven’t compared 0.6B by ear either.&lt;/p&gt;

&lt;h2 id=&quot;deployment&quot;&gt;Deployment&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;# Convert the chosen epoch to MLX 8bit and put it in the models directory&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;Display name·fine-tuned&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; must match the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--speaker_name&lt;/code&gt; used during training. After the change, restart with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;launchctl kickstart -k gui/$(id -u)/&amp;lt;label&amp;gt;&lt;/code&gt;, then use the API to create a temporary script, generate one sentence to verify, and delete the script.&lt;/p&gt;

&lt;p&gt;Changes to ttsd: the voice id is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tuned:&amp;lt;id&amp;gt;&lt;/code&gt;, each fine-tuned voice gets its own model slot; at generation time the language is fixed to auto and no tone is passed; in the web UI it’s under “My Voices”, and the tone box greys out when it’s selected.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/tn-qwen3tts-webui.jpg&quot; alt=&quot;Fine-tuned voices in the web UI (names changed to &amp;quot;Speaker A&amp;quot;)&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;pitfalls&quot;&gt;Pitfalls&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Safari: no sound when I hit play, downloads work fine&lt;/strong&gt;: the page uses Web Audio for playback. The server and code were both fine; restarting Safari fixed it. There’s currently no error message when playback fails; I could add one later.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;ttsd’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;safe_id&lt;/code&gt; only accepts hex&lt;/strong&gt;: fine-tuned voice IDs (e.g. English names) are rejected as invalid, and the voice doesn’t appear. I changed it to “must be a key already in the config”; a newly added unit test caught it.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Speaker keys must be in English&lt;/strong&gt;: the key becomes the speaker ID in the model and the directory name. The open-source version enforces ASCII.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;HF download gets stuck at the last step&lt;/strong&gt;: the file is already downloaded (the size matches) but gets stuck at the checksum and rename step. The temp filename is the sha256 itself; compute it yourself, check that it matches, then &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mv&lt;/code&gt; it into place.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pkill -f&lt;/code&gt; killed the wrong process&lt;/strong&gt;: the match string used to kill the download process also appeared in another background command waiting for the download, so that one got killed too. Killing by PID is safer.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;zsh doesn’t split variables on spaces&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;set -- $var&lt;/code&gt; doesn’t split in zsh; write out the loop arguments or use an array.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;mlx-audio reads &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sys.argv&lt;/code&gt;&lt;/strong&gt;: my script passed the model path on the command line, and mlx-audio took it as the model name. I moved it into the script.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Speaker encoder dtype on MPS&lt;/strong&gt;: when the model is bf16, cast the mel input to the same dtype.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Redacting screenshots&lt;/strong&gt;: before screenshotting, I renamed things and deleted content showing real people on the page using JS; I re-ran the review page with demo data synthesized from system voices before capturing. When Playwright renders charts, the double quotes in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;font-family&lt;/code&gt; truncate the HTML &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;style=&quot;…&quot;&lt;/code&gt;, so the font reverts to the default serif.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;how-to-reproduce&quot;&gt;How to reproduce&lt;/h2&gt;

&lt;p&gt;The shortest path (&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; added on 2026-10-29; video files can go straight into the assets folder, and non-wav files are decoded with 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;# after filling it in&lt;/span&gt;
./run.sh prep                 &lt;span class=&quot;c&quot;&gt;# slice + transcribe + pick speakers; open the review page when done&lt;/span&gt;
./run.sh train alice          &lt;span class=&quot;c&quot;&gt;# encode → train → sample audio → score; on low memory add --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 to pick a round&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;display name&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&lt;span class=&quot;s1&quot;&gt;&apos;s launchd label&amp;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;deploy&lt;/code&gt; copies the model into the console’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;models_dir/tuned/&lt;/code&gt;, backs it up and then edits &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;tuned_voices&lt;/code&gt; (the speaker ID is read from the checkpoint), and optionally restarts it. To run it manually, step by step:&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;# speaker, reference recording and transcript, material directory&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, no login, only open on the local machine; use ssh -L remotely&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;#   low memory: --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;# then open /compare/alice and listen&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;not-verified-yet&quot;&gt;Not verified yet&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;Actual memory usage and speed on 16GB / 24GB / 32GB machines (all extrapolated from the 64GB peak and 81%).&lt;/li&gt;
  &lt;li&gt;What the 0.6B fine-tuned version sounds like; the web page currently loads the 1.7B preset/clone models by default. The 0.6B fine-tuned version can convert and generate, but it isn’t live yet.&lt;/li&gt;
  &lt;li&gt;Whether a few more hours of data would improve it further; how the proportion of clips under 10 seconds affects the accent.&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Thu, 29 Oct 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/en/2026/10/29/mac-mini-qwen3-tts-voice-finetuning/</link>
        <guid isPermaLink="true">https://uulili.com/en/2026/10/29/mac-mini-qwen3-tts-voice-finetuning/</guid>
        
        <category>Qwen3-TTS</category>
        
        <category>voice cloning</category>
        
        <category>fine-tuning</category>
        
        <category>Mac mini</category>
        
        <category>MLX</category>
        
      </item>
    
      <item>
        <title>How Much Mac mini Memory? M6 and M5 Pro Buying Guide</title>
        <description>&lt;p&gt;What’s the key to choosing a Mac mini? The CPU already crushes everything in its class, and you can add storage yourself later; but &lt;strong&gt;the memory is soldered to the motherboard&lt;/strong&gt;, and how much memory you get is closely tied to what you use it for as a server.&lt;/p&gt;

&lt;p&gt;Buy too little and the models you want to run later won’t fit, so you have to replace the machine. Buy too much and you’re using a sledgehammer to crack a nut. And the gap between configurations can be over ¥10,000. If you’re thinking of buying one too: how much memory should you get, which chip, and roughly how much will it cost?&lt;/p&gt;

&lt;p&gt;Below, first, the recommended configurations for different uses; the full configuration and price tables are at the end of the article.&lt;/p&gt;

&lt;h2 id=&quot;recommendations-first&quot;&gt;Recommendations first&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;Just a home server, &lt;strong&gt;no large models: 16GB&lt;/strong&gt;. the M6 16GB+512GB is ¥8,499, or a used M4 for ¥3,000–4,000.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Want to try local models: M6 32GB&lt;/strong&gt;, from ¥9,999. 27B runs but very slowly; MoE models are fast and good enough for writing articles, but overall quality is not as good as dense models.&lt;/li&gt;
  &lt;li&gt;Want to use 27B daily, &lt;strong&gt;long conversations: M5 Pro 48GB&lt;/strong&gt;, ¥19,749.&lt;/li&gt;
  &lt;li&gt;Also want to run other services alongside, or want to try &lt;strong&gt;larger models&lt;/strong&gt; later: &lt;strong&gt;M5 Pro 64GB&lt;/strong&gt;, from ¥20,499.&lt;/li&gt;
  &lt;li&gt;Around ¥20,000 and you care more about speed: &lt;strong&gt;base Mac Studio at ¥19,999&lt;/strong&gt;, cheaper than the mini 64GB but with only 36GB of memory. With a budget of ¥27,000, the Studio 64GB is better value than the mini 64GB.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Once the use is settled, &lt;strong&gt;512GB of storage is enough&lt;/strong&gt;; 256GB is really too small. You don’t have to go for the bigger capacity all at once — spend what you save on an external drive later, or have a professional service mod the internal drive.&lt;/p&gt;

&lt;p&gt;For local models I’ll use the hottest model right now, Qwen3.8-27B, as the example; the engine is oMLX. My machine is an M5 Pro 64GB, which has the most headroom, so I can measure how much memory the model actually uses under different settings. For the other tiers I don’t have the actual machine, so I calculated from the memory peaks I measured; for M6 speed I used publicly available measured results.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/mp007-qwen-omlx.png&quot; alt=&quot;Qwen3.8-27B (7.45 million downloads on Hugging Face last month) and oMLX (22,900 GitHub stars)&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;local-model-memory-usage-reference&quot;&gt;Local model memory usage reference&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;27B 4-bit (Q4) short conversation: peak 17.2GB&lt;/li&gt;
  &lt;li&gt;27B Q4 up to 160K context: peak about 38GB&lt;/li&gt;
  &lt;li&gt;27B 8-bit (Q8) short conversation: peak 29.0GB&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s just the model process itself. macOS itself, the browser and background services also use memory. So &lt;strong&gt;subtract the model peak from total memory&lt;/strong&gt; and see whether the remainder is enough for other programs’ daily use.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/mp007-img2.png&quot; alt=&quot;27B at 160K needs about 38GB; how much each machine has left and how much it falls short&quot; /&gt;&lt;/p&gt;

&lt;p&gt;When memory runs short, the Mac moves some content to disk (swap), and programs don’t crash right away. It looks like it runs. But I don’t count that as “usable”. &lt;strong&gt;Frequent swapping makes the server unstable&lt;/strong&gt;.&lt;/p&gt;

&lt;h2 id=&quot;best-option-for-a-personal-local-server-16gb&quot;&gt;Best option for a personal local server: 16GB&lt;/h2&gt;

&lt;p&gt;Don’t consider local large models on a 16GB machine. &lt;strong&gt;Its role is clear: a local server&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;My machine keeps Hermes (an AI assistant; on a 16GB machine it only uses cloud models), Jellyfin (my home media library) and Syncthing (syncing files between several devices) running all the time. 16GB handles these without strain.&lt;/p&gt;

&lt;p&gt;Other services that are good to keep running long-term:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Time Machine: other Macs at home back up automatically to this machine. It’s built into the system — just set a folder as the backup destination in Sharing settings. Backups eat disk space, so you’d usually attach an external drive with plenty of capacity.&lt;/li&gt;
  &lt;li&gt;AdGuard Home: blocks ad and tracking domains at the DNS layer; point your router at it and every device at home is covered. It only blocks by domain, so not all ads get removed.&lt;/li&gt;
  &lt;li&gt;FreshRSS: your own RSS reader, with read status shared between phone and computer.&lt;/li&gt;
  &lt;li&gt;Paperless-ngx: scan contracts, invoices and manuals into it, it OCRs them automatically, and later you can find the original just by searching the text. When you import a big batch of old files at once, OCR has a spike.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/mp007-img3.png&quot; alt=&quot;Services a 16GB Mac mini can run long-term&quot; /&gt;&lt;/p&gt;

&lt;p&gt;FreshRSS and Paperless-ngx usually run in Docker, which means spinning up an extra Linux virtual machine. Docker Desktop’s default memory limit for it is half the host, so 8GB on a 16GB machine. So &lt;strong&gt;add things one at a time&lt;/strong&gt;, and stagger tasks with spikes like OCR and video transcoding.&lt;/p&gt;

&lt;p&gt;The chip doesn’t matter at this tier. The M6 16GB has 153GB/s of bandwidth, a bit lower than the 170GB/s of the 24GB and 32GB models, but as a server you won’t need that difference. A used M4 is fine too.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/mp007-img4.png&quot; alt=&quot;Apple&apos;s site: M6 16GB model 153GB/s, 24GB and 32GB models 170GB/s, M5 Pro 307GB/s&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Since you’re giving up local large models, this tier suits clear-headed people who’ve committed to cloud models. You get top-tier cloud models plus a personal server that responds 24/7, and save a lot of money.&lt;/p&gt;

&lt;p&gt;Worth noting: although the base M6 is ¥2,500 more than the M4’s ¥4,499 back then, this 2nm chip is genuinely impressive.&lt;/p&gt;

&lt;h2 id=&quot;local-models-m6-32gb&quot;&gt;Local models: M6 32GB&lt;/h2&gt;

&lt;p&gt;Memory first. 27B Q4 short conversation peaks at 17.2GB, leaving 14.8GB out of 32GB. It fits. At 160K it needs about 38GB, already more than total memory, so context has to be kept short (estimated).&lt;/p&gt;

&lt;p&gt;Now speed. For every token the model outputs, it roughly has to read the weights from memory once, so &lt;strong&gt;output speed is heavily tied to memory bandwidth&lt;/strong&gt;. The M6 is 170GB/s and the M5 Pro 307GB/s, a 1.8x difference.&lt;/p&gt;

&lt;p&gt;I don’t have an M6, so I looked up other people’s measurements, all on 32GB models running Qwen3.8-27B 4-bit:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A German user’s public test on GitHub (LM Studio, GGUF format): 11–12 tokens per second writing English and German paragraphs, 17.5 writing code&lt;/li&gt;
  &lt;li&gt;A video review (using MLX directly): 8–9 per second&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Mine writes 300 characters at 25.9 per second. The engines and formats differ, so it’s not a strict comparison, but the gap is over 2x, larger than the bandwidth difference. The M6 has only a 12-core GPU versus 20 cores on mine, which may also matter (my guess).&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/mp007-omlx-27b-300.png&quot; alt=&quot;From the previous post: my machine running 27B with oMLX to write 300 characters, 25.9 tokens per second&quot; /&gt;&lt;/p&gt;

&lt;p&gt;At around 10 per second, asking a few questions occasionally is fine, but as a daily driver it’s on the slow side. So 27B on the M6 32GB is limited to just being able to run. &lt;strong&gt;For daily use it’s basically a no-go&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you want speed on the M6, switch to an MoE model. An MoE model has a large total parameter count, but each output token uses only a small part of it, so fewer weights are read and the bandwidth requirement is much lower.&lt;/p&gt;

&lt;p&gt;Take the 4-bit version of Qwen3.6-35B-A3B. There are two other people’s measurements on the M6 32GB; I also ran the same model’s 4-bit version on my machine with oMLX, using the same prompt types and parameters as the GitHub test:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Short inputs (German, English, code, JSON): M6 63–69 tokens per second, mine about 110, &lt;strong&gt;1.6–1.7x the M6&lt;/strong&gt;&lt;/li&gt;
  &lt;li&gt;After reading in about 20,000 tokens of material: M6 outputs 58.5 per second, mine 80.4; reading speed M6 1,215 per second, mine 2,176&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/mp007-moe.png&quot; alt=&quot;MoE 4-bit output speed, M5 Pro vs M6&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Bandwidth differs by 1.8x, and the short-input speed difference is close to that.&lt;/p&gt;

&lt;p&gt;Memory measurements on my machine:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Short input: peak about 21.5GB&lt;/li&gt;
  &lt;li&gt;Reading 20,000 tokens: about 23.7GB&lt;/li&gt;
  &lt;li&gt;Reading 65,000 tokens: about 24.7GB&lt;/li&gt;
  &lt;li&gt;Reading 130,000 tokens: about 27.0GB&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/mp007-moe-7.png&quot; alt=&quot;MoE 4-bit reading 140,000 characters, memory peak 27GB&quot; /&gt;&lt;/p&gt;

&lt;p&gt;No swap was used at any point. MoE memory grows very slowly when reading long material. Subtracting from 32GB (estimated): about 10GB left for short input, about 8GB at 20,000 tokens, only 5GB at 130,000 tokens. The user in Japan reported 19.7GB, lower than what I measured, possibly because of a different counting method.&lt;/p&gt;

&lt;p&gt;So an M6 32GB running MoE 4-bit &lt;strong&gt;can read long material too, leaving 8–10GB for the system&lt;/strong&gt; — enough for a browser and everyday office work. But you can only run this one model; running Docker, a media library and similar services at the same time probably means staggering them.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/mp007-omlx-moe-300.png&quot; alt=&quot;oMLX web chat: MoE 4-bit writing 300 characters at 106.8 tokens per second&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Of course MoE has a cost. On my mini it does about 110 tokens per second, 3–4x the 27B; but 35B-A3B activates only about 3B parameters per token, so &lt;strong&gt;quality is a bit worse than the 27B&lt;/strong&gt;. My worry is that it keeps putting out low-quality content, and cleaning up after it is harder work. So I still use 27B day to day. If speed matters more to you, MoE is still the first choice on a Mac.&lt;/p&gt;

&lt;p&gt;Another option is the M5 Pro 24GB at ¥12,999. The chip is faster, but it has 8GB less memory than the M6 32GB; 27B Q4 short conversation leaves 6.8GB, which gets tight once you open a few programs. Looking only at local models, &lt;strong&gt;this tier is a bit awkward&lt;/strong&gt;.&lt;/p&gt;

&lt;h2 id=&quot;using-27b-every-day-m5-pro-48gb-and-up&quot;&gt;Using 27B every day: M5 Pro 48GB and up&lt;/h2&gt;

&lt;p&gt;If you use local large models every day, you’ll inevitably have long conversations and feed in long documents. Then &lt;strong&gt;memory has to be sized for 160K&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;48GB: at 160K about 10GB left for the system and other programs — it runs, but it’s not roomy&lt;/li&gt;
  &lt;li&gt;64GB: about 26GB left (measured peak on my machine)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All M5 Pro tiers have 307GB/s of bandwidth, so the 48GB model’s output speed should be close to mine: 35.8 tokens per second answering “hello”, 25.9 per second writing 300 characters (the 48GB figure is estimated).&lt;/p&gt;

&lt;p&gt;Running MoE 4-bit is roomy too: reading 130,000 tokens peaks at about 27GB, leaving 21GB of 48GB.&lt;/p&gt;

&lt;h2 id=&quot;running-other-services-at-the-same-time-64gb&quot;&gt;Running other services at the same time: 64GB&lt;/h2&gt;

&lt;p&gt;This is my configuration: M5 Pro, 18-core CPU / 20-core GPU, 64GB of memory. It keeps Hermes, Jellyfin and local video generation with MiniMax H3 running at the same time.&lt;/p&gt;

&lt;p&gt;I set a 48GB cap on the model process, leaving 16GB for the system and other services. Even so, &lt;strong&gt;the model and H3 can’t run at the same time&lt;/strong&gt;. The model is about 38GB and H3 about 22GB, roughly 60GB together, leaving too little headroom for the system. I’ve tested it; they have to be staggered.&lt;/p&gt;

&lt;p&gt;So 64GB doesn’t mean everything can run at once either. Run them staggered and everything fits.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/mp007-img9.png&quot; alt=&quot;How 64GB is divided: only 27B, and 27B with H3 running together&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Why did I buy 64GB? When I buy a machine I plan to use it for a long time, and &lt;strong&gt;memory can’t be changed&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Apple’s ecosystem is moving fast on AI, and many local models — text, image, video, voice generation — are all heading toward compressing size as much as possible while keeping quality. The more memory, the better your ticket in. It also has to run everyday tasks, and I don’t want memory maxing out and swapping often, because then the server becomes unstable and can’t be called a server anymore.&lt;/p&gt;

&lt;h2 id=&quot;around-20000-mac-mini-or-mac-studio&quot;&gt;Around ¥20,000: Mac mini or Mac Studio&lt;/h2&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/mp007-mini-studio.jpg&quot; alt=&quot;Mac mini (left) and Mac Studio (right). Image: Kyu3a, Wikimedia Commons, CC BY-SA 4.0&quot; /&gt;&lt;/p&gt;

&lt;p&gt;At the ¥20,000 price point, the Mac Studio and the Mac mini cost the same:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Mac mini M5 Pro 48GB+1TB: ¥19,749, bandwidth 307GB/s&lt;/li&gt;
  &lt;li&gt;Mac Studio M5 Max 36GB+512GB: ¥19,999, bandwidth 460GB/s&lt;/li&gt;
  &lt;li&gt;Mac mini M5 Pro 64GB+512GB: ¥20,499, bandwidth 307GB/s&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/mp007-img11.png&quot; alt=&quot;Prices and memory bandwidth of the Mac Studio and Mac mini at the same price point&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The base Studio is ¥500 cheaper than the 64GB mini. It trades away part of the memory for a Max chip: 1.5x the bandwidth, but only 36GB of memory. Running a 27B Q4 model for short conversations leaves 18.8GB free, and it should be faster than mine, but a 160K chat needs about 38GB, which won’t fit. So &lt;strong&gt;if you have long conversations every day, the 48GB or 64GB mini is still the right choice&lt;/strong&gt;; if it’s mostly short questions and answers and you want it a bit faster, the base Studio is the better deal.&lt;/p&gt;

&lt;p&gt;Move up in budget and the difference becomes clearer:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Mac mini M5 Pro 64GB+1TB: ¥22,749, bandwidth 307GB/s&lt;/li&gt;
  &lt;li&gt;Mac mini M5 Pro 64GB+2TB: ¥27,999, bandwidth 307GB/s&lt;/li&gt;
  &lt;li&gt;Mac Studio M5 Max 64GB+512GB: ¥27,499, 40-core GPU, bandwidth 614GB/s&lt;/li&gt;
  &lt;li&gt;Mac Studio M5 Max 128GB+512GB: ¥39,499, bandwidth 614GB/s&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the same 64GB+512GB configuration, the Studio costs ¥7,000 more than the mini, in exchange for 2x the bandwidth and better cooling. Compared with the 64GB+2TB mini, it’s ¥500 cheaper with 2x the bandwidth, except that the drive is 1.5TB smaller. &lt;strong&gt;You can add storage externally, but you can’t add bandwidth&lt;/strong&gt;. If your budget reaches ¥27,000 and it’s mainly for local models, the 64GB Studio is the better value.&lt;/p&gt;

&lt;p&gt;Thinking about it now, if size weren’t a concern, maybe the 128GB Studio would also be the better choice, with better cooling and more headroom. Apple’s own chips keep moving forward too, and I’d guess that if you’re willing to wait, later chips will most likely be better suited to local models. But all of that is the cost of waiting. Life is a hundred years at most; rather than waiting and waiting, it’s better to buy early and start enjoying it sooner.&lt;/p&gt;

&lt;p&gt;Of course, my situation isn’t just about using local large models — cloud models are always more tempting. Unless you’re clear that local models are fully up to your local work, it’s best &lt;strong&gt;not to kid yourself that buying a Mac mini means you can leave cloud models behind for good&lt;/strong&gt;.&lt;/p&gt;

&lt;h2 id=&quot;storage-just-enough-add-external-later&quot;&gt;Storage: just enough, add external later&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Storage, unlike memory, can be added later&lt;/strong&gt;. Once your use is settled, just pick enough built-in storage.&lt;/p&gt;

&lt;p&gt;Running local models needs spare space. If it’s only a server, 512GB is enough; put video, audio and backups on an external drive. 256GB is really too small — the system alone eats a big chunk. If you buy 256GB, I’d suggest just modding the internal drive; internal drives are more stable than external ones. Modding means going to a specialist shop and may affect the official warranty. I think going straight to 512GB is safer.&lt;/p&gt;

&lt;p&gt;Upgrading storage on the official site isn’t cheap: on the M6 32GB, going from 256GB to 1TB costs ¥3,750 more; on the M5 Pro 64GB, going from 1TB to 2TB costs ¥5,250 more. You can save that money for now and buy an external drive later as needed. I chose 2TB because I have to back up a lot of video in my daily work. For just running models and being a server, you don’t really need 2TB.&lt;/p&gt;

&lt;h2 id=&quot;configuration-and-price-tables&quot;&gt;Configuration and price tables&lt;/h2&gt;

&lt;p&gt;Prices are official China prices from October 2026, not counting Double 11 promotions. The M4 is no longer sold on the official site, so the price listed is a used price from Xianyu (China’s second-hand marketplace).&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/mp007-img12.png&quot; alt=&quot;Choosing a Mac by use: configurations and prices&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Earlier tests on this 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 pocket rocket runs 27B, up to 35 token/s”&lt;/a&gt;: how to set up local large models and how fast they run&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://mp.weixin.qq.com/s/iHqF5izRKrq8wT1bJgvGUg&quot;&gt;“Mac mini runs AI video: how much memory it uses and how long generation takes”&lt;/a&gt;: running MiniMax H3 locally to generate video&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://mp.weixin.qq.com/s/s3sMxi6Q_7Ujax9Ce6AU4Q&quot;&gt;“Keeping files in sync between two computers, without using my iCloud space”&lt;/a&gt;: the Syncthing mentioned in the 16GB tier&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have an M6 32GB, an M5 Pro 48GB or a Mac Studio, feel free to leave your measured speeds in the comments. If there’s anything else you’d like me to test on this mini, leave a comment too. Hope this helps.&lt;/p&gt;

&lt;p&gt;Sources:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Data for my machine: measured on my Mac mini M5 Pro 64GB, oMLX running Qwen3.8-27B, see the previous post “Mac mini pocket rocket runs 27B, up to 35 token/s”&lt;/li&gt;
  &lt;li&gt;Mac mini and Mac Studio bandwidth and configurations: apple.com.cn/mac-mini/specs, apple.com.cn/mac-studio/specs&lt;/li&gt;
  &lt;li&gt;M6 32GB running 27B (LM Studio, GGUF): github.com/HDZucht/Mac-Mini-M6-LLM-benchmarks&lt;/li&gt;
  &lt;li&gt;M6 32GB running 27B (MLX, video review): youtube.com/watch?v=7ugFOo8e8H4&lt;/li&gt;
  &lt;li&gt;M6 32GB running MoE: github.com/HDZucht/Mac-Mini-M6-LLM-benchmarks, note.com/corestudio/n/n583c1b00f8ef&lt;/li&gt;
  &lt;li&gt;M5 Pro running MoE 4-bit: measured on my machine with oMLX 0.7.0, model mlx-community/Qwen3.6-35B-A3B-4bit (October 20, 2026)&lt;/li&gt;
  &lt;li&gt;M6 2nm, from ¥6,999: reports from the August 25, 2026 launch event (ITHome, Sina Tech); M4 Mac mini, from ¥4,499 in China: 2024 launch price (ITHome)&lt;/li&gt;
  &lt;li&gt;Services on the 16GB server: official documentation for Docker, AdGuard Home, FreshRSS and Paperless-ngx&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Tue, 20 Oct 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/en/2026/10/20/mac-mini-memory-m6-m5-pro-guide/</link>
        <guid isPermaLink="true">https://uulili.com/en/2026/10/20/mac-mini-memory-m6-m5-pro-guide/</guid>
        
        <category>Mac mini</category>
        
        <category>memory</category>
        
        <category>M6</category>
        
        <category>M5 Pro</category>
        
        <category>local LLM</category>
        
      </item>
    
      <item>
        <title>Mac mini Runs a 27B Model at Up to 35 token/s</title>
        <description>&lt;p&gt;After using cloud AI for a while, one thing has kept distracting me: quota. There’s a cap every 5 hours, and another every week. Halfway through a task, it says I’m out of quota and I just have to wait. Pretty annoying. It also gets stuck when the network is bad. And there are some questions I’d rather not send to the cloud, but I had no other way.&lt;/p&gt;

&lt;p&gt;So this time I seriously tried running a local large model on my Mac mini at home. I was after two things: token freedom — use as much as I want — and no censorship, fewer refusals.&lt;/p&gt;

&lt;p&gt;I spent three days testing it. It works, and it can run 24/7. Once a conversation reaches 130k tokens, a reply still comes in 3–4 seconds. When the cloud quota runs out, it takes over automatically.&lt;/p&gt;

&lt;p&gt;Below: how to configure it, how the settings differ, and if you’re thinking of buying one, how to choose memory.&lt;/p&gt;

&lt;h2 id=&quot;my-machine-and-how-i-use-it&quot;&gt;My machine and how I use it&lt;/h2&gt;

&lt;p&gt;Mac mini, M5 Pro, 20-core GPU, 64GB memory, 2TB disk. It’s normally the home server and also runs an AI assistant, a media library and video generation, so the model can’t take up all the memory. So I set a 48GB cap on the model process; if it goes over, it stops.&lt;/p&gt;

&lt;p&gt;The model is Qwen3.8-27B, a 27-billion-parameter model. I tried two engines, oMLX and MTPLX, and kept oMLX.&lt;/p&gt;

&lt;h2 id=&quot;how-fast-it-actually-runs&quot;&gt;How fast it actually runs&lt;/h2&gt;

&lt;p&gt;Here’s what daily use feels like:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Scenario&lt;/th&gt;
      &lt;th&gt;How long you wait&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Already 130k tokens into a chat, ask one more thing&lt;/td&gt;
      &lt;td&gt;3–4 seconds to start answering&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Chat at 210k tokens&lt;/td&gt;
      &lt;td&gt;4–7 seconds&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Feeding in 10k+ characters of new material at once&lt;/td&gt;
      &lt;td&gt;Tens of seconds to over a minute&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Output speed&lt;/td&gt;
      &lt;td&gt;20–27 tokens per second, short answers up to 35.8&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;While writing this, I opened oMLX’s built-in web chat and tested again. A one-line “hello” reply came at 35.8 tokens per second, the fastest I’ve seen; asking it to write a ~300-character intro gave 25.9 tokens per second, 10.2 seconds total from sending to finishing.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/mp003-omlx-300.png&quot; alt=&quot;oMLX web chat: writing 300 characters, 25.9 tokens per second&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Peak memory for the whole process was about 38GB, and it didn’t use swap. After a service restart, the previous conversation cache was still there, so I could keep chatting without re-reading.&lt;/p&gt;

&lt;p&gt;This speed is usable for everyday work. It depends on one setting: prefix caching.&lt;/p&gt;

&lt;h2 id=&quot;the-most-important-setting-prefix-caching&quot;&gt;The most important setting: prefix caching&lt;/h2&gt;

&lt;p&gt;With caching off, every question makes the model read the entire history from the beginning. Once the chat reaches 60k tokens, each question takes 2–3 minutes. In my first round of testing, I measured “how long it takes to read in 260k tokens at once.” The first token took 18 minutes — I almost concluded that long local conversations were unusable.&lt;/p&gt;

&lt;p&gt;With caching on, it only reads the new part. That’s where the 3–4 seconds come from.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/mp003-img2.png&quot; alt=&quot;With and without caching, the wait differs by tens of times&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Caching matters more than changing quantization or turning on acceleration. That was my biggest takeaway this time. I put the cache on the SSD, capped at 100GB, which holds about four or five long conversations at full length.&lt;/p&gt;

&lt;h2 id=&quot;how-the-settings-differ&quot;&gt;How the settings differ&lt;/h2&gt;

&lt;h3 id=&quot;4-bit-is-enough&quot;&gt;4-bit is enough&lt;/h3&gt;

&lt;p&gt;The model can be compressed to different precisions; the fewer bits, the less memory it uses. I compared 4-bit (Q4) and 8-bit (Q8) on 63 questions:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt; &lt;/th&gt;
      &lt;th&gt;Structured questions&lt;/th&gt;
      &lt;th&gt;Code&lt;/th&gt;
      &lt;th&gt;Peak memory (short chat)&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 is no worse than Q8. It got one fewer code question right; tool calling is 3/3 for both. Reading in 260k tokens at once, Q4 reads 237 tokens per second with a 36.1GB peak; Q8 reads 160 per second with a 45.4GB peak. oMLX has dedicated acceleration for 4-bit, so Q4 is both faster and uses less memory. I also let Q4 run continuously for two hours: 13 questions all correct, memory steady at 36GB.&lt;/p&gt;

&lt;h3 id=&quot;uncensored-version-same-capability-benefit-not-measured&quot;&gt;Uncensored version: same capability, benefit not measured&lt;/h3&gt;

&lt;p&gt;I compressed both the uncensored and official versions to Q4. On the same set of questions, scores were almost identical, and speed was about the same too (16.61 and 16.70 tokens per second).&lt;/p&gt;

&lt;p&gt;But on the few sensitive questions I prepared, both versions answered normally, so I couldn’t measure how much the uncensored version actually helps this time. According to the model author’s notes, it “reduces refusals” rather than “never refuses”; with thinking mode on, about 8.7% still get refused.&lt;/p&gt;

&lt;h3 id=&quot;moe-model-fast-but-memory-hungry&quot;&gt;MoE model: fast, but memory-hungry&lt;/h3&gt;

&lt;p&gt;I also tried a 35B MoE model. It only uses 3B parameters at a time, so it’s fast: 56 tokens per second output on short chats, 3–4 times the config above. But the 8-bit version’s weights alone take about 35GB, peak hit 47GB, and long conversations were unstable. On a 64GB machine, I ruled it out.&lt;/p&gt;

&lt;h2 id=&quot;the-config-to-copy&quot;&gt;The config to copy&lt;/h2&gt;

&lt;p&gt;Engine: oMLX 0.7.0; model: Qwen3.8-27B uncensored 4-bit. Below, each row gives the setting and how to write it in oMLX:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Item&lt;/th&gt;
      &lt;th&gt;Setting&lt;/th&gt;
      &lt;th&gt;In oMLX&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Prefix cache&lt;/td&gt;
      &lt;td&gt;On, on SSD, 100GB cap&lt;/td&gt;
      &lt;td&gt;Startup args &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 acceleration&lt;/td&gt;
      &lt;td&gt;On&lt;/td&gt;
      &lt;td&gt;Model setting &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;Thinking mode&lt;/td&gt;
      &lt;td&gt;Off&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;Context window&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;Memory cap&lt;/td&gt;
      &lt;td&gt;48GB&lt;/td&gt;
      &lt;td&gt;Startup arg &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;Concurrency&lt;/td&gt;
      &lt;td&gt;1 request&lt;/td&gt;
      &lt;td&gt;Startup arg &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;Idle unload&lt;/td&gt;
      &lt;td&gt;30 minutes&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ttl_seconds: 1800&lt;/code&gt;, reloads in about 4 seconds next time&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;Two things to watch:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MTP has to be imported first.&lt;/strong&gt; This 4-bit model’s MTP weights are in a separate file, and oMLX can’t read them by default; you need oMLX’s built-in import function (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;import_mtplx_sidecar&lt;/code&gt;) to sort it out. It modifies the model directory directly, so copy it first with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cp -cR&lt;/code&gt;. Worth the trouble: at 130k tokens, output went from 10.6 to 26 tokens per second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4-bit has dedicated read-in acceleration (oQ A8)&lt;/strong&gt;, about 1.4× faster, and I have it on. Q6 and Q8 can’t use it.&lt;/p&gt;

&lt;p&gt;I tried these and don’t recommend turning them on:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;KV cache quantization&lt;/strong&gt;: both read-in and output became 2–3 times slower.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;ANE prefill&lt;/strong&gt;: 30% slower instead, uses 16GB more memory, and disables the oQ A8 above.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;In-memory hot cache&lt;/strong&gt;: immediately blows up memory usage.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;256K window&lt;/strong&gt;: it can finish, but it sits right at the 48GB cap with no headroom.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also hooked it into the AI assistant Hermes as a backup model: when the cloud is rate-limited, out of quota or offline, it switches to local automatically. The cost is that every new conversation’s first message waits about 20 extra seconds, because the assistant always carries a big chunk of instructions, about 14k tokens. After that it’s fast.&lt;/p&gt;

&lt;h2 id=&quot;where-i-use-it-from&quot;&gt;Where I use it from&lt;/h2&gt;

&lt;p&gt;I use the same model from four places. oMLX provides both OpenAI- and Anthropic-format APIs, so common tools can connect. The speed difference isn’t in the model; it’s in how much fixed content each request has to read first:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Entry point&lt;/th&gt;
      &lt;th&gt;First message of a new chat&lt;/th&gt;
      &lt;th&gt;Good for&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;oMLX’s built-in web chat&lt;/td&gt;
      &lt;td&gt;About 1.4 seconds&lt;/td&gt;
      &lt;td&gt;Pure chat, writing, questions I don’t want going to the cloud&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Hermes (used in WeChat and Feishu)&lt;/td&gt;
      &lt;td&gt;About 20 seconds&lt;/td&gt;
      &lt;td&gt;Daily assistant; switches over automatically when the cloud is unavailable&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Claude Code&lt;/td&gt;
      &lt;td&gt;About 35 seconds&lt;/td&gt;
      &lt;td&gt;Simple coding, when I don’t want code leaving the machine&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Codex&lt;/td&gt;
      &lt;td&gt;About 54 seconds&lt;/td&gt;
      &lt;td&gt;Same as above&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;As the same conversation continues, every entry point hits the cache and the gap shrinks. But honestly, the 27B 4-bit model is clearly worse than cloud models at multi-file coding; I only use it for simple work.&lt;/p&gt;

&lt;p&gt;It’s also convenient on a laptop. I use Tailscale to access it from my own devices; only my devices can connect, and it works when I’m out.&lt;/p&gt;

&lt;h2 id=&quot;can-it-stay-on-all-the-time&quot;&gt;Can it stay on all the time&lt;/h2&gt;

&lt;p&gt;Yes. But a few things to know.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Power&lt;/strong&gt;: inference draws 50–60W more than idle, peaking around 80W. When the model is idle it draws almost nothing extra, and after 30 minutes of no use it unloads automatically. I spend most of my time typing, and at one or two hours of cumulative inference a day, that’s a few dozen kWh more per year.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Temperature&lt;/strong&gt;: the chip rises to 75–77°C in 1–2 minutes and stays there; no thermal warnings, no throttling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disk&lt;/strong&gt;: writes come mainly from the cache, about 140MB per 1000 tokens of new content. With very heavy use, that’s about 28GB a day, around 10TB a year. Mainstream 2TB SSDs are rated for several hundred TB of writes or more, so ten years is no problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can’t run alongside other big tasks&lt;/strong&gt;: this machine also runs video generation, peaking around 22GB; add the model’s 38GB and it goes over, so I can only run them at different times.&lt;/p&gt;

&lt;h2 id=&quot;if-you-want-to-buy-how-to-choose-memory&quot;&gt;If you want to buy, how to choose memory&lt;/h2&gt;

&lt;p&gt;I only tested the 64GB machine. Below, everything except 64GB is extrapolated from measured memory peaks, for reference only; before buying, better to check real-world tests of the same config.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/mp003-img3.png&quot; alt=&quot;Peak memory of the 27B 4-bit model&quot; /&gt;&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Memory&lt;/th&gt;
      &lt;th&gt;My judgment&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;Measured: 27B 4-bit and 160K long conversations both work, and other services can run alongside; it just can’t run at the same time as big tasks like video generation&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;48GB&lt;/td&gt;
      &lt;td&gt;Extrapolated: 27B 4-bit runs, and short chats peak at only 17GB; but when long conversations reach 160K, the model peaks around 38GB, and leaving headroom for the system gets tight, so the context may need to be shortened&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;32GB&lt;/td&gt;
      &lt;td&gt;Extrapolated: with 27B 4-bit, short chats are worth a try, but there isn’t much room for long conversations and it’s hard to run other services alongside; a smaller model is more realistic&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;24GB and below&lt;/td&gt;
      &lt;td&gt;Extrapolated: the 27B tier isn’t realistic, go straight to a small model&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;About the new M6 Mac mini: Apple’s published configurations top out at 32GB of memory, so based on the estimates above, it can only fall in the 32GB tier. There are no real tests yet of how much faster the new chip is or whether the engine is optimized for it, so I won’t draw conclusions.&lt;/p&gt;

&lt;p&gt;My take: for local large models, memory matters more than chip tier. The chip decides speed; memory decides whether it runs and how long you can chat. Don’t skimp on the disk either; cache and models both take space.&lt;/p&gt;

&lt;p&gt;If you mainly use the cloud and occasionally want an unlimited backup that never leaves the machine, a Mac mini with a lot of memory can already do that. I hope these numbers help with your config choice.&lt;/p&gt;
</description>
        <pubDate>Sat, 17 Oct 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/en/2026/10/17/mac-mini-27b-local-model-speed/</link>
        <guid isPermaLink="true">https://uulili.com/en/2026/10/17/mac-mini-27b-local-model-speed/</guid>
        
        <category>local LLM</category>
        
        <category>Mac mini</category>
        
        <category>Qwen</category>
        
        <category>inference speed</category>
        
        <category>prefix caching</category>
        
      </item>
    
      <item>
        <title>Running AI Video on a Mac mini: Memory Use and Generation Time</title>
        <description>&lt;p&gt;If you’re planning to buy a Mac mini to run local AI, choosing how much memory to get is probably the hardest part. With video models in particular, the download alone is over a hundred GB, so it’s easy to think the machine needs a hundred GB of memory as well.&lt;/p&gt;

&lt;p&gt;I ran a round of MiniMax-H3 tests on my M5 Pro with 64GB at home. In the short-clip tests, memory peaks were mostly in the 18.5–22GB range; the 9-second run reached 25.6GB. With the recommended parameters, generating a vertical video of about 2 seconds takes about 3 minutes.&lt;/p&gt;

&lt;p&gt;So this round of short-clip tests doesn’t use all 64GB. But you shouldn’t choose a configuration based only on the 22GB figure either; you also have to account for the system, other software, and the memory available to the GPU. Below I list the results from my actual runs, as a reference for anyone who hasn’t bought a machine yet.&lt;/p&gt;

&lt;p&gt;I used h3.c, written by antirez, the author of Redis. It’s pure C + Metal. It supports text-to-video, first/last frame, and reference images, and can generate audio alongside the video.&lt;/p&gt;

&lt;p&gt;First, here are two generated samples:&lt;/p&gt;

&lt;video src=&quot;https://uulili.com/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;A fox in the snow&quot;&gt;&lt;/video&gt;

&lt;video src=&quot;https://uulili.com/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;A bakery clerk saying “Welcome”&quot;&gt;&lt;/video&gt;

&lt;p&gt;(Blog version: the videos above have been replaced with the original videos with audio; the WeChat Official Account version uses silent GIFs.) The clerk in that clip said “Welcome” correctly, but it doesn’t always go this smoothly — I’ll get to that below.&lt;/p&gt;

&lt;h2 id=&quot;memory-you-wont-use-all-64gb-but-you-cant-just-buy-a-smaller-memory-machine-either&quot;&gt;Memory: you won’t use all 64GB, but you can’t just buy a smaller-memory machine either&lt;/h2&gt;

&lt;p&gt;Mac mini, M5 Pro, 20-core GPU, 64GB memory, 2TB drive. It also runs other services as usual; I didn’t stop them during testing. You may also be wondering: if you’re only running this model, do you need to buy 64GB?&lt;/p&gt;

&lt;p&gt;Here are the records:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Test&lt;/th&gt;
      &lt;th&gt;Recorded memory peak&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;~2-second short clip, various acceleration parameters&lt;/td&gt;
      &lt;td&gt;18.5–22GB&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;9-second vertical video&lt;/td&gt;
      &lt;td&gt;25.6GB&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;These are peaks recorded during generation, not the total usage of the whole machine including the system and other services. These tests didn’t hit swap; 64GB really does leave a fair amount of headroom. Before starting the service, headroom is around 93%; while the service is running, headroom can still stay around 60%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The downloaded model size and the memory it uses at runtime are not the same number.&lt;/strong&gt; h3.c loads different parts in stages; the path my M5 uses also converts to int8 at load time. 134GiB of weights sitting on disk doesn’t mean 134GiB of memory is used all at once.&lt;/p&gt;

&lt;p&gt;But there’s another number to look at: the program identifies this machine’s &lt;strong&gt;recommended GPU working set as 51.8GiB&lt;/strong&gt;. That’s the amount of memory Metal suggests the GPU should use at once; it doesn’t mean the model is already occupying 51.8GiB, nor does exceeding it mean it definitely can’t run. Macs use unified memory, but you can’t treat the 64GB on the box as space the GPU can freely fill up.&lt;/p&gt;

&lt;p&gt;That’s also why I didn’t simply write “24GB is enough.” Short clip peaks are already at 18.5–22GB, and the 9-second run reached 25.6GB; the system and background programs aren’t counted yet. If you switch to a smaller-memory machine, you’d need to re-test whether this same set of parameters still has headroom.&lt;/p&gt;

&lt;p&gt;If I were choosing memory for this use case now, I’d think about it like this:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Memory configuration&lt;/th&gt;
      &lt;th&gt;How to think about it based on this test&lt;/th&gt;
      &lt;th&gt;Actually tested&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;Short clip peaks are already close to the machine’s total memory, and the 9-second run exceeds it; you can’t just reuse this set of parameters, you’d need to test a memory-saving approach&lt;/td&gt;
      &lt;td&gt;No&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;48GB&lt;/td&gt;
      &lt;td&gt;More headroom than these peaks; worth considering if you mainly do short shots and don’t run many programs at once&lt;/td&gt;
      &lt;td&gt;No, inferred from this machine’s records&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;64GB&lt;/td&gt;
      &lt;td&gt;I also keep other services running; these tests didn’t hit swap, and it’s fairly hassle-free&lt;/td&gt;
      &lt;td&gt;Yes, this is the machine in this article&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The original report’s estimate for 48GB assumes the GPU working set is about 80% of memory, roughly 38GB. That ratio is only an estimate and can’t be treated as a fixed quota for every machine; if you buy a different configuration, you still have to look at the number the program actually reports. I also haven’t run this on 48GB, so what I’m saying here is “worth considering,” not a guarantee that it’s as fast as 64GB.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There’s another path for small memory, but it trades time for space.&lt;/strong&gt; h3.c’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--ssd-streaming&lt;/code&gt; reads later parts of the main model from the SSD while computing. The author recorded the main model tensor storage on an M5 Max dropping to about 2.0–2.1GiB; note this isn’t the whole program using only 2GB — text encoding, video decoding, the system and other buffers still need memory.&lt;/p&gt;

&lt;p&gt;In &lt;a href=&quot;https://github.com/antirez/h3.c#tutorial&quot;&gt;the author’s tests&lt;/a&gt;, one round of main model computation was 26% and 84% slower, respectively, for the two aspect ratios, using the same BF16 path. That can’t be directly converted into “how many minutes a 24GB Mac mini would wait in total,” nor can it be applied to my int8 tests here. I didn’t test streaming this time, so I won’t give that answer yet.&lt;/p&gt;

&lt;p&gt;So the value of 64GB is headroom; you can’t call it a must based on these few short clips alone. If I were mainly running H3 short clips, I’d consider 48GB first; if I also wanted to run other models and services at the same time, I’d rather go with 64GB. For 24GB, you’d first have to test a memory-saving approach before deciding whether its speed is acceptable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about the M6 Mac mini everyone’s discussing now?&lt;/strong&gt; &lt;a href=&quot;https://www.apple.com/mac-mini/specs/&quot;&gt;Apple’s published configurations&lt;/a&gt; top out at 32GB unified memory; you can’t choose the 48GB or 64GB mentioned above. Compared with this test’s 18.5–22GB short clip peaks, 32GB looks worth trying; but the 9-second run already hits 25.6GB, and you still need to leave space for the system and other programs, so you can’t conclude it will definitely run just because “32 is bigger than 25.6.” If you have an M6, feel free to leave your test results in the comments.&lt;/p&gt;

&lt;p&gt;There’s also software compatibility. As of this update, &lt;a href=&quot;https://github.com/antirez/h3.c&quot;&gt;h3.c’s public documentation&lt;/a&gt; still focuses mainly on optimizing for M3 Max and M5 Max, and doesn’t list M6-specific optimizations or measured results. I’m also using the M5 acceleration path here; you can’t directly apply this machine’s roughly three-minute result just because M6 is newer.&lt;/p&gt;

&lt;p&gt;So if I were buying an M6 mainly for H3, I’d first wait for its support status and measured results on the same configuration; if I also needed to do other things and H3 was just for occasional fun, I’d keep it on the candidate list. I don’t have an M6, so for now I won’t make up an estimated time for it.&lt;/p&gt;

&lt;p&gt;Once memory is enough, the next question is whether you can live with it day to day: how long does generating a video take?&lt;/p&gt;

&lt;h2 id=&quot;generation-speed-fine-for-short-shots-long-clips-mean-a-wait&quot;&gt;Generation speed: fine for short shots, long clips mean a wait&lt;/h2&gt;

&lt;p&gt;All of the following are actual records from this machine, portrait 576×1024, recommended quality:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Requested duration&lt;/th&gt;
      &lt;th&gt;Total time&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;2 seconds&lt;/td&gt;
      &lt;td&gt;2 min 40 s&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;3 seconds&lt;/td&gt;
      &lt;td&gt;3 min 35 s&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;5 seconds&lt;/td&gt;
      &lt;td&gt;7 min 24 s&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;9 seconds&lt;/td&gt;
      &lt;td&gt;17 min 20 s&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The program rounds the frame count up, so the actual video is slightly longer than requested. The time is measured from when the run starts to when the output file is written, including model loading, generation, and decoding.&lt;/p&gt;

&lt;p&gt;The 2- and 3-second clips are acceptable—enough for a short shot. Beyond that, the wait gets long. I initially estimated from two data points that 9 seconds would take about 12 minutes; it ended up taking 17. I didn’t test 15 seconds, and I don’t usually need clips that long.&lt;/p&gt;

&lt;p&gt;The h3.c version used in this article is &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;8974cc0&lt;/code&gt;; testing was from late September to early October.&lt;/p&gt;

&lt;h2 id=&quot;recommended-mode-and-draft-mode&quot;&gt;Recommended mode and draft mode&lt;/h2&gt;

&lt;p&gt;With the same image, same prompt and seed, I tested several acceleration methods. For a 2.33-second portrait video:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Default parameters: 433 seconds.&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 seconds.&lt;/li&gt;
  &lt;li&gt;Internal size lowered to 288×512, with the previous parameters: 59 seconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Comparing face crops at original size, I could barely tell the second set from the default, so I made it the recommended mode.&lt;/p&gt;

&lt;p&gt;The third set has visibly softer detail, but the composition and motion are basically unchanged, so you can first check whether the direction is right. A draft is one version per minute; once you’re happy, rerun it with the recommended mode.&lt;/p&gt;

&lt;p&gt;The “one minute” and “can’t see a difference” both come from this set of material; with other material, you’ll still need to check for yourself.&lt;/p&gt;

&lt;p&gt;If you first try the direction with a draft and only rerun the recommended mode once you’re satisfied, you don’t have to wait three minutes every time. But after saving memory and cutting down the wait, there’s one more thing you can’t skip: disk space.&lt;/p&gt;

&lt;h2 id=&quot;disk-space-counts-too&quot;&gt;Disk space counts too&lt;/h2&gt;

&lt;p&gt;The whole repository on Hugging Face is 464GiB, but you don’t actually need to download all of it locally. h3.c doesn’t use the weights in the root directory.&lt;/p&gt;

&lt;p&gt;Text-to-video and first/last frame need &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FL2VA&lt;/code&gt;, 134GiB; reference images need &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Ref2VA&lt;/code&gt;, also 134GiB. I later compared the files and found that the text encoder, VAE, and other files are identical on both sides; only the main video model differs. By reusing the identical files with APFS clones, you only need to download another 62GiB.&lt;/p&gt;

&lt;p&gt;If you want both text-to-video and reference images, with this reuse approach the models themselves take up about 196GiB. That doesn’t include the system, software, input assets, and generated files. If you only run text-to-video, you can download just the 134GiB set first; you don’t need to install every feature from the start.&lt;/p&gt;

&lt;p&gt;If you’re downloading in China, you can use MiniMax’s official repository of the same name on ModelScope; I measured 73MB/s here, and 134GiB downloaded in half an hour.&lt;/p&gt;

&lt;p&gt;The machine can run it, but the results are another matter. The GIFs above show successful results; the failures below say more about what to watch for in real use.&lt;/p&gt;

&lt;h2 id=&quot;dialogue-has-to-be-written-in-the-right-format&quot;&gt;Dialogue has to be written in the right format&lt;/h2&gt;

&lt;p&gt;The first time I wrote a prompt, I mixed the dialogue into a descriptive paragraph, and the character’s lines came out as vague syllables that sounded like Japanese but weren’t.&lt;/p&gt;

&lt;p&gt;Later I followed the format and put the dialogue in separately:&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;And marked the speaker &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;(S1)&lt;/code&gt;; only then was it normal.&lt;/p&gt;

&lt;p&gt;I also specifically compared having the program assemble the prompt directly versus having AI expand it. In the bakery scene, the program-assembled prompt also got it right, and the description could be in Chinese or English. The AI version wrote a long paragraph but added a line of subtitles at the start. So now I have the program assemble it by default, and only turn on AI expansion when I need a more detailed shot description.&lt;/p&gt;

&lt;p&gt;I also tried a real person: “国庆快乐” (Happy National Day) came out as “国供快乐”. The animated clerk segment worked, but that doesn’t mean real-person dialogue is stable; this still needs more testing.&lt;/p&gt;

&lt;h2 id=&quot;reference-image-mode&quot;&gt;Reference image mode&lt;/h2&gt;

&lt;p&gt;If you’ve used minimax h3, you know it has 3 modes: text-to-video, image-to-video, and first/last-frame video.&lt;/p&gt;

&lt;p&gt;Text-to-video and first/last-frame video are easy to understand, but image-to-video needs reference images. Supplying the reference images and writing the description still take some skill.&lt;/p&gt;

&lt;p&gt;I supplied a character image and a snowy pine forest, wanting the girl to walk into the forest and introduce the environment. The assets were these:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/minimax-h3-refs.jpg&quot; alt=&quot;Character and scene reference images&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Sometimes the result still started in the bakery; sometimes the whole video was the solid background from the character image, and the pine forest wasn’t used at all.&lt;/p&gt;

&lt;p&gt;Later I added one sentence: take only the character from the character image, and don’t include the background. With the same four combinations, two of them failed without this sentence; with it, all four were correct.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/minimax-h3-bg-without.jpg&quot; alt=&quot;Result without excluding the character-image background&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/minimax-h3-bg-with.jpg&quot; alt=&quot;Result after explicitly excluding the character-image background&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The motion was the same. “Slowly walk into the forest, introduce the environment” — I tested it 6 times and never got both the visuals and dialogue right. I changed it to “walk in from the left, stop, introduce this place” — all 4 were correct.&lt;/p&gt;

&lt;p&gt;You also need to state clearly what each image is. In this set of tests, specific English nouns plus a trait worked reliably; Chinese definitions and vague “person”, “place” didn’t work well.&lt;/p&gt;

&lt;p&gt;These tests used the same material and two seeds, with only a few tries for each phrasing, so don’t treat the success rates here as a guarantee. If you want to try it yourself, use a clean background for the character image where possible, and don’t put eye-catching people or animals in the scene image; generate a draft first to see how it looks.&lt;/p&gt;

&lt;h2 id=&quot;my-own-webui-is-now-open-source&quot;&gt;My own WebUI is now open source&lt;/h2&gt;

&lt;p&gt;I packaged it as a service: the web UI, the command line, and chats in WeChat and Feishu (a Chinese workplace chat app) all share one task queue.&lt;/p&gt;

&lt;p&gt;In the web UI, you enter the description and dialogue, choose the aspect ratio and quality, and submit. For each reference image, you enter its purpose, what it is, and its features, and the program assembles the format. If there’s a scene image, it automatically adds the line above “for other images, take only the subject, not the background”.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/minimax-h3-ui-ref-mode.jpg&quot; alt=&quot;Web UI reference image mode&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Now, in a chat, I can say “the girl in the image waves and says welcome, 3 seconds”, attach the image, and get the video when generation finishes. The service shuts down automatically after an hour of inactivity.&lt;/p&gt;

&lt;p&gt;I cleaned up the web UI and queue and open-sourced them as h3-studio (stars welcome):&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;&lt;/p&gt;

&lt;p&gt;Translation, AI expansion and chat push are all optional plugins. Video generation runs locally; if you choose cloud translation or Codex expansion, the relevant text and images will still be sent out. If you want everything local, choose local translation and leave expansion off.&lt;/p&gt;

&lt;p&gt;For now I’ll keep using it for 2–5-second short shots, to make illustrations move, or to test an idea first. Long videos mean a long wait, and the dialogue isn’t reliable enough to skip checking.&lt;/p&gt;

&lt;p&gt;If you haven’t bought a Mac mini yet, you can look at these results and think about what you’ll use it for: whether you’ll mainly generate two- or three-second clips or often produce long videos, and whether you’ll run only H3 or also run other models and services. The former is already workable for me; for the latter, you need to allow for more waiting time and leave more resource headroom.&lt;/p&gt;

&lt;p&gt;I tested this on an M5 Pro 64GB, and the speeds here can’t be applied directly to other chips or memory configurations. What this article offers is this machine’s actual output, its resource usage, and its timings. When buying, besides memory, don’t overlook the disk: model downloads aren’t small, and generated videos will keep taking up space too.&lt;/p&gt;

&lt;h2 id=&quot;summary&quot;&gt;Summary&lt;/h2&gt;

&lt;p&gt;A local model is certainly not as capable as a cloud model, and MiniMax is of course no exception — it can’t compare with a model like Jimeng (ByteDance’s AI image and video generator). And to run a local model, a base-spec Mac mini certainly isn’t enough either. But it has 2 advantages: 1) you never have to worry about credits or token usage again; 2) strong privacy.&lt;/p&gt;

&lt;p&gt;This post is just me sharing what I tested; I hope it’s useful as a reference. Feel free to comment and discuss below.&lt;/p&gt;
</description>
        <pubDate>Fri, 09 Oct 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/en/2026/10/09/mac-mini-ai-video-memory-generation-time/</link>
        <guid isPermaLink="true">https://uulili.com/en/2026/10/09/mac-mini-ai-video-memory-generation-time/</guid>
        
        <category>Mac mini</category>
        
        <category>MiniMax-H3</category>
        
        <category>local AI</category>
        
        <category>memory usage</category>
        
        <category>video generation</category>
        
      </item>
    
      <item>
        <title>Logic Pro Smart Tempo: Matching Non-Standard Recorded Material</title>
        <description>&lt;p&gt;In Logic Pro, to make the software’s grid and beats follow exactly a song you recorded with a non-standard tempo (free tempo / recorded without a metronome), the core idea is to let the software adapt to your audio (let the grid follow the audio) rather than forcing your audio straight.&lt;/p&gt;

&lt;p&gt;The quickest and smartest solution is to use Logic Pro’s Smart Tempo feature, which can analyze the downbeats in the audio and automatically generate a fluctuating tempo track to match your recording. If you need more precise manual fine-tuning, use Beat Mapping. Here are the steps:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;Import the audio: Drag the audio file you recorded onto an empty audio track in Logic Pro.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;Trim the start: Cut off the extra empty space at the front of the audio and align the audio’s start point with the start of the grid (for example, beat 1 of bar 1).&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;Set the follow mode: Double-click the audio region to open the editor, click the Smart Tempo tab, then click Analyze.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/logic-smartTempo.png&quot; alt=&quot;Smart Tempo&quot; /&gt;&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Switch to Adapt: In the tempo display area at the top of the LCD (or in the Smart Tempo project settings), change the Project Tempo Mode from “Keep” to Adapt.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/logic-adapt.png&quot; alt=&quot;adapt mode&quot; /&gt;&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Apply to the project: Right-click the audio region and choose Tempo -&amp;gt; Apply Region Tempo to Project Tempo. Logic will automatically analyze your recording and build a dedicated tempo track for it (the Tempo Map).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/logic-regionToProject.png&quot; alt=&quot;match to project&quot; /&gt;&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Turn on the metronome and have a listen. The metronome will now speed up and slow down along with your recording, matching it perfectly.&lt;/li&gt;
&lt;/ol&gt;
</description>
        <pubDate>Sun, 12 Jul 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/en/2026/07/12/logic-pro-smart-tempo-non-standard-recordings/</link>
        <guid isPermaLink="true">https://uulili.com/en/2026/07/12/logic-pro-smart-tempo-non-standard-recordings/</guid>
        
        <category>Logic Pro</category>
        
        <category>Smart Tempo</category>
        
        <category>Beat Mapping</category>
        
        <category>tempo matching</category>
        
        <category>audio editing</category>
        
      </item>
    
      <item>
        <title>When AI Mistakes a Bird Outside the Window for a Sloth: Tuning a Raspberry Pi Birding Camera</title>
        <description>&lt;p&gt;I live on the 4th floor, and birds often come and perch outside the window. Sometimes they stay just a few seconds, sometimes they hop back and forth along the railing. I’ve loved watching animal documentaries since I was a kid, and now my kid has inherited that gene too. We both want to know when these birds come and how many times they show up.&lt;/p&gt;

&lt;p&gt;So I dug out a &lt;a href=&quot;https://aiyprojects.withgoogle.com/vision/&quot;&gt;Google AIY Vision Kit&lt;/a&gt; that had been gathering dust for a long time: a Raspberry Pi Zero, a Vision Bonnet, and a Raspberry Pi camera. It’s the vision version of that 2017 wave of “cardboard-box AI kits,” with a vision processing chip on the board that can run image classification locally.&lt;/p&gt;

&lt;p&gt;The goal was simple: sit quietly watching the window at low power, and when something lands in the designated area, automatically record a 10-second video; when I open the browser, I can see the live feed, recent recognition results, and the list of recordings.&lt;/p&gt;

&lt;p&gt;Here’s what it looks like now:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/birdcam-dashboard-202606.png&quot; alt=&quot;New birding camera dashboard&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Here’s what it can do now:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;When a living thing lands in the windowsill area, automatically record a 10-second 1080p video;&lt;/li&gt;
  &lt;li&gt;Keep only the most recent 30 recordings, deleting old ones automatically;&lt;/li&gt;
  &lt;li&gt;The web page shows the live feed, current recognition status, recent events, and the recording list;&lt;/li&gt;
  &lt;li&gt;The recognition log keeps the most recent 1000 entries, and they survive a restart;&lt;/li&gt;
  &lt;li&gt;You can preview recordings right on the page, or download them;&lt;/li&gt;
  &lt;li&gt;All inference runs locally, without depending on any cloud service.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The azure-winged magpie below is one it caught itself.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/birdcam-catch.jpg&quot; alt=&quot;An azure-winged magpie captured by the birding camera&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;the-hardware-isnt-new-but-its-just-enough&quot;&gt;The hardware isn’t new, but it’s just enough&lt;/h2&gt;

&lt;p&gt;This hardware is very old:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;A Raspberry Pi Zero, which is very slow;&lt;/li&gt;
  &lt;li&gt;A Google AIY Vision Bonnet that runs the vision model;&lt;/li&gt;
  &lt;li&gt;A Raspberry Pi camera, pressed up against the window glass, looking outside;&lt;/li&gt;
  &lt;li&gt;The OS is still an old image from around 2017.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Back then, Google made several kinds of models for the Vision Kit. The official introduction mentioned demos such as MobileNets object recognition, expression recognition, and person/cat/dog detection; the &lt;a href=&quot;https://aiyprojects.withgoogle.com/models/&quot;&gt;AIY models page&lt;/a&gt; also lists the model types the Vision Kit supports. In my image, the one that actually runs reliably is a general ImageNet 1000-class classification model based on &lt;a href=&quot;https://arxiv.org/abs/1704.04861&quot;&gt;MobileNet&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I originally wanted to use a model related to the &lt;a href=&quot;https://openaccess.thecvf.com/content_cvpr_2018/html/Van_Horn_The_INaturalist_Species_CVPR_2018_paper.html&quot;&gt;iNaturalist dataset&lt;/a&gt;, since it’s better suited to nature observation — it’s aimed at real-world photos of plants and animals. Unfortunately, the toolchain for this old image and old Bonnet is too old, and after all the fiddling I ended up back with the general classification model.&lt;/p&gt;

&lt;p&gt;That also determined the story that follows: the old model isn’t very good at recognizing birds — but that same trait is what makes the results more fun later.&lt;/p&gt;

&lt;h2 id=&quot;first-make-the-camera-take-it-easy&quot;&gt;First, make the camera take it easy&lt;/h2&gt;

&lt;p&gt;A single Pi Zero doing live streaming, recording, and AI inference at the same time will easily bring itself to its knees. My approach is to split the tasks:&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;When idle:
  Run only one 640x360 low-bitrate H.264 stream, using motion vectors to judge whether the ROI has movement

When there&apos;s movement:
  Capture one 1080p original frame
  Crop out the windowsill/landing area
  Throw it to the Vision Bonnet for classification

When it&apos;s judged that recording is needed:
  Exclusively occupy the camera to record 10 seconds of 1080p
  Package it into mp4 with ffmpeg
  Return to idle detection

When someone opens the web page:
  Temporarily open one 384x216 MJPEG preview
  Close it when nobody&apos;s watching
&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;Detection and preview are two separate paths. The live feed on the web page is very blurry, but what’s actually fed to the model is a crop of the 1080p original frame. That matters, because at first I also assumed the problem was the poor quality of the live feed.&lt;/p&gt;

&lt;p&gt;The core detection logic is roughly like this:&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;The web page is just a viewing window; it isn’t part of the decision. The decision is made on the cropped landing area.&lt;/p&gt;

&lt;h2 id=&quot;it-really-did-mistake-a-bird-for-a-sloth&quot;&gt;It really did mistake a bird for a sloth&lt;/h2&gt;

&lt;p&gt;Now for the most interesting part.&lt;/p&gt;

&lt;p&gt;At first I used a very intuitive rule: if a bird word appears in the model output, like &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;, and the confidence exceeds the threshold, then record.&lt;/p&gt;

&lt;p&gt;In practice it worked badly. Later I went through the logs and found that the labels were very consistent, both for empty scenes and for scenes with a bird:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Scene&lt;/th&gt;
      &lt;th&gt;Common top judgments from the model&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Empty railing&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;Glass / window screen / reflection&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;Bird landed&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;In other words, it often mistakes birds for monkeys, pigs, and orangutans — if these animals really appeared outside my 4th-floor window in Beijing, I’d definitely be dreaming, haha.&lt;/p&gt;

&lt;p&gt;But if I changed the question to “is this still an empty railing?”, it turned out to be very stable.&lt;/p&gt;

&lt;p&gt;So I changed the logic to this:&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;This is the real turning point of the whole system. It doesn’t need to recognize “this is an azure-winged magpie.” It only needs to notice “this area no longer looks like the usual empty railing,” and I record it.&lt;/p&gt;

&lt;p&gt;It sounds a bit off, but it works well in practice. Missing a clip is a permanent loss; a false trigger at most means I skip one clip when I’m going through them. For a birding camera, I’d rather err on the side of recording too much.&lt;/p&gt;

&lt;h2 id=&quot;put-the-ais-judgment-on-the-web-page&quot;&gt;Put the AI’s judgment on the web page&lt;/h2&gt;

&lt;p&gt;Tuning used to be painful because all I had was the logs. Once I hooked the live status and the history onto the web page, tuning got a lot easier.&lt;/p&gt;

&lt;p&gt;This is a concept image I had AI generate first, to set the direction for the dashboard: dark theme, image first, status concentrated on the right side.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/birdcam-ui-concept.png&quot; alt=&quot;Birding camera UI concept image&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The final page didn’t copy the fake data and decorations from the concept image; it kept only the information structure: the live feed on the left, and on the right the current status, confidence, time-of-day profile, quick links, and recent events.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/birdcam-dashboard-202606.png&quot; alt=&quot;New birding camera dashboard&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The live status falls into three categories:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Green “bird”: the model actually listed a bird name among its candidates;&lt;/li&gt;
  &lt;li&gt;Yellow “living thing?”: the model didn’t report a bird, but the top label doesn’t look like an empty railing;&lt;/li&gt;
  &lt;li&gt;Gray “no bird”: still an empty scene, like a railing, a window screen, or a reflection.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The old version of the page was plainer, but you could already see this idea:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/birdcam-overlay.jpg&quot; alt=&quot;Real-time recognition overlay at the bottom of the live page&quot; /&gt;&lt;/p&gt;

&lt;p&gt;The log page is another key piece of the puzzle. It refreshes every 2 seconds and lists recent recognition events. Later I also changed the log from an in-memory ring buffer to a JSONL file on disk, keeping at most 1000 entries. That way the tuning context is still there after the service restarts.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/birdcam-log.jpg&quot; alt=&quot;Recognition history page: the model calls birds all sorts of animals, but anything marked REC gets recorded&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;the-recordings-page-first-you-can-watch-then-you-can-download&quot;&gt;The recordings page: first you can watch, then you can download&lt;/h2&gt;

&lt;p&gt;At first the recordings page was just a list of file names. Later I turned it into a card-style clip library: each video can be previewed directly, with the time, recognition summary, and a download button shown below.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/birdcam-clips-202606.png&quot; alt=&quot;New recordings page&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Only the most recent 30 recordings are kept. Each time a new recording is written, the program deletes older files by modification time; it also cleans up once when the service starts. So a restart doesn’t affect this limit.&lt;/p&gt;

&lt;h2 id=&quot;adjusting-sensitivity-by-time-of-day&quot;&gt;Adjusting sensitivity by time of day&lt;/h2&gt;

&lt;p&gt;Birds don’t show up evenly through the day. They’re more active at dawn and dusk, and at night it’s basically noise. So I gave it a simple time-of-day profile:&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;high activity at dawn&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;high activity at dusk&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;normal daytime&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;conservative at 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;/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;At dawn and dusk the threshold is a bit lower and the cooldown a bit shorter; at night the threshold is higher, so wind moving the curtain or glass reflections don’t set off recordings. This strategy is pretty crude, but it fits how I actually use it.&lt;/p&gt;

&lt;h2 id=&quot;if-you-want-to-build-one-too&quot;&gt;If you want to build one too&lt;/h2&gt;

&lt;p&gt;This approach doesn’t have to use the AIY Vision Kit. Any “Raspberry Pi + camera + a model that can run classification or detection” can replicate it. The only difference is whether inference runs locally or is thrown to another device or the cloud.&lt;/p&gt;

&lt;p&gt;I think the key isn’t how powerful the model is, but these few things:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;First determine a fixed ROI. Don’t look for targets across the whole frame; just watch a small area like a feeder, railing, or nest entrance.&lt;/li&gt;
  &lt;li&gt;Use cheap motion detection when idle; don’t run the model all the time.&lt;/li&gt;
  &lt;li&gt;When there’s movement, capture a high-definition frame, crop the ROI, and run classification.&lt;/li&gt;
  &lt;li&gt;Don’t rush to ask “is it a bird.” First collect what empty scenes get recognized as, then change the rule to “does it still look like an empty scene.”&lt;/li&gt;
  &lt;li&gt;Display the model output. Web page, logs, JSON, anything — don’t tune blind.&lt;/li&gt;
  &lt;li&gt;Separate recording and live streaming. The preview can be blurry; the recording needs to be clear.&lt;/li&gt;
  &lt;li&gt;Set a limit on storage. Mine is 30 recordings and 1000 log entries.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Right now this program is just one Python file plus a long-running service, with no database, no cloud account, and no complicated frontend. It isn’t smart, but every morning it can tell me whether anything came to the window last night.&lt;/p&gt;

&lt;p&gt;It still calls birds sloths. My kid’s English is better than mine, and she caught on right away; I had to ask her to translate it for me before I got it~&lt;/p&gt;

&lt;p&gt;But anyway, detecting something alive is usually good news.&lt;/p&gt;

&lt;p&gt;Youke Lili, adding a little sunshine to your life~&lt;/p&gt;
</description>
        <pubDate>Wed, 03 Jun 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/en/2026/06/03/raspberry-pi-aiy-birding-camera/</link>
        <guid isPermaLink="true">https://uulili.com/en/2026/06/03/raspberry-pi-aiy-birding-camera/</guid>
        
        <category>Raspberry Pi</category>
        
        <category>AIY Vision Kit</category>
        
        <category>birding camera</category>
        
        <category>local AI</category>
        
        <category>image classification</category>
        
      </item>
    
      <item>
        <title>Building a &quot;Sleepy&quot; Timer for the AC: Home Assistant + HomeKit Field Notes</title>
        <description>&lt;p&gt;Summer’s here. When I sleep with the AC on at night, there are two things I dread most: one is running it all night, waking up with my nose stinging from the cold and the meter spinning merrily; the other is wanting to set a timer to turn it off, but this AC of mine &lt;strong&gt;has no timer-off function&lt;/strong&gt; of its own.&lt;/p&gt;

&lt;p&gt;The manufacturer’s mini-program does have a timer — but picture the scene: you’re already lying in bed, and you still have to fumble in the dark to unlock your phone, open the mini-program, wait for it to connect to the cloud, tap in and set the timer… and any sleepiness is gone. What I want is &lt;strong&gt;lying in bed, flicking something in Apple Home or talking to Siri, telling it “turn off in X hours”&lt;/strong&gt;, then closing my eyes with peace of mind.&lt;/p&gt;

&lt;p&gt;This article records the whole process of building this “sleepy timer.” In the end it’s very simple, but along the way I hit several invisible walls — and those walls are exactly the most valuable part of this piece. If you’re also fiddling with Home Assistant hooked up to an AC, these pitfalls can save you two evenings of detours.&lt;/p&gt;

&lt;h2 id=&quot;first-the-final-result&quot;&gt;First, the final result&lt;/h2&gt;

&lt;p&gt;In Apple Home, there’s now a slider control called “Skylight AC Timer.”&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Drag it while lying in bed: &lt;strong&gt;drag to 30% = turn off in 3 hours, 25% = 2.5 hours&lt;/strong&gt;, in 0.5-hour steps;&lt;/li&gt;
  &lt;li&gt;To cancel: drag it back to 0 / turn it off;&lt;/li&gt;
  &lt;li&gt;There’s also a &lt;strong&gt;fallback&lt;/strong&gt;: if I set it and then forget, or fall asleep without setting anything at all, at 23:00 it decides on its own — if the AC is still on, it gives another 3 hours, and turns off automatically in the early morning;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;It only controls the one in the bedroom; daytime AC use is completely unaffected&lt;/strong&gt;, and it will never turn off your daytime AC for no reason.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole chain looks like this:&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
      ↓  drag slider / voice
HomeKit bridge (built into Home Assistant)
      ↓
fan.skylight_ac_timer   ← a &quot;fan shell&quot; slider (hold on, I&apos;ll explain below why it&apos;s a fan)
      ↓  percentage ÷ 10 = hours
input_number.skylight_ac_hours   ← where &quot;how many hours&quot; is actually stored
      ↓
timer.skylight_ac_manual   ← countdown
      ↓  when time&apos;s up
climate.turn_off → bedroom AC (controlled over the local LAN)
&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;Looks unremarkable, right? But “why on earth a fan” and “why not just use the AC’s own timer” — behind these two questions is a whole night of detective work.&lt;/p&gt;

&lt;h2 id=&quot;breaking-down-the-requirements-first&quot;&gt;Breaking down the requirements first&lt;/h2&gt;

&lt;p&gt;Before I started, I broke the vague wish in my head into five hard requirements. This step is especially important — it helps you judge whether each approach “actually works”:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Manual, any duration&lt;/strong&gt;: tonight I might want 2 hours, tomorrow 3 — I have to be able to set it myself.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Fallback safety net&lt;/strong&gt;: people forget things. If I fall asleep without setting anything, there still has to be a safety net to turn it off.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Only one bedroom&lt;/strong&gt;: there are two ACs at home; the other one must not be touched.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;No collateral damage during the day&lt;/strong&gt;: running the AC during the day is normal use; the timer logic must never turn it off during the day.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Operable lying down&lt;/strong&gt;: ideally right in Apple Home / Siri, without opening a third app.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With these five, off we go.&lt;/p&gt;

&lt;h2 id=&quot;the-first-bend-can-the-home-app-do-timers-itself&quot;&gt;The first bend: can the Home app do timers itself?&lt;/h2&gt;

&lt;p&gt;The most natural idea: doesn’t Apple Home do automations? Just set an automation to “turn off in X hours,” right?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No.&lt;/strong&gt; Apple Home’s automation triggers are limited to a few kinds (time, accessory state, someone arriving/leaving, sensors). It &lt;strong&gt;has no native “countdown / delay X hours” control&lt;/strong&gt;. All you can do is “turn off at a fixed time every day” — but my duration differs every day, so a fixed time is out right away.&lt;/p&gt;

&lt;p&gt;iOS Shortcuts does have a “Wait” action, but a “Wait” in Shortcuts only runs on your phone, and it breaks once the screen has been locked for a while — relying on it for 2 hours is extremely unreliable.&lt;/p&gt;

&lt;p&gt;Conclusion: &lt;strong&gt;a real countdown has to be handed to a machine that’s always awake.&lt;/strong&gt; I happen to have a Raspberry Pi at home running Home Assistant (HA), which is the perfect candidate for this. So the question becomes: how does HA control this AC, and how do I hand control back to Apple Home.&lt;/p&gt;

&lt;h2 id=&quot;the-second-bend-detective-time-doesnt-the-ac-have-its-own-timer&quot;&gt;The second bend (detective time): doesn’t the AC have its own timer?&lt;/h2&gt;

&lt;p&gt;This is the part I dug into the longest, and learned the most from.&lt;/p&gt;

&lt;p&gt;My unit is a WAHIN (a Midea brand), connected in HA through the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;midea_ac_lan&lt;/code&gt; &lt;strong&gt;local LAN&lt;/strong&gt; integration — meaning HA talks to the AC directly on the local network, not through the cloud. So, that “timer off” in the mini-program — &lt;strong&gt;is the cloud counting down for me, or is the AC counting down on its own?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At first I assumed “the cloud.” But then I thought, no: if the manufacturer ran a countdown in the cloud for tens of millions of ACs, the cost and logic make no sense. &lt;strong&gt;The countdown is almost certainly written into the AC unit itself and counted by its own firmware&lt;/strong&gt;, and the mini-program just sends down the “turn off in N minutes” command.&lt;/p&gt;

&lt;p&gt;If that’s the case, can I send that local command myself? I dug into the code of the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;midea-local&lt;/code&gt; library. The AC’s set command is the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0x40&lt;/code&gt; packet in the protocol, and I went straight to reading the part where it builds the command body:&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;# ← these hardcoded 0x00s...
&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;# ← are exactly the on-timer / off-timer slots in the Midea protocol
&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;The truth comes out: &lt;strong&gt;the protocol slots are right there (those &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;0x00&lt;/code&gt;s are exactly the timer bytes), but this local library never fills them in&lt;/strong&gt;. Then I looked at the status response parsing (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;XC0MessageBody&lt;/code&gt;), which reads thirty-odd fields — temperature, mode, sleep, freeze protection… &lt;strong&gt;but never reads the timer&lt;/strong&gt;. The only &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timer&lt;/code&gt; in the whole library is just a boolean “capability flag” in a sub-protocol, not a duration.&lt;/p&gt;

&lt;p&gt;So the conclusion is clear:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;My intuition was right — &lt;strong&gt;the AC firmware does support local timers, it’s not cloud logic&lt;/strong&gt;;&lt;/li&gt;
  &lt;li&gt;But this open-source local integration &lt;strong&gt;never exposed that feature&lt;/strong&gt;;&lt;/li&gt;
  &lt;li&gt;To use it, I’d have to hack the library (change command encoding, add writable entities), at the cost of being overwritten on every update, plus having to get the byte encoding right for your specific model;&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;And most critically&lt;/strong&gt;: even if I wired it up, the HomeKit side still has no “duration control” available — the UI problem isn’t solved at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
  &lt;p&gt;💡 &lt;strong&gt;A tip for fellow tinkerers&lt;/strong&gt;: when you hit “this feature works in the app but isn’t in the integration,” go read the integration/library source first — you can often tell within ten minutes whether it’s “not exposed” or “not supported by the protocol.” This one step can save you several evenings of blind trial and error.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So I decisively backed off: &lt;strong&gt;don’t touch the library&lt;/strong&gt;. Let HA do the countdown itself — a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timer&lt;/code&gt; helper is stable enough, and even picks up again after a restart (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;restore: true&lt;/code&gt;). “Where the countdown runs” doesn’t actually affect the experience at all; what’s really unsolved is the next stage.&lt;/p&gt;

&lt;h2 id=&quot;the-third-bend-crash-site-homekits-three-walls&quot;&gt;The third bend (crash site): HomeKit’s three walls&lt;/h2&gt;

&lt;p&gt;Requirement #5 is “operate it from Home while lying down.” I wanted a control &lt;strong&gt;like adjusting the AC temperature&lt;/strong&gt;, one you can nudge +/- continuously, showing “how many hours” at a glance. Sounds easy, right? Apple taught me a lesson with three walls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wall one: HomeKit has no “number / duration slider.”&lt;/strong&gt; I went through the HA HomeKit bridge source, and the control types it supports are just these: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fans / lights / covers / humidifiers / thermostats / locks / switches / sensors…&lt;/code&gt; — &lt;strong&gt;no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;number&lt;/code&gt;&lt;/strong&gt;. Your &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;input_number&lt;/code&gt; simply can’t be passed through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wall two: there are only two units for continuous sliders.&lt;/strong&gt; Going through it, only two categories render as “continuous slider/dial” in Apple’s native Home:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Control&lt;/th&gt;
      &lt;th&gt;Source&lt;/th&gt;
      &lt;th&gt;Display unit&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Light brightness / fan speed / covers / humidifier&lt;/td&gt;
      &lt;td&gt;Percentage characteristic&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;locked to %&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Thermostat&lt;/td&gt;
      &lt;td&gt;Temperature characteristic&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;locked to °&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;The unit is hardcoded by the protocol characteristic; Apple &lt;strong&gt;won’t let you change &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;°&lt;/code&gt; to “hours.”&lt;/strong&gt; (Third-party HomeKit apps like Eve can show custom units, but with Apple’s native Home it’s just these two.)&lt;/p&gt;

&lt;p&gt;Well… at least the thermostat’s number is real! If I set 2.5, it shows 2.5, just with a ° next to it — tolerable. So I actually built a fake thermostat with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;generic_thermostat&lt;/code&gt;, treating “temperature” as “hours,” declaring the range 0–8 with 0.5 steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wall three, and the straw that broke this approach: Apple Home imposes a hard floor of ~7° on a thermostat’s target temperature.&lt;/strong&gt; I had explicitly written &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;min_temp: 0&lt;/code&gt; in HA, but the dial on the phone &lt;strong&gt;can only be set to 7.0 / 7.5 / 8.0&lt;/strong&gt; — 0 through 7 all swallowed by Apple. I can’t exactly ask people to “set a minimum of 7 hours,” can I? This approach was scrapped on the spot.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;🧱 &lt;strong&gt;One line to remember&lt;/strong&gt;: in Apple’s &lt;strong&gt;native&lt;/strong&gt; Home, if you want a fully continuous control, you either accept “the display is %” or accept “thermostat minimum 7°.” You can’t have both — real-number and full-range are mutually exclusive.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;the-final-plan-finish-with-a-fan&quot;&gt;The final plan: finish with a “fan”&lt;/h2&gt;

&lt;p&gt;After hitting three walls, the way forward actually became clear: since “showing real hours” isn’t possible, take a step back and choose a &lt;strong&gt;percentage slider that never lags&lt;/strong&gt;, and smooth over the “it’s a percentage” flaw with an &lt;strong&gt;easy-to-remember mapping&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I chose a &lt;strong&gt;fan&lt;/strong&gt; (a light would also work; the fan wins because you can set the number of speed steps, which makes alignment easy):&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Set &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;speed_count: 20&lt;/code&gt;, so the slider has 20 steps, &lt;strong&gt;each step 5% = 0.5 hours&lt;/strong&gt;, max 100% = 10 hours. Dragging snaps to the steps, so it’s easy to hit half-hour precision;&lt;/li&gt;
  &lt;li&gt;I made the mapping as easy to do in your head as possible: &lt;strong&gt;percentage ÷ 10 = hours&lt;/strong&gt;. 30% → 3 hours, 25% → 2.5 hours;&lt;/li&gt;
  &lt;li&gt;This “fan” is really an empty shell. What actually stores the hours is the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;input_number&lt;/code&gt; behind it; the fan is just the wrapper it hands to HomeKit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full config is below, and HA users can copy it directly (I use &lt;a href=&quot;https://www.home-assistant.io/docs/configuration/packages/&quot;&gt;packages&lt;/a&gt; to put a whole feature into one file, so it’s easy to roll back as a unit). &lt;strong&gt;You only need to replace the actual AC entity &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;climate.197912093838856_climate&lt;/code&gt; with your own&lt;/strong&gt;; everything else can be copied as-is.&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;# Bedroom AC · sleep timer off&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;#   In Apple Home, a &quot;fan shell slider&quot; fan.skylight_ac_timer:&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;#   percentage ÷ 10 = hours (30%→3h, 25%→2.5h), 20 steps, 0.5h each, max 10h, drag to 0 / off = cancel.&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;#   Behind it, input_number.skylight_ac_hours stores the hours; when the countdown ends, turn off the AC and reset to 0.&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;#   Fallback: check once at 23:00; if the AC is still on and no timer is set → start 3 hours (→ off at 02:00). AC on during the day doesn&apos;t enter this logic.&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;Bedroom AC timer hours&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;Bedroom AC manual countdown&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;Bedroom AC fallback countdown&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;# ⚠️ Uses the new template syntax (the old `fan: platform: template` will be removed in 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;Bedroom AC timer&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;# Lock the English entity_id; HomeKit bridge references are more stable&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;# Tapping &quot;on&quot; directly without dragging the slider = default 2 hours&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;# Round to the nearest 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;# Countdown ends: turn off AC + reset hours to 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;Bedroom AC · countdown ends → turn off and reset&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;# ← swap for your AC&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;# Fallback: fixed check at 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;Bedroom AC · 23:00 check → if on and no timer set, start 3-hour fallback&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;Bedroom AC · off → cancel fallback&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;Bedroom AC · fallback 3 hours ends → turn off&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;a-few-design-points-worth-calling-out-separately&quot;&gt;A few design points worth calling out separately&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Why not just have the fan store the hours directly instead of adding an &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;input_number&lt;/code&gt;?&lt;/strong&gt; Because a template fan is itself a “stateless” shell, and its percentage needs a real place to store it. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;input_number&lt;/code&gt; is that “single source of truth”: the fan reads it for display and writes to it to make changes take effect, and the countdown resets it to zero when it finishes. That closes the loop, and you don’t have to worry about losing the value on a restart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is the fallback “check once at 23:00” instead of “start a 3-hour timer whenever it turns on”?&lt;/strong&gt; This is exactly the key to requirement #4 (don’t accidentally affect daytime use). If it were “arm a 3-hour fallback whenever it turns on,” then turning on the AC during the day would also be turned off automatically after 3 hours—not what we want. So I changed it to &lt;strong&gt;check only once at 23:00&lt;/strong&gt;: only if the AC is still on then and no manual timer is set do we assume “this person fell asleep with the AC on” and give it a fallback shutdown. Daytime AC use never enters this logic at all.&lt;/p&gt;

&lt;p&gt;(Small trade-off: if you don’t go into the bedroom and turn on the AC until after 23:00, the fallback won’t take effect that night—in that case just drag the slider manually. My schedule is regular and it basically fits, so I accepted this edge case.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The manual timer takes priority over the fallback.&lt;/strong&gt; The 23:00 check has the condition &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timer.skylight_ac_manual == idle&lt;/code&gt;: as long as you’ve already dragged the slider (the manual countdown is running), the fallback stays out of the way; they can also naturally overlap—if the manual timer is shorter, it turns off first, and the moment the AC turns off, it triggers “cancel fallback,” so it won’t fire twice.&lt;/p&gt;

&lt;h2 id=&quot;how-to-get-this-control-into-apple-home&quot;&gt;How to get this control into Apple Home&lt;/h2&gt;

&lt;p&gt;HA has a built-in &lt;strong&gt;HomeKit Bridge&lt;/strong&gt; integration that can expose specified entities as accessories in Apple Home. Just add the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fan.skylight_ac_timer&lt;/code&gt; entity from above:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;Settings → Devices &amp;amp; Services → find &lt;strong&gt;HomeKit Bridge&lt;/strong&gt; → Configure → under “Entities to include” check &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fan.skylight_ac_timer&lt;/code&gt; → Save.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;After a moment, a “Bedroom AC Timer” fan accessory shows up in Apple Home. Yes, &lt;strong&gt;the icon is a fan&lt;/strong&gt; — that’s the price we pay for “a continuous slider all the way through.” Just think of it as a “countdown that blows the hot air away” 🌬️. Drag the slider, or tell Siri “set the bedroom AC timer to 30%,” and 3 hours later the AC puts itself to sleep.&lt;/p&gt;

&lt;h2 id=&quot;one-last-small-pitfall-old-template-syntax-needs-to-be-migrated&quot;&gt;One last small pitfall: old template syntax needs to be migrated&lt;/h2&gt;

&lt;p&gt;A few days after I finished, HA’s backend popped up a “Repair” notice: &lt;strong&gt;the old &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fan: platform: template&lt;/code&gt; syntax will be removed in 2026.6&lt;/strong&gt;, and it asked me to migrate to the new &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; syntax.&lt;/p&gt;

&lt;p&gt;Fortunately, HA’s repair wizard &lt;strong&gt;generates the migrated config directly&lt;/strong&gt; — just swap it in. The full config above is already in the &lt;strong&gt;new post-migration syntax&lt;/strong&gt;, so if you copy it directly, you won’t see this reminder again. Here’s one more lesson while I’m at it:&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;🔧 &lt;strong&gt;Check HA’s “Repairs” panel regularly.&lt;/strong&gt; Many deprecations are announced one or two versions ahead and come with an auto-generated new config — deal with them early; don’t wait until upgrade day for it to blow up in your face.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;quick-reference-table-of-pitfalls&quot;&gt;Quick-reference table of pitfalls&lt;/h2&gt;

&lt;p&gt;Here are the evening’s detours, condensed into one table, so you can find the one that applies to you:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;Pitfall&lt;/th&gt;
      &lt;th&gt;The truth&lt;/th&gt;
      &lt;th&gt;The fix&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Wanted to schedule directly in Home App&lt;/td&gt;
      &lt;td&gt;No native “delay/countdown” control&lt;/td&gt;
      &lt;td&gt;Let always-on HA handle the countdown (the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timer&lt;/code&gt; helper)&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Wanted to use the AC’s built-in timer&lt;/td&gt;
      &lt;td&gt;Firmware supports it, but the local integration doesn’t expose it (command byte hardcoded to 0)&lt;/td&gt;
      &lt;td&gt;Don’t hack the library; just let HA handle the countdown&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Wanted a numeric slider&lt;/td&gt;
      &lt;td&gt;The HomeKit bridge has no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;number&lt;/code&gt; type&lt;/td&gt;
      &lt;td&gt;Borrow the percentage shell from a “fan/light”&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Wanted the thermostat to show real hours&lt;/td&gt;
      &lt;td&gt;Apple Home pins the target temperature at a ~7° floor&lt;/td&gt;
      &lt;td&gt;Give up “real numbers” and use a % slider + an easy-to-remember mapping&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Percentages aren’t intuitive&lt;/td&gt;
      &lt;td&gt;The unit can’t be changed&lt;/td&gt;
      &lt;td&gt;Pick a mapping that’s easy to do in your head, like &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; to snap between steps&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Old template syntax is deprecated&lt;/td&gt;
      &lt;td&gt;2026.6 removes &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;fan: platform: template&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;Use the Repairs wizard to migrate to the new &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;final-thoughts&quot;&gt;Final thoughts&lt;/h2&gt;

&lt;p&gt;After a long detour, what I ended up with is very plain: &lt;strong&gt;lie in bed, drag the slider, and the AC quietly goes to sleep at the time I set; even if I fall asleep first, a 23:00 safety net catches it for me.&lt;/strong&gt; An unassuming little tool, yet it genuinely erased the small chore of “getting up in the night to turn off the AC” from my life.&lt;/p&gt;

&lt;p&gt;I really enjoy this kind of tinkering. On the surface it’s a fight with the AC; in essence, it’s dealing with those “invisible limits” — Apple’s floor, the zero hardcoded in the protocol, the old syntax about to be removed. Every wall you hit makes your understanding of the system a little deeper. And the most interesting part is often not the final config you can copy, but the whole evening behind “why a fan, of all things.”&lt;/p&gt;

&lt;p&gt;May you also install one or two such “sleepy” little switches into your own life.&lt;/p&gt;

&lt;p&gt;—— Add a little sunshine to your life ☀️&lt;/p&gt;
</description>
        <pubDate>Wed, 03 Jun 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/en/2026/06/03/home-assistant-homekit-ac-sleep-timer/</link>
        <guid isPermaLink="true">https://uulili.com/en/2026/06/03/home-assistant-homekit-ac-sleep-timer/</guid>
        
        <category>Home Assistant</category>
        
        <category>HomeKit</category>
        
        <category>air conditioner</category>
        
        <category>automation</category>
        
        <category>timer</category>
        
      </item>
    
      <item>
        <title>Making the Loctek E2 Standing Desk Smart: ESP32 + ESPHome + Home Assistant</title>
        <description>&lt;p&gt;I recently connected the Loctek E2 standing desk at home to Home Assistant, and then exposed it to Apple Home. The end result: tap “sit” or “stand” in the Home app, and the desk automatically moves to the sitting or standing memory height.&lt;/p&gt;

&lt;p&gt;At first this looked like a simple little task: find two wires and briefly short them together. It was only after actually tinkering with it that I realized that the hand controller port on desks like Loctek’s isn’t a traditional button-shorting setup, but a UART serial protocol running over the RJ45 cable.&lt;/p&gt;

&lt;p&gt;This post documents the whole process: how to identify the pins, how to confirm the protocol, how to connect it to Home Assistant with ESP32 + ESPHome, and finally how to handle power and HomeKit control.&lt;/p&gt;

&lt;h2 id=&quot;final-result&quot;&gt;Final result&lt;/h2&gt;

&lt;p&gt;On the hardware side, I kept the desk’s original hand controller panel; the ESP32 is just connected in parallel to the serial lines in the hand controller port.&lt;/p&gt;

&lt;p&gt;On the software side, the chain now looks like this:&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
        ↓
Loctek E2 standing desk controller
&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;What it can do now:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Two buttons in Home Assistant: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sit&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;stand&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;I can also trigger these two actions in Apple Home&lt;/li&gt;
  &lt;li&gt;The ESP32 doesn’t need a separate USB power supply; it’s powered directly by the 5V on the desk’s RJ45 port&lt;/li&gt;
  &lt;li&gt;The desk’s original hand controller is unaffected, and the physical buttons still work normally&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;hardware-list&quot;&gt;Hardware list&lt;/h2&gt;

&lt;p&gt;I used quite a few things this time, and it was my first time working with a microcontroller:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Loctek E2 standing desk&lt;/li&gt;
  &lt;li&gt;ESP32-WROOM-32 dev board, 30Pin, Type-C port&lt;/li&gt;
  &lt;li&gt;RJ45 to 8Pin solderless terminal block&lt;/li&gt;
  &lt;li&gt;A few jumper wires&lt;/li&gt;
  &lt;li&gt;Multimeter&lt;/li&gt;
  &lt;li&gt;A Raspberry Pi running the Docker version of Home Assistant&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Since I can’t solder, I tried to make the whole approach “solderless, detachable, and reversible” as much as possible. All connections are jumper wires plus screw terminals.&lt;/p&gt;

&lt;h2 id=&quot;phase-1-confirming-its-not-ordinary-buttons&quot;&gt;Phase 1: Confirming it’s not ordinary buttons&lt;/h2&gt;

&lt;p&gt;My first thought was: the standing desk hand controller has up, down, and 1, 2, 3, 4 memory positions, so maybe the RJ45 just contains a few button wires, and pressing a button just grounds one of the wires.&lt;/p&gt;

&lt;p&gt;That turned out not to be the case.&lt;/p&gt;

&lt;p&gt;After debugging the UART on the ESP32, I found that the desk continuously broadcasts data even when stationary. After testing baud rates, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;9600&lt;/code&gt; was the most stable.&lt;/p&gt;

&lt;p&gt;When the desk is stationary, I see frames like this:&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;When the desk moves, the data changes noticeably, for example:&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;This was a key step. It showed that the hand controller port isn’t a simple button-shorting setup, but UART serial communication.&lt;/p&gt;

&lt;h2 id=&quot;measuring-the-rj45-pins&quot;&gt;Measuring the RJ45 pins&lt;/h2&gt;

&lt;p&gt;Hold the RJ45 connector like this:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Metal contacts facing up&lt;/li&gt;
  &lt;li&gt;Clip facing down&lt;/li&gt;
  &lt;li&gt;Cable pointing away from you&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I measured:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pin1&lt;/code&gt;: about 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;: about 4.1V&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pin4&lt;/code&gt;: about 4.2V&lt;/li&gt;
  &lt;li&gt;Other pins about 0V&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Later testing confirmed:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pin1&lt;/code&gt; can power the ESP32’s &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; is 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; are UART TX/RX&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There’s one risk here: strictly speaking, the ESP32’s GPIO is not 5V tolerant, and the UART lines here measure over 4V. It works in my actual testing, but a more rigorous long-term solution should add level shifting or a voltage divider between the desk’s TX and the ESP32’s RX. For now I’m going with the minimal change.&lt;/p&gt;

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

&lt;p&gt;On the ESP32 side, I used:&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;Final wiring:&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;Note: the ESP32’s TX goes to the desk’s RX, and the ESP32’s RX goes to the desk’s TX. If you don’t get data, swap TX/RX first.&lt;/p&gt;

&lt;p&gt;Also, the desk’s 5V goes to the ESP32’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VIN&lt;/code&gt;, not &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; is the 5V input, which goes through the onboard regulator to become the 3.3V the ESP32 uses; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;3V3&lt;/code&gt; is the already-regulated 3.3V power pin on the board, and connecting 5V to it will likely burn the board.&lt;/p&gt;

&lt;p&gt;When testing the desk’s 5V supply, I first unplugged the USB from the computer and used only the desk’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pin1 -&amp;gt; VIN&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pin2 -&amp;gt; GND&lt;/code&gt; to power the ESP32. After confirming it came online reliably, I reconnected TX/RX and tested &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sit&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;stand&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;The final ESPHome configuration is very short:&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;At first I used &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;switch&lt;/code&gt; here, then later changed it to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;button&lt;/code&gt;. The reason is simple: sitting and standing aren’t “switch states” but one-shot commands. If I used a switch, Home Assistant would mistakenly think it has a persistent on/off state; but in reality, pressing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;stand&lt;/code&gt; doesn’t mean the desk has definitely arrived, nor can it know whether it was interrupted midway by the hand controller.&lt;/p&gt;

&lt;p&gt;So for now, the most accurate model is:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sit&lt;/code&gt;: send the sitting memory-position command once&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;stand&lt;/code&gt;: send the standing memory-position command once&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Later, once I can decode the height frames, I’ll consider upgrading to &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;, or even making it a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cover&lt;/code&gt; model similar to curtains.&lt;/p&gt;

&lt;h2 id=&quot;reading-the-current-state&quot;&gt;Reading the current state&lt;/h2&gt;

&lt;p&gt;Later I did another round of tests reading the state. The goal wasn’t to keep reverse-engineering the whole protocol in depth, but to solve a practical problem first: Home Assistant should at least know whether the desk is roughly sitting, standing, or moving.&lt;/p&gt;

&lt;p&gt;When the desk is stationary, it continuously sends frames like this:&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;But these two idle frames alone can’t distinguish sitting from standing. In other words, if the ESP32 has just cold-started and the desk hasn’t moved, these two idle frames alone can’t tell what height the desk is at.&lt;/p&gt;

&lt;p&gt;What’s really useful are the frames during sit-stand transitions. From my testing, I can currently use the following signals to tell:&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              # desk is moving
9B:07:12:07:CF:...:...:9D      # sitting preset related state
9B:07:12:06:06:...:...:9D      # standing preset related state
&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;So ESPHome now has a few more state entities:&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;I didn’t make sitting and standing into a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;switch&lt;/code&gt; here. The reason is it’s not a real switch: the physical panel can also change the desk state, and it can be interrupted midway. A more accurate design is to keep the two &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; buttons and expose read-only state separately.&lt;/p&gt;

&lt;p&gt;Here are the actual test results:&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:
initially standing
after pressing sit, moving=true
about 21 seconds later, moving=false
finally sitting=true, standing=false, posture=sitting

sit -&amp;gt; stand:
initially sitting
after pressing stand, moving=true
about 21 seconds later, moving=false
finally 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;In addition, I also tested pressing the desk’s physical panel &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1&lt;/code&gt; key directly. After the desk returned to sitting, Home Assistant also read it in sync:&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;This shows that the ESP32 isn’t just aware of the commands it sent itself; it’s actually listening in on the desk’s UART communication. Whether the action is triggered by Home Assistant or by pressing the physical panel, as long as the desk completes a sit-stand transition, the state gets updated.&lt;/p&gt;

&lt;p&gt;The current limitation is also clear: the first stationary state after a cold start may only restore the last recorded state, and doesn’t necessarily reflect the real height. But as long as one sit-stand action happens afterward, the state becomes reliable again. For my current use, that’s enough.&lt;/p&gt;

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

&lt;p&gt;Home Assistant is a newly installed Docker version on the Raspberry Pi, at an address like:&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;After flashing the ESPHome node, Home Assistant automatically discovered &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;desk&lt;/code&gt;. After adding the ESPHome integration, I could see two entities:&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;I also set up a DHCP static binding for the ESP32 in the router, so the IP won’t drift after each power loss and reconnect, and the Home Assistant connection is more stable.&lt;/p&gt;

&lt;h2 id=&quot;connecting-to-apple-home&quot;&gt;Connecting to Apple Home&lt;/h2&gt;

&lt;p&gt;To expose entities in Home Assistant to Apple Home, I used Home Assistant’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HomeKit Bridge&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Path:&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;When creating the HomeKit Bridge, select only the desk-related entities to expose:&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 then gives you a HomeKit pairing QR code or PIN. Open the Home app on your iPhone, add an accessory, and scan the code or enter the PIN.&lt;/p&gt;

&lt;p&gt;If you’re running Home Assistant in Docker and HomeKit can’t find the Bridge, it’s usually related to network mode and mDNS broadcast. The simplest fix is to let the Home Assistant container use host network, so it shares the same broadcast domain as your LAN devices.&lt;/p&gt;

&lt;h2 id=&quot;power-from-computer-usb-to-the-desks-5v&quot;&gt;Power: from computer USB to the desk’s 5V&lt;/h2&gt;

&lt;p&gt;At first the ESP32 was powered from the computer’s USB-C port, with only these wires connected:&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;This is “listen-only mode”. It’s safer, but not suitable for long-term use: I’d still have to hang a USB power cable under the desk, which is very inelegant.&lt;/p&gt;

&lt;p&gt;Later, after confirming RJ45 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pin1&lt;/code&gt; was about 5.11V, I connected it to the ESP32’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VIN&lt;/code&gt;. It was stable in testing, and no external USB power was needed.&lt;/p&gt;

&lt;p&gt;A few notes:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Connect 5V to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;VIN&lt;/code&gt;, not &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;3V3&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;Connect GND to a real &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GND&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;Don’t have computer USB and the desk’s 5V connected at the same time while testing&lt;/li&gt;
  &lt;li&gt;Measure &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pin1&lt;/code&gt; to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pin2&lt;/code&gt; with the multimeter’s voltage mode, not by shorting with current mode&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also made a small mistake: I once connected GND to the adjacent &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;D13&lt;/code&gt;. Fortunately the current firmware doesn’t use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;D13&lt;/code&gt;, so it didn’t actually cause any problems. But this mistake reminded me that I must secure the wiring when I finish, and not leave Dupont wires under stress or wobbling for long periods.&lt;/p&gt;

&lt;h2 id=&quot;whats-still-not-perfect&quot;&gt;What’s still not perfect&lt;/h2&gt;

&lt;p&gt;Functionally it works, but it still looks like an engineering prototype: a thick RJ45 cable, a few Dupont wires, and a bare ESP32 board hanging under the desk.&lt;/p&gt;

&lt;p&gt;Next I plan to make a small box to house the RJ45 terminal and the ESP32:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Use a small ABS project box&lt;/li&gt;
  &lt;li&gt;Switch to a short, soft, flat RJ45 cable&lt;/li&gt;
  &lt;li&gt;Secure the ESP32 and terminal with double-sided tape or mounting bases&lt;/li&gt;
  &lt;li&gt;Add strain relief at the cable entry, so pulling from outside doesn’t act directly on the screw terminals&lt;/li&gt;
  &lt;li&gt;Attach the whole box to the underside of the desk with Velcro&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal is to end up with only a small box and a short cable visible, not a tangle of experimental wiring.&lt;/p&gt;

&lt;h2 id=&quot;what-i-got-out-of-this-tinkering&quot;&gt;What I got out of this tinkering&lt;/h2&gt;

&lt;p&gt;The biggest takeaway wasn’t “making the desk controllable from a phone”, but methodically breaking a closed, ordinary piece of furniture into several understandable problems:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Is its interface for power and buttons?&lt;/li&gt;
  &lt;li&gt;If it isn’t buttons, what protocol is it speaking?&lt;/li&gt;
  &lt;li&gt;How can I just listen, without risking interference with the original controller?&lt;/li&gt;
  &lt;li&gt;How do I send the minimal command to switch to sit/stand?&lt;/li&gt;
  &lt;li&gt;How do I make Home Assistant and Apple Home see it as a normal smart home device?&lt;/li&gt;
  &lt;li&gt;How do I replace the temporary USB power with power from inside the desk?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The final answer is quite simple:&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;Loctek E2 RJ45 hand controller interface
  -&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;Now this desk is finally not just an “electric standing desk”, but part of my smart home system.&lt;/p&gt;

&lt;h2 id=&quot;next-step-turning-the-desk-into-a-real-health-reminder&quot;&gt;Next step: turning the desk into a real health reminder&lt;/h2&gt;

&lt;p&gt;The desk can now be controlled by Home Assistant and Apple Home, but it’s still essentially “manually triggered”: I tap a button when I want to sit or stand.&lt;/p&gt;

&lt;p&gt;Next I want to turn it into a more proactive health workflow: combining sensors and a Pomodoro timer, so the desk automatically reminds me at the right time, or even forces a switch between sitting and standing.&lt;/p&gt;

&lt;p&gt;Initial ideas:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Use a presence sensor, a chair pressure sensor, or computer activity to determine whether I’m actually at the desk&lt;/li&gt;
  &lt;li&gt;Use Home Assistant to implement Pomodoro logic: automatically switch to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;stand&lt;/code&gt; after 25 minutes of work&lt;/li&gt;
  &lt;li&gt;After standing for 5 minutes, allow switching back to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sit&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;If the sensor says I’m not at the desk, don’t trigger the desk movement, so an empty desk doesn’t move on its own&lt;/li&gt;
  &lt;li&gt;Later, if I can parse the desk’s height data, I’ll add a check so it doesn’t repeat if it’s already standing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key point of this version is no longer “can I control the desk”, but “when should I control it”. The truly useful part of a smart home is often not turning your phone into a remote control, but letting devices do the right thing on their own according to the situation.&lt;/p&gt;

&lt;h2 id=&quot;hands-on-sequel-i-actually-built-the-pomodoro-timer&quot;&gt;Hands-on sequel: I actually built the pomodoro timer&lt;/h2&gt;

&lt;p&gt;That was the idea. Later I built the whole thing. The end result:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;When I’ve worked at the desk for a full 30 minutes, the desk automatically switches between sitting and standing (the rule is “change posture every 30 minutes”; the first 5 minutes after a switch are a “mental break”)&lt;/li&gt;
  &lt;li&gt;When I leave the desk, the pomodoro pauses automatically; the desk won’t go up and down on its own in front of an empty chair&lt;/li&gt;
  &lt;li&gt;If I’m away more than 10 minutes (lunch, say) and come back, it automatically starts a new round, returns to the sitting position, and restarts the timer&lt;/li&gt;
  &lt;li&gt;When I record vocals, open LogicPro, or am in a meeting, I switch to a macOS Focus mode and the whole desk freezes&lt;/li&gt;
  &lt;li&gt;Hard freeze from 23:00 to 07:00 the next morning; it won’t move for any reason, so I’m not startled by movement in the middle of the night&lt;/li&gt;
  &lt;li&gt;No GUI app running on the Mac at all; everything runs in the background&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After all the work on this version, the time really went into platform and environment pitfalls, not into “writing the automation”. Below I’ve written down the architecture, the decision logic and the three hard pitfalls.&lt;/p&gt;

&lt;h3 id=&quot;how-to-tell-whether-im-at-the-desk&quot;&gt;How to tell whether I’m at the desk&lt;/h3&gt;

&lt;p&gt;This is the input signal for the whole setup. I compared a few approaches:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;ping / online probe&lt;/strong&gt;: only tells you “powered on and online”, can’t distinguish “in use” from “lid closed and asleep”. No good.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;HA Companion App&lt;/strong&gt;: install the official macOS app and it reports &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Active&lt;/code&gt; status; the least effort. But it’s a resident GUI program, and by default it reports a pile of stuff (foreground app, camera, location…). I wanted it unobtrusive. No good.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Final approach&lt;/strong&gt;: read macOS’s built-in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HIDIdleTime&lt;/code&gt; (nanoseconds since the last keyboard/mouse input), and have a background script report it to HA on a schedule. This is the only signal that truly reflects whether the keyboard or mouse is being used.&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;overall-architecture-and-data-flow&quot;&gt;Overall architecture and data flow&lt;/h3&gt;

&lt;p&gt;The final chain looks like this (I’ll explain later why it detours through a webhook in the middle):&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 runs in Docker on a Raspberry Pi, with anonymous access off and username/password plus ACL on. HA connects to it with the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ha&lt;/code&gt; account.&lt;/p&gt;

&lt;h3 id=&quot;decision-logic-splitting-should-it-move-into-independently-verifiable-booleans&quot;&gt;Decision logic: splitting “should it move” into independently verifiable booleans&lt;/h3&gt;

&lt;p&gt;I didn’t lump all the logic together. I split it into a few intermediate values whose truth you can test at a glance:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Am I at the desk&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;Note the MQTT sensor has &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;expire_after: 90&lt;/code&gt;: once the Mac sleeps or the lid closes, launchd stops reporting, and if no data arrives within 90 seconds the sensor goes inactive → &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;at_desk&lt;/code&gt; turns off → the desk by default doesn’t move. That’s a “disconnected means safe” fallback.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Should the pomodoro be running&lt;/strong&gt; (three gates combined)&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;The timer only runs when &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;should_run&lt;/code&gt; is on; if any of away-from-desk / focus / night holds, it immediately does &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. When I come back, do I “resume” or “start a new round” (there’s a pitfall here I only found later)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the most critical part of the experience. A short absence (bathroom, getting water) should pick up where it left off; a long absence (lunch) should start over. I use “length of absence” to tell them apart:&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;This one rule covers lunch, the end of the night period, the end of a long recording session and every other “away for a long time” case, without writing each one separately.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;⚠️ &lt;strong&gt;Don’t do what I did at first&lt;/strong&gt;: my first trigger only had &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;from: &quot;off&quot; → to: &quot;on&quot;&lt;/code&gt;, which planted a landmine. Every time Home Assistant restarted, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;should_run&lt;/code&gt; went from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;unknown → on&lt;/code&gt;, which doesn’t match &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;off→on&lt;/code&gt;, so the startup automation never fired — the pomodoro just sat at idle and the desk never moved again; and since the whole thing had no “start on boot” fallback, it meant &lt;strong&gt;one restart and it was dead&lt;/strong&gt; (I restarted a dozen times in a row while cleaning up the config before I noticed it had long stopped working).&lt;/p&gt;

  &lt;p&gt;The fix: &lt;strong&gt;widen the trigger to “any → on” and add “HA start (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;platform: homeassistant, event: start&lt;/code&gt;)”&lt;/strong&gt;, then use the “timer is not active” condition to catch duplicate starts; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;away_secs&lt;/code&gt; is only computed on a real &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;off→on&lt;/code&gt;, everything else (boot, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;unknown→on&lt;/code&gt;) is treated as a new round. &lt;strong&gt;“Only recognising one particular state transition” is one of the most common pitfalls in HA automations — always include the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;unknown&lt;/code&gt; from a restart / cold start.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;4. Switching posture when time’s up&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;One small improvement I added later: &lt;strong&gt;the timer is really “how long the current posture has been held”&lt;/strong&gt;. So it’s not only the automatic flip at time-up that resets it — &lt;strong&gt;if you manually switch posture yourself, the 30 minutes also restart from the moment it settles&lt;/strong&gt;, so you don’t get the awkward “stood up a few minutes ago and it flipped me back to sitting”. Implementation-wise, it just listens for the desk settling into either sitting or standing while &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;should_run&lt;/code&gt; is on, and does &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timer.start&lt;/code&gt; for 30 minutes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Because &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;should_run&lt;/code&gt; is always off at night and the timer is paused, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;finished&lt;/code&gt; never fires, so the 23:00–07:00 hard freeze holds naturally without any extra interception.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. How to pause for recording / meetings&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I didn’t go identify “is LogicPro in the foreground”, “is the camera in use” and other numerous, fragile conditions one by one. Instead I use a &lt;strong&gt;macOS Focus mode&lt;/strong&gt; as the master switch: build two personal automations with Shortcuts (a macOS automation app), each doing a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt; to an HA webhook when the Focus mode turns on / off, and HA flips &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;input_boolean.desk_pause_focus&lt;/code&gt;. Single source of truth, clean.&lt;/p&gt;

&lt;h3 id=&quot;three-pitfalls-that-stopped-me-the-most-valuable-part-this-time&quot;&gt;Three pitfalls that stopped me (the most valuable part this time)&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Pitfall one: macOS 26’s “Local Network privacy” is granted per binary&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At first I had the Mac-side script use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mosquitto_pub&lt;/code&gt; to send data straight to the broker on the Raspberry Pi. Running it by hand in the terminal — success; handed to launchd to run in the background — &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;runs&lt;/code&gt; was going up, exit code 0, but the broker received nothing.&lt;/p&gt;

&lt;p&gt;Switching to a direct Python connection made the error clearer: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[Errno 65] No route to host&lt;/code&gt;. But in the same launchd context, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt; to the Raspberry Pi returned 200.&lt;/p&gt;

&lt;p&gt;Conclusion: &lt;strong&gt;macOS 26’s Local Network privacy is granted per executable&lt;/strong&gt;. Running by hand in the terminal, the process inherits the terminal app’s “Local Network” permission; a headless process launched by launchd is a separate identity without that permission, so Homebrew’s &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; get silently denied access to the LAN. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/usr/bin/curl&lt;/code&gt; is an Apple-signed system binary and gets through.&lt;/p&gt;

&lt;p&gt;The key diagnostic step was trying &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt; and a direct MQTT connection in the same launchd script:&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;The fix: &lt;strong&gt;let the permitted &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt; make the hop across the LAN&lt;/strong&gt;. The Mac no longer connects to the broker directly; it &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt;s an HA webhook, and HA then does &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mqtt.publish&lt;/code&gt; internally. That gets around Local Network privacy while keeping the broker, the least-privilege account and the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;expire_after&lt;/code&gt; safety net.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pitfall two: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mosquitto_pub&lt;/code&gt; reports “Bad file descriptor” under launchd&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It’s really the poor-quality error message from pitfall one — after the socket is denied by Local Network privacy, mosquitto’s network loop hits a bad fd and reports &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Error: Bad file descriptor&lt;/code&gt;, which sends you off in the stdin/fd direction. Only switching to Python and seeing &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;No route to host&lt;/code&gt; made the truth clear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pitfall three: a Chinese &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;name&lt;/code&gt; generates a pinyin &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;entity_id&lt;/code&gt;, and all cross-entity references break&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In HA, the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;entity_id&lt;/code&gt; of MQTT / template entities is generated by slugifying the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;name&lt;/code&gt;. To save effort I gave a sensor the Chinese name &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Mac 空闲秒数&lt;/code&gt;, and HA turned it into pinyin:&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;But my templates referenced the English &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sensor.mac_idle_seconds&lt;/code&gt; — so it never got a value, and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;at_desk&lt;/code&gt; was always off. The symptom was confusing: the data was clearly updating on the dashboard, yet the sensor stayed 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; aren’t affected; their &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;entity_id&lt;/code&gt; comes from the key name in the config, not from &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;name&lt;/code&gt;.)&lt;/p&gt;

&lt;p&gt;Lesson: &lt;strong&gt;for any technical entity that other templates / automations will reference, always use an English &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;name&lt;/code&gt;&lt;/strong&gt; so the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;entity_id&lt;/code&gt; is clean and predictable; leaving it in Chinese is unsafe because it ends up in the id.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;There’s also a small related pitfall: in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ioreg | awk &apos;… exit}&apos;&lt;/code&gt;, awk stops on the first match, which sends SIGPIPE to the upstream &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ioreg&lt;/code&gt;, and combined with the script’s &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;set -e -o pipefail&lt;/code&gt; it makes the script “quietly exit early” after getting the idle value, never reaching the reporting step. Just remove awk’s early &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;exit&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;a-little-takeaway&quot;&gt;A little takeaway&lt;/h3&gt;

&lt;p&gt;I came away with two main lessons.&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;split complex decisions into a chain of booleans you can feed values into and verify independently&lt;/strong&gt;: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;at desk / not focused / not night&lt;/code&gt;, each layer can be tested on its own by &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;curl&lt;/code&gt;ing a value in and reading the true value back from the database, so when debugging you’re not facing a black box.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;the devil in cross-platform automation is in the environment details, not the main logic&lt;/strong&gt;: Local Network privacy granted per binary, the Chinese-to-pinyin slug rule — these things are perfectly valid in config syntax, yet they silently break the whole chain. Clearly verifying whether each hop actually gets through is far faster than rereading the YAML over and over.&lt;/p&gt;
</description>
        <pubDate>Sun, 31 May 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/en/2026/05/31/loctek-e2-esp32-esphome-home-assistant/</link>
        <guid isPermaLink="true">https://uulili.com/en/2026/05/31/loctek-e2-esp32-esphome-home-assistant/</guid>
        
        <category>ESP32</category>
        
        <category>ESPHome</category>
        
        <category>Home Assistant</category>
        
        <category>standing desk</category>
        
        <category>HomeKit</category>
        
      </item>
    
      <item>
        <title>Ukulele Mixing in Logic Pro: From Thin to Record-Ready</title>
        <description>&lt;p&gt;Ukulele Mixing in Logic Pro: From “Thin and Dry” to “Record-Ready” Sound&lt;/p&gt;

&lt;p&gt;Because the ukulele has a small resonating body, recordings often sound thin and dry. How do you dial in a tone that’s both full and solid, yet bright and clear?&lt;/p&gt;

&lt;p&gt;This article breaks down two approaches: one uses &lt;strong&gt;Logic Pro’s built-in plugins&lt;/strong&gt; to get fast results; the other uses the &lt;strong&gt;UADx series of top-tier analog-modeling plugins&lt;/strong&gt; to create that ultra-high-end feel.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;-core-mixing-approach&quot;&gt;🎧 Core Mixing Approach&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Cut the mud → Add fullness (compression) → Add air (EQ) → Create space (reverb)&lt;/strong&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;part-1-a-must-have-for-beginners-all-built-in-plugins&quot;&gt;Part 1: A Must-Have for Beginners! All Built-In Plugins&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;If you’re a beginner, Logic Pro’s built-in plugins are more than enough. The key is in the parameter details.&lt;/em&gt;&lt;/p&gt;

&lt;h3 id=&quot;1-basic-cleanup-channel-eq&quot;&gt;1. Basic Cleanup (Channel EQ)&lt;/h3&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;High Pass:&lt;/strong&gt; Enable the left-side filter and cut everything below &lt;strong&gt;100Hz&lt;/strong&gt;. Remove hum and unwanted rumble.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Notch:&lt;/strong&gt; Apply a slight &lt;strong&gt;-3dB&lt;/strong&gt; cut around &lt;strong&gt;300Hz-500Hz&lt;/strong&gt; to fix the dull, “hiding in a cardboard box” feel.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;2-add-thickness-compressor&quot;&gt;2. Add Thickness (Compressor)&lt;/h3&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Model:&lt;/strong&gt; Choose &lt;strong&gt;[Vintage Opto]&lt;/strong&gt; (silver panel).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Key settings:&lt;/strong&gt; Set Attack to &lt;strong&gt;20ms-40ms&lt;/strong&gt; to preserve the crisp “ding” of the plucked strings. Lower Threshold until the needle swings to &lt;strong&gt;-3dB&lt;/strong&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Effect:&lt;/strong&gt; The sound instantly becomes smoother and more solid, no longer thin.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;3-brighten-the-tone-vintage-tube-eq--exciter&quot;&gt;3. Brighten the Tone (Vintage Tube EQ &amp;amp; Exciter)&lt;/h3&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Vintage Tube EQ:&lt;/strong&gt; Set the frequency to &lt;strong&gt;10kHz&lt;/strong&gt; and turn the Boost knob to &lt;strong&gt;3-5&lt;/strong&gt;. This gives the tone a “sequined outfit.”&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Exciter:&lt;/strong&gt; Gently add &lt;strong&gt;10%&lt;/strong&gt; Harmonics. It generates natural high-frequency overtones that make the sound more penetrating.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;4-wrapping-it-in-space-chromaverb&quot;&gt;4. Wrapping It in Space (ChromaVerb)&lt;/h3&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Must-do:&lt;/strong&gt; Set up a &lt;strong&gt;Bus send&lt;/strong&gt; and put the reverb on an auxiliary track.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Settings:&lt;/strong&gt; Choose the &lt;strong&gt;Room&lt;/strong&gt; type. &lt;strong&gt;Set Pre-Delay to 20ms&lt;/strong&gt; (key! Let the dry signal come through first so the sound stays clear).&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;part-2-a-leap-in-quality-the-top-tier-uadx-plugins-version&quot;&gt;Part 2: A Leap in Quality! The Top-Tier UADx Plugins Version&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;If you’re after the warmth of analog gear and more refined highs, this “golden chain” is your first choice.&lt;/em&gt;&lt;/p&gt;

&lt;h3 id=&quot;1-fullness-that-adds-soul-uadx-la-2a-tube-compressor&quot;&gt;1. Fullness That Adds Soul (UADx LA-2A Tube Compressor)&lt;/h3&gt;
&lt;p&gt;The &lt;strong&gt;LA-2A&lt;/strong&gt; is the most famous optical compressor in history, and it’s a “perfect match” for the ukulele.&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;How to use:&lt;/strong&gt; Turn the &lt;strong&gt;Peak Reduction&lt;/strong&gt; knob until the large meter needle swings to &lt;strong&gt;-2 or -3&lt;/strong&gt; on the heaviest strums.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;What you hear:&lt;/strong&gt; It’s not just controlling volume; it adds the warmth and thickness of tubes, giving the tone more body.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;2-heavenly-brightness-and-air-uadx-pultec-eqp-1a&quot;&gt;2. Heavenly Brightness and Air (UADx Pultec EQP-1A)&lt;/h3&gt;
&lt;p&gt;This is the pinnacle of the EQ world, and its high-frequency boost is incredibly silky.&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;How to use:&lt;/strong&gt; Set &lt;strong&gt;High Frequency&lt;/strong&gt; on the right to &lt;strong&gt;10kc or 12kc&lt;/strong&gt;. Slowly raise &lt;strong&gt;Boost&lt;/strong&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;What you hear:&lt;/strong&gt; The ukulele’s highs take on an “airy” quality—sweet and clear without being harsh—a texture that digital EQs struggle to achieve.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;3-enhance-midrange-penetration-uadx-pultec-meq-5&quot;&gt;3. Enhance Midrange Penetration (UADx Pultec MEQ-5)&lt;/h3&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Settings:&lt;/strong&gt; In the &lt;strong&gt;Peak&lt;/strong&gt; section, choose &lt;strong&gt;1.5k or 2k&lt;/strong&gt; and boost slightly by &lt;strong&gt;1-2dB&lt;/strong&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Effect:&lt;/strong&gt; This step makes the instrument’s fundamental clearer, letting the sound instantly “jump out” of the backing track.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;4-intelligent-space-processing-sonible-smartreverb&quot;&gt;4. Intelligent Space Processing (sonible smartReverb)&lt;/h3&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;How to use:&lt;/strong&gt; Let &lt;strong&gt;smartReverb&lt;/strong&gt; learn your material. It automatically avoids muddy frequencies and generates the most fitting reverb.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Advanced:&lt;/strong&gt; Pair it with the &lt;strong&gt;Haas effect&lt;/strong&gt; (a Stereo Delay with a 20ms difference between the left and right channels) to instantly make a single ukulele track as wide as a stereo recording.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;-summary-post-mix-checklist&quot;&gt;✅ Summary: Post-Mix Checklist&lt;/h2&gt;
&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Did you high-pass?&lt;/strong&gt; (100Hz, to fix muddiness)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Is it solid?&lt;/strong&gt; (Use a compressor to make the sound full)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Is it bright?&lt;/strong&gt; (Use a vintage EQ to boost highs around 10kHz)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Is it muddy?&lt;/strong&gt; (Did you set Pre-Delay on the reverb, and did you cut the reverb’s low end?)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Following this logic, whether you’re singing while playing or playing instrumentally, your ukulele track will take on a professional, radio-ready quality!&lt;/p&gt;

&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;#LogicPro #Ukulele #MixingTutorial #UADPlugins #MusicProduction #IndependentMusician&lt;/em&gt;&lt;/p&gt;
</description>
        <pubDate>Wed, 06 May 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/en/2026/05/06/ukulele-mixing-logic-pro-guide/</link>
        <guid isPermaLink="true">https://uulili.com/en/2026/05/06/ukulele-mixing-logic-pro-guide/</guid>
        
        <category>ukulele</category>
        
        <category>mixing</category>
        
        <category>Logic Pro</category>
        
        <category>UADx</category>
        
        <category>audio plugins</category>
        
      </item>
    
      <item>
        <title>SUNO and Logic Pro Assisted Arrangement: Platform Approval and Mixing SOP</title>
        <description>&lt;blockquote&gt;
  &lt;p&gt;Disclaimer: The following is for music production hobbyists’ reference only. It summarizes my personal experience: assuming I &lt;strong&gt;already have original lyrics and composition&lt;/strong&gt; and &lt;strong&gt;sing the vocals myself&lt;/strong&gt;, I use SUNO and Logic Pro for arrangement assistance and adjust my mixing workflow so I don’t lose the copyright in my lyrics and composition because of the default AI song. Do not use this method for anything illegal or that infringes copyright.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Core logic:&lt;/strong&gt;
Use “physical analog coloration” to mask the digital characteristics of AI audio (checkerboard noise, high-frequency cliffs, invisible watermarks), and also keep the “AI backing track” and “real human recordings” physically separate at the bus level, creating the acoustic illusion of a “real studio-made backing track + real vocals”.&lt;/p&gt;

&lt;h2 id=&quot;-stage-1-material-cleanup-clearing-landmines&quot;&gt;📍 Stage 1: Material cleanup (clearing landmines)&lt;/h2&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;Goal:&lt;/strong&gt; Remove the “problem areas” most likely to trigger AI detection.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Import the stems:&lt;/strong&gt; Import the individual instrument stems generated by Suno or another AI tool into Logic Pro.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Delete the AI vocals outright (fatal landmine):&lt;/strong&gt; AI-generated lead and backing vocals sound strongly metallic and synthetic, with phase defects. They &lt;strong&gt;must be fully deleted, muted, or discarded outright&lt;/strong&gt;, and replaced entirely with vocals and harmonies you recorded yourself.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;(Optional, advanced) Drum doubling:&lt;/strong&gt; If the AI drums sound too digital, select the drum track and use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Track -&amp;gt; Drum Replacement/Doubling&lt;/code&gt; to layer on a real drum machine sample built into Logic.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;-stage-2-group-isolation-core-architecture&quot;&gt;📍 Stage 2: Group isolation (core architecture)&lt;/h2&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;Goal:&lt;/strong&gt; Prevent AI backing-track processing from “collaterally damaging” the clean human vocals.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ol&gt;
  &lt;li&gt;Select all AI-generated instrument tracks (woodwinds, strings, synths, guitar, bass, drums, etc.).&lt;/li&gt;
  &lt;li&gt;Press &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Command + Shift + D&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;Choose &lt;strong&gt;Summing Stack&lt;/strong&gt; and click Create.&lt;/li&gt;
  &lt;li&gt;Rename the new main track to &lt;strong&gt;“AI Backing Bus”&lt;/strong&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Note:&lt;/strong&gt; All vocals and live instruments you recorded yourself &lt;strong&gt;must stay outside this bus&lt;/strong&gt; and go directly to Stereo Out.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;-stage-3-physical-analog-coloration-breaking-the-ai-watermark&quot;&gt;📍 Stage 3: Physical analog coloration (breaking the AI watermark)&lt;/h2&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;Goal:&lt;/strong&gt; Add analog-circuit harmonics to the backing bus to break up the regularity of the AI spectrum.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In the Audio FX chain of the &lt;strong&gt;“AI Backing Bus”&lt;/strong&gt;, insert &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Phat FX&lt;/code&gt; and set the parameters as follows:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;BANDPASS:&lt;/strong&gt; Completely off (greyed out).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;DISTORTION:&lt;/strong&gt; On.
    &lt;ul&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Bit Crush&lt;/code&gt;: 0.0%&lt;/li&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Sat&lt;/code&gt;: 10% - 15%&lt;/li&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Vari Drive&lt;/code&gt;: choose &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Tube&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Soft&lt;/code&gt;, push to around 5%.&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;BASS ENHANCER:&lt;/strong&gt; On.
    &lt;ul&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Type&lt;/code&gt;: Warm&lt;/li&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Amount&lt;/code&gt;: 10% - 15% (disrupts the low-frequency phase)&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;COMPRESSOR:&lt;/strong&gt; On.
    &lt;ul&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Type&lt;/code&gt;: Vintage VCA (adds hardware nonlinearity)&lt;/li&gt;
      &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Amount&lt;/code&gt;: 15% - 20%&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;MASTER (main output mix) - the most critical:&lt;/strong&gt;
    &lt;ul&gt;
      &lt;li&gt;Turn the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Mix&lt;/code&gt; knob to &lt;strong&gt;25% - 30%&lt;/strong&gt;.&lt;/li&gt;
      &lt;li&gt;&lt;em&gt;(Note: use just 30% analog coloration to dirty up the background while keeping 70% of the original clarity.)&lt;/em&gt;&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;-stage-4-high-frequency-cliff-repair-fooling-the-spectrum-analyzer&quot;&gt;📍 Stage 4: High-frequency cliff repair (fooling the spectrum analyzer)&lt;/h2&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;Goal:&lt;/strong&gt; Fix the cliff where the AI backing track is cut off above 18kHz.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On the &lt;strong&gt;“AI Backing Bus”&lt;/strong&gt;, right after &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Phat FX&lt;/code&gt;, insert a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Channel EQ&lt;/code&gt;:&lt;/p&gt;
&lt;ol&gt;
  &lt;li&gt;Turn on the &lt;strong&gt;High Cut / Low Pass&lt;/strong&gt; filter on the far right.&lt;/li&gt;
  &lt;li&gt;Set the cutoff frequency to around &lt;strong&gt;17500 Hz&lt;/strong&gt;.&lt;/li&gt;
  &lt;li&gt;Set the slope to the gentlest setting, &lt;strong&gt;12dB/Oct&lt;/strong&gt;.
    &lt;ul&gt;
      &lt;li&gt;&lt;em&gt;(Note: artificially recreate the natural high-frequency rolloff of old gear to mask the unnatural drop-off caused by insufficient AI compute.)&lt;/em&gt;&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;-stage-5-mixing-and-export&quot;&gt;📍 Stage 5: Mixing and export&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Vocal blending:&lt;/strong&gt; On the Stereo Out bus, insert your usual mastering chain (a bit of bus glue compression, a limiter, etc.) so the clean human vocals blend naturally with the backing track’s “physical patina”.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;No need to change BPM:&lt;/strong&gt; Once you apply the bus coloration above, the AI’s acoustic fingerprint is completely covered. There’s no need to force a slight BPM adjustment across the whole song (to avoid severe track misalignment when Flex is not enabled).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Export format:&lt;/strong&gt; Bounce it as usual to a 24-bit / 48kHz WAV file.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2 id=&quot;-notes-and-summary&quot;&gt;💡 Notes and summary&lt;/h2&gt;
&lt;p&gt;For audio exported with this workflow, the detection algorithm will read it as: &lt;strong&gt;“extremely realistic vocals + a backing track with the studio character of analog hardware”&lt;/strong&gt;, so it’s judged to be traditional human music production and passes AI detection.&lt;/p&gt;
</description>
        <pubDate>Wed, 08 Apr 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/en/2026/04/08/suno-logic-pro-arrangement-approval-mixing-sop/</link>
        <guid isPermaLink="true">https://uulili.com/en/2026/04/08/suno-logic-pro-arrangement-approval-mixing-sop/</guid>
        
        <category>SUNO</category>
        
        <category>Logic Pro</category>
        
        <category>AI music</category>
        
        <category>mixing</category>
        
        <category>copyright</category>
        
      </item>
    
      <item>
        <title>Building an Always-Online AI Assistant: A Complete Guide to Deploying the PicoClaw Feishu Bot on Raspberry Pi</title>
        <description>&lt;p&gt;In this tutorial, we’ll use a Raspberry Pi as a local server, deploy the &lt;strong&gt;PicoClaw&lt;/strong&gt; gateway, and seamlessly connect Feishu to powerful cloud LLMs (such as Volcengine) to build a dedicated, enterprise-grade AI assistant that responds to your commands anytime, anywhere.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/markdown-img/shisaq/2026-03-09-16-13-13-DtBCHx.png&quot; alt=&quot;Cover&quot; /&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;1-hardware-preparation&quot;&gt;1. Hardware Preparation&lt;/h2&gt;

&lt;p&gt;Choosing the right hardware is the first step toward success, and it saves you from 90% of low-level errors.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Raspberry Pi host&lt;/strong&gt;: I strongly recommend a &lt;strong&gt;Raspberry Pi 3B / 3B+ / 4B / 5&lt;/strong&gt; or &lt;strong&gt;Zero 2 W&lt;/strong&gt;.&lt;/p&gt;

    &lt;blockquote&gt;
      &lt;p&gt;❌ &lt;strong&gt;Watch out&lt;/strong&gt;: &lt;strong&gt;Absolutely do not use&lt;/strong&gt; the first-generation Raspberry Pi Zero W (v1.1) or Pi 1/2! Modern high-performance gateways (including Feishu’s long-connection SDK) require a &lt;strong&gt;64-bit (ARMv8 / aarch64)&lt;/strong&gt; architecture. The old Zero W’s chip is 32-bit, so forcing it to run just throws an architecture-not-supported error.&lt;/p&gt;
    &lt;/blockquote&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Other peripherals&lt;/strong&gt;: A 16GB or larger MicroSD card and a stable power supply (5V 2.5A or above).&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;How to operate&lt;/strong&gt;: Although this tutorial can be done headless over SSH, &lt;strong&gt;if you can, I strongly recommend plugging in a monitor and keyboard directly&lt;/strong&gt;, so you can actually see the boot screen and the IP address — troubleshooting is much easier that way.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/markdown-img/shisaq/2026-03-13-09-52-47-123123.jpg&quot; alt=&quot;Photo of the Raspberry Pi&quot; /&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;2-software-and-environment-choices&quot;&gt;2. Software and Environment Choices&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Operating system&lt;/strong&gt;: We’re using the &lt;strong&gt;Raspberry Pi OS (64-bit)&lt;/strong&gt; desktop version, flashed with Raspberry Pi Imager v2.0.6 (A port of Debian Trixie with the Raspberry Pi Desktop), released 2025-12-04. The desktop version has more features and is friendlier to beginners. &lt;strong&gt;Make sure it’s the 64-bit one&lt;/strong&gt;.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;File manager (FileZilla)&lt;/strong&gt;: I strongly recommend downloading and installing &lt;strong&gt;FileZilla&lt;/strong&gt; on your computer. It’s a free FTP tool that lets you connect directly to the Raspberry Pi over SFTP. With it you can double-click to open, edit and sync config files — far nicer than typing tedious commands into the terminal.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/markdown-img/shisaq/2026-03-09-16-16-51-Ys1ZJ7.png&quot; alt=&quot;Transferring files with FileZilla&quot; /&gt;&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;3-core-deployment-process&quot;&gt;3. Core Deployment Process&lt;/h2&gt;

&lt;h3 id=&quot;31-flashing-the-system-and-booting&quot;&gt;3.1 Flashing the System and Booting&lt;/h3&gt;

&lt;p&gt;After flashing the system with Raspberry Pi Imager, insert the SD card into the Raspberry Pi and boot it. Connect to your home Wi-Fi and note down the Raspberry Pi’s IP address (e.g. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;192.168.31.101&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/markdown-img/shisaq/2026-03-09-16-17-28-ayzdzw.png&quot; alt=&quot;Choosing the Raspberry Pi OS&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;32-final-architecture-check&quot;&gt;3.2 Final Architecture Check&lt;/h3&gt;

&lt;p&gt;Run this command in the Raspberry Pi terminal (or over SSH from your computer). If the output is &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;aarch64&lt;/code&gt;&lt;/strong&gt;, congratulations — your hardware is ready to go!&lt;/p&gt;

&lt;p&gt;Bash&lt;/p&gt;

&lt;div class=&quot;language-sh 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;&lt;span class=&quot;nb&quot;&gt;uname&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-m&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;33-downloading-installing-and-initializing-picoclaw&quot;&gt;3.3 Downloading, Installing and Initializing PicoClaw&lt;/h3&gt;

&lt;p&gt;Since we’re on a 64-bit system, just download the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;aarch64&lt;/code&gt; version of the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.deb&lt;/code&gt; package and run the following in order:&lt;/p&gt;

&lt;p&gt;Bash&lt;/p&gt;

&lt;div class=&quot;language-sh 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;&lt;span class=&quot;c&quot;&gt;# 1. Download the package (check Github&apos;s latest release for the version number)&lt;/span&gt;
wget https://github.com/sipeed/picoclaw/releases/download/v0.2.1/picoclaw_aarch64.deb

&lt;span class=&quot;c&quot;&gt;# 2. Install the package&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;dpkg &lt;span class=&quot;nt&quot;&gt;-i&lt;/span&gt; picoclaw_aarch64.deb

&lt;span class=&quot;c&quot;&gt;# 3. Initialize the working directory (generates default config)&lt;/span&gt;
picoclaw onboard
&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;Once the init command finishes, the terminal prints the cute lobster icon and the guidance below, telling you the config folder has been created:&lt;/p&gt;

&lt;p&gt;Plaintext&lt;/p&gt;

&lt;div class=&quot;language-sh 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
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;pi@raspberrypi:~ &lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;picoclaw onboard
🦞 picoclaw is ready!

Next steps:
  1. Add your API key to /home/pi/.picoclaw/config.json

Recommended:
  - OpenRouter: https://openrouter.ai/keys &lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;access 100+ models&lt;span class=&quot;o&quot;&gt;)&lt;/span&gt;
  - Ollama:     https://ollama.com &lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;local&lt;/span&gt;, free&lt;span class=&quot;o&quot;&gt;)&lt;/span&gt;

  See README.md &lt;span class=&quot;k&quot;&gt;for &lt;/span&gt;17+ supported providers.

  2. Chat: picoclaw agent &lt;span class=&quot;nt&quot;&gt;-m&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;Hello!&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;hr /&gt;

&lt;h2 id=&quot;4-the-ultimate-config-file-walkthrough-copy-paste-zone&quot;&gt;4. The Ultimate Config File Walkthrough (Copy-Paste Zone)&lt;/h2&gt;

&lt;p&gt;After initialization, open &lt;strong&gt;FileZilla&lt;/strong&gt;, connect to the Raspberry Pi, find &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/home/pi/.picoclaw/config.json&lt;/code&gt;, and right-click to edit it.&lt;/p&gt;

&lt;p&gt;You can copy the configuration below — which I’ve verified runs perfectly — straight in and overwrite the original contents (&lt;strong&gt;remember to replace the places with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;XXXX&lt;/code&gt;&lt;/strong&gt;).&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Note: standard JSON doesn’t support &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;//&lt;/code&gt; comments, but I kept them to make things easier to understand. If you get an error when running it, delete the comments manually)&lt;/em&gt;:&lt;/p&gt;

&lt;p&gt;JSON&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
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
&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;session&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;dm_scope&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;per-channel-peer&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;nl&quot;&gt;&quot;agents&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;defaults&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;workspace&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;~/.picoclaw/workspace&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;restrict_to_workspace&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;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;provider&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;openai&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;volcengine&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;max_tokens&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;mi&quot;&gt;32768&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;max_tool_iterations&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;mi&quot;&gt;50&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;span class=&quot;nl&quot;&gt;&quot;channels&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;feishu&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;enabled&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;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;app_id&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;cli_XXXXXXXXXXXXXXXXX&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;err&quot;&gt;//&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;Feishu&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;app_id&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;app_secret&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;XXXXXXXXXXXXXXXXX&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;err&quot;&gt;//&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;Feishu&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;app_secret&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;encrypt_key&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;picoclaw&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;verification_token&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;uEWQaEyh48hQdKXXXXXXXXXXXXXXXX&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;err&quot;&gt;//&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;Feishu&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;verification_token&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;allow_from&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;group_trigger&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;reasoning_channel_id&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;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;maixcam&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;enabled&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;host&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;0.0.0.0&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;port&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;mi&quot;&gt;18790&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;qq&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;enabled&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;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;dingtalk&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;enabled&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;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;wecom&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;enabled&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;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;wecom_app&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;enabled&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;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;pico&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;enabled&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;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;span class=&quot;nl&quot;&gt;&quot;model_list&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;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_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;volcengine&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;openai/kimi-k2.5&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;err&quot;&gt;//&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;Pick&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;a&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;from&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;your&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;LLM&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;list&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;api_base&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;https://ark.cn-beijing.volces.com/api/coding/v3&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;err&quot;&gt;//&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;This&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;is&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;Volcengine&apos;s&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;API&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;address&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;api_key&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;11XXXX72-XXXX-XXXX-XXXX-XXXXXXXXX&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;//&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;This&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;is&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;Volcengine&apos;s&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;API&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;err&quot;&gt;key&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;span class=&quot;nl&quot;&gt;&quot;gateway&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;host&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;127.0.0.1&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;port&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;mi&quot;&gt;18790&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;tools&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;web&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;duckduckgo&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;enabled&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;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;max_results&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;mi&quot;&gt;5&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;span class=&quot;nl&quot;&gt;&quot;cron&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;exec_timeout_minutes&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;mi&quot;&gt;5&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;exec&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;enable_deny_patterns&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;skills&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;registries&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;clawhub&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;enabled&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;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;base_url&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;https://clawhub.ai&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;span class=&quot;nl&quot;&gt;&quot;max_concurrent_searches&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;mi&quot;&gt;2&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;media_cleanup&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;enabled&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;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
      &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;max_age_minutes&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;mi&quot;&gt;30&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;interval_minutes&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;mi&quot;&gt;5&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;span class=&quot;nl&quot;&gt;&quot;heartbeat&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;enabled&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;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;interval&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;mi&quot;&gt;30&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;h3 id=&quot;-where-to-find-the-keys&quot;&gt;🔑 Where to Find the Keys&lt;/h3&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Feishu credentials (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;app_id&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;app_secret&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;verification_token&lt;/code&gt;)&lt;/strong&gt;: Go to the &lt;a href=&quot;https://open.feishu.cn/app/&quot;&gt;Feishu Developer Console&lt;/a&gt; -&amp;gt; your app -&amp;gt; the &lt;strong&gt;“Credentials &amp;amp; Basic Info”&lt;/strong&gt; and &lt;strong&gt;“Events &amp;amp; Callbacks”&lt;/strong&gt; pages in the left menu.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;LLM credentials (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;api_key&lt;/code&gt;)&lt;/strong&gt;: Go to the &lt;a href=&quot;https://console.volcengine.com/ark/&quot;&gt;Volcengine AI Console&lt;/a&gt; -&amp;gt; &lt;strong&gt;“API Key Management”&lt;/strong&gt;.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;5-feishu-developer-console-permissions-and-event-configuration-️-very-easy-to-get-wrong&quot;&gt;5. Feishu developer console permissions and event configuration (⚠️ very easy to get wrong)&lt;/h2&gt;

&lt;p&gt;This is the key step that determines whether your bot can “speak”. Countless people get stuck here and end up with “read but no reply”. Be sure to follow these steps in order:&lt;/p&gt;

&lt;h3 id=&quot;step-1-give-the-bot-a-soul-extremely-easy-to-miss&quot;&gt;Step 1: Give the “bot” a soul (extremely easy to miss!)&lt;/h3&gt;

&lt;p&gt;After creating the app, the first thing you need to do is tell Feishu that it’s a bot.&lt;/p&gt;

&lt;p&gt;In the left menu of the developer console, find &lt;strong&gt;“Add app capabilities”&lt;/strong&gt;, then find the &lt;strong&gt;“Bot”&lt;/strong&gt; card, and click &lt;strong&gt;Add&lt;/strong&gt;. If you skip this step, all the permission setup that follows is wasted effort.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/markdown-img/shisaq/2026-03-13-09-54-59-HdlvKT.png&quot; alt=&quot;Feishu bot permission activation&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;step-2-get-the-raspberry-pi-running-first&quot;&gt;Step 2: Get the Raspberry Pi “running” first&lt;/h3&gt;

&lt;p&gt;Feishu’s long-connection mechanism requires: &lt;strong&gt;you must start the gateway locally first; only then can the Feishu backend successfully detect your long connection!&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Run this in the Raspberry Pi terminal and leave it running:&lt;/p&gt;

&lt;p&gt;Bash&lt;/p&gt;

&lt;div class=&quot;language-sh 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;picoclaw gateway
&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;step-3-enable-the-long-connection-subscription-mode&quot;&gt;Step 3: Enable the “long connection” subscription mode&lt;/h3&gt;

&lt;p&gt;In the Feishu developer console, go to &lt;strong&gt;“Events and callbacks”&lt;/strong&gt; in the left menu -&amp;gt; find &lt;strong&gt;“Subscription method”&lt;/strong&gt;, and click the small edit icon next to it.&lt;/p&gt;

&lt;p&gt;Select &lt;strong&gt;“Receive events via long connection (recommended)”&lt;/strong&gt;, then save.&lt;/p&gt;

&lt;h3 id=&quot;step-4-configure-exactly-4-core-permissions-no-admin-review-needed&quot;&gt;Step 4: Configure exactly 4 core permissions (no admin review needed)&lt;/h3&gt;

&lt;p&gt;In the left menu, go to &lt;strong&gt;“Permission management”&lt;/strong&gt;. To get basic direct-message and group @-mention replies working, while trying to avoid triggering the complicated enterprise admin approval process, search for and enable only these 4 permissions (all under “app identity”):&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;im:message&lt;/code&gt;&lt;/strong&gt; (get and send direct and group messages)&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;im:message.group_at_msg:readonly&lt;/code&gt;&lt;/strong&gt; (receive @bot message events in group chats)&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;im:message.p2p_msg:readonly&lt;/code&gt;&lt;/strong&gt; (read direct messages users send to the bot)&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;im:message:send_as_bot&lt;/code&gt;&lt;/strong&gt; (send messages as the app)&lt;/p&gt;

    &lt;p&gt;&lt;img src=&quot;https://uulili.com/img/markdown-img/shisaq/2026-03-09-16-14-58-OIFbGh.png&quot; alt=&quot;Feishu permissions&quot; /&gt;
 For convenience, you can also copy the JSON below and import the permissions in bulk, which saves you from repeatedly applying for and enabling them:&lt;/p&gt;
  &lt;/li&gt;
&lt;/ol&gt;

&lt;pre&gt;&lt;code class=&quot;language-JSON&quot;&gt;{
  &quot;scopes&quot;: {
    &quot;tenant&quot;: [
      &quot;contact:contact.base:readonly&quot;,
      &quot;docx:document:readonly&quot;,
      &quot;im:chat:read&quot;,
      &quot;im:chat:update&quot;,
      &quot;im:message.group_at_msg:readonly&quot;,
      &quot;im:message.p2p_msg:readonly&quot;,
      &quot;im:message.pins:read&quot;,
      &quot;im:message.pins:write_only&quot;,
      &quot;im:message.reactions:read&quot;,
      &quot;im:message.reactions:write_only&quot;,
      &quot;im:message:readonly&quot;,
      &quot;im:message:recall&quot;,
      &quot;im:message:send_as_bot&quot;,
      &quot;im:message:send_multi_users&quot;,
      &quot;im:message:send_sys_msg&quot;,
      &quot;im:message:update&quot;,
      &quot;im:resource&quot;,
      &quot;application:application:self_manage&quot;,
      &quot;cardkit:card:write&quot;,
      &quot;cardkit:card:read&quot;
    ],
    &quot;user&quot;: [
      &quot;contact:user.employee_id:readonly&quot;,
      &quot;offline_access&quot;,&quot;base:app:copy&quot;,
      &quot;base:field:create&quot;,
      &quot;base:field:delete&quot;,
      &quot;base:field:read&quot;,
      &quot;base:field:update&quot;,
      &quot;base:record:create&quot;,
      &quot;base:record:delete&quot;,
      &quot;base:record:retrieve&quot;,
      &quot;base:record:update&quot;,
      &quot;base:table:create&quot;,
      &quot;base:table:delete&quot;,
      &quot;base:table:read&quot;,
      &quot;base:table:update&quot;,
      &quot;base:view:read&quot;,
      &quot;base:view:write_only&quot;,
      &quot;base:app:create&quot;,
      &quot;base:app:update&quot;,
      &quot;base:app:read&quot;,
      &quot;board:whiteboard:node:create&quot;,
      &quot;board:whiteboard:node:read&quot;,
      &quot;calendar:calendar:read&quot;,
      &quot;calendar:calendar.event:create&quot;,
      &quot;calendar:calendar.event:delete&quot;,
      &quot;calendar:calendar.event:read&quot;,
      &quot;calendar:calendar.event:reply&quot;,
      &quot;calendar:calendar.event:update&quot;,
      &quot;calendar:calendar.free_busy:read&quot;,
      &quot;contact:contact.base:readonly&quot;,
      &quot;contact:user.base:readonly&quot;,
      &quot;contact:user:search&quot;,
      &quot;docs:document.comment:create&quot;,
      &quot;docs:document.comment:read&quot;,
      &quot;docs:document.comment:update&quot;,
      &quot;docs:document.media:download&quot;,
      &quot;docs:document:copy&quot;,
      &quot;docx:document:create&quot;,
      &quot;docx:document:readonly&quot;,
      &quot;docx:document:write_only&quot;,
      &quot;drive:drive.metadata:readonly&quot;,
      &quot;drive:file:download&quot;,
      &quot;drive:file:upload&quot;,
      &quot;im:chat.members:read&quot;,
      &quot;im:chat:read&quot;,
      &quot;im:message&quot;,
      &quot;im:message.group_msg:get_as_user&quot;,
      &quot;im:message.p2p_msg:get_as_user&quot;,
      &quot;im:message:readonly&quot;,
      &quot;search:docs:read&quot;,
      &quot;search:message&quot;,
      &quot;space:document:delete&quot;,
      &quot;space:document:move&quot;,
      &quot;space:document:retrieve&quot;,
      &quot;task:comment:read&quot;,
      &quot;task:comment:write&quot;,
      &quot;task:task:read&quot;,
      &quot;task:task:write&quot;,
      &quot;task:task:writeonly&quot;,
      &quot;task:tasklist:read&quot;,
      &quot;task:tasklist:write&quot;,
      &quot;wiki:node:copy&quot;,
      &quot;wiki:node:create&quot;,
      &quot;wiki:node:move&quot;,
      &quot;wiki:node:read&quot;,
      &quot;wiki:node:retrieve&quot;,
      &quot;wiki:space:read&quot;,
      &quot;wiki:space:retrieve&quot;,
      &quot;wiki:space:write_only&quot;
    ]
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;img src=&quot;https://uulili.com/img/markdown-img/shisaq/2026-03-09-16-15-27-gzKKzu.png&quot; alt=&quot;2026-03-09-16-15-27-gzKKzu&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;step-5-subscribe-to-message-events-so-the-bot-can-hear&quot;&gt;Step 5: Subscribe to message events (so the bot can “hear”)&lt;/h3&gt;

&lt;p&gt;Go back to the &lt;strong&gt;“Events and callbacks”&lt;/strong&gt; page and click the &lt;strong&gt;“Add event”&lt;/strong&gt; button on the right.&lt;/p&gt;

&lt;p&gt;Search for and select &lt;strong&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Receive messages&lt;/code&gt;&lt;/strong&gt; (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;im.message.receive_v1&lt;/code&gt;).&lt;/p&gt;

&lt;h3 id=&quot;step-6-publish-a-version-the-crucial-final-step&quot;&gt;Step 6: Publish a version (the crucial final step!)&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Feishu’s strict rule: any permission or event you change will not take effect until you publish it!&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In the left menu, go to &lt;strong&gt;“Version management and release”&lt;/strong&gt; -&amp;gt; click &lt;strong&gt;“Create version”&lt;/strong&gt; -&amp;gt; enter any version number (e.g. 1.0.0) -&amp;gt; save, then click &lt;strong&gt;“Request release”&lt;/strong&gt;.&lt;/p&gt;

&lt;hr /&gt;

&lt;h2 id=&quot;6-running-as-a-daemon-so-it-never-goes-offline&quot;&gt;6. Running as a daemon (so it never goes offline)&lt;/h2&gt;

&lt;p&gt;The gateway you’re running in the terminal now will disconnect as soon as you close the terminal window. We need to turn it into a background “guardian” on the Raspberry Pi.&lt;/p&gt;

&lt;p&gt;First press &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Ctrl + C&lt;/code&gt; in the Raspberry Pi terminal running &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;picoclaw gateway&lt;/code&gt; to stop it. Then copy the whole block below in one go and press Enter to create a system service:&lt;/p&gt;

&lt;p&gt;Bash&lt;/p&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
9
10
11
12
13
14
15
16
17
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;sudo tee /etc/systemd/system/picoclaw.service &amp;lt;&amp;lt; &apos;EOF&apos;
[Unit]
Description=PicoClaw Gateway Service
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi
ExecStart=/usr/bin/picoclaw gateway
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
EOF
&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;Activate and start the service (run these in order):&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Bash&lt;/p&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
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;sudo systemctl daemon-reload
sudo systemctl enable picoclaw
sudo systemctl start picoclaw
&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;From now on, if you want to see the AI’s runtime logs, just run:&lt;/p&gt;

&lt;p&gt;Bash&lt;/p&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
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;journalctl -u picoclaw -f
&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;hr /&gt;

&lt;h2 id=&quot;7-troubleshooting-common-problems-faq&quot;&gt;7. Troubleshooting common problems (FAQ)&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: When I log in via SSH, I get &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Host key verification failed&lt;/code&gt; and a screenful of uppercase warnings?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Cause&lt;/strong&gt;: You may have assigned the Raspberry Pi an IP address that was previously used by another device, and your computer’s security mechanism thinks it has encountered an impersonator.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: In your &lt;strong&gt;computer’s terminal (note: not on the Raspberry Pi)&lt;/strong&gt;, run &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ssh-keygen -R &amp;lt;the Raspberry Pi&apos;s IP address&amp;gt;&lt;/code&gt; to remove the old entry.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Q: When I start the gateway, I get &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;feishu channel is not supported on 32-bit&lt;/code&gt;?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Cause&lt;/strong&gt;: The Raspberry Pi system was installed incorrectly (as a 32-bit system), or your Raspberry Pi board is too old (such as an old Zero W) and doesn’t support 64-bit at all.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: Switch to a 3B or newer board and re-flash a 64-bit system with Imager.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Q: The terminal shows &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HEARTBEAT_OK&lt;/code&gt;, but messages sent in Feishu get no response, and the terminal shows no &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;receive&lt;/code&gt; logs either?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Cause&lt;/strong&gt;: The LLM API connection works, but the Feishu server isn’t pushing messages to you.&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: Go back to &lt;strong&gt;Section 5&lt;/strong&gt; of the tutorial and focus on checking whether you added the “receive message” event, and &lt;strong&gt;be sure to publish a new version&lt;/strong&gt;.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;&lt;em&gt;(End of article)&lt;/em&gt;&lt;/p&gt;
</description>
        <pubDate>Mon, 09 Mar 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/en/2026/03/09/picoclaw-feishu-bot-raspberry-pi/</link>
        <guid isPermaLink="true">https://uulili.com/en/2026/03/09/picoclaw-feishu-bot-raspberry-pi/</guid>
        
        <category>PicoClaw</category>
        
        <category>Feishu</category>
        
        <category>Raspberry Pi</category>
        
        <category>AI assistant</category>
        
        <category>deployment</category>
        
      </item>
    
      <item>
        <title>Become Siri: A Raspberry Pi Physical Walkie-Talkie Evolution Guide</title>
        <description>&lt;h2 id=&quot;1-architecture-single-source-of-truth&quot;&gt;1. Architecture: Single Source of Truth&lt;/h2&gt;

&lt;p&gt;To build a voice assistant on a severely resource-constrained Raspberry Pi, this design abandons the traditional continuously-listening daemon model and instead adopts a geeky &lt;strong&gt;“event-driven + direct CLI calls”&lt;/strong&gt; architecture.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;One brain, many endpoints&lt;/strong&gt;: A Python script hijacks the physical button and audio peripherals, calling &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;picoclaw agent&lt;/code&gt;’s in single-run (CLI) mode directly on the local machine. This means the physical walkie-talkie on the desk and the Feishu (a Chinese workplace collaboration app) bot in the cloud &lt;strong&gt;share the same LLM brain, the same Feishu credentials and context, and the same local sandbox workspace (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.picoclaw/workspace&lt;/code&gt;)&lt;/strong&gt;.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Extremely lightweight&lt;/strong&gt;: The low-level components wake up only at the moment the button is pressed. After the button is released, the audio stream quickly passes through Volcano Engine (STT) -&amp;gt; local PicoClaw (LLM) -&amp;gt; Microsoft Azure (TTS), with very low memory usage throughout.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;2-key-pitfalls-and-fixes&quot;&gt;2. Key Pitfalls and Fixes&lt;/h2&gt;

&lt;p&gt;In this cross-domain work connecting low-level hardware audio to the LLM’s plain text, we hit and solved these very subtle hidden system-level pitfalls:&lt;/p&gt;

&lt;h3 id=&quot;21-the-physical-buttons-ghost-repeated-presses-mechanical-bouncing&quot;&gt;2.1 The Physical Button’s “Ghost Repeated Presses” (Mechanical Bouncing)&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Symptom&lt;/strong&gt;: A one-second press made the program report &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[系统忙碌]&lt;/code&gt; (system busy) countless times, and even got the recording process killed instantly, causing a fatal error.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Solution&lt;/strong&gt;: This came from the physical bounce of the arcade button’s metal microswitch. During &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gpiozero&lt;/code&gt; initialization, we explicitly set the hardware debounce parameter &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bounce_time=0.3&lt;/code&gt;, so the system ignores all spurious signals within 300 milliseconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;22-audio-lockup-and-swallowed-words-buffer-overrun&quot;&gt;2.2 Audio Lockup and “Swallowed Words” (Buffer Overrun)&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Symptom&lt;/strong&gt;: Frequent &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;overrun!!!&lt;/code&gt; (buffer overflow) errors, or the first one or two words of every utterance (such as “Beijing”) always getting missed; and even after the script crashed, zombie processes could leave the microphone permanently locked.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Solution&lt;/strong&gt;:
    &lt;ol&gt;
      &lt;li&gt;&lt;strong&gt;Startup nuclear cleanup&lt;/strong&gt;: Added &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;killall -9 arecord mpg123&lt;/code&gt; at the top of the Python script to force-kill orphan processes on every launch, so the sound card is completely clean.&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;Very large buffer&lt;/strong&gt;: Added the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--buffer-time=250000&lt;/code&gt; parameter to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;arecord&lt;/code&gt;, completely eliminating frame-drop warnings.&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;Visual foolproofing&lt;/strong&gt;: Turn on the LED after a delay. After the low-level hardware has been powered on and stable for 0.5 seconds, the light comes on to prompt the user to speak, which perfectly avoids the “swallowed words” window during hardware cold start.&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;Graceful release&lt;/strong&gt;: When stopping recording, use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;send_signal(signal.SIGINT)&lt;/code&gt; instead of calling &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;terminate()&lt;/code&gt;, ensuring the end of the WAV file is properly closed and the ALSA hardware lock is released.&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;23-mental-pollution-in-llm-output-and-tts-crashes&quot;&gt;2.3 “Mental Pollution” in LLM Output and TTS Crashes&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Symptom&lt;/strong&gt;: PicoClaw’s CLI output included lots of ASCII art logos and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[INFO]&lt;/code&gt; system logs; and the LLM would sometimes output emoji such as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;🦞&lt;/code&gt; or Markdown tables, causing the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;edge-tts&lt;/code&gt; speech synthesis engine to crash and stop working.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Solution&lt;/strong&gt;:
    &lt;ol&gt;
      &lt;li&gt;&lt;strong&gt;Layered stripping&lt;/strong&gt;: Split the output by line and discard everything before the last &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[INFO]&lt;/code&gt; log line.&lt;/li&gt;
      &lt;li&gt;&lt;strong&gt;Regex cleaning&lt;/strong&gt;: Before sending to the speech engine, use the regex &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;re.sub&lt;/code&gt; to aggressively strip all non-Chinese/English characters and basic punctuation, ensuring smooth and natural speech playback.&lt;/li&gt;
    &lt;/ol&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;24-time-hallucinations-across-time-zones&quot;&gt;2.4 “Time Hallucinations” Across Time Zones&lt;/h3&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Symptom&lt;/strong&gt;: The LLM has no concept of system time and makes wild guesses when asked about “tomorrow.”&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Solution&lt;/strong&gt;: &lt;strong&gt;System Prompt Injection&lt;/strong&gt;. When the Python script assembles the query text, it uses the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;datetime&lt;/code&gt; module to calculate US Eastern Time and Beijing Time in real time, and forcibly prepends them as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[系统隐藏设定]&lt;/code&gt; (hidden system settings) before the user’s voice input. This not only eliminates hallucinations but also gives the LLM the ability to handle business across two time zones perfectly.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;h3 id=&quot;25-installing-dependencies&quot;&gt;2.5 Installing Dependencies&lt;/h3&gt;

&lt;p&gt;After reflashing the Raspberry Pi (e.g., switching to the latest Debian 12 Bookworm Lite), the original environment is completely wiped. Follow these steps in order, and the walkie-talkie will be back up and running within 5 minutes.&lt;/p&gt;

&lt;h4 id=&quot;251-installing-low-level-system-software&quot;&gt;2.5.1 Installing Low-Level System Software&lt;/h4&gt;

&lt;p&gt;Update the package lists and install the basic audio-processing and process-management tools, plus the Python package manager:&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
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;apt update
&lt;span class=&quot;c&quot;&gt;# Install audio player (mpg123), recording driver set (alsa-utils), process force-kill tool (psmisc), and pip3&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;apt &lt;span class=&quot;nb&quot;&gt;install&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-y&lt;/span&gt; mpg123 alsa-utils psmisc python3-pip
&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;252-installing-core-python-driver-libraries&quot;&gt;2.5.2 Installing Core Python Driver Libraries&lt;/h3&gt;

&lt;p&gt;Use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pip3&lt;/code&gt; to install the required third-party libraries.
&lt;em&gt;(Note: In the latest Raspberry Pi OS, the system restricts direct use of pip to protect the global environment; you must append the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--break-system-packages&lt;/code&gt; parameter to force-install into the user directory)&lt;/em&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;pip3 &lt;span class=&quot;nb&quot;&gt;install &lt;/span&gt;requests gpiozero edge-tts &lt;span class=&quot;nt&quot;&gt;--break-system-packages&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;253-fixing-the-environment-variables-resolving-the-command-not-found-warning&quot;&gt;2.5.3 Fixing the Environment Variables (Resolving the “Command Not Found” Warning)&lt;/h3&gt;

&lt;p&gt;The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;edge-tts&lt;/code&gt; installed in the previous step goes into a hidden user-level binary directory. You need to add it to the system PATH:&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
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;c&quot;&gt;# Append the user&apos;s private bin directory to PATH and write it to the bash config file&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;echo&lt;/span&gt; &lt;span class=&quot;s1&quot;&gt;&apos;export PATH=&quot;$HOME/.local/bin:$PATH&quot;&apos;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.bashrc
&lt;span class=&quot;c&quot;&gt;# Make the config take effect immediately&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;source&lt;/span&gt; ~/.bashrc
&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;Verification test&lt;/strong&gt;: Type &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;edge-tts --version&lt;/code&gt; in the terminal; if it prints the version number, the configuration worked.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;254-restoring-the-picoclaw-core-and-workspace&quot;&gt;2.5.4 Restoring the PicoClaw Core and Workspace&lt;/h3&gt;

&lt;p&gt;We need to put the walkie-talkie’s brain back:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Main program in place&lt;/strong&gt;: Move the compiled &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;picoclaw&lt;/code&gt; binary to &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/usr/local/bin/&lt;/code&gt; and make it executable (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sudo chmod +x /usr/local/bin/picoclaw&lt;/code&gt;).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Rebuild the workspace&lt;/strong&gt;: Recreate the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.picoclaw/workspace/&lt;/code&gt; directory and copy the previously backed-up &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;skills&lt;/code&gt; folder (such as the Feishu and search plugins) and configuration files back exactly as they were.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&quot;255-hardware-sound-card-recovery-easy-to-miss&quot;&gt;2.5.5 Hardware Sound Card Recovery (Easy to Miss)&lt;/h3&gt;

&lt;p&gt;On a clean system, an external I2S microphone array (such as the Google Voice HAT) is in a &lt;strong&gt;physically muted&lt;/strong&gt; state by default:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Remount the driver&lt;/strong&gt;: Modify &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/boot/firmware/config.txt&lt;/code&gt; per the official documentation to enable I2S, and configure the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.asoundrc&lt;/code&gt; file.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Unmute the low-level controls and set the volume&lt;/strong&gt;:&lt;/li&gt;
&lt;/ol&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;alsamixer
&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;Press &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;F4&lt;/code&gt; to switch to the Capture screen, select the microphone, press &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Space&lt;/code&gt; to clear the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MM&lt;/code&gt; (muted) state, and raise the gain.&lt;/li&gt;
  &lt;li&gt;Press &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;F3&lt;/code&gt; to switch to the Playback screen, and use the arrow keys to lower the playback volume to 40%–50% to avoid distortion.&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;Persist the volume configuration&lt;/strong&gt;: Run &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sudo alsactl store&lt;/code&gt; to ensure the volume settings are not lost after a reboot.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;At this point, the software and hardware environment has been fully restored. Combined with the Systemd daemon configuration, the system can go back to running fully automatically.&lt;/p&gt;

&lt;h2 id=&quot;3-complete-integration-script-walkie_talkiepy&quot;&gt;3. Complete Integration Script (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;walkie_talkie.py&lt;/code&gt;)&lt;/h2&gt;

&lt;p&gt;This script already includes all the bulletproof safeguards, timezone injection, and exception handling, and can run directly as a production daemon.&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
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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;subprocess&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;os&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;requests&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;threading&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;base64&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;uuid&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;re&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;time&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;signal&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;datetime&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;datetime&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;timezone&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;timedelta&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;gpiozero&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Button&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;LED&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;signal&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pause&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;🧹 Cleaning up any leftover low-level audio processes...&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;c1&quot;&gt;# Force-kill leftover processes before startup to release the ALSA hardware lock
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;subprocess&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;s&quot;&gt;&quot;killall&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;-q&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;-9&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;arecord&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;mpg123&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;edge-tts&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;stderr&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;subprocess&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DEVNULL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# ================= 1. Hardware and path configuration =================
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;BUTTON_PIN&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;23&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;LED_PIN&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;25&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# Use PicoClaw&apos;s official standard workspace
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WORKSPACE&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;expanduser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;~/.picoclaw/workspace&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;makedirs&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WORKSPACE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;exist_ok&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;AUDIO_IN&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;join&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WORKSPACE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;voice_in.wav&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;AUDIO_OUT&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;join&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;WORKSPACE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;voice_out.mp3&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# ================= 2. Volcano Engine (turbo large-model STT) configuration =================
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;VOLC_APPID&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;replace_with_your_APP_ID&quot;&lt;/span&gt;  
&lt;span class=&quot;n&quot;&gt;VOLC_TOKEN&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;replace_with_your_ACCESS_TOKEN&quot;&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# ================= 3. Global state initialization =================
# Core: bounce_time=0.3 handles physical button bounce
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;button&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Button&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;BUTTON_PIN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;bounce_time&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;led&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;LED&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;LED_PIN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;record_proc&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;is_processing&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;False&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# concurrency lock
&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;start_recording&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;global&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;record_proc&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;is_processing&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;is_processing&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;⏳ [System busy] Still processing the previous command...&quot;&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;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;exists&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AUDIO_IN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;remove&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AUDIO_IN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        
    &lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;⏳ [Preparing] Waking up the microphone base...&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    
    &lt;span class=&quot;c1&quot;&gt;# --buffer-time=250000 fixes overruns; DEVNULL suppresses interrupt errors
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;cmd&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;&quot;arecord&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;--buffer-time=250000&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;-f&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;S16_LE&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;-r&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;16000&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;-c&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;1&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;AUDIO_IN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;-q&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;record_proc&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;subprocess&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Popen&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;cmd&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;stderr&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;subprocess&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;DEVNULL&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;c1&quot;&gt;# Delay turning on the LED: let the hardware power up and stabilize so the first word isn&apos;t swallowed
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sleep&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;led&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;🟢 [Recording] Microphone ready. Speak, then release to stop...&quot;&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;stop_recording&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;global&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;record_proc&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;is_processing&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;is_processing&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;record_proc&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;is&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&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;c1&quot;&gt;# Small buffer before stopping so the tail isn&apos;t cut off
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sleep&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;# Use SIGINT to exit gracefully, ensuring the WAV header is written and the sound card is released
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;record_proc&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;send_signal&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;signal&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SIGINT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;record_proc&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;wait&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;record_proc&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;
    
    &lt;span class=&quot;n&quot;&gt;led&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;off&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;🛑 [Released] Recording ended; handing it off to the processing center...&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    
    &lt;span class=&quot;n&quot;&gt;is_processing&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;n&quot;&gt;threading&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Thread&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;process_audio_pipeline&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;start&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;process_audio_pipeline&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;global&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;is_processing&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;led&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;blink&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;on_time&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;off_time&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; 

    &lt;span class=&quot;k&quot;&gt;try&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;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;exists&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AUDIO_IN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;❌ Fatal error: the recording file was not created!&quot;&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;c1&quot;&gt;# ================= STT (Volcano Engine turbo recognition) =================
&lt;/span&gt;        &lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;🔄 Requesting Volcano Engine STT...&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;user_text&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&quot;&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;with&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;AUDIO_IN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;rb&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;audio_file&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;base64_audio&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;base64&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;b64encode&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;audio_file&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;read&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;decode&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;utf-8&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            
            &lt;span class=&quot;n&quot;&gt;headers&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;&quot;X-Api-App-Key&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;VOLC_APPID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&quot;X-Api-Access-Key&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;VOLC_TOKEN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&quot;X-Api-Resource-Id&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;volc.bigasr.auc_turbo&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&quot;X-Api-Request-Id&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;uuid&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;uuid4&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()),&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&quot;X-Api-Sequence&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;-1&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&quot;Content-Type&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;application/json&quot;&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;payload&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;&quot;user&quot;&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;s&quot;&gt;&quot;uid&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;VOLC_APPID&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&quot;audio&quot;&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;s&quot;&gt;&quot;data&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;base64_audio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
                &lt;span class=&quot;s&quot;&gt;&quot;request&quot;&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;s&quot;&gt;&quot;model_name&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;bigmodel&quot;&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;n&quot;&gt;recognize_url&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;https://openspeech.bytedance.com/api/v3/auc/bigmodel/recognize/flash&quot;&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;requests&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;post&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;recognize_url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;timeout&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;10&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            
            &lt;span class=&quot;n&quot;&gt;status_code&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;X-Api-Status-Code&quot;&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;status_code&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;20000000&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;user_text&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;().&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;result&quot;&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;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;text&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&quot;&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;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user_text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user_text&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;I heard something, but couldn&apos;t make out the words.&quot;&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;elif&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;status_code&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;20000003&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user_text&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;It sounds like you didn&apos;t say anything.&quot;&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user_text&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;My ears are on strike for now.&quot;&lt;/span&gt;

        &lt;span class=&quot;k&quot;&gt;except&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Exception&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;user_text&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Network connection failed.&quot;&lt;/span&gt;

        &lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;👤 You said: &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user_text&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

        &lt;span class=&quot;c1&quot;&gt;# ================= AI thinking (PicoClaw dispatch center) =================
&lt;/span&gt;        &lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;🧠 Waking up the local PicoClaw process...&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;c1&quot;&gt;# Inject both time zones dynamically to prevent time hallucination
&lt;/span&gt;            &lt;span class=&quot;n&quot;&gt;utc_dt&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;datetime&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;now&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;timezone&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;utc&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;us_east_dt&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;utc_dt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;astimezone&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;timezone&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;timedelta&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;hours&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;4&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)))&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;beijing_dt&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;utc_dt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;astimezone&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;timezone&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;timedelta&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;hours&lt;/span&gt;&lt;span class=&quot;o&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;n&quot;&gt;weekdays&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;&quot;Monday&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Tuesday&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Wednesday&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Thursday&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Friday&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Saturday&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Sunday&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;time_context&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;[Current system time: US Eastern &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;us_east_dt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;strftime&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;%m-%d %H&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;%&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;M&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;)&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;weekdays&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;us_east_dt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;weekday&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()]&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;) | Beijing &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;beijing_dt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;strftime&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;%m-%d %H&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;%&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;M&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;)&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;weekdays&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;beijing_dt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;weekday&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()]&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;)]&quot;&lt;/span&gt;
            
            &lt;span class=&quot;c1&quot;&gt;# Build the prompt with the tool-dispatch rules
&lt;/span&gt;            &lt;span class=&quot;n&quot;&gt;voice_prompt&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;time_context&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;
User command: &quot;&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;user_text&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;

[System-enforced rules (voice assistant mode)]:
1. [Minimal and fast] You are a capable, efficient personal voice assistant. Reply straight to the point, strictly under 100 characters and at most one or two sentences. (Note: the fewer the characters, the lower the system&apos;s voice response latency!)
2. [No AI tone] Give the answer directly! Never use robotic filler like &quot;I found this for you&quot;, &quot;according to my search&quot; or &quot;I tested it and found&quot;, and don&apos;t throw questions back at the user.
3. [Silent invocation] For things like traffic restrictions, weather and news, silently call the built-in tools, then state the key conclusion directly once you have the data.
4. [Clear pronunciation] Absolutely no Markdown (such as **, -, #), tables, numbered lists, Emoji or unusual symbols; output only plain Chinese characters and basic punctuation that can be read aloud smoothly.&quot;&quot;&quot;&lt;/span&gt;

            &lt;span class=&quot;n&quot;&gt;cmd&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;&quot;picoclaw&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;agent&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;-m&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;voice_prompt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;result&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;subprocess&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;check_output&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;cmd&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;text&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;stderr&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;subprocess&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;STDOUT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            
            &lt;span class=&quot;c1&quot;&gt;# Strip PicoClaw CLI&apos;s ASCII logo and system logs
&lt;/span&gt;            &lt;span class=&quot;n&quot;&gt;lines&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;result&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;split&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;last_log_idx&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;line&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;enumerate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;lines&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;s&quot;&gt;&quot;[INFO]&quot;&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;line&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;[WARN]&quot;&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;line&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;or&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;[ERROR]&quot;&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;line&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                    &lt;span class=&quot;n&quot;&gt;last_log_idx&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;
            
            &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;last_log_idx&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;and&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;last_log_idx&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;len&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;lines&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;ai_reply&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;join&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;lines&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;last_log_idx&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:]).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;strip&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;ai_reply&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;join&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;([&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;l&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;l&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;result&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;split&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;&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;s&quot;&gt;&quot;██&quot;&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;l&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;strip&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;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ai_reply&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ai_reply&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;The brain finished but returned no text.&quot;&lt;/span&gt;
                 
        &lt;span class=&quot;k&quot;&gt;except&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Exception&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;ai_reply&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;Failed to connect to the brain; please check that it&apos;s running.&quot;&lt;/span&gt;

        &lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;🤖 PicoClaw raw reply: &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ai_reply&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        
        &lt;span class=&quot;c1&quot;&gt;# ================= TTS and audio playback =================
&lt;/span&gt;        &lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;🔊 Cleaning text and synthesizing speech...&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        
        &lt;span class=&quot;c1&quot;&gt;# Final cleanup: strip Emoji and other illegal characters that would crash TTS
&lt;/span&gt;        &lt;span class=&quot;n&quot;&gt;clean_text&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;re&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sub&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;r&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;[^\u4e00-\u9fa5A-Za-z0-9，。！？、！\?,\.～~]&apos;&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;n&quot;&gt;ai_reply&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;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;clean_text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;clean_text&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;The reply has no readable text.&quot;&lt;/span&gt;

        &lt;span class=&quot;n&quot;&gt;tts_cmd&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;&quot;python3&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;-m&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;edge_tts&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; 
            &lt;span class=&quot;s&quot;&gt;&quot;--text&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;clean_text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; 
            &lt;span class=&quot;s&quot;&gt;&quot;--voice&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;zh-CN-XiaoxiaoNeural&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;s&quot;&gt;&quot;--write-media&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;AUDIO_OUT&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
        
        &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;subprocess&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;tts_cmd&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;check&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;capture_output&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;text&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;led&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; 
            &lt;span class=&quot;n&quot;&gt;subprocess&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;s&quot;&gt;&quot;mpg123&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;AUDIO_OUT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;except&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;subprocess&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CalledProcessError&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;❌ Speech generation failed!&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;Error details: &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;stderr&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        
    &lt;span class=&quot;k&quot;&gt;except&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Exception&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;❌ Uncaught exception: &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;finally&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;led&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;off&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; 
        &lt;span class=&quot;n&quot;&gt;is_processing&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;False&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;✅ System reset; waiting for the next wake-up.&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;button&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;when_pressed&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;start_recording&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;button&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;when_released&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;stop_recording&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;🚀 Geek walkie-talkie hub is ready!&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;pause&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;hr /&gt;

&lt;h2 id=&quot;4-final-deployment-turning-it-into-a-background-daemon-daemonize&quot;&gt;4. Final Deployment: Turning It into a Background Daemon (Daemonize)&lt;/h2&gt;

&lt;p&gt;To fully free this hardware assistant from the SSH terminal and achieve “plug it in and use it, and recover automatically after power loss or reboot,” we need to use Linux’s native &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;systemd&lt;/code&gt; to register it as a system-level service.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;⚠️ &lt;strong&gt;Pitfall warning&lt;/strong&gt;: Never use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;rc.local&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;crontab&lt;/code&gt; to start audio-related scripts. Audio devices (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sound.target&lt;/code&gt;) load slowly, and only &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;systemd&lt;/code&gt; can precisely control startup order and restart the script automatically if it crashes unexpectedly.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;41-create-the-systemd-service-file&quot;&gt;4.1 Create the systemd service file&lt;/h3&gt;

&lt;p&gt;Assume your script is at &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/home/pi/walkie_talkie.py&lt;/code&gt;. Run the following command in the terminal to create the configuration file:&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;&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;nano /etc/systemd/system/picoclaw-voice.service

&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;Paste the following into it (note: if your username is not &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pi&lt;/code&gt;, or the script path is different, change them accordingly):&lt;/p&gt;

&lt;div class=&quot;language-ini 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
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;nn&quot;&gt;[Unit]&lt;/span&gt;
&lt;span class=&quot;py&quot;&gt;Description&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;PicoClaw Voice Assistant (Hardware Gateway)&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# Make sure this service starts after the network and the underlying sound card hardware are loaded
&lt;/span&gt;&lt;span class=&quot;py&quot;&gt;After&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;network.target sound.target&lt;/span&gt;

&lt;span class=&quot;nn&quot;&gt;[Service]&lt;/span&gt;
&lt;span class=&quot;py&quot;&gt;Type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;simple&lt;/span&gt;
&lt;span class=&quot;c&quot;&gt;# Must specify the pi user; otherwise, when run as root, the ~/.picoclaw path becomes /root/.picoclaw, causing all configuration to fail
&lt;/span&gt;&lt;span class=&quot;py&quot;&gt;User&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;pi&lt;/span&gt;
&lt;span class=&quot;py&quot;&gt;WorkingDirectory&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;/home/pi&lt;/span&gt;
&lt;span class=&quot;py&quot;&gt;ExecStart&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;/usr/bin/python3 /home/pi/walkie_talkie.py&lt;/span&gt;

&lt;span class=&quot;c&quot;&gt;# Crash auto-restart mechanism (try to restart every 5 seconds)
&lt;/span&gt;&lt;span class=&quot;py&quot;&gt;Restart&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;always&lt;/span&gt;
&lt;span class=&quot;py&quot;&gt;RestartSec&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;5&lt;/span&gt;

&lt;span class=&quot;c&quot;&gt;# [Extremely critical] Inject environment variables! Make sure the subprocess underneath the Python script can find picoclaw, edge-tts and arecord
&lt;/span&gt;&lt;span class=&quot;py&quot;&gt;Environment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;PATH=/home/pi/.local/bin:/usr/local/bin:/usr/bin:/bin&quot;&lt;/span&gt;
&lt;span class=&quot;py&quot;&gt;Environment&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;PYTHONUNBUFFERED=1&quot;&lt;/span&gt;

&lt;span class=&quot;nn&quot;&gt;[Install]&lt;/span&gt;
&lt;span class=&quot;py&quot;&gt;WantedBy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;multi-user.target&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;Save and exit (in nano, press &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Ctrl+O&lt;/code&gt; -&amp;gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Enter&lt;/code&gt; -&amp;gt; &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Ctrl+X&lt;/code&gt;).&lt;/p&gt;

&lt;h3 id=&quot;42-reload-the-system-and-set-it-to-start-on-boot&quot;&gt;4.2 Reload the system and set it to start on boot&lt;/h3&gt;

&lt;p&gt;Once the configuration is written, tell the system to reload the configuration and set the service to start on boot:&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
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;c&quot;&gt;# 1. Reload the systemd daemon&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;systemctl daemon-reload

&lt;span class=&quot;c&quot;&gt;# 2. Set to start automatically on boot&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;systemctl &lt;span class=&quot;nb&quot;&gt;enable &lt;/span&gt;picoclaw-voice.service

&lt;span class=&quot;c&quot;&gt;# 3. Start the service immediately&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;systemctl start picoclaw-voice.service

&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;43-common-management-commands-quick-reference&quot;&gt;4.3 Common management commands (quick reference)&lt;/h3&gt;

&lt;p&gt;Once it’s registered as a service, you can manage your walkie-talkie as gracefully as you would manage Nginx or MySQL:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Check the current status&lt;/strong&gt; (seeing a green &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;active (running)&lt;/code&gt; means it’s working):
```bash
sudo systemctl status picoclaw-voice&lt;/li&gt;
&lt;/ul&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
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;

* **Manually stop** the service:
```bash
sudo systemctl stop picoclaw-voice

&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;Manually restart&lt;/strong&gt; the service (after you modify the Python code, you must run this command for the changes to take effect):
```bash
sudo systemctl restart picoclaw-voice&lt;/li&gt;
&lt;/ul&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
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;

* **View logs in real time** (replaces the print output you would otherwise see in the terminal; essential for troubleshooting):
```bash
# -f means real-time scrolling, similar to tail -f
journalctl -u picoclaw-voice -f

&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;hr /&gt;

&lt;h3 id=&quot;additional-notes&quot;&gt;Additional notes:&lt;/h3&gt;

&lt;p&gt;With this piece of the puzzle in place, your Raspberry Pi has become a complete “black-box appliance.” You can safely close the terminal on your computer, unplug the Raspberry Pi, move it to the living room or nightstand, and plug it in. After it boots, wait about one or two minutes (until the network and background processes finish loading), and then you can just press the button and talk to it!&lt;/p&gt;
</description>
        <pubDate>Sun, 08 Mar 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/en/2026/03/08/raspberry-pi-siri-walkie-talkie-evolution/</link>
        <guid isPermaLink="true">https://uulili.com/en/2026/03/08/raspberry-pi-siri-walkie-talkie-evolution/</guid>
        
        <category>Raspberry Pi</category>
        
        <category>voice assistant</category>
        
        <category>STT TTS</category>
        
        <category>hardware</category>
        
        <category>LLM</category>
        
      </item>
    
      <item>
        <title>Reviving and Configuring Google Voice Kit V1 Basic Hardware</title>
        <description>&lt;p&gt;&lt;strong&gt;Supported environment:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Hardware:&lt;/strong&gt; Raspberry Pi + Google Voice Kit V1 (Voice HAT expansion board)&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;OS:&lt;/strong&gt; Raspberry Pi OS (based on Debian Bookworm or newer, kernel 6.x+)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;1-enable-the-low-level-hardware-driver&quot;&gt;1. Enable the low-level hardware driver&lt;/h2&gt;

&lt;p&gt;The device tree config file on modern Raspberry Pi systems has moved to the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/boot/firmware/&lt;/code&gt; directory. You need to disable the default onboard audio and load the Voice HAT-specific driver.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Steps:&lt;/strong&gt;
Edit the config file:&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;&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;nano /boot/firmware/config.txt
&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;Make these two changes:&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;Comment out the default audio line (if present):&lt;/li&gt;
&lt;/ol&gt;

&lt;div class=&quot;language-ini 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;&lt;span class=&quot;c&quot;&gt;#dtparam=audio=on
&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;ol&gt;
  &lt;li&gt;Append the Voice HAT device tree overlay directive to the end of the file:&lt;/li&gt;
&lt;/ol&gt;

&lt;div class=&quot;language-ini 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;&lt;span class=&quot;py&quot;&gt;dtoverlay&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;googlevoicehat-soundcard&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;Save and exit (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Ctrl+O&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Enter&lt;/code&gt;, &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Ctrl+X&lt;/code&gt;), then reboot the system:&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;&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;reboot
&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;2-verify-the-sound-card-and-get-the-device-number&quot;&gt;2. Verify the sound card and get the device number&lt;/h2&gt;

&lt;p&gt;After rebooting, confirm that the system has correctly detected the Voice HAT hardware.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Steps:&lt;/strong&gt;
List the audio devices:&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;aplay &lt;span class=&quot;nt&quot;&gt;-l&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;Expected output:&lt;/strong&gt;
Look for an entry that contains &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;snd_rpi_googlevoicehat_soundcard&lt;/code&gt;. Note the corresponding card number (e.g. &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;card 1&lt;/code&gt; or &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;card 0&lt;/code&gt;). The configuration below uses &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;card 1&lt;/code&gt; as an example.&lt;/p&gt;

&lt;h2 id=&quot;3-configure-global-alsa-audio-routing&quot;&gt;3. Configure global ALSA audio routing&lt;/h2&gt;

&lt;p&gt;By defining a user-level ALSA config file, you force the system to use the Voice HAT as the global default audio input/output device, and use the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;plug&lt;/code&gt; plugin to handle audio resampling automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Steps:&lt;/strong&gt;
Create or edit the ALSA configuration file for the current user:&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;nano ~/.asoundrc
&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;Write the following configuration (make sure to replace the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;1&lt;/code&gt; in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;hw:1,0&lt;/code&gt; and &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;card 1&lt;/code&gt; with the actual card number obtained in the previous step):&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;pcm.!default {
    type plug
    slave.pcm &quot;hw:1,0&quot;
}

ctl.!default {
    type hw
    card 1
}
&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;Save and exit. The system’s basic audio layer is now configured.&lt;/p&gt;

&lt;h2 id=&quot;4-install-dependencies-and-write-the-mvp-script&quot;&gt;4. Install dependencies and write the MVP script&lt;/h2&gt;

&lt;p&gt;The Voice HAT’s button and LED are connected directly to the Raspberry Pi’s standard GPIO pins (button = GPIO 23, LED = GPIO 25). Use the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;gpiozero&lt;/code&gt; library to control them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Steps:&lt;/strong&gt;
Install the necessary Python dependencies:&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;&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;apt update
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;apt &lt;span class=&quot;nb&quot;&gt;install &lt;/span&gt;python3-gpiozero
&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;Create the test script:&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;nano voice_mvp.py
&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;Enter the following code:&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
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;gpiozero&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Button&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;LED&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;signal&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pause&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;subprocess&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# Voice HAT V1 hardware pin definitions
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;BUTTON_PIN&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;23&lt;/span&gt;  
&lt;span class=&quot;n&quot;&gt;LED_PIN&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;25&lt;/span&gt;     
&lt;span class=&quot;c1&quot;&gt;# System audio file for testing
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;AUDIO_FILE&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;/usr/share/sounds/alsa/Front_Center.wav&quot;&lt;/span&gt; 

&lt;span class=&quot;n&quot;&gt;button&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Button&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;BUTTON_PIN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;led&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;LED&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;LED_PIN&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;on_button_pressed&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;[Event] Button Pressed&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;led&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    
    &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;# Since ~/.asoundrc is already configured, no device parameters are needed here
&lt;/span&gt;        &lt;span class=&quot;n&quot;&gt;subprocess&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;s&quot;&gt;&quot;aplay&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;AUDIO_FILE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;except&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Exception&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;[Error] Audio playback failed: &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        
    &lt;span class=&quot;n&quot;&gt;led&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;off&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;[Status] Playback complete. Awaiting next trigger.&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# Bind the button event
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;button&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;when_pressed&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;on_button_pressed&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;[System] Initialization complete. Listening for hardware interrupts...&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;pause&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;h2 id=&quot;5-run-the-test&quot;&gt;5. Run the test&lt;/h2&gt;

&lt;p&gt;Run the script:&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;python3 voice_mvp.py
&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;Test criteria:&lt;/strong&gt; Press the physical button on top of the device; the LED indicator lights up, the speaker plays the “Front Center” audio, then the LED turns off and the terminal prints the corresponding log messages.&lt;/p&gt;
</description>
        <pubDate>Sun, 08 Mar 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/en/2026/03/08/google-voice-kit-v1-hardware-revival-guide/</link>
        <guid isPermaLink="true">https://uulili.com/en/2026/03/08/google-voice-kit-v1-hardware-revival-guide/</guid>
        
        <category>Google Voice Kit</category>
        
        <category>Raspberry Pi</category>
        
        <category>Voice HAT</category>
        
        <category>ALSA</category>
        
        <category>GPIO</category>
        
      </item>
    
      <item>
        <title>10 Core Tips for Boosting Efficiency in Claude Code</title>
        <description>&lt;h1 id=&quot;10-core-tips-for-improving-efficiency-in-claude-code&quot;&gt;10 Core Tips for Improving Efficiency in Claude Code&lt;/h1&gt;

&lt;p&gt;Claude Code is a powerful CLI tool released by Anthropic. After using it extensively, I’ve summarized the following 10 core tips that can significantly speed up your work.&lt;/p&gt;

&lt;h2 id=&quot;1-switch-between-run-modes-easily&quot;&gt;1. Switch between run modes easily&lt;/h2&gt;
&lt;p&gt;Press &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;shift + tab&lt;/code&gt; to quickly switch between three modes:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Default mode (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;? for shortcuts&lt;/code&gt;)&lt;/strong&gt;: The most cautious; asks you before modifying files.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Auto mode (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;accept edits on&lt;/code&gt;)&lt;/strong&gt;: Modifies files automatically, good for quick fixes.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Plan mode (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;plan mode on&lt;/code&gt;)&lt;/strong&gt;: Only discusses the plan and doesn’t modify files, good for early-stage planning.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;2-terminal-commands-and-background-tasks&quot;&gt;2. Terminal commands and background tasks&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;Before you enter a prompt, type &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;! &lt;/code&gt; to run a local shell command directly.&lt;/li&gt;
  &lt;li&gt;Use &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;⌃ + b&lt;/code&gt; to send an already running task (such as &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;npm run dev&lt;/code&gt;) to the background while you continue talking to Claude.&lt;/li&gt;
  &lt;li&gt;Type &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/tasks&lt;/code&gt; to view background tasks; once inside, press &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;k&lt;/code&gt; to close the selected task.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;3-multi-line-prompt-editing&quot;&gt;3. Multi-line prompt editing&lt;/h2&gt;
&lt;p&gt;If you need to type a long, complex prompt, press &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;⌃ + g&lt;/code&gt; to open a temporary file in VS Code for editing; when you save, it syncs back to the terminal automatically.&lt;/p&gt;

&lt;h2 id=&quot;4-sudo-mode-fully-unleashed&quot;&gt;4. Sudo mode, fully unleashed&lt;/h2&gt;
&lt;p&gt;If you’re confident in your system’s security and want maximum efficiency, you can use:
&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;claude --dangerously-skip-permissions&lt;/code&gt;
In this mode, Claude no longer asks before creating or modifying files or running commands. Very powerful, but use it with care.&lt;/p&gt;

&lt;h2 id=&quot;5-graceful-rollback-and-management&quot;&gt;5. Graceful rollback and management&lt;/h2&gt;
&lt;p&gt;Claude Code can’t roll back perfectly (for example, newly created files can’t be undone), so &lt;strong&gt;I recommend using it together with Git&lt;/strong&gt;.&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/rewind&lt;/code&gt;: Undo the last action.&lt;/li&gt;
  &lt;li&gt;Press &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;esc&lt;/code&gt; twice: Enter interactive rollback selection mode.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;6-a-small-trick-for-uploading-images&quot;&gt;6. A small trick for uploading images&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;Simply drag and drop the image file into the terminal window.&lt;/li&gt;
  &lt;li&gt;On macOS, even images copied with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;command + c&lt;/code&gt; have to be pasted into the terminal with &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ctrl + v&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;7-resuming-sessions&quot;&gt;7. Resuming sessions&lt;/h2&gt;
&lt;ul&gt;
  &lt;li&gt;Inside Claude Code: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/resume&lt;/code&gt;.&lt;/li&gt;
  &lt;li&gt;From outside Claude Code, go straight back into the last session: &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;claude -c&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;8-context-compression-and-cleanup&quot;&gt;8. Context compression and cleanup&lt;/h2&gt;
&lt;p&gt;When the context gets too long and responses slow down or token usage gets too high:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/compact [optional description]&lt;/code&gt;: Compress the current context. Afterwards, press &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;⌃ + o&lt;/code&gt; to view the compressed result.&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/clear&lt;/code&gt;: Completely clear the current session context.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;9-build-system-level-memory&quot;&gt;9. Build system-level memory&lt;/h2&gt;
&lt;p&gt;With the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/init&lt;/code&gt; command, Claude creates a &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;CLAUDE.md&lt;/code&gt; file in the current directory. The instructions and project rules in this file are read automatically each time a conversation starts.&lt;/p&gt;

&lt;h2 id=&quot;10-managing-your-personal-preferences-memory&quot;&gt;10. Managing your personal preferences (&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/memory&lt;/code&gt;)&lt;/h2&gt;
&lt;p&gt;Type &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/memory&lt;/code&gt; to set:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Project memory&lt;/strong&gt;: For the current project (saved in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;./CLAUDE.md&lt;/code&gt;).&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;User memory&lt;/strong&gt;: Applies globally (saved in &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;~/.claude/CLAUDE.md&lt;/code&gt;), a good place for your personal coding habits and background.&lt;/li&gt;
&lt;/ul&gt;

&lt;hr /&gt;

&lt;p&gt;I hope these tips help you get a better handle on Claude Code and add a little sunshine to your development life.&lt;/p&gt;
</description>
        <pubDate>Sat, 07 Feb 2026 00:00:00 +0000</pubDate>
        <link>https://uulili.com/en/2026/02/07/claude-code-efficiency-core-tips/</link>
        <guid isPermaLink="true">https://uulili.com/en/2026/02/07/claude-code-efficiency-core-tips/</guid>
        
        <category>Claude Code</category>
        
        <category>CLI</category>
        
        <category>productivity</category>
        
        <category>tips</category>
        
        <category>AI coding</category>
        
      </item>
    
      <item>
        <title>My a6400 HLG Grading Workflow for FCPX</title>
        <description>&lt;p&gt;Sony’s straight-out-of-camera colors aren’t pleasing enough, and when grading, you need to handle things differently depending on the mode you chose when shooting. Most online examples use sLog2 or sLog3 for grading; the dynamic range is higher, but there may also be more work to do in post. Maybe applying a conversion LUT can get you a quick result, but that’s beyond the scope of this article. This article mainly focuses on the workflow for HLG mode.&lt;/p&gt;

&lt;h2 id=&quot;hlg-shooting-mode&quot;&gt;HLG shooting mode&lt;/h2&gt;

&lt;p&gt;The advantage of HLG shooting mode is that, even straight out of camera, it can still offer a wider color range than normal out-of-camera footage (such as cine). Since the color space becomes BT.2020, when we import this footage into FCPX, we need to convert the color space, otherwise it will look overexposed.&lt;/p&gt;

&lt;p&gt;You get HLG mode by changing the picture profile (pp value) in the MR position:&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/shisaq/images/master/images/pp1.jpg&quot; alt=&quot;picture profile&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;color-grading-workflow&quot;&gt;Color grading workflow&lt;/h2&gt;

&lt;h3 id=&quot;convert-color-space-newer-versions-of-fcpx-automatically-turn-on-color-conform-to-convert-from-hlg-to-rec709-for-us&quot;&gt;Convert color space (newer versions of FCPX automatically turn on Color Conform to convert from HLG to Rec.709 for us)&lt;/h3&gt;

&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/shisaq/images/master/images/ColorConform.png&quot; alt=&quot;color conform&quot; /&gt;&lt;/p&gt;

&lt;h3 id=&quot;primary-grading&quot;&gt;Primary grading&lt;/h3&gt;

&lt;p&gt;Basic white balance: you can use balance color to easily fix the color balance; if the result isn’t ideal, find balance color in the inspector panel, switch from automatic to manual mode, and use the eyedropper to pick a white point in the frame.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/shisaq/images/master/images/BalanceColorAuto.png&quot; alt=&quot;balance color automatically&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/shisaq/images/master/images/BalanceColorManual.png&quot; alt=&quot;balance color manually&quot; /&gt;&lt;/p&gt;

&lt;p&gt;Advanced white balance: use the color curve and color wheel together with the waveform scope to make manual adjustments. The goal is to make the red, green and blue in the waveform overlap as much as possible and turn white. But it still depends on the actual frame; for example, skin tones will have a bit more red, which is normal.&lt;/p&gt;

&lt;p&gt;Exposure: pull the highlights to near 100 and the shadows to near 0, then adjust the midtones based on the image. If you run into a high-contrast situation, such as a very bright sky but a very dark subject, you can use the hue/saturation curves to select the bright areas based on luma, pull back the overexposed areas, and then continue adjusting the whole image.&lt;/p&gt;

&lt;p&gt;Saturation: adjust to taste, but I suggest not pushing it too far from the center of the vectorscope, to leave some room for secondary grading.&lt;/p&gt;

&lt;h3 id=&quot;secondary-grading-aka-applying-a-lut&quot;&gt;Secondary grading, aka applying a LUT&lt;/h3&gt;

&lt;p&gt;This step is the simplest: find a LUT you like and drag it onto the clip. If the effect is too strong, you can fix that by adjusting the mix percentage.&lt;/p&gt;

&lt;h3 id=&quot;restoring-skin-tones&quot;&gt;Restoring skin tones&lt;/h3&gt;

&lt;p&gt;After secondary grading, skin tones may become distorted. We need to drag to select the skin area (or the orange area at the bottom) in the Hue/Saturation Curves and adjust the skin tone so it sits as close as possible to the 10:30 line.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/shisaq/images/master/images/HueSatCurve.jpg&quot; alt=&quot;hue/saturation vurve&quot; /&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/shisaq/images/master/images/SkintoneAtVectorscope.png&quot; alt=&quot;skintone&quot; /&gt;&lt;/p&gt;

&lt;p&gt;HUE vs HUE: changes the color of the selected color range;
HUE vs SAT: changes the saturation of the selected color range;
HUE vs LUMA: changes the luma of the selected color range;
LUMA vs SAT: changes the saturation of the selected luma range;
SAT vs SAT: changes the saturation of the selected saturation range;
ORANGE vs SAT: changes the saturation of the orange range (similar to skin tones).&lt;/p&gt;

&lt;p&gt;If other colors are the same as the skin tone, you’ll also need to create a mask to make sure that when we adjust the skin tone, we don’t change the color of other objects.&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/shisaq/images/master/images/HueSatCurveMasks.png&quot; alt=&quot;hue/saturation curve masks&quot; /&gt;&lt;/p&gt;
</description>
        <pubDate>Wed, 10 Jan 2024 00:00:00 +0000</pubDate>
        <link>https://uulili.com/en/2024/01/10/a6400-hlg-grading-workflow-fcpx/</link>
        <guid isPermaLink="true">https://uulili.com/en/2024/01/10/a6400-hlg-grading-workflow-fcpx/</guid>
        
        <category>HLG</category>
        
        <category>FCPX</category>
        
        <category>color grading</category>
        
        <category>Sony a6400</category>
        
        <category>BT.2020</category>
        
      </item>
    
      <item>
        <title>24/7 Livestreaming with a Raspberry Pi</title>
        <description>&lt;p&gt;Recently I wanted to loop the videos from my “10-minute piano practice series” on a live streaming platform, and at first I planned to do it with a cloud server. But the Raspberry Pi that had been gathering dust at home said: “Just leave this to me, and it won’t cost you a cent!” And that’s how this came about.&lt;/p&gt;

&lt;h2 id=&quot;live-stream-link&quot;&gt;Live stream link&lt;/h2&gt;

&lt;p&gt;&lt;a href=&quot;http://live.bilibili.com/3544257&quot;&gt;http://live.bilibili.com/3544257&lt;/a&gt;&lt;/p&gt;

&lt;h2 id=&quot;connecting-to-the-raspberry-pi-over-ssh&quot;&gt;Connecting to the Raspberry Pi over SSH&lt;/h2&gt;

&lt;div class=&quot;language-sh 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;nv&quot;&gt;$ &lt;/span&gt;ssh pi@192.168.x.x

&lt;span class=&quot;c&quot;&gt;# default password raspberry&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;uploading-the-video-files&quot;&gt;Uploading the video files&lt;/h2&gt;

&lt;p&gt;Upload the videos from your local computer to the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/home/pi/Videos/&lt;/code&gt; folder on the Raspberry Pi:&lt;/p&gt;

&lt;div class=&quot;language-sh 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;&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;scp video.mp4 pi@192.168.x.x:/home/pi/Videos/
&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;core-code-for-looping-playback&quot;&gt;Core code for looping playback&lt;/h2&gt;

&lt;p&gt;Create an executable file named &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;stream.sh&lt;/code&gt; in the &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/home/pi/&lt;/code&gt; directory and paste in the following contents:&lt;/p&gt;

&lt;div class=&quot;language-sh 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
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;c&quot;&gt;#!/bin/bash&lt;/span&gt;

&lt;span class=&quot;nv&quot;&gt;RTMP_URL&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;rtmp://live-push.bilivideo.com/live-bvc/?streamname=example&quot;&lt;/span&gt;
&lt;span class=&quot;nv&quot;&gt;VIDEO_DIR&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;/home/pi/Videos&quot;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;while &lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;do
  for &lt;/span&gt;VIDEO_FILE &lt;span class=&quot;k&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;nv&quot;&gt;$VIDEO_DIR&lt;/span&gt;/&lt;span class=&quot;k&quot;&gt;*&lt;/span&gt;.mp4&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;do
    &lt;/span&gt;ffmpeg &lt;span class=&quot;nt&quot;&gt;-re&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-i&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;nv&quot;&gt;$VIDEO_FILE&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
           &lt;span class=&quot;nt&quot;&gt;-c&lt;/span&gt;:v copy &lt;span class=&quot;nt&quot;&gt;-c&lt;/span&gt;:a aac &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
           &lt;span class=&quot;nt&quot;&gt;-b&lt;/span&gt;:a 192k &lt;span class=&quot;nt&quot;&gt;-strict&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-2&lt;/span&gt; &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
           &lt;span class=&quot;nt&quot;&gt;-f&lt;/span&gt; flv &lt;span class=&quot;nv&quot;&gt;$RTMP_URL&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;done
done&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;It’s really just using ffmpeg to push the live stream. You’ll need to get the RTMP_URL here yourself from the streaming platform you’re using. The &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-c:v copy&lt;/code&gt; means the Raspberry Pi doesn’t have to decode the video again, since its performance is pretty modest. I’d suggest processing the videos ahead of time with &lt;a href=&quot;https://handbrake.fr&quot;&gt;HandBrake&lt;/a&gt; or some other format conversion tool; I processed mine at 720p with Constant Quality set to 30.&lt;/p&gt;

&lt;p&gt;Finally, don’t forget to change the file permissions, otherwise the process manager won’t be able to run it:&lt;/p&gt;

&lt;div class=&quot;language-sh 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;&lt;span class=&quot;nv&quot;&gt;$ &lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;chmod&lt;/span&gt; +x stream.sh
&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;process-manager&quot;&gt;Process manager&lt;/h2&gt;

&lt;p&gt;Using &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;supervisorctl&lt;/code&gt; makes it easy to run it in the background, so even if we close the current terminal session, the stream keeps running. If you don’t have it yet, install it first:&lt;/p&gt;

&lt;div class=&quot;language-sh 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;&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;apt &lt;span class=&quot;nb&quot;&gt;install &lt;/span&gt;supervisor
&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;Here are a few common control commands:&lt;/p&gt;

&lt;div class=&quot;language-sh 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;nb&quot;&gt;sudo &lt;/span&gt;supervisorctl start play
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;supervisorctl stop play
&lt;span class=&quot;nb&quot;&gt;sudo &lt;/span&gt;supervisorctl status
&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;configuring-paths-and-content&quot;&gt;Configuring paths and content&lt;/h2&gt;

&lt;p&gt;It covers the working directory, the script to run, the fault-tolerance mechanism, and log storage.&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;/etc/supervisor/conf.d/play.conf&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class=&quot;language-conf 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;n&quot;&gt;program&lt;/span&gt;:&lt;span class=&quot;n&quot;&gt;play&lt;/span&gt;]
&lt;span class=&quot;n&quot;&gt;directory&lt;/span&gt;=/&lt;span class=&quot;n&quot;&gt;home&lt;/span&gt;/&lt;span class=&quot;n&quot;&gt;pi&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;command&lt;/span&gt;=./&lt;span class=&quot;n&quot;&gt;stream&lt;/span&gt;.&lt;span class=&quot;n&quot;&gt;sh&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;autostart&lt;/span&gt;=&lt;span class=&quot;n&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;autorestart&lt;/span&gt;=&lt;span class=&quot;n&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;startretries&lt;/span&gt;=&lt;span class=&quot;m&quot;&gt;10&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;redirect_stderr&lt;/span&gt;=&lt;span class=&quot;n&quot;&gt;true&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;stdout_logfile&lt;/span&gt;=/&lt;span class=&quot;n&quot;&gt;home&lt;/span&gt;/&lt;span class=&quot;n&quot;&gt;pi&lt;/span&gt;/&lt;span class=&quot;n&quot;&gt;stream&lt;/span&gt;.&lt;span class=&quot;n&quot;&gt;log&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;stdout_logfile_maxbytes&lt;/span&gt;=&lt;span class=&quot;m&quot;&gt;512&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;KB&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;stdout_logfile_backups&lt;/span&gt;=&lt;span class=&quot;m&quot;&gt;2&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;environment&lt;/span&gt;=&lt;span class=&quot;n&quot;&gt;ASPNETCORE_ENVIRONMENT&lt;/span&gt;=&lt;span class=&quot;s2&quot;&gt;&quot;Development&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;
</description>
        <pubDate>Mon, 10 Apr 2023 00:00:00 +0000</pubDate>
        <link>https://uulili.com/en/2023/04/10/raspberry-pi-24-hour-livestream/</link>
        <guid isPermaLink="true">https://uulili.com/en/2023/04/10/raspberry-pi-24-hour-livestream/</guid>
        
        <category>Raspberry Pi</category>
        
        <category>ffmpeg</category>
        
        <category>livestreaming</category>
        
        <category>supervisor</category>
        
        <category>Bilibili</category>
        
      </item>
    
      <item>
        <title>For Audiobook Lovers: Convert Ebooks to MP3 with Python</title>
        <description>&lt;h2 id=&quot;prerequisites&quot;&gt;Prerequisites&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;An ebook in .txt format&lt;/li&gt;
  &lt;li&gt;Internet access&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/rany2/edge-tts&quot;&gt;edge-tts&lt;/a&gt; installed &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;$ pip install edge-tts&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;Python 3 installed&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;basic-usage&quot;&gt;Basic usage&lt;/h2&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;$ edge-tts --text &quot;Hello, world!&quot; --write-media hello.mp3 --write-subtitles hello.vtt&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;For more usage, see &lt;a href=&quot;https://github.com/rany2/edge-tts#usage&quot;&gt;Edge-tts Usage&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&quot;using-python-to-split-the-ebook-into-small-chapters&quot;&gt;Using Python to split the ebook into small chapters&lt;/h2&gt;

&lt;p&gt;Suppose we have an ebook named “白鹿原.txt” (White Deer Plain). In the same directory as the ebook, create a file named “split.py” and paste the code below into it.&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
13
14
15
16
17
18
19
20
21
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;c1&quot;&gt;# Open the ebook
&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;with&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;s&quot;&gt;&quot;白鹿原.txt&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;r&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;input_file&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;# Read the contents of the ebook
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;file_contents&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;input_file&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;read&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    
    &lt;span class=&quot;c1&quot;&gt;# Use &quot;\n\n第&quot; to separate the chapters (different ebooks have different structures, confirm the separator manually)
&lt;/span&gt;    &lt;span class=&quot;n&quot;&gt;file_chunks&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;file_contents&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;split&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;第&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    
    &lt;span class=&quot;c1&quot;&gt;# Split the ebook
&lt;/span&gt;    &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;file_chunk&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;enumerate&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;file_chunks&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;# Name the output ebook
&lt;/span&gt;        &lt;span class=&quot;n&quot;&gt;output_filename&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;白鹿原_&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;+&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;.txt&quot;&lt;/span&gt;
        
        &lt;span class=&quot;c1&quot;&gt;# Open the output ebook
&lt;/span&gt;        &lt;span class=&quot;k&quot;&gt;with&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;output_filename&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;w&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;output_file&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;c1&quot;&gt;# Write the split content and print the result in the command line
&lt;/span&gt;            &lt;span class=&quot;n&quot;&gt;output_file&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;write&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;第&quot;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;file_chunk&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; finishing %s&quot;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;%&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;output_file&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# Done
&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;print&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;done.&quot;&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;h2 id=&quot;using-edge-tts-to-convert-the-chapters-to-mp3&quot;&gt;Using edge-tts to convert the chapters to mp3&lt;/h2&gt;

&lt;p&gt;Put all the chapter .txt files you output into a folder named “splitted”. In that folder, create a file named “txt_to_audio.py” and paste the code below into it:&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
13
14
15
16
17
18
19
20
21
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;os&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;subprocess&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# Specify the working directory and output folder
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;txt_path&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;./&quot;&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;output_folder&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;output&quot;&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# Create the output folder
&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;exists&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;output_folder&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;makedirs&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;output_folder&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;c1&quot;&gt;# Iterate over all txt files in the directory
&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;txt_file&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;listdir&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;txt_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;# Check whether it is a txt file
&lt;/span&gt;    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;txt_file&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;endswith&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;.txt&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;# Create the terminal command (see the edge-tts command)
&lt;/span&gt;        &lt;span class=&quot;n&quot;&gt;cmd&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;edge-tts -f &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;txt_path&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;/&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;txt_file&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt; --write-media &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;output_folder&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;/&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;txt_file&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;4&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;.mp3 --write-subtitles &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;output_folder&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;/&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;txt_file&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;4&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;.vtt --voice zh-CN-XiaoxiaoNeural --rate=+10%&quot;&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;# Execute the terminal command and print the status
&lt;/span&gt;        &lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;file %s is being processed...&apos;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;%&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;txt_file&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;subprocess&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;cmd&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;shell&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;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;file %s completed.&apos;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;%&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;txt_file&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;h2 id=&quot;wait-for-it-to-finish&quot;&gt;Wait for it to finish&lt;/h2&gt;

&lt;p&gt;This uses online text-to-speech technology. Just wait for the task to finish, and you’ll have the mp3 files.&lt;/p&gt;

&lt;h2 id=&quot;list-of-available-languages&quot;&gt;List of available languages&lt;/h2&gt;

&lt;p&gt;Only Chinese and English are included here; see here for the full list:&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;$ edge-tts --list-voices&lt;/code&gt;&lt;/p&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
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
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;Name: zh-CN-XiaoxiaoNeural (versatile)
Gender: Female

Name: zh-CN-XiaoyiNeural
Gender: Female

Name: zh-CN-YunjianNeural
Gender: Male

Name: zh-CN-YunxiNeural
Gender: Male

Name: zh-CN-YunxiaNeural
Gender: Male

Name: zh-CN-YunyangNeural (steady)
Gender: Male

Name: zh-CN-liaoning-XiaobeiNeural
Gender: Female

Name: zh-CN-shaanxi-XiaoniNeural
Gender: Female

Name: zh-HK-HiuGaaiNeural
Gender: Female

Name: zh-HK-HiuMaanNeural
Gender: Female

Name: zh-HK-WanLungNeural
Gender: Male

Name: zh-TW-HsiaoChenNeural
Gender: Female

Name: zh-TW-HsiaoYuNeural
Gender: Female

Name: zh-TW-YunJheNeural
Gender: Male

Name: en-US-AnaNeural (child voice)
Gender: Female

Name: en-US-AriaNeural
Gender: Female

Name: en-US-ChristopherNeural
Gender: Male

Name: en-US-EricNeural
Gender: Male

Name: en-US-GuyNeural
Gender: Male

Name: en-US-JennyNeural
Gender: Female

Name: en-US-MichelleNeural
Gender: Female

Name: en-US-RogerNeural
Gender: Male

Name: en-US-SteffanNeural
Gender: Male
&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;
</description>
        <pubDate>Fri, 07 Apr 2023 00:00:00 +0000</pubDate>
        <link>https://uulili.com/en/2023/04/07/convert-ebooks-to-mp3-python/</link>
        <guid isPermaLink="true">https://uulili.com/en/2023/04/07/convert-ebooks-to-mp3-python/</guid>
        
        <category>python</category>
        
        <category>edge-tts</category>
        
        <category>text-to-speech</category>
        
        <category>ebook</category>
        
        <category>mp3</category>
        
      </item>
    
      <item>
        <title>Quickly Create a Countdown in After Effects</title>
        <description>&lt;blockquote&gt;
  &lt;p&gt;Reference: &lt;a href=&quot;https://www.youtube.com/watch?v=2qLJK5W57II&quot;&gt;After Effects Tutorial - Creating a Countdown Timer Using Expressions&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;core-code&quot;&gt;Core code&lt;/h2&gt;

&lt;div class=&quot;language-javascript 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
&lt;/pre&gt;&lt;/td&gt;&lt;td class=&quot;rouge-code&quot;&gt;&lt;pre&gt;&lt;span class=&quot;nx&quot;&gt;slider&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;thisComp&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;layer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;Slider Control&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;effect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;Slider Control&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;Slider&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

&lt;span class=&quot;nx&quot;&gt;sec&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Math&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;floor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;slider&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;%&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;60&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;min&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Math&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;floor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;slider&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;/&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;60&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;


&lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;zero&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;x&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;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;x&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;10&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;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;x&lt;/span&gt;
		
&lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;x&lt;/span&gt;
		
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
	
&lt;span class=&quot;nx&quot;&gt;zero&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;min&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;zero&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;sec&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;h2 id=&quot;steps&quot;&gt;Steps&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;Create a new Null layer, name it &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Slider Control&lt;/code&gt;, and add a “Slider Control” property. &lt;img src=&quot;https://raw.githubusercontent.com/shisaq/images/master/images/sliderControl.png&quot; alt=&quot;Slider Control&quot; /&gt;&lt;/li&gt;
  &lt;li&gt;Create a new text layer.&lt;/li&gt;
  &lt;li&gt;In the text layer, Alt+left-click “Source Text” and paste the core code into it.&lt;/li&gt;
  &lt;li&gt;Alt+left-click the Slider Control property on the Null layer and enter &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;value - time&lt;/code&gt;;&lt;/li&gt;
  &lt;li&gt;Adjust the value of the Slider Control property on the Null layer; the value = the number of minutes you want for the countdown x 60. For example, for a 10-minute countdown, enter 600, i.e., 10x60.&lt;/li&gt;
&lt;/ol&gt;
</description>
        <pubDate>Wed, 15 Jun 2022 00:00:00 +0000</pubDate>
        <link>https://uulili.com/en/2022/06/15/ae-quick-countdown-timer/</link>
        <guid isPermaLink="true">https://uulili.com/en/2022/06/15/ae-quick-countdown-timer/</guid>
        
        <category>After Effects</category>
        
        <category>countdown</category>
        
        <category>expressions</category>
        
        <category>tutorial</category>
        
      </item>
    
  </channel>
</rss>
