+
    ,j_&                        R t ^ RIt^ RIt^ RIt^ RIHtHt ^ RIHt ^ RIH	t	 ^ RI
t
]! R4      t]R,          t]R,          t]R,          t]R	,          tR
tRt^<tR R ltR R ltR)R R lltR*R R lltR*R R lltR*R R lltR R ltR R ltR+R R lltR R lt]3R  R! llt]3R" R# lltR$t] R%8X  d   ^ RI!t!]"! ]!PF                  4      ^8  d:   ]!PF                  ^,          R&8X  d"   ]$! ]PJ                  ! ]! 4       ^R'7      4       R# ]"! ]!PF                  4      ^8  dC   ]!PF                  ^,          R(8X  d+   ]! ^ 4       ]$! ]PJ                  ! ]! ^ 4      ^R'7      4       R# ]$! ] 4       R# R# ),ua  
Whoop access library for Cameron's fleet of agents.

Other agents (e.g. glasses) can import this directly. Handles token refresh,
caching, and v2 API access. Same-user (cameronsmith) only — relies on chmod 600
on creds/tokens.

USAGE (from another agent on the same box):

    import sys
    sys.path.insert(0, "/data/cameron/life/scripts")
    from whoop_lib import (
        get_recovery, get_sleep, get_cycle_strain,
        get_workouts, get_latest_summary, refresh_if_stale,
    )

    summary = get_latest_summary()          # cached <60s, instant after first call
    print(summary["recovery_score"])        # 77.0
    print(summary["hours_asleep"])          # 8.23
    print(summary["strain_today"])          # 4.78

Functions auto-refresh the access token via the stored refresh token when needed.
Cached calls hit a JSON snapshot on disk and avoid spamming Whoop's API.

DATA AVAILABILITY:
- Recovery score, HRV, RHR, SpO2, skin temp: YES
- Sleep hours, REM, deep, performance, efficiency, disturbances: YES
- Current cycle strain (kJ + avg/max HR): YES
- Workouts list / current workout: REQUIRES re-OAuth with `read:workout` scope
  (see EXTEND_WORKOUT_SCOPE below)
- Step count: NOT exposed by Whoop API (they measure strain in kJ, not steps).
  Closest proxy: kilojoule from cycle.score.
- Start/stop workout: NOT supported by Whoop API. User starts/stops on
  device/app; you can read it after the fact.

RATE LIMITS:
Whoop rate limits per app, ~100 req/min. The cached helper functions are safe
for tight polling (e.g. 1 Hz read of cached file). Hard-refresh functions hit
the API — keep those polls >30s apart.
N)datetimetimezone)Path)Anyz/data/cameron/lifez.whoop_creds.jsonz.whoop_tokens.jsonz.whoop_readiness.jsonz.whoop_pull.logz-https://api.prod.whoop.com/oauth/oauth2/tokenz'https://api.prod.whoop.com/developer/v2c                (    V ^8  d   QhR\         RR/# )   msgreturnN)str)formats   "'/data/cameron/life/scripts/whoop_lib.py__annotate__r   ?   s     ' 'c 'd '    c                    \         P                  ! \        P                  4      P	                  4       p\
        P                  R 4      ;_uu_ 4       pVP                  V RV  R24       RRR4       R#   + '       g   i     R# ; i)az [lib] 
N)r   nowr   utc	isoformatLOGopenwrite)r   tsfs   &  r   _logr   ?   sQ    	hll	#	-	-	/B	#!	2$gcU"%& 
s   A22B	c                $    V ^8  d   QhR\         /# )r   r	   dict)r   s   "r   r   r   E   s      t r   c                     \         P                  ! \        P                  4       4      p \         P                  ! \        P                  4       4      p\
        P                  ! \        R RRVR,          RV R,          RV R,          RR/RR/^R7      pVP                  4        VP                  4       p\        P                  \         P                  ! V^R	7      4       \        P                  ! \        R
