昨天把訓練紀錄轉成了 JSON,但要讓儀表板的數字是新的,得自己開終端機打指令。
今天要處理這件事—設定排程,讓它能自己跑。
動手前先翻了一遍自己的程式碼,想確認這條管線裡有幾段可以自動跑。
翻程式碼的第一個發現是:garmin_sync.py 和 notion_sync.py 都只是把抓到的資料印出來,程式結束就沒了。真正把資料寫進訓練紀錄的,是 Telegram bot 那條路:
我在 Telegram 打 /run
→ garmin_sync 抓資料,存成 JSON 丟進 coach/incoming/
→ 交給 Claude:「依 CLAUDE.md 規則分析並記錄到訓練紀錄;
已記錄過的活動(比對日期與內容)不要重複記,只補新的」
→ Claude 判斷後寫進 coach/logs/*.md
看到「已記錄過的不要重複記」這句,當下的反應是:這是判斷,不是搬運,所以不能沒有人做確認。
照這個邏輯排下來,四段管線只有一段能排程:
| 這一段 | 當下的判斷 |
|---|---|
| Garmin/Notion → 訓練紀錄 | ❌ 要判斷「這筆記過沒有」 |
| 體重機 → weight.md | ❌ 要有人站上去 |
| 教練課表 | ❌ 要教練傳圖過來 |
| 訓練紀錄 → JSON | ✅ 純轉檔 |
寫到這裡本來要下一個很有感觸的結論:大部分事情自動化不了,因為它們需要人來處理。
還好動筆之前又回去看了一次程式碼。
自己把兩件事混成一件:
第一件事永遠需要人。第二件事幾乎都不需要。
最明顯的例子是體重。
weight_sync.py 的核心長這樣:
async def read_weight(timeout: float = 90) -> dict | None:
...
scanner = BleakScanner(detection_callback=cb)
await scanner.start()
try:
await asyncio.wait_for(done.wait(), timeout=timeout)
except asyncio.TimeoutError:
pass
await scanner.stop()
它本來就是監聽式的—BleakScanner 加一個 callback,體重機廣播什麼它就收什麼。之所以要「先執行指令再站上去」,只是因為那個 timeout=90:聽 90 秒就收工。
站上去確實要人,但把資料收進來不用—那 90 秒的窗口才是限制所在。把 timeout 拿掉讓它一直聽就好:
scanner = BleakScanner(detection_callback=cb)
await scanner.start()
while True: # 沒有 timeout — 這就是與 weight_sync 唯一的差別
await asyncio.sleep(3600)
剩下的是去重。體重機一次量測會連續廣播數十個封包,而且量完可能再站一次,所以加兩道:十分鐘的冷卻時間,加上比對檔案裡有沒有同一分鐘的紀錄。
if time.time() - last_at < COOLDOWN_SEC:
return # 同一次量測的後續封包
然後把讀數插進 weight.md 表格的最前面(那個檔案是新到舊排序)。
現在的流程是:站上去,站穩,等它跑完,就結束了。 不用開終端機、不用打指令、不用開 app。
「已記錄過的不要重複記」聽起來很像需要智慧,但實際看一下資料就知道不用。
Garmin 回來的每一筆活動都有 activityId:
"id": a.get("activityId"),
去重只要 if id not in seen 就結束了,這是查表,不是判斷。
真正需要判斷的是下一步—把活動寫成一段有分析的文字:
唯一問題是第 1 組開太快(1:38,比其餘快 6–7 秒/圈)。這是老毛病:第一組心率從 146 一路爬到 181⋯⋯
這一段確實要 LLM 寫。但「要 LLM」不等於「要人」—coach_bridge.py 本來就是用 subprocess 呼叫 claude CLI,那條路是現成的,排程一樣叫得動。
Garmin 這段今天先不做,但理由跟技術無關:讓 LLM 無人值守寫進訓練紀錄,跟按下 /run 看著它寫,差別在出錯的時候自己無法即時知曉。
把兩天併成一天、把暖身算進工作組—當場看得出來,隔天再回頭看就很容易忘記當時情況,而訓練紀錄是後面所有分析的底,錯一筆會一路錯下去。
教練課表則是一半一半。on_photo 這個 handler 早就在了—把課表截圖轉傳給 bot,它會自動存檔、自動交給 Claude 讀,連相簿分批送達都處理了。
但自動的部分從轉傳之後才開始。教練是發在 LINE 記事本,那一段接不進來,得自己截圖搬過去。
| 這一段 | 收資料能自動嗎 | 現況 |
|---|---|---|
| 體重機 → weight.md | ✅ | 今天做了,常駐監聽 |
| 訓練紀錄 → JSON | ✅ | 今天做了,每天 07:00 |
| 教練課表 → 系統 | 🟡 一半 | 轉傳之後自動(on_photo),LINE 那段要自己搬 |
| Garmin/Notion → 訓練紀錄 | ✅ 技術上可以 | 沒做,因為不想讓 LLM 無人值守寫入 |
原本那三個 ❌ 一個都沒剩。
真正需要人的只剩下產生資料那一端:得去跑、得去舉、得站上體重機。而那本來就不該自動化—那才是訓練本身。
排程有兩條路:本機定時執行,或丟到 GitHub Actions。Actions 免費、筆電關機照跑,聽起來明顯比較好。
但今天要排的這兩支,情況不太一樣。
體重那支只能在本機。 BLE 廣播的範圍就那麼大,體重機在我家客廳,雲端再強也收不到藍牙訊號。
轉檔那支雲端做得到,只是不划算。 訓練紀錄就在 repo 裡,Actions 讀得到—但產出要寫進儀表板那個 repo,兩邊是分開的,跨 repo 寫檔得另外開一組存取權杖。既然體重那支非得留在本機,轉檔跟著放同一台反而單純。
兩支都留在本機,這個專案的重心本來就在這台電腦上。
(將來如果要把 Garmin 那段也排程,一樣得在本機—登入 token 快取在 ~/.garminconnect,不在任何 repo 裡,Actions 每跑一次就得重登一次。)
決定跑本機之後,直覺是 cron。但 macOS 的正解是 launchd—cron 還能動,但已經不是 Apple 推薦的方法,在權限管理上也容易遇到錯誤。
兩支排程的性格完全不同:
<!-- 轉檔:每天七點跑一次就結束 -->
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>7</integer>
<key>Minute</key><integer>0</integer>
</dict>
<!-- 體重:一直開著等,掛掉自動重啟 -->
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
一個是鬧鐘,一個是守衛。
有個地方第一次寫一定會踩到:路徑一定要寫絕對路徑。launchd 不會載入你的 shell profile,所以 $PATH 只有系統預設值,~ 也不會展開。在終端機打得動的指令交給 launchd 就找不到—因為那些是 .zshrc 幫你設好的。
另一件本來很擔心的事:macOS 的藍牙權限是綁「執行的那個程式」的。在終端機授權過,不代表 launchd 起的行程也有—它的父行程不是終端機。
實測是多慮了。寫了一支小程式,數 12 秒內收到的所有 BLE 廣播(不限體重機),兩邊各跑一次:
終端機 devices=32 packets=241
launchd devices=31 packets=298
沒有差別(裝置數差一台是因為兩次掃描的時間不同,附近的藍牙裝置本來就會來去)。
不過這只證明收得到廣播。體重機平常不發訊號,要有人站上去才廣播—所以真正的驗證還是得等下次量體重。但至少可以確定:如果那時候沒反應,問題不會出在權限。
這是今天真正想清楚的一件事。
排程只能保證轉檔有跑過,管不到訓練紀錄裡有沒有新東西—那得我自己去練、去記。
程式管不到,但至少可以講出來。所以那支腳本轉完檔之後,還會多印一行:
[2026-08-25 01:00:53] ✅ 完成
[2026-08-25 01:00:53] 最新紀錄:2026-08-23 (2 天前)
「✅ 完成」只代表程式沒出錯,「2 天前」才告訴我這份資料有多新。
哪天那個數字變成 10 天前,通常不是程式的問題,是我的問題(?)
無人值守的東西壞掉時,通常是安靜地壞掉。所以有兩件事得顧好:錯誤訊息不能被吞掉,失敗的時候要回非零的退出碼。
先看第一件:
# 2>&1 讓 Python 的錯誤訊息也進 log — 失敗要看得見,不能靜悄悄
if out=$("$REPO/.venv/bin/python" "$REPO/parse_logs.py" \
--json "$DASHBOARD/sessions.json" 2>&1); then
...
else
say "❌ parse_logs 失敗:"
echo "$out" | tail -5 | while read -r line; do say " $line"; done
exit 1
fi
launchctl list 會顯示每個 job 上一次的退出碼:
56415 0 com.superbeeee.weight-daemon
- 0 com.superbeeee.training-sync
左邊是 PID(常駐那支才有),中間那個才是重點。實測三種情況:正常 0、儀表板目錄不見 1、解析程式跑不起來 1。如果全部都 exit 0,那一欄永遠是 0,出事也看不出來。
weight_daemon.py 體重常駐監聽,站上去就自動記錄
scripts/sync_dashboard.sh 轉檔 + 報告資料新舊
scripts/com.superbeeee.weight-daemon.plist 常駐
scripts/com.superbeeee.training-sync.plist 每天 07:00
不過三個 ❌ 裡,只有體重那個是真的動手做掉的。另外兩個是查清楚之後,發現本來就不該打叉。
前九天做的都是同一件事:把資料處理乾淨、轉成 JSON、讓它自己更新。
沒有什麼畫面,明天要把 sessions.json 接到畫面上,這個系列的第一張圖表!