commit efef8a9682ab43adf5380e52258ca9d8ce94fa20 Author: git Date: Mon Mar 22 21:12:58 2021 +0100 Set new Base version diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e75666 --- /dev/null +++ b/.gitignore @@ -0,0 +1,143 @@ +# Created by .ignore support plugin (hsz.mobi) +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +/src/config.ini +\.idea diff --git a/Dockerfile-1 b/Dockerfile-1 new file mode 100644 index 0000000..008f5ac --- /dev/null +++ b/Dockerfile-1 @@ -0,0 +1,9 @@ +FROM python:3-alpine + +COPY . /code/ + +RUN pip install -r /code/requirements.txt + +WORKDIR /code/src/ + +CMD ["python","scheduler.py"] \ No newline at end of file diff --git a/Dockerfile-2 b/Dockerfile-2 new file mode 100644 index 0000000..e225938 --- /dev/null +++ b/Dockerfile-2 @@ -0,0 +1,9 @@ +FROM python:3-alpine + +COPY . /code/ + +RUN pip install -r /code/requirements.txt + +WORKDIR /code/src/ + +CMD ["python","server.py"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c2b1685 --- /dev/null +++ b/README.md @@ -0,0 +1,148 @@ +# PiSwitch + +A Raspberry Pi Relay controller based on the Astral Library + +## Physical setup + +Use physical pin numbering + +![Pi pinout](https://www.jameco.com/Jameco/workshop/circuitnotes/raspberry_pi_circuit_note_fig2.jpg) + +### Switch Toggle Button: + +3.3v: either pin 1 or pin 17 + +signal: pin 11 + +### Astral Toggle Button / Switch: + +3.3v: either pin 1 or pin 17 + +signal: pin 15 + +### LED's +5v: eiter pin 2 or pin 4 + +signal: pin 12 + +ground : any ground pin + +### Relay + +5v: eiter pin 2 or pin 4 + +signal: pin 38 + +ground : any ground pin + +## Installation Process + +Clone the repository +````shell +git clone https://git.dennisvandermeulen.nl/dennis/PiSwitch.git +cd PiSwitch/ +```` +Setup virtual environment & install requirements (skip if you use Docker) +````shell +python3 -m venv venv +source venv/bin/activate +pip3 install -r requirements.txt +```` + +Setup config.ini +````shell +nano src/config.ini +```` +## Config.ini +Set up the base url for your api +````ini +[setup] +baseurl = http(s)://www.example.com:port/ +```` +setup switch id +````ini +switchid = integer +```` +Setup whether information must be pushed to the database +````ini +storedb = 0/1 +```` + +Set the apikey for this PiSwitch +````ini +apikey = string +```` +Set the key of the [PiSwitch reporting server](https://git.dennisvandermeulen.nl/dennis/PiSwitch_reporting) (Do not use if storedb=0) +````ini +srvapikey = string +```` + +Setup your location info +````ini +[location] +name = name +region = region +lat = float +lon = float +timezone = Europe/Amsterdam +```` + +##Docker +Use the included Docker-Compose files to build the scheduler and server container +````shell +docker-compose . -up -d +```` + + +## Systemd +Use the following scripts to automatically start the scripts at boot for a true headless experience + +### server.py +````ini +[Unit] +Description=Relay Switch Server +After=multi-user.target + +[Service] +Type=simple +User=root +Group=root +WorkingDirectory=/path/to/PiSwitch/src +ExecStart=/path/to/venv/bin/python3 path/to/PiSwitch/src/server.py + +[Install] +WantedBy=multi-user.target +```` +save as relayserver.service + +### scheduler.py +````ini +[Unit] +Description=Relay Switch listener +After=multi-user.target +Requires=relayserver.service + +[Service] +Type=simple +User=root +Group=root +WorkingDirectory=/path/to/PiSwitch/src +ExecStart=/path/to/venv/bin/python3 path/to/PiSwitch/src/scheduler.py + +[Install] +WantedBy=multi-user.target +```` +save as scheduler.service + +## Troubleshooting +Check LED color for basic status indications + +White Solid = No Wi-Fi + +Red Solid = Lights off + +Green Solid = Lights on + +Blue Flashing = Astral enabled + +Off = No power / Led dead :) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e80e045 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +version: "3.7" +services: + + piswitch-api: + build: + context: . + dockerfile: Dockerfile-2 + ports: + - 5000:5000 + container_name: piswitch-api + image: piswitch/api + restart: unless-stopped + tty: true + volumes: + - ./:/code/ + working_dir: /code/src/ + networks: + - piswitch-network + + piswitch-scheduler: + build: + context: . + dockerfile: Dockerfile-1 + container_name: piswitch-scheduler + image: piswitch/scheduler + restart: unless-stopped + tty: true + volumes: + - ./:/code/ + working_dir: /code/src/ + networks: + - piswitch-network + + +networks: + piswitch-network: + driver: bridge \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..dac74d8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,27 @@ +Adafruit-Blinka==5.4.0 +adafruit-circuitpython-neopixel==6.0.0 +adafruit-circuitpython-pypixelbuf==2.1.2 +Adafruit-PlatformDetect==2.17.0 +Adafruit-PureIO==1.1.5 +APScheduler==3.6.3 +astral==2.2 +certifi==2020.6.20 +chardet==3.0.4 +click==7.1.2 +configparser==5.0.0 +Flask==1.1.2 +Flask-Cors==3.0.9 +idna==2.10 +itsdangerous==1.1.0 +Jinja2==2.11.2 +MarkupSafe==1.1.1 +pyftdi==0.51.2 +pyserial==3.4 +pytz==2020.1 +pyusb==1.1.0 +requests==2.24.0 +rpi-ws281x==4.2.4 +six==1.15.0 +tzlocal==2.1 +urllib3==1.25.10 +Werkzeug==1.0.1 diff --git a/src/scheduler.py b/src/scheduler.py new file mode 100644 index 0000000..b8269c0 --- /dev/null +++ b/src/scheduler.py @@ -0,0 +1,210 @@ +# /usr/bin/env python3 + +import datetime +import RPi.GPIO as GPIO +import requests +import socket +import time +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.interval import IntervalTrigger +from apscheduler.triggers.cron import CronTrigger +import configparser +import pytz + +GPIO.setwarnings(False) +GPIO.setmode(GPIO.BCM) +GPIO.setup(17, GPIO.IN, pull_up_down=GPIO.PUD_DOWN) +GPIO.setup(22, GPIO.IN, pull_up_down=GPIO.PUD_DOWN) + +turnOnTime = 0 +timeUpdated = False + +scheduler = BackgroundScheduler() +timezone = pytz.timezone('Europe/Amsterdam') + + +def buttoncallback(channel): + time.sleep(0.15) + if GPIO.input(17) != GPIO.HIGH: + pass + else: + requests.post(baseUrl + '/raspiswitch' + str(switchId) + '/override', data={'apikey': switchApiKey}) + + +def toggleastral(channel): + if GPIO.input(22) == GPIO.HIGH: + requests.post(baseUrl + '/raspiswitch' + str(switchId) + '/toggleastral', + data={'command': 1, 'apikey': switchApiKey}) + else: + requests.post(baseUrl + '/raspiswitch' + str(switchId) + '/toggleastral', + data={'command': 0, 'apikey': switchApiKey}) + + +def syncastral(): + today = datetime.datetime.now() + response = requests.get(baseUrl + '/raspiswitch' + str(switchId) + '/gettime', + params={'year': today.year, 'month': today.month, 'day': today.day, 'apikey': switchApiKey}) + global turnOnTime + turnOnTime = datetime.datetime.strptime(response.json()['response'], '%a, %d %b %Y %H:%M:%S %Z') + global timeUpdated + timeUpdated = True + + +def usingastral(): + requests.get(baseUrl + '/raspiswitch' + str(switchId) + '/usingastral', params={'apikey': switchApiKey}) + + +def isconnected(): + try: + socket.create_connection(("www.google.com", 80)) + if centralLoggingEnabled: + requests.post(baseUrl + '/raspapi/status', + data={'id': switchId, 'datetime': datetime.datetime.now(), 'key': 'conn_status', 'value': 1, + 'apikey': serverApiKey}) + + except OSError: + if centralLoggingEnabled: + requests.post(baseUrl + '/raspapi/status', + data={'id': switchId, 'datetime': datetime.datetime.now(), 'key': 'conn_status', 'value': 0, + 'apikey': serverApiKey}) + else: + print('connectionstatus ' + str(0)) + + +# Turn off Switch +def turnoff(): + response = requests.post(baseUrl + '/raspiswitch' + str(switchId) + '/switchoff', params={'apikey': switchApiKey}) + if centralLoggingEnabled: + requests.post(baseUrl + '/raspapi/cmd', + data={'id': switchId, 'datetime': datetime.datetime.now(), + 'response': 'Turned ' + str(response.json()['currentstatus']), 'apikey': serverApiKey}) + requests.post(baseUrl + '/raspapi/status', + data={'id': switchId, 'datetime': datetime.datetime.now(), 'key': 'switch_status', + 'value': int(response.json()['currentstatus']), 'apikey': serverApiKey}) + else: + print(str(response.json()['response'] + ' ' + response.json()['currentstatus'])) + + +# Turn on Switch +def turnon(): + response = requests.post(baseUrl + '/raspiswitch' + str(switchId) + '/switchon', data={'apikey': switchApiKey}) + if centralLoggingEnabled: + requests.post(baseUrl + '/raspapi/cmd', + data={'id': switchId, 'datetime': datetime.datetime.now(), + 'response': 'Turned ' + str(response.json()['currentstatus']), 'apikey': serverApiKey}) + requests.post(baseUrl + '/raspapi/status', + data={'id': switchId, 'datetime': datetime.datetime.now(), 'key': 'switch_status', + 'value': int(response.json()['currentstatus']), 'apikey': serverApiKey}) + else: + print(str(response.json()['response'] + ' ' + response.json()['currentstatus'])) + + +# Initial timer setup +def setuptimers(): + response = requests.get(baseUrl + '/raspiswitch' + str(switchId) + '/usingastral', params={'apikey': switchApiKey}) + + if bool(response.json()['currentstatus']) is True: + global timeUpdated + if timeUpdated is True: + # Set up the scheduled time for the lights to be turned off + if not scheduler.get_job('turnoff'): + trigger = CronTrigger(hour=23, minute=55) + scheduler.add_job(turnoff, trigger, id='turnoff') + requests.post(baseUrl + '/raspapi/status', + data={'id': switchId, 'datetime': datetime.datetime.now(), + 'key': 'off_time', 'value': scheduler.get_job('turnoff').next_run_time, + 'apikey': serverApiKey}) + + # Set up the scheduled time for the lights to be turned on + global turnOnTime + scheduler.add_job(turnon, 'date', run_date=turnOnTime, id='turnon') + requests.post(baseUrl + '/raspapi/status', + data={'id': switchId, 'datetime': datetime.datetime.now(), + 'key': 'on_time', 'value': turnOnTime, 'apikey': serverApiKey}) + timeUpdated = False + + +# Update timers from dashboard changes +def updatetimers(): + response = requests.post(baseUrl + '/raspapi/gettime', + params={'id': int(switchId), 'key': 'off_time', 'apikey': serverApiKey}) + time_processed = timezone.localize(datetime.datetime.combine(datetime.datetime.now().date(), + datetime.datetime.strptime(response.json()['response'], + '%H:%M:%S').time())) + # Reschedule turn off time + if time_processed != scheduler.get_job('turnoff').next_run_time: + print('rescheduling turn off') + scheduler.reschedule_job(job_id='turnoff', trigger='date', run_date=time_processed) + + response = requests.post(baseUrl + '/raspapi/gettime', + params={'id': int(switchId), 'key': 'on_time', 'apikey': serverApiKey}) + time_processed = timezone.localize(datetime.datetime.combine(datetime.datetime.now().date(), + datetime.datetime.strptime(response.json()['response'], + '%H:%M:%S').time())) + # Reschedule turn on time + if scheduler.get_job('turnon'): + if time_processed != scheduler.get_job('turnon').next_run_time: + print('rescheduling turn on') + scheduler.reschedule_job(job_id='turnon', trigger='date', run_date=time_processed) + + +if __name__ == '__main__': + config = configparser.ConfigParser() + config.read('config.ini') + baseUrl = config['setup']['baseurl'] + switchId = config['setup']['switchid'] + switchApiKey = config['setup']['apikey'] + serverApiKey = config['setup']['srvapikey'] + centralLoggingEnabled = config['setup']['storedb'] + + # On startup + # Check if lights are on (To make sure the LED is correct) + requests.post(baseUrl + '/raspiswitch' + str(switchId) + '/lightstatus', data={'apikey': switchApiKey}) + + # Sync Astral time (To set up timers) + syncastral() + time.sleep(1) + # Check if the time will be used (To make sure server.py is correct) + toggleastral(22) + time.sleep(1) + + # Check every 30 seconds if wifi connection is present + trigger = IntervalTrigger(seconds=30) + scheduler.add_job(isconnected, trigger) + + # Check every 15 seconds if Astral is being used + trigger = IntervalTrigger(seconds=15) + scheduler.add_job(usingastral, trigger) + + # Update light on data everyday at 4:00 + trigger = CronTrigger(hour=4) + scheduler.add_job(syncastral, trigger) + + # Overrideswitch button + GPIO.add_event_detect(17, GPIO.RISING, callback=buttoncallback, bouncetime=800) + + # Astral Toggle button + GPIO.add_event_detect(22, GPIO.BOTH, callback=toggleastral, bouncetime=800) + + # Turn the lights off when it is 23:55 + trigger = CronTrigger(hour=23, minute=55) + scheduler.add_job(turnoff, trigger, id='turnoff') + + # Check every 10 minutes if time variables have been updated + trigger = IntervalTrigger(minutes=10) + scheduler.add_job(setuptimers, trigger) + + # Setup timer in case Astral will be activated later + setuptimers() + + # Check if the timer values have been updated from the dashboard + trigger = IntervalTrigger(minutes=5) + scheduler.add_job(updatetimers, trigger) + scheduler.start() + + requests.post(baseUrl + '/raspapi/status', + data={'id': switchId, 'datetime': datetime.datetime.now(), + 'key': 'off_time', 'value': scheduler.get_job('turnoff').next_run_time}) + + while True: + time.sleep(60) diff --git a/src/server.py b/src/server.py new file mode 100644 index 0000000..c4818f0 --- /dev/null +++ b/src/server.py @@ -0,0 +1,220 @@ +# /usr/bin/env python3 + +from flask import Flask, jsonify, request +from flask_cors import CORS +import astral +import astral.sun +import datetime +import time +import argparse +import neopixel +import board +import RPi.GPIO as GPIO +import requests +import configparser + +triggerOnTime = 0 +lightStatus = False +useAstral = True + +GPIO.setwarnings(False) +GPIO.setmode(GPIO.BCM) + +app = Flask(__name__) +CORS(app) + + +def verifyapikey(requestapikey): + if requestapikey == apiKey: + return True + return False + + +# Update LED to reflect if trigger times by Astral are currently used +@app.route('/usingastral', methods=['GET']) +def usingastral(): + if verifyapikey(request.values.get('apikey')): + global useAstral + if useAstral is True: + strip[0] = (0, 0, 255) + time.sleep(0.2) + if lightStatus is True: + strip[0] = (0, 255, 0) + else: + strip[0] = (255, 0, 0) + return jsonify(response='Astral is being used', currentstatus=useAstral), 200 + else: + return jsonify(response='Astral is not being used', currentstatus=useAstral), 200 + return jsonify(response='invalid api key'), 401 + + +# Update LED to reflect if the relay is open or closed (Used at startup) +@app.route('/lightstatus', methods=['POST']) +def lightstatus(): + if verifyapikey(request.values.get('apikey')): + if lightStatus is False: + strip[0] = (255, 0, 0) + elif lightStatus is True: + strip[0] = (0, 255, 0) + return jsonify(response='Startup routine completed'), 200 + return jsonify(response='invalid api key'), 401 + + +# Update LED to reflect if internet works +@app.route('/nointernet', methods=['POST']) +def nointernet(): + if verifyapikey(request.values.get('apikey')): + strip[0] = (85, 85, 85) + return jsonify(response='Internet not working'), 200 + return jsonify(response='invalid api key'), 401 + + +# Override Switch for current status of Light +@app.route('/override', methods=['POST']) +def switchoverride(): + if verifyapikey(request.values.get('apikey')): + global lightStatus + # Turn on the light + if lightStatus is False: + # Turn on outside lights + GPIO.setup(20, GPIO.OUT, initial=1) + # Set the LED to green to indicate the lights outside are on + strip[0] = (0, 255, 0) + lightStatus = True + # Turn off the light + else: + # Turn off outside lights + GPIO.setup(20, GPIO.IN) + # Set the LED to red to indicate the lights outside are off + strip[0] = (255, 0, 0) + lightStatus = False + + if int(centralLoggingEnabled): + requests.post(baseUrl + '/raspapi/cmd', + data={'id': int(switchId), 'response': 'override button pressed, status: ' + str(lightStatus), + 'apikey': srvApiKey}) + requests.post(baseUrl + '/raspapi/status', + data={'id': int(switchId), 'key': 'switch_status', 'value': int(lightStatus), + 'apikey': srvApiKey}) + return jsonify(response='Toggled Lights', currentstatus=lightStatus), 200 + return jsonify(response='invalid api key'), 401 + + +# Toggle Switch for current status of Light +@app.route('/toggleastral', methods=['POST']) +def toggleastral(): + if verifyapikey(request.values.get('apikey')): + global useAstral + useAstral = int(request.values.get('command')) + + if int(centralLoggingEnabled): + requests.post(baseUrl + '/raspapi/cmd', + data={'id': int(switchId), 'response': 'toggled astral ' + str(useAstral), 'apikey': srvApiKey}) + requests.post(baseUrl + '/raspapi/status', + data={'id': int(switchId), 'key': 'astral_status', 'value': int(useAstral), 'apikey': srvApiKey}) + + return jsonify(response='Toggled Astral', currentstatus=useAstral), 200 + return jsonify(response='invalid api key'), 401 + + +@app.route('/switchon', methods=['POST']) +def switchon(): + if verifyapikey(request.values.get('apikey')): + # Turn on the light + GPIO.setup(20, GPIO.OUT, initial=1) + # Set the LED to green to indicate the lights are on + strip[0] = (0, 255, 0) + global lightStatus + lightStatus = True + + if int(centralLoggingEnabled): + requests.post(baseUrl + '/raspapi/cmd', + data={'id': switchId, 'response': 'Turned switch' + str(lightStatus), 'apikey': srvApiKey}) + requests.post(baseUrl + '/raspapi/status', + data={'id': int(switchId), 'key': 'switch_status', 'value': int(lightStatus), + 'apikey': srvApiKey}) + + return jsonify(response='Switched the light on', currenstatus=lightStatus), 200 + return jsonify(response='invalid api key'), 401 + + +@app.route('/switchoff', methods=['POST']) +def switchoff(): + if verifyapikey(request.values.get('apikey')): + # Turn off the light + GPIO.setup(20, GPIO.IN) + # Set the LED color to red to indicate the lights are off + strip[0] = (255, 0, 0) + global lightStatus + lightStatus = False + + if int(centralLoggingEnabled): + requests.post(baseUrl + '/raspapi/cmd', + data={'id': int(switchId), 'response': 'Turned switch' + str(lightStatus), 'apikey': srvApiKey}) + requests.post(baseUrl + '/raspapi/status', + data={'id': int(switchId), 'key': 'switch_status', 'value': int(lightStatus), + 'apikey': srvApiKey}) + + return jsonify(response='Switched the light off', currentstatus=lightStatus), 200 + return jsonify(response='invalid api key'), 401 + + +@app.route('/updatelocation', methods=['POST']) +def updatelocation(): + if verifyapikey(request.values.get('apikey')): + config = configparser.ConfigParser() + config.read('config.ini') + config['location']['name'] = request.values.get('loc_name') + config['location']['region'] = request.values.get('loc_region') + config['location']['lat'] = request.values.get('loc_lat') + config['location']['lon'] = request.values.get('loc_lon') + with open('config.ini', 'w') as configfile: + config.write(configfile) + + if int(centralLoggingEnabled): + requests.post(baseUrl + '/raspapi/updatelocation', + data={'id': int(switchId), + 'loc_name': request.values.get('loc_name'), + 'loc_region': request.values.get('loc_region'), + 'loc_lat': request.values.get('loc_lat'), 'loc_lon': request.values.get('loc_lon'), + 'apikey': srvApiKey}) + return jsonify(response='success'), 200 + return jsonify(response='invalid api key'), 401 + + +@app.route('/gettime', methods=['GET']) +def gettime(): + if verifyapikey(request.values.get('apikey')): + config = configparser.ConfigParser() + config.read('config.ini') + name = config['location']['name'] + region = config['location']['region'] + lat = float(config['location']['lat']) + lon = float(config['location']['lon']) + timezone = config['location']['timezone'] + + # Get Sunset time for location + l = astral.LocationInfo(name=name, region=region, latitude=lat, longitude=lon, timezone=timezone) + global triggerOnTime + triggerOnTime = astral.sun.sunset(observer=l.observer, + date=datetime.date(int(request.args.get('year')), + int(request.args.get('month')), + int(request.args.get('day')))) + return jsonify(response=triggerOnTime), 200 + return jsonify(response='invalid api key'), 401 + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('-c', '--clear', action='store_true', help='clear the display on exit') + args = parser.parse_args() + strip = neopixel.NeoPixel(board.D18, 1, brightness=0.2) + config = configparser.ConfigParser() + config.read('config.ini') + baseUrl = config['setup']['baseurl'] + switchId = config['setup']['switchid'] + apiKey = config['setup']['apikey'] + srvApiKey = config['setup']['srvapikey'] + centralLoggingEnabled = config['setup']['storedb'] + + app.run(host='0.0.0.0')