homebridge-hatch-baby-rest
Version:
Homebridge plugin for Hatch Rest/Restore WiFi sound machines
188 lines (187 loc) • 7.83 kB
JavaScript
import { apiPath, requestWithRetry, RestClient, } from "./rest-client.js";
import { Product, } from "../shared/hatch-sleep-types.js";
import { thingShadow as AwsIotDevice } from 'aws-iot-device-sdk';
import { logDebug, logError, logInfo } from "../shared/util.js";
import { RestPlus } from "./rest-plus.js";
import { RestIot } from "./rest-iot.js";
import { RestMini } from "./rest-mini.js";
import { Restore } from "./restore.js";
import { BehaviorSubject } from 'rxjs';
import { IotDevice } from "./iot-device.js";
import { debounceTime } from 'rxjs/operators';
const knownProducts = [
Product.restPlus,
Product.riot,
Product.riotPlus,
Product.restMini,
Product.restore,
Product.restoreIot,
Product.restoreV4,
Product.restoreV5,
Product.restBaby,
], ignoredProducts = [
// Known, but not supported
Product.rest,
Product.alexa,
Product.grow,
Product.answeredReader,
], iotClientRefreshPeriod = 50 * 60 * 1000; // refresh client every 50 minutes (AWS Cognito credentials expire after ~1 hour)
export class HatchBabyApi {
config;
restClient;
constructor(config) {
this.config = config;
this.restClient = new RestClient(this.config);
}
getAccount() {
return this.restClient.getAccount();
}
getMember() {
return this.restClient.request({
url: apiPath('service/app/v2/member'),
});
}
async getIotDevices(...products) {
// Combine the user's products with the known products to ensure we get all devices
const productFetchQueryString = products
.map((product) => 'iotProducts=' + product)
.join('&'), devices = (await this.restClient.request({
url: apiPath('service/app/iotDevice/v2/fetch?' + productFetchQueryString),
})) || [];
return devices;
}
async createAwsIotClient() {
const iotResponse = await this.restClient.request({
url: apiPath('service/app/restPlus/token/v1/fetch'),
}), { Credentials: credentials } = await requestWithRetry({
url: `https://cognito-identity.${iotResponse.region}.amazonaws.com`,
method: 'POST',
headers: {
'content-type': 'application/x-amz-json-1.1',
'X-Amz-Target': 'AWSCognitoIdentityService.GetCredentialsForIdentity',
},
json: {
IdentityId: iotResponse.identityId,
Logins: {
'cognito-identity.amazonaws.com': iotResponse.token,
},
},
}), mqttClient = new AwsIotDevice({
protocol: 'wss',
host: iotResponse.endpoint.replace('https://', ''),
accessKeyId: credentials.AccessKeyId,
secretKey: credentials.SecretKey,
sessionToken: credentials.SessionToken,
});
return mqttClient;
}
async getOnIotClient() {
// eslint-disable-next-line prefer-const
let onIotClient;
const createNewIotClient = async () => {
try {
const previousMqttClient = onIotClient?.getValue();
if (previousMqttClient) {
try {
previousMqttClient.end();
}
catch (e) {
logError('Failed to end previous MQTT Client');
logError(e);
}
}
logDebug('Creating new MQTT Client');
const mqttClient = await this.createAwsIotClient();
mqttClient.on('error', async (error) => {
if (error.message.includes('(403)')) {
logError('MQTT Client No Longer Authorized');
}
else {
logError('MQTT Error:');
logError(error);
}
try {
onIotClient?.next(await createNewIotClient());
}
catch (_) {
// ignore, already logged
}
});
logDebug('Created new MQTT Client');
return mqttClient;
}
catch (e) {
logError('Failed to Create an MQTT Client');
logError(e);
throw e;
}
};
onIotClient = new BehaviorSubject(await createNewIotClient());
onIotClient.pipe(debounceTime(iotClientRefreshPeriod)).subscribe(() => {
createNewIotClient()
.then((client) => onIotClient?.next(client))
.catch(logError);
});
return onIotClient;
}
async getDevices() {
const [devices, member, onIotClient] = await Promise.all([
this.getIotDevices(...knownProducts),
this.getMember(),
this.getOnIotClient(),
]), createDevices = (product, Device) => {
return devices
.filter((device) => device.product === product)
.map((info) => new Device(info, onIotClient, this.restClient));
};
for (const product of member.products) {
if (!knownProducts.includes(product) &&
!ignoredProducts.includes(product)) {
const debugMessage = this.config.debug
? ''
: '. Set `"debug": true` in your config to see more device details.';
logInfo('Unsupported Product Found: ' + product + debugMessage);
if (this.config.debug) {
this.getIotDevices(product)
.then(async (unknownDevices) => {
const debugDevices = unknownDevices.map((device) => new IotDevice(device, onIotClient));
for (const device of debugDevices) {
try {
logInfo(`Debug info for ${product} ${device.info.name}:`);
logInfo(JSON.stringify({
...device.info,
id: '***',
macAddress: '***',
thingName: '***',
memberId: '***',
email: '***',
}, null, 2));
logInfo(`State for ${product} ${device.info.name}:`);
logInfo(JSON.stringify(await device.getCurrentState(), null, 2));
}
catch (e) {
logError(`Failed to get debug info for ${product} ${device.info.name}`);
logError(e);
}
}
})
.catch((error) => {
logError('Failed to get debug info for ' + product);
logError(error);
});
}
}
}
return {
restPluses: createDevices(Product.restPlus, RestPlus),
restIots: createDevices(Product.riot, RestIot),
restIotPluses: createDevices(Product.riotPlus, RestIot),
restMinis: createDevices(Product.restMini, RestMini),
restores: createDevices(Product.restore, Restore),
restoreIots: createDevices(Product.restoreIot, RestIot),
restoreV4s: createDevices(Product.restoreV4, RestIot),
restoreV5s: createDevices(Product.restoreV5, RestIot),
restBabies: createDevices(Product.restBaby, RestIot),
};
}
}