fewieden/MMM-Fuel

View on GitHub
apis/tankerkoenig.js

Summary

Maintainability
A
0 mins
Test Coverage
/**
 * @file apis/tankerkoenig.js
 *
 * @author fewieden
 * @license MIT
 *
 * @see  https://github.com/fewieden/MMM-Fuel
 */

/**
 * @external lodash
 * @see https://www.npmjs.com/package/lodash
 */
const _ = require('lodash');

/**
 * @external node-fetch
 * @see https://www.npmjs.com/package/node-fetch
 */
const fetch = require('node-fetch');

/**
 * @external logger
 * @see https://github.com/MichMich/MagicMirror/blob/master/js/logger.js
 */
const Log = require('logger');

const { sortByPrice } = require('./utils');

const BASE_URL = 'https://creativecommons.tankerkoenig.de/json';

let config;
let stationInfos;

/**
 * @function generateRadiusUrl
 * @description Helper function to generate API request url.
 *
 * @returns {string} url
 */
function generateRadiusUrl() {
    return `${BASE_URL}/list.php?lat=${config.lat}&lng=${config.lng}&rad=${config.radius}&type=all&apikey=${
        config.api_key}&sort=dist`;
}

/**
 * @function generateStationPricesUrl
 * @description Helper function to generate API request url.
 *
 * @param {string[]} ids - Gas Station IDs
 *
 * @returns {string} url
 */
function generateStationPricesUrl(ids) {
    return `${BASE_URL}/prices.php?ids=${ids.join(',')}&apikey=${config.api_key}`;
}

/**
 * @function generateStationInfoUrl
 * @description Helper function to generate API request url.
 *
 * @param {string} id - Gas Station ID
 *
 * @returns {string} url
 */
function generateStationInfoUrl(id) {
    return `${BASE_URL}/detail.php?id=${id}&apikey=${config.api_key}`;
}

/**
 * @function filterStations
 * @description Helper function to filter gas stations.
 *
 * @param {Object} station - Gas Station
 *
 * @returns {boolean} To keep or filter the station.
 */
function filterStations(station) {
    const hideClosedStation = config.showOpenOnly && !station.isOpen;
    const excludeStation = config.excludeStationIds.includes(station.id);
    const noPriceAvailable = config.types.some(type => station[type] <= 0);

    if (hideClosedStation || excludeStation || noPriceAvailable) {
        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.
 *
 * @returns {void}
 *
 * @see apis/README.md
 */
function 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].street = `${value.street} ${value.houseNumber}`;
    stations[index].address = `${`0${value.postCode}`.slice(-5)} ${
        value.place} - ${stations[index].street}`;
    /* eslint-enable no-param-reassign */
}

/**
 * @function getPricesByRadius
 * @description Fetches the prices by radius.
 * @async
 *
 * @returns {Object[]} List of stations in raw format.
 */
async function getPricesByRadius() {
    const response = await fetch(generateRadiusUrl());
    const parsedResponse = await response.json();

    if (!parsedResponse.ok) {
        throw new Error('Error no fuel radius prices');
    }

    return parsedResponse.stations;
}

/**
 * @function degreesToRadians
 * @description Converst degrees to radians
 * @see https://stackoverflow.com/questions/365826/calculate-distance-between-2-gps-coordinates/365853#365853
 *
 * @param {number} degrees - Degrees
 *
 * @returns {number} Radians
 */
function degreesToRadians(degrees) {
    return degrees * Math.PI / 180;
}

/**
 * @function distanceInMBetweenCoordinates
 * @description Calculates the distance of two coordinates in meters.
 * @see https://stackoverflow.com/questions/365826/calculate-distance-between-2-gps-coordinates/365853#365853
 *
 * @param {number} lat1 - Latitude of point 1.
 * @param {number} lon1 - Longitude of point 1.
 * @param {number} lat2 - Latitude of point 2.
 * @param {number} lon2 - Longitude of point 2.
 *
 * @returns {number} Distance in meters rounded to the closest 100m.
 */
function distanceInMBetweenCoordinates(lat1, lon1, lat2, lon2) {
    const earthRadiusM = 6371000;

    const dLat = degreesToRadians(lat2 - lat1);
    const dLon = degreesToRadians(lon2 - lon1);

    lat1 = degreesToRadians(lat1);
    lat2 = degreesToRadians(lat2);

    const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2);
    const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));

    return Math.round(earthRadiusM * c / 100) * 100;
}

