@brbeaird/myq
Version:
A commonJS fork of hjdhjd's myq implementation
696 lines • 35.7 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.myQApi = void 0;
/* Copyright(C) 2017-2023, HJD (https://github.com/hjdhjd). All rights reserved.
*
* myq-api.ts: Our modern myQ v6 API implementation.
*/
const fetch_1 = require("@adobe/fetch");
const settings_js_1 = require("./settings.js");
const node_http_1 = __importDefault(require("node:http"));
const node_html_parser_1 = require("node-html-parser");
const pkce_challenge_1 = __importDefault(require("pkce-challenge"));
const node_util_1 = __importDefault(require("node:util"));
/*
* The myQ API is undocumented, non-public, and has been derived largely through
* reverse engineering the official app, myQ website, and trial and error.
*
* This project stands on the shoulders of the other myQ projects out there that have
* done much of the heavy lifting of decoding the API.
*
* Starting with v6 of the myQ API, myQ now uses OAuth 2.0 + PKCE to authenticate users and
* provide access tokens for future API calls. In order to successfully use the API, we need
* to first authenticate to the myQ API using OAuth, get the access token, and use that for
* future API calls.
*
* On the plus side, the myQ application identifier and HTTP user agent - previously pain
* points for the community when they get seemingly randomly changed or blacklisted - are
* no longer required.
*
* For those familiar with prior versions of the API, v6 does not represent a substantial
* change outside of the shift in authentication type and slightly different endpoint
* semantics. The largest non-authentication-related change relate to how commands are
* sent to the myQ API to execute actions such as opening and closing a garage door, and
* even those changes are relatively minor.
*
* The myQ API is clearly evolving and will continue to do so. So what's good about v6 of
* the API? A few observations that will be explored with time and lots of experimentation
* by the community:
*
* - It seems possible to use guest accounts to now authenticate to myQ.
* - Cameras seem to be more directly supported.
* - Locks seem to be more directly supported.
*
* Overall, the workflow to using the myQ API should still feel familiar:
*
* 1. Login to the myQ API and acquire an OAuth access token.
* 2. Enumerate the list of myQ devices, including gateways and openers. myQ devices like
* garage openers or lights are associated with gateways. While you can have multiple
* gateways in a home, a more typical setup would be one gateway per home, and one or
* more devices associated with that gateway.
* 3. To check status of myQ devices, we periodically poll to get updates on specific
* devices.
*
* Those are the basics and gets us up and running. There are further API calls that
* allow us to open and close openers, lights, and other devices, as well as periodically
* poll for status updates.
*
* That last part is key. Since there is no way that we know of to monitor status changes
* in real time, we have to resort to polling the myQ API regularly to see if something
* has happened that we're interested in (e.g. a garage door opening or closing). It
* would be great if a monitor API existed to inform us when changes occur, but alas,
* it either doesn't exist or hasn't been discovered yet.
*/
const myQRegions = ["", "east", "west"];
class myQApi {
// Initialize this instance with our login information.
constructor(log) {
// If we didn't get passed a logging parameter, by default we log to the console.
log = log !== null && log !== void 0 ? log : {
/* eslint-disable no-console */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
debug: (message, ...parameters) => { },
error: (message, ...parameters) => console.error(node_util_1.default.format(message, ...parameters)),
info: (message, ...parameters) => console.log(node_util_1.default.format(message, ...parameters)),
warn: (message, ...parameters) => console.log(node_util_1.default.format(message, ...parameters))
/* eslint-enable no-console */
};
this.accessToken = null;
this.accounts = [];
this.apiReturnStatus = 0;
this.email = null;
this.headers = new fetch_1.Headers();
this.password = null;
this.refreshInterval = 0;
this.refreshToken = "";
this.region = 0;
this.tokenScope = "";
this.log = {
debug: (message, ...parameters) => log === null || log === void 0 ? void 0 : log.debug("myQ API: " + message, ...parameters),
error: (message, ...parameters) => log === null || log === void 0 ? void 0 : log.error("myQ API error: " + message, ...parameters),
info: (message, ...parameters) => log === null || log === void 0 ? void 0 : log.info("myQ API: " + message, ...parameters),
warn: (message, ...parameters) => log === null || log === void 0 ? void 0 : log.warn("myQ API: " + message, ...parameters)
};
// The myQ API v6 doesn't seem to require an HTTP user agent to be set - so we don't.
const { fetch } = (0, fetch_1.context)({ alpnProtocols: ["h2" /* ALPNProtocol.ALPN_HTTP2 */], userAgent: "" });
this.myqRetrieve = fetch;
}
// Initialize this instance with our login information.
async login(email, password) {
this.email = email;
this.password = password;
this.accessToken = null;
this.lastAuthenticateCall = this.lastRefreshDevicesCall = 0;
return this.refreshDevices();
}
// Transmit the PKCE challenge and retrieve the myQ OAuth authorization page to prepare to login.
async oauthGetAuthPage(codeChallenge) {
const authEndpoint = new URL("https://partner-identity.myq-cloud.com/connect/authorize");
// Set the client identifier.
authEndpoint.searchParams.set("client_id", settings_js_1.MYQ_API_CLIENT_ID);
// Set the PKCE code challenge.
authEndpoint.searchParams.set("code_challenge", codeChallenge);
// Set the PKCE code challenge method.
authEndpoint.searchParams.set("code_challenge_method", "S256");
// Set the redirect URI to the myQ app.
authEndpoint.searchParams.set("redirect_uri", settings_js_1.MYQ_API_REDIRECT_URI);
// Set the response type.
authEndpoint.searchParams.set("response_type", "code");
// Set the scope.
authEndpoint.searchParams.set("scope", "MyQ_Residential offline_access");
// Send the PKCE challenge and let's begin the login process.
const response = await this.retrieve(authEndpoint.toString(), { redirect: "follow" }, true);
if (!response) {
this.log.debug("Unable to access the OAuth authorization endpoint.");
return null;
}
return response;
}
// Login to the myQ API, using the retrieved authorization page.
async oauthLogin(authPage) {
var _a;
// Sanity check.
if (!this.email || !this.password) {
return null;
}
// Grab the cookie for the OAuth sequence. We need to deal with spurious additions to the cookie that gets returned by the myQ API.
const cookie = this.trimSetCookie(authPage.headers.raw()["set-cookie"]);
// Parse the myQ login page and grab what we need.
const htmlText = await authPage.text();
const loginPageHtml = (0, node_html_parser_1.parse)(htmlText);
const requestVerificationToken = (_a = loginPageHtml.querySelector("input[name=__RequestVerificationToken]")) === null || _a === void 0 ? void 0 : _a.getAttribute("value");
if (!requestVerificationToken) {
this.log.error("Unable to complete login. The verification token could not be retrieved.");
return null;
}
// Set the login info.
const loginBody = new URLSearchParams({ "Email": this.email, "Password": this.password, "UnifiedFlowRequested": "true",
"__RequestVerificationToken": requestVerificationToken, "brand": "myq" });
// Login and we're done.
const response = await this.retrieve(authPage.url, {
body: loginBody.toString(),
headers: {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": cookie
},
method: "POST",
redirect: "manual"
}, true);
// An error occurred and we didn't get a good response.
if (!response || !response.headers) {
this.log.debug("Unable to complete OAuth login.");
return null;
}
// If we don't have the full set of cookies we expect, the user probably gave bad login information.
if (!response.headers.raw()["set-cookie"] || response.headers.raw()["set-cookie"].length < 2) {
this.log.error("Invalid myQ credentials given. Check your login and password.");
return null;
}
return response;
}
// Intercept the OAuth login response to adjust cookie headers before sending on it's way.
async oauthRedirect(loginResponse) {
// Get the location for the redirect for later use.
const redirectUrl = new URL(loginResponse.headers.get("location"), loginResponse.url);
// Cleanup the cookie so we can complete the login process by removing spurious additions
// to the cookie that gets returned by the myQ API.
const cookie = this.trimSetCookie(loginResponse.headers.raw()["set-cookie"]);
// Execute the redirect with the cleaned up cookies and we're done.
const response = await this.retrieve(redirectUrl.toString(), {
headers: {
"Cookie": cookie
},
redirect: "manual"
}, true);
if (!response) {
this.log.debug("Unable to complete the login redirect.");
return null;
}
return response;
}
// Get a new OAuth access token.
async getOAuthToken() {
var _a, _b;
// Generate the OAuth PKCE challenge required for the myQ API.
const pkce = await (0, pkce_challenge_1.default)();
// Call the myQ authorization endpoint using our PKCE challenge to get the web login page.
let response = await this.oauthGetAuthPage(pkce.code_challenge);
if (!response) {
return null;
}
// Attempt to login.
response = await this.oauthLogin(response);
if (!response) {
return null;
}
// Intercept the redirect back to the myQ iOS app.
response = await this.oauthRedirect(response);
if (!response) {
return null;
}
// Parse the redirect URL to extract the PKCE verification code and scope.
const redirectUrl = new URL((_a = response.headers.get("location")) !== null && _a !== void 0 ? _a : "");
// Create the request to get our access and refresh tokens.
const requestBody = new URLSearchParams({
"code": redirectUrl.searchParams.get("code"),
"code_verifier": pkce.code_verifier,
"grant_type": "authorization_code",
"redirect_uri": settings_js_1.MYQ_API_REDIRECT_URI
});
// Now we execute the final login redirect that will validate the PKCE challenge and return our access and refresh tokens.
response = await this.retrieve("https://partner-identity.myq-cloud.com/connect/token", {
body: requestBody.toString(),
headers: {
"Accept": "*/*",
"Authorization": "Basic " + Buffer.from(settings_js_1.MYQ_API_CLIENT_ID + ":").toString("base64"),
"Content-Type": "application/x-www-form-urlencoded"
},
method: "POST"
}, true);
if (!response) {
return null;
}
// Grab the token JSON.
const token = await response.json();
this.refreshInterval = token.expires_in;
this.refreshToken = token.refresh_token;
this.tokenScope = (_b = redirectUrl.searchParams.get("scope")) !== null && _b !== void 0 ? _b : "";
// Refresh our tokens at seven minutes before expiration as a failsafe.
this.refreshInterval -= 420;
// Ensure we never try to refresh more frequently than every five minutes.
if (this.refreshInterval < 300) {
this.refreshInterval = 300;
}
// Return the access token in cookie-ready form: "Bearer ...".
return token.token_type + " " + token.access_token;
}
// Refresh our OAuth access token.
async refreshOAuthToken() {
var _a;
// Create the request to refresh tokens.
const requestBody = new URLSearchParams({
"client_id": settings_js_1.MYQ_API_CLIENT_ID,
"client_secret": Buffer.from(settings_js_1.MYQ_API_CLIENT_SECRET, "base64").toString(),
"grant_type": "refresh_token",
"redirect_uri": settings_js_1.MYQ_API_REDIRECT_URI,
"refresh_token": this.refreshToken,
"scope": this.tokenScope
});
// Execute the refresh token request.
const response = await this.retrieve("https://partner-identity.myq-cloud.com/connect/token", {
body: requestBody.toString(),
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
method: "POST"
}, true);
if (!response) {
return false;
}
// Grab the refresh token JSON.
const token = await response.json();
this.accessToken = token.token_type + " " + token.access_token;
this.accessTokenTimestamp = Date.now();
this.refreshInterval = token.expires_in;
this.refreshToken = token.refresh_token;
this.tokenScope = (_a = token.scope) !== null && _a !== void 0 ? _a : this.tokenScope;
// Refresh our tokens at seven minutes before expiration as a failsafe.
this.refreshInterval -= 420;
// Ensure we never try to refresh more frequently than every five minutes.
if (this.refreshInterval < 300) {
this.refreshInterval = 300;
}
// Update our authorization header.
this.headers.set("Authorization", this.accessToken);
this.log.debug("Successfully refreshed the myQ API access token.");
// We're done.
return true;
}
// Log us into myQ and get an access token.
async acquireAccessToken() {
let firstConnection = true;
const now = Date.now();
// Reset the API call time.
this.lastAuthenticateCall = now;
// Clear out tokens from prior connections.
if (this.accessToken) {
firstConnection = false;
this.accessToken = null;
this.accounts = [];
}
// Login to the myQ API and get an OAuth access token for our session.
const token = await this.getOAuthToken();
if (!token) {
return false;
}
const regionMsg = this.region ? " using the " + myQRegions[this.region] + " myQ cloud region" : "";
// On initial plugin startup, let the user know we've successfully connected.
if (firstConnection) {
this.log.info("Successfully connected to the myQ API%s.", regionMsg);
}
else {
this.log.debug("Successfully reacquired a myQ API access token%s.", regionMsg);
}
this.accessToken = token;
this.accessTokenTimestamp = now;
// Add the token to our headers that we will use for subsequent API calls.
this.headers.set("Authorization", this.accessToken);
// Grab our account information for subsequent calls.
if (!(await this.getAccounts())) {
this.accessToken = null;
this.accounts = [];
return false;
}
// Success.
return true;
}
// Refresh the myQ access token, if needed.
async refreshAccessToken() {
const now = Date.now();
// We want to throttle how often we call this API to no more than once every 2 minutes.
if ((now - this.lastAuthenticateCall) < (2 * 60 * 1000)) {
return (this.accounts.length && this.accessToken) ? true : false;
}
// If we don't have a access token yet, acquire one.
if (!this.accounts.length || !this.accessToken) {
return await this.acquireAccessToken();
}
// Is it time to refresh? If not, we're good for now.
if ((now - this.accessTokenTimestamp) < (this.refreshInterval * 1000)) {
return true;
}
// Try refreshing our existing access token before resorting to acquiring a new one.
if (await this.refreshOAuthToken()) {
return true;
}
this.log.error("Unable to refresh our access token. This error can usually be safely ignored and will be resolved by acquiring a new access token.");
// Now generate a new access token.
if (!(await this.acquireAccessToken())) {
return false;
}
return true;
}
// Get the list of myQ devices associated with an account.
async refreshDevices() {
var _a;
// Sanity check.
if (!this.login || !this.password) {
this.log.error("You must login to the myQ API prior to calling this function.");
return false;
}
const now = Date.now();
// We want to throttle how often we call this API as a failsafe. If we call it more than once every two seconds or so, bad things can happen on the myQ side leading
// to potential account lockouts. The author definitely learned this one the hard way.
if (this.lastRefreshDevicesCall && ((now - this.lastRefreshDevicesCall) < (2 * 1000))) {
this.log.debug("throttling refreshDevices API call. Using cached data from the past two seconds.");
return this.devices ? true : false;
}
// Reset the API call time.
this.lastRefreshDevicesCall = now;
// Validate and potentially refresh our access token.
if (!(await this.refreshAccessToken())) {
return false;
}
// Update our account information, to see if we've added or removed access to any other devices.
if (!(await this.getAccounts())) {
this.accessToken = null;
this.accounts = [];
return false;
}
const newDeviceList = [];
// Loop over all the accounts we know about.
for (const accountId of this.accounts) {
// Get the list of device information for this account.
// eslint-disable-next-line no-await-in-loop
const response = await this.retrieve("https://devices.myq-cloud.com/api/v5.2/Accounts/" + accountId + "/Devices");
if (!response) {
this.log.error("Unable to update device status from the myQ API. Acquiring a new access token.");
this.accessToken = null;
this.accounts = [];
return false;
}
// Now let's get our account information.
// eslint-disable-next-line no-await-in-loop
const data = await response.json();
this.log.debug(node_util_1.default.inspect(data, { colors: true, depth: 10, sorted: true }));
newDeviceList.push(...data.items);
}
// Notify the user about any new devices that we've discovered.
if (newDeviceList) {
for (const newDevice of newDeviceList) {
// We already know about this device.
if ((_a = this.devices) === null || _a === void 0 ? void 0 : _a.some((x) => x.serial_number === newDevice.serial_number)) {
continue;
}
// We've discovered a new device.
this.log.info("Discovered device family %s: %s.", newDevice.device_family, this.getDeviceName(newDevice));
}
}
// Notify the user about any devices that have disappeared.
if (this.devices) {
for (const existingDevice of this.devices) {
// This device still is visible.
if (newDeviceList === null || newDeviceList === void 0 ? void 0 : newDeviceList.some((x) => x.serial_number === existingDevice.serial_number)) {
continue;
}
// We've had a device disappear.
this.log.info("Removed device family %s: %s.", existingDevice.device_family, this.getDeviceName(existingDevice));
}
}
// Save the updated list of devices.
this.devices = newDeviceList;
return true;
}
// Execute an action on a myQ device.
async execute(device, command) {
// Sanity check.
if (!this.login || !this.password) {
this.log.error("You must login to the myQ API prior to calling this function.");
return false;
}
// Validate and potentially refresh our access token.
if (!(await this.refreshAccessToken())) {
return false;
}
let response;
// Ensure we cann the right endpoint to execute commands depending on device family.
if (device.device_family === "lamp") {
// Execute a command on a lamp device.
response = await this.retrieve("https://account-devices-lamp.myq-cloud.com/api/v5.2/Accounts/" + device.account_id +
"/lamps/" + device.serial_number + "/" + command, { method: "PUT" });
}
else {
// By default, we assume we're targeting a garage door opener.
response = await this.retrieve("https://account-devices-gdo.myq-cloud.com/api/v5.2/Accounts/" + device.account_id +
"/door_openers/" + device.serial_number + "/" + command, { method: "PUT" });
}
// Check for errors.
if (!response) {
// If it's a 403 error, the command was likely delivered to an unavailable or offline myQ device.
if (this.apiReturnStatus === 403) {
return false;
}
this.log.error("Unable to send the command to myQ servers. Acquiring a new access token.");
this.accessToken = null;
this.accounts = [];
return false;
}
return true;
}
// Get our myQ account information.
async getAccounts() {
// Get the account information.
const response = await this.retrieve("https://accounts.myq-cloud.com/api/v6.0/accounts");
if (!response) {
this.log.error("Unable to retrieve account information.");
return false;
}
// Now let's get our account information.
const data = await response.json();
this.log.debug(node_util_1.default.inspect(data, { colors: true, depth: 10, sorted: true }));
// No account information returned.
if (!(data === null || data === void 0 ? void 0 : data.accounts)) {
this.log.error("No account information found.");
return false;
}
// Save all the account identifiers we know about for later use.
this.accounts = data.accounts.map(x => x.id);
return true;
}
// Get the details of a specific device in the myQ device list.
getDevice(serial) {
var _a;
// Sanity check.
if (!this.login || !this.password) {
this.log.error("You must login to the myQ API prior to calling this function.");
return null;
}
// Check to make sure we have fresh information from myQ. If it's less than a minute old, it looks good to us.
if (!this.devices || !this.lastRefreshDevicesCall || ((Date.now() - this.lastRefreshDevicesCall) > (60 * 1000))) {
return null;
}
// If we've got no serial number, we're done here.
if (serial.length <= 0) {
return null;
}
// Convert to upper case before searching for it.
serial = serial.toUpperCase();
// Iterate through the list and find the device that matches the serial number we seek.
return (_a = this.devices.find(x => { var _a; return ((_a = x.serial_number) === null || _a === void 0 ? void 0 : _a.toUpperCase()) === serial; })) !== null && _a !== void 0 ? _a : null;
}
// Utility to generate a nicely formatted device string.
getDeviceName(device) {
// A completely enumerated device will appear as: DeviceName [DeviceBrand] (serial number: Serial, gateway: GatewaySerial).
let deviceString = device.name;
// Only grab hardware information for the hardware we know how to decode.
const hwInfo = device.device_family !== "gateway" ? this.getHwInfo(device.serial_number) : null;
if (hwInfo) {
deviceString += " [" + hwInfo.brand + " " + hwInfo.product + "]";
}
if (device.serial_number) {
deviceString += " (serial number: " + device.serial_number;
if (device.parent_device_id) {
deviceString += ", gateway: " + device.parent_device_id;
}
deviceString += ")";
}
return deviceString;
}
// Return device manufacturer and model information based on the serial number, if we can.
getHwInfo(serial) {
var _a;
// We only know about gateway devices and not individual openers, so we can only decode those. According to Liftmaster, here's how you decode device types:
//
// The myQ serial number for the Wi-Fi GDO, myQ Home Bridge, myQ Smart Garage Hub, myQ Garage (Wi-Fi Hub) and Internet Gateway is 12 characters long. The first two
// characters, typically "GW", followed by 2 characters that are decoded according to the table below to identify the device type and brand, with the remaining
// 8 characters representing the serial number.
const HwInfo = {
"00": { brand: "Chamberlain", product: "Ethernet Gateway" },
"01": { brand: "Liftmaster", product: "Ethernet Gateway" },
"02": { brand: "Craftsman", product: "Ethernet Gateway" },
"03": { brand: "Chamberlain", product: "WiFi Hub" },
"04": { brand: "Liftmaster", product: "WiFi Hub" },
"05": { brand: "Craftsman", product: "WiFi Hub" },
"08": { brand: "Liftmaster", product: "WiFi GDO DC w/Battery Backup" },
"09": { brand: "Chamberlain", product: "WiFi GDO DC w/Battery Backup" },
"0A": { brand: "Chamberlain", product: "WiFi GDO AC" },
"0B": { brand: "Liftmaster", product: "WiFi GDO AC" },
"0C": { brand: "Craftsman", product: "WiFi GDO AC" },
"0D": { brand: "myQ Replacement Logic Board", product: "WiFi GDO AC" },
"0E": { brand: "Chamberlain", product: "WiFi GDO AC 3/4 HP" },
"0F": { brand: "Liftmaster", product: "WiFi GDO AC 3/4 HP" },
"10": { brand: "Craftsman", product: "WiFi GDO AC 3/4 HP" },
"11": { brand: "myQ Replacement Logic Board", product: "WiFi GDO AC 3/4 HP" },
"12": { brand: "Chamberlain", product: "WiFi GDO DC 1.25 HP" },
"13": { brand: "Liftmaster", product: "WiFi GDO DC 1.25 HP" },
"14": { brand: "Craftsman", product: "WiFi GDO DC 1.25 HP" },
"15": { brand: "myQ Replacement Logic Board", product: "WiFi GDO DC 1.25 HP" },
"20": { brand: "Chamberlain", product: "myQ Home Bridge" },
"21": { brand: "Liftmaster", product: "myQ Home Bridge" },
"23": { brand: "Chamberlain", product: "Smart Garage Hub" },
"24": { brand: "Liftmaster", product: "Smart Garage Hub" },
"27": { brand: "Liftmaster", product: "WiFi Wall Mount Opener" },
"28": { brand: "Liftmaster Commercial", product: "WiFi Wall Mount Operator" },
"33": { brand: "Chamberlain", product: "Smart Garage Control" },
"34": { brand: "Liftmaster", product: "Smart Garage Control" },
"80": { brand: "Liftmaster EU", product: "Ethernet Gateway" },
"81": { brand: "Chamberlain EU", product: "Ethernet Gateway" }
};
if ((serial === null || serial === void 0 ? void 0 : serial.length) < 4) {
return null;
}
// Use the third and fourth characters as indices into the hardware matrix. Admittedly, we don't have a way to resolve the first two characters to ensure we are
// matching against the right category of devices.
return (_a = (HwInfo[serial[2] + serial[3]])) !== null && _a !== void 0 ? _a : null;
}
// Utility function to return the relevant portions of the cookies used in the login process.
trimSetCookie(setCookie) {
// Let's make sure we're operating on an array that's passed back as a header.
if (!Array.isArray(setCookie)) {
setCookie = [setCookie];
}
// We need to strip spurious additions to the cookie that gets returned by the myQ API.
return setCookie.map(x => x.split(";")[0]).join("; ");
}
// Utility to let us streamline error handling and return checking from the myQ API.
async retrieve(url, options = {}, overrideHeaders = false, decodeResponse = true, isRetry = 0) {
// This could be done with regular expressions, but in the interest of easier readability and maintenance, we parse the URL with a URL object.
const retrieveUrl = new URL(url);
// Retrieve the first part of the hostname.
const hostname = retrieveUrl.hostname.split(".");
// Regular expression to test for whether we already have a region specifier in the hostname.
const regionRegex = new RegExp("^.*-(" + myQRegions.join("|") + ")$");
// Add our region-specific context to the hostname, if it's not already there.
if (!regionRegex.test(hostname[0])) {
// This is a retry request, meaning something went wrong with the original request. We retry in another region as a resiliency measure.
if (isRetry) {
this.region = ++this.region % myQRegions.length;
this.log.debug("Switching to myQ cloud region: %s.", myQRegions[this.region].length ? myQRegions[this.region] : "auto");
}
hostname[0] += this.region ? "-" + myQRegions[this.region] : "";
}
retrieveUrl.hostname = hostname.join(".");
// Catch redirects:
//
// 301: Moved permanently.
// 302: Found.
// 303: See other.
// 307: Temporary redirect.
// 308: Permanent redirect.
const isRedirect = (code) => [301, 302, 303, 307, 308].includes(code);
// Catch myQ credential-related issues:
//
// 400: Bad request.
// 401: Unauthorized.
const isCredentialsIssue = (code) => [400, 401].includes(code);
// Catch myQ server-side issues:
//
// 429: Too many requests.
// 500: Internal server error.
// 502: Bad gateway.
// 503: Service temporarily unavailable.
// 504: Gateway timeout.
// 521: Web server down (Cloudflare-specific).
// 522: Connection timed out (Cloudflare-specific).
const isServerSideIssue = (code) => [429, 500, 502, 503, 504, 521, 522].includes(code);
const retry = async (logMessage) => {
// Retry when we have a connection issue, but no more than once.
if (isRetry < 3) {
this.log.debug(logMessage + " Retrying the API call.");
return this.retrieve(url, options, overrideHeaders, decodeResponse, ++isRetry);
}
this.log.error(logMessage);
return null;
};
let response;
// Set our headers.
if (!overrideHeaders) {
options.headers = this.headers;
}
// Reset our API return status.
this.apiReturnStatus = 0;
try {
response = await this.myqRetrieve(retrieveUrl.toString(), options);
// Save our return status.
this.apiReturnStatus = response.status;
// The caller will sort through responses instead of us, or we've got a successful API call, or we've been redirected.
if (!decodeResponse || response.ok || isRedirect(response.status)) {
if (isRetry) {
this.log.info("Switched to myQ cloud region: %s.", myQRegions[this.region].length ? myQRegions[this.region] : "auto");
}
return response;
}
// Invalid login credentials.
if (isCredentialsIssue(response.status)) {
return retry("Invalid myQ credentials given: Check your username and password. If they are correct, the myQ API may be experiencing temporary issues.");
}
// 403: Command forbidden. In myQ parlance, this usually means the device is unavailable or offline.
if (response.status === 403) {
this.log.error("Forbidden API call. This error is typically due to an offline or unavailable myQ device.");
return null;
}
const httpStatusMessage = response.status + (node_http_1.default.STATUS_CODES[response.status] ? " - " + node_http_1.default.STATUS_CODES[response.status] : "");
// myQ API issues at the server end.
if (isServerSideIssue(response.status)) {
return retry("Temporary myQ API server-side issues encountered: " + httpStatusMessage + ".");
}
// Some other unknown error occurred.
this.log.error("API call returned error: %s.", httpStatusMessage);
return null;
}
catch (error) {
if (error instanceof fetch_1.FetchError) {
switch (error.code) {
case "ECONNREFUSED":
case "ERR_HTTP2_STREAM_CANCEL":
return retry("Connection refused.");
break;
case "ECONNRESET":
return retry("Connection has been reset.");
break;
case "ENOTFOUND":
return retry("Hostname or IP address not found.");
break;
case "ETIMEDOUT":
return retry("Connection timed out.");
break;
case "UNABLE_TO_VERIFY_LEAF_SIGNATURE":
return retry("Unable to verify the myQ TLS security certificate.");
break;
default:
return retry(error.code.toString() + " - " + error.message);
}
}
else {
return retry("Unknown fetch error: " + error.toString());
}
return null;
}
}
}
exports.myQApi = myQApi;
//# sourceMappingURL=myq-api.js.map