├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── README.md ├── app.py ├── camera.py ├── config.json ├── growlab.service ├── index.jinja ├── requirements.txt ├── sample.sh ├── sensor-i2c.png ├── sensors.py ├── specimen.py └── sync-code.sh └── data-logger ├── .gitignore ├── README.md ├── dashboard.json ├── sender ├── main.py ├── requirements.txt └── sensors.py ├── stack.yml └── submit-sample ├── __init__.py ├── handler.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .venv -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Alex Ellis 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Official logo 2 | 3 | ## News! 4 | 5 | If you particpated in Growlab 2021, you can [register for our prize draw here](https://forms.gle/ekZuGnuWDfwpaEGd6) 6 | 7 | ## A global contest to grow seeds and share your progress with the Raspberry Pi 8 | 9 | My early growlab 10 | 11 | ![My green beans](https://pbs.twimg.com/media/Ey1ugNwWgAIyUiJ?format=jpg&name=small) 12 | > A capture from phototimer of my seed tray with a wide-angle camera positioned above 13 | 14 | ![My rig](https://pbs.twimg.com/media/E077X9mXEAY13LZ?format=jpg&name=small) 15 | > My own growlab laboratory 16 | 17 | ![Graduated seedlings](https://pbs.twimg.com/media/E1hw8EZX0AABJNG?format=jpg&name=small) 18 | > Graduated seedlings 19 | 20 | ## Videos 21 | 22 | * [Overview - Growing your own with the Raspberry Pi with Alex Ellis at GIFEE Day](https://www.youtube.com/watch?v=GE7kyi6kFJY) 23 | * [Live stream "Grow your own food with Raspberry Pi" - Alex Ellis & Richard Gee](https://www.youtube.com/watch?v=Ta_LBKpI5-0) 24 | 25 | ## How it works 26 | 27 | 1) Read the launch blog post: [Join the Grow Lab Challenge](https://blog.alexellis.io/the-grow-lab-challenge/). 28 | 3) Decide you're taking part by sending a PR and adding yourself to the "growlab Technicians" section below. 29 | 2) Find or buy the required components for the experiments you want to take part in (read all of this file to learn more) 30 | 4) Build your own `#growlab` using one of the designs, or customise it. And start growing and recording a timelapse. 31 | 5) Use the [#growlab hashtag](https://twitter.com/search?q=%23growlab&src=typed_query) and share as many pictures as you like. 32 | 6) Send a Pull Request and link to each Tweet to unlock each level. 33 | 7) At the conclusion of the growing period, we'll send some prizes from OpenFaaS Ltd and Pimoroni to entries at random for different tiers. 34 | 35 | > Note: Growlab 2021 concludes Sunday 3rd October to mark Harvest Festival in the United Kingdom. 36 | 37 | Prizes to be provided by: [OpenFaaS Ltd](https://openfaas.com) and [Pimoroni](https://www.pimoroni.com). Want to sponsor or provide prizes? Send an email to [alex@openfaas.com](mailto:alex@openfaas.com) 38 | 39 | ### Unlock each level 40 | 41 | Before you get started with your build, send a PR to list yourself as a "lab technician". 42 | 43 | Bronze - assemble your `#growlab` using one of the recommend designs or customise it. Tweet a photo of your build fully assembled. 44 | 45 | Silver - install the software and capture your first photo of your seed tray or pots. Tweet the photo. 46 | 47 | Gold - Wait until at least one of your seeds has germinated and grown into a seedling - around 2-3cm in height. Tweet the photo taken by the timelapse software. 48 | 49 | Platinum - use the [phototimer](https://github.com/alexellis/phototimer) or seeds2 software to capture images over 14 days. Compile the images into a timelapse and upload it to YouTube. We recommend one photo every 10 minutes. Feel free to exclude any photos prior to the seeds germinating. Tweet a link to the video. 50 | 51 | ## Growlab Technicians 52 | 53 | Technicians work in laboratories, and you are no different, so if you've bought your kit, or have decided to join, then add your details below so that we can encourage each other and see how many people are participating. If you don't have a Twitter or GitHub handle just put N/a. 54 | 55 | | # | Name | Twitter | GitHub | Live preview URL | Country | 56 | |---|---------------------|-----------------|---------------|------------------|----------| 57 | | 1 | Alex Ellis | [@alexellisuk](https://twitter.com/alexellisuk) | [alexellis](https://github.com/alexellis) | [Live preview with the growlab app](http://growlab.alexellis.io/) | United Kingdom | 58 | | 2 | Simon Emms | [@MrSimonEmms](https://twitter.com/MrSimonEmms) | [MrSimonEmms](https://github.com/MrSimonEmms) | [Images captured by phototimer](https://growlab.simonemms.com) | United Kingdom | 59 | | 3 | Richard Gee | [@rgee0](https://twitter.com/rgee0) | [rgee0](https://github.com/rgee0) | [Most recently captured image](https://growlab.technologee.co.uk/) | United Kingdom | 60 | | 4 | Jakob Waibel | [@jakobwaibel](https://twitter.com/jakobwaibel) | [JakWai01](https://github.com/JakWai01) | [Live preview with the growlab app](https://jakwai01.github.io/growlab/) | Germany | 61 | | 5 | Florian Clanet | [@FlolightC](https://twitter.com/FlolightC) | [Flolight](https://github.com/Flolight) | | 62 | | 6 | Felix Pojtinger | [@pojntfx](https://twitter.com/pojntfx) | [pojntfx](https://github.com/pojntfx) | | 63 | | 7 | Sam Perrin | [@sam_perrin](https://twitter.com/sam_perrin) | [sam-perrin](https://github.com/sam-perrin) | | United Kingdom | 64 | | 8 | Philippe Charrière | [@k33g_org](https://twitter.com/k33g_org) | [k33g](https://github.com/k33g) | | 65 | | 9 | John McCabe | [@mccabejohn](https://twitter.com/mccabejohn) | [johnmccabe](https://github.com/johnmccabe) | | United Kingdom | 66 | | 10 | Adam Craggs | [@abigpancake](https://twitter.com/abigpancake) | [agcraggs](https://github.com/agcraggs) | | United Kingdom | 67 | | 11 | Martin Woodward | [@martinwoodward](https://twitter.com/martinwoodward) | [martinwoodward](https://github.com/martinwoodward) | [Live preview](https://bfaulty.z16.web.core.windows.net/) | United Kingdom | 68 | | 12 | Jérôme Velociter | [@jvelo](https://twitter.com/jvelo) | [jvelo](https://github.com/jvelo) | | France | 69 | | 13 | Philippe Ensarguet | [@p_ensarguet](https://twitter.com/P_Ensarguet) | [pensarguet](https://github.com/pensarguet) | [Live preview](https://pensarguet.github.io/growlab-livepreview/) | France | 70 | | 14 | Sander Vanhove | [@SanderWaylay](https://twitter.com/SanderWaylay) | [SanderVanhove](https://github.com/SanderVanhove) | [Live preview](https://www.sandervanhove.com/plant-monitor) | 71 | | 15 | Sergei Vasilev | [@nblpblc](https://twitter.com/nblpblc) | [sergevas](https://github.com/sergevas) | [Live preview](https://growlab.sergevas.dev) | 72 | | 16 | Iheb | [@iboonox](https://twitter.com/iboonox) | [iboonox](https://github.com/iboonox) | [Live preview](https://iheb.io) | France | 73 | | 17 | Allan Pead | [@adpead](https://twitter.com/adpead) | [apead](https://github.com/apead) | | 74 | | 18 | Keith Hubner | [@keithhubner](https://twitter.com/keithhubner) | [keithhubner](https://github.com/keithhubner) | [Live preview](https://growlab.hubner.co.uk/) | 75 | | 19 | Antoine Mouchere | [@mouchere_a](https://twitter.com/mouchere_a) | [amouchere](https://github.com/amouchere/growlab-project#readme) | [Live preview](https://amouchere.github.io/growlab-preview/) | 76 | | 20 | Kyle Brennan | [@kylos101](https://twitter.com/kylos101/) | [kylos101](https://github.com/kylos101)| [Live Preview](https://kylos101.github.io/growlab/docs/) | 77 | | 21 | Ben Hughes | [@bwghghs](https://twitter.com/bwghghs) | [bwghughes](https://github.com/bwghughes)| [Live Preview](https://bwghughes.github.io/growlab/app/docs/) | 78 | | 22 | Carlos Panato | [@comedordexis](https://twitter.com/comedordexis) | [cpanato](https://github.com/cpanato)| [Live Preview](https://cpanato.dev/growlab-live/) | Germany | 79 | | 23 | Plant Holmes | [@plantholmes](https://twitter.com/plantholmes) | [scrimples](https://github.com/scrimples)| | | 80 | | 24 | Marian Horgan | [@always_marian](https://twitter.com/always_marian) | [Quarkiness](https://github.com/Quarkiness)| | Ireland | 81 | | 25 | Felipe Cruz | [@felipecruz](https://twitter.com/felipecruz) | [felipecruz91](https://github.com/felipecruz91)| [Live preview with the growlab app](https://felipecruz91.github.io/growlab) | Spain | 82 | | 26 | Iván Gómez | [@ivanusatuiter](https://twitter.com/ivanusatuiter) | [igomezal](https://github.com/igomezal)| [Live Preview](https://igomezal.github.io/growlab/) | Spain | 83 | | 27 | Thibault Jochem | [@tryumk](https://twitter.com/tryumk) | [tryum](https://github.com/Tryum) | N/A (yet) | France | 84 | | 28 | Peter Dongo | [@TheDaN997](https://twitter.com/TheDaN997) | [dpeter79](https://github.com/dpeter79) | [Live preview with the growlab app](https://dpeter79.github.io/growlab/) | Hungary | 85 | | 29 | Dalton Cole | [@LessTechnology](https://twitter.com/LessTechnology) | [dalton-cole](https://github.com/dalton-cole) | [Live preview](https://raspberry.farm) | United States | 86 | | 30 | Beril Kurt| [@berlonics](https://twitter.com/berlonics) | [berlonics](https://github.com/berlonics) | | Germany | 87 | | 31 | Cameron Bunce | | [cameronbunce](https://github.com/cameronbunce) | coming soon | United States | 88 | ### Live preview URLs 89 | 90 | A live preview URL keeps things interesting and lets the community get a view inside your lab. 91 | 92 | See the new [growlab app](/app) for your Raspberry Pi 93 | 94 | ## Contest entries `#growlab` 🥇🥈🥉 95 | 96 | | Name | Bronze | Silver | Gold | Platinum | 97 | |-------|----------|----------|--------|----------| 98 | | Alex Ellis | [Bronze](https://twitter.com/alexellisuk/status/1380227185894690823) | [Silver](https://twitter.com/alexellisuk/status/1380227185894690823) | [Gold](https://twitter.com/alexellisuk/status1380417347861774337) | [Platinum](https://www.youtube.com/watch?v=YiFUVAP0B18) | 99 | | Simon Emms | [Bronze](https://twitter.com/MrSimonEmms/status/1386361659187412996) | [Silver](https://twitter.com/MrSimonEmms/status/1386361659187412996) | [Gold](https://twitter.com/MrSimonEmms/status/1391832940225630212) | [Platinum](https://twitter.com/MrSimonEmms/status/1391832943170146313) | 100 | | Richard Gee | [Bronze](https://twitter.com/rgee0/status/1383379807585521665) | [Silver](https://twitter.com/rgee0/status/1383379805928759301) | [Gold](https://twitter.com/rgee0/status/1384765411913355265) | [Platinum](https://twitter.com/rgee0/status/1393890208446402561) | 101 | | Jakob Waibel | [Bronze](https://twitter.com/jakobwaibel/status/1386372010658443265) | [Silver](https://twitter.com/jakobwaibel/status/1386372010658443265) | [Gold](https://twitter.com/jakobwaibel/status/1388894057955479554) | [Platinum](https://www.youtube.com/watch?v=z8sY37OlFrw) | 102 | | Florian Clanet | [Bronze](https://twitter.com/FlolightC/status/1384587367785369602) | [Silver](https://twitter.com/FlolightC/status/1383802323164561418) | | | 103 | | Felix Pojtinger | | | | 104 | | Sam Perrin | [Bronze](https://twitter.com/sam_perrin/status/1391383693860687876) | [Silver](https://twitter.com/sam_perrin/status/1391383693860687876) | [Gold](https://twitter.com/sam_perrin/status/1393898134527414272) | | 105 | | Philippe Charrière | | | | 106 | | John McCabe | [Bronze](https://twitter.com/mccabejohn/status/1387001148419227648) | [Silver](https://twitter.com/mccabejohn/status/1396484258403950598) | | | 107 | | Adam Craggs | [Bronze](https://twitter.com/ABigPancake/status/1387414840914894851) | | | | 108 | | Martin Woodward | [Bronze](https://twitter.com/martinwoodward/status/1388813602828730369) | [Silver](https://twitter.com/martinwoodward/status/1388887836196098049)| [Gold](https://twitter.com/martinwoodward/status/1392376722612445184) | | 109 | | Jérôme Velociter | [Bronze](https://twitter.com/jvelo/status/1393892964305448961) | | | | 110 | | Philippe Ensarguet | [Bronze](https://twitter.com/P_Ensarguet/status/1391382014557933569) |[Silver](https://twitter.com/P_Ensarguet/status/1395743166507139072) |[Gold](https://twitter.com/P_Ensarguet/status/1401155745157206018) |[Platinum](https://twitter.com/P_Ensarguet/status/1401151522692534281) | 111 | | Sander Vanhove | [Bronze](https://twitter.com/SanderWaylay/status/1391665619616026624) | [Silver](https://twitter.com/SanderWaylay/status/1397853230315540482) | [Gold](https://twitter.com/SanderWaylay/status/1400716134094098433) | | 112 | | Sergei Vasilev | [Bronze](https://twitter.com/nblpblc/status/1398195911390633984) |[Silver](https://twitter.com/nblpblc/status/1398195911390633984) |[Gold](https://twitter.com/nblpblc/status/1398617422400208900) |[Platinum](https://twitter.com/nblpblc/status/1404772425691111427) | 113 | | Iheb | [Bronze](https://twitter.com/iboonox/status/1393532825031397377) | [Silver](https://twitter.com/iboonox/status/1393532908166602753)| [Gold](https://twitter.com/iboonox/status/1395105689161412614) | [Platinium](https://youtu.be/M2rNWlyxRKA) | 114 | | Allan Pead | [Bronze](https://twitter.com/adpead/status/1393984772381229059) | [Silver](https://twitter.com/adpead/status/1394906481515024385) | | | 115 | | Keith Hubner | [Bronze](https://twitter.com/keithhubner/status/1393611768660963335) | [Silver](https://twitter.com/keithhubner/status/1394203963352887301) | [Gold](https://twitter.com/keithhubner/status/1394608874636914690) | | 116 | | Antoine Mouchere | [Bronze](https://twitter.com/mouchere_a/status/1428079585757995015) | [Silver](https://twitter.com/mouchere_a/status/1437878692882100233) | [Gold](https://twitter.com/mouchere_a/status/1437878700763230213)|[Platinium](https://www.youtube.com/watch?v=0tuGF8XQPz4) | 117 | | Kyle Brennan | | | | | 118 | | Carlos Panato | [Bronze](https://twitter.com/comedordexis/status/1398203436559130624) | [Silver](https://twitter.com/comedordexis/status/1398203436559130624) | | | 119 | | Felipe Cruz | [Bronze](https://twitter.com/felipecruz/status/1401197347586068482) | [Silver](https://twitter.com/felipecruz/status/1401197973929873410) | | | 120 | | Marian Horgan | [Bronze](https://twitter.com/always_marian/status/1402967317907226635) | [Silver](https://twitter.com/always_marian/status/1402967860171915269) | [Gold](https://twitter.com/always_marian/status/1405978590714413057) | | 121 | | Thibault Jochem | [Bronze](https://twitter.com/Tryumk/status/1414234033668730886) | | | | 122 | | Peter Dongo | [Bronze](https://twitter.com/TheDaN997/status/1415298756757573637) | [Silver](https://twitter.com/TheDaN997/status/1415299162623594499) | [Gold](https://twitter.com/TheDaN997/status/1422856683575988225) | [Platinum](https://twitter.com/TheDaN997/status/1422857244706807810) | 123 | | Iván Gómez | [Bronze](https://twitter.com/ivanusatuiter/status/1401564344656216069) | [Silver](https://twitter.com/ivanusatuiter/status/1416383863606157312) | [Gold](https://twitter.com/ivanusatuiter/status/1416405907752247299) | | 124 | | Dalton Cole | [Bronze](https://twitter.com/LessTechnology/status/1421238078241484801) | [Silver](https://twitter.com/LessTechnology/status/1421241626354192395) | | | 125 | 126 | ## Official growlab apps 127 | 128 | We have three experiments that you can take part in: 129 | 130 | 1) [phototimer - Record images for the timelapse contest](https://github.com/alexellis/phototimer) 131 | 132 | You'll need a Raspberry Pi Zero or greater with a camera module. This is required for the contest. 133 | 134 | 2) [live preview app - Generate and upload a live-preview with sensor data growlab app](/app) 135 | 136 | You'll need a Raspberry Pi Zero or greater with a camera module. If you have a BME280 sensor or BMP280 sensor, then you can add sensor data to the live preview image. This is optional for the contest, but recommended so that you can share with the community. 137 | 138 | 3) [data-logger - Capture environment data and plot on a Grafana dashboard](/data-logger) 139 | 140 | You'll need an RPi 3 or 4 to run the time series database, and dashboard. Then you can attach a sensor to this unit directly, or have a number of other Raspberry Pi Zeros or greater with the BME280 sensor or BMP280 sensor. 141 | 142 | The data-logger does not require you to grow any plants, you can even use it to monitor your home and garden temperatures during the year. This experiment is optional. 143 | 144 | ### Focusing your camera 145 | 146 | Some cameras like the HQ camera are variable focus, the cheaper lenses can also have their glue broken to enable them to close focus. 147 | 148 | Low latency with built-in web-browser: 149 | 150 | ["Raspberry Pi High Quality Camera setup for low-latency Video Conferencing"](http://www.davidhunt.ie/raspberry-pi-high-quality-camera-setup-for-low-latency-video-conferencing/) 151 | 152 | Quick and functional: 153 | 154 | ```bash 155 | # On your Raspberry Pi 156 | raspivid -t 0 -w 1280 -h 720 -fps 10 -o - | nc -lkv4 8080 157 | 158 | # On your computer, enter this URL into VLC under "Network Stream" 159 | # Replace with your Raspberry Pi's IP 160 | tcp/h264://192.168.0.53:8080 161 | ``` 162 | 163 | ### Making your timelapse 164 | 165 | If you're using phototimer, then you can run the following with ffmpeg. It's advised that you copy the images to your PC before running the command since the Raspberry Pi Zero is rather slow at crunching videos. 166 | 167 | ```bash 168 | echo $(echo $(find ./Desktop/image/ | sort -V|grep jpg)) | xargs cat | ffmpeg -framerate 10 -f image2pipe -vcodec mjpeg -i - -vcodec libx264 out.mp4 169 | ``` 170 | 171 | iMovie is also relatively easy to use, by dragging the images into the timeline and changing the time between images to ~ 0.1s 172 | 173 | Here's a sample from 9th-22nd April you can watch on YouTube: 174 | 175 | [![](https://img.youtube.com/vi/YiFUVAP0B18/hqdefault.jpg)](https://www.youtube.com/watch?v=YiFUVAP0B18) 176 | 177 | [Click here](https://www.youtube.com/watch?v=YiFUVAP0B18) to watch my video timelapse 178 | 179 | ### Extra points and taking things further 180 | 181 | ![Self-watering system](https://pbs.twimg.com/media/EzZ1vDsXMAgNQKF?format=jpg&name=small) 182 | > A self-watering system 183 | 184 | * Overlay temperature and humidity data with a Bosch BME280 or BMP280 sensor 185 | * Add a self-watering system with a small pump and capacitive soil sensor 186 | * Try a garden RGB grow-light to give your seeds a little more help 187 | * Experiment with hydroponics 188 | * Install your lab in an outdoor greenhouse, shed or cold-frame 189 | * Use a light sensor / LDR or UV sensor measure available light 190 | * Try a suitable solar panel and battery capacity to run your experiment outdoors or in a room without a socket 191 | 192 | ## Community projects and add-ons 193 | 194 | * phototimer for capturing photos from the RPi camera for a timelapse: [alexellis/phototimer](https://github.com/alexellis/phototimer) 195 | * Richard Gee's seeds2 repo for tweeting and capturing images: [rgee0/seeds2](https://github.com/rgee0/seeds2) 196 | * Sam Perrin's seed-viewer for viewing the images captured with phototimer [sam-perrin/seed-viewer](https://github.com/sam-perrin/seed-viewer) 197 | * Sander Vanhove's plant-monitor using Waylay: [SanderVanhove/plant-monitor](https://github.com/SanderVanhove/plant-monitor) 198 | * Felipe Cruz's repo for displaying sensor readings in an OLED i2c screen: [felipecruz91/growlab-oled](https://github.com/felipecruz91/growlab-oled) 199 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /.ssh/* 2 | /roboto/* 3 | /html/* 4 | /image.jpg 5 | /preview.jpg 6 | 7 | /__pycache__/** 8 | -------------------------------------------------------------------------------- /app/README.md: -------------------------------------------------------------------------------- 1 | # `#growlab` app for Raspberry Pi 2 | 3 | Record a timelapse and live preview image with sensor data from a Bosch BME280 or BMP280 sensor 4 | 5 | * The BME280 costs slightly more and measures: temperature, humidity and air pressure. 6 | * The BMP can only measure temperature and air pressure. 7 | 8 | ![](https://pbs.twimg.com/media/E0DwywWXoAET9dK?format=jpg&name=medium) 9 | > Example HTML output which can be synced to GitHub Pages, an S3 bucket, or served directly from the RPi using [inlets](https://docs.inlets.dev/) 10 | 11 | See also: [app roadmap](https://github.com/alexellis/growlab/issues/15) 12 | 13 | ## Assembling the build 14 | 15 | * You'll need a Raspberry Pi Zero W or any other Raspberry Pi. 16 | * An RPi camera connected - any version 17 | * A Bosch BME280 or BMP280 sensor connected to GND, VCC SDL and SCL. 18 | 19 | ![How to connect the sensor over i2c](sensor-i2c.png) 20 | > How to connect the sensor over i2c 21 | 22 | ### Configuring the RPi 23 | 24 | Using `raspi-config` 25 | 26 | * Set your hostname such as `growpi` 27 | * Enable i2c under interfacing options 28 | * Change the password for the `pi` user 29 | 30 | ### Getting started with the software 31 | 32 | Install git, tmux, Python and font pages 33 | 34 | ```bash 35 | sudo apt update -qy && \ 36 | sudo apt install -qy python3 \ 37 | i2c-tools \ 38 | python3-pip \ 39 | git \ 40 | tmux \ 41 | libopenjp2-7 \ 42 | libopenjp2-7-dev \ 43 | libopenjp2-tools 44 | ``` 45 | 46 | > The `libopenjp2` package is for overlaying text on top of the images. 47 | 48 | Clone the repo: 49 | 50 | ```bash 51 | git clone https://github.com/alexellis/growlab 52 | cd growlab/app 53 | ``` 54 | 55 | Get the free Roboto font from Google's download page: 56 | 57 | ```bash 58 | curl -sSL https://github.com/googlefonts/roboto/releases/download/v2.138/roboto-unhinted.zip -o roboto.zip \ 59 | && unzip roboto.zip -d roboto \ 60 | && rm roboto.zip 61 | ``` 62 | 63 | Install Python modules with `pip3`: 64 | 65 | ```bash 66 | sudo pip3 install -r requirements.txt 67 | ``` 68 | 69 | Capture a test image to determine if you need a horizontal or vertical flip or not: 70 | 71 | ```bash 72 | # On the RPi 73 | raspistill -o growlab.jpg 74 | 75 | # From your PC: 76 | scp pi@growlab.local:~/growlab.jpg Desktop/ 77 | 78 | # On a Mac: 79 | open Desktop/growlab.jpg 80 | 81 | # On a Linux desktop: 82 | xdg-open Desktop/growlab.jpg 83 | ``` 84 | 85 | If needed, test again with `-vf` or `-hf` to flip the image. 86 | 87 | Edit the `config.json` file if needed and update the flip settings, and width and height to match the file that you got from your test `growlab.jpg` image. 88 | 89 | ```json 90 | { 91 | "images": { 92 | "output_dir": "./images/", 93 | "encoding": "jpeg", 94 | "width": 2592, 95 | "height": 1944, 96 | "image_quality": 70, 97 | "preview_seconds": 1, 98 | "vertical_flip": false, 99 | "horizontal_flip": false, 100 | "interval_seconds": 600 101 | }, 102 | "text": { 103 | "colour": { 104 | "red": 255, 105 | "green": 255, 106 | "blue": 255 107 | }, 108 | "size": 48 109 | } 110 | } 111 | ``` 112 | 113 | Capture a test photo and HTML page. You'll see the files generated in the `html` folder as `image.jpg` and `index.html`. 114 | 115 | ```bash 116 | python3 app.py 117 | ``` 118 | 119 | If you have no sensors, then run: 120 | 121 | ```bash 122 | export SENSOR_TYPE=none 123 | python3 app.py 124 | ``` 125 | 126 | If you have the BMP280, then run this instead: 127 | 128 | ```bash 129 | export SENSOR_TYPE=bmp280 130 | python3 app.py 131 | ``` 132 | 133 | ### Serve a preview with GitHub pages 134 | 135 | Configure GitHub pages and / or a custom domain using the CNAME approach 136 | 137 | Set the folder for serving content to "docs" 138 | 139 | Generate an SSH key: 140 | 141 | ```bash 142 | cd growlab/app 143 | mkdir -p .ssh 144 | 145 | ssh-keygen -f `pwd`/.ssh/id_rsa 146 | ``` 147 | 148 | Remove the HTTPS git remote and add a SSH one, changing "alexellis" to your own name. 149 | 150 | ```bash 151 | git remote rm origin 152 | git remote add origin git@github.com:alexellis/growlab.git 153 | ``` 154 | 155 | Configure your git user/email: 156 | 157 | ``` 158 | git config --global user.name 'YOUR_USERNAME' 159 | git config --global user.email 'YOUR_EMAIL' 160 | ``` 161 | 162 | Go to the repo settings and add the deploy key and check *Allow write access* 163 | 164 | Now run the sample.sh bash script. Feel free to view its contents to see how it works 165 | 166 | ```bash 167 | mkdir -p docs 168 | cd growlab/app 169 | 170 | ./sample.sh 171 | ``` 172 | 173 | You can also put this into a loop to run every 10 minutes: 174 | 175 | ```bash 176 | while [ true ] ; do ./sample.sh && echo "waiting 10 minutes" && sleep 600 ; done 177 | ``` 178 | 179 | ### Install growlab as a service 180 | 181 | In the `growlab.service` file change the line where says `Environment="SENSOR_TYPE=none"` if you are using a sensor 182 | then change for the sensor you have, ie. if you have BMP280 change for `Environment="SENSOR_TYPE=bmp280"` 183 | 184 | Install the systemd service: 185 | 186 | ```bash 187 | chmod +x app.py 188 | sudo cp growlab.service /etc/systemd/system 189 | sudo systemctl enable growlab 190 | sudo systemctl start growlab 191 | ``` 192 | -------------------------------------------------------------------------------- /app/app.py: -------------------------------------------------------------------------------- 1 | #!/bin/python3 2 | 3 | import json 4 | import os, sys 5 | from sensors import growbme280, growbmp280, grownosensor 6 | from camera import camera 7 | from specimen import specimen 8 | 9 | if __name__ == "__main__": 10 | print("Starting growlab") 11 | 12 | config = {} 13 | try: 14 | with open("./config.json") as f: 15 | config = json.loads(f.read()) 16 | except Exception as e: 17 | sys.stderr.write("Error: {}".format(e)) 18 | sys.exit(1) 19 | 20 | print("Loaded config, saving images every {} seconds to {}".format( config["images"]["interval_seconds"], config["images"]["output_directory"])) 21 | 22 | sensor = None 23 | sensor_type = os.getenv("SENSOR_TYPE", "bme280") 24 | if sensor_type == "bme280": 25 | sensor = growbme280() 26 | if sensor_type == "bmp280": 27 | sensor = growbmp280() 28 | elif sensor_type == "none": 29 | sensor = grownosensor() 30 | 31 | readings = sensor.get_readings() 32 | print(readings) 33 | 34 | cam = camera(config["images"]) 35 | frame = cam.get_frame() 36 | 37 | pwd = os.getcwd() 38 | output_path = pwd + "/html" 39 | 40 | try: 41 | os.mkdir(output_path) 42 | except: 43 | pass 44 | 45 | spec = specimen(config["text"], config["images"]) 46 | spec.save_image("{}/image.jpg".format(pwd), frame, readings) 47 | 48 | spec.save_html("{}/image.jpg".format(pwd), output_path, readings) 49 | -------------------------------------------------------------------------------- /app/camera.py: -------------------------------------------------------------------------------- 1 | import picamera 2 | import io 3 | import time 4 | 5 | class camera: 6 | def __init__(self, camera_opts): 7 | self.camera_opts = camera_opts 8 | 9 | def get_frame(self): 10 | stream = io.BytesIO() 11 | with picamera.PiCamera() as camera: 12 | camera.start_preview() 13 | camera.vflip = self.camera_opts["vertical_flip"] 14 | camera.hflip = self.camera_opts["horizontal_flip"] 15 | camera.meter_mode = self.camera_opts["meter_mode"] 16 | camera.exposure_mode = "auto" 17 | camera.resolution = (self.camera_opts["width"], self.camera_opts["height"]) 18 | # Camera warm-up time 19 | time.sleep(self.camera_opts["preview_seconds"]) 20 | camera.capture(stream, format=self.camera_opts["encoding"], quality=self.camera_opts["image_quality"]) 21 | 22 | return stream 23 | -------------------------------------------------------------------------------- /app/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": { 3 | "output_directory": "./images/", 4 | "encoding": "jpeg", 5 | "width": 2592, 6 | "height": 1944, 7 | "image_quality": 70, 8 | "preview_seconds": 1, 9 | "vertical_flip": false, 10 | "horizontal_flip": false, 11 | "meter_mode": "matrix", 12 | "interval_seconds": 600 13 | }, 14 | "text": { 15 | "colour": { 16 | "red": 255, 17 | "green": 255, 18 | "blue": 255 19 | }, 20 | "size": 48 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/growlab.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Growlab 3 | After=network.target 4 | 5 | [Service] 6 | Type=simple 7 | User=pi 8 | Group=pi 9 | Restart=always 10 | 11 | # Every 600 seconds (or 10 minutes) 12 | RestartSec=600 13 | 14 | StartLimitInterval=0 15 | 16 | # Sensor type: "none", "BME280" or "BMP280" 17 | Environment="SENSOR_TYPE=BME280" 18 | WorkingDirectory=/home/pi/growlab/app 19 | ExecStart=/home/pi/growlab/app/sample.sh 20 | 21 | [Install] 22 | WantedBy=multi-user.target 23 | -------------------------------------------------------------------------------- /app/index.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 17 | {{'#'}}GrowLab live 18 | 19 | 20 | 26 |
27 |
28 |
29 |
30 |
31 |
32 | Welcome to your live monitoring 33 |
34 |
35 |
growlab is a community project to monitor nature with technology
36 |

