iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
Modern Web

教練看不到的那六天|從 FIT 檔到 3D 軌跡,馬拉松訓練資料 Dashboard系列 第 9

Day 9|排程:讓資料自己更新,以及為什麼只跑在自己電腦上

  • 分享至 

  • xImage
  •  

昨天把訓練紀錄轉成了 JSON,但要讓儀表板的數字是新的,得自己開終端機打指令。

今天要處理這件事—設定排程,讓它能自己跑。

動手前先翻了一遍自己的程式碼,想確認這條管線裡有幾段可以自動跑。


第一個結論:只有最後一段能排程

翻程式碼的第一個發現是:garmin_sync.pynotion_sync.py 都只是把抓到的資料印出來,程式結束就沒了。真正把資料寫進訓練紀錄的,是 Telegram bot 那條路:

我在 Telegram 打 /run
  → garmin_sync 抓資料,存成 JSON 丟進 coach/incoming/
  → 交給 Claude:「依 CLAUDE.md 規則分析並記錄到訓練紀錄;
     已記錄過的活動(比對日期與內容)不要重複記,只補新的」
  → Claude 判斷後寫進 coach/logs/*.md

看到「已記錄過的不要重複記」這句,當下的反應是:這是判斷,不是搬運,所以不能沒有人做確認。

照這個邏輯排下來,四段管線只有一段能排程:

這一段 當下的判斷
Garmin/Notion → 訓練紀錄 ❌ 要判斷「這筆記過沒有」
體重機 → weight.md ❌ 要有人站上去
教練課表 ❌ 要教練傳圖過來
訓練紀錄 → JSON ✅ 純轉檔

寫到這裡本來要下一個很有感觸的結論:大部分事情自動化不了,因為它們需要人來處理。

還好動筆之前又回去看了一次程式碼。


這個結論與想像中不同

自己把兩件事混成一件:

  • 產生資料—要去跑、要舉、要站上體重機、教練要開課表
  • 把資料收進系統—從裝置或 API 拿到那份資料,寫進檔案

第一件事永遠需要人。第二件事幾乎都不需要。

最明顯的例子是體重。


體重:把 timeout 拿掉就好

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 呢

「已記錄過的不要重複記」聽起來很像需要智慧,但實際看一下資料就知道不用。

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

排程有兩條路:本機定時執行,或丟到 GitHub Actions。Actions 免費、筆電關機照跑,聽起來明顯比較好。

但今天要排的這兩支,情況不太一樣。

體重那支只能在本機。 BLE 廣播的範圍就那麼大,體重機在我家客廳,雲端再強也收不到藍牙訊號。

轉檔那支雲端做得到,只是不划算。 訓練紀錄就在 repo 裡,Actions 讀得到—但產出要寫進儀表板那個 repo,兩邊是分開的,跨 repo 寫檔得另外開一組存取權杖。既然體重那支非得留在本機,轉檔跟著放同一台反而單純。

兩支都留在本機,這個專案的重心本來就在這台電腦上。

(將來如果要把 Garmin 那段也排程,一樣得在本機—登入 token 快取在 ~/.garminconnect,不在任何 repo 裡,Actions 每跑一次就得重登一次。)


macOS 上不要用 cron

決定跑本機之後,直覺是 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 接到畫面上,這個系列的第一張圖表!



上一篇
Day 8|把訓練紀錄變成 JSON:解得出來,也要比得出來
下一篇
Day 10|訓練 Dashboard 的初畫面:九天之後,終於有東西可以看
系列文
教練看不到的那六天|從 FIT 檔到 3D 軌跡,馬拉松訓練資料 Dashboard14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言