diff --git a/.github/workflows/validation.yml b/.github/workflows/validation.yml index c3f2c5c..0f82cca 100644 --- a/.github/workflows/validation.yml +++ b/.github/workflows/validation.yml @@ -71,6 +71,5 @@ jobs: cache: "npm" - name: Install Dependencies run: npm clean-install - - name: Validate JS Sources + - name: Execute Unit Tests run: npm run test - diff --git a/.gitignore b/.gitignore index 3f1bc52..eb83b8b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ # Ignore all node modules. node_modules report +.env +.DS_Store diff --git a/README.md b/README.md index ac387bf..267e4b2 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ cd netatmo && npm ci --production --ignore-scripts ### Connection to Netatmo Service API -To be able to access your data, you need to have an Netatmo Application. Create your personal app in the [Netatmo developer portal][dev-portal] and you will get an `APP_ID` and an `APP_SECRET` which you will need to enter in your [mirror configuration](#configuration). +To be able to access your data, you need to have an Netatmo Application. Create your personal app in the [Netatmo developer portal][dev-portal] and you will get an `APP_ID` and an `APP_SECRET` which you will need to enter in your [mirror configuration](#configuration). On the same page, scroll to *Token Generator* and create a token with the `read_station` scope. During that process you will grant your previously created Netatmo app access to your Netatmo weather station. You will actually not need the `access_token`, but the `refresh_token`. This will also go into your mirror configuration. #### Sample Data @@ -55,8 +55,7 @@ To run the module properly, you need to add the following data to your config.js config: { clientId: '', // your app id clientSecret: '', // your app secret - username: '', // your netatmo username - password: '', // your netatmo password + refresh_token: '', // your generated refresh token } } ``` @@ -69,8 +68,7 @@ The following properties can be configured: |---|---|---|---| |`clientId`|The ID of your Netatmo [application][dev-portal].||yes| |`clientSecret`|The app secret of your Netatmo [application][dev-portal].||yes| -|`username`|Username for your Netatmo weather station.||yes| -|`password`|Password for your Netatmo weather station.||yes| +|`refresh_token`|Generated refresh token for your Netatmo app and Netatmo instance.||yes| |`refreshInterval`|How often does the content needs to be updated (minutes)? Data is updated by netatmo every 10 minutes|`3`|no| |`moduleOrder`|The rendering order of your weather modules, ommit a module to hide the output. **Example:** `["Kitchen","Kid's Bedroom","Garage","Garden"]` Be aware that you need to use the module names that you set in the netatmo configuration.||no| |`dataOrder`|The rendering order of the data types of a module, ommit a data type to hide the output. **Example:** `["Noise","Pressure","CO2","Humidity","Temperature","Rain"]`||no| diff --git a/helper.js b/helper.js new file mode 100644 index 0000000..19be9f2 --- /dev/null +++ b/helper.js @@ -0,0 +1,132 @@ +/* Magic Mirror + * Module: MagicMirror-Netatmo-Module + * + * By Christopher Fenner https://github.com/CFenner + * MIT Licensed. + */ +const fs = require('fs') +const path = require('path') +const fetch = require('sync-fetch') +const URLSearchParams = require('@ungap/url-search-params') + +module.exports = { + notifications: { + AUTH: 'NETATMO_AUTH', + AUTH_RESPONSE: 'NETATMO_AUTH_RESPONSE', + DATA: 'NETATMO_DATA', + DATA_RESPONSE: 'NETATMO_DATA_RESPONSE', + }, + start: function () { + console.log('Netatmo helper started ...') + this.token = null + }, + authenticate: function (config) { + const self = this + self.config = config + + const params = new URLSearchParams() + params.append('grant_type', 'refresh_token') + params.append('refresh_token', self.refresh_token || self.config.refresh_token) + params.append('client_id', self.config.clientId) + params.append('client_secret', self.config.clientSecret) + + try { + const result = fetch('https://' + self.config.apiBase + self.config.authEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params, + }).json() + + if (result.error) { + throw new Error(result.error + ': ' + result.error_description) + } + + console.log('UPDATING TOKEN ' + result.access_token) + self.token = result.access_token + self.token_expires_in = result.expires_in + self.refresh_token = result.refresh_token + // we got a new token, save it to main file to allow it to request the datas + self.sendSocketNotification(self.notifications.AUTH_RESPONSE, { + status: 'OK', + }) + } catch (error) { + console.log('error:', error) + self.sendSocketNotification(self.notifications.AUTH_RESPONSE, { + payloadReturn: error, + status: 'NOTOK', + message: error, + }) + } + }, + loadData: function (config) { + const self = this + self.config = config + + if (self.config.mockData === true) { + self.sendSocketNotification(self.notifications.DATA_RESPONSE, { + payloadReturn: this.mockData(), + status: 'OK', + }) + return + } + if (self.token === null || self.token === undefined) { + self.sendSocketNotification(self.notifications.DATA_RESPONSE, { + payloadReturn: 400, + status: 'INVALID_TOKEN', + message: 'token not set', + }) + return + } + + try { + let result = fetch('https://' + self.config.apiBase + self.config.dataEndpoint, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${self.token}`, + }, + }) + + if (result.status === 403) { + console.log('status code:', result.status, '\n', result.statusText) + self.sendSocketNotification(self.notifications.DATA_RESPONSE, { + payloadReturn: result.statusText, + status: 'INVALID_TOKEN', + message: result, + }) + return + } + + result = result.json() + + if (result.error) { + throw new Error(result.error.message) + } + + self.sendSocketNotification(self.notifications.DATA_RESPONSE, { + payloadReturn: result.body.devices, + status: 'OK', + }) + } catch (error) { + console.log('error:', error) + self.sendSocketNotification(self.notifications.DATA_RESPONSE, { + payloadReturn: error, + status: 'NOTOK', + message: error, + }) + } + }, + mockData: function () { + const sample = fs.readFileSync(path.join(__dirname, 'sample', 'sample.json'), 'utf8') + return JSON.parse(sample) + }, + socketNotificationReceived: function (notification, payload) { + switch (notification) { + case this.notifications.AUTH: + this.authenticate(payload) + break + case this.notifications.DATA: + this.loadData(payload) + break + } + }, +} diff --git a/helper.test.js b/helper.test.js new file mode 100644 index 0000000..cd21f44 --- /dev/null +++ b/helper.test.js @@ -0,0 +1,151 @@ +const moduleUnderTest = require('./helper.js') + +const netatmo = require('./module.js') + +require('dotenv').config() + +describe('helper', () => { + beforeEach(() => { + }) + + afterEach(() => { + delete moduleUnderTest.token + delete moduleUnderTest.token_expires_in + delete moduleUnderTest.refresh_token + delete moduleUnderTest.sendSocketNotification + }) + describe('data', () => { + test('existing token', () => { + moduleUnderTest.token = process.env.TOKEN + // prepare + moduleUnderTest.sendSocketNotification = jest.fn((type, payload) => { + expect(type).toBe(moduleUnderTest.notifications.DATA_RESPONSE) + expect(payload).toHaveProperty('status', 'OK') + expect(payload).toHaveProperty('payloadReturn') + expect(payload.payloadReturn).toHaveLength(2) + }) + expect(moduleUnderTest).toHaveProperty('token') + // test + moduleUnderTest.loadData({ + apiBase: netatmo.defaults.apiBase, + dataEndpoint: netatmo.defaults.dataEndpoint, + }) + // assert + expect(moduleUnderTest.sendSocketNotification).toHaveBeenCalled() + }) + + test('missing token', () => { + // moduleUnderTest.token = process.env.TOKEN + // prepare + moduleUnderTest.sendSocketNotification = jest.fn((type, payload) => { + expect(type).toBe(moduleUnderTest.notifications.DATA_RESPONSE) + expect(payload).toHaveProperty('status', 'INVALID_TOKEN') + }) + // test + moduleUnderTest.loadData({ + apiBase: netatmo.defaults.apiBase, + dataEndpoint: netatmo.defaults.dataEndpoint, + }) + // assert + expect(moduleUnderTest.sendSocketNotification).toHaveBeenCalled() + }) + + test('invalid token', () => { + moduleUnderTest.token = 'something' + // prepare + moduleUnderTest.sendSocketNotification = jest.fn((type, payload) => { + expect(type).toBe(moduleUnderTest.notifications.DATA_RESPONSE) + expect(payload).toHaveProperty('status', 'INVALID_TOKEN') + }) + // test + moduleUnderTest.loadData({ + apiBase: netatmo.defaults.apiBase, + dataEndpoint: netatmo.defaults.dataEndpoint, + }) + // assert + expect(moduleUnderTest.sendSocketNotification).toHaveBeenCalled() + }) + }) + + describe('authentication', () => { + test('verify notifications map', () => { + expect(moduleUnderTest.notifications).toHaveProperty('AUTH', 'NETATMO_AUTH') + expect(moduleUnderTest.notifications).toHaveProperty('AUTH_RESPONSE', 'NETATMO_AUTH_RESPONSE') + expect(moduleUnderTest.notifications).toHaveProperty('DATA', 'NETATMO_DATA') + expect(moduleUnderTest.notifications).toHaveProperty('DATA_RESPONSE', 'NETATMO_DATA_RESPONSE') + }) + + test('with refresh_token from config', () => { + // prepare + moduleUnderTest.sendSocketNotification = jest.fn((type, payload) => { + expect(type).toBe(moduleUnderTest.notifications.AUTH_RESPONSE) + expect(payload).toHaveProperty('status', 'OK') + }) + expect(moduleUnderTest).not.toHaveProperty('token') + expect(moduleUnderTest).not.toHaveProperty('token_expires_in') + expect(moduleUnderTest).not.toHaveProperty('refresh_token') + // test + moduleUnderTest.authenticate({ + apiBase: netatmo.defaults.apiBase, + authEndpoint: netatmo.defaults.authEndpoint, + refresh_token: process.env.REFRESH_TOKEN, + clientId: process.env.CLIENT_ID, + clientSecret: process.env.CLIENT_SECRET, + }) + // assert + expect(moduleUnderTest).toHaveProperty('token') + expect(moduleUnderTest).toHaveProperty('token_expires_in') + expect(moduleUnderTest).toHaveProperty('refresh_token') + expect(moduleUnderTest.sendSocketNotification).toHaveBeenCalled() + }) + + test('with refresh_token from object', () => { + // prepare + moduleUnderTest.refresh_token = process.env.REFRESH_TOKEN + moduleUnderTest.sendSocketNotification = jest.fn((type, payload) => { + expect(type).toBe(moduleUnderTest.notifications.AUTH_RESPONSE) + expect(payload).toHaveProperty('status', 'OK') + }) + expect(moduleUnderTest).not.toHaveProperty('token') + expect(moduleUnderTest).not.toHaveProperty('token_expires_in') + expect(moduleUnderTest).toHaveProperty('refresh_token') + // test + moduleUnderTest.authenticate({ + apiBase: netatmo.defaults.apiBase, + authEndpoint: netatmo.defaults.authEndpoint, + refresh_token: '', + clientId: process.env.CLIENT_ID, + clientSecret: process.env.CLIENT_SECRET, + }) + // assert + expect(moduleUnderTest).toHaveProperty('token') + expect(moduleUnderTest).toHaveProperty('token_expires_in') + expect(moduleUnderTest).toHaveProperty('refresh_token') + expect(moduleUnderTest.sendSocketNotification).toHaveBeenCalled() + }) + + test('test authenticate fail', () => { + // prepare + moduleUnderTest.sendSocketNotification = jest.fn((type, payload) => { + expect(type).toBe(moduleUnderTest.notifications.AUTH_RESPONSE) + expect(payload).toHaveProperty('status', 'NOTOK') + }) + expect(moduleUnderTest).not.toHaveProperty('token') + expect(moduleUnderTest).not.toHaveProperty('token_expires_in') + expect(moduleUnderTest).not.toHaveProperty('refresh_token') + // test + moduleUnderTest.authenticate({ + apiBase: netatmo.defaults.apiBase, + authEndpoint: netatmo.defaults.authEndpoint, + refresh_token: '', + clientId: process.env.CLIENT_ID, + clientSecret: process.env.CLIENT_SECRET, + }) + // assert + expect(moduleUnderTest).not.toHaveProperty('token') + expect(moduleUnderTest).not.toHaveProperty('token_expires_in') + expect(moduleUnderTest).not.toHaveProperty('refresh_token') + expect(moduleUnderTest.sendSocketNotification).toHaveBeenCalled() + }) + }) +}) diff --git a/module.js b/module.js new file mode 100755 index 0000000..b0f9b93 --- /dev/null +++ b/module.js @@ -0,0 +1,458 @@ +/* Magic Mirror + * Module: Netatmo + * + * By Christopher Fenner http://github.com/CFenner + * MIT Licensed. + */ +/* global Log */ +module.exports = { + // default config + defaults: { + initialDelay: 0, + updateInterval: 3, // every 3 minutes, refresh interval on netatmo is 10 minutes + animationSpeed: 1000, + design: 'classic', // or bubbles + horizontal: true, + lastMessageThreshold: 600, // in seconds (10 minutes) + showLastMessage: true, + showBattery: true, + showRadio: true, + showWiFi: true, + showTrend: true, + showMeasurementIcon: true, + showMeasurementLabel: true, + showStationName: true, + showModuleNameOnTop: false, + apiBase: 'api.netatmo.com', + authEndpoint: '/oauth2/token', + dataEndpoint: '/api/getstationsdata', + fontClassModuleName: 'xsmall', + fontClassPrimary: 'large', + fontClassSecondary: 'xsmall', + fontClassMeasurement: 'xsmall', + thresholdCO2Average: 800, + thresholdCO2Bad: 1800, + mockData: false, + }, + notifications: { + AUTH: 'NETATMO_AUTH', + AUTH_RESPONSE: 'NETATMO_AUTH_RESPONSE', + DATA: 'NETATMO_DATA', + DATA_RESPONSE: 'NETATMO_DATA_RESPONSE', + }, + moduleType: { + MAIN: 'NAMain', + INDOOR: 'NAModule4', + OUTDOOR: 'NAModule1', + RAIN: 'NAModule3', + WIND: 'NAModule2', + }, + measurement: { + CO2: 'CO2', + HUMIDITY: 'Humidity', + TEMPERATURE: 'Temperature', + TEMPERATURE_TREND: 'temp_trend', + PRESSURE: 'Pressure', + PRESSURE_TREND: 'pressure_trend', + NOISE: 'Noise', + WIND_STRENGTH: 'WindStrength', + WIND_ANGLE: 'WindAngle', + GUST_STRENGTH: 'GustStrength', + GUST_ANGLE: 'GustAngle', + RAIN: 'Rain', + RAIN_PER_HOUR: 'sum_rain_1', + RAIN_PER_DAY: 'sum_rain_24', + }, + // init method + start: function () { + const self = this + Log.info(`Starting module: ${this.name}`) + self.loaded = false + self.moduleList = [] + + // get a new token at start-up. When receive, GET_CAMERA_EVENTS will be requested + setTimeout(function () { + self.sendSocketNotification(self.notifications.DATA, self.config) + }, this.config.initialDelay * 1000) + + // set auto-update + setInterval(function () { + // request directly the data, with the previous token. When the token will become invalid (error 403), it will be requested again + self.sendSocketNotification(self.notifications.DATA, self.config) + }, this.config.updateInterval * 60 * 1000 + this.config.initialDelay * 1000) + }, + updateModuleList: function (stationList) { + let moduleList = [] + + for (const station of stationList) { + moduleList.push(this.getModule(station, station.home_name)) + + station.modules.forEach(function (module) { + moduleList.push(this.getModule(module, station.home_name)) + }.bind(this)) + + if (station.reachable) { this.lastUpdate = station.dashboard_data.time_utc } + } + this.loaded = true + if (JSON.stringify(this.moduleList) === JSON.stringify(moduleList)) { + return + } + // reorder modules + if (this.config.moduleOrder && this.config.moduleOrder.length > 0) { + const reorderedModuleList = [] + for (const moduleName of this.config.moduleOrder) { + for (const module of moduleList) { + if (module.name === moduleName) { + reorderedModuleList.push(module) + } + } + } + moduleList = reorderedModuleList + } + this.moduleList = moduleList + }, + getModule: function (module, stationName) { + const result = {} + + result.name = module.module_name + if (this.config.showStationName) { + result.name = `${stationName} - ${result.name}` + } + result.measurementList = [] + + if (!module.reachable) { + let measurement = '' + if (module.type === this.moduleType.MAIN) { + measurement = 'wifi' + } else { + measurement = 'radio' + } + + result.measurementList.push({ + name: measurement, + value: this.getValue(measurement, 0), + unit: this.getUnit(measurement), + icon: this.getIcon(measurement, 0) + ' flash red', + label: this.translate(measurement.toUpperCase()), + }) + + return result + } + + // TODO check module.reachable + let primaryType = '' + let primaryValue = '' + let secondaryType = '' + let secondaryValue = '' + + // add module sensor measurements + switch (module.type) { + case this.moduleType.MAIN: + result.measurementList.push(this.getMeasurement(module, this.measurement.PRESSURE)) + if (this.config.showTrend) { result.measurementList.push(this.getMeasurement(module, this.measurement.PRESSURE_TREND)) } + result.measurementList.push(this.getMeasurement(module, this.measurement.NOISE)) + // break; fallthrough + case this.moduleType.INDOOR: + if (this.config.design === 'bubbles') { + secondaryType = this.measurement.CO2 + secondaryValue = module.dashboard_data[secondaryType] + result.secondary = { + value: this.getValue(secondaryType, secondaryValue), + unit: this.getUnit(secondaryType), + class: this.kebabCase(secondaryType), + visualClass: this.getCO2Status(secondaryValue), + } + } else { + result.measurementList.push(this.getMeasurement(module, this.measurement.CO2)) + } + // break; fallthrough + case this.moduleType.OUTDOOR: + if (this.config.design === 'bubbles') { + primaryType = this.measurement.TEMPERATURE + primaryValue = module.dashboard_data ? module.dashboard_data[primaryType] : '' + result.primary = { + value: this.getValue(primaryType, primaryValue), + unit: this.getUnit(primaryType), + class: this.kebabCase(primaryType), + } + } else { + result.measurementList.push(this.getMeasurement(module, this.measurement.TEMPERATURE)) + } + if (this.config.showTrend) { result.measurementList.push(this.getMeasurement(module, this.measurement.TEMPERATURE_TREND)) } + result.measurementList.push(this.getMeasurement(module, this.measurement.HUMIDITY)) + break + case this.moduleType.WIND: + if (this.config.design === 'bubbles') { + primaryType = this.measurement.WIND_STRENGTH + primaryValue = module.dashboard_data ? module.dashboard_data[primaryType] : '' + result.primary = { + value: this.getValue(primaryType, primaryValue), + unit: this.getUnit(primaryType), + class: this.kebabCase(primaryType), + } + secondaryType = this.measurement.WIND_ANGLE + secondaryValue = module.dashboard_data[secondaryType] + result.secondary = { + value: this.getValue(secondaryType, secondaryValue), + unit: this.getUnit(secondaryType), + class: this.kebabCase(secondaryType), + visualClass: 'xlarge wi wi-direction-up', + } + } else { + result.measurementList.push(this.getMeasurement(module, this.measurement.WIND_STRENGTH)) + result.measurementList.push(this.getMeasurement(module, this.measurement.WIND_ANGLE)) + } + // $('
').addClass('visual xlarge wi wi-direction-up').css('transform', 'rotate(' + value + 'deg)') + result.measurementList.push(this.getMeasurement(module, this.measurement.GUST_STRENGTH)) + result.measurementList.push(this.getMeasurement(module, this.measurement.GUST_ANGLE)) + break + case this.moduleType.RAIN: + if (this.config.design === 'bubbles') { + primaryType = this.measurement.RAIN + primaryValue = module.dashboard_data ? module.dashboard_data[primaryType] : '' + result.primary = { + value: this.getValue(primaryType, primaryValue), + unit: this.getUnit(primaryType), + class: this.kebabCase(primaryType), + } + } else { + result.measurementList.push(this.getMeasurement(module, this.measurement.RAIN)) + } + result.measurementList.push(this.getMeasurement(module, this.measurement.RAIN_PER_HOUR)) + result.measurementList.push(this.getMeasurement(module, this.measurement.RAIN_PER_DAY)) + break + default: + break + } + // add module specific measurements + if (module.type === this.moduleType.MAIN) { + if (this.config.showWiFi) { result.measurementList.push(this.getMeasurement(module, 'wifi', module.wifi_status)) } + } else { + if (this.config.showRadio) { result.measurementList.push(this.getMeasurement(module, 'radio', module.rf_status)) } + if (this.config.showBattery) { result.measurementList.push(this.getMeasurement(module, 'battery', module.battery_percent)) } + } + // reorder measurements + if (this.config.dataOrder && this.config.dataOrder.length > 0) { + const reorderedMeasurementList = [] + for (const measurementName of this.config.dataOrder) { + for (const measurement of result.measurementList) { + if (measurement.name === measurementName) { + reorderedMeasurementList.push(measurement) + } + } + } + result.measurementList = reorderedMeasurementList + } + return result + }, + getMeasurement: function (module, measurement, value) { + value = value || module.dashboard_data[measurement] + if (measurement === this.measurement.TEMPERATURE_TREND || measurement === this.measurement.PRESSURE_TREND) { + value = value || 'undefined' + } + return { + name: measurement, + value: this.getValue(measurement, value), + unit: this.getUnit(measurement), + icon: this.getIcon(measurement, value), + label: this.translate(measurement.toUpperCase()), + } + }, + kebabCase: function (name) { + return name.replace(/([a-z])([A-Z])/g, '$1-$2') + .replace(/[\s_]+/g, '-') + .toLowerCase() + }, + getValue: function (measurement, value) { + if (!value) { return value } + switch (measurement) { + case this.measurement.CO2: + return value.toFixed(0)// + ' ppm' + case this.measurement.NOISE: + return value.toFixed(0)// + ' dB' + case this.measurement.HUMIDITY: + case 'battery': + case 'wifi': + case 'radio': + return value.toFixed(0)// + '%' + case this.measurement.PRESSURE: + return value.toFixed(0)// + ' mbar' + case this.measurement.TEMPERATURE: + return value.toFixed(1)// + '°C' + case this.measurement.RAIN: + case this.measurement.RAIN_PER_HOUR: + case this.measurement.RAIN_PER_DAY: + return value.toFixed(1)// + ' mm/h' + case this.measurement.WIND_STRENGTH: + case this.measurement.GUST_STRENGTH: + return value.toFixed(0)// + ' m/s' + case this.measurement.WIND_ANGLE: + case this.measurement.GUST_ANGLE: + return this.getDirection(value) + ' | ' + value// + '°' + case this.measurement.TEMPERATURE_TREND: + case this.measurement.PRESSURE_TREND: + return this.translate(value.toUpperCase()) + default: + return value + } + }, + getUnit: function (measurement) { + switch (measurement) { + case this.measurement.CO2: + return 'ppm' + case this.measurement.NOISE: + return 'dB' + case this.measurement.HUMIDITY: + case 'battery': + case 'wifi': + case 'radio': + return '%' + case this.measurement.PRESSURE: + return 'mbar' + case this.measurement.TEMPERATURE: + return '°C' + case this.measurement.RAIN: + case this.measurement.RAIN_PER_HOUR: + case this.measurement.RAIN_PER_DAY: + return 'mm/h' + case this.measurement.WIND_STRENGTH: + case this.measurement.GUST_STRENGTH: + return 'm/s' + case this.measurement.WIND_ANGLE: + case this.measurement.GUST_ANGLE: + return '°' + default: + return '' + } + }, + getDirection: function (value) { + if (value < 11.25) return 'N' + if (value < 33.75) return 'NNE' + if (value < 56.25) return 'NE' + if (value < 78.75) return 'ENE' + if (value < 101.25) return 'E' + if (value < 123.75) return 'ESE' + if (value < 146.25) return 'SE' + if (value < 168.75) return 'SSE' + if (value < 191.25) return 'S' + if (value < 213.75) return 'SSW' + if (value < 236.25) return 'SW' + if (value < 258.75) return 'WSW' + if (value < 281.25) return 'W' + if (value < 303.75) return 'WNW' + if (value < 326.25) return 'NW' + if (value < 348.75) return 'NNW' + return 'N' + }, + getCO2Status: function (value) { + if (!value || value === 'undefined' || value < 0) return 'undefined' + if (value >= this.config.thresholdCO2Bad) return 'bad' + if (value >= this.config.thresholdCO2Average) return 'average' + return 'good' + }, + getIcon: function (dataType, value) { + switch (dataType) { + // case this.measurement.CO2: + // return 'fa-lungs' + case this.measurement.NOISE: + return 'fa-volume-up' + case this.measurement.HUMIDITY: + return 'fa-tint' + case this.measurement.PRESSURE: + return 'fa-tachometer-alt' + case this.measurement.GUST_STRENGTH: + case this.measurement.WIND_STRENGTH: + return 'fa-wind' + // case this.measurement.GUST_ANGLE: + // case this.measurement.WIND_ANGLE: + case this.measurement.PRESSURE_TREND: + case this.measurement.TEMPERATURE_TREND: + return this.getTrendIcon(value) + case 'wifi': + return 'fa-wifi' + case 'radio': + return 'fa-broadcast-tower' + case 'battery': + return this.getBatteryIcon(value) + default: + return '' + } + }, + getTrendIcon: function (value) { + if (value === 'stable') return 'fa-chevron-circle-right' + if (value === 'down') return 'fa-chevron-circle-down' + if (value === 'up') return 'fa-chevron-circle-up' + if (value === 'undefined') return 'fa-times-circle' + }, + getBatteryIcon: function (value) { + if (value > 80) return 'fa-battery-full' + if (value > 60) return 'fa-battery-three-quarters' + if (value > 40) return 'fa-battery-half' + if (value > 20) return 'fa-battery-quarter' + return 'fa-battery-empty flash red' + }, + getStyles: function () { + return [`${this.name}.${this.config.design}.css`] + }, + getTemplate: function () { + return `${this.name}.${this.config.design}.njk` + }, + getTemplateData: function () { + return { + loaded: this.loaded, + showLastMessage: this.config.showLastMessage, + showBattery: this.config.showBattery, + showRadio: this.config.showRadio, + showWiFi: this.config.showWiFi, + showTrend: this.config.showTrend, + showMeasurementIcon: this.config.showMeasurementIcon, + showMeasurementLabel: this.config.showMeasurementLabel, + showModuleNameOnTop: this.config.showModuleNameOnTop, + horizontal: this.config.horizontal, + moduleList: this.moduleList, + fontClassModuleName: this.config.fontClassModuleName, + fontClassPrimary: this.config.fontClassPrimary, + fontClassSecondary: this.config.fontClassSecondary, + fontClassMeasurement: this.config.fontClassMeasurement, + labelLoading: this.translate('LOADING'), + } + }, + getTranslations: function () { + return { + cs: 'l10n/cs.json', + de: 'l10n/de.json', + en: 'l10n/en.json', + fr: 'l10n/fr.json', + hu: 'l10n/hu.json', + nb: 'l10n/nb.json', + nn: 'l10n/nn.json', + } + }, + socketNotificationReceived: function (notification, payload) { + const self = this + Log.debug('received ' + notification) + switch (notification) { + case self.notifications.AUTH_RESPONSE: + if (payload.status === 'OK') { + self.sendSocketNotification(self.notifications.DATA, self.config) + } else { + console.log('AUTH FAILED ' + payload.message) + } + break + case self.notifications.DATA_RESPONSE: + if (payload.status === 'OK') { + console.log('Devices %o', payload.payloadReturn) + const stationList = payload.payloadReturn + self.updateModuleList(stationList) + self.updateDom(self.config.animationSpeed) + } else if (payload.status === 'INVALID_TOKEN') { + // node_module has no valid token, reauthenticate + console.log('DATA FAILED, refreshing token') + self.sendSocketNotification(self.notifications.AUTH, self.config) + } else { + console.log('DATA FAILED ' + payload.message) + } + break + } + }, +} diff --git a/netatmo.js b/netatmo.js index 81247fa..d9aad60 100755 --- a/netatmo.js +++ b/netatmo.js @@ -4,455 +4,7 @@ * By Christopher Fenner http://github.com/CFenner * MIT Licensed. */ -/* global Module, Log */ -Module.register('netatmo', { - // default config - defaults: { - initialDelay: 0, - updateInterval: 3, // every 3 minutes, refresh interval on netatmo is 10 minutes - animationSpeed: 1000, - design: 'classic', // or bubbles - horizontal: true, - lastMessageThreshold: 600, // in seconds (10 minutes) - showLastMessage: true, - showBattery: true, - showRadio: true, - showWiFi: true, - showTrend: true, - showMeasurementIcon: true, - showMeasurementLabel: true, - showStationName: true, - showModuleNameOnTop: false, - apiBase: 'api.netatmo.com', - authEndpoint: '/oauth2/token', - dataEndpoint: '/api/getstationsdata', - fontClassModuleName: 'xsmall', - fontClassPrimary: 'large', - fontClassSecondary: 'xsmall', - fontClassMeasurement: 'xsmall', - thresholdCO2Average: 800, - thresholdCO2Bad: 1800, - mockData: false, - }, - notifications: { - AUTH: 'NETATMO_AUTH', - AUTH_RESPONSE: 'NETATMO_AUTH_RESPONSE', - DATA: 'NETATMO_DATA', - DATA_RESPONSE: 'NETATMO_DATA_RESPONSE', - }, - moduleType: { - MAIN: 'NAMain', - INDOOR: 'NAModule4', - OUTDOOR: 'NAModule1', - RAIN: 'NAModule3', - WIND: 'NAModule2', - }, - measurement: { - CO2: 'CO2', - HUMIDITY: 'Humidity', - TEMPERATURE: 'Temperature', - TEMPERATURE_TREND: 'temp_trend', - PRESSURE: 'Pressure', - PRESSURE_TREND: 'pressure_trend', - NOISE: 'Noise', - WIND_STRENGTH: 'WindStrength', - WIND_ANGLE: 'WindAngle', - GUST_STRENGTH: 'GustStrength', - GUST_ANGLE: 'GustAngle', - RAIN: 'Rain', - RAIN_PER_HOUR: 'sum_rain_1', - RAIN_PER_DAY: 'sum_rain_24', - }, - // init method - start: function () { - const self = this - Log.info(`Starting module: ${this.name}`) - self.loaded = false - self.moduleList = [] +const module = require('./module') - // get a new token at start-up. When receive, GET_CAMERA_EVENTS will be requested - setTimeout(function () { - self.sendSocketNotification(self.notifications.DATA, self.config) - }, this.config.initialDelay * 1000) - - // set auto-update - setInterval(function () { - // request directly the data, with the previous token. When the token will become invalid (error 403), it will be requested again - self.sendSocketNotification(self.notifications.DATA, self.config) - }, this.config.updateInterval * 60 * 1000 + this.config.initialDelay * 1000) - }, - updateModuleList: function (stationList) { - let moduleList = [] - - for (const station of stationList) { - moduleList.push(this.getModule(station, station.home_name)) - - station.modules.forEach(function (module) { - moduleList.push(this.getModule(module, station.home_name)) - }.bind(this)) - - if (station.reachable) { this.lastUpdate = station.dashboard_data.time_utc } - } - this.loaded = true - if (JSON.stringify(this.moduleList) === JSON.stringify(moduleList)) { - return - } - // reorder modules - if (this.config.moduleOrder && this.config.moduleOrder.length > 0) { - const reorderedModuleList = [] - for (const moduleName of this.config.moduleOrder) { - for (const module of moduleList) { - if (module.name === moduleName) { - reorderedModuleList.push(module) - } - } - } - moduleList = reorderedModuleList - } - this.moduleList = moduleList - }, - getModule: function (module, stationName) { - const result = {} - - result.name = module.module_name - if (this.config.showStationName) { - result.name = `${stationName} - ${result.name}` - } - result.measurementList = [] - - if (!module.reachable) { - let measurement = '' - if (module.type === this.moduleType.MAIN) { - measurement = 'wifi' - } else { - measurement = 'radio' - } - - result.measurementList.push({ - name: measurement, - value: this.getValue(measurement, 0), - unit: this.getUnit(measurement), - icon: this.getIcon(measurement, 0) + ' flash red', - label: this.translate(measurement.toUpperCase()), - }) - - return result - } - - // TODO check module.reachable - let primaryType = '' - let primaryValue = '' - let secondaryType = '' - let secondaryValue = '' - - // add module sensor measurements - switch (module.type) { - case this.moduleType.MAIN: - result.measurementList.push(this.getMeasurement(module, this.measurement.PRESSURE)) - if (this.config.showTrend) { result.measurementList.push(this.getMeasurement(module, this.measurement.PRESSURE_TREND)) } - result.measurementList.push(this.getMeasurement(module, this.measurement.NOISE)) - // break; fallthrough - case this.moduleType.INDOOR: - if (this.config.design === 'bubbles') { - secondaryType = this.measurement.CO2 - secondaryValue = module.dashboard_data[secondaryType] - result.secondary = { - value: this.getValue(secondaryType, secondaryValue), - unit: this.getUnit(secondaryType), - class: this.kebabCase(secondaryType), - visualClass: this.getCO2Status(secondaryValue), - } - } else { - result.measurementList.push(this.getMeasurement(module, this.measurement.CO2)) - } - // break; fallthrough - case this.moduleType.OUTDOOR: - if (this.config.design === 'bubbles') { - primaryType = this.measurement.TEMPERATURE - primaryValue = module.dashboard_data ? module.dashboard_data[primaryType] : '' - result.primary = { - value: this.getValue(primaryType, primaryValue), - unit: this.getUnit(primaryType), - class: this.kebabCase(primaryType), - } - } else { - result.measurementList.push(this.getMeasurement(module, this.measurement.TEMPERATURE)) - } - if (this.config.showTrend) { result.measurementList.push(this.getMeasurement(module, this.measurement.TEMPERATURE_TREND)) } - result.measurementList.push(this.getMeasurement(module, this.measurement.HUMIDITY)) - break - case this.moduleType.WIND: - if (this.config.design === 'bubbles') { - primaryType = this.measurement.WIND_STRENGTH - primaryValue = module.dashboard_data ? module.dashboard_data[primaryType] : '' - result.primary = { - value: this.getValue(primaryType, primaryValue), - unit: this.getUnit(primaryType), - class: this.kebabCase(primaryType), - } - secondaryType = this.measurement.WIND_ANGLE - secondaryValue = module.dashboard_data[secondaryType] - result.secondary = { - value: this.getValue(secondaryType, secondaryValue), - unit: this.getUnit(secondaryType), - class: this.kebabCase(secondaryType), - visualClass: 'xlarge wi wi-direction-up', - } - } else { - result.measurementList.push(this.getMeasurement(module, this.measurement.WIND_STRENGTH)) - result.measurementList.push(this.getMeasurement(module, this.measurement.WIND_ANGLE)) - } - // $('
').addClass('visual xlarge wi wi-direction-up').css('transform', 'rotate(' + value + 'deg)') - result.measurementList.push(this.getMeasurement(module, this.measurement.GUST_STRENGTH)) - result.measurementList.push(this.getMeasurement(module, this.measurement.GUST_ANGLE)) - break - case this.moduleType.RAIN: - if (this.config.design === 'bubbles') { - primaryType = this.measurement.RAIN - primaryValue = module.dashboard_data ? module.dashboard_data[primaryType] : '' - result.primary = { - value: this.getValue(primaryType, primaryValue), - unit: this.getUnit(primaryType), - class: this.kebabCase(primaryType), - } - } else { - result.measurementList.push(this.getMeasurement(module, this.measurement.RAIN)) - } - result.measurementList.push(this.getMeasurement(module, this.measurement.RAIN_PER_HOUR)) - result.measurementList.push(this.getMeasurement(module, this.measurement.RAIN_PER_DAY)) - break - default: - break - } - // add module specific measurements - if (module.type === this.moduleType.MAIN) { - if (this.config.showWiFi) { result.measurementList.push(this.getMeasurement(module, 'wifi', module.wifi_status)) } - } else { - if (this.config.showRadio) { result.measurementList.push(this.getMeasurement(module, 'radio', module.rf_status)) } - if (this.config.showBattery) { result.measurementList.push(this.getMeasurement(module, 'battery', module.battery_percent)) } - } - // reorder measurements - if (this.config.dataOrder && this.config.dataOrder.length > 0) { - const reorderedMeasurementList = [] - for (const measurementName of this.config.dataOrder) { - for (const measurement of result.measurementList) { - if (measurement.name === measurementName) { - reorderedMeasurementList.push(measurement) - } - } - } - result.measurementList = reorderedMeasurementList - } - return result - }, - getMeasurement: function (module, measurement, value) { - value = value || module.dashboard_data[measurement] - if (measurement === this.measurement.TEMPERATURE_TREND || measurement === this.measurement.PRESSURE_TREND) { - value = value || 'undefined' - } - return { - name: measurement, - value: this.getValue(measurement, value), - unit: this.getUnit(measurement), - icon: this.getIcon(measurement, value), - label: this.translate(measurement.toUpperCase()), - } - }, - kebabCase: function (name) { - return name.replace(/([a-z])([A-Z])/g, '$1-$2') - .replace(/[\s_]+/g, '-') - .toLowerCase() - }, - getValue: function (measurement, value) { - if (!value) { return value } - switch (measurement) { - case this.measurement.CO2: - return value.toFixed(0)// + ' ppm' - case this.measurement.NOISE: - return value.toFixed(0)// + ' dB' - case this.measurement.HUMIDITY: - case 'battery': - case 'wifi': - case 'radio': - return value.toFixed(0)// + '%' - case this.measurement.PRESSURE: - return value.toFixed(0)// + ' mbar' - case this.measurement.TEMPERATURE: - return value.toFixed(1)// + '°C' - case this.measurement.RAIN: - case this.measurement.RAIN_PER_HOUR: - case this.measurement.RAIN_PER_DAY: - return value.toFixed(1)// + ' mm/h' - case this.measurement.WIND_STRENGTH: - case this.measurement.GUST_STRENGTH: - return value.toFixed(0)// + ' m/s' - case this.measurement.WIND_ANGLE: - case this.measurement.GUST_ANGLE: - return this.getDirection(value) + ' | ' + value// + '°' - case this.measurement.TEMPERATURE_TREND: - case this.measurement.PRESSURE_TREND: - return this.translate(value.toUpperCase()) - default: - return value - } - }, - getUnit: function (measurement) { - switch (measurement) { - case this.measurement.CO2: - return 'ppm' - case this.measurement.NOISE: - return 'dB' - case this.measurement.HUMIDITY: - case 'battery': - case 'wifi': - case 'radio': - return '%' - case this.measurement.PRESSURE: - return 'mbar' - case this.measurement.TEMPERATURE: - return '°C' - case this.measurement.RAIN: - case this.measurement.RAIN_PER_HOUR: - case this.measurement.RAIN_PER_DAY: - return 'mm/h' - case this.measurement.WIND_STRENGTH: - case this.measurement.GUST_STRENGTH: - return 'm/s' - case this.measurement.WIND_ANGLE: - case this.measurement.GUST_ANGLE: - return '°' - default: - return '' - } - }, - getDirection: function (value) { - if (value < 11.25) return 'N' - if (value < 33.75) return 'NNE' - if (value < 56.25) return 'NE' - if (value < 78.75) return 'ENE' - if (value < 101.25) return 'E' - if (value < 123.75) return 'ESE' - if (value < 146.25) return 'SE' - if (value < 168.75) return 'SSE' - if (value < 191.25) return 'S' - if (value < 213.75) return 'SSW' - if (value < 236.25) return 'SW' - if (value < 258.75) return 'WSW' - if (value < 281.25) return 'W' - if (value < 303.75) return 'WNW' - if (value < 326.25) return 'NW' - if (value < 348.75) return 'NNW' - return 'N' - }, - getCO2Status: function (value) { - if (!value || value === 'undefined' || value < 0) return 'undefined' - if (value >= this.config.thresholdCO2Bad) return 'bad' - if (value >= this.config.thresholdCO2Average) return 'average' - return 'good' - }, - getIcon: function (dataType, value) { - switch (dataType) { - // case this.measurement.CO2: - // return 'fa-lungs' - case this.measurement.NOISE: - return 'fa-volume-up' - case this.measurement.HUMIDITY: - return 'fa-tint' - case this.measurement.PRESSURE: - return 'fa-tachometer-alt' - case this.measurement.GUST_STRENGTH: - case this.measurement.WIND_STRENGTH: - return 'fa-wind' - // case this.measurement.GUST_ANGLE: - // case this.measurement.WIND_ANGLE: - case this.measurement.PRESSURE_TREND: - case this.measurement.TEMPERATURE_TREND: - return this.getTrendIcon(value) - case 'wifi': - return 'fa-wifi' - case 'radio': - return 'fa-broadcast-tower' - case 'battery': - return this.getBatteryIcon(value) - default: - return '' - } - }, - getTrendIcon: function (value) { - if (value === 'stable') return 'fa-chevron-circle-right' - if (value === 'down') return 'fa-chevron-circle-down' - if (value === 'up') return 'fa-chevron-circle-up' - if (value === 'undefined') return 'fa-times-circle' - }, - getBatteryIcon: function (value) { - if (value > 80) return 'fa-battery-full' - if (value > 60) return 'fa-battery-three-quarters' - if (value > 40) return 'fa-battery-half' - if (value > 20) return 'fa-battery-quarter' - return 'fa-battery-empty flash red' - }, - getStyles: function () { - return [`${this.name}.${this.config.design}.css`] - }, - getTemplate: function () { - return `${this.name}.${this.config.design}.njk` - }, - getTemplateData: function () { - return { - loaded: this.loaded, - showLastMessage: this.config.showLastMessage, - showBattery: this.config.showBattery, - showRadio: this.config.showRadio, - showWiFi: this.config.showWiFi, - showTrend: this.config.showTrend, - showMeasurementIcon: this.config.showMeasurementIcon, - showMeasurementLabel: this.config.showMeasurementLabel, - showModuleNameOnTop: this.config.showModuleNameOnTop, - horizontal: this.config.horizontal, - moduleList: this.moduleList, - fontClassModuleName: this.config.fontClassModuleName, - fontClassPrimary: this.config.fontClassPrimary, - fontClassSecondary: this.config.fontClassSecondary, - fontClassMeasurement: this.config.fontClassMeasurement, - labelLoading: this.translate('LOADING'), - } - }, - getTranslations: function () { - return { - cs: 'l10n/cs.json', - de: 'l10n/de.json', - en: 'l10n/en.json', - fr: 'l10n/fr.json', - hu: 'l10n/hu.json', - nb: 'l10n/nb.json', - nn: 'l10n/nn.json', - } - }, - socketNotificationReceived: function (notification, payload) { - const self = this - Log.debug('received ' + notification) - switch (notification) { - case self.notifications.AUTH_RESPONSE: - if (payload.status === 'OK') { - self.sendSocketNotification(self.notifications.DATA, self.config) - } else { - console.log('AUTH FAILED ' + payload.message) - } - break - case self.notifications.DATA_RESPONSE: - if (payload.status === 'OK') { - console.log('Devices %o', payload.payloadReturn) - const stationList = payload.payloadReturn - self.updateModuleList(stationList) - self.updateDom(self.config.animationSpeed) - } else if (payload.status === 'INVALID_TOKEN') { - // node_module has no valid token, reauthenticate - console.log('DATA FAILED, refreshing token') - self.sendSocketNotification(self.notifications.AUTH, self.config) - } else { - console.log('DATA FAILED ' + payload.message) - } - break - } - }, -}) +/* global Module */ +Module.register('netatmo', module) diff --git a/node_helper.js b/node_helper.js index 3fb6fa9..fc7da9f 100644 --- a/node_helper.js +++ b/node_helper.js @@ -5,6 +5,6 @@ * MIT Licensed. */ const NodeHelper = require('node_helper') -const api = require('./api') +const helper = require('./helper') -module.exports = NodeHelper.create(api) +module.exports = NodeHelper.create(helper) diff --git a/package-lock.json b/package-lock.json index af99de0..6074d31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,12 @@ "version": "2.0.0", "license": "MIT", "dependencies": { - "@ungap/url-search-params": "^0.2.2" + "@ungap/url-search-params": "^0.2.2", + "sync-fetch": "^0.5.2" }, "devDependencies": { "@snyk/protect": "^1.1200.0", + "dotenv": "^16.3.1", "eslint": "^8.7.0", "eslint-config-standard": "^17.0.0", "eslint-plugin-import": "^2.25.4", @@ -2401,6 +2403,18 @@ "node": ">=6.0.0" } }, + "node_modules/dotenv": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", + "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/motdotla/dotenv?sponsor=1" + } + }, "node_modules/electron-to-chromium": { "version": "1.4.490", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.490.tgz", @@ -5099,6 +5113,25 @@ "ms": "^2.1.1" } }, + "node_modules/node-fetch": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -6450,6 +6483,17 @@ "integrity": "sha1-WPcc7jvVGbWdSyqEO2x95krAR2Q=", "dev": true }, + "node_modules/sync-fetch": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/sync-fetch/-/sync-fetch-0.5.2.tgz", + "integrity": "sha512-6gBqqkHrYvkH65WI2bzrDwrIKmt3U10s4Exnz3dYuE5Ah62FIfNv/F63inrNhu2Nyh3GH5f42GKU3RrSJoaUyQ==", + "dependencies": { + "node-fetch": "^2.6.1" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/table": { "version": "6.8.1", "resolved": "https://registry.npmjs.org/table/-/table-6.8.1.tgz", @@ -6535,6 +6579,11 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "node_modules/trim-newlines": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-4.1.1.tgz", @@ -6768,6 +6817,20 @@ "makeerror": "1.0.12" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index b0ca9d6..eec8578 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "homepage": "https://github.com/CFenner/MagicMirror-Netatmo-Module#readme", "devDependencies": { "@snyk/protect": "^1.1200.0", + "dotenv": "^16.3.1", "eslint": "^8.7.0", "eslint-config-standard": "^17.0.0", "eslint-plugin-import": "^2.25.4", @@ -43,6 +44,7 @@ }, "snyk": true, "dependencies": { - "@ungap/url-search-params": "^0.2.2" + "@ungap/url-search-params": "^0.2.2", + "sync-fetch": "^0.5.2" } }