iobroker.tado
Version:
Tado cloud connector to control Tado devices
227 lines (202 loc) • 9.19 kB
JavaScript
const https = require('https');
const axios = require('axios');
const TADO_URL = 'https://my.tado.com';
const TADO_APP_URL = `https://app.tado.com/`;
const TADO_TIMEOUT = 15000;
const THRESHOLD_LIMITRENEW = 10;
// @ts-expect-error create axios instance
let axiosInstance = axios.create({
timeout: TADO_TIMEOUT,
baseURL: `${TADO_URL}/`,
httpsAgent: new https.Agent({ keepAlive: true }),
referer: TADO_APP_URL,
origin: TADO_APP_URL,
});
/**
*Handles Tado API interactions with rate limiting for non-GET requests.
*/
class TadoApi {
/**
* Creates an instance of the Tado API handler.
*
* @param {object} adapter - The adapter object containing configuration and state.
* @param {number} [waitingTime] - The minimum waiting time (in ms) between non-GET API calls.
*/
constructor(adapter, waitingTime = 300) {
this.adapter = adapter;
this.axiosInstance = axiosInstance;
this.waitingTime = waitingTime;
this.queue = []; // FIFO queue for non-GET calls
this.processing = false; // worker state
this.lastCallTime = 0; // last non-GET call timestamp
this.firstRun = true;
this.callsLeftLastRun = 99999999;
}
/**
* Executes an API call to the specified URL with the given method and payload.
*
* @param {string} url
* @param {string} method
* @param {any} payload
* @param {string} caller
*/
async apiCall(url, method = 'get', payload = {}, caller = '') {
const stack = new Error().stack;
if (stack && !caller) {
let stackParts = stack.split(' at ');
const regex = /Tado\.\s*(\S+)/;
const match = stackParts[2].match(regex);
if (match) {
caller = match[1];
}
}
method = method.toLowerCase();
if (method === 'get') {
// GET requests run in parallel
return this._doApiCall(url, method, payload, caller);
}
// Non-GET requests go into queue
return new Promise((resolve, reject) => {
this.queue.push({ url, method, payload, caller, resolve, reject });
this._processQueue(); // trigger worker
});
}
/**
* Handler to process the queue of non-GET requests.
*/
async _processQueue() {
if (this.processing) {
return;
} // worker already active
this.processing = true;
while (this.queue.length > 0) {
const { url, method, payload, caller, resolve, reject } = this.queue.shift();
try {
// enforce delay between non-GET calls
const now = Date.now();
const elapsed = now - this.lastCallTime;
if (elapsed < this.waitingTime) {
await this.adapter.sleep(this.waitingTime - elapsed);
}
const result = await this._doApiCall(url, method, payload, caller);
this.adapter.debugLog(`apiCall(${caller}) successful.`);
this.lastCallTime = Date.now(); // update timestamp
resolve(result);
} catch (error) {
reject(error);
}
}
this.processing = false;
}
/**
* Handles the actual API call using axios.
*
* @param {string} url
* @param {string }method
* @param {any} payload
* @param {string} caller
*/
async _doApiCall(url, method, payload, caller = '') {
const maxRetries = 3; //3
const retryDelay = 5000; // 5 seconds
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
if (!this.adapter.accessToken) {
throw new Error('Not yet logged in');
}
await this.adapter.refreshToken();
this.adapter.debugLog(`TadoX ${this.adapter.isTadoX} | method ${method} | URL ${url} | body "${JSON.stringify(payload)}"`);
const response = await this.axiosInstance({
url,
method,
data: payload,
headers: {
Authorization: `Bearer ${this.adapter.accessToken.token.access_token}`,
Source: `iobroker.tado@${this.adapter.version}`,
},
});
const ratelimit = response.headers['ratelimit'] ?? '';
const ratelimitPolicy = response.headers['ratelimit-policy'] ?? '';
let callsLeft = ratelimit != null ? ratelimit.match(/r=(\d+)/) : undefined; //get r=value from the header
let callsQuota = ratelimitPolicy != null ? ratelimitPolicy.match(/q=(\d+)/) : undefined; //get q=value from the header
this.adapter.debugLog(`RateLimit: ${ratelimit}`);
this.adapter.debugLog(`RateLimitPolicy: ${ratelimitPolicy}`);
callsLeft = parseInt(callsLeft?.[1]) ?? undefined;
callsQuota = parseInt(callsQuota?.[1]) ?? undefined;
if (callsLeft !== undefined && callsQuota !== undefined) {
this._updateCallStats(url, callsLeft, callsQuota);
}
return response.data;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('timeout of') && errorMessage.includes('ms exceeded') && attempt < maxRetries) {
if (attempt > maxRetries - 2) {
const info = `apiCall(${caller}) timed out (${TADO_TIMEOUT}ms). Retrying in ${retryDelay / 1000} seconds... (Attempt ${attempt + 1}/${maxRetries})`;
this.adapter.log.warn(info);
console.log(info);
} else {
const info = `apiCall(${caller}) timed out (${TADO_TIMEOUT}ms). Retrying in ${retryDelay / 1000} seconds... (Attempt ${attempt + 1}/${maxRetries})`;
this.adapter.log.info(info);
console.log(info);
}
await this.adapter.sleep(retryDelay);
continue;
}
// @ts-expect-error error.respone exists
if (error?.response?.data && error?.message) {
// @ts-expect-error error.message exists
console.error(`${error.message} with response ${JSON.stringify(error.response.data)}`);
// @ts-expect-error error.message and error.response exists
this.adapter.log.error(`${error.message} with response ${JSON.stringify(error.response.data)}`);
} else {
console.error(error);
this.adapter.log.error(error);
}
if (typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string') {
error.message = `apiCall(${caller}) failed: ${error.message}`;
}
throw error;
}
}
}
/**
*
* @param {string} url
* @param {number} callsLeft
* @param {number} callsQuota
*/
_updateCallStats(url, callsLeft, callsQuota) {
if (typeof this.adapter.numberOfCalls === 'undefined') {
return;
}
if (this.callsLeftLastRun + THRESHOLD_LIMITRENEW < callsLeft) {
this.adapter.log.info(
`Tado API quota was renewed when ${this.callsLeftLastRun} calls were left. For the next 24 hours a quota of ${callsLeft + 1} calls is available.`,
);
}
this.callsLeftLastRun = callsLeft;
if (this.firstRun) {
this.adapter.log.info(`Tado allows ${callsQuota} calls per day for your account | ${callsLeft} calls left`);
if (callsQuota < 1000) {
this.adapter.log.warn(`You have a very low number of API calls per day. This may lead to '429' issues`);
}
this.firstRun = false;
}
const rateLimitInfo = `Ratelimit info: ${callsLeft} calls out of ${callsQuota} still available.`;
if (this.adapter.numberOfCalls.day === new Date().getDate()) {
this.adapter.numberOfCalls.calls++;
} else {
this.adapter.log.info(`${this.adapter.numberOfCalls.calls} API calls at the end of yesterday | ${rateLimitInfo}`);
this.adapter.numberOfCalls.calls = 1;
this.adapter.numberOfCalls.day = new Date().getDate();
}
if (this.adapter.numberOfCalls.calls % 20 === 0 && this.adapter.logCalls) {
this.adapter.log.info(`${this.adapter.numberOfCalls.calls} API calls since last midnight (or restart) | ${rateLimitInfo}`);
}
if (this.adapter.logCallsDetail) {
this.adapter.log.info(`${this.adapter.numberOfCalls.calls} API calls since last midnight (or restart) [${url}]`);
this.adapter.log.info(rateLimitInfo);
}
}
}
module.exports = TadoApi;