diff --git a/.doclets.yml b/.doclets.yml new file mode 100644 index 0000000..ad6b950 --- /dev/null +++ b/.doclets.yml @@ -0,0 +1,10 @@ +dir: . +packageJson: package.json +articles: + - Overview: README.md + - API Provider: apis/README.md + - Changelog: CHANGELOG.md + - License: LICENSE +branches: + - master + - develop diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..a188e06 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +docs/* diff --git a/.eslintrc b/.eslintrc index db007e4..f8aff93 100644 --- a/.eslintrc +++ b/.eslintrc @@ -4,7 +4,8 @@ "comma-dangle": 0, "indent": [2, 4], "max-len": [2, 120, { "ignoreStrings": true }], - "radix": [2, "as-needed"] + "radix": [2, "as-needed"], + "no-console": 0 }, "settings": { "import/core-modules": [ "node_helper" ] @@ -14,4 +15,4 @@ "node": true, "es6": true } -} \ No newline at end of file +} diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..5956f32 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,8 @@ +# Contribution Guidelines + +Thanks for contributing to this module! + +Please create pull requests to the branch `develop`. + +To hold one code style and standard there are several linters and tools in this project set. Make sure you fullfill the requirements. +Also there will be automatically analysis performed once you created the pull request. diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..5bbae68 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,9 @@ +Platform (Hardware/OS): + +Node version: + +MagicMirror version: + +Module version: + +Description of the issue: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..63fa699 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,5 @@ +Please create pull requests to the branch `develop`. + +* Does the pull request solve an issue (add a reference)? +* What are the features of this pr? +* Add screenshots for visual changes. diff --git a/.mdlrc b/.mdlrc index e0ea98e..54d0111 100644 --- a/.mdlrc +++ b/.mdlrc @@ -1,2 +1,2 @@ all -rules "~MD013", "~MD033" \ No newline at end of file +rules "~MD013", "~MD026", "~MD033" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..99aa6b9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# MMM-WienerLinien Changelog + +## [1.1.0] + +### Added + +* API provider development [Guide](apis). +* API provider `spritpreisrechner.at`. +* Disabled markdownlint rule `MD026` (no-trailing-punctuation) +* Disabled eslint rule `no-console` +* Documentation +* [Doclets.io](https://doclets.io/fewieden/MMM-Fuel/master) integration +* Contributing guidelines +* Issue template +* Pull request template + +### Changed + +* Outsourced API provider `tankerkoenig.de`. + +## [1.0.0] + +Initial version diff --git a/MMM-Fuel.js b/MMM-Fuel.js index a99d595..eb27d92 100644 --- a/MMM-Fuel.js +++ b/MMM-Fuel.js @@ -1,20 +1,74 @@ -/* global Module Log google*/ - -/* Magic Mirror - * Module: MMM-Fuel +/** + * @file MMM-Fuel.js + * + * @author fewieden + * @license MIT * - * By fewieden https://github.com/fewieden/MMM-Fuel - * MIT Licensed. + * @see https://github.com/fewieden/MMM-Fuel + */ + +/* global Module Log config google */ + +/** + * @external Module + * @see https://github.com/MichMich/MagicMirror/blob/master/js/module.js + */ + +/** + * @external Log + * @see https://github.com/MichMich/MagicMirror/blob/master/js/logger.js */ +/** + * @module MMM-Fuel + * @description Frontend for the module to display data. + * + * @requires external:Module + * @requires external:Log + */ Module.register('MMM-Fuel', { + /** @member {Object} units - Is used to determine the unit symbol of the global config option units. */ + units: { + imperial: 'ml', + metric: 'km' + }, + + /** @member {Object} currencies - Is used to convert currencies into symbols. */ + currencies: { + EUR: '€' + }, + + /** @member {boolean} sortByPrice - Flag to switch between sorting (price and distance). */ sortByPrice: true, + /** @member {boolean} help - Flag to switch between render help or not. */ help: false, + /** @member {boolean} map - Flag to switch between render map or not. */ map: false, - mapUI: null, + /** @member {?Interval} interval - Toggles sortByPrice */ interval: null, + /** + * @member {Object} defaults - Defines the default config values. + * @property {int} radius - Lookup area for gas stations. + * @property {int} max - Amount of gas stations to display. + * @property {boolean|string} map_api_key - API key for Google Maps. + * @property {int} zoom - Zoom level of the map. + * @property {int} width - Width of the map. + * @property {int} height - Height of the map. + * @property {boolean} colored - Flag to render map in colour or greyscale. + * @property {boolean} open - Flag to render column to indicate if the gas stations are open or closed. + * @property {boolean|int} shortenText - Max characters to be shown for name and address. + * @property {boolean} showAddress - Flag to show the gas stations address. + * @property {boolean} showOpenOnly - Flag to show only open gas stations or all. + * @property {boolean} iconHeader - Flag to display the car icon in the header. + * @property {boolean} rotate - Flag to enable/disable rotation between sort by price and distance. + * @property {string[]} types - Fuel types to show. + * @property {string} sortBy - Type to sort by price. + * @property {int} rotateInterval - Speed of rotation. + * @property {int} updateInterval - Speed of update. + * @property {string} provider - API provider of the data. + */ defaults: { radius: 5, max: 5, @@ -31,10 +85,16 @@ Module.register('MMM-Fuel', { rotate: true, types: ['diesel'], sortBy: 'diesel', - rotateInterval: 60 * 1000, // every minute - updateInterval: 15 * 60 * 1000 // every 15 minutes + rotateInterval: 60 * 1000, // every minute + updateInterval: 15 * 60 * 1000, // every 15 minutes + provider: 'tankerkoenig' }, + /** + * @member {Object} voice - Defines the voice recognition part. + * @property {string} mode - MMM-voice mode of this module. + * @property {string[]} sentences - All commands of this module. + */ voice: { mode: 'FUEL', sentences: [ @@ -45,6 +105,12 @@ Module.register('MMM-Fuel', { ] }, + /** + * @function getTranslations + * @description Translations for this module. + * + * @returns {Object.} Available translations for this module (key: language code, value: filepath). + */ getTranslations() { return { en: 'translations/en.json', @@ -52,10 +118,22 @@ Module.register('MMM-Fuel', { }; }, + /** + * @function getStyles + * @description Style dependencies for this module. + * + * @returns {string[]} List of the style dependency filepaths. + */ getStyles() { return ['font-awesome.css', 'MMM-Fuel.css']; }, + /** + * @function start + * @description Appends Google Map script to the body, if the config option map_api_key is defined. Calls + * createInterval and sends the config to the node_helper. + * @override + */ start() { Log.info(`Starting module: ${this.name}`); // Add script manually, getScripts doesn't work for it! @@ -68,6 +146,11 @@ Module.register('MMM-Fuel', { this.sendSocketNotification('CONFIG', this.config); }, + /** + * @function createInterval + * @description Creates an interval if config option rotate is set. + * @returns {?Interval} The Interval toggles sortByPrice between true and false. + */ createInterval() { if (!this.config.rotate) { return null; @@ -78,6 +161,14 @@ Module.register('MMM-Fuel', { }, this.config.rotateInterval); }, + /** + * @function notificationReceived + * @description Handles incoming broadcasts from other modules or the MagicMirror core. + * + * @param {string} notification - Notification name + * @param {*} payload - Detailed payload of the notification. + * @param {MM} [sender] - The sender of the notification. If sender is undefined the sender is the core. + */ notificationReceived(notification, payload, sender) { if (notification === 'ALL_MODULES_STARTED') { this.sendNotification('REGISTER_VOICE_MODULE', this.voice); @@ -90,6 +181,13 @@ Module.register('MMM-Fuel', { } }, + /** + * @function socketNotificationReceived + * @description Handles incoming messages from node_helper. + * + * @param {string} notification - Notification name + * @param {*} payload - Detailed payload of the notification. + */ socketNotificationReceived(notification, payload) { if (notification === 'PRICELIST') { this.priceList = payload; @@ -97,6 +195,13 @@ Module.register('MMM-Fuel', { } }, + /** + * @function getDom + * @description Creates the UI as DOM for displaying in MagicMirror application. + * @override + * + * @returns {Element} + */ getDom() { const wrapper = document.createElement('div'); const list = document.createElement('div'); @@ -159,8 +264,8 @@ Module.register('MMM-Fuel', { const script = document.createElement('script'); script.innerHTML = `var MMM_Fuel_map = \ new google.maps.Map(document.querySelector('div.MMM-Fuel-map'), \ - {center: new google.maps.LatLng(${parseFloat(this.config.lat)}, \ - ${parseFloat(this.config.lng)}), zoom: ${this.config.zoom}, disableDefaultUI:true}); + {center: new google.maps.LatLng(${this.config.lat}, \ + ${this.config.lng}), zoom: ${this.config.zoom}, disableDefaultUI:true}); var trafficLayer = new google.maps.TrafficLayer(); trafficLayer.setMap(MMM_Fuel_map); var MMM_Fuel_array = ${JSON.stringify(this.priceList.byPrice)}; @@ -186,6 +291,12 @@ Module.register('MMM-Fuel', { return wrapper; }, + /** + * @function createLabelRow + * @description Creates label row for price table. + * + * @returns {Element} + */ createLabelRow() { const labelRow = document.createElement('tr'); @@ -198,20 +309,20 @@ Module.register('MMM-Fuel', { labelRow.appendChild(sortLabel); for (let i = 0; i < this.config.types.length; i += 1) { - const typeLabel = document.createElement('th'); - typeLabel.classList.add('centered'); + if (this.priceList.types.includes(this.config.types[i])) { + const typeLabel = document.createElement('th'); + typeLabel.classList.add('centered'); - const typeSpan = document.createElement('span'); - typeSpan.innerHTML = this.config.types[i].charAt(0).toUpperCase() + this.config.types[i].slice(1); - typeLabel.appendChild(typeSpan); + const typeSpan = document.createElement('span'); + typeSpan.innerHTML = this.capitalizeFirstLetter(this.config.types[i]); + typeLabel.appendChild(typeSpan); - if (this.sortByPrice && this.config.sortBy === this.config.types[i]) { - const sortIcon = document.createElement('i'); - sortIcon.classList.add('fa', 'fa-long-arrow-down', 'sortBy'); - typeLabel.appendChild(sortIcon); - } + if (this.sortByPrice && this.config.sortBy === this.config.types[i]) { + typeLabel.appendChild(this.createSortIcon()); + } - labelRow.appendChild(typeLabel); + labelRow.appendChild(typeLabel); + } } const distanceIconLabel = document.createElement('th'); @@ -222,9 +333,7 @@ Module.register('MMM-Fuel', { distanceIconLabel.appendChild(distanceIcon); if (!this.sortByPrice) { - const sortIcon = document.createElement('i'); - sortIcon.classList.add('fa', 'fa-long-arrow-down', 'sortBy'); - distanceIconLabel.appendChild(sortIcon); + distanceIconLabel.appendChild(this.createSortIcon()); } labelRow.appendChild(distanceIconLabel); @@ -241,6 +350,14 @@ Module.register('MMM-Fuel', { return labelRow; }, + /** + * @function shortenText + * @description Shortens text based on config option (shortenText) and adds ellipsis at the end. + * + * @param {string} text - Text which should be shorten. + * + * @returns {string} The shortened text. + */ shortenText(text) { let temp = text; if (this.config.shortenText && temp.length > this.config.shortenText) { @@ -249,6 +366,33 @@ Module.register('MMM-Fuel', { return temp; }, + /** + * @function appendDataRow + * @description Creates the UI for the station price table. + * + * @param {Object} data - Information about a station. + * @param {string} data.name - The gas station name. + * @param {Object.} data.prices - Prices (value) of the different fuel types (key). + * @param {number} data.distance - Distance between user location and gas station. + * @param {boolean} data.isOpen - Indicator if the gas station is currently open or closed. + * @param {string} data.address - Address of the gas station in the format: Postcode City - Street Housenumber. + * @param {Element} appendTo - DOM Element where the UI gets appended as child. + * + * @example data object + * { + * "name": "Aral Tankstelle", + * "prices": { + * "diesel": 1.009, + * "e5": 1.009, + * "e10": 1.009 + * }, + * "distance": 2.2, + * "isOpen": true, + * "address": "70372 Stuttgart - Waiblinger Straße 23-25", + * "lat": 48.8043442, + * "lng": 9.220273 + * } + */ appendDataRow(data, appendTo) { const row = document.createElement('tr'); @@ -257,16 +401,30 @@ Module.register('MMM-Fuel', { row.appendChild(name); for (let i = 0; i < this.config.types.length; i += 1) { - const price = document.createElement('td'); - price.classList.add('centered'); - price.innerHTML = `${data[this.config.types[i]]} €`; - row.appendChild(price); + if (this.priceList.types.includes(this.config.types[i])) { + const price = document.createElement('td'); + price.classList.add('centered'); + if (data.prices[this.config.types[i]] === -1) { + price.innerHTML = '-'; + } else { + price.innerHTML = `${data.prices[this.config.types[i]].toFixed(2)} ${ + this.currencies[this.priceList.currency]}`; + } + row.appendChild(price); + } } - const distance = document.createElement('td'); - distance.classList.add('centered'); - distance.innerHTML = `${data.dist} km`; - row.appendChild(distance); + const distanceUnit = this.units[config.units]; + let distance = data.distance; + + if (distanceUnit !== this.priceList.unit) { + distance = this[`${this.priceList.unit}2${distanceUnit}`](distance); + } + + const distanceColumn = document.createElement('td'); + distanceColumn.classList.add('centered'); + distanceColumn.innerHTML = `${distance.toFixed(2)} ${distanceUnit}`; + row.appendChild(distanceColumn); if (this.config.open) { const lockUnlockIconLabel = document.createElement('td'); @@ -289,14 +447,19 @@ Module.register('MMM-Fuel', { const address = document.createElement('td'); address.classList.add('xsmall'); - address.innerHTML = this.shortenText(`${(`0${data.postCode}`).slice(-5)} ${data.place} - ${data.street - } ${data.houseNumber}`); + address.innerHTML = this.shortenText(data.address); details.appendChild(address); appendTo.appendChild(details); } }, + /** + * @function checkCommands + * @description Checks for voice commands. + * + * @param {string} data - Text with commands. + */ checkCommands(data) { if (/(HELP)/g.test(data)) { if (/(CLOSE)/g.test(data) || (this.help && !/(OPEN)/g.test(data))) { @@ -318,6 +481,12 @@ Module.register('MMM-Fuel', { this.updateDom(300); }, + /** + * @function appendTo + * @description Creates the UI for the voice command SHOW HELP. + * + * @param {Element} appendTo - DOM Element where the UI gets appended as child. + */ appendHelp(appendTo) { const title = document.createElement('h1'); title.classList.add('medium'); @@ -339,5 +508,54 @@ Module.register('MMM-Fuel', { list.appendChild(item); } appendTo.appendChild(list); + }, + + /** + * @function createSortIcon + * @description Creates a DOM Element with the FontAwesome icon + * fa-long-arrow-down {@link http://fontawesome.io/icons/}. + * + * @returns {Element} Element with icon. + */ + createSortIcon() { + const sortIcon = document.createElement('i'); + sortIcon.classList.add('fa', 'fa-long-arrow-down', 'sortBy'); + return sortIcon; + }, + + /** + * @function capitalizeFirstLetter + * @description Capitalizes the first character in a string. + * + * @param {string} text - text to capitalize the first letter. + * + * @returns {string} Capitalized string. + */ + capitalizeFirstLetter(text) { + return text.charAt(0).toUpperCase() + text.slice(1); + }, + + /** + * @function km2ml + * @description Converts the unit kilometres to miles. + * + * @param {number} value - Distance in kilometres. + * + * @returns {number} Distance in miles. + */ + km2ml(value) { + return value * 0.62137; + }, + + /** + * @function ml2km + * @description Converts the unit miles to kilometres. + * + * @param {number} value - Distance in miles. + * + * @returns {number} Distance in kilometres. + */ + ml2km(value) { + return value * 1.60934; } }); diff --git a/README.md b/README.md index a4c0a58..535a486 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ -# MMM-Fuel [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](https://raw.githubusercontent.com/fewieden/MMM-Fuel/master/LICENSE) [![Build Status](https://travis-ci.org/fewieden/MMM-Fuel.svg?branch=master)](https://travis-ci.org/fewieden/MMM-Fuel) [![Code Climate](https://codeclimate.com/github/fewieden/MMM-Fuel/badges/gpa.svg?style=flat)](https://codeclimate.com/github/fewieden/MMM-Fuel) [![Known Vulnerabilities](https://snyk.io/test/github/fewieden/mmm-fuel/badge.svg)](https://snyk.io/test/github/fewieden/mmm-fuel) +[![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](https://raw.githubusercontent.com/fewieden/MMM-Fuel/master/LICENSE) [![Build Status](https://travis-ci.org/fewieden/MMM-Fuel.svg?branch=master)](https://travis-ci.org/fewieden/MMM-Fuel) [![Code Climate](https://codeclimate.com/github/fewieden/MMM-Fuel/badges/gpa.svg?style=flat)](https://codeclimate.com/github/fewieden/MMM-Fuel) [![Known Vulnerabilities](https://snyk.io/test/github/fewieden/mmm-fuel/badge.svg)](https://snyk.io/test/github/fewieden/mmm-fuel) [![API Doc](https://doclets.io/fewieden/MMM-Fuel/master.svg)](https://doclets.io/fewieden/MMM-Fuel/master) -Gas Station price Module for MagicMirror2 +# MMM-Fuel -## Example +Gas Station Price Module for MagicMirror2 + +## Examples ![](.github/example.jpg) ![](.github/example2.jpg) ![](.github/example3.jpg) @@ -13,14 +15,6 @@ Gas Station price Module for MagicMirror2 * npm * [request](https://www.npmjs.com/package/request) -## Info - -The data used in this module comes from [tankerkoenig.de](http://www.tankerkoenig.de) and is only for Gas Stations in Germany. -If you find an API for other countries let me know and i will implement them as well. - -Read the [Terms of Use](https://creativecommons.tankerkoenig.de/#usage) carefully, especially the restrictions for smart mirrors, -or your API access will be suspended. - ## Installation 1. Clone this repo into `~/MagicMirror/modules` directory. @@ -46,13 +40,13 @@ or your API access will be suspended. | **Option** | **Default** | **Description** | | --- | --- | --- | -| `api_key` | REQUIRED | Get an API key for free access to the data of www.tankerkoenig.de [here](https://creativecommons.tankerkoenig.de/#register). | +| `provider` | `"tankerkoenig"` | API provider (See full list below). | | `lat` | REQUIRED | Decimal degrees latitude. | | `lng` | REQUIRED | Decimal degrees longitude. | -| `types` | `["diesel"]` | Fuel types in an array e.g. `["diesel", "e5"]` valid options: `"diesel"`, `"e5"` and `"e10"`. | -| `sortBy` | `"diesel"` | Price sorting by which fuel type `"diesel"`, `"e5"` or `"e10"`. | +| `types` | `["diesel"]` | Fuel types in an array e.g. `["diesel", "e5"]`. All valid types can be seen in the specific provider section below. | +| `sortBy` | `"diesel"` | Price sorting by which fuel type defined in config option `types`. | | `open` | `false` | Display whether the gas station is open or not. | -| `radius` | `5` | Lookup Area for Gas Stations in km. Possible values 1-25. | +| `radius` | `5` | Lookup Area for Gas Stations in km. | | `max` | `5` | How many gas stations should be displayed. | | `map_api_key` | `false` | Required to show the gas stations map with traffic layer. You can get it [here](https://console.developers.google.com/) and don't forget to activate maps api for javascript. | | `zoom` | `12` | Zoom of the map. (Min 0, Max 18 depends on the area) | @@ -67,6 +61,27 @@ or your API access will be suspended. | `rotateInterval` | `60000` (1 min) | How fast the sorting should be switched between byPrice and byDistance. | | `updateInterval` | `900000` (15 mins) | How often should the data be fetched. | +### tankerkoenig (Germany only) + +Read the [Terms of Use](https://creativecommons.tankerkoenig.de/#usage) carefully, especially the restrictions for smart mirrors, +or your API access will be suspended. + +| **Option** | **Default** | **Description** | +| --- | --- | --- | +| `api_key` | REQUIRED | Get an API key for free access to the data of [tankerkoenig.de](https://creativecommons.tankerkoenig.de/#register). | +| `types` | `["diesel"]` | Valid options are `diesel`, `e5` and `e10`. | +| `radius` | `5` | Valid range is 1-25. | + +### spritpreisrechner (Austria only) + +No API key required. + +| **Option** | **Default** | **Description** | +| --- | --- | --- | +| `types` | `["diesel"]` | Valid options are `diesel`, `e5` and `gas`. | +| `radius` | `5` | Valid range not tested yet. | +| `max` | `5` | The API provider returns maximum of 5 valid datasets. | + ## OPTIONAL: Voice Control This module supports voice control by [MMM-voice](https://github.com/fewieden/MMM-voice). In order to use this feature, it's required to install the voice module. There are no extra config options for voice control needed. @@ -81,3 +96,16 @@ The voice control mode for this module is `FUEL` * CLOSE HELP -> Hides the help information. * SHOW GAS STATIONS MAP -> Shows a map with the gas stations labeled by Price starting with 1. * HIDE GAS STATIONS MAP -> Hide the map. + +## Developer + +* `npm run lint` - Lints JS and CSS files. +* `npm run docs` - Generates documentation. + +### Documentation + +The documentation can be found [here](https://doclets.io/fewieden/MMM-Fuel/master) + +### API Provider Development + +If you want to add another API provider checkout the [Guide](apis). diff --git a/apis/README.md b/apis/README.md new file mode 100644 index 0000000..55e4df2 --- /dev/null +++ b/apis/README.md @@ -0,0 +1,41 @@ +# Documentation for API integration + +## Import + +The API provider gets imported in the node_helper.js. As parameter it gets the config of the module. + +## Stations + +To display the data of the stations in the UI, a single station has to be in the following format: + +``` +{ + "name": "Aral Tankstelle", // String + "prices": { // Object + "diesel": 1.009, // Float + // "type": amount + }, + "distance": 2.2, // Float + "isOpen": true, // Boolean + "address": "70376 Stuttgart - Pragstraße 138A", // String + "lat": 48.8075371, // Float + "lng": 9.194154, // Float +} +``` + +## getData + +The API provider needs to implement the function `getData`, which gets a callback as parameter. +The callback has to have the following format: + +``` +callback(null, { + types: ['diesel', 'e5', 'e10'], // Array | types supported by the API provider. + unit: 'km', // String | unit for the distance either in kilometres (km) or miles (ml). + currency: 'EUR', // String | curreny of the fuel prices either in EUR or USD. + byPrice: stations, // Array | stations (see above) sorted by price. + byDistance: distance // Array | stations (see above) sorted by distance. +}); +``` + +If an error occurs in the process you can return it as the first parameter of the callback. diff --git a/apis/spritpreisrechner.js b/apis/spritpreisrechner.js new file mode 100644 index 0000000..cb3865d --- /dev/null +++ b/apis/spritpreisrechner.js @@ -0,0 +1,218 @@ +/** + * @file apis/spritpreisrechner.js + * + * @author fewieden + * @license MIT + * + * @see https://github.com/fewieden/MMM-Fuel + */ + +/** + * @external request + * @see https://www.npmjs.com/package/request + */ +const request = require('request'); +const Coordinate = require('./utils/Coordinate.js'); + +/** + * @module apis/spritpreisrechner + * @description Queries data from spritpreisrechner.at + * + * @requires external:request + * @requires module:Coordinate + * + * @param {Object} config - Configuration. + * @param {number} config.lat - Latitude of Coordinate. + * @param {number} config.lng - Longitude of Coordinate. + * @param {int} config.radius - Lookup area for gas stations. + * @param {string} config.sortBy - Type to sort by price. + * @param {string[]} config.types - Requested fuel types. + * @param {boolean} config.showOpenOnly - Flag to show only open gas stations. + */ +module.exports = (config) => { + /** @member {string} baseUrl - API url */ + const baseUrl = 'http://www.spritpreisrechner.at/espritmap-app/GasStationServlet'; + + /** @member {Object} types - Mapping of fuel types to API fuel types. */ + const types = { + diesel: 'DIE', + e5: 'SUP', + gas: 'GAS' + }; + + /** @member {Object} topLeft - Top left corner of lookup area. */ + const topLeft = Coordinate.from(config.lat, config.lng).to(315, config.radius); + /** @member {Object} bottomRight - Bottom right corner of lookup area. */ + const bottomRight = Coordinate.to(135, config.radius); + + /** + * @function generateOptions + * @description Helper function to generate API request options. + * + * @param {string} type - Fuel type + * @returns {Object} Options + */ + const generateOptions = type => ({ + url: baseUrl, + method: 'POST', + form: `data=${ + encodeURI( + JSON.stringify( + [ + config.showOpenOnly ? '' : 'checked', + types[type], + topLeft.lng, + topLeft.lat, + bottomRight.lng, + bottomRight.lat + ] + ) + ) + }` + }); + + /** + * @function requestFuelType + * @description API request for specified type. + * + * @param {string} type - Fuel type. + * @returns {Promise} Data or error message. + */ + const requestFuelType = type => new Promise((resolve, reject) => { + request(generateOptions(type), (error, response, body) => { + if (response.statusCode === 200) { + resolve({ type, data: JSON.parse(body) }); + } + reject(`Error getting fuel data ${response.statusCode}`); + }); + }); + + /** + * @function compareStations + * @description Helper function to compare gas stations. + * + * @param {Object} a - Gas Station + * @param {Object} b - Gas Station + * @returns {boolean} + */ + const compareStations = (a, b) => a.city === b.city && + a.postalCode === b.postalCode && + a.gasStationName === b.gasStationName && + a.latitude === b.latitude && + a.longitude === b.longitude; + + /** + * @function reducePrice + * @description Reduces array of prices to single price. + * + * @param {Object[]} prices - All prices. + * @returns {number} Highest price or -1 if there is no price. + */ + const reducePrice = prices => prices.reduce((current, price) => { + if (!Object.prototype.hasOwnProperty.call(price, 'amount') || price.amount === '') { + return current; + } + const newAmount = parseFloat(price.amount); + return current < newAmount ? newAmount : current; + }, -1); + + /** + * @function filterStations + * @description Helper function to filter gas stations. + * + * @param {Object} station - Gas Station + * @returns {boolean} + */ + const filterStations = (station) => { + const prices = Object.keys(station.prices); + return !prices.every(type => station.prices[type] === -1); + }; + + /** + * @function sortByDistance + * @description Helper function to sort gas stations by distance. + * + * @param {Object} a - Gas Station + * @param {Object} b - Gas Station + * @returns {number} + */ + const sortByDistance = (a, b) => a.distance - b.distance; + + /** + * @function normalizeStations + * @description Helper function to normalize the structure of gas stations for the UI. + * + * @param {Object[]} stations - Gas Station. + * @param {string[]} keys - Fuel types except config option sortBy. + * + * @see apis/README.md + */ + const normalizeStations = (stations, keys) => { + stations.forEach((value, index) => { + /* eslint-disable no-param-reassign */ + stations[index].name = value.gasStationName; + stations[index].prices = { [config.sortBy]: reducePrice(value.spritPrice) }; + keys.forEach((type) => { stations[index].prices[type] = -1; }); + stations[index].isOpen = value.open; + stations[index].address = `${value.postalCode} ${value.city} - ${value.address}`; + stations[index].lat = parseFloat(value.latitude); + stations[index].lng = parseFloat(value.longitude); + /* eslint-enable no-param-reassign */ + }); + }; + + return { + /** + * @callback getDataCallback + * @param {?string} error - Error message. + * @param {Object} data - API data. + * + * @see apis/README.md + */ + + /** + * @function getData + * @description Performs the data query and processing. + * + * @param {getDataCallback} callback - Callback that handles the API data. + */ + getData(callback) { + Promise + .all(config.types.map(requestFuelType)) + .then((responses) => { + const collection = {}; + responses.forEach((element) => { collection[element.type] = element.data; }); + + let stations = collection[config.sortBy]; + delete collection[config.sortBy]; + const keys = Object.keys(collection); + + normalizeStations(stations, keys); + + keys.forEach((type) => { + collection[type].forEach((station) => { + for (let i = 0; i < stations.length; i += 1) { + if (compareStations(station, stations[i])) { + stations[i].prices[type] = reducePrice(station.spritPrice); + break; + } + } + }); + }); + + stations = stations.filter(filterStations); + + const distance = stations.slice(0); + distance.sort(sortByDistance); + + callback(null, { + types: ['diesel', 'e5', 'gas'], + unit: 'km', + currency: 'EUR', + byPrice: stations, + byDistance: distance + }); + }); + } + }; +}; diff --git a/apis/tankerkoenig.js b/apis/tankerkoenig.js new file mode 100644 index 0000000..d71f2c6 --- /dev/null +++ b/apis/tankerkoenig.js @@ -0,0 +1,141 @@ +/** + * @file apis/tankerkoenig.js + * + * @author fewieden + * @license MIT + * + * @see https://github.com/fewieden/MMM-Fuel + */ + +/** + * @external request + * @see https://www.npmjs.com/package/request + */ +const request = require('request'); + +/** + * @module apis/tankerkoenig + * @description Queries data from tankerkoenig.de + * + * @requires external:request + * + * @param {Object} config - Configuration. + * @param {number} config.lat - Latitude of Coordinate. + * @param {number} config.lng - Longitude of Coordinate. + * @param {int} config.radius - Lookup area for gas stations. + * @param {string} config.sortBy - Type to sort by price. + * @param {string[]} config.types - Requested fuel types. + * @param {boolean} config.showOpenOnly - Flag to show only open gas stations. + * + * @see https://creativecommons.tankerkoenig.de/ + */ +module.exports = (config) => { + /** @member {string} baseUrl - API url */ + const baseUrl = 'https://creativecommons.tankerkoenig.de/json/list.php'; + + /** @member {Object} options - API url combined with config options. */ + const options = { + url: `${baseUrl}?lat=${config.lat}&lng=${config.lng}&rad=${config.radius}&type=all&apikey=${ + config.api_key}&sort=dist` + }; + + /** + * @function sortByPrice + * @description Helper function to sort gas stations by price. + * + * @param {Object} a - Gas Station + * @param {Object} b - Gas Station + * @returns {number} + */ + const sortByPrice = (a, b) => { + if (b[config.sortBy] === 0) { + return Number.MIN_SAFE_INTEGER; + } else if (a[config.sortBy] === 0) { + return Number.MAX_SAFE_INTEGER; + } + return a[config.sortBy] - b[config.sortBy]; + }; + + /** + * @function filterStations + * @description Helper function to filter gas stations. + * + * @param {Object} station - Gas Station + * @returns {boolean} + */ + const filterStations = (station) => { + for (let i = 0; i < config.types.length; i += 1) { + if (station[config.types[i]] <= 0 || (config.showOpenOnly && !station.isOpen)) { + return false; + } + } + return true; + }; + + /** + * @function normalizeStations + * @description Helper function to normalize the structure of gas stations for the UI. + * + * @param {Object} value - Gas Station + * @param {int} index - Array index + * @param {Object[]} stations - Original Array. + * + * @see apis/README.md + */ + const normalizeStations = (value, index, stations) => { + /* eslint-disable no-param-reassign */ + stations[index].prices = { + diesel: value.diesel, + e5: value.e5, + e10: value.e10 + }; + stations[index].distance = value.dist; + stations[index].address = `${(`0${value.postCode}`).slice(-5)} ${ + value.place} - ${value.street} ${value.houseNumber}`; + /* eslint-enable no-param-reassign */ + }; + + return { + /** + * @callback getDataCallback + * @param {?string} error - Error message. + * @param {Object} data - API data. + * + * @see apis/README.md + */ + + /** + * @function getData + * @description Performs the data query and processing. + * + * @param {getDataCallback} callback - Callback that handles the API data. + */ + getData(callback) { + request(options, (error, response, body) => { + if (response.statusCode === 200) { + const parsedBody = JSON.parse(body); + if (parsedBody.ok) { + const stations = parsedBody.stations.filter(filterStations); + + stations.forEach(normalizeStations); + + const price = stations.slice(0); + price.sort(sortByPrice); + + callback(null, { + types: ['diesel', 'e5', 'e10'], + unit: 'km', + currency: 'EUR', + byPrice: price, + byDistance: stations + }); + } else { + callback('Error no fuel data'); + } + } else { + callback(`Error getting fuel data ${response.statusCode}`); + } + }); + } + }; +}; diff --git a/apis/utils/Coordinate.js b/apis/utils/Coordinate.js new file mode 100644 index 0000000..6774922 --- /dev/null +++ b/apis/utils/Coordinate.js @@ -0,0 +1,88 @@ +/** + * @file apis/utils/Coordinate.js + * + * @author fewieden + * @license MIT + * + * @see https://github.com/fewieden/MMM-Fuel + */ + +/** + * Earth radius in meter. + * @type {number} + */ +const earth = 6371e3; + +/** + * @function deg2rad + * @description Converts degree to radian. + * + * @param {number} degree - Value to convert. + * @returns {number} Converted value. + */ +const deg2rad = degree => degree * (Math.PI / 180); + +/** + * @function rad2deg + * @description Converts radian to degree. + * + * @param {number} rad - Value to convert. + * @returns {number} Converted value. + */ +const rad2deg = rad => rad * (180 / Math.PI); + +/** + * @module apis/utils/Coordinate + * @description Utility to calculate target Coordinate based of a start Coordinate. + */ +module.exports = { + + /** + * @function from + * @description Sets the start Coordinate. + * + * @param {number} lat - Latitude of a Coordinate + * @param {number} lng - Longitude of a Coordinate + * @returns {module:Coordinate} + */ + from(lat, lng) { + this.lat = lat; + this.lng = lng; + return this; + }, + + /** + * @function to + * @description Calculates the target Coordinate. + * + * @param {number} degree - Direction to the target (North is 0). + * @param {number} distance - Distance in kilometres to the target. + * @returns {Object.} Target Coordinate. + * + * @see https://github.com/chrisveness/geodesy + */ + to(degree, distance) { + const radius = distance * 1000; + + const δ = Math.sqrt(2 * (radius * radius)) / earth; + const θ = deg2rad(Number(degree)); + + const φ1 = deg2rad(this.lat); + const λ1 = deg2rad(this.lng); + + const sinφ1 = Math.sin(φ1); + const cosφ1 = Math.cos(φ1); + const sinδ = Math.sin(δ); + const cosδ = Math.cos(δ); + const sinθ = Math.sin(θ); + const cosθ = Math.cos(θ); + + const sinφ2 = (sinφ1 * cosδ) + (cosφ1 * sinδ * cosθ); + const φ2 = Math.asin(sinφ2); + const y = sinθ * sinδ * cosφ1; + const x = cosδ - (sinφ1 * sinφ2); + const λ2 = λ1 + Math.atan2(y, x); + + return { lat: rad2deg(φ2), lng: ((rad2deg(λ2) + 540) % 360) - 180 }; + } +}; diff --git a/jsdoc.json b/jsdoc.json new file mode 100644 index 0000000..292650e --- /dev/null +++ b/jsdoc.json @@ -0,0 +1,19 @@ +{ + "tags": { + "dictionaries": ["jsdoc"] + }, + "source": { + "include": [ + "package.json", + "README.md" + ], + "exclude": [ + "node_modules" + ] + }, + "opts": { + "destination": "docs", + "recurse": true + } + +} diff --git a/node_helper.js b/node_helper.js index c15912b..51e7855 100644 --- a/node_helper.js +++ b/node_helper.js @@ -1,71 +1,76 @@ -/* Magic Mirror - * Module: MMM-Fuel +/** + * @file node_helper.js * - * By fewieden https://github.com/fewieden/MMM-Fuel - * MIT Licensed. + * @author fewieden + * @license MIT + * + * @see https://github.com/fewieden/MMM-Fuel */ -/* eslint-env node */ -/* eslint-disable no-console */ - -const request = require('request'); +/** + * @external node_helper + * @see https://github.com/MichMich/MagicMirror/blob/master/modules/node_modules/node_helper/index.js + */ const NodeHelper = require('node_helper'); -module.exports = NodeHelper.create({ +/** + * @external fs + * @see https://nodejs.org/api/fs.html + */ +const fs = require('fs'); - baseUrl: 'https://creativecommons.tankerkoenig.de/json/list.php', +/** + * @module node_helper + * @description Backend for the module to query data from the API providers. + * + * @requires external:fs + * @requires external:node_helper + */ +module.exports = NodeHelper.create({ + /** + * @function start + * @description Logs a start message to the console. + * @override + */ start() { - console.log(`Starting module: ${this.name}`); + console.log(`Starting module helper: ${this.name}`); }, + /** + * @function socketNotificationReceived + * @description Receives socket notifications from the module. + * @override + * + * @param {string} notification - Notification name + * @param {*} payload - Detailed payload of the notification. + */ socketNotificationReceived(notification, payload) { if (notification === 'CONFIG') { this.config = payload; - this.getData(); - setInterval(() => { + if (fs.existsSync(`modules/${this.name}/apis/${this.config.provider}.js`)) { + // eslint-disable-next-line global-require, import/no-dynamic-require + this.provider = require(`./apis/${this.config.provider}`)(this.config); this.getData(); - }, this.config.updateInterval); + setInterval(() => { + this.getData(); + }, this.config.updateInterval); + } else { + console.log(`${this.name}: Couldn't load provider ${this.config.provider}`); + } } }, + /** + * @function getData + * @description Uses API provider to get data. + */ getData() { - const options = { - url: `${this.baseUrl}?lat=${this.config.lat}&lng=${this.config.lng}&rad=${this.config.radius - }&type=all&apikey=${this.config.api_key}&sort=dist` - }; - request(options, (error, response, body) => { - if (response.statusCode === 200) { - const parsedBody = JSON.parse(body); - if (parsedBody.ok) { - for (let i = parsedBody.stations.length - 1; i >= 0; i -= 1) { - let removeFlag = false; - for (let n = 0; n < this.config.types.length; n += 1) { - if (parsedBody.stations[i][this.config.types[n]] <= 0 || - (this.config.showOpenOnly && !parsedBody.stations[i].isOpen)) { - removeFlag = true; - break; - } - } - if (removeFlag) { - parsedBody.stations.splice(i, 1); - } - } - const price = parsedBody.stations.slice(0); - price.sort((a, b) => { - if (b[this.config.sortBy] === 0) { - return Number.MIN_SAFE_INTEGER; - } else if (a[this.config.sortBy] === 0) { - return Number.MAX_SAFE_INTEGER; - } - return a[this.config.sortBy] - b[this.config.sortBy]; - }); - this.sendSocketNotification('PRICELIST', { byPrice: price, byDistance: parsedBody.stations }); - } else { - console.log('Error no fuel data'); - } + this.provider.getData((err, data) => { + if (err) { + console.log(err); } else { - console.log(`Error getting fuel data ${response.statusCode}`); + this.sendSocketNotification('PRICELIST', data); } }); } diff --git a/package.json b/package.json index ebacb07..b01c890 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,10 @@ { "name": "mmm-fuel", - "version": "1.0.0", + "version": "1.1.0", "description": "Gas Station price Module for MagicMirror2", "scripts": { - "lint": "./node_modules/.bin/eslint . && ./node_modules/.bin/stylelint ." + "lint": "./node_modules/.bin/eslint . && ./node_modules/.bin/stylelint .", + "docs": "./node_modules/.bin/jsdoc -c jsdoc.json ." }, "repository": { "type": "git", @@ -24,6 +25,7 @@ "eslint": "^3.14.1", "eslint-config-airbnb-base": "^11.0.1", "eslint-plugin-import": "^2.2.0", + "jsdoc": "^3.4.3", "stylelint": "^7.8.0", "stylelint-config-standard": "^16.0.0" },