/**
 * @function setStationInfos
 * @description Initializes the gas station information.
 * @async
 *
 * @param {Object[]} stationsByRadius - Gas Stations by radius. Used to filter out possible duplicate stations.
 *
 * @returns {void}
 */
async function setStationInfos(stationsByRadius) {
    for (const station of stationsByRadius) {
        config.stationIds = config.stationIds.filter(id => id !== station.id);
    }

    if (config.stationIds.length > 10) {
        Log.warn(`MMM-Fuel: You can only ask for a maximum of 10 station prices`);
        config.stations = config.stationIds.slice(0, 10);
    }

    stationInfos = {};

    for (const stationId of config.stationIds) {
        const response = await fetch(generateStationInfoUrl(stationId));
        const parsedResponse = await response.json();

        if (!parsedResponse.ok) {
            Log.warn(`MMM-Fuel: No fuel station detail. StationId: ${stationId} Error: ${parsedResponse.message}`);
            continue;
        }

        const station = parsedResponse.station;

        const distanceMeters = distanceInMBetweenCoordinates(config.lat, config.lng, station.lat, station.lng);

        stationInfos[station.id] = { ...station, dist: distanceMeters / 1000 };
    }
}

/**
 * @function getPricing
 * @description Helper function to calculate prices for getPricesByStationList.
 * @async
 *
 * @returns {Object} Fuel prices for all types.
 */
function getPricing({ status, ...prices }) {
    const pricing = { diesel: -1, e5: -1, e10: -1 };

    if (status !== 'open') {
        return pricing;
    }

    for (const type in prices) {
        if (prices[type]) {
            pricing[type] = prices[type];
        }
    }

    return pricing;
}

/**
 * @function getPricesByStationList
 * @description Fetches the prices by station ID list.
 * @async
 *
 * @param {Object[]} stationsByRadius - Gas Stations by radius. Used to filter out possible duplicate stations.
 *
 * @returns {Object[]} List of stations in raw format.
 */
async function getPricesByStationList(stationsByRadius) {
    if (!stationInfos) {
        await setStationInfos(stationsByRadius);
    }

    const stationIds = Object.keys(stationInfos);
    const stations = [];

    if (!stationIds.length) {
        Log.warn('MMM-Fuel: Filtered stationIds list is empty');
        return stations;
    }

    const response = await fetch(generateStationPricesUrl(stationIds));
    const parsedResponse = await response.json();

    if (!parsedResponse.ok) {
        throw new Error('Error no fuel station prices');
    }

    for (const [stationId, info] of Object.entries(parsedResponse.prices)) {
        stations.push({
            ...stationInfos[stationId],
            ...getPricing(info),
            isOpen: info.status !== 'closed'
        });
    }

    return stations;
}

/**
 * @function getData
 * @description Performs the data query and processing.
 * @async
 *
 * @returns {Object} Returns object described in the provider documentation.
 *
 * @see apis
 */
async function getData() {
    let stations = [];

    if (config.radius > 0) {
        stations = stations.concat(await getPricesByRadius());
    }

    stations = stations.concat(await getPricesByStationList(stations));

    const stationsFiltered = stations.filter(filterStations);
    stationsFiltered.forEach(normalizeStations);

    return {
        types: ['diesel', 'e5', 'e10'],
        unit: 'kilometer',
        currency: 'EUR',
        byPrice: _.sortBy(stationsFiltered, sortByPrice.bind(null, config)),
        byDistance: _.sortBy(stationsFiltered, 'dist')
    };
}

/**
 * @module apis/tankerkoenig
 * @description Queries data from tankerkoenig.de
 *
 * @requires external:node-fetch
 * @requires external:logger
 *
 * @param {Object} options - Configuration.
 * @param {number} options.lat - Latitude of Coordinate.
 * @param {number} options.lng - Longitude of Coordinate.
 * @param {int} options.radius - Lookup area for gas stations.
 * @param {string} options.sortBy - Type to sort by price.
 * @param {string[]} options.types - Requested fuel types.
 * @param {boolean} options.showOpenOnly - Flag to show only open gas stations.
 *
 * @returns {Object} Object with function getData.
 *
 * @see https://creativecommons.tankerkoenig.de/
 */
module.exports = options => {
    config = options;

    return { getData };
};