Generated with the growlab app

37 | 38 |
Latest readings
39 |
40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 |
ReadingValue
Time{{ time }}
Temperature{{ temperature }}
Humidity{{ humidity }}
Pressure{{ pressure }}
66 |
67 | 68 |
Latest image
69 |
70 | 71 |
72 |
73 |
74 |
75 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /app/requirements.txt: -------------------------------------------------------------------------------- 1 | smbus 2 | pimoroni-bme280 3 | bmp280 4 | picamera 5 | pillow 6 | Jinja2 -------------------------------------------------------------------------------- /app/sample.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | python3 app.py 3 | 4 | export GIT_SSH_COMMAND="ssh -i `pwd`/.ssh/id_rsa" 5 | 6 | cp ${HOME}/growlab/app/html/* ${HOME}/growlab/docs/ 7 | 8 | git add . 9 | 10 | git commit -s -m "Update images at `date`" 11 | git pull origin master --rebase 12 | git push origin master 13 | 14 | -------------------------------------------------------------------------------- /app/sensor-i2c.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexellis/growlab/ce60e75acddde67e7258ea3f788c73bd076043ad/app/sensor-i2c.png -------------------------------------------------------------------------------- /app/sensors.py: -------------------------------------------------------------------------------- 1 | try: 2 | from smbus2 import SMBus 3 | except ImportError: 4 | from smbus import SMBus 5 | from bme280 import BME280 6 | from bmp280 import BMP280 7 | 8 | import time 9 | 10 | class grownosensor: 11 | def __init__(self): 12 | pass 13 | 14 | def get_readings(self): 15 | time_str = time.strftime("%H:%M:%S") 16 | 17 | return { 18 | "time": time_str, 19 | } 20 | 21 | class growbme280: 22 | def __init__(self): 23 | self.bus = SMBus(1) 24 | self.sensor = BME280(i2c_dev=self.bus) 25 | 26 | def get_readings(self): 27 | # Ignore first result since it seems stale 28 | temperature = self.sensor.get_temperature() 29 | pressure = self.sensor.get_pressure() 30 | humidity = self.sensor.get_humidity() 31 | time.sleep(0.1) 32 | 33 | temperature = self.sensor.get_temperature() 34 | pressure = self.sensor.get_pressure() 35 | humidity = self.sensor.get_humidity() 36 | time_str = time.strftime("%H:%M:%S") 37 | 38 | return { 39 | "time": time_str, 40 | "temperature": temperature, 41 | "pressure": pressure, 42 | "humidity": humidity 43 | } 44 | 45 | class growbmp280: 46 | def __init__(self): 47 | self.bus = SMBus(1) 48 | self.sensor = BMP280(i2c_dev=self.bus) 49 | 50 | def get_readings(self): 51 | # Ignore first result since it seems stale 52 | temperature = self.sensor.get_temperature() 53 | pressure = self.sensor.get_pressure() 54 | time.sleep(0.1) 55 | 56 | temperature = self.sensor.get_temperature() 57 | pressure = self.sensor.get_pressure() 58 | time_str = time.strftime("%H:%M:%S") 59 | 60 | return { 61 | "time": time_str, 62 | "temperature": temperature, 63 | "pressure": pressure, 64 | } 65 | -------------------------------------------------------------------------------- /app/specimen.py: -------------------------------------------------------------------------------- 1 | from PIL import Image, ImageFont, ImageDraw 2 | from jinja2 import Template 3 | import time 4 | 5 | class specimen: 6 | def __init__(self, text_config, image_config): 7 | self.text_config = text_config 8 | self.image_config = image_config 9 | 10 | def save_image(self, filename, image, readings): 11 | with open(filename, 'wb') as file: 12 | file.write(image.getvalue()) 13 | 14 | msg = self.format(readings) 15 | 16 | img = Image.open(filename, "r").convert("RGBA") 17 | img_draw = ImageDraw.Draw(img) 18 | font = ImageFont.truetype('roboto/Roboto-Regular.ttf', self.text_config["size"]) 19 | colour = (self.text_config["colour"]["red"] ,self.text_config["colour"]["green"], self.text_config["colour"]["blue"]) 20 | 21 | text_size = img_draw.textsize(msg, font) 22 | 23 | pos = (10, 20) 24 | bg_size = (text_size[0]+30, text_size[1]+50) 25 | bg_img = Image.new('RGBA', img.size, (0, 0, 0, 0)) 26 | 27 | bg_draw = ImageDraw.Draw(bg_img) 28 | overlay_transparency = 100 29 | bg_draw.rectangle((pos[0], pos[1], bg_size[0], bg_size[1]), fill=(0, 0, 0, overlay_transparency), outline=(255, 255, 255)) 30 | bg_draw.text(xy=(pos[0]+10, pos[1]+10), text=msg, fill=colour, font=font) 31 | 32 | out = Image.alpha_composite(img, bg_img) 33 | print("Saving {}..".format(filename)) 34 | r = out.convert('RGB') 35 | r.save(filename, "JPEG") 36 | print("Saved {}..OK".format(filename)) 37 | 38 | def format(self, readings): 39 | degree_symbol=u"\u00b0" 40 | msg = "#growlab - {}\n".format(readings["time"]) 41 | if "temperature" in readings: 42 | msg = msg + " Temperature: {:05.2f}{}C \n".format(readings["temperature"],degree_symbol) 43 | if "pressure" in readings: 44 | msg = msg + " Pressure: {:05.2f}hPa \n".format(readings["pressure"]) 45 | if "humidity" in readings: 46 | msg = msg + " Humidity: {:05.2f}% \n".format(readings["humidity"]) 47 | 48 | return msg.rstrip() + " " 49 | 50 | def save_html(self, input_filename, output_path, readings): 51 | img = Image.open(input_filename, "r") 52 | 53 | img = img.resize((int(self.image_config["width"]/2), int(self.image_config["height"]/2)), Image.ANTIALIAS) 54 | img.save(output_path+"/preview.jpg", "JPEG") 55 | 56 | template_text = "" 57 | with open("index.jinja", 'r') as file: 58 | template_text = file.read() 59 | 60 | template = Template(template_text) 61 | degree_symbol=u"\u00b0" 62 | 63 | vals = {} 64 | vals["time"] = readings["time"] 65 | if "temperature" in readings: 66 | vals["temperature"] = "{:05.2f}{}C".format(readings["temperature"], degree_symbol) 67 | else: 68 | vals["temperature"] = "N/A" 69 | 70 | if "humidity" in readings: 71 | vals["humidity"] = "{:05.2f}%".format(readings["humidity"]) 72 | else: 73 | vals["humidity"] = "N/A" 74 | 75 | if "pressure" in readings: 76 | vals["pressure"] = "{:05.2f}hPa".format(readings["pressure"]) 77 | else: 78 | vals["pressure"] = "N/A" 79 | 80 | vals["uid"] = "{}".format(time.time()) 81 | 82 | html = template.render(vals) 83 | with open(output_path+"/index.html", "w") as html_file: 84 | html_file.write(html) 85 | print("Wrote {}..OK".format(output_path+"/index.html")) 86 | -------------------------------------------------------------------------------- /app/sync-code.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # sudo apt install inotify-tools 4 | 5 | export RPI=growpi.local 6 | 7 | while inotifywait -r ./*; do 8 | rsync -ravz ../app pi@$RPI:~/ 9 | done 10 | -------------------------------------------------------------------------------- /data-logger/.gitignore: -------------------------------------------------------------------------------- 1 | /template 2 | /build 3 | -------------------------------------------------------------------------------- /data-logger/README.md: -------------------------------------------------------------------------------- 1 | # BME280 Data Logger with faasd 2 | 3 | This data logger writes measurements from a Bosch BME280 or BMP280 sensor into a InfluxDB time-series database. 4 | 5 | You'll need one Raspberry Pi 3 or 4 to run the data collection stack and dashboard, then as many additional Raspberry Pi Zeros as you like for each location you want to monitor. 6 | 7 | ## The hardware 8 | 9 | ![How to connect the sensor over i2c](../app/sensor-i2c.png) 10 | > How to connect the sensor over i2c 11 | 12 | For the hardware, follow the instructions from the [growlab app](../app/) under "Assembling the build". You'll need the same parts, but you can skip the camera module. 13 | 14 | ## Deployment 15 | 16 | You will run faasd on your Raspberry Pi 2, 3 or 4 to store data readings and to run OpenFaaS and Grafana. 17 | 18 | On your Raspberry Pi Zero, or whichever host has a sensor connected to it, you'll run the sender app. 19 | 20 | ### Deploy faasd 21 | 22 | Deploy faasd to your Raspberry Pi 3 or 4 [using these instructions](https://github.com/openfaas/faasd) 23 | 24 | Customise the password, and update ` /var/lib/faasd/docker-compose.yaml` 25 | 26 | ```yaml 27 | influxdb: 28 | image: docker.io/library/influxdb:1.8 29 | environment: 30 | - INFLUXDB_DB=defaultdb 31 | - INFLUXDB_ADMIN_USER=admin 32 | - "INFLUXDB_ADMIN_PASSWORD=PASSWORD" 33 | - INFLUXDB_USER=user 34 | - "INFLUXDB_USER_PASSWORD=PASSWORD" 35 | - INFLUXDB_REPORTING_DISABLED=true 36 | - INFLUXDB_HTTP_AUTH_ENABLED=true 37 | - INFLUXDB_HTTP_BIND_ADDRESS=0.0.0.0:8086 38 | volumes: 39 | # we assume cwd == /var/lib/faasd 40 | - type: bind 41 | source: ./influxdb/ 42 | target: /var/lib/influxdb 43 | user: "1000" 44 | cap_add: 45 | - CAP_NET_RAW 46 | ports: 47 | - "0.0.0.0:8086:8086" 48 | ``` 49 | 50 | Make a directory for InfluxDB: 51 | 52 | ```bash 53 | mkdir -p /var/lib/faasd/influxdb 54 | chown 1000:1000 /var/lib/faasd/influxdb 55 | ``` 56 | 57 | Then reload and restart: 58 | 59 | ```bash 60 | sudo systemctl daemon-reload \ 61 | && sudo systemctl restart faasd 62 | ``` 63 | 64 | ### Deploy the function 65 | 66 | Create a secret for the InfluxDB user: 67 | 68 | ```bash 69 | export PASSWORD="" 70 | 71 | faas-cli secret create influx-pass --from-literal $PASSWORD 72 | faas-cli secret create influx-user --from-literal admin 73 | ``` 74 | 75 | Set the influx_db env-var in `stack.yml` i.e. `192.168.0.21` 76 | 77 | Next, deploy the function: 78 | 79 | ```bash 80 | faas-cli deploy 81 | ``` 82 | 83 | Build if you like: 84 | 85 | ```bash 86 | faas-cli publish -f stack.yml --platforms linux/arm/7 87 | ``` 88 | 89 | ### Deploy the sender onto your Raspberry Pi with a sensor 90 | 91 | On your Raspberry Pi with the sensor, you'll run the "sender" app. 92 | 93 | Enable i2c and change the hostname as required using the `raspi-config` tool. 94 | 95 | If you haven't installed the main growlab app for live-previews, then install the below dependencies: 96 | 97 | ```bash 98 | sudo apt update -qy && \ 99 | sudo apt install -qy python3 \ 100 | i2c-tools \ 101 | python3-pip \ 102 | git \ 103 | tmux 104 | ``` 105 | 106 | Clone the growlab app: 107 | 108 | ```bash 109 | git clone https://github.com/alexellis/growlab 110 | cd growlab/data-logger/sender/ 111 | ``` 112 | 113 | Install any pip modules required for the sender app: 114 | 115 | ```bash 116 | pip3 install -r requirements.txt 117 | ``` 118 | 119 | Then run the sender app: 120 | 121 | ```bash 122 | FUNCTION_URL=http://192.168.0.21:8080/function/submit-sample \ 123 | SENSOR=my-shed \ 124 | python3 main.py 125 | ``` 126 | 127 | > Note: if you're using a BMP280 sensor then add an addition environment variable of `SENSOR=bmp280` 128 | 129 | A sensor reading will be submitted to faasd every 30 seconds. You can alter this sample interval by editing [sender/main.py](sender/main.py). 130 | 131 | ## Going further with a dashboard 132 | 133 | Deploy Grafana to faasd using the instructions in the [eBook Serverless For Everyone Else](https://gumroad.com/l/serverless-for-everyone-else) 134 | 135 | Then create yourself a simple dashboard for the measurements you see in the "readings" database. 136 | 137 | Once you have it up and running, create a datasource, then import the dashboard.json file and open the dashboard to view your sensor readings. 138 | 139 | ![A very cold shed](https://pbs.twimg.com/media/E0H6WhfXIAAMOR3?format=jpg&name=medium) 140 | > My very cold shed - measured overnight! 141 | 142 | A larger version in the summer time: 143 | 144 | ![Getting very warm in my shed](https://pbs.twimg.com/media/E6feyrJWYAMc4l2?format=jpg&name=large) 145 | -------------------------------------------------------------------------------- /data-logger/dashboard.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "builtIn": 1, 6 | "datasource": "-- Grafana --", 7 | "enable": true, 8 | "hide": true, 9 | "iconColor": "rgba(0, 211, 255, 1)", 10 | "name": "Annotations & Alerts", 11 | "type": "dashboard" 12 | } 13 | ] 14 | }, 15 | "editable": true, 16 | "gnetId": null, 17 | "graphTooltip": 0, 18 | "id": 1, 19 | "links": [], 20 | "panels": [ 21 | { 22 | "aliasColors": {}, 23 | "bars": false, 24 | "dashLength": 10, 25 | "dashes": false, 26 | "datasource": null, 27 | "description": "", 28 | "fieldConfig": { 29 | "defaults": {}, 30 | "overrides": [] 31 | }, 32 | "fill": 1, 33 | "fillGradient": 0, 34 | "gridPos": { 35 | "h": 8, 36 | "w": 12, 37 | "x": 0, 38 | "y": 0 39 | }, 40 | "hiddenSeries": false, 41 | "id": 2, 42 | "legend": { 43 | "avg": false, 44 | "current": false, 45 | "max": false, 46 | "min": false, 47 | "show": true, 48 | "total": false, 49 | "values": false 50 | }, 51 | "lines": true, 52 | "linewidth": 1, 53 | "nullPointMode": "null", 54 | "options": { 55 | "alertThreshold": true 56 | }, 57 | "percentage": false, 58 | "pluginVersion": "7.5.5", 59 | "pointradius": 2, 60 | "points": false, 61 | "renderer": "flot", 62 | "seriesOverrides": [], 63 | "spaceLength": 10, 64 | "stack": false, 65 | "steppedLine": false, 66 | "targets": [ 67 | { 68 | "groupBy": [ 69 | { 70 | "params": [ 71 | "$__interval" 72 | ], 73 | "type": "time" 74 | }, 75 | { 76 | "params": [ 77 | "sensor" 78 | ], 79 | "type": "tag" 80 | }, 81 | { 82 | "params": [ 83 | "linear" 84 | ], 85 | "type": "fill" 86 | } 87 | ], 88 | "measurement": "temp", 89 | "orderByTime": "ASC", 90 | "policy": "default", 91 | "refId": "A", 92 | "resultFormat": "time_series", 93 | "select": [ 94 | [ 95 | { 96 | "params": [ 97 | "value" 98 | ], 99 | "type": "field" 100 | }, 101 | { 102 | "params": [], 103 | "type": "mean" 104 | } 105 | ] 106 | ], 107 | "tags": [] 108 | } 109 | ], 110 | "thresholds": [], 111 | "timeFrom": null, 112 | "timeRegions": [], 113 | "timeShift": null, 114 | "title": "Ambient Temperature", 115 | "tooltip": { 116 | "shared": true, 117 | "sort": 0, 118 | "value_type": "individual" 119 | }, 120 | "type": "graph", 121 | "xaxis": { 122 | "buckets": null, 123 | "mode": "time", 124 | "name": null, 125 | "show": true, 126 | "values": [] 127 | }, 128 | "yaxes": [ 129 | { 130 | "$$hashKey": "object:282", 131 | "format": "celsius", 132 | "label": null, 133 | "logBase": 1, 134 | "max": null, 135 | "min": null, 136 | "show": true 137 | }, 138 | { 139 | "$$hashKey": "object:283", 140 | "format": "short", 141 | "label": null, 142 | "logBase": 1, 143 | "max": null, 144 | "min": null, 145 | "show": true 146 | } 147 | ], 148 | "yaxis": { 149 | "align": false, 150 | "alignLevel": null 151 | } 152 | }, 153 | { 154 | "aliasColors": {}, 155 | "bars": false, 156 | "dashLength": 10, 157 | "dashes": false, 158 | "datasource": null, 159 | "description": "", 160 | "fieldConfig": { 161 | "defaults": {}, 162 | "overrides": [] 163 | }, 164 | "fill": 1, 165 | "fillGradient": 0, 166 | "gridPos": { 167 | "h": 8, 168 | "w": 12, 169 | "x": 12, 170 | "y": 0 171 | }, 172 | "hiddenSeries": false, 173 | "id": 3, 174 | "legend": { 175 | "avg": false, 176 | "current": false, 177 | "max": false, 178 | "min": false, 179 | "show": true, 180 | "total": false, 181 | "values": false 182 | }, 183 | "lines": true, 184 | "linewidth": 1, 185 | "nullPointMode": "null", 186 | "options": { 187 | "alertThreshold": true 188 | }, 189 | "percentage": false, 190 | "pluginVersion": "7.5.5", 191 | "pointradius": 2, 192 | "points": false, 193 | "renderer": "flot", 194 | "seriesOverrides": [], 195 | "spaceLength": 10, 196 | "stack": false, 197 | "steppedLine": false, 198 | "targets": [ 199 | { 200 | "groupBy": [ 201 | { 202 | "params": [ 203 | "$__interval" 204 | ], 205 | "type": "time" 206 | }, 207 | { 208 | "params": [ 209 | "sensor" 210 | ], 211 | "type": "tag" 212 | }, 213 | { 214 | "params": [ 215 | "linear" 216 | ], 217 | "type": "fill" 218 | } 219 | ], 220 | "measurement": "cpu_temperature", 221 | "orderByTime": "ASC", 222 | "policy": "default", 223 | "refId": "A", 224 | "resultFormat": "time_series", 225 | "select": [ 226 | [ 227 | { 228 | "params": [ 229 | "value" 230 | ], 231 | "type": "field" 232 | }, 233 | { 234 | "params": [], 235 | "type": "mean" 236 | } 237 | ] 238 | ], 239 | "tags": [] 240 | } 241 | ], 242 | "thresholds": [], 243 | "timeFrom": null, 244 | "timeRegions": [], 245 | "timeShift": null, 246 | "title": "CPU Temperature", 247 | "tooltip": { 248 | "shared": true, 249 | "sort": 0, 250 | "value_type": "individual" 251 | }, 252 | "type": "graph", 253 | "xaxis": { 254 | "buckets": null, 255 | "mode": "time", 256 | "name": null, 257 | "show": true, 258 | "values": [] 259 | }, 260 | "yaxes": [ 261 | { 262 | "$$hashKey": "object:452", 263 | "format": "celsius", 264 | "label": null, 265 | "logBase": 1, 266 | "max": null, 267 | "min": null, 268 | "show": true 269 | }, 270 | { 271 | "$$hashKey": "object:453", 272 | "format": "short", 273 | "label": null, 274 | "logBase": 1, 275 | "max": null, 276 | "min": null, 277 | "show": true 278 | } 279 | ], 280 | "yaxis": { 281 | "align": false, 282 | "alignLevel": null 283 | } 284 | }, 285 | { 286 | "aliasColors": {}, 287 | "bars": false, 288 | "dashLength": 10, 289 | "dashes": false, 290 | "datasource": null, 291 | "description": "", 292 | "fieldConfig": { 293 | "defaults": {}, 294 | "overrides": [] 295 | }, 296 | "fill": 1, 297 | "fillGradient": 0, 298 | "gridPos": { 299 | "h": 8, 300 | "w": 12, 301 | "x": 0, 302 | "y": 8 303 | }, 304 | "hiddenSeries": false, 305 | "id": 4, 306 | "legend": { 307 | "avg": false, 308 | "current": false, 309 | "max": false, 310 | "min": false, 311 | "show": true, 312 | "total": false, 313 | "values": false 314 | }, 315 | "lines": true, 316 | "linewidth": 1, 317 | "nullPointMode": "null", 318 | "options": { 319 | "alertThreshold": true 320 | }, 321 | "percentage": false, 322 | "pluginVersion": "7.5.5", 323 | "pointradius": 2, 324 | "points": false, 325 | "renderer": "flot", 326 | "seriesOverrides": [], 327 | "spaceLength": 10, 328 | "stack": false, 329 | "steppedLine": false, 330 | "targets": [ 331 | { 332 | "groupBy": [ 333 | { 334 | "params": [ 335 | "$__interval" 336 | ], 337 | "type": "time" 338 | }, 339 | { 340 | "params": [ 341 | "sensor" 342 | ], 343 | "type": "tag" 344 | }, 345 | { 346 | "params": [ 347 | "linear" 348 | ], 349 | "type": "fill" 350 | } 351 | ], 352 | "measurement": "humidity", 353 | "orderByTime": "ASC", 354 | "policy": "default", 355 | "refId": "A", 356 | "resultFormat": "time_series", 357 | "select": [ 358 | [ 359 | { 360 | "params": [ 361 | "value" 362 | ], 363 | "type": "field" 364 | }, 365 | { 366 | "params": [], 367 | "type": "mean" 368 | } 369 | ] 370 | ], 371 | "tags": [] 372 | } 373 | ], 374 | "thresholds": [], 375 | "timeFrom": null, 376 | "timeRegions": [], 377 | "timeShift": null, 378 | "title": "Humidity", 379 | "tooltip": { 380 | "shared": true, 381 | "sort": 0, 382 | "value_type": "individual" 383 | }, 384 | "type": "graph", 385 | "xaxis": { 386 | "buckets": null, 387 | "mode": "time", 388 | "name": null, 389 | "show": true, 390 | "values": [] 391 | }, 392 | "yaxes": [ 393 | { 394 | "$$hashKey": "object:196", 395 | "format": "percent", 396 | "label": null, 397 | "logBase": 1, 398 | "max": null, 399 | "min": null, 400 | "show": true 401 | }, 402 | { 403 | "$$hashKey": "object:197", 404 | "format": "short", 405 | "label": null, 406 | "logBase": 1, 407 | "max": null, 408 | "min": null, 409 | "show": true 410 | } 411 | ], 412 | "yaxis": { 413 | "align": false, 414 | "alignLevel": null 415 | } 416 | }, 417 | { 418 | "aliasColors": {}, 419 | "bars": false, 420 | "dashLength": 10, 421 | "dashes": false, 422 | "datasource": null, 423 | "description": "", 424 | "fieldConfig": { 425 | "defaults": {}, 426 | "overrides": [] 427 | }, 428 | "fill": 1, 429 | "fillGradient": 0, 430 | "gridPos": { 431 | "h": 8, 432 | "w": 12, 433 | "x": 12, 434 | "y": 8 435 | }, 436 | "hiddenSeries": false, 437 | "id": 5, 438 | "legend": { 439 | "avg": false, 440 | "current": false, 441 | "max": false, 442 | "min": false, 443 | "show": true, 444 | "total": false, 445 | "values": false 446 | }, 447 | "lines": true, 448 | "linewidth": 1, 449 | "nullPointMode": "null", 450 | "options": { 451 | "alertThreshold": true 452 | }, 453 | "percentage": false, 454 | "pluginVersion": "7.5.5", 455 | "pointradius": 2, 456 | "points": false, 457 | "renderer": "flot", 458 | "seriesOverrides": [], 459 | "spaceLength": 10, 460 | "stack": false, 461 | "steppedLine": false, 462 | "targets": [ 463 | { 464 | "groupBy": [ 465 | { 466 | "params": [ 467 | "$__interval" 468 | ], 469 | "type": "time" 470 | }, 471 | { 472 | "params": [ 473 | "sensor" 474 | ], 475 | "type": "tag" 476 | }, 477 | { 478 | "params": [ 479 | "linear" 480 | ], 481 | "type": "fill" 482 | } 483 | ], 484 | "measurement": "pressure", 485 | "orderByTime": "ASC", 486 | "policy": "default", 487 | "refId": "A", 488 | "resultFormat": "time_series", 489 | "select": [ 490 | [ 491 | { 492 | "params": [ 493 | "value" 494 | ], 495 | "type": "field" 496 | }, 497 | { 498 | "params": [], 499 | "type": "mean" 500 | } 501 | ] 502 | ], 503 | "tags": [] 504 | } 505 | ], 506 | "thresholds": [], 507 | "timeFrom": null, 508 | "timeRegions": [], 509 | "timeShift": null, 510 | "title": "Air Pressure", 511 | "tooltip": { 512 | "shared": true, 513 | "sort": 0, 514 | "value_type": "individual" 515 | }, 516 | "type": "graph", 517 | "xaxis": { 518 | "buckets": null, 519 | "mode": "time", 520 | "name": null, 521 | "show": true, 522 | "values": [] 523 | }, 524 | "yaxes": [ 525 | { 526 | "$$hashKey": "object:88", 527 | "format": "pressurehpa", 528 | "label": null, 529 | "logBase": 1, 530 | "max": null, 531 | "min": null, 532 | "show": true 533 | }, 534 | { 535 | "$$hashKey": "object:89", 536 | "format": "short", 537 | "label": null, 538 | "logBase": 1, 539 | "max": null, 540 | "min": null, 541 | "show": true 542 | } 543 | ], 544 | "yaxis": { 545 | "align": false, 546 | "alignLevel": null 547 | } 548 | } 549 | ], 550 | "schemaVersion": 27, 551 | "style": "dark", 552 | "tags": [], 553 | "templating": { 554 | "list": [] 555 | }, 556 | "time": { 557 | "from": "now-5m", 558 | "to": "now" 559 | }, 560 | "timepicker": {}, 561 | "timezone": "", 562 | "title": "BME280", 563 | "uid": "arM0loRRz", 564 | "version": 14 565 | } -------------------------------------------------------------------------------- /data-logger/sender/main.py: -------------------------------------------------------------------------------- 1 | import time 2 | import os, json 3 | from sensors import growbme280, growbmp280 4 | import requests 5 | from datetime import datetime 6 | 7 | function_url = os.getenv("FUNCTION_URL") # change this on each Pi 8 | sensor_name = os.getenv("SENSOR") 9 | sample_duration = 30 # seconds 10 | 11 | sensor = None 12 | sensor_type = os.getenv("SENSOR_TYPE", "bme280") 13 | print("Sensor type: {}", sensor_type) 14 | 15 | if sensor_type == "bme280": 16 | sensor = growbme280() 17 | elif sensor_type == "bmp280": 18 | sensor = growbmp280() 19 | 20 | if function_url == None: 21 | sys.stderr.write("env-var FUNCTION_URL is required i.e. http://192.168.0.21:8080/function/submit-sample") 22 | os.exit(1) 23 | 24 | if sensor_name == None: 25 | sys.stderr.write("env-var SENSOR is required i.e. shed1") 26 | os.exit(1) 27 | 28 | def get_cpu_temp(): 29 | path="/sys/class/thermal/thermal_zone0/temp" 30 | f = open(path, "r") 31 | temp_raw = int(f.read().strip()) 32 | temp_cpu = float(temp_raw / 1000.0) 33 | return temp_cpu 34 | 35 | try: 36 | while True: 37 | print("Gathering sensor data.") 38 | temp_cpu = get_cpu_temp() 39 | 40 | readings = sensor.get_readings() 41 | 42 | readings["sensor"] = sensor_name 43 | readings["cpu_temperature"] = temp_cpu 44 | 45 | my_date = datetime.now() 46 | readings["iso_time"] = my_date.isoformat() 47 | data = json.dumps(readings) 48 | print(data) 49 | 50 | try: 51 | res = requests.post(function_url, data=data, headers={"Content-type": "application/json"}) 52 | if res.status_code != 200: 53 | print("Unexpected status code: {}".format(res.status_code)) 54 | else: 55 | print("Sent to function..OK.") 56 | 57 | except Exception as e: 58 | print(e) 59 | continue 60 | time.sleep(sample_duration) 61 | 62 | except KeyboardInterrupt: 63 | pass 64 | -------------------------------------------------------------------------------- /data-logger/sender/requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | pimoroni-bme280 3 | bmp280 4 | smbus -------------------------------------------------------------------------------- /data-logger/sender/sensors.py: -------------------------------------------------------------------------------- 1 | try: 2 | from smbus2 import SMBus 3 | except ImportError: 4 | from smbus import SMBus 5 | from bme280 import BME280 6 | from bmp280 import BMP280 7 | 8 | import time 9 | 10 | class growbme280: 11 | def __init__(self): 12 | self.bus = SMBus(1) 13 | self.sensor = BME280(i2c_dev=self.bus) 14 | 15 | def get_readings(self): 16 | # Ignore first result since it seems stale 17 | temperature = self.sensor.get_temperature() 18 | pressure = self.sensor.get_pressure() 19 | humidity = self.sensor.get_humidity() 20 | time.sleep(0.1) 21 | 22 | temperature = self.sensor.get_temperature() 23 | pressure = self.sensor.get_pressure() 24 | humidity = self.sensor.get_humidity() 25 | time_str = time.strftime("%H:%M:%S") 26 | 27 | return { 28 | "time": time_str, 29 | "temperature": temperature, 30 | "pressure": pressure, 31 | "humidity": humidity 32 | } 33 | 34 | class growbmp280: 35 | def __init__(self): 36 | self.bus = SMBus(1) 37 | self.sensor = BMP280(i2c_dev=self.bus) 38 | 39 | def get_readings(self): 40 | # Ignore first result since it seems stale 41 | temperature = self.sensor.get_temperature() 42 | pressure = self.sensor.get_pressure() 43 | time.sleep(0.1) 44 | 45 | temperature = self.sensor.get_temperature() 46 | pressure = self.sensor.get_pressure() 47 | time_str = time.strftime("%H:%M:%S") 48 | 49 | return { 50 | "time": time_str, 51 | "temperature": temperature, 52 | "pressure": pressure, 53 | } -------------------------------------------------------------------------------- /data-logger/stack.yml: -------------------------------------------------------------------------------- 1 | version: 1.0 2 | provider: 3 | name: openfaas 4 | gateway: http://127.0.0.1:8080 5 | 6 | functions: 7 | submit-sample: 8 | lang: python3 9 | handler: ./submit-sample 10 | image: ghcr.io/alexellis/submit-sample:0.2.1 11 | environment: 12 | influx_host: 192.168.0.21 13 | influx_port: 8086 14 | influx_db: readings 15 | write_debug: true 16 | read_debug: true 17 | combine_output: false 18 | secrets: 19 | - influx-user 20 | - influx-pass 21 | -------------------------------------------------------------------------------- /data-logger/submit-sample/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexellis/growlab/ce60e75acddde67e7258ea3f788c73bd076043ad/data-logger/submit-sample/__init__.py -------------------------------------------------------------------------------- /data-logger/submit-sample/handler.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import time 4 | from datetime import datetime 5 | from influxdb import InfluxDBClient 6 | 7 | def handle(req): 8 | """handle a request to the function 9 | Args: 10 | req (str): request body 11 | """ 12 | 13 | # Parse NodeMCU data packet into JSON 14 | r = json.loads(req) 15 | 16 | influx_host = os.getenv("influx_host") 17 | influx_port = os.getenv("influx_port") 18 | influx_db = os.getenv("influx_db") 19 | 20 | influx_user = get_file("/var/openfaas/secrets/influx-user") 21 | influx_pass = get_file("/var/openfaas/secrets/influx-pass") 22 | 23 | client = InfluxDBClient(influx_host, influx_port, influx_user, influx_pass, influx_db) 24 | try: 25 | client.create_database(influx_db) 26 | except: 27 | print("Database {} may already exist", influx_db) 28 | 29 | points = make_points(r) 30 | 31 | res = client.write_points(points) 32 | client.close() 33 | 34 | return json.dumps(res) 35 | 36 | def get_file(path): 37 | v = "" 38 | with open(path) as f: 39 | v = f.read() 40 | f.close() 41 | return v.strip() 42 | 43 | def make_points(r): 44 | tags = {"sensor": r["sensor"]} 45 | my_date = datetime.now() 46 | iso_time = my_date.isoformat() 47 | points = [] 48 | 49 | points.append({ 50 | "measurement": "temp", 51 | "tags": tags, 52 | "time": iso_time, 53 | "fields": { 54 | "value": float(r["temperature"]) 55 | } 56 | }) 57 | 58 | if "cpu_temperature" in r: 59 | points.append({ 60 | "measurement": "cpu_temperature", 61 | "tags": tags, 62 | "time": iso_time, 63 | "fields": { 64 | "value": float(r["cpu_temperature"]) 65 | } 66 | }) 67 | 68 | if "humidity" in r: 69 | points.append({ 70 | "measurement": "humidity", 71 | "tags": tags, 72 | "time": iso_time, 73 | "fields": { 74 | "value": float(r["humidity"]) 75 | } 76 | }) 77 | 78 | if "pressure" in r: 79 | points.append({ 80 | "measurement": "pressure", 81 | "tags": tags, 82 | "time": iso_time, 83 | "fields": { 84 | "value": float(r["pressure"]) 85 | } 86 | }) 87 | 88 | return points 89 | -------------------------------------------------------------------------------- /data-logger/submit-sample/requirements.txt: -------------------------------------------------------------------------------- 1 | influxdb 2 | 3 | --------------------------------------------------------------------------------