# garmin-health

用非官方 `garminconnect` 把 Garmin Connect 全量健康數據同步進本地 SQLite,並做長期分析的個人專案。
完整歷史、分析結論、已知問題與待辦都在交接文件 `SESSION_HANDOFF.md`;動手前先看它的現況與待辦。安裝、檔案結構、使用方式與疑難排解在 `README.md`。

> 這份是 slightlysweets.com〈用 Claude 把 Garmin 健康數據搬回本機〉一文附的分享版（2026 年 11 月）。拿掉了個人資料、健康數據、個人基線與分析結論，`<...>` 要換成自己的值。交接文件、README 與程式沒有附上，這份只當寫法的參考。

## 與使用者合作

- 一律用繁體中文,回覆精簡。
- 較大的功能或架構變更:先提設計、討論後再實作。
- 分析結論要標明樣本數,並說明相關不等於因果;健康異常只說是資料訊號,不做醫療診斷。
- 健康資料只放本機;任何上傳到第三方(包括 Google Drive)都要先取得同意。原專案同意過的例外:每週的週報摘要寄到使用者自己的 Gmail;同一份內容送 Anthropic API 請 Claude Opus 5.5 寫本週分析(只限 `analysis_payload`,即信裡本來就有的內容)。拿這份當範本時,新專案預設不寄信、不呼叫 API:使用者確認收件人、外送的欄位與服務,下面「外送的防線」的檢查也在程式裡做好、驗證過之後才打開。其他資料、其他服務仍要先問。

## 架構(同步與 healthcheck 的行為以 README 與程式為準;週報完整規格見交接文件)

- launchd `com.example.garmin-sync`:每天定時執行 `run_sync.sh` 三次(Mac 休眠錯過時,喚醒後補跑;另有 RunAtLoad)。流程:等 `connectapi.garmin.com` 可解析(最多 60 秒)→ `garmin_fetch.py --daily` → `healthcheck.py` → 同步成功才跑 `analysis_weekly.py --auto`。同步成功會 touch `logs/.last_success`。
  - 每次執行先寫一行「── run_sync 開始(喚醒種類,電源)」標記(例如 `(DarkWake,電池)`,來自 `pmset -g systemstate`/`batt`),查 log 從這行找。DarkWake 等待與 RunAtLoad 防重見下面「規則與坑」。
- 資料庫 `garmin.sqlite`:`raw_data`(每日 × 指標的原始 JSON)、`daily_summary`(每日一列的精簡指標)、`activities`(活動紀錄)。
- 深度分析放在 `analysis/`:用 `bash analysis/run_all.sh` 執行,流程是 extract(呼叫 `external_merge.py` 併入自己記錄的事件、體重、行事曆欄位)→ a1–a12 → build_report,輸出固定檔名 `analysis/garmin-健康數據深度分析.html`(只留最新一份)。a5 異常偵測分急性層(不用呼吸率)與慢性層(居住地 28 天對裝置基線),只放在分析裡、不做即時通知。
- 週報 `analysis_weekly.py`(完整規格、失敗處理與手動旗標見交接文件):上一個完整週(週一–週日)還沒有週報、且資料庫已有下週一以後的資料才產生本機 `reports/weekly/週報-<週一>.md`(完整版)與 `.json`,寄一封到使用者的 Gmail,最底下附 Claude Opus 5.5 的本週分析(`weekly_ai.py`)。輸出寫 `logs/weekly.log`,不影響同步與 healthcheck。訊號只用 R1,本週行動只談睡眠。
  - 外送的防線(對應上面的同意範圍):信與送 API 的 `analysis_payload` 都不含 `<不外送的健康資訊>`,也不含 R1 的預期值、差值與預期/門檻線(信靠 `MAIL_R1_DETAIL = False`;payload 固定不含,和這個開關無關)。信的文字或圖裡的字含 `<不外送的字樣清單>` 就不寄;payload 送出前另查這份清單與 R1 的數字,中了就不送;分析含這些字樣時只放本機版。改信或 payload 時不能拿掉這些檢查,也不能多送資料種類(要先問)。
  - 會寄信或呼叫 API 的執行方式:
    - `--auto`:只在輸出到預設的 reports/weekly 時寄信、呼叫 API;寄信看 `MAIL_ENABLED`,呼叫 API 和它無關(不寄信時也會呼叫)。
    - 手動 `--asof YYYY-MM-DD`:覆寫那週的 .md、.json 與圖,不通知、不寄;排程正在等這週的分析時,不加旗標也會呼叫 API。
    - 加 `--send` 寄出(這週已寄過就不寄,重寄用 `--resend`):資料沒變就沿用 `analysis.json`,否則呼叫 API。加 `--analyze`:一律重新呼叫 API,不寄。
    - `--send`/`--resend` 不看 `MAIL_ENABLED`,也不看 `--out`:用測試的 `--out` 照樣會真的寄到 Gmail;`--send`、`--resend`、`--analyze` 用測試的 `--out` 也照樣呼叫 API。
  - 開關:`MAIL_ENABLED`(使用者看過含分析的測試信後才打開)、`ANALYSIS_ENABLED`。
