Skip to content

Instantly share code, notes, and snippets.

@martinamps
Created January 18, 2026 21:20
Show Gist options
  • Select an option

  • Save martinamps/b2691903b4f5a13a81f41ff3fba4ef04 to your computer and use it in GitHub Desktop.

Select an option

Save martinamps/b2691903b4f5a13a81f41ff3fba4ef04 to your computer and use it in GitHub Desktop.
eightsleep syncer
/**
* 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;
}
/**
* 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