4       V# )
grant_typerefresh_token	client_idclient_secretscopeofflinezContent-Typez!application/x-www-form-urlencoded)dataheaderstimeoutindent  )jsonloadsCREDS	read_textTOKENSrequestspost	TOKEN_URLraise_for_status
write_textdumpsoschmod)credstokensr
new_tokenss       r   _refresh_access_tokenr<   E   s    JJu()EZZ((*+F/VO4{+U?3Y
  !DE	A J
djjA67HHVUr   c                J    V ^8  d   QhR\         R\        R,          R\        /# )r   pathparamsNr	   )r
   r   )r   s   "r   r   r   [   s%     & &# &td{ &d &r   c                   \         P                  ! \        P                  4       4      p\	        ^4       F  p\
        P                  ! \         V  2RRVR,           2/T;'       g    / ^R7      pVP                  R8X  d   V^ 8X  d   \        4       pK_  VP                  4        VP                  4       u # 	  \        R4      h)z!GET with one auto-refresh on 401.AuthorizationzBearer access_token)r&   r?   r'     unreachable)r+   r,   r/   r.   ranger0   getAPI_BASEstatus_coder<   r3   RuntimeError)r>   r?   r9   attemptr:   s   &&   r   _get_with_retryrK   [   s    ZZ((*+F8LLj$~0F/G&HI<<R	
 ==CGqL*,F	vvx  }
%%r   c                0    V ^8  d   QhR\         R\        /# r   force_refreshr	   boolr   )r   s   "r   r   r   o   s     , , , ,r   c                d    \        RR^/4      pVP                  R4      ;'       g    / .^ ,          # )zNLatest recovery score record. Returns dict with score_state and score subdict.z	/recoverylimitrecordsrK   rF   rN   r%   s   & r   get_recoveryrV   o   s0    ;!5DHHY''B4++r   c                0    V ^8  d   QhR\         R\        /# rM   rO   )r   s   "r   r   r   u        , ,T ,d ,r   c                d    \        RR^/4      pVP                  R4      ;'       g    / .^ ,          # )zCLatest cycle (current day). Includes strain, kilojoule, avg/max HR.z/cyclerR   rS   rT   rU   s   & r   	get_cyclerZ   u   s0    8gq\2DHHY''B4++r   c                0    V ^8  d   QhR\         R\        /# rM   rO   )r   s   "r   r   r   {   rX   r   c                d    \        RR^/4      pVP                  R4      ;'       g    / .^ ,          # )zDLatest sleep activity. Includes stage_summary + sleep_performance %.z/activity/sleeprR   rS   rT   rU   s   & r   	get_sleepr]   {   s1    ,wl;DHHY''B4++r   c                2    V ^8  d   QhR\         R,          /# r   r	   Nfloat)r   s   "r   r   r      s     4 4%$, 4r   c                 j    \        4       p V P                  R4      ;'       g    / P                  R4      # )z#Today's strain so far (0-21 scale).scorestrainrZ   rF   cycles    r   get_cycle_strainrh      s+    KEIIg$$"))(33r   c                2    V ^8  d   QhR\         R,          /# r_   r`   )r   s   "r   r   r      s     7 7edl 7r   c                 j    \        4       p V P                  R4      ;'       g    / P                  R4      # )uV   Total kJ burned today (closest Whoop has to 'calories' — divide by ~4.184 for kcal).rc   	kilojoulere   rf   s    r   get_kilojoules_todayrl      s+    KEIIg$$"))+66r   c                F    V ^8  d   QhR\         R\        \        ,          /# )r   rR   r	   )intlistr   )r   s   "r   r   r      s       DJ r   c                     \        RRV /4      pVP                  R4      ;'       g    . #   \        P                   d<   pTP                  e(   TP                  P
                  R8X  d   \        R4      Thh Rp?ii ; i)u   List recent workouts. NOTE: requires `read:workout` scope.
Currently the OAuth handshake doesn't include this — call will 401 until
Cameron re-auths. See EXTEND_WORKOUT_SCOPE below.
z/activity/workoutrR   rS   NrC   zWhoop OAuth doesn't include read:workout scope. Cameron needs to re-run whoop_oauth.py with the scope added. See EXTEND_WORKOUT_SCOPE in whoop_lib.py for details.)rK   rF   r0   	HTTPErrorresponserH   PermissionError)rR   r%   es   &  r   get_workoutsru      s~    

2We4DExx	"((b( ::!ajj&<&<&C!H 	
 	s   %+ + A; 6A66A;c                2    V ^8  d   QhR\         R,          /# r_   r   )r   s   "r   r   r      s      TD[ r   c                 b    \        ^R7       F  p V P                  R4      '       d   K  V u # 	  R# )zMWorkout in progress (end == None), if any. Same scope caveat as get_workouts.)rR   endN)ru   rF   )ws    r   get_current_workoutrz      s(    "uuU||H # r   c                0    V ^8  d   QhR\         R\        /# r   stale_sr	   )rn   r   )r   s   "r   r   r      s     8 8 8$ 8r   c                   \         P                  4       '       dn   \        P                  ! 4       \         P                  4       P                  ,
          pW8  d0   \
        P                  ! \         P                  4       4      R,          # \        ^ 4       \
        P                  ! \         P                  4       4      R,          # )a  
