homebridge-eightsleepthermostat
Version:
Homebridge thermostat accessory for the Eight Sleep Pod smart bed.
242 lines • 9.4 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.EightSleepConnection = void 0;
const promises_1 = require("fs/promises");
const path_1 = __importDefault(require("path"));
const clientRequest_1 = require("./clientRequest");
// ** Uncomment next line to import/enable mocking data
// import * as AxiosMock from './axiosMock';
const EIGHT_SLEEP_DIR = '8slp';
const SESSION_CACHE_FILE = '_login.txt';
const PRIMARY_USER_CACHE_FILE = '_users_me.txt';
class EightSleepConnection {
constructor(platform, email, password) {
this.platform = platform;
this.cacheDir = path_1.default.resolve(this.platform.api.user.storagePath(), EIGHT_SLEEP_DIR);
this.sessionCachePath = path_1.default.resolve(this.cacheDir, SESSION_CACHE_FILE);
this.primaryUserCachePath = path_1.default.resolve(this.cacheDir, PRIMARY_USER_CACHE_FILE);
this.log = this.platform.log;
this.session = this.prepareSession();
this.primaryUserDevice = this.preparePrimaryUser();
/**
* Session validation & reauthentication methods. Can be initiated externally
* at any time to ensure session information (i.e. token) is up to date.
*/
this.validateActiveSession = async () => {
this.log.debug('Validating session');
const activeSession = await this.session;
if (!activeSession || !this.isValid(activeSession)) {
this.log.debug('Reauthenticating expired session');
await this.reauthenticate();
}
this.log.debug('Session validated');
};
// User credentials read from `config.json` on homebridge startup
this.userCreds = {
email: email,
password: password,
};
// ** Uncomment import statement/next line to enable mocking for debugging
// AxiosMock.startIntercepting(clientAPI, this.log);
this.preserveConnection();
}
/**
* This method will initiate a chain of events to either load the session
* containing a userId & token info from cache, or send a new login request
* to the 8slp API to fetch this information.
*
* The result of this method is stored by {@linkcode session} in the
* form of `Promise<Session | null>`
*
* Associated methods:
* {@linkcode loadCachedSession()} // loads cache & verifies token
* {@linkcode login()} // if no cached session, will re-login
*
* @returns a Promise containing the loaded/fetched session
*
* @category Session
*/
async prepareSession() {
try {
let session = await this.loadCachedSession();
if (!session || !this.isValid(session)) {
this.eraseCache(this.sessionCachePath);
session = await this.login();
}
this.updateClientSessionHeaders(session);
return session;
}
catch (error) {
this.log.error('Failed to prepare connection to Eight Sleep:', error);
return null;
}
}
// Catch error here so that a failure doesn't stop login execution from
// proceeding when this method returns to `prepareSession()`
async loadCachedSession() {
try {
const sessionData = await this.readCache(this.sessionCachePath);
return sessionData;
}
catch (error) {
this.log.debug('Error loading session from cache', error);
return null;
}
}
// Forward error up the chain as a failure here means we have already
// exhausted any chance at a successful session load
async login() {
const response = await clientRequest_1.clientAPI.post('/login', this.userCreds);
const session = response.data['session'];
if (!this.isValid(session)) {
throw new Error(`Unexpected issue with Eight Sleep API session - ${JSON.stringify(session)}`);
}
this.writeToCache(this.sessionCachePath, session);
return session;
}
isValid(session) {
const tokenExpDate = new Date(session.expirationDate).valueOf();
return this.verifyFields(session) && tokenExpDate > Date.now() + (1000 * 60 * 20);
}
verifyFields(session) {
return (session.token && session.expirationDate && session.userId) ? true : false;
}
updateClientSessionHeaders(session) {
clientRequest_1.clientAPI.defaults.headers.common['user-id'] = session.userId;
clientRequest_1.clientAPI.defaults.headers.common['session-token'] = session.token;
}
async reauthenticate() {
await this.eraseCache(this.sessionCachePath);
this.session = this.prepareSession();
}
// Check if reauth is needed every 10 minutes. Currently
// being used only for debugging purposes...
preserveConnection() {
setInterval(this.validateActiveSession, 1000 * 60 * 10);
}
/**
* This method will initiate a chain of events to either load the primary user data
* containing the device `id` & `side` properties from cache, or will send a `GET`
* request to `/users/me/` of 8slp API to fetch the user object
*
* The result of this method is stored by {@linkcode primaryUserDevice} in the
* form of `Promise<PrimaryUser | null>`
*
* Associated methods:
* {@linkcode loadCachedUser()}
* {@linkcode fetchPrimaryUser()}
*
* @returns a Promise containing the loaded/fetched device
*
* @category CurrentDeviceType
*/
async preparePrimaryUser() {
try {
const cachedUser = await this.loadCachedUser();
let device = this.verifyDeviceFor(cachedUser);
if (!device) {
this.eraseCache(this.primaryUserCachePath);
device = await this.fetchPrimaryUser();
}
return device;
}
catch (error) {
this.log.debug('Failed to get Eight Sleep device info:', error);
return null;
}
}
async loadCachedUser() {
try {
const cachedUser = await this.readCache(this.primaryUserCachePath);
return cachedUser;
}
catch (error) {
this.log.debug('Error loading user from cache:', error);
return null;
}
}
// GET: Full user profile data from client
// - use on first time load when no user (i.e. device) previously cached
async fetchPrimaryUser() {
// Must wait for session -> if null, we're unable to fetch user profile
// as we need the userId & token headers included in the request
const session = await this.session;
if (!session) {
throw new Error('No session');
}
const response = await clientRequest_1.clientAPI.get('/users/me');
const user = response.data['user'];
const device = this.verifyDeviceFor(user);
if (!device) {
throw new Error('Unable to fetch user profile & current device from client');
}
this.writeToCache(this.primaryUserCachePath, user);
return device;
}
verifyDeviceFor(user) {
if (!user || !user.currentDevice.id || !user.currentDevice.side) {
return null;
}
return user.currentDevice;
}
/**
* Caching functionality for both session data (token, userId, tokenExp) and the
* primary user's full profile (we only care about 'currentDevice')
*
* Associated methods:
* {@linkcode readCache()}
* {@linkcode writeToCache()}
* {@linkcode eraseCache()}
* {@linkcode makeCacheDirectory()}
*
* @category Caching {@linkcode Session} & {@linkcode PrimaryUser}
*/
async readCache(filepath) {
try {
const cache = await (0, promises_1.readFile)(filepath, 'utf-8');
const parsedData = JSON.parse(cache);
return parsedData;
}
catch (_a) {
return Promise.reject('Cache is empty or data in cache could not be parsed.');
}
}
async writeToCache(filepath, data) {
try {
await this.makeCacheDirectory();
const jsonData = JSON.stringify(data);
await (0, promises_1.writeFile)(filepath, jsonData);
}
catch (_a) {
this.log.debug('Unable to write to cache');
}
}
async eraseCache(filepath) {
try {
await this.writeToCache(filepath, '');
}
catch (_a) {
this.log.debug('Unable to erase cache at:', filepath);
}
}
async makeCacheDirectory() {
try {
await (0, promises_1.mkdir)(this.cacheDir);
}
catch (error) {
/*
- If dir already exists, caught error code will be 'EEXIST'
* Only throw if it's NOT an 'EEXIST' error, since we can still
proceed to write to cache directory if it is
*/
if (error.code !== 'EEXIST') {
throw error;
}
}
}
}
exports.EightSleepConnection = EightSleepConnection;
//# sourceMappingURL=eightSleepConnection.js.map