├── .gitignore ├── .env.example ├── .editorconfig ├── package.json ├── report-temperature.js ├── DashboardApi.js ├── LICENSE.md ├── Envoy.blade.php ├── README.md └── report-indoor-air-quality.js /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | node_modules 3 | npm-debug.log 4 | yarn.lock 5 | .env 6 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DASHBOARD_URL=https://dashboard.spatie.be 2 | DASHBOARD_ACCESS_TOKEN= 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at http://editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_size = 4 9 | indent_style = space 10 | end_of_line = lf 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | [{package.json,*.scss,*.css}] 15 | indent_size = 2 16 | 17 | [*.md] 18 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "raspi-temperature-monitor", 4 | "main": "report-temperature.js", 5 | "author": "Alex Vanderbist", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/spatie/raspberrypi-temperature-reporter.git" 10 | }, 11 | "dependencies": { 12 | "dotenv": "^6.0.0", 13 | "jvsbme680": "^1.0.4", 14 | "raspi": "^5.0.2", 15 | "raspi-onewire": "^1.0.1", 16 | "request": "^2.88.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /report-temperature.js: -------------------------------------------------------------------------------- 1 | const raspi = require('raspi'); 2 | const OneWire = require('raspi-onewire').OneWire; 3 | const DashboardApi = require('./DashboardApi'); 4 | 5 | raspi.init(() => { 6 | const bus = new OneWire(); 7 | 8 | bus.searchForDevices((err, devices) => { 9 | bus.readAllAvailable(devices[0], (err, data) => { 10 | parseData(data.toString()); 11 | }); 12 | }); 13 | }); 14 | 15 | function parseData(data) 16 | { 17 | let temperature = Number(data.match(/t=(\d+)/)[1]); 18 | 19 | if (temperature === 0) { 20 | new Error(`Invalid sensor data: ${data}`); 21 | } 22 | 23 | temperature = temperature / 1000; 24 | 25 | console.log(`temperature=${temperature}`); 26 | 27 | (new DashboardApi()).reportTemperature(temperature); 28 | } 29 | -------------------------------------------------------------------------------- /DashboardApi.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const request = require('request'); 3 | 4 | module.exports = class DashboardApi { 5 | reportTemperature(temperature) 6 | { 7 | this.post('/temperature', { temperature }); 8 | } 9 | 10 | reportAirQuality(iaq) 11 | { 12 | this.post('/indoor-air-quality', { indoorAirQuality: iaq }); 13 | } 14 | 15 | post(endpointUrl, data) { 16 | const username = process.env.DASHBOARD_USERNAME; 17 | const password = process.env.DASHBOARD_PASSWORD; 18 | const url = process.env.DASHBOARD_URL + endpointUrl + '?access-token=' + process.env.DASHBOARD_ACCESS_TOKEN; 19 | 20 | request.post({ 21 | url, 22 | json: data, 23 | }, 24 | error => { 25 | if (error) { 26 | console.error(error); 27 | } 28 | } 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Spatie bvba 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /Envoy.blade.php: -------------------------------------------------------------------------------- 1 | @setup 2 | $pathOnPi = '/home/pi/raspberrypi-temperature-reporter'; 3 | @endsetup 4 | 5 | @servers(['pi' => 'pi@10.0.0.53', 'localhost' => '127.0.0.1']) 6 | 7 | @task('display start message', ['on' => 'localhost']) 8 | echo 'start deploying on Raspberry Pi. Path: {{ $pathOnPi }}' 9 | @endtask 10 | 11 | @task('checkout master branch', ['on' => 'pi']) 12 | cd '{{ $pathOnPi }}' 13 | echo 'checking out the master branch' 14 | git checkout master 15 | @endtask 16 | 17 | @task('pull changes on server', ['on' => 'pi']) 18 | cd '{{ $pathOnPi }}' 19 | git pull origin master 20 | @endtask 21 | 22 | @task('run yarn', ['on' => 'pi']) 23 | echo 'running yarn' 24 | cd '{{ $pathOnPi }}' 25 | yarn config set ignore-engines true 26 | yarn 27 | @endtask 28 | 29 | @task('restart supervisor', ['on' => 'pi']) 30 | sudo supervisorctl restart all 31 | @endtask 32 | 33 | @task('display success message', ['on' => 'localhost']) 34 | echo "application successfully deployed" 35 | @endtask 36 | 37 | @macro('deploy') 38 | display start message 39 | checkout master branch 40 | pull changes on server 41 | run yarn 42 | restart supervisor 43 | display success message 44 | @endmacro 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [](https://supportukrainenow.org) 3 | 4 | **WIP: This repo is a wip - indoor air quality data is not working yet** 5 | 6 | # External sensors for dashboard.spatie.be 7 | 8 | Small Node.js script to report **office temperatures** and **indoor air quality** to the [Spatie dashboard](https://github.com/spatie/dashboard.spatie.be). Uses the 1-wire bus and IC2 on a Raspberry Pi to read data from external sensors. 9 | 10 | - office temperature: DS18B20 sensor over 1-wire bus 11 | - indoor air quality (IAQ): BME680 over IC2 12 | 13 | ## Support us 14 | 15 | [](https://spatie.be/github-ad-click/dashboard.spatie.be-external-sensors) 16 | 17 | We invest a lot of resources into creating [best in class open source packages](https://spatie.be/open-source). You can support us by [buying one of our paid products](https://spatie.be/open-source/support-us). 18 | 19 | We highly appreciate you sending us a postcard from your hometown, mentioning which of our package(s) you are using. You'll find our address on [our contact page](https://spatie.be/about-us). We publish all received postcards on [our virtual postcard wall](https://spatie.be/open-source/postcards). 20 | 21 | ## Install 22 | 23 | You can install the reporter on your Raspberry Pi by cloning the repository and installing its dependencies. 24 | 25 | ```bash 26 | git clone git@github.com:spatie/raspberrypi-temperature-reporter.git 27 | yarn install 28 | ``` 29 | 30 | Copy or rename the `.env.example` file to `.env` and update the values in there with your dashboard's URL, basic auth username and password. 31 | 32 | ```bash 33 | cp .env.example .env 34 | ``` 35 | 36 | Run the script to make sure a temperature is being reported and broadcasted: 37 | 38 | ```bash 39 | node report-temperature.js 40 | # expected output: temperature=21.312 41 | ``` 42 | 43 | Finally add the script to your Raspberry's cronjobs using `crontab -e` to run once per minute. 44 | 45 | ``` 46 | * * * * * sudo /bin/bash -c "cd /home/pi/raspberrypi-temperature-reporter/ && node ./report-temperature.js" >> /home/pi/temperature.log 2>&1 47 | ``` 48 | 49 | ## Deploying code changes to the Raspberry Pi 50 | 51 | If you're running a fork of this script you can use the `Envoy.blade.php` file to deploy changes to your pi after pushing. 52 | The deploy script will SSH to your pi and pull in new changes from the git repository. 53 | 54 | ```bash 55 | envoy run deploy 56 | ``` 57 | 58 | ## Contributing 59 | 60 | Since this is an internal project, we don't accept pull requests at this time. If you have discovered a bug or have an idea to improve the code, open an issue first before you start coding. 61 | 62 | ### Security 63 | 64 | If you discover any security related issues, please contact freek@spatie.be instead of using the issue tracker. 65 | 66 | ## License 67 | 68 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 69 | 70 | # 71 | ## Credits 72 | 73 | - [Alex Vanderbist](https://github.com/AlexVanderbist) 74 | - [All Contributors](../../contributors) 75 | 76 | ### About Spatie 77 | 78 | Spatie is a webdesign agency based in Antwerp, Belgium. You'll find an overview of all our open source projects [on our website](https://spatie.be/opensource). 79 | -------------------------------------------------------------------------------- /report-indoor-air-quality.js: -------------------------------------------------------------------------------- 1 | const {BME680} = require('jvsbme680'); 2 | const DashboardApi = require('./DashboardApi'); 3 | const fs = require('fs'); 4 | 5 | const bme680 = new BME680('0x77'); 6 | 7 | const logStream = fs.createWriteStream("iaq.csv", {flags:'a'}); 8 | 9 | /** 10 | * Pauses code execution. 11 | * 12 | * @param {number} duration The duration (in milliseconds). 13 | * @returns {Promise} A promise that is resolved when the duration has elapsed. 14 | */ 15 | function sleep(duration) { 16 | return new Promise(resolve => setTimeout(resolve, duration)); 17 | } 18 | 19 | /** 20 | * Calculates the gas resistance baseline (Ohms). 21 | * 22 | * @async 23 | * @param {number} interval The measurement interval (in milliseconds). 24 | * @param {number} duration The measurement duration (in milliseconds). 25 | * @returns {Promise} A promise that is resolved with the gas resistance baseline (Ohms). 26 | */ 27 | async function calculateGasResistanceBaseline(interval, duration) { 28 | // Create a time reference. 29 | const startTime = process.hrtime(); 30 | 31 | // Create an empty list to which measurement results will be appended. 32 | let gasResistances = []; 33 | 34 | // Measure gas resistance (Ohms) as long as the duration hasn't elapsed. 35 | while (process.hrtime(startTime)[0] < duration / 1000) { 36 | // Measure the gas resistance and append it to the list. 37 | const gasResistance = await bme680.gasSensor.read(); 38 | gasResistances.push(gasResistance); 39 | 40 | // Wait for the specified interval to elapse, before remeasuring. 41 | await sleep(interval); 42 | } 43 | 44 | // We'll use a maximum of 50 of the most recent measurements. 45 | gasResistances = gasResistances.slice(-50); 46 | 47 | // Calculate the sum and average of the measurements. 48 | const sum = gasResistances.reduce((sum, value) => sum + value); 49 | 50 | return sum / gasResistances.length; 51 | } 52 | 53 | /** 54 | * Measures the air quality (%) and logs it to the console. 55 | * 56 | * @async 57 | * @param {number} interval The measurement interval (in milliseconds). 58 | */ 59 | async function measureAirQuality(interval) { 60 | try { 61 | // Calculate the gas resistance baseline, measuring with an interval of one second for a duration of 5 minutes. 62 | const gasResistanceBaseline = await calculateGasResistanceBaseline(1000, 300000); 63 | 64 | // Define the humidity baseline nd humidity weighting. 65 | const humidityBaseline = 40; // 40%RH is an optimal indoor humidity 66 | const humidityWeighting = 25; // use a balance between humidity and gas resistance of 25%:75% 67 | 68 | const startedAt = new Date(); 69 | 70 | // Indefinitely calculate the air quality at the set interval. 71 | while (true) { 72 | // Measure the gas resistance and calculate the offset. 73 | const gasResistance = await bme680.gasSensor.read(); 74 | const gasResistanceOffset = gasResistanceBaseline - gasResistance; 75 | 76 | // Calculate the gas resistance score as the distance from the gas resistance baseline. 77 | let gasResistanceScore = 0; 78 | if (gasResistanceOffset > 0) { 79 | gasResistanceScore = (gasResistance / gasResistanceBaseline) * (100 - humidityWeighting); 80 | } else { 81 | gasResistanceScore = 100 - humidityWeighting; 82 | } 83 | 84 | // Measure the humidity and calculate the offset. 85 | const humidity = await bme680.humiditySensor.read(); 86 | const humidityOffset = humidity - humidityBaseline; 87 | 88 | // Calculate the humidity score as the distance from the humidity baseline. 89 | let humidityScore = 0; 90 | if (humidityOffset > 0) { 91 | humidityScore = (100 - humidityBaseline - humidityOffset) / (100 - humidityBaseline) * humidityWeighting; 92 | } else { 93 | humidityScore = (humidityBaseline + humidityOffset) / humidityBaseline * humidityWeighting; 94 | } 95 | 96 | // Calculate the air quality. 97 | const airQuality = gasResistanceScore + humidityScore; 98 | console.log(`Air quality (%): ${airQuality} - ${gasResistance}/${gasResistanceBaseline} (calibrated at ${startedAt.toTimeString()})`); 99 | 100 | const now = new Date(); 101 | 102 | logStream.write(`${now.toUTCString()};${gasResistance};${humidity}` + "\n"); 103 | 104 | (new DashboardApi()).reportAirQuality(airQuality); 105 | 106 | // Wait for the specified interval to elapse, before recalculating the air quality. 107 | await sleep(interval); 108 | } 109 | } catch (err) { 110 | console.error(`\nFailed to calculate air quality: ${err}`); 111 | } 112 | } 113 | 114 | // Measure the air quality with an interval of one second. 115 | measureAirQuality(1000); 116 | --------------------------------------------------------------------------------