├── .gitattributes ├── .github └── workflows │ ├── build.yml │ ├── qa.yml │ └── test.yml ├── .gitignore ├── .stickler.yml ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── REFERENCE.md ├── check.sh ├── examples ├── README.md ├── advanced │ ├── lcd-demo.py │ ├── moisture.py │ └── settings.yml ├── icons │ ├── flat-1.png │ ├── flat-10.png │ ├── flat-11.png │ ├── flat-12.png │ ├── flat-13.png │ ├── flat-14.png │ ├── flat-2.png │ ├── flat-3.png │ ├── flat-4.png │ ├── flat-5.png │ ├── flat-6.png │ ├── flat-7.png │ ├── flat-8.png │ ├── flat-9.png │ ├── icon-alarm.png │ ├── icon-backdrop.png │ ├── icon-channel.png │ ├── icon-circle.png │ ├── icon-drop.png │ ├── icon-help.png │ ├── icon-nodrop.png │ ├── icon-return.png │ ├── icon-rightarrow.png │ ├── icon-settings.png │ ├── icon-snooze.png │ ├── icon-warningdrop.png │ ├── line-1.png │ ├── line-10.png │ ├── line-11.png │ ├── line-12.png │ ├── line-13.png │ ├── line-14.png │ ├── line-2.png │ ├── line-3.png │ ├── line-4.png │ ├── line-5.png │ ├── line-6.png │ ├── line-7.png │ ├── line-8.png │ ├── line-9.png │ ├── veg-artichoke.png │ ├── veg-asparagus.png │ ├── veg-aubergine.png │ ├── veg-bellpepper.png │ ├── veg-broccoli.png │ ├── veg-carrot.png │ ├── veg-chilli.png │ ├── veg-courgette.png │ ├── veg-garlic.png │ ├── veg-gem.png │ ├── veg-leek.png │ ├── veg-lettuce.png │ ├── veg-mushroom.png │ ├── veg-pea.png │ ├── veg-potato.png │ ├── veg-pumpkin.png │ ├── veg-radish.png │ ├── veg-spinach.png │ ├── veg-sweetcorn.png │ └── veg-tomato.png ├── monitor.py ├── settings.yml ├── tools │ └── calibrate-pump.py └── web_serve.py ├── grow ├── __init__.py ├── moisture.py └── pump.py ├── install.sh ├── pyproject.toml ├── requirements-dev.txt ├── requirements-examples.txt ├── service ├── README.md ├── grow-monitor.service └── install.sh ├── setup.cfg ├── tests ├── conftest.py ├── test_lock.py └── test_setup.py ├── tox.ini └── uninstall.sh /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | test: 11 | name: Python ${{ matrix.python }} 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python: ['3.9', '3.10', '3.11'] 16 | 17 | env: 18 | RELEASE_FILE: ${{ github.event.repository.name }}-${{ github.event.release.tag_name || github.sha }}-py${{ matrix.python }} 19 | 20 | steps: 21 | - name: Checkout Code 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up Python ${{ matrix.python }} 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ matrix.python }} 28 | 29 | - name: Install Dependencies 30 | run: | 31 | make dev-deps 32 | 33 | - name: Build Packages 34 | run: | 35 | make build 36 | 37 | - name: Upload Packages 38 | uses: actions/upload-artifact@v4 39 | with: 40 | name: ${{ env.RELEASE_FILE }} 41 | path: dist/ 42 | -------------------------------------------------------------------------------- /.github/workflows/qa.yml: -------------------------------------------------------------------------------- 1 | name: QA 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | test: 11 | name: linting & spelling 12 | runs-on: ubuntu-latest 13 | env: 14 | TERM: xterm-256color 15 | 16 | steps: 17 | - name: Checkout Code 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up Python '3,11' 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: '3.11' 24 | 25 | - name: Install Dependencies 26 | run: | 27 | make dev-deps 28 | 29 | - name: Run Quality Assurance 30 | run: | 31 | make qa 32 | 33 | - name: Run Code Checks 34 | run: | 35 | make check 36 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | test: 11 | name: Python ${{ matrix.python }} 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python: ['3.9', '3.10', '3.11'] 16 | 17 | steps: 18 | - name: Checkout Code 19 | uses: actions/checkout@v3 20 | 21 | - name: Set up Python ${{ matrix.python }} 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python }} 25 | 26 | - name: Install Dependencies 27 | run: | 28 | make dev-deps 29 | 30 | - name: Run Tests 31 | run: | 32 | make pytest 33 | 34 | - name: Coverage 35 | if: ${{ matrix.python == '3.9' }} 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | run: | 39 | python -m pip install coveralls 40 | coveralls --service=github 41 | 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | _build/ 3 | *.o 4 | *.so 5 | *.a 6 | *.py[cod] 7 | *.egg-info 8 | dist/ 9 | __pycache__ 10 | .DS_Store 11 | *.deb 12 | *.dsc 13 | *.build 14 | *.changes 15 | *.orig.* 16 | packaging/*tar.xz 17 | library/debian/ 18 | .coverage 19 | .pytest_cache 20 | .tox 21 | .vscode/ 22 | -------------------------------------------------------------------------------- /.stickler.yml: -------------------------------------------------------------------------------- 1 | --- 2 | linters: 3 | flake8: 4 | python: 3 5 | max-line-length: 160 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 0.0.2 2 | ----- 3 | 4 | * Add mutually exclusive locking to pumps to avoid brownout running multiple pumps at once 5 | 6 | 0.0.1 7 | ----- 8 | 9 | * Initial Release 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Pimoroni Ltd. 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG.txt 2 | include LICENSE.txt 3 | include README.md 4 | include setup.py 5 | recursive-include grow *.py 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | LIBRARY_NAME := $(shell hatch project metadata name 2> /dev/null) 2 | LIBRARY_VERSION := $(shell hatch version 2> /dev/null) 3 | 4 | .PHONY: usage install uninstall check pytest qa build-deps check tag wheel sdist clean dist testdeploy deploy 5 | usage: 6 | ifdef LIBRARY_NAME 7 | @echo "Library: ${LIBRARY_NAME}" 8 | @echo "Version: ${LIBRARY_VERSION}\n" 9 | else 10 | @echo "WARNING: You should 'make dev-deps'\n" 11 | endif 12 | @echo "Usage: make , where target is one of:\n" 13 | @echo "install: install the library locally from source" 14 | @echo "uninstall: uninstall the local library" 15 | @echo "dev-deps: install Python dev dependencies" 16 | @echo "check: perform basic integrity checks on the codebase" 17 | @echo "qa: run linting and package QA" 18 | @echo "pytest: run Python test fixtures" 19 | @echo "clean: clean Python build and dist directories" 20 | @echo "build: build Python distribution files" 21 | @echo "testdeploy: build and upload to test PyPi" 22 | @echo "deploy: build and upload to PyPi" 23 | @echo "tag: tag the repository with the current version\n" 24 | 25 | install: 26 | ./install.sh --unstable 27 | 28 | uninstall: 29 | ./uninstall.sh 30 | 31 | dev-deps: 32 | python3 -m pip install -r requirements-dev.txt 33 | sudo apt install dos2unix 34 | 35 | check: 36 | @bash check.sh 37 | 38 | qa: 39 | tox -e qa 40 | 41 | pytest: 42 | tox -e py 43 | 44 | nopost: 45 | @bash check.sh --nopost 46 | 47 | tag: 48 | git tag -a "v${LIBRARY_VERSION}" -m "Version ${LIBRARY_VERSION}" 49 | 50 | build: check 51 | @hatch build 52 | 53 | clean: 54 | -rm -r dist 55 | 56 | testdeploy: build 57 | twine upload --repository testpypi dist/* 58 | 59 | deploy: nopost build 60 | twine upload dist/* 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Grow HAT Mini 2 | 3 | Designed as a tiny valet for your plants, Grow HAT mini will monitor the soil moiture for up to 3 plants, water them with tiny pumps, and show you their health on its small but informative screen. Learn more - https://shop.pimoroni.com/products/grow 4 | 5 | [![Build Status](https://img.shields.io/github/actions/workflow/status/pimoroni/grow-python/test.yml?branch=main)](https://github.com/pimoroni/grow-python/actions/workflows/test.yml) 6 | [![Coverage Status](https://coveralls.io/repos/github/pimoroni/grow-python/badge.svg?branch=master)](https://coveralls.io/github/pimoroni/grow-python?branch=master) 7 | [![PyPi Package](https://img.shields.io/pypi/v/growhat.svg)](https://pypi.python.org/pypi/growhat) 8 | [![Python Versions](https://img.shields.io/pypi/pyversions/growhat.svg)](https://pypi.python.org/pypi/growhat) 9 | 10 | # Installing 11 | 12 | You're best using the "One-line" install method. 13 | 14 | ## One-line (Installs from GitHub) 15 | 16 | ``` 17 | curl -sSL https://get.pimoroni.com/grow | bash 18 | ``` 19 | 20 | **Note** report issues with one-line installer here: https://github.com/pimoroni/get 21 | 22 | ## Or... Install and configure dependencies from GitHub: 23 | 24 | * `git clone https://github.com/pimoroni/grow-python` 25 | * `cd grow-python` 26 | * `sudo ./install.sh` 27 | 28 | **Note** Raspbian Lite users may first need to install git: `sudo apt install git` 29 | 30 | ## Or... Install from PyPi and configure manually: 31 | 32 | * Install dependencies: 33 | 34 | ``` 35 | sudo apt install python3-setuptools python3-pip python3-yaml python3-smbus python3-pil python3-spidev python3-rpi.gpio 36 | ``` 37 | 38 | * Run `sudo pip3 install growhat` 39 | 40 | **Note** this won't perform any of the required configuration changes on your Pi, you may additionally need to: 41 | 42 | * Enable i2c: `sudo raspi-config nonint do_i2c 0` 43 | * Enable SPI: `sudo raspi-config nonint do_spi 0` 44 | * Add the following to `/boot/config.txt`: `dtoverlay=spi0-cs,cs0_pin=14` 45 | 46 | ## Monitoring 47 | 48 | You should read the following to get up and running with our monitoring script: 49 | 50 | * [Using and configuring monitor.py](examples/README.md) 51 | * [Setting up monitor.py as a service](service/README.md) 52 | 53 | ## Help & Support 54 | 55 | * GPIO Pinout - https://pinout.xyz/pinout/grow_hat_mini 56 | * Support forums - http://forums.pimoroni.com/c/support 57 | * Discord - https://discord.gg/hr93ByC 58 | 59 | # Changelog 60 | 0.0.2 61 | ----- 62 | 63 | * Add mutually exclusive locking to pumps to avoid brownout running multiple pumps at once 64 | 65 | 0.0.1 66 | ----- 67 | 68 | * Initial Release 69 | -------------------------------------------------------------------------------- /REFERENCE.md: -------------------------------------------------------------------------------- 1 | # Grow 2 | 3 | - [Getting Started](#getting-started) 4 | - [Requirements](#requirements) 5 | - [Python 3 & pip](#python-3--pip) 6 | - [Enabling i2c and spi](#enabling-i2c-and-spi) 7 | - [Installing the library](#installing-the-library) 8 | - [Reference](#reference) 9 | - [Moisture](#moisture) 10 | - [Calibrating the moisture sensor](#calibrating-the-moisture-sensor) 11 | - [Moisture Reference](#moisture-reference) 12 | - [moisture](#moisture-1) 13 | - [saturation](#saturation) 14 | - [range](#range) 15 | - [new_data](#new_data) 16 | - [active](#active) 17 | - [Pump](#pump) 18 | - [Calibrating The Pump](#calibrating-the-pump) 19 | - [Pump Reference](#pump-reference) 20 | - [dose](#dose) 21 | - [set_speed](#set_speed) 22 | - [get_speed](#get_speed) 23 | - [stop](#stop) 24 | - [Light Sensor](#light-sensor) 25 | - [Display](#display) 26 | 27 | ## Getting Started 28 | 29 | You'll need to install the LTP305 software library and enable i2c on your Raspberry Pi. 30 | 31 | ### Requirements 32 | 33 | #### Python 3 & pip 34 | 35 | You should use Python 3, which may need installing on your Pi: 36 | 37 | ``` 38 | sudo apt update 39 | sudo apt install python3 python3-pip python3-setuptools 40 | ``` 41 | 42 | #### Enabling i2c and spi 43 | 44 | You can use `sudo raspi-config` on the command line, the GUI Raspberry Pi Configuration app from the Pi desktop menu, or use the following commands to enable i2c and spi: 45 | 46 | ``` 47 | sudo raspi-config nonint do_i2c 0 48 | sudo raspi-config nonint do_spi 0 49 | ``` 50 | 51 | ### Installing Dependencies 52 | 53 | The following dependencies are required: 54 | 55 | ``` 56 | sudo apt install python3-yaml python3-smbus python3-pil python3-spidev python3-rpi.gpio 57 | ``` 58 | 59 | ### Installing the library 60 | 61 | ```python 62 | python3 -m pip install growhat 63 | ``` 64 | 65 | This will also install the display driver - ST7735 - and a driver for the light sensor - LTR559. 66 | 67 | ## Reference 68 | 69 | The Grow library includes several modules for monitoring, watering and conveying status information. 70 | 71 | ### Moisture 72 | 73 | The moisture module is responsible for reading the moisture sensor. 74 | 75 | Grow moisture sensors output pulses that correspond to the moisture content of the soil. 76 | 77 | Wet soil will produce low frequency pulses, which will gradually become more frequent as the soil dries. 78 | 79 | The range runs from about 27-28 pulses per second in air, to <=1 pulse per second in fully saturated soil. 80 | 81 | To set up a moisture sensor you must import and initialise the class with the channel number you want to monitor: 82 | 83 | ```python 84 | from grow.moisture import Moisture 85 | 86 | moisture1 = Moisture(1) # Monitor channel 1 87 | ``` 88 | 89 | Moisture channels are labelled on the PCB from left to right as S1, S2 and S3. 90 | 91 | #### Calibrating the moisture sensor 92 | 93 | Since every soil substrate and environment is different, you must tell Grow what Wet and Dry soil look like. You can do this using the methods `set_wet_point` and `set_dry_point`. 94 | 95 | The wet and dry points are expressed as raw counts. You can read the raw moisture level of the soil at any time by using: 96 | 97 | ```python 98 | moisture1.moisture() 99 | ``` 100 | 101 | These methods will either use the current read value or, optionally, a value that you supply so that you can save your calibration and load it again if you need to restart your script. A simple calibration process might look like the following: 102 | 103 | ```python 104 | # Leave the soil dry 105 | moisture1.set_dry_point() 106 | # Water the plant 107 | moisture1.set_wet_point() 108 | ``` 109 | 110 | Or you could have variables that you wish to set as your wet/dry points: 111 | 112 | ```python 113 | DRY_POINT = 27 114 | WET_POINT = 1 115 | moisture1.set_dry_point(DRY_POINT) 116 | moisture1.set_wet_point(WET_POINT) 117 | ``` 118 | 119 | #### Moisture Reference 120 | 121 | ##### moisture 122 | 123 | ```python 124 | value = moisture1.moisture() 125 | ``` 126 | 127 | Reads the raw moisture level of the sensor. 128 | 129 | This is expressed in pulses-per-second and is inversely proportional to the moisture content of the soil. 130 | 131 | The moisture sensors are deliberately scaled to a very low frequency and range from around 27-28Hz in dry air down to around 1Hz in saturated soil. 132 | 133 | ##### saturation 134 | 135 | ```python 136 | value = moisture1.saturation() 137 | ``` 138 | 139 | Returns a value from 0.0 to 1.0 that corresponds to how moist the soil is. 140 | 141 | The saturation level is calculated using the wet and dry points and is useful for populating visual indicators, logs or graphs. 142 | 143 | ##### range 144 | 145 | ```python 146 | moisture1.range() 147 | ``` 148 | 149 | Returns the distance, in counts per second, between the wet and dry points. Used predominately in the calculation of saturation level, but may be useful. 150 | 151 | ##### new_data 152 | 153 | ```python 154 | moisture1.new_data() 155 | ``` 156 | 157 | Returns `True` if the internal moisture reading has been updated since the last time `moisture()` or `saturation()` was called. 158 | 159 | This allows you to log or run averaging over each new reading as it is collected or process readings in your program loop. 160 | 161 | For example, this snippet will loop ~60 times a second but only append new moisture readings to `log` once every second: 162 | 163 | ```python 164 | import time 165 | from grow.moisture import Moisture 166 | 167 | moisture1 = Moisture(1) # Monitor channel 1 168 | log = [] 169 | 170 | while True: 171 | if moisture1.new_data(): 172 | log.append(moisture1.saturation()) 173 | 174 | time.sleep(1.0 / 60) # 60 updates/sec 175 | ``` 176 | 177 | ##### active 178 | 179 | ```python 180 | moisture1.active() 181 | ``` 182 | 183 | Returns `True` if the moisture sensor is connected and returning valid readings. 184 | 185 | Checks if a pulse has happened within the last second, and that the reading is within a sensible range. 186 | 187 | ### Pump 188 | 189 | The Pump module is responsible for driving a pump. It uses PWM to run the pump at variable speeds. 190 | 191 | To set up a pump you must import and initialise the class with the channel number you want to control: 192 | 193 | ```python 194 | from grow.pump import Pump 195 | 196 | pump1 = Pump(1) # Control channel 1 197 | ``` 198 | 199 | Pump channels are labelled on the PCB from left to right as P1, P2 and P3. 200 | 201 | #### Calibrating The Pump 202 | 203 | In order for your pump/hose setup to reliably deliver a sensible amount of water you will have to calibrate both the speed at which it runs, and the time that it runs for. The speed/time settings are collectively known as a "dose" and we've produced an example - `dose.py` - to help find the right values. 204 | 205 | Higher pump speeds are useful for defeating gravity; in case where you water tank might be much lower than your plants. Longer pump times will deliver more water. 206 | 207 | Refer to the instructions at the top of `dose.py` for its usage. Generally you should dial in the lowest pump speed that will get water to traverse your hose, and adjust the time to deliver the right amount of water. 208 | 209 | These values are then passed into `pump.dose()` when watering. 210 | 211 | #### Pump Reference 212 | 213 | ##### dose 214 | 215 | ```python 216 | pump1.dose(0.5, 0.5, blocking=False) 217 | ``` 218 | 219 | Delivers a dose of water by running the pump at a specified speed for a specified time. Can be run in blocking (method takes time to return) or non-blocking (returns instantly and pump runs in the background) modes. 220 | 221 | This is the recommended way to water plants, since it delivers a controlled, short dose and is less likely to result in unwanted floods. 222 | 223 | ##### set_speed 224 | 225 | ```python 226 | pump1.set_speed(0.5) 227 | ``` 228 | 229 | Turns the pump on at the given speed. To stop the pump call `stop()` or `set_speed(0)`. 230 | 231 | Unless your setup (vertical hydroponics for example) requires continuous pumping of water then you should not use this function and use the `dose` instead. 232 | 233 | ##### get_speed 234 | 235 | ```python 236 | speed = pump1.get_speed() 237 | ``` 238 | 239 | Returns the current pump speed. 240 | 241 | ##### stop 242 | 243 | ```python 244 | pump1.stop() 245 | ``` 246 | 247 | Stops the pump by setting the speed to 0. 248 | 249 | ### Light Sensor 250 | 251 | Grow is equipped with an LTR-559 light and proximity sensor that you can use to limit waterings to daytime, or monitor the level of light your plant is receiving. 252 | 253 | The LTR-559 has its own library, and should be initialised like so: 254 | 255 | ```python 256 | from ltr559 import LTR559 257 | light = LTR559() 258 | ``` 259 | 260 | You can then update the sensor and retrieve the `lux` and `proximity` values like so: 261 | 262 | ```python 263 | while True: 264 | ltr559.update_sensor() 265 | lux = ltr559.get_lux() 266 | prox = ltr559.get_proximity() 267 | ``` 268 | 269 | See the LTR559 library for a full reference: https://github.com/pimoroni/ltr559-python 270 | 271 | ### Display 272 | 273 | The ST7735 display on Grow is 160x80 pixels and a great way to convey current watering status or build an interface for controlling your watering station. 274 | 275 | The display must be set up like so: 276 | 277 | ```python 278 | display = ST7735.ST7735( 279 | port=0, 280 | cs=1, # Chip select 1 (BCM ) 281 | dc=9, # BCM 9 is the data/command pin 282 | backlight=12, # BCM 12 is the backlight 283 | rotation=270, 284 | spi_speed_hz=80000000 285 | ) 286 | display.begin() 287 | ``` 288 | 289 | You should use the Python Image Library to build up what you want to display, and then display the finished image with: 290 | 291 | ```python 292 | display.display(image) 293 | ``` 294 | 295 | See the examples for demonstrations of this. See the ST7735 library for a full reference: https://github.com/pimoroni/st7735-python/ -------------------------------------------------------------------------------- /check.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script handles some basic QA checks on the source 4 | 5 | NOPOST=$1 6 | LIBRARY_NAME=`hatch project metadata name` 7 | LIBRARY_VERSION=`hatch version | awk -F "." '{print $1"."$2"."$3}'` 8 | POST_VERSION=`hatch version | awk -F "." '{print substr($4,0,length($4))}'` 9 | TERM=${TERM:="xterm-256color"} 10 | 11 | success() { 12 | echo -e "$(tput setaf 2)$1$(tput sgr0)" 13 | } 14 | 15 | inform() { 16 | echo -e "$(tput setaf 6)$1$(tput sgr0)" 17 | } 18 | 19 | warning() { 20 | echo -e "$(tput setaf 1)$1$(tput sgr0)" 21 | } 22 | 23 | while [[ $# -gt 0 ]]; do 24 | K="$1" 25 | case $K in 26 | -p|--nopost) 27 | NOPOST=true 28 | shift 29 | ;; 30 | *) 31 | if [[ $1 == -* ]]; then 32 | printf "Unrecognised option: $1\n"; 33 | exit 1 34 | fi 35 | POSITIONAL_ARGS+=("$1") 36 | shift 37 | esac 38 | done 39 | 40 | inform "Checking $LIBRARY_NAME $LIBRARY_VERSION\n" 41 | 42 | inform "Checking for trailing whitespace..." 43 | grep -IUrn --color "[[:blank:]]$" --exclude-dir=dist --exclude-dir=.tox --exclude-dir=.git --exclude=PKG-INFO 44 | if [[ $? -eq 0 ]]; then 45 | warning "Trailing whitespace found!" 46 | exit 1 47 | else 48 | success "No trailing whitespace found." 49 | fi 50 | printf "\n" 51 | 52 | inform "Checking for DOS line-endings..." 53 | grep -lIUrn --color $'\r' --exclude-dir=dist --exclude-dir=.tox --exclude-dir=.git --exclude=Makefile 54 | if [[ $? -eq 0 ]]; then 55 | warning "DOS line-endings found!" 56 | exit 1 57 | else 58 | success "No DOS line-endings found." 59 | fi 60 | printf "\n" 61 | 62 | inform "Checking CHANGELOG.md..." 63 | cat CHANGELOG.md | grep ^${LIBRARY_VERSION} > /dev/null 2>&1 64 | if [[ $? -eq 1 ]]; then 65 | warning "Changes missing for version ${LIBRARY_VERSION}! Please update CHANGELOG.md." 66 | exit 1 67 | else 68 | success "Changes found for version ${LIBRARY_VERSION}." 69 | fi 70 | printf "\n" 71 | 72 | inform "Checking for git tag ${LIBRARY_VERSION}..." 73 | git tag -l | grep -E "${LIBRARY_VERSION}$" 74 | if [[ $? -eq 1 ]]; then 75 | warning "Missing git tag for version ${LIBRARY_VERSION}" 76 | fi 77 | printf "\n" 78 | 79 | if [[ $NOPOST ]]; then 80 | inform "Checking for .postN on library version..." 81 | if [[ "$POST_VERSION" != "" ]]; then 82 | warning "Found .$POST_VERSION on library version." 83 | inform "Please only use these for testpypi releases." 84 | exit 1 85 | else 86 | success "OK" 87 | fi 88 | fi 89 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Monitoring Your Plants 2 | 3 | The example `monitor.py` monitors the moisture level of your soil and sounds an alarm when it drops below a defined threshold. 4 | 5 | It's configured using `settings.yml`. Your settings for monitoring will look something like this: 6 | 7 | ```yaml 8 | channel1: 9 | warn_level: 0.2 10 | channel2: 11 | warn_level: 0.2 12 | channel3: 13 | warn_level: 0.2 14 | general: 15 | alarm_enable: True 16 | alarm_interval: 1.0 17 | ``` 18 | 19 | `monitor.py` includes a main view showing the moisture status of each channel and the level beyond which the alarm will sound. 20 | 21 | The controls from the main view are as follows: 22 | 23 | * `A` - cycle through the main screen and each channel 24 | * `B` - snooze the alarm 25 | * `X` - configure global settings or the selected channel 26 | 27 | The warning moisture level can be configured for each channel, along with the Wet and Dry points that store the frequency expected from the sensor when soil is fully wet/dry. 28 | 29 | ## Watering 30 | 31 | If you've got pumps attached to Grow and want to automatically water your plants, you'll need some extra configuration options. 32 | 33 | See [Channel Settings](#channel-settings) and [General Settings](#general-settings) for more information on what these do. 34 | 35 | ```yaml 36 | channel1: 37 | water_level: 0.8 38 | warn_level: 0.2 39 | pump_speed: 0.7 40 | pump_time: 0.7 41 | wet_point: 0.7 42 | dry_point: 27.6 43 | auto_water: True 44 | watering_delay: 60 45 | channel2: 46 | water_level: 0.8 47 | warn_level: 0.2 48 | pump_speed: 0.7 49 | pump_time: 0.7 50 | wet_point: 0.7 51 | dry_point: 27.6 52 | auto_water: True 53 | watering_delay: 60 54 | channel3: 55 | water_level: 0.8 56 | warn_level: 0.2 57 | pump_speed: 0.7 58 | pump_time: 0.7 59 | wet_point: 0.7 60 | dry_point: 27.6 61 | auto_water: True 62 | watering_delay: 60 63 | general: 64 | alarm_enable: True 65 | alarm_interval: 1.0 66 | ``` 67 | 68 | ## Channel Settings 69 | 70 | Grow has three channels which are separated into the sections `channel1`, `channel2` and `channel3`, each of these sections has the following configuration options: 71 | 72 | * `water_level` - The level at which auto-watering should be triggered (soil saturation from 0.0 to 1.0) 73 | * `warn_level` - The level at which the alarm should be triggered (soil saturation from 0.0 to 1.0) 74 | * `pump_speed` - The speed at which the pump should be run (from 0.0 low speed to 1.0 full speed) 75 | * `pump_time` - The time that the pump should run for (in seconds) 76 | * `auto_water` - Whether to run the attached pump (True to auto-water, False for manual watering) 77 | * `wet_point` - Value for the sensor in saturated soil (in Hz) 78 | * `dry_point` - Value for the sensor in totally dry soil (in Hz) 79 | * `watering_delay` - Delay between waterings (in seconds) 80 | 81 | ## General Settings 82 | 83 | An additional `general` section can be used for global settings: 84 | 85 | * `alarm_enable` - Whether to enable the alarm 86 | * `alarm_interval` - The interval at which the alarm should beep (in seconds) 87 | -------------------------------------------------------------------------------- /examples/advanced/lcd-demo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import logging 4 | 5 | import ST7735 6 | from fonts.ttf import RobotoMedium as UserFont 7 | from PIL import Image, ImageDraw, ImageFont 8 | 9 | logging.basicConfig( 10 | format="%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s", 11 | level=logging.INFO, 12 | datefmt="%Y-%m-%d %H:%M:%S", 13 | ) 14 | 15 | logging.info( 16 | """lcd.py - Hello, World! example on the 0.96" LCD. 17 | 18 | Press Ctrl+C to exit! 19 | 20 | """ 21 | ) 22 | 23 | # Create LCD class instance. 24 | disp = ST7735.ST7735( 25 | port=0, cs=1, dc=9, backlight=12, rotation=270, spi_speed_hz=10000000 26 | ) 27 | 28 | # Initialize display. 29 | disp.begin() 30 | 31 | # Width and height to calculate text position. 32 | WIDTH = disp.width 33 | HEIGHT = disp.height 34 | 35 | # New canvas to draw on. 36 | img = Image.new("RGB", (WIDTH, HEIGHT), color=(0, 0, 0)) 37 | draw = ImageDraw.Draw(img) 38 | 39 | # Text settings. 40 | font_size = 25 41 | font = ImageFont.truetype(UserFont, font_size) 42 | text_colour = (255, 255, 255) 43 | back_colour = (0, 170, 170) 44 | 45 | message = "Hello, World!" 46 | size_x, size_y = draw.textsize(message, font) 47 | 48 | # Calculate text position 49 | x = (WIDTH - size_x) / 2 50 | y = (HEIGHT / 2) - (size_y / 2) 51 | 52 | # Draw background rectangle and write text. 53 | draw.rectangle((0, 0, 160, 80), back_colour) 54 | draw.text((x, y), message, font=font, fill=text_colour) 55 | disp.display(img) 56 | 57 | # Keep running. 58 | try: 59 | while True: 60 | pass 61 | 62 | # Turn off backlight on control-c 63 | except KeyboardInterrupt: 64 | disp.set_backlight(0) 65 | -------------------------------------------------------------------------------- /examples/advanced/moisture.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from grow.moisture import Moisture 4 | 5 | print("""moisture.py - Print out sensor reading in Hz 6 | 7 | Press Ctrl+C to exit! 8 | 9 | """) 10 | 11 | 12 | m1 = Moisture(1) 13 | m2 = Moisture(2) 14 | m3 = Moisture(3) 15 | 16 | while True: 17 | print(f"""1: {m1.moisture} 18 | 2: {m2.moisture} 19 | 3: {m3.moisture} 20 | """) 21 | time.sleep(1.0) 22 | 23 | -------------------------------------------------------------------------------- /examples/advanced/settings.yml: -------------------------------------------------------------------------------- 1 | channel1: 2 | enabled: true 3 | warn_level: 0.2 4 | icon: icons/flat-4.png 5 | wet_point: 2.5 6 | dry_point: 27.6 7 | channel2: 8 | enabled: false 9 | warn_level: 0.2 10 | wet_point: 2.5 11 | dry_point: 27.6 12 | channel3: 13 | enabled: false 14 | warn_level: 0.2 15 | wet_point: 2.5 16 | dry_point: 27.6 17 | general: 18 | alarm_enable: True 19 | alarm_interval: 10.0 20 | -------------------------------------------------------------------------------- /examples/icons/flat-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/flat-1.png -------------------------------------------------------------------------------- /examples/icons/flat-10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/flat-10.png -------------------------------------------------------------------------------- /examples/icons/flat-11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/flat-11.png -------------------------------------------------------------------------------- /examples/icons/flat-12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/flat-12.png -------------------------------------------------------------------------------- /examples/icons/flat-13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/flat-13.png -------------------------------------------------------------------------------- /examples/icons/flat-14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/flat-14.png -------------------------------------------------------------------------------- /examples/icons/flat-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/flat-2.png -------------------------------------------------------------------------------- /examples/icons/flat-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/flat-3.png -------------------------------------------------------------------------------- /examples/icons/flat-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/flat-4.png -------------------------------------------------------------------------------- /examples/icons/flat-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/flat-5.png -------------------------------------------------------------------------------- /examples/icons/flat-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/flat-6.png -------------------------------------------------------------------------------- /examples/icons/flat-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/flat-7.png -------------------------------------------------------------------------------- /examples/icons/flat-8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/flat-8.png -------------------------------------------------------------------------------- /examples/icons/flat-9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/flat-9.png -------------------------------------------------------------------------------- /examples/icons/icon-alarm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/icon-alarm.png -------------------------------------------------------------------------------- /examples/icons/icon-backdrop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/icon-backdrop.png -------------------------------------------------------------------------------- /examples/icons/icon-channel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/icon-channel.png -------------------------------------------------------------------------------- /examples/icons/icon-circle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/icon-circle.png -------------------------------------------------------------------------------- /examples/icons/icon-drop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/icon-drop.png -------------------------------------------------------------------------------- /examples/icons/icon-help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/icon-help.png -------------------------------------------------------------------------------- /examples/icons/icon-nodrop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/icon-nodrop.png -------------------------------------------------------------------------------- /examples/icons/icon-return.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/icon-return.png -------------------------------------------------------------------------------- /examples/icons/icon-rightarrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/icon-rightarrow.png -------------------------------------------------------------------------------- /examples/icons/icon-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/icon-settings.png -------------------------------------------------------------------------------- /examples/icons/icon-snooze.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/icon-snooze.png -------------------------------------------------------------------------------- /examples/icons/icon-warningdrop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/icon-warningdrop.png -------------------------------------------------------------------------------- /examples/icons/line-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/line-1.png -------------------------------------------------------------------------------- /examples/icons/line-10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/line-10.png -------------------------------------------------------------------------------- /examples/icons/line-11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/line-11.png -------------------------------------------------------------------------------- /examples/icons/line-12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/line-12.png -------------------------------------------------------------------------------- /examples/icons/line-13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/line-13.png -------------------------------------------------------------------------------- /examples/icons/line-14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/line-14.png -------------------------------------------------------------------------------- /examples/icons/line-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/line-2.png -------------------------------------------------------------------------------- /examples/icons/line-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/line-3.png -------------------------------------------------------------------------------- /examples/icons/line-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/line-4.png -------------------------------------------------------------------------------- /examples/icons/line-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/line-5.png -------------------------------------------------------------------------------- /examples/icons/line-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/line-6.png -------------------------------------------------------------------------------- /examples/icons/line-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/line-7.png -------------------------------------------------------------------------------- /examples/icons/line-8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/line-8.png -------------------------------------------------------------------------------- /examples/icons/line-9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/line-9.png -------------------------------------------------------------------------------- /examples/icons/veg-artichoke.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/veg-artichoke.png -------------------------------------------------------------------------------- /examples/icons/veg-asparagus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/veg-asparagus.png -------------------------------------------------------------------------------- /examples/icons/veg-aubergine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/veg-aubergine.png -------------------------------------------------------------------------------- /examples/icons/veg-bellpepper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/veg-bellpepper.png -------------------------------------------------------------------------------- /examples/icons/veg-broccoli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/veg-broccoli.png -------------------------------------------------------------------------------- /examples/icons/veg-carrot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/veg-carrot.png -------------------------------------------------------------------------------- /examples/icons/veg-chilli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/veg-chilli.png -------------------------------------------------------------------------------- /examples/icons/veg-courgette.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/veg-courgette.png -------------------------------------------------------------------------------- /examples/icons/veg-garlic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/veg-garlic.png -------------------------------------------------------------------------------- /examples/icons/veg-gem.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/veg-gem.png -------------------------------------------------------------------------------- /examples/icons/veg-leek.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/veg-leek.png -------------------------------------------------------------------------------- /examples/icons/veg-lettuce.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/veg-lettuce.png -------------------------------------------------------------------------------- /examples/icons/veg-mushroom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/veg-mushroom.png -------------------------------------------------------------------------------- /examples/icons/veg-pea.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/veg-pea.png -------------------------------------------------------------------------------- /examples/icons/veg-potato.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/veg-potato.png -------------------------------------------------------------------------------- /examples/icons/veg-pumpkin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/veg-pumpkin.png -------------------------------------------------------------------------------- /examples/icons/veg-radish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/veg-radish.png -------------------------------------------------------------------------------- /examples/icons/veg-spinach.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/veg-spinach.png -------------------------------------------------------------------------------- /examples/icons/veg-sweetcorn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/veg-sweetcorn.png -------------------------------------------------------------------------------- /examples/icons/veg-tomato.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/grow-python/7513fde92a9412a3afb29fac272313993487a508/examples/icons/veg-tomato.png -------------------------------------------------------------------------------- /examples/monitor.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import logging 3 | import math 4 | import pathlib 5 | import sys 6 | import threading 7 | import time 8 | 9 | import ltr559 10 | import RPi.GPIO as GPIO 11 | import ST7735 12 | import yaml 13 | from fonts.ttf import RobotoMedium as UserFont 14 | from PIL import Image, ImageDraw, ImageFont 15 | 16 | from grow import Piezo 17 | from grow.moisture import Moisture 18 | from grow.pump import Pump 19 | 20 | FPS = 10 21 | 22 | BUTTONS = [5, 6, 16, 24] 23 | LABELS = ["A", "B", "X", "Y"] 24 | 25 | DISPLAY_WIDTH = 160 26 | DISPLAY_HEIGHT = 80 27 | 28 | COLOR_WHITE = (255, 255, 255) 29 | COLOR_BLUE = (31, 137, 251) 30 | COLOR_GREEN = (99, 255, 124) 31 | COLOR_YELLOW = (254, 219, 82) 32 | COLOR_RED = (247, 0, 63) 33 | COLOR_BLACK = (0, 0, 0) 34 | 35 | 36 | # Only the ALPHA channel is used from these images 37 | icon_drop = Image.open("icons/icon-drop.png").convert("RGBA") 38 | icon_nodrop = Image.open("icons/icon-nodrop.png").convert("RGBA") 39 | icon_rightarrow = Image.open("icons/icon-rightarrow.png").convert("RGBA") 40 | icon_alarm = Image.open("icons/icon-alarm.png").convert("RGBA") 41 | icon_snooze = Image.open("icons/icon-snooze.png").convert("RGBA") 42 | icon_help = Image.open("icons/icon-help.png").convert("RGBA") 43 | icon_settings = Image.open("icons/icon-settings.png").convert("RGBA") 44 | icon_channel = Image.open("icons/icon-channel.png").convert("RGBA") 45 | icon_backdrop = Image.open("icons/icon-backdrop.png").convert("RGBA") 46 | icon_return = Image.open("icons/icon-return.png").convert("RGBA") 47 | 48 | 49 | class View: 50 | def __init__(self, image): 51 | self._image = image 52 | self._draw = ImageDraw.Draw(image) 53 | 54 | self.font = ImageFont.truetype(UserFont, 14) 55 | self.font_small = ImageFont.truetype(UserFont, 10) 56 | 57 | def button_a(self): 58 | return False 59 | 60 | def button_b(self): 61 | return False 62 | 63 | def button_x(self): 64 | return False 65 | 66 | def button_y(self): 67 | return False 68 | 69 | def update(self): 70 | pass 71 | 72 | def render(self): 73 | pass 74 | 75 | def clear(self): 76 | self._draw.rectangle((0, 0, DISPLAY_WIDTH, DISPLAY_HEIGHT), (0, 0, 0)) 77 | 78 | def icon(self, icon, position, color): 79 | col = Image.new("RGBA", icon.size, color=color) 80 | self._image.paste(col, position, mask=icon) 81 | 82 | def label( 83 | self, 84 | position="X", 85 | text=None, 86 | bgcolor=(0, 0, 0), 87 | textcolor=(255, 255, 255), 88 | margin=4, 89 | ): 90 | if position not in ["A", "B", "X", "Y"]: 91 | raise ValueError(f"Invalid label position {position}") 92 | 93 | text_w, text_h = self._draw.textsize(text, font=self.font) 94 | text_h = 11 95 | text_w += margin * 2 96 | text_h += margin * 2 97 | 98 | if position == "A": 99 | x, y = 0, 0 100 | if position == "B": 101 | x, y = 0, DISPLAY_HEIGHT - text_h 102 | if position == "X": 103 | x, y = DISPLAY_WIDTH - text_w, 0 104 | if position == "Y": 105 | x, y = DISPLAY_WIDTH - text_w, DISPLAY_HEIGHT - text_h 106 | 107 | x2, y2 = x + text_w, y + text_h 108 | 109 | self._draw.rectangle((x, y, x2, y2), bgcolor) 110 | self._draw.text( 111 | (x + margin, y + margin - 1), text, font=self.font, fill=textcolor 112 | ) 113 | 114 | def overlay(self, text, top=0): 115 | """Draw an overlay with some auto-sized text.""" 116 | self._draw.rectangle( 117 | (0, top, DISPLAY_WIDTH, DISPLAY_HEIGHT), fill=(192, 225, 254) 118 | ) # Overlay backdrop 119 | self._draw.rectangle((0, top, DISPLAY_WIDTH, top + 1), fill=COLOR_BLUE) # Top border 120 | self.text_in_rect( 121 | text, 122 | self.font, 123 | (3, top, DISPLAY_WIDTH - 3, DISPLAY_HEIGHT - 2), 124 | line_spacing=1, 125 | ) 126 | 127 | def text_in_rect(self, text, font, rect, line_spacing=1.1, textcolor=(0, 0, 0)): 128 | x1, y1, x2, y2 = rect 129 | width = x2 - x1 130 | height = y2 - y1 131 | 132 | # Given a rectangle, reflow and scale text to fit, centred 133 | while font.size > 0: 134 | line_height = int(font.size * line_spacing) 135 | max_lines = math.floor(height / line_height) 136 | lines = [] 137 | 138 | # Determine if text can fit at current scale. 139 | words = text.split(" ") 140 | 141 | while len(lines) < max_lines and len(words) > 0: 142 | line = [] 143 | 144 | while ( 145 | len(words) > 0 146 | and font.getsize(" ".join(line + [words[0]]))[0] <= width 147 | ): 148 | line.append(words.pop(0)) 149 | 150 | lines.append(" ".join(line)) 151 | 152 | if len(lines) <= max_lines and len(words) == 0: 153 | # Solution is found, render the text. 154 | y = int( 155 | y1 156 | + (height / 2) 157 | - (len(lines) * line_height / 2) 158 | - (line_height - font.size) / 2 159 | ) 160 | 161 | bounds = [x2, y, x1, y + len(lines) * line_height] 162 | 163 | for line in lines: 164 | line_width = font.getsize(line)[0] 165 | x = int(x1 + (width / 2) - (line_width / 2)) 166 | bounds[0] = min(bounds[0], x) 167 | bounds[2] = max(bounds[2], x + line_width) 168 | self._draw.text((x, y), line, font=self.font, fill=textcolor) 169 | y += line_height 170 | 171 | return tuple(bounds) 172 | 173 | font = ImageFont.truetype(font.path, font.size - 1) 174 | 175 | 176 | class MainView(View): 177 | """Main overview. 178 | 179 | Displays three channels and alarm indicator/snooze. 180 | 181 | """ 182 | 183 | def __init__(self, image, channels=None, alarm=None): 184 | self.channels = channels 185 | self.alarm = alarm 186 | 187 | View.__init__(self, image) 188 | 189 | def render_channel(self, channel): 190 | bar_x = 33 191 | bar_margin = 2 192 | bar_width = 30 193 | label_width = 16 194 | label_y = 0 195 | 196 | x = [ 197 | bar_x, 198 | bar_x + ((bar_width + bar_margin) * 1), 199 | bar_x + ((bar_width + bar_margin) * 2), 200 | ][channel.channel - 1] 201 | 202 | # Saturation amounts from each sensor 203 | saturation = channel.sensor.saturation 204 | active = channel.sensor.active and channel.enabled 205 | warn_level = channel.warn_level 206 | 207 | if active: 208 | # Draw background bars 209 | self._draw.rectangle( 210 | (x, int((1.0 - saturation) * DISPLAY_HEIGHT), x + bar_width - 1, DISPLAY_HEIGHT), 211 | channel.indicator_color(saturation) if active else (229, 229, 229), 212 | ) 213 | 214 | y = int((1.0 - warn_level) * DISPLAY_HEIGHT) 215 | self._draw.rectangle( 216 | (x, y, x + bar_width - 1, y), (255, 0, 0) if channel.alarm else (0, 0, 0) 217 | ) 218 | 219 | # Channel selection icons 220 | x += (bar_width - label_width) // 2 221 | 222 | self.icon(icon_channel, (x, label_y), (200, 200, 200) if active else (64, 64, 64)) 223 | 224 | # TODO: replace number text with graphic 225 | tw, th = self.font.getsize(str(channel.channel)) 226 | self._draw.text( 227 | (x + int(math.ceil(8 - (tw / 2.0))), label_y + 1), 228 | str(channel.channel), 229 | font=self.font, 230 | fill=(55, 55, 55) if active else (100, 100, 100), 231 | ) 232 | 233 | def render(self): 234 | self.clear() 235 | 236 | for channel in self.channels: 237 | self.render_channel(channel) 238 | 239 | # Icons 240 | self.icon(icon_backdrop, (0, 0), COLOR_WHITE) 241 | self.icon(icon_rightarrow, (3, 3), (55, 55, 55)) 242 | 243 | self.alarm.render((3, DISPLAY_HEIGHT - 23)) 244 | 245 | self.icon(icon_backdrop.rotate(180), (DISPLAY_WIDTH - 26, 0), COLOR_WHITE) 246 | self.icon(icon_settings, (DISPLAY_WIDTH - 19 - 3, 3), (55, 55, 55)) 247 | 248 | 249 | class EditView(View): 250 | """Baseclass for a settings edit view.""" 251 | 252 | def __init__(self, image, options=[]): 253 | self._options = options 254 | self._current_option = 0 255 | self._change_mode = False 256 | self._help_mode = False 257 | self.channel = None 258 | 259 | View.__init__(self, image) 260 | 261 | def render(self): 262 | self.icon(icon_backdrop.rotate(180), (DISPLAY_WIDTH - 26, 0), COLOR_WHITE) 263 | self.icon(icon_return, (DISPLAY_WIDTH - 19 - 3, 3), (55, 55, 55)) 264 | 265 | option = self._options[self._current_option] 266 | title = option["title"] 267 | prop = option["prop"] 268 | object = option.get("object", self.channel) 269 | value = getattr(object, prop) 270 | text = option["format"](value) 271 | mode = option.get("mode", "int") 272 | help = option["help"] 273 | 274 | if self._change_mode: 275 | self.label( 276 | "Y", 277 | "Yes" if mode == "bool" else "++", 278 | textcolor=COLOR_BLACK, 279 | bgcolor=COLOR_WHITE, 280 | ) 281 | self.label( 282 | "B", 283 | "No" if mode == "bool" else "--", 284 | textcolor=COLOR_BLACK, 285 | bgcolor=COLOR_WHITE, 286 | ) 287 | else: 288 | self.label("B", "Next", textcolor=COLOR_BLACK, bgcolor=COLOR_WHITE) 289 | self.label("Y", "Change", textcolor=COLOR_BLACK, bgcolor=COLOR_WHITE) 290 | 291 | self._draw.text((3, 36), f"{title} : {text}", font=self.font, fill=COLOR_WHITE) 292 | 293 | if self._help_mode: 294 | self.icon(icon_backdrop.rotate(90), (0, 0), COLOR_BLUE) 295 | self._draw.rectangle((7, 3, 23, 19), COLOR_BLACK) 296 | self.overlay(help, top=26) 297 | 298 | self.icon(icon_help, (0, 0), COLOR_BLUE) 299 | 300 | def button_a(self): 301 | self._help_mode = not self._help_mode 302 | return True 303 | 304 | def button_b(self): 305 | if self._help_mode: 306 | return True 307 | 308 | if self._change_mode: 309 | option = self._options[self._current_option] 310 | prop = option["prop"] 311 | mode = option.get("mode", "int") 312 | object = option.get("object", self.channel) 313 | 314 | value = getattr(object, prop) 315 | if mode == "bool": 316 | value = False 317 | else: 318 | inc = option["inc"] 319 | limit = option["min"] 320 | value -= inc 321 | if mode == "float": 322 | value = round(value, option.get("round", 1)) 323 | if value < limit: 324 | value = limit 325 | setattr(object, prop, value) 326 | else: 327 | self._current_option += 1 328 | self._current_option %= len(self._options) 329 | 330 | return True 331 | 332 | def button_x(self): 333 | if self._change_mode: 334 | self._change_mode = False 335 | return True 336 | return False 337 | 338 | def button_y(self): 339 | if self._help_mode: 340 | return True 341 | if self._change_mode: 342 | option = self._options[self._current_option] 343 | prop = option["prop"] 344 | mode = option.get("mode", "int") 345 | object = option.get("object", self.channel) 346 | 347 | value = getattr(object, prop) 348 | if mode == "bool": 349 | value = True 350 | else: 351 | inc = option["inc"] 352 | limit = option["max"] 353 | value += inc 354 | if mode == "float": 355 | value = round(value, option.get("round", 1)) 356 | if value > limit: 357 | value = limit 358 | setattr(object, prop, value) 359 | else: 360 | self._change_mode = True 361 | 362 | return True 363 | 364 | 365 | class SettingsView(EditView): 366 | """Main settings.""" 367 | 368 | def __init__(self, image, options=[]): 369 | EditView.__init__(self, image, options) 370 | 371 | def render(self): 372 | self.clear() 373 | self._draw.text( 374 | (28, 5), 375 | "Settings", 376 | font=self.font, 377 | fill=COLOR_WHITE, 378 | ) 379 | EditView.render(self) 380 | 381 | 382 | class ChannelView(View): 383 | """Base class for a view that deals with a specific channel instance.""" 384 | 385 | def __init__(self, image, channel=None): 386 | self.channel = channel 387 | View.__init__(self, image) 388 | 389 | def draw_status(self, position): 390 | status = f"Sat: {self.channel.sensor.saturation * 100:.2f}%" 391 | 392 | self._draw.text( 393 | position, 394 | status, 395 | font=self.font, 396 | fill=(255, 255, 255), 397 | ) 398 | 399 | def draw_context(self, position, metric="Hz"): 400 | context = f"Now: {self.channel.sensor.moisture:.2f}Hz" 401 | if metric.lower() == "sat": 402 | context = f"Now: {self.channel.sensor.saturation * 100:.2f}%" 403 | 404 | self._draw.text( 405 | position, 406 | context, 407 | font=self.font, 408 | fill=(255, 255, 255), 409 | ) 410 | 411 | 412 | class DetailView(ChannelView): 413 | """Single channel details. 414 | 415 | Draw the channel graph and status line. 416 | 417 | """ 418 | 419 | def render(self): 420 | self.clear() 421 | 422 | if self.channel.enabled: 423 | graph_height = DISPLAY_HEIGHT - 8 - 20 424 | graph_width = DISPLAY_WIDTH - 64 425 | 426 | graph_x = (DISPLAY_WIDTH - graph_width) // 2 427 | graph_y = 8 428 | 429 | self.draw_status((graph_x, graph_y + graph_height + 4)) 430 | 431 | self._draw.rectangle((graph_x, graph_y, graph_x + graph_width, graph_y + graph_height), (50, 50, 50)) 432 | 433 | for x, value in enumerate(self.channel.sensor.history[:graph_width]): 434 | color = self.channel.indicator_color(value) 435 | h = value * graph_height 436 | x = graph_x + graph_width - x - 1 437 | self._draw.rectangle((x, graph_y + graph_height - h, x + 1, graph_y + graph_height), color) 438 | 439 | alarm_line = int(self.channel.warn_level * graph_height) 440 | r = 255 441 | if self.channel.alarm: 442 | r = int(((math.sin(time.time() * 3 * math.pi) + 1.0) / 2.0) * 128) + 127 443 | 444 | self._draw.rectangle( 445 | ( 446 | 0, 447 | graph_height + 8 - alarm_line, 448 | DISPLAY_WIDTH - 40, 449 | graph_height + 8 - alarm_line, 450 | ), 451 | (r, 0, 0), 452 | ) 453 | self._draw.rectangle( 454 | ( 455 | DISPLAY_WIDTH - 20, 456 | graph_height + 8 - alarm_line, 457 | DISPLAY_WIDTH, 458 | graph_height + 8 - alarm_line, 459 | ), 460 | (r, 0, 0), 461 | ) 462 | 463 | self.icon( 464 | icon_alarm, 465 | (DISPLAY_WIDTH - 40, graph_height + 8 - alarm_line - 10), 466 | (r, 0, 0), 467 | ) 468 | 469 | # Channel icons 470 | 471 | x_positions = [40, 72, 104] 472 | label_x = x_positions[self.channel.channel - 1] 473 | label_y = 0 474 | 475 | active = self.channel.sensor.active and self.channel.enabled 476 | 477 | for x in x_positions: 478 | self.icon(icon_channel, (x, label_y - 10), (16, 16, 16)) 479 | 480 | self.icon(icon_channel, (label_x, label_y), (200, 200, 200)) 481 | 482 | tw, th = self.font.getsize(str(self.channel.channel)) 483 | self._draw.text( 484 | (label_x + int(math.ceil(8 - (tw / 2.0))), label_y + 1), 485 | str(self.channel.channel), 486 | font=self.font, 487 | fill=(55, 55, 55) if active else (100, 100, 100), 488 | ) 489 | 490 | # Next button 491 | self.icon(icon_backdrop, (0, 0), COLOR_WHITE) 492 | self.icon(icon_rightarrow, (3, 3), (55, 55, 55)) 493 | 494 | # Prev button 495 | # self.icon(icon_backdrop, (0, DISPLAY_HEIGHT - 26), COLOR_WHITE) 496 | # self.icon(icon_return, (3, DISPLAY_HEIGHT - 26 + 3), (55, 55, 55)) 497 | 498 | # Edit 499 | self.icon(icon_backdrop.rotate(180), (DISPLAY_WIDTH - 26, 0), COLOR_WHITE) 500 | self.icon(icon_settings, (DISPLAY_WIDTH - 19 - 3, 3), (55, 55, 55)) 501 | 502 | 503 | class ChannelEditView(ChannelView, EditView): 504 | """Single channel edit.""" 505 | 506 | def __init__(self, image, channel=None): 507 | options = [ 508 | { 509 | "title": "Alarm Level", 510 | "prop": "warn_level", 511 | "inc": 0.05, 512 | "min": 0, 513 | "max": 1.0, 514 | "mode": "float", 515 | "round": 2, 516 | "format": lambda value: f"{value * 100:0.2f}%", 517 | "help": "Saturation at which alarm is triggered", 518 | "context": "sat", 519 | }, 520 | { 521 | "title": "Enabled", 522 | "prop": "enabled", 523 | "mode": "bool", 524 | "format": lambda value: "Yes" if value else "No", 525 | "help": "Enable/disable this channel", 526 | }, 527 | { 528 | "title": "Watering Level", 529 | "prop": "water_level", 530 | "inc": 0.05, 531 | "min": 0, 532 | "max": 1.0, 533 | "mode": "float", 534 | "round": 2, 535 | "format": lambda value: f"{value * 100:0.2f}%", 536 | "help": "Saturation at which watering occurs", 537 | "context": "sat", 538 | }, 539 | { 540 | "title": "Auto Water", 541 | "prop": "auto_water", 542 | "mode": "bool", 543 | "format": lambda value: "Yes" if value else "No", 544 | "help": "Enable/disable watering", 545 | }, 546 | { 547 | "title": "Wet Point", 548 | "prop": "wet_point", 549 | "inc": 0.5, 550 | "min": 1, 551 | "max": 27, 552 | "mode": "float", 553 | "round": 2, 554 | "format": lambda value: f"{value:0.2f}Hz", 555 | "help": "Frequency for fully saturated soil", 556 | "context": "hz", 557 | }, 558 | { 559 | "title": "Dry Point", 560 | "prop": "dry_point", 561 | "inc": 0.5, 562 | "min": 1, 563 | "max": 27, 564 | "mode": "float", 565 | "round": 2, 566 | "format": lambda value: f"{value:0.2f}Hz", 567 | "help": "Frequency for fully dried soil", 568 | "context": "hz", 569 | }, 570 | { 571 | "title": "Pump Time", 572 | "prop": "pump_time", 573 | "inc": 0.05, 574 | "min": 0.05, 575 | "max": 2.0, 576 | "mode": "float", 577 | "round": 2, 578 | "format": lambda value: f"{value:0.2f}sec", 579 | "help": "Time to run pump" 580 | }, 581 | { 582 | "title": "Pump Speed", 583 | "prop": "pump_speed", 584 | "inc": 0.05, 585 | "min": 0.05, 586 | "max": 1.0, 587 | "mode": "float", 588 | "round": 2, 589 | "format": lambda value: f"{value*100:0.0f}%", 590 | "help": "Speed of pump" 591 | }, 592 | { 593 | "title": "Watering Delay", 594 | "prop": "watering_delay", 595 | "inc": 10, 596 | "min": 30, 597 | "max": 500, 598 | "mode": "int", 599 | "format": lambda value: f"{value:0.0f}sec", 600 | "help": "Delay between waterings" 601 | }, 602 | 603 | ] 604 | EditView.__init__(self, image, options) 605 | ChannelView.__init__(self, image, channel) 606 | 607 | def render(self): 608 | self.clear() 609 | 610 | EditView.render(self) 611 | 612 | option = self._options[self._current_option] 613 | if "context" in option: 614 | self.draw_context((34, 6), option["context"]) 615 | 616 | 617 | class Channel: 618 | colors = [ 619 | COLOR_BLUE, 620 | COLOR_GREEN, 621 | COLOR_YELLOW, 622 | COLOR_RED 623 | ] 624 | 625 | def __init__( 626 | self, 627 | display_channel, 628 | sensor_channel, 629 | pump_channel, 630 | title=None, 631 | water_level=0.5, 632 | warn_level=0.5, 633 | pump_speed=0.5, 634 | pump_time=0.2, 635 | watering_delay=60, 636 | wet_point=0.7, 637 | dry_point=26.7, 638 | icon=None, 639 | auto_water=False, 640 | enabled=False, 641 | ): 642 | self.channel = display_channel 643 | self.sensor = Moisture(sensor_channel) 644 | self.pump = Pump(pump_channel) 645 | self.water_level = water_level 646 | self.warn_level = warn_level 647 | self.auto_water = auto_water 648 | self.pump_speed = pump_speed 649 | self.pump_time = pump_time 650 | self.watering_delay = watering_delay 651 | self._wet_point = wet_point 652 | self._dry_point = dry_point 653 | self.last_dose = time.time() 654 | self.icon = icon 655 | self._enabled = enabled 656 | self.alarm = False 657 | self.title = f"Channel {display_channel}" if title is None else title 658 | 659 | self.sensor.set_wet_point(wet_point) 660 | self.sensor.set_dry_point(dry_point) 661 | 662 | @property 663 | def enabled(self): 664 | return self._enabled 665 | 666 | @enabled.setter 667 | def enabled(self, enabled): 668 | self._enabled = enabled 669 | 670 | @property 671 | def wet_point(self): 672 | return self._wet_point 673 | 674 | @property 675 | def dry_point(self): 676 | return self._dry_point 677 | 678 | @wet_point.setter 679 | def wet_point(self, wet_point): 680 | self._wet_point = wet_point 681 | self.sensor.set_wet_point(wet_point) 682 | 683 | @dry_point.setter 684 | def dry_point(self, dry_point): 685 | self._dry_point = dry_point 686 | self.sensor.set_dry_point(dry_point) 687 | 688 | def indicator_color(self, value): 689 | value = 1.0 - value 690 | 691 | if value == 1.0: 692 | return self.colors[-1] 693 | 694 | if value == 0.0: 695 | return self.colors[0] 696 | 697 | value *= len(self.colors) - 1 698 | a = int(math.floor(value)) 699 | b = a + 1 700 | blend = float(value - a) 701 | 702 | r, g, b = [int(((self.colors[b][i] - self.colors[a][i]) * blend) + self.colors[a][i]) for i in range(3)] 703 | 704 | return (r, g, b) 705 | 706 | def update_from_yml(self, config): 707 | if config is not None: 708 | self.pump_speed = config.get("pump_speed", self.pump_speed) 709 | self.pump_time = config.get("pump_time", self.pump_time) 710 | self.warn_level = config.get("warn_level", self.warn_level) 711 | self.water_level = config.get("water_level", self.water_level) 712 | self.watering_delay = config.get("watering_delay", self.watering_delay) 713 | self.auto_water = config.get("auto_water", self.auto_water) 714 | self.enabled = config.get("enabled", self.enabled) 715 | self.wet_point = config.get("wet_point", self.wet_point) 716 | self.dry_point = config.get("dry_point", self.dry_point) 717 | 718 | pass 719 | 720 | def __str__(self): 721 | return """Channel: {channel} 722 | Enabled: {enabled} 723 | Alarm level: {warn_level} 724 | Auto water: {auto_water} 725 | Water level: {water_level} 726 | Pump speed: {pump_speed} 727 | Pump time: {pump_time} 728 | Delay: {watering_delay} 729 | Wet point: {wet_point} 730 | Dry point: {dry_point} 731 | """.format( 732 | channel=self.channel, 733 | enabled=self.enabled, 734 | warn_level=self.warn_level, 735 | auto_water=self.auto_water, 736 | water_level=self.water_level, 737 | pump_speed=self.pump_speed, 738 | pump_time=self.pump_time, 739 | watering_delay=self.watering_delay, 740 | wet_point=self.wet_point, 741 | dry_point=self.dry_point, 742 | ) 743 | 744 | def water(self): 745 | if not self.auto_water: 746 | return False 747 | if time.time() - self.last_dose > self.watering_delay: 748 | self.pump.dose(self.pump_speed, self.pump_time, blocking=False) 749 | self.last_dose = time.time() 750 | return True 751 | return False 752 | 753 | def render(self, image, font): 754 | pass 755 | 756 | def update(self): 757 | if not self.enabled: 758 | return 759 | sat = self.sensor.saturation 760 | if sat < self.water_level: 761 | if self.water(): 762 | logging.info( 763 | "Watering Channel: {} - rate {:.2f} for {:.2f}sec".format( 764 | self.channel, self.pump_speed, self.pump_time 765 | ) 766 | ) 767 | if sat < self.warn_level: 768 | if not self.alarm: 769 | logging.warning( 770 | "Alarm on Channel: {} - saturation is {:.2f}% (warn level {:.2f}%)".format( 771 | self.channel, sat * 100, self.warn_level * 100 772 | ) 773 | ) 774 | self.alarm = True 775 | else: 776 | self.alarm = False 777 | 778 | 779 | class Alarm(View): 780 | def __init__(self, image, enabled=True, interval=10.0, beep_frequency=440): 781 | self.piezo = Piezo() 782 | self.enabled = enabled 783 | self.interval = interval 784 | self.beep_frequency = beep_frequency 785 | self._triggered = False 786 | self._time_last_beep = time.time() 787 | self._sleep_until = None 788 | 789 | View.__init__(self, image) 790 | 791 | def update_from_yml(self, config): 792 | if config is not None: 793 | self.enabled = config.get("alarm_enable", self.enabled) 794 | self.interval = config.get("alarm_interval", self.interval) 795 | 796 | def update(self, lights_out=False): 797 | if self._sleep_until is not None: 798 | if self._sleep_until > time.time(): 799 | return 800 | self._sleep_until = None 801 | 802 | if ( 803 | self.enabled 804 | and not lights_out 805 | and self._triggered 806 | and time.time() - self._time_last_beep > self.interval 807 | ): 808 | self.piezo.beep(self.beep_frequency, 0.1, blocking=False) 809 | threading.Timer( 810 | 0.3, 811 | self.piezo.beep, 812 | args=[self.beep_frequency, 0.1], 813 | kwargs={"blocking": False}, 814 | ).start() 815 | threading.Timer( 816 | 0.6, 817 | self.piezo.beep, 818 | args=[self.beep_frequency, 0.1], 819 | kwargs={"blocking": False}, 820 | ).start() 821 | self._time_last_beep = time.time() 822 | 823 | self._triggered = False 824 | 825 | def render(self, position=(0, 0)): 826 | x, y = position 827 | # Draw the snooze icon- will be pulsing red if the alarm state is True 828 | #self._draw.rectangle((x, y, x + 19, y + 19), (255, 255, 255)) 829 | r = 129 830 | if self._triggered and self._sleep_until is None: 831 | r = int(((math.sin(time.time() * 3 * math.pi) + 1.0) / 2.0) * 128) + 127 832 | 833 | if self._sleep_until is None: 834 | self.icon(icon_alarm, (x, y - 1), (r, 129, 129)) 835 | else: 836 | self.icon(icon_snooze, (x, y - 1), (r, 129, 129)) 837 | 838 | def trigger(self): 839 | self._triggered = True 840 | 841 | def disable(self): 842 | self.enabled = False 843 | 844 | def enable(self): 845 | self.enabled = True 846 | 847 | def cancel_sleep(self): 848 | self._sleep_until = None 849 | 850 | def sleeping(self): 851 | return self._sleep_until is not None 852 | 853 | def sleep(self, duration=500): 854 | self._sleep_until = time.time() + duration 855 | 856 | 857 | class ViewController: 858 | def __init__(self, views): 859 | self.views = views 860 | self._current_view = 0 861 | self._current_subview = 0 862 | 863 | @property 864 | def home(self): 865 | return self._current_view == 0 and self._current_subview == 0 866 | 867 | def next_subview(self): 868 | view = self.views[self._current_view] 869 | if isinstance(view, tuple): 870 | self._current_subview += 1 871 | self._current_subview %= len(view) 872 | 873 | def next_view(self): 874 | if self._current_subview == 0: 875 | self._current_view += 1 876 | self._current_view %= len(self.views) 877 | self._current_subview = 0 878 | 879 | def prev_view(self): 880 | if self._current_subview == 0: 881 | self._current_view -= 1 882 | self._current_view %= len(self.views) 883 | self._current_subview = 0 884 | 885 | def get_current_view(self): 886 | view = self.views[self._current_view] 887 | if isinstance(view, tuple): 888 | view = view[self._current_subview] 889 | 890 | return view 891 | 892 | @property 893 | def view(self): 894 | return self.get_current_view() 895 | 896 | def update(self): 897 | self.view.update() 898 | 899 | def render(self): 900 | self.view.render() 901 | 902 | def button_a(self): 903 | if not self.view.button_a(): 904 | self.next_view() 905 | 906 | def button_b(self): 907 | self.view.button_b() 908 | 909 | def button_x(self): 910 | if not self.view.button_x(): 911 | self.next_subview() 912 | return True 913 | return True 914 | 915 | def button_y(self): 916 | return self.view.button_y() 917 | 918 | 919 | class Config: 920 | def __init__(self): 921 | self.config = None 922 | self._last_save = "" 923 | 924 | self.channel_settings = [ 925 | "enabled", 926 | "warn_level", 927 | "wet_point", 928 | "dry_point", 929 | "watering_delay", 930 | "auto_water", 931 | "pump_time", 932 | "pump_speed", 933 | "water_level", 934 | ] 935 | 936 | self.general_settings = [ 937 | "alarm_enable", 938 | "alarm_interval", 939 | ] 940 | 941 | def load(self, settings_file="settings.yml"): 942 | if len(sys.argv) > 1: 943 | settings_file = sys.argv[1] 944 | 945 | settings_file = pathlib.Path(settings_file) 946 | 947 | if settings_file.is_file(): 948 | try: 949 | self.config = yaml.safe_load(open(settings_file)) 950 | except yaml.parser.ParserError as e: 951 | raise yaml.parser.ParserError( 952 | "Error parsing settings file: {} ({})".format(settings_file, e) 953 | ) 954 | 955 | def save(self, settings_file="settings.yml"): 956 | if len(sys.argv) > 1: 957 | settings_file = sys.argv[1] 958 | 959 | settings_file = pathlib.Path(settings_file) 960 | 961 | dump = yaml.dump(self.config) 962 | 963 | if dump == self._last_save: 964 | return 965 | 966 | if settings_file.is_file(): 967 | with open(settings_file, "w") as file: 968 | file.write(dump) 969 | 970 | self._last_save = dump 971 | 972 | def get_channel(self, channel_id): 973 | return self.config.get("channel{}".format(channel_id), {}) 974 | 975 | def set(self, section, settings): 976 | if isinstance(settings, dict): 977 | self.config[section].update(settings) 978 | else: 979 | for key in self.channel_settings: 980 | value = getattr(settings, key, None) 981 | if value is not None: 982 | self.config[section].update({key: value}) 983 | 984 | def set_channel(self, channel_id, settings): 985 | self.set("channel{}".format(channel_id), settings) 986 | 987 | def get_general(self): 988 | return self.config.get("general", {}) 989 | 990 | def set_general(self, settings): 991 | self.set("general", settings) 992 | 993 | 994 | def main(): 995 | def handle_button(pin): 996 | index = BUTTONS.index(pin) 997 | label = LABELS[index] 998 | 999 | if label == "A": # Select View 1000 | viewcontroller.button_a() 1001 | 1002 | if label == "B": # Sleep Alarm 1003 | if not viewcontroller.button_b(): 1004 | if viewcontroller.home: 1005 | if alarm.sleeping(): 1006 | alarm.cancel_sleep() 1007 | else: 1008 | alarm.sleep() 1009 | 1010 | if label == "X": 1011 | viewcontroller.button_x() 1012 | 1013 | if label == "Y": 1014 | viewcontroller.button_y() 1015 | 1016 | 1017 | # Set up the ST7735 SPI Display 1018 | display = ST7735.ST7735( 1019 | port=0, cs=1, dc=9, backlight=12, rotation=270, spi_speed_hz=80000000 1020 | ) 1021 | display.begin() 1022 | 1023 | # Set up light sensor 1024 | light = ltr559.LTR559() 1025 | 1026 | # Set up our canvas and prepare for drawing 1027 | image = Image.new("RGBA", (DISPLAY_WIDTH, DISPLAY_HEIGHT), color=(255, 255, 255)) 1028 | 1029 | # Setup blank image for darkness 1030 | image_blank = Image.new("RGBA", (DISPLAY_WIDTH, DISPLAY_HEIGHT), color=(0, 0, 0)) 1031 | 1032 | 1033 | # Pick a random selection of plant icons to display on screen 1034 | channels = [ 1035 | Channel(1, 1, 1), 1036 | Channel(2, 2, 2), 1037 | Channel(3, 3, 3), 1038 | ] 1039 | 1040 | alarm = Alarm(image) 1041 | 1042 | config = Config() 1043 | 1044 | GPIO.setmode(GPIO.BCM) 1045 | GPIO.setwarnings(False) 1046 | GPIO.setup(BUTTONS, GPIO.IN, pull_up_down=GPIO.PUD_UP) 1047 | 1048 | for pin in BUTTONS: 1049 | GPIO.add_event_detect(pin, GPIO.FALLING, handle_button, bouncetime=200) 1050 | 1051 | config.load() 1052 | 1053 | for channel in channels: 1054 | channel.update_from_yml(config.get_channel(channel.channel)) 1055 | 1056 | alarm.update_from_yml(config.get_general()) 1057 | 1058 | print("Channels:") 1059 | for channel in channels: 1060 | print(channel) 1061 | 1062 | print( 1063 | """Settings: 1064 | Alarm Enabled: {} 1065 | Alarm Interval: {:.2f}s 1066 | Low Light Set Screen To Black: {} 1067 | Low Light Value {:.2f} 1068 | """.format( 1069 | alarm.enabled, 1070 | alarm.interval, 1071 | config.get_general().get("black_screen_when_light_low"), 1072 | config.get_general().get("light_level_low") 1073 | ) 1074 | ) 1075 | 1076 | main_options = [ 1077 | { 1078 | "title": "Alarm Interval", 1079 | "prop": "interval", 1080 | "inc": 1, 1081 | "min": 1, 1082 | "max": 60, 1083 | "format": lambda value: f"{value:02.0f}sec", 1084 | "object": alarm, 1085 | "help": "Time between alarm beeps.", 1086 | }, 1087 | { 1088 | "title": "Alarm Enable", 1089 | "prop": "enabled", 1090 | "mode": "bool", 1091 | "format": lambda value: "Yes" if value else "No", 1092 | "object": alarm, 1093 | "help": "Enable the piezo alarm beep.", 1094 | }, 1095 | ] 1096 | 1097 | viewcontroller = ViewController( 1098 | [ 1099 | ( 1100 | MainView(image, channels=channels, alarm=alarm), 1101 | SettingsView(image, options=main_options), 1102 | ), 1103 | ( 1104 | DetailView(image, channel=channels[0]), 1105 | ChannelEditView(image, channel=channels[0]), 1106 | ), 1107 | ( 1108 | DetailView(image, channel=channels[1]), 1109 | ChannelEditView(image, channel=channels[1]), 1110 | ), 1111 | ( 1112 | DetailView(image, channel=channels[2]), 1113 | ChannelEditView(image, channel=channels[2]), 1114 | ), 1115 | ] 1116 | ) 1117 | 1118 | while True: 1119 | for channel in channels: 1120 | config.set_channel(channel.channel, channel) 1121 | channel.update() 1122 | if channel.alarm: 1123 | alarm.trigger() 1124 | 1125 | light_level_low = light.get_lux() < config.get_general().get("light_level_low") 1126 | 1127 | alarm.update(light_level_low) 1128 | 1129 | viewcontroller.update() 1130 | 1131 | if light_level_low and config.get_general().get("black_screen_when_light_low"): 1132 | display.sleep() 1133 | display.display(image_blank.convert("RGB")) 1134 | else: 1135 | viewcontroller.render() 1136 | display.wake() 1137 | display.display(image.convert("RGB")) 1138 | 1139 | config.set_general( 1140 | { 1141 | "alarm_enable": alarm.enabled, 1142 | "alarm_interval": alarm.interval, 1143 | } 1144 | ) 1145 | 1146 | config.save() 1147 | 1148 | time.sleep(1.0 / FPS) 1149 | 1150 | 1151 | if __name__ == "__main__": 1152 | main() 1153 | -------------------------------------------------------------------------------- /examples/settings.yml: -------------------------------------------------------------------------------- 1 | channel1: 2 | auto_water: false 3 | dry_point: 27 4 | enabled: true 5 | pump_speed: 0.5 6 | pump_time: 0.5 7 | warn_level: 0.2 8 | watering_delay: 60 9 | wet_point: 3 10 | channel2: 11 | auto_water: false 12 | dry_point: 27 13 | enabled: true 14 | pump_speed: 0.5 15 | pump_time: 0.5 16 | warn_level: 0.5 17 | watering_delay: 60 18 | wet_point: 3 19 | channel3: 20 | auto_water: false 21 | dry_point: 27 22 | enabled: true 23 | pump_speed: 0.5 24 | pump_time: 0.5 25 | warn_level: 0.4 26 | watering_delay: 60 27 | wet_point: 3 28 | general: 29 | alarm_enable: true 30 | alarm_interval: 2 31 | light_level_low: 4.0 32 | black_screen_when_light_low: false 33 | -------------------------------------------------------------------------------- /examples/tools/calibrate-pump.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | 4 | import RPi.GPIO as GPIO 5 | import ST7735 6 | from fonts.ttf import RobotoMedium as UserFont 7 | from PIL import Image, ImageDraw, ImageFont 8 | 9 | from grow.moisture import Moisture 10 | from grow.pump import Pump 11 | 12 | """ 13 | Auto water a single target with the channel/pump selected below. 14 | 15 | This example is useful for calibrating watering settings and will give you a good idea 16 | what speed/time you need to run your pump for in order to deliver a thorough watering. 17 | 18 | The buttons allow you to find tune and test the pump settings. 19 | 20 | A = Test settings 21 | B = Select setting to change 22 | X = Decrease value 23 | Y = Increase value 24 | """ 25 | 26 | # Channel settings 27 | pump_channel = 3 28 | moisture_channel = 3 29 | 30 | # Default watering settings 31 | dry_level = 0.7 # Saturation level considered dry 32 | dose_speed = 0.63 # Pump speed for water dose 33 | dose_time = 0.96 # Time (in seconds) for water dose 34 | 35 | # Here be dragons! 36 | FPS = 15 # Display framerate 37 | NUM_SAMPLES = 10 # Number of saturation level samples to average over 38 | DOSE_FREQUENCY = 30.0 # Minimum time between automatic waterings (in seconds) 39 | 40 | BUTTONS = [5, 6, 16, 24] 41 | LABELS = ["A", "B", "X", "Y"] 42 | 43 | p = Pump(pump_channel) 44 | m = Moisture(moisture_channel) 45 | 46 | GPIO.setmode(GPIO.BCM) 47 | GPIO.setwarnings(False) 48 | GPIO.setup(BUTTONS, GPIO.IN, pull_up_down=GPIO.PUD_UP) 49 | 50 | mode = 0 51 | last_dose = time.time() 52 | saturation = [1.0 for _ in range(NUM_SAMPLES)] 53 | 54 | display = ST7735.ST7735( 55 | port=0, cs=1, dc=9, backlight=12, rotation=270, spi_speed_hz=80000000 56 | ) 57 | 58 | display.begin() 59 | 60 | font = ImageFont.truetype(UserFont, 12) 61 | image = Image.new("RGBA", (display.width, display.height), color=(0, 0, 0)) 62 | draw = ImageDraw.Draw(image) 63 | 64 | logging.basicConfig( 65 | format="%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s", 66 | level=logging.INFO, 67 | datefmt="%Y-%m-%d %H:%M:%S", 68 | ) 69 | 70 | 71 | def handle_button(pin): 72 | global mode, last_dose, dose_time, dose_speed, dry_level 73 | index = BUTTONS.index(pin) 74 | label = LABELS[index] 75 | if label == "A": # Test 76 | logging.info("Manual watering triggered.") 77 | p.dose(dose_speed, dose_time, blocking=False) 78 | last_dose = time.time() 79 | 80 | if label == "B": # Switch setting 81 | mode += 1 82 | mode %= 3 # Wrap 0, 1, 2 (Time, Speed, Dry level) 83 | 84 | if label == "Y": # Inc. setting 85 | if mode == 0: 86 | dose_time += 0.01 87 | logging.info("Dose time increased to: {:.2f}".format(dose_time)) 88 | elif mode == 1: 89 | dose_speed += 0.01 90 | logging.info("Dose speed increased to: {:.2f}".format(dose_speed)) 91 | elif mode == 2: 92 | dry_level += 0.01 93 | logging.info("Dry level increased to: {:.2f}".format(dry_level)) 94 | 95 | if label == "X": # Dec. setting 96 | if mode == 0: 97 | dose_time -= 0.01 98 | logging.info("Dose time decreased to: {:.2f}".format(dose_time)) 99 | elif mode == 1: 100 | dose_speed -= 0.01 101 | logging.info("Dose speed decreased to: {:.2f}".format(dose_speed)) 102 | elif mode == 2: 103 | dry_level -= 0.01 104 | logging.info("Dry level decreased to: {:.2f}".format(dry_level)) 105 | 106 | 107 | # Bind the button handler (above) to all buttons 108 | for pin in BUTTONS: 109 | GPIO.add_event_detect(pin, GPIO.FALLING, handle_button, bouncetime=150) 110 | 111 | 112 | current_saturation = 0 113 | 114 | try: 115 | while True: 116 | # New moisture readings are available approximately 1/sec 117 | # push them into the list for averagering 118 | if m.new_data: 119 | current_saturation = m.saturation 120 | saturation.append(current_saturation) 121 | saturation = saturation[-NUM_SAMPLES:] 122 | 123 | avg_saturation = sum(saturation) / float(NUM_SAMPLES) 124 | 125 | # Trigger a dose of water if the average saturation is less than the specified dry level 126 | # dose frequency is rate limited, so this doesn't re-trigger before the moistrure sensor 127 | # has had the opportunity to catch up. 128 | if avg_saturation < dry_level and (time.time() - last_dose) > DOSE_FREQUENCY: 129 | p.dose(dose_speed, dose_time) 130 | logging.info( 131 | "Auto watering. Saturation: {:.2f} (Dry: {:.2f})".format( 132 | avg_saturation, dry_level 133 | ) 134 | ) 135 | last_dose = time.time() 136 | 137 | draw.rectangle((0, 0, display.width, display.height), (0, 0, 0)) 138 | 139 | # Current and average saturation 140 | draw.text( 141 | (5 + display.width // 2, 16), 142 | "Sat: {:.3f}".format(current_saturation), 143 | font=font, 144 | fill=(255, 255, 255), 145 | ) 146 | draw.text( 147 | (5 + display.width // 2, 32), 148 | "AVG: {:.3f}".format(avg_saturation), 149 | font=font, 150 | fill=(255, 255, 255), 151 | ) 152 | 153 | # Selected setting box 154 | draw.rectangle( 155 | (0, 16 + (16 * mode), display.width // 2, 31 + (16 * mode)), (30, 30, 30) 156 | ) 157 | 158 | draw.text( 159 | (5, 16), 160 | "Time: {:.2f}".format(dose_time), 161 | font=font, 162 | fill=(255, 255, 255) if mode == 0 else (128, 128, 128), 163 | ) 164 | draw.text( 165 | (5, 32), 166 | "Speed: {:.2f}".format(dose_speed), 167 | font=font, 168 | fill=(255, 255, 255) if mode == 1 else (128, 128, 128), 169 | ) 170 | draw.text( 171 | (5, 48), 172 | "Dry lvl: {:.2f}".format(dry_level), 173 | font=font, 174 | fill=(255, 255, 255) if mode == 2 else (128, 128, 128), 175 | ) 176 | 177 | # Button label backgrounds 178 | draw.rectangle((0, 0, 42, 14), (255, 255, 255)) 179 | draw.rectangle((display.width - 15, 0, display.width, 14), (255, 255, 255)) 180 | draw.rectangle( 181 | (display.width - 15, display.height - 14, display.width, display.height), 182 | (255, 255, 255), 183 | ) 184 | draw.rectangle((0, display.height - 14, 42, display.height), (255, 255, 255)) 185 | 186 | # Button labels 187 | draw.text((5, 0), "test", font=font, fill=(0, 0, 0)) 188 | draw.text((5, display.height - 16), "next", font=font, fill=(0, 0, 0)) 189 | draw.text((display.width - 10, 0), "-", font=font, fill=(0, 0, 0)) 190 | draw.text( 191 | (display.width - 12, display.height - 15), "+", font=font, fill=(0, 0, 0) 192 | ) 193 | 194 | display.display(image) 195 | time.sleep(1.0 / FPS) 196 | 197 | except KeyboardInterrupt: 198 | print( 199 | "Dose Time: {:.2f} Dose Speed: {:.2f}, Dry Level: {:.2f}".format( 200 | dose_time, dose_speed, dry_level 201 | ) 202 | ) 203 | -------------------------------------------------------------------------------- /examples/web_serve.py: -------------------------------------------------------------------------------- 1 | """ 2 | @jorjun Anno Vvii ☉ in ♓ ☽ in ♋ 3 | License: MIT 4 | Description: Web API for moisture readings: http://:8080/ 5 | """ 6 | import json 7 | import logging 8 | from functools import partial 9 | 10 | from aiohttp import web 11 | 12 | from grow.moisture import Moisture 13 | 14 | json_response = partial(web.json_response, dumps=partial(json.dumps, default=str)) 15 | routes = web.RouteTableDef() 16 | 17 | 18 | @routes.get("/") # Or whatever URL path you want 19 | async def reading(request): 20 | data = { 21 | "m1": meter[0].moisture, 22 | "m2": meter[1].moisture, 23 | "m3": meter[2].moisture, 24 | } 25 | return json_response(data) 26 | 27 | 28 | if __name__ == "__main__": 29 | app = web.Application() 30 | logging.basicConfig(level=logging.INFO) 31 | app.add_routes(routes) 32 | meter = [Moisture(_+1) for _ in range(3)] 33 | web.run_app( 34 | app, 35 | host="0.0.0.0", 36 | port=8080, 37 | access_log_format='%s %r [%b / %Tf] "%{User-Agent}i"', 38 | ) 39 | -------------------------------------------------------------------------------- /grow/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.0.2' 2 | 3 | import atexit 4 | import threading 5 | import time 6 | 7 | import RPi.GPIO as GPIO 8 | 9 | 10 | class Piezo(): 11 | def __init__(self, gpio_pin=13): 12 | GPIO.setmode(GPIO.BCM) 13 | GPIO.setwarnings(False) 14 | GPIO.setup(gpio_pin, GPIO.OUT, initial=GPIO.LOW) 15 | self.pwm = GPIO.PWM(gpio_pin, 440) 16 | self.pwm.start(0) 17 | self._timeout = None 18 | atexit.register(self._exit) 19 | 20 | def frequency(self, value): 21 | """Change the piezo frequency. 22 | 23 | Loosely corresponds to musical pitch, if you suspend disbelief. 24 | 25 | """ 26 | self.pwm.ChangeFrequency(value) 27 | 28 | def start(self, frequency=None): 29 | """Start the piezo. 30 | 31 | Sets the Duty Cycle to 100% 32 | 33 | """ 34 | if frequency is not None: 35 | self.frequency(frequency) 36 | self.pwm.ChangeDutyCycle(1) 37 | 38 | def stop(self): 39 | """Stop the piezo. 40 | 41 | Sets the Duty Cycle to 0% 42 | 43 | """ 44 | self.pwm.ChangeDutyCycle(0) 45 | 46 | def beep(self, frequency=440, timeout=0.1, blocking=True, force=False): 47 | """Beep the piezo for time seconds. 48 | 49 | :param freq: Frequency, in hertz, of the piezo 50 | :param timeout: Time, in seconds, of the piezo beep 51 | :param blocking: If true, function will block until piezo has stopped 52 | 53 | """ 54 | if blocking: 55 | self.start(frequency=frequency) 56 | time.sleep(timeout) 57 | self.stop() 58 | return True 59 | else: 60 | if self._timeout is not None: 61 | if self._timeout.is_alive(): 62 | if force: 63 | self._timeout.cancel() 64 | else: 65 | return False 66 | self._timeout = threading.Timer(timeout, self.stop) 67 | self.start(frequency=frequency) 68 | self._timeout.start() 69 | return True 70 | 71 | def _exit(self): 72 | self.pwm.stop() 73 | -------------------------------------------------------------------------------- /grow/moisture.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import RPi.GPIO as GPIO 4 | 5 | MOISTURE_1_PIN = 23 6 | MOISTURE_2_PIN = 8 7 | MOISTURE_3_PIN = 25 8 | MOISTURE_INT_PIN = 4 9 | 10 | 11 | class Moisture(object): 12 | """Grow moisture sensor driver.""" 13 | 14 | def __init__(self, channel=1, wet_point=None, dry_point=None): 15 | """Create a new moisture sensor. 16 | 17 | Uses an interrupt to count pulses on the GPIO pin corresponding to the selected channel. 18 | 19 | The moisture reading is given as pulses per second. 20 | 21 | :param channel: One of 1, 2 or 3. 4 can optionally be used to set up a sensor on the Int pin (BCM4) 22 | :param wet_point: Wet point in pulses/sec 23 | :param dry_point: Dry point in pulses/sec 24 | 25 | """ 26 | self._gpio_pin = [MOISTURE_1_PIN, MOISTURE_2_PIN, MOISTURE_3_PIN, MOISTURE_INT_PIN][channel - 1] 27 | 28 | GPIO.setwarnings(False) 29 | GPIO.setmode(GPIO.BCM) 30 | GPIO.setup(self._gpio_pin, GPIO.IN) 31 | 32 | self._count = 0 33 | self._reading = 0 34 | self._history = [] 35 | self._history_length = 200 36 | self._last_pulse = time.time() 37 | self._new_data = False 38 | self._wet_point = wet_point if wet_point is not None else 0.7 39 | self._dry_point = dry_point if dry_point is not None else 27.6 40 | self._time_last_reading = time.time() 41 | try: 42 | GPIO.add_event_detect(self._gpio_pin, GPIO.RISING, callback=self._event_handler, bouncetime=1) 43 | except RuntimeError as e: 44 | if self._gpio_pin == 8: 45 | raise RuntimeError("""Unable to set up edge detection on BCM8. 46 | 47 | Please ensure you add the following to /boot/config.txt and reboot: 48 | 49 | dtoverlay=spi0-cs,cs0_pin=14 # Re-assign CS0 from BCM 8 so that Grow can use it 50 | 51 | """) 52 | else: 53 | raise e 54 | 55 | self._time_start = time.time() 56 | 57 | def _event_handler(self, pin): 58 | self._count += 1 59 | self._last_pulse = time.time() 60 | if self._time_elapsed >= 1.0: 61 | self._reading = self._count / self._time_elapsed 62 | self._history.insert(0, self._reading) 63 | self._history = self._history[:self._history_length] 64 | self._count = 0 65 | self._time_last_reading = time.time() 66 | self._new_data = True 67 | 68 | @property 69 | def history(self): 70 | history = [] 71 | 72 | for moisture in self._history: 73 | saturation = float(moisture - self._dry_point) / self.range 74 | saturation = round(saturation, 3) 75 | history.append(max(0.0, min(1.0, saturation))) 76 | 77 | return history 78 | 79 | @property 80 | def _time_elapsed(self): 81 | return time.time() - self._time_last_reading 82 | 83 | def set_wet_point(self, value=None): 84 | """Set the sensor wet point. 85 | 86 | This is the watered, wet state of your soil. 87 | 88 | It should be set shortly after watering. Leave ~5 mins for moisture to permeate. 89 | 90 | :param value: Wet point value to set in pulses/sec, leave as None to set the last sensor reading. 91 | 92 | """ 93 | self._wet_point = value if value is not None else self._reading 94 | 95 | def set_dry_point(self, value=None): 96 | """Set the sensor dry point. 97 | 98 | This is the unwatered, dry state of your soil. 99 | 100 | It should be set when the soil is dry to the touch. 101 | 102 | :param value: Dry point value to set in pulses/sec, leave as None to set the last sensor reading. 103 | 104 | """ 105 | self._dry_point = value if value is not None else self._reading 106 | 107 | @property 108 | def moisture(self): 109 | """Return the raw moisture level. 110 | 111 | The value returned is the pulses/sec read from the soil moisture sensor. 112 | 113 | This value is inversely proportional to the amount of moisture. 114 | 115 | Full immersion in water is approximately 50 pulses/sec. 116 | 117 | Fully dry (in air) is approximately 900 pulses/sec. 118 | 119 | """ 120 | self._new_data = False 121 | return self._reading 122 | 123 | @property 124 | def active(self): 125 | """Check if the moisture sensor is producing a valid reading.""" 126 | return (time.time() - self._last_pulse) < 1.0 and self._reading > 0 and self._reading < 28 127 | 128 | @property 129 | def new_data(self): 130 | """Check for new reading. 131 | 132 | Returns True if moisture value has been updated since last reading moisture or saturation. 133 | 134 | """ 135 | return self._new_data 136 | 137 | @property 138 | def range(self): 139 | """Return the range sensor range (wet - dry points).""" 140 | return self._wet_point - self._dry_point 141 | 142 | @property 143 | def saturation(self): 144 | """Return saturation as a float from 0.0 to 1.0. 145 | 146 | This value is calculated using the wet and dry points. 147 | 148 | """ 149 | saturation = float(self.moisture - self._dry_point) / self.range 150 | saturation = round(saturation, 3) 151 | return max(0.0, min(1.0, saturation)) 152 | -------------------------------------------------------------------------------- /grow/pump.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import threading 3 | import time 4 | 5 | import RPi.GPIO as GPIO 6 | 7 | PUMP_1_PIN = 17 8 | PUMP_2_PIN = 27 9 | PUMP_3_PIN = 22 10 | PUMP_PWM_FREQ = 10000 11 | PUMP_MAX_DUTY = 90 12 | 13 | 14 | global_lock = threading.Lock() 15 | 16 | 17 | class Pump(object): 18 | """Grow pump driver.""" 19 | 20 | def __init__(self, channel=1): 21 | """Create a new pump. 22 | 23 | Uses soft PWM to drive a Grow pump. 24 | 25 | :param channel: One of 1, 2 or 3. 26 | 27 | """ 28 | 29 | self._gpio_pin = [PUMP_1_PIN, PUMP_2_PIN, PUMP_3_PIN][channel - 1] 30 | 31 | GPIO.setmode(GPIO.BCM) 32 | GPIO.setwarnings(False) 33 | GPIO.setup(self._gpio_pin, GPIO.OUT, initial=GPIO.LOW) 34 | self._pwm = GPIO.PWM(self._gpio_pin, PUMP_PWM_FREQ) 35 | self._pwm.start(0) 36 | 37 | self._timeout = None 38 | 39 | atexit.register(self._stop) 40 | 41 | def _stop(self): 42 | self._pwm.stop(0) 43 | GPIO.setup(self._gpio_pin, GPIO.IN) 44 | 45 | def set_speed(self, speed): 46 | """Set pump speed (PWM duty cycle).""" 47 | if speed > 1.0 or speed < 0: 48 | raise ValueError("Speed must be between 0 and 1") 49 | 50 | if speed == 0: 51 | global_lock.release() 52 | elif not global_lock.acquire(blocking=False): 53 | return False 54 | 55 | self._pwm.ChangeDutyCycle(int(PUMP_MAX_DUTY * speed)) 56 | self._speed = speed 57 | return True 58 | 59 | def get_speed(self): 60 | """Return Pump speed (PWM duty cycle).""" 61 | return self._speed 62 | 63 | def stop(self): 64 | """Stop the pump.""" 65 | if self._timeout is not None: 66 | self._timeout.cancel() 67 | self._timeout = None 68 | self.set_speed(0) 69 | 70 | def dose(self, speed, timeout=0.1, blocking=True, force=False): 71 | """Pulse the pump for timeout seconds. 72 | 73 | :param timeout: Timeout, in seconds, of the pump pulse 74 | :param blocking: If true, function will block until pump has stopped 75 | :param force: Applies only to non-blocking. If true, any previous dose will be replaced 76 | 77 | """ 78 | 79 | if blocking: 80 | if self.set_speed(speed): 81 | time.sleep(timeout) 82 | self.stop() 83 | return True 84 | 85 | else: 86 | if self._timeout is not None: 87 | if self._timeout.is_alive(): 88 | if force: 89 | self._timeout.cancel() 90 | 91 | self._timeout = threading.Timer(timeout, self.stop) 92 | if self.set_speed(speed): 93 | self._timeout.start() 94 | return True 95 | 96 | return False 97 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | LIBRARY_NAME=$(grep -m 1 name pyproject.toml | awk -F" = " '{print substr($2,2,length($2)-2)}') 3 | MODULE_NAME="grow" 4 | CONFIG_FILE=config.txt 5 | CONFIG_DIR="/boot/firmware" 6 | DATESTAMP=$(date "+%Y-%m-%d-%H-%M-%S") 7 | CONFIG_BACKUP=false 8 | APT_HAS_UPDATED=false 9 | RESOURCES_TOP_DIR=$HOME/Pimoroni 10 | VENV_BASH_SNIPPET=$RESOURCES_DIR/auto_venv.sh 11 | VENV_DIR=$HOME/.virtualenvs/pimoroni 12 | WD=$(pwd) 13 | USAGE="./install.sh (--unstable)" 14 | POSITIONAL_ARGS=() 15 | FORCE=false 16 | UNSTABLE=false 17 | PYTHON="python" 18 | 19 | 20 | user_check() { 21 | if [ "$(id -u)" -eq 0 ]; then 22 | printf "Script should not be run as root. Try './install.sh'\n" 23 | exit 1 24 | fi 25 | } 26 | 27 | confirm() { 28 | if $FORCE; then 29 | true 30 | else 31 | read -r -p "$1 [y/N] " response < /dev/tty 32 | if [[ $response =~ ^(yes|y|Y)$ ]]; then 33 | true 34 | else 35 | false 36 | fi 37 | fi 38 | } 39 | 40 | prompt() { 41 | read -r -p "$1 [y/N] " response < /dev/tty 42 | if [[ $response =~ ^(yes|y|Y)$ ]]; then 43 | true 44 | else 45 | false 46 | fi 47 | } 48 | 49 | success() { 50 | echo -e "$(tput setaf 2)$1$(tput sgr0)" 51 | } 52 | 53 | inform() { 54 | echo -e "$(tput setaf 6)$1$(tput sgr0)" 55 | } 56 | 57 | warning() { 58 | echo -e "$(tput setaf 1)$1$(tput sgr0)" 59 | } 60 | 61 | find_config() { 62 | if [ ! -f "$CONFIG_DIR/$CONFIG_FILE" ]; then 63 | CONFIG_DIR="/boot" 64 | if [ ! -f "$CONFIG_DIR/$CONFIG_FILE" ]; then 65 | warning "Could not find $CONFIG_FILE!" 66 | exit 1 67 | fi 68 | else 69 | if [ -f "/boot/$CONFIG_FILE" ] && [ ! -L "/boot/$CONFIG_FILE" ]; then 70 | warning "Oops! It looks like /boot/$CONFIG_FILE is not a link to $CONFIG_DIR/$CONFIG_FILE" 71 | warning "You might want to fix this!" 72 | fi 73 | fi 74 | inform "Using $CONFIG_FILE in $CONFIG_DIR" 75 | } 76 | 77 | venv_bash_snippet() { 78 | if [ ! -f "$VENV_BASH_SNIPPET" ]; then 79 | cat << EOF > "$VENV_BASH_SNIPPET" 80 | # Add `source $RESOURCES_DIR/auto_venv.sh` to your ~/.bashrc to activate 81 | # the Pimoroni virtual environment automagically! 82 | VENV_DIR="$VENV_DIR" 83 | if [ ! -f \$VENV_DIR/bin/activate ]; then 84 | printf "Creating user Python environment in \$VENV_DIR, please wait...\n" 85 | mkdir -p \$VENV_DIR 86 | python3 -m venv --system-site-packages \$VENV_DIR 87 | fi 88 | printf " ↓ ↓ ↓ ↓ Hello, we've activated a Python venv for you. To exit, type \"deactivate\".\n" 89 | source \$VENV_DIR/bin/activate 90 | EOF 91 | fi 92 | } 93 | 94 | venv_check() { 95 | PYTHON_BIN=$(which "$PYTHON") 96 | if [[ $VIRTUAL_ENV == "" ]] || [[ $PYTHON_BIN != $VIRTUAL_ENV* ]]; then 97 | printf "This script should be run in a virtual Python environment.\n" 98 | if confirm "Would you like us to create one for you?"; then 99 | if [ ! -f "$VENV_DIR/bin/activate" ]; then 100 | inform "Creating virtual Python environment in $VENV_DIR, please wait...\n" 101 | mkdir -p "$VENV_DIR" 102 | /usr/bin/python3 -m venv "$VENV_DIR" --system-site-packages 103 | venv_bash_snippet 104 | else 105 | inform "Found existing virtual Python environment in $VENV_DIR\n" 106 | fi 107 | inform "Activating virtual Python environment in $VENV_DIR..." 108 | inform "source $VENV_DIR/bin/activate\n" 109 | source "$VENV_DIR/bin/activate" 110 | 111 | else 112 | exit 1 113 | fi 114 | fi 115 | } 116 | 117 | function do_config_backup { 118 | if [ ! $CONFIG_BACKUP == true ]; then 119 | CONFIG_BACKUP=true 120 | FILENAME="config.preinstall-$LIBRARY_NAME-$DATESTAMP.txt" 121 | inform "Backing up $CONFIG_DIR/$CONFIG_FILE to $CONFIG_DIR/$FILENAME\n" 122 | sudo cp "$CONFIG_DIR/$CONFIG_FILE $CONFIG_DIR/$FILENAME" 123 | mkdir -p "$RESOURCES_TOP_DIR/config-backups/" 124 | cp $CONFIG_DIR/$CONFIG_FILE "$RESOURCES_TOP_DIR/config-backups/$FILENAME" 125 | if [ -f "$UNINSTALLER" ]; then 126 | echo "cp $RESOURCES_TOP_DIR/config-backups/$FILENAME $CONFIG_DIR/$CONFIG_FILE" >> "$UNINSTALLER" 127 | fi 128 | fi 129 | } 130 | 131 | function apt_pkg_install { 132 | PACKAGES=() 133 | PACKAGES_IN=("$@") 134 | for ((i = 0; i < ${#PACKAGES_IN[@]}; i++)); do 135 | PACKAGE="${PACKAGES_IN[$i]}" 136 | if [ "$PACKAGE" == "" ]; then continue; fi 137 | printf "Checking for %s\n" "$PACKAGE" 138 | dpkg -L "$PACKAGE" > /dev/null 2>&1 139 | if [ "$?" == "1" ]; then 140 | PACKAGES+=("$PACKAGE") 141 | fi 142 | done 143 | PACKAGES="${PACKAGES[@]}" 144 | if ! [ "$PACKAGES" == "" ]; then 145 | echo "Installing missing packages: $PACKAGES" 146 | if [ ! $APT_HAS_UPDATED ]; then 147 | sudo apt update 148 | APT_HAS_UPDATED=true 149 | fi 150 | sudo apt install -y $PACKAGES 151 | if [ -f "$UNINSTALLER" ]; then 152 | echo "apt uninstall -y $PACKAGES" >> "$UNINSTALLER" 153 | fi 154 | fi 155 | } 156 | 157 | function pip_pkg_install { 158 | PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring $PYTHON -m pip install --upgrade "$@" 159 | } 160 | 161 | while [[ $# -gt 0 ]]; do 162 | K="$1" 163 | case $K in 164 | -u|--unstable) 165 | UNSTABLE=true 166 | shift 167 | ;; 168 | -f|--force) 169 | FORCE=true 170 | shift 171 | ;; 172 | -p|--python) 173 | PYTHON=$2 174 | shift 175 | shift 176 | ;; 177 | *) 178 | if [[ $1 == -* ]]; then 179 | printf "Unrecognised option: %s\n" "$1"; 180 | printf "Usage: %s\n" "$USAGE"; 181 | exit 1 182 | fi 183 | POSITIONAL_ARGS+=("$1") 184 | shift 185 | esac 186 | done 187 | 188 | user_check 189 | venv_check 190 | 191 | if [ ! -f "$(which $PYTHON)" ]; then 192 | printf "Python path %s not found!\n" "$PYTHON" 193 | exit 1 194 | fi 195 | 196 | PYTHON_VER=$($PYTHON --version) 197 | 198 | printf "%s Python Library: Installer\n\n" $LIBRARY_NAME 199 | 200 | inform "Checking Dependencies. Please wait..." 201 | 202 | pip_pkg_install toml 203 | 204 | CONFIG_VARS=`$PYTHON - < "$UNINSTALLER" 239 | printf "It's recommended you run these steps manually.\n" 240 | printf "If you want to run the full script, open it in\n" 241 | printf "an editor and remove 'exit 1' from below.\n" 242 | exit 1 243 | source $VIRTUAL_ENV/bin/activate 244 | EOF 245 | 246 | if $UNSTABLE; then 247 | warning "Installing unstable library from source.\n\n" 248 | else 249 | printf "Installing stable library from pypi.\n\n" 250 | fi 251 | 252 | inform "Installing for $PYTHON_VER...\n" 253 | apt_pkg_install "${APT_PACKAGES[@]}" 254 | if $UNSTABLE; then 255 | pip_pkg_install . 256 | else 257 | pip_pkg_install "$LIBRARY_NAME" 258 | fi 259 | if [ $? -eq 0 ]; then 260 | success "Done!\n" 261 | echo "$PYTHON -m pip uninstall $LIBRARY_NAME" >> "$UNINSTALLER" 262 | fi 263 | 264 | cd "$WD" || exit 1 265 | 266 | find_config 267 | 268 | for ((i = 0; i < ${#SETUP_CMDS[@]}; i++)); do 269 | CMD="${SETUP_CMDS[$i]}" 270 | # Attempt to catch anything that touches config.txt and trigger a backup 271 | if [[ "$CMD" == *"raspi-config"* ]] || [[ "$CMD" == *"$CONFIG_DIR/$CONFIG_FILE"* ]] || [[ "$CMD" == *"\$CONFIG_DIR/\$CONFIG_FILE"* ]]; then 272 | do_config_backup 273 | fi 274 | eval "$CMD" 275 | done 276 | 277 | for ((i = 0; i < ${#CONFIG_TXT[@]}; i++)); do 278 | CONFIG_LINE="${CONFIG_TXT[$i]}" 279 | if ! [ "$CONFIG_LINE" == "" ]; then 280 | do_config_backup 281 | inform "Adding $CONFIG_LINE to $CONFIG_DIR/$CONFIG_FILE\n" 282 | sudo sed -i "s/^#$CONFIG_LINE/$CONFIG_LINE/" $CONFIG_DIR/$CONFIG_FILE 283 | if ! grep -q "^$CONFIG_LINE" $CONFIG_DIR/$CONFIG_FILE; then 284 | printf "%s\n" "$CONFIG_LINE" | sudo tee --append $CONFIG_DIR/$CONFIG_FILE 285 | fi 286 | fi 287 | done 288 | 289 | if [ -d "examples" ]; then 290 | if confirm "Would you like to copy examples to $RESOURCES_DIR?"; then 291 | inform "Copying examples to $RESOURCES_DIR" 292 | cp -r examples/ "$RESOURCES_DIR" 293 | echo "rm -r $RESOURCES_DIR" >> "$UNINSTALLER" 294 | success "Done!" 295 | fi 296 | fi 297 | 298 | printf "\n" 299 | 300 | if confirm "Would you like to generate documentation?"; then 301 | pip_pkg_install pdoc 302 | printf "Generating documentation.\n" 303 | $PYTHON -m pdoc $MODULE_NAME -o "$RESOURCES_DIR/docs" > /dev/null 304 | if [ $? -eq 0 ]; then 305 | inform "Documentation saved to $RESOURCES_DIR/docs" 306 | success "Done!" 307 | else 308 | warning "Error: Failed to generate documentation." 309 | fi 310 | fi 311 | 312 | success "\nAll done!" 313 | inform "If this is your first time installing you should reboot for hardware changes to take effect.\n" 314 | inform "Find uninstall steps in $UNINSTALLER\n" 315 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling", "hatch-fancy-pypi-readme"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "growhat" 7 | dynamic = ["version", "readme"] 8 | description = "Grow HAT Mini. A plant valet add-on for the Raspberry Pi" 9 | license = {file = "LICENSE"} 10 | requires-python = ">= 3.7" 11 | authors = [ 12 | { name = "Philip Howard", email = "phil@pimoroni.com" }, 13 | { name = "Paul Beech", email = "paul@pimoroni.com" }, 14 | ] 15 | maintainers = [ 16 | { name = "Philip Howard", email = "phil@pimoroni.com" }, 17 | ] 18 | keywords = [ 19 | "Pi", 20 | "Raspberry", 21 | ] 22 | classifiers = [ 23 | "Development Status :: 4 - Beta", 24 | "Intended Audience :: Developers", 25 | "License :: OSI Approved :: MIT License", 26 | "Operating System :: POSIX :: Linux", 27 | "Programming Language :: Python :: 3", 28 | "Programming Language :: Python :: 3.7", 29 | "Programming Language :: Python :: 3.8", 30 | "Programming Language :: Python :: 3.9", 31 | "Programming Language :: Python :: 3.10", 32 | "Programming Language :: Python :: 3.11", 33 | "Programming Language :: Python :: 3 :: Only", 34 | "Topic :: Software Development", 35 | "Topic :: Software Development :: Libraries", 36 | "Topic :: System :: Hardware", 37 | ] 38 | dependencies = [ 39 | "ltr559", 40 | "st7735>=0.0.5", 41 | "pyyaml", 42 | "fonts", 43 | "font-roboto" 44 | ] 45 | 46 | [project.urls] 47 | GitHub = "https://www.github.com/pimoroni/grow-python" 48 | Homepage = "https://www.pimoroni.com" 49 | 50 | [tool.hatch.version] 51 | path = "grow/__init__.py" 52 | 53 | [tool.hatch.build] 54 | include = [ 55 | "grow", 56 | "README.md", 57 | "CHANGELOG.md", 58 | "LICENSE" 59 | ] 60 | 61 | [tool.hatch.build.targets.sdist] 62 | include = [ 63 | "*" 64 | ] 65 | exclude = [ 66 | ".*", 67 | "dist" 68 | ] 69 | 70 | [tool.hatch.metadata.hooks.fancy-pypi-readme] 71 | content-type = "text/markdown" 72 | fragments = [ 73 | { path = "README.md" }, 74 | { text = "\n" }, 75 | { path = "CHANGELOG.md" } 76 | ] 77 | 78 | [tool.ruff] 79 | exclude = [ 80 | '.tox', 81 | '.egg', 82 | '.git', 83 | '__pycache__', 84 | 'build', 85 | 'dist' 86 | ] 87 | line-length = 200 88 | 89 | [tool.codespell] 90 | skip = """ 91 | ./service/install.sh,\ 92 | ./.tox,\ 93 | ./.egg,\ 94 | ./.git,\ 95 | ./__pycache__,\ 96 | ./build,\ 97 | ./dist.\ 98 | """ 99 | 100 | [tool.isort] 101 | line_length = 200 102 | 103 | [tool.black] 104 | line-length = 200 105 | 106 | [tool.check-manifest] 107 | ignore = [ 108 | '.stickler.yml', 109 | 'boilerplate.md', 110 | 'check.sh', 111 | 'install.sh', 112 | 'uninstall.sh', 113 | 'Makefile', 114 | 'tox.ini', 115 | 'tests/*', 116 | 'examples/*', 117 | '.coveragerc', 118 | 'requirements-dev.txt', 119 | 'requirements-examples.txt' 120 | ] 121 | 122 | [tool.pimoroni] 123 | apt_packages = [] 124 | configtxt = [] 125 | commands = [] 126 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | check-manifest 2 | ruff 3 | codespell 4 | isort 5 | twine 6 | hatch 7 | hatch-fancy-pypi-readme 8 | tox 9 | pdoc 10 | -------------------------------------------------------------------------------- /requirements-examples.txt: -------------------------------------------------------------------------------- 1 | pyyaml 2 | pillow>=10.0.0 3 | numpy -------------------------------------------------------------------------------- /service/README.md: -------------------------------------------------------------------------------- 1 | # Grow Service 2 | 3 | This script will install Grow as a service on your Raspberry Pi, allowing it to run at boot and recover from errors. 4 | 5 | # Installing 6 | 7 | ``` 8 | sudo ./install.sh 9 | ``` 10 | 11 | # Useful Commands 12 | 13 | * View service status: `systemctl status grow-monitor` 14 | * Stop service: `sudo systemctl stop grow-monitor` 15 | * Start service: `sudo systemctl start grow-monitor` 16 | * View full debug/error output: `journalctl --no-pager --unit grow-monitor` 17 | 18 | # Configuring Grow 19 | 20 | You can configure grow using the on-screen UI, or by editing the settings in `/etc/default/grow`. 21 | 22 | [See the examples README.md](../examples/README.md#channel-settings) for an explanation of the options. 23 | -------------------------------------------------------------------------------- /service/grow-monitor.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Grow Monitoring Service 3 | After=multi-user.target 4 | 5 | [Service] 6 | Type=simple 7 | WorkingDirectory=/usr/share/grow-monitor 8 | ExecStart=/usr/bin/grow-monitor /etc/default/grow 9 | Restart=on-failure 10 | StandardOutput=syslog+console 11 | StandardError=syslog+console 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | -------------------------------------------------------------------------------- /service/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | user_check() { 4 | if [ $(id -u) -ne 0 ]; then 5 | printf "Script must be run as root. Try 'sudo ./install.sh'\n" 6 | exit 1 7 | fi 8 | } 9 | 10 | success() { 11 | echo -e "$(tput setaf 2)$1$(tput sgr0)" 12 | } 13 | 14 | inform() { 15 | echo -e "$(tput setaf 6)$1$(tput sgr0)" 16 | } 17 | 18 | warning() { 19 | echo -e "$(tput setaf 1)$1$(tput sgr0)" 20 | } 21 | 22 | user_check 23 | 24 | inform "Copying icons to /usr/share/grow-monitor...\n" 25 | mkdir -p /usr/share/grow-monitor/icons 26 | cp ../examples/icons/* /usr/share/grow-monitor/icons 27 | 28 | inform "Installing grow-monitor to /usr/bin/grow-monitor...\n" 29 | cp ../examples/monitor.py /usr/bin/grow-monitor 30 | chmod +x /usr/bin/grow-monitor 31 | 32 | inform "Installing settings to /etc/default/grow...\n" 33 | cp ../examples/settings.yml /etc/default/grow 34 | 35 | inform "Installing systemd service...\n" 36 | cp grow-monitor.service /etc/systemd/system/ 37 | systemctl reenable grow-monitor.service 38 | systemctl start grow-monitor.service 39 | 40 | inform "\nTo see grow debug output, run: \"journalctl --no-pager --unit grow-monitor\"\n" 41 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | [metadata] 3 | name = growhat 4 | version = 0.0.2 5 | author = Philip Howard, Paul Beech 6 | author_email = phil@pimoroni.com 7 | description = Grow HAT Mini. A plant valet add-on for the Raspberry Pi 8 | long_description = file: README.md 9 | long_description_content_type = text/markdown 10 | keywords = Raspberry Pi 11 | url = https://www.pimoroni.com 12 | project_urls = 13 | GitHub=https://www.github.com/pimoroni/grow-python 14 | license = MIT 15 | # This includes the license file(s) in the wheel. 16 | # https://wheel.readthedocs.io/en/stable/user_guide.html#including-license-files-in-the-generated-wheel-file 17 | license_files = LICENSE.txt 18 | classifiers = 19 | Development Status :: 4 - Beta 20 | Operating System :: POSIX :: Linux 21 | License :: OSI Approved :: MIT License 22 | Intended Audience :: Developers 23 | Programming Language :: Python :: 3 24 | Topic :: Software Development 25 | Topic :: Software Development :: Libraries 26 | Topic :: System :: Hardware 27 | 28 | [options] 29 | python_requires = >= 3 30 | packages = grow 31 | install_requires = 32 | ltr559 33 | st7735>=0.0.5 34 | pyyaml 35 | fonts 36 | font-roboto 37 | 38 | [flake8] 39 | exclude = 40 | .tox, 41 | .eggs, 42 | .git, 43 | __pycache__, 44 | build, 45 | dist 46 | ignore = 47 | E501 48 | 49 | [pimoroni] 50 | py2deps = 51 | python-pip 52 | python-yaml 53 | python-smbus 54 | python-pil 55 | python-spidev 56 | python-numpy 57 | python-rpi.gpio 58 | py3deps = 59 | python3-pip 60 | python3-yaml 61 | python3-smbus 62 | python3-pil 63 | python3-spidev 64 | python3-numpy 65 | python3-rpi.gpio 66 | commands = 67 | printf "Setting up i2c and SPI..\n" 68 | raspi-config nonint do_spi 0 69 | raspi-config nonint do_i2c 0 70 | configtxt = 71 | dtoverlay=spi0-cs,cs0_pin=14 # Re-assign CS0 from BCM 8 so that Grow can use it 72 | 73 | 74 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Test configuration. 2 | These allow the mocking of various Python modules 3 | that might otherwise have runtime side-effects. 4 | """ 5 | import sys 6 | 7 | import mock 8 | import pytest 9 | from i2cdevice import MockSMBus 10 | 11 | 12 | class SMBusFakeDevice(MockSMBus): 13 | def __init__(self, i2c_bus): 14 | MockSMBus.__init__(self, i2c_bus) 15 | self.regs[0x00:0x01] = 0x0f, 0x00 16 | 17 | 18 | @pytest.fixture(scope='function', autouse=True) 19 | def cleanup(): 20 | yield None 21 | try: 22 | del sys.modules['grow'] 23 | except KeyError: 24 | pass 25 | try: 26 | del sys.modules['grow.moisture'] 27 | except KeyError: 28 | pass 29 | try: 30 | del sys.modules['grow.pump'] 31 | except KeyError: 32 | pass 33 | 34 | 35 | @pytest.fixture(scope='function', autouse=False) 36 | def GPIO(): 37 | """Mock RPi.GPIO module.""" 38 | GPIO = mock.MagicMock() 39 | # Fudge for Python < 37 (possibly earlier) 40 | sys.modules['RPi'] = mock.Mock() 41 | sys.modules['RPi'].GPIO = GPIO 42 | sys.modules['RPi.GPIO'] = GPIO 43 | yield GPIO 44 | del sys.modules['RPi'] 45 | del sys.modules['RPi.GPIO'] 46 | 47 | 48 | @pytest.fixture(scope='function', autouse=False) 49 | def spidev(): 50 | """Mock spidev module.""" 51 | spidev = mock.MagicMock() 52 | sys.modules['spidev'] = spidev 53 | yield spidev 54 | del sys.modules['spidev'] 55 | 56 | 57 | @pytest.fixture(scope='function', autouse=False) 58 | def smbus(): 59 | """Mock smbus module.""" 60 | smbus = mock.MagicMock() 61 | smbus.SMBus = SMBusFakeDevice 62 | sys.modules['smbus'] = smbus 63 | yield smbus 64 | del sys.modules['smbus'] 65 | 66 | 67 | @pytest.fixture(scope='function', autouse=False) 68 | def atexit(): 69 | """Mock atexit module.""" 70 | atexit = mock.MagicMock() 71 | sys.modules['atexit'] = atexit 72 | yield atexit 73 | del sys.modules['atexit'] 74 | -------------------------------------------------------------------------------- /tests/test_lock.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | def test_pumps_actually_stop(GPIO, smbus): 5 | from grow.pump import Pump 6 | 7 | ch1 = Pump(channel=1) 8 | 9 | ch1.dose(speed=0.5, timeout=0.05, blocking=False) 10 | time.sleep(0.1) 11 | assert ch1.get_speed() == 0 12 | 13 | 14 | def test_pumps_are_mutually_exclusive(GPIO, smbus): 15 | from grow.pump import Pump, global_lock 16 | 17 | ch1 = Pump(channel=1) 18 | ch2 = Pump(channel=2) 19 | ch3 = Pump(channel=3) 20 | 21 | ch1.dose(speed=0.5, timeout=1.0, blocking=False) 22 | 23 | assert global_lock.locked() is True 24 | 25 | assert ch2.dose(speed=0.5) is False 26 | assert ch2.dose(speed=0.5, blocking=False) is False 27 | 28 | assert ch3.dose(speed=0.5) is False 29 | assert ch3.dose(speed=0.5, blocking=False) is False 30 | 31 | 32 | def test_pumps_run_sequentially(GPIO, smbus): 33 | from grow.pump import Pump, global_lock 34 | 35 | ch1 = Pump(channel=1) 36 | ch2 = Pump(channel=2) 37 | ch3 = Pump(channel=3) 38 | 39 | assert ch1.dose(speed=0.5, timeout=0.1, blocking=False) is True 40 | assert global_lock.locked() is True 41 | time.sleep(0.3) 42 | assert ch2.dose(speed=0.5, timeout=0.1, blocking=False) is True 43 | assert global_lock.locked() is True 44 | time.sleep(0.3) 45 | assert ch3.dose(speed=0.5, timeout=0.1, blocking=False) is True 46 | assert global_lock.locked() is True 47 | time.sleep(0.3) 48 | -------------------------------------------------------------------------------- /tests/test_setup.py: -------------------------------------------------------------------------------- 1 | import mock 2 | 3 | 4 | def test_moisture_setup(GPIO, smbus): 5 | from grow.moisture import Moisture 6 | 7 | ch1 = Moisture(channel=1) 8 | ch2 = Moisture(channel=2) 9 | ch3 = Moisture(channel=3) 10 | 11 | GPIO.setup.assert_has_calls([ 12 | mock.call(ch1._gpio_pin, GPIO.IN), 13 | mock.call(ch2._gpio_pin, GPIO.IN), 14 | mock.call(ch3._gpio_pin, GPIO.IN) 15 | ]) 16 | 17 | 18 | def test_moisture_read(GPIO, smbus): 19 | from grow.moisture import Moisture 20 | 21 | assert Moisture(channel=1).saturation == 1.0 22 | assert Moisture(channel=2).saturation == 1.0 23 | assert Moisture(channel=3).saturation == 1.0 24 | 25 | assert Moisture(channel=1).moisture == 0 26 | assert Moisture(channel=2).moisture == 0 27 | assert Moisture(channel=3).moisture == 0 28 | 29 | 30 | def test_pump_setup(GPIO, smbus): 31 | from grow.pump import PUMP_PWM_FREQ, Pump 32 | 33 | ch1 = Pump(channel=1) 34 | ch2 = Pump(channel=2) 35 | ch3 = Pump(channel=3) 36 | 37 | GPIO.setup.assert_has_calls([ 38 | mock.call(ch1._gpio_pin, GPIO.OUT, initial=GPIO.LOW), 39 | mock.call(ch2._gpio_pin, GPIO.OUT, initial=GPIO.LOW), 40 | mock.call(ch3._gpio_pin, GPIO.OUT, initial=GPIO.LOW) 41 | ]) 42 | 43 | GPIO.PWM.assert_has_calls([ 44 | mock.call(ch1._gpio_pin, PUMP_PWM_FREQ), 45 | mock.call().start(0), 46 | mock.call(ch2._gpio_pin, PUMP_PWM_FREQ), 47 | mock.call().start(0), 48 | mock.call(ch3._gpio_pin, PUMP_PWM_FREQ), 49 | mock.call().start(0) 50 | ]) 51 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py,qa 3 | skip_missing_interpreters = True 4 | isolated_build = true 5 | minversion = 4.0.0 6 | 7 | [testenv] 8 | commands = 9 | coverage run -m pytest -v -r wsx 10 | coverage report 11 | deps = 12 | mock 13 | pytest>=3.1 14 | pytest-cov 15 | build 16 | 17 | [testenv:qa] 18 | commands = 19 | check-manifest 20 | python -m build --no-isolation 21 | python -m twine check dist/* 22 | isort --check . 23 | ruff . 24 | codespell . 25 | deps = 26 | check-manifest 27 | ruff 28 | codespell 29 | isort 30 | twine 31 | build 32 | hatch 33 | hatch-fancy-pypi-readme 34 | 35 | -------------------------------------------------------------------------------- /uninstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | FORCE=false 4 | LIBRARY_NAME=`grep -m 1 name pyproject.toml | awk -F" = " '{print substr($2,2,length($2)-2)}'` 5 | RESOURCES_DIR=$HOME/Pimoroni/$LIBRARY_NAME 6 | PYTHON="python" 7 | 8 | 9 | venv_check() { 10 | PYTHON_BIN=`which $PYTHON` 11 | if [[ $VIRTUAL_ENV == "" ]] || [[ $PYTHON_BIN != $VIRTUAL_ENV* ]]; then 12 | printf "This script should be run in a virtual Python environment.\n" 13 | exit 1 14 | fi 15 | } 16 | 17 | user_check() { 18 | if [ $(id -u) -eq 0 ]; then 19 | printf "Script should not be run as root. Try './uninstall.sh'\n" 20 | exit 1 21 | fi 22 | } 23 | 24 | confirm() { 25 | if $FORCE; then 26 | true 27 | else 28 | read -r -p "$1 [y/N] " response < /dev/tty 29 | if [[ $response =~ ^(yes|y|Y)$ ]]; then 30 | true 31 | else 32 | false 33 | fi 34 | fi 35 | } 36 | 37 | prompt() { 38 | read -r -p "$1 [y/N] " response < /dev/tty 39 | if [[ $response =~ ^(yes|y|Y)$ ]]; then 40 | true 41 | else 42 | false 43 | fi 44 | } 45 | 46 | success() { 47 | echo -e "$(tput setaf 2)$1$(tput sgr0)" 48 | } 49 | 50 | inform() { 51 | echo -e "$(tput setaf 6)$1$(tput sgr0)" 52 | } 53 | 54 | warning() { 55 | echo -e "$(tput setaf 1)$1$(tput sgr0)" 56 | } 57 | 58 | printf "$LIBRARY_NAME Python Library: Uninstaller\n\n" 59 | 60 | user_check 61 | venv_check 62 | 63 | printf "Uninstalling for Python 3...\n" 64 | $PYTHON -m pip uninstall $LIBRARY_NAME 65 | 66 | if [ -d $RESOURCES_DIR ]; then 67 | if confirm "Would you like to delete $RESOURCES_DIR?"; then 68 | rm -r $RESOURCES_DIR 69 | fi 70 | fi 71 | 72 | printf "Done!\n" 73 | --------------------------------------------------------------------------------