├── static └── favicon.ico ├── .gitattributes ├── templates ├── welcome_page_quiet.html ├── main_page.html └── welcome_page.html ├── config.py ├── main.py ├── LICENSE ├── webapp.py ├── .gitignore ├── hydroponics_server.py ├── hydroponics.py └── README.md /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tpudlik/hydroponics/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | 7 | # Standard to msysgit 8 | *.doc diff=astextplain 9 | *.DOC diff=astextplain 10 | *.docx diff=astextplain 11 | *.DOCX diff=astextplain 12 | *.dot diff=astextplain 13 | *.DOT diff=astextplain 14 | *.pdf diff=astextplain 15 | *.PDF diff=astextplain 16 | *.rtf diff=astextplain 17 | *.RTF diff=astextplain 18 | -------------------------------------------------------------------------------- /templates/welcome_page_quiet.html: -------------------------------------------------------------------------------- 1 | {% extends "main_page.html" %} 2 | {% block body %} 3 | 4 |
5 | The system is currently paused. The lights and pump will not resume 6 | regular operation until {{ resume.strftime("%I:%M %p") }} on 7 | {{ resume.strftime("%A, %B %d") }}. 8 |
9 |10 | You can unpause the system right now by hitting the button below. 11 |
12 | 13 | 16 | 17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /templates/main_page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |4 | The system is operating normally. You can pause it (turn off the lights 5 | and pump) using the dialog below. 6 |
7 | 8 | 29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | """Run the hydroponics system.""" 2 | 3 | import time 4 | 5 | from apscheduler.schedulers.background import BackgroundScheduler 6 | 7 | from config import (PUMP_PIN, LIGHTS_PIN, PUMP_DEFAULT_ON, LIGHTS_DEFAULT_ON, 8 | PUMP_TIME, LIGHTS_TIME_ON, LIGHTS_TIME_OFF) 9 | from hydroponics import HydroponicsController 10 | 11 | 12 | if __name__ == '__main__': 13 | scheduler = BackgroundScheduler() 14 | kwargs = {"pump_pin": PUMP_PIN, 15 | "lights_pin": LIGHTS_PIN, 16 | "pump_default_on": PUMP_DEFAULT_ON, 17 | "lights_default_on": LIGHTS_DEFAULT_ON} 18 | 19 | with HydroponicsController(**kwargs) as h: 20 | scheduler.add_job(h.run_pump, 'interval', hours=1, args=(PUMP_TIME,)) 21 | scheduler.add_job(h.lights_on, 'cron', hour=LIGHTS_TIME_ON) 22 | scheduler.add_job(h.lights_off, 'cron', hour=LIGHTS_TIME_OFF) 23 | scheduler.start() 24 | 25 | try: 26 | while True: 27 | time.sleep(10) 28 | finally: 29 | scheduler.shutdown() 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Tadeusz Pudlik 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /webapp.py: -------------------------------------------------------------------------------- 1 | """The Flask web application serving an interface for entering quiet mode. 2 | 3 | The web application communicates with an RPyC server that actually controls 4 | the hardware. You must start that server via, 5 | 6 | sudo python hydroponics_server.py & 7 | 8 | before starting the web application. 9 | 10 | """ 11 | 12 | import datetime 13 | 14 | from flask import Flask, request, redirect, url_for, render_template, flash 15 | import rpyc 16 | 17 | from config import PORT 18 | 19 | 20 | app = Flask(__name__) 21 | app.secret_key = "SJDFAIVCMAIOEJEOGKATBKPFK" 22 | 23 | @app.route('/') 24 | def welcome_page(): 25 | # TODO: Allow extending the current pause without unpausing 26 | c = rpyc.connect("localhost", PORT) 27 | if c.root.is_paused(): 28 | return render_template("welcome_page_quiet.html", 29 | resume=c.root.get_resume_time()) 30 | else: 31 | return render_template("welcome_page.html") 32 | 33 | @app.route('/pause', methods=['POST']) 34 | def pause_page(): 35 | # TODO: Perform some type of validation of pause_duration? 36 | pause_duration = request.form["duration"] 37 | c = rpyc.connect("localhost", PORT) 38 | c.root.pause(pause_duration) 39 | return redirect(url_for('welcome_page')) 40 | 41 | @app.route('/unpause', methods=['POST']) 42 | def unpause_page(): 43 | c = rpyc.connect("localhost", PORT) 44 | c.root.resume() 45 | return redirect(url_for('welcome_page')) 46 | 47 | 48 | if __name__ == '__main__': 49 | app.run(host='0.0.0.0', debug=True) 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | 43 | # Translations 44 | *.mo 45 | *.pot 46 | 47 | # Django stuff: 48 | *.log 49 | 50 | # Sphinx documentation 51 | docs/_build/ 52 | 53 | # PyBuilder 54 | target/ 55 | 56 | # ========================= 57 | # Operating System Files 58 | # ========================= 59 | 60 | # OSX 61 | # ========================= 62 | 63 | .DS_Store 64 | .AppleDouble 65 | .LSOverride 66 | 67 | # Thumbnails 68 | ._* 69 | 70 | # Files that might appear on external disk 71 | .Spotlight-V100 72 | .Trashes 73 | 74 | # Directories potentially created on remote AFP share 75 | .AppleDB 76 | .AppleDesktop 77 | Network Trash Folder 78 | Temporary Items 79 | .apdisk 80 | 81 | # Windows 82 | # ========================= 83 | 84 | # Windows image file caches 85 | Thumbs.db 86 | ehthumbs.db 87 | 88 | # Folder config file 89 | Desktop.ini 90 | 91 | # Recycle Bin used on file shares 92 | $RECYCLE.BIN/ 93 | 94 | # Windows Installer files 95 | *.cab 96 | *.msi 97 | *.msm 98 | *.msp 99 | 100 | # Windows shortcuts 101 | *.lnk 102 | -------------------------------------------------------------------------------- /hydroponics_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | """ Hydroponics server that runs as a RPyC service and can thus be controlled 4 | by another process, such as the web application. 5 | 6 | You should start the server (by running this script) before starting the 7 | web application. 8 | 9 | The web application does not control the hardware directly because we want 10 | only one instance of the HydroponicsController running at the same time, 11 | but the web server creates a new thread for every connection. 12 | 13 | """ 14 | import datetime 15 | import logging 16 | logging.basicConfig() 17 | 18 | from apscheduler.jobstores.base import JobLookupError 19 | from apscheduler.schedulers.background import BackgroundScheduler 20 | import rpyc 21 | from rpyc.utils.server import ThreadedServer 22 | 23 | from hydroponics import HydroponicsController, MockHydroponicsController 24 | from config import (PUMP_PIN, LIGHTS_PIN, PUMP_DEFAULT_ON, LIGHTS_DEFAULT_ON, 25 | PUMP_TIME, LIGHTS_TIME_ON, LIGHTS_TIME_OFF, PORT) 26 | 27 | 28 | def CustomizedHydroponicsService(hydroponics_controller, scheduler): 29 | state = {"paused": False, "resume_time": None} 30 | 31 | class HydroponicsService(rpyc.Service): 32 | def exposed_is_paused(self): 33 | return state["paused"] 34 | 35 | def exposed_get_resume_time(self): 36 | return state["resume_time"] 37 | 38 | def exposed_resume(self): 39 | """Resume regular operation of the hydroponics system.""" 40 | 41 | state["paused"] = False 42 | try: 43 | scheduler.remove_job("resume") 44 | except JobLookupError: 45 | pass 46 | 47 | for job in ["pump", "lights on", "lights off"]: 48 | scheduler.resume_job(job) 49 | 50 | current_hour = datetime.datetime.now().time() 51 | time_on = datetime.time(LIGHTS_TIME_ON, 0) 52 | time_off = datetime.time(LIGHTS_TIME_OFF, 0) 53 | if current_hour > time_on and current_hour < time_off: 54 | hydroponics_controller.lights_on() 55 | 56 | def exposed_pause(self, duration): 57 | """Pause the hydroponics system for duration seconds.""" 58 | 59 | state["paused"] = True 60 | state["resume_time"] = self.get_resumption_time(duration) 61 | hydroponics_controller.lights_off() 62 | hydroponics_controller.pump_off() 63 | 64 | for job in ["pump", "lights on", "lights off"]: 65 | scheduler.pause_job(job) 66 | 67 | scheduler.add_job(self.exposed_resume, 'date', 68 | run_date=state["resume_time"], id="resume") 69 | 70 | def get_resumption_time(self, pause_duration): 71 | pause_datetime = datetime.timedelta(minutes=int(pause_duration)) 72 | return datetime.datetime.now() + pause_datetime 73 | 74 | return HydroponicsService 75 | 76 | if __name__ == '__main__': 77 | scheduler = BackgroundScheduler() 78 | kwargs = {"pump_pin": PUMP_PIN, 79 | "lights_pin": LIGHTS_PIN, 80 | "pump_default_on": PUMP_DEFAULT_ON, 81 | "lights_default_on": LIGHTS_DEFAULT_ON} 82 | 83 | with HydroponicsController(**kwargs) as h: 84 | scheduler.add_job(h.run_pump, 'interval', hours=1, args=(PUMP_TIME,), 85 | id="pump") 86 | scheduler.add_job(h.lights_on, 'cron', hour=LIGHTS_TIME_ON, 87 | id="lights on") 88 | scheduler.add_job(h.lights_off, 'cron', hour=LIGHTS_TIME_OFF, 89 | id="lights off") 90 | scheduler.start() 91 | 92 | cs = CustomizedHydroponicsService(h, scheduler) 93 | t = ThreadedServer(cs, port=PORT, 94 | protocol_config={"allow_public_attrs": True}) 95 | 96 | try: 97 | t.start() 98 | finally: 99 | scheduler.shutdown() 100 | -------------------------------------------------------------------------------- /hydroponics.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | """ Simple hydroponics system controls. Defines a class Hydroponics which can 4 | be used to turn the pump and lights on and off. 5 | 6 | Both the pump and lights are controlled by relays connected to Raspberry Pi 7 | GPIO pins. These relays may be "default on" (device is turned on when GPIO 8 | pin is set to low) or "default off" (device is turned off when GPIO pin is 9 | set to low). 10 | 11 | You should always use the Python 'with' statement when creating an instance of 12 | the Hydroponics class to ensure that GPIO pins are properly cleaned up if an 13 | exception occurs. Since the system is usually set up to operate indefinitely 14 | from the time it is set up, an exception is the only way for it to terminate! 15 | Cleaning up after the class is critical, since otherwise a 3V3 GPIO signal 16 | will continue being sent on one or both pins; this may damage any other 17 | component connected to the pin. An example of proper syntax is in the "if 18 | __name__ == '__main__'" block at the end of the module. 19 | 20 | The class constructor takes five keyword arguments, all required: 21 | pump_pin : int 22 | The number of the GPIO pin controlling the pump, in the BOARD 23 | numbering scheme (i.e., the number on the GPIO header). 24 | lights_pin : int 25 | The number of the GPIO pin controlling the lights. 26 | pump_default_on : bool 27 | Is the pump on or off when the GPIO pin is set to low? 28 | lights_default_on : bool 29 | Are the lights on or off when the GPIO pin is set to low? 30 | 31 | """ 32 | 33 | import time 34 | try: 35 | import RPi.GPIO as GPIO 36 | except ImportError: 37 | pass 38 | # TODO: This is for testing on machines other than the Pi, but should be 39 | # handled more gracefully. 40 | 41 | 42 | class HydroponicsController(object): 43 | """Object for controlling hydroponics setup. See module docstring for 44 | more details. 45 | 46 | """ 47 | def __init__(self, **kwargs): 48 | self.pump_pin = kwargs["pump_pin"] 49 | self.lights_pin = kwargs["lights_pin"] 50 | self.pump_default_on = kwargs["pump_default_on"] 51 | self.lights_default_on = kwargs["lights_default_on"] 52 | 53 | GPIO.setmode(GPIO.BOARD) 54 | GPIO.setup(self.pump_pin, GPIO.OUT) 55 | GPIO.setup(self.lights_pin, GPIO.OUT) 56 | 57 | def pump_on(self): 58 | if self.pump_default_on: 59 | GPIO.output(self.pump_pin, GPIO.LOW) 60 | else: 61 | GPIO.output(self.pump_pin, GPIO.HIGH) 62 | 63 | def pump_off(self): 64 | if self.pump_default_on: 65 | GPIO.output(self.pump_pin, GPIO.HIGH) 66 | else: 67 | GPIO.output(self.pump_pin, GPIO.LOW) 68 | 69 | def run_pump(self, pump_time): 70 | """Run the pump for `pump_time` seconds.""" 71 | self.pump_on() 72 | time.sleep(pump_time) 73 | self.pump_off() 74 | 75 | def lights_on(self): 76 | if self.lights_default_on: 77 | GPIO.output(self.lights_pin, GPIO.LOW) 78 | else: 79 | GPIO.output(self.lights_pin, GPIO.HIGH) 80 | 81 | def lights_off(self): 82 | if self.lights_default_on: 83 | GPIO.output(self.lights_pin, GPIO.HIGH) 84 | else: 85 | GPIO.output(self.lights_pin, GPIO.LOW) 86 | 87 | def __enter__(self): 88 | return self 89 | 90 | def __exit__(self, type, value, traceback): 91 | GPIO.cleanup() 92 | 93 | 94 | class MockHydroponicsController(object): 95 | """Test object with the same interface as HydroponicsController, but 96 | with methods that print to console instead of accessing the GPIO. 97 | 98 | """ 99 | def __init__(self, **kwargs): 100 | self.pump_pin = kwargs["pump_pin"] 101 | self.lights_pin = kwargs["lights_pin"] 102 | self.pump_default_on = kwargs["pump_default_on"] 103 | self.lights_default_on = kwargs["lights_default_on"] 104 | 105 | print "GPIO.setmode(GPIO.BOARD)" 106 | print "GPIO.setup({}, GPIO.OUT)".format(self.pump_pin) 107 | print "GPIO.setup({}, GPIO.OUT)".format(self.lights_pin) 108 | 109 | def pump_on(self): 110 | msg = "Pump turned on by setting pin {} to {}" 111 | if self.pump_default_on: 112 | print msg.format(self.pump_pin, "GPIO.LOW") 113 | else: 114 | print msg.format(self.pump_pin, "GPIO.HIGH") 115 | 116 | def pump_off(self): 117 | msg = "Pump turned off by setting pin {} to {}" 118 | if self.pump_default_on: 119 | print msg.format(self.pump_pin, "GPIO.HIGH") 120 | else: 121 | print msg.format(self.pump_pin, "GPIO.LOW") 122 | 123 | def run_pump(self, pump_time): 124 | """Run the pump for `pump_time` seconds.""" 125 | self.pump_on() 126 | time.sleep(pump_time) 127 | self.pump_off() 128 | 129 | def lights_on(self): 130 | msg = "Lights turned on by setting pin {} to {}" 131 | if self.lights_default_on: 132 | print msg.format(self.lights_pin, "GPIO.LOW") 133 | else: 134 | print msg.format(self.lights_pin, "GPIO.HIGH") 135 | 136 | def lights_off(self): 137 | msg = "Lights turned off by setting pin {} to {}" 138 | if self.lights_default_on: 139 | print msg.format(self.lights_pin, "GPIO.HIGH") 140 | else: 141 | print msg.format(self.lights_pin, "GPIO.LOW") 142 | 143 | def __enter__(self): 144 | return self 145 | 146 | def __exit__(self, type, value, traceback): 147 | print "GPIO.cleanup()" 148 | 149 | 150 | if __name__ == '__main__': 151 | kwargs = {"pump_pin": 13, 152 | "lights_pin": 11, 153 | "pump_default_on": False, 154 | "lights_default_on": True} 155 | 156 | with HydroponicsController(**kwargs) as h: 157 | print "Hit Ctrl + C to interrupt process." 158 | while True: 159 | h.lights_on() 160 | time.sleep(5) 161 | h.lights_off() 162 | time.sleep(5) 163 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple hydroponics system # 2 | 3 | A Raspberry-Pi based controller for a hydroponics system, turning the lights 4 | on and off at set times of day and running the pump every hour. 5 | 6 | 7 | ## Hardware ## 8 | 9 | ### Parts list ### 10 | 11 | 1. Raspberry Pi with a charger. 12 | 2. A pair of relays for controlling the lights and pumps. An easy and safe 13 | (but certainly not cheapest) solution is to get two 14 | ["IoT Relays" from Digital Loggers Inc.][IoT_relays]. 15 | 3. Ribbon cable and matching header for the Raspberry Pi GPIO, for connecting 16 | the relays. 17 | 4. A pump, tubing, and valves for aerating the water. We used, 18 | * [Tetra 77851 Whisper Air Pump, 10-Gallon][pump] 19 | * [PENN PLAX Standard Airline Tubing, 25-Feet][tubing] 20 | * [Jardin Plastic Air Connectors][valves] 21 | * [Uxcell Airstones][airstones] 22 | 5. Lamps for illuminating the plants. We used a 23 | [5 meter Toogod red:blue 4:1 LED roll][led_roll], which gives 2.5 meters 24 | of LEDs (about 30 W) per container. You will also want two packs of the 25 | [15 cm connectors][led_connectors] for these, and a 26 | [power supply][led_power_supply]. 27 | 6. [IKEA TROFAST cabinet][trofast] (yes, the toy storage one), to serve as 28 | frame, with two matching shallow storage boxes to hold the water, two 29 | lids to hold the plants, and two shelves for adjustable-height lighting. 30 | 7. [Baskets for the plants][plant_baskets], approximately 3" in maximum 31 | diameter. Each TROFAST box can hold 8 baskets, for a total of 16. 32 | 8. [Clay pellets][clay_pellets]. 33 | 9. Liquid plant food. 34 | 10. Seeds to plant and [starter plug][starter_plug] to germinate the seeds 35 | before transferring them to the hydroponics setup. Or, seedlings to 36 | place directly in the setup. 37 | 38 | 39 | ### Tools ### 40 | 41 | 1. A 2.75" hole saw (for cutting holes in container lids). 42 | 2. Soldering iron and solder (for soldering the LED strips). 43 | 3. A utility knife (for stripping rubber insulation from the LED strips). 44 | 4. Glue for the LED strips. The Toogod strips come with adhesive on the 45 | backside, but it is not sufficiently strong to hold their weight for more 46 | than a few hours. 47 | 48 | 49 | ### Assembly ### 50 | 51 | 1. Assemble the IKEA cabinet. 52 | 2. Cut seven 2.75" holes in each container lid. This is most easily done 53 | using a hole saw. 54 | 3. Cut the LED roll into 14 strips of 7 segments each. (You will have two 55 | segments leftover. If you cut 7 strips, then 2 segments, and then another 56 | 7 strips, you will not have to cut through the manufacturer's solder, 57 | which appears every 10th segment.) 58 | 4. Glue 7 LED strips to the backside of each of the TROFAST shelves and 59 | connect them in series using the connectors. Solder the connectors. (The 60 | connectors appear to work without solder at first, but in our experience 61 | fail after a couple of days.) 62 | 5. Wire the electronics. 63 | * Plug the LED power supply into a relay driven by the Pi's pin 11. 64 | * Plug the pump power supply into a relay driven by the Pi's pin 13. 65 | 6. Glue an airstone to the center of each container box. 66 | 7. Assemble the aeration system: split the output of the pump into two tubes 67 | using the air connectors, then connect each tube to an airstone. 68 | 8. Wash the pellets and use them to steady the seedlings (still on their 69 | pieces of starter plug!) in the baskets. 70 | 9. Fill the storage boxes with a mixture of water and plant food, cover them 71 | with the lids, and place the baskets in their holes. 72 | 73 | 74 | ### Figures needed ### 75 | 76 | 1. Container lid with holes cut. 77 | 2. Assembled lighting panel, with at least one connector open to show solder 78 | job. 79 | 3. Assembled lighting panel, showing the "serial" nature of the circuit. 80 | 4. Circuit diagram for the wiring? 81 | 82 | 83 | ## Software ## 84 | 85 | There are two versions of the software. 86 | 87 | 1. The basic version runs the pump and lights on a schedule, but does not 88 | provide any convenient interface for suspending operation. 89 | 2. The web app version provides a Flask web application, accessible from the 90 | browser, which allows you to turn off the lights and stop running the 91 | pump for a period of time ("quiet mode"), and then resumes regular 92 | programming. 93 | 94 | 95 | ### Dependencies ### 96 | 97 | The basic version depends only on `apscheduler`, the 98 | [Advanced Python Scheduler](https://apscheduler.readthedocs.org/en/latest/). 99 | 100 | The web app version requires the `apscheduler`, 101 | [Flask](http://flask.pocoo.org/), and 102 | [RPyC](https://rpyc.readthedocs.org/en/latest/). 103 | 104 | 105 | ### Installation ### 106 | 107 | To have access to the GPIO, we need to run the application as root, so all of 108 | the packages must be installed into the `sudo` Python (rather than the user 109 | Python or some virtualenv). 110 | 111 | 1. Install Flask via `sudo pip install Flask`. 112 | 2. Install the Advanced Python Scheduler via `sudo pip install apscheduler`. 113 | 3. Install RPyC via `sudo pip install rpyc`. 114 | 4. Clone this repository. 115 | 116 | 117 | ### Usage ### 118 | 119 | After setting the values in `config.py`, run either, 120 | 121 | 1. `sudo python main.py &` for the basic version, or, 122 | 2. `sudo python hydroponics_server.py &`, followed by 123 | `sudo python webapp.py &`, for the web app version. The web app will be 124 | served at port 5000 by default. 125 | 126 | Don't forget to do `disown` to keep the job(s) running after you log off. 127 | 128 | 129 | [airstones]: https://www.amazon.com/gp/product/B00NQ8C8P8/ 130 | [clay_pellets]: https://www.amazon.com/gp/product/B004IAM29K/ 131 | [IoT_relays]: https://www.amazon.com/gp/product/B00WV7GMA2/ 132 | [led_connectors]: https://www.amazon.com/gp/product/B013YL51E6/ 133 | [led_power_supply]: https://www.amazon.com/gp/product/B013I01P5M/ 134 | [led_roll]: https://www.amazon.com/gp/product/B00XHRYX2O/ 135 | [plant_baskets]: https://www.amazon.com/gp/product/B00UZ0Q4TG/ 136 | [pump]: https://www.amazon.com/gp/product/B0009YJ4N6/ 137 | [starter_plug]: https://www.amazon.com/gp/product/B00168EO48/ 138 | [trofast]: http://www.ikea.com/us/en/catalog/categories/series/19027/ 139 | [tubing]: https://www.amazon.com/gp/product/B0002563MW/ 140 | [valves]: https://www.amazon.com/gp/product/B0089L3OIW/ 141 | --------------------------------------------------------------------------------