Wellness Stack — Architecture (2026-04-28 pivot)¶
Layer 1 — Capture¶
| Source | Path | Cadence |
|---|---|---|
| Eight Sleep | HA native integration (HACS lukas-clarke/eight_sleep) → sensor.justin_s_eight_sleep_side_* and sensor.krystal_s_eight_sleep_side_* |
real-time (every few minutes during use) |
| Garmin | scripts/integrations/garmin/poll.py cron |
4×/day at 15:00, 18:00, 22:00, 02:00 UTC (= 10/13/17/21 CDT) — all OUTSIDE Justin's typical sleep window |
| Cron Eight Sleep poller | scripts/integrations/eight-sleep/poll.py |
DISABLED 2026-04-28, cron entries commented # DISABLED 2026-04-28. Archived to memory/_archive/eight-sleep-cron-pre-pivot-2026-04-28/. |
Why the Eight Sleep cron was retired: the HA integration covers the same data, updates in real time (vs every 4-8h), and uses named entities (justin_s_* / krystal_s_*) instead of left/right side labels that flipped night to night. The cron was also polling at 06:00 UTC (01:00 CT) — during Justin's sleep — so current_sleep_score returned NULL. The HA integration fires when the score is computed, regardless of clock time. Bonus: it exposes richer metrics (sleep_fitness_score, sleep_quality_score, sleep_routine_score, sleep_stage, breath_rate, bed_temperature).
Confirmed user_id mapping (verified against actual sleep durations on 2026-04-28):
- Justin = right side, user_id c78dce09aa6c4e638dd92bd86a002302
- Krystal = left side, user_id b5ce51a3d34e4b25a053f57ec42af514
(The wellness-stack-improvements handoff doc had these reversed; the cron poller comments were correct.)
Layer 2 — Persistence¶
| Layer | What it stores | Used by |
|---|---|---|
| Home Assistant | Live entity state — all sensor.*_eight_sleep_side_* and sensor.garmin_* |
Dashboard (/wellness-dash), Alexa via HA Cloud, automations |
SQLite data/context.db facts_wellness |
15-min snapshots of all wellness metrics, full time-series | Context API, daily summarizer, brain tools |
Notion "💪 Wellness Daily" DB (3500950b-d7a9-811c-8e10-f3065f763b35) |
One row per day with score / duration / stages / HRV / Δ7d / readiness / Sonnet-written notes / tags / flags / Justin's journal | Justin (life-coach review on phone), brain via wellness_history tool |
ha_poller (infra/context-api/scripts/ha_poller.py) runs every 15 min via cron:
- Eight Sleep: reads sensor.{justin,krystal}_s_eight_sleep_side_{sleep_fitness_score, sleep_quality_score, sleep_routine_score, time_slept, hrv, heart_rate, breath_rate, bed_temperature, target_temperature, sleep_stage, bed_state_type, presence_start, presence_end} and binary_sensor.*.bed_presence. Writes user_side = "justin"/"krystal".
- Eight Sleep hub: sensor.eight_sleep_pod_4_eight_sleep_hub_room_temperature → user_side=NULL, metric=room_temp.
- Garmin: reads sensor.garmin_* → writes user_side="justin", new metrics include hrv_overnight, hrv_status, hrv_weekly_avg, sleep_deep_min, sleep_light_min, sleep_rem_min, sleep_awake_min, respiration_avg, spo2_avg.
- Back-compat alias: ha_poller also writes sleep_fitness_score value under metric=score so older readers (the morning brief's cand.get("score") walk) keep working. Drop this alias once all readers are migrated.
Layer 3 — Daily summarizer¶
scripts/integrations/wellness/daily-summary.py runs at 03:00 UTC (22:00 CT CDT / 21:00 CT CST):
- Reads /context (window=7d) for current values + 7d averages
- Computes deltas (HRV vs 7d, etc.)
- Determines flags (partial-bed if duration<240, anomaly-hrv-low if Δ≤-15, anomaly-stress-high if >60, missing-data, ...)
- Determines tags (Recovery if HRV+5 vs avg AND readiness≥75; Hard Day if HRV-5 OR stress≥50; Sleep Debt <360min; Peak if readiness≥85 AND HRV≥avg)
- Calls Sonnet (claude -p --model claude-sonnet-4-6) for a 1-2 sentence coach note
- Idempotent upsert into Notion via notion-query-database (filter by Day) → notion-update-page if exists, else notion-create-page
- Logs to logs/integrations/wellness-daily.log
Layer 4 — Surfaces¶
| Surface | What it does |
|---|---|
HA /wellness-dash |
5 views: Today (gauges + sleep stages + activity + bed control), Trends 7d, Trends 30d, Krystal, Detail. Uses custom:mini-graph-card (HACS) for sparklines + gauge cards. Storage at /config/.storage/lovelace.wellness_dash. Today + Krystal gauge stacks each show 4 metrics: Sleep Fitness, HRV, Slept (h), RHR (bpm). Garmin sleep stages glance shows Total + Deep + Light + REM + Awake. Display-only template sensors: sensor.{justin,krystal}_eight_sleep_hours_slept (sec→h) and sensor.garmin_total_sleep_hours (min→h) live in /config/packages/wellness_templates.yaml. |
Morning brief (scripts/morning-brief.py, cron 12:00 UTC = 7am CT) |
Reads /context, prefers eight_sleep.justin key, anomaly-flags partial-bed (duration <240 OR score <50) and falls back to Garmin sleep_score in that case. HRV falls back from Eight Sleep → Garmin overnight. |
Brain wellness tools (in inbox_brain.py): |
wellness_now (live snapshot from /context), wellness_history(days=N) (Notion Wellness Daily rows). |
| Alexa via HA Cloud | Wellness sensors auto-exposed via alexa_default_expose: ["sensor", ...]. Justin runs "Alexa, discover devices" once to pick them up. Friendly names default to entity friendly_name (wordy). To rename: HA Settings → Cloud → Alexa → entity-by-entity, OR set up HA Assist + LLM agent for free-form voice queries. |
Garmin sync caveat¶
Modern Garmin watches push sleep / HRV to Garmin Connect via Bluetooth opportunistically — typically within 15-30 min of waking when phone is in range. Not guaranteed: dead phone / Bluetooth drop / wrist off charger can delay. The connectapi library only sees what's in cloud, no "force sync watch" call exists. Mitigation: 28 polls/day, all post-wake; morning brief tolerates stale data and prefers Eight Sleep (always real-time during sleep).
Partial-response handling (added 2026-04-29): when g.get_user_summary(today) returns a dict that lacks totalSteps, the watch hasn't synced today's activity to the cloud yet. The poller now skips the entire user_summary block in that case, so steps / distance / calories / RHR / intensity_minutes keep their last-known good values rather than getting overwritten by stale zeros. The skipped state is logged as kind=warn step=user_summary in garmin.log. Sleep / HRV / body battery / stress / SpO2 endpoints are independent and continue to update.
Files (post-pivot)¶
scripts/integrations/
├── README.md
├── setup.sh ← venv + deps
├── install-cron.sh ← cron (idempotent)
├── morning-wellness-check.sh ← STILL ACTIVE 07:30 UTC; reads sensor.eight_sleep_right_* — soft-deprecated, replaced by morning brief anomaly flagging. Don't trust its alerts post-pivot until repointed.
├── ha_dashboard_setup.py ← STALE (built old dashboard). Don't rerun — would clobber the new one. Delete or rewrite when next touching.
├── .venv/ ← Python venv (urllib3 pinned <2.4 to avoid http2 import bug, 2026-04-28)
├── _lib/{ha,envload}.py
├── eight-sleep/ ← DISABLED. Cron commented. Code preserved in case HA integration breaks.
│ └── poll.py + pyEight/
├── garmin/
│ ├── poll.py ← 4×/day, captures HRV / sleep stages / SpO2 / respiration as of 2026-04-28
│ └── backfill.py
└── wellness/
└── daily-summary.py ← NEW: 22:00 CT nightly Notion upsert
Crontab (UTC)¶
# Eight Sleep cron — DISABLED 2026-04-28 (HA native integration is canonical)
# 0 6 * * * eight-sleep/poll.py
# 0 22 * * * eight-sleep/poll.py
# Garmin — every 30 min during Justin's day window (14:00-03:00 UTC = 09:00-22:00 CT CDT)
*/30 14-23,0-3 * * * forge_garmin_poll.py (28 polls/day, bumped from 14 on 2026-04-29)
30 7 * * * morning-wellness-check.sh ← legacy, soft-deprecated
# Context API
*/15 * * * * infra/context-api/scripts/ha_poller.py
# Wellness daily summarizer (Notion upsert)
0 3 * * * scripts/integrations/wellness/daily-summary.py ← 22:00 CT
# Morning brief (consumes /context)
0 12 * * * scripts/morning-brief.py
On-demand refresh¶
POST /refresh/wellness on the Context API (port 7358 on UDev, bound to 0.0.0.0 since 2026-04-28). Forces:
1. HA homeassistant.update_entity for Eight Sleep sensors (triggers integration coordinator update)
2. Garmin poll subprocess
3. ha_poller subprocess
Wired from HA: rest_command.forge_refresh_wellness in /config/configuration.yaml POSTs to http://192.168.86.50:7358/refresh/wellness with the bearer token. Dashboard has a refresh button (mushroom-template-card) in the top row of the Today view that calls this rest_command.
Eight Sleep cadence: the HA native integration auto-polls every ~15-30 min when idle, more often during active use. No cron needed — the integration handles its own update timing.
Credentials (re-layered 2026-06-10)¶
~/.forge-secrets/home-assistant.envis the ONLY place HA_URL / HA_TOKEN live. forge/.env carried a stale duplicate (old IP 192.168.86.70 after HA moved to .180) that silently killed Garmin + Eight Sleep ingest 2026-05-12..06-10. envload.py layers it in; ha_poller and the HA dashboard scripts load it with override; forge-context-api.service has it as a second EnvironmentFile. Never re-add HA_* to forge/.env./home/justinwieb/forge/.env— CONTEXT_API_TOKEN and other non-HA service config/home/justinwieb/.forge-secrets/wellness.env— Eight Sleep + Garmin creds (chmod 600, outside repo). Eight Sleep creds still used IF Justin re-enables the cron poller; otherwise only Garmin's creds are read./home/justinwieb/.forge-secrets/n8n.env— N8N_BASE_URL / N8N_WEBHOOK_SECRET (for daily-summary.py + brain tools)- n8n encrypted store: cred IDs
mEef8a4KkOzn3d7y(Eight Sleep) andsaT0X6MMAFLKrBOT(Garmin) preserved.
Garmin venv quirk (fixed 2026-04-28)¶
The inbox venv (~/.forge-venvs/inbox/) had urllib3 mid-upgrade to 2.6.3 with broken http2 submodule. Pinned to urllib3<2.4 to fix. Pip in that venv was also missing — restored from infra/context-api/.venv. Both fixes preserved.
Brain tool registry entries (added 2026-04-28)¶
In scripts/integrations/telegram/inbox_brain.py:
- NOTION_DBS["wellness_daily"] = "3500950b-d7a9-811c-8e10-f3065f763b35"
- tool_wellness_now() — Sleep 82 · 6h57m · HRV 85 (Δ7d -12.5) · RHR 53 · Readiness 60 · BB 79 · Stress 19 · Steps 136 · (BALANCED)
- tool_wellness_history(days=7) — pulls last N rows of Notion Wellness Daily, returns formatted summary lines including the Sonnet-written Notes field
Common queries¶
- Live snapshot:
curl -H "Authorization: Bearer $CONTEXT_API_TOKEN" 'http://127.0.0.1:7358/context?about=wellness&window=24h' - Manual ha_poller run:
infra/context-api/.venv/bin/python infra/context-api/scripts/ha_poller.py - Manual Garmin poll:
scripts/integrations/.venv/bin/python scripts/integrations/garmin/poll.py - Manual Wellness Daily upsert:
infra/context-api/.venv/bin/python scripts/integrations/wellness/daily-summary.py