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 @@ + + + + Netatmo Weather Station Converter + + + + + + + + + + + + + +
+

Netatmo Weather Station Converter

+
+
+ + Please wait for status update.. + +
+

Pending

+
+
+
+
+
+ + diff --git a/weather-station/foreground/index.js b/weather-station/foreground/index.js new file mode 100644 index 0000000..24fc55c --- /dev/null +++ b/weather-station/foreground/index.js @@ -0,0 +1,126 @@ +const wappsto = new Wappsto(); + +window.onload = () => { + wappsto.get( + "data", + {}, + { + expand: 1, + subscribe: true, + success: collection => { + const data = collection.first(); + + if(data) { + displayStatus(data); + } + + data.on("change", () => { + displayStatus(data); + }); + }, + error: error => { + console.log(error); + } + } + ); +}; + +const displayStatus = wappstoData => { + const circle = document.getElementById("circle"); + const detailedStatus = document.getElementById("detailed-status"); + const shortStatus = document.getElementById("short-status"); + + if(wappstoData.get("status_message")) { + const status = wappstoData.get("status_message"); + detailedStatus.textContent = ""; + shortStatus.textContent = ""; + + if(status === "Succesfully converted Netatmo data to Wappsto UDM" || + status === "Succesfully updated Wappsto data") { + shortStatus.textContent = "OK"; + circle.style.backgroundColor = "green"; + // When the status is success then get and display the network data + getNetwork(); + } + + if(status === "Failed to convert Wappsto data" || + status === "Failed to update Wappsto data") { + shortStatus.textContent = "Error"; + circle.style.backgroundColor = "red"; + } + detailedStatus.textContent = status; + } +}; + +const getNetwork = () => { + wappsto.get( + "network", + { name: "Netatmo Weather Station" }, + { + expand: 5, + subscribe: true, + success: collection => { + const network = collection.first(); + + if(network) { + displayNetworkData(network); + } + }, + error: error => { + console.log(error); + } + } + ); +}; + +const displayNetworkData = networkToDisplay => { + const dataContainer = document.getElementById("data-container"); + const devices = networkToDisplay.get("device"); + // Clear the container + dataContainer.innerHTML = ""; + + devices.forEach(device => { + const values = device.get("value"); + + values.forEach(value => { + const valueHeader = `
+

${value.get("name")}

+ ${device.get("name")} +
`; + + let valueIcon; + + switch(value.get("name")) { + case "CO2": + valueIcon = ""; + break; + case "Temperature": + valueIcon = ""; + break; + case "Humidity": + valueIcon = ""; + break; + case "Noise": + valueIcon = ""; + break; + case "Pressure": + valueIcon = ""; + break; + default: + valueIcon = ""; + } + + const stateData = `

${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 += `
+ ${valueHeader} ${valueIcon} ${stateData} ${stateUnit} ${lastUpdated} +
`; + }); + }); +}; diff --git a/weather-station/foreground/style.css b/weather-station/foreground/style.css new file mode 100644 index 0000000..d3c0c8b --- /dev/null +++ b/weather-station/foreground/style.css @@ -0,0 +1,143 @@ +* { + margin: 0; + padding: 0; + border: 0; +} + +body { + font-family: sans-serif; + background-color: #f2f2f2; +} + +#primary-header { + background-color: lightblue; + padding: 1.5rem; + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-gap: 0.5rem; +} + +#primary-header h1 { + grid-column: 1; + grid-row: 1; +} + +#status-indicator { + grid-column: 2; + grid-row: 1; + align-self: center; + justify-self: end; + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-gap: 0.5rem; +} + +#circle { + grid-column: 1; + grid-row: 1; + align-self: center; + justify-self: end; + width: 1.5rem; + height: 1.5rem; + border-radius: 0.75rem; + box-shadow: 0px 0px 3px 1px rgba(0, 0, 0, 0.1); + background-color: orange; +} + +/* Custom tooltip - credit to W3Schools https://www.w3schools.com/css/css_tooltip.asp */ +.tooltip { + position: relative; + display: inline-block; + border-bottom: 1px dotted black; +} + +.tooltip .tooltiptext { + visibility: hidden; + width: 10rem; + background-color: #ffffff; + color: #000000; + text-align: center; + border-radius: 0.75rem; + padding: 0.5rem; + + /* Position the tooltip */ + position: absolute; + z-index: 1; + top: -0.75rem; + right: 125%; +} + +.tooltip:hover .tooltiptext { + visibility: visible; +} + +#short-status { + grid-column: 2; + grid-row: 1; + align-self: center; + justify-self: start; +} + +main { + padding: 1.5rem; +} + +#data-container { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + grid-gap: 1rem; +} + +.card { + padding: 1.5rem; + background-color: #ffffff; + border-radius: 0.75rem; + box-shadow: 0px 0px 10px 1px rgba(0, 0, 0, 0.1); + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-template-rows: repeat(3, 1fr) 1rem; + grid-gap: 0.5rem; +} + +.card header { + grid-column: 1 / span 2; + grid-row: 1; +} + +.card small { + color: #999999; +} + +.card i { + grid-column: 1; + grid-row: 2 / span 2; + place-self: center; + font-size: 500%; + color: lightblue; +} + +#state-data { + grid-column: 2; + grid-row: 2; + place-self: center; + font-size: 200%; + font-weight: bold; + margin-bottom: -1.75rem; +} + +#state-unit { + grid-column: 2; + grid-row: 3; + place-self: center; + font-size: 100%; + margin-top: -1.25rem; + color: #999999; +} + +#last-updated { + grid-column: 1 / span 2; + grid-row: 4; + place-self: left; + color: #999999; + font-size: 80%; +}