UNPKG

@homebridge-plugins/homebridge-tado

Version:
539 lines (448 loc) 17.2 kB
import Logger from '../helper/logger.js'; import got from 'got'; import path from 'path'; import fs from 'fs/promises'; const tado_url = "https://my.tado.com"; const tado_auth_url = "https://login.tado.com/oauth2"; const tado_client_id = "1bb50063-6b0c-4d11-bd99-387f4a91cc46"; export default class Tado { constructor(name, config, storagePath) { this.name = name; const usesExternalTokenFile = config.username?.toLowerCase().endsWith(".json"); this._tadoExternalTokenFilePath = usesExternalTokenFile ? config.username : undefined; const fnSimpleHash = (str) => { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = (hash << 5) - hash + char; } return (hash >>> 0).toString(36).padStart(7, '0'); }; this.username = usesExternalTokenFile ? undefined : config.username; this._tadoInternalTokenFilePath = usesExternalTokenFile ? undefined : path.join(storagePath, `.tado-token-${fnSimpleHash(config.username)}.json`); this._tadoApiClientId = tado_client_id; this._tadoTokenPromise = undefined; this._tadoAuthenticationCallback = undefined; Logger.debug("API successfull initialized", this.name); } async getToken() { if (!this._tadoTokenPromise) { this._tadoTokenPromise = this._getToken().finally(() => { this._tadoTokenPromise = undefined; }); } return this._tadoTokenPromise; } async _getToken() { try { if (!this._tadoBearerToken) this._tadoBearerToken = { access_token: undefined, refresh_token: undefined, timestamp: 0 }; if ((Date.now() - this._tadoBearerToken.timestamp) < 9 * 60 * 1000) return this._tadoBearerToken.access_token; if (this._tadoExternalTokenFilePath) await this._retrieveTokenFromExternalFile(); else await this._retrieveToken(); if (!this._tadoBearerToken.access_token) throw new Error("An unknown error occurred."); return this._tadoBearerToken.access_token; } catch (error) { throw new Error(`API call failed. Could not get access token: ${error.message || error}`); } } async _retrieveToken() { try { if (this._tadoBearerToken.refresh_token) return this._refreshToken(this._tadoBearerToken.refresh_token); await fs.access(this._tadoInternalTokenFilePath); const refresh_token = await this._retrieveRefreshTokenFromInternalFile(); return this._refreshToken(refresh_token); } catch (_err) { return this._authenticateUser(); } } async _retrieveRefreshTokenFromInternalFile() { const maxRetries = 3; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const data = await fs.readFile(this._tadoInternalTokenFilePath, "utf8"); const json = JSON.parse(data); if (json.refresh_token) return json.refresh_token; } catch (error) { Logger.warn(`Failed to load from internal file (attempt ${attempt} of ${maxRetries}):`, error); } if (attempt < maxRetries) { await new Promise(resolve => setTimeout(resolve, 1000)); } } throw new Error(`Failed to load from internal file after ${maxRetries} attempts.`); } async _refreshToken(old_refresh_token) { try { const response = await got.post(`${tado_auth_url}/token`, { form: { client_id: this._tadoApiClientId, grant_type: "refresh_token", refresh_token: old_refresh_token }, responseType: "json" }); const { access_token, refresh_token } = response.body; if (!access_token) throw new Error("Empty access token."); await fs.writeFile(this._tadoInternalTokenFilePath, JSON.stringify({ access_token, refresh_token })); this._tadoBearerToken = { access_token, refresh_token, timestamp: Date.now() }; } catch (error) { Logger.warn(`Error while refreshing token: ${error.message || error}`); return this._authenticateUser(); } } async _authenticateUser() { Logger.info('Requesting device authorization...'); const authResponse = await got.post(`${tado_auth_url}/device_authorize`, { form: { client_id: this._tadoApiClientId, scope: "offline_access" }, responseType: "json" }); const { device_code, verification_uri_complete } = authResponse.body; if (!device_code) throw new Error("Failed to retrieve device code."); Logger.info(`Open the following URL in your browser, click "submit" and log in to your tado° account "${this.username}": ${verification_uri_complete}`); if (this._tadoAuthenticationCallback) this._tadoAuthenticationCallback(verification_uri_complete); const maxRetries = 30; for (let attempt = 1; attempt <= maxRetries; attempt++) { await new Promise(resolve => setTimeout(resolve, 5000)); let tokenResponse; try { tokenResponse = await got.post(`${tado_auth_url}/token`, { form: { client_id: this._tadoApiClientId, device_code: device_code, grant_type: "urn:ietf:params:oauth:grant-type:device_code" }, responseType: "json" }); } catch (_error) { //authentication still pending -> response code 400 } if (tokenResponse?.body) { const { access_token, refresh_token } = tokenResponse.body; if (access_token) { await fs.writeFile(this._tadoInternalTokenFilePath, JSON.stringify({ access_token, refresh_token })); this._tadoBearerToken = { access_token, refresh_token, timestamp: Date.now() }; Logger.info("Authentication successful!"); return; } } Logger.info("Waiting for confirmation..."); } throw new Error(`Failed to authenticate after ${maxRetries} attempts.`); } async _retrieveTokenFromExternalFile() { const maxRetries = 3; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const data = await fs.readFile(this._tadoExternalTokenFilePath, 'utf8'); const json = JSON.parse(data); if (json.access_token) { this._tadoBearerToken = { access_token: json.access_token, refresh_token: undefined, timestamp: Date.now() }; return; } } catch (error) { Logger.warn(`Failed to load from external file (attempt ${attempt} of ${maxRetries}):`, error); } if (attempt < maxRetries) { await new Promise(resolve => setTimeout(resolve, 5000)); } } throw new Error(`Failed to load from external file after ${maxRetries} attempts.`); } async apiCall(path, method = 'GET', data = {}, params = {}, tado_url_dif, blockLog) { Logger.debug('Get access token...', this.name); const access_token = await this.getToken(); let tadoLink = tado_url_dif || tado_url; Logger.debug('Using ' + tadoLink, this.name); Logger.debug( 'API request ' + method + ' ' + path + ' ' + (data && Object.keys(data).length ? JSON.stringify(data) + ' <pending>' : '<pending>'), this.name ); let config = { method: method, responseType: 'json', headers: { Authorization: 'Bearer ' + access_token, }, timeout: { request: 30000 }, retry: { limit: 2, statusCodes: [408, 429, 503, 504], methods: ['GET', 'POST', 'DELETE', 'PUT'], }, }; if (Object.keys(data).length) config.json = data; if (Object.keys(params).length) config.searchParams = params; const response = await got(tadoLink + path, config); Logger.debug( 'API request ' + method + ' ' + path + ' ' + (data && Object.keys(data).length ? JSON.stringify(data) + ' <success>' : '<success>'), this.name ); if (!blockLog) Logger.debug('API request ' + method + ' ' + path + ' <response> ' + JSON.stringify(response.body), this.name); return response.body; } async fullAuthentication() { let instructions = ""; let resolve; const oPromise = new Promise((res, _) => { resolve = res; }); this._tadoAuthenticationCallback = (openInBrowserInstructions) => { instructions = openInBrowserInstructions; resolve(); }; try { await Promise.race([ this.getToken(), oPromise ]); } finally { this._tadoAuthenticationCallback = undefined; } return instructions; } async waitForAuthentication() { await this.getToken(); return "Authentication successful!"; } async getMe() { return this.apiCall('/api/v2/me'); } async getHome(home_id) { return this.apiCall(`/api/v2/homes/${home_id}`); } async getWeather(home_id) { return this.apiCall(`/api/v2/homes/${home_id}/weather`); } async getDevices(home_id) { return this.apiCall(`/api/v2/homes/${home_id}/devices`); } async getDeviceTemperatureOffset(device_id) { return this.apiCall(`/api/v2/devices/${device_id}/temperatureOffset`); } async getInstallations(home_id) { return this.apiCall(`/api/v2/homes/${home_id}/installations`); } async getUsers(home_id) { return this.apiCall(`/api/v2/homes/${home_id}/users`); } async getState(home_id) { return this.apiCall(`/api/v2/homes/${home_id}/state`); } async getMobileDevices(home_id) { return this.apiCall(`/api/v2/homes/${home_id}/mobileDevices`); } async getMobileDevice(home_id, device_id) { return this.apiCall(`/api/v2/homes/${home_id}/mobileDevices/${device_id}`); } async getMobileDeviceSettings(home_id, device_id) { return this.apiCall(`/api/v2/homes/${home_id}/mobileDevices/${device_id}/settings`); } async setGeoTracking(home_id, device_id, geoTrackingEnabled) { const settings = await this.getMobileDeviceSettings(home_id, device_id); settings['geoTrackingEnabled'] = geoTrackingEnabled; return this.apiCall(`/api/v2/homes/${home_id}/mobileDevices/${device_id}/settings`, 'PUT', settings); } async getZones(home_id) { return this.apiCall(`/api/v2/homes/${home_id}/zones`); } async getZoneState(home_id, zone_id) { return this.apiCall(`/api/v2/homes/${home_id}/zones/${zone_id}/state`); } async getZoneCapabilities(home_id, zone_id) { return this.apiCall(`/api/v2/homes/${home_id}/zones/${zone_id}/capabilities`); } async getZoneOverlay(home_id, zone_id) { return this.apiCall(`/api/v2/homes/${home_id}/zones/${zone_id}/overlay`).catch((error) => { if (error.response.status === 404) { return {}; } throw error; }); } async getZoneDayReport(home_id, zone_id, reportDate) { return this.apiCall(`/api/v2/homes/${home_id}/zones/${zone_id}/dayReport?date=${reportDate}`); } async getTimeTables(home_id, zone_id) { return this.apiCall(`/api/v2/homes/${home_id}/zones/${zone_id}/schedule/activeTimetable`); } async getAwayConfiguration(home_id, zone_id) { return this.apiCall(`/api/v2/homes/${home_id}/zones/${zone_id}/awayConfiguration`); } async getTimeTable(home_id, zone_id, timetable_id) { return this.apiCall(`/api/v2/homes/${home_id}/zones/${zone_id}/schedule/timetables/${timetable_id}/blocks`); } async clearZoneOverlay(home_id, zone_id) { return this.apiCall(`/api/v2/homes/${home_id}/zones/${zone_id}/overlay`, 'DELETE'); } async setZoneOverlay(home_id, zone_id, power, temperature, termination, tempUnit) { const zone_state = await this.getZoneState(home_id, zone_id); const config = { setting: zone_state.setting, termination: {}, }; if (power.toLowerCase() == 'on') { config.setting.power = 'ON'; if (temperature && !isNaN(temperature)) { if (tempUnit.toLowerCase() === 'fahrenheit') temperature = ((temperature - 32) * 5) / 9; config.setting.temperature = { celsius: temperature }; } else { config.setting.temperature = null; } } else { config.setting.power = 'OFF'; config.setting.temperature = null; } if (!isNaN(parseInt(termination))) { config.termination.type = 'TIMER'; config.termination.durationInSeconds = termination; } else if (termination && termination.toLowerCase() == 'auto') { config.termination.type = 'TADO_MODE'; } else if (termination && termination.toLowerCase() == 'next_time_block') { config.type = 'MANUAL'; config.termination.typeSkillBasedApp = 'NEXT_TIME_BLOCK'; } else { config.termination.type = 'MANUAL'; } return this.apiCall(`/api/v2/homes/${home_id}/zones/${zone_id}/overlay`, 'PUT', config); } async setDeviceTemperatureOffset(device_id, temperatureOffset) { const config = { celsius: temperatureOffset, }; return this.apiCall(`/api/v2/devices/${device_id}/temperatureOffset`, 'PUT', config); } async identifyDevice(device_id) { return this.apiCall(`/api/v2/devices/${device_id}/identify`, 'POST'); } async getPresenceLock(home_id) { return this.apiCall(`/api/v2/homes/${home_id}/state`); } async setPresenceLock(home_id, presence) { presence = presence.toUpperCase(); if (!['HOME', 'AWAY', 'AUTO'].includes(presence)) { throw new Error(`Invalid presence "${presence}" must be "HOME", "AWAY", or "AUTO"`); } const method = presence == 'AUTO' ? 'DELETE' : 'PUT'; const config = { homePresence: presence, }; return this.apiCall(`/api/v2/homes/${home_id}/presenceLock`, method, config); } async isAnyoneAtHome(home_id) { const devices = await this.getMobileDevices(home_id); for (const device of devices) { if (device.settings.geoTrackingEnabled && device.location && device.location.atHome) { return true; } } return false; } async updatePresence(home_id) { const isAnyoneAtHome = await this.isAnyoneAtHome(home_id); let isPresenceAtHome = await this.getState(home_id); isPresenceAtHome = isPresenceAtHome.presence === 'HOME'; if (isAnyoneAtHome !== isPresenceAtHome) { return this.setPresenceLock(home_id, isAnyoneAtHome ? 'HOME' : 'AWAY'); } else { return 'already up to date'; } } async setWindowDetection(home_id, zone_id, enabled, timeout) { const config = { enabled: enabled, timeoutInSeconds: timeout, }; return this.apiCall(`/api/v2/homes/${home_id}/zones/${zone_id}/openWindowDetection`, 'PUT', config); } async setOpenWindowMode(home_id, zone_id, activate) { if (activate) { return this.apiCall(`/api/v2/homes/${home_id}/zones/${zone_id}/state/openWindow/activate`, 'POST'); } else { return this.apiCall(`/api/v2/homes/${home_id}/zones/${zone_id}/state/openWindow`, 'DELETE'); } } async getAirComfort(home_id) { return this.apiCall(`/api/v2/homes/${home_id}/airComfort`); } async getWeatherAirComfort(home_id, longitude, latitude) { let geoLocation = { longitude: longitude, latitude: latitude, }; if (!geoLocation.longitude || !geoLocation.latitude) { const data = await this.getHome(home_id); geoLocation = data.geolocation; } return this.apiCall(`/v1/homes/${home_id}/airComfort`, 'GET', {}, geoLocation, 'https://acme.tado.com'); } async setChildLock(serialNumber, state) { if (!serialNumber) { throw new Error('Cannot change child lock state. No serialNumber is given.'); } return this.apiCall(`/api/v2/devices/${serialNumber}/childLock`, 'PUT', { childLockEnabled: state }); } async switchAll(home_id, zones = []) { const postData = { overlays: [], }; zones.forEach((zone) => { const zoneData = { room: zone.id, overlay: { setting: { power: zone.power || 'OFF', type: zone.type || 'HEATING', }, }, termination: { typeSkillBasedApp: zone.termination || 'MANUAL', }, }; if (zone.maxTempInCelsius) { zoneData.overlay.setting.temperature = { celsius: zone.maxTempInCelsius, fahrenheit: Math.round(((zone.maxTempInCelsius * 9) / 5 + 32 + Number.EPSILON) * 100) / 100, }; } if (zone.termination === 'TIMER') { zoneData.termination.durationInSeconds = zone.timer > 0 ? zone.timer * 60 : 1800; } postData.overlays.push(zoneData); }); return this.apiCall(`/api/v2/homes/${home_id}/overlay`, 'POST', postData); } async resumeShedule(home_id, roomIds = []) { if (!roomIds.length) { throw new Error('Can not resume shedule for zones, no room ids given!'); } const params = { rooms: roomIds.toString(), }; return this.apiCall(`/api/v2/homes/${home_id}/overlay`, 'DELETE', {}, params); } async getRunningTime(home_id, time, from, to) { const period = { aggregate: time || 'day', summary_only: true, }; if (from) period.from = from; if (to) period.to = to; return this.apiCall(`/v1/homes/${home_id}/runningTimes`, 'GET', {}, period, 'https://minder.tado.com', false); } }