UNPKG

iobroker.garmin

Version:
988 lines (909 loc) 33.4 kB
'use strict'; /* * Created with @iobroker/create-adapter v2.3.0 */ // The adapter-core module gives you access to the core ioBroker functions // you need to create an adapter const utils = require('@iobroker/adapter-core'); const crypto = require('crypto'); const OAuth = require('oauth-1.0a'); const axios = require('axios').default; const Json2iob = require('json2iob'); const { CookieJar } = require('tough-cookie'); const { HttpsCookieAgent } = require('http-cookie-agent/http'); const UA_IOS = 'GCM-iOS-5.7.2.1'; const DOMAIN = 'garmin.com'; // Load your modules here, e.g.: // const fs = require("fs"); class Garmin extends utils.Adapter { /** * @param {Partial<utils.AdapterOptions>} [options={}] */ constructor(options) { super({ ...options, name: 'garmin', }); this.on('ready', this.onReady.bind(this)); this.on('stateChange', this.onStateChange.bind(this)); this.on('unload', this.onUnload.bind(this)); this.deviceArray = []; this.json2iob = new Json2iob(this); this.allowlistExactKeys = []; this.allowlistExactPaths = []; this.allowlistSearch = []; } /** * Is called when databases are connected and adapter received configuration. */ async onReady() { // Reset the connection indicator during startup this.setState('info.connection', false, true); this.session = {}; if (this.config.interval < 0.5) { this.log.info('Set interval to minimum 0.5'); this.config.interval = 0.5; } if (!this.config.username || !this.config.password) { this.log.error('Please set username and password in the instance settings'); return; } // Parse allowlists from config if (this.config.allowlistExactKeys && typeof this.config.allowlistExactKeys === 'string') { this.allowlistExactKeys = this.config.allowlistExactKeys .split(',') .map((item) => item.trim().toLowerCase()) .filter((item) => item.length > 0); if (this.allowlistExactKeys.length > 0) { this.log.info('Exact keys allowlist active: ' + this.allowlistExactKeys.join(', ')); } } if (this.config.allowlistExactPaths && typeof this.config.allowlistExactPaths === 'string') { this.allowlistExactPaths = this.config.allowlistExactPaths .split(',') .map((item) => item.trim().toLowerCase()) .filter((item) => item.length > 0); if (this.allowlistExactPaths.length > 0) { this.log.info('Exact paths allowlist active: ' + this.allowlistExactPaths.join(', ')); } } if (this.config.allowlistSearch && typeof this.config.allowlistSearch === 'string') { this.allowlistSearch = this.config.allowlistSearch .split(',') .map((item) => item.trim().toLowerCase()) .filter((item) => item.length > 0); if (this.allowlistSearch.length > 0) { this.log.info('Search allowlist active: ' + this.allowlistSearch.join(', ')); } } await this.extendObject('auth', { type: 'channel', common: { name: 'Auth', }, native: {}, }); await this.extendObject('auth.token', { type: 'state', common: { name: 'Token', type: 'string', role: 'value', read: true, write: false, }, native: {}, }); await this.extendObject('auth.mfaSession', { type: 'state', common: { name: 'MFA Session', type: 'string', role: 'value', read: true, write: false, }, native: {}, }); const tokenState = await this.getStateAsync('auth.token'); if (tokenState && tokenState.val && typeof tokenState.val === 'string') { this.session = JSON.parse(tokenState.val); this.log.info('Old Session found'); } // If no session or no access token, perform full login if (!this.session || !this.session.access_token) { this.log.info('No token found, performing login...'); const loginSuccess = await this.performFullLogin(); if (!loginSuccess) { this.log.error('Login failed'); return; } } await this.refreshToken(); if (!this.session.access_token) { this.log.error('Failed to login'); return; } await axios({ method: 'GET', url: 'https://connect.garmin.com/userprofile-service/userprofile/userProfileBase', headers: { Authorization: 'Bearer ' + this.session.access_token, Accept: 'application/json, text/plain, */*', 'DI-Backend': 'connectapi.garmin.com', 'User-Agent': UA_IOS, }, }) .then((res) => { this.log.debug(JSON.stringify(res.data)); this.userpreferences = res.data; }) .catch((error) => { this.log.error(error.message); error.response && this.log.error(JSON.stringify(error.response.data)); }); this.updateInterval = null; this.reLoginTimeout = null; this.refreshTokenTimeout = null; this.subscribeStates('*'); // this.log.info('Login to Garmin'); // const result = await this.login(); await this.getDeviceList(); await this.updateDevices(); this.updateInterval = setInterval( async () => { await this.updateDevices(); }, this.config.interval * 60 * 1000, ); this.refreshTokenInterval = this.setInterval( async () => { await this.refreshToken(); }, 13 * 60 * 1000 - 5234, ); } async fetchOAuthConsumer() { return axios({ method: 'GET', url: 'https://thegarth.s3.amazonaws.com/oauth_consumer.json', timeout: 10000, }) .then((res) => { this.log.debug('Fetched OAuth consumer from S3'); this.log.debug(JSON.stringify(res.data)); return res.data; }) .catch((error) => { this.log.debug('Failed to fetch OAuth consumer: ' + error.message); this.log.warn('Using fallback OAuth consumer credentials'); return { consumer_key: 'fc3e99d2-118c-44b8-8ae3-03370dde24c0', consumer_secret: 'E08WAR897WEy2knn7aFBrvegVAf0AFdWBBF', }; }); } createOAuthClient(consumerKey, consumerSecret) { return OAuth({ consumer: { key: consumerKey, secret: consumerSecret }, signature_method: 'HMAC-SHA1', hash_function(base_string, key) { return crypto.createHmac('sha1', key).update(base_string).digest('base64'); }, }); } createClient(cookieJar) { return axios.create({ withCredentials: true, httpsAgent: new HttpsCookieAgent({ cookies: { jar: cookieJar, }, }), maxRedirects: 5, }); } async login() { this.log.info('Starting SSO login...'); const SSO = `https://sso.${DOMAIN}/sso`; const SSO_EMBED = `${SSO}/embed`; const SSO_EMBED_PARAMS = { id: 'gauth-widget', embedWidget: 'true', gauthHost: SSO, }; const SIGNIN_PARAMS = { id: 'gauth-widget', embedWidget: 'true', gauthHost: SSO_EMBED, service: SSO_EMBED, source: SSO_EMBED, redirectAfterAccountLoginUrl: SSO_EMBED, redirectAfterAccountCreationUrl: SSO_EMBED, }; // Check if we have a saved MFA session to resume const mfaSessionState = await this.getStateAsync('auth.mfaSession'); if (this.config.mfa && mfaSessionState && mfaSessionState.val && typeof mfaSessionState.val === 'string') { this.log.info('Resuming MFA session...'); try { const mfaSession = JSON.parse(mfaSessionState.val); // Restore cookies from serialized CookieJar const cookieJar = CookieJar.fromJSON(mfaSession.cookieJar); const client = this.createClient(cookieJar); // Submit MFA code with saved session const mfaHtml = await client({ method: 'POST', url: `${SSO}/verifyMFA/loginEnterMfaCode`, params: SIGNIN_PARAMS, headers: { 'User-Agent': UA_IOS, 'Content-Type': 'application/x-www-form-urlencoded', Referer: `${SSO}/signin?${new URLSearchParams(SIGNIN_PARAMS)}`, }, data: { 'mfa-code': this.config.mfa, embed: 'true', _csrf: mfaSession.csrf, fromPage: 'setupEnterMfaCode', }, }) .then((res) => { this.log.debug('MFA resume response: ' + res.status); this.log.debug(JSON.stringify(res.data)); return res.data; }) .catch((error) => { this.log.error('MFA resume failed: ' + error.message); return null; }); if (!mfaHtml) { throw new Error('MFA resume request failed'); } const mfaTitleMatch = mfaHtml.match(/<title>(.+?)<\/title>/); const mfaTitle = mfaTitleMatch ? mfaTitleMatch[1] : ''; this.log.debug('MFA Response title: ' + mfaTitle); // Clear saved MFA session await this.setState('auth.mfaSession', '', true); if (mfaTitle === 'Success') { const ticketMatch = mfaHtml.match(/embed\?ticket=([A-Za-z0-9-]+)/); if (ticketMatch) { this.log.info('MFA verification successful'); this.log.debug('Ticket: ' + ticketMatch[1]); return ticketMatch[1]; } } this.log.warn('Saved MFA session expired, starting fresh login...'); } catch (e) { this.log.warn('Failed to resume MFA session: ' + e); await this.setState('auth.mfaSession', '', true); } } // Create client for fresh login const cookieJar = new CookieJar(); const client = this.createClient(cookieJar); // Step 1: Set cookies this.log.debug('Setting SSO cookies...'); await client({ method: 'GET', url: `${SSO}/embed`, params: SSO_EMBED_PARAMS, headers: { 'User-Agent': UA_IOS }, }) .then((res) => { this.log.debug('SSO cookies response: ' + res.status); this.log.debug(JSON.stringify(res.data)); }) .catch((error) => { this.log.error('SSO cookies failed: ' + error.message); }); // Step 2: Get CSRF token this.log.debug('Getting CSRF token...'); const signinPageHtml = await client({ method: 'GET', url: `${SSO}/signin`, params: SIGNIN_PARAMS, headers: { 'User-Agent': UA_IOS, Referer: `${SSO}/embed?${new URLSearchParams(SSO_EMBED_PARAMS)}`, }, }) .then((res) => { this.log.debug('CSRF response: ' + res.status); this.log.debug(JSON.stringify(res.data)); return res.data; }) .catch((error) => { this.log.error('CSRF request failed: ' + error.message); return null; }); if (!signinPageHtml) { return null; } const csrfMatch = signinPageHtml.match(/name="_csrf"\s+value="(.+?)"/); if (!csrfMatch) { this.log.error('CSRF token not found'); this.log.debug('Response: ' + signinPageHtml.substring(0, 500)); return null; } const csrfToken = csrfMatch[1]; // Step 3: Submit login this.log.debug('Submitting login...'); const loginHtml = await client({ method: 'POST', url: `${SSO}/signin`, params: SIGNIN_PARAMS, headers: { 'User-Agent': UA_IOS, 'Content-Type': 'application/x-www-form-urlencoded', Referer: `${SSO}/signin?${new URLSearchParams(SIGNIN_PARAMS)}`, }, data: { username: this.config.username, password: this.config.password, embed: 'true', _csrf: csrfToken, }, }) .then((res) => { this.log.debug('Login response: ' + res.status); this.log.debug(JSON.stringify(res.data)); return res.data; }) .catch((error) => { this.log.error('Login request failed: ' + error.message); return null; }); if (!loginHtml) { return null; } const titleMatch = loginHtml.match(/<title>(.+?)<\/title>/); const title = titleMatch ? titleMatch[1] : ''; this.log.debug('Response title: ' + title); // Handle MFA if (title.includes('MFA')) { const mfaCsrfMatch = loginHtml.match(/name="_csrf"\s+value="(.+?)"/); const mfaCsrf = mfaCsrfMatch ? mfaCsrfMatch[1] : csrfToken; if (!this.config.mfa) { // Save session for resuming after MFA code is entered this.log.info('MFA required. Saving session...'); const mfaSession = { cookieJar: cookieJar.toJSON(), csrf: mfaCsrf, timestamp: Date.now(), }; await this.setState('auth.mfaSession', JSON.stringify(mfaSession), true); this.log.info('Please enter MFA code in the settings. The session is saved for 5 minutes.'); return null; } // MFA code is available, submit it this.log.info('MFA code found, submitting...'); const mfaHtml = await client({ method: 'POST', url: `${SSO}/verifyMFA/loginEnterMfaCode`, params: SIGNIN_PARAMS, headers: { 'User-Agent': UA_IOS, 'Content-Type': 'application/x-www-form-urlencoded', Referer: `${SSO}/signin?${new URLSearchParams(SIGNIN_PARAMS)}`, }, data: { 'mfa-code': this.config.mfa, embed: 'true', _csrf: mfaCsrf, fromPage: 'setupEnterMfaCode', }, }) .then((res) => { this.log.debug('MFA response: ' + res.status); this.log.debug(JSON.stringify(res.data)); return res.data; }) .catch((error) => { this.log.error('MFA request failed: ' + error.message); return null; }); if (!mfaHtml) { return null; } const mfaTitleMatch = mfaHtml.match(/<title>(.+?)<\/title>/); const mfaTitle = mfaTitleMatch ? mfaTitleMatch[1] : ''; this.log.debug('MFA Response title: ' + mfaTitle); if (mfaTitle !== 'Success') { this.log.error('MFA verification failed. Code may be expired or invalid.'); return null; } const ticketMatch = mfaHtml.match(/embed\?ticket=([A-Za-z0-9-]+)/); if (ticketMatch) { this.log.info('MFA verification successful'); this.log.debug('Ticket: ' + ticketMatch[1]); return ticketMatch[1]; } } if (title !== 'Success') { this.log.error('Login failed. Check username and password.'); this.log.debug('HTML: ' + loginHtml.substring(0, 500)); return null; } // Extract ticket const ticketMatch = loginHtml.match(/embed\?ticket=([A-Za-z0-9-]+)/); if (!ticketMatch) { this.log.error('Ticket not found in response'); return null; } this.log.info('SSO Login successful'); this.log.debug('Ticket: ' + ticketMatch[1]); return ticketMatch[1]; } async getOAuth1Token(ticket) { this.log.debug('Getting OAuth1 token...'); const consumer = await this.fetchOAuthConsumer(); this.oauth = this.createOAuthClient(consumer.consumer_key, consumer.consumer_secret); const loginUrl = `https://sso.${DOMAIN}/sso/embed`; const url = `https://connectapi.${DOMAIN}/oauth-service/oauth/preauthorized?ticket=${ticket}&login-url=${encodeURIComponent(loginUrl)}&accepts-mfa-tokens=true`; const request_data = { url, method: 'GET' }; const authHeader = this.oauth.toHeader(this.oauth.authorize(request_data)); return axios({ method: 'GET', url: url, headers: { 'User-Agent': UA_IOS, ...authHeader, }, }) .then((res) => { this.log.debug('OAuth1 Status: ' + res.status); this.log.debug(JSON.stringify(res.data)); const params = new URLSearchParams(res.data); const oauth1Token = { oauth_token: params.get('oauth_token'), oauth_token_secret: params.get('oauth_token_secret'), mfa_token: params.get('mfa_token'), }; this.log.debug('OAuth1 Token: ' + (oauth1Token.oauth_token ? 'OK' : 'MISSING')); return oauth1Token; }) .catch((error) => { this.log.error('OAuth1 request failed: ' + (error.response?.status || '') + ' ' + error.message); return null; }); } async exchangeOAuth2Token(oauth1Token) { this.log.debug('Exchanging for OAuth2 token...'); this.log.debug('oauth1Token: ' + JSON.stringify(oauth1Token)); if (!this.oauth) { this.log.error('OAuth client not initialized!'); return null; } const url = `https://connectapi.${DOMAIN}/oauth-service/oauth/exchange/user/2.0`; // Body data - must be included in OAuth signature const bodyData = oauth1Token.mfa_token ? { mfa_token: oauth1Token.mfa_token } : {}; const request_data = { url, method: 'POST', data: bodyData, // Include body in signature calculation }; const token = { key: oauth1Token.oauth_token, secret: oauth1Token.oauth_token_secret, }; const authHeader = this.oauth.toHeader(this.oauth.authorize(request_data, token)); this.log.debug('authHeader: ' + JSON.stringify(authHeader)); const body = oauth1Token.mfa_token ? `mfa_token=${oauth1Token.mfa_token}` : ''; this.log.debug('body: ' + body); return axios({ method: 'POST', url: url, headers: { 'User-Agent': UA_IOS, 'Content-Type': 'application/x-www-form-urlencoded', ...authHeader, }, data: body, }) .then((res) => { this.log.debug('OAuth2 Status: ' + res.status); this.log.debug(JSON.stringify(res.data)); const oauth2Token = res.data; oauth2Token.expires_at = Math.floor(Date.now() / 1000) + oauth2Token.expires_in; oauth2Token.refresh_token_expires_at = Math.floor(Date.now() / 1000) + oauth2Token.refresh_token_expires_in; this.log.debug('OAuth2 Token: OK'); return oauth2Token; }) .catch((error) => { this.log.error('OAuth2 request failed: ' + (error.response?.status || '') + ' ' + error.message); if (error.response?.data) { this.log.error('OAuth2 error response: ' + JSON.stringify(error.response.data)); } return null; }); } async performFullLogin() { const ticket = await this.login(); if (!ticket) { this.log.error('Login failed - no ticket'); return false; } const oauth1Token = await this.getOAuth1Token(ticket); if (!oauth1Token) { this.log.error('OAuth1 token exchange failed'); return false; } const oauth2Token = await this.exchangeOAuth2Token(oauth1Token); if (!oauth2Token) { this.log.error('OAuth2 token exchange failed'); // Clear MFA session and token to force fresh login next time this.log.info('Clearing MFA session and token for fresh login...'); await this.setState('auth.mfaSession', '', true); await this.setState('auth.token', '', true); return false; } // Store OAuth2 token - refresh_token is used for token refresh this.session = oauth2Token; await this.setState('auth.token', JSON.stringify(this.session), true); this.setState('info.connection', true, true); this.log.info('Full login successful'); return true; } async getDeviceList() { await axios({ method: 'GET', url: 'https://connect.garmin.com/device-service/deviceregistration/devices', headers: { Authorization: 'Bearer ' + this.session.access_token, 'DI-Backend': 'connectapi.garmin.com', Accept: 'application/json, text/plain, */*', 'User-Agent': UA_IOS, }, }) .then(async (res) => { this.log.debug(JSON.stringify(res.data)); if (res.data) { this.log.info(`Found ${res.data.length} devices`); await this.setObjectNotExistsAsync('devices', { type: 'channel', common: { name: 'Devices', }, native: {}, }); for (const device of res.data) { this.log.debug(JSON.stringify(device)); const id = device.unitId.toString(); this.deviceArray.push(device); const name = device.productDisplayName; await this.setObjectNotExistsAsync('devices.' + id, { type: 'device', common: { name: name, }, native: {}, }); // await this.setObjectNotExistsAsync(id + ".remote", { // type: "channel", // common: { // name: "Remote Controls", // }, // native: {}, // }); // const remoteArray = [{ command: "Refresh", name: "True = Refresh" }]; // remoteArray.forEach((remote) => { // this.setObjectNotExists(id + ".remote." + remote.command, { // type: "state", // common: { // name: remote.name || "", // type: remote.type || "boolean", // role: remote.role || "boolean", // def: remote.def || false, // write: true, // read: true, // }, // native: {}, // }); // }); this.json2iob.parse('devices.' + id + '.general', device, { forceIndex: true }); } } }) .catch((error) => { this.log.error(error.message); error.response && this.log.error(JSON.stringify(error.response.data)); }); } async updateDevices() { this.userpreferences; const date = new Date().toISOString().split('T')[0]; const dateMinus10 = new Date(new Date().setDate(new Date().getDate() - 6)).toISOString().split('T')[0]; const statusArray = [ { path: 'usersummary', url: 'https://connect.garmin.com/usersummary-service/usersummary/daily/' + this.userpreferences.displayName + '?calendarDate=' + date, desc: 'User Summary Daily', }, { path: 'maxmet', url: 'https://connect.garmin.com/metrics-service/metrics/maxmet/daily/' + date + '/' + date, desc: 'Max Metrics Daily', }, { path: 'hydration', url: 'https://connect.garmin.com/usersummary-service/usersummary/hydration/daily/' + date, desc: 'Hydration Daily', }, { path: 'personalrecords', url: 'https://connect.garmin.com/personalrecord-service/personalrecord/prs/' + this.userpreferences.displayName, desc: 'Personal Records', }, { path: 'adhocchallenge', url: 'https://connect.garmin.com/adhocchallenge-service/adHocChallenge/historical', desc: 'Adhoc Challenge', }, { path: 'dailysleep', url: 'https://connect.garmin.com/wellness-service/wellness/dailySleepData/' + this.userpreferences.displayName + '?date=' + date + '&nonSleepBufferMinutes=60', desc: 'Daily Sleep', }, { path: 'dailystress', url: 'https://connect.garmin.com/wellness-service/wellness/dailyStress/' + date, desc: 'Daily Stress', }, { path: 'heartrate', url: 'https://connect.garmin.com/userstats-service/wellness/daily/' + this.userpreferences.displayName + '?fromDate=' + dateMinus10, desc: 'Resting Heartrate', }, { path: 'trainingstatus', url: 'https://connect.garmin.com/metrics-service/metrics/trainingstatus/aggregated/' + date, desc: 'Training Status', }, { path: 'activities', url: 'https://connect.garmin.com/activitylist-service/activities/search/activities?start=0&limit=10', desc: 'Activities', }, { path: 'weight', url: 'https://connect.garmin.com/weight-service/weight/dateRange?startDate=' + dateMinus10 + '&endDate=' + date, desc: 'Weight', }, ]; for (const element of statusArray) { await axios({ method: element.method || 'GET', url: element.url, headers: { Accept: 'application/json, text/plain, */*', Authorization: 'Bearer ' + this.session.access_token, 'DI-Backend': 'connectapi.garmin.com', 'User-Agent': UA_IOS, }, }) .then((res) => { this.log.debug(JSON.stringify(res.data)); if (!res.data) { return; } // Check for empty arrays/objects before filtering if (Array.isArray(res.data) && res.data.length === 0) { this.log.debug('Empty array response for ' + element.path); return; } if (typeof res.data === 'object' && !Array.isArray(res.data) && Object.keys(res.data).length === 0) { this.log.debug('Empty object response for ' + element.path); return; } const filteredData = this.filterByAllowlist(res.data, element.path); if (filteredData === null) { this.log.debug('No data left after allowlist filter for ' + element.path); return; } // Also check if filtered data is empty if (Array.isArray(filteredData) && filteredData.length === 0) { this.log.debug('Empty array after filter for ' + element.path); return; } if (typeof filteredData === 'object' && !Array.isArray(filteredData) && Object.keys(filteredData).length === 0) { this.log.debug('Empty object after filter for ' + element.path); return; } this.log.debug('Parsing ' + element.path + ' with filtered data: ' + JSON.stringify(filteredData).substring(0, 500)); this.json2iob.parse(element.path, filteredData, { forceIndex: true, write: true, preferedArrayName: null, channelName: element.desc, }); }) .catch((error) => { if (error.response && error.response.status === 401) { this.log.debug(JSON.stringify(error.response.data)); this.log.info(element.path + ' received 401 error. Refreshing token in 60 seconds'); this.refreshTokenTimeout && clearTimeout(this.refreshTokenTimeout); this.refreshTokenTimeout = setTimeout(() => { this.refreshToken(); }, 1000 * 60); return; } this.log.error(element.url); this.log.error(error.message); error.response && this.log.error(JSON.stringify(error.response.data)); }); } } extractHidden(body) { const returnObject = {}; const matches = body.matchAll(/<input (?=[^>]* name=["']([^'"]*)|)(?=[^>]* value=["']([^'"]*)|)/g); for (const match of matches) { if (match[2] != null) { returnObject[match[1]] = match[2]; } } return returnObject; } async sleep(ms) { return new Promise((resolve) => { setTimeout(resolve, ms); }); } filterByAllowlist(data, path = '') { // If all lists are empty, return all data if (this.allowlistExactKeys.length === 0 && this.allowlistExactPaths.length === 0 && this.allowlistSearch.length === 0) { return data; } if (Array.isArray(data)) { // For arrays of primitives (numbers, strings), check if array key matches allowlist if (data.length > 0 && typeof data[0] !== 'object') { this.log.debug(`Filter: Skipping primitive array at path "${path}"`); return null; // Primitive arrays can't be filtered by key, skip them unless parent key matched } // For arrays of objects, filter each item this.log.debug(`Filter: Processing array at path "${path}" with ${data.length} items`); const filtered = data.map((item) => this.filterByAllowlist(item, path)).filter((item) => item !== null); this.log.debug(`Filter: Array at "${path}" filtered from ${data.length} to ${filtered.length} items`); return filtered.length > 0 ? filtered : null; } if (data !== null && typeof data === 'object') { const filtered = {}; for (const key of Object.keys(data)) { const fullPath = path ? `${path}.${key}` : key; const keyLower = key.toLowerCase(); const fullPathLower = fullPath.toLowerCase(); // Check exact key match (only field name) const isExactKeyMatch = this.allowlistExactKeys.includes(keyLower); // Check exact path match (full path) const isExactPathMatch = this.allowlistExactPaths.includes(fullPathLower); // Check search/partial match (key or path contains search term) const isSearchMatch = this.allowlistSearch.some((term) => keyLower.includes(term) || fullPathLower.includes(term)); if (isExactKeyMatch || isExactPathMatch || isSearchMatch) { this.log.debug(`Filter: MATCH "${fullPath}" (key=${isExactKeyMatch}, path=${isExactPathMatch}, search=${isSearchMatch})`); filtered[key] = data[key]; } else if (typeof data[key] === 'object' && data[key] !== null) { const nestedFiltered = this.filterByAllowlist(data[key], fullPath); if (nestedFiltered !== null) { if (Array.isArray(nestedFiltered) && nestedFiltered.length > 0) { filtered[key] = nestedFiltered; } else if (!Array.isArray(nestedFiltered) && Object.keys(nestedFiltered).length > 0) { filtered[key] = nestedFiltered; } } } } return Object.keys(filtered).length > 0 ? filtered : null; } return data; } async refreshToken() { this.log.debug('Refreshing OAuth2 token...'); // Check if we have a refresh token if (!this.session || !this.session.refresh_token) { this.log.warn('No refresh token available, performing full login'); await this.performFullLogin(); return; } const url = `https://connectapi.${DOMAIN}/di-oauth2-service/oauth/token`; const newToken = await axios({ method: 'POST', url: url, headers: { 'User-Agent': UA_IOS, 'Content-Type': 'application/x-www-form-urlencoded', }, data: { grant_type: 'refresh_token', client_id: 'GARMIN_CONNECT_MOBILE_ANDROID_DI', refresh_token: this.session.refresh_token, }, }) .then((res) => { this.log.debug('Refresh Status: ' + res.status); this.log.debug(JSON.stringify(res.data)); const token = res.data; token.expires_at = Math.floor(Date.now() / 1000) + token.expires_in; token.refresh_token_expires_at = Math.floor(Date.now() / 1000) + token.refresh_token_expires_in; this.log.debug('Token refreshed successfully'); return token; }) .catch((error) => { this.log.warn('Token refresh failed: ' + (error.response?.status || '') + ' ' + error.message); return null; }); if (!newToken) { this.log.info('Performing full login...'); await this.performFullLogin(); return; } this.session = newToken; await this.setState('auth.token', JSON.stringify(this.session), true); this.setState('info.connection', true, true); } /** * Is called when adapter shuts down - callback has to be called under any circumstances! * @param {() => void} callback */ async onUnload(callback) { try { this.setState('info.connection', false, true); this.reLoginTimeout && clearTimeout(this.reLoginTimeout); this.refreshTokenTimeout && clearTimeout(this.refreshTokenTimeout); this.updateInterval && clearInterval(this.updateInterval); this.refreshTokenInterval && clearInterval(this.refreshTokenInterval); // Clear MFA code from settings after successful login if (this.config.mfa) { const adapterSettings = await this.getForeignObjectAsync('system.adapter.' + this.namespace); if (adapterSettings && adapterSettings.native) { adapterSettings.native.mfa = ''; await this.setForeignObjectAsync('system.adapter.' + this.namespace, adapterSettings); } } callback(); } catch (e) { this.log.error(e); callback(); } } /** * Is called if a subscribed state changes * @param {string} id * @param {ioBroker.State | null | undefined} state */ async onStateChange(id, state) { if (state) { if (!state.ack) { const deviceId = id.split('.')[2]; const command = id.split('.')[5]; if (id.split('.')[4] === 'Refresh') { this.updateDevices(); return; } // TODO: Implement device command handling this.log.debug(`Command ${command} for device ${deviceId}: ${state.val}`); } } } } if (require.main !== module) { // Export the constructor in compact mode /** * @param {Partial<utils.AdapterOptions>} [options={}] */ module.exports = (options) => new Garmin(options); } else { // otherwise start the instance directly new Garmin(); }