- 本機排程任務(Claude app 的 Code → Routines,類型 Local):清單與狀態見交接文件。電腦醒著、app 開著才會跑,錯過時醒來補跑一次;各自開一個 session,改檔前先問;內容在 `~/.claude/scheduled-tasks/<taskId>/SKILL.md`。Routines 標 succeeded 不代表有做事(曾有任務卡在權限詢問 10 小時也標成功),要看 summary 或 session 內容。

## 常用指令

```bash
bash run_sync.sh && cat status.json                        # 手動同步 + 健康檢查
.venv/bin/python garmin_fetch.py --backfill A --end B       # 回補區間(要重抓既有列加 --force)
.venv/bin/python report.py 14                               # 最近 14 天摘要
bash install_launchd.sh && launchctl start com.example.garmin-sync
bash analysis/run_all.sh
```

## 規則與坑

- **版本控制**:本機 git(`main`,沒有遠端)。`.gitignore` 排除資料庫、備份、`.env`、`logs/`、`status.json`、`reports/`、`analysis/external/` 的資料與所有 csv/tsv/png/html 產出;程式與文件有進版控,而 CLAUDE.md、交接文件、程式註解裡也有健康資訊,所以**不要加遠端、不要 push**。改完程式或文件,使用者要求時再 commit。
- **Python 版本**:Python ≥ 3.12(garminconnect 0.3.x 的要求);`.venv` 是 3.13,裝的是 garminconnect 0.3.20 與 anthropic 1.12.1(週報分析),`requirements.txt` 都鎖版本。升級 garminconnect 前先在另一個 venv 用暫存資料庫測過(`--db`),再改 `requirements.txt`。官方 Personal API 的評估見 `docs/official_api_evaluation.md`(結論:先不接)。
- **上游關注**(只看、不參與):Garmin 改登入時,通常先在 python-garminconnect 的 releases 修好(https://github.com/cyberjunky/python-garminconnect/releases),要預警同步會不會被擋先看那裡;soma-intelligence/open-health-sync 的 PR #172(`garmin_connect`,用 Go 重寫私有 Connect API 的連接器)用來看別人怎麼處理同樣的問題。
- **同步只能在這台 Mac 跑**:Garmin API 不要從雲端或 CI 呼叫,因為出口會被封鎖,資料中心 IP 也會被限速(429)。
- **資料庫與備份**:
  - 不要刪除或覆寫 `garmin.sqlite` 和 `backups/`;改 schema 前先備份。
  - daily 模式會自動補最近 30 天內的斷線與抓取缺列;更早的缺口要手動 backfill(healthcheck 會通知並附指令)。同一個資料庫同時只跑一個同步(檔案鎖 `garmin.sqlite.lock`)。
  - 「資料完整」的判斷靠 raw_data 的列:`save_raw` 只在抓取成功時寫入(內容空也寫);強制重抓失敗時 `mark_stale` 把舊列的 `fetched_at` 設成 NULL。所以「沒有列或 fetched_at 是 NULL」= 抓取出錯,下次 daily 會補;沒戴錶的空白日有列,不算缺口。
- **`healthcheck.py`**(通知的去重規則見 README「健康檢查與通知」):
  - 錯誤分類只看最近一次執行的輸出(最後一個「同步完成」或 run_sync 開始標記之後)。
  - 順序 network → rate_limit → token → api_broken → env_broken。網路錯誤的 traceback 常夾帶 `GarminConnectAuthenticationError`,順序弄錯就會誤報 token 失效。
  - 警戒規則 R1(`alert_rules.py`,只用標準函式庫):RHR、HRV 和個人預期值(`<自己的基線與係數>`)比,居住地 28 天 RHR 高於預期 `<門檻>` 連 7 天、7 晚 HRV 低於預期 `<門檻>` 連 5 天才通知。舊規則 R0 退役,只寫 `alerts` 欄位不通知。退回舊行為:`HC_ALERT_MODE=shadow`(`healthcheck.py`)。`.venv/bin/python -B alert_rules.py --history` 可唯讀印出回測。居住地判定會排除 events.csv 的 type=trip(和居住地同時區的出國)。
- **喚醒補跑多半落在 DarkWake**:`pmset -g log` 顯示以前失敗的補跑都在 7–45 秒的電池 DarkWake 內,網路還沒起來。`run_sync.sh` 開頭有兩個開關:腳本裡的 `DARKWAKE_WAIT`、`RUNATLOAD_GUARD` 預設是 1,可以用環境變數 `SYNC_DARKWAKE_WAIT`、`SYNC_RUNATLOAD_GUARD` 覆寫,設 0 即停用;已實地驗證:
  - `SYNC_DARKWAKE_WAIT`:電池 DarkWake 時不同步,每 10 秒檢查,連續 2 次 FullWake 才開始,上限 24 h。
  - `SYNC_RUNATLOAD_GUARD`:非互動執行時,60 分內成功過就略過。從 Claude Code 的 Bash 手動跑(非 tty)也會被防重擋下,要加 `SYNC_RUNATLOAD_GUARD=0`。
- **帳密**(建立鑰匙圈項目的指令見 README「安全」):token 失效時 `garmin_fetch.py` 重登,密碼讀 macOS 鑰匙圈(一般密碼項目,service `<Garmin 密碼的 service>`;先用 email 找,找不到再只用 service 找)。`.env` 只剩 `GARMIN_EMAIL`,沒有密碼。不要讀出或印出鑰匙圈密碼與 `.env` 內容(測試只看 `security find-generic-password` 的結束碼,不加 `-w`/`-g`)。Gmail 應用程式密碼也在鑰匙圈(service `<Gmail 的 service>`),規則相同;測試寄信一律換掉 `smtp_send` 與 `mail_password`,不連 Gmail。Anthropic API key(service `<API key 的 service>`)規則也相同;測試週報一律換掉 `weekly_ai.analyze`(保險起見 `weekly_ai.api_key` 也換成回 `(None, 'missing')`),不呼叫真的 API;要測 `weekly_ai` 本身就用假 key 加 `httpx2.MockTransport` 或 127.0.0.1 的假伺服器。`.env` 不要進版控、不要直接刪。
- **資料語意**:
  - 睡眠列的日期是起床那天。
  - HRV、訓練準備度、VO2max 是否有資料視錶款而定,換錶前後要分開看。
  - 3 分鐘壓力明細 Garmin 只保留近幾個月。
  - 當晚時區 = `sleepStartTimestampLocal − GMT`,旅行偵測靠這個;時區法看不到和居住地同時區的行程,要配合 `analysis/external/calendar_travel.tsv`。
  - 比較不同時期的 RHR/HRV 之前,先確認那段期間的資料可以比;指標和個人預期值的關係也可能隨時間改變。
- **圖表**:
  - 沿用 `analysis/viz_style.py`:dataviz 參考色盤、單一 y 軸、細線、選擇性標註。
  - Mac 上的中文字型也由它偵測(PingFang / Arial Unicode)。
- **`build_report.py` 的敘事文字**:數字都從 stats 重算。頁首出現黃色提示(`CHECKS` 條件不成立,或資料超過版本 45 天)時要重寫敘事。
- **睡眠 → RHR/HRV 的效應**:用 `a11_sleep_effect.py` 的校正版;數字隨資料重算,以 `analysis/stats_sleep_effect.json` 為準。
