diff --git a/weather-station/README.md b/weather-station/README.md new file mode 100644 index 0000000..19ec51f --- /dev/null +++ b/weather-station/README.md @@ -0,0 +1,20 @@ +# Weather Station Example Application + +## Introduction + +This application allows you to connect your Netatmo account and convert your data into the Wappsto Unified Data Model (UDM). It makes use of the [Netatmo Weather API](https://dev.netatmo.com/apidocumentation/weather), as well as the [Wappsto API](https://developer.wappsto.com/), which is made for developing creative IoT solutions. + +## How to get started + +To get started with using this Wapp you need the following: + +* A developer Netatmo account - you can signup for one at the official [Netatmo website](https://auth.netatmo.com/en-us/access/login) +* A Wappsto account +* Netatmo [Smart Home Weather Station](https://www.netatmo.com/en-us/weather/weatherstation) device and optionally one or more Netatmo [Additional Indoor Module](https://www.netatmo.com/en-us/weather/weatherstation/accessories#module) devices + +### Setup +1. Login into your Netatmo developer account and create your application. This enables you to get your own **client ID** and **client secret**. +2. If you haven't done so already, configure your Netatmo devices to function. For detailed instructions, follow this [link](https://helpcenter.netatmo.com/en-us/smart-home-weather-station-and-accessories/setup-installation/how-to-setup-my-smart-home-weather-station). +3. Login into your Wappsto account and create a new Wapp using the files in this GitHub repository. +4. Navigate to the *background* folder and edit the **config.js** by replacing the placeholder values with your own credentials. In the **deviceId** field you need to input your own Weather station MAC address. To find out what your Weather station MAC address is, follow this [link](https://helpcenter.netatmo.com/en-us/smart-thermostat/product-interactions/how-do-i-find-my-products-serial-number-or-its-mac-address). +5. Run the Wapp and allow Wappsto permission to store data in your account . diff --git a/weather-station/background/config.js b/weather-station/background/config.js new file mode 100644 index 0000000..7bab569 --- /dev/null +++ b/weather-station/background/config.js @@ -0,0 +1,9 @@ +const config = { + clientId: "your_client_id", + clientSecret: "your_client_secret", + deviceId: "your_device_id", + username: "your_username", + password: "your_password" +}; + +module.exports = config; diff --git a/weather-station/background/main.js b/weather-station/background/main.js new file mode 100644 index 0000000..94c7286 --- /dev/null +++ b/weather-station/background/main.js @@ -0,0 +1,404 @@ +const Wappsto = require("wapp-api"); +const networkInfo = require("./networkInfo.json"); +const netatmo = require("./netatmo"); + +const wappsto = new Wappsto(); + +let network, data; +// Timer used for updating data +let updateTimer; +// 5 min +let timeInterval = 300000; + +const statusMessage = { + success_convert_netatmo_data: + "Succesfully converted Netatmo data to Wappsto UDM", + error_convert_wappsto_data: "Failed to convert Wappsto data", + success_update_netatmo_data: "Succesfully updated Wappsto data", + error_update_wappsto_data: "Failed to update Wappsto data" +}; + +wappsto + .get( + "data", + {}, + { + expand: 1, + subscribe: true + } + ) + .then(collection => { + data = collection.first(); + + if (data) { + if (!data.get("accessToken")) { + netatmo + .getAccessToken() + .then(response => { + if (response) { + data.save( + { + accessToken: response.data.access_token, + refreshToken: response.data.refresh_token, + expiresIn: response.data.expires_in + }, + { + patch: true, + success: () => { + startWapp(); + }, + error: () => { + console.log("Error saving access tokens to Wappsto data.."); + } + } + ); + } + }) + .catch(error => { + console.log(`Could not get access token: ${error}`); + }); + } else { + startWapp(); + } + } + }) + .catch(error => { + console.log(`Could not get Wappsto data: ${error}`); + }); + +// If the Netatmo Weather Station network does not already exist, start the conversion process +const startWapp = () => { + wappsto + .get( + "network", + { name: "Netatmo Weather Station" }, + { + expand: 5, + subscribe: true + } + ) + .then(collection => { + if (collection.length > 0) { + network = collection.first(); + // Update the Weather Station network with the most recent data + updateStationData(); + } else { + // Get the users station data with which to populate the new Weather Station network + netatmo + .getStationData(data.get("accessToken")) + .then(response => { + if (response) { + convertNetatmoDataToWappstoUDM(response); + } else { + // If response is null then refresh tokens and try to start Wapp again + refreshTokens(startWapp); + } + }) + .catch(error => { + console.log(`Could not get station data: ${error}`); + + updateWappstoData({ + status_message: statusMessage.error_convert_wappsto_data + }); + }); + } + }) + .catch(error => { + console.log(`Could not get Netatmo Weather Station network: ${error}`); + }); +}; + +// Convert the users data from the Netatmo API into the Wappsto UDM +const convertNetatmoDataToWappstoUDM = response => { + // Create the Netatmo Weather Station network + network = createNetwork(); + // Data of the Main Module - every station has this device + let deviceData = response.data.body.devices; + + let stationName = deviceData[0].station_name; + // Saving station name to display in the FG + if (data.get("stationName") !== stationName) { + updateWappstoData({ stationName: stationName }); + } + + addDevicesToNetwork(deviceData); + // Data of the modules associated with the Main Module + let moduleData = response.data.body.devices[0].modules; + + addDevicesToNetwork(moduleData); + // Saving network + saveNetwork(); + + updateWappstoData({ + status_message: statusMessage.success_convert_netatmo_data + }); +}; + +// Update station data +const updateStationData = () => { + // Clear update timer + if (updateTimer) { + clearInterval(updateTimer); + } + + netatmo + .getStationData(data.get("accessToken")) + .then(response => { + if (response) { + let deviceData = response.data.body.devices; + // Saving station name to display in the FG + let stationName = deviceData[0].station_name; + + if (data.get("stationName") !== stationName) { + updateWappstoData({ stationName: stationName }); + } + + let devices = network.get("device"); + // Handling the update of Main module + let mainModuleDevice = devices.at(0); + + let valuesToUpdate = mainModuleDevice.get("value"); + + valuesToUpdate.forEach(valueToUpdate => { + let reportState = valueToUpdate.get("state").find({ type: "Report" }); + // deviceData[0] refers to the data of the Main Module + let newData = deviceData[0].dashboard_data + ? deviceData[0].dashboard_data[valueToUpdate.get("name")] + : 0; + + if (reportState.get("data") !== newData) { + reportState.save({ data: newData.toString() }, { patch: true }); + } + }); + // Handling the update of smaller modules + if (devices.length > 1) { + // Omitting device at index 0 because it is always going to be Main Module by design + for (let i = 1; i < devices.length; i++) { + let currentDevice = devices.at(i); + + let moduleData = response.data.body.devices[0].modules; + + moduleData.forEach(module => { + // Matching the current device with its corresponding module data + if (currentDevice.get("name") === module.module_name) { + let valuesToUpdate = currentDevice.get("value"); + + valuesToUpdate.forEach(valueToUpdate => { + let reportState = valueToUpdate + .get("state") + .find({ type: "Report" }); + + let newData = module.dashboard_data + ? module.dashboard_data[valueToUpdate.get("name")] + : 0; + + if (reportState.get("data") !== newData) { + reportState.save( + { data: newData.toString() }, + { patch: true } + ); + } + }); + } + }); + } + // Main Module device and smaller modules are updated + setUpdateTimer(); + + updateWappstoData({ + status_message: statusMessage.success_update_netatmo_data + }); + } else { + // Main Module device is updated + setUpdateTimer(); + + updateWappstoData({ + status_message: statusMessage.success_update_netatmo_data + }); + } + } else { + // If response is null then refresh tokens and try to update data again + refreshTokens(updateStationData); + } + }) + .catch(error => { + console.log("Could not get station data: " + error); + + updateWappstoData({ + status_message: statusMessage.error_update_wappsto_data + }); + + refreshTokens(updateStationData); + }); +}; + +// Set timer used to update station data +const setUpdateTimer = () => { + if (updateTimer) { + clearInterval(updateTimer); + } + updateTimer = setInterval(() => { + updateStationData(); + }, timeInterval); +}; + +// Create and return network +const createNetwork = () => { + let newNetwork = new wappsto.models.Network(); + + newNetwork.set("name", networkInfo.name); + + return newNetwork; +}; + +// Create and return device +const createDevice = deviceData => { + let newDevice = new wappsto.models.Device(); + + // Device type is used to differentiate between the Main Module and the other modules + // Thus the right attributes can be set for each case + if (deviceData.type === "NAMain") { + newDevice.set({ + name: deviceData.module_name, + description: networkInfo.device[0].description, + manufacturer: networkInfo.device[0].manufacturer, + communication: networkInfo.device[0].communication + }); + } else { + newDevice.set({ + name: deviceData.module_name, + description: "Module device", + manufacturer: networkInfo.device[0].manufacturer, + communication: networkInfo.device[0].communication + }); + } + return newDevice; +}; + +// Create and return device value +const createValue = (dataType, device) => { + let newValue = new wappsto.models.Value(); + + networkInfo.device[0].value.forEach(value => { + if (value.param === dataType) { + newValue.set({ + name: value.name, + type: value.type, + permission: value.permission, + dataType: value.dataType, + // all the values are of type number + number: { + min: value.min ? parseInt(value.min) : -999, + max: value.max ? parseInt(value.max) : 999, + step: value.step ? parseInt(value.step) : 1, + unit: value.unit + }, + description: value.description + }); + + if (newValue) { + // if the device is unreachable then device.dashboard_data will be missing + let stateData = device.dashboard_data + ? device.dashboard_data[value.param] + : 0; + // all the value permissions are of type Report + let reportState = createState("Report", stateData); + + newValue.get("state").push(reportState); + } + } + }); + return newValue; +}; + +// Create and return value state +const createState = (type, data) => { + let newState = new wappsto.models.State(); + + let timestamp = new Date().toISOString(); + + newState.set({ + type: type, + data: data.toString(), + timestamp: timestamp + }); + + return newState; +}; + +// Save network and set update timer +const saveNetwork = () => { + network.save( + {}, + { + subscribe: true, + success: () => { + if (updateTimer) { + clearInterval(updateTimer); + } + setUpdateTimer(); + }, + error: error => { + console.log(error); + } + } + ); +}; + +// Save and update data to wappsto data model +const updateWappstoData = dataToUpdate => { + data.set(dataToUpdate); + data.save(dataToUpdate, { + patch: true, + error: () => { + console.log("Error saving Wappsto data.."); + } + }); +}; + +// Use device data to create device, values and state and then add device to the network +const addDevicesToNetwork = deviceData => { + deviceData.forEach(device => { + let deviceToAdd = createDevice(device); + if (deviceToAdd) { + let deviceDataTypes = device.data_type; + + deviceDataTypes.forEach(dataType => { + let valueToAdd = createValue(dataType, device); + + deviceToAdd.get("value").push(valueToAdd); + }); + network.get("device").push(deviceToAdd); + } + }); +}; + +// Refresh tokens if unable to get station data +const refreshTokens = callback => { + netatmo + .getRefreshToken(data.get("refreshToken")) + .then(response => { + if (response) { + data.save( + { + accessToken: response.data.access_token, + refreshToken: response.data.refresh_token, + expiresIn: response.data.expires_in + }, + { + patch: true, + success: () => { + // Execute callback + callback(); + }, + error: () => { + console.log("Error saving access tokens to Wappsto data.."); + } + } + ); + } + }) + .catch(error => { + console.log("Could not refresh tokens: " + error); + }); +}; diff --git a/weather-station/background/netatmo.js b/weather-station/background/netatmo.js new file mode 100644 index 0000000..0d67aa1 --- /dev/null +++ b/weather-station/background/netatmo.js @@ -0,0 +1,65 @@ +const config = require("./config"); +const axios = require("axios"); +// By default, axios serializes JavaScript objects to JSON. +// To send data in the application/x-www-form-urlencoded format instead, use querystring to stringify nested objects! +const querystring = require("querystring"); +// Get access token with client credentials grant type - use only for development and testing +const getAccessToken = () => { + return axios({ + method: "POST", + headers: { + Host: "api.netatmo.com", + "Content-type": "application/x-www-form-urlencoded;charset=UTF-8" + }, + url: "/oauth2/token", + baseURL: "https://api.netatmo.com/", + data: querystring.stringify({ + grant_type: "password", + client_id: config.clientId, + client_secret: config.clientSecret, + username: config.username, + password: config.password, + scope: "read_station" + }) + }); +}; + +const getRefreshToken = refreshToken => { + return axios({ + method: "POST", + headers: { + Host: "api.netatmo.com", + "Content-type": "application/x-www-form-urlencoded;charset=UTF-8" + }, + url: "/oauth2/token", + baseURL: "https://api.netatmo.com/", + data: querystring.stringify({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: config.clientId, + client_secret: config.clientSecret + }) + }); +}; + +const getStationData = accessToken => { + return axios({ + method: "GET", + headers: { + Host: "api.netatmo.com", + Authorization: "Bearer " + accessToken + }, + url: "/getstationsdata", + baseURL: "https://api.netatmo.com/api/", + data: querystring.stringify({ + device_id: config.deviceId, + get_favorites: false + }) + }); +}; + +module.exports = { + getAccessToken: getAccessToken, + getRefreshToken: getRefreshToken, + getStationData: getStationData +}; diff --git a/weather-station/background/networkInfo.json b/weather-station/background/networkInfo.json new file mode 100644 index 0000000..762c189 --- /dev/null +++ b/weather-station/background/networkInfo.json @@ -0,0 +1,68 @@ +{ + "name": "Netatmo Weather Station", + "device": [ + { + "name": "Indoor", + "description": "Main module - required module in all cases", + "manufacturer": "Netatmo", + "communication": "always", + "value": [ + { + "param": "Temperature", + "name": "Temperature", + "type": "temperature", + "permission": "r", + "dataType": "number", + "min": "-40", + "max": "65", + "step": "1", + "unit": "C" + }, + { + "param": "CO2", + "name": "CO2", + "type": "CO2", + "permission": "r", + "dataType": "number", + "min": "0", + "max": "5000", + "step": "1", + "unit": "ppm" + }, + { + "param": "Humidity", + "name": "Humidity", + "type": "humidity", + "permission": "r", + "dataType": "number", + "min": "0", + "max": "100", + "step": "1", + "unit": "%" + }, + { + "param": "Noise", + "name": "Noise", + "type": "noise", + "permission": "r", + "dataType": "number", + "min": "35", + "max": "120", + "step": "1", + "unit": "dB" + }, + { + "param": "Pressure", + "name": "Pressure", + "type": "pressure", + "permission": "r", + "dataType": "number", + "min": "260", + "max": "1260", + "step": "1", + "unit": "mbar" + } + ] + } + ] +} diff --git a/weather-station/background/package.json b/weather-station/background/package.json new file mode 100644 index 0000000..e9d6311 --- /dev/null +++ b/weather-station/background/package.json @@ -0,0 +1,7 @@ +{ + "dependencies": { + "axios": "0.18.0", + "querystring": "^0.2.0", + "wapp-api": "^1.0.5" + } +} diff --git a/weather-station/foreground/index.html b/weather-station/foreground/index.html new file mode 100644 index 0000000..7efa59a --- /dev/null +++ b/weather-station/foreground/index.html @@ -0,0 +1,41 @@ + +
+ +Pending
+${value.get("state").at(0).get("data")}
`; + + const stateUnit = `${ value.get("number") ? value.get("number").unit : ""}
`; + + const timestamp = value.get("state").at(0).get("timestamp"); + + const lastUpdated = `Last updated ${moment(timestamp).fromNow()}
`; + + dataContainer.innerHTML += `