Created
January 18, 2026 21:20
-
-
Save martinamps/b2691903b4f5a13a81f41ff3fba4ef04 to your computer and use it in GitHub Desktop.
eightsleep syncer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Eight Sleep API Types | |
| * | |
| * Types for Eight Sleep Pod integration including API responses, | |
| * processed data structures, and summary aggregates. | |
| * | |
| * Based on reverse-engineered API from pyEight and 8slp libraries. | |
| */ | |
| // ============================================================================= | |
| // API Authentication Types | |
| // ============================================================================= | |
| /** | |
| * Stored authentication token with expiration. | |
| */ | |
| export interface EightSleepToken { | |
| accessToken: string; | |
| expiresAt: number; // Unix timestamp in seconds | |
| userId: string; | |
| } | |
| /** | |
| * Raw authentication response from Eight Sleep API. | |
| */ | |
| export interface EightSleepAuthResponse { | |
| access_token: string; | |
| expires_in: number; | |
| userId: string; | |
| token_type: string; | |
| } | |
| /** | |
| * Configuration for Eight Sleep sync. | |
| */ | |
| export interface EightSleepConfig { | |
| email: string; | |
| password: string; | |
| timezone?: string; | |
| clientId?: string; | |
| clientSecret?: string; | |
| } | |
| // ============================================================================= | |
| // API Constants | |
| // ============================================================================= | |
| export const EIGHTSLEEP_API_CONSTANTS = { | |
| CLIENT_API_URL: "https://client-api.8slp.net/v1", | |
| APP_API_URL: "https://app-api.8slp.net/", | |
| AUTH_URL: "https://auth-api.8slp.net/v1/tokens", | |
| KNOWN_CLIENT_ID: "0894c7f33bb94800a03f1f4df13a4f38", | |
| KNOWN_CLIENT_SECRET: | |
| "f0954a3ed5763ba3d06834c73731a32f15f168f47d4f164751275def86db0c76", | |
| TOKEN_BUFFER_SECONDS: 120, | |
| DEFAULT_TIMEOUT_MS: 30000, | |
| } as const; | |
| // ============================================================================= | |
| // User & Device Types | |
| // ============================================================================= | |
| /** | |
| * User profile information. | |
| */ | |
| export interface EightSleepUserProfile { | |
| userId: string; | |
| side: "left" | "right" | "solo"; | |
| firstName?: string; | |
| lastName?: string; | |
| email?: string; | |
| } | |
| /** | |
| * User API response from /users/{id} endpoint. | |
| */ | |
| export interface EightSleepUserResponse { | |
| user: { | |
| id: string; | |
| email: string; | |
| firstName: string; | |
| lastName: string; | |
| devices: string[]; | |
| features: string[]; | |
| currentDevice?: { | |
| id: string; | |
| side: "left" | "right" | "solo"; | |
| }; | |
| }; | |
| } | |
| /** | |
| * Device Kelvin (temperature control) status. | |
| */ | |
| export interface EightSleepKelvinStatus { | |
| currentActivity?: string; | |
| currentTargetLevel?: number; | |
| active?: boolean; | |
| } | |
| /** | |
| * Device status from /devices/{id} endpoint. | |
| */ | |
| export interface EightSleepDeviceResponse { | |
| result: { | |
| deviceId: string; | |
| leftUserId?: string; | |
| rightUserId?: string; | |
| awaySides?: Record<string, string>; | |
| needsPriming: boolean; | |
| priming: boolean; | |
| hasWater: boolean; | |
| lastPrime?: string; | |
| // Left side properties | |
| leftHeatingLevel?: number; | |
| leftTargetHeatingLevel?: number; | |
| leftHeatingDuration?: number; | |
| leftNowHeating?: boolean; | |
| leftPresenceEnd?: string; | |
| leftKelvin?: EightSleepKelvinStatus; | |
| // Right side properties | |
| rightHeatingLevel?: number; | |
| rightTargetHeatingLevel?: number; | |
| rightHeatingDuration?: number; | |
| rightNowHeating?: boolean; | |
| rightPresenceEnd?: string; | |
| rightKelvin?: EightSleepKelvinStatus; | |
| // Solo properties | |
| soloHeatingLevel?: number; | |
| soloTargetHeatingLevel?: number; | |
| soloHeatingDuration?: number; | |
| soloNowHeating?: boolean; | |
| soloPresenceEnd?: string; | |
| soloKelvin?: EightSleepKelvinStatus; | |
| }; | |
| } | |
| /** | |
| * Device status summary. | |
| */ | |
| export interface EightSleepDeviceStatus { | |
| deviceId: string; | |
| needsPriming: boolean; | |
| priming: boolean; | |
| hasWater: boolean; | |
| lastPrime?: string; | |
| } | |
| /** | |
| * Side-specific heating status. | |
| */ | |
| export interface EightSleepSideStatus { | |
| side: "left" | "right" | "solo"; | |
| isHeating: boolean; | |
| heatingLevel: number; | |
| targetHeatingLevel: number; | |
| heatingDuration?: number; | |
| presenceEnd?: string; | |
| } | |
| // ============================================================================= | |
| // Sleep Session Types | |
| // ============================================================================= | |
| /** | |
| * Sleep stage with duration. | |
| */ | |
| export interface EightSleepStage { | |
| stage: "awake" | "light" | "deep" | "rem" | "out"; | |
| duration: number; // seconds | |
| } | |
| /** | |
| * Snoring segment with intensity. | |
| */ | |
| export interface EightSleepSnoringSegment { | |
| intensity: "none" | "light" | "medium" | "heavy"; | |
| duration: number; // seconds | |
| } | |
| /** | |
| * Timeseries data structure (array of [timestamp, value] tuples). | |
| */ | |
| export type EightSleepTimeseries = [string, number][]; | |
| /** | |
| * All timeseries data for a sleep session. | |
| */ | |
| export interface EightSleepTimeseriesData { | |
| tnt?: EightSleepTimeseries; // Toss and turns | |
| tempRoomC?: EightSleepTimeseries; | |
| tempBedC?: EightSleepTimeseries; | |
| respiratoryRate?: EightSleepTimeseries; | |
| heartRate?: EightSleepTimeseries; | |
| hrv?: EightSleepTimeseries; | |
| rmssd?: EightSleepTimeseries; | |
| } | |
| /** | |
| * Raw sleep session from /intervals endpoint. | |
| */ | |
| export interface EightSleepSession { | |
| id: string; | |
| ts: string; // Session start timestamp (ISO) | |
| deviceTimeAtUpdate?: string; | |
| day?: string; // YYYY-MM-DD | |
| incomplete?: boolean; | |
| stages?: EightSleepStage[]; | |
| snoring?: EightSleepSnoringSegment[]; | |
| timeseries?: EightSleepTimeseriesData; | |
| score?: number; | |
| sleepFitnessScore?: { | |
| total: number; | |
| sleepDurationSeconds?: { | |
| current: number; | |
| average: number; | |
| score: number; | |
| }; | |
| latencyAsleepSeconds?: { | |
| current: number; | |
| average: number; | |
| score: number; | |
| }; | |
| latencyOutSeconds?: { | |
| current: number; | |
| average: number; | |
| score: number; | |
| }; | |
| wakeupConsistency?: { | |
| current: string; | |
| average: string; | |
| score: number; | |
| }; | |
| }; | |
| presenceStart?: string; | |
| presenceEnd?: string; | |
| stageSummary?: { | |
| totalDuration: number; | |
| sleepDuration: number; | |
| awakeDuration: number; | |
| lightDuration: number; | |
| deepDuration: number; | |
| remDuration: number; | |
| awakeBeforeSleepDuration: number; | |
| awakeBetweenSleepDuration: number; | |
| awakeAfterSleepDuration: number; | |
| wasoDuration: number; | |
| }; | |
| } | |
| /** | |
| * Intervals API response. | |
| */ | |
| export interface EightSleepIntervalsResponse { | |
| intervals: EightSleepSession[]; | |
| } | |
| // ============================================================================= | |
| // Processed Sleep Session | |
| // ============================================================================= | |
| /** | |
| * Processed sleep session with derived fields for easier consumption. | |
| */ | |
| export interface ProcessedSleepSession extends EightSleepSession { | |
| /** Date string (YYYY-MM-DD) derived from session timestamp */ | |
| date: string; | |
| /** Day of week (0=Sunday, 6=Saturday) */ | |
| dayOfWeek: number; | |
| /** Day of week name (e.g., "Monday") */ | |
| dayName: string; | |
| /** Total sleep duration in minutes (excluding awake time) */ | |
| sleepDurationMinutes: number; | |
| /** Total time in bed in minutes */ | |
| presenceDurationMinutes: number; | |
| /** Sleep efficiency percentage */ | |
| sleepEfficiency: number; | |
| /** Stage durations in minutes */ | |
| stageDurations: { | |
| awake: number; | |
| light: number; | |
| deep: number; | |
| rem: number; | |
| }; | |
| /** Stage percentages of total sleep (0-100) */ | |
| stagePercentages: { | |
| deep: number; | |
| rem: number; | |
| light: number; | |
| }; | |
| /** Aggregated metrics from timeseries */ | |
| metrics: { | |
| hrvAvg: number | null; | |
| hrAvg: number | null; | |
| rrAvg: number | null; | |
| bedTempAvgC: number | null; | |
| roomTempAvgC: number | null; | |
| tossAndTurns: number | null; | |
| }; | |
| /** Sleep timing */ | |
| timing: { | |
| sleepStart: string | null; // HH:MM format | |
| sleepEnd: string | null; // HH:MM format | |
| sleepMidpoint: string | null; // HH:MM format | |
| latencyMinutes: number | null; | |
| }; | |
| } | |
| // ============================================================================= | |
| // Trend Types (Daily Aggregates from API) | |
| // ============================================================================= | |
| /** | |
| * Health metric with value, rating, and ranges. | |
| */ | |
| export interface EightSleepHealthMetric { | |
| value: number; | |
| rating: "inRange" | "outOfRange"; | |
| timeseries?: EightSleepTimeseries; | |
| ranges: { | |
| inRange: { min: number; max: number }; | |
| outOfRange: { min: number; max?: number }; | |
| }; | |
| } | |
| /** | |
| * Wellbeing abnormality detection for a metric. | |
| */ | |
| export interface EightSleepAbnormalityMetric { | |
| value: number; | |
| isAbnormal: boolean; | |
| probability: number; | |
| dayOfWeekAverage: number; | |
| dayOfWeekStdDev: number; | |
| dayOfWeekDeviation: number; | |
| abnormalLowerBound: number; | |
| abnormalUpperBound: number; | |
| } | |
| /** | |
| * Wellbeing analysis with abnormality detection. | |
| */ | |
| export interface EightSleepWellbeingHealth { | |
| value: number; | |
| rating: "inRange" | "outOfRange"; | |
| abnormalities: { | |
| hr: EightSleepAbnormalityMetric; | |
| hrv: EightSleepAbnormalityMetric; | |
| rr: EightSleepAbnormalityMetric; | |
| }; | |
| dayCount: number; | |
| daysUntilAvailable: number; | |
| daysUntilCalibrated: number; | |
| ranges: { | |
| inRange: { min: number; max: number }; | |
| outOfRange: { min: number; max?: number }; | |
| }; | |
| } | |
| /** | |
| * Health metrics for a trend day. | |
| */ | |
| export interface EightSleepTrendHealth { | |
| breathing: EightSleepHealthMetric; | |
| heartbeat: EightSleepHealthMetric; | |
| wellbeing: EightSleepWellbeingHealth; | |
| } | |
| /** | |
| * Chronotype information. | |
| */ | |
| export interface EightSleepChronotype { | |
| source: "pod" | "user"; | |
| chronoClass: "early" | "late" | "neutral"; | |
| isCalibrating: boolean; | |
| calibrationDays: number; | |
| internalChronotypeDate?: string; | |
| } | |
| /** | |
| * Social jetlag analysis. | |
| */ | |
| export interface EightSleepSocialJetlag { | |
| socialJetlagSeconds: number; | |
| avgWeekdaySleepMidpoint: string; | |
| avgWeekendSleepMidpoint: string; | |
| } | |
| /** | |
| * Performance window (exercise or cognitive). | |
| */ | |
| export interface EightSleepPerformanceWindow { | |
| activeRuleName: string; | |
| exerciseWindowStart?: string; | |
| exerciseWindowMidpoint?: string; | |
| exerciseWindowEnd?: string; | |
| cognitiveWindowStart?: string; | |
| cognitiveWindowMidpoint?: string; | |
| cognitiveWindowEnd?: string; | |
| } | |
| /** | |
| * Performance windows configuration and stats. | |
| */ | |
| export interface EightSleepPerformanceWindows { | |
| isAvailable: boolean; | |
| configuration: { | |
| chronotypeBaselineDays: number; | |
| chronotypeMinimumCalibrationDays: number; | |
| chronotypeFullyCalibratedDays: number; | |
| socialJetlagMinSessions: number; | |
| performanceWindowsBaselineDays: number; | |
| performanceWindowsMinimumCalibrationDays: number; | |
| }; | |
| performanceWindowStats: { | |
| currentSleepStart: string; | |
| currentSleepMidpoint: string; | |
| currentSleepEnd: string; | |
| currentTotalSleepTimeSeconds: number; | |
| bedtimeBaseline: string; | |
| sleepStartBaseline: string; | |
| sleepEndBaseline: string; | |
| sleepMidpointBaseline: string; | |
| wasoBaseline: number; | |
| totalSleepTimeSecondsBaseline: number; | |
| deepSleepSecondsBaseline: number; | |
| }; | |
| chronotype: EightSleepChronotype; | |
| socialJetlag: EightSleepSocialJetlag; | |
| exerciseWindow: EightSleepPerformanceWindow; | |
| cognitiveWindow: EightSleepPerformanceWindow; | |
| } | |
| /** | |
| * Score component with current value, ranges, and weighted contribution. | |
| */ | |
| export interface EightSleepScoreComponent { | |
| current: number | string; | |
| lowerRange: number | string; | |
| upperRange: number | string; | |
| lowerBound: number | string; | |
| upperBound: number | string; | |
| average: number | string; | |
| stdDev?: number; | |
| score: number; | |
| weight: number; | |
| weighted: number; | |
| available: boolean; | |
| inclusive7DayAverage?: number | string; | |
| } | |
| /** | |
| * Sleep quality score breakdown. | |
| */ | |
| export interface EightSleepQualityScore { | |
| total: number; | |
| weight: number; | |
| weighted: number; | |
| sleepDurationSeconds: EightSleepScoreComponent; | |
| hrv: EightSleepScoreComponent; | |
| respiratoryRate: EightSleepScoreComponent; | |
| heartRate: EightSleepScoreComponent; | |
| deep: EightSleepScoreComponent; | |
| rem: EightSleepScoreComponent; | |
| waso: EightSleepScoreComponent; | |
| snoringDurationSeconds: EightSleepScoreComponent; | |
| heavySnoringDurationSeconds: EightSleepScoreComponent; | |
| sleepDebt?: { | |
| firstSleepDate: string; | |
| dailySleepDebtSeconds: number; | |
| baselineSleepDurationSeconds: number; | |
| isCalibrating: boolean; | |
| }; | |
| } | |
| /** | |
| * Sleep routine score breakdown. | |
| */ | |
| export interface EightSleepRoutineScore { | |
| total: number; | |
| weight: number; | |
| weighted: number; | |
| wakeupConsistency: EightSleepScoreComponent; | |
| sleepStartConsistency: EightSleepScoreComponent; | |
| bedtimeConsistency: EightSleepScoreComponent; | |
| latencyAsleepSeconds: EightSleepScoreComponent; | |
| latencyOutSeconds: EightSleepScoreComponent; | |
| } | |
| /** | |
| * Daily trend data from /trends endpoint. | |
| */ | |
| export interface EightSleepTrendDay { | |
| day: string; // YYYY-MM-DD | |
| incomplete?: boolean; | |
| tags?: string[]; | |
| // Duration metrics (seconds) | |
| presenceDuration: number; | |
| sleepDuration: number; | |
| remDuration: number; | |
| lightDuration: number; | |
| deepDuration: number; | |
| // Percentage metrics (0-1) | |
| remPercent: number; | |
| deepPercent: number; | |
| // Snoring metrics | |
| snoreDuration: number; | |
| heavySnoreDuration: number; | |
| snorePercent: number; // 0-100 | |
| heavySnorePercent: number; // 0-100 | |
| theoreticalSnorePercent?: number; | |
| snoringReductionPercent?: number; | |
| // Snoring mitigation | |
| mitigationEvents?: number; | |
| stoppedSnoringEvents?: number; | |
| reducedSnoringEvents?: number; | |
| ineffectiveExtendedEvents?: number; | |
| cancelledEvents?: number; | |
| elevationDuration?: number; | |
| elevationAutopilotAdjustmentCount?: number; | |
| // Timing | |
| presenceStart: string; | |
| presenceEnd: string; | |
| sleepStart: string; | |
| sleepEnd: string; | |
| // Activity metrics | |
| tnt: number; // Toss and turns count | |
| // Session references | |
| mainSessionId: string; | |
| sessionIds: string[]; | |
| // Health metrics | |
| health?: EightSleepTrendHealth; | |
| // Hot flash data (for menopause tracking) | |
| hotFlash?: { | |
| summary: { sessionCount: number }; | |
| sessions: unknown[]; | |
| }; | |
| // Performance windows & chronotype | |
| performanceWindows?: EightSleepPerformanceWindows; | |
| // Scores | |
| score: number; // Overall sleep score (0-100) | |
| sleepQualityScore: EightSleepQualityScore; | |
| sleepRoutineScore: EightSleepRoutineScore; | |
| } | |
| /** | |
| * Trends API response. | |
| */ | |
| export interface EightSleepTrendsResponse { | |
| days: EightSleepTrendDay[]; | |
| } | |
| // ============================================================================= | |
| // Synced Data Structure (Raw Output) | |
| // ============================================================================= | |
| /** | |
| * Complete synced data from Eight Sleep API. | |
| */ | |
| export interface EightSleepSyncedData { | |
| syncedAt: string; | |
| userId: string; | |
| userProfile: EightSleepUserProfile; | |
| sessions: EightSleepSession[]; | |
| trends: EightSleepTrendDay[]; | |
| deviceStatus: EightSleepDeviceStatus; | |
| sideStatus: EightSleepSideStatus; | |
| } | |
| // ============================================================================= | |
| // Sessions JSON Structure (Processed Output) | |
| // ============================================================================= | |
| /** | |
| * Structure for eightsleepSessions.json file. | |
| */ | |
| export interface EightSleepSessionsData { | |
| sessions: ProcessedSleepSession[]; | |
| lastUpdated: string; // ISO timestamp | |
| } | |
| // ============================================================================= | |
| // Summary JSON Structure (Aggregated Output) | |
| // ============================================================================= | |
| /** | |
| * Nightly aggregate for a single date. | |
| */ | |
| export interface NightlyAggregate { | |
| score: number; | |
| sleepDuration: number; // minutes | |
| presenceDuration: number; // minutes | |
| deepPct: number; // 0-100 | |
| remPct: number; // 0-100 | |
| lightPct: number; // 0-100 | |
| hrvAvg: number | null; | |
| hrAvg: number | null; | |
| rrAvg: number | null; | |
| bedTempAvg: number | null; // Celsius | |
| roomTempAvg: number | null; // Celsius | |
| tnt: number; // Toss and turns | |
| latencyMinutes: number | null; | |
| sleepStart: string | null; // HH:MM | |
| sleepEnd: string | null; // HH:MM | |
| sleepMidpoint: string | null; // HH:MM | |
| sleepEfficiency: number; // 0-100 | |
| } | |
| /** | |
| * Summary statistics for a time period. | |
| */ | |
| export interface TrendSummary { | |
| avgScore: number; | |
| avgSleepDuration: number; // minutes | |
| avgDeepPct: number; | |
| avgRemPct: number; | |
| avgHRV: number | null; | |
| avgHR: number | null; | |
| avgRR: number | null; | |
| avgBedTemp: number | null; | |
| avgRoomTemp: number | null; | |
| avgTnt: number; | |
| avgLatency: number | null; | |
| avgSleepEfficiency: number; | |
| nightCount: number; | |
| } | |
| /** | |
| * Insights derived from sleep data. | |
| */ | |
| export interface EightSleepInsights { | |
| chronotype: "early_bird" | "night_owl" | "neutral"; | |
| optimalSleepWindow: { | |
| start: string; // HH:MM | |
| end: string; // HH:MM | |
| }; | |
| optimalExerciseWindow: { | |
| start: string; // HH:MM | |
| end: string; // HH:MM | |
| }; | |
| optimalCognitiveWindow: { | |
| start: string; // HH:MM | |
| end: string; // HH:MM | |
| }; | |
| sleepDebt: { | |
| currentDebtMinutes: number; | |
| isCalibrating: boolean; | |
| baselineSleepMinutes: number; | |
| }; | |
| sleepConsistency: { | |
| wakeupScore: number; | |
| bedtimeScore: number; | |
| sleepStartScore: number; | |
| }; | |
| socialJetlag: { | |
| minutes: number; | |
| weekdayMidpoint: string; | |
| weekendMidpoint: string; | |
| }; | |
| } | |
| /** | |
| * Structure for eightsleepSummary.json file. | |
| */ | |
| export interface EightSleepSummary { | |
| overview: { | |
| totalNights: number; | |
| avgSleepScore: number; | |
| avgSleepDuration: number; // hours | |
| avgHRV: number | null; | |
| avgBedTemp: number | null; // Celsius | |
| avgRoomTemp: number | null; // Celsius | |
| avgSleepEfficiency: number; | |
| dateRange: { | |
| first: string; // YYYY-MM-DD | |
| last: string; // YYYY-MM-DD | |
| }; | |
| }; | |
| /** Daily aggregates keyed by date (YYYY-MM-DD) */ | |
| nightly: Record<string, NightlyAggregate>; | |
| /** Derived insights */ | |
| insights: EightSleepInsights; | |
| /** Rolling trend summaries */ | |
| trends: { | |
| last7d: TrendSummary; | |
| last30d: TrendSummary; | |
| last90d: TrendSummary; | |
| }; | |
| /** Last sync timestamp */ | |
| lastUpdated: string; | |
| } | |
| // ============================================================================= | |
| // Chart Data Types | |
| // ============================================================================= | |
| /** | |
| * Data point for Eight Sleep score chart. | |
| */ | |
| export interface EightSleepScoreChartPoint { | |
| date: string; | |
| score: number; | |
| qualityScore: number; | |
| routineScore: number; | |
| } | |
| /** | |
| * Data point for sleep stage chart. | |
| */ | |
| export interface EightSleepStageChartPoint { | |
| date: string; | |
| deep: number; // minutes | |
| rem: number; // minutes | |
| light: number; // minutes | |
| awake: number; // minutes | |
| } | |
| /** | |
| * Data point for snoring chart. | |
| */ | |
| export interface EightSleepSnoringChartPoint { | |
| date: string; | |
| totalMinutes: number; | |
| heavyMinutes: number; | |
| percentOfSleep: number; | |
| } | |
| /** | |
| * Data point for temperature chart. | |
| */ | |
| export interface EightSleepTempChartPoint { | |
| date: string; | |
| bedTemp: number | null; | |
| roomTemp: number | null; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Eight Sleep Sync Script | |
| * | |
| * Fetches sleep data from Eight Sleep API and saves processed data to JSON files. | |
| * Follows the incremental merge pattern from stravaSync.ts. | |
| * | |
| * Usage: bun run sync-eightsleep | |
| * | |
| * Environment variables: | |
| * EIGHTSLEEP_EMAIL - Eight Sleep account email | |
| * EIGHTSLEEP_PASSWORD - Eight Sleep account password | |
| * EIGHTSLEEP_TIMEZONE - Timezone (default: America/Los_Angeles) | |
| */ | |
| import fs from "fs"; | |
| import path from "path"; | |
| import type { | |
| EightSleepConfig, | |
| EightSleepToken, | |
| EightSleepAuthResponse, | |
| EightSleepUserResponse, | |
| EightSleepDeviceResponse, | |
| EightSleepSession, | |
| EightSleepIntervalsResponse, | |
| EightSleepTrendDay, | |
| EightSleepTrendsResponse, | |
| EightSleepUserProfile, | |
| EightSleepDeviceStatus, | |
| EightSleepSideStatus, | |
| ProcessedSleepSession, | |
| EightSleepSessionsData, | |
| EightSleepSummary, | |
| NightlyAggregate, | |
| TrendSummary, | |
| EightSleepInsights, | |
| } from "eightsleep.d"; | |
| // API Constants (duplicated here to avoid .d.ts import issues at runtime) | |
| const API_CONSTANTS = { | |
| CLIENT_API_URL: "https://client-api.8slp.net/v1", | |
| AUTH_URL: "https://auth-api.8slp.net/v1/tokens", | |
| KNOWN_CLIENT_ID: "0894c7f33bb94800a03f1f4df13a4f38", | |
| KNOWN_CLIENT_SECRET: | |
| "f0954a3ed5763ba3d06834c73731a32f15f168f47d4f164751275def86db0c76", | |
| TOKEN_BUFFER_SECONDS: 120, | |
| } as const; | |
| class EightSleepSync { | |
| private config: EightSleepConfig; | |
| private dataDir: string; | |
| private token: EightSleepToken | null = null; | |
| private deviceIds: string[] = []; | |
| private userProfile: EightSleepUserProfile | null = null; | |
| private isPod: boolean = false; | |
| constructor() { | |
| this.config = { | |
| email: process.env.EIGHTSLEEP_EMAIL || "", | |
| password: process.env.EIGHTSLEEP_PASSWORD || "", | |
| timezone: process.env.EIGHTSLEEP_TIMEZONE || "America/Los_Angeles", | |
| }; | |
| this.dataDir = path.join(process.cwd(), "src", "data"); | |
| } | |
| // ========================================================================== | |
| // Authentication | |
| // ========================================================================== | |
| private async authenticate(): Promise<EightSleepToken> { | |
| const response = await fetch(API_CONSTANTS.AUTH_URL, { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| "User-Agent": "EightSleepSync/1.0", | |
| Accept: "application/json", | |
| }, | |
| body: JSON.stringify({ | |
| client_id: API_CONSTANTS.KNOWN_CLIENT_ID, | |
| client_secret: API_CONSTANTS.KNOWN_CLIENT_SECRET, | |
| grant_type: "password", | |
| username: this.config.email, | |
| password: this.config.password, | |
| }), | |
| }); | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| throw new Error( | |
| `Authentication failed (${response.status}): ${errorText}` | |
| ); | |
| } | |
| const data = (await response.json()) as EightSleepAuthResponse; | |
| return { | |
| accessToken: data.access_token, | |
| expiresAt: Math.floor(Date.now() / 1000) + data.expires_in, | |
| userId: data.userId, | |
| }; | |
| } | |
| private async getToken(): Promise<EightSleepToken> { | |
| const now = Math.floor(Date.now() / 1000); | |
| if ( | |
| !this.token || | |
| now + API_CONSTANTS.TOKEN_BUFFER_SECONDS >= this.token.expiresAt | |
| ) { | |
| console.log("Authenticating with Eight Sleep..."); | |
| this.token = await this.authenticate(); | |
| } | |
| return this.token; | |
| } | |
| private async apiRequest<T>(method: string, endpoint: string): Promise<T> { | |
| const token = await this.getToken(); | |
| const url = `${API_CONSTANTS.CLIENT_API_URL}/${endpoint}`; | |
| const response = await fetch(url, { | |
| method, | |
| headers: { | |
| "Content-Type": "application/json", | |
| Authorization: `Bearer ${token.accessToken}`, | |
| "User-Agent": "EightSleepSync/1.0", | |
| Accept: "application/json", | |
| }, | |
| }); | |
| if (response.status === 401) { | |
| this.token = null; | |
| return this.apiRequest(method, endpoint); | |
| } | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| throw new Error(`API request failed (${response.status}): ${errorText}`); | |
| } | |
| return (await response.json()) as T; | |
| } | |
| // ========================================================================== | |
| // Initialization | |
| // ========================================================================== | |
| async initialize(): Promise<void> { | |
| console.log("Initializing Eight Sleep client..."); | |
| const token = await this.getToken(); | |
| // Fetch user info | |
| const userResponse = await this.apiRequest<EightSleepUserResponse>( | |
| "GET", | |
| "users/me" | |
| ); | |
| this.deviceIds = userResponse.user.devices; | |
| if (userResponse.user.features.includes("cooling")) { | |
| this.isPod = true; | |
| } | |
| console.log(`Found ${this.deviceIds.length} device(s). Pod: ${this.isPod}`); | |
| // Get user side assignment | |
| if (this.deviceIds.length > 0) { | |
| const deviceId = this.deviceIds[0]; | |
| const deviceResponse = await this.apiRequest<EightSleepDeviceResponse>( | |
| "GET", | |
| `devices/${deviceId}?filter=leftUserId,rightUserId,awaySides` | |
| ); | |
| const result = deviceResponse.result; | |
| let side: "left" | "right" | "solo" = "solo"; | |
| if (result.leftUserId === token.userId) { | |
| side = "left"; | |
| } else if (result.rightUserId === token.userId) { | |
| side = "right"; | |
| } | |
| this.userProfile = { | |
| userId: token.userId, | |
| side, | |
| firstName: userResponse.user.firstName, | |
| lastName: userResponse.user.lastName, | |
| email: userResponse.user.email, | |
| }; | |
| console.log( | |
| `User: ${this.userProfile.firstName || "Unknown"} (${side} side)` | |
| ); | |
| } | |
| } | |
| // ========================================================================== | |
| // Data Fetching | |
| // ========================================================================== | |
| private async getIntervals(): Promise<EightSleepSession[]> { | |
| const token = await this.getToken(); | |
| const response = await this.apiRequest<EightSleepIntervalsResponse>( | |
| "GET", | |
| `users/${token.userId}/intervals` | |
| ); | |
| return response.intervals || []; | |
| } | |
| private async getTrends( | |
| fromDate: Date, | |
| toDate: Date | |
| ): Promise<EightSleepTrendDay[]> { | |
| const token = await this.getToken(); | |
| const fromStr = fromDate.toISOString(); | |
| const toStr = toDate.toISOString(); | |
| const tz = this.config.timezone || "America/Los_Angeles"; | |
| // Add include-all-sessions and model-version params for full historical data | |
| // Note: tz must not be URL-encoded (Eight Sleep API quirk) | |
| const params = [ | |
| `tz=${tz}`, | |
| `from=${encodeURIComponent(fromStr)}`, | |
| `to=${encodeURIComponent(toStr)}`, | |
| "include-main=false", | |
| "include-all-sessions=true", | |
| "model-version=v2", | |
| ].join("&"); | |
| const response = await this.apiRequest<EightSleepTrendsResponse>( | |
| "GET", | |
| `users/${token.userId}/trends?${params}` | |
| ); | |
| return response.days || []; | |
| } | |
| private async getDeviceStatus(): Promise<{ | |
| deviceStatus: EightSleepDeviceStatus; | |
| sideStatus: EightSleepSideStatus; | |
| }> { | |
| if (this.deviceIds.length === 0) { | |
| throw new Error("No devices found"); | |
| } | |
| const deviceId = this.deviceIds[0]; | |
| const response = await this.apiRequest<EightSleepDeviceResponse>( | |
| "GET", | |
| `devices/${deviceId}` | |
| ); | |
| const result = response.result; | |
| const side = this.userProfile?.side || "solo"; | |
| const deviceStatus: EightSleepDeviceStatus = { | |
| deviceId: result.deviceId, | |
| needsPriming: result.needsPriming, | |
| priming: result.priming, | |
| hasWater: result.hasWater, | |
| lastPrime: result.lastPrime, | |
| }; | |
| const prefix = side === "solo" ? "solo" : side; | |
| const sideStatus: EightSleepSideStatus = { | |
| side, | |
| isHeating: | |
| (result[`${prefix}NowHeating` as keyof typeof result] as boolean) || | |
| false, | |
| heatingLevel: | |
| (result[`${prefix}HeatingLevel` as keyof typeof result] as number) || 0, | |
| targetHeatingLevel: | |
| (result[ | |
| `${prefix}TargetHeatingLevel` as keyof typeof result | |
| ] as number) || 0, | |
| heatingDuration: result[ | |
| `${prefix}HeatingDuration` as keyof typeof result | |
| ] as number | undefined, | |
| presenceEnd: result[`${prefix}PresenceEnd` as keyof typeof result] as | |
| | string | |
| | undefined, | |
| }; | |
| return { deviceStatus, sideStatus }; | |
| } | |
| // ========================================================================== | |
| // Processing | |
| // ========================================================================== | |
| private processSession(session: EightSleepSession): ProcessedSleepSession { | |
| const ts = new Date(session.ts); | |
| const date = session.day || ts.toISOString().split("T")[0]; | |
| const dayOfWeek = ts.getDay(); | |
| const dayNames = [ | |
| "Sunday", | |
| "Monday", | |
| "Tuesday", | |
| "Wednesday", | |
| "Thursday", | |
| "Friday", | |
| "Saturday", | |
| ]; | |
| const dayName = dayNames[dayOfWeek]; | |
| // Calculate stage durations | |
| const stageDurations = { awake: 0, light: 0, deep: 0, rem: 0 }; | |
| if (session.stages) { | |
| for (const stage of session.stages) { | |
| if (stage.stage !== "out") { | |
| stageDurations[stage.stage] += stage.duration / 60; // Convert to minutes | |
| } | |
| } | |
| } | |
| const totalSleepMinutes = | |
| stageDurations.light + stageDurations.deep + stageDurations.rem; | |
| const totalTimeInBed = totalSleepMinutes + stageDurations.awake; | |
| // Calculate presence duration | |
| let presenceDurationMinutes = 0; | |
| if (session.presenceStart && session.presenceEnd) { | |
| const start = new Date(session.presenceStart); | |
| const end = new Date(session.presenceEnd); | |
| presenceDurationMinutes = (end.getTime() - start.getTime()) / 1000 / 60; | |
| } else { | |
| presenceDurationMinutes = totalTimeInBed; | |
| } | |
| // Calculate stage percentages | |
| const stagePercentages = { | |
| deep: | |
| totalSleepMinutes > 0 | |
| ? (stageDurations.deep / totalSleepMinutes) * 100 | |
| : 0, | |
| rem: | |
| totalSleepMinutes > 0 | |
| ? (stageDurations.rem / totalSleepMinutes) * 100 | |
| : 0, | |
| light: | |
| totalSleepMinutes > 0 | |
| ? (stageDurations.light / totalSleepMinutes) * 100 | |
| : 0, | |
| }; | |
| // Calculate metrics from timeseries | |
| const metrics = { | |
| hrvAvg: this.calculateTimeseriesAvg(session.timeseries?.hrv), | |
| hrAvg: this.calculateTimeseriesAvg(session.timeseries?.heartRate), | |
| rrAvg: this.calculateTimeseriesAvg(session.timeseries?.respiratoryRate), | |
| bedTempAvgC: this.calculateTimeseriesAvg(session.timeseries?.tempBedC), | |
| roomTempAvgC: this.calculateTimeseriesAvg(session.timeseries?.tempRoomC), | |
| tossAndTurns: session.timeseries?.tnt?.length || null, | |
| }; | |
| // Calculate timing | |
| let sleepStart: string | null = null; | |
| let sleepEnd: string | null = null; | |
| let sleepMidpoint: string | null = null; | |
| let latencyMinutes: number | null = null; | |
| if (session.presenceStart) { | |
| const presenceStartTime = new Date(session.presenceStart); | |
| sleepStart = this.formatTime(presenceStartTime); | |
| } | |
| // Use stageSummary.awakeBeforeSleepDuration for latency (more reliable) | |
| if (session.stageSummary?.awakeBeforeSleepDuration) { | |
| latencyMinutes = session.stageSummary.awakeBeforeSleepDuration / 60; | |
| } | |
| if (session.presenceEnd) { | |
| const presenceEndTime = new Date(session.presenceEnd); | |
| sleepEnd = this.formatTime(presenceEndTime); | |
| if (session.presenceStart) { | |
| const start = new Date(session.presenceStart); | |
| const end = new Date(session.presenceEnd); | |
| const midpoint = new Date((start.getTime() + end.getTime()) / 2); | |
| sleepMidpoint = this.formatTime(midpoint); | |
| } | |
| } | |
| const timing = { sleepStart, sleepEnd, sleepMidpoint, latencyMinutes }; | |
| // Calculate sleep efficiency | |
| const sleepEfficiency = | |
| presenceDurationMinutes > 0 | |
| ? (totalSleepMinutes / presenceDurationMinutes) * 100 | |
| : 0; | |
| return { | |
| ...session, | |
| date, | |
| dayOfWeek, | |
| dayName, | |
| sleepDurationMinutes: totalSleepMinutes, | |
| presenceDurationMinutes, | |
| sleepEfficiency, | |
| stageDurations, | |
| stagePercentages, | |
| metrics, | |
| timing, | |
| }; | |
| } | |
| private calculateTimeseriesAvg( | |
| ts: [string, number][] | undefined | |
| ): number | null { | |
| if (!ts || ts.length === 0) return null; | |
| const sum = ts.reduce((acc, [, val]) => acc + val, 0); | |
| return sum / ts.length; | |
| } | |
| private formatTime(date: Date): string { | |
| const hours = date.getHours().toString().padStart(2, "0"); | |
| const minutes = date.getMinutes().toString().padStart(2, "0"); | |
| return `${hours}:${minutes}`; | |
| } | |
| // ========================================================================== | |
| // Summary Generation | |
| // ========================================================================== | |
| private generateNightlyAggregate( | |
| session: ProcessedSleepSession | null, | |
| trend?: EightSleepTrendDay | |
| ): NightlyAggregate { | |
| // Prefer trend data if available, fall back to session data | |
| if (trend) { | |
| return { | |
| score: trend.score, | |
| sleepDuration: trend.sleepDuration / 60, // seconds to minutes | |
| presenceDuration: trend.presenceDuration / 60, | |
| deepPct: trend.deepPercent * 100, | |
| remPct: trend.remPercent * 100, | |
| lightPct: (1 - trend.deepPercent - trend.remPercent) * 100, | |
| hrvAvg: trend.health?.wellbeing?.abnormalities?.hrv?.value || null, | |
| hrAvg: trend.health?.heartbeat?.value || null, | |
| rrAvg: trend.health?.breathing?.value || null, | |
| bedTempAvg: session?.metrics.bedTempAvgC ?? null, | |
| roomTempAvg: session?.metrics.roomTempAvgC ?? null, | |
| tnt: trend.tnt, | |
| latencyMinutes: trend.sleepQualityScore?.latencyAsleepSeconds?.current | |
| ? trend.sleepQualityScore.latencyAsleepSeconds.current / 60 | |
| : (session?.timing.latencyMinutes ?? null), | |
| sleepStart: trend.sleepStart | |
| ? this.formatTime(new Date(trend.sleepStart)) | |
| : null, | |
| sleepEnd: trend.sleepEnd | |
| ? this.formatTime(new Date(trend.sleepEnd)) | |
| : null, | |
| sleepMidpoint: session?.timing.sleepMidpoint ?? null, | |
| sleepEfficiency: | |
| trend.presenceDuration > 0 | |
| ? (trend.sleepDuration / trend.presenceDuration) * 100 | |
| : 0, | |
| }; | |
| } | |
| // Fall back to session data only (session must exist if no trend) | |
| if (!session) { | |
| throw new Error("Either session or trend must be provided"); | |
| } | |
| return { | |
| score: session.score || 0, | |
| sleepDuration: session.sleepDurationMinutes, | |
| presenceDuration: session.presenceDurationMinutes, | |
| deepPct: session.stagePercentages.deep, | |
| remPct: session.stagePercentages.rem, | |
| lightPct: session.stagePercentages.light, | |
| hrvAvg: session.metrics.hrvAvg, | |
| hrAvg: session.metrics.hrAvg, | |
| rrAvg: session.metrics.rrAvg, | |
| bedTempAvg: session.metrics.bedTempAvgC, | |
| roomTempAvg: session.metrics.roomTempAvgC, | |
| tnt: session.metrics.tossAndTurns || 0, | |
| latencyMinutes: session.timing.latencyMinutes, | |
| sleepStart: session.timing.sleepStart, | |
| sleepEnd: session.timing.sleepEnd, | |
| sleepMidpoint: session.timing.sleepMidpoint, | |
| sleepEfficiency: session.sleepEfficiency, | |
| }; | |
| } | |
| private calculateTrendSummary(aggregates: NightlyAggregate[]): TrendSummary { | |
| if (aggregates.length === 0) { | |
| return { | |
| avgScore: 0, | |
| avgSleepDuration: 0, | |
| avgDeepPct: 0, | |
| avgRemPct: 0, | |
| avgHRV: null, | |
| avgHR: null, | |
| avgRR: null, | |
| avgBedTemp: null, | |
| avgRoomTemp: null, | |
| avgTnt: 0, | |
| avgLatency: null, | |
| avgSleepEfficiency: 0, | |
| nightCount: 0, | |
| }; | |
| } | |
| const avg = (values: (number | null)[]): number | null => { | |
| const valid = values.filter((v): v is number => v !== null && !isNaN(v)); | |
| return valid.length > 0 | |
| ? valid.reduce((a, b) => a + b, 0) / valid.length | |
| : null; | |
| }; | |
| const avgNum = (values: number[]): number => { | |
| return values.length > 0 | |
| ? values.reduce((a, b) => a + b, 0) / values.length | |
| : 0; | |
| }; | |
| return { | |
| avgScore: avgNum(aggregates.map((a) => a.score)), | |
| avgSleepDuration: avgNum(aggregates.map((a) => a.sleepDuration)), | |
| avgDeepPct: avgNum(aggregates.map((a) => a.deepPct)), | |
| avgRemPct: avgNum(aggregates.map((a) => a.remPct)), | |
| avgHRV: avg(aggregates.map((a) => a.hrvAvg)), | |
| avgHR: avg(aggregates.map((a) => a.hrAvg)), | |
| avgRR: avg(aggregates.map((a) => a.rrAvg)), | |
| avgBedTemp: avg(aggregates.map((a) => a.bedTempAvg)), | |
| avgRoomTemp: avg(aggregates.map((a) => a.roomTempAvg)), | |
| avgTnt: avgNum(aggregates.map((a) => a.tnt)), | |
| avgLatency: avg(aggregates.map((a) => a.latencyMinutes)), | |
| avgSleepEfficiency: avgNum(aggregates.map((a) => a.sleepEfficiency)), | |
| nightCount: aggregates.length, | |
| }; | |
| } | |
| private extractInsights(trends: EightSleepTrendDay[]): EightSleepInsights { | |
| // Find the most recent trend with performance windows | |
| const trendWithInsights = trends.find( | |
| (t) => t.performanceWindows?.isAvailable | |
| ); | |
| const pw = trendWithInsights?.performanceWindows; | |
| // Default values | |
| const defaultInsights: EightSleepInsights = { | |
| chronotype: "neutral", | |
| optimalSleepWindow: { start: "22:00", end: "06:00" }, | |
| optimalExerciseWindow: { start: "07:00", end: "09:00" }, | |
| optimalCognitiveWindow: { start: "10:00", end: "12:00" }, | |
| sleepDebt: { | |
| currentDebtMinutes: 0, | |
| isCalibrating: true, | |
| baselineSleepMinutes: 480, | |
| }, | |
| sleepConsistency: { wakeupScore: 0, bedtimeScore: 0, sleepStartScore: 0 }, | |
| socialJetlag: { | |
| minutes: 0, | |
| weekdayMidpoint: "02:00", | |
| weekendMidpoint: "02:00", | |
| }, | |
| }; | |
| if (!pw) return defaultInsights; | |
| // Map chronotype | |
| let chronotype: "early_bird" | "night_owl" | "neutral" = "neutral"; | |
| if (pw.chronotype?.chronoClass === "early") chronotype = "early_bird"; | |
| else if (pw.chronotype?.chronoClass === "late") chronotype = "night_owl"; | |
| // Extract exercise window | |
| const exerciseStart = | |
| pw.exerciseWindow?.exerciseWindowStart?.slice(11, 16) || "07:00"; | |
| const exerciseEnd = | |
| pw.exerciseWindow?.exerciseWindowEnd?.slice(11, 16) || "09:00"; | |
| // Extract cognitive window | |
| const cognitiveStart = | |
| pw.cognitiveWindow?.cognitiveWindowStart?.slice(11, 16) || "10:00"; | |
| const cognitiveEnd = | |
| pw.cognitiveWindow?.cognitiveWindowEnd?.slice(11, 16) || "12:00"; | |
| // Extract sleep window from baseline stats | |
| const sleepStart = | |
| pw.performanceWindowStats?.sleepStartBaseline?.slice(11, 16) || "22:00"; | |
| const sleepEnd = | |
| pw.performanceWindowStats?.sleepEndBaseline?.slice(11, 16) || "06:00"; | |
| // Extract sleep debt from quality score | |
| const latestWithDebt = trends.find((t) => t.sleepQualityScore?.sleepDebt); | |
| const debtInfo = latestWithDebt?.sleepQualityScore?.sleepDebt; | |
| // Extract consistency scores from routine score | |
| const latestWithRoutine = trends.find((t) => t.sleepRoutineScore); | |
| const routineScore = latestWithRoutine?.sleepRoutineScore; | |
| // Extract social jetlag | |
| const socialJetlagSeconds = pw.socialJetlag?.socialJetlagSeconds || 0; | |
| const weekdayMidpoint = | |
| pw.socialJetlag?.avgWeekdaySleepMidpoint?.slice(11, 16) || "02:00"; | |
| const weekendMidpoint = | |
| pw.socialJetlag?.avgWeekendSleepMidpoint?.slice(11, 16) || "02:00"; | |
| return { | |
| chronotype, | |
| optimalSleepWindow: { start: sleepStart, end: sleepEnd }, | |
| optimalExerciseWindow: { start: exerciseStart, end: exerciseEnd }, | |
| optimalCognitiveWindow: { start: cognitiveStart, end: cognitiveEnd }, | |
| sleepDebt: { | |
| currentDebtMinutes: (debtInfo?.dailySleepDebtSeconds || 0) / 60, | |
| isCalibrating: debtInfo?.isCalibrating ?? true, | |
| baselineSleepMinutes: | |
| (debtInfo?.baselineSleepDurationSeconds || 28800) / 60, | |
| }, | |
| sleepConsistency: { | |
| wakeupScore: routineScore?.wakeupConsistency?.score || 0, | |
| bedtimeScore: routineScore?.bedtimeConsistency?.score || 0, | |
| sleepStartScore: routineScore?.sleepStartConsistency?.score || 0, | |
| }, | |
| socialJetlag: { | |
| minutes: Math.abs(socialJetlagSeconds) / 60, | |
| weekdayMidpoint, | |
| weekendMidpoint, | |
| }, | |
| }; | |
| } | |
| private generateSummary( | |
| sessions: ProcessedSleepSession[], | |
| trends: EightSleepTrendDay[] | |
| ): EightSleepSummary { | |
| // Create trend lookup by date | |
| const trendByDate = new Map<string, EightSleepTrendDay>(); | |
| for (const trend of trends) { | |
| trendByDate.set(trend.day, trend); | |
| } | |
| // Generate nightly aggregates from both sessions and trends | |
| const nightly: Record<string, NightlyAggregate> = {}; | |
| // First, add all session-based data | |
| for (const session of sessions) { | |
| const trend = trendByDate.get(session.date); | |
| nightly[session.date] = this.generateNightlyAggregate(session, trend); | |
| } | |
| // Then, add trend-only data for days without sessions | |
| for (const trend of trends) { | |
| if (!nightly[trend.day] && trend.score && trend.sleepDuration > 0) { | |
| nightly[trend.day] = this.generateNightlyAggregate(null, trend); | |
| } | |
| } | |
| // Calculate trend summaries | |
| const allAggregates = Object.values(nightly); | |
| const now = new Date(); | |
| const last7d = allAggregates.filter((_, i) => { | |
| const date = Object.keys(nightly)[i]; | |
| const diff = | |
| (now.getTime() - new Date(date).getTime()) / (1000 * 60 * 60 * 24); | |
| return diff <= 7; | |
| }); | |
| const last30d = allAggregates.filter((_, i) => { | |
| const date = Object.keys(nightly)[i]; | |
| const diff = | |
| (now.getTime() - new Date(date).getTime()) / (1000 * 60 * 60 * 24); | |
| return diff <= 30; | |
| }); | |
| const last90d = allAggregates.filter((_, i) => { | |
| const date = Object.keys(nightly)[i]; | |
| const diff = | |
| (now.getTime() - new Date(date).getTime()) / (1000 * 60 * 60 * 24); | |
| return diff <= 90; | |
| }); | |
| // Calculate overview | |
| const dates = Object.keys(nightly).sort(); | |
| const overview = { | |
| totalNights: allAggregates.length, | |
| avgSleepScore: | |
| allAggregates.length > 0 | |
| ? allAggregates.reduce((a, b) => a + b.score, 0) / | |
| allAggregates.length | |
| : 0, | |
| avgSleepDuration: | |
| allAggregates.length > 0 | |
| ? allAggregates.reduce((a, b) => a + b.sleepDuration, 0) / | |
| allAggregates.length / | |
| 60 | |
| : 0, // Convert to hours | |
| avgHRV: this.avgNullable(allAggregates.map((a) => a.hrvAvg)), | |
| avgBedTemp: this.avgNullable(allAggregates.map((a) => a.bedTempAvg)), | |
| avgRoomTemp: this.avgNullable(allAggregates.map((a) => a.roomTempAvg)), | |
| avgSleepEfficiency: | |
| allAggregates.length > 0 | |
| ? allAggregates.reduce((a, b) => a + b.sleepEfficiency, 0) / | |
| allAggregates.length | |
| : 0, | |
| dateRange: { | |
| first: dates[0] || "", | |
| last: dates[dates.length - 1] || "", | |
| }, | |
| }; | |
| return { | |
| overview, | |
| nightly, | |
| insights: this.extractInsights(trends), | |
| trends: { | |
| last7d: this.calculateTrendSummary(last7d), | |
| last30d: this.calculateTrendSummary(last30d), | |
| last90d: this.calculateTrendSummary(last90d), | |
| }, | |
| lastUpdated: new Date().toISOString(), | |
| }; | |
| } | |
| private avgNullable(values: (number | null)[]): number | null { | |
| const valid = values.filter((v): v is number => v !== null && !isNaN(v)); | |
| return valid.length > 0 | |
| ? valid.reduce((a, b) => a + b, 0) / valid.length | |
| : null; | |
| } | |
| // ========================================================================== | |
| // Diff Display | |
| // ========================================================================== | |
| private showDiff( | |
| existing: ProcessedSleepSession[], | |
| updated: ProcessedSleepSession[] | |
| ): void { | |
| const existingIds = new Set(existing.map((s) => s.id)); | |
| const newSessions = updated.filter((s) => !existingIds.has(s.id)); | |
| if (newSessions.length === 0) { | |
| console.log("\nβ No new sessions found."); | |
| return; | |
| } | |
| console.log(`\nπ Found ${newSessions.length} new sleep session(s):\n`); | |
| // Sort by date (most recent first) | |
| newSessions.sort( | |
| (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime() | |
| ); | |
| for (const session of newSessions) { | |
| console.log(` ποΈ ${session.date} (${session.dayName})`); | |
| console.log( | |
| ` Score: ${session.score || "N/A"} | Duration: ${Math.round(session.sleepDurationMinutes)} min` | |
| ); | |
| console.log( | |
| ` Deep: ${session.stagePercentages.deep.toFixed(1)}% | REM: ${session.stagePercentages.rem.toFixed(1)}% | Light: ${session.stagePercentages.light.toFixed(1)}%` | |
| ); | |
| if (session.metrics.hrvAvg) { | |
| console.log(` HRV: ${session.metrics.hrvAvg.toFixed(1)} ms`); | |
| } | |
| if (session.timing.sleepStart && session.timing.sleepEnd) { | |
| console.log( | |
| ` Time: ${session.timing.sleepStart} - ${session.timing.sleepEnd}` | |
| ); | |
| } | |
| console.log(); | |
| } | |
| // Summary stats for new sessions | |
| const totalSleep = newSessions.reduce( | |
| (sum, s) => sum + s.sleepDurationMinutes, | |
| 0 | |
| ); | |
| const avgScore = | |
| newSessions.reduce((sum, s) => sum + (s.score || 0), 0) / | |
| newSessions.length; | |
| console.log("π New Sessions Summary:"); | |
| console.log(` Total: ${newSessions.length} nights`); | |
| console.log(` Total Sleep: ${(totalSleep / 60).toFixed(1)} hours`); | |
| console.log(` Avg Score: ${avgScore.toFixed(1)}`); | |
| } | |
| // ========================================================================== | |
| // Main Sync | |
| // ========================================================================== | |
| async sync(): Promise<void> { | |
| try { | |
| console.log("ποΈ Starting Eight Sleep sync...\n"); | |
| // Validate config | |
| if (!this.config.email || !this.config.password) { | |
| console.error( | |
| "Error: EIGHTSLEEP_EMAIL and EIGHTSLEEP_PASSWORD must be set" | |
| ); | |
| console.error("\nAdd to your .env file:"); | |
| console.error(" EIGHTSLEEP_EMAIL=your@email.com"); | |
| console.error(" EIGHTSLEEP_PASSWORD=yourpassword"); | |
| process.exit(1); | |
| } | |
| // Load existing data | |
| const sessionsPath = path.join(this.dataDir, "eightsleepSessions.json"); | |
| let existingSessions: ProcessedSleepSession[] = []; | |
| const existingIds = new Set<string>(); | |
| if (fs.existsSync(sessionsPath)) { | |
| try { | |
| const data: EightSleepSessionsData = JSON.parse( | |
| fs.readFileSync(sessionsPath, "utf-8") | |
| ); | |
| existingSessions = data.sessions || []; | |
| existingSessions.forEach((s) => existingIds.add(s.id)); | |
| console.log( | |
| `π Loaded ${existingSessions.length} existing sessions\n` | |
| ); | |
| } catch { | |
| console.log( | |
| "β οΈ Could not load existing sessions, treating as fresh sync\n" | |
| ); | |
| } | |
| } | |
| // Initialize client | |
| await this.initialize(); | |
| // Fetch data | |
| console.log("\nFetching sleep data from Eight Sleep..."); | |
| // Fetch intervals (sessions) | |
| process.stdout.write(" Fetching sessions..."); | |
| const rawSessions = await this.getIntervals(); | |
| console.log(` ${rawSessions.length} sessions`); | |
| // Fetch trends (historical data with date range) | |
| const toDate = new Date(); | |
| const fromDate = new Date(); | |
| fromDate.setDate(fromDate.getDate() - 3650); // Fetch up to 10 years of trends | |
| process.stdout.write(" Fetching trends..."); | |
| const trends = await this.getTrends(fromDate, toDate); | |
| console.log(` ${trends.length} days`); | |
| // Get device status (for initialization, values not used in output) | |
| await this.getDeviceStatus(); | |
| // Process new sessions only | |
| const newRawSessions = rawSessions.filter((s) => !existingIds.has(s.id)); | |
| console.log(`\nπ₯ New sessions to process: ${newRawSessions.length}`); | |
| const processedNewSessions = newRawSessions.map((s) => | |
| this.processSession(s) | |
| ); | |
| // Merge with existing | |
| const allSessions = [...processedNewSessions, ...existingSessions]; | |
| // Sort by date (most recent first) | |
| allSessions.sort( | |
| (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime() | |
| ); | |
| // Show diff | |
| this.showDiff(existingSessions, allSessions); | |
| // Generate summary | |
| console.log("\nπ Generating summary..."); | |
| const summary = this.generateSummary(allSessions, trends); | |
| // Prepare sessions data | |
| const sessionsData: EightSleepSessionsData = { | |
| sessions: allSessions, | |
| lastUpdated: new Date().toISOString(), | |
| }; | |
| // Save files | |
| const summaryPath = path.join(this.dataDir, "eightsleepSummary.json"); | |
| fs.writeFileSync(sessionsPath, JSON.stringify(sessionsData, null, 2)); | |
| fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2)); | |
| console.log("\nβ Sync complete! Data saved to:"); | |
| console.log(` - ${sessionsPath}`); | |
| console.log(` - ${summaryPath}`); | |
| // Print overview | |
| console.log(`\nπ Overview:`); | |
| console.log(` Total nights: ${summary.overview.totalNights}`); | |
| console.log( | |
| ` Avg sleep score: ${summary.overview.avgSleepScore.toFixed(1)}` | |
| ); | |
| console.log( | |
| ` Avg sleep duration: ${summary.overview.avgSleepDuration.toFixed(1)} hours` | |
| ); | |
| if (summary.overview.avgHRV) { | |
| console.log(` Avg HRV: ${summary.overview.avgHRV.toFixed(1)} ms`); | |
| } | |
| console.log( | |
| ` Avg sleep efficiency: ${summary.overview.avgSleepEfficiency.toFixed(1)}%` | |
| ); | |
| // Print last 7 days summary | |
| console.log(`\nπ Last 7 Days:`); | |
| console.log(` Nights: ${summary.trends.last7d.nightCount}`); | |
| console.log(` Avg score: ${summary.trends.last7d.avgScore.toFixed(1)}`); | |
| console.log( | |
| ` Avg duration: ${(summary.trends.last7d.avgSleepDuration / 60).toFixed(1)} hours` | |
| ); | |
| // Print insights | |
| console.log(`\nπ‘ Insights:`); | |
| console.log( | |
| ` Chronotype: ${summary.insights.chronotype.replace("_", " ")}` | |
| ); | |
| console.log( | |
| ` Optimal sleep: ${summary.insights.optimalSleepWindow.start} - ${summary.insights.optimalSleepWindow.end}` | |
| ); | |
| console.log( | |
| ` Best exercise time: ${summary.insights.optimalExerciseWindow.start} - ${summary.insights.optimalExerciseWindow.end}` | |
| ); | |
| console.log( | |
| ` Best cognitive time: ${summary.insights.optimalCognitiveWindow.start} - ${summary.insights.optimalCognitiveWindow.end}` | |
| ); | |
| } catch (error) { | |
| console.error("\nβ Error syncing Eight Sleep data:", error); | |
| throw error; | |
| } | |
| } | |
| } | |
| // Run if called directly | |
| const sync = new EightSleepSync(); | |
| sync.sync().catch(() => { | |
| process.exit(1); | |
| }); | |
| export default EightSleepSync; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment