UNPKG

iobroker.bosch-ebike

Version:
831 lines (792 loc) 29.3 kB
'use strict'; /* * Created with @iobroker/create-adapter v2.1.1 */ // 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 axios = require('axios').default; const qs = require('qs'); const Json2iob = require('json2iob'); const tough = require('tough-cookie'); const { HttpsCookieAgent } = require('http-cookie-agent/http'); class BoschEbike extends utils.Adapter { /** * @param {Partial<utils.AdapterOptions>} [options={}] */ constructor(options) { super({ ...options, name: 'bosch-ebike', }); this.on('ready', this.onReady.bind(this)); this.on('stateChange', this.onStateChange.bind(this)); this.on('unload', this.onUnload.bind(this)); this.deviceArray = []; this.trips = {}; this.json2iob = new Json2iob(this); this.cookieJar = new tough.CookieJar(); this.requestClient = axios.create({ withCredentials: true, httpsAgent: new HttpsCookieAgent({ cookies: { jar: this.cookieJar, }, }), }); } /** * Is called when databases are connected and adapter received configuration. */ async onReady() { // Reset the connection indicator during startup this.setState('info.connection', false, true); 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; } this.updateInterval = null; this.reLoginTimeout = null; this.refreshTokenTimeout = null; this.session = {}; this.subscribeStates('*'); if (this.config.type === 'connect') { this.log.info('Login to eBike Connect'); await this.login(); this.session.expires_in = 3600; if (this.session.token_value + ':' + this.session.mobile_id) { await this.getDeviceList(); await this.updateDevices(); this.updateInterval = setInterval(async () => { await this.updateDevices(); }, this.config.interval * 60 * 1000); } this.refreshTokenInterval = setInterval(() => { this.refreshToken(); }, this.session.expires_in * 1000); } else { const sessionState = await this.getStateAsync('auth.session'); if (sessionState && sessionState.val) { this.session = JSON.parse(sessionState.val); this.log.info('Session found. If the login fails please delete bosch-ebike.0.auth.session and restart the adapter'); this.log.debug(JSON.stringify(this.session)); await this.refreshToken(); } else { this.log.info('Login to eBike flow'); await this.loginFlow(); } if (this.session.access_token) { await this.updateDevicesFlow(true); this.updateInterval = setInterval(async () => { await this.updateDevicesFlow(); }, this.config.interval * 60 * 1000); } } } async login() { await this.requestClient({ method: 'post', url: 'https://www.ebike-connect.com/ebikeconnect/api/app/token/public', headers: { accept: 'application/vnd.ebike-connect.com.v4+json, application/json', 'content-type': 'application/json', 'cache-control': 'no-store', 'protect-from': 'CSRF', 'accept-language': 'de-de', 'user-agent': 'oea_ios/4.8.1 (iPhone; iOS 14.8; Scale/3.00)', }, data: JSON.stringify({ mobile_id: 'C5A16D86-3AC4-48B1-A851-63BAD39EAEC5', password: this.config.password, username: this.config.username, }), }) .then(async (res) => { this.log.debug(JSON.stringify(res.data)); this.setState('info.connection', true, true); this.session = res.data; }) .catch((error) => { this.log.error(error); error.response && this.log.error(JSON.stringify(error.response.data)); }); } async loginFlow() { if (!this.config.captcha) { this.log.error('Please set code url in the instance settings'); return; } if (!this.config.captcha.startsWith('onebikeapp-ios')) { this.log.error('Please set correctcode url in the instance settings'); return; } //let loginUrl = ''; // const formData = await this.requestClient({ // method: 'get', // url: 'https://p9.authz.bosch.com/auth/realms/obc/protocol/openid-connect/auth', // params: { // prompt: 'login', // nonce: '5bkl6RxVoUl3yFKi0SqgORYowCT16PG6htILaP0ujhQ', // response_type: 'code', // kc_idp_hint: 'skid', // scope: 'openid offline_access', // code_challenge: 'dDp31yHNMAGZeMSXeoOK66WOZOtkZjqYzpdZnfbWZfQ', // code_challenge_method: 'S256', // redirect_uri: 'onebikeapp-ios://com.bosch.ebike.onebikeapp/oauth2redirect', // client_id: 'one-bike-app', // state: 'DECUwcce3we_7TDOt9fiLumGwylUrrjaMyX2vfQM90k', // }, // headers: { // Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9', // 'Accept-Language': 'de-de', // 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36', // }, // }) // .then((res) => { // this.log.debug(JSON.stringify(res.data)); // loginUrl = res.request.path; // return this.extractHidden(res.data); // }) // .catch((error) => { // this.log.error(error); // error.errors && this.log.error(JSON.stringify(error.errors)); // error.response && this.log.error(JSON.stringify(error.response.data)); // }); // if (!formData) { // this.log.error('Could not extract form data'); // this.log.warn( // 'Please check your login on https://p9.authz.bosch.com/auth/realms/obc/protocol/openid-connect/auth?client_id=one-bike-app&code_challenge=dDp31yHNMAGZeMSXeoOK66WOZOtkZjqYzpdZnfbWZfQ&code_challenge_method=S256&kc_idp_hint=skid&nonce=5bkl6RxVoUl3yFKi0SqgORYowCT16PG6htILaP0ujhQ&prompt=login&redirect_uri=onebikeapp-ios%3A%2F%2Fcom.bosch.ebike.onebikeapp%2Foauth2redirect&response_type=code&scope=openid%20offline_access&state=DECUwcce3we_7TDOt9fiLumGwylUrrjaMyX2vfQM90k', // ); // return; // } // const loginParams = qs.parse(loginUrl.split('?')[1]); // const returnUrl = loginParams.returnUrl || loginParams.ReturnUrl; // const userResponse = await this.requestClient({ // method: 'post', // maxBodyLength: Infinity, // url: 'https://singlekey-id.com' + loginUrl.split('?')[0], // headers: { // 'content-type': 'application/x-www-form-urlencoded', // accept: '*/*', // 'hx-request': 'true', // 'hx-current-url': 'https://singlekey-id.com/' + loginUrl, // 'sec-fetch-site': 'same-origin', // 'hx-boosted': 'true', // 'accept-language': 'de-DE,de;q=0.9', // 'sec-fetch-mode': 'cors', // origin: 'https://singlekey-id.com', // 'user-agent': 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36', // 'sec-fetch-dest': 'empty', // referer: 'https://singlekey-id.com/' + loginUrl, // pragma: 'no-cache', // priority: 'u=1, i', // }, // params: loginParams, // data: { // 'UserIdentifierInput.EmailInput.StringValue': this.config.username, // 'h-captcha-response': this.config.captcha, // __RequestVerificationToken: formData['undefined'], // }, // }) // .then((res) => { // this.log.debug(JSON.stringify(res.data)); // return this.extractHidden(res.data); // }) // .catch((error) => { // this.log.error(error); // error.response && this.log.error(JSON.stringify(error.response.data)); // }); // if (!userResponse) { // this.log.error('Could not extract user data'); // return; // } // //const query = userResponse.returnPath.split('?')[1].replace(/&amp;/g, '&').replace("ReturnUrl='", 'returnUrl='); // await this.requestClient({ // method: 'post', // maxBodyLength: Infinity, // url: 'https://singlekey-id.com' + userResponse.returnPath.split('?')[0], // headers: { // 'content-type': 'application/x-www-form-urlencoded', // accept: '*/*', // 'hx-request': 'true', // 'sec-fetch-site': 'same-origin', // 'hx-boosted': 'true', // 'accept-language': 'de-DE,de;q=0.9', // 'sec-fetch-mode': 'cors', // origin: 'https://singlekey-id.com', // 'user-agent': // 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1', // 'sec-fetch-dest': 'empty', // }, // params: loginParams, // data: { // Password: this.config.password, // RememberMe: 'true', // __RequestVerificationToken: userResponse['undefined'], // }, // }).catch((error) => { // this.log.error(error); // error.response && this.log.error(JSON.stringify(error.response.data)); // }); // const response = await this.requestClient({ // method: 'get', // url: 'https://singlekey-id.com' + returnUrl, // }) // .then((res) => { // this.log.debug(JSON.stringify(res.data)); // this.log.error('No code received.'); // return res; // }) // .catch((error) => { // if (error && error.message.includes('Unsupported protocol')) { // return qs.parse(error.request._options.path.split('?')[1]); // } // this.log.error(error); // error.response && this.log.error(JSON.stringify(error.response.data)); // }); // if (!response) { // return; // } const response = qs.parse(this.config.captcha.split('?')[1]); await this.requestClient({ method: 'post', url: 'https://p9.authz.bosch.com/auth/realms/obc/protocol/openid-connect/token', headers: { Host: 'p9.authz.bosch.com', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', Accept: '*/*', 'User-Agent': 'Flow/56 CFNetwork/1240.0.4 Darwin/20.6.0', 'Accept-Language': 'de-de', }, data: { code: response.code, code_verifier: 'u_QNKed3HzTrRyUmAuIOapRILsUFfbDWG5i_AwqRKaU', redirect_uri: 'onebikeapp-ios://com.bosch.ebike.onebikeapp/oauth2redirect', client_id: 'one-bike-app', grant_type: 'authorization_code', }, }) .then(async (res) => { this.log.debug(JSON.stringify(res.data)); this.session = res.data; this.log.info('Login successful'); this.setState('info.connection', true, true); await this.extendObject('auth.session', { type: 'state', common: { name: 'Session Token', type: 'string', role: 'value', read: true, write: false, }, native: {}, }); this.setState('auth.session', JSON.stringify(this.session), true); }) .catch((error) => { this.log.error(error); 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 getDeviceList() { await this.requestClient({ method: 'get', url: 'https://www.ebike-connect.com/ebikeconnect/api/app/devices/my_ebikes', headers: { accept: 'application/vnd.ebike-connect.com.v4+json, application/json', 'accept-language': 'de-de', 'cache-control': 'no-store', 'protect-from': 'CSRF', 'user-agent': 'oea_ios/4.8.1 (iPhone; iOS 14.8; Scale/3.00)', 'x-authorization': this.session.token_value + ':' + this.session.mobile_id, }, }) .then(async (res) => { this.log.debug(JSON.stringify(res.data)); this.log.info(`Found ${res.data.my_ebikes.length} devices`); for (const device of res.data.my_ebikes) { const id = device.drive_unit.encoded_serial_number; this.deviceArray.push(id); const name = device.drive_unit.device_name; await this.setObjectNotExistsAsync(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(id, device, { forceIndex: true }); } }) .catch((error) => { this.log.error(error); error.response && this.log.error(JSON.stringify(error.response.data)); }); } async updateDevices() { const statusArray = [ { path: 'trips', url: 'https://www.ebike-connect.com/ebikeconnect/api/app/activities/trip/headers?max=' + this.config.maxTrips + '&offset=' + Date.now(), desc: 'Trips', }, { path: 'routes', url: 'https://www.ebike-connect.com/ebikeconnect/api/app/navigation/my_items/routes?timestamp=0', desc: 'Routes', }, { path: 'destinations', url: 'https://www.ebike-connect.com/ebikeconnect/api/app/navigation/my_items/destinations?timestamp=0', desc: 'Destinations', }, ]; for (const element of statusArray) { // const url = element.url.replace("$id", id); await this.requestClient({ method: element.method || 'get', url: element.url, headers: { accept: 'application/vnd.ebike-connect.com.v4+json, application/json', 'accept-language': 'de-de', 'cache-control': 'no-store', 'protect-from': 'CSRF', 'user-agent': 'oea_ios/4.8.1 (iPhone; iOS 14.8; Scale/3.00)', 'x-authorization': this.session.token_value + ':' + this.session.mobile_id, }, }) .then(async (res) => { this.log.debug(JSON.stringify(res.data)); if (!res.data) { return; } const data = res.data; const forceIndex = true; const preferedArrayName = null; this.json2iob.parse(element.path, data, { forceIndex: forceIndex, preferedArrayName: preferedArrayName, channelName: element.desc, }); await this.setObjectNotExistsAsync(element.path + '.json', { type: 'state', common: { name: 'Raw JSON', write: false, read: true, type: 'string', role: 'json', }, native: {}, }); this.setState(element.path + '.json', JSON.stringify(data), true); }) .catch((error) => { if (error.response) { if (error.response.status === 401) { error.response && this.log.debug(JSON.stringify(error.response.data)); this.log.info(element.path + ' receive 401 error. Refresh 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); error.response && this.log.error(JSON.stringify(error.response.data)); }); } } async updateDevicesFlow(noRefresh) { if (!noRefresh) { await this.refreshToken(); } await this.updateBatteryFlow(); const trips = await this.requestClient({ method: 'get', url: 'https://obc-rider-activity.prod.connected-biking.cloud/v1/activity?page=0&size=' + this.config.maxTrips + '&sort=-startTime', headers: { Host: 'obc-rider-activity.prod.connected-biking.cloud', accept: '*/*', 'content-type': 'application/json', authorization: 'Bearer ' + this.session.access_token, 'accept-language': 'de-de', }, }) .then(async (res) => { this.log.debug(JSON.stringify(res.data)); if (res.data && res.data.data) { await this.json2iob.parse('trips', res.data.data, { forceIndex: true, preferedArrayName: null, channelName: 'Trips' }); return res.data.data; } }) .catch((error) => { this.log.error(error); error.response && this.log.error(JSON.stringify(error.response.data)); }); if (!trips) { return; } this.log.debug(`Found ${trips.length} trips`); for (const trip of trips) { const id = trip.id; let index = trips.indexOf(trip) + 1; if (index < 10) { index = '0' + index; } let details; if (Object.keys(this.trips).includes(id)) { details = this.trips[id]; } else { details = await this.requestClient({ method: 'get', url: 'https://obc-rider-activity.prod.connected-biking.cloud/v1/activity/' + id + '/detail', headers: { Host: 'obc-rider-activity.prod.connected-biking.cloud', accept: '*/*', 'content-type': 'application/json', authorization: 'Bearer ' + this.session.access_token, 'accept-language': 'de-de', }, }) .then(async (res) => { this.log.debug(JSON.stringify(res.data)); if (!res.data || !res.data.data) { return; } this.trips[id] = res.data.data.attributes; return res.data.data.attributes; }) .catch((error) => { if (error.response) { if (error.response.status === 401) { error.response && this.log.debug(JSON.stringify(error.response.data)); this.refreshTokenTimeout && clearTimeout(this.refreshTokenTimeout); this.refreshTokenTimeout = setTimeout(() => { this.refreshToken(); }, 1000 * 60); return; } } this.log.error(error); error.response && this.log.error(JSON.stringify(error.response.data)); }); } await this.setObjectNotExistsAsync('trips.' + index + '.details', { type: 'state', common: { name: 'Trip Details', write: false, read: true, type: 'string', role: 'json', }, native: {}, }); await this.setStateAsync('trips.' + index + '.details', JSON.stringify(details), true); } } async updateBatteryFlow() { const baseUrl = 'https://obc-rider-profile.prod.connected-biking.cloud'; const headers = { Host: 'obc-rider-profile.prod.connected-biking.cloud', accept: 'application/json', 'content-type': 'application/json', authorization: 'Bearer ' + this.session.access_token, 'accept-language': 'de-de', }; const bikes = await this.requestClient({ method: 'get', url: baseUrl + '/v1/bike-profile', headers: headers, }) .then((res) => { this.log.debug(JSON.stringify(res.data)); if (res.data && Array.isArray(res.data.data)) { return res.data.data; } if (Array.isArray(res.data)) { return res.data; } return []; }) .catch((error) => { if (error.response && error.response.status === 401) { this.log.info('bike-profile receive 401 error. Refresh Token in 60 seconds'); this.refreshTokenTimeout && clearTimeout(this.refreshTokenTimeout); this.refreshTokenTimeout = setTimeout(() => { this.refreshToken(); }, 1000 * 60); return; } this.log.error(error); error.response && this.log.error(JSON.stringify(error.response.data)); }); if (!bikes || !bikes.length) { return; } this.log.debug(`Found ${bikes.length} bikes`); // Account wide subscription status (Flow+ / eBike Connect subscription). await this.requestClient({ method: 'get', url: 'https://in-app-purchase.prod.connected-biking.cloud/v1/subscription/status', headers: { Host: 'in-app-purchase.prod.connected-biking.cloud', accept: 'application/json', 'content-type': 'application/json', authorization: 'Bearer ' + this.session.access_token, 'accept-language': 'de-de', }, }) .then(async (res) => { this.log.debug(JSON.stringify(res.data)); if (!res.data) { return; } await this.json2iob.parse('subscription', res.data, { forceIndex: true, channelName: 'Subscription Status' }); }) .catch((error) => { if (error.response && error.response.status === 404) { this.log.debug('subscription status not available'); return; } this.log.error(error); error.response && this.log.error(JSON.stringify(error.response.data)); }); // Account wide bike passes (frame number, theft logs). Matched per bike below. const bikePasses = await this.requestClient({ method: 'get', url: 'https://bike-pass.prod.connected-biking.cloud/v1/bike-passes', headers: { Host: 'bike-pass.prod.connected-biking.cloud', accept: 'application/json', 'content-type': 'application/json', authorization: 'Bearer ' + this.session.access_token, 'accept-language': 'de-de', }, }) .then((res) => { this.log.debug(JSON.stringify(res.data)); return res.data && Array.isArray(res.data.bikePasses) ? res.data.bikePasses : []; }) .catch((error) => { if (error.response && error.response.status === 404) { this.log.debug('bike passes not available'); return []; } this.log.error(error); error.response && this.log.error(JSON.stringify(error.response.data)); return []; }); for (const bike of bikes) { const id = bike.id; if (!id) { continue; } // The list endpoint only returns the bike entry; the battery/driveUnit // details live in the single bike profile under data.attributes. await this.requestClient({ method: 'get', url: baseUrl + '/v1/bike-profile/' + id, headers: headers, }) .then(async (res) => { this.log.debug(JSON.stringify(res.data)); if (!res.data) { return; } const profile = res.data.data && res.data.data.attributes ? res.data.data.attributes : res.data.data ? res.data.data : res.data; await this.json2iob.parse(id + '.profile', profile, { forceIndex: true, channelName: 'Bike Profile' }); }) .catch((error) => { if (error.response && error.response.status === 401) { this.refreshTokenTimeout && clearTimeout(this.refreshTokenTimeout); this.refreshTokenTimeout = setTimeout(() => { this.refreshToken(); }, 1000 * 60); return; } this.log.error(error); error.response && this.log.error(JSON.stringify(error.response.data)); }); await this.requestClient({ method: 'get', url: baseUrl + '/v1/state-of-charge/' + id, headers: headers, }) .then(async (res) => { this.log.debug(JSON.stringify(res.data)); if (!res.data) { return; } const soc = res.data.data ? res.data.data : res.data; await this.json2iob.parse(id + '.stateOfCharge', soc, { forceIndex: true, channelName: 'State of Charge' }); }) .catch((error) => { if (error.response) { if (error.response.status === 404) { this.log.debug('state-of-charge not available for ' + id + ' (bike offline?)'); return; } if (error.response.status === 401) { this.refreshTokenTimeout && clearTimeout(this.refreshTokenTimeout); this.refreshTokenTimeout = setTimeout(() => { this.refreshToken(); }, 1000 * 60); return; } } this.log.error(error); error.response && this.log.error(JSON.stringify(error.response.data)); }); const bikePass = bikePasses.find((pass) => pass.bikeId === id); if (bikePass) { await this.json2iob.parse(id + '.bikePass', bikePass, { forceIndex: true, channelName: 'Bike Pass' }); } } } async refreshToken() { this.log.debug('Refresh token'); if (this.config.type === 'connect') { await this.login(); return; } await this.requestClient({ method: 'post', url: 'https://p9.authz.bosch.com/auth/realms/obc/protocol/openid-connect/token', headers: { Host: 'p9.authz.bosch.com', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', Accept: '*/*', 'User-Agent': 'Flow/56 CFNetwork/1240.0.4 Darwin/20.6.0', 'Accept-Language': 'de-de', }, data: qs.stringify({ refresh_token: this.session.refresh_token, client_id: 'one-bike-app', grant_type: 'refresh_token', }), }) .then(async (res) => { this.log.debug(JSON.stringify(res.data)); this.session = res.data; this.log.debug('Refresh successful'); this.setState('info.connection', true, true); await this.extendObject('auth.session', { type: 'state', common: { name: 'Session Token', type: 'string', role: 'value', read: true, write: false, }, native: {}, }); this.setState('auth.session', JSON.stringify(this.session), true); }) .catch(async (error) => { this.log.error('refresh token failed. Please delete bosch-ebike.0.auth.session and restart the adapter'); this.log.error(error); error.response && this.log.error(JSON.stringify(error.response.data)); this.setStateAsync('info.connection', false, true); await this.loginFlow(); }); } /** * 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.refreshTimeout && clearTimeout(this.refreshTimeout); this.reLoginTimeout && clearTimeout(this.reLoginTimeout); this.refreshTokenTimeout && clearTimeout(this.refreshTokenTimeout); this.updateInterval && clearInterval(this.updateInterval); this.refreshTokenInterval && clearInterval(this.refreshTokenInterval); if (this.config.captcha) { const adapterSettings = await this.getForeignObjectAsync('system.adapter.' + this.namespace); adapterSettings.native.captcha = null; 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('.')[4]; if (id.split('.')[3] !== 'remote') { return; } if (command === 'Refresh') { this.updateDevices(); return; } } } } } if (require.main !== module) { // Export the constructor in compact mode /** * @param {Partial<utils.AdapterOptions>} [options={}] */ module.exports = (options) => new BoschEbike(options); } else { // otherwise start the instance directly new BoschEbike(); }