Fast path: returns the parsed summary from .whoop_readiness.json.
Refreshes via full pull if file is older than `stale_s`.

Schema (every key may be None if Whoop hasn't scored yet):
  recovery_score, hrv_rmssd_milli, resting_heart_rate, spo2_percentage,
  skin_temp_celsius, sleep_performance_percentage, sleep_efficiency_percentage,
  sleep_consistency_percentage, respiratory_rate, hours_asleep,
  disturbance_count, rem_minutes, deep_minutes, strain_today
summary)		READINESSexiststimestatst_mtimer+   r,   r.   refresh_if_stale)r}   ages   & r   get_latest_summaryr      sw     iikINN,555=::i1134Y??Q::i))+,Y77r   c                0    V ^8  d   QhR\         R\        /# r|   )rn   rP   )r   s   "r   r   r      s     - -c - -r   c                    \         P                  4       '       dA   \        P                  ! 4       \         P                  4       P                  ,
          pW8  d   R# \        4       p\        4       p\        4       pVP                  R4      ;'       g    / pVP                  R4      ;'       g    / pVP                  R4      ;'       g    / pVP                  R^ 4      pVP                  R^ 4      p	V'       d   W,
          R,          MRp
RVP                  R4      R	VP                  R	4      R
VP                  R
4      RVP                  R4      RVP                  R4      RVP                  R4      RVP                  R4      RVP                  R4      RVP                  R4      RV
e   \        V
^4      MRRVP                  R4      R\        VP                  R^ 4      R,          4      ;'       g    RR\        VP                  R^ 4      R,          4      ;'       g    RRVP                  R4      ;'       g    / P                  R4      RVP                  R4      ;'       g    / P                  R4      /pR\        P                  ! \        P                  4      P                  4       RVRVRVR V/p\         P                  \         P"                  ! V^R!7      4       \$        P&                  ! \         R"4       \)        R#VR,           R$24       R%# )&zjRe-run the full pull if the readiness file is older than `stale_s`.
Returns True if a fresh pull happened.Frc   stage_summarytotal_in_bed_time_millitotal_awake_time_millii6 Nrecovery_scorehrv_rmssd_milliresting_heart_ratespo2_percentageskin_temp_celsiussleep_performance_percentagesleep_efficiency_percentagesleep_consistency_percentagerespiratory_ratehours_asleepdisturbance_countrem_minutestotal_rem_sleep_time_millii`  deep_minutes total_slow_wave_sleep_time_millistrain_todayrd   kilojoules_todayrk   	pulled_atr   rg   recoverysleepr(   r*   z%refresh_if_stale -> pulled (recovery=)T)r   r   r   r   r   rZ   rV   r]   rF   roundr   r   r   r   r   r4   r+   r5   r6   r7   r   )r}   r   rg   r   r   	rec_scoresleep_scoresleep_stages	in_bed_msawake_ms
asleep_hrsr   results   &            r   r   r      s    iikINN,555=KE~HKEW%++I))G$**K???399rL  !:A>I 8!<H5>)&'1DJ 	)--(899==):;imm,@A9==):;Y]]+>?&8V(W%{7T'U&8V(WKOO,>?
0Fj!,D\--.ABu\--.JANQVVW__[_l../QSTUX]]^ffbf7+11r66x@UYYw/552::;GG$ 	X\\(,,/99;7HF F156HHY09I1J0K1	MNr   a  
To enable workout reads:
1. Cameron updates Whoop app at https://developer-dashboard.whoop.com
   to include `read:workout` scope.
2. Edit /data/cameron/life/scripts/whoop_oauth.py SCOPES string to add `read:workout`.
3. Re-run `python3 /data/cameron/life/scripts/whoop_oauth.py` (one-time browser auth).
4. After that, get_workouts() and get_current_workout() will work.

WHOOP DOES NOT EXPOSE: step count, ability to start/stop workouts via API.
Workouts must be triggered on the device/app; the API can only read them after.
__main__z	--summaryr(   z--fresh)N)F)   )&__doc__r+   r6   r   r   r   pathlibr   typingr   r0   LIFEr-   r/   r   r   r2   rG   DEFAULT_STALE_Sr   r<   rK   rV   rZ   r]   rh   rl   ru   rz   r   r   EXTEND_WORKOUT_SCOPE__name__syslenargvprintr5    r   r   <module>r      sI  'P   '     !""	$	$**	
 <	4 ',&(,,,47$ '6 8& %4 -b
  z
388}qSXXa[K7djj+-a89	SXX	sxx{i7djj+A.q9:g r   